@bevel-software/platform-core-backend 0.27.0 → 0.28.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.
Files changed (95) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +2 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +7 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +5 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-control.interface.d.ts +9 -0
  9. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  10. package/dist/modules/access/access-control.service.d.ts +1 -0
  11. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  12. package/dist/modules/access/access-control.service.js +20 -0
  13. package/dist/modules/access/access-control.service.js.map +1 -1
  14. package/dist/modules/access/access.routes.d.ts +4 -1
  15. package/dist/modules/access/access.routes.d.ts.map +1 -1
  16. package/dist/modules/access/access.routes.js +66 -7
  17. package/dist/modules/access/access.routes.js.map +1 -1
  18. package/dist/modules/access/roles-admin.service.d.ts +35 -6
  19. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  20. package/dist/modules/access/roles-admin.service.js +36 -9
  21. package/dist/modules/access/roles-admin.service.js.map +1 -1
  22. package/dist/modules/access/user-access-removal.service.js +1 -1
  23. package/dist/modules/access/user-access-removal.service.js.map +1 -1
  24. package/dist/modules/auth/account-hooks.d.ts +46 -0
  25. package/dist/modules/auth/account-hooks.d.ts.map +1 -0
  26. package/dist/modules/auth/account-hooks.js +25 -0
  27. package/dist/modules/auth/account-hooks.js.map +1 -0
  28. package/dist/modules/auth/account-switch-guard.d.ts +15 -0
  29. package/dist/modules/auth/account-switch-guard.d.ts.map +1 -0
  30. package/dist/modules/auth/account-switch-guard.js +2 -0
  31. package/dist/modules/auth/account-switch-guard.js.map +1 -0
  32. package/dist/modules/auth/account-switch-lock.d.ts +34 -0
  33. package/dist/modules/auth/account-switch-lock.d.ts.map +1 -0
  34. package/dist/modules/auth/account-switch-lock.js +48 -0
  35. package/dist/modules/auth/account-switch-lock.js.map +1 -0
  36. package/dist/modules/auth/account.routes.d.ts +4 -1
  37. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  38. package/dist/modules/auth/account.routes.js +100 -10
  39. package/dist/modules/auth/account.routes.js.map +1 -1
  40. package/dist/modules/auth/auth.service.d.ts +101 -6
  41. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  42. package/dist/modules/auth/auth.service.js +246 -51
  43. package/dist/modules/auth/auth.service.js.map +1 -1
  44. package/dist/modules/auth/password-hash.d.ts +5 -2
  45. package/dist/modules/auth/password-hash.d.ts.map +1 -1
  46. package/dist/modules/auth/password-hash.js +5 -2
  47. package/dist/modules/auth/password-hash.js.map +1 -1
  48. package/dist/modules/database/pg-error.d.ts +9 -0
  49. package/dist/modules/database/pg-error.d.ts.map +1 -0
  50. package/dist/modules/database/pg-error.js +17 -0
  51. package/dist/modules/database/pg-error.js.map +1 -0
  52. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  53. package/dist/modules/workflow/workflow.service.js +2 -1
  54. package/dist/modules/workflow/workflow.service.js.map +1 -1
  55. package/package.json +3 -3
  56. package/src/__tests__/pg-scratch.ts +48 -0
  57. package/src/core/create-core-server.ts +2 -0
  58. package/src/core/create-core-services.ts +11 -0
  59. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +1 -0
  60. package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
  61. package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
  62. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
  63. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
  64. package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
  65. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
  66. package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
  67. package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
  68. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
  69. package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
  70. package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
  71. package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
  72. package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
  73. package/src/modules/access/__tests__/users-db-double.ts +12 -3
  74. package/src/modules/access/access-control.interface.ts +10 -0
  75. package/src/modules/access/access-control.service.ts +19 -0
  76. package/src/modules/access/access.routes.ts +67 -6
  77. package/src/modules/access/roles-admin.service.ts +51 -11
  78. package/src/modules/access/user-access-removal.service.ts +1 -1
  79. package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
  80. package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
  81. package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
  82. package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
  83. package/src/modules/auth/__tests__/account.routes.test.ts +382 -6
  84. package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
  85. package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
  86. package/src/modules/auth/account-hooks.ts +69 -0
  87. package/src/modules/auth/account-switch-guard.ts +14 -0
  88. package/src/modules/auth/account-switch-lock.ts +58 -0
  89. package/src/modules/auth/account.routes.ts +112 -10
  90. package/src/modules/auth/auth.service.ts +268 -52
  91. package/src/modules/auth/password-hash.ts +5 -2
  92. package/src/modules/database/__tests__/pg-error.test.ts +21 -0
  93. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
  94. package/src/modules/database/pg-error.ts +15 -0
  95. package/src/modules/workflow/workflow.service.ts +2 -1
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { PgDialect } from 'drizzle-orm/pg-core';
3
+ import type { SQL } from 'drizzle-orm';
2
4
  import type { Database } from '../../database/connection.js';
