storage-admin.enforcement.test.ts176 lines · main
1/**
2 * Enforcement-mode unit tests (Sprint 4 Phase 4 — the "block" lever).
3 *
4 * The existing storage-admin.test.ts only exercises the pure flag math; the
5 * stateful enforcement logic (cache, block decision, fail-open) needs the
6 * control DB and the project-DB row count, so we stub the two seams those
7 * touch — `getDb()` (control plane) and `runInProjectDatabase()` (project
8 * plane) — with bun's mock.module. A tiny fake drizzle builder returns
9 * preset rows keyed by which table the chain selected `.from()`, so the same
10 * stub serves getProjectEnforcement, getTierStorageCaps and the projects
11 * lookup inside assertWithinStorageLimit. `_resetEnforcementCache()` runs
12 * between tests so the in-memory cache never leaks across cases.
13 */
14import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
15import { ValidationError } from '@briven/shared';
16
17import { projects, tierStorageCaps } from '../db/schema.js';
18
19/** Mutable state every stub reads — reset in beforeEach. */
20const state = {
21 /** Row returned for a `.from(projects)` lookup (null = no such project). */
22 projectRow: null as Record<string, unknown> | null,
23 /** Rows returned for a `.from(tierStorageCaps)` lookup. */
24 tierCaps: [] as Array<Record<string, unknown>>,
25 /** Counts the fake project DB reports back through getProjectRowCount. */
26 rowCount: 0,
27 tableCount: 0,
28 /** How many control-plane projects reads ran (proves cache hits/misses). */
29 projectSelects: 0,
30};
31
32/** Minimal thenable drizzle-style builder; resolves by the `.from()` table. */
33function makeBuilder() {
34 let table: unknown;
35 const b = {
36 select: () => b,
37 update: () => b,
38 set: () => b,
39 from: (t: unknown) => {
40 table = t;
41 return b;
42 },
43 where: () => b,
44 limit: () => b,
45 then: (resolve: (v: unknown) => unknown) => {
46 if (table === tierStorageCaps) return resolve(state.tierCaps);
47 if (table === projects) {
48 state.projectSelects += 1;
49 return resolve(state.projectRow ? [state.projectRow] : []);
50 }
51 return resolve([]);
52 },
53 };
54 return b;
55}
56
57// runInProjectDatabase is what getProjectRowCount calls; the mock ignores the
58// callback and hands back the preset counts. Tracking its calls lets us prove
59// flag-mode / fail-open paths NEVER touch the project DB.
60const runInProjectDatabase = mock(async () => ({
61 rowCount: state.rowCount,
62 tableCount: state.tableCount,
63}));
64
65mock.module('../db/client.js', () => ({ getDb: () => makeBuilder() }));
66// NOTE: mock.module() is process-global and is not reverted between files, so
67// this data-plane stub shadows the real module for later files too. It is
68// intentionally minimal because the integration suites that need the REAL
69// data-plane run in their own process via `bun test integration` (which never
70// loads this unit file). Keep them split — do not run integration with a DB in
71// the same `bun test` invocation as the mock-based unit tests.
72mock.module('../db/data-plane.js', () => ({ runInProjectDatabase }));
73
74let svc: typeof import('./storage-admin.js');
75beforeAll(async () => {
76 svc = await import('./storage-admin.js');
77});
78
79beforeEach(() => {
80 state.projectRow = null;
81 state.tierCaps = [];
82 state.rowCount = 0;
83 state.tableCount = 0;
84 state.projectSelects = 0;
85 runInProjectDatabase.mockClear();
86 svc._resetEnforcementCache();
87});
88
89afterEach(() => {
90 svc._resetEnforcementCache();
91});
92
93describe('getProjectEnforcement', () => {
94 test("defaults to 'flag' for an unknown/absent project", async () => {
95 state.projectRow = null; // no such project
96 expect(await svc.getProjectEnforcement('p_unknown')).toBe('flag');
97 });
98
99 test('caches — a second call does not re-query the control DB', async () => {
100 state.projectRow = { mode: 'block' };
101 expect(await svc.getProjectEnforcement('p_cache')).toBe('block');
102 expect(state.projectSelects).toBe(1); // first call hit the DB
103
104 // If the cache were ignored this second call would re-query (and could
105 // return a changed value); instead it must short-circuit on the cache.
106 state.projectRow = { mode: 'flag' };
107 expect(await svc.getProjectEnforcement('p_cache')).toBe('block');
108 expect(state.projectSelects).toBe(1); // still 1 → served from cache
109 });
110});
111
112describe('setProjectEnforcement', () => {
113 test('rejects an invalid mode with a ValidationError', async () => {
114 await expect(
115 // @ts-expect-error — deliberately passing an invalid mode
116 svc.setProjectEnforcement('p1', 'nope', null),
117 ).rejects.toBeInstanceOf(ValidationError);
118 });
119
120 test('a valid change invalidates the cache (next read re-queries)', async () => {
121 state.projectRow = { mode: 'block' };
122 expect(await svc.getProjectEnforcement('p1')).toBe('block'); // cached 'block'
123
124 state.projectRow = { mode: 'flag' };
125 await svc.setProjectEnforcement('p1', 'flag', null); // must drop the cache entry
126
127 state.projectSelects = 0;
128 expect(await svc.getProjectEnforcement('p1')).toBe('flag'); // re-queried
129 expect(state.projectSelects).toBe(1); // a fresh read ran → cache was invalidated
130 });
131});
132
133describe('assertWithinStorageLimit', () => {
134 test("'flag' mode is a no-op — never throws, never counts rows", async () => {
135 state.projectRow = { mode: 'flag', tier: 'free', storageMaxRows: null, storageMaxTables: null };
136 await expect(svc.assertWithinStorageLimit('p1', 'row')).resolves.toBeUndefined();
137 expect(runInProjectDatabase.mock.calls.length).toBe(0); // getProjectRowCount not called
138 });
139
140 test("'block' mode throws a ValidationError when a row write exceeds the cap", async () => {
141 state.projectRow = { mode: 'block', tier: 'free', storageMaxRows: null, storageMaxTables: null };
142 state.tierCaps = [{ tier: 'free', maxRows: 100, maxTables: 50 }];
143 state.rowCount = 100; // 100 + 1 > 100 → over
144 state.tableCount = 1;
145 await expect(svc.assertWithinStorageLimit('p1', 'row')).rejects.toBeInstanceOf(ValidationError);
146 });
147
148 test("'block' mode throws when a table write exceeds the table cap", async () => {
149 state.projectRow = { mode: 'block', tier: 'free', storageMaxRows: null, storageMaxTables: null };
150 state.tierCaps = [{ tier: 'free', maxRows: 100, maxTables: 50 }];
151 state.rowCount = 1;
152 state.tableCount = 50; // 50 + 1 > 50 → over
153 await expect(svc.assertWithinStorageLimit('p1', 'table')).rejects.toBeInstanceOf(ValidationError);
154 });
155
156 test("'block' mode does NOT throw when the write stays under the cap", async () => {
157 state.projectRow = { mode: 'block', tier: 'free', storageMaxRows: null, storageMaxTables: null };
158 state.tierCaps = [{ tier: 'free', maxRows: 100, maxTables: 50 }];
159 state.rowCount = 10; // 10 + 1 ≤ 100
160 state.tableCount = 2; // 2 + 1 ≤ 50
161 await expect(svc.assertWithinStorageLimit('p1', 'row')).resolves.toBeUndefined();
162 await expect(svc.assertWithinStorageLimit('p1', 'table')).resolves.toBeUndefined();
163 });
164
165 test('fails OPEN (no throw) for an unknown project even in block mode', async () => {
166 // Prime the cache to 'block' for this id…
167 state.projectRow = { mode: 'block' };
168 expect(await svc.getProjectEnforcement('p_ghost')).toBe('block');
169
170 // …then make the projects lookup inside assert come back empty.
171 state.projectRow = null;
172 runInProjectDatabase.mockClear();
173 await expect(svc.assertWithinStorageLimit('p_ghost', 'row')).resolves.toBeUndefined();
174 expect(runInProjectDatabase.mock.calls.length).toBe(0); // bailed before counting
175 });
176});