feat: phase 4 integrations — connectors, rybbit analytics, webhooks

- core.integrations (migration 00005) with AES-256-GCM-encrypted
  credentials (PRIMORA_ENCRYPTION_KEY, hex/base64, dev fallback);
  has_credentials serialization only — secrets never leave the server
- /projects/:id/integrations list/create/delete/test; health check hits
  the stored base URL with a 5s timeout and records status
- Rybbit connector: GET …/integrations/:id/analytics proxies
  /api/organizations + /api/sites/* server-side into a normalized shape
- core.webhooks + core.webhook_deliveries; CRUD, deliveries, test, and
  POST …/deploy-markers. Events: issue.created (new fingerprint on
  ingest), deploy.marker, webhook.test. X-Primora-Signature HMAC-SHA256
  over the raw body; in-process dispatcher, 3 attempts, quadratic
  backoff, 10s timeout, pending-scan on boot
- HTTPS enforced for public targets; HTTP allowed for loopback/private/
  self-hosted sinks
- Integrations page (connectors + webhooks tabs, inline analytics,
  delivery log, secret-shown-once), demo-mode stubs
- 12 new primora_* MCP tools
- workspace 0.4.0

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-16 18:47:31 +02:00
1 parent 8cfddf9184
commit e25eb92339
82 files changed
+5710 -61

No files matched your search

@@ -0,0 +1,107 @@
package services
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
func TestSignBodyMatchesReceiverVerification(t *testing.T) {
secret := "whsec_test_123"
body := []byte(`{"event":"issue.created","data":{"fingerprint":"abc"}}`)
got := signBody(secret, body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
if got != want {
t.Fatalf("signature mismatch: got %s want %s", got, want)
}
if len(got) != 64 {
t.Fatalf("expected 64-char hex digest, got %d", len(got))
}
if signBody(secret, []byte(`{"event":"issue.created"}`)) == got {
t.Error("different body must produce a different signature")
}
if signBody("other-secret", body) == got {
t.Error("different secret must produce a different signature")
}
}
func TestIntegrationSummaryNeverExposesCredentials(t *testing.T) {
ciphertext := []byte("nonce|aes-gcm-ciphertext|contains|api_key=super-secret")
row := db.CoreIntegration{
ID: uuid.New(),
ProjectID: uuid.New(),
Type: "rybbit",
Name: "prod",
BaseUrl: "https://analytics.example.com",
Config: []byte(`{"site_id":"s1"}`),
Credentials: ciphertext,
Status: "ok",
}
summary := toIntegrationSummary(row)
if !summary.HasCredentials {
t.Error("expected has_credentials=true")
}
raw, err := json.Marshal(summary)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, leak := range []string{"super-secret", "ciphertext", "api_key", "\"credentials\"", "aes-gcm"} {
if strings.Contains(string(raw), leak) {
t.Errorf("serialized summary leaks %q: %s", leak, raw)
}
}
}
func TestNormalizeWebhookURL(t *testing.T) {
ok := []string{
"https://hooks.example.com/x",
"http://localhost:9000/sink",
"http://127.0.0.1:9000/sink",
"http://primora-sink:9000/",
"http://192.168.1.40:8080/hook",
"http://nas.internal/events",
}
for _, u := range ok {
if _, err := normalizeWebhookURL(u); err != nil {
t.Errorf("expected %q accepted: %v", u, err)
}
}
bad := []string{
"",
"not-a-url",
"ftp://example.com",
"http://example.com/plain-remote",
"http://8.8.8.8/x",
"https://",
}
for _, u := range bad {
if _, err := normalizeWebhookURL(u); err == nil {
t.Errorf("expected %q rejected", u)
}
}
}
func TestValidateWebhookEvents(t *testing.T) {
got, err := validateWebhookEvents([]string{"issue.created", "deploy.marker", "issue.created", " "})
if err != nil {
t.Fatalf("validate: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected deduped 2 events, got %v", got)
}
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
t.Error("expected unknown event rejected")
}
}