Skip to content

Commit f1d6be0

Browse files
fix(service-analytics)!: an analytics read gate that could not decide answers 503 SERVICE_UNAVAILABLE, not the denial's 403 (#25595)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuDWJsBGQpadCH8JR2xZQ5re)_ Fixes #25561 Clause-②: no Metadata-protocol: none ## What was wrong Three analytics door refusals are operator-side faults: a resolver threw or could not answer. All three answered with the same wire body as a real denial, `403 PERMISSION_DENIED` naming the object. A caller could not tell "you may not read this" from "the server could not decide". | site | what happened | before | now | |---|---|---|---| | `read-admission.ts` `assertObjectsReadable`, catch branch | the object-level admission provider threw | 403 `PERMISSION_DENIED` (byte-identical to the `!admitted` branch) | 503 `SERVICE_UNAVAILABLE` | | `field-read-admission.ts` (was `fieldReadUnresolvedError`) | the readable or queryable field reader threw | 403 `PERMISSION_DENIED` | 503 `SERVICE_UNAVAILABLE` | | `api-exposure-door.ts` (was `exposureUnresolvedError`) | the object declaration lookup threw | 403 `PERMISSION_DENIED` | 503 `SERVICE_UNAVAILABLE` | ## What changed - **One fault answer for the three gates.** `readAdmissionUnresolvedError` (in `read-admission.ts`, not exported from the package) throws `{ code: 'SERVICE_UNAVAILABLE', status: 503, object }`. `SERVICE_UNAVAILABLE` is an existing ADR-0112 `StandardErrorCode`, and it is the spelling `packages/core/src/security/authz-store-unavailable.ts` uses for the same class under the maintainer ruling quoted there. No code is added, and no envelope key is added. The two per-file fault constructors are removed and their call sites use the shared one. - **No oracle.** The message names the object and nothing else: not which gate could not answer, not why, not whether the object exists. It says only that the server could not decide. The cause stays in the server log at each site, as before (`logger.error` with the cause). - **Still fail-closed.** Every path that refused still refuses: the three catch branches still throw. Only the code and the status move. - **Real denials are byte-unchanged.** The `!admitted` branch keeps `403 PERMISSION_DENIED`, and so do the field-level verdicts (`fieldReadDeniedError`, `fieldReadUnjudgeableError`). The exposure verdicts keep `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`. - **The door.** `POST /api/v1/analytics/dataset/query` already relays a declared 5xx through the shared declared-fault arm (`declaredServerFaultAnswer`). So the fault reaches the caller as `503 {"code":"SERVICE_UNAVAILABLE","error":"Internal server error"}`, not `500 ANALYTICS_QUERY_FAILED`. The message is withheld, as for every declared server fault. The cube query door (`dispatcher-plugin` `errorResponseBase`) relays the declared status and code the same way. **No `packages/rest` source change.** - **The consumer.** `packages/metadata-protocol/src/build-probes.ts` keyed its access fix on `PERMISSION_DENIED`. Without a change, a fault would have fallen to "Fix the dataset definition…", which is wrong for an outage. A `SERVICE_UNAVAILABLE` throw now gets its own fix: the dataset is not the cause; the server could not decide read access to the named object (falling back to the dataset's object); check the server log for the resolver that failed, then republish once it is healthy. The `code` (`widget_query_failed`), `severity` and `message` are unchanged. ## Tests Measured on head `18b8b6fa5d (`service-analytics` and `metadata-protocol` files are byte-identical to their state at each run below; later commits touched only the rest test and the changeset)`: - `@objectstack/service-analytics`, whole package: Test Files 185 passed (185), Tests 4517 passed | 262 skipped. Pins that asserted the 403 on a fault leg moved with the fix: `read-admission-gate.test.ts` (now across all three doors, both strategies; it also asserts the message names the object and not the cause), `field-read-admission-gate.test.ts` (reader throws, both strategies, plus the plugin bridge with no `getReadableFields`), `field-query-admission-gate.test.ts` (queryable reader throws, both strategies), `api-exposure-door.test.ts` (lookup throws; plugin with no data engine), `unexposed-definition-registration.test.ts` (query face after a throwing lookup). The real-denial twins in the same files stay `403 PERMISSION_DENIED` / `404` / `405`, untouched. - `@objectstack/rest`, new `analytics-read-gate-fault-envelope.test.ts`: a seam test through the real route with a real `AnalyticsService`. For each of the three gates, a throwing resolver gives `503 SERVICE_UNAVAILABLE` (not `PERMISSION_DENIED`, not `ANALYTICS_QUERY_FAILED`), the prose is withheld, nothing executes, the cause lands in the service log and the fault lands in the route log. The verdict twin of each gate answers `403 PERMISSION_DENIED`, `403 PERMISSION_DENIED` or `404 OBJECT_API_DISABLED`, and there is a positive control (200). The generic relay was already pinned for a hand-built declared 503; this file pins the producer and the route together. Test Files 1 passed, Tests 7 passed (re-run on 18b8b6f); `src/analytics*` in `@objectstack/rest`: Test Files 23 passed (23), Tests 298 passed | 22 skipped. - `@objectstack/metadata-protocol`, `build-probes-widget-refusal-fix.test.ts`: two new cases (`SERVICE_UNAVAILABLE` with an object; without one, which falls back to the dataset's object). Whole package: Test Files 263 passed | 3 skipped (266), Tests 21472 passed | 19 skipped; the widget-refusal file 8/8 re-run on 18b8b6f. - `@objectstack/runtime` `http-dispatcher.test.ts` + `packages-publish-drafts-probe-caller-context.test.ts`: Test Files 2 passed, Tests 251 passed. - Typecheck: `service-analytics`, `metadata-protocol` and `rest` all exit 0. **Ablation (one site, predicted red).** With the fix committed, I reverted the read-admission catch branch to the denial (`throw readAdmissionDeniedError(...)`, tagged with a marker) through `scripts/ablation-replace.mjs`. Then I rebuilt `service-analytics`, and `ablation-dist-preflight` showed the marker present in `dist/index.js` and `dist/index.cjs`. Result: `read-admission-gate.test.ts` went **6 failed / 19 passed** (the fault case on all three doors × two strategies), and the rest seam went **1 failed / 6 passed** (the object-level fault row; the twins stayed green). Restore: blob equal to HEAD and `git diff HEAD` empty. After the rebuild, preflight `--absent` reported the marker absent from all 6 built files, the tree clean, and the rest seam back at 7/7. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 72 commands. All 72 were run locally and reconciled with `--ran`: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero, all 72 recorded an exit code)". All exit 0 on 18b8b6f. On the first pass `check-rest-log-spy-declared` was red on the new rest test (no `OS_REST_LOG` declared); that is fixed in this PR. Three gates were red only on tree state while a concurrent closure build was rewriting `dist/` (`check:dts-closure`, `check:dual-build-cjs-loads` PREREQUISITE, `check:type-check-debt` exit 3); each was re-run on the settled tree and exits 0. `pnpm lint` (repo-wide eslint): lock VERDICT command-exit 0. CI families are read from this PR's check-runs. ## Dogfood battery 18b8b6f, `pnpm --filter @objectstack/dogfood test` after the `@objectstack/dogfood^...` closure build, under the verify lock: VERDICT command-exit 0. Test Files 291 passed | 3 skipped (294), Tests 2505 passed | 18 skipped (2523). No failures. ## Acceptance notes - The object-level **plugin bridge** (`service-analytics/src/plugin.ts`, the `admitObjectRead` auto-bridge) answers `return false`, not a throw, in its UNUSABLE branch: a wired `security` service that cannot be resolved or exposes neither `canReadObject` nor `explain`. On the in-repo kernels that branch also covers a deployment that registers no security service. That is the same class (the server could not decide), but it arrives at `assertObjectsReadable` as a verdict, so it still answers `403 PERMISSION_DENIED`. It is outside this card's file surface and is left untouched here; see the report's out-of-scope findings. The field-level and exposure bridges throw, so they already answer 503 with this change (pinned: the bridge with no `getReadableFields`; the plugin with no data engine). - On the wire the 503's message is withheld (`"Internal server error"`), as the declared-fault arm does for every declared 5xx. The fault is deliberately not marked `refusal: true`: it is a fault, not a refusal whose prose is authored for the caller. That matches the core precedent. --- _Generated by [Claude Code](https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50af6fa commit f1d6be0

