@substrat-run/contract-tests 0.137.0 → 0.139.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/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 +179 -0
- package/dist/caused-by-suite.js.map +1 -0
- package/dist/directory-restore-suite.d.ts.map +1 -1
- package/dist/directory-restore-suite.js +40 -0
- package/dist/directory-restore-suite.js.map +1 -1
- package/dist/entity-state-module.d.ts +134 -0
- package/dist/entity-state-module.d.ts.map +1 -0
- package/dist/entity-state-module.js +140 -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 +534 -0
- package/dist/entity-state-suite.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/impersonation-suite.d.ts.map +1 -1
- package/dist/impersonation-suite.js +56 -0
- package/dist/impersonation-suite.js.map +1 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/job-run-suite.d.ts.map +1 -1
- package/dist/job-run-suite.js +263 -1
- package/dist/job-run-suite.js.map +1 -1
- package/dist/membership-executor-suite.d.ts +19 -0
- package/dist/membership-executor-suite.d.ts.map +1 -0
- package/dist/membership-executor-suite.js +866 -0
- package/dist/membership-executor-suite.js.map +1 -0
- package/dist/membership-module.d.ts +17 -0
- package/dist/membership-module.d.ts.map +1 -0
- package/dist/membership-module.js +128 -0
- package/dist/membership-module.js.map +1 -0
- package/dist/modules.d.ts +217 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +23 -0
- package/dist/modules.js.map +1 -1
- package/dist/peer-suite.d.ts +10 -1
- package/dist/peer-suite.d.ts.map +1 -1
- package/dist/peer-suite.js +98 -0
- package/dist/peer-suite.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +82 -7
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +190 -34
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/vertical-events-suite.d.ts +14 -0
- package/dist/vertical-events-suite.d.ts.map +1 -1
- package/dist/vertical-events-suite.js +74 -1
- package/dist/vertical-events-suite.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { instant, 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, 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
|
+
* 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
|
+
* 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
|
+
*
|
|
18
|
+
* The fixture's checker must be a REAL one: the bound is a permission-set comparison, and an
|
|
19
|
+
* allow-all checker would cover everything and prove nothing.
|
|
20
|
+
*/
|
|
21
|
+
export function membershipExecutorContractSuite(adapterName, makeFixture) {
|
|
22
|
+
const staff = platformActorId.parse(ulid());
|
|
23
|
+
const vertical = 'invitefix-vertical';
|
|
24
|
+
/**
|
|
25
|
+
* One tenant with two scopes, the two roles, an org, and the executor mounted. `hold`, when
|
|
26
|
+
* given, holds every add's and removal's directory unit until it resolves — the gap a concurrent removal or
|
|
27
|
+
* change of authority would race into. `failNextAdds` fails that many adds' units outright:
|
|
28
|
+
* the transient failure the retry backstop absorbs.
|
|
29
|
+
*/
|
|
30
|
+
const world = (opts = {}) => {
|
|
31
|
+
const w = {
|
|
32
|
+
failNextAdds: 0,
|
|
33
|
+
fixture: undefined,
|
|
34
|
+
host: undefined,
|
|
35
|
+
t: tenantId.parse(ulid()),
|
|
36
|
+
s: scopeId.parse(ulid()),
|
|
37
|
+
s2: scopeId.parse(ulid()),
|
|
38
|
+
org: orgId.parse(ulid()),
|
|
39
|
+
alice: principalId.parse(ulid()), // lead, tenant-wide
|
|
40
|
+
bob: principalId.parse(ulid()), // member, tenant-wide
|
|
41
|
+
};
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
w.fixture = await makeFixture();
|
|
44
|
+
w.host = w.fixture.host;
|
|
45
|
+
w.host.registerModule(membershipFixtureMod);
|
|
46
|
+
// `baseDelayMs: 0` so the backstop case can retry inside a test.
|
|
47
|
+
registerMembershipExecutor(intercepting(w.host, w, opts.hold), { actor: staff, retry: { baseDelayMs: 0 }, orgs: opts.orgs });
|
|
48
|
+
const { host, t } = w;
|
|
49
|
+
await host.admin.createTenant(staff, { id: t, slug: `invitefix-${t.slice(-10).toLowerCase()}`, name: 'Invitefix' });
|
|
50
|
+
await host.admin.grantEntitlement(staff, t, 'invitefix');
|
|
51
|
+
for (const s of [w.s, w.s2]) {
|
|
52
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: s, vertical });
|
|
53
|
+
await host.admin.activateScope(staff, t, s);
|
|
54
|
+
}
|
|
55
|
+
await host.admin.defineRole(staff, t, { key: 'lead', permissions: [INVITEFIX_A, INVITEFIX_B], source: 'vertical' });
|
|
56
|
+
await host.admin.defineRole(staff, t, { key: 'member', permissions: [INVITEFIX_A], source: 'vertical' });
|
|
57
|
+
await host.admin.createOrg(staff, { id: w.org, tenantId: t, slug: 'team', name: 'Team' });
|
|
58
|
+
await host.admin.assignRole(staff, { principalId: w.alice, roleKey: 'lead', node: { tenantId: t, scopeId: null } });
|
|
59
|
+
await host.admin.assignRole(staff, { principalId: w.bob, roleKey: 'member', node: { tenantId: t, scopeId: null } });
|
|
60
|
+
});
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await w.fixture.cleanup();
|
|
63
|
+
});
|
|
64
|
+
return w;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
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.
|
|
69
|
+
*/
|
|
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 an add's `applyMembership` fails while `w.failNextAdds` counts down,
|
|
85
|
+
* and every unit, an add's or a removal's, otherwise waits for `hold` before it runs.
|
|
86
|
+
*/
|
|
87
|
+
const intercepting = (host, w, hold) => {
|
|
88
|
+
const held = (admin) => new Proxy(admin, {
|
|
89
|
+
get: (t, key) => key === 'applyMembership'
|
|
90
|
+
? async (...args) => {
|
|
91
|
+
if (args[1].op === 'add' && w.failNextAdds > 0) {
|
|
92
|
+
w.failNextAdds -= 1;
|
|
93
|
+
throw new Error('directory unavailable');
|
|
94
|
+
}
|
|
95
|
+
await hold?.();
|
|
96
|
+
return t.applyMembership(...args);
|
|
97
|
+
}
|
|
98
|
+
: Reflect.get(t, key),
|
|
99
|
+
});
|
|
100
|
+
return new Proxy(host, {
|
|
101
|
+
get: (t, key) => {
|
|
102
|
+
if (key === 'attributed')
|
|
103
|
+
return (...a) => ({ admin: held(t.attributed(...a).admin) });
|
|
104
|
+
const v = Reflect.get(t, key);
|
|
105
|
+
return typeof v === 'function' ? v.bind(t) : v;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
const send = async (w, from, roleKey, org = w.org) => {
|
|
110
|
+
const inv = { invitationId: ulid(), orgId: org, roleKey };
|
|
111
|
+
await (await w.host.getScope(from, w.t, w.s)).invoke('invitefix/send', inv);
|
|
112
|
+
return inv;
|
|
113
|
+
};
|
|
114
|
+
/** Invoke as `who` and return what the inline tail did with `who`'s membership request. */
|
|
115
|
+
const asJoiner = async (w, who, operation, input) => {
|
|
116
|
+
const outcomes = [];
|
|
117
|
+
await (await w.host.getScope(who, w.t, w.s)).invoke(operation, input, {
|
|
118
|
+
onExecutorOutcomes: (o) => outcomes.push(...o),
|
|
119
|
+
});
|
|
120
|
+
return outcomes.filter((o) => o.eventType === 'member.add-requested' && o.entity === `membership:${who}`);
|
|
121
|
+
};
|
|
122
|
+
/** `who` asks for `joiner` to be removed; returns what the inline tail did with that request. */
|
|
123
|
+
const removeAs = async (w, who, joiner, roleKey, org = w.org) => {
|
|
124
|
+
const outcomes = [];
|
|
125
|
+
await (await w.host.getScope(who, w.t, w.s)).invoke('invitefix/remove', { principal: joiner, orgId: org, roleKey }, {
|
|
126
|
+
onExecutorOutcomes: (o) => outcomes.push(...o),
|
|
127
|
+
});
|
|
128
|
+
return outcomes.filter((o) => o.eventType === 'member.remove-requested' && o.entity === `membership:${joiner}`);
|
|
129
|
+
};
|
|
130
|
+
const holds = async (w, who, permission, s = w.s) => (await (await w.host.getScope(who, w.t, s)).invoke('invitefix/probe', { permission })).allowed;
|
|
131
|
+
/** Whether `who` is a live member of `org` — which the executor must never make anyone. */
|
|
132
|
+
const memberOf = async (w, who, org = w.org) => (await w.host.admin.listMembers(staff, w.t, org)).filter((m) => m.principal === who).length;
|
|
133
|
+
const tenantNode = (w) => ({ tenantId: w.t, scopeId: null });
|
|
134
|
+
/** The audit rows naming `who` with `action` — what K-21 writes only for a real change. */
|
|
135
|
+
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));
|
|
136
|
+
const causedBy = async (w, eventId) => (await w.host.admin.auditLog(staff, { tenantId: w.t, limit: 500 })).filter((e) => e.causedBy === eventId);
|
|
137
|
+
/** A refusal, seen from both sides: the accepting call, and the journal an admin reads. */
|
|
138
|
+
const expectRefused = async (w, outcomes, who, reason) => {
|
|
139
|
+
expect(outcomes).toHaveLength(1);
|
|
140
|
+
expect(outcomes[0].outcome).toBe('refused');
|
|
141
|
+
expect(outcomes[0].error).toMatch(reason);
|
|
142
|
+
const dead = (await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === outcomes[0].eventId);
|
|
143
|
+
expect(dead?.executorId).toBe(MEMBERSHIP_EXECUTOR_ID);
|
|
144
|
+
expect(dead?.error).toMatch(/^refused: /);
|
|
145
|
+
expect(dead?.error).toMatch(reason);
|
|
146
|
+
expect(dead?.attempts).toBe(1);
|
|
147
|
+
expect(await memberOf(w, who)).toBe(0);
|
|
148
|
+
expect(await holds(w, who, INVITEFIX_A)).toBe(false);
|
|
149
|
+
expect(await causedBy(w, outcomes[0].eventId)).toEqual([]);
|
|
150
|
+
};
|
|
151
|
+
describe(`membership executor (#1184): ${adapterName}`, () => {
|
|
152
|
+
const w = world();
|
|
153
|
+
it('an accepted invite assigns the tenant role inline, with the admin trail correlated to the event', async () => {
|
|
154
|
+
const joe = principalId.parse(ulid());
|
|
155
|
+
const inv = await send(w, w.alice, 'member');
|
|
156
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', inv);
|
|
157
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
158
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
159
|
+
// The role, and nothing else: the org the invitation names is not joined.
|
|
160
|
+
expect(await memberOf(w, joe)).toBe(0);
|
|
161
|
+
// The role carried only what was invited — tenant-wide, the one level it assigns at.
|
|
162
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
163
|
+
expect(await holds(w, joe, INVITEFIX_A, w.s2)).toBe(true);
|
|
164
|
+
// The row joins the event that caused it, executed by the platform actor on behalf of the
|
|
165
|
+
// inviter whose authority bounded it.
|
|
166
|
+
const rows = await causedBy(w, outcomes[0].eventId);
|
|
167
|
+
expect(rows.map((r) => r.action)).toEqual(['assignRole']);
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
expect(row.actor).toBe(staff);
|
|
170
|
+
expect(row.onBehalfOf).toMatchObject({ principal: w.alice, tenantId: w.t });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
it('a redelivery is a no-op: the backstop finds nothing due, and the trail gains no row', async () => {
|
|
174
|
+
const joe = principalId.parse(ulid());
|
|
175
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
|
|
176
|
+
const before = await causedBy(w, outcomes[0].eventId);
|
|
177
|
+
const report = await w.host.drainDue(w.t, w.s);
|
|
178
|
+
expect(report.attempted).toBe(0);
|
|
179
|
+
expect(await causedBy(w, outcomes[0].eventId)).toEqual(before);
|
|
180
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
it('a rolled-back accept effects nothing, inline or later', async () => {
|
|
183
|
+
const joe = principalId.parse(ulid());
|
|
184
|
+
const inv = await send(w, w.alice, 'member');
|
|
185
|
+
const outcomes = [];
|
|
186
|
+
await expect((await w.host.getScope(joe, w.t, w.s)).invoke('invitefix/accept-and-throw', inv, {
|
|
187
|
+
onExecutorOutcomes: (o) => outcomes.push(...o),
|
|
188
|
+
})).rejects.toThrow(/deliberate failure/);
|
|
189
|
+
expect(outcomes).toEqual([]);
|
|
190
|
+
expect((await w.host.drainDue(w.t, w.s)).attempted).toBe(0);
|
|
191
|
+
expect(await memberOf(w, joe)).toBe(0);
|
|
192
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
it('refuses an invite for a role beyond the inviter, for good, and says what was missing', async () => {
|
|
195
|
+
const joe = principalId.parse(ulid());
|
|
196
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.bob, 'lead'));
|
|
197
|
+
await expectRefused(w, outcomes, joe, /invitefix:b/);
|
|
198
|
+
// Terminal: the backstop does not try again.
|
|
199
|
+
expect((await w.host.drainDue(w.t, w.s)).attempted).toBe(0);
|
|
200
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
it('an add confers no org membership — whatever the org it names holds, at the tenant or in a scope', async () => {
|
|
203
|
+
// The org grants invitefix:b tenant-wide AND in one scope; bob holds only invitefix:a.
|
|
204
|
+
// Joining it would confer what bob cannot, and the scope grant lives where no directory
|
|
205
|
+
// unit can bound it — so the executor, mounted role-only, joins nobody to any org.
|
|
206
|
+
const strong = orgId.parse(ulid());
|
|
207
|
+
await w.host.admin.createOrg(staff, { id: strong, tenantId: w.t, slug: `strong-${strong.slice(-6).toLowerCase()}`, name: 'Strong' });
|
|
208
|
+
await w.host.admin.grantToOrg(staff, strong, INVITEFIX_B, tenantNode(w));
|
|
209
|
+
await w.host.admin.grantToOrg(staff, strong, INVITEFIX_B, { tenantId: w.t, scopeId: w.s2 });
|
|
210
|
+
const joe = principalId.parse(ulid());
|
|
211
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.bob, 'member', strong));
|
|
212
|
+
// The twin inside it: the role bob may confer is conferred.
|
|
213
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
214
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
215
|
+
expect(await memberOf(w, joe, strong)).toBe(0);
|
|
216
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
217
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(false);
|
|
218
|
+
// And the trail says so: one assignment, no membership row.
|
|
219
|
+
expect((await causedBy(w, outcomes[0].eventId)).map((r) => r.action)).toEqual(['assignRole']);
|
|
220
|
+
});
|
|
221
|
+
it('refuses an invite whose sender was demoted between send and accept', async () => {
|
|
222
|
+
const carol = principalId.parse(ulid());
|
|
223
|
+
await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: { tenantId: w.t, scopeId: null } });
|
|
224
|
+
const inv = await send(w, carol, 'lead');
|
|
225
|
+
await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: { tenantId: w.t, scopeId: null } });
|
|
226
|
+
await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'member', node: { tenantId: w.t, scopeId: null } });
|
|
227
|
+
const joe = principalId.parse(ulid());
|
|
228
|
+
await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', inv), joe, /invitefix:b/);
|
|
229
|
+
});
|
|
230
|
+
it('refuses an invite whose sender was removed from the tenant between send and accept — and its twin delivers', async () => {
|
|
231
|
+
// Dave's authority is his membership of an org the tenant granted both permissions to.
|
|
232
|
+
const staffOrg = orgId.parse(ulid());
|
|
233
|
+
const dave = principalId.parse(ulid());
|
|
234
|
+
await w.host.admin.createOrg(staff, { id: staffOrg, tenantId: w.t, slug: 'staff', name: 'Staff' });
|
|
235
|
+
for (const p of [INVITEFIX_A, INVITEFIX_B]) {
|
|
236
|
+
await w.host.admin.grantToOrg(staff, staffOrg, p, { tenantId: w.t, scopeId: null });
|
|
237
|
+
}
|
|
238
|
+
await w.host.admin.addMember(staff, w.t, dave, staffOrg);
|
|
239
|
+
// Twin: while he is a member, authority through the org is authority he can confer.
|
|
240
|
+
const kept = principalId.parse(ulid());
|
|
241
|
+
expect((await asJoiner(w, kept, 'invitefix/accept', await send(w, dave, 'lead'))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
242
|
+
const inv = await send(w, dave, 'lead');
|
|
243
|
+
await w.host.admin.removeMember(staff, w.t, dave, staffOrg);
|
|
244
|
+
const joe = principalId.parse(ulid());
|
|
245
|
+
await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', inv), joe, /invitefix:a, invitefix:b/);
|
|
246
|
+
});
|
|
247
|
+
it('authority held in one scope does not confer a tenant-wide role', async () => {
|
|
248
|
+
const erin = principalId.parse(ulid());
|
|
249
|
+
await w.host.admin.assignRole(staff, { principalId: erin, roleKey: 'lead', node: { tenantId: w.t, scopeId: w.s } });
|
|
250
|
+
const joe = principalId.parse(ulid());
|
|
251
|
+
await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', await send(w, erin, 'lead')), joe, /invitefix:a, invitefix:b/);
|
|
252
|
+
});
|
|
253
|
+
describe('a request the payload alone vouches for is refused', () => {
|
|
254
|
+
it('naming another tenant', async () => {
|
|
255
|
+
const joe = principalId.parse(ulid());
|
|
256
|
+
const inv = await send(w, w.alice, 'member');
|
|
257
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/request', {
|
|
258
|
+
entityId: joe,
|
|
259
|
+
payload: { principal: joe, orgId: inv.orgId, tenantId: tenantId.parse(ulid()), roleKey: 'member', invitationId: inv.invitationId },
|
|
260
|
+
});
|
|
261
|
+
await expectRefused(w, outcomes, joe, /tenant other than its own/);
|
|
262
|
+
});
|
|
263
|
+
it('naming a principal other than the one who accepted', async () => {
|
|
264
|
+
const joe = principalId.parse(ulid());
|
|
265
|
+
const someoneElse = principalId.parse(ulid());
|
|
266
|
+
const inv = await send(w, w.alice, 'member');
|
|
267
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/request', {
|
|
268
|
+
entityId: joe,
|
|
269
|
+
payload: { principal: someoneElse, orgId: inv.orgId, tenantId: w.t, roleKey: 'member', invitationId: inv.invitationId },
|
|
270
|
+
});
|
|
271
|
+
await expectRefused(w, outcomes, joe, /other than the one who accepted/);
|
|
272
|
+
expect(await memberOf(w, someoneElse)).toBe(0);
|
|
273
|
+
});
|
|
274
|
+
it('claiming a role the invitation was not sent for', async () => {
|
|
275
|
+
const joe = principalId.parse(ulid());
|
|
276
|
+
const inv = await send(w, w.alice, 'member');
|
|
277
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', { ...inv, roleKey: 'lead' });
|
|
278
|
+
await expectRefused(w, outcomes, joe, /does not match invitation/);
|
|
279
|
+
});
|
|
280
|
+
it('for an invitation nobody sent', async () => {
|
|
281
|
+
const joe = principalId.parse(ulid());
|
|
282
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', { invitationId: ulid(), orgId: w.org, roleKey: 'member' });
|
|
283
|
+
await expectRefused(w, outcomes, joe, /no single send/);
|
|
284
|
+
});
|
|
285
|
+
it('accepting an invitation a second time, as somebody else', async () => {
|
|
286
|
+
const first = principalId.parse(ulid());
|
|
287
|
+
const inv = await send(w, w.alice, 'member');
|
|
288
|
+
expect((await asJoiner(w, first, 'invitefix/accept', inv)).map((o) => o.outcome)).toEqual(['delivered']);
|
|
289
|
+
const second = principalId.parse(ulid());
|
|
290
|
+
await expectRefused(w, await asJoiner(w, second, 'invitefix/accept', inv), second, /not first accepted by/);
|
|
291
|
+
});
|
|
292
|
+
it('after an acceptance somebody else wrote in the joiner\'s name', async () => {
|
|
293
|
+
// The first acceptance's payload names joe, but its kernel-stamped actor is mallory.
|
|
294
|
+
const joe = principalId.parse(ulid());
|
|
295
|
+
const mallory = principalId.parse(ulid());
|
|
296
|
+
const inv = await send(w, w.alice, 'member');
|
|
297
|
+
await (await w.host.getScope(mallory, w.t, w.s)).invoke('invitefix/claim-accepted', { ...inv, principal: joe });
|
|
298
|
+
await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', inv), joe, /not first accepted by/);
|
|
299
|
+
});
|
|
300
|
+
it('replaying an invitation after the joiner was removed — one invitation, one join', async () => {
|
|
301
|
+
const joe = principalId.parse(ulid());
|
|
302
|
+
const inv = await send(w, w.alice, 'member');
|
|
303
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', inv)).map((o) => o.outcome)).toEqual(['delivered']);
|
|
304
|
+
const node = { tenantId: w.t, scopeId: null };
|
|
305
|
+
await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
306
|
+
await w.host.admin.removeMember(staff, w.t, joe, w.org);
|
|
307
|
+
// A fresh request, by the very person who first accepted, naming the same invitation.
|
|
308
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/request', {
|
|
309
|
+
entityId: joe,
|
|
310
|
+
payload: { principal: joe, orgId: inv.orgId, tenantId: w.t, roleKey: 'member', invitationId: inv.invitationId },
|
|
311
|
+
});
|
|
312
|
+
await expectRefused(w, outcomes, joe, /invitation .* was already used/);
|
|
313
|
+
});
|
|
314
|
+
it('a request emitted before the acceptance it claims', async () => {
|
|
315
|
+
const joe = principalId.parse(ulid());
|
|
316
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept-request-first', await send(w, w.alice, 'member'));
|
|
317
|
+
await expectRefused(w, outcomes, joe, /precedes invitation .*'s acceptance/);
|
|
318
|
+
});
|
|
319
|
+
it('with no request shape at all', async () => {
|
|
320
|
+
const joe = principalId.parse(ulid());
|
|
321
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/request', { entityId: joe, payload: { principal: joe } });
|
|
322
|
+
await expectRefused(w, outcomes, joe, /malformed/);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
it('the retry backstop effects what the inline path could not', async () => {
|
|
326
|
+
// The directory unit fails once — a transient failure, retried.
|
|
327
|
+
const joe = principalId.parse(ulid());
|
|
328
|
+
w.failNextAdds = 1;
|
|
329
|
+
const inline = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
|
|
330
|
+
expect(inline.map((o) => o.outcome)).toEqual(['retrying']);
|
|
331
|
+
// Read off the trail, not by a probe: any invoke's own tail would drain the retry first.
|
|
332
|
+
expect(await causedBy(w, inline[0].eventId)).toEqual([]);
|
|
333
|
+
await new Promise((r) => setTimeout(r, 10)); // the retry comes due (`baseDelayMs: 0`)
|
|
334
|
+
const report = await w.host.drainDue(w.t, w.s);
|
|
335
|
+
expect(report.delivered).toBe(1);
|
|
336
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
337
|
+
// The backstop's row carries the same correlation id the inline attempt was for.
|
|
338
|
+
expect((await causedBy(w, inline[0].eventId)).map((r) => r.action)).toEqual(['assignRole']);
|
|
339
|
+
});
|
|
340
|
+
describe('removal goes through the same seam, and wins', () => {
|
|
341
|
+
it('a removal takes the role away, correlated and attributed to the remover', async () => {
|
|
342
|
+
const joe = principalId.parse(ulid());
|
|
343
|
+
await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
|
|
344
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
345
|
+
const outcomes = await removeAs(w, w.alice, joe, 'member');
|
|
346
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
347
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
348
|
+
const rows = await causedBy(w, outcomes[0].eventId);
|
|
349
|
+
expect(rows.map((r) => r.action)).toEqual(['unassignRole']);
|
|
350
|
+
for (const row of rows)
|
|
351
|
+
expect(row.onBehalfOf).toMatchObject({ principal: w.alice });
|
|
352
|
+
});
|
|
353
|
+
it('refuses a removal beyond the remover — the bound runs both ways', async () => {
|
|
354
|
+
const joe = principalId.parse(ulid());
|
|
355
|
+
await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'lead'));
|
|
356
|
+
const outcomes = await removeAs(w, w.bob, joe, 'lead');
|
|
357
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
358
|
+
expect(outcomes[0].error).toMatch(/remover .* no longer holds invitefix:b/);
|
|
359
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
it('an add still retrying when the person is removed is refused when it comes due', async () => {
|
|
362
|
+
const joe = principalId.parse(ulid());
|
|
363
|
+
w.failNextAdds = 1;
|
|
364
|
+
const inline = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
|
|
365
|
+
expect(inline.map((o) => o.outcome)).toEqual(['retrying']);
|
|
366
|
+
// Removed while the add waits: nothing to take yet, but the removal is on the record.
|
|
367
|
+
await removeAs(w, w.alice, joe, 'member');
|
|
368
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
369
|
+
await w.host.drainDue(w.t, w.s);
|
|
370
|
+
const dead = (await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === inline[0].eventId);
|
|
371
|
+
expect(dead?.error).toMatch(/^refused: .* was removed after this request was made/);
|
|
372
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
373
|
+
});
|
|
374
|
+
it('a removal recorded outside the seam after the request wins too — the backlog a hand-rolled removal left', async () => {
|
|
375
|
+
const joe = principalId.parse(ulid());
|
|
376
|
+
w.failNextAdds = 1;
|
|
377
|
+
const inline = await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
|
|
378
|
+
expect(inline.map((o) => o.outcome)).toEqual(['retrying']);
|
|
379
|
+
// The old way: the role was granted by hand and taken back by hand, emitting nothing.
|
|
380
|
+
const node = tenantNode(w);
|
|
381
|
+
await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
382
|
+
await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
383
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
384
|
+
await w.host.drainDue(w.t, w.s);
|
|
385
|
+
const dead = (await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === inline[0].eventId);
|
|
386
|
+
expect(dead?.error).toMatch(/was removed after this request was made/);
|
|
387
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
it('a removal outside the seam moments BEFORE a new request still wins — the stated cost of the skew window', async () => {
|
|
390
|
+
// The directory and the scope share no clock, so a removal within
|
|
391
|
+
// MEMBERSHIP_REMOVAL_SKEW_MS of a request is taken to follow it. A NEW invite accepted
|
|
392
|
+
// that soon after a staff removal is refused, and is resent. (Outside the window it
|
|
393
|
+
// lands: held on SQLite, whose host takes a clock — see its own test.)
|
|
394
|
+
const joe = principalId.parse(ulid());
|
|
395
|
+
const node = { tenantId: w.t, scopeId: null };
|
|
396
|
+
await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
397
|
+
await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
398
|
+
await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member')), joe, /was removed after this request was made/);
|
|
399
|
+
});
|
|
400
|
+
it('a staff removal that took nothing still fences a later accept inside the window — and writes no audit row', async () => {
|
|
401
|
+
// joe never held the role, so `unassignRole` is a K-21 no-op: no row. The fence is raised
|
|
402
|
+
// all the same, because "removed" is a decision about the person, not about what they held.
|
|
403
|
+
const joe = principalId.parse(ulid());
|
|
404
|
+
await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node: tenantNode(w) });
|
|
405
|
+
expect(await auditedFor(w, 'unassignRole', joe)).toEqual([]);
|
|
406
|
+
await expectRefused(w, await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member')), joe, /was removed after this request was made/);
|
|
407
|
+
// removeMember too: never a member of the org, so no row — and the fence.
|
|
408
|
+
const kim = principalId.parse(ulid());
|
|
409
|
+
await w.host.admin.removeMember(staff, w.t, kim, w.org);
|
|
410
|
+
expect(await auditedFor(w, 'removeMember', kim)).toEqual([]);
|
|
411
|
+
await expectRefused(w, await asJoiner(w, kim, 'invitefix/accept', await send(w, w.alice, 'member')), kim, /was removed after this request was made/);
|
|
412
|
+
// Twin: the same accept for someone nobody removed lands.
|
|
413
|
+
const lee = principalId.parse(ulid());
|
|
414
|
+
expect((await asJoiner(w, lee, 'invitefix/accept', await send(w, w.alice, 'member'))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
415
|
+
});
|
|
416
|
+
it('a removal at a scope raises no tenant fence — the fence is the tenant node\'s', async () => {
|
|
417
|
+
const joe = principalId.parse(ulid());
|
|
418
|
+
await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node: { tenantId: w.t, scopeId: w.s } });
|
|
419
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
// Last: it mounts a second executor on this host, which every later accept would also run.
|
|
423
|
+
it('the effect is idempotent: re-running it over delivered events changes no membership', async () => {
|
|
424
|
+
const joe = principalId.parse(ulid());
|
|
425
|
+
await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'member'));
|
|
426
|
+
// A crash between the effect and its journal row re-runs the handler. A second executor
|
|
427
|
+
// id sees every request as never delivered, which is that re-run, for all of them.
|
|
428
|
+
registerMembershipExecutor(w.host, { id: 'membership-replay', actor: staff });
|
|
429
|
+
const report = await w.host.drainDue(w.t, w.s);
|
|
430
|
+
expect(report.retrying).toBe(0);
|
|
431
|
+
expect(report.delivered).toBeGreaterThan(0);
|
|
432
|
+
expect(await memberOf(w, joe)).toBe(0);
|
|
433
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
434
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
describe(`membership executor (#1184), tenant level only: ${adapterName}`, () => {
|
|
438
|
+
it('refuses to mount at a scope level — a type error, and a refusal at run time', async () => {
|
|
439
|
+
const fixture = await makeFixture();
|
|
440
|
+
try {
|
|
441
|
+
expect(() =>
|
|
442
|
+
// @ts-expect-error — 'scope' is not a level the executor takes.
|
|
443
|
+
registerMembershipExecutor(fixture.host, { id: 'membership-scope', actor: staff, level: 'scope' })).toThrow(/tenant-level only/);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
await fixture.cleanup();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
describe(`membership executor (#1184), an add held in front of its directory unit: ${adapterName}`, () => {
|
|
451
|
+
// Every add waits here, after the executor's own checks and before its directory unit.
|
|
452
|
+
const g = gated();
|
|
453
|
+
const w = world({ hold: g.hold });
|
|
454
|
+
const { shut, settle } = g;
|
|
455
|
+
const release = () => g.release();
|
|
456
|
+
it('a removal landing while an add is held cannot leave the access restored', async () => {
|
|
457
|
+
const joe = principalId.parse(ulid());
|
|
458
|
+
const node = tenantNode(w);
|
|
459
|
+
// joe already holds the role, so taking it away is a real, recorded removal.
|
|
460
|
+
await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
461
|
+
const inv = await send(w, w.alice, 'member');
|
|
462
|
+
shut();
|
|
463
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
464
|
+
// The add has passed every check and is held in front of its effects. The removal lands.
|
|
465
|
+
await settle();
|
|
466
|
+
await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
467
|
+
release();
|
|
468
|
+
const outcomes = await accepting;
|
|
469
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
470
|
+
expect(outcomes[0].error).toMatch(/was removed after this request was made/);
|
|
471
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
it('a staff removal of someone not yet a member fences their held add', async () => {
|
|
474
|
+
const joe = principalId.parse(ulid());
|
|
475
|
+
const inv = await send(w, w.alice, 'member');
|
|
476
|
+
shut();
|
|
477
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
478
|
+
await settle();
|
|
479
|
+
// Nothing held yet, so nothing to revoke and no audit row — but the fence.
|
|
480
|
+
await w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node: tenantNode(w) });
|
|
481
|
+
release();
|
|
482
|
+
expect((await accepting).map((o) => o.outcome)).toEqual(['refused']);
|
|
483
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
484
|
+
});
|
|
485
|
+
it('twin: a held add nobody removed lands when released', async () => {
|
|
486
|
+
const joe = principalId.parse(ulid());
|
|
487
|
+
const inv = await send(w, w.alice, 'member');
|
|
488
|
+
shut();
|
|
489
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
490
|
+
await settle();
|
|
491
|
+
release();
|
|
492
|
+
expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
|
|
493
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
494
|
+
});
|
|
495
|
+
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 () => {
|
|
496
|
+
// The causedBy is the event's, bound to the handler's writes. A host-wide field set around
|
|
497
|
+
// the handler's await stamped this staff removal, which has nothing to do with the event.
|
|
498
|
+
const joe = principalId.parse(ulid());
|
|
499
|
+
const kim = principalId.parse(ulid());
|
|
500
|
+
const node = tenantNode(w);
|
|
501
|
+
await w.host.admin.assignRole(staff, { principalId: kim, roleKey: 'member', node });
|
|
502
|
+
const inv = await send(w, w.alice, 'member');
|
|
503
|
+
shut();
|
|
504
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
505
|
+
await settle();
|
|
506
|
+
await w.host.admin.unassignRole(staff, { principalId: kim, roleKey: 'member', node });
|
|
507
|
+
release();
|
|
508
|
+
const outcomes = await accepting;
|
|
509
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
510
|
+
const staffRows = await auditedFor(w, 'unassignRole', kim);
|
|
511
|
+
expect(staffRows.map((r) => r.causedBy)).toEqual([null]);
|
|
512
|
+
const rows = await causedBy(w, outcomes[0].eventId);
|
|
513
|
+
expect(rows.map((r) => r.action)).toEqual(['assignRole']);
|
|
514
|
+
expect(rows[0].onBehalfOf).toMatchObject({ principal: w.alice });
|
|
515
|
+
});
|
|
516
|
+
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 () => {
|
|
517
|
+
// The removal's revoke goes out first and the add's unit right behind it. Were the revoke,
|
|
518
|
+
// the audit row and the fence separate directory calls, the add would land between them:
|
|
519
|
+
// past the revoke, before any record of it.
|
|
520
|
+
const joe = principalId.parse(ulid());
|
|
521
|
+
const node = tenantNode(w);
|
|
522
|
+
await w.host.admin.assignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
523
|
+
const inv = await send(w, w.alice, 'member');
|
|
524
|
+
shut();
|
|
525
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
526
|
+
await settle();
|
|
527
|
+
const removing = w.host.admin.unassignRole(staff, { principalId: joe, roleKey: 'member', node });
|
|
528
|
+
release();
|
|
529
|
+
await Promise.all([accepting, removing]);
|
|
530
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
it('a seam removal of someone whose add is held — nothing held yet — still wins', async () => {
|
|
533
|
+
// Where drains run concurrently (the Durable-Object coordinator), the removal can be
|
|
534
|
+
// effected while the add waits: it has nothing to take, and fences anyway, so the add's
|
|
535
|
+
// own unit sees it. Where they cannot (the pure host's scope actor), the removal queues
|
|
536
|
+
// behind the add and undoes it. Either way the person ends with nothing.
|
|
537
|
+
const joe = principalId.parse(ulid());
|
|
538
|
+
const inv = await send(w, w.alice, 'member');
|
|
539
|
+
shut();
|
|
540
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
541
|
+
await settle();
|
|
542
|
+
const removing = removeAs(w, w.alice, joe, 'member');
|
|
543
|
+
await settle();
|
|
544
|
+
release();
|
|
545
|
+
await Promise.all([accepting, removing]);
|
|
546
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
547
|
+
});
|
|
548
|
+
it('a remover demoted while their removal is held removes nobody', async () => {
|
|
549
|
+
const carol = principalId.parse(ulid());
|
|
550
|
+
await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
|
|
551
|
+
const joe = principalId.parse(ulid());
|
|
552
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'lead'))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
553
|
+
shut();
|
|
554
|
+
const removing = removeAs(w, carol, joe, 'lead');
|
|
555
|
+
await settle();
|
|
556
|
+
await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
|
|
557
|
+
release();
|
|
558
|
+
const outcomes = await removing;
|
|
559
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
560
|
+
expect(outcomes[0].error).toMatch(/remover .* no longer holds invitefix:a, invitefix:b/);
|
|
561
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(true);
|
|
562
|
+
});
|
|
563
|
+
it('twin: a remover who keeps their role through the hold removes', async () => {
|
|
564
|
+
const carol = principalId.parse(ulid());
|
|
565
|
+
await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
|
|
566
|
+
const joe = principalId.parse(ulid());
|
|
567
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, w.alice, 'lead'))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
568
|
+
shut();
|
|
569
|
+
const removing = removeAs(w, carol, joe, 'lead');
|
|
570
|
+
await settle();
|
|
571
|
+
release();
|
|
572
|
+
expect((await removing).map((o) => o.outcome)).toEqual(['delivered']);
|
|
573
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
574
|
+
});
|
|
575
|
+
describe('the bound is asked again inside the unit — a change of authority while held is not written past', () => {
|
|
576
|
+
/** A role of its own, so widening it touches no other test. */
|
|
577
|
+
const freshRole = async (permissions) => {
|
|
578
|
+
const key = `held-${ulid().slice(-8).toLowerCase()}`;
|
|
579
|
+
await w.host.admin.defineRole(staff, w.t, { key, permissions, source: 'vertical' });
|
|
580
|
+
return key;
|
|
581
|
+
};
|
|
582
|
+
it('a role widened beyond the inviter while the add is held', async () => {
|
|
583
|
+
const role = await freshRole([INVITEFIX_A]);
|
|
584
|
+
const joe = principalId.parse(ulid());
|
|
585
|
+
const inv = await send(w, w.bob, role); // bob holds invitefix:a: within reach at send
|
|
586
|
+
shut();
|
|
587
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
588
|
+
await settle();
|
|
589
|
+
await w.host.admin.defineRole(staff, w.t, { key: role, permissions: [INVITEFIX_A, INVITEFIX_B], source: 'vertical' });
|
|
590
|
+
release();
|
|
591
|
+
const outcomes = await accepting;
|
|
592
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
593
|
+
expect(outcomes[0].error).toMatch(/inviter .* no longer holds invitefix:b/);
|
|
594
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
595
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
596
|
+
});
|
|
597
|
+
it('twin: the same widening under an inviter who holds it lands', async () => {
|
|
598
|
+
const role = await freshRole([INVITEFIX_A]);
|
|
599
|
+
const joe = principalId.parse(ulid());
|
|
600
|
+
const inv = await send(w, w.alice, role);
|
|
601
|
+
shut();
|
|
602
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
603
|
+
await settle();
|
|
604
|
+
await w.host.admin.defineRole(staff, w.t, { key: role, permissions: [INVITEFIX_A, INVITEFIX_B], source: 'vertical' });
|
|
605
|
+
release();
|
|
606
|
+
expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
|
|
607
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(true);
|
|
608
|
+
});
|
|
609
|
+
it('the inviter demoted while the add is held', async () => {
|
|
610
|
+
const carol = principalId.parse(ulid());
|
|
611
|
+
await w.host.admin.assignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
|
|
612
|
+
const joe = principalId.parse(ulid());
|
|
613
|
+
const inv = await send(w, carol, 'lead');
|
|
614
|
+
shut();
|
|
615
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
616
|
+
await settle();
|
|
617
|
+
await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
|
|
618
|
+
release();
|
|
619
|
+
const outcomes = await accepting;
|
|
620
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
621
|
+
expect(outcomes[0].error).toMatch(/inviter .* no longer holds invitefix:a, invitefix:b/);
|
|
622
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
623
|
+
});
|
|
624
|
+
it('the grant the inviter held through an org, lost while the add is held', async () => {
|
|
625
|
+
const grants = orgId.parse(ulid());
|
|
626
|
+
await w.host.admin.createOrg(staff, { id: grants, tenantId: w.t, slug: `grants-${grants.slice(-6).toLowerCase()}`, name: 'Grants' });
|
|
627
|
+
await w.host.admin.grantToOrg(staff, grants, INVITEFIX_A, tenantNode(w));
|
|
628
|
+
const dave = principalId.parse(ulid());
|
|
629
|
+
await w.host.admin.addMember(staff, w.t, dave, grants);
|
|
630
|
+
const joe = principalId.parse(ulid());
|
|
631
|
+
const inv = await send(w, dave, 'member');
|
|
632
|
+
shut();
|
|
633
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
634
|
+
await settle();
|
|
635
|
+
await w.host.admin.removeMember(staff, w.t, dave, grants);
|
|
636
|
+
release();
|
|
637
|
+
const outcomes = await accepting;
|
|
638
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
639
|
+
expect(outcomes[0].error).toMatch(/inviter .* no longer holds invitefix:a/);
|
|
640
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
it('a grant added to the invitation\'s org while the add is held reaches nobody — the add joins no org', async () => {
|
|
643
|
+
const joe = principalId.parse(ulid());
|
|
644
|
+
const inv = await send(w, w.bob, 'member');
|
|
645
|
+
shut();
|
|
646
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
647
|
+
await settle();
|
|
648
|
+
await w.host.admin.grantToOrg(staff, w.org, INVITEFIX_B, tenantNode(w));
|
|
649
|
+
release();
|
|
650
|
+
expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
|
|
651
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
652
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
653
|
+
expect(await memberOf(w, joe)).toBe(0);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
describe(`membership executor (#2047), org joins opted into: ${adapterName}`, () => {
|
|
658
|
+
const g = gated();
|
|
659
|
+
const w = world({ orgs: 'join', hold: g.hold });
|
|
660
|
+
const { shut, settle } = g;
|
|
661
|
+
const release = () => g.release();
|
|
662
|
+
/** An org of its own per test, granting `invitefix:b` ONLY in scope s2 — a grant no directory unit sees. */
|
|
663
|
+
const scopeOnlyOrg = async () => {
|
|
664
|
+
const org = orgId.parse(ulid());
|
|
665
|
+
await w.host.admin.createOrg(staff, { id: org, tenantId: w.t, slug: `org-${org.slice(-8).toLowerCase()}`, name: 'Org' });
|
|
666
|
+
await w.host.admin.grantToOrg(staff, org, INVITEFIX_B, { tenantId: w.t, scopeId: w.s2 });
|
|
667
|
+
return org;
|
|
668
|
+
};
|
|
669
|
+
/** A lead tenant-wide — the role is never what refuses in these tests — optionally a member of `org`. */
|
|
670
|
+
const lead = async (org, expiresAt) => {
|
|
671
|
+
const who = principalId.parse(ulid());
|
|
672
|
+
await w.host.admin.assignRole(staff, { principalId: who, roleKey: 'lead', node: tenantNode(w) });
|
|
673
|
+
if (org)
|
|
674
|
+
await w.host.admin.addMember(staff, w.t, who, org, expiresAt ? { expiresAt: instant.parse(expiresAt) } : undefined);
|
|
675
|
+
return who;
|
|
676
|
+
};
|
|
677
|
+
const membership = async (who, org) => (await w.host.admin.listMembers(staff, w.t, org)).find((m) => m.principal === who);
|
|
678
|
+
/** Refused for good, and nothing of it written: no role, no membership, no reach into s2, no trail. */
|
|
679
|
+
const expectNothing = async (outcomes, who, org, reason) => {
|
|
680
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
681
|
+
expect(outcomes[0].error).toMatch(reason);
|
|
682
|
+
expect(await memberOf(w, who, org)).toBe(0);
|
|
683
|
+
expect(await holds(w, who, INVITEFIX_A)).toBe(false);
|
|
684
|
+
expect(await holds(w, who, INVITEFIX_B, w.s2)).toBe(false);
|
|
685
|
+
// No write of this add's: neither the role's row nor the membership's.
|
|
686
|
+
const written = (await causedBy(w, outcomes[0].eventId)).filter((r) => r.action === 'assignRole' || r.action === 'addMember');
|
|
687
|
+
expect(written).toEqual([]);
|
|
688
|
+
};
|
|
689
|
+
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 () => {
|
|
690
|
+
const org = await scopeOnlyOrg();
|
|
691
|
+
const alice = await lead(org);
|
|
692
|
+
const joe = principalId.parse(ulid());
|
|
693
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', org));
|
|
694
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
695
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
696
|
+
expect((await membership(joe, org))?.expiresAt).toBeNull();
|
|
697
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
698
|
+
// What the org confers, in the one scope it confers it in, and nowhere else.
|
|
699
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(true);
|
|
700
|
+
expect(await holds(w, joe, INVITEFIX_B)).toBe(false);
|
|
701
|
+
const rows = await causedBy(w, outcomes[0].eventId);
|
|
702
|
+
expect(rows.map((r) => r.action)).toEqual(['assignRole', 'addMember']);
|
|
703
|
+
for (const row of rows)
|
|
704
|
+
expect(row.onBehalfOf).toMatchObject({ principal: alice, tenantId: w.t });
|
|
705
|
+
});
|
|
706
|
+
it('an inviter who is not a member of the org is refused — the role, too — however much tenant authority they hold', async () => {
|
|
707
|
+
const org = await scopeOnlyOrg();
|
|
708
|
+
// A lead tenant-wide, who even holds invitefix:b in s2 directly: everything the org confers.
|
|
709
|
+
const admin = await lead();
|
|
710
|
+
await w.host.admin.grant(staff, { principalId: admin, permission: INVITEFIX_B, node: { tenantId: w.t, scopeId: w.s2 }, grantedBy: w.alice });
|
|
711
|
+
const joe = principalId.parse(ulid());
|
|
712
|
+
const outcomes = await asJoiner(w, joe, 'invitefix/accept', await send(w, admin, 'member', org));
|
|
713
|
+
await expectNothing(outcomes, joe, org, /inviter .* is not a member of org /);
|
|
714
|
+
expect((await w.host.executorDeadLetters(w.t, w.s)).find((d) => d.eventId === outcomes[0].eventId)?.error).toMatch(/^refused: /);
|
|
715
|
+
});
|
|
716
|
+
it('a scope-level org grant cannot be laundered through another org that holds the same grant', async () => {
|
|
717
|
+
// alice is a member of `mine`, which confers exactly what `theirs` does — at the same scope.
|
|
718
|
+
const mine = await scopeOnlyOrg();
|
|
719
|
+
const theirs = await scopeOnlyOrg();
|
|
720
|
+
const alice = await lead(mine);
|
|
721
|
+
const joe = principalId.parse(ulid());
|
|
722
|
+
await expectNothing(await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', theirs)), joe, theirs, /is not a member of org /);
|
|
723
|
+
// Twin: the org she IS a member of, she can join someone to.
|
|
724
|
+
const kim = principalId.parse(ulid());
|
|
725
|
+
expect((await asJoiner(w, kim, 'invitefix/accept', await send(w, alice, 'member', mine))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
726
|
+
expect(await holds(w, kim, INVITEFIX_B, w.s2)).toBe(true);
|
|
727
|
+
expect(await memberOf(w, kim, theirs)).toBe(0);
|
|
728
|
+
});
|
|
729
|
+
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 () => {
|
|
730
|
+
// The payload is module-written: the org it names is bound to the kernel-stamped send, so a
|
|
731
|
+
// sender in X who invited to Y cannot have the request carry X and join someone to it.
|
|
732
|
+
const x = await scopeOnlyOrg();
|
|
733
|
+
const y = await scopeOnlyOrg();
|
|
734
|
+
const alice = await lead(x);
|
|
735
|
+
const joe = principalId.parse(ulid());
|
|
736
|
+
const inv = await send(w, alice, 'member', y);
|
|
737
|
+
await expectNothing(await asJoiner(w, joe, 'invitefix/accept', { ...inv, orgId: x }), joe, x, /does not match invitation/);
|
|
738
|
+
expect(await memberOf(w, joe, y)).toBe(0);
|
|
739
|
+
});
|
|
740
|
+
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 () => {
|
|
741
|
+
const org = await scopeOnlyOrg();
|
|
742
|
+
const carol = await lead(org);
|
|
743
|
+
const joe = principalId.parse(ulid());
|
|
744
|
+
const inv = await send(w, carol, 'member', org);
|
|
745
|
+
shut();
|
|
746
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
747
|
+
await settle();
|
|
748
|
+
await w.host.admin.removeMember(staff, w.t, carol, org);
|
|
749
|
+
release();
|
|
750
|
+
await expectNothing(await accepting, joe, org, /inviter .* is not a member of org /);
|
|
751
|
+
});
|
|
752
|
+
it('twin: an inviter who stays a member through the hold joins them', async () => {
|
|
753
|
+
const org = await scopeOnlyOrg();
|
|
754
|
+
const carol = await lead(org);
|
|
755
|
+
const joe = principalId.parse(ulid());
|
|
756
|
+
const inv = await send(w, carol, 'member', org);
|
|
757
|
+
shut();
|
|
758
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
759
|
+
await settle();
|
|
760
|
+
release();
|
|
761
|
+
expect((await accepting).map((o) => o.outcome)).toEqual(['delivered']);
|
|
762
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
763
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
it('an inviter demoted while the add is held: the role\'s bound refuses, and the org is not joined either', async () => {
|
|
766
|
+
const org = await scopeOnlyOrg();
|
|
767
|
+
const carol = await lead(org);
|
|
768
|
+
const joe = principalId.parse(ulid());
|
|
769
|
+
const inv = await send(w, carol, 'lead', org);
|
|
770
|
+
shut();
|
|
771
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
772
|
+
await settle();
|
|
773
|
+
await w.host.admin.unassignRole(staff, { principalId: carol, roleKey: 'lead', node: tenantNode(w) });
|
|
774
|
+
release();
|
|
775
|
+
await expectNothing(await accepting, joe, org, /inviter .* no longer holds invitefix:a, invitefix:b/);
|
|
776
|
+
});
|
|
777
|
+
it('the joiner removed from the org while the add is held: the fence refuses both', async () => {
|
|
778
|
+
const org = await scopeOnlyOrg();
|
|
779
|
+
const alice = await lead(org);
|
|
780
|
+
const joe = principalId.parse(ulid());
|
|
781
|
+
const inv = await send(w, alice, 'member', org);
|
|
782
|
+
shut();
|
|
783
|
+
const accepting = asJoiner(w, joe, 'invitefix/accept', inv);
|
|
784
|
+
await settle();
|
|
785
|
+
await w.host.admin.removeMember(staff, w.t, joe, org);
|
|
786
|
+
release();
|
|
787
|
+
await expectNothing(await accepting, joe, org, /was removed after this request was made/);
|
|
788
|
+
});
|
|
789
|
+
describe('a temporary member cannot confer a permanent membership', () => {
|
|
790
|
+
const later = '2999-01-01T00:00:00.000Z';
|
|
791
|
+
it('the joiner\'s membership expires when the inviter\'s does', async () => {
|
|
792
|
+
const org = await scopeOnlyOrg();
|
|
793
|
+
const temp = await lead(org, later);
|
|
794
|
+
const joe = principalId.parse(ulid());
|
|
795
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, temp, 'member', org))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
796
|
+
expect((await membership(joe, org))?.expiresAt).toBe(later);
|
|
797
|
+
const row = (await w.host.admin.auditLog(staff, { tenantId: w.t, action: 'addMember', limit: 500 })).find((r) => JSON.stringify(r.after).includes(joe));
|
|
798
|
+
expect(row?.after).toMatchObject({ principal: joe, orgId: org, expiresAt: later });
|
|
799
|
+
});
|
|
800
|
+
it('twin: an inviter whose membership never lapses confers one that never lapses', async () => {
|
|
801
|
+
const org = await scopeOnlyOrg();
|
|
802
|
+
const alice = await lead(org);
|
|
803
|
+
const joe = principalId.parse(ulid());
|
|
804
|
+
await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', org));
|
|
805
|
+
expect(await membership(joe, org)).toMatchObject({ expiresAt: null, revokedAt: null });
|
|
806
|
+
});
|
|
807
|
+
it('a re-invitation by a temporary member does not cut short a membership the joiner already holds', async () => {
|
|
808
|
+
const org = await scopeOnlyOrg();
|
|
809
|
+
const temp = await lead(org, later);
|
|
810
|
+
const joe = principalId.parse(ulid());
|
|
811
|
+
await w.host.admin.addMember(staff, w.t, joe, org);
|
|
812
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, temp, 'member', org))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
813
|
+
expect((await membership(joe, org))?.expiresAt).toBeNull();
|
|
814
|
+
});
|
|
815
|
+
it('an inviter whose membership has already lapsed is no member, and confers nothing', async () => {
|
|
816
|
+
const org = await scopeOnlyOrg();
|
|
817
|
+
const lapsed = await lead(org, '2000-01-01T00:00:00.000Z');
|
|
818
|
+
const joe = principalId.parse(ulid());
|
|
819
|
+
await expectNothing(await asJoiner(w, joe, 'invitefix/accept', await send(w, lapsed, 'member', org)), joe, org, /is not a member of org /);
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
describe('removal is bounded the same way', () => {
|
|
823
|
+
const joined = async () => {
|
|
824
|
+
const org = await scopeOnlyOrg();
|
|
825
|
+
const alice = await lead(org);
|
|
826
|
+
const joe = principalId.parse(ulid());
|
|
827
|
+
expect((await asJoiner(w, joe, 'invitefix/accept', await send(w, alice, 'member', org))).map((o) => o.outcome)).toEqual(['delivered']);
|
|
828
|
+
return { org, alice, joe };
|
|
829
|
+
};
|
|
830
|
+
it('a member removes: the role and the membership go together, each audited', async () => {
|
|
831
|
+
const { org, alice, joe } = await joined();
|
|
832
|
+
const outcomes = await removeAs(w, alice, joe, 'member', org);
|
|
833
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['delivered']);
|
|
834
|
+
expect(await memberOf(w, joe, org)).toBe(0);
|
|
835
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(false);
|
|
836
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(false);
|
|
837
|
+
expect((await causedBy(w, outcomes[0].eventId)).map((r) => r.action)).toEqual(['unassignRole', 'removeMember']);
|
|
838
|
+
});
|
|
839
|
+
it('a remover who is not a member of the org removes nothing — neither the membership nor the role', async () => {
|
|
840
|
+
const { org, joe } = await joined();
|
|
841
|
+
const outsider = await lead();
|
|
842
|
+
const outcomes = await removeAs(w, outsider, joe, 'member', org);
|
|
843
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
844
|
+
expect(outcomes[0].error).toMatch(/remover .* is not a member of org /);
|
|
845
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
846
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
847
|
+
expect(await holds(w, joe, INVITEFIX_B, w.s2)).toBe(true);
|
|
848
|
+
});
|
|
849
|
+
it('a remover taken out of the org while their removal is held removes nothing', async () => {
|
|
850
|
+
const { org, joe } = await joined();
|
|
851
|
+
const carol = await lead(org);
|
|
852
|
+
shut();
|
|
853
|
+
const removing = removeAs(w, carol, joe, 'member', org);
|
|
854
|
+
await settle();
|
|
855
|
+
await w.host.admin.removeMember(staff, w.t, carol, org);
|
|
856
|
+
release();
|
|
857
|
+
const outcomes = await removing;
|
|
858
|
+
expect(outcomes.map((o) => o.outcome)).toEqual(['refused']);
|
|
859
|
+
expect(outcomes[0].error).toMatch(/remover .* is not a member of org /);
|
|
860
|
+
expect(await memberOf(w, joe, org)).toBe(1);
|
|
861
|
+
expect(await holds(w, joe, INVITEFIX_A)).toBe(true);
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
//# sourceMappingURL=membership-executor-suite.js.map
|