@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
@@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import express from 'express';
3
3
  import type { Server } from 'node:http';
4
4
  import { createAccountRoutes } from '../account.routes.js';
5
+ import type { AccountInvitedEvent } from '../account-hooks.js';
5
6
  import { AuthService } from '../auth.service.js';
6
7
  import { AccountAdmissionRefusedError, AccountChangeRefusedError } from '../account-admission.js';
8
+ import { WorkflowDomainError } from '../../../shared/domain-errors.js';
7
9
  import { hashPassword } from '../password-hash.js';
8
10
  import type { Database } from '../../database/connection.js';
9
11
  import type { IAdminAccessService } from '../../admin/admin.interface.js';
@@ -13,8 +15,18 @@ const authService = {
13
15
  { id: 'u1', email: 'a@example.com', name: 'A', hasPassword: true, createdAt: new Date() },
14
16
  ]),
15
17
  createAccount: vi.fn(async (email: string) => ({ id: 'u2', email, name: 'B' })),
18
+ createAccountWithoutPassword: vi.fn(async (email: string) => ({ user: { id: 'u2', email, name: 'B' }, created: true })),
19
+ createAccountWithStartingPassword: vi.fn(async (email: string) => ({
20
+ user: { id: 'u2', email, name: 'B' },
21
+ created: false,
22
+ passwordSet: false,
23
+ deactivated: false,
24
+ })),
16
25
  deactivate: vi.fn(async (userId: string) => userId !== 'missing'),
17
26
  reactivate: vi.fn(async (userId: string) => userId !== 'missing'),
27
+ getUserById: vi.fn(async (id: string) => (id === 'missing' ? null : { id, email: `${id}@example.com`, name: id })),
28
+ isOwnerEmail: vi.fn((email: string) => email.trim().toLowerCase() === 'root@example.com'),
29
+ assertDeletable: vi.fn(() => {}),
18
30
  } as unknown as AuthService;
19
31
 
20
32
  // Satisfies the route's narrow Pick<IAccountErasureService, 'eraseUser'>
@@ -23,6 +35,11 @@ const accountErasure = {
23
35
  eraseUser: vi.fn<(userId: string, opts?: { erasureId?: string }) => Promise<boolean>>(async () => true),
24
36
  };
25
37
 
38
+ /** The invite listeners: nobody registered unless a test says so (see "an invite tells the listeners"). */
39
+ const accountHooks = {
40
+ runAccountInvited: vi.fn<(event: AccountInvitedEvent) => Promise<string[]>>(async () => []),
41
+ };
42
+
26
43
  function makeApp(opts: { admin: boolean; email?: string }) {
27
44
  const adminAccess: IAdminAccessService = {
28
45
  isAdmin: vi.fn(async () => opts.admin),
@@ -36,7 +53,7 @@ function makeApp(opts: { admin: boolean; email?: string }) {
36
53
  req.userEmail = opts.email ?? 'caller@example.com';
37
54
  next();
38
55
  });
39
- app.use('/api', createAccountRoutes(authService, adminAccess, accountErasure));
56
+ app.use('/api', createAccountRoutes(authService, adminAccess, accountErasure, accountHooks));
40
57
  return app;
41
58
  }
42
59
 
@@ -88,6 +105,155 @@ describe('account routes — admin gate', () => {
88
105
  expect(authService.createAccount).toHaveBeenCalledWith('b@example.com', 'B', 'long-enough-pw');
89
106
  });
90
107
 
