|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// SHOWCASE proof for the two new authz capabilities on the REAL showcase app: |
| 4 | +// • ADR-0058 D3 / #1887 — a COMPOUND sharing `condition` (`&&`) compiles to a |
| 5 | +// compound criteria_json and enforces (the AND matters; before #1887 it was |
| 6 | +// silently skipped). |
| 7 | +// • ADR-0058 D4 — an RLS `check` clause validates the write POST-IMAGE: a |
| 8 | +// contributor cannot reassign an invoice they own to a different owner. |
| 9 | +// |
| 10 | +// @proof: showcase-d3-d4-capabilities |
| 11 | + |
| 12 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 13 | +import showcaseStack from '@objectstack/example-showcase'; |
| 14 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 15 | +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; |
| 16 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 17 | + |
| 18 | +const MEMBER = 'd34-member@verify.test'; |
| 19 | +const SYS = { context: { isSystem: true } } as const; |
| 20 | + |
| 21 | +// Member set: invoice CRUD + an OWNER read/pre-image policy AND the D4 CHECK |
| 22 | +// (post-image must keep owner == caller). Mirrors the showcase contributor's |
| 23 | +// `invoice_owner_immutable` rule, exercised end-to-end over HTTP. |
| 24 | +const memberSet = PermissionSetSchema.parse({ |
| 25 | + name: 'showcase_d34_member', |
| 26 | + label: 'D3/D4 Member', |
| 27 | + isProfile: true, |
| 28 | + objects: { |
| 29 | + showcase_project: { allowRead: true }, |
| 30 | + showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true }, |
| 31 | + }, |
| 32 | + rowLevelSecurity: [ |
| 33 | + { name: 'inv_own_read', object: 'showcase_invoice', operation: 'select', using: 'owner == current_user.email' }, |
| 34 | + { name: 'inv_own_write', object: 'showcase_invoice', operation: 'update', using: 'owner == current_user.email' }, |
| 35 | + { name: 'inv_owner_check', object: 'showcase_invoice', operation: 'update', check: 'owner == current_user.email' }, |
| 36 | + ], |
| 37 | +}); |
| 38 | + |
| 39 | +describe('showcase: D3 compound sharing (#1887) + D4 RLS check', () => { |
| 40 | + let stack: VerifyStack; |
| 41 | + let ql: any; |
| 42 | + let token: string; |
| 43 | + let invId: string; |
| 44 | + |
| 45 | + beforeAll(async () => { |
| 46 | + stack = await bootStack(showcaseStack, { |
| 47 | + security: new SecurityPlugin({ |
| 48 | + defaultPermissionSets: [...securityDefaultPermissionSets, memberSet], |
| 49 | + fallbackPermissionSet: 'showcase_d34_member', |
| 50 | + }), |
| 51 | + }); |
| 52 | + await stack.signIn(); |
| 53 | + token = await stack.signUp(MEMBER); |
| 54 | + ql = await stack.kernel.getServiceAsync('objectql'); |
| 55 | + |
| 56 | + const idOf = (r: any) => r?.id ?? r?.record?.id ?? r; |
| 57 | + const acct = idOf(await ql.insert('showcase_account', { name: 'D34 Co', status: 'prospect' }, SYS)); |
| 58 | + |
| 59 | + // Three projects: only the at-risk (red) AND high-budget (>100k) one matches. |
| 60 | + await ql.insert('showcase_project', { id: 'pj_red_hi', name: 'Red-Hi', account: acct, status: 'planned', health: 'red', budget: 250000 }, SYS); |
| 61 | + await ql.insert('showcase_project', { id: 'pj_red_lo', name: 'Red-Lo', account: acct, status: 'planned', health: 'red', budget: 40000 }, SYS); |
| 62 | + await ql.insert('showcase_project', { id: 'pj_grn_hi', name: 'Grn-Hi', account: acct, status: 'planned', health: 'green', budget: 250000 }, SYS); |
| 63 | + |
| 64 | + // An invoice owned by the member (system insert sidesteps authoring rules). |
| 65 | + invId = idOf(await ql.insert('showcase_invoice', { name: 'INV-D34', account: acct, owner: MEMBER, status: 'draft' }, SYS)); |
| 66 | + }, 90_000); |
| 67 | + |
| 68 | + afterAll(async () => { await stack?.stop(); }); |
| 69 | + |
| 70 | + // ── ADR-0058 D3 / #1887 ──────────────────────────────────────────────────── |
| 71 | + it('compound sharing condition is SEEDED as a compound criteria_json (not skipped)', async () => { |
| 72 | + const rule = await ql.findOne('sys_sharing_rule', { where: { name: 'share_high_value_red_projects_with_managers' }, context: SYS.context }); |
| 73 | + expect(rule, 'compound rule was seeded').toBeTruthy(); |
| 74 | + expect(JSON.parse(rule.criteria_json)).toEqual({ |
| 75 | + $and: [{ health: 'red' }, { budget: { $gt: 100000 } }], |
| 76 | + }); |
| 77 | + }); |
| 78 | + |
| 79 | + it('the compound criteria_json matches ONLY the project satisfying BOTH clauses', async () => { |
| 80 | + const rule = await ql.findOne('sys_sharing_rule', { where: { name: 'share_high_value_red_projects_with_managers' }, context: SYS.context }); |
| 81 | + const criteria = JSON.parse(rule.criteria_json); |
| 82 | + // Apply the SEEDED compound criteria to our three projects: only red-AND-high passes. |
| 83 | + const hit = await ql.find('showcase_project', { |
| 84 | + where: { $and: [criteria, { id: { $in: ['pj_red_hi', 'pj_red_lo', 'pj_grn_hi'] } }] }, |
| 85 | + fields: ['id'], context: SYS.context, |
| 86 | + }); |
| 87 | + expect((hit ?? []).map((r: any) => r.id).sort()).toEqual(['pj_red_hi']); |
| 88 | + // The rule also evaluates end-to-end (matched count includes seed data). |
| 89 | + const rules: any = stack.kernel.getService('sharingRules'); |
| 90 | + const res = await rules.evaluateRule('share_high_value_red_projects_with_managers', SYS.context); |
| 91 | + expect(res.matchedRecords, 'rule is evaluable + at least Red-Hi matches').toBeGreaterThanOrEqual(1); |
| 92 | + }); |
| 93 | + |
| 94 | + // ── ADR-0058 D4 ──────────────────────────────────────────────────────────── |
| 95 | + it('RLS check ALLOWS an update that keeps the owner (post-image valid)', async () => { |
| 96 | + const r = await stack.apiAs(token, 'PATCH', `/data/showcase_invoice/${invId}`, { name: 'INV-D34-v2' }); |
| 97 | + expect(r.status, 'owner unchanged → write allowed').toBeLessThan(300); |
| 98 | + }); |
| 99 | + |
| 100 | + it('RLS check DENIES reassigning the invoice to a different owner (post-image invalid)', async () => { |
| 101 | + const r = await stack.apiAs(token, 'PATCH', `/data/showcase_invoice/${invId}`, { owner: 'someone-else@verify.test' }); |
| 102 | + expect(r.status, 'owner reassignment → write denied (fail-closed)').toBeGreaterThanOrEqual(400); |
| 103 | + }); |
| 104 | +}); |
0 commit comments