|
9 | 9 | * the read API, and the global record-lock hook. |
10 | 10 | */ |
11 | 11 |
|
12 | | -import { describe, it, expect, beforeEach } from 'vitest'; |
| 12 | +import { describe, it, expect, beforeEach, vi } from 'vitest'; |
13 | 13 | import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js'; |
14 | 14 | import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js'; |
15 | 15 |
|
@@ -2005,3 +2005,69 @@ describe('ApprovalService — queue approver is unresolved (#3508)', () => { |
2005 | 2005 | expect(warnings.some(([msg]) => String(msg).includes("'queue'") && String(msg).includes('#3508'))).toBe(true); |
2006 | 2006 | }); |
2007 | 2007 | }); |
| 2008 | + |
| 2009 | +// ── File-access delegate (ADR-0104 D3 wave 2) ──────────────────────── |
| 2010 | +// |
| 2011 | +// A decision attachment is OWNED by its `sys_approval_action` row, so the |
| 2012 | +// storage service would otherwise authorize the download by testing whether |
| 2013 | +// the caller can READ that row. It cannot — the table is closed to ordinary |
| 2014 | +// approver positions — which denied the very approver the attachment was filed |
| 2015 | +// for (reproduced in the browser against app-showcase). `sys_approval_action` |
| 2016 | +// therefore declares `fileAccessDelegate: 'approvals'` and the service answers, |
| 2017 | +// reusing the rule that already governs seeing a decision: visibility of the |
| 2018 | +// PARENT REQUEST, exactly as listActions applies it. |
| 2019 | +describe('ApprovalService — authorizeFileRead delegate (ADR-0104 D3 wave 2)', () => { |
| 2020 | + const svcFor = (engine: any) => { |
| 2021 | + let n = 0; |
| 2022 | + return new ApprovalService({ |
| 2023 | + engine, |
| 2024 | + clock: { now: () => new Date(1757000000000 + (n++) * 1000) }, |
| 2025 | + }); |
| 2026 | + }; |
| 2027 | + |
| 2028 | + const seedDecision = async (engine: any) => { |
| 2029 | + const svc = svcFor(engine); |
| 2030 | + const req = await svc.openNodeRequest(openInput(['u9']), CTX); |
| 2031 | + await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS); |
| 2032 | + const action = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve'); |
| 2033 | + return { svc, req, actionId: String(action.id) }; |
| 2034 | + }; |
| 2035 | + |
| 2036 | + it('allows a caller who can see the parent request', async () => { |
| 2037 | + const engine = makeFakeEngine(); |
| 2038 | + const { svc, actionId } = await seedDecision(engine); |
| 2039 | + |
| 2040 | + expect(await svc.authorizeFileRead(actionId, SYS)).toBe(true); |
| 2041 | + }); |
| 2042 | + |
| 2043 | + it('denies a caller who cannot see the parent request', async () => { |
| 2044 | + const engine = makeFakeEngine(); |
| 2045 | + const { svc, actionId } = await seedDecision(engine); |
| 2046 | + // getRequest is the single gate this delegates to — when it yields nothing |
| 2047 | + // for this caller, the bytes must be refused too. |
| 2048 | + vi.spyOn(svc as any, 'getRequest').mockResolvedValue(null); |
| 2049 | + |
| 2050 | + expect(await svc.authorizeFileRead(actionId, CTX)).toBe(false); |
| 2051 | + }); |
| 2052 | + |
| 2053 | + it('denies an unknown action id', async () => { |
| 2054 | + const engine = makeFakeEngine(); |
| 2055 | + const { svc } = await seedDecision(engine); |
| 2056 | + |
| 2057 | + expect(await svc.authorizeFileRead('aact_does_not_exist', SYS)).toBe(false); |
| 2058 | + expect(await svc.authorizeFileRead('', SYS)).toBe(false); |
| 2059 | + }); |
| 2060 | + |
| 2061 | + it('fails CLOSED when the lookup throws', async () => { |
| 2062 | + const engine = makeFakeEngine(); |
| 2063 | + const { svc, actionId } = await seedDecision(engine); |
| 2064 | + vi.spyOn(engine as any, 'find').mockRejectedValue(new Error('driver down')); |
| 2065 | + |
| 2066 | + expect(await svc.authorizeFileRead(actionId, SYS)).toBe(false); |
| 2067 | + }); |
| 2068 | + |
| 2069 | + it('sys_approval_action declares the delegate, so the storage gate asks the service', async () => { |
| 2070 | + const { SysApprovalAction } = await import('./sys-approval-action.object.js'); |
| 2071 | + expect((SysApprovalAction as any).fileAccessDelegate).toBe('approvals'); |
| 2072 | + }); |
| 2073 | +}); |
0 commit comments