@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.
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { coverage, moduleId, permissionKey, platformActorId, orgId, principalId, scopeId, tenantId, delegatedReadParams, moduleManifest, PERMISSION_KEY_MAX_LENGTH, } from '@substrat-run/contracts';
3
3
  import { ulid } from '@substrat-run/kernel';
4
+ import { expectRefusal } from './scope-host-suite.js';
4
5
  import { permMod, permModManifest } from './modules.js';
5
6
  const PERM_USE = permissionKey.parse('perm:use');
6
7
  const PERM_READ = permissionKey.parse('perm:read');
@@ -234,6 +235,248 @@ export function permissionContractSuite(adapterName, makeFixture) {
234
235
  // No node-level access: the narrow grant does not widen.
235
236
  await expect(probe(carol, s1, PERM_READ)).resolves.toMatchObject({ allowed: false });
236
237
  });
238
+ /**
239
+ * #1864. `ctx.link` had no inverse, so a "move" added a second parent and a grant above
240
+ * the old one kept reaching the entity forever. `ctx.relink` replaces the edge: access
241
+ * follows the move, over the tuple checker, on both adapters.
242
+ */
243
+ describe('access follows a relink (#1864)', () => {
244
+ const ivy = principalId.parse(ulid()); // a grant on the OLD parent
245
+ const jon = principalId.parse(ulid()); // a grant on the NEW parent
246
+ const kit = principalId.parse(ulid()); // a grant at the top of a folder chain
247
+ const lee = principalId.parse(ulid()); // two levels further down that chain
248
+ const item = (id) => ({ entityType: 'item', entityId: id });
249
+ const box = (id) => ({ entityType: 'box', entityId: id });
250
+ const folder = (id) => ({ entityType: 'folder', entityId: id });
251
+ const link = async (child, parent) => (await host.getScope(alice, t1, s1)).invoke('perm/link', { child, parent });
252
+ const relink = async (child, from, to) => (await host.getScope(alice, t1, s1)).invoke('perm/relink', { child, from, to });
253
+ const grantOn = (principal, entity) => host.admin.grant(staff, {
254
+ principalId: principal,
255
+ permission: PERM_READ,
256
+ node: { tenantId: t1, scopeId: s1 },
257
+ entity,
258
+ grantedBy: alice,
259
+ });
260
+ beforeAll(async () => {
261
+ await grantOn(ivy, box('mv-old'));
262
+ await grantOn(jon, box('mv-new'));
263
+ // A chain five deep: fd1 ← fd2 ← fd3 ← fd4 ← fd5.
264
+ for (let i = 2; i <= 5; i++)
265
+ await link(folder(`fd${i}`), folder(`fd${i - 1}`));
266
+ await grantOn(kit, folder('fd1'));
267
+ await grantOn(lee, folder('fd3'));
268
+ });
269
+ it('the grant above the old parent stops reaching; the one above the new parent starts', async () => {
270
+ await link(item('mv1'), box('mv-old'));
271
+ await expect(probe(ivy, s1, PERM_READ, item('mv1'))).resolves.toMatchObject({ allowed: true });
272
+ await expect(probe(jon, s1, PERM_READ, item('mv1'))).resolves.toMatchObject({ allowed: false });
273
+ await relink(item('mv1'), box('mv-old'), box('mv-new'));
274
+ await expect(probe(ivy, s1, PERM_READ, item('mv1'))).resolves.toMatchObject({ allowed: false });
275
+ const d = await probe(jon, s1, PERM_READ, item('mv1'));
276
+ expect(d.allowed).toBe(true);
277
+ expect(JSON.stringify(d.proof)).toContain('"box:mv-new"');
278
+ expect(JSON.stringify(d.proof)).not.toContain('"box:mv-old"');
279
+ });
280
+ it('a refused relink moves nothing: the old grant still reaches', async () => {
281
+ await link(item('mv2'), box('mv-old'));
282
+ await expectRefusal(relink(item('mv2'), box('mv-elsewhere'), box('mv-new')), 'conflict');
283
+ await expectRefusal(relink(item('mv2'), box('mv-old'), { entityType: 'widget', entityId: 'w1' }), // declared nowhere
284
+ 'validation_failed');
285
+ await expect(probe(ivy, s1, PERM_READ, item('mv2'))).resolves.toMatchObject({ allowed: true });
286
+ await expect(probe(jon, s1, PERM_READ, item('mv2'))).resolves.toMatchObject({ allowed: false });
287
+ });
288
+ it('link after a relink away revives the edge, and the old access returns', async () => {
289
+ await link(item('mv3'), box('mv-old'));
290
+ await relink(item('mv3'), box('mv-old'), box('mv-new'));
291
+ await expect(probe(ivy, s1, PERM_READ, item('mv3'))).resolves.toMatchObject({ allowed: false });
292
+ await link(item('mv3'), box('mv-old'));
293
+ await expect(probe(ivy, s1, PERM_READ, item('mv3'))).resolves.toMatchObject({ allowed: true });
294
+ });
295
+ it('depth follows the new chain: four levels up reaches, five does not', async () => {
296
+ // item → fd4 → fd3 → fd2 → fd1: fd1 is four edges up.
297
+ await link(item('mv4'), box('mv-old'));
298
+ await relink(item('mv4'), box('mv-old'), folder('fd4'));
299
+ await expect(probe(kit, s1, PERM_READ, item('mv4'))).resolves.toMatchObject({ allowed: true });
300
+ // item → fd5 → … → fd1: five edges up, past the walk's depth.
301
+ await relink(item('mv4'), folder('fd4'), folder('fd5'));
302
+ await expect(probe(kit, s1, PERM_READ, item('mv4'))).resolves.toMatchObject({ allowed: false });
303
+ // The twin: fd3 is three edges up the new chain, so its grant reaches.
304
+ await expect(probe(lee, s1, PERM_READ, item('mv4'))).resolves.toMatchObject({ allowed: true });
305
+ });
306
+ });
307
+ /**
308
+ * #1856. Module entity types are camelCase by convention, and the walk used to throw
309
+ * on every tuple whose namespace carried an upper-case letter — so `ctx.link` wrote
310
+ * an edge that `ctx.check` could not read back. The write verbs now hold refs to the
311
+ * walk's own grammar, so the two cannot part company again.
312
+ */
313
+ describe('a camelCase entity type walks like any other (#1856)', () => {
314
+ const fern = principalId.parse(ulid()); // grants on the two parents
315
+ const gus = principalId.parse(ulid()); // no grant: the twin
316
+ const turn = (id) => ({ entityType: 'aiTurn', entityId: id });
317
+ const box = (id) => ({ entityType: 'box', entityId: id });
318
+ const thread = (id) => ({ entityType: 'chatThread', entityId: id });
319
+ const link = async (child, parent) => (await host.getScope(alice, t1, s1)).invoke('perm/link', { child, parent });
320
+ beforeAll(async () => {
321
+ await link(turn('t1'), box('b9'));
322
+ await link(turn('t2'), thread('c1'));
323
+ for (const entity of [box('b9'), thread('c1')]) {
324
+ await host.admin.grant(staff, {
325
+ principalId: fern,
326
+ permission: PERM_READ,
327
+ node: { tenantId: t1, scopeId: s1 },
328
+ entity,
329
+ grantedBy: alice,
330
+ });
331
+ }
332
+ });
333
+ it('a grant on the parent reaches a camelCase child, and the proof shows the edge', async () => {
334
+ const d = await probe(fern, s1, PERM_READ, turn('t1'));
335
+ expect(d.allowed).toBe(true);
336
+ expect(JSON.stringify(d.proof)).toContain('"aiTurn:t1"');
337
+ expect(JSON.stringify(d.proof)).toContain('"box:b9"');
338
+ });
339
+ it('...and through a camelCase parent too', async () => {
340
+ const d = await probe(fern, s1, PERM_READ, turn('t2'));
341
+ expect(d.allowed).toBe(true);
342
+ expect(JSON.stringify(d.proof)).toContain('"chatThread:c1"');
343
+ });
344
+ it('the twin with no grant is DENIED — an answer, not a throw', async () => {
345
+ await expect(probe(gus, s1, PERM_READ, turn('t1'))).resolves.toMatchObject({ allowed: false });
346
+ await expect(probe(gus, s1, PERM_READ, turn('t2'))).resolves.toMatchObject({ allowed: false });
347
+ });
348
+ it('...and the grant reaches its own subtree only', async () => {
349
+ await link(turn('t3'), box('b10'));
350
+ await expect(probe(fern, s1, PERM_READ, turn('t3'))).resolves.toMatchObject({ allowed: false });
351
+ });
352
+ it('ctx.grant narrows onto a camelCase entity, and ctx.revoke withdraws it', async () => {
353
+ const stub = await host.getScope(alice, t1, s1);
354
+ await stub.invoke('perm/share', { principal: gus, permission: PERM_USE, entity: turn('t4') });
355
+ await expect(probe(gus, s1, PERM_USE, turn('t4'))).resolves.toMatchObject({ allowed: true });
356
+ await stub.invoke('perm/unshare', { principal: gus, permission: PERM_USE, entity: turn('t4') });
357
+ await expect(probe(gus, s1, PERM_USE, turn('t4'))).resolves.toMatchObject({ allowed: false });
358
+ });
359
+ /**
360
+ * Refs the walk could never read back — refused where they are written, with
361
+ * `validation_failed`, instead of being stored and throwing out of a later check.
362
+ * Every one was refused by the walk's grammar before #1856 and still is; the only
363
+ * write-side addition is a colon in the TYPE, which the walk would have parsed as a
364
+ * different ref (`a:b` + `c` reads back as `a` + `b:c`).
365
+ */
366
+ const MALFORMED = [
367
+ ['a space in the type', { entityType: 'ai Turn', entityId: 't1' }],
368
+ ['a colon in the type', { entityType: 'ai:Turn', entityId: 't1' }],
369
+ ['a dot in the type', { entityType: 'ai.turn', entityId: 't1' }],
370
+ ['a non-ASCII type', { entityType: 'aíTurn', entityId: 't1' }],
371
+ ['an empty type', { entityType: '', entityId: 't1' }],
372
+ ['an empty id', { entityType: 'aiTurn', entityId: '' }],
373
+ ['a space in the id', { entityType: 'aiTurn', entityId: 't 1' }],
374
+ ['a newline in the id', { entityType: 'aiTurn', entityId: 't1\nprincipal:x' }],
375
+ ['a no-break space in the id', { entityType: 'aiTurn', entityId: 't\u00a01' }],
376
+ ];
377
+ it.each(MALFORMED)('ctx.link refuses %s, as the child and as the parent', async (_what, bad) => {
378
+ for (const [child, parent] of [
379
+ [bad, box('b9')],
380
+ [turn('t1'), bad],
381
+ ]) {
382
+ await expectRefusal(link(child, parent), 'validation_failed', /^ctx\.link: malformed entity ref/);
383
+ }
384
+ });
385
+ it('ctx.grant refuses the same refs, though the caller holds the key everywhere', async () => {
386
+ const stub = await host.getScope(alice, t1, s1);
387
+ for (const [, bad] of MALFORMED) {
388
+ await expectRefusal(stub.invoke('perm/share', { principal: gus, permission: PERM_USE, entity: bad }), 'validation_failed', /^ctx\.grant: malformed entity ref/);
389
+ }
390
+ });
391
+ it('HostAdmin.grant narrowed onto an entity refuses a ref the walk could not read back', async () => {
392
+ await expectRefusal(host.admin.grant(staff, {
393
+ principalId: gus,
394
+ permission: PERM_READ,
395
+ node: { tenantId: t1, scopeId: s1 },
396
+ entity: { entityType: 'ai:Turn', entityId: 't1' },
397
+ grantedBy: alice,
398
+ }), 'validation_failed');
399
+ });
400
+ /**
401
+ * A kernel namespace is not an entity type, in any case: some spine SQL matches a
402
+ * namespace with LIKE, which ignores ASCII case. Refused on both link ends and on
403
+ * grant, on both adapters.
404
+ */
405
+ const RESERVED = ['principal', 'org', 'tenant', 'scope', 'role', 'connection', 'capability', 'system', 'vertical'];
406
+ const spellings = (name) => [name, name.toUpperCase(), name[0].toUpperCase() + name.slice(1)];
407
+ it.each(RESERVED)('refuses %s as an entity type in any case, on link and on grant', async (name) => {
408
+ const stub = await host.getScope(alice, t1, s1);
409
+ for (const entityType of spellings(name)) {
410
+ const bad = { entityType, entityId: 't1' };
411
+ await expectRefusal(link(bad, box('b9')), 'validation_failed', /^ctx\.link: malformed entity ref/);
412
+ await expectRefusal(link(turn('t1'), bad), 'validation_failed', /^ctx\.link: malformed entity ref/);
413
+ await expectRefusal(stub.invoke('perm/share', { principal: gus, permission: PERM_USE, entity: bad }), 'validation_failed', /^ctx\.grant: malformed entity ref/);
414
+ }
415
+ });
416
+ it('...while a type that only contains one is linked, walked and granted like any other', async () => {
417
+ const item = { entityType: 'scopeItem', entityId: 'x1' };
418
+ await link(item, box('b9'));
419
+ await expect(probe(fern, s1, PERM_READ, item)).resolves.toMatchObject({ allowed: true });
420
+ await expect(probe(gus, s1, PERM_READ, item)).resolves.toMatchObject({ allowed: false });
421
+ const stub = await host.getScope(alice, t1, s1);
422
+ await stub.invoke('perm/share', { principal: gus, permission: PERM_USE, entity: item });
423
+ await expect(probe(gus, s1, PERM_USE, item)).resolves.toMatchObject({ allowed: true });
424
+ });
425
+ it('HostAdmin.grantToOrg narrowed onto an entity refuses the same refs; its twin is granted', async () => {
426
+ const node = { tenantId: t1, scopeId: s1 };
427
+ for (const bad of [
428
+ { entityType: 'ai:Turn', entityId: 't1' },
429
+ { entityType: 'aiTurn', entityId: 't 1' },
430
+ { entityType: 'Scope', entityId: s1 },
431
+ ]) {
432
+ await expectRefusal(host.admin.grantToOrg(staff, acme, PERM_USE, node, bad), 'validation_failed');
433
+ }
434
+ // erin reaches acme's grants through membership (rule 4).
435
+ await expect(probe(erin, s1, PERM_USE, turn('t5'))).resolves.toMatchObject({ allowed: false });
436
+ await host.admin.grantToOrg(staff, acme, PERM_USE, node, turn('t5'));
437
+ await expect(probe(erin, s1, PERM_USE, turn('t5'))).resolves.toMatchObject({ allowed: true });
438
+ });
439
+ it('ctx.revoke does NOT refuse on grammar: it writes nothing to read back, and must stay able to remove an old grant', async () => {
440
+ const stub = await host.getScope(alice, t1, s1);
441
+ await expect(stub.invoke('perm/unshare', {
442
+ principal: gus,
443
+ permission: PERM_USE,
444
+ entity: { entityType: 'ai Turn', entityId: 't1' },
445
+ })).resolves.toEqual({ revoked: true });
446
+ });
447
+ /**
448
+ * The positive twin of the pin above: a malformed grant that is ALREADY stored is
449
+ * actually removed by `ctx.revoke`. No write verb can store one any more, so it is
450
+ * planted the way stored data from before this change would arrive: a restore, which
451
+ * replays spine rows verbatim.
452
+ *
453
+ * Before the revoke, a check that reaches the stored grant throws, because the walk
454
+ * refuses to parse `item:t 1`. That was also true before #1856, and it is pinned here
455
+ * to show the stored row is read exactly as it was. After the revoke the same check
456
+ * denies.
457
+ */
458
+ it('ctx.revoke removes a malformed grant restored from before, and the check then denies', async () => {
459
+ const s3 = scopeId.parse(ulid());
460
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s3, vertical: 'perm-vertical' });
461
+ await host.admin.activateScope(staff, t1, s3);
462
+ const backup = await host.admin.exportScope(staff, t1, s3);
463
+ const tuples = backup.tables.find((t) => t.name === '_substrat_tuples');
464
+ // Without the table in the export this would plant nothing and pass vacuously.
465
+ expect(tuples).toBeDefined();
466
+ const planted = { subject: `principal:${gus}`, relation: `granted:${PERM_USE}`, object: 'item:t 1' };
467
+ await host.restoreScope(staff, t1, s3, {
468
+ ...backup,
469
+ tables: backup.tables.map((t) => t === tuples
470
+ ? { ...t, rows: [...t.rows, t.columns.map((c) => planted[c] ?? null)] }
471
+ : t),
472
+ });
473
+ const malformed = { entityType: 'item', entityId: 't 1' };
474
+ await expect(probe(gus, s3, PERM_USE, malformed)).rejects.toThrow();
475
+ const stub = await host.getScope(alice, t1, s3);
476
+ await expect(stub.invoke('perm/unshare', { principal: gus, permission: PERM_USE, entity: malformed })).resolves.toEqual({ revoked: true });
477
+ await expect(probe(gus, s3, PERM_USE, malformed)).resolves.toMatchObject({ allowed: false });
478
+ });
479
+ });
237
480
  it('expired grants are dead', async () => {
238
481
  await expect(probe(dave, s1, PERM_READ)).resolves.toMatchObject({ allowed: false });
239
482
  });
