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