3
5
  import type { CoreConfig } from '../../../core-config.js';
4
6
  import { AuthService } from '../auth.service.js';
@@ -16,10 +18,19 @@ import { RECOVERY_BOT_EMAIL } from '../../workflow/recovery-bot.js';
16
18
  /**
17
19
  * Drizzle chain stub, as in `auth.service.test.ts`: each db.select() /
18
20
  * insert() / update() consumes the next queued result; awaiting any point of
19
- * the chain resolves it. Records what was inserted and set.
21
+ * the chain resolves it. Records what was inserted and set, and, in order,
22
+ * every write and lock (`execute`) with the handle it went through: `db`
23
+ * itself, or the `tx` a transaction hands its callback (a different object,
24
+ * between `begin` and `commit`), so a lock taken outside the transaction
25
+ * shows up as `db:`.
20
26
  */
21
27
  function makeFakeDb(queue: unknown[]) {
22
- const captured: { values: Record<string, unknown>[]; set: Record<string, unknown>[] } = { values: [], set: [] };
28
+ const captured: { values: Record<string, unknown>[]; set: Record<string, unknown>[]; order: string[] } = {
29
+ values: [],
30
+ set: [],
31
+ order: [],
32
+ };
33
+ const dialect = new PgDialect();
23
34
  function nextChain(): unknown {
24
35
  const result = queue.shift();
25
36
  const chain: Record<string, unknown> = {};
@@ -42,12 +53,27 @@ function makeFakeDb(queue: unknown[]) {
42
53
  });
43
54
  return chain;
44
55
  }
45
- const db = {
46
- insert: vi.fn(() => nextChain()),
56
+ const handle = (tag: 'db' | 'tx') => ({
57
+ insert: vi.fn(() => (captured.order.push(`${tag}:insert`), nextChain())),
47
58
  select: vi.fn(() => nextChain()),
48
- update: vi.fn(() => nextChain()),
49
- } as unknown as Database;
50
- return { db, captured };
59
+ update: vi.fn(() => (captured.order.push(`${tag}:update`), nextChain())),
60
+ execute: vi.fn(async (query: SQL) => {
61
+ const { sql, params } = dialect.sqlToQuery(query);
62
+ captured.order.push(`${tag}:${sql} ${JSON.stringify(params)}`);
63
+ return [];
64
+ }),
65
+ });
66
+ const tx = handle('tx');
67
+ const db = {
68
+ ...handle('db'),
69
+ transaction: async <T,>(cb: (t: typeof tx) => Promise<T>) => {
70
+ captured.order.push('begin');
71
+ const out = await cb(tx);
72
+ captured.order.push('commit');
73
+ return out;
74
+ },
75
+ };
76
+ return { db: db as unknown as Database, captured };
51
77
  }
52
78
 
53
79
  function makeConfig(over: Partial<CoreConfig> = {}): CoreConfig {
@@ -156,6 +182,64 @@ describe('AuthService.isActive', () => {
156
182
  });
157
183
  });
158
184
 
