|
15 | 15 | import { describe, it, expect } from 'vitest'; |
16 | 16 | import { RECYCLE_BIN_ENTRY_FIELDS } from '@objectstack/spec/data'; |
17 | 17 | import { ObjectQL } from '../engine.js'; |
| 18 | +import { bindHooksToEngine } from '../hook-binder.js'; |
18 | 19 |
|
19 | 20 | type Row = Record<string, unknown>; |
20 | 21 |
|
@@ -412,3 +413,130 @@ describe('empty(object)', () => { |
412 | 413 | expect(dispatches).toEqual([]); |
413 | 414 | }); |
414 | 415 | }); |
| 416 | + |
| 417 | +/** |
| 418 | + * [ADR-0148 D3, ruling A recorded in 6691358077] A hook bound from metadata |
| 419 | + * (through the hook binder) is app-authored code, and so is its in-process |
| 420 | + * `handler`: its `ctx.api` refuses a permanent delete as a sandboxed body's |
| 421 | + * does, with the same code and sentence, under the caller and under |
| 422 | + * `runAs: 'system'`, by id and in bulk, on every api derived from it. Its |
| 423 | + * ordinary delete still enters the bin; platform code keeps the permanent |
| 424 | + * delete. |
| 425 | + */ |
| 426 | +describe("an app-authored hook handler's permanent delete (ruling A)", () => { |
| 427 | + const RULE_SENTENCE = |
| 428 | + 'A hook handler cannot delete a record permanently: app-authored code cannot purge; its deletes enter the recycle bin.'; |
| 429 | + |
| 430 | + /** |
| 431 | + * Bind one hook through the binder, on `account`'s `beforeInsert`, whose |
| 432 | + * handler runs `use(ctx.api)` and keeps what it answered or threw; then fire |
| 433 | + * it once with an insert by `ORG1_USER`. |
| 434 | + */ |
| 435 | + async function runHandler( |
| 436 | + engine: ObjectQL, |
| 437 | + runAs: 'inherit' | 'user' | 'system', |
| 438 | + use: (api: any) => Promise<unknown>, |
| 439 | + ): Promise<{ value?: unknown; error?: any }> { |
| 440 | + const seen: { value?: unknown; error?: any } = {}; |
| 441 | + const bound = bindHooksToEngine(engine, [{ |
| 442 | + name: 'purge_on_create', |
| 443 | + object: 'account', |
| 444 | + events: ['beforeInsert'], |
| 445 | + runAs, |
| 446 | + handler: async (ctx: any) => { |
| 447 | + try { seen.value = await use(ctx.api); } catch (e) { seen.error = e; } |
| 448 | + }, |
| 449 | + } as any], { packageId: 'app_under_test' }); |
| 450 | + expect(bound).toMatchObject({ registered: 1, errors: [] }); |
| 451 | + await engine.insert('account', { id: 'acc_trigger', name: 'Trigger', organization_id: 'org_1' }, ORG1_USER); |
| 452 | + return seen; |
| 453 | + } |
| 454 | + |
| 455 | + const lonerDispatches = (dispatches: Dispatch[]) => dispatches.filter((d) => d.object === 'loner'); |
| 456 | + |
| 457 | + for (const runAs of ['inherit', 'user', 'system'] as const) { |
| 458 | + for (const [shape, options] of [ |
| 459 | + ['by id', { where: { id: 'l1' }, permanent: true }], |
| 460 | + ['in bulk', { where: { name: 'one' }, multi: true, permanent: true }], |
| 461 | + ] as const) { |
| 462 | + it(`runAs '${runAs}': \`delete(…, { permanent: true })\` ${shape} is refused 400 naming the rule; nothing is removed`, async () => { |
| 463 | + const { engine, driver, dispatches } = await makeEngine(); |
| 464 | + driver.seed('loner', { id: 'l1', name: 'one' }); |
| 465 | + |
| 466 | + const { error } = await runHandler(engine, runAs, (api) => api.object('loner').delete(options)); |
| 467 | + |
| 468 | + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'permanent', object: 'loner' }); |
| 469 | + expect(String(error?.message).startsWith(RULE_SENTENCE)).toBe(true); |
| 470 | + expect(lonerDispatches(dispatches)).toEqual([]); |
| 471 | + expect(driver.rowsOf('loner').map((r: Row) => r.id)).toEqual(['l1']); |
| 472 | + expect(entriesOf(driver)).toHaveLength(0); |
| 473 | + }); |
| 474 | + } |
| 475 | + } |
| 476 | + |
| 477 | + for (const [derivation, use] of [ |
| 478 | + ['`sudo()`', (api: any) => api.sudo().object('loner').delete({ where: { id: 'l1' }, permanent: true })], |
| 479 | + ['`transaction(cb)`', (api: any) => api.transaction((trx: any) => trx.object('loner').delete({ where: { id: 'l1' }, permanent: true }))], |
| 480 | + ] as const) { |
| 481 | + it(`is refused the same way on the api ${derivation} derives`, async () => { |
| 482 | + const { engine, driver, dispatches } = await makeEngine(); |
| 483 | + driver.seed('loner', { id: 'l1', name: 'one' }); |
| 484 | + |
| 485 | + const { error } = await runHandler(engine, 'inherit', use); |
| 486 | + |
| 487 | + expect(error).toMatchObject({ code: 'VALIDATION_ERROR', status: 400, param: 'permanent', object: 'loner' }); |
| 488 | + expect(String(error?.message).startsWith(RULE_SENTENCE)).toBe(true); |
| 489 | + expect(lonerDispatches(dispatches)).toEqual([]); |
| 490 | + expect(driver.rowsOf('loner').map((r: Row) => r.id)).toEqual(['l1']); |
| 491 | + }); |
| 492 | + } |
| 493 | + |
| 494 | + it('judges the options it forwards: a `permanent` that answers differently on a second read is read once', async () => { |
| 495 | + const { engine, driver, dispatches } = await makeEngine(); |
| 496 | + driver.seed('loner', { id: 'l1', name: 'one' }); |
| 497 | + let reads = 0; |
| 498 | + const options = { where: { id: 'l1' }, get permanent() { reads += 1; return reads > 1; } }; |
| 499 | + |
| 500 | + const { error } = await runHandler(engine, 'system', (api) => api.object('loner').delete(options)); |
| 501 | + |
| 502 | + expect(error).toBeUndefined(); |
| 503 | + expect(reads).toBe(1); |
| 504 | + expect(lonerDispatches(dispatches).map((d) => d.operation)).toEqual(['delete']); |
| 505 | + expect(entriesOf(driver).map((e) => e.record_id)).toEqual(['l1']); |
| 506 | + }); |
| 507 | + |
| 508 | + for (const runAs of ['inherit', 'system'] as const) { |
| 509 | + for (const [label, options] of [ |
| 510 | + ['no `permanent` key', { where: { id: 'l1' } }], |
| 511 | + ['`permanent: false`', { where: { id: 'l1' }, permanent: false }], |
| 512 | + ] as const) { |
| 513 | + it(`runAs '${runAs}': the handler's delete with ${label} is the ordinary delete, which enters the bin`, async () => { |
| 514 | + const { engine, driver, dispatches } = await makeEngine(); |
| 515 | + driver.seed('loner', { id: 'l1', name: 'one' }); |
| 516 | + |
| 517 | + const { error } = await runHandler(engine, runAs, (api) => api.object('loner').delete(options)); |
| 518 | + |
| 519 | + expect(error).toBeUndefined(); |
| 520 | + expect(lonerDispatches(dispatches).map((d) => d.operation)).toEqual(['delete']); |
| 521 | + expect(driver.rowsOf('loner')).toEqual([]); |
| 522 | + expect(entriesOf(driver).map((e) => e.record_id)).toEqual(['l1']); |
| 523 | + }); |
| 524 | + } |
| 525 | + } |
| 526 | + |
| 527 | + it("platform code keeps the permanent delete: a hook registered in code, and the engine's own context", async () => { |
| 528 | + const { engine, driver, dispatches } = await makeEngine(); |
| 529 | + driver.seed('loner', { id: 'l1', name: 'one' }); |
| 530 | + driver.seed('loner', { id: 'l2', name: 'two' }); |
| 531 | + engine.registerHook('beforeInsert', async (ctx: any) => { |
| 532 | + await ctx.api.object('loner').delete({ where: { id: 'l1' }, permanent: true }); |
| 533 | + }, { object: 'account' }); |
| 534 | + |
| 535 | + await engine.insert('account', { id: 'acc_trigger', name: 'Trigger', organization_id: 'org_1' }, ORG1_USER); |
| 536 | + await engine.createContext({ isSystem: true }).object('loner').delete({ where: { id: 'l2' }, permanent: true }); |
| 537 | + |
| 538 | + expect(lonerDispatches(dispatches).map((d) => d.operation)).toEqual(['purge', 'purge']); |
| 539 | + expect(driver.rowsOf('loner')).toEqual([]); |
| 540 | + expect(entriesOf(driver)).toHaveLength(0); |
| 541 | + }); |
| 542 | +}); |
0 commit comments