Skip to content

Commit 5d3018d

Browse files
fix(runtime)!: an active ADR-0069 gate that cannot read the session answers 503 at the dispatcher's seam (#23363)
Fixes #23245 Clause-②: no (narrowing: an active gate's outage now refuses what it served) ## What this changes `HttpDispatcher.enforceAuthGate` (`packages/runtime/src/http-dispatcher.ts`) read the session with `.catch(() => undefined)` and wrapped the whole decision in `catch { return null }`. So while the auth service could not answer, an active ADR-0069 gate took the failed read for "no session" and admitted, on every route the runtime dispatcher serves. REST's `computeExecCtx` answers the same window `503` under #13906's decision 2 (option B, "an *active* auth gate whose session re-read fails answers **503**"), and so does the dispatcher plugin's declared-endpoint fallback since #23190. Now, once `isAuthGateActive()` has answered `true`, a failure to read the session throws `AuthzStoreUnavailableError('auth_gate', err)`. The outer net re-raises that brand alone (`rethrowAuthzStoreUnavailable`, the shape core prescribes) and keeps failing open on every other fault. The call sits above `dispatch()`'s own `try`, so the error leaves `dispatch()`, and the transport's existing error exit (`errorResponseBase` in `dispatcher-plugin.ts`) answers it `503 SERVICE_UNAVAILABLE`. That is the exit the identity step's and the membership gate's outages already take. No second rendering is added. ## The arms, case for case | gate state | dispatcher before | dispatcher after | REST `computeExecCtx` | declared-endpoint fallback | |:--|:--|:--|:--|:--| | no gate feature active (no read is made) | admitted | admitted, unchanged | admitted | admitted | | `isAuthGateActive()` throws | admitted | admitted, unchanged | admitted | admitted | | active, the session read rejects | **admitted** | **503** | 503 | 503 | | active, the session API cannot be obtained, or the header bag cannot be normalised | admitted | **503** | anonymous (see note) | 503 | | active, the read resolves with no posture (no session, an un-held user) | admitted | admitted, unchanged | admitted | admitted | | active, the read carries a posture | 403 with the gate code | 403, unchanged | 403 | 403 | Note on the fourth row. On REST those steps run in the identity half of `computeExecCtx`, before its gate block, and a fault there falls to the method's outer net, so the caller becomes anonymous. The dispatcher's gate runs after identity has been decided, so "anonymous" is not an answer this seam can give: the choice is to admit or to refuse. This PR refuses, as the in-package sibling `readGatedSessionUser` does for exactly these steps. An active gate whose session read could not even be made has failed to read, in the ruling's terms. The arm is named here so the review can rule on it. ## Pin `packages/runtime/src/http-dispatcher.auth-gate-outage.pin.test.ts` drives the three cases through `dispatch()`: 1. **Outage:** an active gate whose session read rejects. `dispatch()` rejects with the branded error (`code` `SERVICE_UNAVAILABLE`, `status` 503, `object` `auth_gate`), and the route's handler never runs. 2. **Gate off:** the same failing backend with no gate feature active is served as before. The gate makes no read of its own, measured as a read count (an active gate on the same request reads exactly once more than an inactive one). 3. **Active and clear:** a read that resolves with an un-held user, or with no session, is admitted. A positive control rides beside them: an expired password on a route the allow-list does not name gets the gate's 403, so a green "served" cannot mean that the gate never ran. A second block mounts the real dispatcher plugin on a recording server and reads the wire, one row per gate state: the outage is answered `503` with an envelope that parses under `BaseResponseSchema` and `ApiErrorSchema` and carries `code: SERVICE_UNAVAILABLE`; gate off keeps its `401 UNAUTHENTICATED`; active and clear keeps the route's own `501`; held keeps `403 PASSWORD_EXPIRED`. **Negative control** (one shot, not kept as a test). With the fix committed (`ebf5848f6`), `node scripts/ablation-replace.mjs` replaced the outer net with `catch { return null }` (anchor 1 hit, x1 to x0, blob `5b42c4c85e3b` to `db96181d7c71`) and ran the pin: ```text × CASE 1 — OUTAGE: … → `dispatch()` RESOLVED — the active gate failed open on the outage: expected undefined to be defined × OUTAGE on the wire: … → expected 401 to be 503 // Object.is equality Tests 2 failed | 7 passed (9) ablation-replace: ok restored: blob == HEAD (5b42c4c) and `git diff HEAD` is empty ``` Both outage cases went red and every control stayed green, which was the direction predicted before the run. The wire reading also confirms the "before" column: the outage used to answer `401` there, byte for byte the gate-off answer. ## Full-repo pin sweep No pin in the repository asserts the old fail-open at this seam, so nothing needed flipping. - Every test that wires `isAuthGateActive` (13 files) was checked. The only ones that drive a failing read on an active gate pin other seams (REST, the declared-endpoint fallback, the storage routes), and each already expects 503. - The error code and object (`'auth_gate'`) appear only at those seams. - The fail-open wording (`fail-open`, `fails open`, `never break dispatch`) in `packages/runtime/src` tests appears in prose only, never in an assertion on this seam. - The full `@objectstack/runtime` suite stays green with the fix. ## Verification (at `061c8f78e`, after merging `origin/main` `228357ab6`) - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: `Test Files 372 passed (372)`, `Tests 5385 passed | 19 skipped (5404)`. The `repo` project: `Test Files 3 passed (3)`, `Tests 808 passed (808)`. - `pnpm --filter @objectstack/runtime typecheck`: exit 0, and `check:test-typecheck: OK` (the new test file adds no test-layer debt). - Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 62 commands at this head. All 62 ran, and `--ran` answered `62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED`. Every one exited 0 at this head. On the pre-merge run, two had refused with PREREQUISITE NOT MET: `check-plugin-teardown-shape --self-test` lacked a fixture commit that is missing from a shallow clone, and `check:dual-build-cjs-loads` needs an all-package build. Both were re-run green after fetching that commit and building every package. Both refusals are recorded as refusals, not as passes. - Lint, as a narrowed proof at `061c8f78e`. ① Population, read from eslint's own config: the two `.ts` files are linted, and the changeset answers "File ignored because no matching configuration was supplied". ② `--format json`: 2 files linted, 0 errors, 0 warnings. ③ Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), and its only disk reads are two baseline JSON files this diff does not touch, so no untouched file's verdict can move. The full `pnpm lint` is CI's. - Changeset: `.changeset/23245-dispatcher-auth-gate-outage.md`, `@objectstack/runtime` `minor`, `fix(runtime)!:`, one ADR-0087 marker (`not-required (no-migration-prescription)`), and the launch-window BREAKING line, per PR #23133's precedent. `check-adr-0087-registration` passes: `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. ## Acceptance notes - `packages/runtime/src/http-dispatcher.root-auth-gate.test.ts` (header near line 44, and the comment near line 110) says `enforceAuthGate` fails open on "any thrown error". After this PR that is no longer true for an active gate's failed session read. The prose is not an assertion, and the file is outside this PR's claimed surface. Carrier: the next PR that touches that file. - #23335 (in flight): its branch head `c12ba7bd0` changes only comments in `packages/core/src/security/auth-gate.ts`. The pin's posture is an expired password on a route the allow-list does not name, which that change does not touch. - PR #23346 has merged. The 503's rendering does not go through `dispatcher-error-vocabulary.ts`, which is a gate's declaration file and is not imported at runtime, so the two changes are not coupled. The pin stays green on top of it. - `CONTRACT_REVIEW_TIER` review of record: owed before the queue (the seat runs it). --- _Generated by [Claude Code](https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44cadd3 commit 5d3018d

