Skip to content

Commit 85d3781

Browse files
test(matrix): team/member-management block integration tests (move exempt→mapped) (#250)
Covers the team & member management user-flow routes (GetTeam, PatchTeam, delete+restore, summary, settings, env-policy, list/invite/remove members, leave) with DB-backed integration tests incl. owner/member/non-member authz + cross-team isolation, and moves those routes from routeCoverageExemptions to routeTestMap in the route-iterating done-bar guard (guard stays green). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 388fc0a commit 85d3781

3 files changed

Lines changed: 951 additions & 69 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

Comments
 (0)