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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user