@@ -264,6 +507,62 @@ export function permissionContractSuite(adapterName, makeFixture) {
264
507
  const row = await lastActed();
265
508
  expect(JSON.parse(row.authorization)).toEqual([{ permission: PERM_READ, grant: 'box:b1' }]);
266
509
  });
510
+ /**
511
+ * #1856: the event envelope records the grant's object in `authorization[].grant`, and
512
+ * held it to its own copy of the old grammar. An emit authorized by a grant on a
513
+ * camelCase entity then failed its own `domainEvent` parse, so the operation failed.
514
+ */
515
+ describe('an emit authorized through a camelCase entity grant (K-34, #1856)', () => {
516
+ const hal = principalId.parse(ulid());
517
+ const node = { tenantId: t1, scopeId: s1 };
518
+ beforeAll(async () => {
519
+ await host.admin.grant(staff, {
520
+ principalId: hal,
521
+ permission: PERM_READ,
522
+ node,
523
+ entity: { entityType: 'aiTurn', entityId: 'ax1' },
524
+ grantedBy: alice,
525
+ });
526
+ await host.admin.grant(staff, {
527
+ principalId: hal,
528
+ permission: PERM_READ,
529
+ node,
530
+ entity: { entityType: 'chatThread', entityId: 'ct9' },
531
+ grantedBy: alice,
532
+ });
533
+ await (await host.getScope(alice, t1, s1)).invoke('perm/link', {
534
+ child: { entityType: 'aiTurn', entityId: 'ax2' },
535
+ parent: { entityType: 'chatThread', entityId: 'ct9' },
536
+ });
537
+ });
538
+ it('a grant directly on the entity: the event records aiTurn:ax1', async () => {
539
+ const stub = await host.getScope(hal, t1, s1);
540
+ await stub.invoke('perm/authorized-emit', {
541
+ permission: PERM_READ,
542
+ entity: { entityType: 'aiTurn', entityId: 'ax1' },
543
+ });
544
+ const row = await lastActed();
545
+ expect(JSON.parse(row.authorization)).toEqual([{ permission: PERM_READ, grant: 'aiTurn:ax1' }]);
546
+ });
547
+ it('a grant on a camelCase parent, reached by the walk: the event records chatThread:ct9', async () => {
548
+ const stub = await host.getScope(hal, t1, s1);
549
+ await stub.invoke('perm/authorized-emit', {
550
+ permission: PERM_READ,
551
+ entity: { entityType: 'aiTurn', entityId: 'ax2' },
552
+ });
553
+ const row = await lastActed();
554
+ expect(JSON.parse(row.authorization)).toEqual([{ permission: PERM_READ, grant: 'chatThread:ct9' }]);
555
+ });
556
+ it('the twin: a role-authorized emit on the same entity records no grant', async () => {
557
+ const stub = await host.getScope(alice, t1, s1);
558
+ await stub.invoke('perm/authorized-emit', {
559
+ permission: PERM_USE,
560
+ entity: { entityType: 'aiTurn', entityId: 'ax1' },
561
+ });
562
+ const row = await lastActed();
563
+ expect(JSON.parse(row.authorization)).toEqual([{ permission: PERM_USE }]);
564
+ });
565
+ });
267
566
  it('records a refused check as a denial, though the operation rolled back (K-35)', async () => {
268
567
  // bob's `tech` role at s1 holds perm:read, never perm:use.
269
568
  const stub = await host.getScope(bob, t1, s1);