185
+ // An earlier version could switch off an owner without a server-held
186
+ // password; nothing in the product can switch them back on for a workspace
187
+ // whose only admin they are. Their way in clears it.
188
+ describe('an owner switched off by an earlier version', () => {
189
+ const OWNER_OFF = { ...OFF, id: 'owner-1', email: 'root@example.com' };
190
+
191
+ for (const [label, adminPassword] of [
192
+ ['with ADMIN_PASSWORD', 'sup3r-secret'],
193
+ ['without ADMIN_PASSWORD (the cloud)', ''],
194
+ ] as const) {
195
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword });
196
+
197
+ it(`signs in with single sign-on, and the switch-off is cleared on the row, ${label}`, async () => {
198
+ const { db, captured } = makeFakeDb([[OWNER_OFF], []]);
199
+ const { user } = await new AuthService(db, config).loginWithSso('root@example.com', 'Root');
200
+ expect(user.id).toBe('owner-1');
201
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
202
+ });
203
+
204
+ it(`is let in as an embed identity, ${label}`, async () => {
205
+ const { db, captured } = makeFakeDb([[OWNER_OFF], []]);
206
+ await expect(new AuthService(db, config).getOrCreateByEmail('root@example.com')).resolves.toMatchObject({ id: 'owner-1' });
207
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
208
+ });
209
+
210
+ it(`keeps a session they already hold, ${label}`, async () => {
211
+ const { db, captured } = makeFakeDb([[{ id: 'owner-1', email: 'root@example.com', deactivatedAt: new Date() }], []]);
212
+ expect(await new AuthService(db, config).isActive('owner-1')).toBe(true);
213
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
214
+ });
215
+ }
216
+
217
+ it('signs in with a password they hold on the row, without ADMIN_PASSWORD', async () => {
218
+ const passwordHash = await hashPassword('a-long-enough-password');
219
+ const { db, captured } = makeFakeDb([[{ ...OWNER_OFF, passwordHash }], []]);
220
+ const svc = new AuthService(db, makeConfig({ adminEmail: 'root@example.com' }));
221
+ const { user } = await svc.loginWithPassword('root@example.com', 'a-long-enough-password');
222
+ expect(user.id).toBe('owner-1');
223
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
224
+ });
225
+
226
+ it('signs in with ADMIN_PASSWORD, and the switch-off is cleared on the row', async () => {
227
+ // The lookup, the upsert, then the clearing update.
228
+ const { db, captured } = makeFakeDb([[OWNER_OFF], [OWNER_OFF], []]);
229
+ const svc = new AuthService(db, makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' }));
230
+ const { user } = await svc.loginWithPassword('root@example.com', 'sup3r-secret');
231
+ expect(user.id).toBe('owner-1');
232
+ expect(captured.set).toEqual([expect.objectContaining({ deactivatedAt: null })]);
233
+ });
234
+
235
+ it('leaves a switched-off account that is not an owner switched off', async () => {
236
+ const { db, captured } = makeFakeDb([[OFF]]);
237
+ const svc = new AuthService(db, makeConfig({ adminEmail: 'root@example.com' }));
238
+ await expect(svc.loginWithSso('alice@example.com', 'Alice')).rejects.toBeInstanceOf(AccountDeactivatedError);
239
+ expect(captured.set).toEqual([]);
240
+ });
241
+ });
242
+
159
243
  describe('AuthService.deactivate', () => {
160
244
  it('records when the account was switched off', async () => {
161
245
  const { db, captured } = makeFakeDb([[ROW], []]);
@@ -163,22 +247,59 @@ describe('AuthService.deactivate', () => {
163
247
  expect(captured.set[0].deactivatedAt).toBeInstanceOf(Date);
164
248
  });
165
249
 
250
+ it('switches off under the address switch lock, taken before the update', async () => {
251
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: 'Alice@Example.com' }], []]);
252
+ await new AuthService(db, makeConfig()).deactivate(ROW.id);
253
+ expect(captured.order).toEqual([
254
+ 'begin',
255
+ // The wait for the lock is bounded, inside the transaction.
256
+ `tx:select set_config('lock_timeout', $1, true) ["15000"]`,
257
+ 'tx:select pg_advisory_xact_lock(hashtext($1)) ["account-switch:alice@example.com"]',
258
+ 'tx:update',
259
+ 'commit',
260
+ ]);
261
+ });
262
+
166
263
  it('answers false for an account that does not exist', async () => {
167
264
  const { db } = makeFakeDb([[]]);
168
265
  expect(await new AuthService(db, makeConfig()).deactivate('nobody')).toBe(false);
169
266
  });
170
267
 
