Files
DongHyeonkaandClaude Opus 5 2cab4974b7 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>
2026-08-18 03:07:02 +09:00

89 lines
3.2 KiB
TypeScript

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/);
});