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 | */ |
| 14 | import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; |
| 15 | import { ValidationError } from '@briven/shared'; |
| 16 | |
| 17 | import { projects, tierStorageCaps } from '../db/schema.js'; |
| 18 | |
| 19 | /** Mutable state every stub reads — reset in beforeEach. */ |
| 20 | const 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. */ |
| 33 | function 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. |
| 60 | const runInProjectDatabase = mock(async () => ({ |
| 61 | rowCount: state.rowCount, |
| 62 | tableCount: state.tableCount, |
| 63 | })); |
| 64 | |
| 65 | mock.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. |
| 72 | mock.module('../db/data-plane.js', () => ({ runInProjectDatabase })); |
| 73 | |
| 74 | let svc: typeof import('./storage-admin.js'); |
| 75 | beforeAll(async () => { |
| 76 | svc = await import('./storage-admin.js'); |
| 77 | }); |
| 78 | |
| 79 | beforeEach(() => { |
| 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 | |
| 89 | afterEach(() => { |
| 90 | svc._resetEnforcementCache(); |
| 91 | }); |
| 92 | |
| 93 | describe('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 | |
| 112 | describe('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 | |
| 133 | describe('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 | }); |