@substrat-run/contract-tests 0.139.0 → 0.140.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/capability-suite.d.ts.map +1 -1
- package/dist/capability-suite.js +5 -0
- package/dist/capability-suite.js.map +1 -1
- package/dist/caused-by-suite.d.ts.map +1 -1
- package/dist/caused-by-suite.js +54 -10
- package/dist/caused-by-suite.js.map +1 -1
- package/dist/connect-link-suite.d.ts +19 -0
- package/dist/connect-link-suite.d.ts.map +1 -0
- package/dist/connect-link-suite.js +215 -0
- package/dist/connect-link-suite.js.map +1 -0
- package/dist/entity-state-migration-suite.d.ts +16 -0
- package/dist/entity-state-migration-suite.d.ts.map +1 -0
- package/dist/entity-state-migration-suite.js +305 -0
- package/dist/entity-state-migration-suite.js.map +1 -0
- package/dist/entity-state-module.d.ts +183 -0
- package/dist/entity-state-module.d.ts.map +1 -1
- package/dist/entity-state-module.js +139 -1
- package/dist/entity-state-module.js.map +1 -1
- package/dist/entity-state-suite.d.ts.map +1 -1
- package/dist/entity-state-suite.js +35 -12
- package/dist/entity-state-suite.js.map +1 -1
- package/dist/entity-trash-module.d.ts +439 -0
- package/dist/entity-trash-module.d.ts.map +1 -0
- package/dist/entity-trash-module.js +287 -0
- package/dist/entity-trash-module.js.map +1 -0
- package/dist/entity-trash-suite.d.ts +59 -0
- package/dist/entity-trash-suite.d.ts.map +1 -0
- package/dist/entity-trash-suite.js +568 -0
- package/dist/entity-trash-suite.js.map +1 -0
- package/dist/erasure-module.d.ts +259 -0
- package/dist/erasure-module.d.ts.map +1 -0
- package/dist/erasure-module.js +251 -0
- package/dist/erasure-module.js.map +1 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/list-suite.d.ts.map +1 -1
- package/dist/list-suite.js +101 -1
- package/dist/list-suite.js.map +1 -1
- package/dist/membership-executor-suite.d.ts.map +1 -1
- package/dist/membership-executor-suite.js +60 -9
- package/dist/membership-executor-suite.js.map +1 -1
- package/dist/migration-comments-suite.d.ts +3 -0
- package/dist/migration-comments-suite.d.ts.map +1 -0
- package/dist/migration-comments-suite.js +92 -0
- package/dist/migration-comments-suite.js.map +1 -0
- package/dist/migration-comments.d.ts +3 -0
- package/dist/migration-comments.d.ts.map +1 -0
- package/dist/migration-comments.js +50 -0
- package/dist/migration-comments.js.map +1 -0
- package/dist/migration-digest-suite.d.ts +3 -0
- package/dist/migration-digest-suite.d.ts.map +1 -0
- package/dist/migration-digest-suite.js +175 -0
- package/dist/migration-digest-suite.js.map +1 -0
- package/dist/modules.d.ts +542 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +155 -7
- package/dist/modules.js.map +1 -1
- package/dist/peer-suite.d.ts +2 -1
- package/dist/peer-suite.d.ts.map +1 -1
- package/dist/peer-suite.js +103 -1
- package/dist/peer-suite.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +515 -2
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +54 -1
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/split-cases.d.ts +12 -0
- package/dist/split-cases.d.ts.map +1 -0
- package/dist/split-cases.js +59 -0
- package/dist/split-cases.js.map +1 -0
- package/dist/subject-erasure-suite.d.ts +6 -0
- package/dist/subject-erasure-suite.d.ts.map +1 -0
- package/dist/subject-erasure-suite.js +287 -0
- package/dist/subject-erasure-suite.js.map +1 -0
- package/dist/switch-audit-fault.d.ts +48 -0
- package/dist/switch-audit-fault.d.ts.map +1 -0
- package/dist/switch-audit-fault.js +80 -0
- package/dist/switch-audit-fault.js.map +1 -0
- package/dist/system-switch-suite.d.ts +2 -1
- package/dist/system-switch-suite.d.ts.map +1 -1
- package/dist/system-switch-suite.js +90 -0
- package/dist/system-switch-suite.js.map +1 -1
- package/dist/vertical-events-suite.d.ts +41 -0
- package/dist/vertical-events-suite.d.ts.map +1 -1
- package/dist/vertical-events-suite.js +55 -0
- package/dist/vertical-events-suite.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite for the trash, held by the host (#119 PR 2): the refusal of an operation on a
|
|
3
|
+
* trashed entity it did not declare it reaches, `ctx.link`/`ctx.relink` refusing a trashed
|
|
4
|
+
* parent, and the purge horizon's sweep.
|
|
5
|
+
*
|
|
6
|
+
* The DEFAULT tuple checker on purpose: the order of the refusal — the declared key before the
|
|
7
|
+
* bin is revealed — is a permission property, and an allow-all checker would pass a refusal that
|
|
8
|
+
* leaked the bin to anyone.
|
|
9
|
+
*
|
|
10
|
+
* Every refusal has its positive twin beside it, so a case cannot pass by a host that refuses
|
|
11
|
+
* everything. Purge cases each run on a FRESH scope: a pass writes the schedule's cadence row,
|
|
12
|
+
* and a later case on the same scope would only see its own work skipped.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
15
|
+
import { declaredSurfaceOf, errorCodeOf, moduleId, operationInputsOf, permissionKey, platformActorId, principalId, scopeId, tenantId, z, } from '@substrat-run/contracts';
|
|
16
|
+
import { PURGE_BATCH, runPlatformSweep, ulid } from '@substrat-run/kernel';
|
|
17
|
+
import { EXPLODING_BOX, TBOX_PURGE_DAYS, TRASH_MODULE_ID, trashMod, trashOperations } from './entity-trash-module.js';
|
|
18
|
+
const KEYS = ['box:read', 'box:write', 'box:archive', 'box:trash', 'box:delete'].map((k) => permissionKey.parse(k));
|
|
19
|
+
const READ = permissionKey.parse('box:read');
|
|
20
|
+
const MODULE = moduleId.parse(TRASH_MODULE_ID);
|
|
21
|
+
const DAY = 86_400_000;
|
|
22
|
+
const noFetch = async () => {
|
|
23
|
+
throw new Error('no outbound fetch in this suite');
|
|
24
|
+
};
|
|
25
|
+
export function entityTrashContractSuite(adapterName, makeFixture, raw, options = {}) {
|
|
26
|
+
describe(`the trash, held by the host (#119 PR 2): ${adapterName}`, () => {
|
|
27
|
+
let fixture;
|
|
28
|
+
let host;
|
|
29
|
+
const t = tenantId.parse(ulid());
|
|
30
|
+
const staff = platformActorId.parse(ulid());
|
|
31
|
+
/** Every key, tenant-wide. */
|
|
32
|
+
const alice = principalId.parse(ulid());
|
|
33
|
+
/** Read only — holds no write key anywhere, so every write on a box is `forbidden` to her. */
|
|
34
|
+
const dave = principalId.parse(ulid());
|
|
35
|
+
let scope;
|
|
36
|
+
let as;
|
|
37
|
+
const errOf = async (call) => call.then(() => {
|
|
38
|
+
throw new Error('expected a refusal, got an answer');
|
|
39
|
+
}, (e) => e);
|
|
40
|
+
/** A new primary scope — the schedule's cadence row starts empty, and the system grant is seated. */
|
|
41
|
+
const freshScope = async (extra = {}) => {
|
|
42
|
+
const s = scopeId.parse(ulid());
|
|
43
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: s, ...extra });
|
|
44
|
+
await host.admin.activateScope(staff, t, s);
|
|
45
|
+
return s;
|
|
46
|
+
};
|
|
47
|
+
const stub = (who, s = scope) => host.getScope(who, t, s);
|
|
48
|
+
const addBox = async (s = scope, name = `box ${ulid()}`) => {
|
|
49
|
+
const id = ulid();
|
|
50
|
+
await (await stub(alice, s)).invoke('trash/add-box', { id, name });
|
|
51
|
+
return id;
|
|
52
|
+
};
|
|
53
|
+
const binnedBox = async (s = scope, name) => {
|
|
54
|
+
const id = await addBox(s, name);
|
|
55
|
+
await (await stub(alice, s)).invoke('trash/trash-box', { boxId: id });
|
|
56
|
+
return id;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Move a binned row's trash instant into the past, past the guard and the trigger — the way
|
|
60
|
+
* a row that has sat in the bin for days looks. The kernel's own authorization row is what
|
|
61
|
+
* the trigger asks for, written and removed around the one UPDATE exactly as a move does.
|
|
62
|
+
*/
|
|
63
|
+
const backdate = async (s, table, type, id, days) => {
|
|
64
|
+
const at = new Date(Date.now() - days * DAY - 60_000).toISOString();
|
|
65
|
+
await raw(t, s, `INSERT INTO _substrat_state_moves (entity_type, entity_id) VALUES (?, ?)`, [type, id]);
|
|
66
|
+
await raw(t, s, `UPDATE ${table} SET _substrat_trashed_at = ? WHERE id = ?`, [at, id]);
|
|
67
|
+
await raw(t, s, `DELETE FROM _substrat_state_moves WHERE entity_type = ? AND entity_id = ?`, [type, id]);
|
|
68
|
+
};
|
|
69
|
+
const dueBox = async (s, name) => {
|
|
70
|
+
const id = await binnedBox(s, name);
|
|
71
|
+
await backdate(s, 'trash_boxes', 'tbox', id, TBOX_PURGE_DAYS + 1);
|
|
72
|
+
return id;
|
|
73
|
+
};
|
|
74
|
+
const exists = async (s, boxId) => (await (await stub(alice, s)).invoke('trash/box-exists', { boxId })).exists;
|
|
75
|
+
const sweep = (s) => host.runDueSchedules(MODULE, t, s);
|
|
76
|
+
/**
|
|
77
|
+
* Bin `count` boxes in one call and make them all due in one set-based backdate, so a case spends
|
|
78
|
+
* its time on the sweep it asserts rather than on setting it up. They share one trash instant, so
|
|
79
|
+
* the walk's order is the id's; the first `exploding` of them fail every purge.
|
|
80
|
+
*/
|
|
81
|
+
const dueBoxes = async (s, count, exploding = 0) => {
|
|
82
|
+
const prefix = ulid().slice(0, 20);
|
|
83
|
+
const { ids } = (await (await stub(alice, s)).invoke('trash/bin-many', { prefix, count }));
|
|
84
|
+
const at = new Date(Date.now() - (TBOX_PURGE_DAYS + 1) * DAY).toISOString();
|
|
85
|
+
const mine = `id >= '${prefix}' AND id < '${prefix}~'`;
|
|
86
|
+
await raw(t, s, `INSERT INTO _substrat_state_moves (entity_type, entity_id) SELECT 'tbox', id FROM trash_boxes WHERE ${mine}`);
|
|
87
|
+
await raw(t, s, `UPDATE trash_boxes SET _substrat_trashed_at = ? WHERE ${mine}`, [at]);
|
|
88
|
+
if (exploding > 0)
|
|
89
|
+
await raw(t, s, `UPDATE trash_boxes SET name = ? WHERE ${mine} AND id <= ?`, [EXPLODING_BOX, ids[exploding - 1]]);
|
|
90
|
+
await raw(t, s, `DELETE FROM _substrat_state_moves WHERE entity_type = 'tbox'`);
|
|
91
|
+
return ids;
|
|
92
|
+
};
|
|
93
|
+
const alive = async (s, boxes) => (await Promise.all(boxes.map((b) => exists(s, b)))).filter(Boolean).length;
|
|
94
|
+
/** Move the purge schedule's run into the past only where it recorded `failed` — then, and only then, it is due again. */
|
|
95
|
+
const rearmIfFailed = (s) => raw(t, s, `UPDATE _substrat_schedule_state SET last_run_at = '2020-01-01T00:00:00.000Z'
|
|
96
|
+
WHERE kind = 'schedule' AND schedule_op = 'trash/delete-box' AND last_status = 'failed'`);
|
|
97
|
+
beforeAll(async () => {
|
|
98
|
+
fixture = await makeFixture();
|
|
99
|
+
host = fixture.host;
|
|
100
|
+
host.registerModule(trashMod);
|
|
101
|
+
await host.admin.createTenant(staff, { id: t, slug: `trash-${t.slice(-10).toLowerCase()}`, name: 'Trash' });
|
|
102
|
+
await host.admin.grantEntitlement(staff, t, 'trash');
|
|
103
|
+
await host.admin.defineRole(staff, t, { key: 'boxer', permissions: KEYS, source: 'vertical' });
|
|
104
|
+
await host.admin.defineRole(staff, t, { key: 'looker', permissions: [READ], source: 'vertical' });
|
|
105
|
+
await host.admin.assignRole(staff, { principalId: alice, roleKey: 'boxer', node: { tenantId: t, scopeId: null } });
|
|
106
|
+
await host.admin.assignRole(staff, { principalId: dave, roleKey: 'looker', node: { tenantId: t, scopeId: null } });
|
|
107
|
+
scope = await freshScope();
|
|
108
|
+
as = { alice: await stub(alice), dave: await stub(dave) };
|
|
109
|
+
});
|
|
110
|
+
afterAll(async () => {
|
|
111
|
+
await fixture.cleanup();
|
|
112
|
+
});
|
|
113
|
+
// -- the refusal ---------------------------------------------------------------------
|
|
114
|
+
describe('an operation that does not declare `trashed`', () => {
|
|
115
|
+
it('is refused not_found on a trashed entity, to a caller who holds its key', async () => {
|
|
116
|
+
const id = await binnedBox();
|
|
117
|
+
const e = await errOf(as.alice.invoke('trash/rename-box', { boxId: id, name: 'x' }));
|
|
118
|
+
expect(errorCodeOf(e)).toBe('not_found');
|
|
119
|
+
});
|
|
120
|
+
it('twin: runs on an active entity, and on an ARCHIVED one — archive is not a delete', async () => {
|
|
121
|
+
const id = await addBox();
|
|
122
|
+
await as.alice.invoke('trash/rename-box', { boxId: id, name: 'renamed' });
|
|
123
|
+
await as.alice.invoke('trash/archive-box', { boxId: id });
|
|
124
|
+
await expect(as.alice.invoke('trash/rename-box', { boxId: id, name: 'again' })).resolves.toEqual({ ok: true });
|
|
125
|
+
});
|
|
126
|
+
it('gives a caller WITHOUT the key the same refusal on a trashed entity as on an active or missing one', async () => {
|
|
127
|
+
const active = await addBox();
|
|
128
|
+
const trashed = await binnedBox();
|
|
129
|
+
const refusal = async (boxId) => {
|
|
130
|
+
const e = (await errOf(as.dave.invoke('trash/rename-box', { boxId, name: 'x' })));
|
|
131
|
+
return { code: errorCodeOf(e), name: e.name, message: e.message, permission: e.permission };
|
|
132
|
+
};
|
|
133
|
+
const onActive = await refusal(active);
|
|
134
|
+
expect(onActive.code).toBe('permission_denied');
|
|
135
|
+
// Status, body and the key named: identical, so the bin is not something to probe.
|
|
136
|
+
expect(await refusal(trashed)).toEqual(onActive);
|
|
137
|
+
expect(await refusal(ulid())).toEqual(onActive);
|
|
138
|
+
});
|
|
139
|
+
it('is refused whoever calls — the refusal is the host\'s, not the handler\'s', async () => {
|
|
140
|
+
// The system principal holds no `box:write`; it meets the same declared check first.
|
|
141
|
+
const id = await binnedBox();
|
|
142
|
+
const system = await host.getSystemScope(MODULE, t, scope);
|
|
143
|
+
expect(errorCodeOf(await errOf(system.invoke('trash/rename-box', { boxId: id, name: 'x' })))).toBe('permission_denied');
|
|
144
|
+
});
|
|
145
|
+
it('and the state is untouched by the refused call', async () => {
|
|
146
|
+
const id = await binnedBox();
|
|
147
|
+
await errOf(as.alice.invoke('trash/rename-box', { boxId: id, name: 'changed' }));
|
|
148
|
+
expect(await as.alice.invoke('trash/peek-box', { boxId: id })).toEqual({ state: 'trashed' });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe("`trashed: 'admits'` and `'purges'`", () => {
|
|
152
|
+
it("'admits' reaches a trashed entity: the restore, and a read of the bin", async () => {
|
|
153
|
+
const id = await binnedBox();
|
|
154
|
+
expect(await as.alice.invoke('trash/peek-box', { boxId: id })).toEqual({ state: 'trashed' });
|
|
155
|
+
await as.alice.invoke('trash/restore-box', { boxId: id });
|
|
156
|
+
expect(await as.alice.invoke('trash/peek-box', { boxId: id })).toEqual({ state: 'active' });
|
|
157
|
+
});
|
|
158
|
+
it("'admits' still checks its key — the opt-in is not an exemption from the permission", async () => {
|
|
159
|
+
const id = await binnedBox();
|
|
160
|
+
expect(errorCodeOf(await errOf(as.dave.invoke('trash/restore-box', { boxId: id })))).toBe('permission_denied');
|
|
161
|
+
});
|
|
162
|
+
it("'purges' is the permanent delete — a person may empty the bin with it", async () => {
|
|
163
|
+
const id = await binnedBox();
|
|
164
|
+
await as.alice.invoke('trash/delete-box', { boxId: id });
|
|
165
|
+
expect(await exists(scope, id)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
it('does not reach an operation whose check is resolved in the handler — the stated gap', async () => {
|
|
169
|
+
const boxId = await addBox();
|
|
170
|
+
const thingId = ulid();
|
|
171
|
+
await as.alice.invoke('trash/link-thing', { boxId, thingId });
|
|
172
|
+
await as.alice.invoke('trash/trash-box', { boxId });
|
|
173
|
+
// The host cannot see which box `touch-thing` reaches; the handler would have to ask.
|
|
174
|
+
await expect(as.alice.invoke('trash/touch-thing', { thingId })).resolves.toEqual({ ok: true });
|
|
175
|
+
});
|
|
176
|
+
// -- ctx.link and ctx.relink -----------------------------------------------------------
|
|
177
|
+
describe('ctx.link and ctx.relink', () => {
|
|
178
|
+
it('link refuses a trashed parent not_found, and writes nothing', async () => {
|
|
179
|
+
const boxId = await binnedBox();
|
|
180
|
+
const thingId = ulid();
|
|
181
|
+
const e = await errOf(as.alice.invoke('trash/link-thing', { boxId, thingId }));
|
|
182
|
+
expect(errorCodeOf(e)).toBe('not_found');
|
|
183
|
+
await as.alice.invoke('trash/restore-box', { boxId });
|
|
184
|
+
expect((await as.alice.invoke('trash/box-exists', { boxId })).things).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
it('a trashed parent and a missing one are refused identically — the trash cannot be probed through link', async () => {
|
|
187
|
+
const trashed = await binnedBox();
|
|
188
|
+
const missing = ulid();
|
|
189
|
+
const refusal = async (who, boxId) => {
|
|
190
|
+
const e = (await errOf(who.invoke('trash/link-thing', { boxId, thingId: ulid() })));
|
|
191
|
+
// Everything but the id the caller named itself.
|
|
192
|
+
return { code: errorCodeOf(e), name: e.name, message: e.message.replace(boxId, '<id>') };
|
|
193
|
+
};
|
|
194
|
+
// A caller who may write the box learns only "not found", whichever it was.
|
|
195
|
+
expect(await refusal(as.alice, trashed)).toEqual(await refusal(as.alice, missing));
|
|
196
|
+
expect((await refusal(as.alice, trashed)).code).toBe('not_found');
|
|
197
|
+
// A caller without access to the parent is stopped before link, the same way for both.
|
|
198
|
+
expect(await refusal(as.dave, trashed)).toEqual(await refusal(as.dave, missing));
|
|
199
|
+
expect((await refusal(as.dave, trashed)).code).toBe('permission_denied');
|
|
200
|
+
});
|
|
201
|
+
it('twin: link accepts an active parent and an ARCHIVED one', async () => {
|
|
202
|
+
const active = await addBox();
|
|
203
|
+
await expect(as.alice.invoke('trash/link-thing', { boxId: active, thingId: ulid() })).resolves.toEqual({ ok: true });
|
|
204
|
+
const archived = await addBox();
|
|
205
|
+
await as.alice.invoke('trash/archive-box', { boxId: archived });
|
|
206
|
+
await expect(as.alice.invoke('trash/link-thing', { boxId: archived, thingId: ulid() })).resolves.toEqual({ ok: true });
|
|
207
|
+
});
|
|
208
|
+
it('relink refuses a trashed `to`, and lets a child be moved OUT from under a trashed `from`', async () => {
|
|
209
|
+
const from = await addBox();
|
|
210
|
+
const thingId = ulid();
|
|
211
|
+
await as.alice.invoke('trash/link-thing', { boxId: from, thingId });
|
|
212
|
+
const binned = await binnedBox();
|
|
213
|
+
const e = await errOf(as.alice.invoke('trash/move-thing', { boxId: binned, thingId, fromBoxId: from }));
|
|
214
|
+
expect(errorCodeOf(e)).toBe('not_found');
|
|
215
|
+
// The rescue: the parent goes in the bin, the child is moved to a live one.
|
|
216
|
+
await as.alice.invoke('trash/trash-box', { boxId: from });
|
|
217
|
+
const to = await addBox();
|
|
218
|
+
await expect(as.alice.invoke('trash/move-thing', { boxId: to, thingId, fromBoxId: from })).resolves.toEqual({ ok: true });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
// -- the purge horizon -----------------------------------------------------------------
|
|
222
|
+
describe('the purge horizon', () => {
|
|
223
|
+
it('purges what has been in the bin past the horizon, and nothing else', async () => {
|
|
224
|
+
const s = await freshScope();
|
|
225
|
+
const due = await dueBox(s);
|
|
226
|
+
const recent = await binnedBox(s);
|
|
227
|
+
const active = await addBox(s);
|
|
228
|
+
const archived = await addBox(s);
|
|
229
|
+
await (await stub(alice, s)).invoke('trash/archive-box', { boxId: archived });
|
|
230
|
+
// A trashable entity with no horizon: in the bin for a year, and kept.
|
|
231
|
+
const keep = ulid();
|
|
232
|
+
await (await stub(alice, s)).invoke('trash/add-keep', { id: keep });
|
|
233
|
+
await (await stub(alice, s)).invoke('trash/trash-keep', { keepId: keep });
|
|
234
|
+
await backdate(s, 'trash_keeps', 'tkeep', keep, 365);
|
|
235
|
+
const report = await sweep(s);
|
|
236
|
+
expect(report).toMatchObject({ fired: 1, failed: 0, errors: [] });
|
|
237
|
+
expect(await exists(s, due)).toBe(false);
|
|
238
|
+
expect(await exists(s, recent)).toBe(true);
|
|
239
|
+
expect(await exists(s, active)).toBe(true);
|
|
240
|
+
expect(await exists(s, archived)).toBe(true);
|
|
241
|
+
expect(await (await stub(alice, s)).invoke('trash/peek-box', { boxId: recent })).toEqual({ state: 'trashed' });
|
|
242
|
+
});
|
|
243
|
+
it("runs the vertical's own delete as the module's system principal, and every event stays", async () => {
|
|
244
|
+
const s = await freshScope();
|
|
245
|
+
const id = await dueBox(s);
|
|
246
|
+
await sweep(s);
|
|
247
|
+
const { entries } = (await (await stub(alice, s)).invoke('trash/history', { boxId: id }));
|
|
248
|
+
expect(entries.map((e) => e.type)).toEqual(expect.arrayContaining(['entity.trashed', 'trashbox.deleted']));
|
|
249
|
+
const deleted = entries.find((e) => e.type === 'trashbox.deleted');
|
|
250
|
+
expect(deleted.actor).toEqual({ system: TRASH_MODULE_ID });
|
|
251
|
+
expect(deleted.operation).toBe('trash/delete-box');
|
|
252
|
+
// K-34: the seated system grant is what authorized it, recorded on the event.
|
|
253
|
+
expect(deleted.authorization?.map((a) => a.permission)).toEqual(['box:delete']);
|
|
254
|
+
});
|
|
255
|
+
it('a purge that fails rolls back alone: the rest commit, it stays in the bin, the pass reports it', async () => {
|
|
256
|
+
const s = await freshScope();
|
|
257
|
+
const a = await dueBox(s);
|
|
258
|
+
const boom = await dueBox(s, EXPLODING_BOX);
|
|
259
|
+
const b = await dueBox(s);
|
|
260
|
+
const report = await sweep(s);
|
|
261
|
+
expect(report.failed).toBe(1);
|
|
262
|
+
expect(report.errors).toEqual([expect.objectContaining({ operation: `trash/delete-box (tbox:${boom})` })]);
|
|
263
|
+
expect(await exists(s, a)).toBe(false);
|
|
264
|
+
expect(await exists(s, b)).toBe(false);
|
|
265
|
+
// Its delete ran and threw: the transaction took the delete back with it.
|
|
266
|
+
expect(await exists(s, boom)).toBe(true);
|
|
267
|
+
expect(await (await stub(alice, s)).invoke('trash/peek-box', { boxId: boom })).toEqual({ state: 'trashed' });
|
|
268
|
+
});
|
|
269
|
+
it('a restored entity is not purged, and the next pass leaves it alone', async () => {
|
|
270
|
+
const s = await freshScope();
|
|
271
|
+
const restored = await dueBox(s);
|
|
272
|
+
await (await stub(alice, s)).invoke('trash/restore-box', { boxId: restored });
|
|
273
|
+
await sweep(s);
|
|
274
|
+
expect(await exists(s, restored)).toBe(true);
|
|
275
|
+
expect(await (await stub(alice, s)).invoke('trash/peek-box', { boxId: restored })).toEqual({ state: 'active' });
|
|
276
|
+
});
|
|
277
|
+
it('purge authority is never a caller\'s option: an invoke naming a cutoff is refused on every door', async () => {
|
|
278
|
+
const s = await freshScope();
|
|
279
|
+
const system = await host.getSystemScope(MODULE, t, s);
|
|
280
|
+
// A box binned a moment ago: a cutoff in the future would make it "due" if one were honoured.
|
|
281
|
+
const recent = await binnedBox(s);
|
|
282
|
+
const future = new Date(Date.now() + 365 * DAY).toISOString();
|
|
283
|
+
for (const [who, call] of [
|
|
284
|
+
['the system principal', system.invoke('trash/delete-box', { boxId: recent }, { purgeCutoff: future })],
|
|
285
|
+
['a person holding the key', (await stub(alice, s)).invoke('trash/other-delete', { boxId: recent }, { purgeCutoff: future })],
|
|
286
|
+
]) {
|
|
287
|
+
expect(errorCodeOf(await errOf(call)), who).toBe('validation_failed');
|
|
288
|
+
}
|
|
289
|
+
// Outside the sweep the system principal holds no purge authority at all, cutoff or not.
|
|
290
|
+
expect(errorCodeOf(await errOf(system.invoke('trash/delete-box', { boxId: recent })))).toBe('permission_denied');
|
|
291
|
+
expect(await exists(s, recent)).toBe(true);
|
|
292
|
+
// Twin: the sweep — the one path that may purge — leaves it too, because it is not due.
|
|
293
|
+
await sweep(s);
|
|
294
|
+
expect(await exists(s, recent)).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
it('purge authority is no argument of any door: a direct call claiming it is refused, and a direct sweep purges only what is due', async () => {
|
|
297
|
+
const direct = options.direct;
|
|
298
|
+
expect(direct, 'every adapter hands the suite its own door').toBeDefined();
|
|
299
|
+
const s = await freshScope();
|
|
300
|
+
const due = await dueBox(s);
|
|
301
|
+
const recent = await binnedBox(s);
|
|
302
|
+
// The purge-only key stays withheld: the claim is not something a caller can make.
|
|
303
|
+
expect(await direct.claimPurge(t, s, 'trash/delete-box', { boxId: due })).toBe('permission_denied');
|
|
304
|
+
expect(await direct.claimPurge(t, s, 'trash/delete-box', { boxId: recent })).toBe('permission_denied');
|
|
305
|
+
expect(await exists(s, due)).toBe(true);
|
|
306
|
+
// The sweep itself, run from outside the coordinator and ahead of any cadence: it decides
|
|
307
|
+
// what is due, so it purges the box past the horizon and leaves the one binned just now.
|
|
308
|
+
expect(await direct.runPurgeSweep(t, s, 'trash/delete-box')).toMatchObject({ purged: 1, skipped: 0 });
|
|
309
|
+
expect(await exists(s, due)).toBe(false);
|
|
310
|
+
expect(await exists(s, recent)).toBe(true);
|
|
311
|
+
expect(await direct.runPurgeSweep(t, s, 'trash/delete-box')).toMatchObject({ purged: 0, skipped: 0 });
|
|
312
|
+
expect(await exists(s, recent)).toBe(true);
|
|
313
|
+
// Only a purge schedule's operation can be swept.
|
|
314
|
+
await expect(direct.runPurgeSweep(t, s, 'trash/other-delete')).rejects.toThrow(/no purge schedule/);
|
|
315
|
+
});
|
|
316
|
+
describe("a direct sweep meets every gate the schedule run applies, from the scope's own state", () => {
|
|
317
|
+
const sweepNow = (s) => options.direct.runPurgeSweep(t, s, 'trash/delete-box');
|
|
318
|
+
it('under the kill switch it purges nothing; restored, it purges', async () => {
|
|
319
|
+
const s = await freshScope();
|
|
320
|
+
const id = await dueBox(s);
|
|
321
|
+
await host.admin.revokeFromSystem(staff, { moduleId: MODULE, node: { tenantId: t, scopeId: s }, reason: 'legal hold' });
|
|
322
|
+
expect(await sweepNow(s)).toMatchObject({ purged: 0, held: expect.stringMatching(/switched off/) });
|
|
323
|
+
await host.admin.restoreToSystem(staff, { moduleId: MODULE, node: { tenantId: t, scopeId: s }, reason: 'released' });
|
|
324
|
+
expect(await exists(s, id)).toBe(true);
|
|
325
|
+
expect(await sweepNow(s)).toMatchObject({ purged: 1 });
|
|
326
|
+
expect(await exists(s, id)).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
it('under a lifecycle hold it purges nothing; released, it purges', async () => {
|
|
329
|
+
const s = await freshScope();
|
|
330
|
+
const id = await dueBox(s);
|
|
331
|
+
await options.direct.holdLifecycle(t, s, true);
|
|
332
|
+
expect(await sweepNow(s)).toMatchObject({ purged: 0, held: expect.stringMatching(/not active/) });
|
|
333
|
+
await options.direct.holdLifecycle(t, s, false);
|
|
334
|
+
expect(await exists(s, id)).toBe(true);
|
|
335
|
+
expect(await sweepNow(s)).toMatchObject({ purged: 1 });
|
|
336
|
+
expect(await exists(s, id)).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
it('on a copy it purges nothing; the primary beside it, as due, purges', async () => {
|
|
339
|
+
const copy = await freshScope();
|
|
340
|
+
const primary = await freshScope();
|
|
341
|
+
const onCopy = await dueBox(copy);
|
|
342
|
+
const onPrimary = await dueBox(primary);
|
|
343
|
+
await options.direct.markCopy(t, copy);
|
|
344
|
+
expect(await sweepNow(copy)).toMatchObject({ purged: 0, held: expect.stringMatching(/copy/) });
|
|
345
|
+
expect(await exists(copy, onCopy)).toBe(true);
|
|
346
|
+
expect(await sweepNow(primary)).toMatchObject({ purged: 1 });
|
|
347
|
+
expect(await exists(primary, onPrimary)).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
it('naming another tenant it is refused, and purges nothing; naming its own, it purges', async () => {
|
|
350
|
+
const s = await freshScope();
|
|
351
|
+
const id = await dueBox(s);
|
|
352
|
+
const other = tenantId.parse(ulid());
|
|
353
|
+
await options.direct.recordTenant(t, s);
|
|
354
|
+
await expect(options.direct.runPurgeSweep(other, s, 'trash/delete-box')).rejects.toThrow();
|
|
355
|
+
expect(await exists(s, id)).toBe(true);
|
|
356
|
+
expect(await sweepNow(s)).toMatchObject({ purged: 1 });
|
|
357
|
+
expect(await exists(s, id)).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
it('on a scope that has not recorded its tenant, a foreign one meets the doors\' rule for an unknown pair', async () => {
|
|
360
|
+
const s = await freshScope();
|
|
361
|
+
const id = await dueBox(s);
|
|
362
|
+
const foreign = options.direct.runPurgeSweep(tenantId.parse(ulid()), s, 'trash/delete-box');
|
|
363
|
+
if (options.direct.unrecordedTenant === 'refused') {
|
|
364
|
+
await expect(foreign).rejects.toThrow();
|
|
365
|
+
expect(await exists(s, id)).toBe(true);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
expect(await foreign).toMatchObject({ purged: 1 });
|
|
369
|
+
expect(await exists(s, id)).toBe(false);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
it.runIf(options.direct?.runPurgeSweepNaming !== undefined)('the scope id it is handed routes nothing, and a wrong one fails closed', async () => {
|
|
373
|
+
const s = await freshScope();
|
|
374
|
+
const elsewhere = await freshScope();
|
|
375
|
+
const here = await dueBox(s);
|
|
376
|
+
const there = await dueBox(elsewhere);
|
|
377
|
+
// Run on `s` while naming `elsewhere`: `s`'s due box goes, and nothing of `elsewhere`'s.
|
|
378
|
+
// Run on `s` while naming `elsewhere`. Nothing of `elsewhere`'s is reached, and `s`'s own
|
|
379
|
+
// purge fails closed: its check asks for the grant on the node it was named, which this
|
|
380
|
+
// scope does not hold.
|
|
381
|
+
const pass = await options.direct.runPurgeSweepNaming(t, s, elsewhere, 'trash/delete-box');
|
|
382
|
+
expect(pass).toMatchObject({ purged: 0, errors: [expect.objectContaining({ entityId: here, error: expect.stringMatching(/permission denied/) })] });
|
|
383
|
+
expect(await exists(s, here)).toBe(true);
|
|
384
|
+
expect(await exists(elsewhere, there)).toBe(true);
|
|
385
|
+
// Twin: named rightly, it purges.
|
|
386
|
+
expect(await options.direct.runPurgeSweepNaming(t, s, s, 'trash/delete-box')).toMatchObject({ purged: 1 });
|
|
387
|
+
expect(await exists(s, here)).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
it('the purge grant runs ONLY the purge: the system principal cannot use its key anywhere else', async () => {
|
|
391
|
+
const s = await freshScope();
|
|
392
|
+
const system = await host.getSystemScope(MODULE, t, s);
|
|
393
|
+
const active = await addBox(s);
|
|
394
|
+
const due = await dueBox(s);
|
|
395
|
+
// Another operation checking the same key, and the purge operation called outside a purge.
|
|
396
|
+
expect(errorCodeOf(await errOf(system.invoke('trash/other-delete', { boxId: active })))).toBe('permission_denied');
|
|
397
|
+
expect(errorCodeOf(await errOf(system.invoke('trash/delete-box', { boxId: due })))).toBe('permission_denied');
|
|
398
|
+
expect(await exists(s, due)).toBe(true);
|
|
399
|
+
// Twin: a person holding the key runs both; the sweep purges.
|
|
400
|
+
await expect((await stub(alice, s)).invoke('trash/other-delete', { boxId: active })).resolves.toEqual({ ok: true });
|
|
401
|
+
await sweep(s);
|
|
402
|
+
expect(await exists(s, due)).toBe(false);
|
|
403
|
+
});
|
|
404
|
+
it('a full batch leaves the schedule due; the next pass finishes the bin, and then it waits its cadence', async () => {
|
|
405
|
+
const s = await freshScope();
|
|
406
|
+
const boxes = await dueBoxes(s, PURGE_BATCH + 1);
|
|
407
|
+
expect((await sweep(s)).fired).toBe(1);
|
|
408
|
+
expect(await alive(s, boxes)).toBe(1);
|
|
409
|
+
await sweep(s);
|
|
410
|
+
expect(await alive(s, boxes)).toBe(0);
|
|
411
|
+
// Nothing more was due, so the lap closed and the cadence row was written: a third pass is inside its window.
|
|
412
|
+
const late = await dueBox(s);
|
|
413
|
+
expect(await sweep(s)).toMatchObject({ fired: 0, skipped: 1 });
|
|
414
|
+
expect(await exists(s, late)).toBe(true);
|
|
415
|
+
}, 60_000);
|
|
416
|
+
it('a full batch that only fails waits its cadence — it does not spin on the same failures', async () => {
|
|
417
|
+
const s = await freshScope();
|
|
418
|
+
await dueBoxes(s, PURGE_BATCH, PURGE_BATCH);
|
|
419
|
+
const first = await sweep(s);
|
|
420
|
+
expect(first.failed).toBe(1);
|
|
421
|
+
expect(first.errors).toHaveLength(PURGE_BATCH);
|
|
422
|
+
// The batch was full and moved nothing, and nothing more is due, so the lap closed and the
|
|
423
|
+
// run is recorded: the next pass is inside its window rather than re-running the same failures.
|
|
424
|
+
expect(await sweep(s)).toMatchObject({ fired: 0, failed: 0, skipped: 1 });
|
|
425
|
+
}, 60_000);
|
|
426
|
+
it('a head that always fails does not starve the bin behind it (#2096): one lap reaches everything due, and the failures are reported every lap', async () => {
|
|
427
|
+
const s = await freshScope();
|
|
428
|
+
// The oldest PURGE_BATCH explode on every purge; the five behind them are ordinary.
|
|
429
|
+
const ids = await dueBoxes(s, PURGE_BATCH + 5, PURGE_BATCH);
|
|
430
|
+
const failing = ids.slice(0, PURGE_BATCH);
|
|
431
|
+
const behind = ids.slice(PURGE_BATCH);
|
|
432
|
+
// Pass 1 tries the failing head, reports every failure, and leaves the lap going.
|
|
433
|
+
const first = await sweep(s);
|
|
434
|
+
expect(first).toMatchObject({ fired: 0, failed: 1 });
|
|
435
|
+
expect(first.errors).toHaveLength(PURGE_BATCH);
|
|
436
|
+
// Mid-lap, the bin changes: the first box behind the cursor is restored, and a box is binned
|
|
437
|
+
// with a trash instant BEHIND the cursor — due, but older than where the walk stands.
|
|
438
|
+
await (await stub(alice, s)).invoke('trash/restore-box', { boxId: behind[0] });
|
|
439
|
+
const late = await binnedBox(s);
|
|
440
|
+
await backdate(s, 'trash_boxes', 'tbox', late, TBOX_PURGE_DAYS + 2);
|
|
441
|
+
// Pass 2 resumes after the cursor: the restored box is not reached, the rest are purged, and
|
|
442
|
+
// the lap closes — recorded failed, because pass 1 of it failed.
|
|
443
|
+
const second = await sweep(s);
|
|
444
|
+
expect(second).toMatchObject({ fired: 0, failed: 1 });
|
|
445
|
+
expect(second.errors).toEqual([]);
|
|
446
|
+
expect(await alive(s, behind.slice(1))).toBe(0);
|
|
447
|
+
expect(await exists(s, behind[0])).toBe(true);
|
|
448
|
+
expect(await alive(s, failing)).toBe(PURGE_BATCH);
|
|
449
|
+
// The one binned behind the cursor waits for the next lap.
|
|
450
|
+
expect(await exists(s, late)).toBe(true);
|
|
451
|
+
// No spin (#2087): the closed lap is the cadence's run, so the next pass is inside its window.
|
|
452
|
+
expect(await sweep(s)).toMatchObject({ fired: 0, failed: 0, skipped: 1 });
|
|
453
|
+
// The closed lap's row says `failed`: re-armed only if it does, the schedule is due again — a
|
|
454
|
+
// row that read `ok` would stay inside its window.
|
|
455
|
+
await rearmIfFailed(s);
|
|
456
|
+
// The next lap starts at the oldest: the late box goes, and the failing head is tried — and
|
|
457
|
+
// reported — again.
|
|
458
|
+
const next = await sweep(s);
|
|
459
|
+
expect(next.failed).toBe(1);
|
|
460
|
+
expect(next.errors).toHaveLength(PURGE_BATCH - 1);
|
|
461
|
+
expect(await exists(s, late)).toBe(false);
|
|
462
|
+
const closing = await sweep(s);
|
|
463
|
+
expect(closing.errors).toHaveLength(1);
|
|
464
|
+
expect(await alive(s, failing)).toBe(PURGE_BATCH);
|
|
465
|
+
}, 120_000);
|
|
466
|
+
it('twin (#2096): nothing fails, and the same bin is purged in two passes with no failure recorded', async () => {
|
|
467
|
+
const s = await freshScope();
|
|
468
|
+
const ids = await dueBoxes(s, PURGE_BATCH + 5);
|
|
469
|
+
expect(await sweep(s)).toMatchObject({ fired: 1, failed: 0 });
|
|
470
|
+
expect(await sweep(s)).toMatchObject({ fired: 1, failed: 0, errors: [] });
|
|
471
|
+
expect(await alive(s, ids)).toBe(0);
|
|
472
|
+
// The lap closed `ok`: the probe that re-arms a failed row finds none, so it stays inside its window.
|
|
473
|
+
await rearmIfFailed(s);
|
|
474
|
+
expect(await sweep(s)).toMatchObject({ fired: 0, skipped: 1 });
|
|
475
|
+
}, 60_000);
|
|
476
|
+
it('a cadence row from before the lap column (#2096) — a run recorded, no cursor — starts a lap at the oldest', async () => {
|
|
477
|
+
const s = await freshScope();
|
|
478
|
+
const id = await dueBox(s);
|
|
479
|
+
await raw(t, s, `INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status, purge_cursor)
|
|
480
|
+
VALUES ('schedule', 'trash/delete-box', '2020-01-01T00:00:00.000Z', 'ok', NULL)`);
|
|
481
|
+
expect(await sweep(s)).toMatchObject({ fired: 1 });
|
|
482
|
+
expect(await exists(s, id)).toBe(false);
|
|
483
|
+
});
|
|
484
|
+
it('a scope with the module switched off purges nothing, and purges once it is restored', async () => {
|
|
485
|
+
const s = await freshScope();
|
|
486
|
+
const id = await dueBox(s);
|
|
487
|
+
await host.admin.revokeFromSystem(staff, { moduleId: MODULE, node: { tenantId: t, scopeId: s }, reason: 'legal hold' });
|
|
488
|
+
expect(await sweep(s)).toMatchObject({ fired: 0, switchedOff: true });
|
|
489
|
+
expect(await exists(s, id)).toBe(true);
|
|
490
|
+
await host.admin.restoreToSystem(staff, { moduleId: MODULE, node: { tenantId: t, scopeId: s }, reason: 'released' });
|
|
491
|
+
await sweep(s);
|
|
492
|
+
expect(await exists(s, id)).toBe(false);
|
|
493
|
+
});
|
|
494
|
+
it('an export and import keeps the purge index: runtime DDL in the copy finds the bin intact, and the copy purges', async () => {
|
|
495
|
+
const s = await freshScope();
|
|
496
|
+
await dueBox(s);
|
|
497
|
+
const copy = scopeId.parse(ulid());
|
|
498
|
+
await host.importScope(staff, { tenantId: t, scopeId: copy }, await host.admin.exportScope(staff, t, s));
|
|
499
|
+
await host.admin.activateScope(staff, t, copy);
|
|
500
|
+
const there = await stub(alice, copy);
|
|
501
|
+
// The kernel's post-DDL check requires every derived object, the purge index among them.
|
|
502
|
+
await expect(there.invoke('trash/ddl', { table: `rt_${ulid().toLowerCase()}` })).resolves.toEqual({ ok: true });
|
|
503
|
+
});
|
|
504
|
+
it.runIf(options.platformSweep !== false)('a preview copy never purges; the primary beside it does, in the same platform sweep', async () => {
|
|
505
|
+
const primary = await freshScope();
|
|
506
|
+
const preview = await freshScope({ kind: 'preview' });
|
|
507
|
+
const onPrimary = await dueBox(primary);
|
|
508
|
+
const onPreview = await dueBox(preview);
|
|
509
|
+
await runPlatformSweep(host, {
|
|
510
|
+
actor: staff,
|
|
511
|
+
fetch: noFetch,
|
|
512
|
+
sweepers: {},
|
|
513
|
+
drainRetries: false,
|
|
514
|
+
gcSnapshots: false,
|
|
515
|
+
reconcileMigrations: false,
|
|
516
|
+
});
|
|
517
|
+
expect(await exists(primary, onPrimary)).toBe(false);
|
|
518
|
+
expect(await exists(preview, onPreview)).toBe(true);
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
// -- registration ----------------------------------------------------------------------
|
|
522
|
+
describe('registration', () => {
|
|
523
|
+
const variant = (patch) => {
|
|
524
|
+
const copy = patch({ ...trashMod, manifest: { ...trashMod.manifest, id: moduleId.parse(`@test/trash-${ulid().toLowerCase()}`) } });
|
|
525
|
+
return () => host.registerModule(copy);
|
|
526
|
+
};
|
|
527
|
+
it('refuses a module with a trashable entity whose operations are not a declared surface', () => {
|
|
528
|
+
expect(variant(({ operationInputs: _, ...m }) => m)).toThrow(/operationInputsOf/);
|
|
529
|
+
// A hand-built map is not one either: there is nothing to derive the targets from.
|
|
530
|
+
expect(variant((m) => ({ ...m, operationInputs: { ...m.operationInputs } }))).toThrow(/operationInputsOf/);
|
|
531
|
+
});
|
|
532
|
+
it('refuses a bound operation its declarations do not name — an omitted target cannot pass', () => {
|
|
533
|
+
expect(variant((m) => ({ ...m, operations: { ...m.operations, 'trash/sneak-rename': m.operations['trash/rename-box'] } }))).toThrow(/trash\/sneak-rename/);
|
|
534
|
+
});
|
|
535
|
+
it('refuses a purge schedule that runs anything but the purging operation', () => {
|
|
536
|
+
expect(variant((m) => ({
|
|
537
|
+
...m,
|
|
538
|
+
manifest: {
|
|
539
|
+
...m.manifest,
|
|
540
|
+
schedules: m.manifest.schedules.map((sch) => ({ ...sch, operation: 'trash/other-delete' })),
|
|
541
|
+
},
|
|
542
|
+
}))).toThrow(/purge schedule/);
|
|
543
|
+
});
|
|
544
|
+
it('refuses a declared surface that was edited or forged — only the map operationInputsOf returned carries one', () => {
|
|
545
|
+
// The pre-r2 carrier, a registered symbol, attached to a copy with every target dropped.
|
|
546
|
+
const forged = Object.defineProperty({ ...trashMod.operationInputs }, Symbol.for('substrat.declaredOperationSurface'), {
|
|
547
|
+
value: { operations: Object.keys(trashMod.operations), targets: {} },
|
|
548
|
+
});
|
|
549
|
+
expect(variant((m) => ({ ...m, operationInputs: forged }))).toThrow(/operationInputsOf/);
|
|
550
|
+
// The genuine surface cannot be emptied in place.
|
|
551
|
+
expect(() => {
|
|
552
|
+
declaredSurfaceOf(trashMod.operationInputs).targets = {};
|
|
553
|
+
}).toThrow(TypeError);
|
|
554
|
+
});
|
|
555
|
+
it('refuses a purge whose input is not strict — a passthrough object would keep an extra id', () => {
|
|
556
|
+
const loose = operationInputsOf({
|
|
557
|
+
...trashOperations,
|
|
558
|
+
'trash/delete-box': { ...trashOperations['trash/delete-box'], input: z.looseObject({ boxId: z.string() }) },
|
|
559
|
+
});
|
|
560
|
+
expect(variant((m) => ({ ...m, operationInputs: loose }))).toThrow(/strict/);
|
|
561
|
+
});
|
|
562
|
+
it('refuses a horizon with no purge schedule', () => {
|
|
563
|
+
expect(variant((m) => ({ ...m, manifest: { ...m.manifest, schedules: [] } }))).toThrow(/purge horizon/);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
//# sourceMappingURL=entity-trash-suite.js.map
|