Skip to content

Commit bdc408b

Browse files
test(matrix): W3 vault-block integration suite (move exempt→mapped) (#252)
Closes the vault routes the done-bar guard (internal/router/route_donebar_guard_test.go) carried as either the shallow TestMerged_Vault_RequiresAuth requires-auth probe (GET list, GET key, PUT key) or as routeCoverageExemptions TODO-rows with no mapped test (POST rotate, DELETE key, POST copy). New DB-backed handler-integration suite (internal/handlers/vault_block_routes_test.go + vault_block_helpers_test.go) drives every vault route through the production RequireAuth + PopulateTeamRole + RequireEnvAccess(VaultWrite) chain (vaultBlockApp mirrors router.New) against a real Postgres: - happy path: write/read/list/rotate/delete/copy + versioned writes - encrypt/decrypt-at-rest: ciphertext at rest never contains plaintext; GET decrypts to the original; list path never returns values - authz: free=403 not-available, hobby non-prod env=403, env_policy locks prod vault_write to owner → developer 403 env_policy_denied, copy on non-multi-env tier=402, missing bearer=401 - cross-team isolation: team B read/delete of team A's secret → 404 (never 403), and team A's secret survives B's delete attempt - rotate semantics: new version + distinct 'rotate' audit action + Idempotency-Key replay does not create a duplicate version - copy semantics: dry_run persists nothing, skip-by-default vs overwrite, missing-source reporting, encrypted bytes preserved - input validation: invalid key/env/version, from==to, missing from Guard: six vault rows move routeCoverageExemptions/shallow-probe → routeTestMap pointing at TestVaultBlock_*. Both done-bar guards stay green (TestDoneBar_EveryRouteCovered + TestDoneBar_TestMapPointsAtRealTests). No handler-source edits — test-only PR. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 19d4b00 commit bdc408b

3 files changed

Lines changed: 773 additions & 8 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package handlers_test
2+
3+
// vault_block_helpers_test.go — shared helpers for the W3 vault-block
4+
// integration suite (vault_block_routes_test.go). These cover the per-team
5+
// encrypted-secret vault user-flow block from
6+
// USER-FLOW-INVENTORY-AND-TEST-MATRIX.md (2026-06-04) §W3: the vault
7+
// read/list/write/rotate/delete/copy surfaces that, prior to this suite, were
8+
// carried in internal/router/route_donebar_guard_test.go either pointing at
9+
// the shallow TestMerged_Vault_RequiresAuth probe (GET/GET/PUT) or sitting in
10+
// routeCoverageExemptions with a "TODO: matrix W3 …" pointer and NO mapped
11+
// test (rotate / delete / copy).
12+
//
13+
// Mirrors the established W3 team-block convention
14+
// (team_block_helpers_test.go): a thin SetupTestDB wrapper + a loud
15+
// skip-when-no-DB guard + DB-backed seed helpers. Helpers here are prefixed
16+
// vaultBlock* so they do NOT collide with — and do NOT redefine — the existing
17+
// seedVerifiedTeamUser / teamBlock* / miniRedis / doJSON / decodeBody helpers,
18+
// which this suite reuses verbatim.
19+
//
20+
// Why a dedicated app builder rather than NewTestAppWithServices: that shared
21+
// test app does NOT register the vault routes. vaultBlockApp registers EVERY
22+
// vault route through the SAME production middleware chain
23+
// internal/router/router.go installs — middleware.RequireAuth (real JWT
24+
// validation, no synthetic shim) + PopulateTeamRole + RequireEnvAccess
25+
// (VaultWrite, :env-param lookup) + Idempotency on rotate — against the real
26+
// migrated test DB. The role/env-policy lookups are wired with the live
27+
// SetRoleLookupDB / SetEnvPolicyDB handles exactly as router.go does, so the
28+
// suite exercises the authz + env-policy + tier + encrypt/decrypt-at-rest
29+
// contracts the done-bar guard's routeTestMap rows now point at.
30+
31+
import (
32+
"context"
33+
"database/sql"
34+
"errors"
35+
"net/http"
36+
"os"
37+
"testing"
38+
39+
"github.com/gofiber/fiber/v2"
40+
"github.com/google/uuid"
41+
"github.com/redis/go-redis/v9"
42+
"github.com/stretchr/testify/require"
43+
44+
"instant.dev/internal/config"
45+
"instant.dev/internal/handlers"
46+
"instant.dev/internal/middleware"
47+
"instant.dev/internal/models"
48+
"instant.dev/internal/plans"
49+
"instant.dev/internal/testhelpers"
50+
)
51+
52+
// vaultBlockSkipNoDB skips a W3 vault-block test when no test Postgres is
53+
// configured. The vault block is a real-backend integration surface — these
54+
// tests assert on actual rows in vault_secrets / vault_audit_log and on the
55+
// AES-256-GCM ciphertext stored at rest, so a missing DB is a loud skip, never
56+
// a false green. Mirrors teamBlockSkipNoDB.
57+
func vaultBlockSkipNoDB(t *testing.T) {
58+
t.Helper()
59+
if os.Getenv("TEST_DATABASE_URL") == "" {
60+
t.Skip("W3 vault-block integration: TEST_DATABASE_URL not set")
61+
}
62+
}
63+
64+
// vaultBlockDB opens a fresh migrated test DB and returns it with its cleanup.
65+
// Thin wrapper over testhelpers.SetupTestDB so every W3 vault-block test reads
66+
// the same way.
67+
func vaultBlockDB(t *testing.T) (*sql.DB, func()) {
68+
t.Helper()
69+
return testhelpers.SetupTestDB(t)
70+
}
71+
72+
// vaultBlockApp builds a Fiber app that registers every vault route through the
73+
// SAME middleware chain production uses (internal/router/router.go): the real
74+
// middleware.RequireAuth(cfg) validates the session JWT (NO synthetic shim —
75+
// callers mint real JWTs via testhelpers.MustSignSessionJWT), PopulateTeamRole
76+
// resolves the caller's role from the DB, RequireEnvAccess(VaultWrite) gates
77+
// the mutating routes on the team's env_policy keyed by the :env param, and
78+
// the rotate route carries Idempotency exactly as the live router does.
79+
//
80+
// The role + env-policy lookups are wired with the live package-level handles
81+
// (SetRoleLookupDB / SetEnvPolicyDB) pointed at the test DB — mirror of
82+
// router.go. cfg.AESKey is the test key so encrypt/decrypt-at-rest round-trips.
83+
func vaultBlockApp(t *testing.T, db *sql.DB, rdb *redis.Client) *fiber.App {
84+
t.Helper()
85+
86+
cfg := &config.Config{
87+
JWTSecret: testhelpers.TestJWTSecret,
88+
AESKey: testhelpers.TestAESKeyHex,
89+
DashboardBaseURL: "http://localhost:5173",
90+
}
91+
planReg := plans.Default()
92+
93+
app := fiber.New(fiber.Config{
94+
ProxyHeader: "X-Forwarded-For",
95+
ErrorHandler: func(c *fiber.Ctx, err error) error {
96+
if errors.Is(err, handlers.ErrResponseWritten) {
97+
return nil
98+
}
99+
code := fiber.StatusInternalServerError
100+
if e, ok := err.(*fiber.Error); ok {
101+
code = e.Code
102+
}
103+
return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error", "message": err.Error()})
104+
},
105+
})
106+
107+
app.Use(middleware.RequestID())
108+
109+
// Wire the live role + env-policy lookup handles, exactly as router.go
110+
// does at startup. RequireEnvAccess reads env_policy through this handle;
111+
// PopulateTeamRole reads auth_team_role through SetRoleLookupDB.
112+
middleware.SetRoleLookupDB(db)
113+
middleware.SetEnvPolicyDB(db)
114+
115+
vaultH := handlers.NewVaultHandler(db, cfg, planReg)
116+
vaultEnvLookup := middleware.WithEnvLookup(func(c *fiber.Ctx) (string, error) {
117+
return c.Params("env"), nil
118+
})
119+
120+
api := app.Group("/api/v1", middleware.RequireAuth(cfg), middleware.PopulateTeamRole())
121+
api.Put("/vault/:env/:key",
122+
middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite, vaultEnvLookup),
123+
vaultH.PutSecret,
124+
)
125+
api.Get("/vault/:env/:key", vaultH.GetSecret)
126+
api.Get("/vault/:env", vaultH.ListKeys)
127+
api.Delete("/vault/:env/:key",
128+
middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite, vaultEnvLookup),
129+
vaultH.DeleteSecret,
130+
)
131+
api.Post("/vault/:env/:key/rotate",
132+
middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite, vaultEnvLookup),
133+
middleware.Idempotency(rdb, "vault.rotate"),
134+
vaultH.RotateSecret,
135+
)
136+
api.Post("/vault/copy",
137+
middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite),
138+
vaultH.CopySecrets,
139+
)
140+
141+
return app
142+
}
143+
144+
// vaultBlockSeedTeamMember inserts a team at the given tier plus a member with
145+
// the given role, returning (teamID, userID, jwt). The JWT is a real session
146+
// token the RequireAuth chain validates. Registers cleanup. Built on the
147+
// package testhelpers seeders — does NOT redefine seedVerifiedTeamUser.
148+
func vaultBlockSeedTeamMember(t *testing.T, db *sql.DB, tier, role string) (uuid.UUID, uuid.UUID, string) {
149+
t.Helper()
150+
teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, tier))
151+
u, err := models.CreateUser(context.Background(), db, teamID,
152+
testhelpers.UniqueEmail(t), "", "", role)
153+
require.NoError(t, err)
154+
require.NoError(t, models.SetEmailVerified(context.Background(), db, u.ID))
155+
t.Cleanup(func() {
156+
db.Exec(`DELETE FROM vault_secrets WHERE team_id = $1`, teamID)
157+
db.Exec(`DELETE FROM vault_audit_log WHERE team_id = $1`, teamID)
158+
db.Exec(`DELETE FROM users WHERE team_id = $1`, teamID)
159+
db.Exec(`DELETE FROM teams WHERE id = $1`, teamID)
160+
})
161+
jwt := testhelpers.MustSignSessionJWT(t, u.ID.String(), teamID.String(), testhelpers.UniqueEmail(t))
162+
return teamID, u.ID, jwt
163+
}
164+
165+
// vaultBlockReq issues a JSON request to the test app carrying the supplied
166+
// bearer JWT and returns (status, decoded JSON body). Body may be nil. Built
167+
// on the existing doJSON helper (which takes a headers map) so it does NOT
168+
// redefine it.
169+
func vaultBlockReq(t *testing.T, app *fiber.App, jwt, method, path string, body any) (int, map[string]any) {
170+
t.Helper()
171+
headers := map[string]string{}
172+
if jwt != "" {
173+
headers["Authorization"] = "Bearer " + jwt
174+
}
175+
resp := doJSON(t, app, method, path, body, headers)
176+
return resp.StatusCode, decodeBody(t, resp)
177+
}
178+
179+
// vaultBlockRawCiphertext reads the encrypted_value bytes stored at rest for
180+
// the latest version of (team,env,key). Used to assert the value is NOT stored
181+
// as plaintext (encrypt-at-rest contract) and that it decrypts to the original.
182+
func vaultBlockRawCiphertext(t *testing.T, db *sql.DB, teamID uuid.UUID, env, key string) []byte {
183+
t.Helper()
184+
var raw []byte
185+
err := db.QueryRow(`
186+
SELECT encrypted_value FROM vault_secrets
187+
WHERE team_id = $1 AND env = $2 AND key = $3
188+
ORDER BY version DESC LIMIT 1
189+
`, teamID, env, key).Scan(&raw)
190+
require.NoError(t, err, "read encrypted_value at rest")
191+
return raw
192+
}
193+
194+
// vaultBlockSetEnvPolicy writes the team's env_policy JSONB so the
195+
// RequireEnvAccess gate can be exercised (e.g. lock production vault_write to
196+
// owners only). Passing an empty string clears it back to the default-allow {}.
197+
func vaultBlockSetEnvPolicy(t *testing.T, db *sql.DB, teamID uuid.UUID, policyJSON string) {
198+
t.Helper()
199+
if policyJSON == "" {
200+
policyJSON = "{}"
201+
}
202+
_, err := db.Exec(`UPDATE teams SET env_policy = $2::jsonb WHERE id = $1`, teamID, policyJSON)
203+
require.NoError(t, err, "set env_policy")
204+
}
205+
206+
// vaultBlockCrossTeamRefused is satisfied for the cross-team-isolation
207+
// assertions: acting on another team's secret must NEVER succeed. Vault's
208+
// contract refuses with 404 (never 403) so existence is unobservable — we
209+
// accept 404 and reject any 2xx.
210+
func vaultBlockCrossTeamRefused(status int) bool {
211+
return status == http.StatusNotFound
212+
}

0 commit comments

Comments
 (0)