@substrat-run/contract-tests 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/directory-restore-suite.d.ts.map +1 -1
  2. package/dist/directory-restore-suite.js +40 -0
  3. package/dist/directory-restore-suite.js.map +1 -1
  4. package/dist/impersonation-suite.d.ts.map +1 -1
  5. package/dist/impersonation-suite.js +56 -0
  6. package/dist/impersonation-suite.js.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/job-run-suite.d.ts.map +1 -1
  12. package/dist/job-run-suite.js +263 -1
  13. package/dist/job-run-suite.js.map +1 -1
  14. package/dist/membership-executor-suite.d.ts +15 -0
  15. package/dist/membership-executor-suite.d.ts.map +1 -0
  16. package/dist/membership-executor-suite.js +620 -0
  17. package/dist/membership-executor-suite.js.map +1 -0
  18. package/dist/membership-module.d.ts +17 -0
  19. package/dist/membership-module.d.ts.map +1 -0
  20. package/dist/membership-module.js +128 -0
  21. package/dist/membership-module.js.map +1 -0
  22. package/dist/modules.d.ts.map +1 -1
  23. package/dist/modules.js +19 -0
  24. package/dist/modules.js.map +1 -1
  25. package/dist/peer-suite.d.ts +10 -1
  26. package/dist/peer-suite.d.ts.map +1 -1
  27. package/dist/peer-suite.js +98 -0
  28. package/dist/peer-suite.js.map +1 -1
  29. package/dist/permission-suite.d.ts.map +1 -1
  30. package/dist/permission-suite.js +7 -7
  31. package/dist/permission-suite.js.map +1 -1
  32. package/dist/scope-host-suite.d.ts.map +1 -1
  33. package/dist/scope-host-suite.js +142 -34
  34. package/dist/scope-host-suite.js.map +1 -1
  35. package/dist/vertical-events-suite.d.ts.map +1 -1
  36. package/dist/vertical-events-suite.js +74 -1
  37. package/dist/vertical-events-suite.js.map +1 -1
  38. package/package.json +4 -4
