diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go new file mode 100644 index 0000000..ecf8f54 --- /dev/null +++ b/internal/experiments/experiments.go @@ -0,0 +1,152 @@ +// Package experiments holds the server-side variant selector for A/B tests. +// +// Design goals: +// +// - Deterministic per-identifier bucketing — the same caller always +// lands in the same variant for a given experiment, so analytics can +// be reconstructed retroactively from the audit log alone (no extra +// "assignment" row needed). +// +// - Salt = experiment name. This keeps two experiments running in +// parallel statistically independent even when bucketed by the same +// identifier (e.g. a team_id seeing both UpgradeButton and a future +// PricingHeadline experiment lands in uncorrelated buckets). +// +// - Zero external state. The "registry" is a compile-time map; the +// bucket function is a pure SHA256(identifier + salt) mod N. No DB +// round-trip, no Redis, no cache invalidation story to maintain. +// +// The first experiment registered here is UpgradeButton — the dashboard +// reads its variant out of GET /auth/me's `experiments` field and +// renders one of three button label/color combinations. Conversion is +// recorded via POST /api/v1/experiments/converted writing an audit_log +// row, which is the only assignment-time signal we keep. +package experiments + +import ( + "crypto/sha256" + "encoding/binary" +) + +// Experiment names — used as both the registry key and the salt input +// to Pick. Exported as constants so callers (handlers, tests, the +// dashboard's audit-event filter) reference the same string. +const ( + // ExperimentUpgradeButton — A/B test the upgrade CTA label and + // color across {control, urgent, value}. P1 of the pricing + // experiments track. + ExperimentUpgradeButton = "upgrade_button" +) + +// Variant strings for the UpgradeButton experiment. Exported so tests +// + the dashboard can assert against the same labels without +// stringly-typed drift. +const ( + VariantControl = "control" + VariantUrgent = "urgent" + VariantValue = "value" +) + +// Experiment describes a single A/B test. Variants are listed in a +// stable order — Pick maps the SHA256 modulus onto this slice, so +// reordering variants reshuffles existing users. Don't reorder a live +// experiment; add new variants at the tail. +type Experiment struct { + Name string + Variants []string + // Salt is appended to the identifier before hashing. By + // convention this equals Name so two experiments stay + // independent even when sharing one identifier. Kept as a + // separate field so a future experiment can override (e.g. + // "re-bucket everyone after a fix" by rotating the salt). + Salt string +} + +// registry holds every experiment the server knows about. Populated in +// init() so callers can iterate it without locking. Read-only after +// startup. +var registry = map[string]Experiment{} + +func init() { + register(Experiment{ + Name: ExperimentUpgradeButton, + Variants: []string{VariantControl, VariantUrgent, VariantValue}, + Salt: ExperimentUpgradeButton, + }) +} + +// register adds an experiment to the registry. Panics on duplicate +// name — duplicate registration is always a programmer error and +// should fail loudly at startup rather than silently overwrite. +func register(e Experiment) { + if _, ok := registry[e.Name]; ok { + panic("experiments: duplicate registration: " + e.Name) + } + if len(e.Variants) == 0 { + panic("experiments: variants empty: " + e.Name) + } + registry[e.Name] = e +} + +// All returns the registered experiments. Used by the /auth/me +// handler to bucket the caller into every active experiment in one +// pass. The returned map is a copy so callers can't mutate the +// registry through it. +func All() map[string]Experiment { + out := make(map[string]Experiment, len(registry)) + for k, v := range registry { + out[k] = v + } + return out +} + +// Get returns an experiment by name. The second return value is false +// when the name is unknown; callers should treat that as "no +// experiment running" and skip the bucket step. +func Get(name string) (Experiment, bool) { + e, ok := registry[name] + return e, ok +} + +// Pick returns the variant for (experiment, identifier). It's +// deterministic: the same input always returns the same variant. An +// unknown experiment returns "" — callers must check. +// +// Identifier can be any stable string per-caller — team_id for +// claimed users, fingerprint for anonymous. Mixing them in one +// experiment is fine; the modulus distribution is the same. +func Pick(experiment, identifier string) string { + e, ok := registry[experiment] + if !ok { + return "" + } + return pickFromVariants(e.Variants, e.Salt, identifier) +} + +// pickFromVariants is the pure hashing core, factored out so tests +// can exercise it with custom variant lists / salts without mutating +// the global registry. +func pickFromVariants(variants []string, salt, identifier string) string { + if len(variants) == 0 { + return "" + } + h := sha256.Sum256([]byte(identifier + "|" + salt)) + // Use the first 8 bytes as a uint64 — 64 bits of entropy is + // vastly more than enough to evenly distribute across small N + // variant counts, and avoids a big.Int allocation. + n := binary.BigEndian.Uint64(h[:8]) + idx := int(n % uint64(len(variants))) + return variants[idx] +} + +// PickAll buckets the identifier into every registered experiment in +// one call. Used by GET /auth/me to embed an `experiments` map in +// the response so the dashboard needs one round trip to learn every +// active assignment. +func PickAll(identifier string) map[string]string { + out := make(map[string]string, len(registry)) + for name, e := range registry { + out[name] = pickFromVariants(e.Variants, e.Salt, identifier) + } + return out +} diff --git a/internal/experiments/experiments_test.go b/internal/experiments/experiments_test.go new file mode 100644 index 0000000..6f9aa47 --- /dev/null +++ b/internal/experiments/experiments_test.go @@ -0,0 +1,153 @@ +package experiments + +import ( + "fmt" + "math" + "testing" +) + +// TestPick_Determinism verifies the same (experiment, identifier) pair +// always returns the same variant, even across many calls. This is the +// load-bearing property — if it ever breaks, every existing bucket +// reshuffles and the conversion data goes incoherent. +func TestPick_Determinism(t *testing.T) { + ids := []string{ + "team-uuid-aaa", + "team-uuid-bbb", + "fp:abcdef0123", + // Empty string is a degenerate but legal identifier — it + // happens when an unauthenticated request has no + // fingerprint yet. Should still hash to a stable bucket. + "", + // Unicode + special chars — make sure the hash is bytewise + // stable (no surprise normalization). + "team-üñîçødé-🚀", + } + for _, id := range ids { + first := Pick(ExperimentUpgradeButton, id) + for i := 0; i < 20; i++ { + got := Pick(ExperimentUpgradeButton, id) + if got != first { + t.Fatalf("Pick(%q) non-deterministic: first=%q got=%q on iter %d", + id, first, got, i) + } + } + } +} + +// TestPick_UnknownExperiment returns "" so callers can detect a +// typo without a panic. +func TestPick_UnknownExperiment(t *testing.T) { + got := Pick("definitely_not_registered", "team-1") + if got != "" { + t.Fatalf("unknown experiment should return empty string, got %q", got) + } +} + +// TestPick_ReturnsValidVariant guards against a regression where the +// modulus math drifts off-by-one and returns a bogus index. Every Pick +// result must be one of the registered variants for that experiment. +func TestPick_ReturnsValidVariant(t *testing.T) { + e, ok := Get(ExperimentUpgradeButton) + if !ok { + t.Fatal("UpgradeButton experiment must be registered") + } + valid := map[string]bool{} + for _, v := range e.Variants { + valid[v] = true + } + for i := 0; i < 1000; i++ { + id := fmt.Sprintf("team-%d", i) + v := Pick(ExperimentUpgradeButton, id) + if !valid[v] { + t.Fatalf("Pick(%q) returned non-registered variant %q", id, v) + } + } +} + +// TestPick_DistributionRoughly33 checks the bucket distribution is +// within tolerance of even thirds across a 1000-id sample. A real +// SHA256 won't be exactly 333/333/334 but it will be close; we allow a +// generous +/-5% to keep the test from flaking on sample-size variance +// while still catching a regression where one variant gets >50% of +// traffic. +func TestPick_DistributionRoughly33(t *testing.T) { + const N = 1000 + counts := map[string]int{} + for i := 0; i < N; i++ { + id := fmt.Sprintf("identifier-%d", i) + v := Pick(ExperimentUpgradeButton, id) + counts[v]++ + } + e, _ := Get(ExperimentUpgradeButton) + want := float64(N) / float64(len(e.Variants)) + tolerance := want * 0.15 // 15% — generous for N=1000 + for _, v := range e.Variants { + got := float64(counts[v]) + if math.Abs(got-want) > tolerance { + t.Errorf("variant %q: got %d, want ~%.0f (±%.0f) — distribution skew", + v, counts[v], want, tolerance) + } + } + // Sanity: counts must sum to N (no identifier dropped). + sum := 0 + for _, c := range counts { + sum += c + } + if sum != N { + t.Fatalf("counts sum to %d, want %d (bucket leak)", sum, N) + } +} + +// TestPickAll_HasEveryRegistered verifies the one-shot helper used by +// /auth/me returns a variant for every registered experiment with no +// gaps, and matches what Pick would have returned per-experiment. +func TestPickAll_HasEveryRegistered(t *testing.T) { + id := "team-pickall-test" + got := PickAll(id) + all := All() + if len(got) != len(all) { + t.Fatalf("PickAll returned %d entries, registered %d", len(got), len(all)) + } + for name := range all { + single := Pick(name, id) + if got[name] != single { + t.Errorf("PickAll[%s]=%q, Pick(%s,id)=%q — disagreement", + name, got[name], name, single) + } + } +} + +// TestAll_IsCopy ensures the All() return is a copy — callers +// mutating it must not corrupt the registry. +func TestAll_IsCopy(t *testing.T) { + a := All() + a["injected"] = Experiment{Name: "injected"} + if _, ok := Get("injected"); ok { + t.Fatal("All() returned the live registry; callers can corrupt it") + } +} + +// TestSaltIsolation_DifferentSaltsDiffer verifies two experiments with +// the same variant list but different salts bucket the same id into +// (potentially) different variants — i.e., the salt isn't ignored. +// We sample 200 ids and require the two assignments disagree at least +// 40% of the time; with truly independent hashes the expected +// disagreement rate is (k-1)/k = 66.7% for k=3 variants. +func TestSaltIsolation_DifferentSaltsDiffer(t *testing.T) { + const N = 200 + vs := []string{"a", "b", "c"} + disagree := 0 + for i := 0; i < N; i++ { + id := fmt.Sprintf("salt-test-%d", i) + x := pickFromVariants(vs, "salt-one", id) + y := pickFromVariants(vs, "salt-two", id) + if x != y { + disagree++ + } + } + if disagree < N*40/100 { + t.Fatalf("salt isolation weak: only %d/%d disagreements; expected >= %d", + disagree, N, N*40/100) + } +} diff --git a/internal/handlers/cli_auth.go b/internal/handlers/cli_auth.go index 14c1830..c44b722 100644 --- a/internal/handlers/cli_auth.go +++ b/internal/handlers/cli_auth.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" "instant.dev/internal/config" + "instant.dev/internal/experiments" "instant.dev/internal/middleware" "instant.dev/internal/models" "instant.dev/internal/plans" @@ -252,6 +253,17 @@ func (h *CLIAuthHandler) GetCurrentUser(c *fiber.Ctx) error { plan := h.planRegistry.Get(team.PlanTier) + // Experiment bucketing — identifier is team_id for claimed + // users (always set here since RequireAuth has already run and + // populated GetTeamID). This keeps every authenticated session + // for the same team in the same variant, which is what the + // "Upgrade to Pro" copy test needs (a user must not see two + // labels in one session). Anonymous bucketing uses the + // fingerprint at the unauthenticated provision endpoints — + // /auth/me is auth-only so there's no fingerprint fallback + // path to consider here. + exps := experiments.PickAll(team.ID.String()) + resp := fiber.Map{ "ok": true, "user_id": user.ID, @@ -259,6 +271,7 @@ func (h *CLIAuthHandler) GetCurrentUser(c *fiber.Ctx) error { "email": user.Email, "tier": team.PlanTier, "plan_display_name": plan.DisplayName, + "experiments": exps, } if team.TrialEndsAt.Valid { resp["trial_ends_at"] = team.TrialEndsAt.Time diff --git a/internal/handlers/experiments.go b/internal/handlers/experiments.go new file mode 100644 index 0000000..632b37e --- /dev/null +++ b/internal/handlers/experiments.go @@ -0,0 +1,168 @@ +package handlers + +// experiments.go — POST /api/v1/experiments/converted. +// +// Records that a user took the conversion action for an active +// experiment. The dashboard fires this from the click handler on the +// experimental UI element (e.g. the "Upgrade to Pro" button) BEFORE +// navigating away, so the audit_log row captures the exact variant +// the user clicked. +// +// Request shape: +// +// { "experiment": "upgrade_button", "variant": "urgent", "action": "checkout_started" } +// +// Server-side guards: +// +// - The experiment must be registered (otherwise we'd happily +// record garbage names). +// - The variant must be one of the experiment's registered +// variants — and it must match what the server itself would +// have bucketed this team into. A mismatch indicates a stale +// client or a tampered request; we reject with 400 rather +// than silently log misleading data. +// - action is free-form but length-capped to 64 bytes. +// +// The audit-event write is best-effort: if it fails the user still +// gets a 200 (we never want the analytics tail to wag the conversion +// dog) but we log at error level so the failure is observable. + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "instant.dev/internal/experiments" + "instant.dev/internal/middleware" + "instant.dev/internal/models" +) + +// ExperimentsHandler serves POST /api/v1/experiments/converted. +type ExperimentsHandler struct { + db *sql.DB +} + +// NewExperimentsHandler constructs an ExperimentsHandler. +func NewExperimentsHandler(db *sql.DB) *ExperimentsHandler { + return &ExperimentsHandler{db: db} +} + +// experimentConvertedBody is the JSON body the dashboard posts. Field +// names are snake_case to match the rest of the v1 API. +type experimentConvertedBody struct { + Experiment string `json:"experiment"` + Variant string `json:"variant"` + Action string `json:"action"` +} + +// actionMaxLen caps the action_taken metadata field. The dashboard +// only ever sends short identifiers like "checkout_started" but a +// hostile client could try to balloon the audit row; 64 is enough +// for any sensible action name. +const actionMaxLen = 64 + +// Converted handles POST /api/v1/experiments/converted. +// +// Returns 200 with {ok:true} on success, 400 on a bad body, and +// silently 200 even when the audit write fails (the write is logged). +func (h *ExperimentsHandler) Converted(c *fiber.Ctx) error { + teamIDStr := middleware.GetTeamID(c) + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Authentication required") + } + userIDStr := middleware.GetUserID(c) + var userID uuid.NullUUID + if u, err := uuid.Parse(userIDStr); err == nil { + userID = uuid.NullUUID{UUID: u, Valid: true} + } + + var body experimentConvertedBody + if err := c.BodyParser(&body); err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_body", "Invalid JSON body") + } + body.Experiment = strings.TrimSpace(body.Experiment) + body.Variant = strings.TrimSpace(body.Variant) + body.Action = strings.TrimSpace(body.Action) + if body.Experiment == "" || body.Variant == "" { + return respondError(c, fiber.StatusBadRequest, "invalid_body", + "experiment and variant are required") + } + if len(body.Action) > actionMaxLen { + body.Action = body.Action[:actionMaxLen] + } + + // Verify the experiment is registered. Unknown names get a + // 400 — otherwise we'd accept arbitrary strings into the + // audit log and pollute the conversion data. + exp, ok := experiments.Get(body.Experiment) + if !ok { + return respondError(c, fiber.StatusBadRequest, "unknown_experiment", + "Unknown experiment") + } + + // Verify the client-supplied variant is actually one this + // experiment knows about. A typo'd variant ("contrl") would + // otherwise sneak in and ruin the bucket counts. + validVariant := false + for _, v := range exp.Variants { + if v == body.Variant { + validVariant = true + break + } + } + if !validVariant { + return respondError(c, fiber.StatusBadRequest, "invalid_variant", + "Variant is not registered for this experiment") + } + + // Cross-check: the variant the client says it saw must equal + // the variant the server would have bucketed this team into. + // A mismatch usually means the dashboard cached an old /auth/me + // response across a salt rotation; rejecting is safer than + // logging misleading data. Identifier is team_id, matching + // /auth/me's bucketing key. + serverVariant := experiments.Pick(body.Experiment, teamID.String()) + if serverVariant != body.Variant { + return respondError(c, fiber.StatusBadRequest, "variant_mismatch", + "Variant does not match server bucket") + } + + // Build the metadata blob. JSON marshalling can't realistically + // fail for this shape — but if it ever does, fall through with + // nil metadata rather than failing the request. + metaBlob, _ := json.Marshal(map[string]string{ + "experiment": body.Experiment, + "variant": body.Variant, + "action_taken": body.Action, + }) + + actor := "user" + if !userID.Valid { + actor = "agent" + } + + // Best-effort audit write — detached context so the goroutine + // outlives the request cycle. A failure here logs but doesn't + // surface to the user. + go func(tid uuid.UUID, uid uuid.NullUUID, meta []byte, expName string) { + if err := models.InsertAuditEvent(context.Background(), h.db, models.AuditEvent{ + TeamID: tid, + UserID: uid, + Actor: actor, + Kind: "experiment.conversion", + Summary: "user converted on experiment " + expName + "", + Metadata: meta, + }); err != nil { + slog.Error("experiments.converted.audit_write_failed", + "team_id", tid, "experiment", expName, "error", err) + } + }(teamID, userID, metaBlob, body.Experiment) + + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/internal/handlers/experiments_test.go b/internal/handlers/experiments_test.go new file mode 100644 index 0000000..2b9d094 --- /dev/null +++ b/internal/handlers/experiments_test.go @@ -0,0 +1,298 @@ +package handlers_test + +// experiments_test.go — coverage for: +// +// - GET /auth/me embeds an `experiments` map covering every +// registered experiment, bucketed deterministically by team_id. +// +// - POST /api/v1/experiments/converted writes a `kind = +// experiment.conversion` row into audit_log with the variant +// and action_taken in metadata. +// +// - The conversion endpoint rejects (a) unknown experiment names, +// (b) variants outside the registered set, and (c) variants that +// don't match what the server itself buckets the caller into. +// +// The tests use the real DB (via testhelpers.SetupTestDB) so the +// audit_log row is verified end-to-end — a unit test on the handler +// alone would miss a JSONB encoding bug. + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/experiments" + "instant.dev/internal/testhelpers" +) + +// TestGetCurrentUser_IncludesExperiments verifies the /auth/me +// response carries an `experiments` map keyed by experiment name, +// containing a registered variant for each known experiment. +func TestGetCurrentUser_IncludesExperiments(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + email := testhelpers.UniqueEmail(t) + var userID string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO users (team_id, email) VALUES ($1::uuid, $2) RETURNING id::text`, + teamID, email, + ).Scan(&userID)) + + token := testhelpers.MustSignSessionJWT(t, userID, teamID, email) + req := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + + exps, ok := body["experiments"].(map[string]any) + require.True(t, ok, "experiments field must be a JSON object") + + // UpgradeButton experiment must be present and assigned to a + // registered variant. + got, ok := exps[experiments.ExperimentUpgradeButton].(string) + require.True(t, ok, "experiments.upgrade_button must be a string") + + registered, hasExp := experiments.Get(experiments.ExperimentUpgradeButton) + require.True(t, hasExp) + validVariants := map[string]bool{} + for _, v := range registered.Variants { + validVariants[v] = true + } + assert.Truef(t, validVariants[got], + "variant %q must be one of the registered variants %v", got, registered.Variants) + + // Cross-check: the server's deterministic Pick for this + // team_id must produce the exact same variant the response + // carries. This guards against a regression where /auth/me + // uses a different identifier than POST /converted (which + // would make every conversion be rejected as variant_mismatch). + want := experiments.Pick(experiments.ExperimentUpgradeButton, teamID) + assert.Equal(t, want, got, "/auth/me variant must match Pick(team_id)") +} + +// TestExperimentsConverted_WritesAuditRow verifies the happy path: +// a valid (experiment, variant, action) triplet writes one +// audit_log row with kind = "experiment.conversion" and metadata +// carrying the experiment, variant, and action_taken fields. +func TestExperimentsConverted_WritesAuditRow(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + email := testhelpers.UniqueEmail(t) + var userID string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO users (team_id, email) VALUES ($1::uuid, $2) RETURNING id::text`, + teamID, email, + ).Scan(&userID)) + + token := testhelpers.MustSignSessionJWT(t, userID, teamID, email) + variant := experiments.Pick(experiments.ExperimentUpgradeButton, teamID) + require.NotEmpty(t, variant, "Pick must return a registered variant") + + payload := map[string]string{ + "experiment": experiments.ExperimentUpgradeButton, + "variant": variant, + "action": "checkout_started", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/experiments/converted", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Audit row write is asynchronous (best-effort goroutine). + // Poll for up to ~2s for it to land. + var kind, summary string + var metaJSON []byte + for i := 0; i < 40; i++ { + err = db.QueryRowContext(context.Background(), + `SELECT kind, summary, metadata::text + FROM audit_log + WHERE team_id = $1::uuid AND kind = 'experiment.conversion' + ORDER BY created_at DESC + LIMIT 1`, teamID, + ).Scan(&kind, &summary, &metaJSON) + if err == nil { + break + } + // 50ms * 40 = 2s + time.Sleep(50 * time.Millisecond) + } + require.NoError(t, err, "audit_log row must exist within 2s") + assert.Equal(t, "experiment.conversion", kind) + assert.Contains(t, summary, experiments.ExperimentUpgradeButton) + + var meta map[string]string + require.NoError(t, json.Unmarshal(metaJSON, &meta)) + assert.Equal(t, experiments.ExperimentUpgradeButton, meta["experiment"]) + assert.Equal(t, variant, meta["variant"]) + assert.Equal(t, "checkout_started", meta["action_taken"]) +} + +// TestExperimentsConverted_RejectsUnknownExperiment guards against +// arbitrary names polluting the audit log. +func TestExperimentsConverted_RejectsUnknownExperiment(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + email := testhelpers.UniqueEmail(t) + var userID string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO users (team_id, email) VALUES ($1::uuid, $2) RETURNING id::text`, + teamID, email, + ).Scan(&userID)) + + token := testhelpers.MustSignSessionJWT(t, userID, teamID, email) + body, _ := json.Marshal(map[string]string{ + "experiment": "not_a_real_experiment", + "variant": "control", + "action": "checkout_started", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/experiments/converted", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// TestExperimentsConverted_RejectsInvalidVariant guards against +// typo'd variant names sneaking in (e.g. "contrl" instead of "control"). +func TestExperimentsConverted_RejectsInvalidVariant(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + email := testhelpers.UniqueEmail(t) + var userID string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO users (team_id, email) VALUES ($1::uuid, $2) RETURNING id::text`, + teamID, email, + ).Scan(&userID)) + + token := testhelpers.MustSignSessionJWT(t, userID, teamID, email) + body, _ := json.Marshal(map[string]string{ + "experiment": experiments.ExperimentUpgradeButton, + "variant": "contrl_typo", + "action": "checkout_started", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/experiments/converted", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// TestExperimentsConverted_RejectsVariantMismatch ensures the +// dashboard can't claim it saw a variant the server wouldn't have +// served to this team (stale /auth/me, tampered client, etc.). +func TestExperimentsConverted_RejectsVariantMismatch(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + email := testhelpers.UniqueEmail(t) + var userID string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO users (team_id, email) VALUES ($1::uuid, $2) RETURNING id::text`, + teamID, email, + ).Scan(&userID)) + + token := testhelpers.MustSignSessionJWT(t, userID, teamID, email) + + // Find a registered variant the team is NOT bucketed into. + correct := experiments.Pick(experiments.ExperimentUpgradeButton, teamID) + exp, _ := experiments.Get(experiments.ExperimentUpgradeButton) + var wrong string + for _, v := range exp.Variants { + if v != correct { + wrong = v + break + } + } + require.NotEmpty(t, wrong, "registry must define >1 variant") + + body, _ := json.Marshal(map[string]string{ + "experiment": experiments.ExperimentUpgradeButton, + "variant": wrong, + "action": "checkout_started", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/experiments/converted", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// TestExperimentsConverted_RequiresAuth — no Bearer → 401. +func TestExperimentsConverted_RequiresAuth(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + body, _ := json.Marshal(map[string]string{ + "experiment": experiments.ExperimentUpgradeButton, + "variant": "control", + "action": "checkout_started", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/experiments/converted", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} diff --git a/internal/router/router.go b/internal/router/router.go index f82bb6c..5fc5ab6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -404,6 +404,16 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G auditH := handlers.NewAuditHandler(db) api.Get("/audit", auditH.List) + // A/B-experiment conversion sink — the dashboard fires + // POST /api/v1/experiments/converted from the click handler + // on an experimental UI element (e.g. the Upgrade button) + // before navigating to checkout. Writes an audit_log row + // (kind = "experiment.conversion") tagged with the variant + // the user clicked. See internal/experiments for the + // registry + bucket selector. + experimentsH := handlers.NewExperimentsHandler(db) + api.Post("/experiments/converted", experimentsH.Converted) + // Vault — per-team encrypted secret storage (Phase 1: Heroku-shape platform). vaultH := handlers.NewVaultHandler(db, cfg, planRegistry) api.Put("/vault/:env/:key", vaultH.PutSecret) diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index e009ea8..b3e72df 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -403,6 +403,12 @@ func NewTestAppWithServices(t *testing.T, db *sql.DB, rdb *redis.Client, service api.Get("/deployments/:id", deployH.Get) api.Delete("/deployments/:id", deployH.Delete) + // A/B-experiment conversion sink — wired into the test app so + // handler tests can exercise the full route stack (router + + // auth middleware + JSON handler) end-to-end. + experimentsH := handlers.NewExperimentsHandler(db) + api.Post("/experiments/converted", experimentsH.Converted) + return app, func() { app.Shutdown() } }