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(); + }); +});