From 2cab4974b7651e1ebbf2d58d5641482df99c3ad4 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 03:07:02 +0900 Subject: [PATCH] fix: break the TechLog CSRF bootstrap cycle and close the review's fix-round-1 items C1 (Critical): getStudioSession was stamped with the same TECH_LOG_STUDIO_SESSION auth profile as every other Studio operation, and that profile requires the CSRF header it is getStudioSession's own job to issue -- an unconditional cycle that recursed without bound in HTTP mode. Fixed with a credential-free TECH_LOG_STUDIO_BOOTSTRAP auth profile for getStudioSession alone, a synchronous re-entrancy guard in createCsrfTokenProvider as defense in depth, and a throwing stub in place of the prior `let x!: T` assertion. Added a composition-level regression test that wires the real executor, CSRF provider, and credential-attach function together and proves getStudioSession dispatches exactly once while its token reaches both a JSON operation and the multipart upload. Also: invalidate the cached CSRF token on a 401/403 from the upload path (I2), a throwing useStudioAssetGateway() accessor so Task 11 cannot silently compile a null-gateway UI (I3), and the M1-M5 minors from the review (guard a malformed success body, cover the untested error fallbacks, align aborted uploads with the JSON path's non-retryable CANCELLED mapping, derive the credential header name from one source instead of two, and correct the adapter review doc's operation count). Co-Authored-By: Claude Opus 5 (1M context) --- .../adapters/06-tech-log-asset-upload.md | 8 +- src/bootstrap/runtime-adapters.ts | 70 ++++--- src/contracts/rest-profiles.ts | 19 ++ .../adapters/http/asset-upload-transport.ts | 31 ++- .../http/http-studio-asset-gateway.ts | 31 ++- .../http/studio-session-credentials.ts | 72 +++++++ .../adapters/http/studio-session-csrf.ts | 52 +++-- .../tech-log-studio-contract-contribution.ts | 14 +- .../presentation/studio/use-studio.ts | 24 +++ .../tech-log/asset-upload-transport.test.ts | 87 +++++++++ .../tech-log/studio-asset-gateway.test.ts | 91 ++++++++- .../tech-log/studio-csrf-composition.test.ts | 184 ++++++++++++++++++ .../tech-log/studio-session-csrf.test.ts | 88 +++++++++ .../use-studio-asset-gateway.test.tsx | 65 +++++++ tests/runtime-schema/http-schema.test.ts | 11 ++ .../contract-registry-immutability.test.ts | 1 + 16 files changed, 793 insertions(+), 55 deletions(-) create mode 100644 src/features/tech-log/adapters/http/studio-session-credentials.ts create mode 100644 tests/features/tech-log/studio-csrf-composition.test.ts create mode 100644 tests/features/tech-log/studio-session-csrf.test.ts create mode 100644 tests/features/tech-log/use-studio-asset-gateway.test.tsx diff --git a/docs/reviews/adapters/06-tech-log-asset-upload.md b/docs/reviews/adapters/06-tech-log-asset-upload.md index 5a528c8..ba624c8 100644 --- a/docs/reviews/adapters/06-tech-log-asset-upload.md +++ b/docs/reviews/adapters/06-tech-log-asset-upload.md @@ -18,7 +18,7 @@ TechLog Studio는 canonical 계약상 19개 operation을 갖는다. 그중 18개 ## 우회 범위 -`tech-log-studio-contract-contribution.ts`가 등록하는 canonical operation은 19개다. +canonical Studio API 전체는 19개 operation이다. `tech-log-studio-contract-contribution.ts`는 그중 18개만 등록한다 — `uploadStudioAsset`은 계약 실행기가 표현할 수 없으므로 애초에 그 파일에 없다(`studio-contract-contribution.test.ts`의 "declares every canonical operation except the multipart upload"가 18을 고정한다). | 분류 | operation | 경로 | | --- | --- | --- | @@ -33,7 +33,7 @@ TechLog Studio는 canonical 계약상 19개 operation을 갖는다. 그중 18개 | 보증 | JSON 경로 | multipart 경로 | | --- | --- | --- | -| CSRF | `attachCredentials`가 `techLogCsrf.token()`으로 얻은 값을 `x-csrf-token`에 싣는다 | `StudioAssetGateway.uploadAsset()`이 **같은** `techLogCsrf` provider에서 `token()`/`headerName()`을 읽어 transport에 넘긴다 — provider가 composition root에 하나뿐이므로 세션당 토큰도 하나다 | +| CSRF | `attachCredentials`가 `techLogCsrf.token()`/`headerName()`으로 얻은 값을 그 이름 그대로 요청 헤더에 싣는다 | `StudioAssetGateway.uploadAsset()`이 **같은** `techLogCsrf` provider에서 `token()`/`headerName()`을 읽어 transport에 넘긴다 — provider가 composition root에 하나뿐이므로 세션당 토큰도 하나다. `getStudioSession` 자신은 이 provider를 거치지 않는 `TECH_LOG_STUDIO_BOOTSTRAP` auth profile을 쓴다: 그 provider가 토큰을 얻으려고 호출하는 operation이 같은 provider의 토큰을 요구하면 순환이 되기 때문이다(`docs`가 아니라 코드로 고정: `studio-csrf-composition.test.ts`) | | Idempotency-Key | 실행 intent(`mutationIntent()`)에서 나와 client가 헤더로 싣는다 | 호출자(`uploadAsset` options)의 `idempotencyKey`를 gateway가 그대로 헤더로 전달한다 | | canonical 오류 코드 매핑 | `toStudioGatewayError()`가 `STUDIO_ERROR_CODES`에 있는 `problem.code`만 `StudioGatewayError`로 승격하고, 계약 밖 코드는 `STUDIO_UNAVAILABLE`로 접는다 | transport가 동일한 `STUDIO_ERROR_CODES` 집합을 재사용해 같은 규칙으로 매핑한다. 서버가 `PAYLOAD_TOO_LARGE`/`UNSUPPORTED_MEDIA_TYPE`처럼 이 목록에 있는 코드를 보내면 그대로 `StudioGatewayError`가 되고, 계약에 없는 코드나 파싱 불가능한 본문은 도메인 코드를 지어내지 않고 `STUDIO_UNAVAILABLE`로 접는다 | | timeout | 계약의 `requestDeadlineCeilingMs` | `AbortSignal.timeout(deps.timeoutMs)`를 호출자 signal과 `AbortSignal.any()`로 합성한다 | @@ -69,8 +69,12 @@ TechLog Studio는 canonical 계약상 19개 operation을 갖는다. 그중 18개 corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test.ts corepack pnpm exec vitest run tests/features/tech-log/runtime-composition.test.ts +corepack pnpm exec vitest run tests/features/tech-log/studio-csrf-composition.test.ts +corepack pnpm exec vitest run tests/features/tech-log/studio-session-csrf.test.ts corepack pnpm check:types 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의 재진입 가드를 단독으로 고정한다. + 정확한 실행 결과는 `.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 5c2b1f2..c24e57a 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -28,6 +28,7 @@ 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 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"; @@ -426,7 +427,8 @@ export async function createRuntimeAdapters( * §7.7 / Task 7. There is exactly one CSRF provider per Studio session, and * it is owned by the composition root — not by the TechLog feature input — * because two collaborators share it: `attachCredentials` below (the only - * path by which `x-csrf-token` reaches the 18 JSON operations) and the + * path by which the CSRF header reaches the 17 JSON operations that + * require it — every operation except `getStudioSession` itself) and the * multipart upload transport, which bypasses the platform executor * entirely and must set the header itself. If each built its own provider, * one Studio session would hold two different tokens. @@ -434,14 +436,37 @@ export async function createRuntimeAdapters( * `execute` calls `getStudioSession` through `contractOperations`, which is * declared further below — a real ordering hazard, since `attachCredentials` * (needed to build `contractHttp`, needed to build `contractOperations`) - * needs this provider first. `getStudioSession` is a SAFE operation and - * needs no CSRF itself, so there is no true cycle: the callback below only - * *runs* once the whole runtime is composed and a Studio request is made, - * by which point `contractOperations` is assigned. `let` plus a forward - * reference inside this closure defers the read to call time instead of - * declaration time. + * needs this provider first. + * + * Fix round 1 (C1). The original comment here claimed "`getStudioSession` + * is a SAFE operation and needs no CSRF, so there is no true cycle" — that + * was wrong. `retrySemantics: "SAFE"` and the auth profile's + * `requiredCredentialHeaders` are orthogonal; every operation stamped with + * `TECH_LOG_STUDIO_SESSION` (originally including `getStudioSession` + * itself) required the CSRF header, so fetching the token required already + * having it — an unconditional, deterministic cycle, reproduced against the + * real composition root as unbounded recursion + * (`RangeError: Maximum call stack size exceeded`), not a first-request + * race. The real fix is `getStudioSession` now using the credential-free + * `TECH_LOG_STUDIO_BOOTSTRAP` profile (`rest-profiles.ts`, + * `tech-log-studio-contract-contribution.ts`), so its own + * `attachCredentials` call never reaches this provider. `let` plus a + * forward reference inside this closure still defers the read of + * `contractOperations` to call time instead of declaration time — that part + * of the original design was fine — but the binding below is a throwing + * stub rather than a bare `let x!: T` definite-assignment assertion: `!` + * silently accepts `undefined` forever if a future refactor inserts an + * `await` between this declaration and the real assignment, where a stub + * fails loudly instead. */ - let contractOperations!: TechLogStudioOperationExecutor; + let contractOperations: TechLogStudioOperationExecutor = Object.freeze({ + async execute() { + throw new Error( + "contractOperations used before assignment in createRuntimeAdapters — " + + "construction order regressed.", + ); + }, + }); const techLogCsrf = createCsrfTokenProvider({ async execute(options) { const outcome = await contractOperations.execute( @@ -475,25 +500,16 @@ export async function createRuntimeAdapters( if (serverStateScope.getPhase() !== "READY") { return Object.freeze({ kind: "SCOPE_FENCED" as const }); } - if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") { - // Studio authenticates with a session cookie and carries only the - // CSRF token as a proof header. Read operations use this profile too - // — the server does not require the header for them — so a failure - // to fetch the token fails only this one request (`UNAVAILABLE`) and - // is not promoted to a session-level `UNAUTHENTICATED`, which would - // trigger a global re-authentication flow the session itself did not - // warrant. - try { - return Object.freeze({ - kind: "READY" as const, - headers: Object.freeze({ - "x-csrf-token": await techLogCsrf.token({ signal: authContext.signal }), - }), - }); - } catch { - return Object.freeze({ kind: "UNAVAILABLE" as const }); - } - } + // Fix round 1 (C1, M4). Extracted to `studio-session-credentials.ts` so + // this exact code — not a re-implementation of it — is what + // `tests/features/tech-log/studio-csrf-composition.test.ts` exercises + // against the real executor and provider. + const studioOutcome = await attachStudioSessionCredentials( + operation.authProfileId, + authContext, + techLogCsrf, + ); + if (studioOutcome) return studioOutcome; const state = authSession.getState(); if (state === "integration-failed") { return Object.freeze({ kind: "UNAVAILABLE" as const }); diff --git a/src/contracts/rest-profiles.ts b/src/contracts/rest-profiles.ts index a20eb85..ea0e6a4 100644 --- a/src/contracts/rest-profiles.ts +++ b/src/contracts/rest-profiles.ts @@ -83,6 +83,25 @@ export const REST_AUTH_PROFILES = Object.freeze({ allowedCredentialHeaders: Object.freeze(["x-csrf-token"] as const), requiredCredentialHeaders: Object.freeze(["x-csrf-token"] as const), }), + /** + * Task 7 fix round 1 (C1). `getStudioSession` is the operation that + * *issues* the CSRF token, so it cannot itself require one — + * `TECH_LOG_STUDIO_SESSION.requiredCredentialHeaders` demanding + * `x-csrf-token` on every request using that profile, including this one, + * made fetching the token depend on already having it. Same session cookie + * (`SAME_ORIGIN_COOKIE` / `credentials: "include"`), but zero credential + * headers allowed or required: `attachCredentials` returns `READY` with an + * empty header set for this profile, synchronously, so no cycle exists. + * Only `getStudioSession` uses this profile; the other seventeen operations + * stay on `TECH_LOG_STUDIO_SESSION`. + */ + TECH_LOG_STUDIO_BOOTSTRAP: Object.freeze({ + authProfileId: "TECH_LOG_STUDIO_BOOTSTRAP", + transport: "SAME_ORIGIN_COOKIE", + credentials: "include", + allowedCredentialHeaders: Object.freeze([]), + requiredCredentialHeaders: Object.freeze([]), + }), } satisfies Readonly>); function isCredentialHeaderName(value: unknown): value is CredentialHeaderName { 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 247dd80..6d27a33 100644 --- a/src/features/tech-log/adapters/http/asset-upload-transport.ts +++ b/src/features/tech-log/adapters/http/asset-upload-transport.ts @@ -24,14 +24,17 @@ export function createAssetUploadTransport( const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis); const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href; - function unavailable(detail: string): StudioGatewayError { + function unavailable( + detail: string, + options?: Readonly<{ status?: number; retryable?: boolean }>, + ): StudioGatewayError { return new StudioGatewayError({ type: "https://techlog.local/problems/studio-unavailable", title: "STUDIO_UNAVAILABLE", - status: 503, + status: options?.status ?? 503, detail, code: "STUDIO_UNAVAILABLE", - retryable: true, + retryable: options?.retryable ?? true, }); } @@ -61,13 +64,33 @@ export function createAssetUploadTransport( credentials: "include", }); } catch (error) { + if (signal.aborted) { + // M3 (fix round 1). Align with the JSON path's `CANCELLED` mapping + // (`toStudioGatewayError` maps it to `STUDIO_UNAVAILABLE` / 499 / + // `retryable: false`) — a caller-cancelled or deadline-exceeded + // upload is not a retryable outage, so it must not carry + // `retryable: true` the way a genuine transport failure does. + throw unavailable("Upload was aborted before it completed.", { + status: 499, + retryable: false, + }); + } throw unavailable( error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.", ); } if (response.status === 201 || response.status === 200) { - return (await response.json()) as Asset; + try { + return (await response.json()) as Asset; + } catch { + // M1 (fix round 1). This port's contract is `StudioGatewayError`; + // a malformed success body must not throw a raw `SyntaxError` out + // of it. + throw unavailable( + `Upload returned status ${response.status} with a body that could not be parsed as JSON.`, + ); + } } let problem: ProblemDetails | null; diff --git a/src/features/tech-log/adapters/http/http-studio-asset-gateway.ts b/src/features/tech-log/adapters/http/http-studio-asset-gateway.ts index fff1aaa..143904c 100644 --- a/src/features/tech-log/adapters/http/http-studio-asset-gateway.ts +++ b/src/features/tech-log/adapters/http/http-studio-asset-gateway.ts @@ -10,6 +10,7 @@ import type { UploadAssetForm, } from "../../application/ports/studio-asset-gateway.ts"; import type { IdempotentOptions, RequestOptions } from "../../application/ports/studio-gateway.ts"; +import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import { toStudioGatewayError } from "./studio-error-mapping.ts"; import { mutationIntent, type StudioOperationExecutor } from "./http-studio-gateway.ts"; import type { CsrfTokenProvider } from "./studio-session-csrf.ts"; @@ -62,14 +63,28 @@ export function createHttpStudioAssetGateway( deps.csrf.token(options.signal ? { signal: options.signal } : undefined), deps.csrf.headerName(options.signal ? { signal: options.signal } : undefined), ]); - return deps.upload.upload( - form, - Object.freeze({ - [headerName]: token, - "Idempotency-Key": options.idempotencyKey, - }), - options.signal ? { signal: options.signal } : undefined, - ); + try { + return await deps.upload.upload( + form, + Object.freeze({ + [headerName]: token, + "Idempotency-Key": options.idempotencyKey, + }), + options.signal ? { signal: options.signal } : undefined, + ); + } catch (error) { + // Fix round 1 (I2). This multipart path bypasses + // `contractOperations.execute` entirely, so its `UNAUTHENTICATED` + // branch (the only other place `techLogCsrf.invalidate()` is called) + // never sees an upload's 401/403. Without this, a rejected token + // stays cached for every other Studio operation until something else + // happens to invalidate it. A CSRF-specific rejection usually arrives + // as 403 rather than 401, so both are handled here. + if (isStudioGatewayError(error) && (error.status === 401 || error.status === 403)) { + deps.csrf.invalidate(); + } + throw error; + } }, updateAssetMetadata: (assetId, cmd: UpdateAssetCommand, options) => command("updateStudioAsset", { assetId, ...cmd }, options), diff --git a/src/features/tech-log/adapters/http/studio-session-credentials.ts b/src/features/tech-log/adapters/http/studio-session-credentials.ts new file mode 100644 index 0000000..19e9f6b --- /dev/null +++ b/src/features/tech-log/adapters/http/studio-session-credentials.ts @@ -0,0 +1,72 @@ +import type { CredentialPatchOutcome } from "../../../../adapters/http/http-contract-bridge.ts"; +import { + CREDENTIAL_HEADER_NAMES, + type CredentialHeaderName, +} from "../../../../contracts/rest-profiles.ts"; +import type { CsrfTokenProvider } from "./studio-session-csrf.ts"; + +/** + * Task 7 fix round 1 (C1, I1). `getStudioSession` is the operation that + * *issues* the CSRF token every other Studio operation requires, so it must + * not require one itself. Giving it the credential-free + * {@link TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID} instead of + * {@link TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID} is what breaks the cycle; + * see `rest-profiles.ts` and `tech-log-studio-contract-contribution.ts`. + */ +export const TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID = + "TECH_LOG_STUDIO_BOOTSTRAP"; + +/** The seventeen other Studio operations — everything but `getStudioSession`. */ +export const TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID = "TECH_LOG_STUDIO_SESSION"; + +function isCredentialHeaderName(value: string): value is CredentialHeaderName { + return (CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value); +} + +/** + * The Studio branch of the platform's `attachCredentials` collaborator, + * extracted so the composition root (`bootstrap/runtime-adapters.ts`) and its + * regression test (`tests/features/tech-log/studio-csrf-composition.test.ts`) + * call the exact same code — a duplicated copy in a test would only prove the + * test's own understanding of the fix, not the production wiring. + * + * Returns `null` for any non-Studio `authProfileId` so the caller falls + * through to its own (e.g. bearer) credential logic; that fallback is not + * this function's concern. + */ +export async function attachStudioSessionCredentials( + authProfileId: string, + authContext: Readonly<{ signal?: AbortSignal }>, + csrf: CsrfTokenProvider, +): Promise { + if (authProfileId === TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID) { + return Object.freeze({ kind: "READY" as const, headers: Object.freeze({}) }); + } + if (authProfileId !== TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID) { + return null; + } + // Every operation other than `getStudioSession` uses this profile and + // requires the header, so a failure to fetch the token fails only this one + // request (`UNAVAILABLE`) and is not promoted to a session-level + // `UNAUTHENTICATED`, which would trigger a global re-authentication flow + // the session itself did not warrant. + try { + const [token, headerName] = await Promise.all([ + csrf.token({ signal: authContext.signal }), + csrf.headerName({ signal: authContext.signal }), + ]); + // One naming authority: the header name comes from the same provider the + // multipart upload transport reads (`http-studio-asset-gateway.ts`'s + // `uploadAsset`), not a second, independently hardcoded literal. + const normalized = headerName.toLowerCase(); + if (!isCredentialHeaderName(normalized)) { + return Object.freeze({ kind: "UNAVAILABLE" as const }); + } + return Object.freeze({ + kind: "READY" as const, + headers: Object.freeze({ [normalized]: token }), + }); + } catch { + return Object.freeze({ kind: "UNAVAILABLE" as const }); + } +} diff --git a/src/features/tech-log/adapters/http/studio-session-csrf.ts b/src/features/tech-log/adapters/http/studio-session-csrf.ts index bf5ffae..66e011b 100644 --- a/src/features/tech-log/adapters/http/studio-session-csrf.ts +++ b/src/features/tech-log/adapters/http/studio-session-csrf.ts @@ -16,28 +16,58 @@ export type CsrfTokenProvider = Readonly<{ /** * CSRF는 전송 관심사다. UI는 토큰을 보지 않으므로 포트로 노출하지 않고 * 어댑터 내부에서 캐시한다. 동시 요청은 하나의 in-flight 조회를 공유한다. + * + * Task 7 fix round 1 (C1). `deps.execute` must not itself require this + * provider's token — if the caller wires it to an operation that is stamped + * with an auth profile requiring this same CSRF header, `resolve()` re-enters + * itself *synchronously*, before `inFlight` is assigned: `inFlight ??= + * deps.execute(options).then(...)` evaluates its right-hand side (which calls + * back into `resolve()` through the whole executor stack) before the `??=` + * assignment completes, so the re-entrant call still sees `inFlight === null` + * and recurses without bound (`RangeError: Maximum call stack size + * exceeded`), not deduplicates. The `resolving` flag below is a synchronous + * guard: it is set before `deps.execute` is invoked, so a re-entrant call + * observes it and fails loudly and immediately with a clear diagnostic + * instead of overflowing the stack. It does not replace giving the token's + * own issuing operation a credential-free auth profile — it is the seatbelt + * for the next time someone gets that wiring wrong. */ export function createCsrfTokenProvider( deps: Readonly<{ execute: CsrfSessionExecutor }>, ): CsrfTokenProvider { let cached: StudioSessionSnapshot | null = null; let inFlight: Promise | null = null; + let resolving = false; async function resolve( options?: Readonly<{ signal?: AbortSignal }>, ): Promise { if (cached) return cached; - inFlight ??= deps.execute(options).then( - (snapshot) => { - cached = snapshot; - inFlight = null; - return snapshot; - }, - (error: unknown) => { - inFlight = null; - throw error; - }, - ); + if (inFlight) return inFlight; + if (resolving) { + throw new Error( + "createCsrfTokenProvider: re-entered while fetching the token — the " + + "operation that issues the token must not itself require this " + + "provider's token. Give it a bootstrap auth profile with no " + + "required credential headers instead of reusing the session profile.", + ); + } + resolving = true; + try { + inFlight = deps.execute(options).then( + (snapshot) => { + cached = snapshot; + inFlight = null; + return snapshot; + }, + (error: unknown) => { + inFlight = null; + throw error; + }, + ); + } finally { + resolving = false; + } return inFlight; } 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 7ab71ef..d5835ff 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 @@ -9,6 +9,10 @@ import type { 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 { + TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID, + TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID, +} from "../adapters/http/studio-session-credentials.ts"; function zodValidator(schemaId: string, schema: z.ZodType): RuntimeValidator { return Object.freeze({ @@ -75,6 +79,11 @@ function safeOperation( pathTemplate: string, responseByteLimit: number, project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }>, + // Task 7 fix round 1 (C1). `getStudioSession` issues the CSRF token, so it + // 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, ): InstalledHttpContract { return Object.freeze({ contract: Object.freeze({ @@ -102,7 +111,7 @@ function safeOperation( responseByteLimit, totalDeadlineMs: 10_000, retryBudget: 2 as const, - authProfileId: "TECH_LOG_STUDIO_SESSION", + authProfileId, diagnosticsOperation: `techLog.studio.${operationId}`, }), }) as InstalledHttpContract; @@ -151,7 +160,7 @@ function keyedOperation( totalDeadlineMs: 10_000, // §8.3. 발신된 KEYED 명령은 자동 재시도하지 않는다. retryBudget: 0 as const, - authProfileId: "TECH_LOG_STUDIO_SESSION", + authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID, diagnosticsOperation: `techLog.studio.${operationId}`, }), }) as InstalledHttpContract; @@ -178,6 +187,7 @@ const GET_STUDIO_SESSION = safeOperation( "/api/v1/studio/session", 8_192, () => Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }), + TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID, ); const GET_STUDIO_DASHBOARD = safeOperation( diff --git a/src/features/tech-log/presentation/studio/use-studio.ts b/src/features/tech-log/presentation/studio/use-studio.ts index 85c97b6..aea95e4 100644 --- a/src/features/tech-log/presentation/studio/use-studio.ts +++ b/src/features/tech-log/presentation/studio/use-studio.ts @@ -43,6 +43,30 @@ export function useStudio(): StudioContextValue { return context; } +/** + * Fix round 1 (I3). `StudioContextValue.assetGateway` stays nullable — most + * test harnesses render `StudioProvider` without a `createAssetGateway` prop + * and that must keep compiling — but a nullable field lets Asset UI write + * `if (!assetGateway) return null` and ship a confusingly empty screen with + * the type checker fully satisfied, since "no gateway" and "gateway with an + * empty list" look identical to that check. Asset UI must use this accessor + * instead of reading `useStudio().assetGateway` directly: it throws with a + * clear message the one time this is actually unmet — in the real app, + * `StudioShell` always supplies `createAssetGateway`, so this never throws in + * production. + */ +export function useStudioAssetGateway(): StudioAssetGateway { + const { assetGateway } = useStudio(); + if (!assetGateway) { + throw new Error( + "useStudioAssetGateway must be used within a StudioProvider that was " + + "given a createAssetGateway prop. StudioShell (the production path) " + + "always supplies one; a test harness that renders Asset UI must too.", + ); + } + return assetGateway; +} + export function useStudioEditorSession() { const studio = useStudio(); return useMemo( diff --git a/tests/features/tech-log/asset-upload-transport.test.ts b/tests/features/tech-log/asset-upload-transport.test.ts index fd35d22..2acbafe 100644 --- a/tests/features/tech-log/asset-upload-transport.test.ts +++ b/tests/features/tech-log/asset-upload-transport.test.ts @@ -118,3 +118,90 @@ test("maps a network failure onto STUDIO_UNAVAILABLE", async () => { return true; }); }); + +// M2 (fix round 1). The two "don't invent a domain error" fallback branches +// had no test coverage — the code was already correct, but nothing pinned it. + +test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead of inventing one", async () => { + server.use( + http.post(`${BASE}/api/v1/studio/assets`, () => + HttpResponse.json( + { + type: "https://techlog.local/problems/teapot", + title: "IM_A_TEAPOT", + status: 418, + detail: "이 서버는 커피를 내릴 수 없습니다.", + code: "IM_A_TEAPOT", + }, + { status: 418, headers: { "content-type": "application/problem+json" } }, + ), + ), + ); + + await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + return true; + }); +}); + +test("falls back to STUDIO_UNAVAILABLE when the error body cannot be parsed as JSON", async () => { + server.use( + http.post( + `${BASE}/api/v1/studio/assets`, + () => new HttpResponse("not json", { status: 500 }), + ), + ); + + await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + return true; + }); +}); + +// M1 (fix round 1). A malformed 201 body must not throw a raw SyntaxError out +// of a port whose contract is StudioGatewayError. + +test("falls back to STUDIO_UNAVAILABLE when a success body cannot be parsed as JSON", async () => { + server.use( + http.post( + `${BASE}/api/v1/studio/assets`, + () => new HttpResponse("not json", { status: 201 }), + ), + ); + + await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + return true; + }); +}); + +// M3 (fix round 1). A cancelled/deadline-exceeded upload must read the same +// as the JSON path's CANCELLED mapping: not retryable. + +test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async () => { + server.use( + http.post(`${BASE}/api/v1/studio/assets`, async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return HttpResponse.json({ id: "a" }, { status: 201 }); + }), + ); + + const controller = new AbortController(); + const pending = transport().upload( + { file: svg(), kind: "IMAGE" }, + {}, + { signal: controller.signal }, + ); + controller.abort(); + + await assert.rejects(pending, (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + assert.equal(error.status, 499); + assert.equal(error.retryable, false); + return true; + }); +}); diff --git a/tests/features/tech-log/studio-asset-gateway.test.ts b/tests/features/tech-log/studio-asset-gateway.test.ts index 94aa067..3bde062 100644 --- a/tests/features/tech-log/studio-asset-gateway.test.ts +++ b/tests/features/tech-log/studio-asset-gateway.test.ts @@ -3,7 +3,10 @@ import { test } from "vitest"; 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"; +import { + isStudioGatewayError, + StudioGatewayError, +} from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; const READY_ASSET = { id: "11111111-1111-4111-8111-111111111111", @@ -117,6 +120,92 @@ test("delegates upload to the transport with CSRF and idempotency headers", asyn assert.equal(received["Idempotency-Key"], "upload-1"); }); +// I2 (fix round 1). `techLogCsrf.invalidate()` at the composition root only +// runs from `contractOperations.execute`'s `UNAUTHENTICATED` branch, which +// the multipart upload bypasses entirely. Without an explicit call from the +// gateway itself, a 401/403 on upload left a stale token cached for every +// other Studio operation. + +for (const status of [401, 403]) { + test(`invalidates the cached CSRF token when upload rejects with ${status}`, async () => { + let executions = 0; + const csrf = createCsrfTokenProvider({ + async execute() { + executions += 1; + return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + const { dependencies } = deps({}); + const gateway = createHttpStudioAssetGateway({ + ...dependencies, + csrf, + upload: { + async upload() { + throw new StudioGatewayError({ + type: "https://techlog.local/problems/authentication-required", + title: "AUTHENTICATION_REQUIRED", + status, + detail: "세션이 만료되었습니다.", + code: "AUTHENTICATION_REQUIRED", + }); + }, + }, + } as never); + + await assert.rejects( + gateway.uploadAsset( + { file: new File([""], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" }, + { idempotencyKey: "upload-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. + await csrf.token(); + assert.equal(executions, 2); + }); +} + +test("does not invalidate the cached CSRF token for an unrelated upload failure", async () => { + let executions = 0; + const csrf = createCsrfTokenProvider({ + async execute() { + executions += 1; + return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + const { dependencies } = deps({}); + const gateway = createHttpStudioAssetGateway({ + ...dependencies, + csrf, + upload: { + async upload() { + throw new StudioGatewayError({ + type: "https://techlog.local/problems/payload-too-large", + title: "PAYLOAD_TOO_LARGE", + status: 413, + detail: "파일이 너무 큽니다.", + code: "PAYLOAD_TOO_LARGE", + }); + }, + }, + } as never); + + await assert.rejects( + gateway.uploadAsset( + { file: new File([""], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" }, + { idempotencyKey: "upload-1" }, + ), + ); + assert.equal(executions, 1); + + await csrf.token(); + // Still cached — a 413 says nothing about the token's validity. + assert.equal(executions, 1); +}); + test("surfaces ASSET_IN_USE from a rejected delete", async () => { const { dependencies } = deps({ deleteStudioAsset: { diff --git a/tests/features/tech-log/studio-csrf-composition.test.ts b/tests/features/tech-log/studio-csrf-composition.test.ts new file mode 100644 index 0000000..d0b7fa5 --- /dev/null +++ b/tests/features/tech-log/studio-csrf-composition.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { afterAll, afterEach, beforeAll, test } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +import { createContractHttpExecutor } from "../../../src/adapters/http/http-execution-v3.ts"; +import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts"; +import { INSTALLED_REST_AUTH_PROFILES } from "../../../src/contracts/rest-profiles.ts"; +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 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"; + +/** + * I1 (Task 7 fix round 1). C1 was a self-referential CSRF bootstrap cycle + * that no unit test caught, because every existing test either mocked + * `contractOperations` directly (never touching `attachCredentials`) or + * mocked `attachCredentials` directly (never touching the real + * `contractOperations`/`createCsrfTokenProvider` composition). This file + * composes the real `createContractHttpExecutor`, the real + * `createCsrfTokenProvider`, and the real `attachStudioSessionCredentials` — + * the exact function `bootstrap/runtime-adapters.ts` calls, not a + * reimplementation of it — the same way the composition root does, and + * proves `getStudioSession` dispatches exactly once while its token reaches + * both a JSON operation's request and the multipart upload's headers. + * + * If this test is deleted and either the `TECH_LOG_STUDIO_BOOTSTRAP` auth + * profile disappears from `getStudioSession`, or `csrf` is threaded through a + * second `createCsrfTokenProvider()` call instead of the one instance built + * here, this is the test that would have caught it. + */ + +const BASE = "http://api.test"; +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function scopeSnapshot() { + return Object.freeze({ + generation: 1, + fingerprint: "scope-1", + identities: Object.freeze({}) as never, + signal: new AbortController().signal, + isCurrent: () => true, + }); +} + +/** + * Mirrors `createRuntimeAdapters`'s wiring in `bootstrap/runtime-adapters.ts` + * exactly: `techLogCsrf` is declared closing over a forward reference to + * `contractOperations` (a throwing stub until assigned), `contractHttp`'s + * `attachCredentials` calls the same `attachStudioSessionCredentials` the + * production composition root calls, and `contractOperations` is assigned + * afterward. + */ +function composeStudioRuntime() { + const composed = composeContractContributions([TECH_LOG_STUDIO_CONTRIBUTION]); + + let contractOperations: StudioOperationExecutor = Object.freeze({ + async execute() { + throw new Error("contractOperations used before assignment"); + }, + }); + + const techLogCsrf = createCsrfTokenProvider({ + async execute(options) { + const outcome = await contractOperations.execute( + "getStudioSession", + {}, + { + routeId: "TECH_LOG_STUDIO", + ...(options?.signal ? { signal: options.signal } : {}), + }, + ); + if (outcome.kind !== "SUCCESS") { + throw new Error("studio session is unavailable"); + } + const value = outcome.value as { csrfToken: string; csrfHeaderName: string }; + return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName }; + }, + }); + + const contractHttp = createContractHttpExecutor({ + baseUrl: `${BASE}/`, + maxRetryAttempts: 0, + authProfiles: INSTALLED_REST_AUTH_PROFILES, + async attachCredentials(operation, authContext) { + const outcome = await attachStudioSessionCredentials( + operation.authProfileId, + authContext, + techLogCsrf, + ); + return outcome ?? Object.freeze({ kind: "UNAVAILABLE" as const }); + }, + }); + + contractOperations = Object.freeze({ + 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, { + routeId: executionContext.routeId, + scope: scopeSnapshot(), + ...(executionContext.signal ? { signal: executionContext.signal } : {}), + ...(executionContext.intent ? { intent: executionContext.intent } : {}), + }); + }, + }); + + return { contractOperations, techLogCsrf }; +} + +test( + "getStudioSession dispatches exactly once and its token reaches both a JSON operation and the upload", + 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-1", + csrfHeaderName: "X-CSRF-TOKEN", + }); + }), + ); + + let jsonRequestHeader: string | null = null; + server.use( + http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { + jsonRequestHeader = request.headers.get("x-csrf-token"); + return HttpResponse.json({ + documentTotals: {}, + workflowSections: [], + }); + }), + ); + + let uploadRequestHeader: string | null = null; + server.use( + http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { + uploadRequestHeader = request.headers.get("X-CSRF-TOKEN"); + return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 }); + }), + ); + + const { contractOperations, techLogCsrf } = composeStudioRuntime(); + + // JSON path: a plain read that uses `TECH_LOG_STUDIO_SESSION` and + // therefore requires the CSRF header — this is what C1 made impossible + // (unbounded recursion, zero dispatched requests). + const dashboardOutcome = await contractOperations.execute( + "getStudioDashboard", + {}, + { routeId: "TECH_LOG_STUDIO" }, + ); + assert.equal(dashboardOutcome.kind, "SUCCESS"); + assert.equal(jsonRequestHeader, "csrf-token-1"); + + // Multipart path: bypasses `contractOperations` entirely but reads the + // token from the same `techLogCsrf` instance. + const assetGateway = createHttpStudioAssetGateway({ + operations: contractOperations, + csrf: techLogCsrf, + upload: createAssetUploadTransport({ baseUrl: `${BASE}/`, timeoutMs: 10_000 }), + }); + const uploaded = await assetGateway.uploadAsset( + { file: new File([""], "b.svg", { type: "image/svg+xml" }), kind: "IMAGE" }, + { idempotencyKey: "up-1" }, + ); + assert.equal((uploaded as { id: string }).id, "a"); + assert.equal(uploadRequestHeader, "csrf-token-1"); + + // The one-provider-per-session invariant: both consumers dispatched + // `getStudioSession` through the very same in-flight/cached lookup. + assert.equal(sessionCalls, 1); + }, +); diff --git a/tests/features/tech-log/studio-session-csrf.test.ts b/tests/features/tech-log/studio-session-csrf.test.ts new file mode 100644 index 0000000..56f9075 --- /dev/null +++ b/tests/features/tech-log/studio-session-csrf.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + createCsrfTokenProvider, + type CsrfTokenProvider, +} from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; + +/** + * Task 7 fix round 1 (C1, item 2). `createCsrfTokenProvider`'s `resolve()` + * previously used `inFlight ??= deps.execute(options).then(...)`, which + * evaluates `deps.execute(options)` — and therefore any re-entrant call back + * into this same provider — before the `??=` assignment to `inFlight` + * completes. If the operation an `execute` implementation calls happens to + * require this same provider's token, `resolve()` re-enters itself while + * `inFlight` is still `null`, recursing without bound + * (`RangeError: Maximum call stack size exceeded`) instead of deduplicating. + * The real fix for the TechLog Studio case is giving `getStudioSession` a + * credential-free auth profile (`studio-csrf-composition.test.ts` proves + * that end to end); this file pins the provider's own defense-in-depth + * guard in isolation, so any future `execute` implementation with the same + * mistake fails loudly and immediately instead of overflowing the stack. + */ + +test("shares one in-flight request across concurrent callers", async () => { + let executions = 0; + let resolveExecute: ((snapshot: { csrfToken: string; csrfHeaderName: string }) => void) | undefined; + const provider = createCsrfTokenProvider({ + execute() { + executions += 1; + return new Promise((resolve) => { + resolveExecute = resolve; + }); + }, + }); + + const first = provider.token(); + const second = provider.token(); + resolveExecute?.({ csrfToken: "csrf-1", csrfHeaderName: "X-CSRF-TOKEN" }); + + assert.equal(await first, "csrf-1"); + assert.equal(await second, "csrf-1"); + assert.equal(executions, 1); +}); + +test("caches the token after the first successful resolution", async () => { + let executions = 0; + const provider = createCsrfTokenProvider({ + async execute() { + executions += 1; + return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + + assert.equal(await provider.token(), "csrf-1"); + assert.equal(await provider.token(), "csrf-1"); + assert.equal(executions, 1); +}); + +test("re-fetches after invalidate()", async () => { + let executions = 0; + const provider = createCsrfTokenProvider({ + async execute() { + executions += 1; + return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + + assert.equal(await provider.token(), "csrf-1"); + provider.invalidate(); + assert.equal(await provider.token(), "csrf-2"); + assert.equal(executions, 2); +}); + +test("fails loudly instead of recursing when execute re-enters the provider before its first request settles", async () => { + let provider!: CsrfTokenProvider; + provider = createCsrfTokenProvider({ + async execute() { + // Reproduces the shape of the C1 cycle directly: the operation that + // issues the token itself asks this same provider for the token, + // synchronously re-entering `resolve()` before `inFlight` is assigned. + await provider.token(); + return { csrfToken: "unreachable", csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + + await assert.rejects(provider.token(), /re-entered/); +}); diff --git a/tests/features/tech-log/use-studio-asset-gateway.test.tsx b/tests/features/tech-log/use-studio-asset-gateway.test.tsx new file mode 100644 index 0000000..b8a41f9 --- /dev/null +++ b/tests/features/tech-log/use-studio-asset-gateway.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import assert from "node:assert/strict"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { test } from "vitest"; + +import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts"; +import { + StudioContext, + useStudioAssetGateway, + type StudioContextValue, +} from "../../../src/features/tech-log/presentation/studio/use-studio.ts"; + +/** + * I3 (Task 7 fix round 1). `StudioContextValue.assetGateway` stays nullable + * so the many test harnesses that render `StudioProvider` without a + * `createAssetGateway` prop keep compiling — but that nullability would let + * Asset UI (Task 11) write `if (!assetGateway) return null` and ship a + * confusingly empty screen with the type checker satisfied. This pins the + * throwing accessor Asset UI must use instead. + */ +function contextValue( + assetGateway: StudioAssetGateway | null, +): StudioContextValue { + return { + gateway: {} as never, + assetGateway, + resolvePublishedLabel: () => undefined, + now: () => new Date("2026-08-14T01:00:00.000Z"), + editor: null, + requestAnnouncement: "", + setRequestAnnouncement: () => {}, + navigateInternal: () => {}, + beginEditor: () => {}, + updateEditorDraft: () => {}, + setEditorStatus: () => {}, + clearEditor: () => {}, + }; +} + +function wrapperFor(assetGateway: StudioAssetGateway | null) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +test("returns the asset gateway when the provider supplied one", () => { + const assetGateway = {} as StudioAssetGateway; + const { result } = renderHook(() => useStudioAssetGateway(), { + wrapper: wrapperFor(assetGateway), + }); + assert.equal(result.current, assetGateway); +}); + +test("throws a clear error instead of returning null when no asset gateway was supplied", () => { + assert.throws( + () => renderHook(() => useStudioAssetGateway(), { wrapper: wrapperFor(null) }), + /useStudioAssetGateway must be used within a StudioProvider that was given a createAssetGateway prop/, + ); +}); diff --git a/tests/runtime-schema/http-schema.test.ts b/tests/runtime-schema/http-schema.test.ts index 9c66ea5..409365e 100644 --- a/tests/runtime-schema/http-schema.test.ts +++ b/tests/runtime-schema/http-schema.test.ts @@ -85,4 +85,15 @@ describe("REST auth profile registry", () => { "x-csrf-token", ]); }); + + it("installs a credential-header-free bootstrap profile for getStudioSession", () => { + const profile = INSTALLED_REST_AUTH_PROFILES.get( + "TECH_LOG_STUDIO_BOOTSTRAP", + ); + expect(profile).toBeTruthy(); + expect(profile?.transport).toBe("SAME_ORIGIN_COOKIE"); + expect(profile?.credentials).toBe("include"); + expect([...(profile?.requiredCredentialHeaders ?? [])]).toEqual([]); + expect([...(profile?.allowedCredentialHeaders ?? [])]).toEqual([]); + }); }); diff --git a/tests/unit/contract-registry-immutability.test.ts b/tests/unit/contract-registry-immutability.test.ts index cc47844..9533159 100644 --- a/tests/unit/contract-registry-immutability.test.ts +++ b/tests/unit/contract-registry-immutability.test.ts @@ -91,6 +91,7 @@ describe("LIVE-02 installed REST auth profile registry", () => { expect([...registry.keys()].sort()).toEqual([ "ANONYMOUS", "REFERENCE_EXTERNAL_BEARER", + "TECH_LOG_STUDIO_BOOTSTRAP", "TECH_LOG_STUDIO_SESSION", ]); expect([...registry.entries()].length).toBe(registry.size);