|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// SHOWCASE turnkey-demo proof (ADR-0055). The showcase now SEEDS invoices owned |
| 4 | +// by different contributors (ada/linus/grace) + their lines, and the |
| 5 | +// `showcase_contributor` permission set scopes invoice SELECT to |
| 6 | +// `owner == current_user.name`. Because `showcase_invoice_line` is |
| 7 | +// `controlled_by_parent`, each contributor sees ONLY their own invoices' lines. |
| 8 | +// |
| 9 | +// This boots the REAL showcase app (its seed data loads at ready) and asserts the |
| 10 | +// per-owner isolation over that seed, through the real HTTP stack. To exercise the |
| 11 | +// owner predicate without a contributor-role user (the showcase seeds no |
| 12 | +// non-admin users), it governs sign-ups with an equivalent, non-role-gated mirror |
| 13 | +// of the contributor set — same `owner = current_user.email` predicate, same |
| 14 | +// derived-line behavior. Sign-ups use the seed owners' emails so |
| 15 | +// `current_user.email` resolves and matches. |
| 16 | + |
| 17 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 18 | +import showcaseStack from '@objectstack/example-showcase'; |
| 19 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 20 | +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; |
| 21 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 22 | + |
| 23 | +// Non-role-gated mirror of `showcase_contributor`'s invoice access, so a plain |
| 24 | +// sign-up (which holds no role) is governed by it. |
| 25 | +const demoSet = PermissionSetSchema.parse({ |
| 26 | + name: 'showcase_cbp_demo', |
| 27 | + label: 'Showcase CBP Demo', |
| 28 | + isProfile: true, |
| 29 | + objects: { |
| 30 | + showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, |
| 31 | + showcase_product: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, |
| 32 | + showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, |
| 33 | + showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, |
| 34 | + }, |
| 35 | + rowLevelSecurity: [ |
| 36 | + { |
| 37 | + name: 'invoice_own_rows_demo', |
| 38 | + label: 'Own Invoices Only (demo)', |
| 39 | + object: 'showcase_invoice', |
| 40 | + operation: 'select', |
| 41 | + using: 'owner = current_user.email', |
| 42 | + enabled: true, |
| 43 | + priority: 10, |
| 44 | + }, |
| 45 | + ], |
| 46 | +}); |
| 47 | + |
| 48 | +describe('showcase: seeded invoice/line owner isolation (ADR-0055 turnkey demo)', () => { |
| 49 | + let stack: VerifyStack; |
| 50 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 51 | + let ql: any; |
| 52 | + let adaToken: string; |
| 53 | + let linusToken: string; |
| 54 | + let adaLineId: string; |
| 55 | + let linusLineId: string; |
| 56 | + |
| 57 | + const lineUnder = async (invoiceName: string) => { |
| 58 | + const inv = await ql.findOne('showcase_invoice', { where: { name: invoiceName }, context: { isSystem: true } }); |
| 59 | + expect(inv, `seed invoice ${invoiceName} must exist`).toBeTruthy(); |
| 60 | + const line = await ql.findOne('showcase_invoice_line', { where: { invoice: inv.id }, context: { isSystem: true } }); |
| 61 | + expect(line, `seed line under ${invoiceName} must exist`).toBeTruthy(); |
| 62 | + return { invoiceId: inv.id as string, lineId: line.id as string, owner: inv.owner as string }; |
| 63 | + }; |
| 64 | + |
| 65 | + beforeAll(async () => { |
| 66 | + stack = await bootStack(showcaseStack, { |
| 67 | + security: new SecurityPlugin({ |
| 68 | + defaultPermissionSets: [...securityDefaultPermissionSets, demoSet], |
| 69 | + fallbackPermissionSet: 'showcase_cbp_demo', |
| 70 | + }), |
| 71 | + }); |
| 72 | + await stack.signIn(); |
| 73 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 74 | + ql = await stack.kernel.getServiceAsync<any>('objectql'); |
| 75 | + |
| 76 | + const ada = await lineUnder('INV-1001'); // owner ada@example.com |
| 77 | + const linus = await lineUnder('INV-1003'); // owner linus@example.com |
| 78 | + adaLineId = ada.lineId; |
| 79 | + linusLineId = linus.lineId; |
| 80 | + expect(ada.owner).toBe('ada@example.com'); |
| 81 | + expect(linus.owner).toBe('linus@example.com'); |
| 82 | + |
| 83 | + // Sign-ups whose EMAIL matches the seed owner -> `current_user.email` resolves. |
| 84 | + adaToken = await stack.signUp('ada@example.com', 'Member-Pass-123'); |
| 85 | + linusToken = await stack.signUp('linus@example.com', 'Member-Pass-123'); |
| 86 | + }, 60_000); |
| 87 | + |
| 88 | + afterAll(async () => { |
| 89 | + await stack?.stop(); |
| 90 | + }); |
| 91 | + |
| 92 | + it('seed loaded: 4 invoices with expected owners + their lines', async () => { |
| 93 | + const invoices = (await ql.find('showcase_invoice', { context: { isSystem: true } })) as Array<{ name: string; owner: string }>; |
| 94 | + const byName = Object.fromEntries(invoices.map((i) => [i.name, i.owner])); |
| 95 | + expect(byName['INV-1001']).toBe('ada@example.com'); |
| 96 | + expect(byName['INV-1002']).toBe('ada@example.com'); |
| 97 | + expect(byName['INV-1003']).toBe('linus@example.com'); |
| 98 | + expect(byName['INV-1004']).toBe('grace@example.com'); |
| 99 | + const lines = await ql.find('showcase_invoice_line', { context: { isSystem: true } }); |
| 100 | + expect(lines.length).toBeGreaterThanOrEqual(5); |
| 101 | + }); |
| 102 | + |
| 103 | + it('invoice RLS: a contributor lists only the invoices they own', async () => { |
| 104 | + const r = await stack.apiAs(adaToken, 'GET', '/data/showcase_invoice'); |
| 105 | + expect(r.status).toBe(200); |
| 106 | + const body = (await r.json()) as any; |
| 107 | + const rows: Array<{ name: string }> = body.records ?? body.data ?? body; |
| 108 | + const names = rows.map((x) => x.name); |
| 109 | + expect(names).toContain('INV-1001'); |
| 110 | + expect(names).toContain('INV-1002'); |
| 111 | + expect(names).not.toContain('INV-1003'); // linus's |
| 112 | + expect(names).not.toContain('INV-1004'); // grace's |
| 113 | + }); |
| 114 | + |
| 115 | + it('DERIVED: ada reads her own invoice line but not linus line', async () => { |
| 116 | + const own = await stack.apiAs(adaToken, 'GET', `/data/showcase_invoice_line/${adaLineId}`); |
| 117 | + expect(own.status, 'ada reads INV-1001 line').toBe(200); |
| 118 | + const foreign = await stack.apiAs(adaToken, 'GET', `/data/showcase_invoice_line/${linusLineId}`); |
| 119 | + expect(foreign.status, 'ada must not read INV-1003 line').not.toBe(200); |
| 120 | + }); |
| 121 | + |
| 122 | + it('DERIVED: linus reads his own invoice line but not ada line', async () => { |
| 123 | + const own = await stack.apiAs(linusToken, 'GET', `/data/showcase_invoice_line/${linusLineId}`); |
| 124 | + expect(own.status, 'linus reads INV-1003 line').toBe(200); |
| 125 | + const foreign = await stack.apiAs(linusToken, 'GET', `/data/showcase_invoice_line/${adaLineId}`); |
| 126 | + expect(foreign.status, 'linus must not read INV-1001 line').not.toBe(200); |
| 127 | + }); |
| 128 | +}); |
0 commit comments