Skip to content

Commit bea4449

Browse files
Merge branch 'master' into w3/billing-block-integration-tests-2026-06-04
2 parents 36bf5ac + 262d10d commit bea4449

8 files changed

Lines changed: 483 additions & 0 deletions

File tree

dump.rdb

24.4 KB
Binary file not shown.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
-- Migration: 067_teams_is_test_cohort
2+
--
3+
-- Add teams.is_test_cohort — the cohort-isolation foundation (W0 / PR-1) for the
4+
-- continuous synthetic-monitoring program. See
5+
-- docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.5/§1.6.
6+
--
7+
-- Background:
8+
-- The synthetic-monitoring program seeds durable per-tier test teams
9+
-- (`synthetic+hobby@instanode.dev`, etc.) and provisions real resources on a
10+
-- continuous cadence. Without an isolation flag those seeded teams would look
11+
-- like real customers to every background job and funnel/billing surface:
12+
-- - quota nudges + expiry/TTL warning emails would fire at synthetic
13+
-- addresses (Brevo-rejected noise + ledger pollution),
14+
-- - the billing reconciler would flag them as drift (no real Razorpay sub),
15+
-- - the conversion funnel / churn predictor would count synthetic activity
16+
-- in the real 2%/20% targets,
17+
-- - self-serve checkout / change-plan would attempt a real charge.
18+
--
19+
-- `is_test_cohort` is the single tag every such path keys off to no-op or
20+
-- exclude the team. It is INERT by default: every existing team gets `false`,
21+
-- so behaviour is unchanged for all real teams until a seeder sets it true.
22+
--
23+
-- This migration:
24+
-- - Adds teams.is_test_cohort BOOLEAN NOT NULL DEFAULT false.
25+
-- - Adds a tiny PARTIAL index on the true rows only. The team-iterating jobs
26+
-- (worker-side, follow-up PR) filter `AND NOT is_test_cohort`; the api-side
27+
-- charge guards (this PR) look up a single team by id. The partial index
28+
-- covers the "list the synthetic teams" / "is this team synthetic" lookups
29+
-- while staying near-zero cost (only the handful of seeded rows are
30+
-- indexed — the DEFAULT-false universe is excluded).
31+
--
32+
-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS, safe to
33+
-- re-run on every startup (matches the RunMigrations forward-only contract).
34+
--
35+
-- Rollback (forward-only project; documented, not auto-applied):
36+
-- DROP INDEX IF EXISTS idx_teams_is_test_cohort;
37+
-- ALTER TABLE teams DROP COLUMN IF EXISTS is_test_cohort;
38+
-- (Safe — no FK or other constraint references this column.)
39+
40+
ALTER TABLE teams
41+
ADD COLUMN IF NOT EXISTS is_test_cohort BOOLEAN NOT NULL DEFAULT false;
42+
43+
CREATE INDEX IF NOT EXISTS idx_teams_is_test_cohort
44+
ON teams (id)
45+
WHERE is_test_cohort;

internal/handlers/billing.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ const checkoutInflightTTL = 60 * time.Second
5151
// the same team also bounces — the subscription belongs to the team.
5252
const checkoutInflightKeyPrefix = "team_checkout_inflight:"
5353

54+
// errCodeSyntheticTestCohort is returned by the self-serve charge-initiation
55+
// handlers (CreateCheckoutAPI, ChangePlanAPI) when the authenticated team is a
56+
// synthetic-monitoring test cohort (teams.is_test_cohort, migration 067). A
57+
// synthetic team must NEVER reach the real Razorpay subscription-create /
58+
// change-plan call — that would create a live charge for a test identity. The
59+
// guard returns a deterministic 403 with this distinct code BEFORE the Redis
60+
// dedup slot or any Razorpay call, so the synthetic runner gets a stable,
61+
// non-charging response it can assert on. See
62+
// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6.
63+
const errCodeSyntheticTestCohort = "synthetic_test_cohort"
64+
5465
// monthlyOngoingTotalCount / yearlyOngoingTotalCount are the Razorpay
5566
// subscription `total_count` values for an ONGOING (effectively indefinite)
5667
// plan. Razorpay's create-subscription API requires a finite total_count, so
@@ -659,6 +670,44 @@ func (h *BillingHandler) requireVerifiedEmail(c *fiber.Ctx, action string) (bool
659670
AgentActionEmailNotVerified, "")
660671
}
661672

