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 */
16import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
17
18/** Mutable state every stub reads — reset in beforeEach. */
19const 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. */
31function 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
49const execute = mock(async () => undefined);
50mock.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.
56const 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});
62mock.module('./storage.js', () => ({ getFile }));
63
64let svc: typeof import('./storage-grants.js');
65beforeAll(async () => {
66 svc = await import('./storage-grants.js');
67});
68
69beforeEach(() => {
70 state.grantRows = [];
71 state.filePaths = {};
72 state.throwOnSelect = false;
73 state.throwOnGetFile = false;
74 getFile.mockClear();
75 execute.mockClear();
76});
77
78describe('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});