diff --git a/internal/handlers/billing_testcard_payment_test.go b/internal/handlers/billing_testcard_payment_test.go new file mode 100644 index 0000000..67d643e --- /dev/null +++ b/internal/handlers/billing_testcard_payment_test.go @@ -0,0 +1,305 @@ +package handlers_test + +// billing_testcard_payment_test.go — Wave 4 (docs/ci/01-CI-INTEGRATION-DESIGN.md +// §Razorpay, Approach B): the deterministic webhook-injection payment +// integration test. Runs in the api test gate against the test Postgres — NOT +// the live-k8s e2e suite (that lives in e2e/plan_upgrade_e2e_test.go and drives +// the real cluster). This is the per-PR, hermetic, real-backend proof that the +// free → upgrade → Pro payment path works end-to-end through RazorpayWebhook. +// +// Razorpay TEST MODE needs no recurring approval (the prod live-checkout gate is +// an operator/account blocker, not a code one), so Approach B is green TODAY: +// it self-signs the exact subscription.charged webhook body and POSTs it, +// exercising the real handler path the live Razorpay would hit. +// +// Coverage (mirrors the brief): +// - mint a free cohort team (Part A factory model path via testhelpers). +// - construct the EXACT subscription.charged body (raw bytes, signed in place +// — never re-marshalled after signing, so the HMAC matches verbatim). +// - sign hex(HMAC-SHA256(rawBody, webhookSecret)) — reusing the SAME +// signRazorpayPayload the existing suite + the verifier agree on. +// - assert tier upgraded to pro AND active permanent resources were elevated +// (ElevateResourceTiersByTeam ran inside UpgradeTeamAllTiersWithSubscription). +// - assert the upgrade contract surfaces: plans.Registry resolves pro limits +// that are strictly larger than free, and the elevated resource now carries +// the pro snapshot — a sanity check that the elevation is real, not just a +// plan_tier column flip. +// - NEGATIVES: tampered body / wrong secret → 400, NO upgrade. +// - IDEMPOTENCY: same x-razorpay-event-id twice → exactly one upgrade. +// - FAILURE PATH: payment.failed → no upgrade (+ the grace/audit state the +// handler produces; we assert state, not email delivery — the failure email +// is webhook-gated per project_payment_failure_email_coverage). + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +// testCardSkipUnlessDB skips the suite when no test Postgres is wired (mirrors +// dunningWebhookSkipUnlessDB so a baseline `go test -short` with no DB is clean). +func testCardSkipUnlessDB(t *testing.T) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("billing test-card payment: TEST_DATABASE_URL not set") + } +} + +// nowUnix returns the current Unix-second timestamp — used to stamp the +// webhook body's created_at inside the handler's ±5-min replay window so the +// timestamp guard (verifyRazorpayTimestamp) accepts the event. +func nowUnix() int64 { return time.Now().Unix() } + +// subscriptionChargedRawBody builds the EXACT subscription.charged JSON body the +// handler reads, with a `created_at` inside the ±5-min replay window so the +// timestamp guard accepts it. Returns the raw bytes — the caller signs THESE +// bytes and POSTs THEM unchanged (re-marshalling after signing would change the +// byte order and break the HMAC). +// +// teamID is stamped into notes.team_id (resolveTeamFromNotes' primary path). +// planID is the Pro plan_id so planIDToTier resolves "pro". +func subscriptionChargedRawBody(t *testing.T, teamID, subID, planID string, createdAt int64) []byte { + t.Helper() + subEntity, err := json.Marshal(map[string]any{ + "id": subID, + "entity": "subscription", + "plan_id": planID, + "status": "active", + "notes": map[string]any{"team_id": teamID}, + }) + require.NoError(t, err) + payEntity, err := json.Marshal(map[string]any{ + "id": "pay_test_" + uuid.NewString()[:12], + "entity": "payment", + "status": "captured", + "amount": 410000, + "currency": "INR", + }) + require.NoError(t, err) + event := map[string]any{ + "id": "evt_test_" + uuid.NewString(), + "entity": "event", + "event": "subscription.charged", + "created_at": createdAt, + "payload": map[string]any{ + "subscription": map[string]any{"entity": json.RawMessage(subEntity)}, + "payment": map[string]any{"entity": json.RawMessage(payEntity)}, + }, + } + body, err := json.Marshal(event) + require.NoError(t, err) + return body +} + +// postSignedWebhookRaw signs the EXACT bytes with the given secret (reusing the +// shared signRazorpayPayload — the same primitive verifyRazorpaySignature +// checks, guaranteeing parity) and POSTs them unchanged, optionally setting +// X-Razorpay-Event-Id. Returns the response. +func postSignedWebhookRaw(t *testing.T, app interface { + Test(*http.Request, ...int) (*http.Response, error) +}, secret string, body []byte, eventID string, +) *http.Response { + t.Helper() + sig := signRazorpayPayload(t, secret, body) + req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Razorpay-Signature", sig) + if eventID != "" { + req.Header.Set("X-Razorpay-Event-Id", eventID) + } + resp, err := app.Test(req, 5000) + require.NoError(t, err) + return resp +} + +// ── HAPPY PATH: free → subscription.charged(pro) → pro + resources elevated ── + +func TestBillingTestCard_SubscriptionCharged_UpgradesAndElevatesResources(t *testing.T) { + testCardSkipUnlessDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + app, cfg := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "free") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + // A real active permanent resource, minted under the free tier — it must be + // elevated to pro by the charge (ElevateResourceTiersByTeam). + _, resID := seedActiveResource(t, db, teamID, "redis", "free") + + body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix()) + resp := postSignedWebhookRaw(t, app, testWebhookSecret, body, "evt_"+uuid.NewString()) + require.Equal(t, http.StatusOK, resp.StatusCode, "valid signed subscription.charged must 200") + + // Tier upgraded. + var planTier string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&planTier)) + assert.Equal(t, "pro", planTier, "subscription.charged with the Pro plan_id must upgrade the team to pro") + + // Active permanent resource elevated to the pro snapshot. + var resTier string + require.NoError(t, db.QueryRow(`SELECT tier FROM resources WHERE id = $1::uuid`, resID).Scan(&resTier)) + assert.Equal(t, "pro", resTier, "ElevateResourceTiersByTeam must lift the active resource to pro") + + // Upgrade contract surface: pro limits resolve strictly larger than free, + // and the elevated resource now resolves the larger limit. Reads the live + // plans.Registry (not hardcoded numbers) so a plans.yaml change can't drift + // this assertion into a lie. + reg := plans.Default() + freeRedis := reg.StorageLimitMB("free", "redis") + proRedis := reg.StorageLimitMB("pro", "redis") + require.Greater(t, proRedis, freeRedis, + "sanity: pro redis limit must exceed free (else the elevation proves nothing)") + assert.Equal(t, proRedis, reg.StorageLimitMB(resTier, "redis"), + "the elevated resource's tier must resolve pro-tier limits") +} + +// ── NEGATIVE: tampered body → 400, NO upgrade ─────────────────────────────── + +func TestBillingTestCard_TamperedBody_Rejected_NoUpgrade(t *testing.T) { + testCardSkipUnlessDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + app, cfg := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "free") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix()) + // Sign the ORIGINAL body, then tamper a byte AFTER signing → HMAC no longer + // matches the bytes on the wire. + sig := signRazorpayPayload(t, testWebhookSecret, body) + tampered := append([]byte{}, body...) + tampered[len(tampered)/2] ^= 0xFF // flip a byte in the middle + + req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(tampered)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Razorpay-Signature", sig) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "tampered body must be rejected 400") + + assertNotUpgraded(t, db, teamID) +} + +// ── NEGATIVE: wrong secret → 400, NO upgrade ──────────────────────────────── + +func TestBillingTestCard_WrongSecret_Rejected_NoUpgrade(t *testing.T) { + testCardSkipUnlessDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + app, cfg := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "free") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix()) + // Sign with a DIFFERENT secret than the handler is configured with. + resp := postSignedWebhookRaw(t, app, "totally-the-wrong-secret", body, "evt_"+uuid.NewString()) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "wrong-secret signature must be rejected 400") + + assertNotUpgraded(t, db, teamID) +} + +// ── IDEMPOTENCY: same x-razorpay-event-id twice → exactly one upgrade ──────── + +func TestBillingTestCard_DuplicateEventID_UpgradesExactlyOnce(t *testing.T) { + testCardSkipUnlessDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + app, cfg := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "free") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + // ONE body + ONE event_id, replayed. + body := subscriptionChargedRawBody(t, teamID, "sub_"+uuid.NewString(), cfg.RazorpayPlanIDPro, nowUnix()) + eventID := "evt_replay_" + uuid.NewString() + + resp1 := postSignedWebhookRaw(t, app, testWebhookSecret, body, eventID) + require.Equal(t, http.StatusOK, resp1.StatusCode, "first delivery must 200") + var planAfterFirst string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&planAfterFirst)) + require.Equal(t, "pro", planAfterFirst, "first delivery must upgrade to pro") + + resp2 := postSignedWebhookRaw(t, app, testWebhookSecret, body, eventID) + require.Equal(t, http.StatusOK, resp2.StatusCode, "replayed delivery must 200") + var replay struct { + OK bool `json:"ok"` + Deduped bool `json:"deduped"` + } + require.NoError(t, decodeJSON(resp2, &replay)) + assert.True(t, replay.Deduped, + "the second delivery of the same event_id must be deduped (upgrade state machine fires exactly once)") + + // The dedup table must hold exactly one row for this event_id — the + // load-bearing "exactly once" proof (the tier column would read pro after a + // re-fire too, so the count is the real guard). + var n int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM razorpay_webhook_events WHERE event_id = $1`, eventID).Scan(&n)) + assert.Equal(t, 1, n, "exactly one dedup row must exist for a replayed event_id") +} + +// ── FAILURE PATH: payment.failed → no upgrade ─────────────────────────────── + +func TestBillingTestCard_PaymentFailed_NoUpgrade(t *testing.T) { + testCardSkipUnlessDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + app, _ := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "free") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + // A payment.failed carrying notes.team_id (so the handler resolves the team) + // must NOT upgrade — a declined card never grants a tier. + payEntity, err := json.Marshal(map[string]any{ + "id": "pay_test_" + uuid.NewString()[:12], + "entity": "payment", + "status": "failed", + "amount": 410000, + "currency": "INR", + "attempt_count": 1, + "error_description": "Card declined (test)", + "notes": map[string]any{"team_id": teamID}, + }) + require.NoError(t, err) + event := map[string]any{ + "id": "evt_test_" + uuid.NewString(), + "entity": "event", + "event": "payment.failed", + "created_at": nowUnix(), + "payload": map[string]any{ + "payment": map[string]any{"entity": json.RawMessage(payEntity)}, + }, + } + body, err := json.Marshal(event) + require.NoError(t, err) + + resp := postSignedWebhookRaw(t, app, testWebhookSecret, body, "evt_"+uuid.NewString()) + require.Equal(t, http.StatusOK, resp.StatusCode, "payment.failed must 200 (acknowledged, not retried)") + + // The team must stay on free — the declined payment grants nothing. + assertNotUpgraded(t, db, teamID) +} + +// assertNotUpgraded asserts the team is still on the free tier (no upgrade +// leaked through) — the shared negative-path invariant. +func assertNotUpgraded(t *testing.T, db *sql.DB, teamID string) { + t.Helper() + var planTier string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&planTier)) + assert.Equal(t, "free", planTier, "a rejected/failed payment must NOT upgrade the team") +} diff --git a/internal/handlers/error_envelope_coverage_test.go b/internal/handlers/error_envelope_coverage_test.go index 1a16d33..d593dd6 100644 --- a/internal/handlers/error_envelope_coverage_test.go +++ b/internal/handlers/error_envelope_coverage_test.go @@ -60,6 +60,7 @@ var coverageAllowlist = map[string]string{ "tier_not_allowed": "CI-only /internal/e2e/account gated-tier 400 (machine-to-machine; not customer-facing)", "tier_set_failed": "CI-only /internal/e2e/account mint 503 (machine-to-machine; not customer-facing)", "rand_failed": "CI-only /internal/e2e/account mint 503 (machine-to-machine; not customer-facing)", + "seed_failed": "CI-only /internal/e2e/account with_resources mint 503 (machine-to-machine; not customer-facing)", } // TestErrorCode_HasAgentAction is the registry-iterating coverage gate. diff --git a/internal/handlers/internal_e2e_account.go b/internal/handlers/internal_e2e_account.go index e5cbd34..bb1e340 100644 --- a/internal/handlers/internal_e2e_account.go +++ b/internal/handlers/internal_e2e_account.go @@ -135,12 +135,33 @@ func NewE2EAccountHandler(db *sql.DB, rdb *redis.Client, cfg *config.Config) *E2 return &E2EAccountHandler{db: db, rdb: rdb, cfg: cfg} } -// e2eCreateRequest is the POST body. Both fields optional. +// e2eCreateRequest is the POST body. All fields optional. type e2eCreateRequest struct { Tier string `json:"tier"` Env string `json:"env"` + // WithResources, when true, pre-seeds a small set of FAST resources on the + // minted team so a CI journey can start from a populated account (a list + // has rows, a detail page resolves, a delete-then-replace flow has + // something to delete) without first having to drive a provision. + // + // Deliberately minimal + fast: it seeds ONLY row-only resources that need + // no backend RPC (a webhook receiver + a cache row), inserted directly as + // active rows at the team's tier. It does NOT provision a dedicated + // Postgres / Mongo (those are slow and need the provisioner + hot-pool) — + // a journey that needs those drives the real provision endpoint with the + // forceAnon hot-pool headers. The seeded rows carry the team's tier + // snapshot, exactly like a real provision under that tier, and are reaped + // with the team (team_id→NULL + marked-for-reaper) by ReapAccount. + WithResources bool `json:"with_resources"` } +// e2eSeedResourceTypes is the closed set of FAST, row-only resource types the +// with_resources pre-seed creates. Both need no backend provision RPC, so the +// seed is synchronous + sub-millisecond — safe inside the mint request. +// Iterated (not hand-listed at the call site) so adding a type here +// automatically expands what the seed creates AND what the seed test asserts. +var e2eSeedResourceTypes = []string{"webhook", "cache"} + // authorize runs the X-E2E-Token guard. It returns true iff the token is // configured AND the header matches in constant time. On any failure it has // ALREADY written the 404 response and bumped the unauthorized metric — the @@ -263,6 +284,23 @@ func (h *E2EAccountHandler) CreateAccount(c *fiber.Ctx) error { team.PlanTier = tier } + // 3b. Optionally pre-seed a small set of FAST resources so the journey can + // start from a populated account. Synchronous (row-only inserts, no + // backend RPC) and tier-snapshotted at the team's tier. A seed failure + // is a hard error — CI asked for a populated account and got a partial + // one, which would make the journey flaky; better to fail the mint + // loudly. The rows are reaped with the team. + var seededTokens []string + if req.WithResources { + toks, serr := e2eSeedFastResources(h, ctx, team.ID, tier, env) + if serr != nil { + metrics.E2EAccountTotal.WithLabelValues(e2eMetricOpCreate, e2eResultError).Inc() + slog.Error("internal.e2e.create.seed_failed", "error", serr, "team_id", team.ID.String()) + return respondError(c, fiber.StatusServiceUnavailable, "seed_failed", "failed to seed resources") + } + seededTokens = toks + } + // 4. Mint the session JWT with the SAME signer + claim shape the customer // auth path uses, so it authenticates through ordinary RequireAuth. expiresAt := time.Now().UTC().Add(e2eSessionTTL) @@ -294,16 +332,62 @@ func (h *E2EAccountHandler) CreateAccount(c *fiber.Ctx) error { metrics.E2EAccountTotal.WithLabelValues(e2eMetricOpCreate, e2eResultOK).Inc() slog.Info("internal.e2e.create.done", "team_id", team.ID.String(), "tier", tier, "env", env) + // seededTokens is always present in the response (empty array when + // with_resources was false) so a CI caller can branch on its length + // without a nil check. + if seededTokens == nil { + seededTokens = []string{} + } return c.JSON(fiber.Map{ - "team_id": team.ID.String(), - "user_id": user.ID.String(), - "email": email, - "tier": tier, - "session_jwt": sessionJWT, - "expires_at": expiresAt.Format(time.RFC3339), + "team_id": team.ID.String(), + "user_id": user.ID.String(), + "email": email, + "tier": tier, + "session_jwt": sessionJWT, + "expires_at": expiresAt.Format(time.RFC3339), + "seeded_tokens": seededTokens, + "seeded_count": len(seededTokens), }) } +// seedFastResources pre-seeds the with_resources set: one active row per +// e2eSeedResourceTypes entry, owned by teamID, tier-snapshotted at `tier`, +// scoped to `env` (empty → the model's EnvDefault). Returns the seeded tokens. +// +// Each row is created with CreateResource (status=pending) then flipped to +// active via MarkResourceActive — the SAME two-phase lifecycle a real +// provision uses — so the seeded rows are indistinguishable from a normal +// provision under that tier for any read path (list/detail/limits). No backend +// RPC is issued: these types are row-only (a webhook receiver lives in Redis, +// a cache row needs no dedicated infra to satisfy a list/detail journey), so +// the seed is fast and synchronous. Any error aborts (returns it) — the caller +// turns it into a 503 so CI never receives a half-populated account. +// +// A package-var seam (not a direct method call) so a test can force the +// caller's seed_failed (503) arm without needing to make the real resources +// table reject an insert mid-request. +var e2eSeedFastResources = (*E2EAccountHandler).seedFastResources + +func (h *E2EAccountHandler) seedFastResources(ctx context.Context, teamID uuid.UUID, tier, env string) ([]string, error) { + tokens := make([]string, 0, len(e2eSeedResourceTypes)) + for _, rt := range e2eSeedResourceTypes { + res, err := models.CreateResource(ctx, h.db, models.CreateResourceParams{ + TeamID: &teamID, + ResourceType: rt, + Tier: tier, + Env: env, + }) + if err != nil { + return nil, fmt.Errorf("seed %s: %w", rt, err) + } + if err := models.MarkResourceActive(ctx, h.db, res.ID); err != nil { + return nil, fmt.Errorf("activate seeded %s: %w", rt, err) + } + tokens = append(tokens, res.Token.String()) + } + return tokens, nil +} + // ReapAccount handles DELETE /internal/e2e/account/:team_id. func (h *E2EAccountHandler) ReapAccount(c *fiber.Ctx) error { if !h.authorize(c, e2eMetricOpReap) { diff --git a/internal/handlers/internal_e2e_account_export_test.go b/internal/handlers/internal_e2e_account_export_test.go index b5cd5cb..1869005 100644 --- a/internal/handlers/internal_e2e_account_export_test.go +++ b/internal/handlers/internal_e2e_account_export_test.go @@ -4,11 +4,55 @@ package handlers // internal_e2e_account_*_test.go coverage suite (package handlers_test). import ( + "context" "time" "github.com/google/uuid" ) +// E2EAllowedTiersForTest exposes the closed set of tiers the mint accepts so a +// registry-iterating test (rule 18) can assert every allowed tier round-trips +// without re-typing the list — a hand-typed slice would itself be a single-site +// fallacy. Returns a copy so a test cannot mutate the handler's source of truth. +func E2EAllowedTiersForTest() []string { + out := make([]string, 0, len(e2eAllowedTiers)) + for tier := range e2eAllowedTiers { + out = append(out, tier) + } + return out +} + +// E2EBlockedTiersForTest exposes the explicitly-rejected (gated) tiers so a +// registry-iterating test asserts each one 400s with tier_not_allowed. +func E2EBlockedTiersForTest() []string { + out := make([]string, 0, len(e2eBlockedTiers)) + for tier := range e2eBlockedTiers { + out = append(out, tier) + } + return out +} + +// E2ESeedResourceTypesForTest exposes the with_resources seed set so the seed +// test asserts exactly the resource types the handler creates — iterated, not +// hand-listed, so adding a seed type auto-expands the assertion. +func E2ESeedResourceTypesForTest() []string { + out := make([]string, len(e2eSeedResourceTypes)) + copy(out, e2eSeedResourceTypes) + return out +} + +// SetE2ESeedFastResourcesForTest overrides the e2eSeedFastResources seam so a +// test can force CreateAccount's seed_failed (503) arm deterministically, +// without making the real resources table reject an insert mid-request. +// Returns a restore func. +func SetE2ESeedFastResourcesForTest(err error) (restore func()) { + prev := e2eSeedFastResources + e2eSeedFastResources = func(_ *E2EAccountHandler, _ context.Context, _ uuid.UUID, _, _ string) ([]string, error) { + return nil, err + } + return func() { e2eSeedFastResources = prev } +} + // SetE2ESignSessionJWTForTest overrides the e2eSignSessionJWT seam so a test // can force the token_issue_failed (503) arm of CreateAccount. Returns a // restore func. HS256-over-[]byte never errors in practice, so this seam is diff --git a/internal/handlers/internal_e2e_account_seed_whitebox_test.go b/internal/handlers/internal_e2e_account_seed_whitebox_test.go new file mode 100644 index 0000000..6223958 --- /dev/null +++ b/internal/handlers/internal_e2e_account_seed_whitebox_test.go @@ -0,0 +1,77 @@ +package handlers + +// internal_e2e_account_seed_whitebox_test.go — whitebox coverage for the +// with_resources seed error arms (seedFastResources). The happy seed path is +// covered end-to-end by the external suite against a real test DB; these tests +// drive the two failure branches deterministically with sqlmock so the 100%- +// patch gate is satisfied without a flaky "make the real DB fail" dance: +// +// - CreateResource error → seedFastResources returns "seed : ..." +// - MarkResourceActive err → seedFastResources returns "activate seeded ...: ..." + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" +) + +// resourceReturningRow builds a single fully-populated resources row in the +// exact column order scanResource expects, so a mocked CreateResource INSERT … +// RETURNING parses cleanly and the test can advance to the MarkResourceActive +// step. Values are placeholders — only the shape matters. +func resourceReturningRow() *sqlmock.Rows { + cols := []string{ + "id", "team_id", "token", "resource_type", "name", "connection_url", "key_prefix", "tier", + "env", "fingerprint", "cloud_vendor", "country_code", "status", "migration_status", + "expires_at", "storage_bytes", "provider_resource_id", "created_request_id", "parent_resource_id", "paused_at", + "last_seen_at", "degraded", "degraded_reason", "last_reconciled_at", "auth_mode", "created_at", + } + return sqlmock.NewRows(cols).AddRow( + uuid.New(), uuid.New(), uuid.New(), "cache", nil, nil, nil, "pro", + "development", nil, nil, nil, "pending", "none", + nil, int64(0), nil, nil, nil, nil, + nil, false, nil, nil, "legacy_open", time.Now(), + ) +} + +func TestSeedFastResources_CreateResourceError(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + // First seed type's INSERT fails outright. + mock.ExpectQuery(`INSERT INTO resources`).WillReturnError(errors.New("insert boom")) + + h := &E2EAccountHandler{db: db, cfg: &config.Config{}} + toks, serr := h.seedFastResources(context.Background(), uuid.New(), "pro", "") + require.Error(t, serr) + require.Contains(t, serr.Error(), "seed") + require.Contains(t, serr.Error(), "insert boom") + require.Nil(t, toks) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestSeedFastResources_MarkResourceActiveError(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + defer db.Close() + + // INSERT succeeds (returns a pending row); the activate UPDATE then fails. + mock.ExpectQuery(`INSERT INTO resources`).WillReturnRows(resourceReturningRow()) + mock.ExpectExec(`UPDATE resources SET status = 'active'`).WillReturnError(errors.New("update boom")) + + h := &E2EAccountHandler{db: db, cfg: &config.Config{}} + toks, serr := h.seedFastResources(context.Background(), uuid.New(), "pro", "") + require.Error(t, serr) + require.Contains(t, serr.Error(), "activate seeded") + require.Contains(t, serr.Error(), "update boom") + require.Nil(t, toks) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/handlers/internal_e2e_account_test.go b/internal/handlers/internal_e2e_account_test.go index 2145e3d..ccae448 100644 --- a/internal/handlers/internal_e2e_account_test.go +++ b/internal/handlers/internal_e2e_account_test.go @@ -92,13 +92,15 @@ func newE2ETestApp(t *testing.T, db *sql.DB, rdb *redis.Client, token string) *f // e2eCreateResp is the create-endpoint response shape we assert on. type e2eCreateResp struct { - TeamID string `json:"team_id"` - UserID string `json:"user_id"` - Email string `json:"email"` - Tier string `json:"tier"` - SessionJWT string `json:"session_jwt"` - ExpiresAt string `json:"expires_at"` - Error string `json:"error"` + TeamID string `json:"team_id"` + UserID string `json:"user_id"` + Email string `json:"email"` + Tier string `json:"tier"` + SessionJWT string `json:"session_jwt"` + ExpiresAt string `json:"expires_at"` + SeededTokens []string `json:"seeded_tokens"` + SeededCount int `json:"seeded_count"` + Error string `json:"error"` } func postE2ECreate(t *testing.T, app *fiber.App, token, body string) *http.Response { @@ -278,6 +280,146 @@ func TestE2EAccount_Create_UnknownTier_Rejected400(t *testing.T) { require.Equal(t, "invalid_tier", out.Error) } +// TestE2EAccount_Create_AllAllowedTiers_RoundTrip is the rule-18 registry- +// iterating guard: EVERY tier the handler advertises as allowed must mint +// successfully and the minted team must carry that tier as its plan_tier +// snapshot. Iterating handlers.E2EAllowedTiersForTest() (not a hand-typed +// slice) means adding a tier to the allow-set automatically expands this +// assertion — a new allowed tier can't silently ship un-exercised. +func TestE2EAccount_Create_AllAllowedTiers_RoundTrip(t *testing.T) { + skipUnlessE2EDB(t) + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + app := newE2ETestApp(t, db, nil, testE2EToken) + + for _, tier := range handlers.E2EAllowedTiersForTest() { + tier := tier + t.Run(tier, func(t *testing.T) { + resp := postE2ECreate(t, app, testE2EToken, fmt.Sprintf(`{"tier":%q}`, tier)) + require.Equal(t, http.StatusOK, resp.StatusCode, "tier %q must mint", tier) + out := decodeE2ECreate(t, resp) + require.Equal(t, tier, out.Tier, "response tier must echo the requested tier") + + var planTier string + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT plan_tier FROM teams WHERE id = $1`, out.TeamID).Scan(&planTier)) + // The team's persisted plan_tier mirrors the requested tier for every + // real team plan. "anonymous" is NOT a team plan — an anonymous + // account is a free team row (CreateTestCohortTeam starts at 'free') + // with tier="anonymous" echoed on the response for the caller to + // drive the anon-path journey. So the persisted plan_tier is 'free'. + wantPlanTier := tier + if tier == "anonymous" { + wantPlanTier = "free" + } + require.Equal(t, wantPlanTier, planTier, + "minted team's plan_tier must reflect the requested tier (snapshot at creation)") + }) + } +} + +// TestE2EAccount_Create_AllBlockedTiers_Rejected is the companion guard: every +// gated tier (team/growth) must 400 with tier_not_allowed. Iterating the +// handler's blocked-set keeps the test honest if a tier is added to the gate. +func TestE2EAccount_Create_AllBlockedTiers_Rejected(t *testing.T) { + t.Parallel() + app := newE2ETestApp(t, nil, nil, testE2EToken) + for _, tier := range handlers.E2EBlockedTiersForTest() { + tier := tier + t.Run(tier, func(t *testing.T) { + resp := postE2ECreate(t, app, testE2EToken, fmt.Sprintf(`{"tier":%q}`, tier)) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "gated tier %q must be rejected (never minted)", tier) + out := decodeE2ECreate(t, resp) + require.Equal(t, "tier_not_allowed", out.Error) + }) + } +} + +// --- create: with_resources pre-seed ---------------------------------------- + +// TestE2EAccount_Create_WithResources_SeedsFastResources asserts that +// with_resources=true pre-seeds exactly the handler's seed set, that each seeded +// row is active + owned by the minted team + tier-snapshotted, and that the +// response surfaces the seeded tokens. Iterates the handler's seed-type list so +// adding a seed type auto-expands the assertion. +func TestE2EAccount_Create_WithResources_SeedsFastResources(t *testing.T) { + skipUnlessE2EDB(t) + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + app := newE2ETestApp(t, db, nil, testE2EToken) + + resp := postE2ECreate(t, app, testE2EToken, `{"tier":"pro","with_resources":true}`) + require.Equal(t, http.StatusOK, resp.StatusCode) + out := decodeE2ECreate(t, resp) + + wantTypes := handlers.E2ESeedResourceTypesForTest() + require.Equal(t, len(wantTypes), out.SeededCount, + "seeded_count must equal the handler's seed-type count") + require.Len(t, out.SeededTokens, len(wantTypes), + "seeded_tokens must carry one token per seed type") + + ctx := context.Background() + // Every seeded resource row must be active, owned by the minted team, and + // carry the team's tier snapshot. + gotTypes := map[string]bool{} + for _, tok := range out.SeededTokens { + var rtype, status, tier string + var teamID string + require.NoError(t, db.QueryRowContext(ctx, + `SELECT resource_type, status, tier, team_id::text FROM resources WHERE token = $1`, tok). + Scan(&rtype, &status, &tier, &teamID)) + require.Equal(t, "active", status, "seeded resource must be active") + require.Equal(t, "pro", tier, "seeded resource must carry the team tier snapshot") + require.Equal(t, out.TeamID, teamID, "seeded resource must be owned by the minted team") + gotTypes[rtype] = true + } + for _, want := range wantTypes { + require.True(t, gotTypes[want], "seed set must include a %q resource", want) + } +} + +// TestE2EAccount_Create_WithoutResources_SeedsNothing pins that the seed is +// opt-in: omitting with_resources mints an empty account (seeded_count=0) and +// the response still carries an empty (non-null) seeded_tokens array. +func TestE2EAccount_Create_WithoutResources_SeedsNothing(t *testing.T) { + skipUnlessE2EDB(t) + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + app := newE2ETestApp(t, db, nil, testE2EToken) + + resp := postE2ECreate(t, app, testE2EToken, `{"tier":"free"}`) + require.Equal(t, http.StatusOK, resp.StatusCode) + out := decodeE2ECreate(t, resp) + require.Equal(t, 0, out.SeededCount) + require.NotNil(t, out.SeededTokens, "seeded_tokens must be [] not null when nothing is seeded") + require.Empty(t, out.SeededTokens) + + var n int + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT count(*) FROM resources WHERE team_id = $1`, out.TeamID).Scan(&n)) + require.Equal(t, 0, n, "no resources must be seeded when with_resources is omitted") +} + +// TestE2EAccount_Create_WithResources_SeedFailure_Returns503 forces the seed +// step to fail (via the e2eSeedFastResources seam) and asserts CreateAccount +// surfaces a 503 seed_failed — CI must never receive a half-populated account. +func TestE2EAccount_Create_WithResources_SeedFailure_Returns503(t *testing.T) { + skipUnlessE2EDB(t) + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + app := newE2ETestApp(t, db, nil, testE2EToken) + + restore := handlers.SetE2ESeedFastResourcesForTest(errors.New("seed exploded")) + defer restore() + + resp := postE2ECreate(t, app, testE2EToken, `{"tier":"pro","with_resources":true}`) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, + "a seed failure must surface as 503, never a half-populated 200") + out := decodeE2ECreate(t, resp) + require.Equal(t, "seed_failed", out.Error) +} + // --- reap -------------------------------------------------------------------- func TestE2EAccount_Reap_TestCohortTeam_Purged(t *testing.T) { diff --git a/internal/handlers/provision_atomicity_coverage_test.go b/internal/handlers/provision_atomicity_coverage_test.go index df8e31b..0ea6d85 100644 --- a/internal/handlers/provision_atomicity_coverage_test.go +++ b/internal/handlers/provision_atomicity_coverage_test.go @@ -90,7 +90,17 @@ func TestEveryCreateResourceCallSiteIsFollowedByFinalizeProvision(t *testing.T) // finalizeProvision. Empty today — add an entry only with a justifying // comment naming the alternate persistence path the file uses. Any new // entry here is a code-review trigger by itself. - allowList := map[string]string{} + allowList := map[string]string{ + // internal_e2e_account.go seeds row-only test resources (webhook/cache) + // for the CI with_resources factory. There is NO backend provision and + // NO credential to persist — the orphan-generator shape finalizeProvision + // guards against cannot occur. The alternate persistence path is the + // explicit CreateResource (pending) → MarkResourceActive two-phase + // lifecycle in seedFastResources; a failure of either arm returns an + // error the caller turns into a 503, never a half-populated 200. + "internal_e2e_account.go": "CI-only row-only test-seed; no backend RPC/credentials — " + + "finalize is CreateResource→MarkResourceActive in seedFastResources", + } type violation struct { file string