diff --git a/internal/handlers/billing_block_helpers_test.go b/internal/handlers/billing_block_helpers_test.go new file mode 100644 index 0000000..4b56152 --- /dev/null +++ b/internal/handlers/billing_block_helpers_test.go @@ -0,0 +1,110 @@ +package handlers_test + +// billing_block_helpers_test.go — shared helpers for the W3 billing-block +// integration suite (billing_block_*_test.go). These are W3-local helpers +// (prefixed billingBlock*) so they do not collide with the existing cov2*/ +// billing* helpers this suite also reuses. NOTHING here redefines an existing +// helper — seedVerifiedTeamUser, cov2CheckoutApp, changePlanAppReal, +// changePlanReq, postCheckoutReq, signRazorpayPayload, +// makeSubscriptionChargedPayloadWithPlan, makeSubscriptionCancelledPayload and +// cov2WebhookAppReal all already exist in the package and are used as-is. + +import ( + "context" + "database/sql" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// billingBlockJWTSecret is the ≥32-byte HMAC secret the W3 suite stamps onto +// every test cfg. Identical value to testhelpers.TestJWTSecret; named locally +// so the intent ("any valid secret, never a real one") is explicit at call +// sites. +const billingBlockJWTSecret = testhelpers.TestJWTSecret + +// billingBlockSkipNoDB skips a W3 test when no test Postgres is configured. +// The billing block is a real-backend integration surface — these tests +// assert on actual rows in teams/resources/audit_log, so a missing DB is a +// loud skip, never a false green. +func billingBlockSkipNoDB(t *testing.T) bool { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("W3 billing-block integration: TEST_DATABASE_URL not set") + return true + } + return false +} + +// billingBlockDB opens a fresh migrated test DB and returns it with its +// cleanup. Thin wrapper over testhelpers.SetupTestDB so every W3 test reads +// the same way. +func billingBlockDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + return testhelpers.SetupTestDB(t) +} + +// mustSeedTeam creates a team row at the given plan tier and registers a +// cleanup. Returns the team id as a string (the shape changePlanAppReal + +// the webhook payload builders consume). +func mustSeedTeam(t *testing.T, db *sql.DB, tier string) string { + t.Helper() + id := testhelpers.MustCreateTeamDB(t, db, tier) + t.Cleanup(func() { + db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, id) + }) + return id +} + +// billingBlockSeedResource inserts an active resource owned by teamID at the +// given tier and returns its id. Used by the webhook-transition tests to +// prove that an upgrade ELEVATES existing resources and a downgrade LEAVES +// them. expires_at is left NULL (a claimed, permanent resource) so the +// reaper-race guard in the elevation UPDATE never excludes it. +func billingBlockSeedResource(t *testing.T, db *sql.DB, teamID uuid.UUID, resourceType, tier string) uuid.UUID { + t.Helper() + res, err := models.CreateResource(context.Background(), db, models.CreateResourceParams{ + TeamID: &teamID, + ResourceType: resourceType, + Name: "w3-" + resourceType + "-" + uuid.NewString()[:8], + Tier: tier, + Env: "production", + }) + require.NoError(t, err, "seed resource (%s/%s)", resourceType, tier) + // CreateResource inserts a 'pending' row; the tier-elevation UPDATE only + // touches active/paused/suspended rows. Flip to 'active' so the resource + // is in the state a real claimed resource would be in when an upgrade + // webhook fires. + require.NoError(t, models.MarkResourceActive(context.Background(), db, res.ID), + "activate seeded resource (%s/%s)", resourceType, tier) + t.Cleanup(func() { + db.Exec(`DELETE FROM resources WHERE id = $1`, res.ID) + }) + return res.ID +} + +// billingBlockResourceTier reads back the current tier of a resource row so a +// test can assert whether a webhook elevated or left it. +func billingBlockResourceTier(t *testing.T, db *sql.DB, id uuid.UUID) string { + t.Helper() + var tier string + require.NoError(t, + db.QueryRow(`SELECT tier FROM resources WHERE id = $1`, id).Scan(&tier), + "read resource tier") + return tier +} + +// billingBlockTeamTier reads back the current plan_tier of a team row. +func billingBlockTeamTier(t *testing.T, db *sql.DB, teamID string) string { + t.Helper() + var tier string + require.NoError(t, + db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&tier), + "read team plan_tier") + return tier +} diff --git a/internal/handlers/billing_block_no_cancel_downgrade_test.go b/internal/handlers/billing_block_no_cancel_downgrade_test.go new file mode 100644 index 0000000..583a166 --- /dev/null +++ b/internal/handlers/billing_block_no_cancel_downgrade_test.go @@ -0,0 +1,216 @@ +package handlers_test + +// billing_block_no_cancel_downgrade_test.go — W3 §E10: there is NO self-serve +// cancel or downgrade path. +// +// Policy (memory: project_no_self_serve_cancel_downgrade): cancellation and +// downgrade are SUPPORT-ONLY. Downgrade flows through the Razorpay +// subscription.cancelled / .updated webhook or a support agent; a paying team +// must NOT be able to drop itself to a cheaper tier or cancel via any +// session-authenticated endpoint. The self-serve POST /billing/cancel was +// REMOVED (router.go documents the removal next to /billing/change-plan). +// +// Two complementary assertions: +// 1. ROUTE NEGATIVE: string-parse the live router.go and prove no route +// registers a self-serve cancel/downgrade verb. This is the same +// source-scan technique the OpenAPI route-parity test uses +// (extractRouterRoutes) so it tracks the real registration table, not a +// stale mental model. If someone re-adds POST /billing/cancel, this reds. +// 2. HANDLER NEGATIVE: drive the real ChangePlanAPI with a lower-or-equal +// target tier and assert it is rejected with downgrade_not_self_serve + +// a mailto:support agent_action — the exact policy in +// billing.go:ChangePlanAPI. This is verified against the code, not +// assumed: a downgrade returns 400 downgrade_not_self_serve, NOT a +// silent tier drop. + +import ( + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" +) + +// blockRouterRoute is a (method, path, isAdmin) tuple parsed from router.go. +// Local to this W3 file because the OpenAPI test's identically-shaped parser +// lives in the white-box `handlers` test package and is not reachable from +// this black-box `handlers_test` package. +type blockRouterRoute struct { + method string + path string + isAdmin bool +} + +// blockExtractRouterRoutes string-parses router.go and returns every literal +// route registration. Same conservative technique the OpenAPI route-parity +// test uses: it expects a literal "(" after the verb and a quoted path as the +// first arg, skipping any dynamic registration (router.go uses only literal +// paths today). Groups carry their URL prefix so the returned path is fully +// qualified. +func blockExtractRouterRoutes(src string) []blockRouterRoute { + patterns := []struct { + groupRe *regexp.Regexp + urlPrefix string + isAdmin bool + }{ + {regexp.MustCompile(`\bapp\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "", false}, + {regexp.MustCompile(`\bapi\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/api/v1", false}, + {regexp.MustCompile(`\badminGroup\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/api/v1/", true}, + {regexp.MustCompile(`\bdeployGroup\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/deploy", false}, + {regexp.MustCompile(`\binternal\.(Get|Post|Put|Patch|Delete)\("([^"]+)"`), "/internal", false}, + } + var out []blockRouterRoute + for _, p := range patterns { + for _, m := range p.groupRe.FindAllStringSubmatch(src, -1) { + path := m[2] + if p.urlPrefix != "" { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + path = p.urlPrefix + path + } + out = append(out, blockRouterRoute{method: strings.ToUpper(m[1]), path: path, isAdmin: p.isAdmin}) + } + } + return out +} + +// forbiddenSelfServeBillingPaths is the set of route SUFFIXES that, if they +// ever appear as a registered self-serve (session-authenticated, non-admin, +// non-webhook) route, would constitute a self-serve cancel/downgrade surface +// the policy forbids. Matched as a suffix against the parsed router path so +// both the legacy alias and the /api/v1 group form are caught. +var forbiddenSelfServeBillingPaths = []string{ + "/billing/cancel", + "/billing/downgrade", + "/billing/subscription/cancel", + "/subscription/cancel", +} + +// TestBillingBlock_NoSelfServeCancelOrDowngradeRoute parses router.go and +// asserts none of the forbidden self-serve cancel/downgrade paths are +// registered on a non-admin route. Admin routes (e.g. an operator demote) are +// allowed and excluded — cancellation IS supported, just support/operator-side. +// +// This does not require a DB — it reads the router source, the same way +// TestOpenAPI route-parity does, so it runs even in the -short unit lane. +func TestBillingBlock_NoSelfServeCancelOrDowngradeRoute(t *testing.T) { + routerPath := filepath.Join("..", "router", "router.go") + src, err := os.ReadFile(routerPath) + require.NoError(t, err, "read router.go") + + routes := blockExtractRouterRoutes(string(src)) + require.NotEmpty(t, routes, + "blockExtractRouterRoutes returned 0 — parser is out of sync with router.go (the negative assertion would pass vacuously)") + + // Guard against a vacuous pass: confirm the parser actually sees the + // billing block by requiring the legitimate change-plan route to be + // present. If the parser silently broke, this trips before the negative + // assertion can give a false green. + var sawChangePlan bool + for _, r := range routes { + if strings.HasSuffix(r.path, "/billing/change-plan") { + sawChangePlan = true + break + } + } + require.True(t, sawChangePlan, + "expected the router parser to see POST /billing/change-plan — if it doesn't, the no-cancel negative assertion is meaningless") + + for _, r := range routes { + if r.isAdmin { + continue // operator/support-side cancellation is allowed. + } + for _, forbidden := range forbiddenSelfServeBillingPaths { + assert.Falsef(t, strings.HasSuffix(r.path, forbidden), + "self-serve cancel/downgrade is support-only (§E10, memory project_no_self_serve_cancel_downgrade) — "+ + "router.go must not register a non-admin route ending in %q, but found %s %s", + forbidden, r.method, r.path) + } + } +} + +// TestBillingBlock_ChangePlanRejectsDowngrade pins the handler-level policy: a +// paying team requesting a LOWER or EQUAL tier via the in-app change-plan path +// is rejected with downgrade_not_self_serve and routed to support, NOT +// silently dropped. Verified against billing.go:ChangePlanAPI (it returns 400 +// downgrade_not_self_serve + a mailto:support@instanode.dev agent_action for +// any target whose rank ≤ the current tier's rank). +func TestBillingBlock_ChangePlanRejectsDowngrade(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + + cases := []struct { + name string + startTier string + target string + }{ + {"pro → hobby is a downgrade", "pro", "hobby"}, + {"pro → hobby_plus is a downgrade", "pro", "hobby_plus"}, + {"hobby_plus → hobby is a downgrade", "hobby_plus", "hobby"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, clean := billingBlockDB(t) + defer clean() + teamID := mustSeedTeam(t, db, tc.startTier) + cfg := &config.Config{ + JWTSecret: billingBlockJWTSecret, + RazorpayKeyID: "rzp_test_k", + RazorpayKeySecret: "s", + RazorpayPlanIDHobby: "plan_hobby", + RazorpayPlanIDHobbyPlus: "plan_hobby_plus", + RazorpayPlanIDPro: "plan_pro", + } + app := changePlanAppReal(t, db, cfg, teamID) + code, body := changePlanReq(t, app, map[string]any{"target_plan": tc.target}) + + assert.Equal(t, http.StatusBadRequest, code, "downgrade must be a 400, body=%v", body) + assert.Equal(t, "downgrade_not_self_serve", body["error"], + "%s must be rejected as a support-only downgrade, not applied", tc.name) + // The agent_action must route the user to support so an agent does + // not retry or invent a different path. + action, _ := body["agent_action"].(string) + assert.Contains(t, strings.ToLower(action), "support", + "downgrade rejection must carry a support-routing agent_action (got %q)", action) + + // And CRITICALLY: the team's tier must be UNCHANGED — a downgrade + // rejection that still mutated the row would be the worst outcome. + assert.Equal(t, tc.startTier, billingBlockTeamTier(t, db, teamID), + "a rejected downgrade must not mutate the team's plan_tier") + }) + } +} + +// TestBillingBlock_ChangePlanSamePlanRejected covers the lateral/no-op edge: +// requesting the tier the team already holds is rejected with same_plan (not +// treated as a downgrade, not a no-op success that churns the Razorpay +// subscription). Part of the §E10 surface — no self-serve tier mutation that +// isn't a genuine upgrade. +func TestBillingBlock_ChangePlanSamePlanRejected(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + db, clean := billingBlockDB(t) + defer clean() + teamID := mustSeedTeam(t, db, "pro") + cfg := &config.Config{ + JWTSecret: billingBlockJWTSecret, + RazorpayKeyID: "rzp_test_k", + RazorpayKeySecret: "s", + RazorpayPlanIDPro: "plan_pro", + } + app := changePlanAppReal(t, db, cfg, teamID) + code, body := changePlanReq(t, app, map[string]any{"target_plan": "pro"}) + assert.Equal(t, http.StatusBadRequest, code, "body=%v", body) + assert.Equal(t, "same_plan", body["error"], + "requesting the current tier must return same_plan, not a no-op success") + assert.Equal(t, "pro", billingBlockTeamTier(t, db, teamID)) +} diff --git a/internal/handlers/billing_block_purchasable_set_test.go b/internal/handlers/billing_block_purchasable_set_test.go new file mode 100644 index 0000000..f842bb6 --- /dev/null +++ b/internal/handlers/billing_block_purchasable_set_test.go @@ -0,0 +1,234 @@ +package handlers_test + +// billing_block_purchasable_set_test.go — W3 (Billing block integration tests). +// +// The single most revenue-critical invariant of the billing block is which +// tiers a team can self-serve PURCHASE through POST /api/v1/billing/checkout. +// The §E3 hard gate (shipped in #245, 2026-06-04 CEO directive) is that the +// Team plan ($199 "unlimited") is NOT rolled out and must never be reachable +// by a self-serve charge until its unlimited-resource delivery is proven +// built. Memory: project_team_plan_not_rolled_out_no_payment. +// +// The danger is not just "team is gated today" — it is "a NEW tier added to +// plans.yaml tomorrow silently becomes purchasable" (the registry-drift bug +// class, rule 18). A hand-typed allowlist would itself be a single-site +// fallacy. So this suite drives EVERY tier in the live plans.Registry through +// the REAL CreateCheckoutAPI handler and asserts the set of tiers the handler +// accepts (reaches CreateSubscription for) is EXACTLY {hobby, hobby_plus, +// pro}. Add a tier to plans.yaml without wiring it into the checkout switch and +// this test reds; re-enable Team checkout and this test reds. +// +// Real-backend integration test: each tier is exercised against a real test +// Postgres team row (TEST_DATABASE_URL) so the email-verify gate, the +// already-on-tier guard, and the team-gate all run on real data. The Razorpay +// CreateSubscription call is the ONLY fake — it must never reach a real +// Razorpay account. + +import ( + "net/http" + "sort" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/plans" +) + +// selfServePurchasableTiers is the EXACT set of canonical tiers a team may +// purchase via self-serve checkout, per the 2026-06-04 CEO directive. It is +// the EXPECTED value the registry-iterating test asserts the handler's actual +// behaviour against — it is NOT consulted by any production code path. If this +// list and the live handler ever disagree, one of them is wrong; the test +// makes that a red, not a silent revenue bug. +// +// - hobby ($9) — purchasable +// - hobby_plus ($19) — purchasable +// - pro ($49) — purchasable +// - growth ($99) — NOT self-serve via checkout (sales-assisted; the +// checkout switch rejects it as invalid_plan today) +// - team ($199) — HARD-GATED: tier_not_yet_available (§E3) +// - anonymous/free — not chargeable tiers at all +var selfServePurchasableTiers = []string{"hobby", "hobby_plus", "pro"} + +// checkoutClassification is the observed outcome of driving one tier through +// the real CreateCheckoutAPI handler. +type checkoutClassification struct { + tier string + httpStatus int + errorCode string // "" when the handler accepted the checkout + reachedRazorpay bool +} + +// TestBillingBlock_SelfServePurchasableSet_IsExactlyHobbyHobbyPlusPro is the +// W3 headline assertion. It iterates the LIVE plans.Registry (no hand-typed +// tier list drives the loop), canonicalises each tier, and drives it through +// the production CreateCheckoutAPI handler with a real verified team. It then +// asserts the set of tiers the handler ACCEPTS (reaches the Razorpay +// CreateSubscription seam) equals selfServePurchasableTiers exactly. +// +// Why registry-iterating (rule 18): the failure mode this guards is "someone +// adds 'starter' to plans.yaml and forgets the checkout switch, making it +// either silently purchasable or silently 500." Driving the registry, not a +// fixed slice, makes the new tier appear in the loop automatically. +func TestBillingBlock_SelfServePurchasableSet_IsExactlyHobbyHobbyPlusPro(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + + reg := plans.Default() + require.NotNil(t, reg, "plans.Default() must return a registry") + + // Collect the distinct canonical tiers from the live registry. Yearly + // variants (hobby_yearly, pro_yearly, …) canonicalise onto their base + // tier so we test each tier identity once. + canonicalSet := map[string]struct{}{} + for rawTier := range reg.All() { + canonicalSet[plans.CanonicalTier(rawTier)] = struct{}{} + } + require.NotEmpty(t, canonicalSet, "registry must expose at least one tier") + // Sanity: the registry MUST contain the gated tier — otherwise the test + // would pass vacuously (no Team row to gate). + _, hasTeam := canonicalSet["team"] + require.True(t, hasTeam, + "plans.Registry must contain the 'team' tier — the §E3 gate is meaningless if Team is absent from the registry") + + classifications := make([]checkoutClassification, 0, len(canonicalSet)) + for tier := range canonicalSet { + classifications = append(classifications, classifyCheckoutTier(t, tier)) + } + + // Build the ACTUAL purchasable set from the handler's behaviour. + var actualPurchasable []string + for _, cl := range classifications { + if cl.reachedRazorpay { + actualPurchasable = append(actualPurchasable, cl.tier) + } + } + sort.Strings(actualPurchasable) + want := append([]string(nil), selfServePurchasableTiers...) + sort.Strings(want) + + assert.Equal(t, want, actualPurchasable, + "the self-serve purchasable tier set (tiers that reach the Razorpay CreateSubscription seam) must be EXACTLY %v — "+ + "a new tier here means a tier became chargeable without review; a missing tier means a paid tier stopped being purchasable. "+ + "per-tier outcomes: %+v", want, classifications) + + // Explicit §E3 belt: Team must be rejected with the DISTINCT + // tier_not_yet_available code (not the generic invalid_plan), so the SPA + // renders "contact sales / not yet available" rather than "you made a + // typo". This is asserted separately from the set so a regression that + // changed only the error CODE (still rejecting, but with the wrong code) + // is caught. + for _, cl := range classifications { + if cl.tier != "team" { + continue + } + assert.False(t, cl.reachedRazorpay, + "Team checkout must NEVER reach Razorpay CreateSubscription (§E3 hard gate)") + assert.Equal(t, http.StatusBadRequest, cl.httpStatus, + "Team checkout must be a 400, not a 5xx — a misconfigured gate that 500s is still a bug") + assert.Equal(t, "tier_not_yet_available", cl.errorCode, + "Team checkout must return the distinct 'tier_not_yet_available' code so the SPA shows the contact-sales copy") + } +} + +// classifyCheckoutTier drives a single canonical tier through the real +// CreateCheckoutAPI handler with a fresh verified FREE team (so no +// already-on-tier short-circuit fires for any paid tier) and a fully +// configured Razorpay cfg (key + secret + every paid tier's plan_id), then +// reports whether the handler reached the Razorpay CreateSubscription seam. +// +// CreateSubscription is faked to set reachedRazorpay=true and return a valid +// subscription — it must NEVER hit a real Razorpay account. A tier that is +// gated/invalid is rejected BEFORE this fake is called, so reachedRazorpay +// stays false for non-purchasable tiers. +func classifyCheckoutTier(t *testing.T, tier string) checkoutClassification { + t.Helper() + db, clean := billingBlockDB(t) + defer clean() + + // Configure plan_ids for every paid tier so the only thing that can stop + // a checkout from reaching Razorpay is the handler's own gate logic, NOT a + // missing plan_id (which would yield a false "not purchasable" via the + // billing_not_configured branch). The live-key guard is avoided by using a + // rzp_test_* style key with Environment="production" so no misconfig fires. + cfg := &config.Config{ + JWTSecret: billingBlockJWTSecret, + Environment: "production", + RazorpayKeyID: "rzp_test_blockfixturekey", + RazorpayKeySecret: "secret", + RazorpayPlanIDHobby: "plan_hobby", + RazorpayPlanIDHobbyPlus: "plan_hobby_plus", + RazorpayPlanIDPro: "plan_pro", + RazorpayPlanIDGrowth: "plan_growth", + RazorpayPlanIDTeam: "plan_team", + } + + // A fresh FREE team: rank 1, below every paid tier, so the already-on-tier + // guard never short-circuits a paid-tier checkout. + teamID, userID := seedVerifiedTeamUser(t, db, "free") + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + + reached := false + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + reached = true + return map[string]any{ + "id": "sub_block_" + uuid.NewString(), + "short_url": "https://rzp.example/checkout", + }, nil + } + + status, body := postCheckoutReq(t, app, map[string]any{"plan": tier}) + errCode, _ := body["error"].(string) + return checkoutClassification{ + tier: tier, + httpStatus: status, + errorCode: errCode, + reachedRazorpay: reached, + } +} + +// TestBillingBlock_ChangePlanPurchasableSet_RejectsTeamAndUnknown mirrors the +// purchasable-set invariant for the in-app change-plan path: an existing +// paying team trying to MOVE to Team must hit the same §E3 gate +// (tier_not_yet_available), and an unknown/non-purchasable target must be +// rejected. This complements the checkout set above — Team must be unreachable +// from BOTH self-serve charge-initiation surfaces. +func TestBillingBlock_ChangePlanPurchasableSet_RejectsTeamAndUnknown(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + + cases := []struct { + name string + startTier string + target string + wantCode string + }{ + {"team is gated from change-plan", "hobby", "team", "tier_not_yet_available"}, + {"growth is not self-serve via change-plan", "hobby", "growth", "invalid_plan"}, + {"unknown tier rejected", "hobby", "definitely_not_a_tier", "invalid_plan"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, clean := billingBlockDB(t) + defer clean() + teamID := mustSeedTeam(t, db, tc.startTier) + cfg := &config.Config{ + JWTSecret: billingBlockJWTSecret, + RazorpayKeyID: "rzp_test_k", + RazorpayKeySecret: "s", + RazorpayPlanIDPro: "plan_pro", + RazorpayPlanIDTeam: "plan_team", + } + app := changePlanAppReal(t, db, cfg, teamID) + code, body := changePlanReq(t, app, map[string]any{"target_plan": tc.target}) + assert.Equal(t, http.StatusBadRequest, code, "body=%v", body) + assert.Equal(t, tc.wantCode, body["error"], + "change-plan target=%q must be rejected with %q (body=%v)", tc.target, tc.wantCode, body) + }) + } +} diff --git a/internal/handlers/billing_block_webhook_transitions_test.go b/internal/handlers/billing_block_webhook_transitions_test.go new file mode 100644 index 0000000..2adc1b9 --- /dev/null +++ b/internal/handlers/billing_block_webhook_transitions_test.go @@ -0,0 +1,292 @@ +package handlers_test + +// billing_block_webhook_transitions_test.go — W3 §E4/E5/E6/E7 + checkout +// graceful-failure. The Razorpay webhook is the surface that actually moves a +// team between paid tiers; these are DB-backed integration tests against a +// real test Postgres so the tier mutation and resource elevation land in real +// rows (no mocks on the asserted path). +// +// Covered: +// - UPGRADE (subscription.charged): elevates teams.plan_tier AND promotes +// every active resource to the new tier (the ElevateResourceTiersByTeam +// contract folded into UpgradeTeamAllTiersWithSubscription). §E4 / rule 5. +// - DOWNGRADE (subscription.cancelled): drops teams.plan_tier to the courtesy +// floor but LEAVES existing resources at their current tier (UpdatePlanTier +// only — the deliberate user-benefit asymmetry). §E5 / rule 5. +// - BAD SIGNATURE: a tampered/forged signature is rejected with 400 +// invalid_signature before any state change. §E6 / rule 9. +// - ROWS-AFFECTED 0: a signed charged event for an unknown team returns 404 +// team_not_found (ErrTeamNotFound on the 0-row UPDATE). §E7. +// +// Reuses the existing webhook conventions: cov2WebhookAppReal, +// signRazorpayPayload, makeSubscriptionChargedPayloadWithPlan, +// makeSubscriptionCancelledPayload. Nothing redefined. + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/email" +) + +// postSignedWebhook signs payload with the canonical test webhook secret and +// POSTs it, returning the HTTP status + decoded JSON body. eventID, when +// non-empty, is set on the X-Razorpay-Event-Id header (the dedup claim key). +func postSignedWebhook(t *testing.T, app *fiber.App, payload []byte, eventID string) (int, map[string]any) { + t.Helper() + sig := signRazorpayPayload(t, testWebhookSecret, payload) + req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(payload)) + 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) + defer resp.Body.Close() + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + return resp.StatusCode, body +} + +// billingBlockRespMap decodes an *http.Response JSON body into a map. +func billingBlockRespMap(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + return body +} + +// TestBillingBlock_WebhookUpgrade_ElevatesTierAndResources — §E4. A signed +// subscription.charged event with a Pro plan_id must (a) flip the team's +// plan_tier to pro and (b) promote every active resource the team owns to pro. +// The resources start at 'free' (a just-claimed team) and must end at 'pro' — +// proving ElevateResourceTiersByTeam ran inside the upgrade tx. +func TestBillingBlock_WebhookUpgrade_ElevatesTierAndResources(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + db, clean := billingBlockDB(t) + defer clean() + + teamIDStr := mustSeedTeam(t, db, "free") + teamID := uuid.MustParse(teamIDStr) + + // Two active resources at the free tier — the rows that must be elevated. + dbRes := billingBlockSeedResource(t, db, teamID, "postgres", "free") + cacheRes := billingBlockSeedResource(t, db, teamID, "redis", "free") + + app, cfg := cov2WebhookAppReal(t, db, email.NewNoop()) + + subID := "sub_upgrade_" + uuid.NewString() + eventID := "evt_upgrade_" + uuid.NewString() + payload := makeSubscriptionChargedPayloadWithPlan(t, teamIDStr, subID, cfg.RazorpayPlanIDPro) + + status, body := postSignedWebhook(t, app, payload, eventID) + require.Equal(t, http.StatusOK, status, "charged webhook must 200 on a known team, body=%v", body) + + // Team tier elevated. + assert.Equal(t, "pro", billingBlockTeamTier(t, db, teamIDStr), + "subscription.charged with a Pro plan_id must set teams.plan_tier=pro") + + // ALL active resources promoted (rule 5: upgrade elevates active resources). + assert.Equal(t, "pro", billingBlockResourceTier(t, db, dbRes), + "upgrade must promote the postgres resource from free → pro (ElevateResourceTiersByTeam)") + assert.Equal(t, "pro", billingBlockResourceTier(t, db, cacheRes), + "upgrade must promote the redis resource from free → pro (ElevateResourceTiersByTeam)") + + // Cleanup the dedup claim row so a re-run can re-process the same event id. + db.Exec(`DELETE FROM razorpay_webhook_events WHERE event_id = $1`, eventID) +} + +// TestBillingBlock_WebhookDowngrade_KeepsResourceTiers — §E5. A signed +// subscription.cancelled event for the team's LIVE subscription drops the +// team's plan_tier to the courtesy floor (hobby) but must LEAVE existing +// resources at their current (pro) tier — the deliberate user-benefit +// asymmetry (UpdatePlanTier only, no resource teardown). rule 5. +func TestBillingBlock_WebhookDowngrade_KeepsResourceTiers(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + db, clean := billingBlockDB(t) + defer clean() + + teamIDStr := mustSeedTeam(t, db, "pro") + teamID := uuid.MustParse(teamIDStr) + + // A pro resource that must SURVIVE the downgrade at tier=pro. + proRes := billingBlockSeedResource(t, db, teamID, "postgres", "pro") + + // Store the live subscription id on the team so the cancelled webhook's + // stale-subscription guard recognises this event as the team's live sub + // (and proceeds with the downgrade) rather than skipping it as superseded. + subID := "sub_downgrade_" + uuid.NewString() + _, err := db.Exec(`UPDATE teams SET stripe_customer_id = $1 WHERE id = $2::uuid`, subID, teamIDStr) + require.NoError(t, err) + + app, _ := cov2WebhookAppReal(t, db, email.NewNoop()) + + payload := makeSubscriptionCancelledPayload(t, teamIDStr, subID) + // makeSubscriptionCancelledPayload omits the top-level event id; set one on + // the header so the dedup claim has a key and we can clean it up. + eventID := "evt_downgrade_" + uuid.NewString() + + status, body := postSignedWebhook(t, app, payload, eventID) + require.Equal(t, http.StatusOK, status, "cancelled webhook must 200, body=%v", body) + + // Team dropped to the courtesy floor (paid_count nil → hobby). + assert.Equal(t, "hobby", billingBlockTeamTier(t, db, teamIDStr), + "subscription.cancelled (with a prior paid invoice / nil paid_count) drops the team to the hobby courtesy floor") + + // CRITICAL user-benefit invariant: the resource KEEPS its pro tier. + assert.Equal(t, "pro", billingBlockResourceTier(t, db, proRes), + "downgrade must NOT touch existing resource tiers — they stay at pro as a customer courtesy (rule 5)") + + db.Exec(`DELETE FROM razorpay_webhook_events WHERE event_id = $1`, eventID) +} + +// TestBillingBlock_WebhookBadSignature_Rejected — §E6 / rule 9. A signed body +// with a TAMPERED signature is rejected 400 invalid_signature and must NOT +// mutate the team tier. We post a valid-shape (64-hex) but wrong signature so +// we exercise the constant-time-compare failure, not the length pre-check. +func TestBillingBlock_WebhookBadSignature_Rejected(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + db, clean := billingBlockDB(t) + defer clean() + + teamIDStr := mustSeedTeam(t, db, "free") + app, cfg := cov2WebhookAppReal(t, db, email.NewNoop()) + + subID := "sub_badsig_" + uuid.NewString() + payload := makeSubscriptionChargedPayloadWithPlan(t, teamIDStr, subID, cfg.RazorpayPlanIDPro) + + // A signature signed with the WRONG secret — correct length (64 hex), fails + // the HMAC compare. + wrongSig := signRazorpayPayload(t, "definitely-not-the-webhook-secret", payload) + req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Razorpay-Signature", wrongSig) + + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, + "a bad-signature webhook must be rejected 400 before any state change") + body := billingBlockRespMap(t, resp) + assert.Equal(t, "invalid_signature", body["error"], + "bad-signature rejection must carry the stable invalid_signature code") + + // The team tier must be UNCHANGED — a forged webhook must never upgrade. + assert.Equal(t, "free", billingBlockTeamTier(t, db, teamIDStr), + "a rejected forged webhook must not mutate the team tier") +} + +// TestBillingBlock_WebhookUnknownTeam_RowsAffectedZero — §E7. A signed +// subscription.charged event whose notes.team_id refers to a non-existent team +// causes UpgradeTeamAllTiersWithSubscription to see 0 rows affected and return +// ErrTeamNotFound → the webhook maps it to 404 team_not_found (4xx so Razorpay +// does not retry). This is the rows-affected guard. +func TestBillingBlock_WebhookUnknownTeam_RowsAffectedZero(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + db, clean := billingBlockDB(t) + defer clean() + + app, cfg := cov2WebhookAppReal(t, db, email.NewNoop()) + + // A syntactically-valid UUID that is NOT in teams → 0 rows affected. + bogusTeamID := uuid.NewString() + subID := "sub_unknown_" + uuid.NewString() + eventID := "evt_unknown_" + uuid.NewString() + payload := makeSubscriptionChargedPayloadWithPlan(t, bogusTeamID, subID, cfg.RazorpayPlanIDPro) + + status, body := postSignedWebhook(t, app, payload, eventID) + assert.Equal(t, http.StatusNotFound, status, + "a signed charged event for an unknown team must 404 (rows-affected 0 → ErrTeamNotFound; 4xx = non-retryable)") + assert.Equal(t, "team_not_found", body["error"], + "the 404 envelope must carry the stable team_not_found code") + + db.Exec(`DELETE FROM razorpay_webhook_events WHERE event_id = $1`, eventID) + db.Exec(`DELETE FROM audit_log WHERE metadata->>'event_id' = $1`, eventID) +} + +// TestBillingBlock_CheckoutGracefulFailure_BillingNotConfigured — checkout +// graceful failure. With Razorpay credentials present but the requested tier's +// plan_id UNSET, /billing/checkout must return the documented 503 +// billing_not_configured — never a 500/panic. The handler must reach the +// not-configured branch (not be short-circuited by a misconfig/already-on-tier +// guard), so we use a fresh free team + a rzp_test_* key with no plan_id. +func TestBillingBlock_CheckoutGracefulFailure_BillingNotConfigured(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + db, clean := billingBlockDB(t) + defer clean() + + // Key + secret set, but RazorpayPlanIDPro deliberately UNSET → planID=="" + // → 503 billing_not_configured. Environment=production + rzp_test_ key so + // the live-key-in-nonprod guard does not fire first. + cfg := &config.Config{ + JWTSecret: billingBlockJWTSecret, + Environment: "production", + RazorpayKeyID: "rzp_test_blockfixturekey", + RazorpayKeySecret: "secret", + // RazorpayPlanIDPro intentionally empty. + } + teamID, userID := seedVerifiedTeamUser(t, db, "free") + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + t.Fatal("CreateSubscription must NOT be called when the plan_id is unconfigured") + return nil, nil + } + + code, body := postCheckoutReq(t, app, map[string]any{"plan": "pro"}) + assert.Equal(t, http.StatusServiceUnavailable, code, "body=%v", body) + assert.Equal(t, "billing_not_configured", body["error"], + "an unconfigured plan_id must yield a graceful 503 billing_not_configured, not a 500/panic") +} + +// TestBillingBlock_CheckoutGracefulFailure_LiveKeyInNonProd — the +// live-key-in-nonprod guard path. A LIVE Razorpay key on a non-production +// deployment must fast-fail with 503 billing_misconfigured BEFORE any +// subscription is minted — real money must never flow through a dev/staging +// deployment. +func TestBillingBlock_CheckoutGracefulFailure_LiveKeyInNonProd(t *testing.T) { + if billingBlockSkipNoDB(t) { + return + } + db, clean := billingBlockDB(t) + defer clean() + + cfg := &config.Config{ + JWTSecret: billingBlockJWTSecret, + Environment: "development", // non-prod + RazorpayKeyID: "rzp_live_blockfixturekey", + RazorpayKeySecret: "secret", + RazorpayPlanIDPro: "plan_pro", + } + teamID, userID := seedVerifiedTeamUser(t, db, "free") + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + t.Fatal("CreateSubscription must NOT be called with a live key on a non-prod deployment") + return nil, nil + } + + code, body := postCheckoutReq(t, app, map[string]any{"plan": "pro"}) + assert.Equal(t, http.StatusServiceUnavailable, code, "body=%v", body) + assert.Equal(t, "billing_misconfigured", body["error"], + "a LIVE key on a non-prod deployment must 503 billing_misconfigured before minting a subscription") +}