3 files changed

Lines changed: 357 additions & 22 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
fix(runtime)!: the runtime dispatcher's ADR-0069 gate answers `503` when an active gate cannot read the session, as REST does
6+
7+
Clause-②: no (narrowing) — an active gate's outage now refuses what it served; core's existing exports only, no published schema or export moves.
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authentication narrowing at one seam, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and every request whose session read answers receives the same answer as before, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is one window: while an ADR-0069 gate feature is active and the dispatcher's gate cannot read the session, the request is now refused with `503 SERVICE_UNAVAILABLE` instead of being served. The other categories are closed on facts: `@objectstack/runtime` publishes (not unpublished); no ADR-0087 id covers this seam and this diff adds none (not registered / already-registered); and no published interface or type changes, the method being private (not runtime-interface-only / type-surface-only / runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled. While a gate feature is active, the runtime dispatcher (`HttpDispatcher`) reads the session again at its own gate to ask that question. When that read failed, because the auth service could not answer, the dispatcher took the failure for "no session" and served the request, so a session the policy holds passed the gate on every dispatcher-served route for as long as the outage lasted. REST, and the dispatcher plugin's declared-endpoint fallback, answer the same window `503`.
14+
15+
- **Refused now:** with a gate feature active, a request whose gate read fails (the session read rejects, or the auth service cannot hand back its session API) is answered `503` with the code `SERVICE_UNAVAILABLE`, in the dispatcher's existing error envelope. It is the authorization-store outage (`AuthzStoreUnavailableError`, `@objectstack/core`, read object `auth_gate`) that REST raises for the same window, and the route's own handler does not run.
16+
- **Unchanged:** with no gate feature active the gate makes no read at all, and a deployment whose gate probe itself fails is treated as having no active gate, as on REST. Whenever the read answers, the answers are as before: a held session gets the gate's `403` with its own `code`, and a session the policy does not hold, an API-key caller and an anonymous caller are served as before.
17+
18+
What changes for you. Only deployments with password expiry or enforced MFA turned on are affected, and only while their auth service cannot answer a session read: requests on routes the runtime dispatcher serves now answer a retriable `503` instead of being served. Retry once the auth service answers. Nothing needs configuring.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23245 — #13906 decision 2 option B, at the dispatcher's own seam]
5+
*
6+
* "An *active* auth gate whose session re-read fails answers **503**." REST's
7+
* `computeExecCtx` has answered that window since #13906, and the declared-
8+
* endpoint fallback since #23190 (`endpoint-policy.auth-gate.pin.test.ts`).
9+
* `HttpDispatcher.enforceAuthGate` was the one gated seam that still failed
10+
* open: its read carried `.catch(() => undefined)` and the whole decision sat
11+
* under `catch { return null }`, so while the auth service could not answer, a
12+
* session the ADR-0069 policy holds passed the gate on every dispatcher-served
13+
* route.
14+
*
15+
* ## The three cases, driven through `dispatch()`
16+
*
17+
* 1. OUTAGE — the gate is active and the session read fails: `dispatch()`
18+
* rejects with the branded `AuthzStoreUnavailableError('auth_gate', …)`
19+
* (`SERVICE_UNAVAILABLE`, 503) and the route's member never runs.
20+
* 2. GATE OFF — the SAME failing backend with no gate feature active: the
21+
* gate makes no read of its own and the request is served as before.
22+
* 3. ACTIVE, CLEAR — the read succeeds and carries no posture: served.
23+
*
24+
* Case 2 runs on the outage's own backend on purpose: the only difference
25+
* between it and case 1 is `isAuthGateActive()`, so it proves the refusal is
26+
* the ACTIVE gate's and nothing else's.
27+
*
28+
* A positive control rides beside them — an expired password on a route the
29+
* allow-list does not name answers the gate's 403 — so the fixture provably
30+
* gates and a green "served" cannot mean "the gate never ran".
31+
*
32+
* ## The rendering
33+
*
34+
* `enforceAuthGate` sits above `dispatch()`'s `try`, so the outage leaves
35+
* `dispatch()` and the transport's envelope answers it, as it answers the
36+
* identity step's and the membership gate's outages. The second block mounts
37+
* the real dispatcher plugin on a recording server and reads the wire body.
38+
*
39+
* Negative control (one-shot, recorded in the PR, not kept as a test):
40+
* restoring `catch { return null }` reds both OUTAGE cases and leaves every
41+
* control green.
42+
*/
43+
44+
import { describe, it, expect, vi } from 'vitest';
45+
46+
import { isAuthzStoreUnavailableError } from '@objectstack/core';
47+
import { ApiErrorSchema, BaseResponseSchema } from '@objectstack/spec/api';
48+
49+
import { HttpDispatcher } from './http-dispatcher.js';
50+
import type { HttpProtocolContext } from './http-dispatcher.js';
51+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
52+
53+
const ORIGIN = 'http://tenant.example';
54+
/** Not allow-listed, and the domain hands the request to a recording member. */
55+
const ROUTE = '/webhooks/redeliver';
56+
const JSON_HEADERS = { 'Content-Type': 'application/json' };
57+
58+
/** An ADR-0069 posture (`normalizeAuthGate`'s shape): an expired password. */
59+
const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' };
60+
61+
type Backend = 'healthy' | 'outage';
62+
63+
/**
64+
* An `auth` service. `gateActive` answers the probe; `backend` decides whether
65+
* a session read answers or rejects; `user` is the session's user. Every read
66+
* is counted, so "the gate made no read" is a number rather than a hope.
67+
*/
68+
function authService(opts: { gateActive: boolean; backend: Backend; user?: Record<string, unknown> }) {
69+
const getSession = vi.fn(async () => {
70+
if (opts.backend === 'outage') throw new Error('session backend unavailable');
71+
return opts.user ? { user: opts.user, session: { userId: opts.user.id, activeOrganizationId: 'org_tenant' } } : null;
72+
});
73+
const isAuthGateActive = vi.fn(() => opts.gateActive);
74+
return { service: { isAuthGateActive, getApi: async () => ({ getSession }) }, getSession, isAuthGateActive };
75+
}
76+
77+
/** A member shaped like `IWebhookService.handleRedeliver`, recording each run. */
78+
function recordingMember() {
79+
const handleRedeliver = vi.fn(async () => new Response(
80+
JSON.stringify({ success: true, data: { status: 'pending' } }),
81+
{ status: 200, headers: JSON_HEADERS },
82+
));
83+
return { handleRedeliver };
84+
}
85+
86+
/** A kernel with no `http.server` — only the services named. */
87+
function kernelWith(services: Record<string, unknown>): any {
88+
return {
89+
getState: () => 'running',
90+
getService: (n: string) => services[n] ?? null,
91+
getServiceAsync: async (n: string) => services[n] ?? null,
92+
context: { getService: (n: string) => services[n] ?? null },
93+
};
94+
}
95+
96+
function objectqlStub() {
97+
return {
98+
find: vi.fn().mockResolvedValue([]),
99+
getObjects: vi.fn().mockReturnValue({}),
100+
registry: {
101+
getObject: vi.fn().mockReturnValue(null),
102+
getRegisteredTypes: vi.fn().mockReturnValue([]),
103+
},
104+
};
105+
}
106+
107+
function harness(opts: { gateActive: boolean; backend: Backend; user?: Record<string, unknown> }) {
108+
const auth = authService(opts);
109+
const member = recordingMember();
110+
const services = { objectql: objectqlStub(), auth: auth.service, webhooks: { handleRedeliver: member.handleRedeliver } };
111+
const dispatcher = new HttpDispatcher(kernelWith(services), undefined, { enforceProjectMembership: false });
112+
const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
113+
(dispatcher as any).domainDeps.logger = logger;
114+
return { dispatcher, auth, member };
115+
}
116+
117+
const request = () => new Request(`${ORIGIN}/api/v1${ROUTE}`, {
118+
method: 'POST',
119+
headers: { ...JSON_HEADERS, cookie: 'session=held' },
120+
body: JSON.stringify({ deliveryId: 'del_1' }),
121+
});
122+
const ctx = (r: Request): HttpProtocolContext => ({ request: r });
123+
124+
/** Settle to the rejection, or to `undefined` when the call RESOLVED. */
125+
const rejectionOf = (p: Promise<unknown>) => p.then(() => undefined, (e) => e);
126+
127+
const HELD_USER = { id: 'u_held', authGate: PASSWORD_EXPIRED };
128+
const CLEAR_USER = { id: 'u_clear' };
129+
130+
describe('[#23245] HttpDispatcher.enforceAuthGate — an active gate that cannot read the session answers 503', () => {
131+
it('⭐ POSITIVE CONTROL — this fixture gates: an expired password is refused 403 with the gate code, and the member never runs', async () => {
132+
const { dispatcher, member } = harness({ gateActive: true, backend: 'healthy', user: HELD_USER });
133+
const result: any = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request()));
134+
expect(result.response?.status).toBe(403);
135+
expect(result.response?.body?.error?.code).toBe('PASSWORD_EXPIRED');
136+
expect(member.handleRedeliver).not.toHaveBeenCalled();
137+
});
138+
139+
it('CASE 1 — OUTAGE: an active gate whose session read fails rejects with the branded 503, and the member never runs', async () => {
140+
// What origin/main answered on this fixture: the read's rejection
141+
// became "no session", the gate admitted, and `dispatch()` RESOLVED —
142+
// the request went on to the route exactly as the gate-off control
143+
// (CASE 2, same backend) still does.
144+
const { dispatcher, member } = harness({ gateActive: true, backend: 'outage' });
145+
const err: any = await rejectionOf(dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request())));
146+
expect(err, '`dispatch()` RESOLVED — the active gate failed open on the outage').toBeDefined();
147+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
148+
expect(err.code).toBe('SERVICE_UNAVAILABLE');
149+
expect(err.status).toBe(503);
150+
expect(err.object).toBe('auth_gate');
151+
expect(member.handleRedeliver).not.toHaveBeenCalled();
152+
});
153+
154+
it('CASE 2 — GATE OFF: the SAME failing backend with no gate active makes no gate read and is served as before', async () => {
155+
const off = harness({ gateActive: false, backend: 'outage' });
156+
const result: any = await off.dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request()));
157+
expect(result.response, JSON.stringify(result.response?.body)).toBeUndefined();
158+
expect((result.result as Response).status).toBe(200);
159+
expect(off.member.handleRedeliver).toHaveBeenCalledTimes(1);
160+
expect(off.auth.isAuthGateActive).toHaveBeenCalled();
161+
162+
// "No read" measured, not assumed: an ACTIVE gate on the same request
163+
// reads exactly once more than the inactive one does (the identity
164+
// step's own reads are the shared baseline).
165+
const on = harness({ gateActive: true, backend: 'healthy', user: CLEAR_USER });
166+
const offHealthy = harness({ gateActive: false, backend: 'healthy', user: CLEAR_USER });
167+
await on.dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request()));
168+
await offHealthy.dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request()));
169+
expect(on.auth.getSession.mock.calls.length).toBe(offHealthy.auth.getSession.mock.calls.length + 1);
170+
expect(off.auth.getSession.mock.calls.length).toBe(offHealthy.auth.getSession.mock.calls.length);
171+
});
172+
173+
it('CASE 3 — ACTIVE, CLEAR: a successful read carrying no posture still admits', async () => {
174+
const { dispatcher, member } = harness({ gateActive: true, backend: 'healthy', user: CLEAR_USER });
175+
const result: any = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request()));
176+
expect(result.response, JSON.stringify(result.response?.body)).toBeUndefined();
177+
expect((result.result as Response).status).toBe(200);
178+
expect(member.handleRedeliver).toHaveBeenCalledTimes(1);
179+
});
180+
181+
it('CASE 3 — ACTIVE, NO SESSION: a read that resolves with no session (an anonymous caller) is not an outage', async () => {
182+
const { dispatcher, member } = harness({ gateActive: true, backend: 'healthy' });
183+
const result: any = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request()));
184+
expect(result.response, JSON.stringify(result.response?.body)).toBeUndefined();
185+
expect(member.handleRedeliver).toHaveBeenCalledTimes(1);
186+
});
187+
});
188+
189+
describe('[#23245] the outage reaches the transport envelope as 503 SERVICE_UNAVAILABLE', () => {
190+
/** A fake `IHttpServer` recording the handlers the plugin mounts. */
191+
function makeFakeServer() {
192+
const handlers: Record<string, (req: any, res: any) => any> = {};
193+
const rec = (verb: string) => (path: string, handler: any) => { handlers[`${verb} ${path}`] = handler; };
194+
return {
195+
handlers,
196+
server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') },
197+
};
198+
}
199+
200+
async function mountOn(opts: { gateActive: boolean; backend: Backend; user?: Record<string, unknown> }) {
201+
const { server, handlers } = makeFakeServer();
202+
const auth = authService(opts);
203+
const services: Record<string, unknown> = { objectql: objectqlStub(), auth: auth.service };
204+
const kernel = kernelWith({ ...services, 'http.server': server });
205+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
206+
await plugin.start?.({
207+
getKernel: () => kernel,
208+
getService: (n: string) => (n === 'http.server' ? server : undefined),
209+
environmentId: undefined,
210+
logger: { info() {}, warn() {}, error() {}, debug() {} },
211+
hook: () => {}, on: () => {},
212+
} as any);
213+
return handlers;
214+
}
215+
216+
async function drive(handler: (req: any, res: any) => any, req: any) {
217+
expect(handler, 'route must be mounted').toBeTypeOf('function');
218+
const res: any = {
219+
statusCode: undefined, body: undefined,
220+
status(c: number) { res.statusCode = c; return res; },
221+
header() { return res; },
222+
json(b: any) { res.body = b; return res; },
223+
end() { return res; },
224+
};
225+
await handler(req, res);
226+
return { status: res.statusCode as number, body: res.body };
227+
}
228+
229+
// A mounted route whose own answer is distinguishable from every gate
230+
// answer — `501 NOT_IMPLEMENTED` with no i18n service — measured on this
231+
// fixture, one row per gate state:
232+
//
233+
// gate | session read | before (origin/main) | after
234+
// ---------|---------------------|------------------------|------------------------
235+
// active | FAILS | 401 UNAUTHENTICATED | 503 SERVICE_UNAVAILABLE
236+
// inactive | FAILS (same backend)| 401 UNAUTHENTICATED | 401 — unchanged
237+
// active | resolves, no posture| 501 NOT_IMPLEMENTED | 501 — unchanged (admitted)
238+
// active | resolves, held | 403 PASSWORD_EXPIRED | 403 — unchanged
239+
//
240+
// The 401 on the first row is the defect on the wire: the gate admitted,
241+
// and the request ran on as the anonymous caller the identity step's own
242+
// failed read left behind, byte-identical to the gate-off row.
243+
const MOUNTED = 'GET /api/v1/i18n/locales';
244+
const wireRequest = { headers: { cookie: 'session=held' }, query: {} };
245+
246+
it('OUTAGE on the wire: an active gate whose read fails answers 503 with a declared `SERVICE_UNAVAILABLE` envelope', async () => {
247+
const handlers = await mountOn({ gateActive: true, backend: 'outage' });
248+
const { status, body } = await drive(handlers[MOUNTED], wireRequest);
249+
expect(status).toBe(503);
250+
expect(BaseResponseSchema.safeParse(body).success).toBe(true);
251+
expect(body?.success).toBe(false);
252+
const parsed = ApiErrorSchema.safeParse(body?.error);
253+
expect(parsed.error?.issues ?? []).toEqual([]);
254+
expect(body?.error?.code).toBe('SERVICE_UNAVAILABLE');
255+
});
256+
257+
it('CONTROL on the wire: the same failing backend with no gate active keeps its anonymous answer', async () => {
258+
const handlers = await mountOn({ gateActive: false, backend: 'outage' });
259+
const { status, body } = await drive(handlers[MOUNTED], wireRequest);
260+
expect(status, JSON.stringify(body)).toBe(401);
261+
expect(body?.error?.code).toBe('UNAUTHENTICATED');
262+
});
263+
264+
it('CONTROL on the wire: an active gate whose read resolves with no posture admits — the route answers for itself', async () => {
265+
const handlers = await mountOn({ gateActive: true, backend: 'healthy', user: CLEAR_USER });
266+
const { status, body } = await drive(handlers[MOUNTED], wireRequest);
267+
expect(status, JSON.stringify(body)).toBe(501);
268+
expect(body?.error?.code).toBe('NOT_IMPLEMENTED');
269+
});
270+
271+
it('POSITIVE CONTROL on the wire: a held session is refused with the gate code', async () => {
272+
const handlers = await mountOn({ gateActive: true, backend: 'healthy', user: HELD_USER });
273+
const { status, body } = await drive(handlers[MOUNTED], wireRequest);
274+
expect(status, JSON.stringify(body)).toBe(403);
275+
expect(body?.error?.code).toBe('PASSWORD_EXPIRED');
276+
});
277+
});

0 commit comments

Comments
 (0)