Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added dump.rdb
Binary file not shown.
45 changes: 45 additions & 0 deletions internal/db/migrations/067_teams_is_test_cohort.sql
Original file line number Diff line number Diff line change
@@ -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;
61 changes: 61 additions & 0 deletions internal/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -659,6 +670,44 @@ 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) {
// 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.
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.
Expand Down Expand Up @@ -690,6 +739,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.
Expand Down Expand Up @@ -3173,6 +3229,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 {
Expand Down
179 changes: 179 additions & 0 deletions internal/handlers/billing_test_cohort_test.go
Original file line number Diff line number Diff line change
@@ -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
}
59 changes: 59 additions & 0 deletions internal/models/is_test_cohort_db_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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). 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)

// 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 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)

// SetTestCohort on a non-existent team returns ErrTeamNotFound.
err = models.SetTestCohort(ctx, db, uuid.New(), true)
var notFound *models.ErrTeamNotFound
require.ErrorAs(t, err, &notFound)

// 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)
}
Loading
Loading