|
| 1 | +package handlers_test |
| 2 | + |
| 3 | +// team_block_helpers_test.go — shared helpers for the W3 team-block |
| 4 | +// integration suite (team_block_routes_test.go). These cover the team & |
| 5 | +// member-management user-flow block (USER-FLOW-INVENTORY-AND-TEST-MATRIX.md |
| 6 | +// §F: F1–F11) that, prior to this suite, lived in the route-iterating |
| 7 | +// done-bar guard's routeCoverageExemptions with no mapped test. |
| 8 | +// |
| 9 | +// Mirrors the established W3 billing-block convention |
| 10 | +// (billing_block_helpers_test.go): a thin SetupTestDB wrapper + a loud |
| 11 | +// skip-when-no-DB guard + DB-backed seed helpers. Helpers here are prefixed |
| 12 | +// teamBlock* so they do NOT collide with — and do NOT redefine — the existing |
| 13 | +// seedVerifiedTeamUser / seedTeam / seedMember / miniRedis / doJSON / |
| 14 | +// decodeBody helpers, which this suite reuses verbatim. |
| 15 | +// |
| 16 | +// What this suite adds over the existing per-handler tests |
| 17 | +// (team_members_test.go, team_self_test.go, env_policy_test.go, …): a single |
| 18 | +// app builder (teamBlockApp) that wires EVERY team/member route through the |
| 19 | +// PRODUCTION RBAC middleware chain — middleware.RequireRole + PopulateTeamRole |
| 20 | +// + RequireWritable, with middleware.SetRoleLookupDB pointed at the real test |
| 21 | +// DB — exactly as internal/router/router.go does. The existing per-handler |
| 22 | +// tests deliberately omit RequireRole (they probe the in-handler requireOwner |
| 23 | +// check in isolation); this suite is the route-layer authz contract the |
| 24 | +// done-bar guard's routeTestMap rows point at. |
| 25 | + |
| 26 | +import ( |
| 27 | + "context" |
| 28 | + "database/sql" |
| 29 | + "errors" |
| 30 | + "net/http" |
| 31 | + "os" |
| 32 | + "testing" |
| 33 | + |
| 34 | + "github.com/gofiber/fiber/v2" |
| 35 | + "github.com/google/uuid" |
| 36 | + "github.com/redis/go-redis/v9" |
| 37 | + "github.com/stretchr/testify/require" |
| 38 | + |
| 39 | + "instant.dev/internal/config" |
| 40 | + "instant.dev/internal/email" |
| 41 | + "instant.dev/internal/handlers" |
| 42 | + "instant.dev/internal/middleware" |
| 43 | + "instant.dev/internal/models" |
| 44 | + "instant.dev/internal/plans" |
| 45 | + "instant.dev/internal/testhelpers" |
| 46 | +) |
| 47 | + |
| 48 | +// teamBlockSkipNoDB skips a W3 team-block test when no test Postgres is |
| 49 | +// configured. The team block is a real-backend integration surface — these |
| 50 | +// tests assert on actual rows in teams/users/team_invitations/audit_log, so a |
| 51 | +// missing DB is a loud skip, never a false green. Mirrors |
| 52 | +// billingBlockSkipNoDB. |
| 53 | +func teamBlockSkipNoDB(t *testing.T) { |
| 54 | + t.Helper() |
| 55 | + if os.Getenv("TEST_DATABASE_URL") == "" { |
| 56 | + t.Skip("W3 team-block integration: TEST_DATABASE_URL not set") |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +// teamBlockDB opens a fresh migrated test DB and returns it with its cleanup. |
| 61 | +// Thin wrapper over testhelpers.SetupTestDB so every W3 team-block test reads |
| 62 | +// the same way. |
| 63 | +func teamBlockDB(t *testing.T) (*sql.DB, func()) { |
| 64 | + t.Helper() |
| 65 | + return testhelpers.SetupTestDB(t) |
| 66 | +} |
| 67 | + |
| 68 | +// teamBlockApp builds a Fiber app that registers every team & member |
| 69 | +// management route through the SAME middleware chain production uses |
| 70 | +// (internal/router/router.go): a synthetic auth shim sets LocalKeyUserID + |
| 71 | +// LocalKeyTeamID (standing in for RequireAuth's JWT validation), |
| 72 | +// PopulateTeamRole resolves the caller's REAL role from the DB, and the |
| 73 | +// owner/admin-gated routes carry RequireRole / RequireWritable exactly as |
| 74 | +// the live router does. |
| 75 | +// |
| 76 | +// actorUserID / actorTeamID are the seeded identity the synthetic auth shim |
| 77 | +// injects. Pass "" for actorUserID to simulate an unauthenticated caller |
| 78 | +// (the shim then sets no locals and the RequireRole / handler 401 paths |
| 79 | +// fire). |
| 80 | +// |
| 81 | +// rdb must be a working client (use the existing miniRedis(t) helper) — the |
| 82 | +// invite path reads it for rate-limiting and idempotency. |
| 83 | +func teamBlockApp(t *testing.T, db *sql.DB, rdb *redis.Client, actorUserID, actorTeamID string) *fiber.App { |
| 84 | + t.Helper() |
| 85 | + |
| 86 | + cfg := &config.Config{ |
| 87 | + JWTSecret: testhelpers.TestJWTSecret, |
| 88 | + DashboardBaseURL: "http://localhost:5173", |
| 89 | + } |
| 90 | + mail := email.NewNoop() |
| 91 | + |
| 92 | + app := fiber.New(fiber.Config{ |
| 93 | + ErrorHandler: func(c *fiber.Ctx, err error) error { |
| 94 | + if errors.Is(err, handlers.ErrResponseWritten) { |
| 95 | + return nil |
| 96 | + } |
| 97 | + code := fiber.StatusInternalServerError |
| 98 | + if e, ok := err.(*fiber.Error); ok { |
| 99 | + code = e.Code |
| 100 | + } |
| 101 | + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()}) |
| 102 | + }, |
| 103 | + }) |
| 104 | + |
| 105 | + app.Use(middleware.RequestID()) |
| 106 | + // Synthetic auth shim — production's RequireAuth sets these two locals |
| 107 | + // after validating the session JWT. We set them directly from the seeded |
| 108 | + // identity so the suite exercises the role/ownership chain without minting |
| 109 | + // real JWTs (the auth surface itself is covered by auth_flow_e2e_test.go). |
| 110 | + app.Use(func(c *fiber.Ctx) error { |
| 111 | + if actorUserID != "" { |
| 112 | + c.Locals(middleware.LocalKeyUserID, actorUserID) |
| 113 | + } |
| 114 | + if actorTeamID != "" { |
| 115 | + c.Locals(middleware.LocalKeyTeamID, actorTeamID) |
| 116 | + } |
| 117 | + return c.Next() |
| 118 | + }) |
| 119 | + |
| 120 | + // PopulateTeamRole resolves auth_team_role from the real DB so RequireRole |
| 121 | + // gates on the seeded user's actual role — production wiring. |
| 122 | + middleware.SetRoleLookupDB(db) |
| 123 | + planReg := plans.Default() |
| 124 | + |
| 125 | + teamSelfH := handlers.NewTeamSelfHandler(db, planReg) |
| 126 | + teamSettingsH := handlers.NewTeamSettingsHandler(db) |
| 127 | + teamSummaryH := handlers.NewTeamSummaryHandler(db, rdb, planReg) |
| 128 | + envPolicyH := handlers.NewEnvPolicyHandler(db) |
| 129 | + teamMembersH := handlers.NewTeamMembersHandler(db, cfg, planReg, mail, rdb) |
| 130 | + teamsH := handlers.NewTeamsHandler(db, cfg, mail) |
| 131 | + teamDelH := handlers.NewTeamDeletionHandler(db, cfg) |
| 132 | + |
| 133 | + api := app.Group("/api/v1", middleware.PopulateTeamRole()) |
| 134 | + |
| 135 | + // Team self — GET open to any member; PATCH owner-only + writable. |
| 136 | + api.Get("/team", teamSelfH.Get) |
| 137 | + api.Patch("/team", middleware.RequireRole(middleware.RoleOwner), middleware.RequireWritable(), teamSelfH.Update) |
| 138 | + |
| 139 | + // Team deletion / restore — owner-only (RequireRole at the route layer). |
| 140 | + api.Delete("/team", middleware.RequireRole(middleware.RoleOwner), teamDelH.Delete) |
| 141 | + api.Post("/team/restore", middleware.RequireRole(middleware.RoleOwner), teamDelH.Restore) |
| 142 | + |
| 143 | + // Team summary — any member. |
| 144 | + api.Get("/team/summary", teamSummaryH.GetSummary) |
| 145 | + |
| 146 | + // Team settings — GET any member; PATCH writable + admin. |
| 147 | + api.Get("/team/settings", teamSettingsH.Get) |
| 148 | + api.Patch("/team/settings", |
| 149 | + middleware.RequireWritable(), |
| 150 | + middleware.RequireRole(middleware.RoleAdmin), |
| 151 | + teamSettingsH.Update, |
| 152 | + ) |
| 153 | + |
| 154 | + // Env-policy — GET any member; PUT owner enforced inside the handler |
| 155 | + // (canonical env_policy 403 shape), so NO RequireRole here. Mirrors |
| 156 | + // router.go exactly. |
| 157 | + api.Get("/team/env-policy", envPolicyH.Get) |
| 158 | + api.Put("/team/env-policy", envPolicyH.Put) |
| 159 | + |
| 160 | + // Members + invitations. RBAC is enforced INSIDE these handlers |
| 161 | + // (requireOwner / owner-or-admin), matching router.go which installs no |
| 162 | + // RequireRole on the members subtree. |
| 163 | + api.Get("/team/members", teamMembersH.ListMembers) |
| 164 | + api.Post("/team/members/invite", teamMembersH.InviteMember) |
| 165 | + api.Post("/team/members/leave", teamMembersH.LeaveTeam) |
| 166 | + api.Delete("/team/members/:user_id", teamMembersH.RemoveMember) |
| 167 | + api.Patch("/team/members/:user_id", teamMembersH.UpdateRole) |
| 168 | + api.Post("/team/members/:user_id/promote-to-primary", teamMembersH.PromoteToPrimary) |
| 169 | + api.Get("/team/invitations", teamMembersH.ListInvitations) |
| 170 | + api.Delete("/team/invitations/:id", teamMembersH.RevokeInvitation) |
| 171 | + api.Post("/team/invitations/:id/accept", teamMembersH.AcceptInvitation) |
| 172 | + |
| 173 | + // Plural-teams invitation alias — admin-only (RequireRole), team-match |
| 174 | + // enforced inside the handler. |
| 175 | + api.Delete("/teams/:team_id/invitations/:id", middleware.RequireRole(middleware.RoleAdmin), teamsH.RevokeInvitation) |
| 176 | + |
| 177 | + return app |
| 178 | +} |
| 179 | + |
| 180 | +// teamBlockSeedTeamOwner inserts a team at the given tier plus its primary |
| 181 | +// owner, returning (teamID, ownerID). Registers cleanup. Built on the package |
| 182 | +// testhelpers seeders — does NOT redefine seedVerifiedTeamUser/seedMember. |
| 183 | +func teamBlockSeedTeamOwner(t *testing.T, db *sql.DB, tier string) (uuid.UUID, uuid.UUID) { |
| 184 | + t.Helper() |
| 185 | + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, tier)) |
| 186 | + owner, err := models.CreateUser(context.Background(), db, teamID, |
| 187 | + testhelpers.UniqueEmail(t), "", "", "owner") |
| 188 | + require.NoError(t, err) |
| 189 | + t.Cleanup(func() { |
| 190 | + db.Exec(`DELETE FROM users WHERE team_id = $1`, teamID) |
| 191 | + db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) |
| 192 | + }) |
| 193 | + return teamID, owner.ID |
| 194 | +} |
| 195 | + |
| 196 | +// teamBlockAddMember adds a non-owner user with the given role to teamID and |
| 197 | +// returns its id. |
| 198 | +func teamBlockAddMember(t *testing.T, db *sql.DB, teamID uuid.UUID, role string) uuid.UUID { |
| 199 | + t.Helper() |
| 200 | + u, err := models.CreateUser(context.Background(), db, teamID, |
| 201 | + testhelpers.UniqueEmail(t), "", "", role) |
| 202 | + require.NoError(t, err) |
| 203 | + return u.ID |
| 204 | +} |
| 205 | + |
| 206 | +// teamBlockReq issues a request to the test app and returns (status, decoded |
| 207 | +// JSON body). Body may be nil. Thin wrapper so every team-block test reads the |
| 208 | +// same; built on net/http + app.Test, not redefining doJSON (which takes a |
| 209 | +// headers map — this variant needs none). |
| 210 | +func teamBlockReq(t *testing.T, app *fiber.App, method, path string, body any) (int, map[string]any) { |
| 211 | + t.Helper() |
| 212 | + resp := doJSON(t, app, method, path, body, nil) |
| 213 | + return resp.StatusCode, decodeBody(t, resp) |
| 214 | +} |
| 215 | + |
| 216 | +// teamBlockTeamName reads back a team's name column so a test can assert a |
| 217 | +// PATCH persisted. |
| 218 | +func teamBlockTeamName(t *testing.T, db *sql.DB, teamID uuid.UUID) string { |
| 219 | + t.Helper() |
| 220 | + var name sql.NullString |
| 221 | + require.NoError(t, |
| 222 | + db.QueryRow(`SELECT name FROM teams WHERE id = $1`, teamID).Scan(&name), |
| 223 | + "read team name") |
| 224 | + return name.String |
| 225 | +} |
| 226 | + |
| 227 | +// teamBlockUserRole reads back a user's role column. |
| 228 | +func teamBlockUserRole(t *testing.T, db *sql.DB, teamID, userID uuid.UUID) string { |
| 229 | + t.Helper() |
| 230 | + role, err := models.GetUserRole(context.Background(), db, teamID, userID) |
| 231 | + require.NoError(t, err, "read user role") |
| 232 | + return role |
| 233 | +} |
| 234 | + |
| 235 | +// teamBlockNotFoundOK is satisfied for the cross-team-isolation assertions: |
| 236 | +// acting on another team's resource must NEVER succeed. We accept the |
| 237 | +// documented refusal codes (403 forbidden / 404 not_found) and reject any 2xx. |
| 238 | +func teamBlockNotFoundOK(status int) bool { |
| 239 | + return status == http.StatusForbidden || status == http.StatusNotFound |
| 240 | +} |
0 commit comments