@@ -57,9 +57,11 @@ const (
5757// ── Pure-function inert proofs (NO DB — always run in CI) ────────────────────
5858
5959// TestTestModeConfigured_InertWhenUnset proves the whole test-mode path is
60- // INERT when the operator has not wired the rzp_test_* key+secret. This is the
61- // "default empty = inert" guarantee: on prod (no test keys) the cohort routing
62- // never engages, so live billing is provably untouched.
60+ // INERT unless the operator has BOTH wired the rzp_test_* key+secret AND flipped
61+ // the PAYMENT_TEST_MODE_ENABLED kill-switch on. This is the "default empty +
62+ // default off = inert" guarantee: on prod (no test keys, flag off) the cohort
63+ // routing never engages, so live billing is provably untouched — and even with
64+ // the secrets present, the flag must be ON for test mode to arm.
6365func TestTestModeConfigured_InertWhenUnset (t * testing.T ) {
6466 t .Parallel ()
6567 cases := []struct {
@@ -70,7 +72,9 @@ func TestTestModeConfigured_InertWhenUnset(t *testing.T) {
7072 {"both unset" , config.Config {}, false },
7173 {"only id set" , config.Config {RazorpayTestKeyID : testCohortKeyID }, false },
7274 {"only secret set" , config.Config {RazorpayTestKeySecret : testCohortKeySecret }, false },
73- {"both set → configured" , config.Config {RazorpayTestKeyID : testCohortKeyID , RazorpayTestKeySecret : testCohortKeySecret }, true },
75+ {"keys set, flag OFF → still inert" , config.Config {RazorpayTestKeyID : testCohortKeyID , RazorpayTestKeySecret : testCohortKeySecret }, false },
76+ {"flag on, keys unset → inert" , config.Config {PaymentTestModeEnabled : true }, false },
77+ {"keys set + flag ON → configured" , config.Config {RazorpayTestKeyID : testCohortKeyID , RazorpayTestKeySecret : testCohortKeySecret , PaymentTestModeEnabled : true }, true },
7478 }
7579 for _ , tc := range cases {
7680 tc := tc
@@ -121,7 +125,7 @@ func TestCreateSubscription_TestModeDefaultClosure(t *testing.T) {
121125func TestResolveCheckoutTestMode_FailsClosedOnDBError (t * testing.T ) {
122126 cov2NeedsDB (t )
123127 db , clean := testhelpers .SetupTestDB (t )
124- cfg := & config.Config {RazorpayTestKeyID : testCohortKeyID , RazorpayTestKeySecret : testCohortKeySecret , RazorpayTestPlanIDPro : testCohortPlanPro }
128+ cfg := & config.Config {PaymentTestModeEnabled : true , RazorpayTestKeyID : testCohortKeyID , RazorpayTestKeySecret : testCohortKeySecret , RazorpayTestPlanIDPro : testCohortPlanPro }
125129 bh := handlers .NewBillingHandler (db , cfg , nil )
126130 clean () // close the DB so IsTestCohort errors
127131 useTest , planID := handlers .ExerciseResolveCheckoutTestMode (bh , context .Background (), uuid .New (), "pro" )
@@ -145,13 +149,14 @@ func TestCohortCheckout_UsesTestKeyAndPlan(t *testing.T) {
145149 JWTSecret : "test-secret-that-is-at-least-32-bytes-long!!" ,
146150 // Live key on a dev deployment would normally 503 via the BUG-P112
147151 // guard — the test-mode path MUST bypass it for cohort teams.
148- Environment : "development" ,
149- RazorpayKeyID : liveKeyExample ,
150- RazorpayKeySecret : "live-secret-fixture" ,
151- RazorpayPlanIDPro : "plan_LIVE_pro" ,
152- RazorpayTestKeyID : testCohortKeyID ,
153- RazorpayTestKeySecret : testCohortKeySecret ,
154- RazorpayTestPlanIDPro : testCohortPlanPro ,
152+ Environment : "development" ,
153+ PaymentTestModeEnabled : true ,
154+ RazorpayKeyID : liveKeyExample ,
155+ RazorpayKeySecret : "live-secret-fixture" ,
156+ RazorpayPlanIDPro : "plan_LIVE_pro" ,
157+ RazorpayTestKeyID : testCohortKeyID ,
158+ RazorpayTestKeySecret : testCohortKeySecret ,
159+ RazorpayTestPlanIDPro : testCohortPlanPro ,
155160 }
156161 teamID , userID := seedVerifiedTeamUser (t , db , "free" )
157162 require .NoError (t , models .SetTestCohort (context .Background (), db , uuid .MustParse (teamID ), true ))
@@ -210,13 +215,14 @@ func TestCohortCheckout_InertWhenTierHasNoTestPlan(t *testing.T) {
210215 db , clean := testhelpers .SetupTestDB (t )
211216 defer clean ()
212217 cfg := & config.Config {
213- JWTSecret : "test-secret-that-is-at-least-32-bytes-long!!" ,
214- Environment : "production" ,
215- RazorpayKeyID : liveKeyExample ,
216- RazorpayKeySecret : "live-secret-fixture" ,
217- RazorpayPlanIDPro : "plan_LIVE_pro" ,
218- RazorpayTestKeyID : testCohortKeyID ,
219- RazorpayTestKeySecret : testCohortKeySecret ,
218+ JWTSecret : "test-secret-that-is-at-least-32-bytes-long!!" ,
219+ Environment : "production" ,
220+ PaymentTestModeEnabled : true ,
221+ RazorpayKeyID : liveKeyExample ,
222+ RazorpayKeySecret : "live-secret-fixture" ,
223+ RazorpayPlanIDPro : "plan_LIVE_pro" ,
224+ RazorpayTestKeyID : testCohortKeyID ,
225+ RazorpayTestKeySecret : testCohortKeySecret ,
220226 // RazorpayTestPlanIDPro intentionally UNSET → no test plan for pro.
221227 }
222228 teamID , userID := seedVerifiedTeamUser (t , db , "free" )
@@ -239,14 +245,15 @@ func TestNonCohortCheckout_AlwaysLivePath(t *testing.T) {
239245 db , clean := testhelpers .SetupTestDB (t )
240246 defer clean ()
241247 cfg := & config.Config {
242- JWTSecret : "test-secret-that-is-at-least-32-bytes-long!!" ,
243- Environment : "production" ,
244- RazorpayKeyID : liveKeyExample ,
245- RazorpayKeySecret : "live-secret-fixture" ,
246- RazorpayPlanIDPro : "plan_LIVE_pro" ,
247- RazorpayTestKeyID : testCohortKeyID ,
248- RazorpayTestKeySecret : testCohortKeySecret ,
249- RazorpayTestPlanIDPro : testCohortPlanPro ,
248+ JWTSecret : "test-secret-that-is-at-least-32-bytes-long!!" ,
249+ Environment : "production" ,
250+ PaymentTestModeEnabled : true ,
251+ RazorpayKeyID : liveKeyExample ,
252+ RazorpayKeySecret : "live-secret-fixture" ,
253+ RazorpayPlanIDPro : "plan_LIVE_pro" ,
254+ RazorpayTestKeyID : testCohortKeyID ,
255+ RazorpayTestKeySecret : testCohortKeySecret ,
256+ RazorpayTestPlanIDPro : testCohortPlanPro ,
250257 }
251258 // NOT a cohort team — default is_test_cohort=false.
252259 teamID , userID := seedVerifiedTeamUser (t , db , "free" )
@@ -313,6 +320,7 @@ func postRzpWebhook(t *testing.T, app *fiber.App, body []byte, sig string) int {
313320func TestWebhook_VerifiesTestSecret (t * testing.T ) {
314321 t .Parallel ()
315322 cfg := & config.Config {
323+ PaymentTestModeEnabled : true ,
316324 RazorpayWebhookSecret : "live-webhook-secret" ,
317325 RazorpayTestWebhookSecret : testCohortWebhookSecret ,
318326 }
@@ -350,3 +358,58 @@ func TestWebhook_TestSecretInertWhenUnset(t *testing.T) {
350358 assert .Equal (t , http .StatusBadRequest , postRzpWebhook (t , app , body , signRzp (body , testCohortWebhookSecret )),
351359 "with no test secret configured, the test-secret leg must be inert" )
352360}
361+
362+ // TestWebhook_TestSecretIgnoredWhenFlagOff is the kill-switch proof for the
363+ // webhook leg: with the rzp_test_* webhook secret fully configured but
364+ // PAYMENT_TEST_MODE_ENABLED OFF, a test-secret-signed payload is REJECTED (400).
365+ // The flag disables test-mode independently of the secret values, so a leftover
366+ // test secret can never be honoured on a deployment where test mode is off.
367+ func TestWebhook_TestSecretIgnoredWhenFlagOff (t * testing.T ) {
368+ t .Parallel ()
369+ cfg := & config.Config {
370+ PaymentTestModeEnabled : false , // kill-switch OFF (explicit)
371+ RazorpayWebhookSecret : "live-webhook-secret" ,
372+ RazorpayTestWebhookSecret : testCohortWebhookSecret , // configured but must be ignored
373+ }
374+ bh := handlers .NewBillingHandler (nil , cfg , nil )
375+ app := newRzpWebhookApp (bh )
376+ body := []byte (`{"event":"order.paid","created_at":` + nowUnixStr () + `,"id":"evt_flagoff_4b"}` )
377+
378+ assert .Equal (t , http .StatusOK , postRzpWebhook (t , app , body , signRzp (body , "live-webhook-secret" )),
379+ "the live secret must still verify regardless of the test-mode flag" )
380+ assert .Equal (t , http .StatusBadRequest , postRzpWebhook (t , app , body , signRzp (body , testCohortWebhookSecret )),
381+ "with the kill-switch OFF, a configured test secret must NOT be honoured" )
382+ }
383+
384+ // TestCohortCheckout_InertWhenFlagOff is the kill-switch proof for the checkout
385+ // leg: a cohort team with the FULL rzp_test_* key+secret+plan configured but
386+ // PAYMENT_TEST_MODE_ENABLED OFF must NOT mint a test subscription — it hits the
387+ // inert synthetic_test_cohort skip (403) and never reaches CreateSubscription.
388+ // This proves test-mode routing is gated on the flag, not merely on the presence
389+ // of the secrets, so the operator has a single instant kill-switch.
390+ func TestCohortCheckout_InertWhenFlagOff (t * testing.T ) {
391+ cov2NeedsDB (t )
392+ db , clean := testhelpers .SetupTestDB (t )
393+ defer clean ()
394+ cfg := & config.Config {
395+ JWTSecret : "test-secret-that-is-at-least-32-bytes-long!!" ,
396+ Environment : "production" ,
397+ PaymentTestModeEnabled : false , // kill-switch OFF — fully configured otherwise
398+ RazorpayKeyID : liveKeyExample ,
399+ RazorpayKeySecret : "live-secret-fixture" ,
400+ RazorpayPlanIDPro : "plan_LIVE_pro" ,
401+ RazorpayTestKeyID : testCohortKeyID ,
402+ RazorpayTestKeySecret : testCohortKeySecret ,
403+ RazorpayTestPlanIDPro : testCohortPlanPro ,
404+ }
405+ teamID , userID := seedVerifiedTeamUser (t , db , "free" )
406+ require .NoError (t , models .SetTestCohort (context .Background (), db , uuid .MustParse (teamID ), true ))
407+
408+ app , bh := cov2CheckoutApp (t , db , cfg , teamID , userID )
409+ bh .CreateSubscription = func (_ map [string ]any ) (map [string ]any , error ) {
410+ return nil , assertingError ("kill-switch-off cohort must NOT mint a subscription" )
411+ }
412+ status , respBody := postCheckoutReq (t , app , map [string ]any {"plan" : "pro" })
413+ require .Equal (t , http .StatusForbidden , status , "flag-off cohort must 403-skip, body=%v" , respBody )
414+ assert .Equal (t , "synthetic_test_cohort" , respBody ["error" ])
415+ }
0 commit comments