12 files changed

Lines changed: 404 additions & 68 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(service-analytics)!: an analytics read gate that could not decide answers `503 SERVICE_UNAVAILABLE`, not the `403 PERMISSION_DENIED` of a real denial
7+
8+
Three of the analytics door's refusals are operator-side faults: the object-level read admission provider threw, the field-level read permissions could not be resolved, or the API exposure declaration could not be read. Each refused the query, which is right, but each answered with the same body as a real denial, `403 PERMISSION_DENIED`. A caller could not tell "you may not read this" from "the server could not decide", except by parsing the English message. All three now answer `503 SERVICE_UNAVAILABLE`. That is an existing ADR-0112 code, and it is the spelling `@objectstack/core` already uses when the permission store is unreachable. The refusal names the object and nothing else; it names no gate, grant or existence fact. The cause stays in the server log, as before. Fail-closed is unchanged: nothing that was refused is now admitted. Real denials keep their answers byte for byte: `403 PERMISSION_DENIED` when the caller holds no read grant or reads a hidden field, and `404 OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED` for an unexposed object. `POST /api/v1/analytics/dataset/query` relays the declared fault as `503 SERVICE_UNAVAILABLE`, with the message withheld as for every declared server fault. It does not answer `500 ANALYTICS_QUERY_FAILED`. The cube query door relays the status the same way.
9+
10+
The publish build probe (`@objectstack/metadata-protocol`) reads the code too. A widget query that fails with `SERVICE_UNAVAILABLE` now gets a `widget_query_failed` fix that names the server-side fault: the dataset is not the cause, and the server could not decide read access to the named object. Before, it fell to "Fix the dataset definition…", which is wrong for an outage. The finding's `code`, `severity` and `message` are unchanged.
11+
12+
Clause-②: no
13+
Metadata-protocol: none
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored shape or exported declaration is added, removed, renamed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the code and status three analytics refusals answer when a permission or exposure resolver fails (403 PERMISSION_DENIED to 503 SERVICE_UNAVAILABLE); every request that was refused is still refused and every request that was served is still served. The remedy is on the consumer's side, reading the code, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16+
17+
**BREAKING** (the wire answer of three fault refusals changes; no request that was served is refused and none that was refused is served), shipped as `patch` because the metadata protocol does not move.
18+
19+
- **Before:** when the analytics read-admission provider threw, or the field-level read permissions or the API exposure declaration could not be resolved, the query was refused `403 PERMISSION_DENIED`. That was the same code and status as a caller who genuinely may not read the object.
20+
- **Now:** the same refusals answer `503 SERVICE_UNAVAILABLE` (`status: 503`, `object` naming the object). On `POST /api/v1/analytics/dataset/query` the body is `{"code":"SERVICE_UNAVAILABLE","error":"Internal server error"}`. Real denials still answer `403 PERMISSION_DENIED` (and `404` / `405` for exposure), unchanged.
21+
- **Fix:** a client that treated every analytics `403` as "no access" should treat `503 SERVICE_UNAVAILABLE` as a server-side fault: retry once the permission or exposure resolver is healthy, and check the server log for the cause. Do not show it as a missing grant.

