@byline/db-postgres 1.12.0 → 1.12.2
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.
- package/dist/lib/test-bootstrap.d.ts +8 -0
- package/dist/lib/test-bootstrap.js +27 -0
- package/dist/lib/test-db.d.ts +39 -0
- package/dist/lib/test-db.js +106 -0
- package/dist/lib/test-helper.js +7 -8
- package/dist/modules/admin/tests/auth-integration.test.js +91 -73
- package/dist/modules/admin/tests/session-provider.test.js +77 -60
- package/dist/modules/storage/tests/storage-document-paths.test.js +14 -32
- package/dist/modules/storage/tests/storage-field-types.test.js +13 -14
- package/dist/modules/storage/tests/storage-flatten-reconstruct.test.js +22 -23
- package/dist/modules/storage/tests/storage-restore.test.js +10 -21
- package/dist/modules/storage/tests/storage-store-manifest.test.js +15 -16
- package/dist/modules/storage/tests/storage-versioning.test.js +8 -9
- package/package.json +15 -7
|
@@ -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
|
+
}
|
package/dist/lib/test-helper.js
CHANGED
|
@@ -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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
116
|
+
expect(created.password_hash).toBe(undefined);
|
|
106
117
|
const fetched = await store.adminUsers.getById(created.id);
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
129
|
+
expect(plain?.password_hash).toBe(undefined);
|
|
119
130
|
const withPw = await store.adminUsers.getByEmailForSignIn('b@example.com');
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
145
|
+
expect(updated.given_name).toBe('Charlie');
|
|
146
|
+
expect(updated.is_enabled).toBe(true);
|
|
132
147
|
// Unchanged fields remain
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
expect(role.machine_name).toBe('test-editor');
|
|
222
240
|
const byMachine = await store.adminRoles.getByMachineName('test-editor');
|
|
223
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
+
expect(assignsForRole.length).toBe(0);
|
|
263
281
|
// The user still exists.
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
361
|
+
expect(actor).toBeTruthy();
|
|
362
|
+
expect(actor?.isSuperAdmin).toBe(true);
|
|
345
363
|
// Even without granting any abilities, super-admins pass every check
|
|
346
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
402
|
+
expect(second.created).toEqual({
|
|
385
403
|
user: false,
|
|
386
404
|
role: false,
|
|
387
405
|
assignment: false,
|