|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `POST /analytics/query` and `POST /analytics/sql` refuse an EMPTY cube name |
| 5 | + * at the door — `400 VALIDATION_FAILED`, a `fields[]` entry at `cube` — and |
| 6 | + * the analytics service is never reached. |
| 7 | + * |
| 8 | + * The defect this pins shut: `AnalyticsQueryRequestSchema.cube` was a bare |
| 9 | + * `z.string()`, so `{ cube: '' }` passed the door as "a string" and reached the |
| 10 | + * engine, whose own guard threw a plain `Error`, which the dispatcher mapped |
| 11 | + * to `500 INTERNAL_ERROR` — a client input mistake answered as a server fault, |
| 12 | + * while the same body with `cube` left out answered 400. The accept set closes |
| 13 | + * at the schema (`.min(1)`, one prescriptive sentence), so the assertions read |
| 14 | + * the code, the status, the field entry and the "service not called" fact. |
| 15 | + * |
| 16 | + * Harness: the shape `analytics-daterange-refusal-envelope.test.ts` uses to |
| 17 | + * drive the mounted route through `dispatcher-plugin`'s thrown-error exit. |
| 18 | + */ |
| 19 | + |
| 20 | +import { describe, it, expect } from 'vitest'; |
| 21 | +import { ApiErrorSchema, ANALYTICS_CUBE_NAME_REQUIRED } from '@objectstack/spec/api'; |
| 22 | + |
| 23 | +import { createDispatcherPlugin } from './dispatcher-plugin.js'; |
| 24 | + |
| 25 | +// [#21061] The analytics domain refuses an anonymous caller first (ADR-0056 D2), |
| 26 | +// so this harness signs its caller in; only identity is stubbed. |
| 27 | +const SIGNED_IN_AUTH = { api: { getSession: async () => ({ user: { id: 'usr_analytics_caller' } }) } }; |
| 28 | + |
| 29 | +function makeFakeServer() { |
| 30 | + const handlers: Record<string, (req: any, res: any) => any> = {}; |
| 31 | + const rec = (verb: string) => (path: string, handler: any) => { |
| 32 | + handlers[`${verb} ${path}`] = handler; |
| 33 | + }; |
| 34 | + return { |
| 35 | + handlers, |
| 36 | + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, |
| 37 | + }; |
| 38 | +} |
| 39 | + |
| 40 | +function makeCtx(fakeServer: any, calls: { query: unknown[]; sql: unknown[] }) { |
| 41 | + const analytics = { |
| 42 | + query: async (body: unknown) => { calls.query.push(body); return { rows: [], fields: [] }; }, |
| 43 | + getMeta: async () => [], |
| 44 | + generateSql: async (body: unknown) => { calls.sql.push(body); return { sql: 'SELECT 1', params: [] }; }, |
| 45 | + }; |
| 46 | + const kernel = { |
| 47 | + getService: (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined), |
| 48 | + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : name === 'auth' ? SIGNED_IN_AUTH : undefined), |
| 49 | + }; |
| 50 | + return { |
| 51 | + getKernel: () => kernel, |
| 52 | + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), |
| 53 | + environmentId: undefined, |
| 54 | + logger: { info() {}, warn() {}, error() {}, debug() {} }, |
| 55 | + hook: () => {}, |
| 56 | + on: () => {}, |
| 57 | + } as any; |
| 58 | +} |
| 59 | + |
| 60 | +function makeRes() { |
| 61 | + const res: any = { |
| 62 | + statusCode: undefined as number | undefined, |
| 63 | + body: undefined as any, |
| 64 | + status(c: number) { res.statusCode = c; return res; }, |
| 65 | + header() { return res; }, |
| 66 | + json(b: any) { res.body = b; return res; }, |
| 67 | + }; |
| 68 | + return res; |
| 69 | +} |
| 70 | + |
| 71 | +type Door = '/analytics/query' | '/analytics/sql'; |
| 72 | + |
| 73 | +async function post(path: Door, body: unknown) { |
| 74 | + const { server, handlers } = makeFakeServer(); |
| 75 | + const calls = { query: [] as unknown[], sql: [] as unknown[] }; |
| 76 | + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); |
| 77 | + await plugin.start?.(makeCtx(server, calls)); |
| 78 | + const handler = handlers[`POST /api/v1${path}`]; |
| 79 | + expect(handler, `POST /api/v1${path} must be mounted`).toBeTypeOf('function'); |
| 80 | + const res = makeRes(); |
| 81 | + await handler({ body, query: {} }, res); |
| 82 | + return { res, calls }; |
| 83 | +} |
| 84 | + |
| 85 | +const serviceCalls = (calls: { query: unknown[]; sql: unknown[] }) => [...calls.query, ...calls.sql]; |
| 86 | + |
| 87 | +describe.each<Door>(['/analytics/query', '/analytics/sql'])('%s — an empty cube name', (door) => { |
| 88 | + it('answers 400 VALIDATION_FAILED with a `cube` field entry, and never calls the service', async () => { |
| 89 | + const { res, calls } = await post(door, { cube: '', measures: ['x.count'] }); |
| 90 | + |
| 91 | + expect(res.statusCode).toBe(400); |
| 92 | + expect(res.body.success).toBe(false); |
| 93 | + expect(res.body.error.httpStatus).toBe(400); |
| 94 | + expect(res.body.error.code).toBe('VALIDATION_FAILED'); |
| 95 | + // The per-field carrier the UI anchors on: the ADR-0114 catalog code for |
| 96 | + // a too-short string, never Zod's `too_small`. |
| 97 | + expect(res.body.error.details.fields).toEqual([ |
| 98 | + { field: 'cube', code: 'min_length', message: ANALYTICS_CUBE_NAME_REQUIRED }, |
| 99 | + ]); |
| 100 | + // The message locates the field and carries the schema's prescription. |
| 101 | + expect(res.body.error.message).toContain('cube: Name the cube to query'); |
| 102 | + // The whole point: a client input mistake is no longer the engine's 500. |
| 103 | + expect(serviceCalls(calls)).toEqual([]); |
| 104 | + }); |
| 105 | + |
| 106 | + it('the envelope parses against ApiErrorSchema', async () => { |
| 107 | + const { res } = await post(door, { cube: '', measures: ['x.count'] }); |
| 108 | + const parsed = ApiErrorSchema.safeParse(res.body.error); |
| 109 | + expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); |
| 110 | + }); |
| 111 | + |
| 112 | + it('a missing cube keeps the 400 VALIDATION_FAILED it already had, now with the `required` field code', async () => { |
| 113 | + const { res, calls } = await post(door, { measures: ['x.count'] }); |
| 114 | + |
| 115 | + expect(res.statusCode).toBe(400); |
| 116 | + expect(res.body.error.httpStatus).toBe(400); |
| 117 | + expect(res.body.error.code).toBe('VALIDATION_FAILED'); |
| 118 | + expect(res.body.error.details.fields).toHaveLength(1); |
| 119 | + // `required`, the ADR-0114 D3 code for an ABSENT key — the door hands |
| 120 | + // the body to the mapper now; it used to answer Zod's `invalid_type` |
| 121 | + // for the same body. The same code the engine's in-process guard |
| 122 | + // answers (service-analytics `cube-name-refusal.ts`), so one condition |
| 123 | + // reads one code on both sides; and the one prescription, carried by |
| 124 | + // the member's type-level `error`, not Zod's generic words. |
| 125 | + expect(res.body.error.details.fields[0]).toEqual({ field: 'cube', code: 'required', message: ANALYTICS_CUBE_NAME_REQUIRED }); |
| 126 | + expect(res.body.error.message).toContain('cube: Name the cube to query'); |
| 127 | + expect(serviceCalls(calls)).toEqual([]); |
| 128 | + }); |
| 129 | + |
| 130 | + it('the `required` code reaches `measures` left out and a bodiless request too', async () => { |
| 131 | + // The door hands the body to the mapper, so the D3 split reports a |
| 132 | + // missing key as `required` (it used to read `invalid_type`): the two |
| 133 | + // other cases the changeset names, pinned by field and code (their |
| 134 | + // messages are Zod's own, no sentence of this change's). |
| 135 | + const noMeasures = await post(door, { cube: 'orders' }); |
| 136 | + expect(noMeasures.res.statusCode).toBe(400); |
| 137 | + expect(noMeasures.res.body.error.code).toBe('VALIDATION_FAILED'); |
| 138 | + expect(noMeasures.res.body.error.details.fields).toEqual([ |
| 139 | + expect.objectContaining({ field: 'measures', code: 'required' }), |
| 140 | + ]); |
| 141 | + |
| 142 | + const noBody = await post(door, undefined); |
| 143 | + expect(noBody.res.statusCode).toBe(400); |
| 144 | + expect(noBody.res.body.error.code).toBe('VALIDATION_FAILED'); |
| 145 | + expect(noBody.res.body.error.details.fields).toEqual([ |
| 146 | + expect.objectContaining({ field: '(body)', code: 'required' }), |
| 147 | + ]); |
| 148 | + expect(serviceCalls(noMeasures.calls)).toEqual([]); |
| 149 | + expect(serviceCalls(noBody.calls)).toEqual([]); |
| 150 | + }); |
| 151 | + |
| 152 | + it('preservation: a named cube reaches the service with the body untouched', async () => { |
| 153 | + const body = { cube: 'orders', measures: ['orders.count'] }; |
| 154 | + const { res, calls } = await post(door, body); |
| 155 | + |
| 156 | + expect(res.statusCode ?? 200).toBe(200); |
| 157 | + expect(res.body.success).toBe(true); |
| 158 | + expect(serviceCalls(calls)).toEqual([body]); |
| 159 | + }); |
| 160 | +}); |
0 commit comments