@substrat-run/contract-tests 0.5.0 → 0.7.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modules.d.ts +161 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +87 -0
- package/dist/modules.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +328 -12
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +361 -43
- package/dist/scope-host-suite.js.map +1 -1
- package/package.json +3 -3
package/dist/scope-host-suite.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
-
import { moduleManifest, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
|
|
2
|
+
import { moduleManifest, orgId, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
|
|
3
3
|
import { ulid } from '@substrat-run/kernel';
|
|
4
4
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
|
|
5
5
|
/**
|
|
@@ -35,12 +35,19 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
35
35
|
for (const reg of contractTestInitialModules) {
|
|
36
36
|
host.registerModule(reg);
|
|
37
37
|
}
|
|
38
|
+
// The connector seam's out-of-band half (K-22 §4.2). Host code, not module
|
|
39
|
+
// code: it holds platform authority, which is exactly what a module must
|
|
40
|
+
// never have. Idempotent because delivery is at-least-once.
|
|
41
|
+
host.registerExecutor('member-adder', 'member.add-requested', async (admin, event) => {
|
|
42
|
+
const p = event.payload;
|
|
43
|
+
await admin.addMember(staff, p.tenantId, p.principal, p.orgId);
|
|
44
|
+
});
|
|
38
45
|
// A scope requires an existing active tenant (§4.1) — create then provision.
|
|
39
46
|
await host.admin.createTenant(staff, { id: t1, slug: 'tenant-one', name: 'Tenant One' });
|
|
40
47
|
await host.admin.createTenant(staff, { id: t2, slug: 'tenant-two', name: 'Tenant Two' });
|
|
41
48
|
// Entitlements are default-deny (§4.3): t1 invokes these modules' operations,
|
|
42
49
|
// so it must hold their SKU flags. (t2 only exercises bare, ungated ops.)
|
|
43
|
-
for (const key of ['testmod', 'flow', 'guarded', 'victim', 'late']) {
|
|
50
|
+
for (const key of ['testmod', 'flow', 'guarded', 'victim', 'late', 'connector']) {
|
|
44
51
|
await host.admin.grantEntitlement(staff, t1, key);
|
|
45
52
|
}
|
|
46
53
|
await host.provisionScope(staff, { tenantId: t1, scopeId: s1, jurisdiction: 'eu' });
|
|
@@ -133,6 +140,317 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
133
140
|
const journal = await stub.invoke('testmod/read-journal');
|
|
134
141
|
expect(journal).toContainEqual({ module_id: '@test/late', version: '0001-init' });
|
|
135
142
|
});
|
|
143
|
+
// -- the connector seam (K-22 §4.2) --------------------------------------
|
|
144
|
+
// A module asks, inside its transaction, for an effect it cannot perform:
|
|
145
|
+
// membership is tenant-wide and lives in the directory, outside this scope's
|
|
146
|
+
// serialization domain. A privileged executor effects it through HostAdmin.
|
|
147
|
+
it('effects a module\'s request through an executor, and correlates the trail', async () => {
|
|
148
|
+
const org = orgId.parse(ulid());
|
|
149
|
+
const joiner = principalId.parse(ulid());
|
|
150
|
+
await host.admin.createOrg(staff, { id: org, tenantId: t1, slug: 'seam', name: 'Seam' });
|
|
151
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
152
|
+
await stub.invoke('connector/request-member', { principal: joiner, orgId: org });
|
|
153
|
+
// Prompt dispatch: effected by the time the request returns, not on a timer.
|
|
154
|
+
const members = await host.admin.listMembers(staff, t1, org);
|
|
155
|
+
expect(members.map((m) => m.principal)).toEqual([joiner]);
|
|
156
|
+
// The two halves join. The executor's admin row carries the id of the event
|
|
157
|
+
// that caused it, which is what stops the split trail being unreadable —
|
|
158
|
+
// control-plane.md §3 named that as the main cost of this pattern.
|
|
159
|
+
const added = (await host.admin.auditLog(staff, { tenantId: t1 })).find((e) => e.action === 'addMember' && JSON.stringify(e.after).includes(joiner));
|
|
160
|
+
expect(added?.causedBy).toEqual(expect.any(String));
|
|
161
|
+
// A staff member acting directly caused nothing but themselves.
|
|
162
|
+
const orgRow = (await host.admin.auditLog(staff, { tenantId: t1 })).find((e) => e.action === 'createOrg');
|
|
163
|
+
expect(orgRow?.causedBy).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
it('effects nothing when the emitting transaction rolls back', async () => {
|
|
166
|
+
// The property the connector is chosen FOR. `ctx.emit` commits with the
|
|
167
|
+
// domain write, so a rollback leaves no event and nothing to effect. An
|
|
168
|
+
// in-scope cross-DO write could not offer this: it could land in the
|
|
169
|
+
// directory and then be orphaned by the scope's rollback.
|
|
170
|
+
const org = orgId.parse(ulid());
|
|
171
|
+
const ghost = principalId.parse(ulid());
|
|
172
|
+
await host.admin.createOrg(staff, { id: org, tenantId: t1, slug: 'ghost', name: 'Ghost' });
|
|
173
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
174
|
+
await expect(stub.invoke('connector/request-and-throw', { principal: ghost, orgId: org })).rejects.toThrow(/deliberate failure/);
|
|
175
|
+
expect(await host.admin.listMembers(staff, t1, org)).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
it('delivers each event to an executor exactly once', async () => {
|
|
178
|
+
// At-least-once with a journal, so a second dispatch pass must not re-effect.
|
|
179
|
+
// Membership is idempotent anyway; the audit trail is what would show a
|
|
180
|
+
// double-run, and it must not.
|
|
181
|
+
const org = orgId.parse(ulid());
|
|
182
|
+
const once = principalId.parse(ulid());
|
|
183
|
+
await host.admin.createOrg(staff, { id: org, tenantId: t1, slug: 'once', name: 'Once' });
|
|
184
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
185
|
+
await stub.invoke('connector/request-member', { principal: once, orgId: org });
|
|
186
|
+
// Another operation on the same scope drains the outbox again.
|
|
187
|
+
await stub.invoke('connector/requests');
|
|
188
|
+
const rows = (await host.admin.auditLog(staff, { tenantId: t1 })).filter((e) => e.action === 'addMember' && JSON.stringify(e.after).includes(once));
|
|
189
|
+
expect(rows).toHaveLength(1);
|
|
190
|
+
});
|
|
191
|
+
// -- vertical + version registry (#31) -----------------------------------
|
|
192
|
+
// A scope binds to a VERSION, so dev/staging/prod are the same vertical pinned
|
|
193
|
+
// differently. The invariant that earns the registry its keep is that a push
|
|
194
|
+
// is not a deploy.
|
|
195
|
+
it('publishes a version as pending, and refuses to bind it until admitted', async () => {
|
|
196
|
+
const versionId = ulid();
|
|
197
|
+
await host.admin.registerVertical(staff, {
|
|
198
|
+
slug: 'callout',
|
|
199
|
+
name: 'Callout',
|
|
200
|
+
source: 'builtin',
|
|
201
|
+
});
|
|
202
|
+
await host.admin.publishVersion(staff, {
|
|
203
|
+
id: versionId,
|
|
204
|
+
verticalSlug: 'callout',
|
|
205
|
+
version: '1.0.0',
|
|
206
|
+
manifestDigest: 'm1',
|
|
207
|
+
permissionDigest: 'p1',
|
|
208
|
+
migrationDigest: 'g1',
|
|
209
|
+
deploymentRef: null,
|
|
210
|
+
});
|
|
211
|
+
const [published] = await host.admin.listVersions(staff, 'callout');
|
|
212
|
+
expect(published?.admission).toBe('pending');
|
|
213
|
+
// The refusal the registry exists for. Without it "a push lands pending" is
|
|
214
|
+
// a convention, and D-30's lockstep-upgrade argument is that conventions are
|
|
215
|
+
// what we cannot afford here.
|
|
216
|
+
await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/pending, not admitted/);
|
|
217
|
+
await host.admin.admitVersion(staff, versionId);
|
|
218
|
+
await host.admin.bindScopeVersion(staff, t1, s1, versionId);
|
|
219
|
+
const scope = await host.admin.getScopeRecord(staff, t1, s1);
|
|
220
|
+
expect(scope?.verticalVersionId).toBe(versionId);
|
|
221
|
+
// The slug is denormalized alongside, so the console and the audit target
|
|
222
|
+
// keep reading a label that survives the version being superseded.
|
|
223
|
+
expect(scope?.vertical).toBe('callout');
|
|
224
|
+
});
|
|
225
|
+
it('refuses to bind a rejected version, and rejection is terminal', async () => {
|
|
226
|
+
const versionId = ulid();
|
|
227
|
+
await host.admin.publishVersion(staff, {
|
|
228
|
+
id: versionId,
|
|
229
|
+
verticalSlug: 'callout',
|
|
230
|
+
version: '1.1.0-bad',
|
|
231
|
+
manifestDigest: 'm2',
|
|
232
|
+
permissionDigest: 'p2',
|
|
233
|
+
migrationDigest: 'g2',
|
|
234
|
+
deploymentRef: null,
|
|
235
|
+
});
|
|
236
|
+
await host.admin.rejectVersion(staff, versionId, 'permission diff widened a role');
|
|
237
|
+
await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/rejected, not admitted/);
|
|
238
|
+
// Terminal: a rejected version is not resurrected, a new one is published.
|
|
239
|
+
await expect(host.admin.admitVersion(staff, versionId)).rejects.toThrow(/was rejected/);
|
|
240
|
+
const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
|
|
241
|
+
expect(rejected?.admissionNote).toContain('widened a role');
|
|
242
|
+
});
|
|
243
|
+
it('carries the digests promotion compares', async () => {
|
|
244
|
+
// "Has the permission surface changed between what is in prod and what I am
|
|
245
|
+
// promoting?" is a string comparison here. Today it is a person remembering
|
|
246
|
+
// to look, and a checkpoint that can be skipped is not a checkpoint.
|
|
247
|
+
const versions = await host.admin.listVersions(staff, 'callout');
|
|
248
|
+
for (const v of versions) {
|
|
249
|
+
expect(v.manifestDigest).toEqual(expect.any(String));
|
|
250
|
+
expect(v.permissionDigest).toEqual(expect.any(String));
|
|
251
|
+
expect(v.migrationDigest).toEqual(expect.any(String));
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
it('refuses a version for a vertical nobody registered', async () => {
|
|
255
|
+
await expect(host.admin.publishVersion(staff, {
|
|
256
|
+
id: ulid(),
|
|
257
|
+
verticalSlug: 'ghost',
|
|
258
|
+
version: '1.0.0',
|
|
259
|
+
manifestDigest: 'm',
|
|
260
|
+
permissionDigest: 'p',
|
|
261
|
+
migrationDigest: 'g',
|
|
262
|
+
deploymentRef: null,
|
|
263
|
+
})).rejects.toThrow(/unknown vertical/);
|
|
264
|
+
});
|
|
265
|
+
// -- channels and promotion-time checkpoints (#31 step 2) ----------------
|
|
266
|
+
// Promotion is the moment a change reaches anyone, so it is where §4's two
|
|
267
|
+
// human checkpoints belong. Today they are a merge-time convention: CI renders
|
|
268
|
+
// the diffs and a human is expected to look, but nothing ties that looking to
|
|
269
|
+
// the moment of exposure.
|
|
270
|
+
const publish = async (version, digests) => {
|
|
271
|
+
const id = ulid();
|
|
272
|
+
await host.admin.publishVersion(staff, {
|
|
273
|
+
id,
|
|
274
|
+
verticalSlug: 'callout',
|
|
275
|
+
version,
|
|
276
|
+
manifestDigest: `man-${version}`,
|
|
277
|
+
permissionDigest: digests.perm,
|
|
278
|
+
migrationDigest: digests.mig,
|
|
279
|
+
deploymentRef: null,
|
|
280
|
+
});
|
|
281
|
+
await host.admin.admitVersion(staff, id);
|
|
282
|
+
return id;
|
|
283
|
+
};
|
|
284
|
+
it('promotes a first version with nothing to acknowledge', async () => {
|
|
285
|
+
// The gate is about CHANGE, not existence. A first promotion has no
|
|
286
|
+
// predecessor to diff against, so demanding an acknowledgement would be
|
|
287
|
+
// ceremony rather than review.
|
|
288
|
+
const v1 = await publish('2.0.0', { perm: 'pA', mig: 'gA' });
|
|
289
|
+
await host.admin.promoteVersion(staff, 'callout', 'prod', v1);
|
|
290
|
+
const channels = await host.admin.listChannels(staff, 'callout');
|
|
291
|
+
expect(channels.find((c) => c.channel === 'prod')?.versionId).toBe(v1);
|
|
292
|
+
});
|
|
293
|
+
it('refuses a promotion that changes permissions until it is acknowledged', async () => {
|
|
294
|
+
const v2 = await publish('2.1.0', { perm: 'pB', mig: 'gA' }); // permissions moved
|
|
295
|
+
await expect(host.admin.promoteVersion(staff, 'callout', 'prod', v2)).rejects.toThrow(/changes the permission surface/);
|
|
296
|
+
// Still pointing at the old one — a refused promotion changes nothing.
|
|
297
|
+
const before = await host.admin.listChannels(staff, 'callout');
|
|
298
|
+
expect(before.find((c) => c.channel === 'prod')?.versionId).not.toBe(v2);
|
|
299
|
+
await host.admin.promoteVersion(staff, 'callout', 'prod', v2, { permissionChange: true });
|
|
300
|
+
const after = await host.admin.listChannels(staff, 'callout');
|
|
301
|
+
expect(after.find((c) => c.channel === 'prod')?.versionId).toBe(v2);
|
|
302
|
+
});
|
|
303
|
+
it('refuses a promotion that changes migrations until it is acknowledged', async () => {
|
|
304
|
+
const v3 = await publish('2.2.0', { perm: 'pB', mig: 'gB' }); // migrations moved
|
|
305
|
+
await expect(host.admin.promoteVersion(staff, 'callout', 'prod', v3)).rejects.toThrow(/changes migrations/);
|
|
306
|
+
await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true });
|
|
307
|
+
expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')
|
|
308
|
+
?.versionId).toBe(v3);
|
|
309
|
+
});
|
|
310
|
+
it('records the acknowledgement, so review is evidence rather than a claim', async () => {
|
|
311
|
+
const promotions = (await host.admin.auditLog(staff, {})).filter((e) => e.action === 'promoteVersion');
|
|
312
|
+
const acknowledged = promotions.filter((e) => JSON.stringify(e.after).includes('"permissionChange":true'));
|
|
313
|
+
// D-32's compliance product has to produce evidence that a control operated.
|
|
314
|
+
// "Someone reviewed the permission change" is exactly such a control, and
|
|
315
|
+
// this row is what makes it checkable after the fact.
|
|
316
|
+
expect(acknowledged.length).toBeGreaterThan(0);
|
|
317
|
+
expect(acknowledged[0].actor).toBe(staff);
|
|
318
|
+
});
|
|
319
|
+
it('promotes per channel — dev moving does not move prod', async () => {
|
|
320
|
+
// The whole point of channels: the same vertical at different versions.
|
|
321
|
+
const preview = await publish('2.3.0-preview', { perm: 'pC', mig: 'gC' });
|
|
322
|
+
const prodBefore = (await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId;
|
|
323
|
+
await host.admin.promoteVersion(staff, 'callout', 'dev', preview);
|
|
324
|
+
const channels = await host.admin.listChannels(staff, 'callout');
|
|
325
|
+
expect(channels.find((c) => c.channel === 'dev')?.versionId).toBe(preview);
|
|
326
|
+
expect(channels.find((c) => c.channel === 'prod')?.versionId).toBe(prodBefore);
|
|
327
|
+
});
|
|
328
|
+
it('refuses to promote a version that was never admitted', async () => {
|
|
329
|
+
const pending = ulid();
|
|
330
|
+
await host.admin.publishVersion(staff, {
|
|
331
|
+
id: pending,
|
|
332
|
+
verticalSlug: 'callout',
|
|
333
|
+
version: '9.9.9',
|
|
334
|
+
manifestDigest: 'm',
|
|
335
|
+
permissionDigest: 'p',
|
|
336
|
+
migrationDigest: 'g',
|
|
337
|
+
deploymentRef: null,
|
|
338
|
+
});
|
|
339
|
+
await expect(host.admin.promoteVersion(staff, 'callout', 'dev', pending)).rejects.toThrow(/pending, not admitted/);
|
|
340
|
+
});
|
|
341
|
+
// -- the hostname map (K-26) ---------------------------------------------
|
|
342
|
+
// §4.2 provisions a scope; this is what finally gives it a URL. The router
|
|
343
|
+
// resolves against these rows before dispatching to a vertical's worker.
|
|
344
|
+
it('binds a hostname as pending, and resolves nothing until it is active', async () => {
|
|
345
|
+
await host.admin.bindHostname(staff, {
|
|
346
|
+
hostname: 'acme.example.com',
|
|
347
|
+
tenantId: t1,
|
|
348
|
+
scopeId: s1,
|
|
349
|
+
surface: 'app',
|
|
350
|
+
region: null,
|
|
351
|
+
canonical: true,
|
|
352
|
+
});
|
|
353
|
+
// A custom domain is DNS validation and cert issuance, not a string somebody
|
|
354
|
+
// sets — so it does not serve until those finish.
|
|
355
|
+
expect(await host.admin.resolveHostname('acme.example.com')).toBeUndefined();
|
|
356
|
+
await host.admin.setHostnameStatus(staff, 'acme.example.com', 'verifying');
|
|
357
|
+
expect(await host.admin.resolveHostname('acme.example.com')).toBeUndefined();
|
|
358
|
+
await host.admin.setHostnameStatus(staff, 'acme.example.com', 'active');
|
|
359
|
+
expect(await host.admin.resolveHostname('acme.example.com')).toMatchObject({
|
|
360
|
+
tenantId: t1,
|
|
361
|
+
scopeId: s1,
|
|
362
|
+
surface: 'app',
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
it('routes two surfaces of ONE scope to different hostnames', async () => {
|
|
366
|
+
// The reason §5.5's one-hostname-per-scope was not enough: the shop fronts a
|
|
367
|
+
// storefront and a back office from the same data, and RallyPoint a player
|
|
368
|
+
// app and a manager console.
|
|
369
|
+
for (const [hostname, surface] of [
|
|
370
|
+
['shop.example.com', 'storefront'],
|
|
371
|
+
['admin.shop.example.com', 'back-office'],
|
|
372
|
+
]) {
|
|
373
|
+
await host.admin.bindHostname(staff, {
|
|
374
|
+
hostname: hostname,
|
|
375
|
+
tenantId: t1,
|
|
376
|
+
scopeId: s1,
|
|
377
|
+
surface: surface,
|
|
378
|
+
region: null,
|
|
379
|
+
canonical: true,
|
|
380
|
+
});
|
|
381
|
+
await host.admin.setHostnameStatus(staff, hostname, 'active');
|
|
382
|
+
}
|
|
383
|
+
expect((await host.admin.resolveHostname('shop.example.com'))?.surface).toBe('storefront');
|
|
384
|
+
expect((await host.admin.resolveHostname('admin.shop.example.com'))?.surface).toBe('back-office');
|
|
385
|
+
});
|
|
386
|
+
it('keeps exactly one canonical hostname per surface', async () => {
|
|
387
|
+
// "Which one do certs and redirects use" has to have one answer, so a new
|
|
388
|
+
// canonical demotes the old rather than producing two.
|
|
389
|
+
await host.admin.bindHostname(staff, {
|
|
390
|
+
hostname: 'alias.example.com',
|
|
391
|
+
tenantId: t1,
|
|
392
|
+
scopeId: s1,
|
|
393
|
+
surface: 'app',
|
|
394
|
+
region: null,
|
|
395
|
+
canonical: true,
|
|
396
|
+
});
|
|
397
|
+
const forApp = (await host.admin.listHostnames(staff, { scopeId: s1 })).filter((h) => h.surface === 'app');
|
|
398
|
+
expect(forApp.filter((h) => h.canonical).map((h) => h.hostname)).toEqual([
|
|
399
|
+
'alias.example.com',
|
|
400
|
+
]);
|
|
401
|
+
// The demoted one is still bound — an alias, not a deletion.
|
|
402
|
+
expect(forApp.map((h) => h.hostname)).toContain('acme.example.com');
|
|
403
|
+
});
|
|
404
|
+
it('treats hostname case as insignificant, because DNS does', async () => {
|
|
405
|
+
// Otherwise two scopes could each hold "the same" name and a request would
|
|
406
|
+
// resolve to whichever casing it arrived in.
|
|
407
|
+
await host.admin.bindHostname(staff, {
|
|
408
|
+
hostname: 'MiXeD.Example.COM',
|
|
409
|
+
tenantId: t1,
|
|
410
|
+
scopeId: s1,
|
|
411
|
+
surface: 'app',
|
|
412
|
+
region: null,
|
|
413
|
+
canonical: false,
|
|
414
|
+
});
|
|
415
|
+
await host.admin.setHostnameStatus(staff, 'mixed.EXAMPLE.com', 'active');
|
|
416
|
+
expect((await host.admin.resolveHostname('MIXED.example.com'))?.scopeId).toBe(s1);
|
|
417
|
+
expect((await host.admin.listHostnames(staff, { scopeId: s1 })).map((h) => h.hostname)).toContain('mixed.example.com');
|
|
418
|
+
});
|
|
419
|
+
it('refuses to move a hostname to another scope', async () => {
|
|
420
|
+
// A hostname routes to exactly one place. Silently rebinding would move
|
|
421
|
+
// another tenant's traffic.
|
|
422
|
+
await expect(host.admin.bindHostname(staff, {
|
|
423
|
+
hostname: 'acme.example.com',
|
|
424
|
+
tenantId: t2,
|
|
425
|
+
scopeId: s2,
|
|
426
|
+
surface: 'app',
|
|
427
|
+
region: null,
|
|
428
|
+
canonical: false,
|
|
429
|
+
})).rejects.toThrow(/already bound to another scope/);
|
|
430
|
+
});
|
|
431
|
+
it('carries the region, which is what Regional Services is set from', async () => {
|
|
432
|
+
// Residency is per hostname, which is why it lives here rather than in a
|
|
433
|
+
// router deployed per jurisdiction (K-26).
|
|
434
|
+
await host.admin.bindHostname(staff, {
|
|
435
|
+
hostname: 'eu.example.com',
|
|
436
|
+
tenantId: t1,
|
|
437
|
+
scopeId: s1,
|
|
438
|
+
surface: 'app',
|
|
439
|
+
region: 'eu',
|
|
440
|
+
canonical: false,
|
|
441
|
+
});
|
|
442
|
+
await host.admin.setHostnameStatus(staff, 'eu.example.com', 'active');
|
|
443
|
+
expect((await host.admin.resolveHostname('eu.example.com'))?.region).toBe('eu');
|
|
444
|
+
});
|
|
445
|
+
it('records a failed hostname with the reason, rather than losing it', async () => {
|
|
446
|
+
await host.admin.setHostnameStatus(staff, 'eu.example.com', 'failed', 'DNS validation timed out');
|
|
447
|
+
const row = (await host.admin.listHostnames(staff, { scopeId: s1 })).find((h) => h.hostname === 'eu.example.com');
|
|
448
|
+
expect(row?.status).toBe('failed');
|
|
449
|
+
expect(row?.statusNote).toContain('DNS validation');
|
|
450
|
+
// And it stops serving — "broken" and "not yet working" are different states,
|
|
451
|
+
// but neither of them routes traffic.
|
|
452
|
+
expect(await host.admin.resolveHostname('eu.example.com')).toBeUndefined();
|
|
453
|
+
});
|
|
136
454
|
it('rejects duplicate module registration', () => {
|
|
137
455
|
expect(() => host.registerModule({ manifest: testModManifest })).toThrow(/already registered/);
|
|
138
456
|
});
|
|
@@ -228,14 +546,14 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
228
546
|
it('creates a tenant record, idempotently; only real creates are audited', async () => {
|
|
229
547
|
await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' });
|
|
230
548
|
await host.admin.createTenant(staff, { id: t3, slug: 'acme-co', name: 'Acme Co' }); // no-op
|
|
231
|
-
expect(await host.admin.getTenant(t3)).toMatchObject({
|
|
549
|
+
expect(await host.admin.getTenant(staff, t3)).toMatchObject({
|
|
232
550
|
id: t3,
|
|
233
551
|
slug: 'acme-co',
|
|
234
552
|
name: 'Acme Co',
|
|
235
553
|
status: 'active',
|
|
236
554
|
});
|
|
237
|
-
expect((await host.admin.listTenants()).filter((x) => x.id === t3)).toHaveLength(1);
|
|
238
|
-
const creates = (await host.admin.auditLog({ tenantId: t3 })).filter((r) => r.action === 'createTenant');
|
|
555
|
+
expect((await host.admin.listTenants(staff)).filter((x) => x.id === t3)).toHaveLength(1);
|
|
556
|
+
const creates = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'createTenant');
|
|
239
557
|
expect(creates).toHaveLength(1); // the idempotent no-op left no row
|
|
240
558
|
expect(creates[0].actor).toBe(staff);
|
|
241
559
|
});
|
|
@@ -248,7 +566,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
248
566
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
249
567
|
});
|
|
250
568
|
it('records setTenantStatus with before/after status', async () => {
|
|
251
|
-
const transitions = (await host.admin.auditLog({ tenantId: t3 })).filter((r) => r.action === 'setTenantStatus');
|
|
569
|
+
const transitions = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'setTenantStatus');
|
|
252
570
|
expect(transitions.length).toBeGreaterThanOrEqual(2);
|
|
253
571
|
const suspend = transitions.find((r) => r.after.status === 'suspended');
|
|
254
572
|
expect(suspend.before.status).toBe('active');
|
|
@@ -274,7 +592,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
274
592
|
await expect(host.getScope(alice, t3, s3)).rejects.toThrow(/scope not active/);
|
|
275
593
|
await host.admin.unarchiveScope(staff, t3, s3);
|
|
276
594
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
277
|
-
const transitions = (await host.admin.auditLog({ tenantId: t3 })).filter((r) => r.action === 'archiveScope' || r.action === 'unarchiveScope');
|
|
595
|
+
const transitions = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'archiveScope' || r.action === 'unarchiveScope');
|
|
278
596
|
expect(transitions.map((r) => r.action)).toEqual(expect.arrayContaining(['archiveScope', 'unarchiveScope']));
|
|
279
597
|
for (const r of transitions) {
|
|
280
598
|
expect(r.scopeId).toBe(s3);
|
|
@@ -299,22 +617,22 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
299
617
|
// Granting the flag loads the module for this tenant.
|
|
300
618
|
await host.admin.grantEntitlement(staff, t4, 'billed');
|
|
301
619
|
await expect(stub.invoke('billed/act')).resolves.toBe('ran');
|
|
302
|
-
expect(await host.admin.listEntitlements(t4)).toContain('billed');
|
|
620
|
+
expect(await host.admin.listEntitlements(staff, t4)).toContain('billed');
|
|
303
621
|
// Revoking it takes the operation away again — as if never registered.
|
|
304
622
|
await host.admin.revokeEntitlement(staff, t4, 'billed');
|
|
305
623
|
await expect(stub.invoke('billed/act')).rejects.toThrow(/not entitled/);
|
|
306
|
-
expect(await host.admin.listEntitlements(t4)).not.toContain('billed');
|
|
624
|
+
expect(await host.admin.listEntitlements(staff, t4)).not.toContain('billed');
|
|
307
625
|
});
|
|
308
626
|
it('audits grant/revoke idempotently and records the SKU flag', async () => {
|
|
309
627
|
// Re-grant twice: only the first is a real change, so only one row.
|
|
310
628
|
await host.admin.grantEntitlement(staff, t4, 'audited-sku');
|
|
311
629
|
await host.admin.grantEntitlement(staff, t4, 'audited-sku');
|
|
312
|
-
const grants = (await host.admin.auditLog({ tenantId: t4 })).filter((r) => r.action === 'grantEntitlement' &&
|
|
630
|
+
const grants = (await host.admin.auditLog(staff, { tenantId: t4 })).filter((r) => r.action === 'grantEntitlement' &&
|
|
313
631
|
r.after.entitlementKey === 'audited-sku');
|
|
314
632
|
expect(grants).toHaveLength(1);
|
|
315
633
|
expect(grants[0].actor).toBe(staff);
|
|
316
634
|
await host.admin.revokeEntitlement(staff, t4, 'audited-sku');
|
|
317
|
-
const revokes = (await host.admin.auditLog({ tenantId: t4 })).filter((r) => r.action === 'revokeEntitlement');
|
|
635
|
+
const revokes = (await host.admin.auditLog(staff, { tenantId: t4 })).filter((r) => r.action === 'revokeEntitlement');
|
|
318
636
|
expect(revokes.length).toBeGreaterThanOrEqual(1);
|
|
319
637
|
expect(revokes[revokes.length - 1].before.entitlementKey).toBe('audited-sku');
|
|
320
638
|
});
|
|
@@ -332,7 +650,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
332
650
|
await host.admin.createTenant(staff, { id: t5, slug: 'directory-co', name: 'Directory Co' });
|
|
333
651
|
const bare = scopeId.parse(ulid());
|
|
334
652
|
await host.provisionScope(staff, { tenantId: t5, scopeId: bare });
|
|
335
|
-
const rec = await host.admin.getScopeRecord(t5, bare);
|
|
653
|
+
const rec = await host.admin.getScopeRecord(staff, t5, bare);
|
|
336
654
|
// A ULID lowercases into a valid slug, so the placeholder is unique by
|
|
337
655
|
// construction — every pre-existing caller provisions without naming.
|
|
338
656
|
expect(rec).toMatchObject({
|
|
@@ -358,7 +676,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
358
676
|
vertical: 'housing',
|
|
359
677
|
jurisdiction: 'eu',
|
|
360
678
|
});
|
|
361
|
-
expect(await host.admin.getScopeRecord(t5, named)).toMatchObject({
|
|
679
|
+
expect(await host.admin.getScopeRecord(staff, t5, named)).toMatchObject({
|
|
362
680
|
slug: 'brf-vasastan',
|
|
363
681
|
kind: 'brf',
|
|
364
682
|
name: 'Brf Vasastan',
|
|
@@ -371,7 +689,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
371
689
|
await expect(host.provisionScope(staff, { tenantId: t5, scopeId: other, slug: 'brf-vasastan' })).rejects.toThrow(/already taken/);
|
|
372
690
|
// Idempotency is keyed on the scope id, so re-provisioning the SAME scope
|
|
373
691
|
// must not collide with its own slug.
|
|
374
|
-
const named = (await host.admin.listScopes({ tenantId: t5 })).find((s) => s.slug === 'brf-vasastan');
|
|
692
|
+
const named = (await host.admin.listScopes(staff, { tenantId: t5 })).find((s) => s.slug === 'brf-vasastan');
|
|
375
693
|
await expect(host.provisionScope(staff, { tenantId: t5, scopeId: named.id, slug: 'brf-vasastan' })).resolves.toBeUndefined();
|
|
376
694
|
});
|
|
377
695
|
it('scopes slug uniqueness to the tenant, not the fleet (§3.2)', async () => {
|
|
@@ -390,108 +708,108 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
390
708
|
})).rejects.toThrow(/already taken/);
|
|
391
709
|
});
|
|
392
710
|
it('enumerates the scopes under a tenant, and filters by status (§4.5)', async () => {
|
|
393
|
-
const all = await host.admin.listScopes({ tenantId: t5 });
|
|
711
|
+
const all = await host.admin.listScopes(staff, { tenantId: t5 });
|
|
394
712
|
expect(all.length).toBeGreaterThanOrEqual(2);
|
|
395
713
|
expect(all.every((s) => s.tenantId === t5)).toBe(true);
|
|
396
714
|
// Ordered by scope id — ULID order is chronological.
|
|
397
715
|
expect(all.map((s) => s.id)).toEqual([...all.map((s) => s.id)].sort());
|
|
398
716
|
const target = all[0];
|
|
399
717
|
await host.admin.suspendScope(staff, t5, target.id);
|
|
400
|
-
const suspended = await host.admin.listScopes({ tenantId: t5, status: 'suspended' });
|
|
718
|
+
const suspended = await host.admin.listScopes(staff, { tenantId: t5, status: 'suspended' });
|
|
401
719
|
expect(suspended.map((s) => s.id)).toEqual([target.id]);
|
|
402
720
|
// Several statuses at once — the console's All / Suspended / Archived tabs.
|
|
403
|
-
const both = await host.admin.listScopes({
|
|
721
|
+
const both = await host.admin.listScopes(staff, {
|
|
404
722
|
tenantId: t5,
|
|
405
723
|
status: ['active', 'suspended'],
|
|
406
724
|
});
|
|
407
725
|
expect(both.length).toBe(all.length);
|
|
408
726
|
// An empty status list means "no status is acceptable" — it must match
|
|
409
727
|
// nothing, never degenerate into an unfiltered read of the whole fleet.
|
|
410
|
-
expect(await host.admin.listScopes({ tenantId: t5, status: [] })).toEqual([]);
|
|
728
|
+
expect(await host.admin.listScopes(staff, { tenantId: t5, status: [] })).toEqual([]);
|
|
411
729
|
await host.admin.unsuspendScope(staff, t5, target.id);
|
|
412
730
|
});
|
|
413
731
|
it('lists the whole fleet across tenants when unfiltered (§4.5)', async () => {
|
|
414
|
-
const fleet = await host.admin.listScopes();
|
|
732
|
+
const fleet = await host.admin.listScopes(staff);
|
|
415
733
|
const tenants = new Set(fleet.map((s) => s.tenantId));
|
|
416
734
|
expect(tenants.size).toBeGreaterThan(1);
|
|
417
|
-
expect(fleet.length).toBeGreaterThanOrEqual((await host.admin.listScopes({ tenantId: t5 })).length);
|
|
735
|
+
expect(fleet.length).toBeGreaterThanOrEqual((await host.admin.listScopes(staff, { tenantId: t5 })).length);
|
|
418
736
|
});
|
|
419
737
|
it('filters the fleet by vertical (§4.5)', async () => {
|
|
420
|
-
const housing = await host.admin.listScopes({ vertical: 'housing' });
|
|
738
|
+
const housing = await host.admin.listScopes(staff, { vertical: 'housing' });
|
|
421
739
|
expect(housing.length).toBeGreaterThanOrEqual(1);
|
|
422
740
|
expect(housing.every((s) => s.vertical === 'housing')).toBe(true);
|
|
423
741
|
});
|
|
424
742
|
it('fails closed reading a scope record on a mismatched pair (K-3)', async () => {
|
|
425
|
-
const [any] = await host.admin.listScopes({ tenantId: t5 });
|
|
743
|
+
const [any] = await host.admin.listScopes(staff, { tenantId: t5 });
|
|
426
744
|
// The scope exists — but not under t1. It must read as absent, never as
|
|
427
745
|
// itself: the same rule getScope applies when minting a stub.
|
|
428
|
-
expect(await host.admin.getScopeRecord(t1, any.id)).toBeUndefined();
|
|
429
|
-
expect(await host.admin.getScopeRecord(t5, scopeId.parse(ulid()))).toBeUndefined();
|
|
746
|
+
expect(await host.admin.getScopeRecord(staff, t1, any.id)).toBeUndefined();
|
|
747
|
+
expect(await host.admin.getScopeRecord(staff, t5, scopeId.parse(ulid()))).toBeUndefined();
|
|
430
748
|
});
|
|
431
749
|
it('projects the applied-migration count into the directory (§5.4)', async () => {
|
|
432
750
|
// schema_version shipped as a column and was written by nothing — always
|
|
433
751
|
// '0'. Registered modules carry migrations, so a provisioned scope must
|
|
434
752
|
// report a count, which is what makes "which scopes are behind" answerable
|
|
435
753
|
// from the index without fanning out.
|
|
436
|
-
const [any] = await host.admin.listScopes({ tenantId: t5 });
|
|
754
|
+
const [any] = await host.admin.listScopes(staff, { tenantId: t5 });
|
|
437
755
|
expect(Number(any.schemaVersion)).toBeGreaterThan(0);
|
|
438
756
|
});
|
|
439
757
|
it('stamps the audit target with the scope vertical for lifecycle actions (§4.4)', async () => {
|
|
440
758
|
// `vertical` was plumbed end-to-end and passed by no call site — every row
|
|
441
759
|
// was null. A lifecycle action on a scope that names one must carry it.
|
|
442
|
-
const named = (await host.admin.listScopes({ tenantId: t5 })).find((s) => s.vertical === 'housing');
|
|
760
|
+
const named = (await host.admin.listScopes(staff, { tenantId: t5 })).find((s) => s.vertical === 'housing');
|
|
443
761
|
await host.admin.suspendScope(staff, t5, named.id);
|
|
444
|
-
const rows = (await host.admin.auditLog({ tenantId: t5, scopeId: named.id })).filter((r) => r.action === 'suspendScope');
|
|
762
|
+
const rows = (await host.admin.auditLog(staff, { tenantId: t5, scopeId: named.id })).filter((r) => r.action === 'suspendScope');
|
|
445
763
|
expect(rows).toHaveLength(1);
|
|
446
764
|
expect(rows[0].vertical).toBe('housing');
|
|
447
765
|
await host.admin.unsuspendScope(staff, t5, named.id);
|
|
448
766
|
});
|
|
449
767
|
// -- the admin audit log, read side (control-plane.md §4.4/§4.5) ----------
|
|
450
768
|
it('narrows the audit log by scope, actor and action (§4.5)', async () => {
|
|
451
|
-
const [any] = await host.admin.listScopes({ tenantId: t5 });
|
|
452
|
-
const byScope = await host.admin.auditLog({ tenantId: t5, scopeId: any.id });
|
|
769
|
+
const [any] = await host.admin.listScopes(staff, { tenantId: t5 });
|
|
770
|
+
const byScope = await host.admin.auditLog(staff, { tenantId: t5, scopeId: any.id });
|
|
453
771
|
expect(byScope.length).toBeGreaterThan(0);
|
|
454
772
|
expect(byScope.every((r) => r.scopeId === any.id)).toBe(true);
|
|
455
|
-
const byActor = await host.admin.auditLog({ tenantId: t5, actor: staff });
|
|
773
|
+
const byActor = await host.admin.auditLog(staff, { tenantId: t5, actor: staff });
|
|
456
774
|
expect(byActor.length).toBeGreaterThan(0);
|
|
457
775
|
expect(byActor.every((r) => r.actor === staff)).toBe(true);
|
|
458
776
|
// A different actor shares the log and must not see these rows.
|
|
459
|
-
expect(await host.admin.auditLog({ actor: platformActorId.parse(ulid()) })).toEqual([]);
|
|
460
|
-
const lifecycle = await host.admin.auditLog({
|
|
777
|
+
expect(await host.admin.auditLog(staff, { actor: platformActorId.parse(ulid()) })).toEqual([]);
|
|
778
|
+
const lifecycle = await host.admin.auditLog(staff, {
|
|
461
779
|
tenantId: t5,
|
|
462
780
|
action: ['suspendScope', 'unsuspendScope'],
|
|
463
781
|
});
|
|
464
782
|
expect(lifecycle.length).toBeGreaterThan(0);
|
|
465
783
|
expect(lifecycle.every((r) => r.action === 'suspendScope' || r.action === 'unsuspendScope')).toBe(true);
|
|
466
784
|
// Single action, not an array — both spellings are accepted.
|
|
467
|
-
const provisions = await host.admin.auditLog({ tenantId: t5, action: 'provisionScope' });
|
|
785
|
+
const provisions = await host.admin.auditLog(staff, { tenantId: t5, action: 'provisionScope' });
|
|
468
786
|
expect(provisions.every((r) => r.action === 'provisionScope')).toBe(true);
|
|
469
787
|
// Empty action list matches nothing rather than everything.
|
|
470
|
-
expect(await host.admin.auditLog({ tenantId: t5, action: [] })).toEqual([]);
|
|
788
|
+
expect(await host.admin.auditLog(staff, { tenantId: t5, action: [] })).toEqual([]);
|
|
471
789
|
});
|
|
472
790
|
it('orders oldest-first by default and newest-first on request (§4.5)', async () => {
|
|
473
|
-
const asc = await host.admin.auditLog({ tenantId: t5 });
|
|
474
|
-
const desc = await host.admin.auditLog({ tenantId: t5, order: 'desc' });
|
|
791
|
+
const asc = await host.admin.auditLog(staff, { tenantId: t5 });
|
|
792
|
+
const desc = await host.admin.auditLog(staff, { tenantId: t5, order: 'desc' });
|
|
475
793
|
expect(asc.length).toBe(desc.length);
|
|
476
794
|
expect(asc.length).toBeGreaterThan(1);
|
|
477
795
|
// The default preserves the ordering the log shipped with; the console reads desc.
|
|
478
796
|
expect(desc.map((r) => r.id)).toEqual([...asc.map((r) => r.id)].reverse());
|
|
479
797
|
});
|
|
480
798
|
it('limits and pages the audit log by cursor (§4.5)', async () => {
|
|
481
|
-
const all = await host.admin.auditLog({ tenantId: t5 });
|
|
799
|
+
const all = await host.admin.auditLog(staff, { tenantId: t5 });
|
|
482
800
|
expect(all.length).toBeGreaterThan(2);
|
|
483
|
-
const first = await host.admin.auditLog({ tenantId: t5, limit: 2 });
|
|
801
|
+
const first = await host.admin.auditLog(staff, { tenantId: t5, limit: 2 });
|
|
484
802
|
expect(first.map((r) => r.id)).toEqual(all.slice(0, 2).map((r) => r.id));
|
|
485
803
|
// The cursor IS the last entry's id — ULID order is chronological, so no
|
|
486
804
|
// separate encoding is needed. Paging forward resumes strictly after it.
|
|
487
|
-
const next = await host.admin.auditLog({
|
|
805
|
+
const next = await host.admin.auditLog(staff, {
|
|
488
806
|
tenantId: t5,
|
|
489
807
|
limit: 2,
|
|
490
808
|
cursor: first[first.length - 1].id,
|
|
491
809
|
});
|
|
492
810
|
expect(next.map((r) => r.id)).toEqual(all.slice(2, 4).map((r) => r.id));
|
|
493
811
|
// Descending pages backward from the cursor.
|
|
494
|
-
const descPage = await host.admin.auditLog({
|
|
812
|
+
const descPage = await host.admin.auditLog(staff, {
|
|
495
813
|
tenantId: t5,
|
|
496
814
|
order: 'desc',
|
|
497
815
|
limit: 2,
|
|
@@ -503,13 +821,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
503
821
|
.reverse());
|
|
504
822
|
});
|
|
505
823
|
it('bounds the audit log by time (§4.5)', async () => {
|
|
506
|
-
const all = await host.admin.auditLog({ tenantId: t5 });
|
|
824
|
+
const all = await host.admin.auditLog(staff, { tenantId: t5 });
|
|
507
825
|
const pivot = all[1].at;
|
|
508
826
|
// `since` is inclusive, `until` exclusive.
|
|
509
|
-
const since = await host.admin.auditLog({ tenantId: t5, since: pivot });
|
|
827
|
+
const since = await host.admin.auditLog(staff, { tenantId: t5, since: pivot });
|
|
510
828
|
expect(since.every((r) => r.at >= pivot)).toBe(true);
|
|
511
829
|
expect(since.some((r) => r.id === all[1].id)).toBe(true);
|
|
512
|
-
const until = await host.admin.auditLog({ tenantId: t5, until: pivot });
|
|
830
|
+
const until = await host.admin.auditLog(staff, { tenantId: t5, until: pivot });
|
|
513
831
|
expect(until.every((r) => r.at < pivot)).toBe(true);
|
|
514
832
|
});
|
|
515
833
|
});
|