diff --git a/internal/handlers/team_block_helpers_test.go b/internal/handlers/team_block_helpers_test.go new file mode 100644 index 0000000..f9f2e07 --- /dev/null +++ b/internal/handlers/team_block_helpers_test.go @@ -0,0 +1,240 @@ +package handlers_test + +// team_block_helpers_test.go — shared helpers for the W3 team-block +// integration suite (team_block_routes_test.go). These cover the team & +// member-management user-flow block (USER-FLOW-INVENTORY-AND-TEST-MATRIX.md +// §F: F1–F11) that, prior to this suite, lived in the route-iterating +// done-bar guard's routeCoverageExemptions with no mapped test. +// +// Mirrors the established W3 billing-block convention +// (billing_block_helpers_test.go): a thin SetupTestDB wrapper + a loud +// skip-when-no-DB guard + DB-backed seed helpers. Helpers here are prefixed +// teamBlock* so they do NOT collide with — and do NOT redefine — the existing +// seedVerifiedTeamUser / seedTeam / seedMember / miniRedis / doJSON / +// decodeBody helpers, which this suite reuses verbatim. +// +// What this suite adds over the existing per-handler tests +// (team_members_test.go, team_self_test.go, env_policy_test.go, …): a single +// app builder (teamBlockApp) that wires EVERY team/member route through the +// PRODUCTION RBAC middleware chain — middleware.RequireRole + PopulateTeamRole +// + RequireWritable, with middleware.SetRoleLookupDB pointed at the real test +// DB — exactly as internal/router/router.go does. The existing per-handler +// tests deliberately omit RequireRole (they probe the in-handler requireOwner +// check in isolation); this suite is the route-layer authz contract the +// done-bar guard's routeTestMap rows point at. + +import ( + "context" + "database/sql" + "errors" + "net/http" + "os" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "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/plans" + "instant.dev/internal/testhelpers" +) + +// teamBlockSkipNoDB skips a W3 team-block test when no test Postgres is +// configured. The team block is a real-backend integration surface — these +// tests assert on actual rows in teams/users/team_invitations/audit_log, so a +// missing DB is a loud skip, never a false green. Mirrors +// billingBlockSkipNoDB. +func teamBlockSkipNoDB(t *testing.T) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("W3 team-block integration: TEST_DATABASE_URL not set") + } +} + +// teamBlockDB opens a fresh migrated test DB and returns it with its cleanup. +// Thin wrapper over testhelpers.SetupTestDB so every W3 team-block test reads +// the same way. +func teamBlockDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + return testhelpers.SetupTestDB(t) +} + +// teamBlockApp builds a Fiber app that registers every team & member +// management route through the SAME middleware chain production uses +// (internal/router/router.go): a synthetic auth shim sets LocalKeyUserID + +// LocalKeyTeamID (standing in for RequireAuth's JWT validation), +// PopulateTeamRole resolves the caller's REAL role from the DB, and the +// owner/admin-gated routes carry RequireRole / RequireWritable exactly as +// the live router does. +// +// actorUserID / actorTeamID are the seeded identity the synthetic auth shim +// injects. Pass "" for actorUserID to simulate an unauthenticated caller +// (the shim then sets no locals and the RequireRole / handler 401 paths +// fire). +// +// rdb must be a working client (use the existing miniRedis(t) helper) — the +// invite path reads it for rate-limiting and idempotency. +func teamBlockApp(t *testing.T, db *sql.DB, rdb *redis.Client, actorUserID, actorTeamID string) *fiber.App { + t.Helper() + + cfg := &config.Config{ + JWTSecret: testhelpers.TestJWTSecret, + DashboardBaseURL: "http://localhost:5173", + } + mail := 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", "message": err.Error()}) + }, + }) + + app.Use(middleware.RequestID()) + // Synthetic auth shim — production's RequireAuth sets these two locals + // after validating the session JWT. We set them directly from the seeded + // identity so the suite exercises the role/ownership chain without minting + // real JWTs (the auth surface itself is covered by auth_flow_e2e_test.go). + app.Use(func(c *fiber.Ctx) error { + if actorUserID != "" { + c.Locals(middleware.LocalKeyUserID, actorUserID) + } + if actorTeamID != "" { + c.Locals(middleware.LocalKeyTeamID, actorTeamID) + } + return c.Next() + }) + + // PopulateTeamRole resolves auth_team_role from the real DB so RequireRole + // gates on the seeded user's actual role — production wiring. + middleware.SetRoleLookupDB(db) + planReg := plans.Default() + + teamSelfH := handlers.NewTeamSelfHandler(db, planReg) + teamSettingsH := handlers.NewTeamSettingsHandler(db) + teamSummaryH := handlers.NewTeamSummaryHandler(db, rdb, planReg) + envPolicyH := handlers.NewEnvPolicyHandler(db) + teamMembersH := handlers.NewTeamMembersHandler(db, cfg, planReg, mail, rdb) + teamsH := handlers.NewTeamsHandler(db, cfg, mail) + teamDelH := handlers.NewTeamDeletionHandler(db, cfg) + + api := app.Group("/api/v1", middleware.PopulateTeamRole()) + + // Team self — GET open to any member; PATCH owner-only + writable. + api.Get("/team", teamSelfH.Get) + api.Patch("/team", middleware.RequireRole(middleware.RoleOwner), middleware.RequireWritable(), teamSelfH.Update) + + // Team deletion / restore — owner-only (RequireRole at the route layer). + api.Delete("/team", middleware.RequireRole(middleware.RoleOwner), teamDelH.Delete) + api.Post("/team/restore", middleware.RequireRole(middleware.RoleOwner), teamDelH.Restore) + + // Team summary — any member. + api.Get("/team/summary", teamSummaryH.GetSummary) + + // Team settings — GET any member; PATCH writable + admin. + api.Get("/team/settings", teamSettingsH.Get) + api.Patch("/team/settings", + middleware.RequireWritable(), + middleware.RequireRole(middleware.RoleAdmin), + teamSettingsH.Update, + ) + + // Env-policy — GET any member; PUT owner enforced inside the handler + // (canonical env_policy 403 shape), so NO RequireRole here. Mirrors + // router.go exactly. + api.Get("/team/env-policy", envPolicyH.Get) + api.Put("/team/env-policy", envPolicyH.Put) + + // Members + invitations. RBAC is enforced INSIDE these handlers + // (requireOwner / owner-or-admin), matching router.go which installs no + // RequireRole on the members subtree. + api.Get("/team/members", teamMembersH.ListMembers) + api.Post("/team/members/invite", teamMembersH.InviteMember) + api.Post("/team/members/leave", teamMembersH.LeaveTeam) + api.Delete("/team/members/:user_id", teamMembersH.RemoveMember) + api.Patch("/team/members/:user_id", teamMembersH.UpdateRole) + api.Post("/team/members/:user_id/promote-to-primary", teamMembersH.PromoteToPrimary) + api.Get("/team/invitations", teamMembersH.ListInvitations) + api.Delete("/team/invitations/:id", teamMembersH.RevokeInvitation) + api.Post("/team/invitations/:id/accept", teamMembersH.AcceptInvitation) + + // Plural-teams invitation alias — admin-only (RequireRole), team-match + // enforced inside the handler. + api.Delete("/teams/:team_id/invitations/:id", middleware.RequireRole(middleware.RoleAdmin), teamsH.RevokeInvitation) + + return app +} + +// teamBlockSeedTeamOwner inserts a team at the given tier plus its primary +// owner, returning (teamID, ownerID). Registers cleanup. Built on the package +// testhelpers seeders — does NOT redefine seedVerifiedTeamUser/seedMember. +func teamBlockSeedTeamOwner(t *testing.T, db *sql.DB, tier string) (uuid.UUID, uuid.UUID) { + t.Helper() + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, tier)) + owner, err := models.CreateUser(context.Background(), db, teamID, + testhelpers.UniqueEmail(t), "", "", "owner") + require.NoError(t, err) + t.Cleanup(func() { + db.Exec(`DELETE FROM users WHERE team_id = $1`, teamID) + db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + }) + return teamID, owner.ID +} + +// teamBlockAddMember adds a non-owner user with the given role to teamID and +// returns its id. +func teamBlockAddMember(t *testing.T, db *sql.DB, teamID uuid.UUID, role string) uuid.UUID { + t.Helper() + u, err := models.CreateUser(context.Background(), db, teamID, + testhelpers.UniqueEmail(t), "", "", role) + require.NoError(t, err) + return u.ID +} + +// teamBlockReq issues a request to the test app and returns (status, decoded +// JSON body). Body may be nil. Thin wrapper so every team-block test reads the +// same; built on net/http + app.Test, not redefining doJSON (which takes a +// headers map — this variant needs none). +func teamBlockReq(t *testing.T, app *fiber.App, method, path string, body any) (int, map[string]any) { + t.Helper() + resp := doJSON(t, app, method, path, body, nil) + return resp.StatusCode, decodeBody(t, resp) +} + +// teamBlockTeamName reads back a team's name column so a test can assert a +// PATCH persisted. +func teamBlockTeamName(t *testing.T, db *sql.DB, teamID uuid.UUID) string { + t.Helper() + var name sql.NullString + require.NoError(t, + db.QueryRow(`SELECT name FROM teams WHERE id = $1`, teamID).Scan(&name), + "read team name") + return name.String +} + +// teamBlockUserRole reads back a user's role column. +func teamBlockUserRole(t *testing.T, db *sql.DB, teamID, userID uuid.UUID) string { + t.Helper() + role, err := models.GetUserRole(context.Background(), db, teamID, userID) + require.NoError(t, err, "read user role") + return role +} + +// teamBlockNotFoundOK is satisfied for the cross-team-isolation assertions: +// acting on another team's resource must NEVER succeed. We accept the +// documented refusal codes (403 forbidden / 404 not_found) and reject any 2xx. +func teamBlockNotFoundOK(status int) bool { + return status == http.StatusForbidden || status == http.StatusNotFound +} diff --git a/internal/handlers/team_block_routes_test.go b/internal/handlers/team_block_routes_test.go new file mode 100644 index 0000000..160dea8 --- /dev/null +++ b/internal/handlers/team_block_routes_test.go @@ -0,0 +1,609 @@ +package handlers_test + +// team_block_routes_test.go — W3 team-block integration suite. +// +// Covers the team & member management user-flow block from +// USER-FLOW-INVENTORY-AND-TEST-MATRIX.md §F (F1–F11). Each route below was, in +// internal/router/route_donebar_guard_test.go, listed in +// routeCoverageExemptions with a "TODO: matrix W3 …" pointer and NO mapped +// test. This suite supplies the DB-backed integration coverage the done-bar +// guard's routeTestMap now points at, so the routes move exempt → mapped. +// +// Every test runs against a real migrated Postgres (testhelpers.SetupTestDB) +// through the production RBAC middleware chain (teamBlockApp). For each route +// the suite asserts, where applicable: +// - happy path (correct 2xx + persisted state / response contract), +// - authz (owner / member / non-owner → correct 200 / 403), +// - cross-team isolation (acting on another team's id is refused), +// - the response/contract shape (ok flag, key fields, env defaults). +// +// Skips loudly when TEST_DATABASE_URL is unset (teamBlockSkipNoDB). + +import ( + "context" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/models" +) + +// ───────────────────────────────────────────────────────────────────────── +// GET /api/v1/team — TeamSelfHandler.Get (F1) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_GetTeam(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("owner happy path returns team contract", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodGet, "/api/v1/team", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + team, ok := body["team"].(map[string]any) + require.True(t, ok, "team object present") + assert.Equal(t, teamID.String(), team["id"]) + assert.Equal(t, "pro", team["plan_tier"]) + }) + + t.Run("viewer member can read team", func(t *testing.T) { + viewerID := teamBlockAddMember(t, db, teamID, "viewer") + app := teamBlockApp(t, db, miniRedis(t), viewerID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodGet, "/api/v1/team", nil) + require.Equal(t, http.StatusOK, status) + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), "", "") + status, _ := teamBlockReq(t, app, http.MethodGet, "/api/v1/team", nil) + require.Equal(t, http.StatusUnauthorized, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// PATCH /api/v1/team — TeamSelfHandler.Update (F1, owner-only) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_PatchTeam(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("owner renames team and it persists", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team", + map[string]any{"name": "Renamed Team"}) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + assert.Equal(t, "Renamed Team", teamBlockTeamName(t, db, teamID)) + }) + + t.Run("developer member forbidden (RequireRole owner)", func(t *testing.T) { + devID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), devID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team", + map[string]any{"name": "Hacked"}) + require.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "forbidden", body["error"]) + }) + + t.Run("empty name rejected 400", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team", + map[string]any{"name": " "}) + require.Equal(t, http.StatusBadRequest, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// DELETE /api/v1/team + POST /api/v1/team/restore — TeamDeletionHandler (F10) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_DeleteAndRestoreTeam(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + + t.Run("owner deletes (two-step slug confirm) then restores", func(t *testing.T) { + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + team, err := models.GetTeamByID(context.Background(), db, teamID) + require.NoError(t, err) + slug := models.TeamSlug(team) + + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + + // Delete — 202 Accepted, grace window opens. + status, body := teamBlockReq(t, app, http.MethodDelete, "/api/v1/team", + map[string]any{"confirm_team_slug": slug}) + require.Equal(t, http.StatusAccepted, status) + assert.Equal(t, true, body["ok"]) + assert.NotEmpty(t, body["deletion_at"]) + + // Restore — 200, team back to active. + rstatus, rbody := teamBlockReq(t, app, http.MethodPost, "/api/v1/team/restore", nil) + require.Equal(t, http.StatusOK, rstatus) + assert.Equal(t, true, rbody["ok"]) + }) + + t.Run("slug mismatch refuses with 409", func(t *testing.T) { + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodDelete, "/api/v1/team", + map[string]any{"confirm_team_slug": "definitely-not-the-slug"}) + require.Equal(t, http.StatusConflict, status) + assert.Equal(t, "slug_mismatch", body["error"]) + }) + + t.Run("admin member forbidden from deleting (RequireRole owner)", func(t *testing.T) { + teamID, _ := teamBlockSeedTeamOwner(t, db, "pro") + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodDelete, "/api/v1/team", + map[string]any{"confirm_team_slug": "anything"}) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("restore when not pending returns 409", func(t *testing.T) { + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPost, "/api/v1/team/restore", nil) + require.Equal(t, http.StatusConflict, status) + assert.Equal(t, "not_pending", body["error"]) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /api/v1/team/summary — TeamSummaryHandler.GetSummary (E13/F-aggregation) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_GetTeamSummary(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "growth") + + t.Run("member gets summary with tier + counts + cache header", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + resp := doJSON(t, app, http.MethodGet, "/api/v1/team/summary", nil, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Cache-Control"), "private", "summary aggregation is privately cached") + body := decodeBody(t, resp) + assert.Equal(t, true, body["ok"]) + assert.Equal(t, "growth", body["tier"]) + _, hasCounts := body["counts"] + assert.True(t, hasCounts, "counts present") + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), "", "") + status, _ := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/summary", nil) + require.Equal(t, http.StatusUnauthorized, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET + PATCH /api/v1/team/settings — TeamSettingsHandler (F9) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_TeamSettings(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("GET any member reads settings with default policy", func(t *testing.T) { + viewerID := teamBlockAddMember(t, db, teamID, "viewer") + app := teamBlockApp(t, db, miniRedis(t), viewerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/settings", nil) + require.Equal(t, http.StatusOK, status) + settings, ok := body["settings"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "auto_24h", settings["default_deployment_ttl_policy"]) + }) + + t.Run("PATCH admin updates default ttl policy and it persists", func(t *testing.T) { + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team/settings", + map[string]any{"default_deployment_ttl_policy": "permanent"}) + require.Equal(t, http.StatusOK, status) + settings := body["settings"].(map[string]any) + assert.Equal(t, "permanent", settings["default_deployment_ttl_policy"]) + + reloaded, err := models.GetTeamByID(context.Background(), db, teamID) + require.NoError(t, err) + assert.Equal(t, "permanent", reloaded.DefaultDeploymentTTLPolicy) + }) + + t.Run("PATCH developer forbidden (RequireRole admin)", func(t *testing.T) { + devID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), devID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team/settings", + map[string]any{"default_deployment_ttl_policy": "permanent"}) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("PATCH invalid policy rejected 400", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team/settings", + map[string]any{"default_deployment_ttl_policy": "forever-and-ever"}) + require.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_ttl_policy", body["error"]) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET + PUT /api/v1/team/env-policy — EnvPolicyHandler (F8) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_EnvPolicy(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("GET any member reads policy (empty default object)", func(t *testing.T) { + devID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), devID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/env-policy", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + _, ok := body["policy"] + assert.True(t, ok, "policy key present (never null)") + }) + + t.Run("PUT owner sets policy and GET reflects it", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPut, "/api/v1/team/env-policy", + map[string]any{"production": map[string]any{"deploy": []string{"owner"}}}) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + + gstatus, gbody := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/env-policy", nil) + require.Equal(t, http.StatusOK, gstatus) + policy := gbody["policy"].(map[string]any) + assert.Contains(t, policy, "production") + }) + + t.Run("PUT non-owner forbidden (owner_required handler check)", func(t *testing.T) { + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPut, "/api/v1/team/env-policy", + map[string]any{"production": map[string]any{"deploy": []string{"owner"}}}) + require.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "owner_required", body["error"]) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /api/v1/team/members — TeamMembersHandler.ListMembers (F4) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_ListMembers(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + teamBlockAddMember(t, db, teamID, "developer") + + t.Run("member lists all members with limit", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/members", nil) + require.Equal(t, http.StatusOK, status) + members, ok := body["members"].([]any) + require.True(t, ok) + assert.Len(t, members, 2) + _, hasLimit := body["member_limit"] + assert.True(t, hasLimit) + }) + + t.Run("non-member forbidden", func(t *testing.T) { + // A user who belongs to a DIFFERENT team but claims this team in the + // session — the in-handler role lookup returns no row → 403. + otherTeamID, otherUserID := teamBlockSeedTeamOwner(t, db, "pro") + _ = otherTeamID + app := teamBlockApp(t, db, miniRedis(t), otherUserID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/members", nil) + require.Equal(t, http.StatusForbidden, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// POST /api/v1/team/members/invite — TeamMembersHandler.InviteMember (F2) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_InviteMember(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "team") // unlimited seats + + t.Run("owner invites a developer (RBAC token flow)", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPost, "/api/v1/team/members/invite", + map[string]any{"email": "invitee-" + uuid.NewString()[:8] + "@instant.dev", "role": "developer"}) + require.Equal(t, http.StatusCreated, status) + inv, ok := body["invitation"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "developer", inv["role"]) + assert.NotEmpty(t, inv["token"]) + }) + + t.Run("developer member forbidden from inviting", func(t *testing.T) { + devID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), devID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPost, "/api/v1/team/members/invite", + map[string]any{"email": "x-" + uuid.NewString()[:8] + "@instant.dev", "role": "viewer"}) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("invalid role rejected 400", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPost, "/api/v1/team/members/invite", + map[string]any{"email": "y-" + uuid.NewString()[:8] + "@instant.dev", "role": "superuser"}) + require.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_role", body["error"]) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// POST /api/v1/team/members/leave — TeamMembersHandler.LeaveTeam (F5) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_LeaveTeam(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("non-primary member leaves successfully", func(t *testing.T) { + memberID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), memberID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPost, "/api/v1/team/members/leave", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + }) + + t.Run("sole owner cannot leave (409)", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPost, "/api/v1/team/members/leave", nil) + require.Equal(t, http.StatusConflict, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// DELETE /api/v1/team/members/:user_id — TeamMembersHandler.RemoveMember (F4) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_RemoveMember(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("owner removes a member", func(t *testing.T) { + memberID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodDelete, "/api/v1/team/members/"+memberID.String(), nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + }) + + t.Run("non-owner forbidden", func(t *testing.T) { + targetID := teamBlockAddMember(t, db, teamID, "viewer") + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodDelete, "/api/v1/team/members/"+targetID.String(), nil) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("invalid user id 400", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodDelete, "/api/v1/team/members/not-a-uuid", nil) + require.Equal(t, http.StatusBadRequest, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// PATCH /api/v1/team/members/:user_id — TeamMembersHandler.UpdateRole (F4) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_UpdateMemberRole(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("owner promotes developer to admin and it persists", func(t *testing.T) { + memberID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team/members/"+memberID.String(), + map[string]any{"role": "admin"}) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, "admin", body["role"]) + assert.Equal(t, "admin", teamBlockUserRole(t, db, teamID, memberID)) + }) + + t.Run("cannot assign owner role via PATCH (400)", func(t *testing.T) { + memberID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team/members/"+memberID.String(), + map[string]any{"role": "owner"}) + require.Equal(t, http.StatusBadRequest, status) + }) + + t.Run("non-owner forbidden", func(t *testing.T) { + targetID := teamBlockAddMember(t, db, teamID, "viewer") + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPatch, "/api/v1/team/members/"+targetID.String(), + map[string]any{"role": "developer"}) + require.Equal(t, http.StatusForbidden, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// POST /api/v1/team/members/:user_id/promote-to-primary +// TeamMembersHandler.PromoteToPrimary (F6) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_PromoteToPrimary(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "pro") + + t.Run("owner transfers primary to another member", func(t *testing.T) { + memberID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodPost, + "/api/v1/team/members/"+memberID.String()+"/promote-to-primary", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, memberID.String(), body["primary_user_id"]) + // New primary is now owner. + assert.Equal(t, "owner", teamBlockUserRole(t, db, teamID, memberID)) + }) + + t.Run("non-owner forbidden", func(t *testing.T) { + targetID := teamBlockAddMember(t, db, teamID, "viewer") + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPost, + "/api/v1/team/members/"+targetID.String()+"/promote-to-primary", nil) + require.Equal(t, http.StatusForbidden, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /api/v1/team/invitations + DELETE /api/v1/team/invitations/:id +// TeamMembersHandler.ListInvitations / RevokeInvitation (F2) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_Invitations(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "team") + + // Seed one pending RBAC invitation directly via the model. + inv, err := models.CreateRBACInvitation(context.Background(), db, teamID, + "pending-"+uuid.NewString()[:8]+"@instant.dev", "developer", ownerID) + require.NoError(t, err) + + t.Run("owner lists pending invitations", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/invitations", nil) + require.Equal(t, http.StatusOK, status) + invs, ok := body["invitations"].([]any) + require.True(t, ok) + assert.GreaterOrEqual(t, len(invs), 1) + }) + + t.Run("non-owner forbidden from listing", func(t *testing.T) { + devID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), devID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodGet, "/api/v1/team/invitations", nil) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("owner revokes the invitation", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodDelete, + "/api/v1/team/invitations/"+inv.ID.String(), nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + }) + + t.Run("cross-team invitation cannot be revoked", func(t *testing.T) { + // Another team's owner + invitation. + otherTeamID, otherOwnerID := teamBlockSeedTeamOwner(t, db, "team") + otherInv, err := models.CreateRBACInvitation(context.Background(), db, otherTeamID, + "other-"+uuid.NewString()[:8]+"@instant.dev", "developer", otherOwnerID) + require.NoError(t, err) + // Our owner tries to revoke the OTHER team's invitation id. + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodDelete, + "/api/v1/team/invitations/"+otherInv.ID.String(), nil) + assert.True(t, teamBlockNotFoundOK(status), "cross-team revoke must be refused, got %d", status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// POST /api/v1/team/invitations/:id/accept +// TeamMembersHandler.AcceptInvitation (F3, by-id authed variant) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_AcceptInvitationByID(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "team") + + t.Run("unknown invitation id returns 404", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), ownerID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodPost, + "/api/v1/team/invitations/"+uuid.NewString()+"/accept", nil) + require.Equal(t, http.StatusNotFound, status) + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + app := teamBlockApp(t, db, miniRedis(t), "", "") + status, _ := teamBlockReq(t, app, http.MethodPost, + "/api/v1/team/invitations/"+uuid.NewString()+"/accept", nil) + require.Equal(t, http.StatusUnauthorized, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// DELETE /api/v1/teams/:team_id/invitations/:id +// TeamsHandler.RevokeInvitation (plural-teams alias, admin-only) (F2) +// ───────────────────────────────────────────────────────────────────────── + +func TestTeamBlock_TeamsAliasRevokeInvitation(t *testing.T) { + teamBlockSkipNoDB(t) + db, cleanup := teamBlockDB(t) + defer cleanup() + teamID, ownerID := teamBlockSeedTeamOwner(t, db, "team") + + inv, err := models.CreateRBACInvitation(context.Background(), db, teamID, + "alias-"+uuid.NewString()[:8]+"@instant.dev", "developer", ownerID) + require.NoError(t, err) + + t.Run("admin revokes via plural-teams alias", func(t *testing.T) { + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + status, body := teamBlockReq(t, app, http.MethodDelete, + "/api/v1/teams/"+teamID.String()+"/invitations/"+inv.ID.String(), nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + }) + + t.Run("developer forbidden (RequireRole admin)", func(t *testing.T) { + inv2, err := models.CreateRBACInvitation(context.Background(), db, teamID, + "alias2-"+uuid.NewString()[:8]+"@instant.dev", "developer", ownerID) + require.NoError(t, err) + devID := teamBlockAddMember(t, db, teamID, "developer") + app := teamBlockApp(t, db, miniRedis(t), devID.String(), teamID.String()) + status, _ := teamBlockReq(t, app, http.MethodDelete, + "/api/v1/teams/"+teamID.String()+"/invitations/"+inv2.ID.String(), nil) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("cross-team path team_id mismatch refused", func(t *testing.T) { + otherTeamID, _ := teamBlockSeedTeamOwner(t, db, "team") + adminID := teamBlockAddMember(t, db, teamID, "admin") + app := teamBlockApp(t, db, miniRedis(t), adminID.String(), teamID.String()) + // Admin of teamID tries to act on otherTeamID's path — requireTeamMatch + // 403s because the path team_id != the session team_id. + status, _ := teamBlockReq(t, app, http.MethodDelete, + "/api/v1/teams/"+otherTeamID.String()+"/invitations/"+uuid.NewString(), nil) + require.Equal(t, http.StatusForbidden, status) + }) +} diff --git a/internal/router/route_donebar_guard_test.go b/internal/router/route_donebar_guard_test.go index a2efb8c..8141926 100644 --- a/internal/router/route_donebar_guard_test.go +++ b/internal/router/route_donebar_guard_test.go @@ -31,12 +31,13 @@ package router_test // must correspond to a route that is actually in the live tree. A stale // row (route renamed/removed) is itself drift and REDs. // -// 4. TestDoneBar_TestMapPointsAtRealTests parses the e2e package's *_test.go -// via go/ast and asserts every test name referenced by routeTestMap -// actually EXISTS. Without this, the map could rot: a row could point at a -// deleted test and TestDoneBar_EveryRouteCovered would still pass (it only -// checks the key is present). This closes that loophole — same intent as -// cli's TestDoneBar_TestMapPointsAtRealTests. +// 4. TestDoneBar_TestMapPointsAtRealTests parses every *_test.go in +// mappedTestDirs (api/e2e + api/internal/handlers) via go/ast and asserts +// every test name referenced by routeTestMap actually EXISTS. Without +// this, the map could rot: a row could point at a deleted test and +// TestDoneBar_EveryRouteCovered would still pass (it only checks the key +// is present). This closes that loophole — same intent as cli's +// TestDoneBar_TestMapPointsAtRealTests. // // WHY A MAP, NOT "any test mentions the path": a substring match over test // source is a false-positive magnet (every resource test mentions "/db/new"). @@ -44,13 +45,17 @@ package router_test // exercises its handler + auth chain + response/error contract. // // COVERING-TEST LAYER. routeTestMap points at the REAL-backend integration -// suite in api/e2e (//go:build e2e) — the matrix's W1–W4 "UI action -> backend -// state -> UI reflects it" surface. A few routes whose only integration cover -// lives at the handler-integration layer (e.g. /storage/:token/presign in -// package handlers) are EXEMPTED here with a justification citing that test + -// a TODO to add the e2e round-trip in the named wave; they are not silently -// "covered". The e2e directory is the single AST-scanned source of truth so -// the integrity check (§4) stays a one-directory parse. +// suites in mappedTestDirs: +// - api/e2e (//go:build e2e) — the matrix's W1–W4 black-box "UI action -> +// backend state -> UI reflects it" round-trips; and +// - api/internal/handlers — the DB-backed handler-integration suites +// (testhelpers.SetupTestDB + the production RequireAuth/RequireRole chain), +// where the W3 team/member-management block is exercised against a real +// Postgres. +// A route whose authz + state-change contract is proven at the handler- +// integration layer is genuinely covered, so its row points there rather than +// carrying an exemption. Routes with no integration cover at all stay in +// routeCoverageExemptions with a justification + TODO matrix-wave pointer. // // This is a pure descriptor + source-scan test: it builds the router in-memory // (no DB/Redis/network — route registration issues no queries) and parses files @@ -75,16 +80,31 @@ import ( "instant.dev/internal/router" ) -// e2eTestDir is the directory (relative to this package) holding the real- -// backend integration suite whose Test funcs routeTestMap references. The -// integrity check (TestDoneBar_TestMapPointsAtRealTests) AST-parses it. -const e2eTestDir = "../../e2e" +// mappedTestDirs are the directories (relative to this package) holding the +// integration suites whose Test funcs routeTestMap references. The integrity +// check (TestDoneBar_TestMapPointsAtRealTests) AST-parses every one. +// +// - ../../e2e — the black-box real-backend HTTP suite (W1–W4 +// liveness/provision/onboarding/deploy round-trips). Build-tagged +// //go:build e2e; go/parser ignores build tags so it parses fine here. +// - ../handlers — the DB-backed handler-integration suites +// (testhelpers.SetupTestDB + the production middleware chain). The W3 +// billing-block and team-block suites live here: a route whose authz + +// state-change contract is exercised against a real Postgres through +// RequireAuth/RequireRole is genuinely covered, so its routeTestMap row +// points at the handler test, not a black-box probe. +// +// Both directories are AST-scanned into one defined-test set, so a row may +// point at a test in either suite. This keeps the guard honest about coverage +// that lives at the handler-integration layer (W3 team/member management) +// without forcing a black-box e2e round-trip to exist first. +var mappedTestDirs = []string{"../../e2e", "../handlers"} // routeTestMap maps a live route key ("POST /db/new") to the name of the -// integration Test function (in package e2e) that provides its contract -// coverage. EVERY route in the live tree must appear here OR in -// routeCoverageExemptions. Adding a route without an entry in one of the two -// fails TestDoneBar_EveryRouteCovered. +// integration Test function (in one of the mappedTestDirs suites — package +// e2e or package handlers) that provides its contract coverage. EVERY route in +// the live tree must appear here OR in routeCoverageExemptions. Adding a route +// without an entry in one of the two fails TestDoneBar_EveryRouteCovered. var routeTestMap = map[string]string{ // ── liveness / health / discovery (public, unauth) ─────────────────────── "GET /livez": "TestE2E_Healthz_ReturnsOK", @@ -171,6 +191,32 @@ var routeTestMap = map[string]string{ "GET /api/v1/teams/:team_id/invitations": "TestMerged_Teams_InvitationsRequireAuth", "POST /api/v1/teams/:team_id/invitations": "TestMerged_Teams_InvitationsRequireAuth", + // ── team & member management (W3 §F) — DB-backed handler-integration suite + // (internal/handlers/team_block_routes_test.go). Each row points at the + // TestTeamBlock_* test that drives the route through the production RBAC + // middleware chain (RequireRole/PopulateTeamRole/RequireWritable) against a + // real Postgres: happy path + owner/member/non-member authz + cross-team + // isolation + contract shape. Moved here from routeCoverageExemptions. + "GET /api/v1/team": "TestTeamBlock_GetTeam", + "PATCH /api/v1/team": "TestTeamBlock_PatchTeam", + "DELETE /api/v1/team": "TestTeamBlock_DeleteAndRestoreTeam", + "POST /api/v1/team/restore": "TestTeamBlock_DeleteAndRestoreTeam", + "GET /api/v1/team/summary": "TestTeamBlock_GetTeamSummary", + "GET /api/v1/team/settings": "TestTeamBlock_TeamSettings", + "PATCH /api/v1/team/settings": "TestTeamBlock_TeamSettings", + "GET /api/v1/team/env-policy": "TestTeamBlock_EnvPolicy", + "PUT /api/v1/team/env-policy": "TestTeamBlock_EnvPolicy", + "GET /api/v1/team/members": "TestTeamBlock_ListMembers", + "POST /api/v1/team/members/invite": "TestTeamBlock_InviteMember", + "POST /api/v1/team/members/leave": "TestTeamBlock_LeaveTeam", + "DELETE /api/v1/team/members/:user_id": "TestTeamBlock_RemoveMember", + "PATCH /api/v1/team/members/:user_id": "TestTeamBlock_UpdateMemberRole", + "POST /api/v1/team/members/:user_id/promote-to-primary": "TestTeamBlock_PromoteToPrimary", + "GET /api/v1/team/invitations": "TestTeamBlock_Invitations", + "DELETE /api/v1/team/invitations/:id": "TestTeamBlock_Invitations", + "POST /api/v1/team/invitations/:id/accept": "TestTeamBlock_AcceptInvitationByID", + "DELETE /api/v1/teams/:team_id/invitations/:id": "TestTeamBlock_TeamsAliasRevokeInvitation", + // ── vault: requires-auth contract (merged surfaces) ────────────────────── "GET /api/v1/vault/:env": "TestMerged_Vault_RequiresAuth", "GET /api/v1/vault/:env/:key": "TestMerged_Vault_RequiresAuth", @@ -263,26 +309,10 @@ var routeCoverageExemptions = map[string]string{ "POST /api/v1/stacks/:slug/domains/:id/verify": "custom-domain verify. TODO: matrix W4 custom-domain flow.", "DELETE /api/v1/stacks/:slug/domains/:id": "custom-domain delete. TODO: matrix W4 custom-domain flow.", - // ── team management (members / invitations / env-policy / settings). - "GET /api/v1/team": "team detail. TODO: matrix W3 team-management flow.", - "PATCH /api/v1/team": "team rename/update. TODO: matrix W3 team-management flow.", - "DELETE /api/v1/team": "team self-delete (two-step). TODO: matrix W3 team-deletion flow.", - "POST /api/v1/team/restore": "team undelete. TODO: matrix W3 team-deletion flow.", - "GET /api/v1/team/summary": "team dashboard summary (aggregation). TODO: matrix W3 team-summary flow.", - "GET /api/v1/team/settings": "team settings read. TODO: matrix W3 team-settings flow.", - "PATCH /api/v1/team/settings": "team settings write. TODO: matrix W3 team-settings flow.", - "GET /api/v1/team/env-policy": "team env-policy read. TODO: matrix W3 env-policy flow.", - "PUT /api/v1/team/env-policy": "team env-policy write. TODO: matrix W3 env-policy flow.", - "GET /api/v1/team/members": "team member list. TODO: matrix W3 team-members flow.", - "POST /api/v1/team/members/invite": "invite member. TODO: matrix W3 team-members flow.", - "POST /api/v1/team/members/leave": "leave team. TODO: matrix W3 team-members flow.", - "DELETE /api/v1/team/members/:user_id": "remove member. TODO: matrix W3 team-members flow.", - "PATCH /api/v1/team/members/:user_id": "change member role. TODO: matrix W3 team-members flow.", - "POST /api/v1/team/members/:user_id/promote-to-primary": "promote member to primary owner. TODO: matrix W3 team-members flow.", - "GET /api/v1/team/invitations": "pending invitation list. TODO: matrix W3 team-invitations flow.", - "DELETE /api/v1/team/invitations/:id": "revoke invitation. TODO: matrix W3 team-invitations flow.", - "POST /api/v1/team/invitations/:id/accept": "accept invitation (authed). TODO: matrix W3 team-invitations flow.", - "DELETE /api/v1/teams/:team_id/invitations/:id": "revoke team invitation (plural-teams alias). TODO: matrix W3 team-invitations flow.", + // ── team & member management (members / invitations / env-policy / + // settings / deletion) — MOVED to routeTestMap. Now covered by the + // W3 team-block handler-integration suite + // (internal/handlers/team_block_routes_test.go, TestTeamBlock_*). // ── billing: invoices / update-payment / change-plan / promotion / usage. "GET /api/v1/billing/invoices": "invoice list. TODO: matrix W3 billing-invoices flow.", @@ -440,7 +470,7 @@ func TestDoneBar_EveryRouteCovered(t *testing.T) { // TestDoneBar_TestMapPointsAtRealTests. go/parser ignores build tags, so the // //go:build e2e files parse fine here even in the -short gate. func TestDoneBar_TestMapPointsAtRealTests(t *testing.T) { - defined := definedE2ETestFuncs(t) + defined := definedMappedTestFuncs(t) refs := map[string]bool{} for _, name := range routeTestMap { @@ -454,46 +484,49 @@ func TestDoneBar_TestMapPointsAtRealTests(t *testing.T) { for _, name := range names { if !defined[name] { - t.Errorf("routeTestMap references test %q which is not defined in package e2e (%s) — it was renamed or deleted. Point the row at the real covering test.", name, e2eTestDir) + t.Errorf("routeTestMap references test %q which is not defined in any mapped-test dir %v — it was renamed or deleted. Point the row at the real covering test.", name, mappedTestDirs) } } } -// definedE2ETestFuncs parses every *_test.go in the e2e directory and returns -// the set of top-level `func TestXxx(...)` names. Source-driven (not -// reflection) because Go test functions aren't reflectable, and because the e2e -// package is build-tagged out of this binary. -func definedE2ETestFuncs(t *testing.T) map[string]bool { +// definedMappedTestFuncs parses every *_test.go in each mappedTestDirs entry +// and returns the set of top-level `func TestXxx(...)` names across all of +// them. Source-driven (not reflection) because Go test functions aren't +// reflectable, and because the e2e package is build-tagged out of this binary. +func definedMappedTestFuncs(t *testing.T) map[string]bool { t.Helper() out := map[string]bool{} - - entries, err := os.ReadDir(e2eTestDir) - if err != nil { - t.Fatalf("read e2e dir %q: %v", e2eTestDir, err) - } fset := token.NewFileSet() - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), "_test.go") { - continue - } - path := filepath.Join(e2eTestDir, e.Name()) - f, err := parser.ParseFile(fset, path, nil, 0) + + for _, dir := range mappedTestDirs { + entries, err := os.ReadDir(dir) if err != nil { - t.Fatalf("parse %s: %v", path, err) + t.Fatalf("read mapped-test dir %q: %v", dir, err) } - for _, decl := range f.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Recv != nil { + before := len(out) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), "_test.go") { continue } - name := fn.Name.Name - if strings.HasPrefix(name, "Test") { - out[name] = true + path := filepath.Join(dir, e.Name()) + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil { + continue + } + name := fn.Name.Name + if strings.HasPrefix(name, "Test") { + out[name] = true + } } } - } - if len(out) == 0 { - t.Fatalf("found zero Test functions in %q — parser/path misconfigured", e2eTestDir) + if len(out) == before { + t.Fatalf("found zero Test functions in %q — parser/path misconfigured", dir) + } } return out }