@bevel-software/platform-core-backend 0.27.0 → 0.28.1
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 +14 -2
- 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 +83 -42
- 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/kb-template/access.md +2 -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 +24 -3
- package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
- 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__/change-read-gate.test.ts +36 -5
- 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 +15 -2
- package/src/modules/access/access-control.service.ts +93 -49
- 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
|
@@ -21,6 +21,7 @@ import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
|
21
21
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
22
22
|
import { branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
23
23
|
import type { AuthService } from '../auth/auth.service.js';
|
|
24
|
+
import type { IAccountSwitchGuard } from '../auth/account-switch-guard.js';
|
|
24
25
|
import type { WorkflowService } from '../workflow/workflow.service.js';
|
|
25
26
|
import type { WorkflowEventBus } from '../workflow/event-bus.js';
|
|
26
27
|
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
@@ -51,6 +52,7 @@ import { RolesAdminService } from './roles-admin.service.js';
|
|
|
51
52
|
import type { Principal } from '../access-model/access-splice.js';
|
|
52
53
|
import type { Database } from '../database/connection.js';
|
|
53
54
|
import { users } from '../database/schema.js';
|
|
55
|
+
import { canonicalEmail } from '../../shared/email-identity.js';
|
|
54
56
|
import '../auth/auth.middleware.js';
|
|
55
57
|
|
|
56
58
|
/** Verbs the share UI may grant. Verbs are independent — `download` is grantable on its own. */
|
|
@@ -85,6 +87,8 @@ export function createAccessRoutes(
|
|
|
85
87
|
accessControl: IAccessControl,
|
|
86
88
|
workspaceService: WorkspaceService,
|
|
87
89
|
authService: AuthService,
|
|
90
|
+
/** Auth's switch-off coordination, for the invite's promotion (`ifActive`). */
|
|
91
|
+
switchGuard: IAccountSwitchGuard,
|
|
88
92
|
workflowService: WorkflowService,
|
|
89
93
|
eventBus: WorkflowEventBus,
|
|
90
94
|
db: Database,
|
|
@@ -431,12 +435,15 @@ export function createAccessRoutes(
|
|
|
431
435
|
|
|
432
436
|
/**
|
|
433
437
|
* POST /api/workspace/:id/access/batch
|
|
434
|
-
* Body: `{ paths: string[], verb?: 'write' | 'owner' }`. Returns
|
|
438
|
+
* Body: `{ paths: string[], verb?: 'write' | 'owner' | 'read' }`. Returns
|
|
435
439
|
* `{ results: { [path]: boolean } }`.
|
|
436
440
|
*
|
|
437
441
|
* `verb` defaults to `write`, the question every existing caller asks.
|
|
438
442
|
* `owner` answers from the `owner:` lists alone — no admin rescue, and a
|
|
439
|
-
* writer is not an owner — which is what an Owner pill has to mean.
|
|
443
|
+
* writer is not an owner — which is what an Owner pill has to mean. `read`
|
|
444
|
+
* is the verdict the single lookup's `canRead` gives, for many paths at
|
|
445
|
+
* once: what New page asks on a draft branch, where a page may go wherever
|
|
446
|
+
* its author may read, of every Knowledge folder in one request.
|
|
440
447
|
*/
|
|
441
448
|
router.post('/workspace/:id/access/batch', async (req, res) => {
|
|
442
449
|
const user = await requireUser(req, res);
|
|
@@ -451,13 +458,18 @@ export function createAccessRoutes(
|
|
|
451
458
|
res.status(400).json({ error: 'paths cannot exceed 500 entries per request' });
|
|
452
459
|
return;
|
|
453
460
|
}
|
|
454
|
-
if (verb !== 'write' && verb !== 'owner') {
|
|
455
|
-
res.status(400).json({ error: 'verb must be "write" or "
|
|
461
|
+
if (verb !== 'write' && verb !== 'owner' && verb !== 'read') {
|
|
462
|
+
res.status(400).json({ error: 'verb must be "write", "owner" or "read"' });
|
|
456
463
|
return;
|
|
457
464
|
}
|
|
458
465
|
|
|
459
466
|
try {
|
|
460
|
-
const batch =
|
|
467
|
+
const batch =
|
|
468
|
+
verb === 'owner'
|
|
469
|
+
? accessControl.canOwnerBatch
|
|
470
|
+
: verb === 'read'
|
|
471
|
+
? accessControl.canReadBatch
|
|
472
|
+
: accessControl.canWriteBatch;
|
|
461
473
|
const result = await batch.call(
|
|
462
474
|
accessControl,
|
|
463
475
|
req.params.id,
|
|
@@ -1228,6 +1240,39 @@ export function createAccessRoutes(
|
|
|
1228
1240
|
}
|
|
1229
1241
|
});
|
|
1230
1242
|
|
|
1243
|
+
// Who the admins are, for anyone signed in: the top bar tells someone who
|
|
1244
|
+
// is not an admin to ask one, and names them. Names and emails ONLY — the
|
|
1245
|
+
// roster above (every role, its members, groups and references) stays
|
|
1246
|
+
// admin-only. Names come from the accounts table; an admin with no account
|
|
1247
|
+
// yet (a deployment admin before first sign-in, say) is named by address.
|
|
1248
|
+
// A role member whose account is switched off cannot sign in to invite
|
|
1249
|
+
// anyone, so is left out; a deployment admin is not (it signs in with the
|
|
1250
|
+
// environment's password regardless).
|
|
1251
|
+
router.get('/access/admins', async (req, res) => {
|
|
1252
|
+
const user = await requireUser(req, res);
|
|
1253
|
+
if (!user) return;
|
|
1254
|
+
try {
|
|
1255
|
+
const emails = await rolesAdmin.getAdminEmails();
|
|
1256
|
+
const rows =
|
|
1257
|
+
emails.length === 0
|
|
1258
|
+
? []
|
|
1259
|
+
: await db
|
|
1260
|
+
.select({ email: users.email, name: users.name, deactivatedAt: users.deactivatedAt })
|
|
1261
|
+
.from(users)
|
|
1262
|
+
.where(inArray(users.emailBidx, emails));
|
|
1263
|
+
const names = new Map(rows.map((r) => [canonicalEmail(r.email), r.name?.trim() ?? '']));
|
|
1264
|
+
const off = new Set(rows.filter((r) => r.deactivatedAt).map((r) => canonicalEmail(r.email)));
|
|
1265
|
+
const admins = emails
|
|
1266
|
+
.filter((email) => !off.has(email) || rolesAdmin.isFixedAdmin(email))
|
|
1267
|
+
.map((email) => ({ name: names.get(email) || email, email }))
|
|
1268
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.email.localeCompare(b.email));
|
|
1269
|
+
res.json({ admins });
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
const { status, body } = toHttpError(err);
|
|
1272
|
+
res.status(status).json(body);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1231
1276
|
// NOTE: POST /access/roles (create), PATCH /access/roles/:canonical
|
|
1232
1277
|
// (rename), and DELETE /access/roles/:canonical are GONE — roles are
|
|
1233
1278
|
// app-defined capabilities, not user-editable objects.
|
|
@@ -1239,7 +1284,23 @@ export function createAccessRoutes(
|
|
|
1239
1284
|
await assertRolesAdmin(user.email);
|
|
1240
1285
|
const canonical = canonicalRoleName(req.params.canonical);
|
|
1241
1286
|
const email = requireNonEmptyString((req.body ?? {}).email, 'email');
|
|
1242
|
-
|
|
1287
|
+
// `ifActive`: the invite's promotion, which leaves a switched-off
|
|
1288
|
+
// account unchanged. Inside the roles file's lock, auth reads the
|
|
1289
|
+
// account under the address's switch lock and keeps that transaction
|
|
1290
|
+
// open until roles.yaml is written and committed
|
|
1291
|
+
// (`IAccountSwitchGuard.whileSwitchedOn`). So a switch-off either committed
|
|
1292
|
+
// before the read (the promotion is refused) or waits until the
|
|
1293
|
+
// promotion is written (it lands after, as for any Admin switched off
|
|
1294
|
+
// later: the place in roles stays, the credentials stop being honoured).
|
|
1295
|
+
const ifActive = (req.body ?? {}).ifActive;
|
|
1296
|
+
if (ifActive !== undefined && typeof ifActive !== 'boolean') {
|
|
1297
|
+
res.status(400).json({ error: 'ifActive must be a boolean' });
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const whileSwitchedOn = (write: () => Promise<void>): Promise<void> => switchGuard.whileSwitchedOn(email, write);
|
|
1301
|
+
res.json({
|
|
1302
|
+
roles: await rolesAdmin.addMember(user, canonical, email, ifActive ? { aroundWrite: whileSwitchedOn } : undefined),
|
|
1303
|
+
});
|
|
1243
1304
|
} catch (err) {
|
|
1244
1305
|
const { status, body } = toHttpError(err);
|
|
1245
1306
|
res.status(status).json(body);
|
|
@@ -103,6 +103,13 @@ export interface RoleRosterEntry {
|
|
|
103
103
|
* email once, as fixed.
|
|
104
104
|
*/
|
|
105
105
|
fixedMembers: string[];
|
|
106
|
+
/**
|
|
107
|
+
* The note the page prints beside {@link fixedMembers}, and the sentence
|
|
108
|
+
* both Admin-membership refusals lead with. Sent with the roster so the
|
|
109
|
+
* page keeps no copy of the wording. Null when the role has no fixed
|
|
110
|
+
* members.
|
|
111
|
+
*/
|
|
112
|
+
fixedMembersNote: string | null;
|
|
106
113
|
/** Canonical names of groups this role is assigned to (`group:` members). */
|
|
107
114
|
groups: string[];
|
|
108
115
|
isAdmin: boolean;
|
|
@@ -165,11 +172,12 @@ export class RolesAdminService {
|
|
|
165
172
|
private readonly fixedAdminEmails: readonly string[];
|
|
166
173
|
/**
|
|
167
174
|
* The one explanation both Admin-role refusals carry, verbatim: the note the
|
|
168
|
-
* page prints beside a fixed member
|
|
169
|
-
*
|
|
175
|
+
* page prints beside a fixed member, which it reads from the roster's
|
|
176
|
+
* `fixedMembersNote`. One constant, so the refusal an API caller reads and
|
|
177
|
+
* the note a UI reader sees cannot drift apart.
|
|
170
178
|
*/
|
|
171
179
|
private static readonly FIXED_ADMIN_NOTE =
|
|
172
|
-
'
|
|
180
|
+
'Owner; cannot be removed here';
|
|
173
181
|
|
|
174
182
|
constructor(
|
|
175
183
|
private readonly workspaceService: IWorkspaceService,
|
|
@@ -227,9 +235,10 @@ export class RolesAdminService {
|
|
|
227
235
|
/**
|
|
228
236
|
* Is `email` a deployment admin — an address the server configuration makes
|
|
229
237
|
* an Admin, ahead of `roles.yaml`? Drives both the fixed member rows and
|
|
230
|
-
* the add/remove refusals on the Admin role
|
|
238
|
+
* the add/remove refusals on the Admin role, and keeps a switched-off
|
|
239
|
+
* deployment admin among the admins people are told to ask.
|
|
231
240
|
*/
|
|
232
|
-
|
|
241
|
+
isFixedAdmin(email: string): boolean {
|
|
233
242
|
return this.fixedAdminEmails.includes(canonicalEmail(email));
|
|
234
243
|
}
|
|
235
244
|
|
|
@@ -326,6 +335,8 @@ export class RolesAdminService {
|
|
|
326
335
|
// Only Admin has fixed members: `deploymentOwners` is an Admin rescue
|
|
327
336
|
// in the resolver, never a general grant, so no other role gains one.
|
|
328
337
|
fixedMembers: isAdmin ? [...this.fixedAdminEmails] : [],
|
|
338
|
+
fixedMembersNote:
|
|
339
|
+
isAdmin && this.fixedAdminEmails.length > 0 ? RolesAdminService.FIXED_ADMIN_NOTE : null,
|
|
329
340
|
groups: role.members
|
|
330
341
|
.filter(isGroupRefMember)
|
|
331
342
|
.map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
|
|
@@ -342,6 +353,18 @@ export class RolesAdminService {
|
|
|
342
353
|
return out;
|
|
343
354
|
}
|
|
344
355
|
|
|
356
|
+
/**
|
|
357
|
+
* The addresses the app treats as an Admin, each once — the resolver's own
|
|
358
|
+
* membership answer ({@link IAccessControl.adminEmails}), so who this names
|
|
359
|
+
* and who the admin gates admit cannot drift apart. Nothing else about
|
|
360
|
+
* roles: this backs a read any signed-in person may make (who to ask for an
|
|
361
|
+
* invite), unlike the roster.
|
|
362
|
+
*/
|
|
363
|
+
async getAdminEmails(): Promise<string[]> {
|
|
364
|
+
const workspaceId = await this.ensureWorkspace();
|
|
365
|
+
return this.accessControl.adminEmails(workspaceId);
|
|
366
|
+
}
|
|
367
|
+
|
|
345
368
|
/**
|
|
346
369
|
* Whether the default-branch roles.yaml parses. Drives the "roles file
|
|
347
370
|
* corrupted" banner. Auth-only (never admin-gated): a corrupted file resolves
|
|
@@ -446,10 +469,24 @@ export class RolesAdminService {
|
|
|
446
469
|
* admits it ahead of `roles.yaml` — so the write would add a row that means
|
|
447
470
|
* nothing, next to the fixed row the page already shows. Other roles are
|
|
448
471
|
* unaffected: a deployment admin is an ordinary member of "Sales".
|
|
472
|
+
*
|
|
473
|
+
* `aroundWrite`, when given, runs once the roles file's lock is held and
|
|
474
|
+
* is handed the edit itself (read, check, write, commit) to run. What it
|
|
475
|
+
* does around that call brackets the whole write: the invite holds the
|
|
476
|
+
* address's account-switch lock (a transaction-scoped advisory lock keyed
|
|
477
|
+
* on the canonical email, see `AuthService.whileSwitchedOn`) across it, so
|
|
478
|
+
* a switch-off cannot commit in between, whether or not the account exists
|
|
479
|
+
* yet (see the members route). It may throw instead of calling `write`;
|
|
480
|
+
* nothing is written then.
|
|
449
481
|
*/
|
|
450
|
-
async addMember(
|
|
482
|
+
async addMember(
|
|
483
|
+
actor: AuthUser,
|
|
484
|
+
canonical: string,
|
|
485
|
+
email: string,
|
|
486
|
+
opts?: { aroundWrite?: (write: () => Promise<void>) => Promise<void> },
|
|
487
|
+
): Promise<RoleRosterEntry[]> {
|
|
451
488
|
this.assertNotFixedAdmin(canonical, email);
|
|
452
|
-
await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
|
|
489
|
+
await this.runEdit(actor, (text) => editAddMember(text, canonical, email), opts?.aroundWrite);
|
|
453
490
|
return this.getRoster();
|
|
454
491
|
}
|
|
455
492
|
|
|
@@ -682,17 +719,20 @@ export class RolesAdminService {
|
|
|
682
719
|
* `pre` produces the candidate (and may throw RolesAdminError for invariant
|
|
683
720
|
* violations before any write). Skips on a no-op. Every candidate passes
|
|
684
721
|
* the resolver's own parser (assertLoadable) plus the pre-disk validator
|
|
685
|
-
* before a byte lands.
|
|
722
|
+
* before a byte lands. `aroundWrite` wraps the locked edit (see
|
|
723
|
+
* {@link addMember}).
|
|
686
724
|
*/
|
|
687
725
|
private async runEdit(
|
|
688
726
|
actor: AuthUser,
|
|
689
727
|
pre: (currentText: string) => EditResult,
|
|
728
|
+
aroundWrite?: (write: () => Promise<void>) => Promise<void>,
|
|
690
729
|
): Promise<void> {
|
|
691
730
|
const workspaceId = await this.ensureWorkspace();
|
|
692
731
|
await this.assertRolesUnlocked(workspaceId, actor);
|
|
693
|
-
return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () =>
|
|
694
|
-
this.runEditLocked(workspaceId, actor, pre)
|
|
695
|
-
|
|
732
|
+
return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () => {
|
|
733
|
+
const write = () => this.runEditLocked(workspaceId, actor, pre);
|
|
734
|
+
return aroundWrite ? aroundWrite(write) : write();
|
|
735
|
+
});
|
|
696
736
|
}
|
|
697
737
|
|
|
698
738
|
private async runEditLocked(
|
|
@@ -492,7 +492,7 @@ export class UserAccessRemovalService {
|
|
|
492
492
|
/** Why `email` may not be removed from access files, or null when it may. */
|
|
493
493
|
private blockedReason(rolesText: string | undefined, email: string): string | null {
|
|
494
494
|
if (this.deploymentOwners.some((o) => canonicalEmail(o) === email)) {
|
|
495
|
-
return '
|
|
495
|
+
return 'The owner stays in roles and access rules.';
|
|
496
496
|
}
|
|
497
497
|
const admin = rolesText
|
|
498
498
|
? (() => {
|
|
@@ -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>[]
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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(
|
|
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(
|
|
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
|
+
});
|