@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
package/dist/scope-host-suite.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
2
|
import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
|
|
3
|
-
import { connectionId, dataSubjectId, domainEvent, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
|
|
3
|
+
import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
|
|
4
4
|
import { INERT_SCOPE_REASON, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, ulid, } from '@substrat-run/kernel';
|
|
5
5
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, testModManifest, victimModManifest, } from './modules.js';
|
|
6
6
|
/**
|
|
@@ -133,10 +133,11 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
133
133
|
await expect(host.provisionScope(staff, { tenantId: t1, scopeId: s1, jurisdiction: 'eu' })).resolves.toBeUndefined();
|
|
134
134
|
});
|
|
135
135
|
it('refuses to provision a scope under a tenant with no record (§4.1)', async () => {
|
|
136
|
-
|
|
136
|
+
// `conflict`, not `not_found`: POST /scopes exists, and what is missing is its precondition.
|
|
137
|
+
await expectRefusal(host.provisionScope(staff, {
|
|
137
138
|
tenantId: tenantId.parse(ulid()),
|
|
138
139
|
scopeId: scopeId.parse(ulid()),
|
|
139
|
-
})
|
|
140
|
+
}), 'conflict');
|
|
140
141
|
});
|
|
141
142
|
it('fails closed on a mismatched (tenantId, scopeId) pair (K-3)', async () => {
|
|
142
143
|
await expect(host.getScope(alice, t2, s1)).rejects.toThrow();
|
|
@@ -1903,6 +1904,52 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1903
1904
|
expect((await host.admin.accessLog(staff, { tenantId: t1, method: 'lifecycleFlow' })).length).toBeGreaterThan(0);
|
|
1904
1905
|
});
|
|
1905
1906
|
});
|
|
1907
|
+
describe('operationSeries on both adapters (#1750)', () => {
|
|
1908
|
+
// `readOperationSeries`'s own suite pins the counting over a hand-built outbox; this
|
|
1909
|
+
// proves the wiring — real emits, with real ids, read back through the platform verb.
|
|
1910
|
+
// On the Durable Object it is also the proof that `strftime`, `json_each` and the
|
|
1911
|
+
// primary-key seek run in workerd's SQLite.
|
|
1912
|
+
const MOVES = [{ entityType: 'test-lifecycle', operation: 'test/move' }];
|
|
1913
|
+
/** A window from the current whole second, a minute wide, in one-minute buckets. */
|
|
1914
|
+
const window = () => {
|
|
1915
|
+
const since = Math.floor(Date.now() / 1000) * 1000;
|
|
1916
|
+
return { moves: MOVES, since: new Date(since).toISOString(), until: new Date(since + 60_000).toISOString(), bucketMinutes: 1 };
|
|
1917
|
+
};
|
|
1918
|
+
const total = (r) => r.series[0].total;
|
|
1919
|
+
it('counts every call that made the move, inside the window', async () => {
|
|
1920
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
1921
|
+
const w = window();
|
|
1922
|
+
const earlier = { ...w, since: new Date(Date.parse(w.since) - 120_000).toISOString(), until: w.since };
|
|
1923
|
+
const before = total(await host.admin.operationSeries(staff, t1, s1, w));
|
|
1924
|
+
const beforeEarlier = total(await host.admin.operationSeries(staff, t1, s1, earlier));
|
|
1925
|
+
await stub.invoke('test/move', { entityId: 'v1', state: 'draft' });
|
|
1926
|
+
await stub.invoke('test/move', { entityId: 'v1', state: 'live' });
|
|
1927
|
+
await stub.invoke('test/move', { entityId: 'v2', state: 'draft' });
|
|
1928
|
+
const after = await host.admin.operationSeries(staff, t1, s1, w);
|
|
1929
|
+
expect(total(after)).toBe(before + 3);
|
|
1930
|
+
expect(after.series[0].buckets.every((b) => Date.parse(b.start) >= Date.parse(w.since))).toBe(true);
|
|
1931
|
+
// The twin: a window that closed before the calls ran counts none of them. Compared
|
|
1932
|
+
// with itself, not with zero: earlier cases in this scope may have moved inside it.
|
|
1933
|
+
expect(total(await host.admin.operationSeries(staff, t1, s1, earlier))).toBe(beforeEarlier);
|
|
1934
|
+
});
|
|
1935
|
+
it("answers only for the tenant's own scope: a mismatched pair is refused, another tenant's scope counts none of it", async () => {
|
|
1936
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
1937
|
+
const w = window();
|
|
1938
|
+
await stub.invoke('test/move', { entityId: 'v3', state: 'draft' });
|
|
1939
|
+
// The positive twin: t1's own pair sees the move.
|
|
1940
|
+
expect(total(await host.admin.operationSeries(staff, t1, s1, w))).toBeGreaterThanOrEqual(1);
|
|
1941
|
+
// K-3: never another tenant's outbox, and never an empty answer for one either.
|
|
1942
|
+
await expect(host.admin.operationSeries(staff, t2, s1, w)).rejects.toThrow();
|
|
1943
|
+
await expect(host.admin.operationSeries(staff, t1, s2, w)).rejects.toThrow();
|
|
1944
|
+
// t2's own scope answers from its own outbox, in which t1 moved nothing.
|
|
1945
|
+
expect(total(await host.admin.operationSeries(staff, t2, s2, w))).toBe(0);
|
|
1946
|
+
// K-24: the read is recorded against the actor, with the window and no operation names.
|
|
1947
|
+
const rows = await host.admin.accessLog(staff, { tenantId: t1, method: 'operationSeries' });
|
|
1948
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
1949
|
+
expect(rows[0].params).toContain('test-lifecycle');
|
|
1950
|
+
expect(rows[0].params).not.toContain('test/move');
|
|
1951
|
+
});
|
|
1952
|
+
});
|
|
1906
1953
|
it('records a refused transition after the rollback, and the replay counts it (#1745)', async () => {
|
|
1907
1954
|
// The operation throws the platform's conflict, so its transaction rolls back — the
|
|
1908
1955
|
// refusal is the one write that must survive it, on both adapters.
|
|
@@ -2005,7 +2052,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2005
2052
|
expect(page.limit).toBe(1);
|
|
2006
2053
|
});
|
|
2007
2054
|
it('rejects an unknown table', async () => {
|
|
2008
|
-
await
|
|
2055
|
+
await expectRefusal(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 }), 'not_found');
|
|
2009
2056
|
});
|
|
2010
2057
|
it('refuses a page bound SQLite would misread, and still clamps to the ceiling (#1632)', async () => {
|
|
2011
2058
|
// The clamp passed NaN straight through to LIMIT / OFFSET. Refused instead, with the
|
|
@@ -2104,9 +2151,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2104
2151
|
`WITH x AS (SELECT v FROM marker) INSERT INTO marker (v) SELECT v FROM x`,
|
|
2105
2152
|
];
|
|
2106
2153
|
for (const sql of writes) {
|
|
2107
|
-
// The
|
|
2108
|
-
//
|
|
2109
|
-
await
|
|
2154
|
+
// The code is what the transport renders (a 400). The prefix stays contract too:
|
|
2155
|
+
// a CP-less vertical's console route and the dashboard still recognise it by it.
|
|
2156
|
+
await expectRefusal(host.admin.queryScope(staff, t1, s1, { sql }), 'validation_failed', /read-only console/);
|
|
2110
2157
|
}
|
|
2111
2158
|
// No write happened and nothing was forged: the marker rows are intact.
|
|
2112
2159
|
const after = await host.admin.queryScope(staff, t1, s1, {
|
|
@@ -3759,7 +3806,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3759
3806
|
await expect(host.admin.exportScope(staff, t1, snapId)).rejects.toThrow();
|
|
3760
3807
|
});
|
|
3761
3808
|
it('refuses to delete a primary scope (forkedFrom is null)', async () => {
|
|
3762
|
-
await
|
|
3809
|
+
await expectRefusal(host.deleteSnapshot(staff, t1, s1), 'forbidden', /not a fork/);
|
|
3763
3810
|
// Still there, still readable.
|
|
3764
3811
|
expect((await host.admin.getScopeRecord(staff, t1, s1))?.id).toBe(s1);
|
|
3765
3812
|
});
|
|
@@ -5448,7 +5495,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5448
5495
|
});
|
|
5449
5496
|
it('refuses a promotion that changes permissions until it is acknowledged', async () => {
|
|
5450
5497
|
const v2 = await publish('2.1.0', { perm: 'pB', mig: 'gA' }); // permissions moved
|
|
5451
|
-
await
|
|
5498
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v2), 'conflict', /changes the permission surface/);
|
|
5452
5499
|
// Still pointing at the old one — a refused promotion changes nothing.
|
|
5453
5500
|
const before = await host.admin.listChannels(staff, 'callout');
|
|
5454
5501
|
expect(before.find((c) => c.channel === 'prod')?.versionId).not.toBe(v2);
|
|
@@ -5458,7 +5505,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5458
5505
|
});
|
|
5459
5506
|
it('refuses a promotion that changes migrations until it is acknowledged', async () => {
|
|
5460
5507
|
const v3 = await publish('2.2.0', { perm: 'pB', mig: 'gB' }); // migrations moved
|
|
5461
|
-
await
|
|
5508
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3), 'conflict', /changes migrations/);
|
|
5462
5509
|
await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true });
|
|
5463
5510
|
expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')
|
|
5464
5511
|
?.versionId).toBe(v3);
|
|
@@ -5673,7 +5720,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5673
5720
|
const sc = scopeId.parse(ulid());
|
|
5674
5721
|
await host.admin.createTenant(staff, { id: t, slug: `t-${t.toLowerCase()}`, name: 'T' });
|
|
5675
5722
|
await host.provisionScope(staff, { tenantId: t, scopeId: sc, jurisdiction: 'eu' });
|
|
5676
|
-
await
|
|
5723
|
+
await expectRefusal(host.getScope(alice, t, sc), 'conflict', /not active/);
|
|
5677
5724
|
await host.admin.activateScope(staff, t, sc);
|
|
5678
5725
|
await expect(host.getScope(alice, t, sc)).resolves.toBeDefined();
|
|
5679
5726
|
});
|
|
@@ -5790,7 +5837,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5790
5837
|
canonical: true,
|
|
5791
5838
|
});
|
|
5792
5839
|
await bind(holder);
|
|
5793
|
-
await
|
|
5840
|
+
await expectRefusal(bind(claimant), 'conflict', /already bound to another scope/);
|
|
5794
5841
|
await host.admin.archiveScope(staff, t1, holder);
|
|
5795
5842
|
await bind(claimant); // the holder released the name
|
|
5796
5843
|
await host.admin.setHostnameStatus(staff, 'reclaim.example.com', 'active');
|
|
@@ -5905,14 +5952,14 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5905
5952
|
it('refuses to move a hostname to another scope', async () => {
|
|
5906
5953
|
// A hostname routes to exactly one place. Silently rebinding would move
|
|
5907
5954
|
// another tenant's traffic.
|
|
5908
|
-
await
|
|
5955
|
+
await expectRefusal(host.admin.bindHostname(staff, {
|
|
5909
5956
|
hostname: 'acme.example.com',
|
|
5910
5957
|
tenantId: t2,
|
|
5911
5958
|
scopeId: s2,
|
|
5912
5959
|
surface: 'app',
|
|
5913
5960
|
region: null,
|
|
5914
5961
|
canonical: false,
|
|
5915
|
-
})
|
|
5962
|
+
}), 'conflict', /already bound to another scope/);
|
|
5916
5963
|
});
|
|
5917
5964
|
it('carries the region, which is what Regional Services is set from', async () => {
|
|
5918
5965
|
// Residency is per hostname, which is why it lives here rather than in a
|
|
@@ -6288,6 +6335,43 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6288
6335
|
]);
|
|
6289
6336
|
expect(await relinked(item('rl6'))).toHaveLength(2);
|
|
6290
6337
|
});
|
|
6338
|
+
/**
|
|
6339
|
+
* The documented way to DETACH one parent of a multi-parent child (#2044): relink it
|
|
6340
|
+
* onto a parent it already has. ticket0 takes a conversation's public thread off a
|
|
6341
|
+
* widget session this way, so it is contract, not an accident of `writeEdge`. The
|
|
6342
|
+
* expiries are planted (only a restore can hold one) to pin what the verb does to
|
|
6343
|
+
* them: `to` is left permanent, as every edge a verb leaves live is, and a bystander
|
|
6344
|
+
* parent keeps its own.
|
|
6345
|
+
*/
|
|
6346
|
+
it('relink onto a live parent detaches `from` only: `to` left live and permanent, others untouched', async () => {
|
|
6347
|
+
const FUTURE = '2999-01-01T00:00:00.000Z';
|
|
6348
|
+
await plant([
|
|
6349
|
+
{ subject: 'item:rl-dt', object: 'box:rb1' }, // from
|
|
6350
|
+
{ subject: 'item:rl-dt', object: 'box:rb2', expires_at: FUTURE }, // to, already live
|
|
6351
|
+
{ subject: 'item:rl-dt', object: 'box:rb3', expires_at: FUTURE }, // a bystander
|
|
6352
|
+
]);
|
|
6353
|
+
await move(item('rl-dt'), box('rb1'), box('rb2'));
|
|
6354
|
+
const table = (await host.admin.exportScope(staff, t1, s1)).tables.find((t) => t.name === '_substrat_tuples');
|
|
6355
|
+
const col = (name) => table.columns.indexOf(name);
|
|
6356
|
+
const rows = table.rows
|
|
6357
|
+
.filter((r) => r[col('subject')] === 'item:rl-dt')
|
|
6358
|
+
.map((r) => ({
|
|
6359
|
+
object: r[col('object')],
|
|
6360
|
+
revoked: r[col('revoked_at')] !== null,
|
|
6361
|
+
expires: r[col('expires_at')],
|
|
6362
|
+
}))
|
|
6363
|
+
.sort((x, y) => String(x.object).localeCompare(String(y.object)));
|
|
6364
|
+
expect(rows).toEqual([
|
|
6365
|
+
{ object: 'box:rb1', revoked: true, expires: null },
|
|
6366
|
+
{ object: 'box:rb2', revoked: false, expires: null },
|
|
6367
|
+
{ object: 'box:rb3', revoked: false, expires: FUTURE },
|
|
6368
|
+
]);
|
|
6369
|
+
// One move, recorded as one; the live `to` was neither revived nor re-announced.
|
|
6370
|
+
const events = await relinked(item('rl-dt'));
|
|
6371
|
+
expect(events).toHaveLength(1);
|
|
6372
|
+
expect(events[0]).toMatchObject({ payload: { child: item('rl-dt'), from: box('rb1'), to: box('rb2') } });
|
|
6373
|
+
expect(await relinked(item('rl-dt'), 'entity.linked')).toEqual([]);
|
|
6374
|
+
});
|
|
6291
6375
|
it('link revives an edge relink moved away from — INSERT OR IGNORE would have kept it dead', async () => {
|
|
6292
6376
|
await link(item('rl7'), box('rb1'));
|
|
6293
6377
|
// The twin first: a first-time link records nothing, as it never has.
|
|
@@ -6822,7 +6906,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6822
6906
|
await host.admin.activateScope(staff, t3, s3);
|
|
6823
6907
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
6824
6908
|
await host.admin.setTenantStatus(staff, t3, 'suspended');
|
|
6825
|
-
await
|
|
6909
|
+
await expectRefusal(host.getScope(alice, t3, s3), 'conflict', /tenant not active/);
|
|
6826
6910
|
await host.admin.setTenantStatus(staff, t3, 'active');
|
|
6827
6911
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
6828
6912
|
});
|
|
@@ -6833,7 +6917,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6833
6917
|
expect(suspend.before.status).toBe('active');
|
|
6834
6918
|
});
|
|
6835
6919
|
it('rejects a status transition on an unknown tenant', async () => {
|
|
6836
|
-
await
|
|
6920
|
+
await expectRefusal(host.admin.setTenantStatus(staff, tenantId.parse(ulid()), 'suspended'), 'not_found');
|
|
6837
6921
|
});
|
|
6838
6922
|
it('renames a tenant display name — audited with before/after, slug untouched', async () => {
|
|
6839
6923
|
const before = (await host.admin.getTenant(staff, t3));
|
|
@@ -6848,13 +6932,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6848
6932
|
// Renaming to the current name is a no-op and leaves no audit row.
|
|
6849
6933
|
await host.admin.setTenantName(staff, t3, 'Renamed Co');
|
|
6850
6934
|
expect((await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'setTenantName')).toHaveLength(1);
|
|
6851
|
-
await
|
|
6935
|
+
await expectRefusal(host.admin.setTenantName(staff, tenantId.parse(ulid()), 'Ghost'), 'not_found');
|
|
6852
6936
|
});
|
|
6853
6937
|
// -- scope lifecycle (control-plane.md §4.2) ------------------------------
|
|
6854
6938
|
it('suspend/unsuspend a scope gates getScope for that scope alone (§4.2)', async () => {
|
|
6855
6939
|
// s3 is active (reactivated above). Suspending it fails closed…
|
|
6856
6940
|
await host.admin.suspendScope(staff, t3, s3);
|
|
6857
|
-
await
|
|
6941
|
+
await expectRefusal(host.getScope(alice, t3, s3), 'conflict', /scope not active/);
|
|
6858
6942
|
// …while a sibling scope under the same tenant is untouched.
|
|
6859
6943
|
const sibling = scopeId.parse(ulid());
|
|
6860
6944
|
await host.provisionScope(staff, { tenantId: t3, scopeId: sibling, jurisdiction: 'eu' });
|
|
@@ -6866,7 +6950,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6866
6950
|
});
|
|
6867
6951
|
it('archive then un-archive is an explicit audited restore (§4.2)', async () => {
|
|
6868
6952
|
await host.admin.archiveScope(staff, t3, s3);
|
|
6869
|
-
await
|
|
6953
|
+
await expectRefusal(host.getScope(alice, t3, s3), 'conflict', /scope not active/);
|
|
6870
6954
|
await host.admin.unarchiveScope(staff, t3, s3);
|
|
6871
6955
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
6872
6956
|
const transitions = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'archiveScope' || r.action === 'unarchiveScope');
|
|
@@ -6878,7 +6962,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6878
6962
|
});
|
|
6879
6963
|
it('rejects an illegal scope transition, fail closed (§4.2)', async () => {
|
|
6880
6964
|
// s3 is active — you cannot un-archive an active scope.
|
|
6881
|
-
await
|
|
6965
|
+
await expectRefusal(host.admin.unarchiveScope(staff, t3, s3), 'conflict', /illegal scope transition/);
|
|
6882
6966
|
});
|
|
6883
6967
|
// -- reap: the terminal storage wipe (control-plane.md §4.4) ---------------
|
|
6884
6968
|
it('archive stamps archivedAt; unarchive clears it (§4.4)', async () => {
|
|
@@ -6903,7 +6987,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6903
6987
|
await host.provisionScope(staff, { tenantId: t3, scopeId: s, slug: 'reap-me', jurisdiction: 'eu' });
|
|
6904
6988
|
await host.admin.activateScope(staff, t3, s);
|
|
6905
6989
|
// Reap refuses a live (active) scope — only archived may be reaped.
|
|
6906
|
-
await
|
|
6990
|
+
await expectRefusal(host.admin.reapScope(staff, t3, s), 'conflict', /not archived/);
|
|
6907
6991
|
await host.admin.archiveScope(staff, t3, s);
|
|
6908
6992
|
await host.admin.reapScope(staff, t3, s);
|
|
6909
6993
|
// The directory row SURVIVES as a tombstone, now `reaped`.
|
|
@@ -6917,10 +7001,10 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6917
7001
|
// "this scope has no events" where the truth is "this scope is gone".
|
|
6918
7002
|
await expect(host.admin.facetEvents(staff, t3, s, { groupBy: { kind: 'type' } })).rejects.toThrow(/reaped/);
|
|
6919
7003
|
await expect(host.admin.entityHistory(staff, t3, s, { entityType: 'test-thing', entityId: 'x1' })).rejects.toThrow(/reaped/);
|
|
6920
|
-
await
|
|
7004
|
+
await expectRefusal(host.admin.listScopeTables(staff, t3, s), 'conflict', /reaped/);
|
|
6921
7005
|
// #1524: a size read is refused BEFORE it reaches the storage. Addressing a reaped
|
|
6922
7006
|
// DO to ask its size would recreate an empty database and report that as the scope.
|
|
6923
|
-
await
|
|
7007
|
+
await expectRefusal(host.admin.scopeDatabaseSize(staff, t3, s), 'conflict', /reaped/);
|
|
6924
7008
|
// Audited as reapScope against the right scope + actor.
|
|
6925
7009
|
const reapEntry = (await host.admin.auditLog(staff, { tenantId: t3 })).find((r) => r.action === 'reapScope' && r.scopeId === s);
|
|
6926
7010
|
expect(reapEntry?.actor).toBe(staff);
|
|
@@ -6955,8 +7039,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6955
7039
|
await host.admin.activateScope(staff, t3, fresh);
|
|
6956
7040
|
await expect(host.getScope(alice, t3, fresh)).resolves.toBeDefined();
|
|
6957
7041
|
// Terminal: a reaped scope cannot be unarchived (bytes are gone) or reaped again.
|
|
6958
|
-
await
|
|
6959
|
-
await
|
|
7042
|
+
await expectRefusal(host.admin.unarchiveScope(staff, t3, s), 'conflict', /illegal scope transition/);
|
|
7043
|
+
await expectRefusal(host.admin.reapScope(staff, t3, s), 'conflict', /not archived/);
|
|
6960
7044
|
});
|
|
6961
7045
|
it('reap refuses while a hostname still resolves; unbind first, then it reaps (§4.4)', async () => {
|
|
6962
7046
|
// The regression this guards: a console `archiveScope` (unlike the dashboard delete
|
|
@@ -6977,7 +7061,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6977
7061
|
// Archive alone (the console path) leaves the hostname bound…
|
|
6978
7062
|
await host.admin.archiveScope(staff, t3, s);
|
|
6979
7063
|
// …so reap refuses, naming the offending hostname, and the row survives untouched.
|
|
6980
|
-
await
|
|
7064
|
+
await expectRefusal(host.admin.reapScope(staff, t3, s), 'conflict', /still resolves hostname 'still-serving\.example\.com'/);
|
|
6981
7065
|
expect((await host.admin.getScopeRecord(staff, t3, s)).status).toBe('archived');
|
|
6982
7066
|
// Unbinding is the visible, reversible step that clears the wall…
|
|
6983
7067
|
await host.admin.unbindHostname(staff, 'still-serving.example.com');
|
|
@@ -6998,7 +7082,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6998
7082
|
const deleting = (await host.admin.getTenant(staff, t));
|
|
6999
7083
|
expect(deleting.status).toBe('deleting');
|
|
7000
7084
|
expect(deleting.deletingAt).not.toBeNull();
|
|
7001
|
-
await
|
|
7085
|
+
await expectRefusal(host.getScope(alice, t, s), 'conflict', /tenant not active/);
|
|
7002
7086
|
// Un-delete (→ active) is a full restore and clears deletingAt.
|
|
7003
7087
|
await host.admin.setTenantStatus(staff, t, 'active');
|
|
7004
7088
|
const restored = (await host.admin.getTenant(staff, t));
|
|
@@ -7023,8 +7107,17 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
7023
7107
|
expect(await host.admin.listEntitlements(staff, t)).toHaveLength(1);
|
|
7024
7108
|
expect((await host.admin.listOrgs(staff, t)).filter((o) => o.id === org)).toHaveLength(1);
|
|
7025
7109
|
expect((await host.admin.resolveIdentity(t, `oidc:reap-${t.toLowerCase()}`, 'x1'))?.principal).toBe(person);
|
|
7110
|
+
// #1748: a finding and a suppress rule are the tenant's too.
|
|
7111
|
+
await host.admin.recordOpsFailure({ actor: staff, operation: 'reap.finding', tenantId: t, message: 'x' });
|
|
7112
|
+
await host.admin.createFindingRule(staff, t, {
|
|
7113
|
+
operation: 'reap.other',
|
|
7114
|
+
expiresAt: instant.parse(new Date(Date.now() + 86_400_000).toISOString()),
|
|
7115
|
+
reason: 'reap fixture',
|
|
7116
|
+
});
|
|
7117
|
+
expect(await host.admin.listFindings(staff, { tenantId: t })).toHaveLength(1);
|
|
7118
|
+
expect(await host.admin.listFindingRules(staff, t)).toHaveLength(1);
|
|
7026
7119
|
// Reap only follows the reversible delete state.
|
|
7027
|
-
await
|
|
7120
|
+
await expectRefusal(host.admin.reapTenant(staff, t), 'conflict', /not deleting/);
|
|
7028
7121
|
// The caller reaps the scope first (the sweep/route orchestrates this above the
|
|
7029
7122
|
// kernel); reapTenant itself is directory-side only.
|
|
7030
7123
|
await host.admin.setTenantStatus(staff, t, 'deleting');
|
|
@@ -7041,6 +7134,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
7041
7134
|
expect(await host.admin.listEntitlements(staff, t)).toHaveLength(0);
|
|
7042
7135
|
expect((await host.admin.listOrgs(staff, t)).filter((o) => o.id === org)).toHaveLength(0);
|
|
7043
7136
|
expect(await host.admin.resolveIdentity(t, `oidc:reap-${t.toLowerCase()}`, 'x1')).toBeUndefined();
|
|
7137
|
+
expect(await host.admin.listFindings(staff, { tenantId: t })).toHaveLength(0);
|
|
7138
|
+
expect(await host.admin.listFindingRules(staff, t)).toHaveLength(0);
|
|
7044
7139
|
// The admin log is KEPT WHOLE — the witness outlives the tenant — and records the reap.
|
|
7045
7140
|
const log = await host.admin.auditLog(staff, { tenantId: t });
|
|
7046
7141
|
expect(log.some((r) => r.action === 'createTenant')).toBe(true);
|
|
@@ -7049,11 +7144,72 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
7049
7144
|
expect(reapEntry.before.status).toBe('deleting');
|
|
7050
7145
|
expect(reapEntry.after.status).toBe('reaped');
|
|
7051
7146
|
// Terminal: `reaped` is unreachable via setTenantStatus and reapTenant refuses it.
|
|
7052
|
-
await
|
|
7053
|
-
await
|
|
7147
|
+
await expectRefusal(host.admin.setTenantStatus(staff, t, 'reaped'), 'validation_failed', /cannot be set to 'reaped'/);
|
|
7148
|
+
await expectRefusal(host.admin.reapTenant(staff, t), 'conflict', /not deleting/);
|
|
7054
7149
|
});
|
|
7055
7150
|
it('rejects reapTenant on an unknown tenant', async () => {
|
|
7056
|
-
await
|
|
7151
|
+
await expectRefusal(host.admin.reapTenant(staff, tenantId.parse(ulid())), 'not_found');
|
|
7152
|
+
});
|
|
7153
|
+
// #113: the last directory refusals the control plane matched by message, each typed now.
|
|
7154
|
+
// Asserted on the code — the thing a transport renders — with its positive twin beside it.
|
|
7155
|
+
it('marks the scope gate\'s refusal as the gate\'s, so a public edge can answer it neutrally (#113)', async () => {
|
|
7156
|
+
const t = tenantId.parse(ulid());
|
|
7157
|
+
const sc = scopeId.parse(ulid());
|
|
7158
|
+
await host.admin.createTenant(staff, { id: t, slug: `gate-${t.toLowerCase()}`, name: 'Gate Co' });
|
|
7159
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: sc });
|
|
7160
|
+
await host.admin.activateScope(staff, t, sc);
|
|
7161
|
+
await host.admin.suspendScope(staff, t, sc);
|
|
7162
|
+
const scopeHeld = await host.getScope(alice, t, sc).then(() => undefined, (e) => e);
|
|
7163
|
+
expect(errorCodeOf(scopeHeld)).toBe('conflict');
|
|
7164
|
+
expect(scopeHeld.extensions.reason).toBe(SCOPE_GATE_REASONS.notActive);
|
|
7165
|
+
await host.admin.unsuspendScope(staff, t, sc);
|
|
7166
|
+
await host.admin.setTenantStatus(staff, t, 'suspended');
|
|
7167
|
+
const tenantHeld = await host.getScope(alice, t, sc).then(() => undefined, (e) => e);
|
|
7168
|
+
expect(tenantHeld.extensions.reason).toBe(SCOPE_GATE_REASONS.notActive);
|
|
7169
|
+
// The twin: an admin refusal of the same code is not the gate's.
|
|
7170
|
+
const illegal = await host.admin.unarchiveScope(staff, t, sc).then(() => undefined, (e) => e);
|
|
7171
|
+
expect(errorCodeOf(illegal)).toBe('conflict');
|
|
7172
|
+
expect(illegal.extensions.reason).toBeUndefined();
|
|
7173
|
+
});
|
|
7174
|
+
it('refuses what used to answer the generic 500 with its own code (#113)', async () => {
|
|
7175
|
+
const t = tenantId.parse(ulid());
|
|
7176
|
+
const sc = scopeId.parse(ulid());
|
|
7177
|
+
await host.admin.createTenant(staff, { id: t, slug: `was500-${t.toLowerCase()}`, name: 'Was 500' });
|
|
7178
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: sc });
|
|
7179
|
+
await host.admin.activateScope(staff, t, sc);
|
|
7180
|
+
// An unknown hostname, on both of its doors.
|
|
7181
|
+
await expectRefusal(host.admin.setHostnameStatus(staff, `ghost-${t.toLowerCase()}.example.com`, 'active'), 'not_found', /unknown hostname/);
|
|
7182
|
+
// A link into a pool nobody registered: its precondition is missing.
|
|
7183
|
+
await expectRefusal(host.admin.linkIdentity(staff, { provider: `oidc:ghost-${t.toLowerCase()}`, externalId: 'u1', principal: alice, tenantId: t }), 'conflict', /is not registered/);
|
|
7184
|
+
// A system door for a module this host never registered.
|
|
7185
|
+
await expectRefusal(host.getSystemScope(moduleId.parse('@ghost/none'), t, sc), 'not_found', /module not registered/);
|
|
7186
|
+
// A migration asked of a scope that is archived.
|
|
7187
|
+
await host.admin.archiveScope(staff, t, sc);
|
|
7188
|
+
await expectRefusal(host.migrateScope(t, sc), 'conflict', /not migratable \(status: archived\)/);
|
|
7189
|
+
});
|
|
7190
|
+
it('refuses to provision a scope under a non-active tenant, typed conflict (#113)', async () => {
|
|
7191
|
+
const t = tenantId.parse(ulid());
|
|
7192
|
+
await host.admin.createTenant(staff, { id: t, slug: `held-${t.toLowerCase()}`, name: 'Held Co' });
|
|
7193
|
+
await host.admin.setTenantStatus(staff, t, 'suspended');
|
|
7194
|
+
await expectRefusal(host.provisionScope(staff, { tenantId: t, scopeId: scopeId.parse(ulid()) }), 'conflict', /non-active tenant \(status: suspended\)/);
|
|
7195
|
+
await host.admin.setTenantStatus(staff, t, 'active');
|
|
7196
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: scopeId.parse(ulid()) }); // the twin: active again, it provisions
|
|
7197
|
+
});
|
|
7198
|
+
it('refuses an identity pool re-registered with another topology, typed conflict (#113)', async () => {
|
|
7199
|
+
const t = tenantId.parse(ulid());
|
|
7200
|
+
const provider = `oidc:pool-${t.toLowerCase()}`;
|
|
7201
|
+
await host.admin.createTenant(staff, { id: t, slug: `pool-${t.toLowerCase()}`, name: 'Pool Co' });
|
|
7202
|
+
await host.admin.registerIdentityPool(staff, { provider, topology: 'tenant-bound', tenantId: t });
|
|
7203
|
+
// The same declaration again is idempotent…
|
|
7204
|
+
await expect(host.admin.registerIdentityPool(staff, { provider, topology: 'tenant-bound', tenantId: t })).resolves.toBeUndefined();
|
|
7205
|
+
// …a different one conflicts.
|
|
7206
|
+
await expectRefusal(host.admin.registerIdentityPool(staff, { provider, topology: 'central', tenantId: null }), 'conflict', /is already registered as tenant-bound/);
|
|
7207
|
+
});
|
|
7208
|
+
it('refuses a vertical re-registered from another source, typed conflict (#113)', async () => {
|
|
7209
|
+
const slug = `src-${ulid().toLowerCase()}`;
|
|
7210
|
+
await host.admin.registerVertical(staff, { slug, name: 'Source Test', source: 'git' });
|
|
7211
|
+
await expect(host.admin.registerVertical(staff, { slug, name: 'Source Test', source: 'git' })).resolves.toBeUndefined();
|
|
7212
|
+
await expectRefusal(host.admin.registerVertical(staff, { slug, name: 'Source Test', source: 'cli' }), 'conflict', /is already registered as git/);
|
|
7057
7213
|
});
|
|
7058
7214
|
// -- identity links: the projection read (#406) ---------------------------
|
|
7059
7215
|
it('listIdentityLinks returns one tenant’s links and tracks link/unlink', async () => {
|
|
@@ -7347,7 +7503,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
7347
7503
|
});
|
|
7348
7504
|
it('refuses a slug already taken under the tenant, and re-provision is still idempotent (§3.2)', async () => {
|
|
7349
7505
|
const other = scopeId.parse(ulid());
|
|
7350
|
-
await
|
|
7506
|
+
await expectRefusal(host.provisionScope(staff, { tenantId: t5, scopeId: other, slug: 'brf-vasastan' }), 'conflict', /already taken/);
|
|
7351
7507
|
// Idempotency is keyed on the scope id, so re-provisioning the SAME scope
|
|
7352
7508
|
// must not collide with its own slug.
|
|
7353
7509
|
const named = (await host.admin.listScopes(staff, { tenantId: t5 })).find((s) => s.slug === 'brf-vasastan');
|
|
@@ -7362,11 +7518,11 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
7362
7518
|
it('refuses a tenant slug already taken, fail closed (§4.1)', async () => {
|
|
7363
7519
|
// INSERT OR IGNORE would have reported this as an idempotent no-op and
|
|
7364
7520
|
// silently not created the tenant the caller asked for.
|
|
7365
|
-
await
|
|
7521
|
+
await expectRefusal(host.admin.createTenant(staff, {
|
|
7366
7522
|
id: tenantId.parse(ulid()),
|
|
7367
7523
|
slug: 'directory-co',
|
|
7368
7524
|
name: 'Impostor Co',
|
|
7369
|
-
})
|
|
7525
|
+
}), 'conflict', /already taken/);
|
|
7370
7526
|
});
|
|
7371
7527
|
it('enumerates the scopes under a tenant, and filters by status (§4.5)', async () => {
|
|
7372
7528
|
const all = await host.admin.listScopes(staff, { tenantId: t5 });
|