@substrat-run/contract-tests 0.133.0 → 0.135.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 +36 -0
- package/dist/attachment-text-fixtures.d.ts.map +1 -0
- package/dist/attachment-text-fixtures.js +956 -0
- package/dist/attachment-text-fixtures.js.map +1 -0
- package/dist/attachment-text-suite.d.ts +3 -0
- package/dist/attachment-text-suite.d.ts.map +1 -0
- package/dist/attachment-text-suite.js +359 -0
- package/dist/attachment-text-suite.js.map +1 -0
- package/dist/capability-attachment-suite.d.ts.map +1 -1
- package/dist/capability-attachment-suite.js +25 -0
- package/dist/capability-attachment-suite.js.map +1 -1
- package/dist/capability-suite.d.ts.map +1 -1
- package/dist/capability-suite.js +265 -0
- package/dist/capability-suite.js.map +1 -1
- package/dist/idempotency-suite.d.ts.map +1 -1
- package/dist/idempotency-suite.js +43 -2
- package/dist/idempotency-suite.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/list-suite.d.ts.map +1 -1
- package/dist/list-suite.js +18 -2
- package/dist/list-suite.js.map +1 -1
- package/dist/modules.d.ts +271 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +95 -4
- package/dist/modules.js.map +1 -1
- package/dist/schedule-entitlement-suite.d.ts +23 -0
- package/dist/schedule-entitlement-suite.d.ts.map +1 -0
- package/dist/schedule-entitlement-suite.js +98 -0
- package/dist/schedule-entitlement-suite.js.map +1 -0
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +494 -12
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/timeline-suite.d.ts.map +1 -1
- package/dist/timeline-suite.js +119 -1
- package/dist/timeline-suite.js.map +1 -1
- package/package.json +3 -3
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, dataSubjectId, domainEvent, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, } from '@substrat-run/contracts';
|
|
4
|
-
import { isSearchIndexTable, REDACTED_DELIVERY_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, ulid, } from '@substrat-run/kernel';
|
|
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';
|
|
4
|
+
import { isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, ulid, } from '@substrat-run/kernel';
|
|
5
5
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, 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
|
|
@@ -1606,6 +1606,47 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1606
1606
|
// `test/move` leads to two states where it is legal, so the attempt is not one of them.
|
|
1607
1607
|
expect(flow.refused).toEqual([{ from: 'done', attempted: null, operation: 'test/move', count: 2, actors: { principal: 2 } }]);
|
|
1608
1608
|
});
|
|
1609
|
+
it('reads a refused transition back as a row — record, states, actor, call — like a denial (#1745)', async () => {
|
|
1610
|
+
// A record of its own and a call id of its own, so the case above does not leak in.
|
|
1611
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
1612
|
+
const call = ulid();
|
|
1613
|
+
await expect(stub.invoke('test/refuse', { entityId: 'r2', from: 'done' }, { invocationId: call })).rejects.toThrow(/invalid transition/);
|
|
1614
|
+
const rows = await host.admin.listRefusals(staff, t1, s1, { entityType: 'test-lifecycle', entityId: 'r2' });
|
|
1615
|
+
expect(rows).toHaveLength(1);
|
|
1616
|
+
expect(rows[0]).toMatchObject({
|
|
1617
|
+
kind: 'transition',
|
|
1618
|
+
reason: 'invalid_transition',
|
|
1619
|
+
actor: alice,
|
|
1620
|
+
actorKind: 'principal',
|
|
1621
|
+
tenantId: t1,
|
|
1622
|
+
scopeId: s1,
|
|
1623
|
+
entityType: 'test-lifecycle',
|
|
1624
|
+
entityId: 'r2',
|
|
1625
|
+
fromState: 'done',
|
|
1626
|
+
attemptedState: null,
|
|
1627
|
+
operation: 'test/move',
|
|
1628
|
+
invokedOperation: 'test/refuse',
|
|
1629
|
+
impersonation: null,
|
|
1630
|
+
invocationId: call,
|
|
1631
|
+
drainedAt: null,
|
|
1632
|
+
});
|
|
1633
|
+
expect(rows[0].decodeError).toBeUndefined();
|
|
1634
|
+
// The row survived the operation's rollback — and the operation itself left nothing
|
|
1635
|
+
// behind in the outbox for that call.
|
|
1636
|
+
expect((await host.admin.invocationEvents(staff, t1, s1, { invocationId: call })).events).toEqual([]);
|
|
1637
|
+
// Narrowed like the denial log: by call, by actor, by window; newest first, bounded.
|
|
1638
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: call })).toHaveLength(1);
|
|
1639
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: ulid() })).toEqual([]);
|
|
1640
|
+
const all = await host.admin.listRefusals(staff, t1, s1, { actor: alice });
|
|
1641
|
+
expect(all.length).toBeGreaterThanOrEqual(3); // r1 twice (the case above) + r2
|
|
1642
|
+
expect(all[0].entityId).toBe('r2');
|
|
1643
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { until: all[all.length - 1].at })).toEqual([]);
|
|
1644
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { limit: 1 })).toHaveLength(1);
|
|
1645
|
+
// K-24: the read is recorded; K-3: a mismatched pair is refused, never another tenant's log.
|
|
1646
|
+
const logged = await host.admin.accessLog(staff, { tenantId: t1, method: 'listRefusals' });
|
|
1647
|
+
expect(logged.length).toBeGreaterThan(0);
|
|
1648
|
+
await expect(host.admin.listRefusals(staff, t2, s1)).rejects.toThrow();
|
|
1649
|
+
});
|
|
1609
1650
|
// -- scope data introspection: the §5.4 admin-query RPC --------------------
|
|
1610
1651
|
//
|
|
1611
1652
|
// A read-only window into a scope's OWN database (the console/dashboard Data
|
|
@@ -1641,6 +1682,21 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1641
1682
|
it('rejects an unknown table', async () => {
|
|
1642
1683
|
await expect(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 })).rejects.toThrow(/unknown table/);
|
|
1643
1684
|
});
|
|
1685
|
+
it('refuses a page bound SQLite would misread, and still clamps to the ceiling (#1632)', async () => {
|
|
1686
|
+
// The clamp passed NaN straight through to LIMIT / OFFSET. Refused instead, with the
|
|
1687
|
+
// option's name; a large but valid limit is still clamped rather than refused.
|
|
1688
|
+
const read = (limit, offset) => host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit, offset });
|
|
1689
|
+
const refused = (pattern) => (err) => errorCodeOf(err) === 'validation_failed' && pattern.test(err.message);
|
|
1690
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 0, 1.5]) {
|
|
1691
|
+
await expect(read(bad, 0)).rejects.toSatisfy(refused(/limit must be a positive integer/));
|
|
1692
|
+
}
|
|
1693
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) {
|
|
1694
|
+
await expect(read(1, bad)).rejects.toSatisfy(refused(/offset must be a non-negative integer/));
|
|
1695
|
+
}
|
|
1696
|
+
const clamped = await read(SCOPE_TABLE_PAGE_MAX + 1_000, 0);
|
|
1697
|
+
expect(clamped.limit).toBe(SCOPE_TABLE_PAGE_MAX);
|
|
1698
|
+
expect((await read(1, 0)).offset).toBe(0);
|
|
1699
|
+
});
|
|
1644
1700
|
// #1524: the size behind the on-demand storage reading. The growth assertion is what
|
|
1645
1701
|
// separates a real size from a constant. A host that answered a fixed number, or
|
|
1646
1702
|
// the size of some other database, passes "positive integer" and fails this.
|
|
@@ -2204,6 +2260,401 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2204
2260
|
const twice = (await journal(kind)).find((r) => r.id === id);
|
|
2205
2261
|
expect(twice).toEqual(once);
|
|
2206
2262
|
});
|
|
2263
|
+
// -- the free-text copies (#1632) ---------------------------------------
|
|
2264
|
+
//
|
|
2265
|
+
// The directory keeps failure text about a scope: an ops failure's message, its
|
|
2266
|
+
// issue's newest exemplar, a sweep record's error. The drain's failure row quotes the
|
|
2267
|
+
// intent it gave up on, so a row about a tombstoned intent is a copy of redacted text;
|
|
2268
|
+
// anything else is reached only when it names the subject's id.
|
|
2269
|
+
describe('the directory\'s failure text, and a queued sweep record (#1632)', () => {
|
|
2270
|
+
const op = (name) => `intent.erasure-${name}-${ulid()}`;
|
|
2271
|
+
const fail = (operation, message, tenant = t1, scope = s1) => host.admin.recordOpsFailure({
|
|
2272
|
+
actor: staff,
|
|
2273
|
+
operation,
|
|
2274
|
+
stage: 'terminal',
|
|
2275
|
+
tenantId: tenant,
|
|
2276
|
+
scopeId: scope,
|
|
2277
|
+
message,
|
|
2278
|
+
});
|
|
2279
|
+
const failureOf = async (operation) => (await host.admin.listOpsFailures(staff, { operation }))[0];
|
|
2280
|
+
const issueOf = async (operation) => (await host.admin.listIssues(staff, { operation }))[0];
|
|
2281
|
+
const sweepOf = async (unit) => (await host.admin.listSweepRuns(staff, { unit }))[0];
|
|
2282
|
+
const snapshot = async (ops, units) => {
|
|
2283
|
+
const out = {};
|
|
2284
|
+
for (const [k, o] of Object.entries(ops)) {
|
|
2285
|
+
out[`failure:${k}`] = await failureOf(o);
|
|
2286
|
+
out[`issue:${k}`] = await issueOf(o);
|
|
2287
|
+
}
|
|
2288
|
+
for (const [k, u] of Object.entries(units))
|
|
2289
|
+
out[`sweep:${k}`] = await sweepOf(u);
|
|
2290
|
+
return out;
|
|
2291
|
+
};
|
|
2292
|
+
it('redacts the subject\'s failure text, and leaves every other row as it was', async () => {
|
|
2293
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
2294
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2295
|
+
const spared = dataSubjectId.parse(ulid());
|
|
2296
|
+
const linked = await routeIntent(kind, erased, 'Anna Ek');
|
|
2297
|
+
const unlinked = await routeIntent(kind, spared, 'Bo Lund');
|
|
2298
|
+
const ops = {
|
|
2299
|
+
linked: op('linked'),
|
|
2300
|
+
byId: op('by-id'),
|
|
2301
|
+
platform: op('platform'),
|
|
2302
|
+
spared: op('spared'),
|
|
2303
|
+
otherTenant: op('other-tenant'),
|
|
2304
|
+
unlinked: op('unlinked'),
|
|
2305
|
+
nameOnly: op('name-only'),
|
|
2306
|
+
};
|
|
2307
|
+
await fail(ops.linked, platformIntentFailureMessage(linked, 'failed: HTTP 409: Anna Ek needs a personal number'));
|
|
2308
|
+
await fail(ops.byId, `schedule threw on contact ${erased} (Anna Ek)`);
|
|
2309
|
+
await fail(ops.platform, `digest named contact ${erased}`, null, null);
|
|
2310
|
+
await fail(ops.spared, `schedule threw on contact ${spared} (Bo Lund)`);
|
|
2311
|
+
await fail(ops.otherTenant, `schedule threw on contact ${erased}`, t2, null);
|
|
2312
|
+
await fail(ops.unlinked, platformIntentFailureMessage(unlinked, 'failed: HTTP 409: Bo Lund needs a personal number'));
|
|
2313
|
+
await fail(ops.nameOnly, 'schedule threw: Anna Ek has no postal address');
|
|
2314
|
+
const units = {
|
|
2315
|
+
byId: `${s1}:erasure/by-id-${ulid()}`,
|
|
2316
|
+
spared: `${s1}:erasure/spared-${ulid()}`,
|
|
2317
|
+
otherTenant: `${s1}:erasure/other-tenant-${ulid()}`,
|
|
2318
|
+
};
|
|
2319
|
+
const sweep = (unit, error, tenant = t1) => host.admin.recordSweepRun({
|
|
2320
|
+
kind: 'schedule',
|
|
2321
|
+
unit,
|
|
2322
|
+
outcome: 'failed',
|
|
2323
|
+
tenantId: tenant,
|
|
2324
|
+
scopeId: tenant === t1 ? s1 : null,
|
|
2325
|
+
operation: 'erasure/run',
|
|
2326
|
+
error,
|
|
2327
|
+
});
|
|
2328
|
+
await sweep(units.byId, `contact ${erased} (Anna Ek) bounced`);
|
|
2329
|
+
await sweep(units.spared, `contact ${spared} (Bo Lund) bounced`);
|
|
2330
|
+
await sweep(units.otherTenant, `contact ${erased} bounced`, t2);
|
|
2331
|
+
const before = await snapshot(ops, units);
|
|
2332
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2333
|
+
const after = await snapshot(ops, units);
|
|
2334
|
+
// Text about the subject: the drain's row about a tombstoned intent, and every row
|
|
2335
|
+
// in this tenant (or the platform's own) that names the subject's id.
|
|
2336
|
+
for (const k of ['linked', 'byId', 'platform']) {
|
|
2337
|
+
expect(after[`failure:${k}`]).toEqual({ ...before[`failure:${k}`], message: REDACTED_FAILURE_NOTE });
|
|
2338
|
+
// The platform's own exemplar is attributed as the platform's, and goes on a
|
|
2339
|
+
// direct id match exactly as its ops-failure row does.
|
|
2340
|
+
expect(after[`issue:${k}`]).toEqual({ ...before[`issue:${k}`], lastMessage: REDACTED_FAILURE_NOTE });
|
|
2341
|
+
}
|
|
2342
|
+
expect(after['sweep:byId']).toEqual({ ...before['sweep:byId'], error: REDACTED_FAILURE_NOTE });
|
|
2343
|
+
// The twins: another subject's text, another tenant's row, a drain row about an
|
|
2344
|
+
// intent the erasure did not touch — and the documented residual, a sentence that
|
|
2345
|
+
// names the person with neither their id nor an intent link (§13.1).
|
|
2346
|
+
for (const k of ['spared', 'otherTenant', 'unlinked', 'nameOnly']) {
|
|
2347
|
+
expect(after[`failure:${k}`]).toEqual(before[`failure:${k}`]);
|
|
2348
|
+
expect(after[`issue:${k}`]).toEqual(before[`issue:${k}`]);
|
|
2349
|
+
}
|
|
2350
|
+
expect(after['sweep:spared']).toEqual(before['sweep:spared']);
|
|
2351
|
+
expect(after['sweep:otherTenant']).toEqual(before['sweep:otherTenant']);
|
|
2352
|
+
// Idempotent: the note names neither the subject nor an intent.
|
|
2353
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2354
|
+
expect(await snapshot(ops, units)).toEqual(after);
|
|
2355
|
+
await host.settlePlatformRequest(t1, s1, unlinked, { status: 'done' });
|
|
2356
|
+
});
|
|
2357
|
+
it('reaches a drain failure recorded after an earlier erasure tombstoned its intent', async () => {
|
|
2358
|
+
// An erasure that crashed between its scope half and its directory half, re-run:
|
|
2359
|
+
// the intent is already a tombstone, so the link has to come from every tombstone
|
|
2360
|
+
// in the journal and not only the ones this pass wrote.
|
|
2361
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
2362
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2363
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
2364
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2365
|
+
const operation = op('late');
|
|
2366
|
+
await fail(operation, platformIntentFailureMessage(id, 'gave up after 5 drain attempts — last error: Anna Ek'));
|
|
2367
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2368
|
+
expect((await failureOf(operation)).message).toBe(REDACTED_FAILURE_NOTE);
|
|
2369
|
+
expect((await issueOf(operation)).lastMessage).toBe(REDACTED_FAILURE_NOTE);
|
|
2370
|
+
});
|
|
2371
|
+
/** Whose an exemplar is, as the directory stores it — not on the issue read shape. */
|
|
2372
|
+
const exemplarOwnerOf = async (operation) => {
|
|
2373
|
+
const dump = await host.admin.exportDirectory(staff);
|
|
2374
|
+
const t = dump.tables.find((x) => x.name === '_substrat_issues');
|
|
2375
|
+
const row = t.rows.find((r) => r[t.columns.indexOf('operation')] === operation);
|
|
2376
|
+
return { kind: row[t.columns.indexOf('last_owner_kind')], tenant: row[t.columns.indexOf('last_tenant_id')] };
|
|
2377
|
+
};
|
|
2378
|
+
it('rewrites an issue exemplar only when its writer attributed it to the erasing tenant', async () => {
|
|
2379
|
+
// An issue has no tenant of its own and outlives its ops-failure rows (180 days vs
|
|
2380
|
+
// 90), so the writer records whose failure each exemplar copied, and erasure reads
|
|
2381
|
+
// that rather than the rows. Expiry is simulated by restoring a directory copy with
|
|
2382
|
+
// the source rows taken out — what the 90-day prune leaves behind.
|
|
2383
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2384
|
+
const ops = {
|
|
2385
|
+
otherExpired: op('other-expired'),
|
|
2386
|
+
ownExpired: op('own-expired'),
|
|
2387
|
+
legacy: op('legacy'),
|
|
2388
|
+
legacyPlatform: op('legacy-platform'),
|
|
2389
|
+
};
|
|
2390
|
+
await fail(ops.otherExpired, `schedule threw on contact ${erased}`, t2, null);
|
|
2391
|
+
await fail(ops.ownExpired, `schedule threw on contact ${erased} (Anna Ek)`);
|
|
2392
|
+
await fail(ops.legacy, `schedule threw on contact ${erased} (Anna Ek)`);
|
|
2393
|
+
await fail(ops.legacyPlatform, `digest named contact ${erased}`, null, null);
|
|
2394
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2395
|
+
const expired = new Set(Object.values(ops));
|
|
2396
|
+
await host.admin.restoreDirectory(staff, {
|
|
2397
|
+
...copy,
|
|
2398
|
+
tables: copy.tables.map((t) => {
|
|
2399
|
+
if (t.name === '_substrat_ops_failures') {
|
|
2400
|
+
const col = t.columns.indexOf('operation');
|
|
2401
|
+
return { ...t, rows: t.rows.filter((r) => !expired.has(r[col])) };
|
|
2402
|
+
}
|
|
2403
|
+
if (t.name === '_substrat_issues') {
|
|
2404
|
+
// Issues from before attribution that nothing could backfill: owner unknown.
|
|
2405
|
+
const op = t.columns.indexOf('operation');
|
|
2406
|
+
const owner = [t.columns.indexOf('last_tenant_id'), t.columns.indexOf('last_owner_kind')];
|
|
2407
|
+
const legacy = new Set([ops.legacy, ops.legacyPlatform]);
|
|
2408
|
+
return {
|
|
2409
|
+
...t,
|
|
2410
|
+
rows: t.rows.map((r) => (legacy.has(r[op]) ? r.map((c, i) => (owner.includes(i) ? null : c)) : r)),
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
return t;
|
|
2414
|
+
}),
|
|
2415
|
+
});
|
|
2416
|
+
expect(await host.admin.listOpsFailures(staff, { operation: ops.ownExpired })).toEqual([]);
|
|
2417
|
+
const before = {
|
|
2418
|
+
otherExpired: await issueOf(ops.otherExpired),
|
|
2419
|
+
ownExpired: await issueOf(ops.ownExpired),
|
|
2420
|
+
legacy: await issueOf(ops.legacy),
|
|
2421
|
+
legacyPlatform: await issueOf(ops.legacyPlatform),
|
|
2422
|
+
};
|
|
2423
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2424
|
+
// Another tenant's exemplar, its rows expired: byte-equal.
|
|
2425
|
+
expect(await issueOf(ops.otherExpired)).toEqual(before.otherExpired);
|
|
2426
|
+
// This tenant's, its rows expired: the attribution is enough.
|
|
2427
|
+
expect(await issueOf(ops.ownExpired)).toEqual({ ...before.ownExpired, lastMessage: REDACTED_FAILURE_NOTE });
|
|
2428
|
+
// Owner unknown: skipped — the legacy residual (§13.1 limit 9), never a guess, whether
|
|
2429
|
+
// the text was a tenant's or the platform's.
|
|
2430
|
+
expect(await issueOf(ops.legacy)).toEqual(before.legacy);
|
|
2431
|
+
expect(await issueOf(ops.legacyPlatform)).toEqual(before.legacyPlatform);
|
|
2432
|
+
});
|
|
2433
|
+
it('attributes each exemplar to the failure it copied, and a newer one takes it over', async () => {
|
|
2434
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2435
|
+
const shared = op('shared');
|
|
2436
|
+
const text = `schedule threw on contact ${erased}`;
|
|
2437
|
+
await fail(shared, text);
|
|
2438
|
+
expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t1 });
|
|
2439
|
+
// The same group, a newer exemplar from another tenant: the attribution moves with it.
|
|
2440
|
+
await fail(shared, text, t2, null);
|
|
2441
|
+
expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t2 });
|
|
2442
|
+
const exemplar = await issueOf(shared);
|
|
2443
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2444
|
+
// This tenant's own row goes; the exemplar is now another tenant's and stays.
|
|
2445
|
+
const rows = await host.admin.listOpsFailures(staff, { operation: shared });
|
|
2446
|
+
expect(rows.find((r) => r.tenantId === t1).message).toBe(REDACTED_FAILURE_NOTE);
|
|
2447
|
+
expect(rows.find((r) => r.tenantId === t2).message).toBe(text);
|
|
2448
|
+
expect(await issueOf(shared)).toEqual(exemplar);
|
|
2449
|
+
// And the platform's own failure leaves the platform's exemplar — never "unknown".
|
|
2450
|
+
await fail(shared, 'the platform failed', null, null);
|
|
2451
|
+
expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'platform', tenant: null });
|
|
2452
|
+
});
|
|
2453
|
+
it('ages telemetry out on the scheduled pass, not only when something new is written', async () => {
|
|
2454
|
+
// Issues, ops failures and sweep runs prune on write, which bounds them only while
|
|
2455
|
+
// something is written. Erasure skips an issue of unknown owner on the promise that
|
|
2456
|
+
// it ages out within its retention, so a quiet directory must shed it on time too.
|
|
2457
|
+
// Aged by restoring a directory copy with the rows' timestamps moved past retention.
|
|
2458
|
+
const expired = { op: op('expired'), unit: `${s1}:erasure/expired-${ulid()}` };
|
|
2459
|
+
const fresh = { op: op('fresh'), unit: `${s1}:erasure/fresh-${ulid()}` };
|
|
2460
|
+
for (const x of [expired, fresh]) {
|
|
2461
|
+
await fail(x.op, 'a failure');
|
|
2462
|
+
await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
|
|
2463
|
+
}
|
|
2464
|
+
const LONG_AGO = '2020-01-01T00:00:00.000Z';
|
|
2465
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2466
|
+
const age = (t, match, matchCol, cols) => {
|
|
2467
|
+
const m = t.columns.indexOf(matchCol);
|
|
2468
|
+
const at = cols.map((c) => t.columns.indexOf(c));
|
|
2469
|
+
return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
|
|
2470
|
+
};
|
|
2471
|
+
await host.admin.restoreDirectory(staff, {
|
|
2472
|
+
...copy,
|
|
2473
|
+
tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
|
|
2474
|
+
: t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
|
|
2475
|
+
: t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
|
|
2476
|
+
: t),
|
|
2477
|
+
});
|
|
2478
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
|
|
2479
|
+
const report = await runPlatformSweep(host, {
|
|
2480
|
+
actor: staff,
|
|
2481
|
+
fetch: connectorTestFetch,
|
|
2482
|
+
sweepers: {},
|
|
2483
|
+
drainRetries: false,
|
|
2484
|
+
});
|
|
2485
|
+
expect(report.errors.filter((e) => e.kind === 'telemetry')).toEqual([]);
|
|
2486
|
+
expect(report.telemetry?.opsFailures).toBeGreaterThanOrEqual(1);
|
|
2487
|
+
expect(report.telemetry?.issues).toBeGreaterThanOrEqual(1);
|
|
2488
|
+
expect(report.telemetry?.sweepRuns).toBeGreaterThanOrEqual(1);
|
|
2489
|
+
expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
|
|
2490
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
|
|
2491
|
+
expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
|
|
2492
|
+
// The positive twin: rows inside their retention stay.
|
|
2493
|
+
expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2494
|
+
expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2495
|
+
expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
|
|
2496
|
+
});
|
|
2497
|
+
it('drains a telemetry backlog larger than the batch over passes, oldest first', async () => {
|
|
2498
|
+
// After a long pause or a restore the backlog can be months of rows, and one
|
|
2499
|
+
// unbounded DELETE over it fails on the Durable Object every tick. Each pass
|
|
2500
|
+
// deletes at most `limit` rows per table, so the backlog converges instead.
|
|
2501
|
+
await host.admin.pruneTelemetry(staff, 100_000); // whatever earlier tests left
|
|
2502
|
+
const backlog = Array.from({ length: 5 }, (_, i) => ({ op: op(`backlog-${i}`), unit: `${s1}:erasure/backlog-${i}-${ulid()}` }));
|
|
2503
|
+
const fresh = { op: op('kept'), unit: `${s1}:erasure/kept-${ulid()}` };
|
|
2504
|
+
for (const x of [...backlog, fresh]) {
|
|
2505
|
+
await fail(x.op, 'a failure');
|
|
2506
|
+
await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
|
|
2507
|
+
}
|
|
2508
|
+
const ops = new Set(backlog.map((b) => b.op));
|
|
2509
|
+
const units = new Set(backlog.map((b) => b.unit));
|
|
2510
|
+
const LONG_AGO = '2020-01-01T00:00:00.000Z';
|
|
2511
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2512
|
+
const age = (t, match, matchCol, cols) => {
|
|
2513
|
+
const m = t.columns.indexOf(matchCol);
|
|
2514
|
+
const at = cols.map((c) => t.columns.indexOf(c));
|
|
2515
|
+
return { ...t, rows: t.rows.map((r) => (match.has(r[m]) ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
|
|
2516
|
+
};
|
|
2517
|
+
await host.admin.restoreDirectory(staff, {
|
|
2518
|
+
...copy,
|
|
2519
|
+
tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, ops, 'operation', ['at'])
|
|
2520
|
+
: t.name === '_substrat_issues' ? age(t, ops, 'operation', ['first_seen', 'last_seen'])
|
|
2521
|
+
: t.name === '_substrat_sweep_runs' ? age(t, units, 'unit', ['at'])
|
|
2522
|
+
: t),
|
|
2523
|
+
});
|
|
2524
|
+
expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 3, issues: 3, sweepRuns: 3 });
|
|
2525
|
+
expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 2, issues: 2, sweepRuns: 2 });
|
|
2526
|
+
expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 0, issues: 0, sweepRuns: 0 });
|
|
2527
|
+
for (const b of backlog) {
|
|
2528
|
+
expect(await host.admin.listOpsFailures(staff, { operation: b.op })).toEqual([]);
|
|
2529
|
+
expect(await host.admin.listIssues(staff, { operation: b.op })).toEqual([]);
|
|
2530
|
+
expect(await host.admin.listSweepRuns(staff, { unit: b.unit })).toEqual([]);
|
|
2531
|
+
}
|
|
2532
|
+
// Rows inside their retention are never part of a batch.
|
|
2533
|
+
expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2534
|
+
expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2535
|
+
expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
|
|
2536
|
+
});
|
|
2537
|
+
it('refuses a row bound that is not a positive integer, wherever one reaches a LIMIT', async () => {
|
|
2538
|
+
// SQLite reads a negative LIMIT as no limit at all, so `-1` turned the bounded
|
|
2539
|
+
// telemetry prune into an unbounded one; a fraction or a non-finite value fails
|
|
2540
|
+
// inside SQLite on every later call. Each is refused with the option's name, and
|
|
2541
|
+
// nothing is deleted on the way.
|
|
2542
|
+
const expired = { op: op('bound'), unit: `${s1}:erasure/bound-${ulid()}` };
|
|
2543
|
+
await fail(expired.op, 'a failure');
|
|
2544
|
+
await host.admin.recordSweepRun({ kind: 'schedule', unit: expired.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
|
|
2545
|
+
const LONG_AGO = '2020-01-01T00:00:00.000Z';
|
|
2546
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2547
|
+
const age = (t, match, matchCol, cols) => {
|
|
2548
|
+
const m = t.columns.indexOf(matchCol);
|
|
2549
|
+
const at = cols.map((c) => t.columns.indexOf(c));
|
|
2550
|
+
return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
|
|
2551
|
+
};
|
|
2552
|
+
await host.admin.restoreDirectory(staff, {
|
|
2553
|
+
...copy,
|
|
2554
|
+
tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
|
|
2555
|
+
: t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
|
|
2556
|
+
: t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
|
|
2557
|
+
: t),
|
|
2558
|
+
});
|
|
2559
|
+
const sweep = { actor: staff, fetch: connectorTestFetch, sweepers: {}, drainRetries: false };
|
|
2560
|
+
const refused = (name) => (err) => errorCodeOf(err) === 'validation_failed' && new RegExp(`${name} must be a positive integer`).test(err.message);
|
|
2561
|
+
for (const bad of [-1, 0, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
2562
|
+
await expect(host.admin.pruneTelemetry(staff, bad)).rejects.toSatisfy(refused('limit'));
|
|
2563
|
+
await expect(host.admin.pruneAccessLog(staff, bad)).rejects.toSatisfy(refused('limit'));
|
|
2564
|
+
await expect(runPlatformSweep(host, { ...sweep, telemetryBatch: bad })).rejects.toSatisfy(refused('telemetryBatch'));
|
|
2565
|
+
await expect(runPlatformSweep(host, { ...sweep, accessLogBatch: bad })).rejects.toSatisfy(refused('accessLogBatch'));
|
|
2566
|
+
expect(() => startPlatformSweeper(host, { ...sweep, telemetryBatch: bad, intervalMs: 60_000 })).toThrow(/telemetryBatch must be a positive integer/);
|
|
2567
|
+
await expect(host.runDueJobs(t1, s1, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
|
|
2568
|
+
await expect(host.admin.readUndrainedEvents(staff, t1, s1, bad)).rejects.toThrow(/limit must be a positive integer/);
|
|
2569
|
+
await expect(host.admin.auditLog(staff, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
|
|
2570
|
+
}
|
|
2571
|
+
// Nothing went on the way: the expired rows are all still there.
|
|
2572
|
+
expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toHaveLength(1);
|
|
2573
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
|
|
2574
|
+
expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toHaveLength(1);
|
|
2575
|
+
// The positive twin: a valid bound drains them.
|
|
2576
|
+
while (Object.values(await host.admin.pruneTelemetry(staff, 1)).some((n) => n > 0))
|
|
2577
|
+
;
|
|
2578
|
+
expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
|
|
2579
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
|
|
2580
|
+
expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
|
|
2581
|
+
});
|
|
2582
|
+
it('rewrites a queued sweep-runs entry\'s error, and keeps the rest of the intent', async () => {
|
|
2583
|
+
// A CP-less pass queues its schedule outcomes as one `sweep-runs` intent, and the
|
|
2584
|
+
// drain lands each entry's `error` in `_substrat_sweep_runs`. The intent is kept like
|
|
2585
|
+
// every other, so the same text is held twice. `sweep-runs` is platform-authored —
|
|
2586
|
+
// module code cannot enqueue it — so the rows are planted through a restore.
|
|
2587
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2588
|
+
const spared = dataSubjectId.parse(ulid());
|
|
2589
|
+
// Guarantees the journal has a row to copy the envelope columns from.
|
|
2590
|
+
const template = await routeIntent(`connector:erasure-${ulid()}`, spared, 'Bo Lund');
|
|
2591
|
+
const backup = await host.admin.exportScope(staff, t1, s1);
|
|
2592
|
+
const table = backup.tables.find((t) => t.name === '_substrat_platform_requests');
|
|
2593
|
+
const idCol = table.columns.indexOf('id');
|
|
2594
|
+
const base = table.rows.find((r) => r[idCol] === template);
|
|
2595
|
+
const at = '2026-09-20T00:00:00.000Z';
|
|
2596
|
+
const entry = (operation, error) => ({
|
|
2597
|
+
kind: 'schedule',
|
|
2598
|
+
operation,
|
|
2599
|
+
outcome: error === null ? 'ok' : 'failed',
|
|
2600
|
+
at,
|
|
2601
|
+
error,
|
|
2602
|
+
elapsedMs: 12,
|
|
2603
|
+
});
|
|
2604
|
+
const payloads = {
|
|
2605
|
+
mine: {
|
|
2606
|
+
version: null,
|
|
2607
|
+
entries: [
|
|
2608
|
+
entry('crm/sync', `contact ${erased} (Anna Ek) bounced`),
|
|
2609
|
+
entry('crm/tidy', 'nothing to tidy'),
|
|
2610
|
+
entry('crm/ok', null),
|
|
2611
|
+
],
|
|
2612
|
+
},
|
|
2613
|
+
theirs: { version: 'v-1', entries: [entry('crm/sync', `contact ${spared} (Bo Lund) bounced`)] },
|
|
2614
|
+
};
|
|
2615
|
+
const ids = { mine: ulid(), theirs: ulid() };
|
|
2616
|
+
const plant = (id, payload) => table.columns.map((c, i) => c === 'id' ? id
|
|
2617
|
+
: c === 'kind' ? SWEEP_RUNS_KIND
|
|
2618
|
+
: c === 'payload' ? JSON.stringify(payload)
|
|
2619
|
+
: c === 'status' ? 'pending'
|
|
2620
|
+
: c === 'attempts' ? 0
|
|
2621
|
+
: c === 'settled_at' || c === 'last_error' || c === 'last_failure' || c === 'result' ? null
|
|
2622
|
+
: base[i]);
|
|
2623
|
+
const s = scopeId.parse(ulid());
|
|
2624
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s, jurisdiction: 'eu', vertical: 'connector-vertical' });
|
|
2625
|
+
await host.admin.activateScope(staff, t1, s);
|
|
2626
|
+
await host.restoreScope(staff, t1, s, {
|
|
2627
|
+
...backup,
|
|
2628
|
+
scopeId: s,
|
|
2629
|
+
tables: backup.tables.map((t) => t === table ? { ...t, rows: [...t.rows, plant(ids.mine, payloads.mine), plant(ids.theirs, payloads.theirs)] } : t),
|
|
2630
|
+
});
|
|
2631
|
+
await host.settlePlatformRequest(t1, s1, template, { status: 'done' });
|
|
2632
|
+
const queued = async () => {
|
|
2633
|
+
const rows = await host.listPlatformRequestHistory(t1, s, { kind: SWEEP_RUNS_KIND });
|
|
2634
|
+
return { mine: rows.find((r) => r.id === ids.mine), theirs: rows.find((r) => r.id === ids.theirs) };
|
|
2635
|
+
};
|
|
2636
|
+
const before = await queued();
|
|
2637
|
+
await host.admin.shredSubject(staff, t1, s, erased);
|
|
2638
|
+
const after = await queued();
|
|
2639
|
+
// Only the error that named the subject; the shape, the other entries, the
|
|
2640
|
+
// version and the row's own columns are as they were — still pending, so the
|
|
2641
|
+
// drain lands the note instead of the text.
|
|
2642
|
+
expect(after.mine).toEqual({
|
|
2643
|
+
...before.mine,
|
|
2644
|
+
payload: {
|
|
2645
|
+
...payloads.mine,
|
|
2646
|
+
entries: [
|
|
2647
|
+
{ ...payloads.mine.entries[0], error: REDACTED_FAILURE_NOTE },
|
|
2648
|
+
payloads.mine.entries[1],
|
|
2649
|
+
payloads.mine.entries[2],
|
|
2650
|
+
],
|
|
2651
|
+
},
|
|
2652
|
+
});
|
|
2653
|
+
expect(after.theirs).toEqual(before.theirs);
|
|
2654
|
+
await host.admin.shredSubject(staff, t1, s, erased);
|
|
2655
|
+
expect(await queued()).toEqual(after);
|
|
2656
|
+
});
|
|
2657
|
+
});
|
|
2207
2658
|
});
|
|
2208
2659
|
it('records the erasure in BOTH logs — mutation and evidence-destruction', async () => {
|
|
2209
2660
|
const subject = dataSubjectId.parse(ulid());
|
|
@@ -4024,7 +4475,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4024
4475
|
// The refusal the registry exists for. Without it "a push lands pending" is
|
|
4025
4476
|
// a convention, and D-30's lockstep-upgrade argument is that conventions are
|
|
4026
4477
|
// what we cannot afford here.
|
|
4027
|
-
await
|
|
4478
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
|
|
4028
4479
|
await host.admin.admitVersion(staff, versionId);
|
|
4029
4480
|
await host.admin.bindScopeVersion(staff, t1, s1, versionId);
|
|
4030
4481
|
const scope = await host.admin.getScopeRecord(staff, t1, s1);
|
|
@@ -4085,7 +4536,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4085
4536
|
await host.admin.bindScopeVersion(staff, t1, preview, pending);
|
|
4086
4537
|
expect((await host.admin.getScopeRecord(staff, t1, preview))?.verticalVersionId).toBe(pending);
|
|
4087
4538
|
// …while a normal (serving) scope keeps the refusal the registry exists for.
|
|
4088
|
-
await
|
|
4539
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, pending), 'conflict');
|
|
4089
4540
|
});
|
|
4090
4541
|
it('routes a PREVIEW to its bound version, not the vertical serving script (#527)', async () => {
|
|
4091
4542
|
// The bug: a preview forks into — and binds — a specific (usually not-yet-serving)
|
|
@@ -4238,19 +4689,20 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4238
4689
|
ownerTenant: t2,
|
|
4239
4690
|
});
|
|
4240
4691
|
// A slug's owner is fixed at first push: a different owner (or claiming a
|
|
4241
|
-
// platform vertical) is refused, naming both owners.
|
|
4242
|
-
|
|
4692
|
+
// platform vertical) is refused, naming both owners. The message is asserted beside
|
|
4693
|
+
// the code only for WHICH owner it names; the code is the contract.
|
|
4694
|
+
await expectRefusal(host.admin.registerVertical(staff, {
|
|
4243
4695
|
slug: 'helpdesk',
|
|
4244
4696
|
name: 'Helpdesk',
|
|
4245
4697
|
source: 'cli',
|
|
4246
4698
|
ownerTenant: t1,
|
|
4247
|
-
})
|
|
4248
|
-
await
|
|
4699
|
+
}), 'conflict', /owned by/);
|
|
4700
|
+
await expectRefusal(host.admin.registerVertical(staff, {
|
|
4249
4701
|
slug: 'callout',
|
|
4250
4702
|
name: 'Callout',
|
|
4251
4703
|
source: 'builtin',
|
|
4252
4704
|
ownerTenant: t2,
|
|
4253
|
-
})
|
|
4705
|
+
}), 'conflict', /owned by the platform/);
|
|
4254
4706
|
});
|
|
4255
4707
|
it('carries registry-driven install metadata (marketplace-publish.md §3) and refreshes it', async () => {
|
|
4256
4708
|
// ownerGrants/entitlements/provides/requires ride the registry (one install_spec JSON
|
|
@@ -4560,12 +5012,31 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4560
5012
|
deploymentRef: null,
|
|
4561
5013
|
});
|
|
4562
5014
|
await host.admin.rejectVersion(staff, versionId, 'permission diff widened a role');
|
|
4563
|
-
await
|
|
5015
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
|
|
4564
5016
|
// Terminal: a rejected version is not resurrected, a new one is published.
|
|
4565
5017
|
await expectRefusal(host.admin.admitVersion(staff, versionId), 'conflict');
|
|
4566
5018
|
const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
|
|
4567
5019
|
expect(rejected?.admissionNote).toContain('widened a role');
|
|
4568
5020
|
});
|
|
5021
|
+
it('refuses to reject a version that is already admitted, and leaves it admitted', async () => {
|
|
5022
|
+
// `conflict` (#113 phase 6): the version may already be bound, so admission is one-way
|
|
5023
|
+
// from here. Rejecting an admitted version is the one refusal `rejectVersion` makes.
|
|
5024
|
+
const versionId = ulid();
|
|
5025
|
+
await host.admin.publishVersion(staff, {
|
|
5026
|
+
id: versionId,
|
|
5027
|
+
verticalSlug: 'callout',
|
|
5028
|
+
version: '1.1.0-vouched',
|
|
5029
|
+
manifestDigest: 'm3',
|
|
5030
|
+
permissionDigest: 'p3',
|
|
5031
|
+
migrationDigest: 'g3',
|
|
5032
|
+
deploymentRef: null,
|
|
5033
|
+
});
|
|
5034
|
+
await host.admin.admitVersion(staff, versionId);
|
|
5035
|
+
await expectRefusal(host.admin.rejectVersion(staff, versionId, 'changed my mind'), 'conflict');
|
|
5036
|
+
const after = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
|
|
5037
|
+
expect(after?.admission).toBe('admitted');
|
|
5038
|
+
expect(after?.admissionNote).toBeNull();
|
|
5039
|
+
});
|
|
4569
5040
|
it('carries the digests promotion compares', async () => {
|
|
4570
5041
|
// "Has the permission surface changed between what is in prod and what I am
|
|
4571
5042
|
// promoting?" is a string comparison here. Today it is a person remembering
|
|
@@ -4656,7 +5127,18 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4656
5127
|
migrationDigest: 'g',
|
|
4657
5128
|
deploymentRef: null,
|
|
4658
5129
|
});
|
|
4659
|
-
await
|
|
5130
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', pending), 'conflict');
|
|
5131
|
+
});
|
|
5132
|
+
it('refuses to promote a version through a vertical that does not own it', async () => {
|
|
5133
|
+
// `conflict` (#113 phase 6). The version is real and admitted — it is the pairing that
|
|
5134
|
+
// is wrong — so this is neither `not_found` (the version exists) nor a state to wait out.
|
|
5135
|
+
const v = await publish('2.9.0', { perm: 'pZ', mig: 'gZ' });
|
|
5136
|
+
await host.admin.registerVertical(staff, { slug: 'elsewhere', name: 'Elsewhere', source: 'builtin' });
|
|
5137
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'elsewhere', 'prod', v), 'conflict');
|
|
5138
|
+
// Positive twin: through its own vertical the same version promotes (the earlier tests
|
|
5139
|
+
// moved prod, so the digest checkpoints are acknowledged — they are not under test here).
|
|
5140
|
+
await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true });
|
|
5141
|
+
expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId).toBe(v);
|
|
4660
5142
|
});
|
|
4661
5143
|
// -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
|
|
4662
5144
|
// A private vertical's blast radius is its own tenant — dev/staging already
|
|
@@ -4798,7 +5280,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4798
5280
|
// Listing is the moment OTHER tenants start trusting this code, so the version
|
|
4799
5281
|
// they would install needs a recorded human decision — the auto-admission note
|
|
4800
5282
|
// is exactly what marks its absence.
|
|
4801
|
-
await
|
|
5283
|
+
await expectRefusal(host.admin.setVerticalListed(staff, 'tenant-a/crm', true), 'conflict');
|
|
4802
5284
|
// A staff admit of the already-admitted version upgrades it to a manual vouch
|
|
4803
5285
|
// (clears the note, audited) — then listing passes.
|
|
4804
5286
|
const prodNow = (await host.admin.listChannels(staff, 'tenant-a/crm')).find((c) => c.channel === 'prod')
|