@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/permission-suite.js
CHANGED
|
@@ -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);
|