Skip to content

Commit af2d0eb

Browse files
feat(teams): is_test_cohort column + api-side synthetic skip-guards (W0 / PR-1)
Cohort-isolation foundation for the continuous synthetic-monitoring program (docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.5/§1.6). Inert by default: every existing team is is_test_cohort=false, so behaviour is unchanged for all real teams until a seeder sets it. Zero external effect until synthetic accounts use it. - Migration 067: teams.is_test_cohort BOOLEAN NOT NULL DEFAULT false + tiny partial index on the true rows only. Forward-only (rollback documented). - Model: Team.IsTestCohort scanned in CreateTeam / GetTeamByID / GetTeamByRazorpaySubscriptionID; IsTestCohort(teamID) lookup helper + SetTestCohort setter (seeder-only — no public endpoint mutates the flag). - api-side skip-guards: CreateCheckoutAPI + ChangePlanAPI reject a test-cohort team with 403 synthetic_test_cohort BEFORE any Razorpay charge call (fail-open on a DB blip so a real customer is never blocked). These are the only api-side charge-initiation surfaces; every other §1.6 guard (quota nudge, churn, expiry/TTL emailers, billing reconciler, lifecycle/digest email) is worker-side and deferred to the follow-up worker PR. Tests: handler guard (test-cohort rejected, normal team passes, fail-open on DB error) for both endpoints; model helper/setter branches (sqlmock) + DB-backed migration smoke (column exists, defaults false, round-trips). 100% patch coverage on new model funcs + rejectIfTestCohort. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b0b3c3c commit af2d0eb

8 files changed

Lines changed: 496 additions & 9 deletions

File tree

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: 55 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,38 @@ 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+
isTest, err := models.IsTestCohort(c.Context(), h.db, teamID)
688+
if err != nil {
689+
// Fail open: a real customer's charge must not be blocked by a DB blip.
690+
slog.Warn("billing.test_cohort_check_failed_open",
691+
"error", err, "team_id", teamID, "action", action,
692+
"request_id", middleware.GetRequestID(c))
693+
return true, nil
694+
}
695+
if !isTest {
696+
return true, nil
697+
}
698+
slog.Info("billing.test_cohort_skip",
699+
"team_id", teamID, "action", action,
700+
"request_id", middleware.GetRequestID(c))
701+
return false, respondError(c, fiber.StatusForbidden, errCodeSyntheticTestCohort,
702+
"This is a synthetic test-cohort team and cannot start a real billing charge.")
703+
}
704+
662705
// CreateCheckoutAPI handles POST /api/v1/billing/checkout (and the legacy
663706
// alias POST /billing/checkout). Creates a Razorpay subscription and returns
664707
// the hosted payment short_url plus the subscription_id.
@@ -690,6 +733,13 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error {
690733
return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required")
691734
}
692735

736+
// Synthetic-cohort skip-guard (migration 067): a test-cohort team must
737+
// never reach the real Razorpay subscription-create call. Checked before
738+
// the email gate / Redis dedup so a synthetic call consumes nothing.
739+
if ok, errResp := h.rejectIfTestCohort(c, teamID, "checkout"); !ok {
740+
return errResp
741+
}
742+
693743
// Email-verified gate (migration 052): a /claim-created account must
694744
// verify its email before it can start a paid checkout. Checked before
695745
// the Redis dedup so an unverified caller never consumes a dedup slot.
@@ -3173,6 +3223,11 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error {
31733223
if err != nil {
31743224
return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required")
31753225
}
3226+
// Synthetic-cohort skip-guard (migration 067) — same as checkout: a
3227+
// test-cohort team must never reach the real Razorpay change-plan call.
3228+
if ok, errResp := h.rejectIfTestCohort(c, teamID, "change_plan"); !ok {
3229+
return errResp
3230+
}
31763231
// Email-verified gate (migration 052) — same gate as checkout: a
31773232
// /claim-created account must verify its email before changing plans.
31783233
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+
}

internal/models/coverage_team_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ func TestNormalizeEmail(t *testing.T) {
2121
}
2222

2323
func teamCols() []string {
24-
return []string{"id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy"}
24+
return []string{"id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy", "is_test_cohort"}
2525
}
2626

2727
func teamRow() *sqlmock.Rows {
28-
return sqlmock.NewRows(teamCols()).AddRow(uuid.New(), nil, "free", nil, time.Now(), "auto_24h")
28+
return sqlmock.NewRows(teamCols()).AddRow(uuid.New(), nil, "free", nil, time.Now(), "auto_24h", false)
2929
}
3030

3131
func userCols() []string {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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).
31+
team, err := models.CreateTeam(ctx, db, "cohort-smoke")
32+
require.NoError(t, err)
33+
require.False(t, team.IsTestCohort, "new team must default to is_test_cohort=false")
34+
35+
// IsTestCohort helper agrees on the default.
36+
isTest, err := models.IsTestCohort(ctx, db, team.ID)
37+
require.NoError(t, err)
38+
require.False(t, isTest)
39+
40+
// Flip it via the seeder setter and confirm both the helper and the
41+
// GetTeamByID scan path observe the new value.
42+
require.NoError(t, models.SetTestCohort(ctx, db, team.ID, true))
43+
44+
isTest, err = models.IsTestCohort(ctx, db, team.ID)
45+
require.NoError(t, err)
46+
require.True(t, isTest)
47+
48+
reread, err := models.GetTeamByID(ctx, db, team.ID)
49+
require.NoError(t, err)
50+
require.True(t, reread.IsTestCohort, "GetTeamByID must scan is_test_cohort")
51+
52+
// SetTestCohort on a non-existent team returns ErrTeamNotFound.
53+
err = models.SetTestCohort(ctx, db, uuid.New(), true)
54+
var notFound *models.ErrTeamNotFound
55+
require.ErrorAs(t, err, &notFound)
56+
57+
// IsTestCohort on a non-existent team is (false, nil).
58+
isTest, err = models.IsTestCohort(ctx, db, uuid.New())
59+
require.NoError(t, err)
60+
require.False(t, isTest)
61+
}

0 commit comments

Comments
 (0)