@@ -0,0 +1,620 @@
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';
4
+ import { INVITEFIX_A, INVITEFIX_B, membershipFixtureMod } from './membership-module.js';
5
+ /**
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 redelivery changes
8
+ * nothing, a rolled-back accept effects nothing, and an invite for authority its sender does not
9
+ * hold — or no longer holds, demoted or removed, before the add or while it is held in front of
10
+ * its directory unit — is refused for good, where both the accepting call and an admin can see
11
+ * it. Every tenant-level removal, staff's and no-op ones included, fences a pending add. Both
12
+ * delivery paths are held: the inline tail of the accept, and `drainDue` as backstop.
13
+ *
14
+ * The fixture's checker must be a REAL one: the bound is a permission-set comparison, and an
15
+ * allow-all checker would cover everything and prove nothing.
16
+ */
17
+ export function membershipExecutorContractSuite(adapterName, makeFixture) {
18
+ const staff = platformActorId.parse(ulid());
19
+ const vertical = 'invitefix-vertical';
20
+ /**
21
+ * One tenant with two scopes, the two roles, an org, and the executor mounted. `hold`, when
22
+ * given, holds every add's and removal's directory unit until it resolves — the gap a concurrent removal or
23
+ * change of authority would race into. `failNextAdds` fails that many adds' units outright:
24
+ * the transient failure the retry backstop absorbs.
25
+ */
26
+ const world = (opts = {}) => {
27
+ const w = {
28
+ failNextAdds: 0,
29
+ fixture: undefined,
30
+ host: undefined,
31
+ t: tenantId.parse(ulid()),
32
+ s: scopeId.parse(ulid()),
33
+ s2: scopeId.parse(ulid()),
34
+ org: orgId.parse(ulid()),
35
+ alice: principalId.parse(ulid()), // lead, tenant-wide
36
+ bob: principalId.parse(ulid()), // member, tenant-wide
37
+ };
38
+ beforeAll(async () => {
39
+ w.fixture = await makeFixture();
40
+ w.host = w.fixture.host;
41
+ w.host.registerModule(membershipFixtureMod);
42
+ // `baseDelayMs: 0` so the backstop case can retry inside a test.
43
+ registerMembershipExecutor(intercepting(w.host, w, opts.hold), { actor: staff, retry: { baseDelayMs: 0 } });
44
+ const { host, t } = w;
45
+ await host.admin.createTenant(staff, { id: t, slug: `invitefix-${t.slice(-10).toLowerCase()}`, name: 'Invitefix' });
46
+ await host.admin.grantEntitlement(staff, t, 'invitefix');
47
+ for (const s of [w.s, w.s2]) {
48
+ await host.provisionScope(staff, { tenantId: t, scopeId: s, vertical });
49
+ await host.admin.activateScope(staff, t, s);
50
+ }
51
+ await host.admin.defineRole(staff, t, { key: 'lead', permissions: [INVITEFIX_A, INVITEFIX_B], source: 'vertical' });
52
+ await host.admin.defineRole(staff, t, { key: 'member', permissions: [INVITEFIX_A], source: 'vertical' });
53
+ await host.admin.createOrg(staff, { id: w.org, tenantId: t, slug: 'team', name: 'Team' });
54
+ await host.admin.assignRole(staff, { principalId: w.alice, roleKey: 'lead', node: { tenantId: t, scopeId: null } });
55
+ await host.admin.assignRole(staff, { principalId: w.bob, roleKey: 'member', node: { tenantId: t, scopeId: null } });
56
+ });
57
+ afterAll(async () => {
58
+ await w.fixture.cleanup();
59
+ });
60
+ return w;
61
+ };
62
+ /**
63
+ * `host`, except that an add's `applyMembership` fails while `w.failNextAdds` counts down,
64
+ * and every unit, an add's or a removal's, otherwise waits for `hold` before it runs.
65
+ */
66
+ const intercepting = (host, w, hold) => {
67
+ const held = (admin) => new Proxy(admin, {
68
+ get: (t, key) => key === 'applyMembership'
69
+ ? async (...args) => {
70
+ if (args[1].op === 'add' && w.failNextAdds > 0) {
71
+ w.failNextAdds -= 1;
72
+ throw new Error('directory unavailable');
73
+ }
74
+ await hold?.();
75
+ return t.applyMembership(...args);
76
+ }
77
+ : Reflect.get(t, key),
78
+ });
79
+ return new Proxy(host, {
80
+ get: (t, key) => {
81
+ if (key === 'attributed')
82
+ return (o) => ({ admin: held(t.attributed(o).admin) });
83
+ const v = Reflect.get(t, key);
84
+ return typeof v === 'function' ? v.bind(t) : v;
85
+ },
86
+ });
87
+ };
88
+ const send = async (w, from, roleKey, org = w.org) => {
89
+ const inv = { invitationId: ulid(), orgId: org, roleKey };
90
+ await (await w.host.getScope(from, w.t, w.s)).invoke('invitefix/send', inv);
91
+ return inv;
92
+ };
93
+ /** Invoke as `who` and return what the inline tail did with `who`'s membership request. */
94
+ const asJoiner = async (w, who, operation, input) => {
95
+ const outcomes = [];
96
+ await (await w.host.getScope(who, w.t, w.s)).invoke(operation, input, {
97
+ onExecutorOutcomes: (o) => outcomes.push(...o),
98
+ });
99
+ return outcomes.filter((o) => o.eventType === 'member.add-requested' && o.entity === `membership:${who}`);
100
+ };
101
+ /** `who` asks for `joiner` to be removed; returns what the inline tail did with that request. */
102
+ const removeAs = async (w, who, joiner, roleKey, org = w.org) => {
103
+ const outcomes = [];
104
+ await (await w.host.getScope(who, w.t, w.s)).invoke('invitefix/remove', { principal: joiner, orgId: org, roleKey }, {
105
+ onExecutorOutcomes: (o) => outcomes.push(...o),
106
+ });
107
+ return outcomes.filter((o) => o.eventType === 'member.remove-requested' && o.entity === `membership:${joiner}`);
108
+ };
109
+ const holds = async (w, who, permission, s = w.s) => (await (await w.host.getScope(who, w.t, s)).invoke('invitefix/probe', { permission })).allowed;
110
+ /** Whether `who` is a live member of `org` — which the executor must never make anyone. */
111
+ const memberOf = async (w, who, org = w.org) => (await w.host.admin.listMembers(staff, w.t, org)).filter((m) => m.principal === who).length;
112
+ const tenantNode = (w) => ({ tenantId: w.t, scopeId: null });
113
+ /** The audit rows naming `who` with `action` — what K-21 writes only for a real change. */
114
+ const auditedFor = async (w, action, who) => (await w.host.admin.auditLog(staff, { tenantId: w.t, action, limit: 500 })).filter((r) => JSON.stringify(r.before).includes(who));
115
+ const causedBy = async (w, eventId) => (await w.host.admin.auditLog(staff, { tenantId: w.t, limit: 500 })).filter((e) => e.causedBy === eventId);
116
+ /** A refusal, seen from both sides: the accepting call, and the journal an admin reads. */
117
+ const expectRefused = async (w, outcomes, who, reason) => {
118
+ expect(outcomes).toHaveLength(1);
119
+ expect(outcomes[0].outcome).toBe('refused');
120
+ expect(outcomes[0].error).toMatch(reason);
121
+ const dead = (await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === outcomes[0].eventId);
122
+ expect(dead?.executorId).toBe(MEMBERSHIP_EXECUTOR_ID);
123
+ expect(dead?.error).toMatch(/^refused: /);
124
+ expect(dead?.error).toMatch(reason);
125
+ expect(dead?.attempts).toBe(1);
126
+ expect(await memberOf(w, who)).toBe(0);
127
+ expect(await holds(w, who, INVITEFIX_A)).toBe(false);
128
+ expect(await causedBy(w, outcomes[0].eventId)).toEqual([]);
129
+ };
130
+ describe(`membership executor (#1184): ${adapterName}`, () => {
131
+ const w = world();
132
+ it('an accepted invite assigns the tenant role inline, with the admin trail correlated to the event', async () => {
133
+ const joe = principalId.parse(ulid());
134
+ const inv = await send(w, w.alice, 'member');
135
+ const outcomes = await asJoiner(w, joe, 'invitefix/accept', inv);
136
+ expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
137
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
138
+ // The role, and nothing else: the org the invitation names is not joined.
139
+ expect(await memberOf(w, joe)).toBe(0);
140
+ // The role carried only what was invited — tenant-wide, the one level it assigns at.
141
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
142
+ expect(await holds(w, joe, INVITEFIX_A, w.s2)).toBe(true);
143
+ // The row joins the event that caused it, executed by the platform actor on behalf of the
144
+ // inviter whose authority bounded it.
145
+ const rows = await causedBy(w, outcomes[0].eventId);
146
+ expect(rows.map((r) => r.action)).toEqual(['assignRole']);
147
+ for (const row of rows) {
148
+ expect(row.actor).toBe(staff);
149
+ expect(row.onBehalfOf).toMatchObject({ principal: w.alice, tenantId: w.t });
150
+ }
151
+ });
152
+ it('a redelivery is a no-op: the backstop finds nothing due, and the trail gains no row', async () => {
153
+ const joe = principalId.parse(ulid());
154
+ const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
155
+ const before = await causedBy(w, outcomes[0].eventId);
156
+ const report = await w.host.drainDue(w.t, w.s);
157
+ expect(report.attempted).toBe(0);
158
+ expect(await causedBy(w, outcomes[0].eventId)).toEqual(before);
159
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
160
+ });
161
+ it('a rolled-back accept effects nothing, inline or later', async () => {
162
+ const joe = principalId.parse(ulid());
163
+ const inv = await send(w, w.alice, 'member');
164
+ const outcomes = [];
165
+ await expect((await w.host.getScope(joe, w.t, w.s)).invoke('invitefix/accept-and-throw', inv, {
166
+ onExecutorOutcomes: (o) => outcomes.push(...o),
167
+ })).rejects.toThrow(/deliberate failure/);
168
+ expect(outcomes).toEqual([]);
169
+ expect((await w.host.drainDue(w.t, w.s)).attempted).toBe(0);
170
+ expect(await memberOf(w, joe)).toBe(0);
171
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
172
+ });
173
+ it('refuses an invite for a role beyond the inviter, for good, and says what was missing', async () => {
174
+ const joe = principalId.parse(ulid());
175
+ const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.bob, 'lead'));
176
+ await expectRefused(w, outcomes, joe, /invitefix:b/);
177
+ // Terminal: the backstop does not try again.
178
+ expect((await w.host.drainDue(w.t, w.s)).attempted).toBe(0);
179
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
180
+ });
181
+ it('an add confers no org membership — whatever the org it names holds, at the tenant or in a scope', async () => {
182
+ // The org grants invitefix:b tenant-wide AND in one scope; bob holds only invitefix:a.
183
+ // 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.
185
+ const strong = orgId.parse(ulid());
186
+ await w.host.admin.createOrg(staff, { id: strong, tenantId: w.t, slug: `strong-${strong.slice(-6).toLowerCase()}`, name: 'Strong' });
187
+ await w.host.admin.grantToOrg(staff, strong, INVITEFIX_B, tenantNode(w));
188
+ await w.host.admin.grantToOrg(staff, strong, INVITEFIX_B, { tenantId: w.t, scopeId: w.s2 });
189
+ const joe = principalId.parse(ulid());
190
+ const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.bob, 'member', strong));
191
+ // The twin inside it: the role bob may confer is conferred.
192
+ expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
193
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
194
+ expect(await memberOf(w, joe, strong)).toBe(0);
195
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
196
+ expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(false);
197
+ // And the trail says so: one assignment, no membership row.
198
+ expect((await causedBy(w, outcomes[0].eventId)).map((r) => r.action)).toEqual(['assignRole']);
199
+ });
200
+ it('refuses an invite whose sender was demoted between send and accept', async () => {
201
+ const carol = principalId.parse(ulid());
202
+ await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: { tenantId: w.t, scopeId: null } });
203
+ const inv = await send(w, carol, 'lead');
204
+ await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: { tenantId: w.t, scopeId: null } });
205
+ await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'member', node: { tenantId: w.t, scopeId: null } });
206
+ const joe = principalId.parse(ulid());
207
+ await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', inv), joe, /invitefix:b/);
208
+ });
209
+ it('refuses an invite whose sender was removed from the tenant between send and accept — and its twin delivers', async () => {
210
+ // Dave's authority is his membership of an org the tenant granted both permissions to.
211
+ const staffOrg = orgId.parse(ulid());
212
+ const dave = principalId.parse(ulid());
213
+ await w.host.admin.createOrg(staff, { id: staffOrg, tenantId: w.t, slug: 'staff', name: 'Staff' });
214
+ for (const p of [INVITEFIX_A, INVITEFIX_B]) {
215
+ await w.host.admin.grantToOrg(staff, staffOrg, p, { tenantId: w.t, scopeId: null });
216
+ }
217
+ await w.host.admin.addMember(staff, w.t, dave, staffOrg);
218
+ // Twin: while he is a member, authority through the org is authority he can confer.
219
+ const kept = principalId.parse(ulid());
220
+ expect((await asJoiner(w, kept, 'invitefix/accept', await send(w, dave, 'lead'))).map((o) => o.outcome)).toEqual(['delivered']);
221
+ const inv = await send(w, dave, 'lead');
222
+ await w.host.admin.removeMember(staff, w.t, dave, staffOrg);
223
+ const joe = principalId.parse(ulid());
224
+ await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', inv), joe, /invitefix:a, invitefix:b/);
225
+ });
226
+ it('authority held in one scope does not confer a tenant-wide role', async () => {
227
+ const erin = principalId.parse(ulid());
228
+ await w.host.admin.assignRole(staff, { principalId: erin, roleKey: 'lead', node: { tenantId: w.t, scopeId: w.s } });
229
+ const joe = principalId.parse(ulid());
230
+ await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', await send(w, erin, 'lead')), joe, /invitefix:a, invitefix:b/);
231
+ });
232
+ describe('a request the payload alone vouches for is refused', () => {
233
+ it('naming another tenant', async () => {
234
+ const joe = principalId.parse(ulid());
235
+ const inv = await send(w, w.alice, 'member');
236
+ const outcomes = await asJoiner(w, joe, 'invitefix/request', {
237
+ entityId: joe,
238
+ payload: { principal: joe, orgId: inv.orgId, tenantId: tenantId.parse(ulid()), roleKey: 'member', invitationId: inv.invitationId },
239
+ });
240
+ await expectRefused(w, outcomes, joe, /tenant other than its own/);
241
+ });
242
+ it('naming a principal other than the one who accepted', async () => {
243
+ const joe = principalId.parse(ulid());
244
+ const someoneElse = principalId.parse(ulid());
245
+ const inv = await send(w, w.alice, 'member');
246
+ const outcomes = await asJoiner(w, joe, 'invitefix/request', {
247
+ entityId: joe,
248
+ payload: { principal: someoneElse, orgId: inv.orgId, tenantId: w.t, roleKey: 'member', invitationId: inv.invitationId },
249
+ });
250
+ await expectRefused(w, outcomes, joe, /other than the one who accepted/);
251
+ expect(await memberOf(w, someoneElse)).toBe(0);
252
+ });
253
+ it('claiming a role the invitation was not sent for', async () => {
254
+ const joe = principalId.parse(ulid());
255
+ const inv = await send(w, w.alice, 'member');
256
+ const outcomes = await asJoiner(w, joe, 'invitefix/accept', { ...inv, roleKey: 'lead' });
257
+ await expectRefused(w, outcomes, joe, /does not match invitation/);
258
+ });
259
+ it('for an invitation nobody sent', async () => {
260
+ const joe = principalId.parse(ulid());
261
+ const outcomes = await asJoiner(w, joe, 'invitefix/accept', { invitationId: ulid(), orgId: w.org, roleKey: 'member' });
262
+ await expectRefused(w, outcomes, joe, /no single send/);
263
+ });
264
+ it('accepting an invitation a second time, as somebody else', async () => {
265
+ const first = principalId.parse(ulid());
266
+ const inv = await send(w, w.alice, 'member');
267
+ expect((await asJoiner(w, first, 'invitefix/accept', inv)).map((o) => o.outcome)).toEqual(['delivered']);
268
+ const second = principalId.parse(ulid());
269
+ await expectRefused(w, await asJoiner(w, second, 'invitefix/accept', inv), second, /not first accepted by/);
270
+ });
271
+ it('after an acceptance somebody else wrote in the joiner\'s name', async () => {
272
+ // The first acceptance's payload names joe, but its kernel-stamped actor is mallory.
273
+ const joe = principalId.parse(ulid());
274
+ const mallory = principalId.parse(ulid());
275
+ const inv = await send(w, w.alice, 'member');
276
+ await (await w.host.getScope(mallory, w.t, w.s)).invoke('invitefix/claim-accepted', { ...inv, principal: joe });
277
+ await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', inv), joe, /not first accepted by/);
278
+ });
279
+ it('replaying an invitation after the joiner was removed — one invitation, one join', async () => {
280
+ const joe = principalId.parse(ulid());
281
+ const inv = await send(w, w.alice, 'member');
282
+ expect((await asJoiner(w, joe, 'invitefix/accept', inv)).map((o) => o.outcome)).toEqual(['delivered']);
283
+ const node = { tenantId: w.t, scopeId: null };
284
+ await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
285
+ await w.host.admin.removeMember(staff, w.t, joe, w.org);
286
+ // A fresh request, by the very person who first accepted, naming the same invitation.
287
+ const outcomes = await asJoiner(w, joe, 'invitefix/request', {
288
+ entityId: joe,
289
+ payload: { principal: joe, orgId: inv.orgId, tenantId: w.t, roleKey: 'member', invitationId: inv.invitationId },
290
+ });
291
+ await expectRefused(w, outcomes, joe, /invitation .* was already used/);
292
+ });
293
+ it('a request emitted before the acceptance it claims', async () => {
294
+ const joe = principalId.parse(ulid());
295
+ const outcomes = await asJoiner(w, joe, 'invitefix/accept-request-first', await send(w, w.alice, 'member'));
296
+ await expectRefused(w, outcomes, joe, /precedes invitation .*'s acceptance/);
297
+ });
298
+ it('with no request shape at all', async () => {
299
+ const joe = principalId.parse(ulid());
300
+ const outcomes = await asJoiner(w, joe, 'invitefix/request', { entityId: joe, payload: { principal: joe } });
301
+ await expectRefused(w, outcomes, joe, /malformed/);
302
+ });
303
+ });
304
+ it('the retry backstop effects what the inline path could not', async () => {
305
+ // The directory unit fails once — a transient failure, retried.
306
+ const joe = principalId.parse(ulid());
307
+ w.failNextAdds = 1;
308
+ const inline = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
309
+ expect(inline.map((o) => o.outcome)).toEqual(['retrying']);
310
+ // Read off the trail, not by a probe: any invoke's own tail would drain the retry first.
311
+ expect(await causedBy(w, inline[0].eventId)).toEqual([]);
312
+ await new Promise((r) => setTimeout(r, 10)); // the retry comes due (`baseDelayMs: 0`)
313
+ const report = await w.host.drainDue(w.t, w.s);
314
+ expect(report.delivered).toBe(1);
315
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
316
+ // The backstop's row carries the same correlation id the inline attempt was for.
317
+ expect((await causedBy(w, inline[0].eventId)).map((r) => r.action)).toEqual(['assignRole']);
318
+ });
319
+ describe('removal goes through the same seam, and wins', () => {
320
+ it('a removal takes the role away, correlated and attributed to the remover', async () => {
321
+ const joe = principalId.parse(ulid());
322
+ await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
323
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
324
+ const outcomes = await removeAs(w, w.alice, joe, 'member');
325
+ expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
326
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
327
+ const rows = await causedBy(w, outcomes[0].eventId);
328
+ expect(rows.map((r) => r.action)).toEqual(['unassignRole']);
329
+ for (const row of rows)
330
+ expect(row.onBehalfOf).toMatchObject({ principal: w.alice });
331
+ });
332
+ it('refuses a removal beyond the remover — the bound runs both ways', async () => {
333
+ const joe = principalId.parse(ulid());
334
+ await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'lead'));
335
+ const outcomes = await removeAs(w, w.bob, joe, 'lead');
336
+ expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
337
+ expect(outcomes[0].error).toMatch(/remover .* no longer holds invitefix:b/);
338
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(true);
339
+ });
340
+ it('an add still retrying when the person is removed is refused when it comes due', async () => {
341
+ const joe = principalId.parse(ulid());
342
+ w.failNextAdds = 1;
343
+ const inline = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
344
+ expect(inline.map((o) => o.outcome)).toEqual(['retrying']);
345
+ // Removed while the add waits: nothing to take yet, but the removal is on the record.
346
+ await removeAs(w, w.alice, joe, 'member');
347
+ await new Promise((r) => setTimeout(r, 10));
348
+ await w.host.drainDue(w.t, w.s);
349
+ const dead = (await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === inline[0].eventId);
350
+ expect(dead?.error).toMatch(/^refused: .* was removed after this request was made/);
351
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
352
+ });
353
+ it('a removal recorded outside the seam after the request wins too — the backlog a hand-rolled removal left', async () => {
354
+ const joe = principalId.parse(ulid());
355
+ w.failNextAdds = 1;
356
+ const inline = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
357
+ expect(inline.map((o) => o.outcome)).toEqual(['retrying']);
358
+ // The old way: the role was granted by hand and taken back by hand, emitting nothing.
359
+ const node = tenantNode(w);
360
+ await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
361
+ await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
362
+ await new Promise((r) => setTimeout(r, 10));
363
+ await w.host.drainDue(w.t, w.s);
364
+ const dead = (await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === inline[0].eventId);
365
+ expect(dead?.error).toMatch(/was removed after this request was made/);
366
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
367
+ });
368
+ it('a removal outside the seam moments BEFORE a new request still wins — the stated cost of the skew window', async () => {
369
+ // The directory and the scope share no clock, so a removal within
370
+ // MEMBERSHIP_REMOVAL_SKEW_MS of a request is taken to follow it. A NEW invite accepted
371
+ // that soon after a staff removal is refused, and is resent. (Outside the window it
372
+ // lands: held on SQLite, whose host takes a clock — see its own test.)
373
+ const joe = principalId.parse(ulid());
374
+ const node = { tenantId: w.t, scopeId: null };
375
+ await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
376
+ await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
377
+ await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member')), joe, /was removed after this request was made/);
378
+ });
379
+ it('a staff removal that took nothing still fences a later accept inside the window — and writes no audit row', async () => {
380
+ // joe never held the role, so `unassignRole` is a K-21 no-op: no row. The fence is raised
381
+ // all the same, because "removed" is a decision about the person, not about what they held.
382
+ const joe = principalId.parse(ulid());
383
+ await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node: tenantNode(w) });
384
+ expect(await auditedFor(w, 'unassignRole', joe)).toEqual([]);
385
+ await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member')), joe, /was removed after this request was made/);
386
+ // removeMember too: never a member of the org, so no row — and the fence.
387
+ const kim = principalId.parse(ulid());
388
+ await w.host.admin.removeMember(staff, w.t, kim, w.org);
389
+ expect(await auditedFor(w, 'removeMember', kim)).toEqual([]);
390
+ await expectRefused(w, await asJoiner(w, kim, 'invitefix/accept', await send(w, w.alice, 'member')), kim, /was removed after this request was made/);
391
+ // Twin: the same accept for someone nobody removed lands.
392
+ const lee = principalId.parse(ulid());
393
+ expect((await asJoiner(w, lee, 'invitefix/accept', await send(w, w.alice, 'member'))).map((o) => o.outcome)).toEqual(['delivered']);
394
+ });
395
+ it('a removal at a scope raises no tenant fence — the fence is the tenant node\'s', async () => {
396
+ const joe = principalId.parse(ulid());
397
+ await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node: { tenantId: w.t, scopeId: w.s } });
398
+ expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'))).map((o) => o.outcome)).toEqual(['delivered']);
399
+ });
400
+ });
401
+ // Last: it mounts a second executor on this host, which every later accept would also run.
402
+ it('the effect is idempotent: re-running it over delivered events changes no membership', async () => {
403
+ const joe = principalId.parse(ulid());
404
+ await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
405
+ // A crash between the effect and its journal row re-runs the handler. A second executor
406
+ // id sees every request as never delivered, which is that re-run, for all of them.
407
+ registerMembershipExecutor(w.host, { id: 'membership-replay', actor: staff });
408
+ const report = await w.host.drainDue(w.t, w.s);
409
+ expect(report.retrying).toBe(0);
410
+ expect(report.delivered).toBeGreaterThan(0);
411
+ expect(await memberOf(w, joe)).toBe(0);
412
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
413
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
414
+ });
415
+ });
416
+ describe(`membership executor (#1184), tenant level only: ${adapterName}`, () => {
417
+ it('refuses to mount at a scope level — a type error, and a refusal at run time', async () => {
418
+ const fixture = await makeFixture();
419
+ try {
420
+ expect(() =>
421
+ // @ts-expect-error — 'scope' is not a level the executor takes.
422
+ registerMembershipExecutor(fixture.host, { id: 'membership-scope', actor: staff, level: 'scope' })).toThrow(/tenant-level only/);
423
+ }
424
+ finally {
425
+ await fixture.cleanup();
426
+ }
427
+ });
428
+ });
429
+ describe(`membership executor (#1184), an add held in front of its directory unit: ${adapterName}`, () => {
430
+ // Every add waits here, after the executor's own checks and before its directory unit.
431
+ let release = () => undefined;
432
+ let gate = Promise.resolve();
433
+ const w = world({ hold: () => gate });
434
+ const shut = () => {
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));
439
+ it('a removal landing while an add is held cannot leave the access restored', async () => {
440
+ const joe = principalId.parse(ulid());
441
+ const node = tenantNode(w);
442
+ // joe already holds the role, so taking it away is a real, recorded removal.
443
+ await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
444
+ const inv = await send(w, w.alice, 'member');
445
+ shut();
446
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
447
+ // The add has passed every check and is held in front of its effects. The removal lands.
448
+ await settle();
449
+ await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
450
+ release();
451
+ const outcomes = await accepting;
452
+ expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
453
+ expect(outcomes[0].error).toMatch(/was removed after this request was made/);
454
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
455
+ });
456
+ it('a staff removal of someone not yet a member fences their held add', async () => {
457
+ const joe = principalId.parse(ulid());
458
+ const inv = await send(w, w.alice, 'member');
459
+ shut();
460
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
461
+ await settle();
462
+ // Nothing held yet, so nothing to revoke and no audit row — but the fence.
463
+ await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node: tenantNode(w) });
464
+ release();
465
+ expect((await accepting).map((o) => o.outcome)).toEqual(['refused']);
466
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
467
+ });
468
+ it('twin: a held add nobody removed lands when released', async () => {
469
+ const joe = principalId.parse(ulid());
470
+ const inv = await send(w, w.alice, 'member');
471
+ shut();
472
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
473
+ await settle();
474
+ release();
475
+ expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
476
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
477
+ });
478
+ 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
+ // The removal's revoke goes out first and the add's unit right behind it. Were the revoke,
480
+ // the audit row and the fence separate directory calls, the add would land between them:
481
+ // past the revoke, before any record of it.
482
+ const joe = principalId.parse(ulid());
483
+ const node = tenantNode(w);
484
+ await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
485
+ const inv = await send(w, w.alice, 'member');
486
+ shut();
487
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
488
+ await settle();
489
+ const removing = w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
490
+ release();
491
+ await Promise.all([accepting, removing]);
492
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
493
+ });
494
+ it('a seam removal of someone whose add is held — nothing held yet — still wins', async () => {
495
+ // Where drains run concurrently (the Durable-Object coordinator), the removal can be
496
+ // effected while the add waits: it has nothing to take, and fences anyway, so the add's
497
+ // own unit sees it. Where they cannot (the pure host's scope actor), the removal queues
498
+ // behind the add and undoes it. Either way the person ends with nothing.
499
+ const joe = principalId.parse(ulid());
500
+ const inv = await send(w, w.alice, 'member');
501
+ shut();
502
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
503
+ await settle();
504
+ const removing = removeAs(w, w.alice, joe, 'member');
505
+ await settle();
506
+ release();
507
+ await Promise.all([accepting, removing]);
508
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
509
+ });
510
+ it('a remover demoted while their removal is held removes nobody', async () => {
511
+ const carol = principalId.parse(ulid());
512
+ await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
513
+ const joe = principalId.parse(ulid());
514
+ expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'lead'))).map((o) => o.outcome)).toEqual(['delivered']);
515
+ shut();
516
+ const removing = removeAs(w, carol, joe, 'lead');
517
+ await settle();
518
+ await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
519
+ release();
520
+ const outcomes = await removing;
521
+ expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
522
+ expect(outcomes[0].error).toMatch(/remover .* no longer holds invitefix:a, invitefix:b/);
523
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(true);
524
+ });
525
+ it('twin: a remover who keeps their role through the hold removes', async () => {
526
+ const carol = principalId.parse(ulid());
527
+ await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
528
+ const joe = principalId.parse(ulid());
529
+ expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'lead'))).map((o) => o.outcome)).toEqual(['delivered']);
530
+ shut();
531
+ const removing = removeAs(w, carol, joe, 'lead');
532
+ await settle();
533
+ release();
534
+ expect((await removing).map((o) => o.outcome)).toEqual(['delivered']);
535
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
536
+ });
537
+ describe('the bound is asked again inside the unit — a change of authority while held is not written past', () => {
538
+ /** A role of its own, so widening it touches no other test. */
539
+ const freshRole = async (permissions) => {
540
+ const key = `held-${ulid().slice(-8).toLowerCase()}`;
541
+ await w.host.admin.defineRole(staff, w.t, { key, permissions, source: 'vertical' });
542
+ return key;
543
+ };
544
+ it('a role widened beyond the inviter while the add is held', async () => {
545
+ const role = await freshRole([INVITEFIX_A]);
546
+ const joe = principalId.parse(ulid());
547
+ const inv = await send(w, w.bob, role); // bob holds invitefix:a: within reach at send
548
+ shut();
549
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
550
+ await settle();
551
+ await w.host.admin.defineRole(staff, w.t, { key: role, permissions: [INVITEFIX_A, INVITEFIX_B], source: 'vertical' });
552
+ release();
553
+ const outcomes = await accepting;
554
+ expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
555
+ expect(outcomes[0].error).toMatch(/inviter .* no longer holds invitefix:b/);
556
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
557
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
558
+ });
559
+ it('twin: the same widening under an inviter who holds it lands', async () => {
560
+ const role = await freshRole([INVITEFIX_A]);
561
+ const joe = principalId.parse(ulid());
562
+ const inv = await send(w, w.alice, role);
563
+ shut();
564
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
565
+ await settle();
566
+ await w.host.admin.defineRole(staff, w.t, { key: role, permissions: [INVITEFIX_A, INVITEFIX_B], source: 'vertical' });
567
+ release();
568
+ expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
569
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(true);
570
+ });
571
+ it('the inviter demoted while the add is held', async () => {
572
+ const carol = principalId.parse(ulid());
573
+ await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
574
+ const joe = principalId.parse(ulid());
575
+ const inv = await send(w, carol, 'lead');
576
+ shut();
577
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
578
+ await settle();
579
+ await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
580
+ release();
581
+ const outcomes = await accepting;
582
+ expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
583
+ expect(outcomes[0].error).toMatch(/inviter .* no longer holds invitefix:a, invitefix:b/);
584
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
585
+ });
586
+ it('the grant the inviter held through an org, lost while the add is held', async () => {
587
+ const grants = orgId.parse(ulid());
588
+ await w.host.admin.createOrg(staff, { id: grants, tenantId: w.t, slug: `grants-${grants.slice(-6).toLowerCase()}`, name: 'Grants' });
589
+ await w.host.admin.grantToOrg(staff, grants, INVITEFIX_A, tenantNode(w));
590
+ const dave = principalId.parse(ulid());
591
+ await w.host.admin.addMember(staff, w.t, dave, grants);
592
+ const joe = principalId.parse(ulid());
593
+ const inv = await send(w, dave, 'member');
594
+ shut();
595
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
596
+ await settle();
597
+ await w.host.admin.removeMember(staff, w.t, dave, grants);
598
+ release();
599
+ const outcomes = await accepting;
600
+ expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
601
+ expect(outcomes[0].error).toMatch(/inviter .* no longer holds invitefix:a/);
602
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
603
+ });
604
+ it('a grant added to the invitation\'s org while the add is held reaches nobody — the add joins no org', async () => {
605
+ const joe = principalId.parse(ulid());
606
+ const inv = await send(w, w.bob, 'member');
607
+ shut();
608
+ const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
609
+ await settle();
610
+ await w.host.admin.grantToOrg(staff, w.org, INVITEFIX_B, tenantNode(w));
611
+ release();
612
+ expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
613
+ expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
614
+ expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
615
+ expect(await memberOf(w, joe)).toBe(0);
616
+ });
617
+ });
618
+ });
619
+ }
620
+ //# sourceMappingURL=membership-executor-suite.js.map