@substrat-run/contract-tests 0.136.0 → 0.138.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/job-run-suite.d.ts.map +1 -1
- package/dist/job-run-suite.js +296 -2
- package/dist/job-run-suite.js.map +1 -1
- package/dist/membership-executor-suite.d.ts +15 -0
- package/dist/membership-executor-suite.d.ts.map +1 -0
- package/dist/membership-executor-suite.js +620 -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 +30 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +77 -3
- 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 +7 -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 +306 -49
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/system-switch-suite.d.ts.map +1 -1
- package/dist/system-switch-suite.js +114 -12
- package/dist/system-switch-suite.js.map +1 -1
- 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,8 +1,8 @@
|
|
|
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
|
-
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
|
|
5
|
+
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, testModManifest, victimModManifest, } from './modules.js';
|
|
6
6
|
/**
|
|
7
7
|
* Assert a refusal by the CODE it declared, not by the sentence it happens to carry
|
|
8
8
|
* (#113 phase 5).
|
|
@@ -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();
|
|
@@ -1815,15 +1816,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1815
1816
|
await stub.invoke('test/move', { entityId: 'l1' });
|
|
1816
1817
|
const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
|
|
1817
1818
|
entityType: 'test-lifecycle',
|
|
1818
|
-
lifecycle:
|
|
1819
|
-
field: 'state',
|
|
1820
|
-
initial: 'draft',
|
|
1821
|
-
states: {
|
|
1822
|
-
draft: { on: { 'test/move': 'live' } },
|
|
1823
|
-
live: { on: { 'test/move': 'done' } },
|
|
1824
|
-
done: { terminal: true },
|
|
1825
|
-
},
|
|
1826
|
-
},
|
|
1819
|
+
lifecycle: TEST_LIFECYCLE,
|
|
1827
1820
|
since,
|
|
1828
1821
|
until: new Date(Date.now() + 60_000).toISOString(),
|
|
1829
1822
|
});
|
|
@@ -1833,6 +1826,130 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1833
1826
|
expect(flow.observation).toMatchObject({ entities: 1, events: 3, inferred: 1, complete: true });
|
|
1834
1827
|
expect(flow.totals.finished).toBe(1);
|
|
1835
1828
|
});
|
|
1829
|
+
describe('lifecycleFlow on both adapters (#1744)', () => {
|
|
1830
|
+
let stub;
|
|
1831
|
+
beforeAll(async () => {
|
|
1832
|
+
stub = await host.getScope(alice, t1, s1);
|
|
1833
|
+
});
|
|
1834
|
+
/** One move, as its own call, returning the instant the outbox stamped on it. */
|
|
1835
|
+
const move = async (entityId, state) => {
|
|
1836
|
+
const invocationId = ulid();
|
|
1837
|
+
await stub.invoke('test/move', { entityId, state }, { invocationId });
|
|
1838
|
+
const { events } = await host.admin.invocationEvents(staff, t1, s1, { invocationId });
|
|
1839
|
+
expect(events).toHaveLength(1);
|
|
1840
|
+
const at = events[0].occurredAt;
|
|
1841
|
+
// The boundaries below are exact instants, so no two moves may share one.
|
|
1842
|
+
while (Date.now() <= Date.parse(at))
|
|
1843
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
1844
|
+
return at;
|
|
1845
|
+
};
|
|
1846
|
+
const edge = (flow, from, to) => flow.edges.find((e) => e.from === from && e.to === to && e.declared);
|
|
1847
|
+
const state = (flow, name) => flow.states.find((s) => s.state === name);
|
|
1848
|
+
it('counts an event at `since` and not one at `until`: the window is half-open', async () => {
|
|
1849
|
+
const a = await move('w1', 'draft');
|
|
1850
|
+
const b = await move('w1', 'live');
|
|
1851
|
+
const c = await move('w1', 'done');
|
|
1852
|
+
expect(a < b && b < c).toBe(true);
|
|
1853
|
+
const read = (since, until) => host.admin.lifecycleFlow(staff, t1, s1, { entityType: 'test-lifecycle', lifecycle: TEST_LIFECYCLE, since, until });
|
|
1854
|
+
// [b, c): the move AT `since` is in; the move AT `until` is not replayed at all,
|
|
1855
|
+
// so "now" is still `live` — and the declared edge it would have taken reads 0.
|
|
1856
|
+
const mid = await read(b, c);
|
|
1857
|
+
expect(edge(mid, 'draft', 'live').count).toBe(1);
|
|
1858
|
+
expect(edge(mid, 'live', 'done').count).toBe(0);
|
|
1859
|
+
expect(state(mid, 'live').current).toBe(1);
|
|
1860
|
+
// [a, b): history before `since` is replayed, never counted, and `b` is outside.
|
|
1861
|
+
const before = await read(a, b);
|
|
1862
|
+
expect(edge(before, 'draft', 'live').count).toBe(0);
|
|
1863
|
+
expect(state(before, 'draft').current).toBeGreaterThanOrEqual(1);
|
|
1864
|
+
// The twin: one millisecond past `c` and the same move is counted.
|
|
1865
|
+
const after = await read(b, new Date(Date.parse(c) + 1).toISOString());
|
|
1866
|
+
expect(edge(after, 'draft', 'live').count).toBe(1);
|
|
1867
|
+
expect(edge(after, 'live', 'done').count).toBe(1);
|
|
1868
|
+
expect(state(after, 'live').current).toBe(0);
|
|
1869
|
+
});
|
|
1870
|
+
it('lists a move the declaration does not have apart from the declared edges', async () => {
|
|
1871
|
+
const since = await move('u1', 'draft');
|
|
1872
|
+
await move('u1', 'done'); // `test/move` from draft is declared to lead to `live`
|
|
1873
|
+
await move('u2', 'draft');
|
|
1874
|
+
await move('u2', 'limbo'); // a state the declaration does not have at all
|
|
1875
|
+
const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
|
|
1876
|
+
entityType: 'test-lifecycle',
|
|
1877
|
+
lifecycle: TEST_LIFECYCLE,
|
|
1878
|
+
since,
|
|
1879
|
+
until: new Date(Date.now() + 60_000).toISOString(),
|
|
1880
|
+
});
|
|
1881
|
+
const undeclared = flow.edges.filter((e) => !e.declared);
|
|
1882
|
+
expect(undeclared).toHaveLength(2);
|
|
1883
|
+
expect(undeclared).toEqual(expect.arrayContaining([
|
|
1884
|
+
expect.objectContaining({ from: 'draft', to: 'done', operation: 'test/move', count: 1, seenLate: false }),
|
|
1885
|
+
expect.objectContaining({ from: 'draft', to: 'limbo', operation: 'test/move', count: 1, seenLate: false }),
|
|
1886
|
+
]));
|
|
1887
|
+
// Not folded into the declared edge out of the same state under the same operation.
|
|
1888
|
+
expect(edge(flow, 'draft', 'live').count).toBe(0);
|
|
1889
|
+
expect(state(flow, 'limbo')).toMatchObject({ declared: false, current: 1 });
|
|
1890
|
+
});
|
|
1891
|
+
it("answers only for the tenant's own scope: a mismatched pair is refused, another tenant's scope sees none of it", async () => {
|
|
1892
|
+
const since = await move('i1', 'draft');
|
|
1893
|
+
const window = { entityType: 'test-lifecycle', lifecycle: TEST_LIFECYCLE, since, until: new Date(Date.now() + 60_000).toISOString() };
|
|
1894
|
+
// The positive twin: t1's own pair sees the instance just started.
|
|
1895
|
+
expect((await host.admin.lifecycleFlow(staff, t1, s1, window)).totals.started).toBe(1);
|
|
1896
|
+
// K-3: never another tenant's outbox, and never an empty answer for one either.
|
|
1897
|
+
await expect(host.admin.lifecycleFlow(staff, t2, s1, window)).rejects.toThrow();
|
|
1898
|
+
await expect(host.admin.lifecycleFlow(staff, t1, s2, window)).rejects.toThrow();
|
|
1899
|
+
// t2's own scope answers, from its own outbox — in which t1 emitted nothing.
|
|
1900
|
+
const own = await host.admin.lifecycleFlow(staff, t2, s2, window);
|
|
1901
|
+
expect(own.observation).toMatchObject({ entities: 0, events: 0, complete: true });
|
|
1902
|
+
expect(own.edges.every((e) => e.count === 0)).toBe(true);
|
|
1903
|
+
// K-24: the read is recorded against the actor.
|
|
1904
|
+
expect((await host.admin.accessLog(staff, { tenantId: t1, method: 'lifecycleFlow' })).length).toBeGreaterThan(0);
|
|
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
|
+
});
|
|
1836
1953
|
it('records a refused transition after the rollback, and the replay counts it (#1745)', async () => {
|
|
1837
1954
|
// The operation throws the platform's conflict, so its transaction rolls back — the
|
|
1838
1955
|
// refusal is the one write that must survive it, on both adapters.
|
|
@@ -1842,11 +1959,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1842
1959
|
await expect(stub.invoke('test/refuse', { entityId: 'r1', from: 'done' })).rejects.toThrow(/invalid transition/);
|
|
1843
1960
|
const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
|
|
1844
1961
|
entityType: 'test-lifecycle',
|
|
1845
|
-
lifecycle:
|
|
1846
|
-
field: 'state',
|
|
1847
|
-
initial: 'draft',
|
|
1848
|
-
states: { draft: { on: { 'test/move': 'live' } }, live: { on: { 'test/move': 'done' } }, done: { terminal: true } },
|
|
1849
|
-
},
|
|
1962
|
+
lifecycle: TEST_LIFECYCLE,
|
|
1850
1963
|
since,
|
|
1851
1964
|
until: new Date(Date.now() + 60_000).toISOString(),
|
|
1852
1965
|
});
|
|
@@ -1878,6 +1991,11 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1878
1991
|
drainedAt: null,
|
|
1879
1992
|
});
|
|
1880
1993
|
expect(rows[0].decodeError).toBeUndefined();
|
|
1994
|
+
expect(rows[0].guard).toBeNull();
|
|
1995
|
+
// A move the lifecycle allows records nothing.
|
|
1996
|
+
const allowed = ulid();
|
|
1997
|
+
await stub.invoke('test/move', { entityId: 'r2-ok', state: 'draft' }, { invocationId: allowed });
|
|
1998
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: allowed })).toEqual([]);
|
|
1881
1999
|
// The row survived the operation's rollback — and the operation itself left nothing
|
|
1882
2000
|
// behind in the outbox for that call.
|
|
1883
2001
|
expect((await host.admin.invocationEvents(staff, t1, s1, { invocationId: call })).events).toEqual([]);
|
|
@@ -1893,6 +2011,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1893
2011
|
const logged = await host.admin.accessLog(staff, { tenantId: t1, method: 'listRefusals' });
|
|
1894
2012
|
expect(logged.length).toBeGreaterThan(0);
|
|
1895
2013
|
await expect(host.admin.listRefusals(staff, t2, s1)).rejects.toThrow();
|
|
2014
|
+
// A record whose status column holds arbitrary text: the refusal is recorded, with the
|
|
2015
|
+
// state as the marker — the lifecycle never vouched for that text, so it is not kept.
|
|
2016
|
+
const undeclared = ulid();
|
|
2017
|
+
await expect(stub.invoke('test/refuse', { entityId: 'r2-odd', from: 'call ada@example.com' }, { invocationId: undeclared })).rejects.toThrow(/invalid transition/);
|
|
2018
|
+
const [oddRow] = await host.admin.listRefusals(staff, t1, s1, { invocationId: undeclared });
|
|
2019
|
+
expect(oddRow).toMatchObject({ kind: 'transition', fromState: 'undeclared', entityId: 'r2-odd', operation: 'test/move' });
|
|
2020
|
+
expect(JSON.stringify(oddRow)).not.toMatch(/ada@example\.com|call ada/);
|
|
1896
2021
|
});
|
|
1897
2022
|
// -- scope data introspection: the §5.4 admin-query RPC --------------------
|
|
1898
2023
|
//
|
|
@@ -1927,7 +2052,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1927
2052
|
expect(page.limit).toBe(1);
|
|
1928
2053
|
});
|
|
1929
2054
|
it('rejects an unknown table', async () => {
|
|
1930
|
-
await
|
|
2055
|
+
await expectRefusal(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 }), 'not_found');
|
|
1931
2056
|
});
|
|
1932
2057
|
it('refuses a page bound SQLite would misread, and still clamps to the ceiling (#1632)', async () => {
|
|
1933
2058
|
// The clamp passed NaN straight through to LIMIT / OFFSET. Refused instead, with the
|
|
@@ -2026,9 +2151,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2026
2151
|
`WITH x AS (SELECT v FROM marker) INSERT INTO marker (v) SELECT v FROM x`,
|
|
2027
2152
|
];
|
|
2028
2153
|
for (const sql of writes) {
|
|
2029
|
-
// The
|
|
2030
|
-
//
|
|
2031
|
-
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/);
|
|
2032
2157
|
}
|
|
2033
2158
|
// No write happened and nothing was forged: the marker rows are intact.
|
|
2034
2159
|
const after = await host.admin.queryScope(staff, t1, s1, {
|
|
@@ -3681,7 +3806,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
3681
3806
|
await expect(host.admin.exportScope(staff, t1, snapId)).rejects.toThrow();
|
|
3682
3807
|
});
|
|
3683
3808
|
it('refuses to delete a primary scope (forkedFrom is null)', async () => {
|
|
3684
|
-
await
|
|
3809
|
+
await expectRefusal(host.deleteSnapshot(staff, t1, s1), 'forbidden', /not a fork/);
|
|
3685
3810
|
// Still there, still readable.
|
|
3686
3811
|
expect((await host.admin.getScopeRecord(staff, t1, s1))?.id).toBe(s1);
|
|
3687
3812
|
});
|
|
@@ -5370,7 +5495,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5370
5495
|
});
|
|
5371
5496
|
it('refuses a promotion that changes permissions until it is acknowledged', async () => {
|
|
5372
5497
|
const v2 = await publish('2.1.0', { perm: 'pB', mig: 'gA' }); // permissions moved
|
|
5373
|
-
await
|
|
5498
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v2), 'conflict', /changes the permission surface/);
|
|
5374
5499
|
// Still pointing at the old one — a refused promotion changes nothing.
|
|
5375
5500
|
const before = await host.admin.listChannels(staff, 'callout');
|
|
5376
5501
|
expect(before.find((c) => c.channel === 'prod')?.versionId).not.toBe(v2);
|
|
@@ -5380,7 +5505,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5380
5505
|
});
|
|
5381
5506
|
it('refuses a promotion that changes migrations until it is acknowledged', async () => {
|
|
5382
5507
|
const v3 = await publish('2.2.0', { perm: 'pB', mig: 'gB' }); // migrations moved
|
|
5383
|
-
await
|
|
5508
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3), 'conflict', /changes migrations/);
|
|
5384
5509
|
await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true });
|
|
5385
5510
|
expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')
|
|
5386
5511
|
?.versionId).toBe(v3);
|
|
@@ -5595,7 +5720,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5595
5720
|
const sc = scopeId.parse(ulid());
|
|
5596
5721
|
await host.admin.createTenant(staff, { id: t, slug: `t-${t.toLowerCase()}`, name: 'T' });
|
|
5597
5722
|
await host.provisionScope(staff, { tenantId: t, scopeId: sc, jurisdiction: 'eu' });
|
|
5598
|
-
await
|
|
5723
|
+
await expectRefusal(host.getScope(alice, t, sc), 'conflict', /not active/);
|
|
5599
5724
|
await host.admin.activateScope(staff, t, sc);
|
|
5600
5725
|
await expect(host.getScope(alice, t, sc)).resolves.toBeDefined();
|
|
5601
5726
|
});
|
|
@@ -5712,7 +5837,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5712
5837
|
canonical: true,
|
|
5713
5838
|
});
|
|
5714
5839
|
await bind(holder);
|
|
5715
|
-
await
|
|
5840
|
+
await expectRefusal(bind(claimant), 'conflict', /already bound to another scope/);
|
|
5716
5841
|
await host.admin.archiveScope(staff, t1, holder);
|
|
5717
5842
|
await bind(claimant); // the holder released the name
|
|
5718
5843
|
await host.admin.setHostnameStatus(staff, 'reclaim.example.com', 'active');
|
|
@@ -5827,14 +5952,14 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5827
5952
|
it('refuses to move a hostname to another scope', async () => {
|
|
5828
5953
|
// A hostname routes to exactly one place. Silently rebinding would move
|
|
5829
5954
|
// another tenant's traffic.
|
|
5830
|
-
await
|
|
5955
|
+
await expectRefusal(host.admin.bindHostname(staff, {
|
|
5831
5956
|
hostname: 'acme.example.com',
|
|
5832
5957
|
tenantId: t2,
|
|
5833
5958
|
scopeId: s2,
|
|
5834
5959
|
surface: 'app',
|
|
5835
5960
|
region: null,
|
|
5836
5961
|
canonical: false,
|
|
5837
|
-
})
|
|
5962
|
+
}), 'conflict', /already bound to another scope/);
|
|
5838
5963
|
});
|
|
5839
5964
|
it('carries the region, which is what Regional Services is set from', async () => {
|
|
5840
5965
|
// Residency is per hostname, which is why it lives here rather than in a
|
|
@@ -5917,6 +6042,77 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5917
6042
|
const stub = await host.getScope(alice, t1, s1);
|
|
5918
6043
|
await expect(stub.invoke('guarded/rows')).resolves.toEqual(['go']);
|
|
5919
6044
|
});
|
|
6045
|
+
it('records a guard refusal once, after the rollback, with no message or input on the row (#1745)', async () => {
|
|
6046
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
6047
|
+
const rowsBefore = await stub.invoke('guarded/rows');
|
|
6048
|
+
const call = ulid();
|
|
6049
|
+
const note = 'call me at ada@example.com';
|
|
6050
|
+
await expect(stub.invoke('guarded/open', { id: 'g-refused', note }, { invocationId: call })).rejects.toThrow(/gate closed/);
|
|
6051
|
+
// The caller's answer is the predicate's own 409, unchanged by being recorded.
|
|
6052
|
+
await expect(stub.invoke('guarded/open', { id: 'g-refused', note })).rejects.toMatchObject({
|
|
6053
|
+
code: 'conflict',
|
|
6054
|
+
});
|
|
6055
|
+
const rows = await host.admin.listRefusals(staff, t1, s1, { invocationId: call });
|
|
6056
|
+
expect(rows).toHaveLength(1);
|
|
6057
|
+
expect(rows[0]).toMatchObject({
|
|
6058
|
+
kind: 'guard',
|
|
6059
|
+
guard: 'gate/must-be-open',
|
|
6060
|
+
reason: 'gate_closed',
|
|
6061
|
+
actor: alice,
|
|
6062
|
+
actorKind: 'principal',
|
|
6063
|
+
tenantId: t1,
|
|
6064
|
+
scopeId: s1,
|
|
6065
|
+
entityType: 'guarded-thing',
|
|
6066
|
+
entityId: 'g-refused',
|
|
6067
|
+
// A guard stands before an operation; the kernel does not know the record's state.
|
|
6068
|
+
fromState: null,
|
|
6069
|
+
attemptedState: null,
|
|
6070
|
+
operation: 'guarded/open',
|
|
6071
|
+
invokedOperation: 'guarded/open',
|
|
6072
|
+
impersonation: null,
|
|
6073
|
+
invocationId: call,
|
|
6074
|
+
drainedAt: null,
|
|
6075
|
+
});
|
|
6076
|
+
expect(rows[0].decodeError).toBeUndefined();
|
|
6077
|
+
// Keys and vocabulary only: neither the input nor the message the caller saw.
|
|
6078
|
+
expect(JSON.stringify(rows[0])).not.toMatch(/ada@example\.com|gate closed|call me/);
|
|
6079
|
+
// Both attempts recorded, each once, and as guards.
|
|
6080
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { kind: 'guard', entityId: 'g-refused' })).toHaveLength(2);
|
|
6081
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { kind: 'transition', entityId: 'g-refused' })).toEqual([]);
|
|
6082
|
+
// The wire takes any non-empty string as a conflict's reason, so a predicate can put
|
|
6083
|
+
// request text there; the row keeps a code or the fixed marker, never the text.
|
|
6084
|
+
const leaked = ulid();
|
|
6085
|
+
await expect(stub.invoke('guarded/open', { id: 'g-leak', reason: 'call ada@example.com' }, { invocationId: leaked })).rejects.toThrow(/gate closed/);
|
|
6086
|
+
const [leakRow] = await host.admin.listRefusals(staff, t1, s1, { invocationId: leaked });
|
|
6087
|
+
expect(leakRow).toMatchObject({ kind: 'guard', reason: 'unrecognized', entityId: 'g-leak' });
|
|
6088
|
+
expect(JSON.stringify(leakRow)).not.toMatch(/ada@example\.com|call ada/);
|
|
6089
|
+
// The same for the record's type, which a predicate names from whatever it was handed:
|
|
6090
|
+
// kept only when spelled as an entity type. The id stays — it is the record's key.
|
|
6091
|
+
const typed = ulid();
|
|
6092
|
+
await expect(stub.invoke('guarded/open', { id: 'g-typed', entityType: 'ada@example.com' }, { invocationId: typed })).rejects.toThrow(/gate closed/);
|
|
6093
|
+
const [typedRow] = await host.admin.listRefusals(staff, t1, s1, { invocationId: typed });
|
|
6094
|
+
expect(typedRow).toMatchObject({ kind: 'guard', entityType: 'undeclared', entityId: 'g-typed' });
|
|
6095
|
+
expect(JSON.stringify(typedRow)).not.toMatch(/ada@example\.com/);
|
|
6096
|
+
// The rollback took the predicate's own write with it, and the handler never ran —
|
|
6097
|
+
// the refusal row is the one write that survived.
|
|
6098
|
+
expect(await stub.invoke('guarded/rows')).toEqual(rowsBefore);
|
|
6099
|
+
});
|
|
6100
|
+
it('records nothing for a guard that passes, or one that throws something other than a refusal (#1745)', async () => {
|
|
6101
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
6102
|
+
const rowsBefore = await stub.invoke('guarded/rows');
|
|
6103
|
+
const passed = ulid();
|
|
6104
|
+
await stub.invoke('guarded/open', { id: 'g-open', open: true }, { invocationId: passed });
|
|
6105
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: passed })).toEqual([]);
|
|
6106
|
+
expect(await stub.invoke('guarded/rows')).toEqual([...rowsBefore, 'gate saw g-open', 'opened g-open']);
|
|
6107
|
+
// A bare Error is a guard failing, not a guard refusing: blocked, never recorded.
|
|
6108
|
+
const failed = ulid();
|
|
6109
|
+
await expect(stub.invoke('guarded/act', { flag: 'stop' }, { invocationId: failed })).rejects.toThrow(/expected flag/);
|
|
6110
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: failed })).toEqual([]);
|
|
6111
|
+
// And a guard whose predicate is missing fails closed, also unrecorded.
|
|
6112
|
+
const orphan = ulid();
|
|
6113
|
+
await expect(stub.invoke('guarded/orphan', {}, { invocationId: orphan })).rejects.toThrow(/unknown guard predicate/);
|
|
6114
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: orphan })).toEqual([]);
|
|
6115
|
+
});
|
|
5920
6116
|
// -- operation withdrawal (K-17) -----------------------------------------
|
|
5921
6117
|
it('withdraws a default binding regardless of registration order (K-17)', async () => {
|
|
5922
6118
|
const stub = await host.getScope(alice, t1, s1);
|
|
@@ -6673,7 +6869,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6673
6869
|
await host.admin.activateScope(staff, t3, s3);
|
|
6674
6870
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
6675
6871
|
await host.admin.setTenantStatus(staff, t3, 'suspended');
|
|
6676
|
-
await
|
|
6872
|
+
await expectRefusal(host.getScope(alice, t3, s3), 'conflict', /tenant not active/);
|
|
6677
6873
|
await host.admin.setTenantStatus(staff, t3, 'active');
|
|
6678
6874
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
6679
6875
|
});
|
|
@@ -6684,7 +6880,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6684
6880
|
expect(suspend.before.status).toBe('active');
|
|
6685
6881
|
});
|
|
6686
6882
|
it('rejects a status transition on an unknown tenant', async () => {
|
|
6687
|
-
await
|
|
6883
|
+
await expectRefusal(host.admin.setTenantStatus(staff, tenantId.parse(ulid()), 'suspended'), 'not_found');
|
|
6688
6884
|
});
|
|
6689
6885
|
it('renames a tenant display name — audited with before/after, slug untouched', async () => {
|
|
6690
6886
|
const before = (await host.admin.getTenant(staff, t3));
|
|
@@ -6699,13 +6895,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6699
6895
|
// Renaming to the current name is a no-op and leaves no audit row.
|
|
6700
6896
|
await host.admin.setTenantName(staff, t3, 'Renamed Co');
|
|
6701
6897
|
expect((await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'setTenantName')).toHaveLength(1);
|
|
6702
|
-
await
|
|
6898
|
+
await expectRefusal(host.admin.setTenantName(staff, tenantId.parse(ulid()), 'Ghost'), 'not_found');
|
|
6703
6899
|
});
|
|
6704
6900
|
// -- scope lifecycle (control-plane.md §4.2) ------------------------------
|
|
6705
6901
|
it('suspend/unsuspend a scope gates getScope for that scope alone (§4.2)', async () => {
|
|
6706
6902
|
// s3 is active (reactivated above). Suspending it fails closed…
|
|
6707
6903
|
await host.admin.suspendScope(staff, t3, s3);
|
|
6708
|
-
await
|
|
6904
|
+
await expectRefusal(host.getScope(alice, t3, s3), 'conflict', /scope not active/);
|
|
6709
6905
|
// …while a sibling scope under the same tenant is untouched.
|
|
6710
6906
|
const sibling = scopeId.parse(ulid());
|
|
6711
6907
|
await host.provisionScope(staff, { tenantId: t3, scopeId: sibling, jurisdiction: 'eu' });
|
|
@@ -6717,7 +6913,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6717
6913
|
});
|
|
6718
6914
|
it('archive then un-archive is an explicit audited restore (§4.2)', async () => {
|
|
6719
6915
|
await host.admin.archiveScope(staff, t3, s3);
|
|
6720
|
-
await
|
|
6916
|
+
await expectRefusal(host.getScope(alice, t3, s3), 'conflict', /scope not active/);
|
|
6721
6917
|
await host.admin.unarchiveScope(staff, t3, s3);
|
|
6722
6918
|
await expect(host.getScope(alice, t3, s3)).resolves.toBeDefined();
|
|
6723
6919
|
const transitions = (await host.admin.auditLog(staff, { tenantId: t3 })).filter((r) => r.action === 'archiveScope' || r.action === 'unarchiveScope');
|
|
@@ -6729,7 +6925,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6729
6925
|
});
|
|
6730
6926
|
it('rejects an illegal scope transition, fail closed (§4.2)', async () => {
|
|
6731
6927
|
// s3 is active — you cannot un-archive an active scope.
|
|
6732
|
-
await
|
|
6928
|
+
await expectRefusal(host.admin.unarchiveScope(staff, t3, s3), 'conflict', /illegal scope transition/);
|
|
6733
6929
|
});
|
|
6734
6930
|
// -- reap: the terminal storage wipe (control-plane.md §4.4) ---------------
|
|
6735
6931
|
it('archive stamps archivedAt; unarchive clears it (§4.4)', async () => {
|
|
@@ -6754,7 +6950,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6754
6950
|
await host.provisionScope(staff, { tenantId: t3, scopeId: s, slug: 'reap-me', jurisdiction: 'eu' });
|
|
6755
6951
|
await host.admin.activateScope(staff, t3, s);
|
|
6756
6952
|
// Reap refuses a live (active) scope — only archived may be reaped.
|
|
6757
|
-
await
|
|
6953
|
+
await expectRefusal(host.admin.reapScope(staff, t3, s), 'conflict', /not archived/);
|
|
6758
6954
|
await host.admin.archiveScope(staff, t3, s);
|
|
6759
6955
|
await host.admin.reapScope(staff, t3, s);
|
|
6760
6956
|
// The directory row SURVIVES as a tombstone, now `reaped`.
|
|
@@ -6768,10 +6964,10 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6768
6964
|
// "this scope has no events" where the truth is "this scope is gone".
|
|
6769
6965
|
await expect(host.admin.facetEvents(staff, t3, s, { groupBy: { kind: 'type' } })).rejects.toThrow(/reaped/);
|
|
6770
6966
|
await expect(host.admin.entityHistory(staff, t3, s, { entityType: 'test-thing', entityId: 'x1' })).rejects.toThrow(/reaped/);
|
|
6771
|
-
await
|
|
6967
|
+
await expectRefusal(host.admin.listScopeTables(staff, t3, s), 'conflict', /reaped/);
|
|
6772
6968
|
// #1524: a size read is refused BEFORE it reaches the storage. Addressing a reaped
|
|
6773
6969
|
// DO to ask its size would recreate an empty database and report that as the scope.
|
|
6774
|
-
await
|
|
6970
|
+
await expectRefusal(host.admin.scopeDatabaseSize(staff, t3, s), 'conflict', /reaped/);
|
|
6775
6971
|
// Audited as reapScope against the right scope + actor.
|
|
6776
6972
|
const reapEntry = (await host.admin.auditLog(staff, { tenantId: t3 })).find((r) => r.action === 'reapScope' && r.scopeId === s);
|
|
6777
6973
|
expect(reapEntry?.actor).toBe(staff);
|
|
@@ -6806,8 +7002,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6806
7002
|
await host.admin.activateScope(staff, t3, fresh);
|
|
6807
7003
|
await expect(host.getScope(alice, t3, fresh)).resolves.toBeDefined();
|
|
6808
7004
|
// Terminal: a reaped scope cannot be unarchived (bytes are gone) or reaped again.
|
|
6809
|
-
await
|
|
6810
|
-
await
|
|
7005
|
+
await expectRefusal(host.admin.unarchiveScope(staff, t3, s), 'conflict', /illegal scope transition/);
|
|
7006
|
+
await expectRefusal(host.admin.reapScope(staff, t3, s), 'conflict', /not archived/);
|
|
6811
7007
|
});
|
|
6812
7008
|
it('reap refuses while a hostname still resolves; unbind first, then it reaps (§4.4)', async () => {
|
|
6813
7009
|
// The regression this guards: a console `archiveScope` (unlike the dashboard delete
|
|
@@ -6828,7 +7024,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6828
7024
|
// Archive alone (the console path) leaves the hostname bound…
|
|
6829
7025
|
await host.admin.archiveScope(staff, t3, s);
|
|
6830
7026
|
// …so reap refuses, naming the offending hostname, and the row survives untouched.
|
|
6831
|
-
await
|
|
7027
|
+
await expectRefusal(host.admin.reapScope(staff, t3, s), 'conflict', /still resolves hostname 'still-serving\.example\.com'/);
|
|
6832
7028
|
expect((await host.admin.getScopeRecord(staff, t3, s)).status).toBe('archived');
|
|
6833
7029
|
// Unbinding is the visible, reversible step that clears the wall…
|
|
6834
7030
|
await host.admin.unbindHostname(staff, 'still-serving.example.com');
|
|
@@ -6849,7 +7045,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6849
7045
|
const deleting = (await host.admin.getTenant(staff, t));
|
|
6850
7046
|
expect(deleting.status).toBe('deleting');
|
|
6851
7047
|
expect(deleting.deletingAt).not.toBeNull();
|
|
6852
|
-
await
|
|
7048
|
+
await expectRefusal(host.getScope(alice, t, s), 'conflict', /tenant not active/);
|
|
6853
7049
|
// Un-delete (→ active) is a full restore and clears deletingAt.
|
|
6854
7050
|
await host.admin.setTenantStatus(staff, t, 'active');
|
|
6855
7051
|
const restored = (await host.admin.getTenant(staff, t));
|
|
@@ -6875,7 +7071,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6875
7071
|
expect((await host.admin.listOrgs(staff, t)).filter((o) => o.id === org)).toHaveLength(1);
|
|
6876
7072
|
expect((await host.admin.resolveIdentity(t, `oidc:reap-${t.toLowerCase()}`, 'x1'))?.principal).toBe(person);
|
|
6877
7073
|
// Reap only follows the reversible delete state.
|
|
6878
|
-
await
|
|
7074
|
+
await expectRefusal(host.admin.reapTenant(staff, t), 'conflict', /not deleting/);
|
|
6879
7075
|
// The caller reaps the scope first (the sweep/route orchestrates this above the
|
|
6880
7076
|
// kernel); reapTenant itself is directory-side only.
|
|
6881
7077
|
await host.admin.setTenantStatus(staff, t, 'deleting');
|
|
@@ -6900,11 +7096,72 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
6900
7096
|
expect(reapEntry.before.status).toBe('deleting');
|
|
6901
7097
|
expect(reapEntry.after.status).toBe('reaped');
|
|
6902
7098
|
// Terminal: `reaped` is unreachable via setTenantStatus and reapTenant refuses it.
|
|
6903
|
-
await
|
|
6904
|
-
await
|
|
7099
|
+
await expectRefusal(host.admin.setTenantStatus(staff, t, 'reaped'), 'validation_failed', /cannot be set to 'reaped'/);
|
|
7100
|
+
await expectRefusal(host.admin.reapTenant(staff, t), 'conflict', /not deleting/);
|
|
6905
7101
|
});
|
|
6906
7102
|
it('rejects reapTenant on an unknown tenant', async () => {
|
|
6907
|
-
await
|
|
7103
|
+
await expectRefusal(host.admin.reapTenant(staff, tenantId.parse(ulid())), 'not_found');
|
|
7104
|
+
});
|
|
7105
|
+
// #113: the last directory refusals the control plane matched by message, each typed now.
|
|
7106
|
+
// Asserted on the code — the thing a transport renders — with its positive twin beside it.
|
|
7107
|
+
it('marks the scope gate\'s refusal as the gate\'s, so a public edge can answer it neutrally (#113)', async () => {
|
|
7108
|
+
const t = tenantId.parse(ulid());
|
|
7109
|
+
const sc = scopeId.parse(ulid());
|
|
7110
|
+
await host.admin.createTenant(staff, { id: t, slug: `gate-${t.toLowerCase()}`, name: 'Gate Co' });
|
|
7111
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: sc });
|
|
7112
|
+
await host.admin.activateScope(staff, t, sc);
|
|
7113
|
+
await host.admin.suspendScope(staff, t, sc);
|
|
7114
|
+
const scopeHeld = await host.getScope(alice, t, sc).then(() => undefined, (e) => e);
|
|
7115
|
+
expect(errorCodeOf(scopeHeld)).toBe('conflict');
|
|
7116
|
+
expect(scopeHeld.extensions.reason).toBe(SCOPE_GATE_REASONS.notActive);
|
|
7117
|
+
await host.admin.unsuspendScope(staff, t, sc);
|
|
7118
|
+
await host.admin.setTenantStatus(staff, t, 'suspended');
|
|
7119
|
+
const tenantHeld = await host.getScope(alice, t, sc).then(() => undefined, (e) => e);
|
|
7120
|
+
expect(tenantHeld.extensions.reason).toBe(SCOPE_GATE_REASONS.notActive);
|
|
7121
|
+
// The twin: an admin refusal of the same code is not the gate's.
|
|
7122
|
+
const illegal = await host.admin.unarchiveScope(staff, t, sc).then(() => undefined, (e) => e);
|
|
7123
|
+
expect(errorCodeOf(illegal)).toBe('conflict');
|
|
7124
|
+
expect(illegal.extensions.reason).toBeUndefined();
|
|
7125
|
+
});
|
|
7126
|
+
it('refuses what used to answer the generic 500 with its own code (#113)', async () => {
|
|
7127
|
+
const t = tenantId.parse(ulid());
|
|
7128
|
+
const sc = scopeId.parse(ulid());
|
|
7129
|
+
await host.admin.createTenant(staff, { id: t, slug: `was500-${t.toLowerCase()}`, name: 'Was 500' });
|
|
7130
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: sc });
|
|
7131
|
+
await host.admin.activateScope(staff, t, sc);
|
|
7132
|
+
// An unknown hostname, on both of its doors.
|
|
7133
|
+
await expectRefusal(host.admin.setHostnameStatus(staff, `ghost-${t.toLowerCase()}.example.com`, 'active'), 'not_found', /unknown hostname/);
|
|
7134
|
+
// A link into a pool nobody registered: its precondition is missing.
|
|
7135
|
+
await expectRefusal(host.admin.linkIdentity(staff, { provider: `oidc:ghost-${t.toLowerCase()}`, externalId: 'u1', principal: alice, tenantId: t }), 'conflict', /is not registered/);
|
|
7136
|
+
// A system door for a module this host never registered.
|
|
7137
|
+
await expectRefusal(host.getSystemScope(moduleId.parse('@ghost/none'), t, sc), 'not_found', /module not registered/);
|
|
7138
|
+
// A migration asked of a scope that is archived.
|
|
7139
|
+
await host.admin.archiveScope(staff, t, sc);
|
|
7140
|
+
await expectRefusal(host.migrateScope(t, sc), 'conflict', /not migratable \(status: archived\)/);
|
|
7141
|
+
});
|
|
7142
|
+
it('refuses to provision a scope under a non-active tenant, typed conflict (#113)', async () => {
|
|
7143
|
+
const t = tenantId.parse(ulid());
|
|
7144
|
+
await host.admin.createTenant(staff, { id: t, slug: `held-${t.toLowerCase()}`, name: 'Held Co' });
|
|
7145
|
+
await host.admin.setTenantStatus(staff, t, 'suspended');
|
|
7146
|
+
await expectRefusal(host.provisionScope(staff, { tenantId: t, scopeId: scopeId.parse(ulid()) }), 'conflict', /non-active tenant \(status: suspended\)/);
|
|
7147
|
+
await host.admin.setTenantStatus(staff, t, 'active');
|
|
7148
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: scopeId.parse(ulid()) }); // the twin: active again, it provisions
|
|
7149
|
+
});
|
|
7150
|
+
it('refuses an identity pool re-registered with another topology, typed conflict (#113)', async () => {
|
|
7151
|
+
const t = tenantId.parse(ulid());
|
|
7152
|
+
const provider = `oidc:pool-${t.toLowerCase()}`;
|
|
7153
|
+
await host.admin.createTenant(staff, { id: t, slug: `pool-${t.toLowerCase()}`, name: 'Pool Co' });
|
|
7154
|
+
await host.admin.registerIdentityPool(staff, { provider, topology: 'tenant-bound', tenantId: t });
|
|
7155
|
+
// The same declaration again is idempotent…
|
|
7156
|
+
await expect(host.admin.registerIdentityPool(staff, { provider, topology: 'tenant-bound', tenantId: t })).resolves.toBeUndefined();
|
|
7157
|
+
// …a different one conflicts.
|
|
7158
|
+
await expectRefusal(host.admin.registerIdentityPool(staff, { provider, topology: 'central', tenantId: null }), 'conflict', /is already registered as tenant-bound/);
|
|
7159
|
+
});
|
|
7160
|
+
it('refuses a vertical re-registered from another source, typed conflict (#113)', async () => {
|
|
7161
|
+
const slug = `src-${ulid().toLowerCase()}`;
|
|
7162
|
+
await host.admin.registerVertical(staff, { slug, name: 'Source Test', source: 'git' });
|
|
7163
|
+
await expect(host.admin.registerVertical(staff, { slug, name: 'Source Test', source: 'git' })).resolves.toBeUndefined();
|
|
7164
|
+
await expectRefusal(host.admin.registerVertical(staff, { slug, name: 'Source Test', source: 'cli' }), 'conflict', /is already registered as git/);
|
|
6908
7165
|
});
|
|
6909
7166
|
// -- identity links: the projection read (#406) ---------------------------
|
|
6910
7167
|
it('listIdentityLinks returns one tenant’s links and tracks link/unlink', async () => {
|
|
@@ -7198,7 +7455,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
7198
7455
|
});
|
|
7199
7456
|
it('refuses a slug already taken under the tenant, and re-provision is still idempotent (§3.2)', async () => {
|
|
7200
7457
|
const other = scopeId.parse(ulid());
|
|
7201
|
-
await
|
|
7458
|
+
await expectRefusal(host.provisionScope(staff, { tenantId: t5, scopeId: other, slug: 'brf-vasastan' }), 'conflict', /already taken/);
|
|
7202
7459
|
// Idempotency is keyed on the scope id, so re-provisioning the SAME scope
|
|
7203
7460
|
// must not collide with its own slug.
|
|
7204
7461
|
const named = (await host.admin.listScopes(staff, { tenantId: t5 })).find((s) => s.slug === 'brf-vasastan');
|
|
@@ -7213,11 +7470,11 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
7213
7470
|
it('refuses a tenant slug already taken, fail closed (§4.1)', async () => {
|
|
7214
7471
|
// INSERT OR IGNORE would have reported this as an idempotent no-op and
|
|
7215
7472
|
// silently not created the tenant the caller asked for.
|
|
7216
|
-
await
|
|
7473
|
+
await expectRefusal(host.admin.createTenant(staff, {
|
|
7217
7474
|
id: tenantId.parse(ulid()),
|
|
7218
7475
|
slug: 'directory-co',
|
|
7219
7476
|
name: 'Impostor Co',
|
|
7220
|
-
})
|
|
7477
|
+
}), 'conflict', /already taken/);
|
|
7221
7478
|
});
|
|
7222
7479
|
it('enumerates the scopes under a tenant, and filters by status (§4.5)', async () => {
|
|
7223
7480
|
const all = await host.admin.listScopes(staff, { tenantId: t5 });
|