@substrat-run/contract-tests 0.138.0 → 0.140.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/attachment-text-fixtures.d.ts +17 -0
- package/dist/attachment-text-fixtures.d.ts.map +1 -1
- package/dist/attachment-text-fixtures.js +344 -4
- package/dist/attachment-text-fixtures.js.map +1 -1
- package/dist/attachment-text-suite.d.ts +16 -1
- package/dist/attachment-text-suite.d.ts.map +1 -1
- package/dist/attachment-text-suite.js +212 -28
- package/dist/attachment-text-suite.js.map +1 -1
- package/dist/capability-suite.d.ts.map +1 -1
- package/dist/capability-suite.js +5 -0
- package/dist/capability-suite.js.map +1 -1
- package/dist/caused-by-module.d.ts +23 -0
- package/dist/caused-by-module.d.ts.map +1 -0
- package/dist/caused-by-module.js +56 -0
- package/dist/caused-by-module.js.map +1 -0
- package/dist/caused-by-suite.d.ts +28 -0
- package/dist/caused-by-suite.d.ts.map +1 -0
- package/dist/caused-by-suite.js +223 -0
- package/dist/caused-by-suite.js.map +1 -0
- package/dist/connect-link-suite.d.ts +19 -0
- package/dist/connect-link-suite.d.ts.map +1 -0
- package/dist/connect-link-suite.js +215 -0
- package/dist/connect-link-suite.js.map +1 -0
- package/dist/entity-state-migration-suite.d.ts +16 -0
- package/dist/entity-state-migration-suite.d.ts.map +1 -0
- package/dist/entity-state-migration-suite.js +305 -0
- package/dist/entity-state-migration-suite.js.map +1 -0
- package/dist/entity-state-module.d.ts +317 -0
- package/dist/entity-state-module.d.ts.map +1 -0
- package/dist/entity-state-module.js +278 -0
- package/dist/entity-state-module.js.map +1 -0
- package/dist/entity-state-suite.d.ts +9 -0
- package/dist/entity-state-suite.d.ts.map +1 -0
- package/dist/entity-state-suite.js +557 -0
- package/dist/entity-state-suite.js.map +1 -0
- package/dist/entity-trash-module.d.ts +439 -0
- package/dist/entity-trash-module.d.ts.map +1 -0
- package/dist/entity-trash-module.js +287 -0
- package/dist/entity-trash-module.js.map +1 -0
- package/dist/entity-trash-suite.d.ts +59 -0
- package/dist/entity-trash-suite.d.ts.map +1 -0
- package/dist/entity-trash-suite.js +568 -0
- package/dist/entity-trash-suite.js.map +1 -0
- package/dist/erasure-module.d.ts +259 -0
- package/dist/erasure-module.d.ts.map +1 -0
- package/dist/erasure-module.js +251 -0
- package/dist/erasure-module.js.map +1 -0
- package/dist/findings-atomic-suite.d.ts +21 -0
- package/dist/findings-atomic-suite.d.ts.map +1 -0
- package/dist/findings-atomic-suite.js +131 -0
- package/dist/findings-atomic-suite.js.map +1 -0
- package/dist/findings-suite.d.ts +11 -0
- package/dist/findings-suite.d.ts.map +1 -0
- package/dist/findings-suite.js +391 -0
- package/dist/findings-suite.js.map +1 -0
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/list-suite.d.ts.map +1 -1
- package/dist/list-suite.js +101 -1
- package/dist/list-suite.js.map +1 -1
- package/dist/membership-executor-suite.d.ts +6 -2
- package/dist/membership-executor-suite.d.ts.map +1 -1
- package/dist/membership-executor-suite.js +318 -21
- package/dist/membership-executor-suite.js.map +1 -1
- package/dist/migration-comments-suite.d.ts +3 -0
- package/dist/migration-comments-suite.d.ts.map +1 -0
- package/dist/migration-comments-suite.js +92 -0
- package/dist/migration-comments-suite.js.map +1 -0
- package/dist/migration-comments.d.ts +3 -0
- package/dist/migration-comments.d.ts.map +1 -0
- package/dist/migration-comments.js +50 -0
- package/dist/migration-comments.js.map +1 -0
- package/dist/migration-digest-suite.d.ts +3 -0
- package/dist/migration-digest-suite.d.ts.map +1 -0
- package/dist/migration-digest-suite.js +175 -0
- package/dist/migration-digest-suite.js.map +1 -0
- package/dist/modules.d.ts +759 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +158 -6
- package/dist/modules.js.map +1 -1
- package/dist/peer-suite.d.ts +2 -1
- package/dist/peer-suite.d.ts.map +1 -1
- package/dist/peer-suite.js +103 -1
- package/dist/peer-suite.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +590 -2
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +102 -1
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/split-cases.d.ts +12 -0
- package/dist/split-cases.d.ts.map +1 -0
- package/dist/split-cases.js +59 -0
- package/dist/split-cases.js.map +1 -0
- package/dist/subject-erasure-suite.d.ts +6 -0
- package/dist/subject-erasure-suite.d.ts.map +1 -0
- package/dist/subject-erasure-suite.js +287 -0
- package/dist/subject-erasure-suite.js.map +1 -0
- package/dist/switch-audit-fault.d.ts +48 -0
- package/dist/switch-audit-fault.d.ts.map +1 -0
- package/dist/switch-audit-fault.js +80 -0
- package/dist/switch-audit-fault.js.map +1 -0
- package/dist/system-switch-suite.d.ts +2 -1
- package/dist/system-switch-suite.d.ts.map +1 -1
- package/dist/system-switch-suite.js +90 -0
- package/dist/system-switch-suite.js.map +1 -1
- package/dist/vertical-events-suite.d.ts +55 -0
- package/dist/vertical-events-suite.d.ts.map +1 -1
- package/dist/vertical-events-suite.js +55 -0
- package/dist/vertical-events-suite.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
-
import { orgId, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
|
|
3
|
-
import { MEMBERSHIP_EXECUTOR_ID, registerMembershipExecutor, ulid, } from '@substrat-run/kernel';
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { instant, orgId, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
|
|
3
|
+
import { MEMBERSHIP_EXECUTOR_ID, membershipRemoveExecutorId, registerMembershipExecutor, ulid, } from '@substrat-run/kernel';
|
|
4
4
|
import { INVITEFIX_A, INVITEFIX_B, membershipFixtureMod } from './membership-module.js';
|
|
5
5
|
/**
|
|
6
6
|
* The membership executor (#1184, K-22 §4.2) on every adapter: an accepted invite assigns the
|
|
7
|
-
* invited TENANT role — and joins no org — with the admin trail correlated, a
|
|
8
|
-
* nothing, a rolled-back accept effects nothing, and an invite for authority its sender does not
|
|
7
|
+
* invited TENANT role — and, by default, joins no org — with the admin trail correlated, a
|
|
8
|
+
* redelivery changes nothing, a rolled-back accept effects nothing, and an invite for authority its sender does not
|
|
9
9
|
* hold — or no longer holds, demoted or removed, before the add or while it is held in front of
|
|
10
10
|
* its directory unit — is refused for good, where both the accepting call and an admin can see
|
|
11
11
|
* it. Every tenant-level removal, staff's and no-op ones included, fences a pending add. Both
|
|
12
12
|
* delivery paths are held: the inline tail of the accept, and `drainDue` as backstop.
|
|
13
13
|
*
|
|
14
|
+
* Mounted with `orgs: 'join'` (#2047), an add also joins the org and a removal leaves it, bounded
|
|
15
|
+
* inside the same unit by the inviter's or remover's own live membership of that org: a scope-level
|
|
16
|
+
* org grant reaches nobody through a non-member, and a join expires no later than the inviter's.
|
|
17
|
+
*
|
|
14
18
|
* The fixture's checker must be a REAL one: the bound is a permission-set comparison, and an
|
|
15
19
|
* allow-all checker would cover everything and prove nothing.
|
|
16
20
|
*/
|
|
@@ -40,7 +44,7 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
40
44
|
w.host = w.fixture.host;
|
|
41
45
|
w.host.registerModule(membershipFixtureMod);
|
|
42
46
|
// `baseDelayMs: 0` so the backstop case can retry inside a test.
|
|
43
|
-
registerMembershipExecutor(intercepting(w.host, w, opts.hold), { actor: staff, retry: { baseDelayMs: 0 } });
|
|
47
|
+
registerMembershipExecutor(intercepting(w.host, w, opts.hold, opts.legacy), { actor: staff, retry: { baseDelayMs: 0 }, orgs: opts.orgs });
|
|
44
48
|
const { host, t } = w;
|
|
45
49
|
await host.admin.createTenant(staff, { id: t, slug: `invitefix-${t.slice(-10).toLowerCase()}`, name: 'Invitefix' });
|
|
46
50
|
await host.admin.grantEntitlement(staff, t, 'invitefix');
|
|
@@ -60,10 +64,29 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
60
64
|
return w;
|
|
61
65
|
};
|
|
62
66
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
67
|
+
* A gate every directory unit waits at while shut — the gap a concurrent removal or change of
|
|
68
|
+
* authority races into. Open, a unit passes straight through.
|
|
65
69
|
*/
|
|
66
|
-
const
|
|
70
|
+
const gated = () => {
|
|
71
|
+
const g = {
|
|
72
|
+
release: () => undefined,
|
|
73
|
+
gate: Promise.resolve(),
|
|
74
|
+
shut: () => {
|
|
75
|
+
g.gate = new Promise((r) => (g.release = r));
|
|
76
|
+
},
|
|
77
|
+
hold: () => g.gate,
|
|
78
|
+
/** Long enough for an invoke to reach the hold, and for a queued call to reach its own. */
|
|
79
|
+
settle: () => new Promise((r) => setTimeout(r, 50)),
|
|
80
|
+
};
|
|
81
|
+
return g;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* `host`, except that, through the `admin` an executor is handed and every attributed view of
|
|
85
|
+
* it, an add's `applyMembership` fails while `w.failNextAdds` counts down, and every unit, an
|
|
86
|
+
* add's or a removal's, otherwise waits for `hold` before it runs. `legacy` shapes it as an
|
|
87
|
+
* older host.
|
|
88
|
+
*/
|
|
89
|
+
const intercepting = (host, w, hold, legacy) => {
|
|
67
90
|
const held = (admin) => new Proxy(admin, {
|
|
68
91
|
get: (t, key) => key === 'applyMembership'
|
|
69
92
|
? async (...args) => {
|
|
@@ -74,12 +97,19 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
74
97
|
await hold?.();
|
|
75
98
|
return t.applyMembership(...args);
|
|
76
99
|
}
|
|
77
|
-
:
|
|
100
|
+
: key === 'attributed'
|
|
101
|
+
? legacy
|
|
102
|
+
? undefined
|
|
103
|
+
: (...a) => held(t.attributed(...a))
|
|
104
|
+
: Reflect.get(t, key),
|
|
78
105
|
});
|
|
79
106
|
return new Proxy(host, {
|
|
80
107
|
get: (t, key) => {
|
|
81
|
-
if (key === '
|
|
82
|
-
return (
|
|
108
|
+
if (key === 'registerExecutor') {
|
|
109
|
+
return (...[id, type, handler, retry]) => t.registerExecutor(id, type, (admin, event, scope) => handler(held(admin), event, scope), retry);
|
|
110
|
+
}
|
|
111
|
+
if (key === 'attributed' && legacy === 'host')
|
|
112
|
+
return undefined;
|
|
83
113
|
const v = Reflect.get(t, key);
|
|
84
114
|
return typeof v === 'function' ? v.bind(t) : v;
|
|
85
115
|
},
|
|
@@ -181,7 +211,7 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
181
211
|
it('an add confers no org membership — whatever the org it names holds, at the tenant or in a scope', async () => {
|
|
182
212
|
// The org grants invitefix:b tenant-wide AND in one scope; bob holds only invitefix:a.
|
|
183
213
|
// Joining it would confer what bob cannot, and the scope grant lives where no directory
|
|
184
|
-
// unit can bound it — so the executor joins nobody to any org.
|
|
214
|
+
// unit can bound it — so the executor, mounted role-only, joins nobody to any org.
|
|
185
215
|
const strong = orgId.parse(ulid());
|
|
186
216
|
await w.host.admin.createOrg(staff, { id: strong, tenantId: w.t, slug: `strong-${strong.slice(-6).toLowerCase()}`, name: 'Strong' });
|
|
187
217
|
await w.host.admin.grantToOrg(staff, strong, INVITEFIX_B, tenantNode(w));
|
|
@@ -413,6 +443,48 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
413
443
|
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
414
444
|
});
|
|
415
445
|
});
|
|
446
|
+
describe(`membership executor on a host older than HostAdmin.attributed (#2069): ${adapterName}`, () => {
|
|
447
|
+
const older = world({ legacy: 'admin' });
|
|
448
|
+
const oldest = world({ legacy: 'host' });
|
|
449
|
+
it('a handed admin with no `attributed`: the add and the removal still name the person AND the event', async () => {
|
|
450
|
+
const joe = principalId.parse(ulid());
|
|
451
|
+
const added = await asJoiner(older, joe, 'invitefix/accept', await send(older, older.alice, 'member'));
|
|
452
|
+
expect(added.map((o) => o.outcome)).toEqual(['delivered']);
|
|
453
|
+
const removed = await removeAs(older, older.alice, joe, 'member');
|
|
454
|
+
expect(removed.map((o) => o.outcome)).toEqual(['delivered']);
|
|
455
|
+
// Through the deprecated `host.attributed(…, { causedBy })`: neither half is lost.
|
|
456
|
+
const addRows = await causedBy(older, added[0].eventId);
|
|
457
|
+
const removeRows = await causedBy(older, removed[0].eventId);
|
|
458
|
+
expect(addRows.map((r) => r.action)).toEqual(['assignRole']);
|
|
459
|
+
expect(removeRows.map((r) => r.action)).toEqual(['unassignRole']);
|
|
460
|
+
for (const row of [...addRows, ...removeRows]) {
|
|
461
|
+
expect(row.onBehalfOf).toMatchObject({ principal: older.alice, tenantId: older.t });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
it('a host with no attribution at all: each row names its event and no person, and each path says so under its own id', async () => {
|
|
465
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
466
|
+
try {
|
|
467
|
+
const joe = principalId.parse(ulid());
|
|
468
|
+
const added = await asJoiner(oldest, joe, 'invitefix/accept', await send(oldest, oldest.alice, 'member'));
|
|
469
|
+
expect(added.map((o) => o.outcome)).toEqual(['delivered']);
|
|
470
|
+
const removed = await removeAs(oldest, oldest.alice, joe, 'member');
|
|
471
|
+
expect(removed.map((o) => o.outcome)).toEqual(['delivered']);
|
|
472
|
+
const addRows = await causedBy(oldest, added[0].eventId);
|
|
473
|
+
const removeRows = await causedBy(oldest, removed[0].eventId);
|
|
474
|
+
expect(addRows.map((r) => r.action)).toEqual(['assignRole']);
|
|
475
|
+
expect(removeRows.map((r) => r.action)).toEqual(['unassignRole']);
|
|
476
|
+
for (const row of [...addRows, ...removeRows])
|
|
477
|
+
expect(row.onBehalfOf ?? null).toBeNull();
|
|
478
|
+
// The id each warning names is the one its path is registered (and journaled) under.
|
|
479
|
+
const warnedAs = (eventId) => warn.mock.calls.map(([line]) => String(line)).filter((line) => line.includes(eventId)).map((line) => line.split(': ')[0]);
|
|
480
|
+
expect(warnedAs(added[0].eventId)).toEqual([`executor:${MEMBERSHIP_EXECUTOR_ID}`]);
|
|
481
|
+
expect(warnedAs(removed[0].eventId)).toEqual([`executor:${membershipRemoveExecutorId()}`]);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
warn.mockRestore();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
});
|
|
416
488
|
describe(`membership executor (#1184), tenant level only: ${adapterName}`, () => {
|
|
417
489
|
it('refuses to mount at a scope level — a type error, and a refusal at run time', async () => {
|
|
418
490
|
const fixture = await makeFixture();
|
|
@@ -428,14 +500,10 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
428
500
|
});
|
|
429
501
|
describe(`membership executor (#1184), an add held in front of its directory unit: ${adapterName}`, () => {
|
|
430
502
|
// Every add waits here, after the executor's own checks and before its directory unit.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
const
|
|
435
|
-
gate = new Promise((r) => (release = r));
|
|
436
|
-
};
|
|
437
|
-
/** Long enough for an invoke to reach the hold, and for a queued call to reach its own. */
|
|
438
|
-
const settle = () => new Promise((r) => setTimeout(r, 50));
|
|
503
|
+
const g = gated();
|
|
504
|
+
const w = world({ hold: g.hold });
|
|
505
|
+
const { shut, settle } = g;
|
|
506
|
+
const release = () => g.release();
|
|
439
507
|
it('a removal landing while an add is held cannot leave the access restored', async () => {
|
|
440
508
|
const joe = principalId.parse(ulid());
|
|
441
509
|
const node = tenantNode(w);
|
|
@@ -475,6 +543,27 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
475
543
|
expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
|
|
476
544
|
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
477
545
|
});
|
|
546
|
+
it('a staff call made while an add is held carries no cause, and the add\'s own row still carries its event (#2055)', async () => {
|
|
547
|
+
// The causedBy is the event's, bound to the handler's writes. A host-wide field set around
|
|
548
|
+
// the handler's await stamped this staff removal, which has nothing to do with the event.
|
|
549
|
+
const joe = principalId.parse(ulid());
|
|
550
|
+
const kim = principalId.parse(ulid());
|
|
551
|
+
const node = tenantNode(w);
|
|
552
|
+
await w.host.admin.assignRole(staff, { principalId: kim, roleKey: 'member', node });
|
|
553
|
+
const inv = await send(w, w.alice, 'member');
|
|
554
|
+
shut();
|
|
555
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
556
|
+
await settle();
|
|
557
|
+
await w.host.admin.unassignRole(staff, { principalId: kim, roleKey: 'member', node });
|
|
558
|
+
release();
|
|
559
|
+
const outcomes = await accepting;
|
|
560
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
561
|
+
const staffRows = await auditedFor(w, 'unassignRole', kim);
|
|
562
|
+
expect(staffRows.map((r) => r.causedBy)).toEqual([null]);
|
|
563
|
+
const rows = await causedBy(w, outcomes[0].eventId);
|
|
564
|
+
expect(rows.map((r) => r.action)).toEqual(['assignRole']);
|
|
565
|
+
expect(rows[0].onBehalfOf).toMatchObject({ principal: w.alice });
|
|
566
|
+
});
|
|
478
567
|
it('a removal and a held add released in the same tick: the revoke and its fence are one unit, so the access never survives', async () => {
|
|
479
568
|
// The removal's revoke goes out first and the add's unit right behind it. Were the revoke,
|
|
480
569
|
// the audit row and the fence separate directory calls, the add would land between them:
|
|
@@ -616,5 +705,213 @@ export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
|
616
705
|
});
|
|
617
706
|
});
|
|
618
707
|
});
|
|
708
|
+
describe(`membership executor (#2047), org joins opted into: ${adapterName}`, () => {
|
|
709
|
+
const g = gated();
|
|
710
|
+
const w = world({ orgs: 'join', hold: g.hold });
|
|
711
|
+
const { shut, settle } = g;
|
|
712
|
+
const release = () => g.release();
|
|
713
|
+
/** An org of its own per test, granting `invitefix:b` ONLY in scope s2 — a grant no directory unit sees. */
|
|
714
|
+
const scopeOnlyOrg = async () => {
|
|
715
|
+
const org = orgId.parse(ulid());
|
|
716
|
+
await w.host.admin.createOrg(staff, { id: org, tenantId: w.t, slug: `org-${org.slice(-8).toLowerCase()}`, name: 'Org' });
|
|
717
|
+
await w.host.admin.grantToOrg(staff, org, INVITEFIX_B, { tenantId: w.t, scopeId: w.s2 });
|
|
718
|
+
return org;
|
|
719
|
+
};
|
|
720
|
+
/** A lead tenant-wide — the role is never what refuses in these tests — optionally a member of `org`. */
|
|
721
|
+
const lead = async (org, expiresAt) => {
|
|
722
|
+
const who = principalId.parse(ulid());
|
|
723
|
+
await w.host.admin.assignRole(staff, { principalId: who, roleKey: 'lead', node: tenantNode(w) });
|
|
724
|
+
if (org)
|
|
725
|
+
await w.host.admin.addMember(staff, w.t, who, org, expiresAt ? { expiresAt: instant.parse(expiresAt) } : undefined);
|
|
726
|
+
return who;
|
|
727
|
+
};
|
|
728
|
+
const membership = async (who, org) => (await w.host.admin.listMembers(staff, w.t, org)).find((m) => m.principal === who);
|
|
729
|
+
/** Refused for good, and nothing of it written: no role, no membership, no reach into s2, no trail. */
|
|
730
|
+
const expectNothing = async (outcomes, who, org, reason) => {
|
|
731
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
732
|
+
expect(outcomes[0].error).toMatch(reason);
|
|
733
|
+
expect(await memberOf(w, who, org)).toBe(0);
|
|
734
|
+
expect(await holds(w, who, INVITEFIX_A)).toBe(false);
|
|
735
|
+
expect(await holds(w, who, INVITEFIX_B, w.s2)).toBe(false);
|
|
736
|
+
// No write of this add's: neither the role's row nor the membership's.
|
|
737
|
+
const written = (await causedBy(w, outcomes[0].eventId)).filter((r) => r.action === 'assignRole' || r.action === 'addMember');
|
|
738
|
+
expect(written).toEqual([]);
|
|
739
|
+
};
|
|
740
|
+
it('a member of the org joins someone to it: the role and the membership in one unit, and the org reaches them in every scope', async () => {
|
|
741
|
+
const org = await scopeOnlyOrg();
|
|
742
|
+
const alice = await lead(org);
|
|
743
|
+
const joe = principalId.parse(ulid());
|
|
744
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', org));
|
|
745
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
746
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
747
|
+
expect((await membership(joe, org))?.expiresAt).toBeNull();
|
|
748
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
749
|
+
// What the org confers, in the one scope it confers it in, and nowhere else.
|
|
750
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(true);
|
|
751
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
752
|
+
const rows = await causedBy(w, outcomes[0].eventId);
|
|
753
|
+
expect(rows.map((r) => r.action)).toEqual(['assignRole', 'addMember']);
|
|
754
|
+
for (const row of rows)
|
|
755
|
+
expect(row.onBehalfOf).toMatchObject({ principal: alice, tenantId: w.t });
|
|
756
|
+
});
|
|
757
|
+
it('an inviter who is not a member of the org is refused — the role, too — however much tenant authority they hold', async () => {
|
|
758
|
+
const org = await scopeOnlyOrg();
|
|
759
|
+
// A lead tenant-wide, who even holds invitefix:b in s2 directly: everything the org confers.
|
|
760
|
+
const admin = await lead();
|
|
761
|
+
await w.host.admin.grant(staff, { principalId: admin, permission: INVITEFIX_B, node: { tenantId: w.t, scopeId: w.s2 }, grantedBy: w.alice });
|
|
762
|
+
const joe = principalId.parse(ulid());
|
|
763
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, admin, 'member', org));
|
|
764
|
+
await expectNothing(outcomes, joe, org, /inviter .* is not a member of org /);
|
|
765
|
+
expect((await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === outcomes[0].eventId)?.error).toMatch(/^refused: /);
|
|
766
|
+
});
|
|
767
|
+
it('a scope-level org grant cannot be laundered through another org that holds the same grant', async () => {
|
|
768
|
+
// alice is a member of `mine`, which confers exactly what `theirs` does — at the same scope.
|
|
769
|
+
const mine = await scopeOnlyOrg();
|
|
770
|
+
const theirs = await scopeOnlyOrg();
|
|
771
|
+
const alice = await lead(mine);
|
|
772
|
+
const joe = principalId.parse(ulid());
|
|
773
|
+
await expectNothing(await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', theirs)), joe, theirs, /is not a member of org /);
|
|
774
|
+
// Twin: the org she IS a member of, she can join someone to.
|
|
775
|
+
const kim = principalId.parse(ulid());
|
|
776
|
+
expect((await asJoiner(w, kim, 'invitefix/accept', await send(w, alice, 'member', mine))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
777
|
+
expect(await holds(w, kim, INVITEFIX_B, w.s2)).toBe(true);
|
|
778
|
+
expect(await memberOf(w, kim, theirs)).toBe(0);
|
|
779
|
+
});
|
|
780
|
+
it('a request naming an org other than the one the invitation was sent for joins nobody — even one the sender is a member of', async () => {
|
|
781
|
+
// The payload is module-written: the org it names is bound to the kernel-stamped send, so a
|
|
782
|
+
// sender in X who invited to Y cannot have the request carry X and join someone to it.
|
|
783
|
+
const x = await scopeOnlyOrg();
|
|
784
|
+
const y = await scopeOnlyOrg();
|
|
785
|
+
const alice = await lead(x);
|
|
786
|
+
const joe = principalId.parse(ulid());
|
|
787
|
+
const inv = await send(w, alice, 'member', y);
|
|
788
|
+
await expectNothing(await asJoiner(w, joe, 'invitefix/accept', { ...inv, orgId: x }), joe, x, /does not match invitation/);
|
|
789
|
+
expect(await memberOf(w, joe, y)).toBe(0);
|
|
790
|
+
});
|
|
791
|
+
it('an inviter removed from the org while the add is held: refused in the unit, and neither the role nor the membership lands', async () => {
|
|
792
|
+
const org = await scopeOnlyOrg();
|
|
793
|
+
const carol = await lead(org);
|
|
794
|
+
const joe = principalId.parse(ulid());
|
|
795
|
+
const inv = await send(w, carol, 'member', org);
|
|
796
|
+
shut();
|
|
797
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
798
|
+
await settle();
|
|
799
|
+
await w.host.admin.removeMember(staff, w.t, carol, org);
|
|
800
|
+
release();
|
|
801
|
+
await expectNothing(await accepting, joe, org, /inviter .* is not a member of org /);
|
|
802
|
+
});
|
|
803
|
+
it('twin: an inviter who stays a member through the hold joins them', async () => {
|
|
804
|
+
const org = await scopeOnlyOrg();
|
|
805
|
+
const carol = await lead(org);
|
|
806
|
+
const joe = principalId.parse(ulid());
|
|
807
|
+
const inv = await send(w, carol, 'member', org);
|
|
808
|
+
shut();
|
|
809
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
810
|
+
await settle();
|
|
811
|
+
release();
|
|
812
|
+
expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
|
|
813
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
814
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(true);
|
|
815
|
+
});
|
|
816
|
+
it('an inviter demoted while the add is held: the role\'s bound refuses, and the org is not joined either', async () => {
|
|
817
|
+
const org = await scopeOnlyOrg();
|
|
818
|
+
const carol = await lead(org);
|
|
819
|
+
const joe = principalId.parse(ulid());
|
|
820
|
+
const inv = await send(w, carol, 'lead', org);
|
|
821
|
+
shut();
|
|
822
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
823
|
+
await settle();
|
|
824
|
+
await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
|
|
825
|
+
release();
|
|
826
|
+
await expectNothing(await accepting, joe, org, /inviter .* no longer holds invitefix:a, invitefix:b/);
|
|
827
|
+
});
|
|
828
|
+
it('the joiner removed from the org while the add is held: the fence refuses both', async () => {
|
|
829
|
+
const org = await scopeOnlyOrg();
|
|
830
|
+
const alice = await lead(org);
|
|
831
|
+
const joe = principalId.parse(ulid());
|
|
832
|
+
const inv = await send(w, alice, 'member', org);
|
|
833
|
+
shut();
|
|
834
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
835
|
+
await settle();
|
|
836
|
+
await w.host.admin.removeMember(staff, w.t, joe, org);
|
|
837
|
+
release();
|
|
838
|
+
await expectNothing(await accepting, joe, org, /was removed after this request was made/);
|
|
839
|
+
});
|
|
840
|
+
describe('a temporary member cannot confer a permanent membership', () => {
|
|
841
|
+
const later = '2999-01-01T00:00:00.000Z';
|
|
842
|
+
it('the joiner\'s membership expires when the inviter\'s does', async () => {
|
|
843
|
+
const org = await scopeOnlyOrg();
|
|
844
|
+
const temp = await lead(org, later);
|
|
845
|
+
const joe = principalId.parse(ulid());
|
|
846
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, temp, 'member', org))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
847
|
+
expect((await membership(joe, org))?.expiresAt).toBe(later);
|
|
848
|
+
const row = (await w.host.admin.auditLog(staff, { tenantId: w.t, action: 'addMember', limit: 500 })).find((r) => JSON.stringify(r.after).includes(joe));
|
|
849
|
+
expect(row?.after).toMatchObject({ principal: joe, orgId: org, expiresAt: later });
|
|
850
|
+
});
|
|
851
|
+
it('twin: an inviter whose membership never lapses confers one that never lapses', async () => {
|
|
852
|
+
const org = await scopeOnlyOrg();
|
|
853
|
+
const alice = await lead(org);
|
|
854
|
+
const joe = principalId.parse(ulid());
|
|
855
|
+
await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', org));
|
|
856
|
+
expect(await membership(joe, org)).toMatchObject({ expiresAt: null, revokedAt: null });
|
|
857
|
+
});
|
|
858
|
+
it('a re-invitation by a temporary member does not cut short a membership the joiner already holds', async () => {
|
|
859
|
+
const org = await scopeOnlyOrg();
|
|
860
|
+
const temp = await lead(org, later);
|
|
861
|
+
const joe = principalId.parse(ulid());
|
|
862
|
+
await w.host.admin.addMember(staff, w.t, joe, org);
|
|
863
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, temp, 'member', org))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
864
|
+
expect((await membership(joe, org))?.expiresAt).toBeNull();
|
|
865
|
+
});
|
|
866
|
+
it('an inviter whose membership has already lapsed is no member, and confers nothing', async () => {
|
|
867
|
+
const org = await scopeOnlyOrg();
|
|
868
|
+
const lapsed = await lead(org, '2000-01-01T00:00:00.000Z');
|
|
869
|
+
const joe = principalId.parse(ulid());
|
|
870
|
+
await expectNothing(await asJoiner(w, joe, 'invitefix/accept', await send(w, lapsed, 'member', org)), joe, org, /is not a member of org /);
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
describe('removal is bounded the same way', () => {
|
|
874
|
+
const joined = async () => {
|
|
875
|
+
const org = await scopeOnlyOrg();
|
|
876
|
+
const alice = await lead(org);
|
|
877
|
+
const joe = principalId.parse(ulid());
|
|
878
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', org))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
879
|
+
return { org, alice, joe };
|
|
880
|
+
};
|
|
881
|
+
it('a member removes: the role and the membership go together, each audited', async () => {
|
|
882
|
+
const { org, alice, joe } = await joined();
|
|
883
|
+
const outcomes = await removeAs(w, alice, joe, 'member', org);
|
|
884
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
885
|
+
expect(await memberOf(w, joe, org)).toBe(0);
|
|
886
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
887
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(false);
|
|
888
|
+
expect((await causedBy(w, outcomes[0].eventId)).map((r) => r.action)).toEqual(['unassignRole', 'removeMember']);
|
|
889
|
+
});
|
|
890
|
+
it('a remover who is not a member of the org removes nothing — neither the membership nor the role', async () => {
|
|
891
|
+
const { org, joe } = await joined();
|
|
892
|
+
const outsider = await lead();
|
|
893
|
+
const outcomes = await removeAs(w, outsider, joe, 'member', org);
|
|
894
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
895
|
+
expect(outcomes[0].error).toMatch(/remover .* is not a member of org /);
|
|
896
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
897
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
898
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(true);
|
|
899
|
+
});
|
|
900
|
+
it('a remover taken out of the org while their removal is held removes nothing', async () => {
|
|
901
|
+
const { org, joe } = await joined();
|
|
902
|
+
const carol = await lead(org);
|
|
903
|
+
shut();
|
|
904
|
+
const removing = removeAs(w, carol, joe, 'member', org);
|
|
905
|
+
await settle();
|
|
906
|
+
await w.host.admin.removeMember(staff, w.t, carol, org);
|
|
907
|
+
release();
|
|
908
|
+
const outcomes = await removing;
|
|
909
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
910
|
+
expect(outcomes[0].error).toMatch(/remover .* is not a member of org /);
|
|
911
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
912
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
});
|
|
619
916
|
}
|
|
620
917
|
//# sourceMappingURL=membership-executor-suite.js.map
|