|
6 | 6 | // their lines. `showcase_contributor` scopes invoice SELECT to |
7 | 7 | // `owner == current_user.email` (`invoice_own_rows`) and refuses an UPDATE whose |
8 | 8 | // post-image moves the owner off the caller (`invoice_owner_immutable`, |
9 | | -// ADR-0058 D4). `showcase_invoice_line` is `controlled_by_parent`, so a line is |
| 9 | +// ADR-0058 D4), and the same check on INSERT refuses a create — clone included — |
| 10 | +// whose new row is owned by anyone but the caller (`invoice_owner_on_create`). |
| 11 | +// `showcase_invoice_line` is `controlled_by_parent`, so a line is |
10 | 12 | // read and written only through its master invoice. |
11 | 13 | // |
12 | 14 | // This boots the REAL showcase app with its own `isDefault` baseline and grants |
@@ -91,10 +93,20 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut |
91 | 93 | expect(line, `a seed line under invoice ${invoiceId} must exist`).toBeTruthy(); |
92 | 94 | return line as { id: string; quantity: number }; |
93 | 95 | }; |
| 96 | + /** A seeded account that is not churned — an invoice against a churned one is refused 400. */ |
| 97 | + const activeAccount = async (): Promise<string> => { |
| 98 | + const rows = (await ql.find('showcase_account', { context: SYS })) as Array<{ id: string; status?: string }>; |
| 99 | + const acct = rows.find((a) => a.status !== 'churned'); |
| 100 | + expect(acct?.id, 'a non-churned seed account must exist').toBeTruthy(); |
| 101 | + return acct!.id; |
| 102 | + }; |
| 103 | + const countNamed = async (name: string): Promise<number> => |
| 104 | + ((await ql.find('showcase_invoice', { where: { name }, context: SYS })) ?? []).length; |
| 105 | + let adminToken = ''; |
94 | 106 |
|
95 | 107 | beforeAll(async () => { |
96 | 108 | stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); |
97 | | - await stack.signIn(); |
| 109 | + adminToken = await stack.signIn(); |
98 | 110 | ql = await stack.kernel.getServiceAsync<any>('objectql'); |
99 | 111 |
|
100 | 112 | const set = await catalogPermissionSet(ql, SET); |
@@ -241,6 +253,73 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut |
241 | 253 | expect(((await r.json()) as any)?.code).toBe('PERMISSION_DENIED'); |
242 | 254 | expect((await invoice(p.ownUnpaidInvoice)).owner, 'owner unchanged').toBe(p.email); |
243 | 255 | }); |
| 256 | + |
| 257 | + // `invoice_owner_on_create` — the update-time ownership check above, on |
| 258 | + // insert. Both halves per holder shape: the admitted create proves the |
| 259 | + // refusal is the row check, not a missing object grant. |
| 260 | + it('creates an invoice it owns (create-time check admits)', async () => { |
| 261 | + const r = await stack.apiAs(tok(), 'POST', '/data/showcase_invoice', { |
| 262 | + name: `INV-OWN-${p.route}`, |
| 263 | + owner: p.email, |
| 264 | + account: await activeAccount(), |
| 265 | + }); |
| 266 | + expect(r.status, 'own-owner create').toBe(201); |
| 267 | + const id = String(idOf(await r.json())); |
| 268 | + const back = await stack.apiAs(tok(), 'GET', `/data/showcase_invoice/${id}`); |
| 269 | + expect(back.status, 'the creator reads its new invoice').toBe(200); |
| 270 | + }); |
| 271 | + |
| 272 | + it('cannot create an invoice it does not own — foreign or absent owner (create-time check)', async () => { |
| 273 | + const name = `INV-FOREIGN-${p.route}`; |
| 274 | + const r = await stack.apiAs(tok(), 'POST', '/data/showcase_invoice', { |
| 275 | + name, |
| 276 | + owner: 'grace@example.com', |
| 277 | + account: await activeAccount(), |
| 278 | + }); |
| 279 | + expect(r.status).toBe(403); |
| 280 | + expect(((await r.json()) as any)?.code).toBe('PERMISSION_DENIED'); |
| 281 | + expect(await countNamed(name), 'nothing written').toBe(0); |
| 282 | + |
| 283 | + // `owner` is a plain text field with no default: a create that omits it |
| 284 | + // writes a row nobody owns, which is outside the caller's scope too. |
| 285 | + const unowned = `INV-UNOWNED-${p.route}`; |
| 286 | + const r2 = await stack.apiAs(tok(), 'POST', '/data/showcase_invoice', { |
| 287 | + name: unowned, |
| 288 | + account: await activeAccount(), |
| 289 | + }); |
| 290 | + expect(r2.status, 'absent-owner create').toBe(403); |
| 291 | + expect(((await r2.json()) as any)?.code).toBe('PERMISSION_DENIED'); |
| 292 | + expect(await countNamed(unowned), 'nothing written').toBe(0); |
| 293 | + }); |
| 294 | + |
| 295 | + it("cannot clone an invoice into someone else's ownership; a same-owner clone is admitted", async () => { |
| 296 | + const own = await invoice(p.ownUnpaidInvoice); |
| 297 | + const foreignName = `INV-CLONE-FOREIGN-${p.route}`; |
| 298 | + const refused = await stack.apiAs(tok(), 'POST', `/data/showcase_invoice/${own.id}/clone`, { |
| 299 | + overrides: { name: foreignName, owner: 'grace@example.com' }, |
| 300 | + }); |
| 301 | + expect(refused.status, 'foreign-owner clone').toBe(403); |
| 302 | + expect(((await refused.json()) as any)?.code).toBe('PERMISSION_DENIED'); |
| 303 | + expect(await countNamed(foreignName), 'nothing written').toBe(0); |
| 304 | + |
| 305 | + const ownName = `INV-CLONE-OWN-${p.route}`; |
| 306 | + const admitted = await stack.apiAs(tok(), 'POST', `/data/showcase_invoice/${own.id}/clone`, { |
| 307 | + overrides: { name: ownName }, |
| 308 | + }); |
| 309 | + expect(admitted.status, 'same-owner clone').toBe(201); |
| 310 | + expect(await countNamed(ownName)).toBe(1); |
| 311 | + }); |
244 | 312 | }); |
245 | 313 | } |
| 314 | + |
| 315 | + it('the create-time check does not bind the admin: an admin creates an invoice for any owner', async () => { |
| 316 | + const name = 'INV-ADMIN-FOR-GRACE'; |
| 317 | + const r = await stack.apiAs(adminToken, 'POST', '/data/showcase_invoice', { |
| 318 | + name, |
| 319 | + owner: 'grace@example.com', |
| 320 | + account: await activeAccount(), |
| 321 | + }); |
| 322 | + expect(r.status).toBe(201); |
| 323 | + expect(await countNamed(name)).toBe(1); |
| 324 | + }); |
246 | 325 | }); |
0 commit comments