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
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user