@byline/db-postgres 1.12.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Per-test-file bootstrap for node:test integration tests.
10
+ *
11
+ * The `tsx --env-file=.env.test --import ./src/lib/test-bootstrap.ts --test`
12
+ * invocation runs this module once at the start of each test-file process,
13
+ * before any test imports resolve. It:
14
+ *
15
+ * 1. Asserts `POSTGRES_CONNECTION_STRING` targets a `_test` database
16
+ * (belt; the script-level guard in `common.sh` is the braces).
17
+ * 2. Applies Drizzle migrations (idempotent — cheap on re-run).
18
+ * 3. Truncates all `public` tables so the file starts from a known state.
19
+ *
20
+ * Top-level await ensures the file's own `before()` hooks don't run until
21
+ * the database is ready.
22
+ */
23
+ import { assertTestDatabase, migrateTestDatabase, resetTestDatabase } from './test-db.js';
24
+ const connectionString = process.env.POSTGRES_CONNECTION_STRING;
25
+ assertTestDatabase(connectionString);
26
+ await migrateTestDatabase(connectionString);
27
+ await resetTestDatabase(connectionString);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { type NodePgDatabase } from 'drizzle-orm/node-postgres';
9
+ import * as schema from '../database/schema/index.js';
10
+ /**
11
+ * Belt for the script-level braces in `common.sh`. Parses the connection
12
+ * string and refuses to continue unless the database name ends in `_test`.
13
+ * Called at every test-process entry point so a stray `.env` pointed at
14
+ * `byline_dev` (or anything else) trips the guard before any DDL runs.
15
+ */
16
+ export declare function assertTestDatabase(connectionString: string | undefined): string;
17
+ /**
18
+ * Run Drizzle migrations against the configured connection. Idempotent —
19
+ * Drizzle tracks applied migrations in `__drizzle_migrations`. Opens and
20
+ * closes its own pool; safe to call from a vitest globalSetup or a node:test
21
+ * bootstrap module.
22
+ */
23
+ export declare function migrateTestDatabase(connectionString: string): Promise<void>;
24
+ /**
25
+ * Wipe every user table in the `public` schema (skipping Drizzle's own
26
+ * `__drizzle_migrations` ledger). Uses a single `TRUNCATE ... RESTART
27
+ * IDENTITY CASCADE` so foreign-key chains, sequences, and dependent rows
28
+ * all reset cleanly in one statement.
29
+ *
30
+ * Self-maintaining as the schema grows — new tables come along for the
31
+ * ride without any code change.
32
+ */
33
+ export declare function truncateAllTables(db: NodePgDatabase<typeof schema>): Promise<void>;
34
+ /**
35
+ * Convenience: assert + open a short-lived pool + truncate + close. Useful
36
+ * from a vitest setupFile (`beforeAll`) where the caller doesn't otherwise
37
+ * need a long-lived db handle.
38
+ */
39
+ export declare function resetTestDatabase(connectionString: string): Promise<void>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { sql } from 'drizzle-orm';
11
+ import { drizzle } from 'drizzle-orm/node-postgres';
12
+ import { migrate } from 'drizzle-orm/node-postgres/migrator';
13
+ import pg from 'pg';
14
+ import * as schema from '../database/schema/index.js';
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ /**
17
+ * Drizzle migrations folder. Migrations (`*.sql` + `meta/_journal.json`)
18
+ * live only under `src/` — the TypeScript build doesn't copy them into
19
+ * `dist/`. Anchor on `src/database/migrations` from either location:
20
+ *
21
+ * src/lib/test-db.ts → ../../src/database/migrations ✓
22
+ * dist/lib/test-db.js → ../../src/database/migrations ✓
23
+ *
24
+ * `path.resolve` normalises the `../..` away, so the same string works
25
+ * for both build modes.
26
+ */
27
+ const MIGRATIONS_FOLDER = path.resolve(__dirname, '../../src/database/migrations');
28
+ /**
29
+ * Belt for the script-level braces in `common.sh`. Parses the connection
30
+ * string and refuses to continue unless the database name ends in `_test`.
31
+ * Called at every test-process entry point so a stray `.env` pointed at
32
+ * `byline_dev` (or anything else) trips the guard before any DDL runs.
33
+ */
34
+ export function assertTestDatabase(connectionString) {
35
+ if (!connectionString) {
36
+ throw new Error('POSTGRES_CONNECTION_STRING is not set. Copy .env.test.example to .env.test.');
37
+ }
38
+ let dbName;
39
+ try {
40
+ const url = new URL(connectionString);
41
+ dbName = url.pathname.replace(/^\//, '');
42
+ }
43
+ catch (err) {
44
+ throw new Error(`POSTGRES_CONNECTION_STRING is not a valid URL: ${err.message}`);
45
+ }
46
+ if (!dbName.endsWith('_test')) {
47
+ throw new Error(`Refusing to run tests against database '${dbName}'. ` +
48
+ `Integration tests require a database whose name ends in '_test'. ` +
49
+ `Update POSTGRES_CONNECTION_STRING in .env.test.`);
50
+ }
51
+ return dbName;
52
+ }
53
+ /**
54
+ * Run Drizzle migrations against the configured connection. Idempotent —
55
+ * Drizzle tracks applied migrations in `__drizzle_migrations`. Opens and
56
+ * closes its own pool; safe to call from a vitest globalSetup or a node:test
57
+ * bootstrap module.
58
+ */
59
+ export async function migrateTestDatabase(connectionString) {
60
+ assertTestDatabase(connectionString);
61
+ const pool = new pg.Pool({ connectionString, max: 1 });
62
+ try {
63
+ const db = drizzle(pool, { schema });
64
+ await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
65
+ }
66
+ finally {
67
+ await pool.end();
68
+ }
69
+ }
70
+ /**
71
+ * Wipe every user table in the `public` schema (skipping Drizzle's own
72
+ * `__drizzle_migrations` ledger). Uses a single `TRUNCATE ... RESTART
73
+ * IDENTITY CASCADE` so foreign-key chains, sequences, and dependent rows
74
+ * all reset cleanly in one statement.
75
+ *
76
+ * Self-maintaining as the schema grows — new tables come along for the
77
+ * ride without any code change.
78
+ */
79
+ export async function truncateAllTables(db) {
80
+ const rows = await db.execute(sql `
81
+ SELECT table_name FROM information_schema.tables
82
+ WHERE table_schema = 'public'
83
+ AND table_type = 'BASE TABLE'
84
+ AND table_name <> '__drizzle_migrations'
85
+ `);
86
+ const tables = rows.rows.map((r) => `"public"."${r.table_name}"`);
87
+ if (tables.length === 0)
88
+ return;
89
+ await db.execute(sql.raw(`TRUNCATE ${tables.join(', ')} RESTART IDENTITY CASCADE`));
90
+ }
91
+ /**
92
+ * Convenience: assert + open a short-lived pool + truncate + close. Useful
93
+ * from a vitest setupFile (`beforeAll`) where the caller doesn't otherwise
94
+ * need a long-lived db handle.
95
+ */
96
+ export async function resetTestDatabase(connectionString) {
97
+ assertTestDatabase(connectionString);
98
+ const pool = new pg.Pool({ connectionString, max: 1 });
99
+ try {
100
+ const db = drizzle(pool, { schema });
101
+ await truncateAllTables(db);
102
+ }
103
+ finally {
104
+ await pool.end();
105
+ }
106
+ }
@@ -3,22 +3,21 @@ import pg from 'pg';
3
3
  import * as schema from '../database/schema/index.js';
4
4
  import { createCommandBuilders } from '../modules/storage/storage-commands.js';
5
5
  import { createQueryBuilders } from '../modules/storage/storage-queries.js';
6
+ import { assertTestDatabase } from './test-db.js';
6
7
  let pool;
7
8
  let db;
8
9
  let commandBuilders;
9
10
  let queryBuilders;
10
11
  export function setupTestDB(collections = []) {
11
12
  if (!pool) {
13
+ assertTestDatabase(process.env.POSTGRES_CONNECTION_STRING);
12
14
  pool = new pg.Pool({
13
15
  connectionString: process.env.POSTGRES_CONNECTION_STRING,
14
- // Integration tests share the dev database with the running webapp,
15
- // and node:test may run multiple test files in separate processes.
16
- // A pool-per-file of 20 connections × N files + the webapp's own
17
- // pool of 20 blows past Postgres's default `max_connections=100`
18
- // and throws `FATAL: sorry, too many clients already`. The tests
19
- // are serial and run one query at a time, so a small pool is
20
- // sufficient — keep total test connections low regardless of
21
- // process / file parallelism.
16
+ // node:test runs each test file in its own process. Even though
17
+ // tests target a dedicated `byline_test` database, a pool-per-file
18
+ // of 20 connections × N files can still pressure Postgres's default
19
+ // `max_connections=100`. Tests are serial and run one query at a
20
+ // time, so a small pool is sufficient.
22
21
  max: 4,
23
22
  idleTimeoutMillis: 2000,
24
23
  connectionTimeoutMillis: 1000,
@@ -5,12 +5,11 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import assert from 'node:assert';
9
- import { after, afterEach, before, describe, it } from 'node:test';
10
8
  import { seedSuperAdmin } from '@byline/admin/admin-users';
11
9
  import { hashPassword, resolveActor, verifyPassword } from '@byline/admin/auth';
12
10
  import { AdminAuth } from '@byline/auth';
13
11
  import { eq, inArray } from 'drizzle-orm';
12
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
14
13
  import { adminPermissions, adminRoleAdminUser, adminRoles, adminUsers, } from '../../../database/schema/auth.js';
15
14
  import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
16
15
  import { createAdminStore } from '../admin-store.js';
@@ -35,6 +34,13 @@ let db;
35
34
  let store;
36
35
  const trackedUserIds = new Set();
37
36
  const trackedRoleIds = new Set();
37
+ // Pure-JS argon2id takes ~1 s per hash; this suite makes ~20 users, only
38
+ // one of which is later verified against its plaintext (the
39
+ // getByEmailForSignIn test). Pre-compute one hash for the common-case
40
+ // password ('pw') and reuse it for every test that doesn't care about
41
+ // the actual hash value — saves ~20 s on every run.
42
+ const SHARED_PASSWORD = 'pw';
43
+ let SHARED_HASH = '';
38
44
  function trackUser(id) {
39
45
  trackedUserIds.add(id);
40
46
  }
@@ -45,7 +51,11 @@ async function createUser(input) {
45
51
  const email = input.email.toLowerCase();
46
52
  // Clear any stale row left by a crashed prior run.
47
53
  await db.delete(adminUsers).where(eq(adminUsers.email, email));
48
- const password_hash = await hashPassword(input.password);
54
+ // Reuse the pre-computed hash when the password is the shared marker;
55
+ // tests that actually verify the plaintext (verifyPassword,
56
+ // signInWithPassword) supply their own password and pay the real hash
57
+ // cost.
58
+ const password_hash = input.password === SHARED_PASSWORD ? SHARED_HASH : await hashPassword(input.password);
49
59
  const row = await store.adminUsers.create({
50
60
  email: input.email,
51
61
  password_hash,
@@ -74,15 +84,16 @@ async function cleanupTrackedRows() {
74
84
  }
75
85
  // ---------------------------------------------------------------------------
76
86
  describe('auth integration', () => {
77
- before(() => {
87
+ beforeAll(async () => {
78
88
  const testDB = setupTestDB([]);
79
89
  db = testDB.db;
80
90
  store = createAdminStore(db);
91
+ SHARED_HASH = await hashPassword(SHARED_PASSWORD);
81
92
  });
82
93
  afterEach(async () => {
83
94
  await cleanupTrackedRows();
84
95
  });
85
- after(async () => {
96
+ afterAll(async () => {
86
97
  await cleanupTrackedRows();
87
98
  await teardownTestDB();
88
99
  });
@@ -96,90 +107,97 @@ describe('auth integration', () => {
96
107
  password: 'alice-password',
97
108
  given_name: 'Alice',
98
109
  });
99
- assert.ok(created.id);
100
- assert.strictEqual(created.email, 'alice@example.com');
101
- assert.strictEqual(created.given_name, 'Alice');
102
- assert.strictEqual(created.is_enabled, false); // default false
103
- assert.strictEqual(created.is_super_admin, false);
110
+ expect(created.id).toBeTruthy();
111
+ expect(created.email).toBe('alice@example.com');
112
+ expect(created.given_name).toBe('Alice');
113
+ expect(created.is_enabled).toBe(false); // default false
114
+ expect(created.is_super_admin).toBe(false);
104
115
  // Public columns: password_hash is never returned
105
- assert.strictEqual(created.password_hash, undefined);
116
+ expect(created.password_hash).toBe(undefined);
106
117
  const fetched = await store.adminUsers.getById(created.id);
107
- assert.strictEqual(fetched?.email, 'alice@example.com');
118
+ expect(fetched?.email).toBe('alice@example.com');
108
119
  });
109
120
  it('lowercases the email on insert and on lookup', async () => {
110
121
  await createUser({ email: 'Alice@Example.COM', password: 'pw' });
111
122
  const byMixed = await store.adminUsers.getByEmail('ALICE@example.com');
112
- assert.ok(byMixed);
113
- assert.strictEqual(byMixed.email, 'alice@example.com');
123
+ expect(byMixed).toBeTruthy();
124
+ expect(byMixed?.email).toBe('alice@example.com');
114
125
  });
115
126
  it('returns the password hash only via getByEmailForSignIn', async () => {
116
127
  await createUser({ email: 'b@example.com', password: 'pw-value' });
117
128
  const plain = await store.adminUsers.getByEmail('b@example.com');
118
- assert.strictEqual(plain?.password_hash, undefined);
129
+ expect(plain?.password_hash).toBe(undefined);
119
130
  const withPw = await store.adminUsers.getByEmailForSignIn('b@example.com');
120
- assert.ok(withPw);
121
- assert.ok(await verifyPassword('pw-value', withPw.password_hash));
131
+ // `if !x throw` instead of `x!` so Biome's --unsafe rewrite (which
132
+ // turns `!` into `?.`) doesn't reintroduce `string | undefined`
133
+ // through verifyPassword's `hash: string` arg.
134
+ if (!withPw)
135
+ throw new Error('expected withPw to be defined');
136
+ expect(await verifyPassword('pw-value', withPw.password_hash)).toBeTruthy();
122
137
  });
123
138
  it('update applies partial patches and bumps vid', async () => {
124
139
  const created = await createUser({ email: 'c@example.com', password: 'pw' });
125
- assert.strictEqual(created.vid, 1);
140
+ expect(created.vid).toBe(1);
126
141
  const updated = await store.adminUsers.update(created.id, created.vid, {
127
142
  given_name: 'Charlie',
128
143
  is_enabled: true,
129
144
  });
130
- assert.strictEqual(updated.given_name, 'Charlie');
131
- assert.strictEqual(updated.is_enabled, true);
145
+ expect(updated.given_name).toBe('Charlie');
146
+ expect(updated.is_enabled).toBe(true);
132
147
  // Unchanged fields remain
133
- assert.strictEqual(updated.email, 'c@example.com');
134
- assert.strictEqual(updated.vid, created.vid + 1);
148
+ expect(updated.email).toBe('c@example.com');
149
+ expect(updated.vid).toBe(created.vid + 1);
135
150
  });
136
151
  it('update throws VERSION_CONFLICT on a stale vid', async () => {
137
152
  const created = await createUser({ email: 'c2@example.com', password: 'pw' });
138
153
  // First update succeeds and bumps vid.
139
154
  await store.adminUsers.update(created.id, created.vid, { given_name: 'First' });
140
155
  // Replaying the same vid must conflict.
141
- await assert.rejects(() => store.adminUsers.update(created.id, created.vid, { given_name: 'Second' }), (err) => err.code === 'admin.users.versionConflict');
156
+ await expect(() => store.adminUsers.update(created.id, created.vid, { given_name: 'Second' })).rejects.toMatchObject({ code: 'admin.users.versionConflict' });
142
157
  });
143
158
  it('setPasswordHash rehashes, bumps vid, and returns the fresh row', async () => {
144
159
  const created = await createUser({ email: 'd@example.com', password: 'old' });
145
160
  const updated = await store.adminUsers.setPasswordHash(created.id, created.vid, await hashPassword('new-password'));
146
- assert.strictEqual(updated.id, created.id);
147
- assert.strictEqual(updated.vid, created.vid + 1);
161
+ expect(updated.id).toBe(created.id);
162
+ expect(updated.vid).toBe(created.vid + 1);
148
163
  const signIn = await store.adminUsers.getByEmailForSignIn('d@example.com');
149
- assert.ok(signIn);
150
- assert.ok(await verifyPassword('new-password', signIn.password_hash));
151
- assert.strictEqual(await verifyPassword('old', signIn.password_hash), false);
152
- assert.strictEqual(signIn.vid, created.vid + 1);
164
+ if (!signIn)
165
+ throw new Error('expected signIn to be defined');
166
+ expect(await verifyPassword('new-password', signIn.password_hash)).toBeTruthy();
167
+ expect(await verifyPassword('old', signIn.password_hash)).toBe(false);
168
+ expect(signIn.vid).toBe(created.vid + 1);
153
169
  });
154
170
  it('setPasswordHash throws VERSION_CONFLICT on a stale vid', async () => {
155
171
  const created = await createUser({ email: 'd2@example.com', password: 'pw' });
156
172
  await store.adminUsers.update(created.id, created.vid, { given_name: 'D' });
157
- await assert.rejects(() => store.adminUsers.setPasswordHash(created.id, created.vid, '$argon2id$stale-hash'), (err) => err.code === 'admin.users.versionConflict');
173
+ await expect(() => store.adminUsers.setPasswordHash(created.id, created.vid, '$argon2id$stale-hash')).rejects.toMatchObject({ code: 'admin.users.versionConflict' });
158
174
  });
159
175
  it('recordLoginSuccess resets failed_login_attempts and stamps last_login', async () => {
160
176
  const created = await createUser({ email: 'e@example.com', password: 'pw' });
161
177
  await store.adminUsers.recordLoginFailure(created.id);
162
178
  await store.adminUsers.recordLoginFailure(created.id);
163
179
  let row = await store.adminUsers.getById(created.id);
164
- assert.strictEqual(row?.failed_login_attempts, 2);
180
+ expect(row?.failed_login_attempts).toBe(2);
165
181
  await store.adminUsers.recordLoginSuccess(created.id, '10.0.0.1');
166
182
  row = await store.adminUsers.getById(created.id);
167
- assert.strictEqual(row?.failed_login_attempts, 0);
168
- assert.strictEqual(row?.last_login_ip, '10.0.0.1');
169
- assert.ok(row?.last_login);
183
+ expect(row?.failed_login_attempts).toBe(0);
184
+ expect(row?.last_login_ip).toBe('10.0.0.1');
185
+ expect(row?.last_login).toBeTruthy();
170
186
  });
171
187
  it('delete removes the row when vid matches', async () => {
172
188
  const created = await createUser({ email: 'f@example.com', password: 'pw' });
173
189
  await store.adminUsers.delete(created.id, created.vid);
174
190
  const fetched = await store.adminUsers.getById(created.id);
175
- assert.strictEqual(fetched, null);
191
+ expect(fetched).toBe(null);
176
192
  });
177
193
  it('delete throws VERSION_CONFLICT on a stale vid', async () => {
178
194
  const created = await createUser({ email: 'f2@example.com', password: 'pw' });
179
195
  await store.adminUsers.update(created.id, created.vid, { given_name: 'F' });
180
- await assert.rejects(() => store.adminUsers.delete(created.id, created.vid), (err) => err.code === 'admin.users.versionConflict');
196
+ await expect(() => store.adminUsers.delete(created.id, created.vid)).rejects.toMatchObject({
197
+ code: 'admin.users.versionConflict',
198
+ });
181
199
  // Row should still be present.
182
- assert.ok(await store.adminUsers.getById(created.id));
200
+ expect(await store.adminUsers.getById(created.id)).toBeTruthy();
183
201
  });
184
202
  it('list applies pagination, order, and query filter', async () => {
185
203
  await createUser({ email: 'list1@example.com', password: 'pw', given_name: 'Aaron' });
@@ -194,7 +212,7 @@ describe('auth integration', () => {
194
212
  order: 'email',
195
213
  desc: false,
196
214
  });
197
- assert.strictEqual(filtered.length, 3);
215
+ expect(filtered.length).toBe(3);
198
216
  const named = await store.adminUsers.list({
199
217
  page: 1,
200
218
  pageSize: 10,
@@ -202,10 +220,10 @@ describe('auth integration', () => {
202
220
  order: 'email',
203
221
  desc: false,
204
222
  });
205
- assert.strictEqual(named.length, 1);
206
- assert.strictEqual(named[0]?.given_name, 'Bea');
223
+ expect(named.length).toBe(1);
224
+ expect(named[0]?.given_name).toBe('Bea');
207
225
  const total = await store.adminUsers.count({ query: 'list' });
208
- assert.strictEqual(total, 3);
226
+ expect(total).toBe(3);
209
227
  });
210
228
  });
211
229
  // -------------------------------------------------------------------------
@@ -218,9 +236,9 @@ describe('auth integration', () => {
218
236
  machine_name: 'test-editor',
219
237
  description: 'Can edit content',
220
238
  });
221
- assert.strictEqual(role.machine_name, 'test-editor');
239
+ expect(role.machine_name).toBe('test-editor');
222
240
  const byMachine = await store.adminRoles.getByMachineName('test-editor');
223
- assert.strictEqual(byMachine?.id, role.id);
241
+ expect(byMachine?.id).toBe(role.id);
224
242
  });
225
243
  it('assignToUser is idempotent and listRolesForUser returns the role', async () => {
226
244
  const user = await createUser({ email: 'g@example.com', password: 'pw' });
@@ -228,17 +246,17 @@ describe('auth integration', () => {
228
246
  await store.adminRoles.assignToUser(role.id, user.id);
229
247
  await store.adminRoles.assignToUser(role.id, user.id); // idempotent
230
248
  const userRoles = await store.adminRoles.listRolesForUser(user.id);
231
- assert.strictEqual(userRoles.length, 1);
232
- assert.strictEqual(userRoles[0]?.machine_name, 'test-r');
249
+ expect(userRoles.length).toBe(1);
250
+ expect(userRoles[0]?.machine_name).toBe('test-r');
233
251
  const usersForRole = await store.adminRoles.listUsersForRole(role.id);
234
- assert.deepStrictEqual(usersForRole, [user.id]);
252
+ expect(usersForRole).toEqual([user.id]);
235
253
  });
236
254
  it('unassignFromUser removes the assignment', async () => {
237
255
  const user = await createUser({ email: 'h@example.com', password: 'pw' });
238
256
  const role = await createRole({ name: 'test-r', machine_name: 'test-r' });
239
257
  await store.adminRoles.assignToUser(role.id, user.id);
240
258
  await store.adminRoles.unassignFromUser(role.id, user.id);
241
- assert.strictEqual((await store.adminRoles.listRolesForUser(user.id)).length, 0);
259
+ expect((await store.adminRoles.listRolesForUser(user.id)).length).toBe(0);
242
260
  });
243
261
  it('delete cascades to permissions and role-user assignments', async () => {
244
262
  const user = await createUser({ email: 'i@example.com', password: 'pw' });
@@ -247,21 +265,21 @@ describe('auth integration', () => {
247
265
  await store.adminRoles.assignToUser(role.id, user.id);
248
266
  await store.adminRoles.delete(role.id, role.vid);
249
267
  // The role is gone…
250
- assert.strictEqual(await store.adminRoles.getById(role.id), null);
268
+ expect(await store.adminRoles.getById(role.id)).toBe(null);
251
269
  // …and its grants are gone…
252
270
  const grantsForRole = await db
253
271
  .select()
254
272
  .from(adminPermissions)
255
273
  .where(eq(adminPermissions.admin_role_id, role.id));
256
- assert.strictEqual(grantsForRole.length, 0);
274
+ expect(grantsForRole.length).toBe(0);
257
275
  // …and no assignment for the role remains.
258
276
  const assignsForRole = await db
259
277
  .select()
260
278
  .from(adminRoleAdminUser)
261
279
  .where(eq(adminRoleAdminUser.admin_role_id, role.id));
262
- assert.strictEqual(assignsForRole.length, 0);
280
+ expect(assignsForRole.length).toBe(0);
263
281
  // The user still exists.
264
- assert.ok(await store.adminUsers.getById(user.id));
282
+ expect(await store.adminUsers.getById(user.id)).toBeTruthy();
265
283
  });
266
284
  });
267
285
  // -------------------------------------------------------------------------
@@ -273,7 +291,7 @@ describe('auth integration', () => {
273
291
  await store.adminPermissions.grantAbility(role.id, 'collections.pages.publish');
274
292
  await store.adminPermissions.grantAbility(role.id, 'collections.pages.publish');
275
293
  const abilities = await store.adminPermissions.listAbilities(role.id);
276
- assert.deepStrictEqual(abilities, ['collections.pages.publish']);
294
+ expect(abilities).toEqual(['collections.pages.publish']);
277
295
  });
278
296
  it('revokeAbility removes the grant', async () => {
279
297
  const role = await createRole({ name: 'test-r', machine_name: 'test-r' });
@@ -281,7 +299,7 @@ describe('auth integration', () => {
281
299
  await store.adminPermissions.grantAbility(role.id, 'a.two');
282
300
  await store.adminPermissions.revokeAbility(role.id, 'a.one');
283
301
  const abilities = await store.adminPermissions.listAbilities(role.id);
284
- assert.deepStrictEqual(abilities.sort(), ['a.two']);
302
+ expect(abilities.sort()).toEqual(['a.two']);
285
303
  });
286
304
  it('setAbilities replaces the ability set wholesale', async () => {
287
305
  const role = await createRole({ name: 'test-r', machine_name: 'test-r' });
@@ -289,7 +307,7 @@ describe('auth integration', () => {
289
307
  await store.adminPermissions.grantAbility(role.id, 'a.two');
290
308
  await store.adminPermissions.setAbilities(role.id, ['a.three', 'a.four']);
291
309
  const abilities = await store.adminPermissions.listAbilities(role.id);
292
- assert.deepStrictEqual(abilities.sort(), ['a.four', 'a.three']);
310
+ expect(abilities.sort()).toEqual(['a.four', 'a.three']);
293
311
  });
294
312
  });
295
313
  // -------------------------------------------------------------------------
@@ -298,13 +316,13 @@ describe('auth integration', () => {
298
316
  describe('resolveActor', () => {
299
317
  it('returns null for unknown user ids', async () => {
300
318
  const actor = await resolveActor(store, '00000000-0000-7000-8000-000000000000');
301
- assert.strictEqual(actor, null);
319
+ expect(actor).toBe(null);
302
320
  });
303
321
  it('returns null for disabled users', async () => {
304
322
  const user = await createUser({ email: 'j@example.com', password: 'pw' });
305
323
  // Created with is_enabled: false by default
306
324
  const actor = await resolveActor(store, user.id);
307
- assert.strictEqual(actor, null);
325
+ expect(actor).toBe(null);
308
326
  });
309
327
  it('builds an AdminAuth with the union of abilities across roles', async () => {
310
328
  const user = await createUser({
@@ -322,15 +340,15 @@ describe('auth integration', () => {
322
340
  await store.adminRoles.assignToUser(roleA.id, user.id);
323
341
  await store.adminRoles.assignToUser(roleB.id, user.id);
324
342
  const actor = await resolveActor(store, user.id);
325
- assert.ok(actor instanceof AdminAuth);
326
- assert.strictEqual(actor.id, user.id);
327
- assert.strictEqual(actor.isSuperAdmin, false);
328
- assert.strictEqual(actor.hasAbility('collections.pages.read'), true);
329
- assert.strictEqual(actor.hasAbility('collections.pages.update'), true);
330
- assert.strictEqual(actor.hasAbility('collections.pages.publish'), true);
331
- assert.strictEqual(actor.hasAbility('collections.pages.delete'), false);
343
+ expect(actor instanceof AdminAuth).toBeTruthy();
344
+ expect(actor?.id).toBe(user.id);
345
+ expect(actor?.isSuperAdmin).toBe(false);
346
+ expect(actor?.hasAbility('collections.pages.read')).toBe(true);
347
+ expect(actor?.hasAbility('collections.pages.update')).toBe(true);
348
+ expect(actor?.hasAbility('collections.pages.publish')).toBe(true);
349
+ expect(actor?.hasAbility('collections.pages.delete')).toBe(false);
332
350
  // Distinct — duplicates across roles collapse
333
- assert.strictEqual(actor.abilities.size, 3);
351
+ expect(actor?.abilities.size).toBe(3);
334
352
  });
335
353
  it('honours the is_super_admin flag', async () => {
336
354
  const user = await createUser({
@@ -340,10 +358,10 @@ describe('auth integration', () => {
340
358
  is_enabled: true,
341
359
  });
342
360
  const actor = await resolveActor(store, user.id);
343
- assert.ok(actor);
344
- assert.strictEqual(actor.isSuperAdmin, true);
361
+ expect(actor).toBeTruthy();
362
+ expect(actor?.isSuperAdmin).toBe(true);
345
363
  // Even without granting any abilities, super-admins pass every check
346
- assert.strictEqual(actor.hasAbility('anything.at.all'), true);
364
+ expect(actor?.hasAbility('anything.at.all')).toBe(true);
347
365
  });
348
366
  });
349
367
  // -------------------------------------------------------------------------
@@ -367,12 +385,12 @@ describe('auth integration', () => {
367
385
  const result = await seedSuperAdmin(store, seedInput);
368
386
  trackUser(result.userId);
369
387
  trackRole(result.roleId);
370
- assert.ok(result.userId);
371
- assert.ok(result.roleId);
372
- assert.deepStrictEqual(result.created, { user: true, role: true, assignment: true });
388
+ expect(result.userId).toBeTruthy();
389
+ expect(result.roleId).toBeTruthy();
390
+ expect(result.created).toEqual({ user: true, role: true, assignment: true });
373
391
  const actor = await resolveActor(store, result.userId);
374
- assert.ok(actor);
375
- assert.strictEqual(actor.isSuperAdmin, true);
392
+ expect(actor).toBeTruthy();
393
+ expect(actor?.isSuperAdmin).toBe(true);
376
394
  });
377
395
  it('is idempotent — second run reports nothing newly created', async () => {
378
396
  await db.delete(adminUsers).where(eq(adminUsers.email, seedInput.email));
@@ -381,7 +399,7 @@ describe('auth integration', () => {
381
399
  trackUser(first.userId);
382
400
  trackRole(first.roleId);
383
401
  const second = await seedSuperAdmin(store, seedInput);
384
- assert.deepStrictEqual(second.created, {
402
+ expect(second.created).toEqual({
385
403
  user: false,
386
404
  role: false,
387
405
  assignment: false,