|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#5423] A 4xx domain error's message is TRUNCATED at the passthrough bound, |
| 4 | +// never swapped wholesale for 'Request failed'. |
| 5 | +// |
| 6 | +// Both explicit-status passthrough branches in `rest-server.ts` bounded the |
| 7 | +// message at 500 characters by REPLACING it: `code` and `status` landed as |
| 8 | +// usual and every word of the body text disappeared. Nothing in the suite ever |
| 9 | +// looked at the long-message side of either branch — the one assertion that |
| 10 | +// touched it (`rest.test.ts`, "guards the passthrough message length") pinned |
| 11 | +// the replacement as if it were the intent — which is how the behaviour stayed |
| 12 | +// invisible while the messages it silences grew past the bound. |
| 13 | +// |
| 14 | +// It inverted the incentive on the whole rejection vocabulary. driver-sql's |
| 15 | +// filter refusals exist ONLY to tell an author which operator or field they got |
| 16 | +// wrong and how the spec declares it; #5158's unlowered-`FilterArray` and |
| 17 | +// #5347's non-boolean `$null` refusals are both over 500 characters, so the two |
| 18 | +// most carefully worded rejections in the driver were the two the client could |
| 19 | +// not read at all. Worse, they were readable BEFORE they carried `status: 400` |
| 20 | +// (`mapDataError`'s final `{ status: 400, body: { error: raw } }` ships the raw |
| 21 | +// text and the leak heuristics do not match this wording) — #4436 added the |
| 22 | +// status to give them a wire identity and, in this band, cost them their body. |
| 23 | +// |
| 24 | +// Reverse verification, direction predicted BEFORE running: restoring the |
| 25 | +// wholesale replacement turns every "long" case here RED (they assert on text |
| 26 | +// that only exists once the message survives) and leaves every "short" case |
| 27 | +// GREEN (short messages are byte-for-byte unchanged by this fix — that is what |
| 28 | +// those cases are for: they catch the opposite overreach, a "fix" that starts |
| 29 | +// mangling messages that were always fine). |
| 30 | + |
| 31 | +import { describe, it, expect, vi } from 'vitest'; |
| 32 | +import { mapDataError, RestServer } from './rest-server'; |
| 33 | + |
| 34 | +/** The bound both branches use. Unchanged by #5423 — only what happens at it. */ |
| 35 | +const MAX = 500; |
| 36 | + |
| 37 | +// --------------------------------------------------------------------------- |
| 38 | +// Realistic long 4xx messages |
| 39 | +// --------------------------------------------------------------------------- |
| 40 | + |
| 41 | +/** |
| 42 | + * driver-sql's `nonBooleanNullComparandError` (#5347/#5368), instantiated the |
| 43 | + * way a real request produces it: `{ status: { $null: "false" } }`. |
| 44 | + * |
| 45 | + * Copied rather than imported — `@objectstack/rest` must not take a dependency |
| 46 | + * on a driver package to run its own tests. The wording is what matters: the |
| 47 | + * MAIN CLAUSE (operator, field, what arrived, what the spec declares) is at the |
| 48 | + * front, the attribution and issue number at the back. |
| 49 | + */ |
| 50 | +const NULL_COMPARAND_MESSAGE = |
| 51 | + `Operator "$null" on field "status" requires a boolean comparand (true or false). ` + |
| 52 | + `Received string ("false") at where.status.$null. ` + |
| 53 | + `@objectstack/spec FieldOperatorsSchema declares $null as a boolean. It is refused rather ` + |
| 54 | + `than coerced because the backends read a non-boolean in OPPOSITE directions — this driver ` + |
| 55 | + `compiled IS NULL (anything but false), driver-memory's query path and driver-mongodb ` + |
| 56 | + `compiled IS NOT NULL (anything but true), and driver-memory's matcher dropped the ` + |
| 57 | + `constraint entirely. Note "false" the STRING is truthy, so it landed on the side opposite ` + |
| 58 | + `the false it was written to mean (#5347).`; |
| 59 | + |
| 60 | +function invalidFilterError(message: string) { |
| 61 | + return Object.assign(new Error(message), { code: 'INVALID_FILTER', status: 400 }); |
| 62 | +} |
| 63 | + |
| 64 | +/** A 600-character 4xx whose leading sentence is identifiable after slicing. */ |
| 65 | +function longClientError(status: number, code: string) { |
| 66 | + const head = 'The main clause a caller must read is right here at the front. '; |
| 67 | + return Object.assign( |
| 68 | + new Error(head + 'x'.repeat(600 - head.length)), |
| 69 | + { code, status }, |
| 70 | + ); |
| 71 | +} |
| 72 | + |
| 73 | +// --------------------------------------------------------------------------- |
| 74 | +// mapDataError — the branch the generic data routes reach directly |
| 75 | +// --------------------------------------------------------------------------- |
| 76 | + |
| 77 | +describe('mapDataError: 4xx passthrough truncates an over-long message (#5423)', () => { |
| 78 | + it('a 600-character 4xx keeps its main clause instead of becoming "Request failed"', () => { |
| 79 | + const r = mapDataError(longClientError(400, 'INVALID_FILTER'), 'showcase_account'); |
| 80 | + |
| 81 | + expect(r.status).toBe(400); |
| 82 | + expect(r.body.code).toBe('INVALID_FILTER'); |
| 83 | + expect(r.body.object).toBe('showcase_account'); |
| 84 | + // The regression this issue is about. |
| 85 | + expect(r.body.error).not.toBe('Request failed'); |
| 86 | + // The part worth reading survived, verbatim and at the front. |
| 87 | + expect(r.body.error).toContain('The main clause a caller must read is right here at the front.'); |
| 88 | + // ...and it is still bounded. |
| 89 | + expect(String(r.body.error)).toHaveLength(MAX); |
| 90 | + expect(String(r.body.error).endsWith('…')).toBe(true); |
| 91 | + }); |
| 92 | + |
| 93 | + it("#5347's $null refusal reaches the client with its operator/field/spec sentence intact", () => { |
| 94 | + // The concrete case #5423 was raised on. Guard the premise first: if |
| 95 | + // this message ever drops under the bound the assertions below stop |
| 96 | + // proving anything, so assert it is genuinely in the truncated band. |
| 97 | + expect(NULL_COMPARAND_MESSAGE.length).toBeGreaterThanOrEqual(MAX); |
| 98 | + |
| 99 | + const r = mapDataError(invalidFilterError(NULL_COMPARAND_MESSAGE), 'showcase_account'); |
| 100 | + |
| 101 | + expect(r.status).toBe(400); |
| 102 | + expect(r.body.code).toBe('INVALID_FILTER'); |
| 103 | + expect(r.body.error).toContain('Operator "$null" on field "status" requires a boolean comparand'); |
| 104 | + expect(r.body.error).toContain('Received string ("false") at where.status.$null'); |
| 105 | + expect(r.body.error).toContain('FieldOperatorsSchema declares $null as a boolean'); |
| 106 | + // What is cut is the tail — attribution and issue number, the part that |
| 107 | + // belongs in the log rather than in the response. |
| 108 | + expect(r.body.error).not.toContain('(#5347)'); |
| 109 | + }); |
| 110 | + |
| 111 | + it('the truncated text is a PREFIX of the original — no reordering, no summarising', () => { |
| 112 | + const r = mapDataError(invalidFilterError(NULL_COMPARAND_MESSAGE)); |
| 113 | + const body = String(r.body.error); |
| 114 | + |
| 115 | + expect(body.slice(0, -1)).toBe(NULL_COMPARAND_MESSAGE.slice(0, MAX - 1)); |
| 116 | + expect(NULL_COMPARAND_MESSAGE.startsWith(body.slice(0, -1))).toBe(true); |
| 117 | + }); |
| 118 | +}); |
| 119 | + |
| 120 | +describe('mapDataError: short 4xx messages are byte-for-byte unchanged (#5423)', () => { |
| 121 | + it('a normal-length message passes through with no ellipsis and no slicing', () => { |
| 122 | + const msg = 'FORBIDDEN: insufficient privileges to update showcase_inquiry rec1'; |
| 123 | + const r = mapDataError(Object.assign(new Error(msg), { code: 'FORBIDDEN', status: 403 })); |
| 124 | + |
| 125 | + expect(r.status).toBe(403); |
| 126 | + expect(r.body.error).toBe(msg); |
| 127 | + }); |
| 128 | + |
| 129 | + it('exactly 499 characters is still verbatim; exactly 500 is the first truncated length', () => { |
| 130 | + const at499 = mapDataError(Object.assign(new Error('y'.repeat(499)), { status: 400 })); |
| 131 | + expect(at499.body.error).toBe('y'.repeat(499)); |
| 132 | + |
| 133 | + const at500 = mapDataError(Object.assign(new Error('y'.repeat(500)), { status: 400 })); |
| 134 | + expect(String(at500.body.error)).toHaveLength(MAX); |
| 135 | + expect(at500.body.error).toBe(`${'y'.repeat(MAX - 1)}…`); |
| 136 | + }); |
| 137 | + |
| 138 | + it('an absent or empty message still degrades to generic text — nothing to truncate', () => { |
| 139 | + expect(mapDataError({ status: 400, code: 'X' }).body.error).toBe('Request failed'); |
| 140 | + expect(mapDataError(Object.assign(new Error(''), { status: 400, code: 'X' })).body.error) |
| 141 | + .toBe('Request failed'); |
| 142 | + }); |
| 143 | + |
| 144 | + it('5xx never enters this branch at all (unchanged: sanitizing heuristics own it)', () => { |
| 145 | + const r = mapDataError( |
| 146 | + Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432 '.repeat(20)), { status: 502 }), |
| 147 | + ); |
| 148 | + expect(r.status).not.toBe(502); |
| 149 | + }); |
| 150 | +}); |
| 151 | + |
| 152 | +// --------------------------------------------------------------------------- |
| 153 | +// sendError — walked through a real route, in-process |
| 154 | +// |
| 155 | +// The issue read this branch statically and said so ("`sendError` 那处是同款 |
| 156 | +// 写法,未单独走通"). It is walked here: a registered metadata route rejects, |
| 157 | +// the handler's catch calls `sendError`, and the assertions read the body the |
| 158 | +// client would actually receive. |
| 159 | +// --------------------------------------------------------------------------- |
| 160 | + |
| 161 | +function createMockServer() { |
| 162 | + return { |
| 163 | + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), |
| 164 | + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), |
| 165 | + }; |
| 166 | +} |
| 167 | + |
| 168 | +function makeRes() { |
| 169 | + const res: any = { statusCode: 200, body: undefined }; |
| 170 | + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); |
| 171 | + res.json = vi.fn((b: any) => { res.body = b; return res; }); |
| 172 | + res.header = vi.fn(() => res); |
| 173 | + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); |
| 174 | + return res; |
| 175 | +} |
| 176 | + |
| 177 | +function setup(protocolOverrides: Record<string, unknown> = {}) { |
| 178 | + const protocol: any = { |
| 179 | + getDiscovery: vi.fn().mockResolvedValue({ |
| 180 | + version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' }, |
| 181 | + }), |
| 182 | + getMetaTypes: vi.fn().mockResolvedValue([]), |
| 183 | + getMetaItems: vi.fn().mockResolvedValue([]), |
| 184 | + getMetaItem: vi.fn().mockResolvedValue({}), |
| 185 | + saveMetaItem: vi.fn().mockResolvedValue({}), |
| 186 | + findData: vi.fn().mockResolvedValue([]), |
| 187 | + ...protocolOverrides, |
| 188 | + }; |
| 189 | + const rest = new RestServer( |
| 190 | + createMockServer() as any, |
| 191 | + protocol, |
| 192 | + { api: { requireAuth: false } } as any, |
| 193 | + ); |
| 194 | + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); |
| 195 | + rest.registerRoutes(); |
| 196 | + return rest; |
| 197 | +} |
| 198 | + |
| 199 | +async function callRoute(rest: any, method: string, path: string, req: Record<string, unknown>) { |
| 200 | + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); |
| 201 | + if (!route) throw new Error(`${method} ${path} route not registered`); |
| 202 | + const res = makeRes(); |
| 203 | + await route.handler({ method, params: {}, query: {}, body: {}, headers: {}, ...req }, res); |
| 204 | + return res; |
| 205 | +} |
| 206 | + |
| 207 | +/** |
| 208 | + * The metadata save validator's 422 — the NON-FILTER 4xx the issue asked to be |
| 209 | + * sampled, confirming the bound bites well outside driver-sql's filter family. |
| 210 | + * |
| 211 | + * Built the way `metadata-protocol`'s `saveMetaItem` builds it: the first THREE |
| 212 | + * issues are summarised as `<path>: <message>` joined by `; `, behind an |
| 213 | + * `[invalid_metadata] <type>/<name> failed spec validation: ` prefix, with a |
| 214 | + * `(+N more)` suffix for the remainder. |
| 215 | + * |
| 216 | + * Worth recording how close this family runs to the line: the same fixture with |
| 217 | + * three issues and no suffix measured 492 characters — under the bound by 8. |
| 218 | + * A metadata save is not an exotic path and a five-issue rejection is not an |
| 219 | + * exotic mistake, so this family straddles the cliff exactly as #5423 suspected |
| 220 | + * the near-miss filter refusals (#5240 at ~469, #5327 at ~454) do. |
| 221 | + */ |
| 222 | +function invalidMetadataError() { |
| 223 | + const issues = [ |
| 224 | + { path: 'fields.amount.type', message: 'Invalid enum value. Expected one of text | number | currency | date | datetime | boolean | select | lookup | master_detail | formula | rollup, received "money"', code: 'invalid_enum_value' }, |
| 225 | + { path: 'fields.owner.referenceTo', message: 'Required — a lookup field must name the object it references, and the name must be a registered object', code: 'invalid_type' }, |
| 226 | + { path: 'views.grid_default.columns', message: 'Expected array, received string — a grid view declares its columns as a list of field names', code: 'invalid_type' }, |
| 227 | + { path: 'fields.status.options', message: 'Required — a select field must declare its options', code: 'invalid_type' }, |
| 228 | + { path: 'label', message: 'Required', code: 'invalid_type' }, |
| 229 | + ]; |
| 230 | + const summary = issues.slice(0, 3).map((i) => `${i.path}: ${i.message}`).join('; '); |
| 231 | + return Object.assign( |
| 232 | + new Error( |
| 233 | + `[invalid_metadata] object/maint_asset failed spec validation: ${summary}` |
| 234 | + + (issues.length > 3 ? ` (+${issues.length - 3} more)` : ''), |
| 235 | + ), |
| 236 | + { code: 'INVALID_METADATA', status: 422, issues }, |
| 237 | + ); |
| 238 | +} |
| 239 | + |
| 240 | +describe('sendError: the same bound, walked through a real route (#5423)', () => { |
| 241 | + it('a metadata-save 422 keeps its leading sentence and its structured issues', async () => { |
| 242 | + const err = invalidMetadataError(); |
| 243 | + // Premise guard: this must actually be in the truncated band. |
| 244 | + expect(err.message.length).toBeGreaterThanOrEqual(MAX); |
| 245 | + |
| 246 | + const rest = setup({ saveMetaItem: vi.fn().mockRejectedValue(err) }); |
| 247 | + const res = await callRoute(rest, 'PUT', '/api/v1/meta/:type/:name', { |
| 248 | + params: { type: 'object', name: 'maint_asset' }, |
| 249 | + body: { name: 'maint_asset', label: 'Asset' }, |
| 250 | + }); |
| 251 | + |
| 252 | + expect(res.statusCode).toBe(422); |
| 253 | + expect(res.body.code).toBe('INVALID_METADATA'); |
| 254 | + expect(res.body.error).not.toBe('Request failed'); |
| 255 | + expect(res.body.error).toContain('[invalid_metadata] object/maint_asset failed spec validation'); |
| 256 | + expect(res.body.error).toContain('fields.amount.type'); |
| 257 | + expect(String(res.body.error)).toHaveLength(MAX); |
| 258 | + expect(String(res.body.error).endsWith('…')).toBe(true); |
| 259 | + // The structured half of the envelope is untouched by any of this. |
| 260 | + expect(Array.isArray(res.body.issues)).toBe(true); |
| 261 | + expect(res.body.issues).toHaveLength(5); |
| 262 | + }, 60_000); |
| 263 | + |
| 264 | + it('a 600-character 404 truncates too — this is not special-cased per status', async () => { |
| 265 | + const rest = setup({ getMetaItem: vi.fn().mockRejectedValue(longClientError(404, 'NO_DRAFT')) }); |
| 266 | + const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', { |
| 267 | + params: { type: 'object', name: 'showcase_account' }, |
| 268 | + }); |
| 269 | + |
| 270 | + expect(res.statusCode).toBe(404); |
| 271 | + expect(res.body.code).toBe('NO_DRAFT'); |
| 272 | + expect(res.body.error).toContain('The main clause a caller must read is right here at the front.'); |
| 273 | + expect(String(res.body.error)).toHaveLength(MAX); |
| 274 | + }, 60_000); |
| 275 | + |
| 276 | + it('a short message is byte-for-byte what it always was', async () => { |
| 277 | + const msg = '[no_draft] No pending draft exists for object/showcase_account.'; |
| 278 | + const rest = setup({ |
| 279 | + getMetaItem: vi.fn().mockRejectedValue( |
| 280 | + Object.assign(new Error(msg), { code: 'NO_DRAFT', status: 404 }), |
| 281 | + ), |
| 282 | + }); |
| 283 | + const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', { |
| 284 | + params: { type: 'object', name: 'showcase_account' }, |
| 285 | + }); |
| 286 | + |
| 287 | + expect(res.statusCode).toBe(404); |
| 288 | + expect(res.body).toEqual({ error: msg, code: 'NO_DRAFT' }); |
| 289 | + }, 60_000); |
| 290 | + |
| 291 | + it('an over-long 5xx is DELIBERATELY still replaced — the asymmetry is the point', async () => { |
| 292 | + // This branch's passthrough range is 400-599, wider than mapDataError's. |
| 293 | + // A 4xx message is addressed to the caller and is the remedy; a 5xx |
| 294 | + // message is a server fault's log diagnostic that happens to be |
| 295 | + // reachable here, and `mapDataError`'s sibling branch is already |
| 296 | + // "deliberately limited to 4xx ... so internal/SQL details never reach |
| 297 | + // the client verbatim". #5423 does not widen 5xx leniency. |
| 298 | + const rest = setup({ |
| 299 | + getMetaItem: vi.fn().mockRejectedValue( |
| 300 | + Object.assign(new Error('z'.repeat(600)), { code: 'INTERNAL', status: 503 }), |
| 301 | + ), |
| 302 | + }); |
| 303 | + const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', { |
| 304 | + params: { type: 'object', name: 'showcase_account' }, |
| 305 | + }); |
| 306 | + |
| 307 | + expect(res.statusCode).toBe(503); |
| 308 | + expect(res.body.error).toBe('Request failed'); |
| 309 | + }, 60_000); |
| 310 | +}); |
0 commit comments