diff --git a/internal/config/config.go b/internal/config/config.go index 45c3f8c..6e35f5f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,7 +44,29 @@ type Config struct { RazorpayPlanIDProYearly string // RAZORPAY_PLAN_ID_PRO_YEARLY — plan_id for pro tier (yearly) RazorpayPlanIDGrowthYearly string // RAZORPAY_PLAN_ID_GROWTH_ANNUAL — plan_id for growth tier (yearly) RazorpayPlanIDTeamYearly string // RAZORPAY_PLAN_ID_TEAM_YEARLY — plan_id for team tier (yearly) - ResendAPIKey string + + // ── Razorpay TEST-mode credentials (Wave 4b, docs/ci/01-CI-INTEGRATION-DESIGN.md) ── + // These are the rzp_test_* keys + their plan_ids used ONLY for the + // synthetic test-cohort (teams.is_test_cohort=true, migration 067) so CI can + // drive a real test-mode hosted checkout + test-card payment WITHOUT touching + // the live Razorpay account and WITHOUT needing the live-recurring approval + // (test mode has no recurring gate). Every field defaults to "" (empty) so + // the whole test-mode path is INERT in any deployment where the operator has + // not configured it — a non-cohort team always uses the live keys above, and + // a cohort team falls back to the normal (skip/inert) behaviour when these + // are unset. The actual key values MUST NEVER leak in any API response + // (same NEVER-leak contract as RazorpayKeyID — see trafficEnv/BUG-P112). + RazorpayTestKeyID string // RAZORPAY_TEST_KEY_ID — rzp_test_* API key ID (test-cohort only) + RazorpayTestKeySecret string // RAZORPAY_TEST_KEY_SECRET — rzp_test_* API key secret (test-cohort only) + RazorpayTestWebhookSecret string // RAZORPAY_TEST_WEBHOOK_SECRET — webhook signature secret for test-mode events + // Test-mode plan_ids for the self-serve checkout tiers (hobby / hobby_plus / + // pro, monthly). Created by the operator in the Razorpay TEST dashboard. When + // a tier's test plan_id is unset, a cohort checkout for that tier falls back + // to the inert path (no test-mode subscription is minted). + RazorpayTestPlanIDHobby string // RAZORPAY_TEST_PLAN_ID_HOBBY + RazorpayTestPlanIDHobbyPlus string // RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS + RazorpayTestPlanIDPro string // RAZORPAY_TEST_PLAN_ID_PRO + ResendAPIKey string // EmailProvider explicitly selects the outbound email backend. Accepted // values: "brevo" | "resend" | "noop". When empty, internal/email // auto-detects: BREVO_API_KEY > RESEND_API_KEY (≠ "CHANGE_ME") > noop. @@ -368,27 +390,36 @@ func Load() *Config { RazorpayPlanIDProYearly: os.Getenv("RAZORPAY_PLAN_ID_PRO_ANNUAL"), RazorpayPlanIDGrowthYearly: os.Getenv("RAZORPAY_PLAN_ID_GROWTH_ANNUAL"), RazorpayPlanIDTeamYearly: os.Getenv("RAZORPAY_PLAN_ID_TEAM_ANNUAL"), - ResendAPIKey: os.Getenv("RESEND_API_KEY"), - EmailProvider: os.Getenv("EMAIL_PROVIDER"), - BrevoAPIKey: os.Getenv("BREVO_API_KEY"), - EmailFromName: os.Getenv("EMAIL_FROM_NAME"), - EmailFromAddress: os.Getenv("EMAIL_FROM_ADDRESS"), - GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), - GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), - GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), - GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), - GoogleRedirectURI: os.Getenv("GOOGLE_REDIRECT_URI"), - EnabledServices: getenv("INSTANT_ENABLED_SERVICES", "redis,postgres,mongodb,queue"), - Environment: getenv("ENVIRONMENT", "development"), - TrustedProxyCIDRs: os.Getenv("TRUSTED_PROXY_CIDRS"), - RedisProvisionBackend: getenv("REDIS_PROVISION_BACKEND", "local"), - RedisProvisionHost: getenv("REDIS_PROVISION_HOST", "localhost"), - MongoAdminURI: getenv("MONGO_ADMIN_URI", "mongodb://root:root@localhost:27017"), - MongoHost: getenv("MONGO_HOST", "localhost:27017"), - PostgresProvisionBackend: getenv("POSTGRES_PROVISION_BACKEND", "local"), - NeonAPIKey: os.Getenv("NEON_API_KEY"), - NeonRegionID: getenv("NEON_REGION_ID", "aws-us-east-1"), - PostgresCustomersURL: getenv("POSTGRES_CUSTOMERS_URL", "postgres://postgres:postgres@postgres-customers:5432/postgres"), + + // Razorpay TEST-mode (rzp_test_*) creds for the synthetic test cohort + // only. All default "" (inert) — see the struct doc above (Wave 4b). + RazorpayTestKeyID: os.Getenv("RAZORPAY_TEST_KEY_ID"), + RazorpayTestKeySecret: os.Getenv("RAZORPAY_TEST_KEY_SECRET"), + RazorpayTestWebhookSecret: os.Getenv("RAZORPAY_TEST_WEBHOOK_SECRET"), + RazorpayTestPlanIDHobby: os.Getenv("RAZORPAY_TEST_PLAN_ID_HOBBY"), + RazorpayTestPlanIDHobbyPlus: os.Getenv("RAZORPAY_TEST_PLAN_ID_HOBBY_PLUS"), + RazorpayTestPlanIDPro: os.Getenv("RAZORPAY_TEST_PLAN_ID_PRO"), + ResendAPIKey: os.Getenv("RESEND_API_KEY"), + EmailProvider: os.Getenv("EMAIL_PROVIDER"), + BrevoAPIKey: os.Getenv("BREVO_API_KEY"), + EmailFromName: os.Getenv("EMAIL_FROM_NAME"), + EmailFromAddress: os.Getenv("EMAIL_FROM_ADDRESS"), + GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), + GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + GoogleRedirectURI: os.Getenv("GOOGLE_REDIRECT_URI"), + EnabledServices: getenv("INSTANT_ENABLED_SERVICES", "redis,postgres,mongodb,queue"), + Environment: getenv("ENVIRONMENT", "development"), + TrustedProxyCIDRs: os.Getenv("TRUSTED_PROXY_CIDRS"), + RedisProvisionBackend: getenv("REDIS_PROVISION_BACKEND", "local"), + RedisProvisionHost: getenv("REDIS_PROVISION_HOST", "localhost"), + MongoAdminURI: getenv("MONGO_ADMIN_URI", "mongodb://root:root@localhost:27017"), + MongoHost: getenv("MONGO_HOST", "localhost:27017"), + PostgresProvisionBackend: getenv("POSTGRES_PROVISION_BACKEND", "local"), + NeonAPIKey: os.Getenv("NEON_API_KEY"), + NeonRegionID: getenv("NEON_REGION_ID", "aws-us-east-1"), + PostgresCustomersURL: getenv("POSTGRES_CUSTOMERS_URL", "postgres://postgres:postgres@postgres-customers:5432/postgres"), } cfg.ProvisionerAddr = os.Getenv("PROVISIONER_ADDR") // intentionally empty = use local providers cfg.ProvisionerSecret = os.Getenv("PROVISIONER_SECRET") diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index d1af62c..aead838 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -38,6 +38,16 @@ import ( // the checkout side and the webhook side cannot drift. const checkoutNoteAdminPromoCodeID = "admin_promo_code_id" +// subBodyTestModeKey is a PRIVATE marker key the checkout handler injects into +// the Razorpay subscription-create body for synthetic test-cohort teams (Wave +// 4b). The production CreateSubscription closure reads it to select the +// rzp_test_* credentials, then deletes it before the body is sent to Razorpay +// (Razorpay rejects unknown top-level fields). It is never persisted and never +// surfaced — purely an in-process hint that keeps the CreateSubscription field +// signature unchanged (so the existing test seam is untouched) while remaining +// goroutine-safe (the flag is per-call body state, not shared handler state). +const subBodyTestModeKey = "__instant_test_mode" + // checkoutInflightTTL bounds the server-side dedup window for a team's // concurrent /api/v1/billing/checkout calls. ~60s is well within the // time it takes a user to read the Razorpay hosted-checkout response, @@ -228,7 +238,13 @@ type BillingHandler struct { // callers should default the relevant response fields. FetchSubscriptionDetails func(subscriptionID string) (*razorpaybilling.SubscriptionDetails, error) - // CreateSubscription mints a new Razorpay subscription. Factored into an + // CreateSubscription mints a new Razorpay subscription. The subBody MAY + // carry the private subBodyTestModeKey flag (Wave 4b): when present and + // true, the PRODUCTION default closure routes the create through the + // rzp_test_* credentials instead of the live keys, and strips the flag + // before it reaches Razorpay. See subBodyTestModeKey + resolveCheckoutTestMode. + // + // Factored into an // overridable field (not an inline razorpay client call) so the F7 // idempotency guard in CreateCheckoutAPI is unit-testable: a test can // assert the function is invoked EXACTLY ONCE across two checkout calls @@ -279,12 +295,23 @@ func NewBillingHandler(db *sql.DB, cfg *config.Config, emailClient email.Mailer) // CreateSubscription mints a new Razorpay subscription. Wired once here so // CreateCheckoutAPI never mutates the field per-request (see the doc above). h.CreateSubscription = func(subBody map[string]any) (map[string]any, error) { + // Wave 4b: a synthetic test-cohort checkout carries subBodyTestModeKey. + // When set (and test keys configured) the create goes through the + // rzp_test_* credentials so a real TEST-mode subscription/short_url is + // minted — accepting test cards, no live-recurring approval needed, and + // the live Razorpay account is never touched. Strip the private flag + // before it reaches Razorpay (Razorpay rejects unknown top-level keys). + keyID, keySecret := h.cfg.RazorpayKeyID, h.cfg.RazorpayKeySecret + if testMode, _ := subBody[subBodyTestModeKey].(bool); testMode { + keyID, keySecret = h.cfg.RazorpayTestKeyID, h.cfg.RazorpayTestKeySecret + } + delete(subBody, subBodyTestModeKey) // P0-2 (CIRCUIT-RETRY-AUDIT-2026-05-20): NewTimeoutClient applies the // audit-mandated 30s HTTP timeout. Never razorpay.NewClient directly — // the SDK default is 10s, below Razorpay's documented p99 for // subscription create, so a brownout would 10s-fail every checkout // without ever flipping the breaker. - client := razorpaybilling.NewTimeoutClient(h.cfg.RazorpayKeyID, h.cfg.RazorpayKeySecret) + client := razorpaybilling.NewTimeoutClient(keyID, keySecret) return razorpaybilling.CallWithBreaker(func() (map[string]any, error) { return client.Subscription.Create(subBody, nil) }) @@ -515,6 +542,75 @@ func (h *BillingHandler) razorpayPlanIDFor(tier, frequency string) string { return "" } +// ── Wave 4b: Razorpay TEST-mode (synthetic test-cohort) routing ────────────── +// +// docs/ci/01-CI-INTEGRATION-DESIGN.md §"Razorpay test-card payment E2E". +// CI mints a synthetic cohort team (teams.is_test_cohort=true, migration 067) +// and drives a REAL hosted checkout + test-card payment. To do that without +// touching the live Razorpay account (and without the live-recurring approval +// that blocks prod), a cohort checkout routes through the rzp_test_* keys. + +// testModeConfigured reports whether the operator has wired the minimum +// rzp_test_* credentials (key id + secret) for the synthetic test-cohort +// checkout path. When false the whole test-mode path is INERT: a cohort team +// falls back to the normal skip/inert behaviour (rejectIfTestCohort), and the +// live path is never affected. +func (h *BillingHandler) testModeConfigured() bool { + return h.cfg.RazorpayTestKeyID != "" && h.cfg.RazorpayTestKeySecret != "" +} + +// razorpayTestPlanIDFor returns the configured rzp_test_* plan_id for a +// self-serve checkout tier (hobby / hobby_plus / pro, monthly only — yearly and +// growth/team are out of scope for the cohort test path). Returns "" when the +// operator has not created the corresponding test plan, in which case the +// cohort checkout falls back to the inert path rather than minting against the +// wrong (live) plan. +func (h *BillingHandler) razorpayTestPlanIDFor(tier string) string { + switch tier { + case "hobby": + return h.cfg.RazorpayTestPlanIDHobby + case "hobby_plus": + return h.cfg.RazorpayTestPlanIDHobbyPlus + case "pro": + return h.cfg.RazorpayTestPlanIDPro + } + return "" +} + +// resolveCheckoutTestMode decides, for a single checkout call, whether to route +// through the rzp_test_* credentials. It returns useTest=true (with the +// resolved test plan_id) ONLY when ALL of: +// - the team is a synthetic test cohort (teams.is_test_cohort=true), AND +// - the operator has configured rzp_test_* key + secret, AND +// - the requested tier has a configured test plan_id. +// +// In every other case it returns useTest=false (caller keeps the existing +// behaviour: a non-cohort team uses live keys; a cohort team with test mode +// unconfigured/partial is handled by the inert rejectIfTestCohort skip-guard). +// A DB error on the is_test_cohort lookup fails CLOSED to useTest=false so a DB +// blip can never accidentally route a real customer through the test account. +func (h *BillingHandler) resolveCheckoutTestMode(ctx context.Context, teamID uuid.UUID, tier string) (useTest bool, testPlanID string) { + if h.db == nil || !h.testModeConfigured() { + return false, "" + } + isTest, err := models.IsTestCohort(ctx, h.db, teamID) + if err != nil { + // Fail closed: never route a real customer through test keys on a blip. + slog.Warn("billing.checkout.test_cohort_lookup_failed_closed", + "error", err, "team_id", teamID) + return false, "" + } + if !isTest { + return false, "" + } + planID := h.razorpayTestPlanIDFor(tier) + if planID == "" { + // Test cohort, test keys set, but no test plan for this tier → inert. + return false, "" + } + return true, planID +} + // planIDToTierFallback is the tier returned when a Razorpay plan_id cannot be // mapped to any configured tier. Deliberately the LOWEST paid tier (hobby) // rather than "pro": an env-var typo may result in a $9 Hobby grant instead @@ -740,10 +836,20 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { } // Synthetic-cohort skip-guard (migration 067): a test-cohort team must - // never reach the real Razorpay subscription-create call. Checked before - // the email gate / Redis dedup so a synthetic call consumes nothing. - if ok, errResp := h.rejectIfTestCohort(c, teamID, "checkout"); !ok { - return errResp + // never reach the real (LIVE) Razorpay subscription-create call. Checked + // before the email gate / Redis dedup so a synthetic call consumes nothing. + // + // Wave 4b exception: when the operator has configured rzp_test_* keys + // (testModeConfigured), a cohort team is ALLOWED to flow through so it can + // mint a real TEST-mode subscription (routed to the test account below). + // The post-planID resolution (resolveCheckoutTestMode) decides per-tier + // whether a test plan exists; if not, it falls back to the inert reject + // path right after the tier is known. When test mode is NOT configured, + // behaviour is unchanged: cohort teams are rejected here. + if !h.testModeConfigured() { + if ok, errResp := h.rejectIfTestCohort(c, teamID, "checkout"); !ok { + return errResp + } } // Email-verified gate (migration 052): a /claim-created account must @@ -869,6 +975,31 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { } planID := h.razorpayPlanIDFor(plan, frequency) + // ── Wave 4b: synthetic test-cohort → rzp_test_* routing ───────────────── + // Resolve, ONCE per call (goroutine-safe — derived from per-call args), the + // test-mode decision for this team+tier. The flow only reaches here for a + // cohort team when testModeConfigured()==true (the reject guard above was + // skipped). resolveCheckoutTestMode re-confirms is_test_cohort + a configured + // test plan_id for the tier: + // - useTest=true → swap planID to the test plan and route the create + // through the rzp_test_* keys via subBodyTestModeKey. + // Skip the live-key / billing_not_configured guards + // below (they validate the LIVE creds, irrelevant here). + // - useTest=false on a cohort team (test keys set but no test plan for + // this tier — partial config) → fall back to the inert + // reject path so a cohort call never mints against the + // LIVE plan. Non-cohort teams: useTest=false, unchanged. + useTest, testPlanID := h.resolveCheckoutTestMode(c.Context(), teamID, plan) + if useTest { + planID = testPlanID + } else if h.testModeConfigured() { + // Re-run the cohort skip-guard for the partial-config case: a cohort + // team whose tier has no test plan must NOT proceed to the live path. + if ok, errResp := h.rejectIfTestCohort(c, teamID, "checkout"); !ok { + return errResp + } + } + // ── BUG-P112 live-key-in-dev guard ───────────────────────────────────── // A LIVE Razorpay key pointed at a non-prod deployment is the // BUG-P111/P112 root cause: anyone (even unauth, via the BUG-P111 SPA @@ -881,7 +1012,10 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { // deployment is dangerous EVEN IF the operator forgot to set // RAZORPAY_PLAN_ID_TEAM — the underlying configuration drift is the // signal we must surface first. - if code, message := detectBillingMisconfiguration(h.cfg.Environment, h.cfg.RazorpayKeyID); code != "" { + // + // Wave 4b: skipped on the test-mode path — the guard validates the LIVE + // key, but a test-cohort checkout deliberately uses rzp_test_* creds. + if code, message := detectBillingMisconfiguration(h.cfg.Environment, h.cfg.RazorpayKeyID); !useTest && code != "" { // Derive (but DO NOT log) the key class. Logging the actual key value // is a hard no — only the boolean derivation is safe. derivedTrafficEnv, _ := trafficEnv(h.cfg.RazorpayKeyID) @@ -897,13 +1031,22 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { "https://instanode.dev/docs/operator/billing-modes") } - if h.cfg.RazorpayKeyID == "" || h.cfg.RazorpayKeySecret == "" || planID == "" { + // Credential presence check. On the test-mode path validate the rzp_test_* + // creds (the live keys may legitimately be unset in CI); otherwise validate + // the live creds as before. planID is already the resolved (live or test) + // plan and must be non-empty either way. + keySet, secretSet := h.cfg.RazorpayKeyID != "", h.cfg.RazorpayKeySecret != "" + if useTest { + keySet, secretSet = h.cfg.RazorpayTestKeyID != "", h.cfg.RazorpayTestKeySecret != "" + } + if !keySet || !secretSet || planID == "" { slog.Warn("billing.checkout.not_configured", "team_id", teamID, "plan", plan, "plan_frequency", frequency, - "key_set", h.cfg.RazorpayKeyID != "", - "secret_set", h.cfg.RazorpayKeySecret != "", + "test_mode", useTest, + "key_set", keySet, + "secret_set", secretSet, "plan_id_set", planID != "", "request_id", requestID, ) @@ -1038,6 +1181,15 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { "customer_notify": 1, "notes": notes, } + // Wave 4b: tag the body so the production CreateSubscription closure routes + // this synthetic cohort create through the rzp_test_* credentials. The flag + // is a PRIVATE key the closure deletes before the body reaches Razorpay. + // Test overrides of CreateSubscription that don't care simply ignore it. + if useTest { + subBody[subBodyTestModeKey] = true + slog.Info("billing.checkout.test_mode", + "team_id", teamID, "plan", plan, "request_id", requestID) + } // h.CreateSubscription wraps the outbound Subscription.Create with the // package-level Razorpay circuit breaker (wired once in NewBillingHandler). @@ -1203,7 +1355,22 @@ func (h *BillingHandler) RazorpayWebhook(c *fiber.Ctx) error { payload := c.Body() sig := c.Get("X-Razorpay-Signature") - if !verifyRazorpaySignature(payload, sig, h.cfg.RazorpayWebhookSecret) { + // Wave 4b: verify against the LIVE webhook secret first, then (only if that + // fails) the rzp_test_* webhook secret. This lets a real test-mode + // subscription.charged/activated from Razorpay's TEST account upgrade a + // synthetic cohort team WITHOUT a separate endpoint, while live webhooks are + // unaffected (live secret matches first, test branch never runs). Both legs + // use the same constant-time verifier; an unset test secret is a no-op + // (verifyRazorpaySignature returns false on an empty secret). The live-first + // ordering means the common path costs exactly one HMAC. + sigOK := verifyRazorpaySignature(payload, sig, h.cfg.RazorpayWebhookSecret) + if !sigOK && h.cfg.RazorpayTestWebhookSecret != "" { + sigOK = verifyRazorpaySignature(payload, sig, h.cfg.RazorpayTestWebhookSecret) + if sigOK { + slog.Info("billing.webhook.verified_test_secret") + } + } + if !sigOK { slog.Error("billing.webhook.signature_failed") // B18 wave-3 hardening (2026-05-21): emit an audit_log row on every // signature-mismatch attempt so an operator dashboard can chart diff --git a/internal/handlers/billing_test_cohort_checkout_test.go b/internal/handlers/billing_test_cohort_checkout_test.go new file mode 100644 index 0000000..767eb05 --- /dev/null +++ b/internal/handlers/billing_test_cohort_checkout_test.go @@ -0,0 +1,352 @@ +// billing_test_cohort_checkout_test.go — Wave 4b coverage for the synthetic +// test-cohort → rzp_test_* checkout routing +// (docs/ci/01-CI-INTEGRATION-DESIGN.md §"Razorpay test-card payment E2E"). +// +// The CEO ask: CI must drive a REAL test-card payment (free user → upgrade → +// Razorpay TEST hosted-checkout → test card → Pro active) with NO real money. +// To do that the api routes a cohort team's checkout through the rzp_test_* +// credentials (test mode has no live-recurring approval gate), while the LIVE +// billing path stays untouched. +// +// Routing contract (all enforced here): +// - cohort team + test keys+plan configured → checkout uses the TEST key + +// TEST plan_id; the live-key guards are bypassed; response still hides keys. +// - cohort team + test keys UNSET → INERT: falls back to the existing +// synthetic_test_cohort skip (403), live path never touched, no crash. +// - cohort team + test keys set but NO test plan for the tier → INERT skip. +// - NON-cohort team → ALWAYS the live path, regardless of test-key config. +// - webhook verifies the TEST webhook secret (try-both: live first, then test). +// +// The pure-function inert proofs (testModeConfigured / razorpayTestPlanIDFor) +// run with NO DB so they execute in every CI run; the full routing tests are +// DB-gated (cov2NeedsDB) like the rest of the checkout suite. +package handlers_test + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "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/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// fixture test-mode creds — non-secret, prefix is the only load-bearing part. +const ( + testCohortKeyID = "rzp_test_0cohortfixture" //nolint:gosec // fixture, not a credential + testCohortKeySecret = "test-cohort-secret-fixture" + testCohortWebhookSecret = "test-cohort-webhook-secret-fixture" + testCohortPlanPro = "plan_test_pro_fixture" +) + +// ── Pure-function inert proofs (NO DB — always run in CI) ──────────────────── + +// TestTestModeConfigured_InertWhenUnset proves the whole test-mode path is +// INERT when the operator has not wired the rzp_test_* key+secret. This is the +// "default empty = inert" guarantee: on prod (no test keys) the cohort routing +// never engages, so live billing is provably untouched. +func TestTestModeConfigured_InertWhenUnset(t *testing.T) { + t.Parallel() + cases := []struct { + name string + cfg config.Config + expect bool + }{ + {"both unset", config.Config{}, false}, + {"only id set", config.Config{RazorpayTestKeyID: testCohortKeyID}, false}, + {"only secret set", config.Config{RazorpayTestKeySecret: testCohortKeySecret}, false}, + {"both set → configured", config.Config{RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret}, true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cfg := tc.cfg + bh := handlers.NewBillingHandler(nil, &cfg, nil) + assert.Equal(t, tc.expect, handlers.ExerciseTestModeConfigured(bh)) + }) + } +} + +// TestRazorpayTestPlanIDFor_OnlySelfServeTiers proves only the self-serve +// checkout tiers (hobby/hobby_plus/pro) resolve a test plan_id; growth/team and +// junk return "" so a cohort checkout for those tiers falls back to inert. +func TestRazorpayTestPlanIDFor_OnlySelfServeTiers(t *testing.T) { + t.Parallel() + cfg := config.Config{ + RazorpayTestPlanIDHobby: "p_hobby", + RazorpayTestPlanIDHobbyPlus: "p_hobby_plus", + RazorpayTestPlanIDPro: "p_pro", + } + bh := handlers.NewBillingHandler(nil, &cfg, nil) + assert.Equal(t, "p_hobby", handlers.ExerciseRazorpayTestPlanIDFor(bh, "hobby")) + assert.Equal(t, "p_hobby_plus", handlers.ExerciseRazorpayTestPlanIDFor(bh, "hobby_plus")) + assert.Equal(t, "p_pro", handlers.ExerciseRazorpayTestPlanIDFor(bh, "pro")) + assert.Equal(t, "", handlers.ExerciseRazorpayTestPlanIDFor(bh, "growth")) + assert.Equal(t, "", handlers.ExerciseRazorpayTestPlanIDFor(bh, "team")) + assert.Equal(t, "", handlers.ExerciseRazorpayTestPlanIDFor(bh, "nonsense")) +} + +// TestCreateSubscription_TestModeDefaultClosure exercises the PRODUCTION +// default CreateSubscription closure with the private test-mode flag set, so +// the rzp_test_* key-swap + flag-strip branch runs (no DB; the unconfigured +// Razorpay call errors out, which is fine — we only need the closure body to +// execute). Pairs with the non-flag ExerciseCreateSubscription. +func TestCreateSubscription_TestModeDefaultClosure(t *testing.T) { + t.Parallel() + cfg := &config.Config{RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret} + bh := handlers.NewBillingHandler(nil, cfg, nil) + handlers.ExerciseCreateSubscriptionTestMode(bh) // must not panic +} + +// TestResolveCheckoutTestMode_FailsClosedOnDBError proves the is_test_cohort +// lookup error path returns useTest=false (fail CLOSED) so a DB blip never +// routes a real customer through the test account. A closed *sql.DB makes +// IsTestCohort return an error. +func TestResolveCheckoutTestMode_FailsClosedOnDBError(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + cfg := &config.Config{RazorpayTestKeyID: testCohortKeyID, RazorpayTestKeySecret: testCohortKeySecret, RazorpayTestPlanIDPro: testCohortPlanPro} + bh := handlers.NewBillingHandler(db, cfg, nil) + clean() // close the DB so IsTestCohort errors + useTest, planID := handlers.ExerciseResolveCheckoutTestMode(bh, context.Background(), uuid.New(), "pro") + assert.False(t, useTest, "a DB error on is_test_cohort must fail CLOSED (live path)") + assert.Equal(t, "", planID) +} + +// ── Full routing tests (DB-gated) ──────────────────────────────────────────── + +// TestCohortCheckout_UsesTestKeyAndPlan is the core Wave 4b proof: a cohort +// team with test keys+plan configured mints a subscription through the TEST +// credentials (asserted by capturing the plan_id the CreateSubscription closure +// receives — it must be the TEST plan, not the live one) and the private +// test-mode flag must be set on the body. The live-key-in-dev guard is bypassed +// even though the deployment is "development" + a live key is present. +func TestCohortCheckout_UsesTestKeyAndPlan(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + cfg := &config.Config{ + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + // Live key on a dev deployment would normally 503 via the BUG-P112 + // guard — the test-mode path MUST bypass it for cohort teams. + Environment: "development", + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + RazorpayTestKeyID: testCohortKeyID, + RazorpayTestKeySecret: testCohortKeySecret, + RazorpayTestPlanIDPro: testCohortPlanPro, + } + teamID, userID := seedVerifiedTeamUser(t, db, "free") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + var gotPlanID string + var gotTestFlag bool + bh.CreateSubscription = func(body map[string]any) (map[string]any, error) { + gotPlanID, _ = body["plan_id"].(string) + gotTestFlag, _ = body[handlers.SubBodyTestModeKeyForTest].(bool) + return map[string]any{"id": "sub_test_cohort_" + uuid.NewString(), "short_url": "https://rzp.io/test"}, nil + } + status, respBody := postCheckoutReq(t, app, map[string]any{"plan": "pro"}) + require.Equal(t, http.StatusOK, status, "cohort+test-keys must succeed, body=%v", respBody) + assert.Equal(t, testCohortPlanPro, gotPlanID, "must mint against the TEST plan_id, not the live one") + assert.True(t, gotTestFlag, "the private test-mode flag must be set so the closure picks test creds") + assert.NotNil(t, respBody["short_url"]) + // Key-leak contract holds on the test path too. + raw, _ := json.Marshal(respBody) + assert.NotContains(t, string(raw), testCohortKeyID) + assert.NotContains(t, string(raw), liveKeyExample) +} + +// TestCohortCheckout_InertWhenTestKeysUnset proves the inert fallback: a cohort +// team with NO test keys configured hits the existing synthetic_test_cohort +// skip (403) and NEVER reaches CreateSubscription — no crash, live path safe. +func TestCohortCheckout_InertWhenTestKeysUnset(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + cfg := &config.Config{ + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: "production", + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + // No RazorpayTest* — test mode inert. + } + teamID, userID := seedVerifiedTeamUser(t, db, "free") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + return nil, assertingError("inert cohort path must NOT mint a subscription") + } + status, respBody := postCheckoutReq(t, app, map[string]any{"plan": "pro"}) + require.Equal(t, http.StatusForbidden, status, "inert cohort must 403-skip, body=%v", respBody) + assert.Equal(t, "synthetic_test_cohort", respBody["error"]) +} + +// TestCohortCheckout_InertWhenTierHasNoTestPlan proves partial config (test +// keys set, but no test plan for the requested tier) falls back to the inert +// skip rather than minting against the LIVE plan. +func TestCohortCheckout_InertWhenTierHasNoTestPlan(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + cfg := &config.Config{ + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: "production", + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + RazorpayTestKeyID: testCohortKeyID, + RazorpayTestKeySecret: testCohortKeySecret, + // RazorpayTestPlanIDPro intentionally UNSET → no test plan for pro. + } + teamID, userID := seedVerifiedTeamUser(t, db, "free") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + bh.CreateSubscription = func(_ map[string]any) (map[string]any, error) { + return nil, assertingError("partial-config cohort must NOT mint against the live plan") + } + status, respBody := postCheckoutReq(t, app, map[string]any{"plan": "pro"}) + require.Equal(t, http.StatusForbidden, status, "partial-config cohort must 403-skip, body=%v", respBody) + assert.Equal(t, "synthetic_test_cohort", respBody["error"]) +} + +// TestNonCohortCheckout_AlwaysLivePath proves a NON-cohort team always uses the +// LIVE key+plan even when test keys are fully configured — live billing is +// provably unaffected by the test-mode wiring. +func TestNonCohortCheckout_AlwaysLivePath(t *testing.T) { + cov2NeedsDB(t) + db, clean := testhelpers.SetupTestDB(t) + defer clean() + cfg := &config.Config{ + JWTSecret: "test-secret-that-is-at-least-32-bytes-long!!", + Environment: "production", + RazorpayKeyID: liveKeyExample, + RazorpayKeySecret: "live-secret-fixture", + RazorpayPlanIDPro: "plan_LIVE_pro", + RazorpayTestKeyID: testCohortKeyID, + RazorpayTestKeySecret: testCohortKeySecret, + RazorpayTestPlanIDPro: testCohortPlanPro, + } + // NOT a cohort team — default is_test_cohort=false. + teamID, userID := seedVerifiedTeamUser(t, db, "free") + + app, bh := cov2CheckoutApp(t, db, cfg, teamID, userID) + var gotPlanID string + var gotTestFlag bool + bh.CreateSubscription = func(body map[string]any) (map[string]any, error) { + gotPlanID, _ = body["plan_id"].(string) + gotTestFlag, _ = body[handlers.SubBodyTestModeKeyForTest].(bool) + return map[string]any{"id": "sub_live_" + uuid.NewString(), "short_url": "https://rzp.io/live"}, nil + } + status, respBody := postCheckoutReq(t, app, map[string]any{"plan": "pro"}) + require.Equal(t, http.StatusOK, status, "non-cohort must use live path, body=%v", respBody) + assert.Equal(t, "plan_LIVE_pro", gotPlanID, "non-cohort must mint against the LIVE plan_id") + assert.False(t, gotTestFlag, "non-cohort must NOT carry the test-mode flag") + assert.Equal(t, "production", respBody["traffic_env"]) +} + +// ── Webhook try-both verification ───────────────────────────────────────────── + +// signRzp produces the X-Razorpay-Signature for a body under a secret +// (hex(HMAC-SHA256(body, secret)) — no timestamp prefix, matches the verifier). +func signRzp(body []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +// nowUnixStr is the current Unix second as a string, so the body's created_at +// is always inside the ±5min replay window. +func nowUnixStr() string { return strconv.FormatInt(time.Now().Unix(), 10) } + +// newRzpWebhookApp wires a Fiber app with just the webhook route. No DB is +// needed for the signature-verification leg under test (an unrecognised event +// type hits the switch default → 200 without touching the DB). +func newRzpWebhookApp(bh *handlers.BillingHandler) *fiber.App { + // cov2ErrHandler mirrors production: a respond* helper writes the response + // then returns ErrResponseWritten, which the error handler must treat as a + // no-op (default Fiber would turn it into a 500). Without this a 400 + // signature-mismatch surfaces as 500. + app := fiber.New(fiber.Config{ErrorHandler: cov2ErrHandler}) + app.Use(middleware.RequestID()) + app.Post("/razorpay/webhook", bh.RazorpayWebhook) + return app +} + +// postWebhook POSTs a signed body and returns the status code. +func postRzpWebhook(t *testing.T, app *fiber.App, body []byte, sig string) int { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/razorpay/webhook", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Razorpay-Signature", sig) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + return resp.StatusCode +} + +// TestWebhook_VerifiesTestSecret proves the try-both verification: a payload +// signed with the TEST webhook secret is accepted (so a real test-mode event +// upgrades a cohort team), while live webhooks remain accepted under the live +// secret and a wrong signature is still rejected 400. +func TestWebhook_VerifiesTestSecret(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + RazorpayWebhookSecret: "live-webhook-secret", + RazorpayTestWebhookSecret: testCohortWebhookSecret, + } + bh := handlers.NewBillingHandler(nil, cfg, nil) + app := newRzpWebhookApp(bh) + + // An unrecognised event type verifies the signature but is a no-op handler + // → still 200 (Razorpay must see 2xx). The point here is the SIGNATURE leg. + body := []byte(`{"event":"order.paid","created_at":` + nowUnixStr() + `,"id":"evt_test_4b"}`) + + t.Run("test secret accepted", func(t *testing.T) { + status := postRzpWebhook(t, app, body, signRzp(body, testCohortWebhookSecret)) + assert.Equal(t, http.StatusOK, status, "a payload signed with the TEST webhook secret must verify") + }) + t.Run("live secret still accepted", func(t *testing.T) { + status := postRzpWebhook(t, app, body, signRzp(body, "live-webhook-secret")) + assert.Equal(t, http.StatusOK, status, "live webhook secret must still verify") + }) + t.Run("wrong secret rejected", func(t *testing.T) { + status := postRzpWebhook(t, app, body, signRzp(body, "totally-wrong-secret")) + assert.Equal(t, http.StatusBadRequest, status, "a signature under neither secret must be rejected 400") + }) +} + +// TestWebhook_TestSecretInertWhenUnset proves that with no test webhook secret +// configured, only the live secret verifies (no accidental acceptance). +func TestWebhook_TestSecretInertWhenUnset(t *testing.T) { + t.Parallel() + cfg := &config.Config{RazorpayWebhookSecret: "live-webhook-secret"} // no test secret + bh := handlers.NewBillingHandler(nil, cfg, nil) + app := newRzpWebhookApp(bh) + body := []byte(`{"event":"order.paid","created_at":` + nowUnixStr() + `,"id":"evt_inert_4b"}`) + + assert.Equal(t, http.StatusOK, postRzpWebhook(t, app, body, signRzp(body, "live-webhook-secret"))) + assert.Equal(t, http.StatusBadRequest, postRzpWebhook(t, app, body, signRzp(body, testCohortWebhookSecret)), + "with no test secret configured, the test-secret leg must be inert") +} diff --git a/internal/handlers/export_billing_test.go b/internal/handlers/export_billing_test.go index 50564ee..c90d1b1 100644 --- a/internal/handlers/export_billing_test.go +++ b/internal/handlers/export_billing_test.go @@ -38,6 +38,43 @@ func ExportedRazorpayPlanIDFor(h *BillingHandler, tier, freq string) string { return h.razorpayPlanIDFor(tier, freq) } +// ── Wave 4b: synthetic test-cohort → rzp_test_* routing exports ─────────────── + +// SubBodyTestModeKeyForTest exposes the private subBody marker key the checkout +// handler sets for a cohort test-mode create, so a fake CreateSubscription can +// assert it is (or is not) present. +const SubBodyTestModeKeyForTest = subBodyTestModeKey + +// ExerciseTestModeConfigured exposes testModeConfigured for the +// inert-when-unset pure-function proof (no DB). +func ExerciseTestModeConfigured(h *BillingHandler) bool { + return h.testModeConfigured() +} + +// ExerciseRazorpayTestPlanIDFor exposes razorpayTestPlanIDFor for the +// self-serve-tier-only pure-function proof (no DB). +func ExerciseRazorpayTestPlanIDFor(h *BillingHandler, tier string) string { + return h.razorpayTestPlanIDFor(tier) +} + +// ExerciseCreateSubscriptionTestMode invokes the PRODUCTION default +// CreateSubscription closure WITH the subBodyTestModeKey flag set, so the +// test-key-swap + flag-strip branch (the rzp_test_* routing) runs. As with +// ExerciseCreateSubscription the unconfigured creds make the underlying +// Razorpay call error/panic — recovered here — but the closure body lines +// (key selection + delete) execute for coverage. +func ExerciseCreateSubscriptionTestMode(h *BillingHandler) { + defer func() { _ = recover() }() + _, _ = h.CreateSubscription(map[string]any{"plan_id": "plan_x", subBodyTestModeKey: true}) +} + +// ExerciseResolveCheckoutTestMode exposes resolveCheckoutTestMode so the +// fail-closed (DB error) + not-cohort + no-test-plan branches can be asserted +// directly without standing up a full checkout request. +func ExerciseResolveCheckoutTestMode(h *BillingHandler, ctx context.Context, teamID uuid.UUID, tier string) (bool, string) { + return h.resolveCheckoutTestMode(ctx, teamID, tier) +} + // ExportedPlanIDRecognised exposes planIDRecognised for coverage. func ExportedPlanIDRecognised(h *BillingHandler, planID string) bool { return h.planIDRecognised(planID)