‎packages/metadata-protocol/src/build-probes-widget-refusal-fix.test.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
* The widget plane runs `queryDataset` as the publishing caller, so read
88
* admission can refuse a dataset that is correct. That refusal is told apart
99
* by the thrown error's ADR-0112 `code` (`PERMISSION_DENIED`), and its `fix`
10-
* names the caller's read access — never a dataset edit. Every other throw
10+
* names the caller's read access — never a dataset edit. A read gate that
11+
* could not decide at all answers `SERVICE_UNAVAILABLE`, and its `fix` names
12+
* the server-side fault — neither a grant nor a dataset edit. Every other throw
1113
* (a selection the dataset does not declare, an unjudgeable member) keeps the
12-
* dataset fix. `code`, `severity` and `message` are the same on both arms.
14+
* dataset fix. `code`, `severity` and `message` are the same on every arm.
1315
*
1416
* The errors below are the analytics service's envelopes field for field
1517
* (`read-admission.ts`, `field-read-admission.ts`, `dataset-refusal.ts`); the
@@ -84,6 +86,31 @@ describe('widget_query_failed — the fix follows the failure', () => {
8486
expect(issue.fix).toContain('read access to object "task_item" and its field(s) salary');
8587
});
8688

89+
it('a read gate that could not decide (SERVICE_UNAVAILABLE) names neither the dataset nor an access grant', async () => {
90+
// The analytics service's fault refusal (`readAdmissionUnresolvedError`):
91+
// a permission or exposure resolver failed, so no verdict was reached.
92+
const issue = await probeThrowing(envelope(
93+
'[Analytics] Read access to "project" could not be determined, so the query was not run. '
94+
+ 'This is a server-side fault, not a permission denial.',
95+
{ code: 'SERVICE_UNAVAILABLE', status: 503, object: 'project' },
96+
));
97+
expect(issue.fix).toContain('Dataset "ds" is not the cause');
98+
expect(issue.fix).toContain('could not decide read access to object "project"');
99+
expect(issue.fix).toContain('server log');
100+
expect(issue.fix).not.toContain(DATASET_FIX);
101+
// Not the denial's remedy: nobody is told to grant anything.
102+
expect(issue.fix).not.toContain('grant it');
103+
});
104+
105+
it('a SERVICE_UNAVAILABLE that names no object falls back to the dataset\'s object', async () => {
106+
const issue = await probeThrowing(envelope(
107+
'unavailable',
108+
{ code: 'SERVICE_UNAVAILABLE', status: 503 },
109+
));
110+
expect(issue.fix).toContain('could not decide read access to object "task_item"');
111+
expect(issue.fix).not.toContain(DATASET_FIX);
112+
});
113+
87114
it('a selection the dataset does not declare keeps the dataset fix', async () => {
88115
const issue = await probeThrowing(envelope(
89116
'Dataset "ds" does not declare measure "nope".',

‎packages/metadata-protocol/src/build-probes.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,27 @@ function readAdmissionRefusal(e: unknown): { object?: string; fields: string[] }
138138
};
139139
}
140140

141+
/**
142+
* `SERVICE_UNAVAILABLE`, pinned against the STANDARD catalog — the code the
143+
* analytics service throws (ADR-0112 envelope, 503) when a read gate could not
144+
* reach a verdict: a permission or API-exposure resolver failed.
145+
*/
146+
const SERVICE_UNAVAILABLE: StandardErrorCode = 'SERVICE_UNAVAILABLE';
147+
148+
/**
149+
* The fault refusal a widget query can be answered with, judged by the thrown
150+
* error's ADR-0112 `code` — never by its message text.
151+
*
152+
* Neither the dataset nor the caller's access is the cause: the server could
153+
* not decide, so the remedy is the operator's. `object` names the object the
154+
* server could not decide read access to, when the error carries one.
155+
*/
156+
function serviceUnavailableRefusal(e: unknown): { object?: string } | undefined {
157+
const err = e && typeof e === 'object' ? (e as Rec) : undefined;
158+
if (!err || err.code !== SERVICE_UNAVAILABLE) return undefined;
159+
return { object: typeof err.object === 'string' && err.object ? err.object : undefined };
160+
}
161+
141162
/** Result rows of a queryDataset call, tolerant of {rows}/{data}/array shapes. */
142163
function resultRows(result: unknown): unknown[] {
143164
if (Array.isArray(result)) return result;
@@ -419,15 +440,24 @@ export async function runBuildProbes(opts: RunBuildProbesOptions): Promise<Build
419440
// The query runs as the publishing caller, so read admission
420441
// can refuse a dataset that is correct. That refusal names an
421442
// access remedy; telling the author to edit the dataset would
422-
// send them to change something that works.
443+
// send them to change something that works. A fault — the
444+
// server could not decide read access at all — is neither: its
445+
// remedy is the operator's.
423446
const refusal = readAdmissionRefusal(e);
447+
const unavailable = refusal ? undefined : serviceUnavailableRefusal(e);
424448
const refusedObject = refusal?.object ?? objectName;
449+
const undecidedObject = unavailable?.object ?? objectName;
425450
const fix = refusal
426451
? `Dataset "${dsName}" is not the cause and needs no change: read admission refused the publishing caller. ` +
427452
`The publisher (or the role this dashboard is for) needs read access to ` +
428453
`${refusedObject ? `object "${refusedObject}"` : 'the object this dataset reads'}` +
429454
`${refusal.fields.length ? ` and its field(s) ${refusal.fields.join(', ')}` : ''}; grant it, then republish. ` +
430455
`The finding is about this caller's access: a viewer who has that access can read the widget.`
456+
: unavailable
457+
? `Dataset "${dsName}" is not the cause and needs no change: the server could not decide read access to ` +
458+
`${undecidedObject ? `object "${undecidedObject}"` : 'the object this dataset reads'}, ` +
459+
`so the query was not run (SERVICE_UNAVAILABLE). This is a server-side fault, not a denial: ` +
460+
`check the server log for the permission or API-exposure resolver that failed, then republish once it is healthy.`
431461
: `Fix the dataset definition (or the widget's values/dimensions) so the query compiles, then republish.`;
432462
issues.push({
433463
layer: 'runtime',
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25561] `POST /analytics/dataset/query` — a read gate that could not reach a
5+
* verdict answers `503 SERVICE_UNAVAILABLE`, and a real denial still answers
6+
* `403 PERMISSION_DENIED`.
7+
*
8+
* Three of the analytics door's refusals are operator-side FAULTS: the
9+
* object-level admission provider threw, the field-level read permissions could
10+
* not be resolved, or the API exposure declaration could not be read. Each
11+
* answered the wire body of a real denial, `403 PERMISSION_DENIED`, so a caller
12+
* could not tell "you may not read this" from "the server could not decide".
13+
* The producer now declares the fault — `SERVICE_UNAVAILABLE` / 503, the
14+
* spelling `@objectstack/core`'s `authz-store-unavailable.ts` chose for the same
15+
* class — and this route relays a declared 5xx's status and code (the #11718
16+
* declared-fault arm), never collapsing it onto `500 ANALYTICS_QUERY_FAILED`.
17+
*
18+
* ## Why this file boots the REAL analytics service
19+
*
20+
* The relay is pinned for a hand-built declared 503
21+
* (`analytics-filter-refusal-envelope.test.ts`), and the producer is pinned per
22+
* site in `service-analytics`. Either can be green while the caller still sees
23+
* the wrong answer, so this file asserts the SEAM: the error crossing into the
24+
* route's catch is the one the real `AnalyticsService` throws.
25+
*
26+
* Every fault row sits beside its verdict twin on the same route — the same
27+
* gate answering "no" — so a fault row cannot pass because the route never
28+
* reached the gate, and a fix that moved the denial along with the fault goes
29+
* red on the twin.
30+
*/
31+
32+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
33+
import type { Logger } from '@objectstack/spec/contracts';
34+
import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
35+
import { AnalyticsService, type AnalyticsServiceConfig } from '@objectstack/service-analytics';
36+
import { RestServer } from './rest-server';
37+
38+
function mockServer() {
39+
return {
40+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
41+
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
42+
};
43+
}
44+
function mockProtocol() {
45+
return {
46+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
47+
getMetaTypes: vi.fn().mockResolvedValue([]),
48+
getMetaItems: vi.fn().mockResolvedValue([]),
49+
};
50+
}
51+
function mockRes() {
52+
const res: any = { statusCode: 200, body: undefined };
53+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
54+
res.json = vi.fn((b: any) => { res.body = b; return res; });
55+
res.end = vi.fn(() => res);
56+
return res;
57+
}
58+
59+
/** Build a RestServer over an analytics provider (positional arg #15) — a resolver of the service. */
60+
function buildRoute(analyticsProvider: () => Promise<AnalyticsService>) {
61+
const rest = new RestServer(
62+
mockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any,
63+
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
64+
undefined, undefined, undefined, undefined,
65+
analyticsProvider as any,
66+
);
67+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
68+
rest.registerRoutes();
69+
return rest.getRoutes().find((r) => r.method === 'POST' && r.path.endsWith('/analytics/dataset/query'))!;
70+
}
71+
72+
// The route's fault line is asserted below, so the level this file reads is the loud one.
73+
beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); });
74+
afterAll(() => { vi.unstubAllEnvs(); });
75+
76+
/** The service's log sink, recording every line it is handed — where the cause must land. */
77+
function recordingLogger(): { logger: Logger; lines: string[] } {
78+
const lines: string[] = [];
79+
const record = (message: string, err?: unknown) => {
80+
lines.push(`${message} ${err instanceof Error ? err.message : ''}`);
81+
};
82+
return { lines, logger: { debug() {}, info() {}, warn: record, error: record } as Logger };
83+
}
84+
85+
type Gates = Pick<AnalyticsServiceConfig, 'admitObjectRead' | 'getReadableFields' | 'getObjectDeclaration'>;
86+
87+
/** A real `AnalyticsService` on the ObjectQL aggregate path, with the gates under test. */
88+
function analytics(gates: Gates, executed: string[], logger: Logger): AnalyticsService {
89+
return new AnalyticsService({
90+
logger,
91+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
92+
executeAggregate: async (object: string) => {
93+
executed.push(object);
94+
return [{ stage: 'won', revenue: 100 }];
95+
},
96+
isRegisteredObject: () => true,
97+
...gates,
98+
});
99+
}
100+
101+
const OBJECT = 'crm_opportunity';
102+
const body = {
103+
dataset: {
104+
name: 'pipeline',
105+
label: 'Pipeline',
106+
object: OBJECT,
107+
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
108+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
109+
},
110+
selection: { dimensions: ['stage'], measures: ['revenue'] },
111+
};
112+
113+
async function post(gates: Gates) {
114+
const executed: string[] = [];
115+
const { logger, lines } = recordingLogger();
116+
const service = analytics(gates, executed, logger);
117+
const route = buildRoute(async () => service);
118+
const res = mockRes();
119+
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
120+
let routeLog: string;
121+
try {
122+
await route.handler({ method: 'POST', params: {}, headers: {}, body } as any, res);
123+
// Read the calls BEFORE restoring: `mockRestore` clears them.
124+
routeLog = logSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
125+
} finally {
126+
logSpy.mockRestore();
127+
}
128+
return { status: res.statusCode as number, body: res.body, executed, serviceLog: lines.join('\n'), routeLog };
129+
}
130+
131+
const exploded = () => {
132+
throw new Error('permission resolver exploded');
133+
};
134+
135+
const ROWS: Array<{ gate: string; fault: Gates; verdict: Gates; verdictCode: string; verdictStatus: number }> = [
136+
{
137+
gate: 'the object-level read admission',
138+
fault: { admitObjectRead: exploded },
139+
verdict: { admitObjectRead: () => false },
140+
verdictCode: 'PERMISSION_DENIED',
141+
verdictStatus: 403,
142+
},
143+
{
144+
gate: 'the field-level read permissions',
145+
fault: { getReadableFields: exploded },
146+
verdict: { getReadableFields: () => ['stage'] },
147+
verdictCode: 'PERMISSION_DENIED',
148+
verdictStatus: 403,
149+
},
150+
{
151+
gate: 'the API exposure declaration',
152+
fault: { getObjectDeclaration: exploded },
153+
verdict: { getObjectDeclaration: () => ({ enable: { apiEnabled: false } }) },
154+
verdictCode: 'OBJECT_API_DISABLED',
155+
verdictStatus: 404,
156+
},
157+
];
158+
159+
describe('[#25561] a read gate that could not decide answers 503 SERVICE_UNAVAILABLE on the dataset door', () => {
160+
it('the control: with every gate admitting, the route serves the query', async () => {
161+
const res = await post({
162+
admitObjectRead: () => true,
163+
getReadableFields: () => ['stage', 'amount'],
164+
getObjectDeclaration: () => ({}),
165+
});
166+
expect(res.status, JSON.stringify(res.body)).toBe(200);
167+
expect(res.executed).toEqual([OBJECT]);
168+
});
169+
170+
describe.each(ROWS)('$gate', ({ fault, verdict, verdictCode, verdictStatus }) => {
171+
it('a resolver that THROWS answers 503 SERVICE_UNAVAILABLE — not the denial, not ANALYTICS_QUERY_FAILED — and nothing runs', async () => {
172+
const res = await post(fault);
173+
expect(res.status).toBe(503);
174+
expect(res.body.code).toBe('SERVICE_UNAVAILABLE');
175+
expect(res.body.code).not.toBe('PERMISSION_DENIED');
176+
expect(res.body.code).not.toBe('ANALYTICS_QUERY_FAILED');
177+
// A declared server fault's prose is the operator's: withheld here, kept
178+
// in the server log. Neither the cause nor a layer reaches the caller.
179+
expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE);
180+
expect(JSON.stringify(res.body)).not.toContain('exploded');
181+
expect(res.executed).toEqual([]);
182+
// …and the operator keeps it: the cause at the site, the fault at the route.
183+
expect(res.serviceLog).toContain('exploded');
184+
expect(res.routeLog).toContain('Analytics dataset query error');
185+
});
186+
187+
it(`its verdict twin — the same gate answering "no" — still answers ${verdictStatus} ${verdictCode}`, async () => {
188+
const res = await post(verdict);
189+
expect(res.status).toBe(verdictStatus);
190+
expect(res.body.code).toBe(verdictCode);
191+
expect(res.executed).toEqual([]);
192+
});
193+
});
194+
});

0 commit comments

Comments
 (0)