From fc4b07315f1f447c88e67ce4b7b55193b99834b5 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 5 Jun 2026 04:39:31 +0530 Subject: [PATCH] =?UTF-8?q?test(matrix):=20W3=20vault-block=20integration?= =?UTF-8?q?=20suite=20(move=20exempt=E2=86=92mapped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/handlers/vault_block_helpers_test.go | 212 +++++++ internal/handlers/vault_block_routes_test.go | 541 ++++++++++++++++++ internal/router/route_donebar_guard_test.go | 28 +- 3 files changed, 773 insertions(+), 8 deletions(-) create mode 100644 internal/handlers/vault_block_helpers_test.go create mode 100644 internal/handlers/vault_block_routes_test.go diff --git a/internal/handlers/vault_block_helpers_test.go b/internal/handlers/vault_block_helpers_test.go new file mode 100644 index 0000000..1f8c19b --- /dev/null +++ b/internal/handlers/vault_block_helpers_test.go @@ -0,0 +1,212 @@ +package handlers_test + +// vault_block_helpers_test.go — shared helpers for the W3 vault-block +// integration suite (vault_block_routes_test.go). These cover the per-team +// encrypted-secret vault user-flow block from +// USER-FLOW-INVENTORY-AND-TEST-MATRIX.md (2026-06-04) §W3: the vault +// read/list/write/rotate/delete/copy surfaces that, prior to this suite, were +// carried in internal/router/route_donebar_guard_test.go either pointing at +// the shallow TestMerged_Vault_RequiresAuth probe (GET/GET/PUT) or sitting in +// routeCoverageExemptions with a "TODO: matrix W3 …" pointer and NO mapped +// test (rotate / delete / copy). +// +// Mirrors the established W3 team-block convention +// (team_block_helpers_test.go): a thin SetupTestDB wrapper + a loud +// skip-when-no-DB guard + DB-backed seed helpers. Helpers here are prefixed +// vaultBlock* so they do NOT collide with — and do NOT redefine — the existing +// seedVerifiedTeamUser / teamBlock* / miniRedis / doJSON / decodeBody helpers, +// which this suite reuses verbatim. +// +// Why a dedicated app builder rather than NewTestAppWithServices: that shared +// test app does NOT register the vault routes. vaultBlockApp registers EVERY +// vault route through the SAME production middleware chain +// internal/router/router.go installs — middleware.RequireAuth (real JWT +// validation, no synthetic shim) + PopulateTeamRole + RequireEnvAccess +// (VaultWrite, :env-param lookup) + Idempotency on rotate — against the real +// migrated test DB. The role/env-policy lookups are wired with the live +// SetRoleLookupDB / SetEnvPolicyDB handles exactly as router.go does, so the +// suite exercises the authz + env-policy + tier + encrypt/decrypt-at-rest +// contracts the done-bar guard's routeTestMap rows now 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/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +// vaultBlockSkipNoDB skips a W3 vault-block test when no test Postgres is +// configured. The vault block is a real-backend integration surface — these +// tests assert on actual rows in vault_secrets / vault_audit_log and on the +// AES-256-GCM ciphertext stored at rest, so a missing DB is a loud skip, never +// a false green. Mirrors teamBlockSkipNoDB. +func vaultBlockSkipNoDB(t *testing.T) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("W3 vault-block integration: TEST_DATABASE_URL not set") + } +} + +// vaultBlockDB opens a fresh migrated test DB and returns it with its cleanup. +// Thin wrapper over testhelpers.SetupTestDB so every W3 vault-block test reads +// the same way. +func vaultBlockDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + return testhelpers.SetupTestDB(t) +} + +// vaultBlockApp builds a Fiber app that registers every vault route through the +// SAME middleware chain production uses (internal/router/router.go): the real +// middleware.RequireAuth(cfg) validates the session JWT (NO synthetic shim — +// callers mint real JWTs via testhelpers.MustSignSessionJWT), PopulateTeamRole +// resolves the caller's role from the DB, RequireEnvAccess(VaultWrite) gates +// the mutating routes on the team's env_policy keyed by the :env param, and +// the rotate route carries Idempotency exactly as the live router does. +// +// The role + env-policy lookups are wired with the live package-level handles +// (SetRoleLookupDB / SetEnvPolicyDB) pointed at the test DB — mirror of +// router.go. cfg.AESKey is the test key so encrypt/decrypt-at-rest round-trips. +func vaultBlockApp(t *testing.T, db *sql.DB, rdb *redis.Client) *fiber.App { + t.Helper() + + cfg := &config.Config{ + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + DashboardBaseURL: "http://localhost:5173", + } + planReg := plans.Default() + + app := fiber.New(fiber.Config{ + ProxyHeader: "X-Forwarded-For", + 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()) + + // Wire the live role + env-policy lookup handles, exactly as router.go + // does at startup. RequireEnvAccess reads env_policy through this handle; + // PopulateTeamRole reads auth_team_role through SetRoleLookupDB. + middleware.SetRoleLookupDB(db) + middleware.SetEnvPolicyDB(db) + + vaultH := handlers.NewVaultHandler(db, cfg, planReg) + vaultEnvLookup := middleware.WithEnvLookup(func(c *fiber.Ctx) (string, error) { + return c.Params("env"), nil + }) + + api := app.Group("/api/v1", middleware.RequireAuth(cfg), middleware.PopulateTeamRole()) + api.Put("/vault/:env/:key", + middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite, vaultEnvLookup), + vaultH.PutSecret, + ) + api.Get("/vault/:env/:key", vaultH.GetSecret) + api.Get("/vault/:env", vaultH.ListKeys) + api.Delete("/vault/:env/:key", + middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite, vaultEnvLookup), + vaultH.DeleteSecret, + ) + api.Post("/vault/:env/:key/rotate", + middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite, vaultEnvLookup), + middleware.Idempotency(rdb, "vault.rotate"), + vaultH.RotateSecret, + ) + api.Post("/vault/copy", + middleware.RequireEnvAccess(middleware.EnvPolicyActionVaultWrite), + vaultH.CopySecrets, + ) + + return app +} + +// vaultBlockSeedTeamMember inserts a team at the given tier plus a member with +// the given role, returning (teamID, userID, jwt). The JWT is a real session +// token the RequireAuth chain validates. Registers cleanup. Built on the +// package testhelpers seeders — does NOT redefine seedVerifiedTeamUser. +func vaultBlockSeedTeamMember(t *testing.T, db *sql.DB, tier, role string) (uuid.UUID, uuid.UUID, string) { + t.Helper() + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, tier)) + u, err := models.CreateUser(context.Background(), db, teamID, + testhelpers.UniqueEmail(t), "", "", role) + require.NoError(t, err) + require.NoError(t, models.SetEmailVerified(context.Background(), db, u.ID)) + t.Cleanup(func() { + db.Exec(`DELETE FROM vault_secrets WHERE team_id = $1`, teamID) + db.Exec(`DELETE FROM vault_audit_log WHERE team_id = $1`, teamID) + db.Exec(`DELETE FROM users WHERE team_id = $1`, teamID) + db.Exec(`DELETE FROM teams WHERE id = $1`, teamID) + }) + jwt := testhelpers.MustSignSessionJWT(t, u.ID.String(), teamID.String(), testhelpers.UniqueEmail(t)) + return teamID, u.ID, jwt +} + +// vaultBlockReq issues a JSON request to the test app carrying the supplied +// bearer JWT and returns (status, decoded JSON body). Body may be nil. Built +// on the existing doJSON helper (which takes a headers map) so it does NOT +// redefine it. +func vaultBlockReq(t *testing.T, app *fiber.App, jwt, method, path string, body any) (int, map[string]any) { + t.Helper() + headers := map[string]string{} + if jwt != "" { + headers["Authorization"] = "Bearer " + jwt + } + resp := doJSON(t, app, method, path, body, headers) + return resp.StatusCode, decodeBody(t, resp) +} + +// vaultBlockRawCiphertext reads the encrypted_value bytes stored at rest for +// the latest version of (team,env,key). Used to assert the value is NOT stored +// as plaintext (encrypt-at-rest contract) and that it decrypts to the original. +func vaultBlockRawCiphertext(t *testing.T, db *sql.DB, teamID uuid.UUID, env, key string) []byte { + t.Helper() + var raw []byte + err := db.QueryRow(` + SELECT encrypted_value FROM vault_secrets + WHERE team_id = $1 AND env = $2 AND key = $3 + ORDER BY version DESC LIMIT 1 + `, teamID, env, key).Scan(&raw) + require.NoError(t, err, "read encrypted_value at rest") + return raw +} + +// vaultBlockSetEnvPolicy writes the team's env_policy JSONB so the +// RequireEnvAccess gate can be exercised (e.g. lock production vault_write to +// owners only). Passing an empty string clears it back to the default-allow {}. +func vaultBlockSetEnvPolicy(t *testing.T, db *sql.DB, teamID uuid.UUID, policyJSON string) { + t.Helper() + if policyJSON == "" { + policyJSON = "{}" + } + _, err := db.Exec(`UPDATE teams SET env_policy = $2::jsonb WHERE id = $1`, teamID, policyJSON) + require.NoError(t, err, "set env_policy") +} + +// vaultBlockCrossTeamRefused is satisfied for the cross-team-isolation +// assertions: acting on another team's secret must NEVER succeed. Vault's +// contract refuses with 404 (never 403) so existence is unobservable — we +// accept 404 and reject any 2xx. +func vaultBlockCrossTeamRefused(status int) bool { + return status == http.StatusNotFound +} diff --git a/internal/handlers/vault_block_routes_test.go b/internal/handlers/vault_block_routes_test.go new file mode 100644 index 0000000..c874b93 --- /dev/null +++ b/internal/handlers/vault_block_routes_test.go @@ -0,0 +1,541 @@ +package handlers_test + +// vault_block_routes_test.go — W3 vault-block integration suite. +// +// Covers the per-team encrypted-secret vault user-flow block from +// USER-FLOW-INVENTORY-AND-TEST-MATRIX.md (2026-06-04) §W3. Each route below +// was, in internal/router/route_donebar_guard_test.go, either pointing at the +// shallow TestMerged_Vault_RequiresAuth requires-auth probe (GET list, GET +// key, PUT key) or listed in routeCoverageExemptions with a "TODO: matrix W3 +// …" pointer and NO mapped test (rotate, delete, copy). This suite supplies +// the DB-backed integration coverage the done-bar guard's routeTestMap now +// points at, so all six routes move (shallow|exempt) → mapped. +// +// Routes covered here (by route key): +// +// GET /api/v1/vault/:env ListKeys → TestVaultBlock_ListKeys +// GET /api/v1/vault/:env/:key GetSecret → TestVaultBlock_GetSecret +// PUT /api/v1/vault/:env/:key PutSecret → TestVaultBlock_PutSecret +// POST /api/v1/vault/:env/:key/rotate RotateSecret → TestVaultBlock_RotateSecret +// DELETE /api/v1/vault/:env/:key DeleteSecret → TestVaultBlock_DeleteSecret +// POST /api/v1/vault/copy CopySecrets → TestVaultBlock_CopySecrets +// +// Every test runs against a real migrated Postgres (testhelpers.SetupTestDB) +// through the PRODUCTION RequireAuth + PopulateTeamRole + RequireEnvAccess +// chain (vaultBlockApp mirrors internal/router/router.go), asserting where +// applicable: +// - happy path (correct 2xx + persisted versioned row / response contract), +// - authz (owner / member / env-policy-gated role → 200 / 403), +// - cross-team isolation (another team's secret → 404, never 403), +// - the encrypt/decrypt-at-rest contract (ciphertext at rest ≠ plaintext; +// GET decrypts to the original; the list path never returns values), +// - rotate / copy semantics (new version, dry-run, overwrite, skip, missing, +// Pro+ tier gate), +// - input validation (invalid env / key / body → 400). +// +// Skips loudly when TEST_DATABASE_URL is unset (vaultBlockSkipNoDB). + +import ( + "context" + "database/sql" + "encoding/base64" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/crypto" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// ───────────────────────────────────────────────────────────────────────── +// PUT /api/v1/vault/:env/:key — VaultHandler.PutSecret (write) +// ───────────────────────────────────────────────────────────────────────── + +func TestVaultBlock_PutSecret(t *testing.T) { + vaultBlockSkipNoDB(t) + db, cleanup := vaultBlockDB(t) + defer cleanup() + + t.Run("hobby member writes a secret to production: 201 + v1 + encrypt-at-rest", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "hobby", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + + status, body := vaultBlockReq(t, app, jwt, http.MethodPut, + "/api/v1/vault/production/DATABASE_URL", map[string]any{"value": "postgres://secret-conn"}) + require.Equal(t, http.StatusCreated, status, "body=%v", body) + assert.Equal(t, true, body["ok"]) + assert.Equal(t, "DATABASE_URL", body["key"]) + assert.Equal(t, "production", body["env"]) + assert.EqualValues(t, 1, body["version"]) + + // Encrypt-at-rest contract: the stored bytes must NOT contain the + // plaintext, and must decrypt back to the original via GET. + raw := vaultBlockRawCiphertext(t, db, teamID, "production", "DATABASE_URL") + assert.NotContains(t, string(raw), "postgres://secret-conn", + "value must be encrypted at rest, never stored as plaintext") + + gs, gb := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/DATABASE_URL", nil) + require.Equal(t, http.StatusOK, gs) + assert.Equal(t, "postgres://secret-conn", gb["value"], "ciphertext decrypts to the original plaintext") + }) + + t.Run("re-PUT same key bumps to v2 (versioned write)", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "hobby", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/API_KEY", + map[string]any{"value": "v1-value"}) + status, body := vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/API_KEY", + map[string]any{"value": "v2-value"}) + require.Equal(t, http.StatusCreated, status, "body=%v", body) + assert.EqualValues(t, 2, body["version"], "second write to same key is v2") + + gs, gb := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/API_KEY", nil) + require.Equal(t, http.StatusOK, gs) + assert.Equal(t, "v2-value", gb["value"], "GET returns the latest version by default") + }) + + t.Run("free tier: vault not available → 403", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "free", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/X", + map[string]any{"value": "v"}) + require.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "vault_not_available", body["error"]) + }) + + t.Run("hobby tier: non-production env not allowed → 403", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "hobby", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/staging/X", + map[string]any{"value": "v"}) + require.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "vault_env_not_allowed", body["error"], + "hobby vault_envs_allowed is production-only") + }) + + t.Run("env_policy locks production vault_write to owner → developer member 403 env_policy_denied", func(t *testing.T) { + teamID, _, _ := vaultBlockSeedTeamMember(t, db, "pro", "owner") + // Add a developer member to the SAME team and lock the env policy. + dev, err := models.CreateUser(context.Background(), db, teamID, "vbdev-"+uuid.NewString()[:8]+"@example.com", "", "", "developer") + require.NoError(t, err) + require.NoError(t, models.SetEmailVerified(context.Background(), db, dev.ID)) + devJWT := signVaultBlockJWT(t, dev.ID, teamID) + vaultBlockSetEnvPolicy(t, db, teamID, `{"production":{"vault_write":["owner"]}}`) + + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, devJWT, http.MethodPut, "/api/v1/vault/production/SECRET", + map[string]any{"value": "v"}) + require.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "env_policy_denied", body["error"], + "RequireEnvAccess(vault_write) gates the mutating route on env_policy") + }) + + t.Run("invalid key → 400", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "hobby", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/bad%20key", + map[string]any{"value": "v"}) + require.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_key", body["error"]) + }) + + t.Run("missing bearer → 401", func(t *testing.T) { + app := vaultBlockApp(t, db, miniRedis(t)) + status, _ := vaultBlockReq(t, app, "", http.MethodPut, "/api/v1/vault/production/X", + map[string]any{"value": "v"}) + assert.Equal(t, http.StatusUnauthorized, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /api/v1/vault/:env/:key — VaultHandler.GetSecret (read) +// ───────────────────────────────────────────────────────────────────────── + +func TestVaultBlock_GetSecret(t *testing.T) { + vaultBlockSkipNoDB(t) + db, cleanup := vaultBlockDB(t) + defer cleanup() + + t.Run("happy path returns decrypted value + version", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/TOKEN", + map[string]any{"value": "tok-123"}) + + status, body := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/TOKEN", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + assert.Equal(t, "tok-123", body["value"]) + assert.EqualValues(t, 1, body["version"]) + }) + + t.Run("explicit ?version=1 returns that version after a v2 write", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/ROT", + map[string]any{"value": "first"}) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/ROT", + map[string]any{"value": "second"}) + + status, body := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/ROT?version=1", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, "first", body["value"]) + assert.EqualValues(t, 1, body["version"]) + }) + + t.Run("missing key → 404", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/NOPE", nil) + require.Equal(t, http.StatusNotFound, status) + assert.Equal(t, "not_found", body["error"]) + }) + + t.Run("cross-team isolation: team B cannot read team A's secret → 404", func(t *testing.T) { + teamA, _, jwtA := vaultBlockSeedTeamMember(t, db, "pro", "owner") + _, _, jwtB := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + + _, _ = vaultBlockReq(t, app, jwtA, http.MethodPut, "/api/v1/vault/production/ISOLATED", + map[string]any{"value": "team-a-only"}) + + status, body := vaultBlockReq(t, app, jwtB, http.MethodGet, "/api/v1/vault/production/ISOLATED", nil) + require.True(t, vaultBlockCrossTeamRefused(status), + "cross-team read must be refused (404, never 403); got %d", status) + assert.Equal(t, "not_found", body["error"], "existence of another team's secret must be unobservable") + _ = teamA + }) + + t.Run("invalid version → 400", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, _ := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/X?version=abc", nil) + assert.Equal(t, http.StatusBadRequest, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// GET /api/v1/vault/:env — VaultHandler.ListKeys (list, never values) +// ───────────────────────────────────────────────────────────────────────── + +func TestVaultBlock_ListKeys(t *testing.T) { + vaultBlockSkipNoDB(t) + db, cleanup := vaultBlockDB(t) + defer cleanup() + + t.Run("lists key names only — never values", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/AAA", + map[string]any{"value": "secret-aaa"}) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/BBB", + map[string]any{"value": "secret-bbb"}) + + status, body := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, true, body["ok"]) + assert.Equal(t, "production", body["env"]) + keys, ok := body["keys"].([]any) + require.True(t, ok, "keys array present") + assert.ElementsMatch(t, []any{"AAA", "BBB"}, keys) + + // The list path must never leak ciphertext or plaintext values. + _, hasValue := body["value"] + assert.False(t, hasValue, "list response must not carry any value field") + }) + + t.Run("cross-team isolation: team B sees an empty list for an env team A populated", func(t *testing.T) { + _, _, jwtA := vaultBlockSeedTeamMember(t, db, "pro", "owner") + _, _, jwtB := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwtA, http.MethodPut, "/api/v1/vault/production/ONLY_A", + map[string]any{"value": "v"}) + + status, body := vaultBlockReq(t, app, jwtB, http.MethodGet, "/api/v1/vault/production", nil) + require.Equal(t, http.StatusOK, status) + keys, _ := body["keys"].([]any) + assert.Empty(t, keys, "team B's vault must not enumerate team A's keys") + }) + + t.Run("invalid env → 400", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/bad$env", nil) + require.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_env", body["error"]) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// POST /api/v1/vault/:env/:key/rotate — VaultHandler.RotateSecret +// ───────────────────────────────────────────────────────────────────────── + +func TestVaultBlock_RotateSecret(t *testing.T) { + vaultBlockSkipNoDB(t) + db, cleanup := vaultBlockDB(t) + defer cleanup() + + t.Run("rotate creates a new version + audits as 'rotate'", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/ROTKEY", + map[string]any{"value": "old"}) + + status, body := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/production/ROTKEY/rotate", + map[string]any{"value": "new"}) + require.Equal(t, http.StatusCreated, status, "body=%v", body) + assert.EqualValues(t, 2, body["version"], "rotate bumps to a new version") + + gs, gb := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/ROTKEY", nil) + require.Equal(t, http.StatusOK, gs) + assert.Equal(t, "new", gb["value"], "rotate replaces the latest value") + + n, err := models.CountVaultAudit(context.Background(), db, teamID, "rotate", "production", "ROTKEY") + require.NoError(t, err) + assert.Equal(t, 1, n, "rotate writes a distinct 'rotate' audit action") + }) + + t.Run("rotate is idempotent under a repeated Idempotency-Key (no duplicate version)", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/IDEM", + map[string]any{"value": "base"}) + + idemKey := "idem-" + uuid.NewString() + body := map[string]any{"value": "rotated"} + h := map[string]string{"Authorization": "Bearer " + jwt, "Idempotency-Key": idemKey} + r1 := doJSON(t, app, http.MethodPost, "/api/v1/vault/production/IDEM/rotate", body, h) + _ = decodeBody(t, r1) + r2 := doJSON(t, app, http.MethodPost, "/api/v1/vault/production/IDEM/rotate", body, h) + _ = decodeBody(t, r2) + + var maxVersion int + require.NoError(t, db.QueryRow(` + SELECT COALESCE(MAX(version),0) FROM vault_secrets + WHERE team_id=$1 AND env='production' AND key='IDEM' + `, teamID).Scan(&maxVersion)) + assert.Equal(t, 2, maxVersion, "replayed rotate under one Idempotency-Key must not create a 3rd version") + }) + + t.Run("missing bearer → 401", func(t *testing.T) { + app := vaultBlockApp(t, db, miniRedis(t)) + status, _ := vaultBlockReq(t, app, "", http.MethodPost, "/api/v1/vault/production/X/rotate", + map[string]any{"value": "v"}) + assert.Equal(t, http.StatusUnauthorized, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// DELETE /api/v1/vault/:env/:key — VaultHandler.DeleteSecret (hard delete) +// ───────────────────────────────────────────────────────────────────────── + +func TestVaultBlock_DeleteSecret(t *testing.T) { + vaultBlockSkipNoDB(t) + db, cleanup := vaultBlockDB(t) + defer cleanup() + + t.Run("delete removes ALL versions → 204; subsequent GET 404", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/DELME", + map[string]any{"value": "v1"}) + _, _ = vaultBlockReq(t, app, jwt, http.MethodPut, "/api/v1/vault/production/DELME", + map[string]any{"value": "v2"}) + + resp := doJSON(t, app, http.MethodDelete, "/api/v1/vault/production/DELME", nil, + map[string]string{"Authorization": "Bearer " + jwt}) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + var n int + require.NoError(t, db.QueryRow(` + SELECT COUNT(*) FROM vault_secrets WHERE team_id=$1 AND env='production' AND key='DELME' + `, teamID).Scan(&n)) + assert.Equal(t, 0, n, "hard delete removes every version") + + gs, _ := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/DELME", nil) + assert.Equal(t, http.StatusNotFound, gs) + }) + + t.Run("delete of a non-existent key → 404 (non-leaking)", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + resp := doJSON(t, app, http.MethodDelete, "/api/v1/vault/production/GHOST", nil, + map[string]string{"Authorization": "Bearer " + jwt}) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("cross-team isolation: team B cannot delete team A's secret → 404 + secret survives", func(t *testing.T) { + teamA, _, jwtA := vaultBlockSeedTeamMember(t, db, "pro", "owner") + _, _, jwtB := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + _, _ = vaultBlockReq(t, app, jwtA, http.MethodPut, "/api/v1/vault/production/KEEP", + map[string]any{"value": "team-a"}) + + resp := doJSON(t, app, http.MethodDelete, "/api/v1/vault/production/KEEP", nil, + map[string]string{"Authorization": "Bearer " + jwtB}) + require.True(t, vaultBlockCrossTeamRefused(resp.StatusCode), + "cross-team delete must be refused (404); got %d", resp.StatusCode) + + var n int + require.NoError(t, db.QueryRow(` + SELECT COUNT(*) FROM vault_secrets WHERE team_id=$1 AND env='production' AND key='KEEP' + `, teamA).Scan(&n)) + assert.Equal(t, 1, n, "team A's secret must survive team B's delete attempt") + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// POST /api/v1/vault/copy — VaultHandler.CopySecrets (Pro+ bulk copy) +// ───────────────────────────────────────────────────────────────────────── + +func TestVaultBlock_CopySecrets(t *testing.T) { + vaultBlockSkipNoDB(t) + db, cleanup := vaultBlockDB(t) + defer cleanup() + + t.Run("pro tier copies staging→production with encrypted bytes preserved", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + // Seed two keys in staging directly (bypasses hobby env restriction; + // pro vault_envs_allowed is production-only but CopySecrets reads from + // any source env — it gates on multiEnvTierAllowed, not the env list). + seedVaultSecret(t, db, teamID, "staging", "ALPHA", "alpha-secret") + seedVaultSecret(t, db, teamID, "staging", "BETA", "beta-secret") + + status, body := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "staging", "to": "production"}) + require.Equal(t, http.StatusOK, status, "body=%v", body) + assert.EqualValues(t, 2, body["copied"]) + assert.EqualValues(t, 0, body["skipped"]) + + // Copied secrets decrypt to the originals in the target env. + gs, gb := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/ALPHA", nil) + require.Equal(t, http.StatusOK, gs) + assert.Equal(t, "alpha-secret", gb["value"]) + }) + + t.Run("dry_run reports the plan but persists nothing", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + seedVaultSecret(t, db, teamID, "staging", "DRY", "dry-secret") + + status, body := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "staging", "to": "production", "dry_run": true}) + require.Equal(t, http.StatusOK, status, "body=%v", body) + assert.Equal(t, true, body["dry_run"]) + assert.EqualValues(t, 1, body["copied"]) + + var n int + require.NoError(t, db.QueryRow(` + SELECT COUNT(*) FROM vault_secrets WHERE team_id=$1 AND env='production' AND key='DRY' + `, teamID).Scan(&n)) + assert.Equal(t, 0, n, "dry_run must not write any target rows") + }) + + t.Run("existing target key skipped by default; overwrite=true bumps version", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + seedVaultSecret(t, db, teamID, "staging", "DUP", "from-staging") + seedVaultSecret(t, db, teamID, "production", "DUP", "already-in-prod") + + // Default: existing target key is skipped. + _, sb := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "staging", "to": "production"}) + assert.EqualValues(t, 0, sb["copied"]) + assert.EqualValues(t, 1, sb["skipped"]) + + // overwrite=true: existing key is bumped to a new version. + _, ob := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "staging", "to": "production", "overwrite": true}) + assert.EqualValues(t, 1, ob["copied"]) + + gs, gb := vaultBlockReq(t, app, jwt, http.MethodGet, "/api/v1/vault/production/DUP", nil) + require.Equal(t, http.StatusOK, gs) + assert.Equal(t, "from-staging", gb["value"], "overwrite copies the source value over the target") + _ = teamID + }) + + t.Run("missing source key reported as 'missing'", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "staging", "to": "production", "keys": []string{"ABSENT"}}) + require.Equal(t, http.StatusOK, status, "body=%v", body) + assert.EqualValues(t, 1, body["missing"]) + assert.EqualValues(t, 0, body["copied"]) + }) + + t.Run("hobby tier: not multi-env → 402 upgrade required", func(t *testing.T) { + teamID, _, jwt := vaultBlockSeedTeamMember(t, db, "hobby", "owner") + seedVaultSecret(t, db, teamID, "staging", "X", "v") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "staging", "to": "production"}) + require.Equal(t, http.StatusPaymentRequired, status) + assert.Contains(t, body, "agent_action", "402 carries the canonical upgrade agent_action") + }) + + t.Run("from == to → 400", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "production", "to": "production"}) + require.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_target", body["error"]) + }) + + t.Run("missing 'from' → 400 invalid_env", func(t *testing.T) { + _, _, jwt := vaultBlockSeedTeamMember(t, db, "pro", "owner") + app := vaultBlockApp(t, db, miniRedis(t)) + status, body := vaultBlockReq(t, app, jwt, http.MethodPost, "/api/v1/vault/copy", + map[string]any{"to": "production"}) + require.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "invalid_env", body["error"]) + }) + + t.Run("missing bearer → 401", func(t *testing.T) { + app := vaultBlockApp(t, db, miniRedis(t)) + status, _ := vaultBlockReq(t, app, "", http.MethodPost, "/api/v1/vault/copy", + map[string]any{"from": "staging", "to": "production"}) + assert.Equal(t, http.StatusUnauthorized, status) + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// Local seam helpers (defined here to keep this suite self-contained without +// redefining package-shared helpers). +// ───────────────────────────────────────────────────────────────────────── + +// signVaultBlockJWT mints a real session JWT for an arbitrary (userID, teamID) +// pair — used for the second member in the env_policy authz test, where +// vaultBlockSeedTeamMember (which seeds its own team) doesn't fit. Thin wrapper +// over the package testhelpers signer — does NOT redefine it. +func signVaultBlockJWT(t *testing.T, userID, teamID uuid.UUID) string { + t.Helper() + return testhelpers.MustSignSessionJWT(t, userID.String(), teamID.String(), testhelpers.UniqueEmail(t)) +} + +// seedVaultSecret inserts an AES-256-GCM-encrypted secret straight into +// vault_secrets at the next version for (team,env,key), using the same test +// AES key the app is wired with so a later GET decrypts it. Used to stage +// source secrets for copy tests in envs the tier's write path would reject +// (e.g. staging on hobby/pro), keeping the copy assertions focused on the +// copy contract rather than the write path. Mirrors VaultHandler.encryptPlaintext: +// crypto.Encrypt returns a base64url string; the at-rest representation is the +// decoded raw bytes (opaque BYTEA). +func seedVaultSecret(t *testing.T, db *sql.DB, teamID uuid.UUID, env, key, plaintext string) { + t.Helper() + aesKey, err := crypto.ParseAESKey(testhelpers.TestAESKeyHex) + require.NoError(t, err) + encoded, err := crypto.Encrypt(aesKey, plaintext) + require.NoError(t, err) + raw, err := base64.URLEncoding.DecodeString(encoded) + require.NoError(t, err) + _, err = models.CreateVaultSecret(context.Background(), db, teamID, env, key, raw, uuid.NullUUID{}) + require.NoError(t, err, "seed vault secret %s/%s", env, key) +} diff --git a/internal/router/route_donebar_guard_test.go b/internal/router/route_donebar_guard_test.go index 166b741..cacdb4f 100644 --- a/internal/router/route_donebar_guard_test.go +++ b/internal/router/route_donebar_guard_test.go @@ -232,10 +232,21 @@ var routeTestMap = map[string]string{ "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", - "PUT /api/v1/vault/:env/:key": "TestMerged_Vault_RequiresAuth", + // ── vault: per-team encrypted secret store (W3) — DB-backed handler- + // integration suite (internal/handlers/vault_block_routes_test.go). Each + // row points at the TestVaultBlock_* test that drives the route through the + // production RequireAuth + PopulateTeamRole + RequireEnvAccess(VaultWrite) + // chain (vaultBlockApp mirrors router.New) against a real Postgres: happy + // path + tier/env-policy authz (403/402) + cross-team isolation (404, never + // 403) + the encrypt/decrypt-at-rest contract + rotate/copy semantics + + // input validation. Moved here from the shallow TestMerged_Vault_RequiresAuth + // probe (GET/GET/PUT) and from routeCoverageExemptions (rotate/delete/copy). + "GET /api/v1/vault/:env": "TestVaultBlock_ListKeys", + "GET /api/v1/vault/:env/:key": "TestVaultBlock_GetSecret", + "PUT /api/v1/vault/:env/:key": "TestVaultBlock_PutSecret", + "POST /api/v1/vault/:env/:key/rotate": "TestVaultBlock_RotateSecret", + "DELETE /api/v1/vault/:env/:key": "TestVaultBlock_DeleteSecret", + "POST /api/v1/vault/copy": "TestVaultBlock_CopySecrets", } // routeCoverageExemptions lists routes that have NO mapped e2e integration test @@ -373,10 +384,11 @@ var routeCoverageExemptions = map[string]string{ "GET /api/v1/admin/promos/audit": "admin promo audit. TODO: matrix W10 admin-console flow.", "GET /api/v1/admin/promos/stats": "admin promo stats. TODO: matrix W10 admin-console flow.", - // ── vault rotate / copy (vault read/write contract is mapped). - "POST /api/v1/vault/:env/:key/rotate": "vault secret rotate (read/write contract mapped via TestMerged_Vault_RequiresAuth). TODO: matrix W3 vault-rotate flow.", - "DELETE /api/v1/vault/:env/:key": "vault secret delete. TODO: matrix W3 vault-delete flow.", - "POST /api/v1/vault/copy": "vault cross-env copy. TODO: matrix W3 vault-copy flow.", + // ── vault rotate / delete / copy — MOVED to routeTestMap. Now covered by + // the W3 vault-block handler-integration suite + // (internal/handlers/vault_block_routes_test.go, TestVaultBlock_*), which + // also upgraded the GET/GET/PUT rows off the shallow + // TestMerged_Vault_RequiresAuth probe to full contract coverage. } // buildLiveRouter constructs the production router in-memory. Route