171
- it('refuses the deployment admin, whose environment password is the way back in', async () => {
172
- const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
173
- const { db } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
174
- await expect(new AuthService(db, config).deactivate(ROW.id)).rejects.toBeInstanceOf(AccountChangeRefusedError);
175
- expect(vi.mocked(db.update)).not.toHaveBeenCalled();
268
+ // With a server-held owner password (self-hosted) and without one (the
269
+ // cloud's configuration): the owner is never switched off, and the refusal
270
+ // comes before any lock or write (`captured.order` stays empty).
271
+ for (const [label, adminPassword] of [
272
+ ['with ADMIN_PASSWORD', 'sup3r-secret'],
273
+ ['without ADMIN_PASSWORD (the cloud)', ''],
274
+ ] as const) {
275
+ it(`refuses the owner, ${label}, and says why`, async () => {
276
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword });
277
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
278
+ const refusal = new AuthService(db, config).deactivate(ROW.id);
279
+ await expect(refusal).rejects.toBeInstanceOf(AccountChangeRefusedError);
280
+ await expect(refusal).rejects.toThrow("The owner can't be switched off.");
281
+ expect(captured.order).toEqual([]);
282
+ });
283
+
284
+ it(`still switches off an account that is not the owner, ${label}`, async () => {
285
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword });
286
+ const { db, captured } = makeFakeDb([[ROW], []]);
287
+ expect(await new AuthService(db, config).deactivate(ROW.id)).toBe(true);
288
+ expect(captured.set[0].deactivatedAt).toBeInstanceOf(Date);
289
+ });
290
+ }
291
+
292
+ it('refuses the owner even with password sign-in switched off', async () => {
293
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret', loginPasswordEnabled: false });
294
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
295
+ await expect(new AuthService(db, config).deactivate(ROW.id)).rejects.toThrow("The owner can't be switched off.");
296
+ expect(captured.order).toEqual([]);
176
297
  });
177
298
 
178
299
  it('refuses the accounts the platform runs its own work as', async () => {
179
- const { db } = makeFakeDb([[{ ...ROW, email: RECOVERY_BOT_EMAIL }]]);
300
+ const { db, captured } = makeFakeDb([[{ ...ROW, email: RECOVERY_BOT_EMAIL }]]);
180
301
  await expect(new AuthService(db, makeConfig()).deactivate(ROW.id)).rejects.toThrow('platform itself');
181
- expect(vi.mocked(db.update)).not.toHaveBeenCalled();
302
+ expect(captured.order).toEqual([]);
182
303
  });
183
304
  });
184
305
 
@@ -221,17 +342,25 @@ describe('a first sign-in the port would rather keep waiting', () => {
221
342
  expect(captured.values).toHaveLength(1);
222
343
  expect(captured.values[0]).toMatchObject({ email: 'new@example.com' });
223
344
  expect(captured.values[0].deactivatedAt).toBeInstanceOf(Date);
345
+ // Created switched off, so under the address's switch lock.
346
+ expect(captured.order).toEqual([
347
+ 'begin',
348
+ `tx:select set_config('lock_timeout', $1, true) ["15000"]`,
349
+ 'tx:select pg_advisory_xact_lock(hashtext($1)) ["account-switch:new@example.com"]',
350
+ 'tx:insert',
351
+ 'commit',
352
+ ]);
224
353
  });
225
354
 
226
355
  it('is a plain refusal when an admin creates the account', async () => {
227
356
  const { port } = recordingPort(waiting);
228
- const { db } = makeFakeDb([[]]);
357
+ const { db, captured } = makeFakeDb([[]]);
229
358
  const err = await new AuthService(db, makeConfig(), port)
230
359
  .createAccount('new@example.com', 'New')
231
360
  .catch((e: unknown) => e);
232
361
  expect(err).toBeInstanceOf(AccountAdmissionRefusedError);
233
362
  expect((err as AccountAdmissionRefusedError).waitingForAdmin).toBe(false);
234
- expect(vi.mocked(db.insert)).not.toHaveBeenCalled();
363
+ expect(captured.order).toEqual([]);
235
364
  });
236
365
  });