108
+ it('gives an invite\'s starting password through the keep-existing path, and says whether it was set', async () => {
109
+ const base = await listen(makeApp({ admin: true }));
110
+ const create = await fetch(`${base}/api/admin/accounts`, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ email: 'b@example.com', password: 'long-enough-pw', keepExistingPassword: true }),
114
+ });
115
+ expect(create.status).toBe(201);
116
+ expect(await create.json()).toEqual({
117
+ id: 'u2',
118
+ email: 'b@example.com',
119
+ name: 'B',
120
+ passwordSet: false,
121
+ deactivated: false,
122
+ notified: [],
123
+ });
124
+ expect(authService.createAccountWithStartingPassword).toHaveBeenCalledWith('b@example.com', 'long-enough-pw');
125
+ expect(authService.createAccount).not.toHaveBeenCalled();
126
+ // The account was already there: nobody is told of an invitation.
127
+ expect(accountHooks.runAccountInvited).not.toHaveBeenCalled();
128
+
129
+ const without = await fetch(`${base}/api/admin/accounts`, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ email: 'b@example.com', keepExistingPassword: true }),
133
+ });
134
+ expect(without.status).toBe(400);
135
+
136
+ // Anything but a boolean fails closed — never the overwriting path.
137
+ for (const keepExistingPassword of ['true', 1, null, {}]) {
138
+ const odd = await fetch(`${base}/api/admin/accounts`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ email: 'b@example.com', password: 'long-enough-pw', keepExistingPassword }),
142
+ });
143
+ expect(odd.status).toBe(400);
144
+ expect(await odd.json()).toEqual({ error: 'keepExistingPassword must be a boolean' });
145
+ }
146
+ expect(authService.createAccount).not.toHaveBeenCalled();
147
+ });
148
+
149
+ describe('an invite tells the listeners, and the answer says who was told', () => {
150
+ afterEach(() => {
151
+ vi.mocked(accountHooks.runAccountInvited).mockReset().mockResolvedValue([]);
152
+ vi.mocked(authService.createAccountWithStartingPassword).mockClear();
153
+ vi.mocked(authService.createAccountWithoutPassword).mockClear();
154
+ });
155
+
156
+ it('a NEW account with a starting password: once, with the account, the admin and the fact of the password, never the password', async () => {
157
+ vi.mocked(authService.createAccountWithStartingPassword).mockResolvedValueOnce({
158
+ user: { id: 'u-new', email: 'new@example.com', name: 'new' },
159
+ created: true,
160
+ passwordSet: true,
161
+ deactivated: false,
162
+ });
163
+ vi.mocked(accountHooks.runAccountInvited).mockResolvedValueOnce(['email']);
164
+ const base = await listen(makeApp({ admin: true }));
165
+ const res = await fetch(`${base}/api/admin/accounts`, {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({ email: 'new@example.com', password: 'long-enough-pw', keepExistingPassword: true }),
169
+ });
170
+ expect(res.status).toBe(201);
171
+ expect(await res.json()).toEqual({
172
+ id: 'u-new',
173
+ email: 'new@example.com',
174
+ name: 'new',
175
+ passwordSet: true,
176
+ deactivated: false,
177
+ notified: ['email'],
178
+ });
179
+ expect(accountHooks.runAccountInvited).toHaveBeenCalledTimes(1);
180
+ const [event] = vi.mocked(accountHooks.runAccountInvited).mock.calls[0]!;
181
+ expect(event).toEqual({
182
+ account: { id: 'u-new', email: 'new@example.com', name: 'new' },
183
+ // The caller, as the route knows them: `getUserById(req.userId)`.
184
+ invitedBy: { id: 'u1', email: 'u1@example.com', name: 'u1' },
185
+ passwordSet: true,
186
+ });
187
+ expect(JSON.stringify(event)).not.toContain('long-enough-pw');
188
+ });
189
+
190
+ it('when the inviting admin cannot be read, the invite still goes out, naming them by address', async () => {
191
+ vi.mocked(authService.getUserById).mockRejectedValueOnce(new Error('users table is away'));
192
+ vi.mocked(accountHooks.runAccountInvited).mockResolvedValueOnce(['email']);
193
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
194
+ try {
195
+ const base = await listen(makeApp({ admin: true }));
196
+ const res = await fetch(`${base}/api/admin/accounts`, {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json' },
199
+ body: JSON.stringify({ email: 'b@example.com' }),
200
+ });
201
+ expect(res.status).toBe(201);
202
+ expect(await res.json()).toEqual({ id: 'u2', email: 'b@example.com', name: 'B', notified: ['email'] });
203
+ expect(accountHooks.runAccountInvited).toHaveBeenCalledWith(
204
+ expect.objectContaining({ invitedBy: { id: 'u1', email: 'caller@example.com', name: '' } }),
205
+ );
206
+ } finally {
207
+ warn.mockRestore();
208
+ }
209
+ });
210
+
211
+ it('a NEW account for single sign-on (no password) goes through the same seam, with passwordSet false', async () => {
212
+ vi.mocked(accountHooks.runAccountInvited).mockResolvedValueOnce(['email', 'chat']);
213
+ const base = await listen(makeApp({ admin: true }));
214
+ const res = await fetch(`${base}/api/admin/accounts`, {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ email: 'b@example.com', name: 'B' }),
218
+ });
219
+ expect(res.status).toBe(201);
220
+ expect(await res.json()).toEqual({ id: 'u2', email: 'b@example.com', name: 'B', notified: ['email', 'chat'] });
221
+ expect(authService.createAccountWithoutPassword).toHaveBeenCalledWith('b@example.com', 'B');
222
+ expect(authService.createAccount).not.toHaveBeenCalled();
223
+ expect(accountHooks.runAccountInvited).toHaveBeenCalledWith(
224
+ expect.objectContaining({ account: { id: 'u2', email: 'b@example.com', name: 'B' }, passwordSet: false }),
225
+ );
226
+ });
227
+
228
+ it('an account that was already there is not an invitation: the listeners are not run', async () => {
229
+ vi.mocked(authService.createAccountWithoutPassword).mockResolvedValueOnce({
230
+ user: { id: 'u2', email: 'b@example.com', name: 'B' },
231
+ created: false,
232
+ });
233
+ const base = await listen(makeApp({ admin: true }));
234
+ const res = await fetch(`${base}/api/admin/accounts`, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json' },
237
+ body: JSON.stringify({ email: 'b@example.com' }),
238
+ });
239
+ expect(res.status).toBe(201);
240
+ expect(await res.json()).toEqual({ id: 'u2', email: 'b@example.com', name: 'B', notified: [] });
241
+ expect(accountHooks.runAccountInvited).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it('a set or reset password (a password without keepExistingPassword) is not an invitation either', async () => {
245
+ const base = await listen(makeApp({ admin: true }));
246
+ const res = await fetch(`${base}/api/admin/accounts`, {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ email: 'b@example.com', password: 'long-enough-pw' }),
250
+ });
251
+ expect(res.status).toBe(201);
252
+ expect(await res.json()).toEqual({ id: 'u2', email: 'b@example.com', name: 'B' });
253
+ expect(accountHooks.runAccountInvited).not.toHaveBeenCalled();
254
+ });
255
+ });
256
+
91
257
  it('list reports hasPassword + isEnvAdmin and never carries a hash or password', async () => {
92
258
  const passwordHash = await hashPassword('stored-password-1');
93
259
  const rows = [
@@ -113,7 +279,7 @@ describe('account routes — admin gate', () => {
113
279
  req.userEmail = 'caller@example.com';
114
280
  next();
115
281
  });
116
- app.use('/api', createAccountRoutes(realAuth, { isAdmin: async () => true }, accountErasure));
282
+ app.use('/api', createAccountRoutes(realAuth, { isAdmin: async () => true }, accountErasure, accountHooks));
117
283
  const base = await listen(app);
118
284
 
119
285
  const res = await fetch(`${base}/api/admin/accounts`);
@@ -233,7 +399,7 @@ describe('account routes — removing the erased address from access files', ()
233
399
  req.userEmail = 'caller@example.com';
234
400
  next();
235
401
  });
236
- app.use('/api', createAccountRoutes(auth, { isAdmin: async () => true }, accountErasure, accessRemoval));
402
+ app.use('/api', createAccountRoutes(auth, { isAdmin: async () => true }, accountErasure, accountHooks, accessRemoval));
237
403
  return { app, accessRemoval };
238
404
  }
239
405
 
@@ -375,12 +541,27 @@ describe('account routes — switching an account off and on', () => {
375
541
  expect(await res.json()).toEqual({ error: 'All 3 seats are taken', kind: 'admission' });
376
542
  });
377
543
 
378
- it('answers a refused deactivation (the deployment admin, say) with 400 and the reason', async () => {
379
- vi.mocked(authService.deactivate).mockRejectedValueOnce(new AccountChangeRefusedError('The deployment admin cannot be switched off'));
544
+ it('answers a switch lock that timed out with the 409 account-busy the lock promises, not a 500', async () => {
545
+ vi.mocked(authService.deactivate).mockRejectedValueOnce(
546
+ new WorkflowDomainError('Another change to this account is still being saved; try again in a moment.', 409, {
547
+ kind: 'account-busy',
548
+ }),
549
+ );
550
+ const base = await listen(makeApp({ admin: true }));
551
+ const res = await post(base, 'u2/deactivate');
552
+ expect(res.status).toBe(409);
553
+ expect(await res.json()).toEqual({
554
+ error: 'Another change to this account is still being saved; try again in a moment.',
555
+ kind: 'account-busy',
556
+ });
557
+ });
558
+
559
+ it('answers a refused deactivation (the owner, say) with 400 and the reason', async () => {
560
+ vi.mocked(authService.deactivate).mockRejectedValueOnce(new AccountChangeRefusedError("The owner can't be switched off."));
380
561
  const base = await listen(makeApp({ admin: true }));
381
562
  const res = await post(base, 'u2/deactivate');
382
563
  expect(res.status).toBe(400);
383
- expect(((await res.json()) as { error: string }).error).toContain('deployment admin');
564
+ expect(((await res.json()) as { error: string }).error).toBe("The owner can't be switched off.");
384
565
  });
385
566
 
386
567
  it('answers an unexpected failure with a 500 that names nothing of it', async () => {
@@ -409,3 +590,198 @@ describe('account routes — switching an account off and on', () => {
409
590
  expect(authService.createAccount).not.toHaveBeenCalled();
410
591
  });
411
592
  });
593
+
594
+ /**
595
+ * The owner (`ADMIN_EMAIL`) through the accounts API, against a real
596
+ * AuthService, on a deployment with a server-held owner password
597
+ * (self-hosted) and one without (the cloud's configuration). The refusals
598
+ * live on the server, so they hold for every caller, not only the page.
599
+ */
600
+ describe('account routes — the owner cannot be locked out', () => {
601
+ const OWNER_ROW = {
602
+ id: 'owner',
603
+ email: 'root@example.com',
604
+ name: 'Root',
605
+ avatarUrl: null,
606
+ onboardingDone: true,
607
+ passwordHash: null,
608
+ deactivatedAt: null,
609
+ createdAt: new Date(),
610
+ };
611
+ const OTHER_ROW = { ...OWNER_ROW, id: 'u2', email: 'lee@example.com', name: 'Lee' };
612
+
613
+ /**
614
+ * Every read answers with `row`; every write is recorded. A switch-off runs
615
+ * in a transaction that first takes the address's switch lock (see
616
+ * account-switch-lock), so the handle a transaction hands out reads, locks
617
+ * and writes like the plain one.
618
+ */
619
+ function fakeDb(row: typeof OWNER_ROW) {
620
+ const updates: unknown[] = [];
621
+ const chain = (result: unknown): Record<string, unknown> => {
622
+ const c: Record<string, unknown> = {};
623
+ for (const k of ['from', 'where', 'limit', 'set', 'values', 'returning', 'onConflictDoUpdate']) c[k] = () => c;
624
+ c.then = (ok: (v: unknown) => unknown, ko: (e: unknown) => unknown) => Promise.resolve(result).then(ok, ko);
625
+ return c;
626
+ };
627
+ const handle = {
628
+ select: () => chain([row]),
629
+ update: () => {
630
+ updates.push(row.id);
631
+ return chain([]);
632
+ },
633
+ execute: async () => [],
634
+ };
635
+ const db = {
636
+ ...handle,
637
+ transaction: async <T,>(cb: (tx: typeof handle) => Promise<T>) => cb(handle),
638
+ } as unknown as Database;
639
+ return { db, updates };
640
+ }
641
+
642
+ function makeOwnerApp(adminPassword: string, row: typeof OWNER_ROW, opts: { loginPasswordEnabled?: boolean } = {}) {
643
+ const { db, updates } = fakeDb(row);
644
+ const realAuth = new AuthService(db, {
645
+ jwtSecret: 'test-jwt-secret',
646
+ adminEmail: 'root@example.com',
647
+ adminPassword,
648
+ allowedEmailDomains: [],
649
+ loginPasswordEnabled: opts.loginPasswordEnabled,
650
+ });
651
+ const app = express();
652
+ app.use(express.json());
653
+ app.use((req, _res, next) => {
654
+ req.userId = 'second-admin';
655
+ req.userEmail = 'second-admin@example.com';
656
+ next();
657
+ });
658
+ app.use('/api', createAccountRoutes(realAuth, { isAdmin: async () => true }, accountErasure, accountHooks));
659
+ return { app, updates };
660
+ }
661
+
662
+ const CONFIGS = [
663
+ ['with a server-held owner password', 'sup3r-secret'],
664
+ ['without one (the cloud)', ''],
665
+ ] as const;
666
+
667
+ for (const [label, adminPassword] of CONFIGS) {
668
+ it(`refuses to switch the owner off, ${label}, and says why`, async () => {
669
+ const { app, updates } = makeOwnerApp(adminPassword, OWNER_ROW);
670
+ const base = await listen(app);
671
+ const res = await fetch(`${base}/api/admin/accounts/owner/deactivate`, { method: 'POST' });
672
+ expect(res.status).toBe(400);
673
+ expect(await res.json()).toEqual({ error: "The owner can't be switched off." });
674
+ expect(updates).toEqual([]);
675
+ });
676
+
677
+ it(`switches off an account that is not the owner as today, ${label}`, async () => {
678
+ const { app, updates } = makeOwnerApp(adminPassword, OTHER_ROW);
679
+ const base = await listen(app);
680
+ const res = await fetch(`${base}/api/admin/accounts/u2/deactivate`, { method: 'POST' });
681
+ expect(res.status).toBe(204);
682
+ expect(updates).toEqual(['u2']);
683
+ });
684
+
685
+ it(`deletes an account that is not the owner as today, ${label}`, async () => {
686
+ const { app } = makeOwnerApp(adminPassword, OTHER_ROW);
687
+ const base = await listen(app);
688
+ const res = await fetch(`${base}/api/admin/accounts/u2`, { method: 'DELETE' });
689
+ expect(res.status).toBe(204);
690
+ expect(accountErasure.eraseUser).toHaveBeenLastCalledWith('u2');
691
+ });
692
+
693
+ it(`lists the owner as one, ${label}`, async () => {
694
+ const { app } = makeOwnerApp(adminPassword, OWNER_ROW);
695
+ const base = await listen(app);
696
+ const body = (await (await fetch(`${base}/api/admin/accounts`)).json()) as {
697
+ accounts: Array<{ isOwner: boolean; ownerCanBeDeleted: boolean }>;
698
+ };
699
+ expect(body.accounts[0]).toMatchObject({ isOwner: true, ownerCanBeDeleted: adminPassword !== '' });
700
+ });
701
+
702
+ it(`refuses another admin setting the owner's password, ${label}`, async () => {
703
+ const { app } = makeOwnerApp(adminPassword, OWNER_ROW);
704
+ const base = await listen(app);
705
+ const res = await fetch(`${base}/api/admin/accounts`, {
706
+ method: 'POST',
707
+ headers: { 'Content-Type': 'application/json' },
708
+ body: JSON.stringify({ email: 'Root@Example.com', password: 'long-enough-pw' }),
709
+ });
710
+ expect(res.status).toBe(400);
711
+ expect(((await res.json()) as { error: string }).error).toMatch(/^The owner's password can't be set by another admin/);
712
+ });
713
+
714
+ it(`answers an email that is not text with a 400, not a crash, ${label}`, async () => {
715
+ const { app } = makeOwnerApp(adminPassword, OWNER_ROW);
716
+ const base = await listen(app);
717
+ const res = await fetch(`${base}/api/admin/accounts`, {
718
+ method: 'POST',
719
+ headers: { 'Content-Type': 'application/json' },
720
+ body: JSON.stringify({ email: { evil: true }, password: 'long-enough-pw' }),
721
+ });
722
+ expect(res.status).toBe(400);
723
+ });
724
+ }
725
+
726
+ it("lets another admin create the owner's account with an empty password, which means none", async () => {
727
+ const base = await listen(makeApp({ admin: true }));
728
+ const res = await fetch(`${base}/api/admin/accounts`, {
729
+ method: 'POST',
730
+ headers: { 'Content-Type': 'application/json' },
731
+ body: JSON.stringify({ email: 'root@example.com', password: '' }),
732
+ });
733
+ expect(res.status).toBe(201);
734
+ expect(authService.createAccountWithoutPassword).toHaveBeenCalledWith('root@example.com', undefined);
735
+ });
736
+
737
+ it("refuses to delete the owner's account where they could not sign back in (the cloud): nothing is erased", async () => {
738
+ const { app } = makeOwnerApp('', OWNER_ROW);
739
+ const base = await listen(app);
740
+ for (const query of ['', '?removeFromAccess=1']) {
741
+ const res = await fetch(`${base}/api/admin/accounts/owner${query}`, { method: 'DELETE' });
742
+ expect(res.status).toBe(400);
743
+ expect(await res.json()).toEqual({ error: "The owner's account can't be deleted: they would have no way to sign in." });
744
+ }
745
+ expect(accountErasure.eraseUser).not.toHaveBeenCalled();
746
+ });
747
+
748
+ it('refuses it too where the server holds a password but password sign-in is off', async () => {
749
+ const { app } = makeOwnerApp('sup3r-secret', OWNER_ROW, { loginPasswordEnabled: false });
750
+ const base = await listen(app);
751
+ const res = await fetch(`${base}/api/admin/accounts/owner`, { method: 'DELETE' });
752
+ expect(res.status).toBe(400);
753
+ expect(accountErasure.eraseUser).not.toHaveBeenCalled();
754
+ });
755
+
756
+ it("deletes the owner's account as today where they can sign back in with the server's password", async () => {
757
+ const { app } = makeOwnerApp('sup3r-secret', OWNER_ROW);
758
+ const base = await listen(app);
759
+ const res = await fetch(`${base}/api/admin/accounts/owner`, { method: 'DELETE' });
760
+ expect(res.status).toBe(204);
761
+ expect(accountErasure.eraseUser).toHaveBeenLastCalledWith('owner');
762
+ });
763
+
764
+ it("still lets the owner's account be (re)created without a password, so an admin can restore it", async () => {
765
+ vi.mocked(authService.createAccountWithoutPassword).mockClear();
766
+ const base = await listen(makeApp({ admin: true }));
767
+ const res = await fetch(`${base}/api/admin/accounts`, {
768
+ method: 'POST',
769
+ headers: { 'Content-Type': 'application/json' },
770
+ body: JSON.stringify({ email: 'root@example.com', name: 'Root' }),
771
+ });
772
+ expect(res.status).toBe(201);
773
+ expect(authService.createAccountWithoutPassword).toHaveBeenCalledWith('root@example.com', 'Root');
774
+ });
775
+
776
+ it('leaves the owner setting their own password through the same call to the service', async () => {
777
+ vi.mocked(authService.createAccount).mockClear();
778
+ const base = await listen(makeApp({ admin: true, email: 'root@example.com' }));
779
+ const res = await fetch(`${base}/api/admin/accounts`, {
780
+ method: 'POST',
781
+ headers: { 'Content-Type': 'application/json' },
782
+ body: JSON.stringify({ email: 'root@example.com', password: 'long-enough-pw' }),
783
+ });
784
+ expect(res.status).toBe(201);
785
+ expect(authService.createAccount).toHaveBeenCalledWith('root@example.com', undefined, 'long-enough-pw');
786
+ });
787
+ });
@@ -0,0 +1,69 @@
1
+ import { afterAll, describe, expect, it } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { closeDb, createDb, type Database } from '../../database/connection.js';
4
+ import { runCoreMigrations } from '../../database/migrate.js';
5
+ import { coreMigrationsDir } from '../../../assets.js';
6
+ import { PG_ADMIN_URL, PG_TEST_TIMEOUT, dropScratchDatabases, scratchDatabaseUrl } from '../../../__tests__/pg-scratch.js';
7
+ import { AuthService } from '../auth.service.js';
8
+
9
+ /**
10
+ * Whether an account is NEW is answered by the upsert's own returned row,
11
+ * through Postgres's `xmax = 0` (see `CREATED` in auth.service.ts): zero on
12
+ * a row the statement inserted, set on one its `ON CONFLICT DO UPDATE`
13
+ * touched. No stub can stand in for that, so this crosses a real Postgres
14
+ * (see `pg-scratch.ts`): the invite's notification hangs on the answer, and
15
+ * a wrong one would either tell nobody or tell the same person twice.
16
+ */
17
+ const KEY = randomBytes(32).toString('base64');
18
+ const open: Database[] = [];
19
+
20
+ async function migratedDatabase(): Promise<Database> {
21
+ const db = createDb(await scratchDatabaseUrl('hexis_created'), { piiKey: KEY });
22
+ open.push(db);
23
+ await runCoreMigrations(db, coreMigrationsDir());
24
+ return db;
25
+ }
26
+
27
+ describe.skipIf(!PG_ADMIN_URL)('whether an account is new, on a real Postgres', () => {
28
+ afterAll(async () => {
29
+ for (const db of open) await closeDb(db);
30
+ await dropScratchDatabases();
31
+ }, PG_TEST_TIMEOUT);
32
+
33
+ it(
34
+ 'says created once per address: the insert, and never the conflict-update after it',
35
+ async () => {
36
+ const db = await migratedDatabase();
37
+ const auth = new AuthService(db, { jwtSecret: 'test-jwt-secret', adminEmail: '', adminPassword: '', allowedEmailDomains: [] });
38
+
39
+ // Without a password: the first write inserts, the second only updates.
40
+ const first = await auth.createAccountWithoutPassword('Lena@Acme.test', 'Lena');
41
+ expect(first.created).toBe(true);
42
+ expect(first.user).toMatchObject({ email: 'lena@acme.test', name: 'Lena' });
43
+ const again = await auth.createAccountWithoutPassword('lena@acme.test', 'Lena Park');
44
+ expect(again.created).toBe(false);
45
+ expect(again.user).toMatchObject({ id: first.user.id, name: 'Lena Park' });
46
+
47
+ // With a starting password: a new address is created with it...
48
+ const tom = await auth.createAccountWithStartingPassword('tom@acme.test', 'welcome-to-acme');
49
+ expect(tom).toMatchObject({ created: true, passwordSet: true, deactivated: false });
50
+ // ...an account that has a password keeps it, and is not new...
51
+ const tomAgain = await auth.createAccountWithStartingPassword('tom@acme.test', 'another-password');
52
+ expect(tomAgain).toMatchObject({ created: false, passwordSet: false, deactivated: false });
53
+ expect(tomAgain.user.id).toBe(tom.user.id);
54
+ // ...and an existing account with none gets it, through the conflict
55
+ // update: a password was set, and the account is still not new.
56
+ const lenaPassword = await auth.createAccountWithStartingPassword('lena@acme.test', 'welcome-to-acme');
57
+ expect(lenaPassword).toMatchObject({ created: false, passwordSet: true, deactivated: false });
58
+ expect(lenaPassword.user.id).toBe(first.user.id);
59
+
60
+ // What the rows say agrees: two accounts, both with a password now.
61
+ const accounts = await auth.listAccounts();
62
+ expect(accounts.map((a) => [a.email, a.hasPassword]).sort()).toEqual([
63
+ ['lena@acme.test', true],
64
+ ['tom@acme.test', true],
65
+ ]);
66
+ },
67
+ PG_TEST_TIMEOUT,
68
+ );
69
+ });