@substrat-run/contract-tests 0.124.0 → 0.126.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 +21 -0
- package/dist/capability-suite.js.map +1 -1
- package/dist/impersonation-suite.d.ts.map +1 -1
- package/dist/impersonation-suite.js +13 -0
- package/dist/impersonation-suite.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +97 -5
- package/dist/modules.js.map +1 -1
- package/dist/permission-suite.d.ts +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +299 -0
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts +23 -0
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +301 -3
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/scope-repoint-suite.d.ts +19 -0
- package/dist/scope-repoint-suite.d.ts.map +1 -0
- package/dist/scope-repoint-suite.js +231 -0
- package/dist/scope-repoint-suite.js.map +1 -0
- package/package.json +4 -4
package/dist/scope-host-suite.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
2
|
import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
|
|
3
|
-
import { connectionId, dataSubjectId, domainEvent, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, } from '@substrat-run/contracts';
|
|
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
4
|
import { isSearchIndexTable, REDACTED_DELIVERY_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, ulid, } from '@substrat-run/kernel';
|
|
5
5
|
import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
|
|
6
6
|
/**
|
|
@@ -24,7 +24,7 @@ import { billedMod, contractTestBareOps, contractTestInitialModules, gateModMani
|
|
|
24
24
|
* `conflict`, and an adapter that answered the wrong one would send an operator to a button
|
|
25
25
|
* that is not there. Pass it only for that; a code is the contract everywhere else.
|
|
26
26
|
*/
|
|
27
|
-
const expectRefusal = async (p, code, message) => {
|
|
27
|
+
export const expectRefusal = async (p, code, message) => {
|
|
28
28
|
const err = await p.then(() => undefined, (e) => e);
|
|
29
29
|
expect(errorCodeOf(err)).toBe(code);
|
|
30
30
|
if (message !== undefined)
|
|
@@ -5044,7 +5044,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5044
5044
|
it('links declared entity relations, idempotently (K-16)', async () => {
|
|
5045
5045
|
const stub = await host.getScope(alice, t1, s1);
|
|
5046
5046
|
await stub.invoke('testmod/add', { id: 'i1', box: 'b1' });
|
|
5047
|
-
await stub.invoke('testmod/
|
|
5047
|
+
await stub.invoke('testmod/link-again', { id: 'i1', box: 'b1' }); // no duplicate
|
|
5048
5048
|
const tuples = await stub.invoke('testmod/read-tuples');
|
|
5049
5049
|
expect(tuples.filter((t) => t.subject === 'item:i1')).toEqual([
|
|
5050
5050
|
{ subject: 'item:i1', relation: 'parent', object: 'box:b1' },
|
|
@@ -5054,6 +5054,304 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5054
5054
|
const stub = await host.getScope(alice, t1, s1);
|
|
5055
5055
|
await expect(stub.invoke('testmod/link-undeclared')).rejects.toThrow(/undeclared entity relation/);
|
|
5056
5056
|
});
|
|
5057
|
+
/**
|
|
5058
|
+
* #1864: `ctx.relink(child, from, to)` — the inverse `link` never had. The tuple half
|
|
5059
|
+
* lives here (this suite runs the allow-all checker); that access follows the move is
|
|
5060
|
+
* proven against the tuple checker in the permission suite.
|
|
5061
|
+
*/
|
|
5062
|
+
describe('ctx.relink moves one parent edge, atomically (K-16, #1864)', () => {
|
|
5063
|
+
const item = (id) => ({ entityType: 'item', entityId: id });
|
|
5064
|
+
const box = (id) => ({ entityType: 'box', entityId: id });
|
|
5065
|
+
const folder = (id) => ({ entityType: 'folder', entityId: id });
|
|
5066
|
+
const stub = () => host.getScope(alice, t1, s1);
|
|
5067
|
+
const link = async (child, parent) => (await stub()).invoke('testmod/link', { child, parent });
|
|
5068
|
+
const move = async (child, from, to) => (await stub()).invoke('testmod/move', { child, from, to });
|
|
5069
|
+
const edges = async (child) => (await stub()).invoke('testmod/read-edges', {
|
|
5070
|
+
subject: `${child.entityType}:${child.entityId}`,
|
|
5071
|
+
});
|
|
5072
|
+
const relinked = async (child, type) => (await stub()).invoke('testmod/read-relinked', { entityId: child.entityId, type });
|
|
5073
|
+
it('tombstones the old edge, writes the new one, and records ONE entity.relinked', async () => {
|
|
5074
|
+
await link(item('rl1'), box('rb1'));
|
|
5075
|
+
await move(item('rl1'), box('rb1'), box('rb2'));
|
|
5076
|
+
// Exactly one live edge; the old one is kept as K-21 evidence, never deleted.
|
|
5077
|
+
expect(await edges(item('rl1'))).toEqual([
|
|
5078
|
+
{ object: 'box:rb1', revoked: true },
|
|
5079
|
+
{ object: 'box:rb2', revoked: false },
|
|
5080
|
+
]);
|
|
5081
|
+
const events = await relinked(item('rl1'));
|
|
5082
|
+
expect(events).toHaveLength(1);
|
|
5083
|
+
expect(events[0]).toMatchObject({
|
|
5084
|
+
entity_type: 'item',
|
|
5085
|
+
operation: 'testmod/move',
|
|
5086
|
+
payload: { child: item('rl1'), from: box('rb1'), to: box('rb2') },
|
|
5087
|
+
});
|
|
5088
|
+
expect(JSON.parse(events[0].actor)).toBe(alice); // a principal actor is its bare id
|
|
5089
|
+
});
|
|
5090
|
+
it.each([...KERNEL_AUTHORED_EVENT_TYPES])('ctx.emit refuses the kernel-authored type %s', async (type) => {
|
|
5091
|
+
await expectRefusal((await stub()).invoke('testmod/emit-type', { type }), 'validation_failed', /the kernel authors that event type itself/);
|
|
5092
|
+
});
|
|
5093
|
+
it.each([...KERNEL_AUTHORED_EVENT_TYPES])('emptying the exported list first does not unlock %s', async (type) => {
|
|
5094
|
+
await expectRefusal((await stub()).invoke('testmod/forge-after-mutating', { type }), 'validation_failed', /the kernel authors that event type itself/);
|
|
5095
|
+
});
|
|
5096
|
+
it('the twin: an ordinary type still emits — and relink still writes its own event', async () => {
|
|
5097
|
+
await (await stub()).invoke('testmod/emit-type', { type: 'testmod.ordinary' });
|
|
5098
|
+
await link(item('rl-twin'), box('rb1'));
|
|
5099
|
+
await move(item('rl-twin'), box('rb1'), box('rb2'));
|
|
5100
|
+
expect(await relinked(item('rl-twin'))).toHaveLength(1);
|
|
5101
|
+
});
|
|
5102
|
+
it('refuses a `from` that is not a live parent with conflict — and its twin moves', async () => {
|
|
5103
|
+
await link(item('rl2'), box('rb1'));
|
|
5104
|
+
// Never a parent.
|
|
5105
|
+
await expectRefusal(move(item('rl2'), box('rb9'), box('rb2')), 'conflict', /not a live parent/);
|
|
5106
|
+
// Once a parent, since moved away: a tombstone is not an edge to move.
|
|
5107
|
+
await move(item('rl2'), box('rb1'), box('rb2'));
|
|
5108
|
+
await expectRefusal(move(item('rl2'), box('rb1'), box('rb3')), 'conflict', /not a live parent/);
|
|
5109
|
+
expect(await edges(item('rl2'))).toEqual([
|
|
5110
|
+
{ object: 'box:rb1', revoked: true },
|
|
5111
|
+
{ object: 'box:rb2', revoked: false },
|
|
5112
|
+
]);
|
|
5113
|
+
expect(await relinked(item('rl2'))).toHaveLength(1);
|
|
5114
|
+
});
|
|
5115
|
+
/**
|
|
5116
|
+
* Parent edges `ctx.link` could never write today — an undeclared relation, an expiry —
|
|
5117
|
+
* planted the only way the platform can: a restore of an edited dump.
|
|
5118
|
+
*/
|
|
5119
|
+
const plant = async (rows) => {
|
|
5120
|
+
const dump = await host.admin.exportScope(staff, t1, s1);
|
|
5121
|
+
const table = dump.tables.find((t) => t.name === '_substrat_tuples');
|
|
5122
|
+
for (const r of rows) {
|
|
5123
|
+
const cells = { relation: 'parent', ...r };
|
|
5124
|
+
table.rows.push(table.columns.map((c) => cells[c] ?? null));
|
|
5125
|
+
}
|
|
5126
|
+
await host.restoreScope(staff, t1, s1, dump);
|
|
5127
|
+
};
|
|
5128
|
+
it('moves out of an edge whose relation is no longer declared — an edge that grants stays movable', async () => {
|
|
5129
|
+
// The walk expands every live parent edge, declared or not, so refusing this move
|
|
5130
|
+
// would trap whatever access the edge carries.
|
|
5131
|
+
await plant([{ subject: 'item:rl-ud', object: 'widget:w9' }]);
|
|
5132
|
+
await move(item('rl-ud'), { entityType: 'widget', entityId: 'w9' }, box('rb1'));
|
|
5133
|
+
expect(await edges(item('rl-ud'))).toEqual([
|
|
5134
|
+
{ object: 'box:rb1', revoked: false },
|
|
5135
|
+
{ object: 'widget:w9', revoked: true },
|
|
5136
|
+
]);
|
|
5137
|
+
});
|
|
5138
|
+
it('refuses an expired `from` with conflict — it is no longer a parent the walk sees', async () => {
|
|
5139
|
+
await plant([{ subject: 'item:rl-ex', object: 'box:rb1', expires_at: '2000-01-01T00:00:00.000Z' }]);
|
|
5140
|
+
await expectRefusal(move(item('rl-ex'), box('rb1'), box('rb2')), 'conflict', /not a live parent/);
|
|
5141
|
+
expect(await relinked(item('rl-ex'))).toEqual([]);
|
|
5142
|
+
});
|
|
5143
|
+
it('a revive is unconditional: link clears an expiry as well as a tombstone', async () => {
|
|
5144
|
+
await plant([
|
|
5145
|
+
// Tombstoned AND expired, the shape a restored dump can hold.
|
|
5146
|
+
{
|
|
5147
|
+
subject: 'item:rl-rv1',
|
|
5148
|
+
object: 'box:rb1',
|
|
5149
|
+
revoked_at: '2001-01-01T00:00:00.000Z',
|
|
5150
|
+
expires_at: '2000-01-01T00:00:00.000Z',
|
|
5151
|
+
},
|
|
5152
|
+
// Expired only: not revoked, and still not a parent the walk sees.
|
|
5153
|
+
{ subject: 'item:rl-rv2', object: 'box:rb1', expires_at: '2000-01-01T00:00:00.000Z' },
|
|
5154
|
+
]);
|
|
5155
|
+
await link(item('rl-rv1'), box('rb1'));
|
|
5156
|
+
await link(item('rl-rv2'), box('rb1'));
|
|
5157
|
+
const table = (await host.admin.exportScope(staff, t1, s1)).tables.find((t) => t.name === '_substrat_tuples');
|
|
5158
|
+
const col = (name) => table.columns.indexOf(name);
|
|
5159
|
+
for (const id of ['rl-rv1', 'rl-rv2']) {
|
|
5160
|
+
const row = table.rows.find((r) => r[col('subject')] === `item:${id}`);
|
|
5161
|
+
expect([row[col('revoked_at')], row[col('expires_at')]]).toEqual([null, null]);
|
|
5162
|
+
// Access resumed, so each revive is recorded; the proof that it is live is that a
|
|
5163
|
+
// relink from it now succeeds.
|
|
5164
|
+
expect(await relinked(item(id), 'entity.linked')).toHaveLength(1);
|
|
5165
|
+
await move(item(id), box('rb1'), box('rb2'));
|
|
5166
|
+
}
|
|
5167
|
+
});
|
|
5168
|
+
it('link and relink leave a live edge permanent: a future expiry is cleared, silently', async () => {
|
|
5169
|
+
const FUTURE = '2999-01-01T00:00:00.000Z';
|
|
5170
|
+
await plant([
|
|
5171
|
+
{ subject: 'item:rl-fx1', object: 'box:rb1', expires_at: FUTURE }, // link target
|
|
5172
|
+
{ subject: 'item:rl-fx2', object: 'box:rb1' }, // relink from…
|
|
5173
|
+
{ subject: 'item:rl-fx2', object: 'box:rb2', expires_at: FUTURE }, // …onto this
|
|
5174
|
+
{ subject: 'item:rl-fx3', object: 'box:rb1' }, // the twin: plain and live
|
|
5175
|
+
]);
|
|
5176
|
+
const expiry = async (subject, object) => {
|
|
5177
|
+
const table = (await host.admin.exportScope(staff, t1, s1)).tables.find((t) => t.name === '_substrat_tuples');
|
|
5178
|
+
const col = (name) => table.columns.indexOf(name);
|
|
5179
|
+
const row = table.rows.find((r) => r[col('subject')] === subject && r[col('object')] === object);
|
|
5180
|
+
return { expires: row[col('expires_at')], revoked: row[col('revoked_at')] };
|
|
5181
|
+
};
|
|
5182
|
+
await link(item('rl-fx1'), box('rb1'));
|
|
5183
|
+
expect(await expiry('item:rl-fx1', 'box:rb1')).toEqual({ expires: null, revoked: null });
|
|
5184
|
+
// Access never stopped, so this is not a revive: nothing is recorded.
|
|
5185
|
+
expect(await relinked(item('rl-fx1'), 'entity.linked')).toEqual([]);
|
|
5186
|
+
await move(item('rl-fx2'), box('rb1'), box('rb2'));
|
|
5187
|
+
expect(await expiry('item:rl-fx2', 'box:rb2')).toEqual({ expires: null, revoked: null });
|
|
5188
|
+
expect(await relinked(item('rl-fx2'), 'entity.linked')).toEqual([]);
|
|
5189
|
+
expect(await relinked(item('rl-fx2'))).toHaveLength(1); // the move itself
|
|
5190
|
+
await link(item('rl-fx3'), box('rb1'));
|
|
5191
|
+
expect(await expiry('item:rl-fx3', 'box:rb1')).toEqual({ expires: null, revoked: null });
|
|
5192
|
+
expect(await relinked(item('rl-fx3'), 'entity.linked')).toEqual([]);
|
|
5193
|
+
});
|
|
5194
|
+
it('refuses a `to` that link could not write — and leaves the old edge intact', async () => {
|
|
5195
|
+
await link(item('rl3'), box('rb1'));
|
|
5196
|
+
await expectRefusal(move(item('rl3'), box('rb1'), { entityType: 'widget', entityId: 'w1' }), // declared nowhere
|
|
5197
|
+
'validation_failed', /^ctx\.relink: undeclared entity relation: item → widget/);
|
|
5198
|
+
expect(await edges(item('rl3'))).toEqual([{ object: 'box:rb1', revoked: false }]);
|
|
5199
|
+
expect(await relinked(item('rl3'))).toEqual([]);
|
|
5200
|
+
});
|
|
5201
|
+
it('refuses a malformed or reserved ref at any of the three ends', async () => {
|
|
5202
|
+
await link(item('rl4'), box('rb1'));
|
|
5203
|
+
const bad = [
|
|
5204
|
+
{ entityType: 'bo:x', entityId: 'rb1' },
|
|
5205
|
+
{ entityType: 'scope', entityId: 'rb1' },
|
|
5206
|
+
{ entityType: 'box', entityId: 'r b1' },
|
|
5207
|
+
];
|
|
5208
|
+
for (const b of bad) {
|
|
5209
|
+
for (const args of [
|
|
5210
|
+
[b, box('rb1'), box('rb2')],
|
|
5211
|
+
[item('rl4'), b, box('rb2')],
|
|
5212
|
+
[item('rl4'), box('rb1'), b],
|
|
5213
|
+
]) {
|
|
5214
|
+
await expectRefusal(move(...args), 'validation_failed', /^ctx\.relink: malformed entity ref/);
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
expect(await edges(item('rl4'))).toEqual([{ object: 'box:rb1', revoked: false }]);
|
|
5218
|
+
});
|
|
5219
|
+
it('from === to is a no-op: nothing written, nothing emitted', async () => {
|
|
5220
|
+
await link(item('rl5'), box('rb1'));
|
|
5221
|
+
await move(item('rl5'), box('rb1'), box('rb1'));
|
|
5222
|
+
expect(await edges(item('rl5'))).toEqual([{ object: 'box:rb1', revoked: false }]);
|
|
5223
|
+
expect(await relinked(item('rl5'))).toEqual([]);
|
|
5224
|
+
});
|
|
5225
|
+
it('moves only `from` on a multi-parent entity, including into a parent it already has', async () => {
|
|
5226
|
+
await link(item('rl6'), box('rb1'));
|
|
5227
|
+
await link(item('rl6'), box('rb2'));
|
|
5228
|
+
await move(item('rl6'), box('rb1'), box('rb3'));
|
|
5229
|
+
expect(await edges(item('rl6'))).toEqual([
|
|
5230
|
+
{ object: 'box:rb1', revoked: true },
|
|
5231
|
+
{ object: 'box:rb2', revoked: false },
|
|
5232
|
+
{ object: 'box:rb3', revoked: false },
|
|
5233
|
+
]);
|
|
5234
|
+
// Into a parent it already has: the move still happened, so it is still recorded.
|
|
5235
|
+
await move(item('rl6'), box('rb3'), box('rb2'));
|
|
5236
|
+
expect(await edges(item('rl6'))).toEqual([
|
|
5237
|
+
{ object: 'box:rb1', revoked: true },
|
|
5238
|
+
{ object: 'box:rb2', revoked: false },
|
|
5239
|
+
{ object: 'box:rb3', revoked: true },
|
|
5240
|
+
]);
|
|
5241
|
+
expect(await relinked(item('rl6'))).toHaveLength(2);
|
|
5242
|
+
});
|
|
5243
|
+
it('link revives an edge relink moved away from — INSERT OR IGNORE would have kept it dead', async () => {
|
|
5244
|
+
await link(item('rl7'), box('rb1'));
|
|
5245
|
+
// The twin first: a first-time link records nothing, as it never has.
|
|
5246
|
+
expect(await relinked(item('rl7'), 'entity.linked')).toEqual([]);
|
|
5247
|
+
await move(item('rl7'), box('rb1'), box('rb2'));
|
|
5248
|
+
await link(item('rl7'), box('rb1'));
|
|
5249
|
+
// A revive resumes access a move stopped, so it is recorded: once, on the child.
|
|
5250
|
+
const revives = await relinked(item('rl7'), 'entity.linked');
|
|
5251
|
+
expect(revives).toHaveLength(1);
|
|
5252
|
+
expect(revives[0]).toMatchObject({
|
|
5253
|
+
entity_type: 'item',
|
|
5254
|
+
operation: 'testmod/link',
|
|
5255
|
+
payload: { child: item('rl7'), parent: box('rb1') },
|
|
5256
|
+
});
|
|
5257
|
+
// Linking a live edge again is still a no-op: no second event.
|
|
5258
|
+
await link(item('rl7'), box('rb1'));
|
|
5259
|
+
expect(await relinked(item('rl7'), 'entity.linked')).toHaveLength(1);
|
|
5260
|
+
expect(await edges(item('rl7'))).toEqual([
|
|
5261
|
+
{ object: 'box:rb1', revoked: false },
|
|
5262
|
+
{ object: 'box:rb2', revoked: false },
|
|
5263
|
+
]);
|
|
5264
|
+
// …and a move back onto a tombstone revives it the same way.
|
|
5265
|
+
await move(item('rl7'), box('rb2'), box('rb4'));
|
|
5266
|
+
await move(item('rl7'), box('rb4'), box('rb2'));
|
|
5267
|
+
// That revive is part of a move, which entity.relinked already records.
|
|
5268
|
+
expect(await relinked(item('rl7'), 'entity.linked')).toHaveLength(1);
|
|
5269
|
+
expect(await edges(item('rl7'))).toEqual([
|
|
5270
|
+
{ object: 'box:rb1', revoked: false },
|
|
5271
|
+
{ object: 'box:rb2', revoked: false },
|
|
5272
|
+
{ object: 'box:rb4', revoked: true },
|
|
5273
|
+
]);
|
|
5274
|
+
});
|
|
5275
|
+
it('an operation that throws after relinking leaves the old edge live and emits nothing', async () => {
|
|
5276
|
+
await link(item('rl8'), box('rb1'));
|
|
5277
|
+
await expect((await stub()).invoke('testmod/move-then-throw', {
|
|
5278
|
+
child: item('rl8'),
|
|
5279
|
+
from: box('rb1'),
|
|
5280
|
+
to: box('rb2'),
|
|
5281
|
+
})).rejects.toThrow(/boom after relink/);
|
|
5282
|
+
expect(await edges(item('rl8'))).toEqual([{ object: 'box:rb1', revoked: false }]);
|
|
5283
|
+
expect(await relinked(item('rl8'))).toEqual([]);
|
|
5284
|
+
});
|
|
5285
|
+
it('a relink inside a caught ctx.atomic is undone with it (#770)', async () => {
|
|
5286
|
+
await link(item('rl9'), box('rb1'));
|
|
5287
|
+
await expect((await stub()).invoke('testmod/move-in-caught-atomic', {
|
|
5288
|
+
child: item('rl9'),
|
|
5289
|
+
from: box('rb1'),
|
|
5290
|
+
to: box('rb2'),
|
|
5291
|
+
})).resolves.toEqual({ caught: 'inner boom' });
|
|
5292
|
+
expect(await edges(item('rl9'))).toEqual([{ object: 'box:rb1', revoked: false }]);
|
|
5293
|
+
expect(await relinked(item('rl9'))).toEqual([]);
|
|
5294
|
+
});
|
|
5295
|
+
describe('cycles', () => {
|
|
5296
|
+
// fr ← f1 ← f2 ← f3, and a sibling subtree fr ← f4.
|
|
5297
|
+
beforeAll(async () => {
|
|
5298
|
+
await link(folder('f1'), folder('fr'));
|
|
5299
|
+
await link(folder('f2'), folder('f1'));
|
|
5300
|
+
await link(folder('f3'), folder('f2'));
|
|
5301
|
+
await link(folder('f4'), folder('fr'));
|
|
5302
|
+
});
|
|
5303
|
+
it('refuses a move beneath its own descendant, or onto itself', async () => {
|
|
5304
|
+
await expectRefusal(move(folder('f1'), folder('fr'), folder('f3')), 'validation_failed', /its own ancestor/);
|
|
5305
|
+
await expectRefusal(move(folder('f1'), folder('fr'), folder('f1')), 'validation_failed', /its own ancestor/);
|
|
5306
|
+
expect(await edges(folder('f1'))).toEqual([{ object: 'folder:fr', revoked: false }]);
|
|
5307
|
+
});
|
|
5308
|
+
it('the twin: a move into a sibling subtree is allowed', async () => {
|
|
5309
|
+
await move(folder('f2'), folder('f1'), folder('f4'));
|
|
5310
|
+
expect(await edges(folder('f2'))).toEqual([
|
|
5311
|
+
{ object: 'folder:f1', revoked: true },
|
|
5312
|
+
{ object: 'folder:f4', revoked: false },
|
|
5313
|
+
]);
|
|
5314
|
+
// …and the subtree came with it: f1 is no longer above f3, so f1 may now move
|
|
5315
|
+
// beneath f3 without a cycle.
|
|
5316
|
+
await move(folder('f1'), folder('fr'), folder('f3'));
|
|
5317
|
+
});
|
|
5318
|
+
it('terminates on a graph that is already cyclic', async () => {
|
|
5319
|
+
// `link` does not refuse a cycle (a follow-up), so one can exist: c1 ⇄ c2.
|
|
5320
|
+
await link(folder('c1'), folder('c2'));
|
|
5321
|
+
await link(folder('c2'), folder('c1'));
|
|
5322
|
+
await link(folder('cx'), folder('fr'));
|
|
5323
|
+
await move(folder('cx'), folder('fr'), folder('c1'));
|
|
5324
|
+
expect(await edges(folder('cx'))).toEqual([
|
|
5325
|
+
{ object: 'folder:c1', revoked: false },
|
|
5326
|
+
{ object: 'folder:fr', revoked: true },
|
|
5327
|
+
]);
|
|
5328
|
+
// Moving a member of the cycle beneath itself is still refused.
|
|
5329
|
+
await expectRefusal(move(folder('c1'), folder('c2'), folder('cx')), 'validation_failed', /its own ancestor/);
|
|
5330
|
+
});
|
|
5331
|
+
});
|
|
5332
|
+
it('a tombstone survives export, restore and snapshot — never resurrected', async () => {
|
|
5333
|
+
await link(item('rl10'), box('rb1'));
|
|
5334
|
+
await move(item('rl10'), box('rb1'), box('rb2'));
|
|
5335
|
+
const tupleRows = (dump) => {
|
|
5336
|
+
const table = dump.tables.find((t) => t.name === '_substrat_tuples');
|
|
5337
|
+
const col = (name) => table.columns.indexOf(name);
|
|
5338
|
+
return table.rows
|
|
5339
|
+
.filter((r) => r[col('subject')] === 'item:rl10')
|
|
5340
|
+
.map((r) => ({ object: r[col('object')], revoked: r[col('revoked_at')] !== null }))
|
|
5341
|
+
.sort((a, b) => String(a.object).localeCompare(String(b.object)));
|
|
5342
|
+
};
|
|
5343
|
+
const expected = [
|
|
5344
|
+
{ object: 'box:rb1', revoked: true },
|
|
5345
|
+
{ object: 'box:rb2', revoked: false },
|
|
5346
|
+
];
|
|
5347
|
+
const backup = await host.admin.exportScope(staff, t1, s1);
|
|
5348
|
+
expect(tupleRows(backup)).toEqual(expected);
|
|
5349
|
+
await host.restoreScope(staff, t1, s1, backup);
|
|
5350
|
+
expect(await edges(item('rl10'))).toEqual(expected);
|
|
5351
|
+
const snapId = await host.snapshotScope(staff, t1, s1);
|
|
5352
|
+
expect(tupleRows(await host.admin.exportScope(staff, t1, snapId))).toEqual(expected);
|
|
5353
|
+
});
|
|
5354
|
+
});
|
|
5057
5355
|
it('dispatches events to consumers, cascading, exactly once per (event, consumer)', async () => {
|
|
5058
5356
|
const stub = await host.getScope(alice, t1, s1);
|
|
5059
5357
|
await stub.invoke('flow/produce');
|