@substrat-run/contract-tests 0.133.0 → 0.134.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/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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- 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.map +1 -1
- package/dist/modules.js +10 -3
- package/dist/modules.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +423 -12
- package/dist/scope-host-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
|
|
@@ -1641,6 +1641,21 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1641
1641
|
it('rejects an unknown table', async () => {
|
|
1642
1642
|
await expect(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 })).rejects.toThrow(/unknown table/);
|
|
1643
1643
|
});
|
|
1644
|
+
it('refuses a page bound SQLite would misread, and still clamps to the ceiling (#1632)', async () => {
|
|
1645
|
+
// The clamp passed NaN straight through to LIMIT / OFFSET. Refused instead, with the
|
|
1646
|
+
// option's name; a large but valid limit is still clamped rather than refused.
|
|
1647
|
+
const read = (limit, offset) => host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit, offset });
|
|
1648
|
+
const refused = (pattern) => (err) => errorCodeOf(err) === 'validation_failed' && pattern.test(err.message);
|
|
1649
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 0, 1.5]) {
|
|
1650
|
+
await expect(read(bad, 0)).rejects.toSatisfy(refused(/limit must be a positive integer/));
|
|
1651
|
+
}
|
|
1652
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) {
|
|
1653
|
+
await expect(read(1, bad)).rejects.toSatisfy(refused(/offset must be a non-negative integer/));
|
|
1654
|
+
}
|
|
1655
|
+
const clamped = await read(SCOPE_TABLE_PAGE_MAX + 1_000, 0);
|
|
1656
|
+
expect(clamped.limit).toBe(SCOPE_TABLE_PAGE_MAX);
|
|
1657
|
+
expect((await read(1, 0)).offset).toBe(0);
|
|
1658
|
+
});
|
|
1644
1659
|
// #1524: the size behind the on-demand storage reading. The growth assertion is what
|
|
1645
1660
|
// separates a real size from a constant. A host that answered a fixed number, or
|
|
1646
1661
|
// the size of some other database, passes "positive integer" and fails this.
|
|
@@ -2204,6 +2219,401 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2204
2219
|
const twice = (await journal(kind)).find((r) => r.id === id);
|
|
2205
2220
|
expect(twice).toEqual(once);
|
|
2206
2221
|
});
|
|
2222
|
+
// -- the free-text copies (#1632) ---------------------------------------
|
|
2223
|
+
//
|
|
2224
|
+
// The directory keeps failure text about a scope: an ops failure's message, its
|
|
2225
|
+
// issue's newest exemplar, a sweep record's error. The drain's failure row quotes the
|
|
2226
|
+
// intent it gave up on, so a row about a tombstoned intent is a copy of redacted text;
|
|
2227
|
+
// anything else is reached only when it names the subject's id.
|
|
2228
|
+
describe('the directory\'s failure text, and a queued sweep record (#1632)', () => {
|
|
2229
|
+
const op = (name) => `intent.erasure-${name}-${ulid()}`;
|
|
2230
|
+
const fail = (operation, message, tenant = t1, scope = s1) => host.admin.recordOpsFailure({
|
|
2231
|
+
actor: staff,
|
|
2232
|
+
operation,
|
|
2233
|
+
stage: 'terminal',
|
|
2234
|
+
tenantId: tenant,
|
|
2235
|
+
scopeId: scope,
|
|
2236
|
+
message,
|
|
2237
|
+
});
|
|
2238
|
+
const failureOf = async (operation) => (await host.admin.listOpsFailures(staff, { operation }))[0];
|
|
2239
|
+
const issueOf = async (operation) => (await host.admin.listIssues(staff, { operation }))[0];
|
|
2240
|
+
const sweepOf = async (unit) => (await host.admin.listSweepRuns(staff, { unit }))[0];
|
|
2241
|
+
const snapshot = async (ops, units) => {
|
|
2242
|
+
const out = {};
|
|
2243
|
+
for (const [k, o] of Object.entries(ops)) {
|
|
2244
|
+
out[`failure:${k}`] = await failureOf(o);
|
|
2245
|
+
out[`issue:${k}`] = await issueOf(o);
|
|
2246
|
+
}
|
|
2247
|
+
for (const [k, u] of Object.entries(units))
|
|
2248
|
+
out[`sweep:${k}`] = await sweepOf(u);
|
|
2249
|
+
return out;
|
|
2250
|
+
};
|
|
2251
|
+
it('redacts the subject\'s failure text, and leaves every other row as it was', async () => {
|
|
2252
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
2253
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2254
|
+
const spared = dataSubjectId.parse(ulid());
|
|
2255
|
+
const linked = await routeIntent(kind, erased, 'Anna Ek');
|
|
2256
|
+
const unlinked = await routeIntent(kind, spared, 'Bo Lund');
|
|
2257
|
+
const ops = {
|
|
2258
|
+
linked: op('linked'),
|
|
2259
|
+
byId: op('by-id'),
|
|
2260
|
+
platform: op('platform'),
|
|
2261
|
+
spared: op('spared'),
|
|
2262
|
+
otherTenant: op('other-tenant'),
|
|
2263
|
+
unlinked: op('unlinked'),
|
|
2264
|
+
nameOnly: op('name-only'),
|
|
2265
|
+
};
|
|
2266
|
+
await fail(ops.linked, platformIntentFailureMessage(linked, 'failed: HTTP 409: Anna Ek needs a personal number'));
|
|
2267
|
+
await fail(ops.byId, `schedule threw on contact ${erased} (Anna Ek)`);
|
|
2268
|
+
await fail(ops.platform, `digest named contact ${erased}`, null, null);
|
|
2269
|
+
await fail(ops.spared, `schedule threw on contact ${spared} (Bo Lund)`);
|
|
2270
|
+
await fail(ops.otherTenant, `schedule threw on contact ${erased}`, t2, null);
|
|
2271
|
+
await fail(ops.unlinked, platformIntentFailureMessage(unlinked, 'failed: HTTP 409: Bo Lund needs a personal number'));
|
|
2272
|
+
await fail(ops.nameOnly, 'schedule threw: Anna Ek has no postal address');
|
|
2273
|
+
const units = {
|
|
2274
|
+
byId: `${s1}:erasure/by-id-${ulid()}`,
|
|
2275
|
+
spared: `${s1}:erasure/spared-${ulid()}`,
|
|
2276
|
+
otherTenant: `${s1}:erasure/other-tenant-${ulid()}`,
|
|
2277
|
+
};
|
|
2278
|
+
const sweep = (unit, error, tenant = t1) => host.admin.recordSweepRun({
|
|
2279
|
+
kind: 'schedule',
|
|
2280
|
+
unit,
|
|
2281
|
+
outcome: 'failed',
|
|
2282
|
+
tenantId: tenant,
|
|
2283
|
+
scopeId: tenant === t1 ? s1 : null,
|
|
2284
|
+
operation: 'erasure/run',
|
|
2285
|
+
error,
|
|
2286
|
+
});
|
|
2287
|
+
await sweep(units.byId, `contact ${erased} (Anna Ek) bounced`);
|
|
2288
|
+
await sweep(units.spared, `contact ${spared} (Bo Lund) bounced`);
|
|
2289
|
+
await sweep(units.otherTenant, `contact ${erased} bounced`, t2);
|
|
2290
|
+
const before = await snapshot(ops, units);
|
|
2291
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2292
|
+
const after = await snapshot(ops, units);
|
|
2293
|
+
// Text about the subject: the drain's row about a tombstoned intent, and every row
|
|
2294
|
+
// in this tenant (or the platform's own) that names the subject's id.
|
|
2295
|
+
for (const k of ['linked', 'byId', 'platform']) {
|
|
2296
|
+
expect(after[`failure:${k}`]).toEqual({ ...before[`failure:${k}`], message: REDACTED_FAILURE_NOTE });
|
|
2297
|
+
// The platform's own exemplar is attributed as the platform's, and goes on a
|
|
2298
|
+
// direct id match exactly as its ops-failure row does.
|
|
2299
|
+
expect(after[`issue:${k}`]).toEqual({ ...before[`issue:${k}`], lastMessage: REDACTED_FAILURE_NOTE });
|
|
2300
|
+
}
|
|
2301
|
+
expect(after['sweep:byId']).toEqual({ ...before['sweep:byId'], error: REDACTED_FAILURE_NOTE });
|
|
2302
|
+
// The twins: another subject's text, another tenant's row, a drain row about an
|
|
2303
|
+
// intent the erasure did not touch — and the documented residual, a sentence that
|
|
2304
|
+
// names the person with neither their id nor an intent link (§13.1).
|
|
2305
|
+
for (const k of ['spared', 'otherTenant', 'unlinked', 'nameOnly']) {
|
|
2306
|
+
expect(after[`failure:${k}`]).toEqual(before[`failure:${k}`]);
|
|
2307
|
+
expect(after[`issue:${k}`]).toEqual(before[`issue:${k}`]);
|
|
2308
|
+
}
|
|
2309
|
+
expect(after['sweep:spared']).toEqual(before['sweep:spared']);
|
|
2310
|
+
expect(after['sweep:otherTenant']).toEqual(before['sweep:otherTenant']);
|
|
2311
|
+
// Idempotent: the note names neither the subject nor an intent.
|
|
2312
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2313
|
+
expect(await snapshot(ops, units)).toEqual(after);
|
|
2314
|
+
await host.settlePlatformRequest(t1, s1, unlinked, { status: 'done' });
|
|
2315
|
+
});
|
|
2316
|
+
it('reaches a drain failure recorded after an earlier erasure tombstoned its intent', async () => {
|
|
2317
|
+
// An erasure that crashed between its scope half and its directory half, re-run:
|
|
2318
|
+
// the intent is already a tombstone, so the link has to come from every tombstone
|
|
2319
|
+
// in the journal and not only the ones this pass wrote.
|
|
2320
|
+
const kind = `connector:erasure-${ulid()}`;
|
|
2321
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2322
|
+
const id = await routeIntent(kind, erased, 'Anna Ek');
|
|
2323
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2324
|
+
const operation = op('late');
|
|
2325
|
+
await fail(operation, platformIntentFailureMessage(id, 'gave up after 5 drain attempts — last error: Anna Ek'));
|
|
2326
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2327
|
+
expect((await failureOf(operation)).message).toBe(REDACTED_FAILURE_NOTE);
|
|
2328
|
+
expect((await issueOf(operation)).lastMessage).toBe(REDACTED_FAILURE_NOTE);
|
|
2329
|
+
});
|
|
2330
|
+
/** Whose an exemplar is, as the directory stores it — not on the issue read shape. */
|
|
2331
|
+
const exemplarOwnerOf = async (operation) => {
|
|
2332
|
+
const dump = await host.admin.exportDirectory(staff);
|
|
2333
|
+
const t = dump.tables.find((x) => x.name === '_substrat_issues');
|
|
2334
|
+
const row = t.rows.find((r) => r[t.columns.indexOf('operation')] === operation);
|
|
2335
|
+
return { kind: row[t.columns.indexOf('last_owner_kind')], tenant: row[t.columns.indexOf('last_tenant_id')] };
|
|
2336
|
+
};
|
|
2337
|
+
it('rewrites an issue exemplar only when its writer attributed it to the erasing tenant', async () => {
|
|
2338
|
+
// An issue has no tenant of its own and outlives its ops-failure rows (180 days vs
|
|
2339
|
+
// 90), so the writer records whose failure each exemplar copied, and erasure reads
|
|
2340
|
+
// that rather than the rows. Expiry is simulated by restoring a directory copy with
|
|
2341
|
+
// the source rows taken out — what the 90-day prune leaves behind.
|
|
2342
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2343
|
+
const ops = {
|
|
2344
|
+
otherExpired: op('other-expired'),
|
|
2345
|
+
ownExpired: op('own-expired'),
|
|
2346
|
+
legacy: op('legacy'),
|
|
2347
|
+
legacyPlatform: op('legacy-platform'),
|
|
2348
|
+
};
|
|
2349
|
+
await fail(ops.otherExpired, `schedule threw on contact ${erased}`, t2, null);
|
|
2350
|
+
await fail(ops.ownExpired, `schedule threw on contact ${erased} (Anna Ek)`);
|
|
2351
|
+
await fail(ops.legacy, `schedule threw on contact ${erased} (Anna Ek)`);
|
|
2352
|
+
await fail(ops.legacyPlatform, `digest named contact ${erased}`, null, null);
|
|
2353
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2354
|
+
const expired = new Set(Object.values(ops));
|
|
2355
|
+
await host.admin.restoreDirectory(staff, {
|
|
2356
|
+
...copy,
|
|
2357
|
+
tables: copy.tables.map((t) => {
|
|
2358
|
+
if (t.name === '_substrat_ops_failures') {
|
|
2359
|
+
const col = t.columns.indexOf('operation');
|
|
2360
|
+
return { ...t, rows: t.rows.filter((r) => !expired.has(r[col])) };
|
|
2361
|
+
}
|
|
2362
|
+
if (t.name === '_substrat_issues') {
|
|
2363
|
+
// Issues from before attribution that nothing could backfill: owner unknown.
|
|
2364
|
+
const op = t.columns.indexOf('operation');
|
|
2365
|
+
const owner = [t.columns.indexOf('last_tenant_id'), t.columns.indexOf('last_owner_kind')];
|
|
2366
|
+
const legacy = new Set([ops.legacy, ops.legacyPlatform]);
|
|
2367
|
+
return {
|
|
2368
|
+
...t,
|
|
2369
|
+
rows: t.rows.map((r) => (legacy.has(r[op]) ? r.map((c, i) => (owner.includes(i) ? null : c)) : r)),
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
return t;
|
|
2373
|
+
}),
|
|
2374
|
+
});
|
|
2375
|
+
expect(await host.admin.listOpsFailures(staff, { operation: ops.ownExpired })).toEqual([]);
|
|
2376
|
+
const before = {
|
|
2377
|
+
otherExpired: await issueOf(ops.otherExpired),
|
|
2378
|
+
ownExpired: await issueOf(ops.ownExpired),
|
|
2379
|
+
legacy: await issueOf(ops.legacy),
|
|
2380
|
+
legacyPlatform: await issueOf(ops.legacyPlatform),
|
|
2381
|
+
};
|
|
2382
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2383
|
+
// Another tenant's exemplar, its rows expired: byte-equal.
|
|
2384
|
+
expect(await issueOf(ops.otherExpired)).toEqual(before.otherExpired);
|
|
2385
|
+
// This tenant's, its rows expired: the attribution is enough.
|
|
2386
|
+
expect(await issueOf(ops.ownExpired)).toEqual({ ...before.ownExpired, lastMessage: REDACTED_FAILURE_NOTE });
|
|
2387
|
+
// Owner unknown: skipped — the legacy residual (§13.1 limit 9), never a guess, whether
|
|
2388
|
+
// the text was a tenant's or the platform's.
|
|
2389
|
+
expect(await issueOf(ops.legacy)).toEqual(before.legacy);
|
|
2390
|
+
expect(await issueOf(ops.legacyPlatform)).toEqual(before.legacyPlatform);
|
|
2391
|
+
});
|
|
2392
|
+
it('attributes each exemplar to the failure it copied, and a newer one takes it over', async () => {
|
|
2393
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2394
|
+
const shared = op('shared');
|
|
2395
|
+
const text = `schedule threw on contact ${erased}`;
|
|
2396
|
+
await fail(shared, text);
|
|
2397
|
+
expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t1 });
|
|
2398
|
+
// The same group, a newer exemplar from another tenant: the attribution moves with it.
|
|
2399
|
+
await fail(shared, text, t2, null);
|
|
2400
|
+
expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t2 });
|
|
2401
|
+
const exemplar = await issueOf(shared);
|
|
2402
|
+
await host.admin.shredSubject(staff, t1, s1, erased);
|
|
2403
|
+
// This tenant's own row goes; the exemplar is now another tenant's and stays.
|
|
2404
|
+
const rows = await host.admin.listOpsFailures(staff, { operation: shared });
|
|
2405
|
+
expect(rows.find((r) => r.tenantId === t1).message).toBe(REDACTED_FAILURE_NOTE);
|
|
2406
|
+
expect(rows.find((r) => r.tenantId === t2).message).toBe(text);
|
|
2407
|
+
expect(await issueOf(shared)).toEqual(exemplar);
|
|
2408
|
+
// And the platform's own failure leaves the platform's exemplar — never "unknown".
|
|
2409
|
+
await fail(shared, 'the platform failed', null, null);
|
|
2410
|
+
expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'platform', tenant: null });
|
|
2411
|
+
});
|
|
2412
|
+
it('ages telemetry out on the scheduled pass, not only when something new is written', async () => {
|
|
2413
|
+
// Issues, ops failures and sweep runs prune on write, which bounds them only while
|
|
2414
|
+
// something is written. Erasure skips an issue of unknown owner on the promise that
|
|
2415
|
+
// it ages out within its retention, so a quiet directory must shed it on time too.
|
|
2416
|
+
// Aged by restoring a directory copy with the rows' timestamps moved past retention.
|
|
2417
|
+
const expired = { op: op('expired'), unit: `${s1}:erasure/expired-${ulid()}` };
|
|
2418
|
+
const fresh = { op: op('fresh'), unit: `${s1}:erasure/fresh-${ulid()}` };
|
|
2419
|
+
for (const x of [expired, fresh]) {
|
|
2420
|
+
await fail(x.op, 'a failure');
|
|
2421
|
+
await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
|
|
2422
|
+
}
|
|
2423
|
+
const LONG_AGO = '2020-01-01T00:00:00.000Z';
|
|
2424
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2425
|
+
const age = (t, match, matchCol, cols) => {
|
|
2426
|
+
const m = t.columns.indexOf(matchCol);
|
|
2427
|
+
const at = cols.map((c) => t.columns.indexOf(c));
|
|
2428
|
+
return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
|
|
2429
|
+
};
|
|
2430
|
+
await host.admin.restoreDirectory(staff, {
|
|
2431
|
+
...copy,
|
|
2432
|
+
tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
|
|
2433
|
+
: t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
|
|
2434
|
+
: t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
|
|
2435
|
+
: t),
|
|
2436
|
+
});
|
|
2437
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
|
|
2438
|
+
const report = await runPlatformSweep(host, {
|
|
2439
|
+
actor: staff,
|
|
2440
|
+
fetch: connectorTestFetch,
|
|
2441
|
+
sweepers: {},
|
|
2442
|
+
drainRetries: false,
|
|
2443
|
+
});
|
|
2444
|
+
expect(report.errors.filter((e) => e.kind === 'telemetry')).toEqual([]);
|
|
2445
|
+
expect(report.telemetry?.opsFailures).toBeGreaterThanOrEqual(1);
|
|
2446
|
+
expect(report.telemetry?.issues).toBeGreaterThanOrEqual(1);
|
|
2447
|
+
expect(report.telemetry?.sweepRuns).toBeGreaterThanOrEqual(1);
|
|
2448
|
+
expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
|
|
2449
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
|
|
2450
|
+
expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
|
|
2451
|
+
// The positive twin: rows inside their retention stay.
|
|
2452
|
+
expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2453
|
+
expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2454
|
+
expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
|
|
2455
|
+
});
|
|
2456
|
+
it('drains a telemetry backlog larger than the batch over passes, oldest first', async () => {
|
|
2457
|
+
// After a long pause or a restore the backlog can be months of rows, and one
|
|
2458
|
+
// unbounded DELETE over it fails on the Durable Object every tick. Each pass
|
|
2459
|
+
// deletes at most `limit` rows per table, so the backlog converges instead.
|
|
2460
|
+
await host.admin.pruneTelemetry(staff, 100_000); // whatever earlier tests left
|
|
2461
|
+
const backlog = Array.from({ length: 5 }, (_, i) => ({ op: op(`backlog-${i}`), unit: `${s1}:erasure/backlog-${i}-${ulid()}` }));
|
|
2462
|
+
const fresh = { op: op('kept'), unit: `${s1}:erasure/kept-${ulid()}` };
|
|
2463
|
+
for (const x of [...backlog, fresh]) {
|
|
2464
|
+
await fail(x.op, 'a failure');
|
|
2465
|
+
await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
|
|
2466
|
+
}
|
|
2467
|
+
const ops = new Set(backlog.map((b) => b.op));
|
|
2468
|
+
const units = new Set(backlog.map((b) => b.unit));
|
|
2469
|
+
const LONG_AGO = '2020-01-01T00:00:00.000Z';
|
|
2470
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2471
|
+
const age = (t, match, matchCol, cols) => {
|
|
2472
|
+
const m = t.columns.indexOf(matchCol);
|
|
2473
|
+
const at = cols.map((c) => t.columns.indexOf(c));
|
|
2474
|
+
return { ...t, rows: t.rows.map((r) => (match.has(r[m]) ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
|
|
2475
|
+
};
|
|
2476
|
+
await host.admin.restoreDirectory(staff, {
|
|
2477
|
+
...copy,
|
|
2478
|
+
tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, ops, 'operation', ['at'])
|
|
2479
|
+
: t.name === '_substrat_issues' ? age(t, ops, 'operation', ['first_seen', 'last_seen'])
|
|
2480
|
+
: t.name === '_substrat_sweep_runs' ? age(t, units, 'unit', ['at'])
|
|
2481
|
+
: t),
|
|
2482
|
+
});
|
|
2483
|
+
expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 3, issues: 3, sweepRuns: 3 });
|
|
2484
|
+
expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 2, issues: 2, sweepRuns: 2 });
|
|
2485
|
+
expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 0, issues: 0, sweepRuns: 0 });
|
|
2486
|
+
for (const b of backlog) {
|
|
2487
|
+
expect(await host.admin.listOpsFailures(staff, { operation: b.op })).toEqual([]);
|
|
2488
|
+
expect(await host.admin.listIssues(staff, { operation: b.op })).toEqual([]);
|
|
2489
|
+
expect(await host.admin.listSweepRuns(staff, { unit: b.unit })).toEqual([]);
|
|
2490
|
+
}
|
|
2491
|
+
// Rows inside their retention are never part of a batch.
|
|
2492
|
+
expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2493
|
+
expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
|
|
2494
|
+
expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
|
|
2495
|
+
});
|
|
2496
|
+
it('refuses a row bound that is not a positive integer, wherever one reaches a LIMIT', async () => {
|
|
2497
|
+
// SQLite reads a negative LIMIT as no limit at all, so `-1` turned the bounded
|
|
2498
|
+
// telemetry prune into an unbounded one; a fraction or a non-finite value fails
|
|
2499
|
+
// inside SQLite on every later call. Each is refused with the option's name, and
|
|
2500
|
+
// nothing is deleted on the way.
|
|
2501
|
+
const expired = { op: op('bound'), unit: `${s1}:erasure/bound-${ulid()}` };
|
|
2502
|
+
await fail(expired.op, 'a failure');
|
|
2503
|
+
await host.admin.recordSweepRun({ kind: 'schedule', unit: expired.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
|
|
2504
|
+
const LONG_AGO = '2020-01-01T00:00:00.000Z';
|
|
2505
|
+
const copy = await host.admin.exportDirectory(staff);
|
|
2506
|
+
const age = (t, match, matchCol, cols) => {
|
|
2507
|
+
const m = t.columns.indexOf(matchCol);
|
|
2508
|
+
const at = cols.map((c) => t.columns.indexOf(c));
|
|
2509
|
+
return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
|
|
2510
|
+
};
|
|
2511
|
+
await host.admin.restoreDirectory(staff, {
|
|
2512
|
+
...copy,
|
|
2513
|
+
tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
|
|
2514
|
+
: t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
|
|
2515
|
+
: t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
|
|
2516
|
+
: t),
|
|
2517
|
+
});
|
|
2518
|
+
const sweep = { actor: staff, fetch: connectorTestFetch, sweepers: {}, drainRetries: false };
|
|
2519
|
+
const refused = (name) => (err) => errorCodeOf(err) === 'validation_failed' && new RegExp(`${name} must be a positive integer`).test(err.message);
|
|
2520
|
+
for (const bad of [-1, 0, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
2521
|
+
await expect(host.admin.pruneTelemetry(staff, bad)).rejects.toSatisfy(refused('limit'));
|
|
2522
|
+
await expect(host.admin.pruneAccessLog(staff, bad)).rejects.toSatisfy(refused('limit'));
|
|
2523
|
+
await expect(runPlatformSweep(host, { ...sweep, telemetryBatch: bad })).rejects.toSatisfy(refused('telemetryBatch'));
|
|
2524
|
+
await expect(runPlatformSweep(host, { ...sweep, accessLogBatch: bad })).rejects.toSatisfy(refused('accessLogBatch'));
|
|
2525
|
+
expect(() => startPlatformSweeper(host, { ...sweep, telemetryBatch: bad, intervalMs: 60_000 })).toThrow(/telemetryBatch must be a positive integer/);
|
|
2526
|
+
await expect(host.runDueJobs(t1, s1, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
|
|
2527
|
+
await expect(host.admin.readUndrainedEvents(staff, t1, s1, bad)).rejects.toThrow(/limit must be a positive integer/);
|
|
2528
|
+
await expect(host.admin.auditLog(staff, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
|
|
2529
|
+
}
|
|
2530
|
+
// Nothing went on the way: the expired rows are all still there.
|
|
2531
|
+
expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toHaveLength(1);
|
|
2532
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
|
|
2533
|
+
expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toHaveLength(1);
|
|
2534
|
+
// The positive twin: a valid bound drains them.
|
|
2535
|
+
while (Object.values(await host.admin.pruneTelemetry(staff, 1)).some((n) => n > 0))
|
|
2536
|
+
;
|
|
2537
|
+
expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
|
|
2538
|
+
expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
|
|
2539
|
+
expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
|
|
2540
|
+
});
|
|
2541
|
+
it('rewrites a queued sweep-runs entry\'s error, and keeps the rest of the intent', async () => {
|
|
2542
|
+
// A CP-less pass queues its schedule outcomes as one `sweep-runs` intent, and the
|
|
2543
|
+
// drain lands each entry's `error` in `_substrat_sweep_runs`. The intent is kept like
|
|
2544
|
+
// every other, so the same text is held twice. `sweep-runs` is platform-authored —
|
|
2545
|
+
// module code cannot enqueue it — so the rows are planted through a restore.
|
|
2546
|
+
const erased = dataSubjectId.parse(ulid());
|
|
2547
|
+
const spared = dataSubjectId.parse(ulid());
|
|
2548
|
+
// Guarantees the journal has a row to copy the envelope columns from.
|
|
2549
|
+
const template = await routeIntent(`connector:erasure-${ulid()}`, spared, 'Bo Lund');
|
|
2550
|
+
const backup = await host.admin.exportScope(staff, t1, s1);
|
|
2551
|
+
const table = backup.tables.find((t) => t.name === '_substrat_platform_requests');
|
|
2552
|
+
const idCol = table.columns.indexOf('id');
|
|
2553
|
+
const base = table.rows.find((r) => r[idCol] === template);
|
|
2554
|
+
const at = '2026-09-20T00:00:00.000Z';
|
|
2555
|
+
const entry = (operation, error) => ({
|
|
2556
|
+
kind: 'schedule',
|
|
2557
|
+
operation,
|
|
2558
|
+
outcome: error === null ? 'ok' : 'failed',
|
|
2559
|
+
at,
|
|
2560
|
+
error,
|
|
2561
|
+
elapsedMs: 12,
|
|
2562
|
+
});
|
|
2563
|
+
const payloads = {
|
|
2564
|
+
mine: {
|
|
2565
|
+
version: null,
|
|
2566
|
+
entries: [
|
|
2567
|
+
entry('crm/sync', `contact ${erased} (Anna Ek) bounced`),
|
|
2568
|
+
entry('crm/tidy', 'nothing to tidy'),
|
|
2569
|
+
entry('crm/ok', null),
|
|
2570
|
+
],
|
|
2571
|
+
},
|
|
2572
|
+
theirs: { version: 'v-1', entries: [entry('crm/sync', `contact ${spared} (Bo Lund) bounced`)] },
|
|
2573
|
+
};
|
|
2574
|
+
const ids = { mine: ulid(), theirs: ulid() };
|
|
2575
|
+
const plant = (id, payload) => table.columns.map((c, i) => c === 'id' ? id
|
|
2576
|
+
: c === 'kind' ? SWEEP_RUNS_KIND
|
|
2577
|
+
: c === 'payload' ? JSON.stringify(payload)
|
|
2578
|
+
: c === 'status' ? 'pending'
|
|
2579
|
+
: c === 'attempts' ? 0
|
|
2580
|
+
: c === 'settled_at' || c === 'last_error' || c === 'last_failure' || c === 'result' ? null
|
|
2581
|
+
: base[i]);
|
|
2582
|
+
const s = scopeId.parse(ulid());
|
|
2583
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s, jurisdiction: 'eu', vertical: 'connector-vertical' });
|
|
2584
|
+
await host.admin.activateScope(staff, t1, s);
|
|
2585
|
+
await host.restoreScope(staff, t1, s, {
|
|
2586
|
+
...backup,
|
|
2587
|
+
scopeId: s,
|
|
2588
|
+
tables: backup.tables.map((t) => t === table ? { ...t, rows: [...t.rows, plant(ids.mine, payloads.mine), plant(ids.theirs, payloads.theirs)] } : t),
|
|
2589
|
+
});
|
|
2590
|
+
await host.settlePlatformRequest(t1, s1, template, { status: 'done' });
|
|
2591
|
+
const queued = async () => {
|
|
2592
|
+
const rows = await host.listPlatformRequestHistory(t1, s, { kind: SWEEP_RUNS_KIND });
|
|
2593
|
+
return { mine: rows.find((r) => r.id === ids.mine), theirs: rows.find((r) => r.id === ids.theirs) };
|
|
2594
|
+
};
|
|
2595
|
+
const before = await queued();
|
|
2596
|
+
await host.admin.shredSubject(staff, t1, s, erased);
|
|
2597
|
+
const after = await queued();
|
|
2598
|
+
// Only the error that named the subject; the shape, the other entries, the
|
|
2599
|
+
// version and the row's own columns are as they were — still pending, so the
|
|
2600
|
+
// drain lands the note instead of the text.
|
|
2601
|
+
expect(after.mine).toEqual({
|
|
2602
|
+
...before.mine,
|
|
2603
|
+
payload: {
|
|
2604
|
+
...payloads.mine,
|
|
2605
|
+
entries: [
|
|
2606
|
+
{ ...payloads.mine.entries[0], error: REDACTED_FAILURE_NOTE },
|
|
2607
|
+
payloads.mine.entries[1],
|
|
2608
|
+
payloads.mine.entries[2],
|
|
2609
|
+
],
|
|
2610
|
+
},
|
|
2611
|
+
});
|
|
2612
|
+
expect(after.theirs).toEqual(before.theirs);
|
|
2613
|
+
await host.admin.shredSubject(staff, t1, s, erased);
|
|
2614
|
+
expect(await queued()).toEqual(after);
|
|
2615
|
+
});
|
|
2616
|
+
});
|
|
2207
2617
|
});
|
|
2208
2618
|
it('records the erasure in BOTH logs — mutation and evidence-destruction', async () => {
|
|
2209
2619
|
const subject = dataSubjectId.parse(ulid());
|
|
@@ -4024,7 +4434,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4024
4434
|
// The refusal the registry exists for. Without it "a push lands pending" is
|
|
4025
4435
|
// a convention, and D-30's lockstep-upgrade argument is that conventions are
|
|
4026
4436
|
// what we cannot afford here.
|
|
4027
|
-
await
|
|
4437
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
|
|
4028
4438
|
await host.admin.admitVersion(staff, versionId);
|
|
4029
4439
|
await host.admin.bindScopeVersion(staff, t1, s1, versionId);
|
|
4030
4440
|
const scope = await host.admin.getScopeRecord(staff, t1, s1);
|
|
@@ -4085,7 +4495,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4085
4495
|
await host.admin.bindScopeVersion(staff, t1, preview, pending);
|
|
4086
4496
|
expect((await host.admin.getScopeRecord(staff, t1, preview))?.verticalVersionId).toBe(pending);
|
|
4087
4497
|
// …while a normal (serving) scope keeps the refusal the registry exists for.
|
|
4088
|
-
await
|
|
4498
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, pending), 'conflict');
|
|
4089
4499
|
});
|
|
4090
4500
|
it('routes a PREVIEW to its bound version, not the vertical serving script (#527)', async () => {
|
|
4091
4501
|
// The bug: a preview forks into — and binds — a specific (usually not-yet-serving)
|
|
@@ -4238,19 +4648,20 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4238
4648
|
ownerTenant: t2,
|
|
4239
4649
|
});
|
|
4240
4650
|
// A slug's owner is fixed at first push: a different owner (or claiming a
|
|
4241
|
-
// platform vertical) is refused, naming both owners.
|
|
4242
|
-
|
|
4651
|
+
// platform vertical) is refused, naming both owners. The message is asserted beside
|
|
4652
|
+
// the code only for WHICH owner it names; the code is the contract.
|
|
4653
|
+
await expectRefusal(host.admin.registerVertical(staff, {
|
|
4243
4654
|
slug: 'helpdesk',
|
|
4244
4655
|
name: 'Helpdesk',
|
|
4245
4656
|
source: 'cli',
|
|
4246
4657
|
ownerTenant: t1,
|
|
4247
|
-
})
|
|
4248
|
-
await
|
|
4658
|
+
}), 'conflict', /owned by/);
|
|
4659
|
+
await expectRefusal(host.admin.registerVertical(staff, {
|
|
4249
4660
|
slug: 'callout',
|
|
4250
4661
|
name: 'Callout',
|
|
4251
4662
|
source: 'builtin',
|
|
4252
4663
|
ownerTenant: t2,
|
|
4253
|
-
})
|
|
4664
|
+
}), 'conflict', /owned by the platform/);
|
|
4254
4665
|
});
|
|
4255
4666
|
it('carries registry-driven install metadata (marketplace-publish.md §3) and refreshes it', async () => {
|
|
4256
4667
|
// ownerGrants/entitlements/provides/requires ride the registry (one install_spec JSON
|
|
@@ -4560,7 +4971,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4560
4971
|
deploymentRef: null,
|
|
4561
4972
|
});
|
|
4562
4973
|
await host.admin.rejectVersion(staff, versionId, 'permission diff widened a role');
|
|
4563
|
-
await
|
|
4974
|
+
await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
|
|
4564
4975
|
// Terminal: a rejected version is not resurrected, a new one is published.
|
|
4565
4976
|
await expectRefusal(host.admin.admitVersion(staff, versionId), 'conflict');
|
|
4566
4977
|
const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
|
|
@@ -4656,7 +5067,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4656
5067
|
migrationDigest: 'g',
|
|
4657
5068
|
deploymentRef: null,
|
|
4658
5069
|
});
|
|
4659
|
-
await
|
|
5070
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', pending), 'conflict');
|
|
4660
5071
|
});
|
|
4661
5072
|
// -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
|
|
4662
5073
|
// A private vertical's blast radius is its own tenant — dev/staging already
|
|
@@ -4798,7 +5209,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4798
5209
|
// Listing is the moment OTHER tenants start trusting this code, so the version
|
|
4799
5210
|
// they would install needs a recorded human decision — the auto-admission note
|
|
4800
5211
|
// is exactly what marks its absence.
|
|
4801
|
-
await
|
|
5212
|
+
await expectRefusal(host.admin.setVerticalListed(staff, 'tenant-a/crm', true), 'conflict');
|
|
4802
5213
|
// A staff admit of the already-admitted version upgrades it to a manual vouch
|
|
4803
5214
|
// (clears the note, audited) — then listing passes.
|
|
4804
5215
|
const prodNow = (await host.admin.listChannels(staff, 'tenant-a/crm')).find((c) => c.channel === 'prod')
|