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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9c832c365
commit
2cab4974b7
@@ -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`에 있다.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<Record<string, RestAuthProfile>>);
|
||||
|
||||
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Asset>("updateStudioAsset", { assetId, ...cmd }, options),
|
||||
|
||||
@@ -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<CredentialPatchOutcome | null> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<StudioSessionSnapshot> | null = null;
|
||||
let resolving = false;
|
||||
|
||||
async function resolve(
|
||||
options?: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<StudioSessionSnapshot> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(schemaId: string, schema: z.ZodType<T>): RuntimeValidator<T> {
|
||||
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<unknown, unknown, unknown> {
|
||||
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<unknown, unknown, unknown>;
|
||||
@@ -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<unknown, unknown, unknown>;
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("<html>not json</html>", { 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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(["<svg/>"], "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(["<svg/>"], "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: {
|
||||
|
||||
@@ -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(["<svg/>"], "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);
|
||||
},
|
||||
);
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 (
|
||||
<StudioContext.Provider value={contextValue(assetGateway)}>
|
||||
{children}
|
||||
</StudioContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user