From 87c3cf643fa77d1ac3ee2e21d2d9d356c9bf503a Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 5 Jun 2026 01:58:33 +0530 Subject: [PATCH] test(matrix): stacks + storage-presign block integration tests (W4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining P0/P1 API matrix legs: stacks (D2 over-cap 402, D4 anon NULL-team+6h TTL, D11 multi-service persist+env-tag, D12 env-merge counters / auth / deleting-409) and storage presign (C16 tenant-prefix-scoped, ≤1h TTL, cross-team reject, broker no-long-lived-cred, non-storage 400, inactive 410, unknown 404, path-traversal reject). New test files only; no source edits. Co-Authored-By: Claude Opus 4.8 (1M context) --- dump.rdb | Bin 25009 -> 0 bytes .../handlers/stacks_block_integration_test.go | 453 ++++++++++++++++++ .../storage_presign_block_integration_test.go | 235 +++++++++ 3 files changed, 688 insertions(+) delete mode 100644 dump.rdb create mode 100644 internal/handlers/stacks_block_integration_test.go create mode 100644 internal/handlers/storage_presign_block_integration_test.go diff --git a/dump.rdb b/dump.rdb deleted file mode 100644 index 3c0a4fbcf30e648b960ade3365ab6420c4de5b95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25009 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 qI{F4;)MF;aZohAOJTbkS?P*2xd= minRows { + break + } + if time.Now().After(deadline) { + t.Fatalf("stack.env.updated audit row never landed: have %d, want >=%d", n, minRows) + } + time.Sleep(20 * time.Millisecond) + } + var meta []byte + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT metadata FROM audit_log + WHERE kind='stack.env.updated' AND resource_id=$1::uuid + ORDER BY created_at DESC, id DESC LIMIT 1`, + stackID).Scan(&meta)) + var c stackBlockEnvCounters + require.NoError(t, json.Unmarshal(meta, &c)) + return c +} + +// stackIDForSlug resolves a stack's UUID from its slug (audit_log.resource_id +// is the stack UUID, not the slug). +func stackIDForSlug(t *testing.T, db *sql.DB, slug string) string { + t.Helper() + var id string + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT id::text FROM stacks WHERE slug=$1`, slug).Scan(&id)) + return id +} + +// readStackEnvVars reads stacks.env_vars directly so persistence is asserted +// against the row, not the (masked) handler response. +func readStackEnvVars(t *testing.T, db *sql.DB, slug string) map[string]string { + t.Helper() + var raw sql.NullString + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT env_vars::text FROM stacks WHERE slug = $1`, slug).Scan(&raw)) + out := map[string]string{} + if raw.Valid && raw.String != "" { + require.NoError(t, json.Unmarshal([]byte(raw.String), &out)) + } + return out +} + +// ── D11 — multi-service create persists stack + per-service rows + env tag ──── + +// TestStackBlock_D11_MultiService_PersistsMembersAndEnvTag is the matrix D11 +// row: a 2-service manifest must persist one stacks row plus exactly one +// stack_services row per declared service, and (rule 11) every row must carry +// the resolved env tag. With no `env` field on /stacks/new, the default is +// 'development' (mig 026) — accidental no-env creates land in the lowest-stakes +// bucket, never production. +func TestStackBlock_D11_MultiService_PersistsMembersAndEnvTag(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") // pro: deployments_apps=10 + jwt := testhelpers.MustSignSessionJWT(t, mustUUIDStr(), teamID, "d11@example.com") + app := newStackTestApp(t, db) + + tarball := createMinimalTarball(t) + resp := postStackNew(t, app, jwt, testManifest, map[string][]byte{ + "api": tarball, + "worker": tarball, + }) + defer resp.Body.Close() + require.Equal(t, http.StatusAccepted, resp.StatusCode) + + var created struct { + OK bool `json:"ok"` + StackID string `json:"stack_id"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) + require.True(t, created.OK) + slug := created.StackID + require.NotEmpty(t, slug) + + // One stacks row, env tag = development (rule 11 default). + var stackEnv, stackTier string + var stackTeam sql.NullString + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT env, tier, team_id::text FROM stacks WHERE slug = $1`, slug, + ).Scan(&stackEnv, &stackTier, &stackTeam)) + assert.Equal(t, "development", stackEnv, "no-env create resolves to development (mig 026 / rule 11)") + assert.Equal(t, "pro", stackTier, "authenticated stack snapshots the team plan_tier at creation") + require.True(t, stackTeam.Valid, "authenticated stack must carry the team_id") + assert.Equal(t, teamID, stackTeam.String) + + // Exactly two member services, both linked to the stack. + var svcCount int + require.NoError(t, db.QueryRowContext(context.Background(), ` + SELECT COUNT(*) FROM stack_services ss + JOIN stacks s ON s.id = ss.stack_id + WHERE s.slug = $1`, slug).Scan(&svcCount)) + assert.Equal(t, 2, svcCount, "one stack_services row per declared manifest service") + + // The member service NAMES match the manifest (enumeration, not a count). + rows, err := db.QueryContext(context.Background(), ` + SELECT ss.name FROM stack_services ss + JOIN stacks s ON s.id = ss.stack_id + WHERE s.slug = $1 ORDER BY ss.name`, slug) + require.NoError(t, err) + defer rows.Close() + var names []string + for rows.Next() { + var n string + require.NoError(t, rows.Scan(&n)) + names = append(names, n) + } + require.NoError(t, rows.Err()) + assert.Equal(t, []string{"api", "worker"}, names, "member rows must be exactly the manifest services") +} + +// ── D4 — anonymous stack: NULL team_id + 6h TTL ────────────────────────────── + +// TestStackBlock_D4_Anonymous_NullTeamAnd6hTTL is the matrix D4 row. Anonymous +// deploy goes through /stacks/new (NOT /deploy/new, which is RequireAuth + +// deployments.team_id NOT NULL — memory +// project_anonymous_deploy_via_stacks_not_deploy_new). The row must carry NULL +// team_id and a ~6h expires_at (PR #214 anonymousStackTTL), tighter than the +// 24h anon-data-resource TTL because a stack is live compute. +func TestStackBlock_D4_Anonymous_NullTeamAnd6hTTL(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + app := newStackTestApp(t, db) + + resp := postStackNew(t, app, "", testManifestSingleService, map[string][]byte{ + "web": createMinimalTarball(t), + }) + defer resp.Body.Close() + if resp.StatusCode == http.StatusServiceUnavailable { + t.Skip("POST /stacks/new: service unavailable") + } + require.Equal(t, http.StatusAccepted, resp.StatusCode) + + var body struct { + OK bool `json:"ok"` + StackID string `json:"stack_id"` + Tier string `json:"tier"` + ExpiresIn string `json:"expires_in"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.True(t, body.OK) + assert.Equal(t, "anonymous", body.Tier) + assert.Equal(t, "6h", body.ExpiresIn) + + var teamID sql.NullString + var expiresAt sql.NullTime + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT team_id::text, expires_at FROM stacks WHERE slug = $1`, body.StackID, + ).Scan(&teamID, &expiresAt)) + assert.False(t, teamID.Valid, "anonymous stack must have NULL team_id (mig 005)") + require.True(t, expiresAt.Valid, "anonymous stack must have a non-NULL TTL") + assert.InDelta(t, (6 * time.Hour).Seconds(), time.Until(expiresAt.Time).Seconds(), 300, + "anon stack TTL must be ~6h, not the 24h data-resource window") +} + +// ── D2/C2.S — create over deployments_apps cap → 402 + agent_action ────────── + +// TestStackBlock_D2_OverCap_402WithAgentAction is the matrix D2 / C2.S row: +// hobby allows deployments_apps=1 (plans.yaml). With one active stack already +// present, the next /stacks/new must 402 and carry an agent_action upgrade hint +// (rule 3 — the limit comes from plans.Registry, never a hardcoded literal). +func TestStackBlock_D2_OverCap_402WithAgentAction(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + app := newStackTestApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + // Seed one active (compute-consuming) stack so the next create trips the cap. + _, err := db.Exec(`INSERT INTO stacks (team_id, slug, namespace, tier, env, status) + VALUES ($1::uuid, $2, $3, 'hobby', 'production', 'healthy')`, + teamID, "blk-cap-"+teamID[:8], "ns-blk-cap-"+teamID[:8]) + require.NoError(t, err) + + jwt := testhelpers.MustSignSessionJWT(t, mustUUIDStr(), teamID, "d2cap@example.com") + resp := postStackNew(t, app, jwt, testManifestSingleService, map[string][]byte{ + "web": createMinimalTarball(t), + }) + defer resp.Body.Close() + require.Equal(t, http.StatusPaymentRequired, resp.StatusCode) + + var body struct { + OK bool `json:"ok"` + Error string `json:"error"` + AgentAction string `json:"agent_action"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.False(t, body.OK) + assert.NotEmpty(t, body.AgentAction, "402 over-cap must carry an agent_action upgrade hint") +} + +// ── D12 — env merge JSONB + keys_set/keys_deleted/total_after counters ──────── + +// TestStackBlock_D12_EnvMergeCounters is the matrix D12 row and the regression +// gate for the rule-12 audit-surface fix (stack.go: "keys_set counts only +// actual upserts"). It walks a 4-step sequence and asserts BOTH the response +// counters AND the persisted env_vars at each step: +// +// 1. set 2 keys → keys_set=2, deleted=0, total=2 +// 2. add 1 + overwrite 1 → keys_set=2, deleted=0, total=3 +// 3. delete 1 present + no-op delete 1 → keys_set=0, deleted=1, total=2 +// absent key (the over-count bug) +// 4. set 1 + delete 1 in same patch → keys_set=1, deleted=1, total=2 +// +// Step 3 is the load-bearing assertion: the pre-fix math len(body.Env)-deletes +// would have reported keys_set=1 for a patch that set NOTHING (one real delete +// + one no-op empty-delete of an absent key). keys_deleted counts only deletes +// that actually removed a present key. +func TestStackBlock_D12_EnvMergeCounters(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + jwt := testhelpers.MustSignSessionJWT(t, mustUUIDStr(), teamID, "d12@example.com") + app := newStackTestApp(t, db) + + resp := postStackNew(t, app, jwt, testManifestSingleService, map[string][]byte{ + "web": createMinimalTarball(t), + }) + require.Equal(t, http.StatusAccepted, resp.StatusCode) + var created struct { + StackID string `json:"stack_id"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) + resp.Body.Close() + slug := created.StackID + require.NotEmpty(t, slug) + stackID := stackIDForSlug(t, db, slug) + + // The counters are stamped on the audit_log row (rule 12: the ledger is the + // truth surface, not the handler's 200 — the HTTP body carries only the + // merged env). Each PATCH adds one stack.env.updated row; we read the latest + // after waiting for the Nth row to land (audit insert is fire-and-forget). + + // 1) Set two keys. + r1, b1 := patchStackEnvBlock(t, app, slug, map[string]string{ + "API_URL": "https://api.example", + "LOG_LEVEL": "info", + }, jwt) + r1.Body.Close() + require.Equal(t, http.StatusOK, r1.StatusCode) + assert.True(t, b1.OK) + c1 := stackBlockEnvAuditCounters(t, db, stackID, 1) + assert.Equal(t, 2, c1.KeysSet, "two non-empty upserts") + assert.Equal(t, 0, c1.KeysDeleted) + assert.Equal(t, 2, c1.TotalAfter) + assert.Len(t, readStackEnvVars(t, db, slug), 2) + + // 2) Add one, overwrite one — both are upserts. + r2, _ := patchStackEnvBlock(t, app, slug, map[string]string{ + "LOG_LEVEL": "debug", // overwrite + "FEATURE_X": "on", // new + }, jwt) + r2.Body.Close() + require.Equal(t, http.StatusOK, r2.StatusCode) + c2 := stackBlockEnvAuditCounters(t, db, stackID, 2) + assert.Equal(t, 2, c2.KeysSet, "overwrite + new both count as set") + assert.Equal(t, 0, c2.KeysDeleted) + assert.Equal(t, 3, c2.TotalAfter) + + // 3) Delete one PRESENT key + no-op delete one ABSENT key. This is the + // over-count guard: keys_set MUST be 0 (the pre-fix len(body.Env)-deletes + // math would have made it 1), keys_deleted MUST be 1 (only the present key + // was actually removed — the absent no-op increments neither). + r3, _ := patchStackEnvBlock(t, app, slug, map[string]string{ + "FEATURE_X": "", // delete (present) + "NEVER_SET": "", // no-op delete (absent) — counts as NEITHER + }, jwt) + r3.Body.Close() + require.Equal(t, http.StatusOK, r3.StatusCode) + c3 := stackBlockEnvAuditCounters(t, db, stackID, 3) + assert.Equal(t, 0, c3.KeysSet, "an all-empty-value patch sets nothing — pre-fix bug reported 1") + assert.Equal(t, 1, c3.KeysDeleted, "only the present key counts as deleted; the absent no-op does not") + assert.Equal(t, 2, c3.TotalAfter) + got3 := readStackEnvVars(t, db, slug) + _, hasFeature := got3["FEATURE_X"] + assert.False(t, hasFeature, "deleted key gone from env_vars") + assert.Equal(t, "https://api.example", got3["API_URL"], "untouched key survives the merge") + assert.Equal(t, "debug", got3["LOG_LEVEL"]) + + // 4) Mixed set + delete in one patch. + r4, _ := patchStackEnvBlock(t, app, slug, map[string]string{ + "API_URL": "", // delete (present) + "NEW_TOKEN": "v", // set + }, jwt) + r4.Body.Close() + require.Equal(t, http.StatusOK, r4.StatusCode) + c4 := stackBlockEnvAuditCounters(t, db, stackID, 4) + assert.Equal(t, 1, c4.KeysSet, "one real upsert") + assert.Equal(t, 1, c4.KeysDeleted, "one real delete") + assert.Equal(t, 2, c4.TotalAfter, "LOG_LEVEL + NEW_TOKEN remain") +} + +// TestStackBlock_D12_RequiresAuth confirms the RequireAuth gate on the PATCH +// route — an unauthenticated env merge is a 401, never a silent no-op. +func TestStackBlock_D12_RequiresAuth(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + app := newStackTestApp(t, db) + + resp, _ := patchStackEnvBlock(t, app, "stk-anything", map[string]string{"FOO": "bar"}, "") + resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +// ── D12 — deleting stack rejects env merge under the FOR UPDATE guard ───────── + +// TestStackBlock_D12_DeletingStack_Returns409 is the matrix D12 teardown-guard +// row (mig/PR #238). A stack in status='deleting' must reject PATCH env with +// 409 stack_deleting — the authoritative check lives INSIDE MergeStackEnvVars +// under the SELECT … FOR UPDATE lock (NOT a TOCTOU pre-read), so a stack flipped +// to deleting by the teardown worker between GetStackBySlug and the merge tx is +// still caught. The env_vars row must be unchanged by the rejected patch. +func TestStackBlock_D12_DeletingStack_Returns409(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := uuid.MustParse(testhelpers.MustCreateTeamDB(t, db, "pro")) + jwt := testhelpers.MustSignSessionJWT(t, mustUUIDStr(), teamID.String(), "deleting-blk@example.com") + // seedStackWithService creates a stack in the given status with one service. + stack, _ := seedStackWithService(t, db, &teamID, "deleting", "web") + + app := newStackTestApp(t, db) + resp, body := patchStackEnvBlock(t, app, stack.Slug, map[string]string{"FOO": "bar"}, jwt) + resp.Body.Close() + assert.Equal(t, http.StatusConflict, resp.StatusCode) + assert.Equal(t, "stack_deleting", body.Error) + + // The rejected patch must not have touched env_vars (rolled back in-tx). + assert.Empty(t, readStackEnvVars(t, db, stack.Slug), + "a rejected (stack_deleting) patch must leave env_vars untouched") +} diff --git a/internal/handlers/storage_presign_block_integration_test.go b/internal/handlers/storage_presign_block_integration_test.go new file mode 100644 index 0000000..4f101f7 --- /dev/null +++ b/internal/handlers/storage_presign_block_integration_test.go @@ -0,0 +1,235 @@ +package handlers_test + +// storage_presign_block_integration_test.go — W4 storage-presign-block suite. +// +// Closes the matrix C16 row (Storage presign, broker) — +// docs/sessions/2026-06-04/USER-FLOW-INVENTORY-AND-TEST-MATRIX.md: +// +// C16 POST /storage/:token/presign — signed URL ≤1h, tenant-prefix-scoped; +// per-token rate-limit 10/min; cross-team JWT rejected. Sev P0. +// +// The individual C16 behaviors already have dedicated tests +// (storage_presign_provarms_test.go: GET/PUT/HEAD signing + TTL cap + +// cross-team 403; storage_presign_middleware_test.go: 10/min rate-limit + +// Retry-After). This block suite is the matrix INVENTORY cross-link that +// asserts the C16 contract end-to-end through ONE broker fixture wiring (the +// production middleware chain — OptionalAuth → PresignTokenRateLimit → +// Idempotency → handler), proving the four C16 promises hold together rather +// than in isolation: +// +// 1. tenant-prefix-scoped — the signed object_key is rooted at the resource's +// provider_resource_id prefix; a tenant can never sign outside its prefix. +// 2. ≤1h TTL — expires_at is bounded at ~now+1h even when the caller asks for +// more (presignMaxTTL=3600, silently capped). +// 3. cross-team JWT rejected — a sibling-team session bearer against another +// team's token is 403 cross_team_session (a leaked token laundered through +// a legit-but-wrong-team session must not sign). +// 4. broker mode hands out NO long-lived credential — the only access path is +// the fresh per-request signed URL (the whole point of broker mode). +// +// Plus the two error legs the matrix's "appropriate error" wording implies: +// - a non-storage token → 400 not_a_storage_resource (presign signs storage +// resources only; the handler does NOT mode-gate beyond resource_type + +// active status — broker vs prefix-scoped is a provisioning-time +// distinction, both sign through the platform master key here). +// - an unknown token → 404 resource_not_found. +// +// In-repo integration via the package's setupStorageProvFixture (offline +// do-spaces provider; minio-go signs locally via HMAC, no network) + existing +// seed helpers (seedStorageResource, seedResourceWithType, authSessionJWT, +// doPresign, testhelpers.MustCreateTeamDB). NOTHING here redefines an existing +// helper. + +import ( + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/testhelpers" +) + +// ── C16.1 — tenant-prefix-scoped signed URL ────────────────────────────────── + +// TestPresignBlock_C16_TenantPrefixScoped asserts the signed object key is +// rooted at the resource's provider_resource_id prefix. The tenant supplies a +// relative key ("exports/jan.csv"); the handler MUST prepend the stored prefix, +// so the object the URL grants access to is always inside the tenant's space. +func TestPresignBlock_C16_TenantPrefixScoped(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + const prefix = "tenants/acme" + token := seedStorageResource(t, fx.db, "", prefix) + + resp, body := doPresign(t, fx, token, "", + map[string]any{"operation": "GET", "key": "exports/jan.csv"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, body.OK) + assert.Equal(t, prefix+"/exports/jan.csv", body.ObjectKey, + "signed object_key must be rooted at the resource prefix (tenant-prefix-scoped)") + // The signed URL itself must contain the prefixed object path. + assert.Contains(t, body.URL, prefix+"/exports/jan.csv") +} + +// ── C16.2 — TTL bounded at ≤1h ─────────────────────────────────────────────── + +// TestPresignBlock_C16_TTLBoundedAtOneHour asserts the 1h hard cap. A caller +// asking for 24h is silently capped to presignMaxTTL (3600s) — a leaked 1h URL +// is already a lot of attack surface; longer would approach handing out the +// long-lived key. +func TestPresignBlock_C16_TTLBoundedAtOneHour(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "ttl-prefix") + + resp, body := doPresign(t, fx, token, "", + map[string]any{"operation": "GET", "key": "obj.bin", "expires_in": 24 * 3600}) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + exp, err := time.Parse(time.RFC3339, body.ExpiresAt) + require.NoError(t, err) + assert.LessOrEqual(t, time.Until(exp), time.Hour+2*time.Minute, + "a 24h request must be capped to ~1h (presignMaxTTL)") + assert.Greater(t, time.Until(exp), 30*time.Minute, + "the cap should land near 1h, not collapse the TTL to near-zero") +} + +// ── C16.3 — cross-team session bearer rejected ─────────────────────────────── + +// TestPresignBlock_C16_CrossTeamSessionRejected asserts the session/team +// cross-check: a team-B session bearer presented against team-A's storage token +// is 403 cross_team_session. The token alone is the primary credential, but a +// present session JWT MUST match the resource team — this blocks a leaked token +// being laundered through an admin's view-as-customer session for a different +// tenant. +func TestPresignBlock_C16_CrossTeamSessionRejected(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + ownerTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + otherTeam := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + require.NotEqual(t, ownerTeam, otherTeam) + + token := seedStorageResource(t, fx.db, ownerTeam, "owner-prefix") + otherJWT := authSessionJWT(t, fx.db, otherTeam) + + resp, body := doPresign(t, fx, token, otherJWT, + map[string]any{"operation": "GET", "key": "secret.bin"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, "cross_team_session", body.Error) +} + +// TestPresignBlock_C16_SameTeamSessionSigns confirms the positive side of the +// cross-check: the OWNING team's session bearer signs successfully (a 403 for +// the legitimate owner would be a false-positive lockout). +func TestPresignBlock_C16_SameTeamSessionSigns(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + team := testhelpers.MustCreateTeamDB(t, fx.db, "pro") + token := seedStorageResource(t, fx.db, team, "team-prefix") + jwt := authSessionJWT(t, fx.db, team) + + resp, body := doPresign(t, fx, token, jwt, + map[string]any{"operation": "PUT", "key": "upload.bin"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, body.OK) + assert.Equal(t, "PUT", body.Method) + assert.Contains(t, body.ObjectKey, "team-prefix/upload.bin") +} + +// ── C16.4 — broker mode hands out no long-lived credential ─────────────────── + +// TestPresignBlock_C16_BrokerHandsNoLongLivedCredential asserts the broker +// contract end-to-end: the ONLY access artifact is the short-lived signed URL. +// The presign response must never carry an access_key_id / secret / +// session_token — those belong to the prefix-scoped credential path, not +// broker mode. (The presignResp struct deliberately has no credential fields, +// so we assert positively on what IS returned: a URL + bounded expiry.) +func TestPresignBlock_C16_BrokerHandsNoLongLivedCredential(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "broker-prefix") + + resp, body := doPresign(t, fx, token, "", + map[string]any{"operation": "GET", "key": "data.json"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, body.OK) + assert.NotEmpty(t, body.URL, "broker access path returns a signed URL") + assert.NotEmpty(t, body.ExpiresAt, "the signed URL is short-lived (has an expiry)") + // The signed URL is a SigV4 presigned request, not a credential handout — + // it carries the signature inline and expires. + assert.Contains(t, body.URL, "X-Amz-Signature=", + "broker URL must be a SigV4 presigned request, not a bare credential") + assert.Contains(t, body.URL, "X-Amz-Expires=", + "broker URL must carry an explicit expiry, not a long-lived key") +} + +// ── C16 error legs — non-storage token + unknown token ─────────────────────── + +// TestPresignBlock_C16_NonStorageToken_Returns400 asserts presign only signs +// storage resources. A token owning a postgres resource → 400 +// not_a_storage_resource (this is the "non-broker / wrong resource → appropriate +// error" leg; the handler gates on resource_type, not on the storage backend +// mode, since broker vs prefix-scoped both sign through the master key here). +func TestPresignBlock_C16_NonStorageToken_Returns400(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedResourceWithType(t, fx.db, "postgres", "active") + + resp, body := doPresign(t, fx, token, "", + map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "not_a_storage_resource", body.Error) +} + +// TestPresignBlock_C16_InactiveStorage_Returns410 asserts a paused/inactive +// storage resource cannot be presigned (the credential window is closed) — 410 +// resource_inactive. +func TestPresignBlock_C16_InactiveStorage_Returns410(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedResourceWithType(t, fx.db, "storage", "paused") + + resp, body := doPresign(t, fx, token, "", + map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusGone, resp.StatusCode) + assert.Equal(t, "resource_inactive", body.Error) +} + +// TestPresignBlock_C16_UnknownToken_Returns404 asserts an unknown token UUID is +// 404 resource_not_found (not a 500, not a silent sign of an empty prefix). +func TestPresignBlock_C16_UnknownToken_Returns404(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + + resp, body := doPresign(t, fx, uuid.NewString(), "", + map[string]any{"operation": "GET", "key": "x"}) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "resource_not_found", body.Error) +} + +// TestPresignBlock_C16_PathTraversalRejected asserts a tenant cannot escape its +// prefix via a "../" key — the handler hard-rejects with 400 path_unsafe +// (B17-P0; silent stripping would hide exploit intent). This is the +// tenant-prefix-scoping enforcement at the input boundary. +func TestPresignBlock_C16_PathTraversalRejected(t *testing.T) { + fx := setupStorageProvFixture(t, newDOSpacesProvider(t), false) + token := seedStorageResource(t, fx.db, "", "scoped-prefix") + + for _, key := range []string{"../escape", "a/../../etc", "/leading"} { + resp, body := doPresign(t, fx, token, "", + map[string]any{"operation": "GET", "key": key}) + require.Equalf(t, http.StatusBadRequest, resp.StatusCode, "key=%q", key) + assert.Equalf(t, "path_unsafe", body.Error, "key=%q must reject as path_unsafe", key) + resp.Body.Close() + } +}