673+
// rejectIfTestCohort is the api-side synthetic-cohort skip-guard for the
674+
// self-serve charge-initiation handlers. It looks up teams.is_test_cohort
675+
// (migration 067) and, when true, writes a deterministic 403
676+
// synthetic_test_cohort response and returns ok=false so the caller returns
677+
// immediately WITHOUT reaching Razorpay. A lookup error fails CLOSED (treated
678+
// as "not a test cohort" → proceed) so a transient DB blip never blocks a real
679+
// paying customer's checkout — the worst case is one synthetic call slipping
680+
// through to the inflight dedup, which the seeded test teams have no real
681+
// subscription to complete anyway.
682+
//
683+
// ok=true means "not a test cohort, proceed". On ok=false the returned error is
684+
// the already-written fiber response (or ErrResponseWritten) and the caller must
685+
// return it unchanged.
686+
func (h *BillingHandler) rejectIfTestCohort(c *fiber.Ctx, teamID uuid.UUID, action string) (ok bool, resp error) {
687+
// No DB wired: only happens on db-independent handler paths exercised in
688+
// tests (e.g. the Redis SETNX dedup guard, which short-circuits before any
689+
// DB use). In prod h.db is always set. Nothing to check — proceed.
690+
if h.db == nil {
691+
return true, nil
692+
}
693+
isTest, err := models.IsTestCohort(c.Context(), h.db, teamID)
694+
if err != nil {
695+
// Fail open: a real customer's charge must not be blocked by a DB blip.
696+
slog.Warn("billing.test_cohort_check_failed_open",
697+
"error", err, "team_id", teamID, "action", action,
698+
"request_id", middleware.GetRequestID(c))
699+
return true, nil
700+
}
701+
if !isTest {
702+
return true, nil
703+
}
704+
slog.Info("billing.test_cohort_skip",
705+
"team_id", teamID, "action", action,
706+
"request_id", middleware.GetRequestID(c))
707+
return false, respondError(c, fiber.StatusForbidden, errCodeSyntheticTestCohort,
708+
"This is a synthetic test-cohort team and cannot start a real billing charge.")
709+
}
710+
662711
// CreateCheckoutAPI handles POST /api/v1/billing/checkout (and the legacy
663712
// alias POST /billing/checkout). Creates a Razorpay subscription and returns
664713
// the hosted payment short_url plus the subscription_id.
@@ -690,6 +739,13 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
690739
return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required")
691740
}
692741

