|
12 | 12 |
|
13 | 13 | import { describe, it, expect } from 'vitest'; |
14 | 14 | import type { IDataDriver } from '@objectstack/spec/contracts'; |
| 15 | +import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; |
15 | 16 | import { ObjectQL } from './engine'; |
16 | | -import { EXTERNAL_ROW_CAP, ExternalRowCapExceededError, ExternalWriteRefusedError } from './external-driver-errors.js'; |
| 17 | +import { |
| 18 | + EXTERNAL_ROW_CAP, ExternalReadRefusedError, ExternalRowCapExceededError, ExternalWriteRefusedError, |
| 19 | +} from './external-driver-errors.js'; |
17 | 20 | import { DatasourceUnavailableError } from './driver-connect-errors.js'; |
18 | 21 | import { isExternalDriverFloor, needsExternalDriverFloor, unwrapExternalDriverFloor } from './external-driver-floor.js'; |
19 | 22 |
|
@@ -233,6 +236,108 @@ describe('[#27277] reads: the engine applies the query', () => { |
233 | 236 | }); |
234 | 237 | }); |
235 | 238 |
|
| 239 | +describe('[#27471] a read the driver refuses with a request-shaped 4xx is 400', () => { |
| 240 | + const refusingRead = (error: unknown) => makeEngine({ extra: { async find() { throw error; } } }); |
| 241 | + /** The virtual-objects guide's `refuse()`: a by-name refusal carrying the status the floor reads. */ |
| 242 | + const refuse = (what: string) => |
| 243 | + Object.assign(new Error(`tree driver: the remote cannot run ${what}`), { status: 400 }); |
| 244 | + /** A `where` a merge boundary vouched for as the caller's own. */ |
| 245 | + const authored = <T>(where: T): T => markFilterSubtreeProvenance(where, 'author'); |
| 246 | + |
| 247 | + it('the guide\'s refuse() answers INVALID_QUERY / 400 naming what the remote cannot run, on every read', async () => { |
| 248 | + const { engine } = refusingRead(refuse('the operator $gte on \'weight\'')); |
| 249 | + for (const read of [ |
| 250 | + () => engine.find('tree_node', { where: authored({ weight: { $gte: 2 } }) }), |
| 251 | + () => engine.findOne('tree_node', { where: authored({ weight: { $gte: 2 } }) }), |
| 252 | + () => engine.count('tree_node', { where: authored({ weight: { $gte: 2 } }) }), |
| 253 | + ]) { |
| 254 | + const err = await read().then(() => undefined, (e) => e); |
| 255 | + expect(err).toBeInstanceOf(ExternalReadRefusedError); |
| 256 | + expect(err.code).toBe('INVALID_QUERY'); |
| 257 | + expect(err.status).toBe(400); |
| 258 | + expect(err.message).toContain("the remote cannot run the operator $gte on 'weight'"); |
| 259 | + expect(err.details).toEqual({ datasource: 'tree', object: 'tree_node', operation: 'find', remoteStatus: 400 }); |
| 260 | + } |
| 261 | + }); |
| 262 | + |
| 263 | + for (const status of [400, 404, 422]) { |
| 264 | + it(`${status} (as \`status\` or \`statusCode\`) is 400 carrying the driver's message`, async () => { |
| 265 | + for (const key of ['status', 'statusCode']) { |
| 266 | + const { engine } = refusingRead(Object.assign(new Error(`remote said ${status}`), { [key]: status })); |
| 267 | + const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e); |
| 268 | + expect(err).toBeInstanceOf(ExternalReadRefusedError); |
| 269 | + expect(err.code).toBe('INVALID_QUERY'); |
| 270 | + expect(err.status).toBe(400); |
| 271 | + expect(err.message).toContain(`remote said ${status}`); |
| 272 | + expect(err.details.remoteStatus).toBe(status); |
| 273 | + } |
| 274 | + }); |
| 275 | + } |
| 276 | + |
| 277 | + it('a registered code the driver declared is kept (ADR-0112), and remoteCode names it', async () => { |
| 278 | + const { engine } = refusingRead( |
| 279 | + Object.assign(new Error('tree driver: $regex is not a filter'), { status: 400, code: 'INVALID_FILTER' }), |
| 280 | + ); |
| 281 | + const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e); |
| 282 | + expect(err).toBeInstanceOf(ExternalReadRefusedError); |
| 283 | + expect(err.code).toBe('INVALID_FILTER'); |
| 284 | + expect(err.status).toBe(400); |
| 285 | + expect(err.details.remoteCode).toBe('INVALID_FILTER'); |
| 286 | + }); |
| 287 | + |
| 288 | + it('a code that is not registered answers INVALID_QUERY, and remoteCode still names it', async () => { |
| 289 | + const { engine } = refusingRead( |
| 290 | + Object.assign(new Error('tree driver: no such filter'), { status: 400, code: 'TREE_BAD_FILTER' }), |
| 291 | + ); |
| 292 | + const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e); |
| 293 | + expect(err.code).toBe('INVALID_QUERY'); |
| 294 | + expect(err.details.remoteCode).toBe('TREE_BAD_FILTER'); |
| 295 | + }); |
| 296 | + |
| 297 | + it('a where no boundary vouched for is refused 400 with its reason withheld and logged', async () => { |
| 298 | + const warned: Array<Record<string, unknown> | undefined> = []; |
| 299 | + const { engine } = refusingRead(refuse("a filter on 'region' with comparand EMEA")); |
| 300 | + (engine as unknown as { logger: { warn: (m: string, meta?: Record<string, unknown>) => void } }).logger.warn = |
| 301 | + (_m, meta) => { warned.push(meta); }; |
| 302 | + const err = await engine.find('tree_node', { where: { kind: 'leaf' } }).then(() => undefined, (e) => e); |
| 303 | + expect(err).toBeInstanceOf(ExternalReadRefusedError); |
| 304 | + expect(err.status).toBe(400); |
| 305 | + expect(err.message).not.toContain('EMEA'); |
| 306 | + expect(err.message).toContain('server log'); |
| 307 | + expect(warned.some((meta) => String(meta?.error).includes('EMEA'))).toBe(true); |
| 308 | + }); |
| 309 | + |
| 310 | + it('a policy arm under an author root withholds the reason (#7929 fail-closed)', async () => { |
| 311 | + const { engine } = refusingRead(refuse("a filter on 'region' with comparand EMEA")); |
| 312 | + const where = authored({ |
| 313 | + $and: [authored({ kind: 'leaf' }), markFilterSubtreeProvenance({ region: 'EMEA' }, 'policy')], |
| 314 | + }); |
| 315 | + const err = await engine.find('tree_node', { where }).then(() => undefined, (e) => e); |
| 316 | + expect(err).toBeInstanceOf(ExternalReadRefusedError); |
| 317 | + expect(err.status).toBe(400); |
| 318 | + expect(err.message).not.toContain('EMEA'); |
| 319 | + expect(err.details.remoteStatus).toBe(400); |
| 320 | + }); |
| 321 | + |
| 322 | + for (const status of [401, 403, 407, 429, 500, 502, 503, 409, 412]) { |
| 323 | + it(`${status} stays ERR_DATASOURCE_UNAVAILABLE / 503, its text withheld`, async () => { |
| 324 | + const { engine } = refusingRead(Object.assign(new Error('token sk_live_123 rejected'), { status })); |
| 325 | + const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e); |
| 326 | + expect(err).toBeInstanceOf(DatasourceUnavailableError); |
| 327 | + expect(err.code).toBe('ERR_DATASOURCE_UNAVAILABLE'); |
| 328 | + expect(err.kind).toBe('remote'); |
| 329 | + expect(err.message).not.toContain('sk_live_123'); |
| 330 | + }); |
| 331 | + } |
| 332 | + |
| 333 | + it('a throw that declares no status stays ERR_DATASOURCE_UNAVAILABLE / 503, its text withheld', async () => { |
| 334 | + const { engine } = refusingRead(new Error('tree driver: the remote cannot run the combinator $or')); |
| 335 | + const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e); |
| 336 | + expect(err).toBeInstanceOf(DatasourceUnavailableError); |
| 337 | + expect(err.message).not.toContain('$or'); |
| 338 | + }); |
| 339 | +}); |
| 340 | + |
236 | 341 | describe('[#27277] writes pass through; a remote refusal is 422', () => { |
237 | 342 | it('a write the remote accepts is the remote\'s answer', async () => { |
238 | 343 | const { engine } = makeEngine({ |
|
0 commit comments