From af2d0eb4f45d479cbc84ec9a167a31ff1332b50e Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 4 Jun 2026 23:51:21 +0530 Subject: [PATCH 1/5] feat(teams): is_test_cohort column + api-side synthetic skip-guards (W0 / PR-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migrations/067_teams_is_test_cohort.sql | 45 +++++ internal/handlers/billing.go | 55 ++++++ internal/handlers/billing_test_cohort_test.go | 179 ++++++++++++++++++ internal/models/coverage_team_test.go | 4 +- internal/models/is_test_cohort_db_test.go | 61 ++++++ internal/models/is_test_cohort_test.go | 87 +++++++++ internal/models/team.go | 70 ++++++- internal/testhelpers/testhelpers.go | 4 + 8 files changed, 496 insertions(+), 9 deletions(-) create mode 100644 internal/db/migrations/067_teams_is_test_cohort.sql create mode 100644 internal/handlers/billing_test_cohort_test.go create mode 100644 internal/models/is_test_cohort_db_test.go create mode 100644 internal/models/is_test_cohort_test.go diff --git a/internal/db/migrations/067_teams_is_test_cohort.sql b/internal/db/migrations/067_teams_is_test_cohort.sql new file mode 100644 index 00000000..57a40fd7 --- /dev/null +++ b/internal/db/migrations/067_teams_is_test_cohort.sql @@ -0,0 +1,45 @@ +-- Migration: 067_teams_is_test_cohort +-- +-- Add teams.is_test_cohort — the cohort-isolation foundation (W0 / PR-1) for the +-- continuous synthetic-monitoring program. See +-- docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.5/§1.6. +-- +-- Background: +-- The synthetic-monitoring program seeds durable per-tier test teams +-- (`synthetic+hobby@instanode.dev`, etc.) and provisions real resources on a +-- continuous cadence. Without an isolation flag those seeded teams would look +-- like real customers to every background job and funnel/billing surface: +-- - quota nudges + expiry/TTL warning emails would fire at synthetic +-- addresses (Brevo-rejected noise + ledger pollution), +-- - the billing reconciler would flag them as drift (no real Razorpay sub), +-- - the conversion funnel / churn predictor would count synthetic activity +-- in the real 2%/20% targets, +-- - self-serve checkout / change-plan would attempt a real charge. +-- +-- `is_test_cohort` is the single tag every such path keys off to no-op or +-- exclude the team. It is INERT by default: every existing team gets `false`, +-- so behaviour is unchanged for all real teams until a seeder sets it true. +-- +-- This migration: +-- - Adds teams.is_test_cohort BOOLEAN NOT NULL DEFAULT false. +-- - Adds a tiny PARTIAL index on the true rows only. The team-iterating jobs +-- (worker-side, follow-up PR) filter `AND NOT is_test_cohort`; the api-side +-- charge guards (this PR) look up a single team by id. The partial index +-- covers the "list the synthetic teams" / "is this team synthetic" lookups +-- while staying near-zero cost (only the handful of seeded rows are +-- indexed — the DEFAULT-false universe is excluded). +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS, safe to +-- re-run on every startup (matches the RunMigrations forward-only contract). +-- +-- Rollback (forward-only project; documented, not auto-applied): +-- DROP INDEX IF EXISTS idx_teams_is_test_cohort; +-- ALTER TABLE teams DROP COLUMN IF EXISTS is_test_cohort; +-- (Safe — no FK or other constraint references this column.) + +ALTER TABLE teams + ADD COLUMN IF NOT EXISTS is_test_cohort BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS idx_teams_is_test_cohort + ON teams (id) + WHERE is_test_cohort; diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index ef5e1ed5..5e122867 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -51,6 +51,17 @@ const checkoutInflightTTL = 60 * time.Second // the same team also bounces — the subscription belongs to the team. const checkoutInflightKeyPrefix = "team_checkout_inflight:" +// errCodeSyntheticTestCohort is returned by the self-serve charge-initiation +// handlers (CreateCheckoutAPI, ChangePlanAPI) when the authenticated team is a +// synthetic-monitoring test cohort (teams.is_test_cohort, migration 067). A +// synthetic team must NEVER reach the real Razorpay subscription-create / +// change-plan call — that would create a live charge for a test identity. The +// guard returns a deterministic 403 with this distinct code BEFORE the Redis +// dedup slot or any Razorpay call, so the synthetic runner gets a stable, +// non-charging response it can assert on. See +// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. +const errCodeSyntheticTestCohort = "synthetic_test_cohort" + // monthlyOngoingTotalCount / yearlyOngoingTotalCount are the Razorpay // subscription `total_count` values for an ONGOING (effectively indefinite) // 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 AgentActionEmailNotVerified, "") } +// rejectIfTestCohort is the api-side synthetic-cohort skip-guard for the +// self-serve charge-initiation handlers. It looks up teams.is_test_cohort +// (migration 067) and, when true, writes a deterministic 403 +// synthetic_test_cohort response and returns ok=false so the caller returns +// immediately WITHOUT reaching Razorpay. A lookup error fails CLOSED (treated +// as "not a test cohort" → proceed) so a transient DB blip never blocks a real +// paying customer's checkout — the worst case is one synthetic call slipping +// through to the inflight dedup, which the seeded test teams have no real +// subscription to complete anyway. +// +// ok=true means "not a test cohort, proceed". On ok=false the returned error is +// the already-written fiber response (or ErrResponseWritten) and the caller must +// return it unchanged. +func (h *BillingHandler) rejectIfTestCohort(c *fiber.Ctx, teamID uuid.UUID, action string) (ok bool, resp error) { + isTest, err := models.IsTestCohort(c.Context(), h.db, teamID) + if err != nil { + // Fail open: a real customer's charge must not be blocked by a DB blip. + slog.Warn("billing.test_cohort_check_failed_open", + "error", err, "team_id", teamID, "action", action, + "request_id", middleware.GetRequestID(c)) + return true, nil + } + if !isTest { + return true, nil + } + slog.Info("billing.test_cohort_skip", + "team_id", teamID, "action", action, + "request_id", middleware.GetRequestID(c)) + return false, respondError(c, fiber.StatusForbidden, errCodeSyntheticTestCohort, + "This is a synthetic test-cohort team and cannot start a real billing charge.") +} + // CreateCheckoutAPI handles POST /api/v1/billing/checkout (and the legacy // alias POST /billing/checkout). Creates a Razorpay subscription and returns // the hosted payment short_url plus the subscription_id. @@ -690,6 +733,13 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required") } + // 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 + } + // Email-verified gate (migration 052): a /claim-created account must // verify its email before it can start a paid checkout. Checked before // the Redis dedup so an unverified caller never consumes a dedup slot. @@ -3173,6 +3223,11 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { if err != nil { return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required") } + // Synthetic-cohort skip-guard (migration 067) — same as checkout: a + // test-cohort team must never reach the real Razorpay change-plan call. + if ok, errResp := h.rejectIfTestCohort(c, teamID, "change_plan"); !ok { + return errResp + } // Email-verified gate (migration 052) — same gate as checkout: a // /claim-created account must verify its email before changing plans. if ok, errResp := h.requireVerifiedEmail(c, "change_plan"); !ok { diff --git a/internal/handlers/billing_test_cohort_test.go b/internal/handlers/billing_test_cohort_test.go new file mode 100644 index 00000000..7164fee1 --- /dev/null +++ b/internal/handlers/billing_test_cohort_test.go @@ -0,0 +1,179 @@ +package handlers_test + +// billing_test_cohort_test.go — W0 / PR-1 (cohort-isolation foundation). +// +// Proves the api-side synthetic-cohort skip-guard on the two self-serve +// charge-initiation handlers (CreateCheckoutAPI, ChangePlanAPI): a team with +// teams.is_test_cohort=true (migration 067) is rejected with a deterministic +// 403 synthetic_test_cohort BEFORE any Razorpay call, while a normal team +// sails past the guard. See +// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "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" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// errCodeSyntheticTestCohort mirrors the unexported handler constant — the +// stable wire code the synthetic runner asserts on. +const errCodeSyntheticTestCohort = "synthetic_test_cohort" + +func cohortNeedsDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("billing_test_cohort_test: TEST_DATABASE_URL not set — skipping integration test") + } + return testhelpers.SetupTestDB(t) +} + +// cohortBillingApp wires both charge-initiation endpoints with a fake-auth +// middleware that injects only team_id (no user_id, so the email-verify gate +// fails OPEN — isolating the cohort guard as the only blocker under test). +// Razorpay creds are intentionally empty so a normal team that passes the +// guard halts at billing_not_configured (503) without any network call. +func cohortBillingApp(t *testing.T, db *sql.DB, teamID string) *fiber.App { + t.Helper() + cfg := &config.Config{JWTSecret: testhelpers.TestJWTSecret} // no Razorpay creds + bh := handlers.NewBillingHandler(db, cfg, email.NewNoop()) + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error"}) + }, + }) + app.Use(func(c *fiber.Ctx) error { + if teamID != "" { + c.Locals(middleware.LocalKeyTeamID, teamID) + } + return c.Next() + }) + app.Post("/api/v1/billing/checkout", bh.CreateCheckoutAPI) + app.Post("/api/v1/billing/change-plan", bh.ChangePlanAPI) + return app +} + +func cohortPost(t *testing.T, app *fiber.App, path, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +// TestCheckout_TestCohortGuard_FailsOpenOnDBError: a DB blip on the cohort +// lookup must NOT block a real customer's checkout. The guard fails open +// (treats the lookup error as "not a test cohort") and execution proceeds +// past it — so the response is anything OTHER than synthetic_test_cohort. +// Uses sqlmock so the error branch is deterministic and DB-independent. +func TestCheckout_TestCohortGuard_FailsOpenOnDBError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + teamID := uuid.NewString() + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id"). + WillReturnError(errors.New("db blip")) + + app := cohortBillingApp(t, db, teamID) // no Razorpay creds → halts at not_configured + status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`) + + assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"], + "a DB error on the cohort lookup must fail OPEN, not block the customer") + assert.NotEqual(t, http.StatusForbidden, status) +} + +// TestCheckout_TestCohortTeam_Rejected: a synthetic team is 403'd with the +// distinct code on the checkout path before any Razorpay call. +func TestCheckout_TestCohortTeam_Rejected(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`) + + assert.Equal(t, http.StatusForbidden, status) + assert.Equal(t, errCodeSyntheticTestCohort, body["error"]) +} + +// TestChangePlan_TestCohortTeam_Rejected: same guard on the change-plan path. +func TestChangePlan_TestCohortTeam_Rejected(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/change-plan", `{"target_plan":"pro"}`) + + assert.Equal(t, http.StatusForbidden, status) + assert.Equal(t, errCodeSyntheticTestCohort, body["error"]) +} + +// TestCheckout_NormalTeam_NotSkipped: a normal (default is_test_cohort=false) +// team is NOT caught by the guard — it passes through and halts later +// (billing_not_configured, since Razorpay creds are empty). The assertion is +// that the response is anything OTHER than synthetic_test_cohort, proving the +// guard is cohort-specific and inert for real teams. +func TestCheckout_NormalTeam_NotSkipped(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") // is_test_cohort defaults false + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`) + + assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"], + "a normal team must NOT be rejected by the synthetic-cohort guard") + assert.NotEqual(t, http.StatusForbidden, status, + "a normal team must pass the guard (halts later at billing_not_configured)") +} + +// TestChangePlan_NormalTeam_NotSkipped: change-plan twin of the above. +func TestChangePlan_NormalTeam_NotSkipped(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/change-plan", `{"target_plan":"pro"}`) + + assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"], + "a normal team must NOT be rejected by the synthetic-cohort guard") + _ = status +} diff --git a/internal/models/coverage_team_test.go b/internal/models/coverage_team_test.go index 43416d58..5d7cfb0b 100644 --- a/internal/models/coverage_team_test.go +++ b/internal/models/coverage_team_test.go @@ -21,11 +21,11 @@ func TestNormalizeEmail(t *testing.T) { } func teamCols() []string { - return []string{"id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy"} + return []string{"id", "name", "plan_tier", "stripe_customer_id", "created_at", "default_deployment_ttl_policy", "is_test_cohort"} } func teamRow() *sqlmock.Rows { - return sqlmock.NewRows(teamCols()).AddRow(uuid.New(), nil, "free", nil, time.Now(), "auto_24h") + return sqlmock.NewRows(teamCols()).AddRow(uuid.New(), nil, "free", nil, time.Now(), "auto_24h", false) } func userCols() []string { diff --git a/internal/models/is_test_cohort_db_test.go b/internal/models/is_test_cohort_db_test.go new file mode 100644 index 00000000..5527f8e0 --- /dev/null +++ b/internal/models/is_test_cohort_db_test.go @@ -0,0 +1,61 @@ +package models_test + +// is_test_cohort_db_test.go — DB-backed smoke for migration 067 +// (teams.is_test_cohort). Asserts the column exists, defaults to false, is +// scanned onto the Team struct, and round-trips through SetTestCohort / +// IsTestCohort. Skips when TEST_DATABASE_URL is unset so the suite runs +// cleanly without Postgres. + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +func TestIsTestCohort_MigrationSmokeAndRoundTrip(t *testing.T) { + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("TEST_DATABASE_URL not set; skipping integration test") + } + ctx := context.Background() + db, clean := testhelpers.SetupTestDB(t) + defer clean() + + // A freshly-created team defaults to is_test_cohort = false (inert by + // default — behaviour unchanged for every real team). + team, err := models.CreateTeam(ctx, db, "cohort-smoke") + require.NoError(t, err) + require.False(t, team.IsTestCohort, "new team must default to is_test_cohort=false") + + // IsTestCohort helper agrees on the default. + isTest, err := models.IsTestCohort(ctx, db, team.ID) + require.NoError(t, err) + require.False(t, isTest) + + // Flip it via the seeder setter and confirm both the helper and the + // GetTeamByID scan path observe the new value. + require.NoError(t, models.SetTestCohort(ctx, db, team.ID, true)) + + isTest, err = models.IsTestCohort(ctx, db, team.ID) + require.NoError(t, err) + require.True(t, isTest) + + reread, err := models.GetTeamByID(ctx, db, team.ID) + require.NoError(t, err) + require.True(t, reread.IsTestCohort, "GetTeamByID must scan is_test_cohort") + + // SetTestCohort on a non-existent team returns ErrTeamNotFound. + err = models.SetTestCohort(ctx, db, uuid.New(), true) + var notFound *models.ErrTeamNotFound + require.ErrorAs(t, err, ¬Found) + + // IsTestCohort on a non-existent team is (false, nil). + isTest, err = models.IsTestCohort(ctx, db, uuid.New()) + require.NoError(t, err) + require.False(t, isTest) +} diff --git a/internal/models/is_test_cohort_test.go b/internal/models/is_test_cohort_test.go new file mode 100644 index 00000000..1eafef59 --- /dev/null +++ b/internal/models/is_test_cohort_test.go @@ -0,0 +1,87 @@ +package models + +// is_test_cohort_test.go — white-box sqlmock coverage for the cohort-isolation +// helpers added in migration 067 (W0 / PR-1): IsTestCohort + SetTestCohort. +// Every DB-error / no-rows / rows-affected branch is driven here without a real +// Postgres; the DB-backed smoke (default + real scan) lives in +// is_test_cohort_db_test.go (package models_test). + +import ( + "context" + "errors" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestIsTestCohort_Branches(t *testing.T) { + ctx := context.Background() + id := uuid.New() + + // true value scans through. + db, mock := newMock(t) + mock.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(true)) + got, err := IsTestCohort(ctx, db, id) + require.NoError(t, err) + require.True(t, got) + + // false value scans through. + db2, mock2 := newMock(t) + mock2.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(false)) + got, err = IsTestCohort(ctx, db2, id) + require.NoError(t, err) + require.False(t, got) + + // no rows → (false, nil) — missing team is not a test cohort. + db3, mock3 := newMock(t) + mock3.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`).WillReturnError(errNoRows()) + got, err = IsTestCohort(ctx, db3, id) + require.NoError(t, err) + require.False(t, got) + + // query error → wrapped error, false. + db4, mock4 := newMock(t) + mock4.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`).WillReturnError(errors.New("boom")) + got, err = IsTestCohort(ctx, db4, id) + require.ErrorContains(t, err, "boom") + require.False(t, got) +} + +func TestSetTestCohort_Branches(t *testing.T) { + ctx := context.Background() + id := uuid.New() + + // success — one row updated. + db, mock := newMock(t) + mock.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(true, id). + WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, SetTestCohort(ctx, db, id, true)) + + // zero rows → ErrTeamNotFound. + db2, mock2 := newMock(t) + mock2.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(false, id). + WillReturnResult(sqlmock.NewResult(0, 0)) + err := SetTestCohort(ctx, db2, id, false) + var notFound *ErrTeamNotFound + require.ErrorAs(t, err, ¬Found) + + // exec error → wrapped error. + db3, mock3 := newMock(t) + mock3.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(true, id). + WillReturnError(errors.New("boom")) + require.ErrorContains(t, SetTestCohort(ctx, db3, id, true), "boom") + + // RowsAffected error → wrapped "rows" error. + db4, mock4 := newMock(t) + mock4.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(true, id). + WillReturnResult(sqlmock.NewErrorResult(errors.New("ra-boom"))) + require.ErrorContains(t, SetTestCohort(ctx, db4, id, true), "rows") +} diff --git a/internal/models/team.go b/internal/models/team.go index bf23c381..8c591206 100644 --- a/internal/models/team.go +++ b/internal/models/team.go @@ -43,7 +43,15 @@ type Team struct { // Per-request ttl_policy in the deploy body always overrides this. // Only owner/admin can mutate via PATCH /api/v1/team/settings. DefaultDeploymentTTLPolicy string - CreatedAt time.Time + // IsTestCohort marks a team as part of the synthetic-monitoring test + // cohort (migration 067, W0 / PR-1). Inert by default (every real team is + // false). Seeder-set true on the durable per-tier test teams so that + // charge-initiation / conversion-funnel / background-email paths can no-op + // or exclude them — keeping synthetic traffic out of the real + // funnel/billing/email surfaces. See + // docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. + IsTestCohort bool + CreatedAt time.Time } // User represents an authenticated user belonging to a team. @@ -95,10 +103,10 @@ func CreateTeam(ctx context.Context, db *sql.DB, name string) (*Team, error) { err := db.QueryRowContext(ctx, ` INSERT INTO teams (name, plan_tier) VALUES ($1, 'free') RETURNING id, name, plan_tier, stripe_customer_id, created_at, - COALESCE(default_deployment_ttl_policy, 'auto_24h') + COALESCE(default_deployment_ttl_policy, 'auto_24h'), is_test_cohort `, name).Scan( &t.ID, &t.Name, &t.PlanTier, &t.RazorpaySubscriptionID, &t.CreatedAt, - &t.DefaultDeploymentTTLPolicy, + &t.DefaultDeploymentTTLPolicy, &t.IsTestCohort, ) if err != nil { return nil, fmt.Errorf("models.CreateTeam: %w", err) @@ -111,11 +119,11 @@ func GetTeamByID(ctx context.Context, db *sql.DB, id uuid.UUID) (*Team, error) { t := &Team{} err := db.QueryRowContext(ctx, ` SELECT id, name, plan_tier, stripe_customer_id, created_at, - COALESCE(default_deployment_ttl_policy, 'auto_24h') + COALESCE(default_deployment_ttl_policy, 'auto_24h'), is_test_cohort FROM teams WHERE id = $1 `, id).Scan( &t.ID, &t.Name, &t.PlanTier, &t.RazorpaySubscriptionID, &t.CreatedAt, - &t.DefaultDeploymentTTLPolicy, + &t.DefaultDeploymentTTLPolicy, &t.IsTestCohort, ) if err == sql.ErrNoRows { return nil, &ErrTeamNotFound{ID: id} @@ -339,6 +347,54 @@ func UpdatePlanTier(ctx context.Context, db *sql.DB, teamID uuid.UUID, tier stri return nil } +// IsTestCohort reports whether a team is part of the synthetic-monitoring test +// cohort (migration 067). It is the single lookup every api-side charge / +// conversion path keys off to no-op for a synthetic team so that continuous +// synthetic traffic never pollutes the real funnel / billing / email surfaces +// (W0 / PR-1 — see TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6). +// +// A missing team is treated as NOT a test cohort: callers reach this only with +// an already-authenticated team id, and a stricter ErrNoRows surface here would +// just turn a 404 into a 500 on the chargeable path. The flag is inert by +// default — every real team is false until a seeder sets it true via +// SetTestCohort — so the common case returns false with one indexed lookup. +func IsTestCohort(ctx context.Context, db *sql.DB, teamID uuid.UUID) (bool, error) { + var isTest bool + err := db.QueryRowContext(ctx, ` + SELECT is_test_cohort FROM teams WHERE id = $1 + `, teamID).Scan(&isTest) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("models.IsTestCohort: %w", err) + } + return isTest, nil +} + +// SetTestCohort flips teams.is_test_cohort for a single team. It is the only +// writer of the cohort flag and is intended for the worker-side synthetic +// seeder job (flow_synthetic_seed) — there is deliberately NO public HTTP +// surface that mutates it (a self-serve "mark my team as test" would let any +// caller opt out of billing/quota). Idempotent: setting the same value twice +// is a harmless no-op UPDATE. +func SetTestCohort(ctx context.Context, db *sql.DB, teamID uuid.UUID, isTest bool) error { + res, err := db.ExecContext(ctx, ` + UPDATE teams SET is_test_cohort = $1 WHERE id = $2 + `, isTest, teamID) + if err != nil { + return fmt.Errorf("models.SetTestCohort: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("models.SetTestCohort rows: %w", err) + } + if rows == 0 { + return &ErrTeamNotFound{ID: teamID} + } + return nil +} + // UpgradeTeamAllTiers atomically upgrades the team tier and promotes every // active resource, deployment, and stack owned by that team. All four updates // run inside a single transaction so a partial failure (e.g. ElevateDeployments @@ -481,11 +537,11 @@ func GetTeamByRazorpaySubscriptionID(ctx context.Context, db *sql.DB, subscripti t := &Team{} err := db.QueryRowContext(ctx, ` SELECT id, name, plan_tier, stripe_customer_id, created_at, - COALESCE(default_deployment_ttl_policy, 'auto_24h') + COALESCE(default_deployment_ttl_policy, 'auto_24h'), is_test_cohort FROM teams WHERE stripe_customer_id = $1 `, subscriptionID).Scan( &t.ID, &t.Name, &t.PlanTier, &t.RazorpaySubscriptionID, &t.CreatedAt, - &t.DefaultDeploymentTTLPolicy, + &t.DefaultDeploymentTTLPolicy, &t.IsTestCohort, ) if err == sql.ErrNoRows { return nil, &ErrTeamNotFound{} diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index d2d0275c..f3ecd783 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -325,6 +325,10 @@ func runMigrations(t *testing.T, db *sql.DB) { `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS git_token_enc TEXT NOT NULL DEFAULT ''`, // teams.default_deployment_ttl_policy — Wave FIX-J team preference. `ALTER TABLE teams ADD COLUMN IF NOT EXISTS default_deployment_ttl_policy TEXT NOT NULL DEFAULT 'auto_24h'`, + // teams.is_test_cohort — migration 067 (W0 synthetic-monitoring cohort + // isolation). Inert by default; seeder-set true on durable test teams so + // charge/funnel/email paths can exclude them. + `ALTER TABLE teams ADD COLUMN IF NOT EXISTS is_test_cohort BOOLEAN NOT NULL DEFAULT false`, // 012_audit_log — per-team event stream consumed by the dashboard's // Recent Activity feed AND by the admin customer-detail endpoint. `CREATE TABLE IF NOT EXISTS audit_log ( From 8f5cab4419c1e88c3ed824c2fa59b9e1e470a50d Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 5 Jun 2026 00:10:17 +0530 Subject: [PATCH 2/5] test(deploy): sync expectTeamLookupOK mock with is_test_cohort column (W0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W0 (#246) added is_test_cohort to the GetTeamByID SELECT; the redeploy mock helper's NewRows must include it or sqlmock scan mismatches → the 7 TestDeployNew_Redeploy_*/TestDeployRedeploy_* tests failed build-and-test. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/deploy_redeploy_inplace_mock_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/handlers/deploy_redeploy_inplace_mock_test.go b/internal/handlers/deploy_redeploy_inplace_mock_test.go index c7e1c20c..15dc6e1b 100644 --- a/internal/handlers/deploy_redeploy_inplace_mock_test.go +++ b/internal/handlers/deploy_redeploy_inplace_mock_test.go @@ -163,8 +163,8 @@ func expectTeamLookupOK(mock sqlmock.Sqlmock, teamID uuid.UUID, tier string) { WithArgs(teamID). WillReturnRows(sqlmock.NewRows([]string{ "id", "name", "plan_tier", "stripe_customer_id", "created_at", - "default_deployment_ttl_policy", - }).AddRow(teamID, "mock-team", tier, sql.NullString{}, time.Now(), "auto_24h")) + "default_deployment_ttl_policy", "is_test_cohort", + }).AddRow(teamID, "mock-team", tier, sql.NullString{}, time.Now(), "auto_24h", false)) } // TestDeployNew_Redeploy_LookupDriverError_Returns503 pins deploy.go:678-683. From ba02de620358f89462948686498ac40888341559 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 5 Jun 2026 00:14:06 +0530 Subject: [PATCH 3/5] fix(teams): keep is_test_cohort off the main team SELECTs (minimal blast radius) W0 added is_test_cohort to GetTeamByID/CreateTeam/GetTeamByRazorpaySubscriptionID + the Team struct, which broke ~all sqlmock tests mocking those queries (deploy_redeploy + admin/impersonate build-and-test failures). The only consumer is the dedicated models.IsTestCohort(teamID) point-lookup used by the billing guard, so revert the column from the 3 main SELECTs + the struct field and keep ONLY the dedicated IsTestCohort()/SetTestCohort() helpers. No mock resync needed; migration 067 + guards + tests unchanged. DB round-trip test now asserts via the helper, not a struct field. Co-Authored-By: Claude Opus 4.8 (1M context) --- dump.rdb | Bin 0 -> 2312 bytes .../deploy_redeploy_inplace_mock_test.go | 4 ++-- internal/models/is_test_cohort_db_test.go | 16 ++++++------- internal/models/team.go | 22 ++++++------------ 4 files changed, 16 insertions(+), 26 deletions(-) create mode 100644 dump.rdb diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..acf333fad38c2520264e722086d770e2c370627c GIT binary patch literal 2312 zcmb7`&5N5=6vk7qwz?=?D0C+c6a;(2{XXYrRlg~?Qd|_P!@cL8ThnHeA<2wXDRt%2 zjV=`Y09^=0ir}KS8w%3@z_mMS!Fx(Ep&_c`x*&hI=oufF)g+1Io- ze&?|n*U?;U@$4`TaG-Y{zxPEME#}*2b`H~Gw9Yp#+&X!2=ji5~t6Jyvn?GMY)!R9m zr%jbS=k4Cz-knE!Jxf!O$rpHSHr-tC+QvIW4B0!#XtklrI#bQSuEwA>Hs|@wOS{zu zqse@cCksWJ^Tl+XXX+BpH)hi(pKd>f!q{~Cp&S0IBeo4#0YFmrJ8Eep3UC41du(mI9vQYpmEg9k!weKFgbK=DE z#`C8>{^C%t*E{pd`gq9ec{Co*#zP<%Vu-Oa*{X|^heBTyqfZ440*Et}Bj%W0vB?#5 z(E5(`Di*BE_g1OOlPyrjfFzY+BK}lCewx5%HvU0 z7w6N-@<#vrPj-*``^N?|-kM(GI+Q9pCJqW}LgkYKg*tfUSy+LT!9&ac%wnCsJ z8#FakExIDd(5a+bQji+Nrh%j-5qR>C@d^Qxe8OmxYG9i3sG~~7Hx;9^pc$e@F9P;K z_ox5ey|E>SLOwt&4xCNadrTPt!DORZzt`X3NgYit^oNVtCZF!-$#k-|klrWr{_u^y zd{Vi8diTBb-@LLs|Jm{V<|orBghEk_mUB&s4Qq^^j5JvcRLU|oSt}{(!HI2AYwb~B zH(aT0Kf1j7!OQoQ_Y5JJC_gGjS#DxIBC6z)R@9vMC-i#gEO_hs6QAxs#a76YXI8F+ zs=R69VJ9H6Qar_&f);@v_Gi<0u+^|7$gFkr8FS-CCe<8UpxQXpAfB3LSO` zNGQA{mfpU6{r7up?&(eEgeg?5WJ1D63R$5yUO~$ycpuPkmPh;Mne!{mX;>%*0@W=| z2NziRls$ z+zh&4YT;7Qll8Ls@d5jLH~&7}D#*;JqCNgU;IAy7`t|yXz8uts0*Go>*4|f5VygHg zNhgmdcvsgsd-=J`D+D4)h>|!FII!|mok9vtfoK7GbU9QJ_$Lz2N+gapx;!+AC3-?g zauaB%-WDXL0>CXqYb6)uCD8{U`N0)ymEcXXS+-`%!rY;ye&EV~5+PU5Hgxx!TvjGS zP8tfDD5H|aV!zj*U%uK|!LHm`+DC7dBTC9Q5D0Z+RkOELS~U>5!GHbT(<|7;8dBCe zNv6t5dZ9+;0Hm6aP0;Un0ui`$q_<+RCH_-wm6ZumOEN%HsivTu^^(tIgpVWwx5j_2 oYJqLGa?z{UEuEJ?8Y-DYN`k6pmwclxp8vZ2+vjh8IGUdS8%25OVgLXD literal 0 HcmV?d00001 diff --git a/internal/handlers/deploy_redeploy_inplace_mock_test.go b/internal/handlers/deploy_redeploy_inplace_mock_test.go index 15dc6e1b..c7e1c20c 100644 --- a/internal/handlers/deploy_redeploy_inplace_mock_test.go +++ b/internal/handlers/deploy_redeploy_inplace_mock_test.go @@ -163,8 +163,8 @@ func expectTeamLookupOK(mock sqlmock.Sqlmock, teamID uuid.UUID, tier string) { WithArgs(teamID). WillReturnRows(sqlmock.NewRows([]string{ "id", "name", "plan_tier", "stripe_customer_id", "created_at", - "default_deployment_ttl_policy", "is_test_cohort", - }).AddRow(teamID, "mock-team", tier, sql.NullString{}, time.Now(), "auto_24h", false)) + "default_deployment_ttl_policy", + }).AddRow(teamID, "mock-team", tier, sql.NullString{}, time.Now(), "auto_24h")) } // TestDeployNew_Redeploy_LookupDriverError_Returns503 pins deploy.go:678-683. diff --git a/internal/models/is_test_cohort_db_test.go b/internal/models/is_test_cohort_db_test.go index 5527f8e0..4d2e0975 100644 --- a/internal/models/is_test_cohort_db_test.go +++ b/internal/models/is_test_cohort_db_test.go @@ -27,28 +27,26 @@ func TestIsTestCohort_MigrationSmokeAndRoundTrip(t *testing.T) { defer clean() // A freshly-created team defaults to is_test_cohort = false (inert by - // default — behaviour unchanged for every real team). + // default — behaviour unchanged for every real team). The dedicated + // IsTestCohort lookup is the single read path: the column is intentionally + // NOT scanned into the main Team struct, to keep the cohort flag off the + // widely-mocked GetTeamByID/CreateTeam/GetTeamByRazorpaySubscriptionID + // SELECTs (which would otherwise force a 16-file sqlmock resync). team, err := models.CreateTeam(ctx, db, "cohort-smoke") require.NoError(t, err) - require.False(t, team.IsTestCohort, "new team must default to is_test_cohort=false") - // IsTestCohort helper agrees on the default. + // IsTestCohort helper reports the default. isTest, err := models.IsTestCohort(ctx, db, team.ID) require.NoError(t, err) require.False(t, isTest) - // Flip it via the seeder setter and confirm both the helper and the - // GetTeamByID scan path observe the new value. + // Flip it via the seeder setter and confirm the helper observes the new value. require.NoError(t, models.SetTestCohort(ctx, db, team.ID, true)) isTest, err = models.IsTestCohort(ctx, db, team.ID) require.NoError(t, err) require.True(t, isTest) - reread, err := models.GetTeamByID(ctx, db, team.ID) - require.NoError(t, err) - require.True(t, reread.IsTestCohort, "GetTeamByID must scan is_test_cohort") - // SetTestCohort on a non-existent team returns ErrTeamNotFound. err = models.SetTestCohort(ctx, db, uuid.New(), true) var notFound *models.ErrTeamNotFound diff --git a/internal/models/team.go b/internal/models/team.go index 8c591206..349bc4c7 100644 --- a/internal/models/team.go +++ b/internal/models/team.go @@ -43,15 +43,7 @@ type Team struct { // Per-request ttl_policy in the deploy body always overrides this. // Only owner/admin can mutate via PATCH /api/v1/team/settings. DefaultDeploymentTTLPolicy string - // IsTestCohort marks a team as part of the synthetic-monitoring test - // cohort (migration 067, W0 / PR-1). Inert by default (every real team is - // false). Seeder-set true on the durable per-tier test teams so that - // charge-initiation / conversion-funnel / background-email paths can no-op - // or exclude them — keeping synthetic traffic out of the real - // funnel/billing/email surfaces. See - // docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. - IsTestCohort bool - CreatedAt time.Time + CreatedAt time.Time } // User represents an authenticated user belonging to a team. @@ -103,10 +95,10 @@ func CreateTeam(ctx context.Context, db *sql.DB, name string) (*Team, error) { err := db.QueryRowContext(ctx, ` INSERT INTO teams (name, plan_tier) VALUES ($1, 'free') RETURNING id, name, plan_tier, stripe_customer_id, created_at, - COALESCE(default_deployment_ttl_policy, 'auto_24h'), is_test_cohort + COALESCE(default_deployment_ttl_policy, 'auto_24h') `, name).Scan( &t.ID, &t.Name, &t.PlanTier, &t.RazorpaySubscriptionID, &t.CreatedAt, - &t.DefaultDeploymentTTLPolicy, &t.IsTestCohort, + &t.DefaultDeploymentTTLPolicy, ) if err != nil { return nil, fmt.Errorf("models.CreateTeam: %w", err) @@ -119,11 +111,11 @@ func GetTeamByID(ctx context.Context, db *sql.DB, id uuid.UUID) (*Team, error) { t := &Team{} err := db.QueryRowContext(ctx, ` SELECT id, name, plan_tier, stripe_customer_id, created_at, - COALESCE(default_deployment_ttl_policy, 'auto_24h'), is_test_cohort + COALESCE(default_deployment_ttl_policy, 'auto_24h') FROM teams WHERE id = $1 `, id).Scan( &t.ID, &t.Name, &t.PlanTier, &t.RazorpaySubscriptionID, &t.CreatedAt, - &t.DefaultDeploymentTTLPolicy, &t.IsTestCohort, + &t.DefaultDeploymentTTLPolicy, ) if err == sql.ErrNoRows { return nil, &ErrTeamNotFound{ID: id} @@ -537,11 +529,11 @@ func GetTeamByRazorpaySubscriptionID(ctx context.Context, db *sql.DB, subscripti t := &Team{} err := db.QueryRowContext(ctx, ` SELECT id, name, plan_tier, stripe_customer_id, created_at, - COALESCE(default_deployment_ttl_policy, 'auto_24h'), is_test_cohort + COALESCE(default_deployment_ttl_policy, 'auto_24h') FROM teams WHERE stripe_customer_id = $1 `, subscriptionID).Scan( &t.ID, &t.Name, &t.PlanTier, &t.RazorpaySubscriptionID, &t.CreatedAt, - &t.DefaultDeploymentTTLPolicy, &t.IsTestCohort, + &t.DefaultDeploymentTTLPolicy, ) if err == sql.ErrNoRows { return nil, &ErrTeamNotFound{} From 76c68fdb7c7c5bbc0b90b7a743dcb7be6c6c164b Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 5 Jun 2026 00:33:00 +0530 Subject: [PATCH 4/5] test(models): revert teamCols()/teamRow() is_test_cohort to match reverted SELECT Follow-on to the blast-radius revert: the shared teamCols()/teamRow() mock helpers still carried is_test_cohort after the SELECT reverted to 6 columns, breaking TestCreateTeam_Branches / TestGetTeamByID_Branches / TestGetTeamByRazorpaySubscriptionID_Branches. Back to 6 columns. Full models -short suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- dump.rdb | Bin 2312 -> 25009 bytes internal/models/coverage_team_test.go | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dump.rdb b/dump.rdb index acf333fad38c2520264e722086d770e2c370627c..3c0a4fbcf30e648b960ade3365ab6420c4de5b95 100644 GIT binary patch literal 25009 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 Date: Fri, 5 Jun 2026 00:55:52 +0530 Subject: [PATCH 5/5] fix(billing): guard rejectIfTestCohort against nil h.db (dedup-test panic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W0's rejectIfTestCohort runs models.IsTestCohort(ctx, h.db, teamID) at the top of CreateCheckoutAPI/ChangePlanAPI. billing_checkout_dedup_test.go wires NewBillingHandler(nil, ...) (db-independent — it only exercises the Redis SETNX dedup guard), so the new DB query panicked on a nil *sql.DB in CI build-and-test. Skip the cohort check when h.db == nil (prod always has a db). Targeted dedup + cohort + checkout tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/billing.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index 5e122867..11361b16 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -684,6 +684,12 @@ func (h *BillingHandler) requireVerifiedEmail(c *fiber.Ctx, action string) (bool // the already-written fiber response (or ErrResponseWritten) and the caller must // return it unchanged. func (h *BillingHandler) rejectIfTestCohort(c *fiber.Ctx, teamID uuid.UUID, action string) (ok bool, resp error) { + // No DB wired: only happens on db-independent handler paths exercised in + // tests (e.g. the Redis SETNX dedup guard, which short-circuits before any + // DB use). In prod h.db is always set. Nothing to check — proceed. + if h.db == nil { + return true, nil + } isTest, err := models.IsTestCohort(c.Context(), h.db, teamID) if err != nil { // Fail open: a real customer's charge must not be blocked by a DB blip.