742+
// Synthetic-cohort skip-guard (migration 067): a test-cohort team must
743+
// never reach the real Razorpay subscription-create call. Checked before
744+
// the email gate / Redis dedup so a synthetic call consumes nothing.
745+
if ok, errResp := h.rejectIfTestCohort(c, teamID, "checkout"); !ok {
746+
return errResp
747+
}
748+
693749
// Email-verified gate (migration 052): a /claim-created account must
694750
// verify its email before it can start a paid checkout. Checked before
695751
// the Redis dedup so an unverified caller never consumes a dedup slot.
@@ -3173,6 +3229,11 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error {
31733229
if err != nil {
31743230
return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required")
31753231
}
3232+
// Synthetic-cohort skip-guard (migration 067) — same as checkout: a
3233+
// test-cohort team must never reach the real Razorpay change-plan call.
3234+
if ok, errResp := h.rejectIfTestCohort(c, teamID, "change_plan"); !ok {
3235+
return errResp
3236+
}
31763237
// Email-verified gate (migration 052) — same gate as checkout: a
31773238
// /claim-created account must verify its email before changing plans.
31783239
if ok, errResp := h.requireVerifiedEmail(c, "change_plan"); !ok {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package handlers_test
2+
3+
// billing_test_cohort_test.go — W0 / PR-1 (cohort-isolation foundation).
4+
//
5+
// Proves the api-side synthetic-cohort skip-guard on the two self-serve
6+
// charge-initiation handlers (CreateCheckoutAPI, ChangePlanAPI): a team with
7+
// teams.is_test_cohort=true (migration 067) is rejected with a deterministic
8+
// 403 synthetic_test_cohort BEFORE any Razorpay call, while a normal team
9+
// sails past the guard. See
10+
// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6.
11+
12+
import (
13+
"context"
14+
"database/sql"
15+
"encoding/json"
16+
"errors"
17+
"net/http"
18+
"net/http/httptest"
19+
"os"
20+
"strings"
21+
"testing"
22+
23+
sqlmock "github.com/DATA-DOG/go-sqlmock"
24+
"github.com/gofiber/fiber/v2"
25+
"github.com/google/uuid"
26+
"github.com/stretchr/testify/assert"
27+
"github.com/stretchr/testify/require"
28+
29+
"instant.dev/internal/config"
30+
"instant.dev/internal/email"
31+
"instant.dev/internal/handlers"
32+
"instant.dev/internal/middleware"
33+
"instant.dev/internal/models"
34+
"instant.dev/internal/testhelpers"
35+
)
36+
37+
// errCodeSyntheticTestCohort mirrors the unexported handler constant — the
38+
// stable wire code the synthetic runner asserts on.
39+
const errCodeSyntheticTestCohort = "synthetic_test_cohort"
40+
41+
func cohortNeedsDB(t *testing.T) (*sql.DB, func()) {
42+
t.Helper()
43+
if os.Getenv("TEST_DATABASE_URL") == "" {
44+
t.Skip("billing_test_cohort_test: TEST_DATABASE_URL not set — skipping integration test")
45+
}
46+
return testhelpers.SetupTestDB(t)
47+
}
48+
49+
// cohortBillingApp wires both charge-initiation endpoints with a fake-auth
50+
// middleware that injects only team_id (no user_id, so the email-verify gate
51+
// fails OPEN — isolating the cohort guard as the only blocker under test).
52+
// Razorpay creds are intentionally empty so a normal team that passes the
53+
// guard halts at billing_not_configured (503) without any network call.
54+
func cohortBillingApp(t *testing.T, db *sql.DB, teamID string) *fiber.App {
55+
t.Helper()
56+
cfg := &config.Config{JWTSecret: testhelpers.TestJWTSecret} // no Razorpay creds
57+
bh := handlers.NewBillingHandler(db, cfg, email.NewNoop())
58+
app := fiber.New(fiber.Config{
59+
ErrorHandler: func(c *fiber.Ctx, err error) error {
60+
if errors.Is(err, handlers.ErrResponseWritten) {
61+
return nil
62+
}
63+
code := fiber.StatusInternalServerError
64+
if e, ok := err.(*fiber.Error); ok {
65+
code = e.Code
66+
}
67+
return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error"})
68+
},
69+
})
70+
app.Use(func(c *fiber.Ctx) error {
71+
if teamID != "" {
72+
c.Locals(middleware.LocalKeyTeamID, teamID)
73+
}
74+
return c.Next()
75+
})
76+
app.Post("/api/v1/billing/checkout", bh.CreateCheckoutAPI)
77+
app.Post("/api/v1/billing/change-plan", bh.ChangePlanAPI)
78+
return app
79+
}
80+
81+
func cohortPost(t *testing.T, app *fiber.App, path, body string) (int, map[string]any) {
82+
t.Helper()
83+
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
84+
req.Header.Set("Content-Type", "application/json")
85+
resp, err := app.Test(req, 5000)
86+
require.NoError(t, err)
87+
defer resp.Body.Close()
88+
var out map[string]any
89+
_ = json.NewDecoder(resp.Body).Decode(&out)
90+
return resp.StatusCode, out
91+
}
92+
93+
// TestCheckout_TestCohortGuard_FailsOpenOnDBError: a DB blip on the cohort
94+
// lookup must NOT block a real customer's checkout. The guard fails open
95+
// (treats the lookup error as "not a test cohort") and execution proceeds
96+
// past it — so the response is anything OTHER than synthetic_test_cohort.
97+
// Uses sqlmock so the error branch is deterministic and DB-independent.
98+
func TestCheckout_TestCohortGuard_FailsOpenOnDBError(t *testing.T) {
99+
db, mock, err := sqlmock.New()
100+
require.NoError(t, err)
101+
defer db.Close()
102+
103+
teamID := uuid.NewString()
104+
mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id").
105+
WillReturnError(errors.New("db blip"))
106+
107+
app := cohortBillingApp(t, db, teamID) // no Razorpay creds → halts at not_configured
108+
status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`)
109+
110+
assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"],
111+
"a DB error on the cohort lookup must fail OPEN, not block the customer")
112+
assert.NotEqual(t, http.StatusForbidden, status)
113+
}
114+
115+
// TestCheckout_TestCohortTeam_Rejected: a synthetic team is 403'd with the
116+
// distinct code on the checkout path before any Razorpay call.
117+
func TestCheckout_TestCohortTeam_Rejected(t *testing.T) {
118+
db, cleanup := cohortNeedsDB(t)
119+
defer cleanup()
120+
121+
teamID := testhelpers.MustCreateTeamDB(t, db, "hobby")
122+
require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true))
123+
124+
app := cohortBillingApp(t, db, teamID)
125+
status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`)
126+
127+
assert.Equal(t, http.StatusForbidden, status)
128+
assert.Equal(t, errCodeSyntheticTestCohort, body["error"])
129+
}
130+
131+
// TestChangePlan_TestCohortTeam_Rejected: same guard on the change-plan path.
132+
func TestChangePlan_TestCohortTeam_Rejected(t *testing.T) {
133+
db, cleanup := cohortNeedsDB(t)
134+
defer cleanup()
135+
136+
teamID := testhelpers.MustCreateTeamDB(t, db, "hobby")
137+
require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true))
138+
139+
app := cohortBillingApp(t, db, teamID)
140+
status, body := cohortPost(t, app, "/api/v1/billing/change-plan", `{"target_plan":"pro"}`)
141+
142+
assert.Equal(t, http.StatusForbidden, status)
143+
assert.Equal(t, errCodeSyntheticTestCohort, body["error"])
144+
}
145+
146+
// TestCheckout_NormalTeam_NotSkipped: a normal (default is_test_cohort=false)
147+
// team is NOT caught by the guard — it passes through and halts later
148+
// (billing_not_configured, since Razorpay creds are empty). The assertion is
149+
// that the response is anything OTHER than synthetic_test_cohort, proving the
150+
// guard is cohort-specific and inert for real teams.
151+
func TestCheckout_NormalTeam_NotSkipped(t *testing.T) {
152+
db, cleanup := cohortNeedsDB(t)
153+
defer cleanup()
154+
155+
teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") // is_test_cohort defaults false
156+
157+
app := cohortBillingApp(t, db, teamID)
158+
status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`)
159+
160+
assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"],
161+
"a normal team must NOT be rejected by the synthetic-cohort guard")
162+
assert.NotEqual(t, http.StatusForbidden, status,
163+
"a normal team must pass the guard (halts later at billing_not_configured)")
164+
}
165+
166+
// TestChangePlan_NormalTeam_NotSkipped: change-plan twin of the above.
167+
func TestChangePlan_NormalTeam_NotSkipped(t *testing.T) {
168+
db, cleanup := cohortNeedsDB(t)
169+
defer cleanup()
170+
171+
teamID := testhelpers.MustCreateTeamDB(t, db, "hobby")
172+
173+
app := cohortBillingApp(t, db, teamID)
174+
status, body := cohortPost(t, app, "/api/v1/billing/change-plan", `{"target_plan":"pro"}`)
175+
176+
assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"],
177+
"a normal team must NOT be rejected by the synthetic-cohort guard")
178+
_ = status
179+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package models_test
2+
3+
// is_test_cohort_db_test.go — DB-backed smoke for migration 067
4+
// (teams.is_test_cohort). Asserts the column exists, defaults to false, is
5+
// scanned onto the Team struct, and round-trips through SetTestCohort /
6+
// IsTestCohort. Skips when TEST_DATABASE_URL is unset so the suite runs
7+
// cleanly without Postgres.
8+
9+
import (
10+
"context"
11+
"os"
12+
"testing"
13+
14+
"github.com/google/uuid"
15+
"github.com/stretchr/testify/require"
16+
17+
"instant.dev/internal/models"
18+
"instant.dev/internal/testhelpers"
19+
)
20+
21+
func TestIsTestCohort_MigrationSmokeAndRoundTrip(t *testing.T) {
22+
if os.Getenv("TEST_DATABASE_URL") == "" {
23+
t.Skip("TEST_DATABASE_URL not set; skipping integration test")
24+
}
25+
ctx := context.Background()
26+
db, clean := testhelpers.SetupTestDB(t)
27+
defer clean()
28+
29+
// A freshly-created team defaults to is_test_cohort = false (inert by
30+
// default — behaviour unchanged for every real team). The dedicated
31+
// IsTestCohort lookup is the single read path: the column is intentionally
32+
// NOT scanned into the main Team struct, to keep the cohort flag off the
33+
// widely-mocked GetTeamByID/CreateTeam/GetTeamByRazorpaySubscriptionID
34+
// SELECTs (which would otherwise force a 16-file sqlmock resync).
35+
team, err := models.CreateTeam(ctx, db, "cohort-smoke")
36+
require.NoError(t, err)
37+
38+
// IsTestCohort helper reports the default.
39+
isTest, err := models.IsTestCohort(ctx, db, team.ID)
40+
require.NoError(t, err)
41+
require.False(t, isTest)
42+
43+
// Flip it via the seeder setter and confirm the helper observes the new value.
44+
require.NoError(t, models.SetTestCohort(ctx, db, team.ID, true))
45+
46+
isTest, err = models.IsTestCohort(ctx, db, team.ID)
47+
require.NoError(t, err)
48+
require.True(t, isTest)
49+
50+
// SetTestCohort on a non-existent team returns ErrTeamNotFound.
51+
err = models.SetTestCohort(ctx, db, uuid.New(), true)
52+
var notFound *models.ErrTeamNotFound
53+
require.ErrorAs(t, err, &notFound)
54+
55+
// IsTestCohort on a non-existent team is (false, nil).
56+
isTest, err = models.IsTestCohort(ctx, db, uuid.New())
57+
require.NoError(t, err)
58+
require.False(t, isTest)
59+
}

0 commit comments

Comments
 (0)