|
| 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