From 35cc5c868aaf05adc668e4a31af1220185e7a6c5 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 03:25:29 +0900 Subject: [PATCH] fix: invalidate the TechLog CSRF token on 403 and harden the bootstrap profile wiring Item 1 (real bug): contractOperations.execute only invalidated the cached CSRF token on UNAUTHENTICATED (401). A CSRF-specific rejection normally arrives as FORBIDDEN (403) -- the platform classifies any 403 response as FORBIDDEN unconditionally -- so a token rejected during an ordinary document save left the stale token cached and every subsequent Studio mutation kept failing until reload. Extracted invalidateTechLogCsrfOnOutcome() so production and the composition test call the identical function; it now invalidates on both UNAUTHENTICATED and FORBIDDEN. Item 2: the upload transport's uncontracted-status fallback hardcoded status 503, so an uncontracted 401/403 body never reached the gateway's error.status === 401 || 403 invalidation check. Passes the real response.status through. Item 3: safeOperation()'s auth-profile parameter is now typed as a union of the two valid profile constants instead of a bare string, and assertExactlyOneTechLogStudioBootstrapOperation() fails composition closed if getStudioSession stops being the sole caller of the credential-free bootstrap profile. Item 4: corrected two stale operation counts in the adapter review doc. Both new tests for items 1 and 2 were run and shown failing before their fix, per this task's TDD standard for error-path changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../adapters/06-tech-log-asset-upload.md | 6 +- src/bootstrap/runtime-adapters.ts | 19 +++-- .../adapters/http/asset-upload-transport.ts | 8 +- .../http/studio-session-credentials.ts | 68 +++++++++++++++++ .../tech-log-studio-contract-contribution.ts | 13 +++- .../tech-log/asset-upload-transport.test.ts | 64 ++++++++++++++++ .../studio-contract-contribution.test.ts | 41 ++++++++++ .../tech-log/studio-csrf-composition.test.ts | 76 ++++++++++++++++++- 8 files changed, 283 insertions(+), 12 deletions(-) diff --git a/docs/reviews/adapters/06-tech-log-asset-upload.md b/docs/reviews/adapters/06-tech-log-asset-upload.md index ba624c8..96208f9 100644 --- a/docs/reviews/adapters/06-tech-log-asset-upload.md +++ b/docs/reviews/adapters/06-tech-log-asset-upload.md @@ -25,7 +25,7 @@ canonical Studio API 전체는 19개 operation이다. `tech-log-studio-contract- | JSON, 계약 실행기 경유 | `getStudioSession`, `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioCatalog`, `listStudioAssets`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` (18개) | `contractHttp.execute()` → `client.ts` → `attachCredentials` | | multipart, 플랫폼 우회 | `uploadStudioAsset` (1개) | `asset-upload-transport.ts`의 직접 `fetch()` | -18개는 `contractOperations.execute(operationId, input, { routeId, intent? })`를 통해 나가며 `attachCredentials`가 매 요청에 `x-csrf-token`을 싣는다. 1개(`uploadStudioAsset`)만 이 경로를 완전히 벗어나 `createAssetUploadTransport`가 직접 `fetch()`한다. `StudioAssetGateway.uploadAsset()`이 이 transport를 호출하는 유일한 지점이며, 포트 시그니처(`Promise`)는 나머지 4개 asset operation과 동일해 호출자는 어느 경로인지 알 필요가 없다. +18개는 `contractOperations.execute(operationId, input, { routeId, intent? })`를 통해 나가며, 그중 17개(`getStudioSession` 제외)에 `attachCredentials`가 매 요청 `x-csrf-token`을 싣는다 — `getStudioSession`은 그 토큰을 발급하는 operation 자신이라 CSRF 헤더를 요구하지 않는 `TECH_LOG_STUDIO_BOOTSTRAP` auth profile을 쓴다(자세한 내용은 아래 "유지되는 보증"의 CSRF 행). 1개(`uploadStudioAsset`)만 이 경로를 완전히 벗어나 `createAssetUploadTransport`가 직접 `fetch()`한다. `StudioAssetGateway.uploadAsset()`이 이 transport를 호출하는 유일한 지점이며, 포트 시그니처(`Promise`)는 나머지 4개 asset operation과 동일해 호출자는 어느 경로인지 알 필요가 없다. ## 유지되는 보증 @@ -59,7 +59,7 @@ canonical Studio API 전체는 19개 operation이다. `tech-log-studio-contract- ## 교체 계획 1. **presigned/resumable 업로드로 이전.** `src/adapters/browser-transfer/`에 이미 presigned capability와 resumable checkpoint 인프라가 있다(별도 리뷰: [04 — Browser transfer](./04-browser-transfer.md)). Studio asset 업로드가 그쪽으로 옮겨가면, 이 transport는 presigned URL 발급을 위한 작은 JSON operation(계약 실행기 경유 가능)과 실제 바이트 전송을 위한 presigned executor 호출로 나뉜다. `POST /api/v1/studio/assets`의 multipart 자체가 없어진다. -2. **플랫폼에 `requestBody: "MULTIPART"` 모드가 생기는 경우.** `external-contract-runtime.ts`와 `client.ts`가 `FormData` 본문을 표현할 수 있게 확장되면, `uploadStudioAsset`을 다른 17개 operation과 함께 `tech-log-studio-contract-contribution.ts`에 등록하고 `createHttpStudioAssetGateway`의 `upload` 의존성을 제거한다. `StudioAssetGateway` 포트 시그니처(`uploadAsset(form, options): Promise`)는 바뀌지 않는다 — 교체는 이 파일과 `create-tech-log-feature-input.ts`의 배선 한 줄에서 끝난다. +2. **플랫폼에 `requestBody: "MULTIPART"` 모드가 생기는 경우.** `external-contract-runtime.ts`와 `client.ts`가 `FormData` 본문을 표현할 수 있게 확장되면, `uploadStudioAsset`을 이미 등록된 다른 18개 operation과 함께 `tech-log-studio-contract-contribution.ts`에 등록하고 `createHttpStudioAssetGateway`의 `upload` 의존성을 제거한다. `StudioAssetGateway` 포트 시그니처(`uploadAsset(form, options): Promise`)는 바뀌지 않는다 — 교체는 이 파일과 `create-tech-log-feature-input.ts`의 배선 한 줄에서 끝난다. 두 경로 모두 `StudioAssetUploadTransport`/`StudioAssetGateway` 포트 경계 뒤에서 일어나므로, presentation 계층(Task 11의 Asset Library UI)은 재작성하지 않는다. @@ -77,4 +77,6 @@ corepack pnpm test:tech-log `studio-csrf-composition.test.ts`는 fix round 1에서 추가됐다 — 실 `createContractHttpExecutor` · `createCsrfTokenProvider` · `attachStudioSessionCredentials`를 composition root와 같은 방식으로 조립해 `getStudioSession`이 정확히 한 번만 나가고 그 토큰이 JSON operation과 업로드 양쪽에 모두 실리는지 검증한다. `studio-session-csrf.test.ts`는 provider의 재진입 가드를 단독으로 고정한다. +fix round 2에서 같은 파일에 "JSON operation의 403이 캐시된 토큰을 무효화해 다음 operation이 세션을 다시 가져온다"는 테스트를 더했다 — `invalidateTechLogCsrfOnOutcome`(`studio-session-credentials.ts`)를 composition root와 동일하게 호출한다. `asset-upload-transport.test.ts`에는 업로드 transport가 계약 밖 상태 코드의 실제 HTTP status를 그대로 통과시키는지, 그리고 계약 밖 401 본문도 게이트웨이의 토큰 무효화를 실제로 촉발하는지 검증하는 테스트를 더했다. `studio-contract-contribution.test.ts`에는 `getStudioSession`이 bootstrap profile의 유일한 사용자인지와 `assertExactlyOneTechLogStudioBootstrapOperation`이 0개·2개 위반을 거절하는지 고정하는 테스트를 더했다. + 정확한 실행 결과는 `.superpowers/sdd/2026-08-17-techlog-backend-alignment/task-7-report.md`에 있다. diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index c24e57a..5726227 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -28,7 +28,10 @@ import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts"; import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts"; import { createCsrfTokenProvider } from "../features/tech-log/adapters/http/studio-session-csrf.ts"; -import { attachStudioSessionCredentials } from "../features/tech-log/adapters/http/studio-session-credentials.ts"; +import { + attachStudioSessionCredentials, + invalidateTechLogCsrfOnOutcome, +} from "../features/tech-log/adapters/http/studio-session-credentials.ts"; import type { StudioOperationExecutor as TechLogStudioOperationExecutor } from "../features/tech-log/adapters/http/http-studio-gateway.ts"; import type { AuthSessionPort } from "../application/ports/auth-session-port.ts"; import type { ReleaseInfo } from "../application/ports/release-info-port.ts"; @@ -573,12 +576,16 @@ export async function createRuntimeAdapters( }); if (outcome.kind === "UNAUTHENTICATED") { authSession.onUnauthenticated(); - // The Studio session (and the CSRF token it issued) expired. The - // cache owner discards it here, not the gateway — the gateway has no - // way to know a 401 on one operation invalidates a token shared by - // every other in-flight and future Studio request. - techLogCsrf.invalidate(); } + // Fix round 2, item 1. `UNAUTHENTICATED` (401) and `FORBIDDEN` (403 — + // the shape a CSRF-specific rejection normally takes) both leave a + // stale token cached for every other in-flight and future Studio + // request if nothing discards it. The cache owner discards it here, + // not the gateway — the gateway has no way to know a rejection on one + // operation invalidates a token shared across all of them. Same call + // the composition test drives + // (`tests/features/tech-log/studio-csrf-composition.test.ts`). + invalidateTechLogCsrfOnOutcome(outcome.kind, techLogCsrf); return outcome; }, }); diff --git a/src/features/tech-log/adapters/http/asset-upload-transport.ts b/src/features/tech-log/adapters/http/asset-upload-transport.ts index 6d27a33..5347aec 100644 --- a/src/features/tech-log/adapters/http/asset-upload-transport.ts +++ b/src/features/tech-log/adapters/http/asset-upload-transport.ts @@ -102,7 +102,13 @@ export function createAssetUploadTransport( if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { throw new StudioGatewayError(problem); } - throw unavailable(`Upload returned an uncontracted status ${response.status}.`); + // Fix round 2, item 2. The real status is passed through (not the + // hardcoded 503 default) so `http-studio-asset-gateway.ts`'s + // `error.status === 401 || error.status === 403` check can still act on + // an uncontracted 401/403 body and invalidate the cached CSRF token. + throw unavailable(`Upload returned an uncontracted status ${response.status}.`, { + status: response.status, + }); }, }); } diff --git a/src/features/tech-log/adapters/http/studio-session-credentials.ts b/src/features/tech-log/adapters/http/studio-session-credentials.ts index 19e9f6b..98c7947 100644 --- a/src/features/tech-log/adapters/http/studio-session-credentials.ts +++ b/src/features/tech-log/adapters/http/studio-session-credentials.ts @@ -19,10 +19,78 @@ export const TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID = /** The seventeen other Studio operations — everything but `getStudioSession`. */ export const TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID = "TECH_LOG_STUDIO_SESSION"; +/** + * Fix round 2, item 3. `tech-log-studio-contract-contribution.ts`'s + * `safeOperation()` used to accept a bare `string` for its auth-profile + * override, so a typo (or a copy-pasted wrong constant) would silently + * compile. Closing the type to this union turns that class of mistake into a + * compile error instead of a runtime footgun — a future *mutating* operation + * stamped with the bootstrap profile by mistake would otherwise dispatch + * with no CSRF header at all and nothing would object, because + * `resolveRestSecurityProfiles`'s "unsafe method + cookie ⇒ CSRF" rule only + * applies at `contractVersion === 2`, which this feature's operations are + * not. {@link assertExactlyOneTechLogStudioBootstrapOperation} closes the + * remaining gap the type alone cannot: two operations (or zero) correctly + * typed but wrongly assigned. + */ +export type TechLogStudioAuthProfileId = + | typeof TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID + | typeof TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID; + function isCredentialHeaderName(value: string): value is CredentialHeaderName { return (CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value); } +/** + * Fix round 2, item 3. Fails composition closed if `getStudioSession` stops + * being the sole caller of the bootstrap profile — whether because it was + * removed from every operation (reopening the C1 cycle) or because a second + * operation picked it up (dispatching with no CSRF header, silently, since + * the bootstrap profile allows none). Called once at module load from + * `tech-log-studio-contract-contribution.ts`, after `HTTP_CONTRACTS` is + * built — a composition failure, never a runtime downgrade, matching + * `installRestAuthProfileRegistry`'s own fail-fast pattern. + */ +export function assertExactlyOneTechLogStudioBootstrapOperation( + operations: readonly Readonly<{ operationId: string; authProfileId: string }>[], +): void { + const bootstrapOperationIds = operations + .filter( + (operation) => + operation.authProfileId === TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID, + ) + .map((operation) => operation.operationId); + if (bootstrapOperationIds.length !== 1) { + throw new Error( + `${TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID} must be used by exactly one ` + + `operation (the one that issues the CSRF token this profile exists to ` + + `avoid requiring). Found ${bootstrapOperationIds.length}: ` + + `[${bootstrapOperationIds.join(", ")}].`, + ); + } +} + +/** + * Fix round 2, item 1. A CSRF rejection on a JSON operation can arrive as + * either `UNAUTHENTICATED` (the Studio session itself expired) or + * `FORBIDDEN` — the platform classifies any HTTP 403 as `FORBIDDEN` + * regardless of the response body (`http-execution-v3.ts:1050`), and 403 is + * the shape a CSRF-specific rejection normally takes. Both leave a stale + * token cached for every subsequent Studio operation if nothing discards it. + * The multipart upload path already invalidates on both statuses + * (`http-studio-asset-gateway.ts`); this keeps the JSON path in agreement. + * Only `UNAUTHENTICATED` also tears down the auth session itself — that stays + * the caller's responsibility, not this function's. + */ +export function invalidateTechLogCsrfOnOutcome( + outcomeKind: string, + csrf: CsrfTokenProvider, +): void { + if (outcomeKind === "UNAUTHENTICATED" || outcomeKind === "FORBIDDEN") { + csrf.invalidate(); + } +} + /** * The Studio branch of the platform's `attachCredentials` collaborator, * extracted so the composition root (`bootstrap/runtime-adapters.ts`) and its diff --git a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts index d5835ff..2d70c2a 100644 --- a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts @@ -10,8 +10,10 @@ import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts"; import canonicalSource from "./studio/canonical-source.json" with { type: "json" }; import { STUDIO_ERROR_CODES } from "../adapters/http/studio-error-mapping.ts"; import { + assertExactlyOneTechLogStudioBootstrapOperation, TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID, TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID, + type TechLogStudioAuthProfileId, } from "../adapters/http/studio-session-credentials.ts"; function zodValidator(schemaId: string, schema: z.ZodType): RuntimeValidator { @@ -83,7 +85,7 @@ function safeOperation( // is the one operation that must NOT require one — it is the only caller // of `TECH_LOG_STUDIO_BOOTSTRAP`. Every other safe/keyed operation keeps // the default `TECH_LOG_STUDIO_SESSION`. - authProfileId: string = TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID, + authProfileId: TechLogStudioAuthProfileId = TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID, ): InstalledHttpContract { return Object.freeze({ contract: Object.freeze({ @@ -558,6 +560,15 @@ const HTTP_CONTRACTS = Object.freeze([ DELETE_STUDIO_ASSET, ]); +// Fix round 2, item 3. Fails composition closed rather than letting a wrong +// assignment (two operations, or zero) reach production undetected. +assertExactlyOneTechLogStudioBootstrapOperation( + HTTP_CONTRACTS.map((entry) => ({ + operationId: entry.contract.operationId, + authProfileId: entry.frontend.authProfileId, + })), +); + export const TECH_LOG_STUDIO_OPERATION_IDS = Object.freeze( HTTP_CONTRACTS.map((entry) => entry.contract.operationId), ); diff --git a/tests/features/tech-log/asset-upload-transport.test.ts b/tests/features/tech-log/asset-upload-transport.test.ts index 2acbafe..1ca9522 100644 --- a/tests/features/tech-log/asset-upload-transport.test.ts +++ b/tests/features/tech-log/asset-upload-transport.test.ts @@ -4,6 +4,8 @@ import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { createAssetUploadTransport } from "../../../src/features/tech-log/adapters/http/asset-upload-transport.ts"; +import { createHttpStudioAssetGateway } from "../../../src/features/tech-log/adapters/http/http-studio-asset-gateway.ts"; +import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; const BASE = "http://api.test"; @@ -205,3 +207,65 @@ test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async () return true; }); }); + +// Fix round 2, item 2. The uncontracted-status fallback previously hardcoded +// status 503, discarding the real HTTP status the server sent. +// http-studio-asset-gateway.ts's uploadAsset() only invalidates the cached +// CSRF token when `error.status === 401 || error.status === 403` — an +// uncontracted 401/403 body silently became "not 401/403" (503) and never +// triggered invalidation. + +test("passes the real HTTP status through for an uncontracted status instead of hardcoding 503", async () => { + server.use( + http.post(`${BASE}/api/v1/studio/assets`, () => + HttpResponse.json({ message: "token rejected" }, { status: 401 }), + ), + ); + + await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + assert.equal(error.status, 401); + return true; + }); +}); + +test("an uncontracted 401 body still invalidates the cached CSRF token end to end", async () => { + server.use( + http.post(`${BASE}/api/v1/studio/assets`, () => + HttpResponse.json({ message: "token rejected" }, { status: 401 }), + ), + ); + + let executions = 0; + const csrf = createCsrfTokenProvider({ + async execute() { + executions += 1; + return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + const gateway = createHttpStudioAssetGateway({ + operations: { + async execute() { + throw new Error("not used by this test"); + }, + }, + csrf, + upload: transport(), + }); + + await assert.rejects( + gateway.uploadAsset( + { file: svg(), kind: "IMAGE" }, + { idempotencyKey: "up-1" }, + ), + ); + // The upload itself already consumed one fetch. + assert.equal(executions, 1); + + // A fresh token() call after the failure must re-fetch, not replay the + // (now-rejected) cached value — proving the gateway actually saw status + // 401, not the transport's old default of 503. + await csrf.token(); + assert.equal(executions, 2); +}); diff --git a/tests/features/tech-log/studio-contract-contribution.test.ts b/tests/features/tech-log/studio-contract-contribution.test.ts index f921859..018d009 100644 --- a/tests/features/tech-log/studio-contract-contribution.test.ts +++ b/tests/features/tech-log/studio-contract-contribution.test.ts @@ -7,6 +7,10 @@ import { TECH_LOG_STUDIO_OPERATION_IDS, } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts"; +import { + assertExactlyOneTechLogStudioBootstrapOperation, + TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID, +} from "../../../src/features/tech-log/adapters/http/studio-session-credentials.ts"; const UPLOAD = "uploadStudioAsset"; @@ -62,3 +66,40 @@ test("path templates match the canonical /api/v1/studio prefix", () => { ); } }); + +// Fix round 2, item 3. `getStudioSession` must be the sole operation on the +// credential-free bootstrap profile — anything else either reopens the C1 +// cycle (zero operations) or lets a mutating operation dispatch with no CSRF +// header at all (a second operation on the bootstrap profile). + +test("getStudioSession is the sole operation on the bootstrap auth profile", () => { + const bootstrapOperations = TECH_LOG_STUDIO_CONTRIBUTION.http.filter( + (entry) => entry.frontend.authProfileId === TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID, + ); + assert.deepEqual( + bootstrapOperations.map((entry) => entry.contract.operationId), + ["getStudioSession"], + ); +}); + +test("assertExactlyOneTechLogStudioBootstrapOperation rejects zero or multiple bootstrap operations", () => { + assert.throws( + () => assertExactlyOneTechLogStudioBootstrapOperation([ + { operationId: "getStudioDashboard", authProfileId: "TECH_LOG_STUDIO_SESSION" }, + ]), + /must be used by exactly one operation/, + ); + assert.throws( + () => assertExactlyOneTechLogStudioBootstrapOperation([ + { operationId: "getStudioSession", authProfileId: TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID }, + { operationId: "deleteStudioAsset", authProfileId: TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID }, + ]), + /must be used by exactly one operation/, + ); + assert.doesNotThrow(() => + assertExactlyOneTechLogStudioBootstrapOperation([ + { operationId: "getStudioSession", authProfileId: TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID }, + { operationId: "getStudioDashboard", authProfileId: "TECH_LOG_STUDIO_SESSION" }, + ]), + ); +}); diff --git a/tests/features/tech-log/studio-csrf-composition.test.ts b/tests/features/tech-log/studio-csrf-composition.test.ts index d0b7fa5..b88bf93 100644 --- a/tests/features/tech-log/studio-csrf-composition.test.ts +++ b/tests/features/tech-log/studio-csrf-composition.test.ts @@ -9,7 +9,10 @@ import { INSTALLED_REST_AUTH_PROFILES } from "../../../src/contracts/rest-profil import { createAssetUploadTransport } from "../../../src/features/tech-log/adapters/http/asset-upload-transport.ts"; import { createHttpStudioAssetGateway } from "../../../src/features/tech-log/adapters/http/http-studio-asset-gateway.ts"; import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; -import { attachStudioSessionCredentials } from "../../../src/features/tech-log/adapters/http/studio-session-credentials.ts"; +import { + attachStudioSessionCredentials, + invalidateTechLogCsrfOnOutcome, +} from "../../../src/features/tech-log/adapters/http/studio-session-credentials.ts"; import type { StudioOperationExecutor } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts"; import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; @@ -102,12 +105,17 @@ function composeStudioRuntime() { async execute(operationId, input, executionContext) { const operation = composed.httpByOperationId.get(operationId); if (!operation) throw new Error(`no such operation: ${operationId}`); - return contractHttp.execute(operation, input, { + const outcome = await contractHttp.execute(operation, input, { routeId: executionContext.routeId, scope: scopeSnapshot(), ...(executionContext.signal ? { signal: executionContext.signal } : {}), ...(executionContext.intent ? { intent: executionContext.intent } : {}), }); + // Fix round 2, item 1. Same production call as + // `bootstrap/runtime-adapters.ts`'s `contractOperations.execute` — not + // a reimplementation of it. + invalidateTechLogCsrfOnOutcome(outcome.kind, techLogCsrf); + return outcome; }, }); @@ -182,3 +190,67 @@ test( assert.equal(sessionCalls, 1); }, ); + +/** + * Fix round 2, item 1. `contractOperations.execute` previously invalidated + * `techLogCsrf` only on `UNAUTHENTICATED` (HTTP 401). A CSRF-specific + * rejection normally arrives as `FORBIDDEN` (HTTP 403) instead — the + * platform classifies any 403 response as `FORBIDDEN` regardless of body + * (`http-execution-v3.ts:1050`) — so a token rejected during an ordinary + * document save left the stale token cached, and every subsequent Studio + * mutation kept failing until the page reloaded. The multipart upload path + * already invalidated on both 401 and 403; this proves the JSON path now + * agrees. + */ +test( + "a 403 on a JSON operation invalidates the cached token so the next operation re-fetches the session", + async () => { + let sessionCalls = 0; + server.use( + http.get(`${BASE}/api/v1/studio/session`, () => { + sessionCalls += 1; + return HttpResponse.json({ + authenticated: true, + displayName: "테스터", + roles: ["editor"], + csrfToken: `csrf-token-${sessionCalls}`, + csrfHeaderName: "X-CSRF-TOKEN", + }); + }), + ); + + const dashboardHeaders: (string | null)[] = []; + server.use( + http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { + dashboardHeaders.push(request.headers.get("x-csrf-token")); + // First call: the server rejects the (now-stale) token with 403. + // Second call: succeeds with whatever token is presented. + return dashboardHeaders.length === 1 + ? new HttpResponse(null, { status: 403 }) + : HttpResponse.json({ documentTotals: {}, workflowSections: [] }); + }), + ); + + const { contractOperations } = composeStudioRuntime(); + + const first = await contractOperations.execute( + "getStudioDashboard", + {}, + { routeId: "TECH_LOG_STUDIO" }, + ); + assert.equal(first.kind, "FORBIDDEN"); + assert.equal(dashboardHeaders[0], "csrf-token-1"); + + const second = await contractOperations.execute( + "getStudioDashboard", + {}, + { routeId: "TECH_LOG_STUDIO" }, + ); + assert.equal(second.kind, "SUCCESS"); + assert.equal(dashboardHeaders[1], "csrf-token-2"); + + // Two fresh session fetches: the cache was discarded after the 403, not + // replayed on the retry. + assert.equal(sessionCalls, 2); + }, +);