@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +5 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +9 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +20 -0
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +4 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +66 -7
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +35 -6
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +36 -9
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/user-access-removal.service.js +1 -1
- package/dist/modules/access/user-access-removal.service.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +46 -0
- package/dist/modules/auth/account-hooks.d.ts.map +1 -0
- package/dist/modules/auth/account-hooks.js +25 -0
- package/dist/modules/auth/account-hooks.js.map +1 -0
- package/dist/modules/auth/account-switch-guard.d.ts +15 -0
- package/dist/modules/auth/account-switch-guard.d.ts.map +1 -0
- package/dist/modules/auth/account-switch-guard.js +2 -0
- package/dist/modules/auth/account-switch-guard.js.map +1 -0
- package/dist/modules/auth/account-switch-lock.d.ts +34 -0
- package/dist/modules/auth/account-switch-lock.d.ts.map +1 -0
- package/dist/modules/auth/account-switch-lock.js +48 -0
- package/dist/modules/auth/account-switch-lock.js.map +1 -0
- package/dist/modules/auth/account.routes.d.ts +4 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +100 -10
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +101 -6
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +246 -51
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/password-hash.d.ts +5 -2
- package/dist/modules/auth/password-hash.d.ts.map +1 -1
- package/dist/modules/auth/password-hash.js +5 -2
- package/dist/modules/auth/password-hash.js.map +1 -1
- package/dist/modules/database/pg-error.d.ts +9 -0
- package/dist/modules/database/pg-error.d.ts.map +1 -0
- package/dist/modules/database/pg-error.js +17 -0
- package/dist/modules/database/pg-error.js.map +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +2 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/pg-scratch.ts +48 -0
- package/src/core/create-core-server.ts +2 -0
- package/src/core/create-core-services.ts +11 -0
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
- package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
- package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
- package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
- package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
- package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
- package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
- package/src/modules/access/__tests__/users-db-double.ts +12 -3
- package/src/modules/access/access-control.interface.ts +10 -0
- package/src/modules/access/access-control.service.ts +19 -0
- package/src/modules/access/access.routes.ts +67 -6
- package/src/modules/access/roles-admin.service.ts +51 -11
- package/src/modules/access/user-access-removal.service.ts +1 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
- package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
- package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
- package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +382 -6
- package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
- package/src/modules/auth/account-hooks.ts +69 -0
- package/src/modules/auth/account-switch-guard.ts +14 -0
- package/src/modules/auth/account-switch-lock.ts +58 -0
- package/src/modules/auth/account.routes.ts +112 -10
- package/src/modules/auth/auth.service.ts +268 -52
- package/src/modules/auth/password-hash.ts +5 -2
- package/src/modules/database/__tests__/pg-error.test.ts +21 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
- package/src/modules/database/pg-error.ts +15 -0
- 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
|
|
379
|
-
vi.mocked(authService.deactivate).mockRejectedValueOnce(
|
|
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).
|
|
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
|
+
});
|