237
366
 
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AccountHooks, type AccountInvitedEvent } from '../account-hooks.js';
3
+
4
+ const EVENT: AccountInvitedEvent = {
5
+ account: { id: 'u-lena', email: 'lena@acme.test', name: 'lena' },
6
+ invitedBy: { id: 'u-dana', email: 'dana@acme.test', name: 'Dana Admin' },
7
+ passwordSet: true,
8
+ };
9
+
10
+ describe('AccountHooks: an account was invited', () => {
11
+ it('notifies nobody when nothing is registered, and says so', async () => {
12
+ expect(await new AccountHooks().runAccountInvited(EVENT)).toEqual([]);
13
+ });
14
+
15
+ it('runs every listener in registration order, with the event, and collects the channels that reached the person', async () => {
16
+ const hooks = new AccountHooks();
17
+ const seen: string[] = [];
18
+ hooks.onAccountInvited(async (event) => {
19
+ seen.push(`mail:${event.account.email}:${event.passwordSet}`);
20
+ return 'email';
21
+ });
22
+ hooks.onAccountInvited(async () => {
23
+ seen.push('chat');
24
+ return null;
25
+ });
26
+ hooks.onAccountInvited(async () => {
27
+ seen.push('sms');
28
+ return 'sms';
29
+ });
30
+ expect(await hooks.runAccountInvited(EVENT)).toEqual(['email', 'sms']);
31
+ expect(seen).toEqual(['mail:lena@acme.test:true', 'chat', 'sms']);
32
+ });
33
+
34
+ it('a listener that throws counts as not having notified, and stops nothing', async () => {
35
+ const hooks = new AccountHooks();
36
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
37
+ try {
38
+ hooks.onAccountInvited(async () => {
39
+ throw new Error('smtp down');
40
+ });
41
+ hooks.onAccountInvited(async () => 'email');
42
+ expect(await hooks.runAccountInvited(EVENT)).toEqual(['email']);
43
+ // Logged, so a failing mailer is diagnosable from the server's log.
44
+ expect(error).toHaveBeenCalledTimes(1);
45
+ expect(String(error.mock.calls[0]![0])).toContain('an account-invited listener failed');
46
+ } finally {
47
+ error.mockRestore();
48
+ }
49
+ });
50
+
51
+ it('an empty channel name is no channel: it counts as not having notified', async () => {
52
+ const hooks = new AccountHooks();
53
+ hooks.onAccountInvited(async () => '');
54
+ hooks.onAccountInvited(async () => 'email');
55
+ expect(await hooks.runAccountInvited(EVENT)).toEqual(['email']);
56
+ });
57
+ });
@@ -0,0 +1,78 @@
1
+ import { afterAll, describe, expect, it } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
3
+ import pg from 'pg';
4
+ import { closeDb, createDb, type Database } from '../../database/connection.js';
5
+ import { runCoreMigrations } from '../../database/migrate.js';
6
+ import { coreMigrationsDir } from '../../../assets.js';
7
+ import { PG_ADMIN_URL, PG_TEST_TIMEOUT, dropScratchDatabases, scratchDatabaseUrl } from '../../../__tests__/pg-scratch.js';
8
+ import { WorkflowDomainError } from '../../../shared/domain-errors.js';
9
+ import { AuthService } from '../auth.service.js';
10
+ import { ACCOUNT_BUSY_MESSAGE, takeAccountSwitchLock } from '../account-switch-lock.js';
11
+
12
+ /**
13
+ * The account switch lock's promise, against a real Postgres (see
14
+ * `pg-scratch.ts`): a wait that runs out is a 409 `account-busy`, never the
15
+ * driver's error. Drizzle wraps that error (the SQLSTATE `55P03` is on its
16
+ * cause), which no stub of the driver shows, so the contention is real here:
17
+ * another connection holds the address's lock while the lock is asked for.
18
+ */
19
+ const KEY = randomBytes(32).toString('base64');
20
+ const open: Database[] = [];
21
+ const holders: pg.Client[] = [];
22
+
23
+ async function migratedDatabase(): Promise<{ db: Database; url: string }> {
24
+ const url = await scratchDatabaseUrl('hexis_switch');
25
+ const db = createDb(url, { piiKey: KEY });
26
+ open.push(db);
27
+ await runCoreMigrations(db, coreMigrationsDir());
28
+ return { db, url };
29
+ }
30
+
31
+ /** Another connection holding `email`'s switch lock in an open transaction, as a promotion mid-write does. */
32
+ async function holdLock(url: string, email: string): Promise<pg.Client> {
33
+ const holder = new pg.Client({ connectionString: url });
34
+ await holder.connect();
35
+ holders.push(holder);
36
+ await holder.query('begin');
37
+ await holder.query('select pg_advisory_xact_lock(hashtext($1))', [`account-switch:${email}`]);
38
+ return holder;
39
+ }
40
+
41
+ describe.skipIf(!PG_ADMIN_URL)('the account switch lock under contention, on a real Postgres', () => {
42
+ afterAll(async () => {
43
+ for (const holder of holders) await holder.end().catch(() => undefined);
44
+ for (const db of open) await closeDb(db);
45
+ await dropScratchDatabases();
46
+ }, PG_TEST_TIMEOUT);
47
+
48
+ it(
49
+ "answers a wait that runs out as the 409 account-busy, through Drizzle's wrapping",
50
+ async () => {
51
+ const { db, url } = await migratedDatabase();
52
+ const holder = await holdLock(url, 'alice@acme.test');
53
+ const refusal = db.transaction((tx) => takeAccountSwitchLock(tx, 'Alice@Acme.test', 200));
54
+ await expect(refusal).rejects.toBeInstanceOf(WorkflowDomainError);
55
+ await expect(refusal).rejects.toMatchObject({ status: 409, message: ACCOUNT_BUSY_MESSAGE, payload: { kind: 'account-busy' } });
56
+ // Released, the lock is taken at once.
57
+ await holder.query('commit');
58
+ await expect(db.transaction((tx) => takeAccountSwitchLock(tx, 'alice@acme.test', 200))).resolves.toBeUndefined();
59
+ },
60
+ PG_TEST_TIMEOUT,
61
+ );
62
+
63
+ it(
64
+ 'switching an account off while a promotion holds its lock is refused as account-busy, and changes nothing',
65
+ async () => {
66
+ const { db, url } = await migratedDatabase();
67
+ const auth = new AuthService(db, { jwtSecret: 'test-jwt-secret', adminEmail: '', adminPassword: '', allowedEmailDomains: [] });
68
+ const { user } = await auth.createAccountWithoutPassword('lee@acme.test', 'Lee');
69
+ const holder = await holdLock(url, 'lee@acme.test');
70
+ // The real wait (15 s): what an admin's switch-off meets.
71
+ await expect(auth.deactivate(user.id)).rejects.toMatchObject({ status: 409, payload: { kind: 'account-busy' } });
72
+ await holder.query('commit');
73
+ const [lee] = (await auth.listAccounts()).filter((a) => a.email === 'lee@acme.test');
74
+ expect(lee?.deactivatedAt).toBeNull();
75
+ },
76
+ PG_TEST_TIMEOUT,
77
+ );
78
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { PgDialect } from 'drizzle-orm/pg-core';
3
+ import type { SQL } from 'drizzle-orm';
4
+ import { WorkflowDomainError } from '../../../shared/domain-errors.js';
5
+ import { ACCOUNT_BUSY_MESSAGE, ACCOUNT_SWITCH_LOCK_WAIT_MS, takeAccountSwitchLock } from '../account-switch-lock.js';
6
+
7
+ /** A transaction handle that records what it ran, and fails the lock statement when told to. */
8
+ function fakeTx(failLockWith?: { code: string; message: string }) {
9
+ const dialect = new PgDialect();
10
+ const ran: string[] = [];
11
+ const execute = vi.fn(async (query: SQL) => {
12
+ const { sql, params } = dialect.sqlToQuery(query);
13
+ ran.push(`${sql} ${JSON.stringify(params)}`);
14
+ if (failLockWith && sql.includes('pg_advisory_xact_lock')) {
15
+ throw Object.assign(new Error(failLockWith.message), { code: failLockWith.code });
16
+ }
17
+ return [];
18
+ });
19
+ return { tx: { execute }, ran };
20
+ }
21
+
22
+ describe('takeAccountSwitchLock', () => {
23
+ it('bounds the wait with a transaction-local lock_timeout, set before the lock is asked for', async () => {
24
+ const { tx, ran } = fakeTx();
25
+ await takeAccountSwitchLock(tx, 'Alice@Example.com');
26
+ expect(ran).toEqual([
27
+ `select set_config('lock_timeout', $1, true) ["${ACCOUNT_SWITCH_LOCK_WAIT_MS}"]`,
28
+ 'select pg_advisory_xact_lock(hashtext($1)) ["account-switch:alice@example.com"]',
29
+ ]);
30
+ });
31
+
32
+ it('answers a wait that ran out as a 409 account-busy, in the words the admin is told', async () => {
33
+ const { tx } = fakeTx({ code: '55P03', message: 'canceling statement due to lock timeout' });
34
+ const refusal = takeAccountSwitchLock(tx, 'alice@example.com');
35
+ await expect(refusal).rejects.toBeInstanceOf(WorkflowDomainError);
36
+ await expect(refusal).rejects.toMatchObject({ status: 409, message: ACCOUNT_BUSY_MESSAGE, payload: { kind: 'account-busy' } });
37
+ });
38
+
39
+ it('lets any other failure of the lock through unchanged', async () => {
40
+ const { tx } = fakeTx({ code: '57P01', message: 'terminating connection due to administrator command' });
41
+ await expect(takeAccountSwitchLock(tx, 'alice@example.com')).rejects.toThrow('terminating connection');
42
+ });
43
+ });