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); + }, +);