fix: give each API surface its own error-code enum
The public site answered every screen with the terminal error surface. Three defects stacked, and each one hid the next. The first refused the request outright: `attachCredentials` asks the Studio helper, which returns null for a profile it does not own, and the fallback below read the session and rejected anything not authenticated. Public reads declare the ANONYMOUS profile, so a signed-out visitor — the public site's entire audience — never got a request out of the browser. An anonymous profile carries no credentials by definition and must never consult the session. With requests flowing, the second surfaced: `envelopeError()` pinned `ApiError.code` to the Studio enum and all three surfaces shared it. Public and Management each declare their own enum in their own contract, so every error they returned failed validation and arrived as a CONTRACT_VIOLATION — an unclassifiable transport fault — rather than the domain error it was. A strict enum checked against the wrong surface's contract still looks strict, which is why no gate caught it. Each surface now passes its own contract's codes. The third was the not-found path: it read `status` and `code` off the problem body, but the envelope has no `status` and names the code for its surface (PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The HTTP status from the transport is the authoritative signal and the only one that holds across both shapes. The regression test composes the real runtime adapters against the deployed backend's actual 404 body. Neither the gateway tests (which stub the executor) nor the screen tests (which stub the gateway) cover this seam, and the whole outage lived in it. Two page-level fixes came out of the same investigation: the profile page asked for two project slugs that only ever existed in the static fixture, and the index pages held their fixed header copy behind a request that had nothing to do with it. Headers now paint immediately; only the sections that are actually waiting show a fallback, and an empty list says so instead of rendering blank.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||
|
||||
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
|
||||
type Release = Parameters<typeof createRuntimeAdapters>[0]["release"];
|
||||
|
||||
/**
|
||||
* The public site is read by people who are not signed in — that is the whole
|
||||
* point of it — and the composition root is the only place that decides whether
|
||||
* an operation is allowed to leave the browser.
|
||||
*
|
||||
* Two defects met there and took the entire public surface down in production.
|
||||
* `attachCredentials` asked the Studio credential helper first, which returns
|
||||
* null for a profile it does not own; the fallback below it read the session
|
||||
* and refused anything not authenticated, so every ANONYMOUS operation resolved
|
||||
* to UNAUTHENTICATED with no request ever dispatched. And the public gateway
|
||||
* detected "not found" by reading `status`/`code` off the problem body, fields
|
||||
* the ADR-006 envelope does not have — so a 404 raised the terminal-error
|
||||
* surface instead of the page's own not-found state.
|
||||
*
|
||||
* Neither was caught by the gateway tests (which stub the executor) or the
|
||||
* screen tests (which stub the gateway). This is the seam between them.
|
||||
*/
|
||||
|
||||
const runtime = {
|
||||
config: {
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://api.test",
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "demo",
|
||||
REQUEST_TIMEOUT_MS: 5000,
|
||||
MAX_RETRY_ATTEMPTS: 0,
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
CONFIG_SCHEMA_VERSION: "2.0",
|
||||
CAPABILITY_OVERRIDES: {
|
||||
REALTIME: "DEFAULT",
|
||||
WEB_WORKER: "DEFAULT",
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
},
|
||||
FEATURE_OVERRIDES: {},
|
||||
TECH_LOG_STUDIO_SOURCE: "HTTP",
|
||||
TECH_LOG_PUBLIC_SOURCE: "HTTP",
|
||||
},
|
||||
configSchema: "V2",
|
||||
build: {
|
||||
buildId: "build-a",
|
||||
commitSha: "abc123",
|
||||
routerBasePath: "/",
|
||||
runtimeConfigUrl: "/config.json",
|
||||
},
|
||||
validationDurationMs: 0,
|
||||
} as unknown as Runtime;
|
||||
|
||||
const release = {
|
||||
releaseId: "release-a",
|
||||
manifestUrl: "/release-manifest.json",
|
||||
} as unknown as Release;
|
||||
|
||||
// Copied verbatim from the deployed backend's answer for a slug that does not
|
||||
// exist. The code is PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND, and there is no
|
||||
// top-level `status` — the two facts the old detection assumed away.
|
||||
const NOT_FOUND_BODY = JSON.stringify({
|
||||
success: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: "PUBLIC_RESOURCE_NOT_FOUND",
|
||||
category: "NOT_FOUND",
|
||||
message: "요청한 자료를 찾을 수 없습니다",
|
||||
retryable: false,
|
||||
details: null,
|
||||
},
|
||||
meta: {
|
||||
requestId: "dd674218-a0c0-4a1b-a3d3-d0b8563783a2",
|
||||
traceId: "78b4fdd3dba6436281e9d4ac884ecd72",
|
||||
correlationId: "26c37920-7df6-4a06-afe1-61b6f1b094ae",
|
||||
page: null,
|
||||
},
|
||||
});
|
||||
|
||||
describe("public reads by a signed-out visitor", () => {
|
||||
it("dispatches the request and reads a 404 envelope as absent", async () => {
|
||||
const fetcher = vi.fn(
|
||||
async () =>
|
||||
new Response(NOT_FOUND_BODY, {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime,
|
||||
release,
|
||||
host: {},
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
// The precondition that made the bug: nobody is signed in.
|
||||
expect(adapters.outputPorts.session.getState()).toBe("unauthenticated");
|
||||
|
||||
const { publicContent } = adapters.featureInputs[TECH_LOG_FEATURE_ID];
|
||||
await expect(publicContent.getRecord("CASE", "missing-slug")).resolves.toBeUndefined();
|
||||
|
||||
// The load-bearing assertion. Before the fix this was 0: the operation was
|
||||
// refused as UNAUTHENTICATED inside the composition root.
|
||||
expect(fetcher).toHaveBeenCalled();
|
||||
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user