storage-grants.enforcement.test.ts154 lines · main
| 1 | /** |
| 2 | * Cross-project storage GRANTS — enforcement unit tests (M5, S3 sprint). |
| 3 | * |
| 4 | * This is security code, so the tests pin the strict-deny contract of |
| 5 | * `isGranted()`: a matching active grant → true; no grant / revoked / wrong |
| 6 | * grantee → false; exact-id vs prefix matching (and non-match); and strict-deny |
| 7 | * on any lookup error. |
| 8 | * |
| 9 | * We stub the two seams the service touches — `getDb()` (control plane, the |
| 10 | * grants table) and `getFile()` (resolves a file's object path for prefix |
| 11 | * matching) — with bun's `mock.module`, exactly like |
| 12 | * storage-admin.enforcement.test.ts. A tiny fake drizzle builder returns the |
| 13 | * grant rows the current test set up, and lets us force a DB error to prove the |
| 14 | * fail-closed path. |
| 15 | */ |
| 16 | import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; |
| 17 | |
| 18 | /** Mutable state every stub reads — reset in beforeEach. */ |
| 19 | const state = { |
| 20 | /** Active grant rows a `.select().from(projectStorageGrants)` returns. */ |
| 21 | grantRows: [] as Array<{ resource: string; isPrefix: boolean }>, |
| 22 | /** Object path getFile() resolves for a file id, keyed by id. */ |
| 23 | filePaths: {} as Record<string, string>, |
| 24 | /** When true, the grants select throws (proves strict-deny on error). */ |
| 25 | throwOnSelect: false, |
| 26 | /** When true, getFile throws (file missing/deleted for the granter). */ |
| 27 | throwOnGetFile: false, |
| 28 | }; |
| 29 | |
| 30 | /** Minimal thenable drizzle-style builder — resolves to the preset grant rows. */ |
| 31 | function makeBuilder() { |
| 32 | const b = { |
| 33 | select: () => b, |
| 34 | from: () => b, |
| 35 | where: () => b, |
| 36 | limit: () => b, |
| 37 | then: (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) => { |
| 38 | if (state.throwOnSelect) { |
| 39 | const err = new Error('simulated grants lookup failure'); |
| 40 | if (reject) return reject(err); |
| 41 | throw err; |
| 42 | } |
| 43 | return resolve(state.grantRows); |
| 44 | }, |
| 45 | }; |
| 46 | return b; |
| 47 | } |
| 48 | |
| 49 | const execute = mock(async () => undefined); |
| 50 | mock.module('../db/client.js', () => ({ |
| 51 | getDb: () => ({ ...makeBuilder(), execute }), |
| 52 | })); |
| 53 | |
| 54 | // getFile resolves a file's object path (used for prefix matching) — scoped to |
| 55 | // the granter. Throwing here mimics a missing/deleted file for that project. |
| 56 | const getFile = mock(async (fileId: string) => { |
| 57 | if (state.throwOnGetFile) throw new Error('file not found'); |
| 58 | const objectKey = state.filePaths[fileId]; |
| 59 | if (!objectKey) throw new Error('file not found'); |
| 60 | return { objectKey }; |
| 61 | }); |
| 62 | mock.module('./storage.js', () => ({ getFile })); |
| 63 | |
| 64 | let svc: typeof import('./storage-grants.js'); |
| 65 | beforeAll(async () => { |
| 66 | svc = await import('./storage-grants.js'); |
| 67 | }); |
| 68 | |
| 69 | beforeEach(() => { |
| 70 | state.grantRows = []; |
| 71 | state.filePaths = {}; |
| 72 | state.throwOnSelect = false; |
| 73 | state.throwOnGetFile = false; |
| 74 | getFile.mockClear(); |
| 75 | execute.mockClear(); |
| 76 | }); |
| 77 | |
| 78 | describe('isGranted — strict-deny enforcement', () => { |
| 79 | const B = 'p_granter'; |
| 80 | const C = 'p_grantee'; |
| 81 | const FILE = 'f_shared'; |
| 82 | const PATH = `projects/${B}/${FILE}`; |
| 83 | |
| 84 | test('active exact grant → true', async () => { |
| 85 | state.grantRows = [{ resource: FILE, isPrefix: false }]; |
| 86 | state.filePaths[FILE] = PATH; |
| 87 | expect(await svc.isGranted(C, B, FILE)).toBe(true); |
| 88 | }); |
| 89 | |
| 90 | test('no grant at all → false', async () => { |
| 91 | state.grantRows = []; // isGranted must not even resolve the file |
| 92 | state.filePaths[FILE] = PATH; |
| 93 | expect(await svc.isGranted(C, B, FILE)).toBe(false); |
| 94 | expect(getFile.mock.calls.length).toBe(0); // short-circuits before file lookup |
| 95 | }); |
| 96 | |
| 97 | test('revoked grant → false (revoked rows are filtered out of grantRows)', async () => { |
| 98 | // The service filters revoked_at IS NULL in SQL, so a revoked grant simply |
| 99 | // never appears in grantRows — model that by returning an empty set. |
| 100 | state.grantRows = []; |
| 101 | state.filePaths[FILE] = PATH; |
| 102 | expect(await svc.isGranted(C, B, FILE)).toBe(false); |
| 103 | }); |
| 104 | |
| 105 | test('exact-file match: grant for one file does NOT cover a different file', async () => { |
| 106 | state.grantRows = [{ resource: FILE, isPrefix: false }]; |
| 107 | state.filePaths['f_other'] = `projects/${B}/f_other`; |
| 108 | expect(await svc.isGranted(C, B, 'f_other')).toBe(false); |
| 109 | }); |
| 110 | |
| 111 | test('prefix grant covers a file whose path starts with the prefix', async () => { |
| 112 | state.grantRows = [{ resource: `projects/${B}/shared/`, isPrefix: true }]; |
| 113 | state.filePaths[FILE] = `projects/${B}/shared/${FILE}`; |
| 114 | expect(await svc.isGranted(C, B, FILE)).toBe(true); |
| 115 | }); |
| 116 | |
| 117 | test('prefix grant does NOT cover a file outside the prefix', async () => { |
| 118 | state.grantRows = [{ resource: `projects/${B}/shared/`, isPrefix: true }]; |
| 119 | state.filePaths[FILE] = `projects/${B}/private/${FILE}`; |
| 120 | expect(await svc.isGranted(C, B, FILE)).toBe(false); |
| 121 | }); |
| 122 | |
| 123 | test('a grant to project B does NOT let project C read (grantee is scoped in SQL)', async () => { |
| 124 | // The grantee filter is in the SQL WHERE, so a grant to a different grantee |
| 125 | // never lands in grantRows for C — model that with an empty result set. |
| 126 | state.grantRows = []; |
| 127 | state.filePaths[FILE] = PATH; |
| 128 | expect(await svc.isGranted('p_thirdparty', B, FILE)).toBe(false); |
| 129 | }); |
| 130 | |
| 131 | test('strict-deny on a lookup error → false (never throws)', async () => { |
| 132 | state.throwOnSelect = true; |
| 133 | state.filePaths[FILE] = PATH; |
| 134 | await expect(svc.isGranted(C, B, FILE)).resolves.toBe(false); |
| 135 | }); |
| 136 | |
| 137 | test('prefix grant with an unresolvable file (getFile throws) → false', async () => { |
| 138 | state.grantRows = [{ resource: `projects/${B}/`, isPrefix: true }]; |
| 139 | state.throwOnGetFile = true; // no real path to prefix-match against |
| 140 | expect(await svc.isGranted(C, B, FILE)).toBe(false); |
| 141 | }); |
| 142 | |
| 143 | test('self-reference (grantee === granter) → false', async () => { |
| 144 | state.grantRows = [{ resource: FILE, isPrefix: false }]; |
| 145 | state.filePaths[FILE] = PATH; |
| 146 | expect(await svc.isGranted(B, B, FILE)).toBe(false); |
| 147 | }); |
| 148 | |
| 149 | test('empty / missing args → false', async () => { |
| 150 | expect(await svc.isGranted('', B, FILE)).toBe(false); |
| 151 | expect(await svc.isGranted(C, '', FILE)).toBe(false); |
| 152 | expect(await svc.isGranted(C, B, '')).toBe(false); |
| 153 | }); |
| 154 | }); |