From 760071156da932dee05397480f2c65d92ef9f05b Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 21 Aug 2026 01:14:20 +0900 Subject: [PATCH] fix: give each API surface its own error-code enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../adapters/http/http-management-gateway.ts | 15 ++- .../http/http-public-content-gateway.ts | 26 +++- ...ch-log-management-contract-contribution.ts | 18 ++- .../tech-log-public-contract-contribution.ts | 13 +- .../tech-log-studio-contract-contribution.ts | 39 ++++-- .../public/pages/profile-page.tsx | 52 +++++--- .../public/pages/projects-page.tsx | 11 +- .../public/pages/releases-page.tsx | 13 +- .../tech-log/presentation/styles/globals.css | 5 + .../tech-log/public-index-screens.test.tsx | 9 +- tests/unit/public-anonymous-read.test.ts | 112 ++++++++++++++++++ 11 files changed, 270 insertions(+), 43 deletions(-) create mode 100644 tests/unit/public-anonymous-read.test.ts diff --git a/src/features/tech-log/adapters/http/http-management-gateway.ts b/src/features/tech-log/adapters/http/http-management-gateway.ts index 9f62f14..cc1092c 100644 --- a/src/features/tech-log/adapters/http/http-management-gateway.ts +++ b/src/features/tech-log/adapters/http/http-management-gateway.ts @@ -38,8 +38,19 @@ export function createHttpManagementGateway( const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID }); if (outcome.kind === "SUCCESS") return outcome.value as T; if (outcome.kind === "PROBLEM") { - const problem = outcome.problem as Readonly<{ code?: string }> | null; - throw new ManagementGatewayError(operationId, problem?.code ?? "PROBLEM"); + // The management surface answers with the ADR-006 envelope, which nests + // the code under `error` — reading `problem.code` found nothing and every + // failure surfaced as the literal "PROBLEM", matching no i18n key. + const body = outcome.problem as + | Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }> + | null; + const code = + typeof body?.code === "string" + ? body.code + : typeof body?.error?.code === "string" + ? body.error.code + : "PROBLEM"; + throw new ManagementGatewayError(operationId, code); } throw new ManagementGatewayError(operationId, outcome.kind); } diff --git a/src/features/tech-log/adapters/http/http-public-content-gateway.ts b/src/features/tech-log/adapters/http/http-public-content-gateway.ts index 6298066..51cc11e 100644 --- a/src/features/tech-log/adapters/http/http-public-content-gateway.ts +++ b/src/features/tech-log/adapters/http/http-public-content-gateway.ts @@ -46,6 +46,20 @@ function gatewayError(operationId: string, detail: string): PublicContentGateway return error; } +/** + * Reads the backend's error code out of either response shape — RFC7807 puts it + * at the top level, the ADR-006 envelope nests it under `error`. Without this + * every failure was reported as the literal "PROBLEM", which told a reader + * nothing and matched no i18n key. + */ +function problemCode(problem: unknown): string { + if (!problem || typeof problem !== "object") return "PROBLEM"; + const body = problem as Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>; + if (typeof body.code === "string") return body.code; + if (typeof body.error?.code === "string") return body.error.code; + return "PROBLEM"; +} + export function createHttpPublicContentGateway( deps: Readonly<{ operations: StudioOperationExecutor }>, ): PublicContentQueries { @@ -53,9 +67,15 @@ export function createHttpPublicContentGateway( const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID }); if (outcome.kind === "SUCCESS") return outcome.value as T; if (outcome.kind === "PROBLEM") { - const problem = outcome.problem as Readonly<{ status?: number; code?: string }> | null; - if (problem?.status === 404 || problem?.code === "NOT_FOUND") return NOT_FOUND; - throw gatewayError(operationId, problem?.code ?? "PROBLEM"); + // The HTTP status is the authoritative signal, and the only one that + // holds across both shapes this surface answers with. RFC7807 carries + // `status` in the body; the ADR-006 envelope does not — it puts the + // reason in `error.category` and a backend-specific string in + // `error.code` (PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The old + // body-only check matched neither, so every 404 raised the terminal + // error surface on a page whose real state was "this does not exist". + if (outcome.metadata.status === 404) return NOT_FOUND; + throw gatewayError(operationId, problemCode(outcome.problem)); } throw gatewayError(operationId, outcome.kind); } diff --git a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts index 2dc5c2a..17275c6 100644 --- a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts @@ -18,7 +18,23 @@ type QueryEntries = readonly (readonly [string, string])[]; const NO_PATH: PathValues = Object.freeze({}); const NO_QUERY = Object.freeze([]) as QueryEntries; -const PROBLEM = envelopeError(); +/** studio-management-v1.yaml `ApiError.code` enum과 1:1이다. */ +const MANAGEMENT_ERROR_CODES = Object.freeze([ + "AUTHENTICATION_REQUIRED", + "STUDIO_ACCESS_DENIED", + "REQUEST_VALIDATION_FAILED", + "VERSION_CONFLICT", + "TOPIC_NOT_FOUND", + "TOPIC_NAME_TAKEN", + "TOPIC_SLUG_TAKEN", + "TOPIC_IN_USE", + "PROJECT_NOT_FOUND", + "PROJECT_SLUG_TAKEN", + "PROJECT_IN_USE", + "INTERNAL_ERROR", +]); + +const PROBLEM = envelopeError(MANAGEMENT_ERROR_CODES, "ManagementErrorEnvelope"); /** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */ const COMMAND_EFFECT: CommandEffectDescriptor = Object.freeze({ diff --git a/src/features/tech-log/contracts/tech-log-public-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-public-contract-contribution.ts index c3f8779..dac5eda 100644 --- a/src/features/tech-log/contracts/tech-log-public-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-public-contract-contribution.ts @@ -11,7 +11,18 @@ type QueryEntries = readonly (readonly [string, string])[]; const NO_PATH: PathValues = Object.freeze({}); const NO_QUERY = Object.freeze([]) as QueryEntries; -const PROBLEM = envelopeError(); +/** + * public-v1.yaml `ApiError.code` enum과 1:1이다. `INTERNAL_ERROR` 는 이 기능이 + * 아니라 스켈레톤 공통 처리기가 내는 코드이고, 계약이 그것까지 열거하므로 여기도 + * 열거한다 — 빠지면 500 응답이 계약 위반으로 분류된다. + */ +const PUBLIC_ERROR_CODES = Object.freeze([ + "PUBLIC_REQUEST_INVALID", + "PUBLIC_RESOURCE_NOT_FOUND", + "INTERNAL_ERROR", +]); + +const PROBLEM = envelopeError(PUBLIC_ERROR_CODES, "PublicErrorEnvelope"); function queryOf(input: Readonly>): QueryEntries { const entries: (readonly [string, string])[] = []; diff --git a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts index cfb20ee..e1cf9ea 100644 --- a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts @@ -77,14 +77,15 @@ export const envelopeData = (schemaId: string): RuntimeValidator => .transform((envelope) => envelope.data as T) as unknown as z.ZodType, ); -const apiErrorSchema = z - .object({ - code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]), - category: z.string().min(1), - message: z.string().min(1).max(5000), - retryable: z.boolean(), - }) - .loose(); +const apiErrorSchema = (codes: readonly string[]) => + z + .object({ + code: z.enum(codes as unknown as [string, ...string[]]), + category: z.string().min(1), + message: z.string().min(1).max(5000), + retryable: z.boolean(), + }) + .loose(); /** * 봉투 오류를 기존 ProblemDetails 형태로 옮긴다. 앱 계층(`StudioGatewayError`)은 @@ -100,11 +101,27 @@ const apiErrorSchema = z * 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가 * 생겼을 때 추가할 투기적 작업이다. */ -export const envelopeError = (): RuntimeValidator => +/** + * The code enum is per-surface, and getting that wrong took the public site + * down. Public, Studio and Management each declare their own `ApiError.code` + * enum in their own contract; this validator was pinned to the Studio list and + * shared by all three, so every public error — `PUBLIC_RESOURCE_NOT_FOUND` + * first among them — failed the enum, became a CONTRACT_VIOLATION rather than a + * PROBLEM, and reached the screens as an unclassifiable failure. A visitor + * following a link to a project that no longer exists got the terminal error + * surface instead of a not-found page, and no gate noticed, because a strict + * enum checked against the wrong surface's contract still looks strict. + * + * Each caller now passes the enum from its own contract. + */ +export const envelopeError = ( + codes: readonly string[] = STUDIO_ERROR_CODES as readonly string[], + schemaId = "StudioErrorEnvelope", +): RuntimeValidator => zodValidator( - "StudioErrorEnvelope", + schemaId, z - .object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema }) + .object({ success: z.literal(false), error: apiErrorSchema(codes), meta: metaSchema }) .loose() .transform((envelope) => ({ type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`, diff --git a/src/features/tech-log/presentation/public/pages/profile-page.tsx b/src/features/tech-log/presentation/public/pages/profile-page.tsx index 4947adf..f4055b3 100644 --- a/src/features/tech-log/presentation/public/pages/profile-page.tsx +++ b/src/features/tech-log/presentation/public/pages/profile-page.tsx @@ -23,19 +23,29 @@ const principles = [ }, ] as const; -const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const; const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const; export function ProfilePage() { + // The two project slugs this named were the static fixture's, and they exist + // in no real deployment — the page asked the backend for them, took two 404s, + // and rendered nothing but an error. "Current projects" means the published + // ones, so read them from the catalogue the projects index already reads. const view = usePublicContent(["tech-log", "profile"], async (queries) => { + const entries = (await queries.searchPublicContent("")).filter( + (item) => item.contentType === "PROJECT", + ); const resolved = await Promise.all( - currentProjectSlugs.map((slug) => queries.getProject(slug)), + entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))), ); return { currentProjects: resolved.filter((project) => project !== undefined) }; }); - if (!view.ready) return view.fallback; - const { currentProjects } = view.data; + // Only the project list comes from the network. Returning the page-wide + // fallback here — as every public screen did — held the operator's name, the + // principles, and the topics behind a request that has nothing to do with + // them, so a visitor saw a skeleton, then possibly an error, where the page + // could have been readable the whole time. The markup below is unchanged; + // the fallback now sits in the one section that is actually waiting. return (
@@ -65,20 +75,26 @@ export function ProfilePage() {

Current

현재 프로젝트

-
    - {currentProjects.map((project) => ( -
  • - -
    - {project.title} - {project.stage} -
    -

    {project.currentGoal}

    - - -
  • - ))} -
+ {!view.ready ? ( + view.fallback + ) : view.data.currentProjects.length === 0 ? ( +

아직 공개된 프로젝트가 없습니다.

+ ) : ( +
    + {view.data.currentProjects.map((project) => ( +
  • + +
    + {project.title} + {project.stage} +
    +

    {project.currentGoal}

    + + +
  • + ))} +
+ )}

Topics

diff --git a/src/features/tech-log/presentation/public/pages/projects-page.tsx b/src/features/tech-log/presentation/public/pages/projects-page.tsx index b3ae107..705e057 100644 --- a/src/features/tech-log/presentation/public/pages/projects-page.tsx +++ b/src/features/tech-log/presentation/public/pages/projects-page.tsx @@ -12,9 +12,8 @@ export function ProjectsPage() { ); return { projects: resolved.filter((project) => project !== undefined) }; }); - if (!view.ready) return view.fallback; - const { projects } = view.data; + // Header first: it is fixed copy and owes the network nothing. return (
+ {!view.ready ? ( + view.fallback + ) : view.data.projects.length === 0 ? ( +

아직 공개된 프로젝트가 없습니다.

+ ) : (
    - {projects.map((project, index) => ( + {view.data.projects.map((project, index) => (
  1. {String(index + 1).padStart(2, "0")} @@ -55,6 +59,7 @@ export function ProjectsPage() {
  2. ))}
+ )}
); } diff --git a/src/features/tech-log/presentation/public/pages/releases-page.tsx b/src/features/tech-log/presentation/public/pages/releases-page.tsx index 6818caf..2b68ff1 100644 --- a/src/features/tech-log/presentation/public/pages/releases-page.tsx +++ b/src/features/tech-log/presentation/public/pages/releases-page.tsx @@ -12,9 +12,10 @@ export function ReleasesPage() { ); return { releases: resolved.filter((release) => release !== undefined) }; }); - if (!view.ready) return view.fallback; - const { releases } = view.data; + // The header is fixed copy; only the list is a request. Returning the + // page-wide fallback here left a visitor with a skeleton — or an error — + // where the page's own explanation of itself could already be on screen. return (
+ {!view.ready ? ( + view.fallback + ) : view.data.releases.length === 0 ? ( +

아직 공개된 릴리즈가 없습니다.

+ ) : (
    - {releases.map((release) => ( + {view.data.releases.map((release) => (
  1. @@ -47,6 +53,7 @@ export function ReleasesPage() {
  2. ))}
+ )}
); } diff --git a/src/features/tech-log/presentation/styles/globals.css b/src/features/tech-log/presentation/styles/globals.css index fd9c3c5..84290e1 100644 --- a/src/features/tech-log/presentation/styles/globals.css +++ b/src/features/tech-log/presentation/styles/globals.css @@ -1958,6 +1958,11 @@ dialog::backdrop { } } +/* Shown where a public list would be, when the site has nothing published yet. + Takes the same top border and muted tone the lists it replaces already use, + so an empty page reads as a page rather than as a failure. */ +.public-empty-note { margin: 70px 0 0; padding: 44px 0; border-top: 1px solid var(--line-strong); color: var(--muted); font-size: 14px; } + .release-index-list { margin: 70px 0 0; padding: 0; border-top: 1px solid var(--line-strong); list-style: none; } .release-index-list > li { border-bottom: 1px solid var(--line); } .release-index-list a { display: grid; min-height: 150px; grid-template-columns: 150px minmax(0, 1fr) 24px; gap: 28px; padding: 30px 0 33px; } diff --git a/tests/features/tech-log/public-index-screens.test.tsx b/tests/features/tech-log/public-index-screens.test.tsx index 2d07fc7..2b1a41c 100644 --- a/tests/features/tech-log/public-index-screens.test.tsx +++ b/tests/features/tech-log/public-index-screens.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render, screen, within } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import { type ComponentType } from "react"; import { createMemoryRouter, @@ -97,7 +97,14 @@ async function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: stri )); // 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤 // 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다. + // + // `main` 이 있다는 것만으로는 더 이상 정착이 아니다. index 화면들은 고정 카피인 + // 헤더를 네트워크와 무관하게 즉시 그리므로 (그게 목적이다), `main` 은 데이터가 + // 오기 전에 존재한다. 기다려야 하는 것은 대기 중이던 구역이 대기를 멈추는 것이다. await screen.findByRole("main"); + await waitFor(() => { + expect(document.querySelector('[aria-busy="true"]')).toBeNull(); + }); return { ...view, router }; } diff --git a/tests/unit/public-anonymous-read.test.ts b/tests/unit/public-anonymous-read.test.ts new file mode 100644 index 0000000..435e16e --- /dev/null +++ b/tests/unit/public-anonymous-read.test.ts @@ -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[0]["runtime"]; +type Release = Parameters[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(); + }); +});