package console import ( "context" "net/http" "strings" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) const testWebhookMaster = "test-current-webhook-master" func grantScanning(t *testing.T, fx *consoleFixture, token, app string) { t.Helper() if s, o := postJSON(t, scanURL(fx, app), map[string]any{"encrypted": false}, token); s != http.StatusOK { t.Fatalf("scan request %s = %d %v", app, s, o) } if s, o := postJSON(t, scanURL(fx, app)+"/grant", map[string]any{}, token); s != http.StatusOK { t.Fatalf("scan grant %s = %d %v", app, s, o) } } func callbackSecret(t *testing.T, fx *consoleFixture, priv, app string) string { t.Helper() v, ok := appVars(t, fx, app)[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"] if !ok { t.Fatal("callback secret was not delivered") } return openSecret(t, priv, v) } func TestGrantDeliversWebhookKey(t *testing.T) { fx, _, token, priv := storeArmed(t, "code-webhook-grant") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) grantScanning(t, fx, token, "titogo") rec := storedRecord(t, fx, "titogo") if rec.Scanning.WebhookGen <= 0 || rec.Scanning.WebhookGen >= 1<<53 { t.Fatalf("webhook_gen = %d, want a positive JSON-safe generation", rec.Scanning.WebhookGen) } want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen)) if got := callbackSecret(t, fx, priv, "titogo"); got != want { t.Fatalf("callback secret = %q, want derived app key %q", got, want) } if rec.Scanning.WebhookKeyID != fx.srv.cfg.CallbackSigner.CurrentKeyID() { t.Fatalf("webhook_key_id = %q, want current key id", rec.Scanning.WebhookKeyID) } } type callbackPutCounter struct { release.Store configPuts atomic.Int64 cancel context.CancelFunc } func (s *callbackPutCounter) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { got, err := s.Store.Put(ctx, key, body, etag) if err == nil && strings.HasSuffix(key, "/config.json") { s.configPuts.Add(1) if s.cancel != nil { s.cancel() } } return got, err } func TestRotateWebhookKeySkipsUnchangedApps(t *testing.T) { fx, _, token, _ := storeArmed(t, "code-webhook-skip") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) grantScanning(t, fx, token, "titogo") counter := &callbackPutCounter{Store: fx.rel} fx.srv.cfg.Releases = counter pub, err := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test") if err != nil { t.Fatal(err) } remaining, changed, err := fx.srv.rotateWebhookBudget(context.Background(), pub) if err != nil { t.Fatal(err) } if remaining != 0 || changed != 0 || counter.configPuts.Load() != 0 { t.Fatalf("unchanged rotation = remaining %d, changed %d, config puts %d; want all zero", remaining, changed, counter.configPuts.Load()) } } func TestRotateWebhookKeyDoesNotDropPrevious(t *testing.T) { fx, _, token, priv := storeArmed(t, "code-webhook-previous") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) grantScanning(t, fx, token, "titogo") fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "new-master", testWebhookMaster) pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test") if _, _, err := fx.srv.rotateWebhookBudget(context.Background(), pub); err != nil { t.Fatal(err) } if fx.srv.cfg.CallbackSigner.previous == nil || fx.srv.cfg.CallbackSigner.previous.id == "" { t.Fatal("rotation dropped the previous callback master") } rec := storedRecord(t, fx, "titogo") want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen)) if got := callbackSecret(t, fx, priv, "titogo"); got != want { t.Fatal("rotation did not deliver the current master") } } func TestGrantFoldsBothSecretsIntoOneApply(t *testing.T) { fx, _, token, _ := storeArmed(t, "code-webhook-one-apply") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) requestScanning(t, fx, token, false) counter := &callbackPutCounter{Store: fx.rel} fx.srv.cfg.Releases = counter if s, o := postJSON(t, scanURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { t.Fatalf("scan grant = %d %v", s, o) } if got := counter.configPuts.Load(); got != 1 { t.Fatalf("scan grant wrote config %d times, want one Apply", got) } } func TestRevokeUnsetsTheCallbackSecret(t *testing.T) { fx, _, token, priv := storeArmed(t, "code-webhook-revoke") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) grantScanning(t, fx, token, "titogo") _ = callbackSecret(t, fx, priv, "titogo") if s, o := postJSON(t, scanURL(fx, "titogo")+"/revoke", map[string]any{}, token); s != http.StatusOK { t.Fatalf("scan revoke = %d %v", s, o) } if _, ok := appVars(t, fx, "titogo")[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"]; ok { t.Fatal("revoke left the callback secret in app config") } } func TestRotateIsBudgetedAndResumable(t *testing.T) { fx, _, token, _ := storeArmed(t, "code-webhook-budget") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) grantScanning(t, fx, token, "titogo") if _, err := fx.st.CreateAppIn(OpsAccountID, "second", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if s, o := postJSON(t, storeURL(fx, "second"), map[string]any{}, token); s != http.StatusOK { t.Fatalf("declare second = %d %v", s, o) } if s, o := postJSON(t, storeURL(fx, "second")+"/grant", map[string]any{}, token); s != http.StatusOK { t.Fatalf("store grant second = %d %v", s, o) } grantScanning(t, fx, token, "second") fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "replacement-master", testWebhookMaster) pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test") ctx, cancel := context.WithCancel(context.Background()) counter := &callbackPutCounter{Store: fx.rel, cancel: cancel} fx.srv.cfg.Releases = counter remaining, changed, err := fx.srv.rotateWebhookBudget(ctx, pub) if err != nil { t.Fatal(err) } if changed != 1 || remaining != 1 { t.Fatalf("bounded rotation = changed %d remaining %d, want 1/1", changed, remaining) } counter.cancel = nil remaining, changed, err = fx.srv.rotateWebhookBudget(context.Background(), pub) if err != nil { t.Fatal(err) } if changed != 1 || remaining != 0 { t.Fatalf("resumed rotation = changed %d remaining %d, want 1/0", changed, remaining) } } func TestNoMasterRefusesACallbackPath(t *testing.T) { fx, token := enqueueArmed(t, "code-webhook-no-master") fx.srv.cfg.CallbackSigner = nil status, out := postJSON(t, enqueueURL(fx, "titogo"), map[string]any{"key": "uploads/x", "callback_path": "/scan-result"}, token) if status != http.StatusServiceUnavailable || out["reason"] != "callbacks_not_configured" { t.Fatalf("enqueue callback without master = %d %v, want 503 callbacks_not_configured", status, out) } if got := queuedKeys(t, fx, "titogo"); len(got) != 0 { t.Fatalf("refused callback still queued work: %v", got) } } func TestPerAppKeyGenerationRotatesOneApp(t *testing.T) { fx, _, token, priv := storeArmed(t, "code-webhook-one-app") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) grantScanning(t, fx, token, "titogo") before := storedRecord(t, fx, "titogo").Scanning.WebhookGen secretBefore := callbackSecret(t, fx, priv, "titogo") if s, o := postJSON(t, scanURL(fx, "titogo")+"/callback-key/rotate", map[string]any{}, token); s != http.StatusOK { t.Fatalf("per-app rotate = %d %v", s, o) } after := storedRecord(t, fx, "titogo").Scanning.WebhookGen if after == before || after <= 0 || after >= 1<<53 { t.Fatalf("webhook_gen did not rotate safely: %d -> %d", before, after) } if got := callbackSecret(t, fx, priv, "titogo"); got == secretBefore { t.Fatal("per-app rotation left the delivered callback secret unchanged") } } func TestReclaimedAppNameGetsADifferentKey(t *testing.T) { fx, _, token, priv := storeArmed(t, "code-webhook-reclaim") grantStore(t, fx, token) fx.srv.cfg.StoreScanner = objstore.NewMemScanner() enableDeploymentScanning(t, fx) grantScanning(t, fx, token, "titogo") old := callbackSecret(t, fx, priv, "titogo") keys, err := fx.rel.List(context.Background(), release.AppPrefix(fx.sqid, "titogo")) if err != nil { t.Fatal(err) } for _, key := range keys { if err := fx.rel.Delete(context.Background(), key); err != nil { t.Fatal(err) } } declareStore(t, fx, token) if s, o := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); s != http.StatusOK { t.Fatalf("reclaimed store grant = %d %v", s, o) } grantScanning(t, fx, token, "titogo") if got := callbackSecret(t, fx, priv, "titogo"); got == old { t.Fatal("reclaimed app name received the deleted app's callback key") } }