@substrat-run/contract-tests 0.13.0 → 0.16.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/README.md +1 -1
- package/dist/modules.d.ts +168 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +590 -2
- package/dist/scope-host-suite.js.map +1 -1
- package/package.json +4 -4
package/dist/scope-host-suite.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
2
|
import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
|
|
3
|
-
import { connectionId, moduleManifest, orgId, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
|
|
4
|
-
import { ulid } from '@substrat-run/kernel';
|
|
3
|
+
import { connectionId, moduleManifest, orgId, AUTO_ADMISSION_NOTE, platformActorId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
|
|
4
|
+
import { runPlatformSweep, ulid } from '@substrat-run/kernel';
|
|
5
5
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
|
|
6
6
|
/**
|
|
7
7
|
* The scope-host contract suite (design doc §11). Every adapter — pure SQLite,
|
|
@@ -285,6 +285,365 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
285
285
|
const after = await host.executorDeadLetters(t1, s1);
|
|
286
286
|
expect(after).toHaveLength(before.length);
|
|
287
287
|
});
|
|
288
|
+
// -- scope data introspection: the §5.4 admin-query RPC --------------------
|
|
289
|
+
//
|
|
290
|
+
// A read-only window into a scope's OWN database (the console/dashboard Data
|
|
291
|
+
// view). The properties that matter: it sees the vertical's tables AND the
|
|
292
|
+
// spine (flagged apart), pages, refuses an unknown table, and fails closed on
|
|
293
|
+
// a mismatched (tenantId, scopeId) pair exactly as getScope does (K-3).
|
|
294
|
+
describe('scope data introspection (§5.4)', () => {
|
|
295
|
+
it('lists the scope tables, flagging the spine as system', async () => {
|
|
296
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
297
|
+
await stub.invoke('test/write-marker', { v: 'introspect-me' });
|
|
298
|
+
const tables = await host.admin.listScopeTables(staff, t1, s1);
|
|
299
|
+
const byName = new Map(tables.map((t) => [t.name, t]));
|
|
300
|
+
// The vertical's own table: present, not flagged system, with rows.
|
|
301
|
+
expect(byName.get('marker')?.system).toBe(false);
|
|
302
|
+
expect(byName.get('marker').rowCount).toBeGreaterThan(0);
|
|
303
|
+
// The spine: present and flagged system, so the UI can group it apart.
|
|
304
|
+
expect(byName.get('_substrat_outbox')?.system).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
it('reads a bounded page of a table', async () => {
|
|
307
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
308
|
+
await stub.invoke('test/write-marker', { v: 'page-row' });
|
|
309
|
+
const page = await host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit: 50, offset: 0 });
|
|
310
|
+
expect(page.columns).toContain('v');
|
|
311
|
+
const vCol = page.columns.indexOf('v');
|
|
312
|
+
expect(page.rows.map((r) => r[vCol])).toContain('page-row');
|
|
313
|
+
expect(page.rowCount).toBeGreaterThanOrEqual(page.rows.length);
|
|
314
|
+
});
|
|
315
|
+
it('respects limit (paging)', async () => {
|
|
316
|
+
const page = await host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit: 1, offset: 0 });
|
|
317
|
+
expect(page.rows.length).toBeLessThanOrEqual(1);
|
|
318
|
+
expect(page.limit).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
it('rejects an unknown table', async () => {
|
|
321
|
+
await expect(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 })).rejects.toThrow(/unknown table/);
|
|
322
|
+
});
|
|
323
|
+
it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
|
|
324
|
+
await expect(host.admin.listScopeTables(staff, t2, s1)).rejects.toThrow();
|
|
325
|
+
await expect(host.admin.readScopeTable(staff, t2, s1, { table: 'marker', limit: 50, offset: 0 })).rejects.toThrow();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
// -- the SQL console: queryScope (#219) -----------------------------------
|
|
329
|
+
//
|
|
330
|
+
// The table reads above are safe by construction; here user SQL DOES reach the
|
|
331
|
+
// scope's DB, so what the contract pins is the enforcement: any write shape is
|
|
332
|
+
// refused AND leaves no trace, results are row-capped, and the K-3 cross-check
|
|
333
|
+
// fails closed exactly as the table reads do.
|
|
334
|
+
describe('scope SQL console: queryScope (#219)', () => {
|
|
335
|
+
it('runs a read-only SELECT, joins included', async () => {
|
|
336
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
337
|
+
await stub.invoke('test/write-marker', { v: 'console-row' });
|
|
338
|
+
const result = await host.admin.queryScope(staff, t1, s1, {
|
|
339
|
+
sql: `SELECT m.v, count(*) AS n FROM marker m WHERE m.v = 'console-row' GROUP BY m.v`,
|
|
340
|
+
});
|
|
341
|
+
expect(result.columns).toEqual(['v', 'n']);
|
|
342
|
+
expect(result.rows.length).toBe(1);
|
|
343
|
+
expect(result.rows[0][0]).toBe('console-row');
|
|
344
|
+
expect(result.truncated).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
it('reads the spine (projections read it; only writes forge it)', async () => {
|
|
347
|
+
const result = await host.admin.queryScope(staff, t1, s1, {
|
|
348
|
+
sql: 'SELECT type FROM _substrat_outbox ORDER BY id LIMIT 5',
|
|
349
|
+
});
|
|
350
|
+
expect(result.columns).toEqual(['type']);
|
|
351
|
+
expect(result.rows.length).toBeGreaterThan(0);
|
|
352
|
+
});
|
|
353
|
+
it("a ';' or a write verb inside a string literal does not trip the gate", async () => {
|
|
354
|
+
const result = await host.admin.queryScope(staff, t1, s1, {
|
|
355
|
+
sql: `SELECT 'update t; drop table x' AS s -- comment with; semicolon`,
|
|
356
|
+
});
|
|
357
|
+
expect(result.rows[0][0]).toBe('update t; drop table x');
|
|
358
|
+
});
|
|
359
|
+
it('caps the result and reports truncation, never an error', async () => {
|
|
360
|
+
const result = await host.admin.queryScope(staff, t1, s1, {
|
|
361
|
+
sql: `WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 1000)
|
|
362
|
+
SELECT x FROM n`,
|
|
363
|
+
});
|
|
364
|
+
expect(result.rows.length).toBe(SCOPE_QUERY_ROW_MAX);
|
|
365
|
+
expect(result.truncated).toBe(true);
|
|
366
|
+
});
|
|
367
|
+
it('rejects every write shape — and the refusal leaves no trace', async () => {
|
|
368
|
+
const writes = [
|
|
369
|
+
`INSERT INTO marker (v) VALUES ('forged')`,
|
|
370
|
+
`UPDATE marker SET v = 'forged'`,
|
|
371
|
+
`DELETE FROM marker`,
|
|
372
|
+
`REPLACE INTO marker (v) VALUES ('forged')`,
|
|
373
|
+
`DROP TABLE marker`,
|
|
374
|
+
`CREATE TABLE forged (id TEXT)`,
|
|
375
|
+
`PRAGMA journal_mode = OFF`,
|
|
376
|
+
`SELECT 1; DELETE FROM marker`,
|
|
377
|
+
// The first-keyword check alone would pass this one — SQLite allows a
|
|
378
|
+
// write behind a CTE, which is exactly why write verbs are refused anywhere.
|
|
379
|
+
`WITH x AS (SELECT v FROM marker) INSERT INTO marker (v) SELECT v FROM x`,
|
|
380
|
+
];
|
|
381
|
+
for (const sql of writes) {
|
|
382
|
+
// The message prefix is CONTRACT: the transport maps /read-only console/
|
|
383
|
+
// to a 400, so both adapters must refuse with it (errors.ts).
|
|
384
|
+
await expect(host.admin.queryScope(staff, t1, s1, { sql })).rejects.toThrow(/read-only console/);
|
|
385
|
+
}
|
|
386
|
+
// No write happened and nothing was forged: the marker rows are intact.
|
|
387
|
+
const after = await host.admin.queryScope(staff, t1, s1, {
|
|
388
|
+
sql: `SELECT count(*) FROM marker WHERE v = 'forged'`,
|
|
389
|
+
});
|
|
390
|
+
expect(after.rows[0][0]).toBe(0);
|
|
391
|
+
});
|
|
392
|
+
it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
|
|
393
|
+
await expect(host.admin.queryScope(staff, t2, s1, { sql: 'SELECT 1' })).rejects.toThrow();
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
// -- scope export (preview-and-snapshots.md §3) ---------------------------
|
|
397
|
+
//
|
|
398
|
+
// The privileged sibling of introspection: not a bounded page but the WHOLE
|
|
399
|
+
// scope — every table, its DDL, every row — the source a fork/pull reads. What
|
|
400
|
+
// matters: it is complete (the vertical's tables AND the spine), it is faithful
|
|
401
|
+
// (rows + schema, not blob-as-null), it drops only the un-recreatable sqlite_*
|
|
402
|
+
// internals, and it fails closed on a mismatched pair exactly as the reads do.
|
|
403
|
+
describe('scope export (§3)', () => {
|
|
404
|
+
it('dumps every table — the vertical\'s own AND the spine — with DDL and rows', async () => {
|
|
405
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
406
|
+
await stub.invoke('test/write-marker', { v: 'export-me' });
|
|
407
|
+
const dump = await host.admin.exportScope(staff, t1, s1);
|
|
408
|
+
expect(dump.tenantId).toBe(t1);
|
|
409
|
+
expect(dump.scopeId).toBe(s1);
|
|
410
|
+
expect(dump.capturedAt).toBeTruthy();
|
|
411
|
+
const byName = new Map(dump.tables.map((t) => [t.name, t]));
|
|
412
|
+
// The vertical's own table: dumped, with its CREATE statement and its rows.
|
|
413
|
+
const marker = byName.get('marker');
|
|
414
|
+
expect(marker).toBeDefined();
|
|
415
|
+
expect(marker.ddl.toLowerCase()).toContain('create table');
|
|
416
|
+
const vCol = marker.columns.indexOf('v');
|
|
417
|
+
expect(vCol).toBeGreaterThanOrEqual(0);
|
|
418
|
+
expect(marker.rows.map((r) => r[vCol])).toContain('export-me');
|
|
419
|
+
// The `_substrat_*` spine is INCLUDED — a fork must carry event/migration state.
|
|
420
|
+
expect(byName.has('_substrat_outbox')).toBe(true);
|
|
421
|
+
expect(byName.has('_substrat_migrations')).toBe(true);
|
|
422
|
+
// SQLite's own internals are NOT dumped (auto-managed, un-recreatable).
|
|
423
|
+
expect(dump.tables.some((t) => t.name.startsWith('sqlite_'))).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
it('is complete — every introspected table (minus sqlite internals) is present', async () => {
|
|
426
|
+
const tables = await host.admin.listScopeTables(staff, t1, s1);
|
|
427
|
+
const dump = await host.admin.exportScope(staff, t1, s1);
|
|
428
|
+
const dumped = new Set(dump.tables.map((t) => t.name));
|
|
429
|
+
for (const t of tables) {
|
|
430
|
+
if (t.name.startsWith('sqlite_'))
|
|
431
|
+
continue;
|
|
432
|
+
expect(dumped.has(t.name)).toBe(true);
|
|
433
|
+
}
|
|
434
|
+
// The vertical table's rows are dumped in full (count matches introspection).
|
|
435
|
+
const marker = dump.tables.find((t) => t.name === 'marker');
|
|
436
|
+
const introMarker = tables.find((t) => t.name === 'marker');
|
|
437
|
+
expect(marker.rows.length).toBe(introMarker.rowCount);
|
|
438
|
+
});
|
|
439
|
+
it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
|
|
440
|
+
await expect(host.admin.exportScope(staff, t2, s1)).rejects.toThrow();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
// -- scope import: the fork round-trip (preview-and-snapshots.md §3) -------
|
|
444
|
+
//
|
|
445
|
+
// The write side of exportScope. `importScope` provisions a NEW scope and loads
|
|
446
|
+
// a dump into it — a faithful, INDEPENDENT copy at the source's frontier. What
|
|
447
|
+
// matters: the copy has the source's tables + rows (fidelity), and the two do not
|
|
448
|
+
// share storage (a later write to one is invisible to the other).
|
|
449
|
+
describe('scope import — fork (§3)', () => {
|
|
450
|
+
it('round-trips: import a dump into a new scope, identical to the source', async () => {
|
|
451
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
452
|
+
await stub.invoke('test/write-marker', { v: 'fork-me' });
|
|
453
|
+
const dump = await host.admin.exportScope(staff, t1, s1);
|
|
454
|
+
const copy = scopeId.parse(ulid());
|
|
455
|
+
await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, dump);
|
|
456
|
+
// Same tables and row counts as the source (the spine came across too).
|
|
457
|
+
const src = (await host.admin.listScopeTables(staff, t1, s1))
|
|
458
|
+
.map((t) => `${t.name}:${t.rowCount}`)
|
|
459
|
+
.sort();
|
|
460
|
+
const dst = (await host.admin.listScopeTables(staff, t1, copy))
|
|
461
|
+
.map((t) => `${t.name}:${t.rowCount}`)
|
|
462
|
+
.sort();
|
|
463
|
+
expect(dst).toEqual(src);
|
|
464
|
+
// The vertical's data is present in the copy.
|
|
465
|
+
const page = await host.admin.readScopeTable(staff, t1, copy, {
|
|
466
|
+
table: 'marker',
|
|
467
|
+
limit: 200,
|
|
468
|
+
offset: 0,
|
|
469
|
+
});
|
|
470
|
+
const vCol = page.columns.indexOf('v');
|
|
471
|
+
expect(page.rows.map((r) => r[vCol])).toContain('fork-me');
|
|
472
|
+
});
|
|
473
|
+
it('is an independent copy — a later write to the source does not reach it', async () => {
|
|
474
|
+
const dump = await host.admin.exportScope(staff, t1, s1);
|
|
475
|
+
const copy = scopeId.parse(ulid());
|
|
476
|
+
await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, dump);
|
|
477
|
+
const countMarker = async (sc) => (await host.admin.listScopeTables(staff, t1, sc)).find((t) => t.name === 'marker')
|
|
478
|
+
?.rowCount ?? 0;
|
|
479
|
+
const before = await countMarker(copy);
|
|
480
|
+
// Write to the SOURCE after the fork; the copy is frozen at capture time.
|
|
481
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
482
|
+
await stub.invoke('test/write-marker', { v: 'after-fork' });
|
|
483
|
+
expect(await countMarker(copy)).toBe(before);
|
|
484
|
+
});
|
|
485
|
+
it('records fork provenance (forked_from/at + kind) on the new scope', async () => {
|
|
486
|
+
const dump = await host.admin.exportScope(staff, t1, s1);
|
|
487
|
+
const copy = scopeId.parse(ulid());
|
|
488
|
+
await host.importScope(staff, {
|
|
489
|
+
tenantId: t1,
|
|
490
|
+
scopeId: copy,
|
|
491
|
+
jurisdiction: 'eu',
|
|
492
|
+
vertical: 'connector-vertical',
|
|
493
|
+
kind: 'preview',
|
|
494
|
+
}, dump);
|
|
495
|
+
const rec = await host.admin.getScopeRecord(staff, t1, copy);
|
|
496
|
+
expect(rec?.kind).toBe('preview');
|
|
497
|
+
expect(rec?.forkedFrom).toBe(s1); // stamped from the dump's source scope
|
|
498
|
+
expect(rec?.forkedAt).toBe(dump.capturedAt);
|
|
499
|
+
// A normally-provisioned scope carries no provenance — the fields are what
|
|
500
|
+
// set a fork apart.
|
|
501
|
+
const src = await host.admin.getScopeRecord(staff, t1, s1);
|
|
502
|
+
expect(src?.forkedFrom).toBeNull();
|
|
503
|
+
expect(src?.forkedAt).toBeNull();
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
// -- snapshot & auto-snapshot on bind (preview-and-snapshots.md §3/§4) -----
|
|
507
|
+
//
|
|
508
|
+
// snapshotScope forks an `archive` copy at the source's frontier. bindScopeVersion,
|
|
509
|
+
// opted in, does it automatically before a MIGRATION-changing rebind — and only
|
|
510
|
+
// then: a code-only rebind (same migration digest) snapshots nothing, and opting
|
|
511
|
+
// out never snapshots.
|
|
512
|
+
describe('scope snapshot (§3/§4)', () => {
|
|
513
|
+
const countForks = async (of) => (await host.admin.listScopes(staff, { tenantId: t1 })).filter((sc) => sc.forkedFrom === of)
|
|
514
|
+
.length;
|
|
515
|
+
it('snapshotScope forks an archive copy at the source frontier', async () => {
|
|
516
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
517
|
+
await stub.invoke('test/write-marker', { v: 'snap-me' });
|
|
518
|
+
const snapId = await host.snapshotScope(staff, t1, s1);
|
|
519
|
+
const rec = await host.admin.getScopeRecord(staff, t1, snapId);
|
|
520
|
+
expect(rec?.kind).toBe('archive');
|
|
521
|
+
expect(rec?.forkedFrom).toBe(s1);
|
|
522
|
+
const page = await host.admin.readScopeTable(staff, t1, snapId, {
|
|
523
|
+
table: 'marker',
|
|
524
|
+
limit: 200,
|
|
525
|
+
offset: 0,
|
|
526
|
+
});
|
|
527
|
+
const vCol = page.columns.indexOf('v');
|
|
528
|
+
expect(page.rows.map((r) => r[vCol])).toContain('snap-me');
|
|
529
|
+
});
|
|
530
|
+
it('bindScopeVersion snapshots a migration-changing rebind, and only that', async () => {
|
|
531
|
+
// A fresh scope + a vertical with two admitted versions of differing migration
|
|
532
|
+
// digest, plus a third that is a code-only change (same migration digest).
|
|
533
|
+
const sb = scopeId.parse(ulid());
|
|
534
|
+
await host.provisionScope(staff, {
|
|
535
|
+
tenantId: t1,
|
|
536
|
+
scopeId: sb,
|
|
537
|
+
jurisdiction: 'eu',
|
|
538
|
+
vertical: 'snaptest',
|
|
539
|
+
});
|
|
540
|
+
await host.admin.activateScope(staff, t1, sb);
|
|
541
|
+
await host.admin.registerVertical(staff, {
|
|
542
|
+
slug: 'snaptest',
|
|
543
|
+
name: 'Snap Test',
|
|
544
|
+
source: 'builtin',
|
|
545
|
+
});
|
|
546
|
+
const publish = async (version, mig) => {
|
|
547
|
+
const id = ulid();
|
|
548
|
+
await host.admin.publishVersion(staff, {
|
|
549
|
+
id,
|
|
550
|
+
verticalSlug: 'snaptest',
|
|
551
|
+
version,
|
|
552
|
+
manifestDigest: `man-${version}`,
|
|
553
|
+
permissionDigest: 'p',
|
|
554
|
+
migrationDigest: mig,
|
|
555
|
+
deploymentRef: null,
|
|
556
|
+
});
|
|
557
|
+
await host.admin.admitVersion(staff, id);
|
|
558
|
+
return id;
|
|
559
|
+
};
|
|
560
|
+
const vA = await publish('1.0.0', 'gA');
|
|
561
|
+
const vB = await publish('2.0.0', 'gB'); // migration change vs vA
|
|
562
|
+
const vBcode = await publish('2.0.1', 'gB'); // code-only vs vB
|
|
563
|
+
const before = await countForks(sb);
|
|
564
|
+
await host.admin.bindScopeVersion(staff, t1, sb, vA); // first bind — no prior version
|
|
565
|
+
await host.admin.bindScopeVersion(staff, t1, sb, vB, { snapshot: true }); // gA→gB — snapshot
|
|
566
|
+
expect(await countForks(sb)).toBe(before + 1);
|
|
567
|
+
// The snapshot is an archive bound to the OUTGOING version (vA) — the frontier
|
|
568
|
+
// it froze at.
|
|
569
|
+
const forks = (await host.admin.listScopes(staff, { tenantId: t1 })).filter((sc) => sc.forkedFrom === sb);
|
|
570
|
+
expect(forks.every((f) => f.kind === 'archive')).toBe(true);
|
|
571
|
+
expect(forks.some((f) => f.verticalVersionId === vA)).toBe(true);
|
|
572
|
+
// A code-only rebind (same migration digest), even opted in, snapshots nothing.
|
|
573
|
+
await host.admin.bindScopeVersion(staff, t1, sb, vBcode, { snapshot: true });
|
|
574
|
+
expect(await countForks(sb)).toBe(before + 1);
|
|
575
|
+
// Opting out never snapshots, even across a migration boundary.
|
|
576
|
+
await host.admin.bindScopeVersion(staff, t1, sb, vA); // gB→gA, no opts
|
|
577
|
+
expect(await countForks(sb)).toBe(before + 1);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
// -- snapshot retention: deleteSnapshot + the GC sweep (§3/§9) -------------
|
|
581
|
+
//
|
|
582
|
+
// The one sanctioned hard delete, kept narrow: only a FORK may be reaped, and the
|
|
583
|
+
// sweep only reaps forks whose creator asked for an expiry. What matters: the
|
|
584
|
+
// delete removes record + hostnames + storage; a primary scope is refused; the
|
|
585
|
+
// sweep takes exactly the expired and leaves the unexpired and the pinned.
|
|
586
|
+
describe('snapshot retention (§3/§9)', () => {
|
|
587
|
+
it('deleteSnapshot removes the fork — record, hostnames, and reachability', async () => {
|
|
588
|
+
const snapId = await host.snapshotScope(staff, t1, s1);
|
|
589
|
+
await host.admin.bindHostname(staff, {
|
|
590
|
+
hostname: `snap-${snapId.toLowerCase()}.test.example`,
|
|
591
|
+
tenantId: t1,
|
|
592
|
+
scopeId: snapId,
|
|
593
|
+
surface: 'app',
|
|
594
|
+
region: null,
|
|
595
|
+
canonical: true,
|
|
596
|
+
});
|
|
597
|
+
expect(await host.admin.listHostnames(staff, { scopeId: snapId })).toHaveLength(1);
|
|
598
|
+
await host.deleteSnapshot(staff, t1, snapId);
|
|
599
|
+
expect(await host.admin.getScopeRecord(staff, t1, snapId)).toBeUndefined();
|
|
600
|
+
expect(await host.admin.listHostnames(staff, { scopeId: snapId })).toHaveLength(0);
|
|
601
|
+
// The storage is unreachable through every read: the directory row is gone,
|
|
602
|
+
// so the K-3 cross-check fails closed.
|
|
603
|
+
await expect(host.admin.exportScope(staff, t1, snapId)).rejects.toThrow();
|
|
604
|
+
});
|
|
605
|
+
it('refuses to delete a primary scope (forkedFrom is null)', async () => {
|
|
606
|
+
await expect(host.deleteSnapshot(staff, t1, s1)).rejects.toThrow(/not a fork/);
|
|
607
|
+
// Still there, still readable.
|
|
608
|
+
expect((await host.admin.getScopeRecord(staff, t1, s1))?.id).toBe(s1);
|
|
609
|
+
});
|
|
610
|
+
it('the GC sweep reaps exactly the expired forks', async () => {
|
|
611
|
+
const past = new Date(Date.now() - 60_000).toISOString();
|
|
612
|
+
const future = new Date(Date.now() + 3_600_000).toISOString();
|
|
613
|
+
const expired = await host.snapshotScope(staff, t1, s1, { expiresAt: past });
|
|
614
|
+
const unexpired = await host.snapshotScope(staff, t1, s1, { expiresAt: future });
|
|
615
|
+
const pinned = await host.snapshotScope(staff, t1, s1); // no expiry — retained
|
|
616
|
+
const report = await runPlatformSweep(host, {
|
|
617
|
+
actor: staff,
|
|
618
|
+
fetch: connectorTestFetch,
|
|
619
|
+
sweepers: {},
|
|
620
|
+
drainRetries: false,
|
|
621
|
+
});
|
|
622
|
+
expect(report.snapshotsReaped).toBeGreaterThanOrEqual(1);
|
|
623
|
+
expect(await host.admin.getScopeRecord(staff, t1, expired)).toBeUndefined();
|
|
624
|
+
expect((await host.admin.getScopeRecord(staff, t1, unexpired))?.id).toBe(unexpired);
|
|
625
|
+
expect((await host.admin.getScopeRecord(staff, t1, pinned))?.id).toBe(pinned);
|
|
626
|
+
// The primary scope was never in danger.
|
|
627
|
+
expect((await host.admin.getScopeRecord(staff, t1, s1))?.id).toBe(s1);
|
|
628
|
+
// Clean up the survivors so later suite state stays predictable.
|
|
629
|
+
await host.deleteSnapshot(staff, t1, unexpired);
|
|
630
|
+
await host.deleteSnapshot(staff, t1, pinned);
|
|
631
|
+
});
|
|
632
|
+
it('gcSnapshots: false skips the phase', async () => {
|
|
633
|
+
const past = new Date(Date.now() - 60_000).toISOString();
|
|
634
|
+
const snapId = await host.snapshotScope(staff, t1, s1, { expiresAt: past });
|
|
635
|
+
const report = await runPlatformSweep(host, {
|
|
636
|
+
actor: staff,
|
|
637
|
+
fetch: connectorTestFetch,
|
|
638
|
+
sweepers: {},
|
|
639
|
+
drainRetries: false,
|
|
640
|
+
gcSnapshots: false,
|
|
641
|
+
});
|
|
642
|
+
expect(report.snapshotsReaped).toBe(0);
|
|
643
|
+
expect((await host.admin.getScopeRecord(staff, t1, snapId))?.id).toBe(snapId);
|
|
644
|
+
await host.deleteSnapshot(staff, t1, snapId);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
288
647
|
// -- the integrations hub: connections (#101) -----------------------------
|
|
289
648
|
//
|
|
290
649
|
// The store exists so a vertical's connector can reach a tenant's provider
|
|
@@ -611,6 +970,115 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
611
970
|
ownerTenant: t2,
|
|
612
971
|
})).rejects.toThrow(/owned by the platform/);
|
|
613
972
|
});
|
|
973
|
+
it('carries registry-driven install metadata (marketplace-publish.md §3) and refreshes it', async () => {
|
|
974
|
+
// ownerGrants/entitlements/provides/requires ride the registry (one install_spec JSON
|
|
975
|
+
// column) so the dashboard installs a vertical without a hardcoded catalog entry.
|
|
976
|
+
await host.admin.registerVertical(staff, {
|
|
977
|
+
slug: 'authy',
|
|
978
|
+
name: 'Authy',
|
|
979
|
+
source: 'cli',
|
|
980
|
+
ownerTenant: t2,
|
|
981
|
+
entitlements: ['authy'],
|
|
982
|
+
ownerGrants: ['content:admin'],
|
|
983
|
+
provides: ['oidc-issuer'],
|
|
984
|
+
});
|
|
985
|
+
const first = (await host.admin.listVerticals(staff)).find((v) => v.slug === 'authy');
|
|
986
|
+
expect(first?.entitlements).toEqual(['authy']);
|
|
987
|
+
expect(first?.ownerGrants).toEqual(['content:admin']);
|
|
988
|
+
expect(first?.provides).toEqual(['oidc-issuer']);
|
|
989
|
+
expect(first?.requires).toBeUndefined();
|
|
990
|
+
// Like envSpec, install metadata evolves with the manifest: an otherwise-identical
|
|
991
|
+
// re-registration refreshes it rather than conflicting.
|
|
992
|
+
await host.admin.registerVertical(staff, {
|
|
993
|
+
slug: 'authy',
|
|
994
|
+
name: 'Authy',
|
|
995
|
+
source: 'cli',
|
|
996
|
+
ownerTenant: t2,
|
|
997
|
+
entitlements: ['authy'],
|
|
998
|
+
ownerGrants: ['content:admin', 'content:publish'],
|
|
999
|
+
requires: ['oidc-issuer'],
|
|
1000
|
+
});
|
|
1001
|
+
const second = (await host.admin.listVerticals(staff)).find((v) => v.slug === 'authy');
|
|
1002
|
+
expect(second?.ownerGrants).toEqual(['content:admin', 'content:publish']);
|
|
1003
|
+
expect(second?.requires).toEqual(['oidc-issuer']);
|
|
1004
|
+
expect(second?.provides).toBeUndefined(); // dropped on refresh — the blob is replaced whole
|
|
1005
|
+
});
|
|
1006
|
+
it('publishes/unpublishes a vertical to the marketplace (setVerticalListed), preserved across re-push', async () => {
|
|
1007
|
+
const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
|
|
1008
|
+
await host.admin.registerVertical(staff, { slug: 'listtest', name: 'ListTest', source: 'cli', ownerTenant: t2 });
|
|
1009
|
+
expect((await at('listtest'))?.listed).toBe(false); // private on push
|
|
1010
|
+
await host.admin.setVerticalListed(staff, 'listtest', true);
|
|
1011
|
+
expect((await at('listtest'))?.listed).toBe(true); // published
|
|
1012
|
+
// The invariant: a re-push does NOT unpublish it — `listed` is its own column, untouched
|
|
1013
|
+
// by the register refresh (publish is a distinct action from push).
|
|
1014
|
+
await host.admin.registerVertical(staff, { slug: 'listtest', name: 'ListTest', source: 'cli', ownerTenant: t2 });
|
|
1015
|
+
expect((await at('listtest'))?.listed).toBe(true);
|
|
1016
|
+
await host.admin.setVerticalListed(staff, 'listtest', true); // idempotent
|
|
1017
|
+
await host.admin.setVerticalListed(staff, 'listtest', false);
|
|
1018
|
+
expect((await at('listtest'))?.listed).toBe(false); // unpublished
|
|
1019
|
+
// A builder's publish REQUEST is recorded, and RESOLVED (cleared) by the staff listing.
|
|
1020
|
+
await host.admin.requestPublish(staff, 'listtest');
|
|
1021
|
+
expect((await at('listtest'))?.publishRequestedAt).toBeTruthy();
|
|
1022
|
+
await host.admin.setVerticalListed(staff, 'listtest', true);
|
|
1023
|
+
expect((await at('listtest'))?.publishRequestedAt).toBeUndefined(); // cleared on review
|
|
1024
|
+
await expect(host.admin.setVerticalListed(staff, 'no-such-vertical', true)).rejects.toThrow(/unknown vertical/);
|
|
1025
|
+
await expect(host.admin.requestPublish(staff, 'no-such-vertical')).rejects.toThrow(/unknown vertical/);
|
|
1026
|
+
});
|
|
1027
|
+
it('blocks new installs (setVerticalInstallsBlocked) — a provisioning gate, not a delete', async () => {
|
|
1028
|
+
const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
|
|
1029
|
+
await host.admin.registerVertical(staff, { slug: 'blocktest', name: 'BlockTest', source: 'cli', ownerTenant: t2 });
|
|
1030
|
+
expect((await at('blocktest'))?.installsBlocked).toBe(false); // installable on push
|
|
1031
|
+
await host.admin.setVerticalInstallsBlocked(staff, 'blocktest', true);
|
|
1032
|
+
expect((await at('blocktest'))?.installsBlocked).toBe(true);
|
|
1033
|
+
await host.admin.setVerticalInstallsBlocked(staff, 'blocktest', true); // idempotent
|
|
1034
|
+
// Orthogonal to `listed` — blocking is not unpublishing.
|
|
1035
|
+
expect((await at('blocktest'))?.listed).toBe(false);
|
|
1036
|
+
await host.admin.setVerticalInstallsBlocked(staff, 'blocktest', false);
|
|
1037
|
+
expect((await at('blocktest'))?.installsBlocked).toBe(false);
|
|
1038
|
+
await expect(host.admin.setVerticalInstallsBlocked(staff, 'no-such-vertical', true)).rejects.toThrow(/unknown vertical/);
|
|
1039
|
+
});
|
|
1040
|
+
it('deletes a vertical — refused while a scope is bound, total once nothing is', async () => {
|
|
1041
|
+
// 'callout' still backs s1 (bound above): the refusal that stops a delete from
|
|
1042
|
+
// stranding a live scope's version pin and routing.
|
|
1043
|
+
await expect(host.admin.deleteVertical(staff, 'callout')).rejects.toThrow(/still backs/);
|
|
1044
|
+
expect((await host.admin.listVerticals(staff)).some((v) => v.slug === 'callout')).toBe(true);
|
|
1045
|
+
// A vertical nothing is bound to deletes totally: row, versions, channels.
|
|
1046
|
+
const versionId = ulid();
|
|
1047
|
+
await host.admin.registerVertical(staff, { slug: 'deletable', name: 'Deletable', source: 'cli', ownerTenant: t2 });
|
|
1048
|
+
await host.admin.publishVersion(staff, {
|
|
1049
|
+
id: versionId,
|
|
1050
|
+
verticalSlug: 'deletable',
|
|
1051
|
+
version: '0.0.1',
|
|
1052
|
+
manifestDigest: 'm',
|
|
1053
|
+
permissionDigest: 'p',
|
|
1054
|
+
migrationDigest: 'g',
|
|
1055
|
+
deploymentRef: null,
|
|
1056
|
+
});
|
|
1057
|
+
await host.admin.admitVersion(staff, versionId);
|
|
1058
|
+
await host.admin.promoteVersion(staff, 'deletable', 'dev', versionId);
|
|
1059
|
+
await host.admin.deleteVertical(staff, 'deletable');
|
|
1060
|
+
expect((await host.admin.listVerticals(staff)).some((v) => v.slug === 'deletable')).toBe(false);
|
|
1061
|
+
expect(await host.admin.listVersions(staff, 'deletable')).toEqual([]);
|
|
1062
|
+
expect(await host.admin.listChannels(staff, 'deletable')).toEqual([]);
|
|
1063
|
+
// The slug is claimable again — a delete is a real removal, not a tombstone.
|
|
1064
|
+
await host.admin.registerVertical(staff, { slug: 'deletable', name: 'Deletable', source: 'cli', ownerTenant: t2 });
|
|
1065
|
+
expect(await host.admin.listVersions(staff, 'deletable')).toEqual([]); // no resurrected versions
|
|
1066
|
+
await host.admin.deleteVertical(staff, 'deletable');
|
|
1067
|
+
await expect(host.admin.deleteVertical(staff, 'no-such-vertical')).rejects.toThrow(/unknown vertical/);
|
|
1068
|
+
});
|
|
1069
|
+
it('refreshes `listed` on builtin re-registration (the catalog re-seed can list a row)', async () => {
|
|
1070
|
+
const at = (slug) => host.admin.listVerticals(staff).then((vs) => vs.find((v) => v.slug === slug));
|
|
1071
|
+
// A builtin first registered UNLISTED (bundled but not yet deployable, or a row
|
|
1072
|
+
// predating the `listed` column) must become listed when the seed says so —
|
|
1073
|
+
// `ensureCatalog` re-registers each boot, and for builtins `listed` is seed metadata.
|
|
1074
|
+
await host.admin.registerVertical(staff, { slug: 'seeded', name: 'Seeded', source: 'builtin' });
|
|
1075
|
+
expect((await at('seeded'))?.listed).toBe(false);
|
|
1076
|
+
await host.admin.registerVertical(staff, { slug: 'seeded', name: 'Seeded', source: 'builtin', listed: true });
|
|
1077
|
+
expect((await at('seeded'))?.listed).toBe(true);
|
|
1078
|
+
// And back: flipping the seed to `connected: false` delists on the next re-seed.
|
|
1079
|
+
await host.admin.registerVertical(staff, { slug: 'seeded', name: 'Seeded', source: 'builtin' });
|
|
1080
|
+
expect((await at('seeded'))?.listed).toBe(false);
|
|
1081
|
+
});
|
|
614
1082
|
it('refuses to bind a rejected version, and rejection is terminal', async () => {
|
|
615
1083
|
const versionId = ulid();
|
|
616
1084
|
await host.admin.publishVersion(staff, {
|
|
@@ -727,6 +1195,102 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
727
1195
|
});
|
|
728
1196
|
await expect(host.admin.promoteVersion(staff, 'callout', 'dev', pending)).rejects.toThrow(/pending, not admitted/);
|
|
729
1197
|
});
|
|
1198
|
+
// -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
|
|
1199
|
+
// A private vertical's blast radius is its own tenant — dev/staging already
|
|
1200
|
+
// run the same bundle in the same sandbox — so its versions self-admit on
|
|
1201
|
+
// push and prod is the owner's to move. The staff seam moves to publish,
|
|
1202
|
+
// where the audience actually widens.
|
|
1203
|
+
const publishPrivate = async (slug, version, digests) => {
|
|
1204
|
+
const id = ulid();
|
|
1205
|
+
await host.admin.publishVersion(staff, {
|
|
1206
|
+
id,
|
|
1207
|
+
verticalSlug: slug,
|
|
1208
|
+
version,
|
|
1209
|
+
manifestDigest: `man-${version}`,
|
|
1210
|
+
permissionDigest: digests?.perm ?? 'pp',
|
|
1211
|
+
migrationDigest: digests?.mig ?? 'gg',
|
|
1212
|
+
deploymentRef: null,
|
|
1213
|
+
});
|
|
1214
|
+
return id;
|
|
1215
|
+
};
|
|
1216
|
+
it('a private vertical version lands ADMITTED on push, noted as auto-admission', async () => {
|
|
1217
|
+
await host.admin.registerVertical(staff, {
|
|
1218
|
+
slug: 'egeryds/crm',
|
|
1219
|
+
name: 'Egeryds CRM',
|
|
1220
|
+
source: 'cli',
|
|
1221
|
+
ownerTenant: t2,
|
|
1222
|
+
});
|
|
1223
|
+
const vid = await publishPrivate('egeryds/crm', '0.1.0');
|
|
1224
|
+
const [v] = await host.admin.listVersions(staff, 'egeryds/crm');
|
|
1225
|
+
expect(v?.admission).toBe('admitted');
|
|
1226
|
+
expect(v?.admissionNote).toBe(AUTO_ADMISSION_NOTE);
|
|
1227
|
+
// Promotable immediately — for a private vertical, push + promote IS the deploy.
|
|
1228
|
+
await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', vid);
|
|
1229
|
+
expect((await host.admin.listChannels(staff, 'egeryds/crm')).find((c) => c.channel === 'prod')?.versionId).toBe(vid);
|
|
1230
|
+
});
|
|
1231
|
+
it('a platform vertical still lands pending — auto-admission is scoped to private ownership', async () => {
|
|
1232
|
+
// 'callout' is platform-owned (ownerTenant null): the 9.9.9 push in the test
|
|
1233
|
+
// above landed pending. The distinction is the whole design: self-admission
|
|
1234
|
+
// exists only where the author and the audience are the same tenant.
|
|
1235
|
+
const versions = await host.admin.listVersions(staff, 'callout');
|
|
1236
|
+
expect(versions.find((v) => v.version === '9.9.9')?.admission).toBe('pending');
|
|
1237
|
+
});
|
|
1238
|
+
it('promotion appends to the channel history — the go-live timeline rollback picks from', async () => {
|
|
1239
|
+
const first = (await host.admin.listChannelHistory(staff, 'egeryds/crm', 'prod'));
|
|
1240
|
+
expect(first).toHaveLength(1);
|
|
1241
|
+
expect(first[0].fromVersionId).toBeNull(); // nothing before the first go-live
|
|
1242
|
+
const v2 = await publishPrivate('egeryds/crm', '0.2.0');
|
|
1243
|
+
await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', v2);
|
|
1244
|
+
// Rollback is a NEW promotion of the older version — the timeline only grows.
|
|
1245
|
+
const v1 = first[0].versionId;
|
|
1246
|
+
await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', v1);
|
|
1247
|
+
const timeline = await host.admin.listChannelHistory(staff, 'egeryds/crm', 'prod');
|
|
1248
|
+
expect(timeline.map((h) => h.versionId)).toEqual([v1, v2, v1]); // newest first
|
|
1249
|
+
expect(timeline[0].fromVersionId).toBe(v2);
|
|
1250
|
+
expect(timeline[1].fromVersionId).toBe(v1);
|
|
1251
|
+
// Each entry records who and exactly when — `at` is the PITR anchor a data
|
|
1252
|
+
// rollback would rewind to (preview-and-snapshots.md §7).
|
|
1253
|
+
expect(timeline[0].actor).toBe(staff);
|
|
1254
|
+
expect(new Date(timeline[0].at).getTime()).toBeGreaterThan(0);
|
|
1255
|
+
// Narrowing by channel works: dev never moved, so its timeline is empty.
|
|
1256
|
+
expect(await host.admin.listChannelHistory(staff, 'egeryds/crm', 'dev')).toHaveLength(0);
|
|
1257
|
+
});
|
|
1258
|
+
it("prod promote of a private vertical re-points the owner's live scopes (merge IS the deploy)", async () => {
|
|
1259
|
+
// A fresh app scope in the owning tenant, bound to the current prod version.
|
|
1260
|
+
// Promoting a new one re-points it without a separate update step — that is
|
|
1261
|
+
// what makes merge-to-main a complete deploy, and a rollback promote reach
|
|
1262
|
+
// the running app.
|
|
1263
|
+
const appScope = scopeId.parse(ulid());
|
|
1264
|
+
await host.provisionScope(staff, { tenantId: t2, scopeId: appScope });
|
|
1265
|
+
await host.admin.activateScope(staff, t2, appScope);
|
|
1266
|
+
const prodNow = (await host.admin.listChannels(staff, 'egeryds/crm')).find((c) => c.channel === 'prod')
|
|
1267
|
+
.versionId;
|
|
1268
|
+
await host.admin.bindScopeVersion(staff, t2, appScope, prodNow);
|
|
1269
|
+
const v3 = await publishPrivate('egeryds/crm', '0.3.0');
|
|
1270
|
+
await host.admin.promoteVersion(staff, 'egeryds/crm', 'prod', v3);
|
|
1271
|
+
expect((await host.admin.getScopeRecord(staff, t2, appScope))?.verticalVersionId).toBe(v3);
|
|
1272
|
+
});
|
|
1273
|
+
it('publish refuses an auto-admitted prod version until a staff admit vouches for it', async () => {
|
|
1274
|
+
// Listing is the moment OTHER tenants start trusting this code, so the version
|
|
1275
|
+
// they would install needs a recorded human decision — the auto-admission note
|
|
1276
|
+
// is exactly what marks its absence.
|
|
1277
|
+
await expect(host.admin.setVerticalListed(staff, 'egeryds/crm', true)).rejects.toThrow(/auto-admitted.*staff admit/);
|
|
1278
|
+
// A staff admit of the already-admitted version upgrades it to a manual vouch
|
|
1279
|
+
// (clears the note, audited) — then listing passes.
|
|
1280
|
+
const prodNow = (await host.admin.listChannels(staff, 'egeryds/crm')).find((c) => c.channel === 'prod')
|
|
1281
|
+
.versionId;
|
|
1282
|
+
await host.admin.admitVersion(staff, prodNow);
|
|
1283
|
+
const upgraded = (await host.admin.listVersions(staff, 'egeryds/crm')).find((v) => v.id === prodNow);
|
|
1284
|
+
expect(upgraded?.admission).toBe('admitted');
|
|
1285
|
+
expect(upgraded?.admissionNote).toBeNull();
|
|
1286
|
+
await host.admin.setVerticalListed(staff, 'egeryds/crm', true);
|
|
1287
|
+
// Listed now: the next push lands PENDING — staff admission is back in the
|
|
1288
|
+
// path exactly when the audience widened.
|
|
1289
|
+
const v4 = await publishPrivate('egeryds/crm', '0.4.0');
|
|
1290
|
+
expect((await host.admin.listVersions(staff, 'egeryds/crm')).find((v) => v.id === v4)?.admission).toBe('pending');
|
|
1291
|
+
// Unlist again so later suites see the vertical private (and pushes self-admit).
|
|
1292
|
+
await host.admin.setVerticalListed(staff, 'egeryds/crm', false);
|
|
1293
|
+
});
|
|
730
1294
|
// -- the provisioning state (K-31) ---------------------------------------
|
|
731
1295
|
it('provisions a scope as `provisioning`, not `active`', async () => {
|
|
732
1296
|
// The directory row exists before the vertical has created the scope DO. Born
|
|
@@ -883,6 +1447,30 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
883
1447
|
// but neither of them routes traffic.
|
|
884
1448
|
expect(await host.admin.resolveHostname('eu.example.com')).toBeUndefined();
|
|
885
1449
|
});
|
|
1450
|
+
it('unbinds a hostname: the row is gone, it stops resolving, and the unbind is audited', async () => {
|
|
1451
|
+
// The inverse of bindHostname — what a cleanup pass uses on orphaned rows.
|
|
1452
|
+
// A hard delete, not a tombstone: the row is routing config, and its history
|
|
1453
|
+
// lives in the admin log.
|
|
1454
|
+
await host.admin.unbindHostname(staff, 'EU.example.com'); // case-insensitive, like DNS
|
|
1455
|
+
expect((await host.admin.listHostnames(staff, { scopeId: s1 })).map((h) => h.hostname)).not.toContain('eu.example.com');
|
|
1456
|
+
expect(await host.admin.resolveHostname('eu.example.com')).toBeUndefined();
|
|
1457
|
+
const entries = await host.admin.auditLog(staff, { scopeId: s1 });
|
|
1458
|
+
expect(entries.map((e) => e.action)).toContain('unbindHostname');
|
|
1459
|
+
// Idempotent: unbinding an unknown hostname is a no-op, not an error — a
|
|
1460
|
+
// cleanup pass can re-run over a partial failure.
|
|
1461
|
+
await expect(host.admin.unbindHostname(staff, 'eu.example.com')).resolves.toBeUndefined();
|
|
1462
|
+
// The released name is immediately reclaimable by another scope.
|
|
1463
|
+
await host.admin.bindHostname(staff, {
|
|
1464
|
+
hostname: 'eu.example.com',
|
|
1465
|
+
tenantId: t2,
|
|
1466
|
+
scopeId: s2,
|
|
1467
|
+
surface: 'app',
|
|
1468
|
+
region: null,
|
|
1469
|
+
canonical: false,
|
|
1470
|
+
});
|
|
1471
|
+
const reclaimed = (await host.admin.listHostnames(staff, { scopeId: s2 })).find((h) => h.hostname === 'eu.example.com');
|
|
1472
|
+
expect(reclaimed?.scopeId).toBe(s2);
|
|
1473
|
+
});
|
|
886
1474
|
it('rejects duplicate module registration', () => {
|
|
887
1475
|
expect(() => host.registerModule({ manifest: testModManifest })).toThrow(/already registered/);
|
|
888
1476
|
});
|