@substrat-run/contract-tests 0.134.0 → 0.136.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.
@@ -0,0 +1,10 @@
1
+ import { type InvocationLogLine, type ModuleLogLine } from '@substrat-run/kernel';
2
+ import type { ScopeHostFixture } from './scope-host-suite.js';
3
+ export type AsyncLogFixture = ScopeHostFixture & {
4
+ /** Every invocation line written so far, in order. */
5
+ lines: () => InvocationLogLine[];
6
+ /** Every `ctx.log` line written so far, in order. */
7
+ logs: () => ModuleLogLine[];
8
+ };
9
+ export declare function asyncLogContractSuite(adapterName: string, makeFixture: () => Promise<AsyncLogFixture>): void;
10
+ //# sourceMappingURL=async-log-suite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async-log-suite.d.ts","sourceRoot":"","sources":["../src/async-log-suite.ts"],"names":[],"mappings":"AAWA,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAEnB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAM9D,MAAM,MAAM,eAAe,GAAG,gBAAgB,GAAG;IAC/C,sDAAsD;IACtD,KAAK,EAAE,MAAM,iBAAiB,EAAE,CAAC;IACjC,qDAAqD;IACrD,IAAI,EAAE,MAAM,aAAa,EAAE,CAAC;CAC7B,CAAC;AAEF,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC,eAAe,CAAC,GAAG,IAAI,CAuO5G"}
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Contract suite for async work's invocation lines (#1901): the one line the scope host
3
+ * writes per consumer delivery, executor attempt and schedule run.
4
+ *
5
+ * Every case drives a real scope and reads the lines the host wrote, on both adapters — the
6
+ * Durable Object's consumers run inside workerd and its executors and schedules on the
7
+ * coordinator, the SQLite host does all of it in process, and any of the three could stamp
8
+ * the wrong id, call a failure a success, or let the error's text through.
9
+ */
10
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11
+ import { moduleId, platformActorId, principalId, scopeId, tenantId } from '@substrat-run/contracts';
12
+ import { ASYNC_LINES_PER_PASS, ulid, } from '@substrat-run/kernel';
13
+ import { ASYNC_LOG_SECRET_ENTITY, ASYNC_LOG_SECRET_TEXT, asyncLogMod } from './modules.js';
14
+ const ASYNC_MODULE = moduleId.parse('@test/asynclog');
15
+ const EFFECTOR = 'asynclog-effector';
16
+ export function asyncLogContractSuite(adapterName, makeFixture) {
17
+ describe(`async invocation lines (#1901): ${adapterName}`, () => {
18
+ let fixture;
19
+ let host;
20
+ const t = tenantId.parse(ulid());
21
+ const staff = platformActorId.parse(ulid());
22
+ const alice = principalId.parse(ulid());
23
+ let s;
24
+ /** The tags the executor ran for — the effect, observed. */
25
+ const effected = [];
26
+ const scope = async (extra = {}) => {
27
+ const id = scopeId.parse(ulid());
28
+ await host.provisionScope(staff, { tenantId: t, scopeId: id, vertical: 'asynclog-vertical', ...extra });
29
+ await host.admin.activateScope(staff, t, id);
30
+ return id;
31
+ };
32
+ const act = async (on, tags, fail = false) => {
33
+ const invocationId = ulid();
34
+ const stub = await host.getScope(alice, t, on);
35
+ await stub.invoke('asynclog/act', { tags, fail }, { invocationId });
36
+ return invocationId;
37
+ };
38
+ /** The async lines about one event, by consumer. */
39
+ const linesAbout = (eventTag, operation) => {
40
+ const ids = new Set(fixture
41
+ .logs()
42
+ .filter((l) => l.template === 'consumed {tag}' && l.fields['tag'] === eventTag)
43
+ .map((l) => l.invocationId));
44
+ return fixture.lines().filter((l) => l.kind === 'consumer' && l.operation === operation && ids.has(l.invocationId));
45
+ };
46
+ const byEvent = (eventId) => fixture.lines().filter((l) => l.eventId === eventId);
47
+ beforeAll(async () => {
48
+ fixture = await makeFixture();
49
+ host = fixture.host;
50
+ host.registerModule(asyncLogMod);
51
+ // Retries at once, and dead-letters on the second attempt: the shortest real retry.
52
+ host.registerExecutor(EFFECTOR, 'asynclog.acted', async (_admin, event) => {
53
+ const { tag } = event.payload;
54
+ if (tag.startsWith('flaky'))
55
+ throw new Error(`effector refused ${tag}: ${ASYNC_LOG_SECRET_TEXT}`);
56
+ effected.push(tag);
57
+ }, { maxAttempts: 2, baseDelayMs: 0 });
58
+ await host.admin.createTenant(staff, { id: t, slug: `asynclog-${t.slice(-8).toLowerCase()}`, name: 'Async log' });
59
+ await host.admin.grantEntitlement(staff, t, 'asynclog');
60
+ s = await scope();
61
+ });
62
+ afterAll(async () => {
63
+ await fixture.cleanup();
64
+ });
65
+ it('a delivered consumer writes one info line, under the call it ran in, beside its ctx.log line', async () => {
66
+ const tag = `ok-${ulid()}`;
67
+ const invocationId = await act(s, [tag]);
68
+ const [line, ...more] = fixture
69
+ .lines()
70
+ .filter((l) => l.kind === 'consumer' && l.operation === '@test/asynclog' && l.invocationId === invocationId);
71
+ expect(more).toEqual([]);
72
+ expect(line).toMatchObject({
73
+ substrat: 'invocation',
74
+ kind: 'consumer',
75
+ tenantId: t,
76
+ scopeId: s,
77
+ method: null,
78
+ path: null,
79
+ status: null,
80
+ threw: false,
81
+ level: 'info',
82
+ outcome: 'delivered',
83
+ operation: '@test/asynclog',
84
+ eventType: 'asynclog.acted',
85
+ attempt: 1,
86
+ problemCode: null,
87
+ principalKind: 'system',
88
+ });
89
+ expect(line.eventId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
90
+ // The handler's ctx.log line joins it: same id, so the call's drill-down shows both.
91
+ expect(fixture.logs().find((l) => l.fields['tag'] === tag)).toMatchObject({ invocationId, operation: null });
92
+ });
93
+ it('outside any call, a delivery mints its own id, and its ctx.log line still joins it', async () => {
94
+ const tag = `nocall-${ulid()}`;
95
+ const stub = await host.getScope(alice, t, s);
96
+ // No invocation id: the tail that delivers the event runs in no call.
97
+ await stub.invoke('asynclog/act', { tags: [tag], fail: false });
98
+ const log = fixture.logs().find((l) => l.fields['tag'] === tag);
99
+ expect(log?.invocationId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
100
+ expect(linesAbout(tag, '@test/asynclog')).toEqual([
101
+ expect.objectContaining({ kind: 'consumer', outcome: 'delivered', invocationId: log.invocationId }),
102
+ ]);
103
+ });
104
+ it('a consumer that throws is an error line, dead-lettered, with the code and none of the text', async () => {
105
+ const tag = `refuse-${ulid()}`;
106
+ const invocationId = await act(s, [tag], true);
107
+ const [line] = fixture
108
+ .lines()
109
+ .filter((l) => l.kind === 'consumer' && l.operation === '@test/asynclog' && l.invocationId === invocationId);
110
+ expect(line).toMatchObject({
111
+ level: 'error',
112
+ threw: true,
113
+ outcome: 'dead-lettered',
114
+ problemCode: 'precondition_failed',
115
+ attempt: 1,
116
+ });
117
+ // Ids, names and codes only: neither the error's text nor the payload's tag.
118
+ const text = JSON.stringify(line);
119
+ expect(text).not.toContain(ASYNC_LOG_SECRET_TEXT);
120
+ expect(text).not.toContain(tag);
121
+ expect(linesAbout(tag, '@test/asynclog')).toEqual([line]);
122
+ });
123
+ it('an executor that throws is retrying on attempt 1 and dead-lettered on attempt 2, each its own line', async () => {
124
+ const tag = `flaky-${ulid()}`;
125
+ const invocationId = await act(s, [tag]);
126
+ const first = fixture
127
+ .lines()
128
+ .find((l) => l.operation === `executor:${EFFECTOR}` && l.invocationId === invocationId);
129
+ expect(first).toMatchObject({
130
+ kind: 'consumer',
131
+ level: 'error',
132
+ threw: true,
133
+ outcome: 'retrying',
134
+ attempt: 1,
135
+ eventType: 'asynclog.acted',
136
+ });
137
+ expect(JSON.stringify(first)).not.toContain(ASYNC_LOG_SECRET_TEXT);
138
+ // The retry runs in a sweep, which is no call: its line mints its own id.
139
+ const report = await host.drainDue(t, s);
140
+ expect(report.deadLettered).toBeGreaterThanOrEqual(1);
141
+ const attempts = byEvent(first.eventId).filter((l) => l.operation === `executor:${EFFECTOR}`);
142
+ expect(attempts.map((l) => [l.attempt, l.outcome, l.level])).toEqual([
143
+ [1, 'retrying', 'error'],
144
+ [2, 'dead-lettered', 'error'],
145
+ ]);
146
+ expect(attempts[1].invocationId).not.toBe(invocationId);
147
+ });
148
+ it('a delivered executor is an info line, and a drain with nothing due writes none', async () => {
149
+ const tag = `effect-${ulid()}`;
150
+ const invocationId = await act(s, [tag]);
151
+ expect(effected).toContain(tag);
152
+ const line = fixture.lines().find((l) => l.operation === `executor:${EFFECTOR}` && l.invocationId === invocationId);
153
+ expect(line).toMatchObject({ outcome: 'delivered', level: 'info', attempt: 1, threw: false });
154
+ const before = fixture.lines().length;
155
+ await host.drainDue(t, s);
156
+ expect(fixture.lines().slice(before).filter((l) => l.scopeId === s)).toEqual([]);
157
+ });
158
+ it('a copy of a scope logs its held deliveries as inert — a warning, never delivered', async () => {
159
+ const copy = await scope({ kind: 'preview' });
160
+ const tag = `inert-${ulid()}`;
161
+ const invocationId = await act(copy, [tag]);
162
+ expect(effected).not.toContain(tag);
163
+ const line = fixture.lines().find((l) => l.operation === `executor:${EFFECTOR}` && l.invocationId === invocationId);
164
+ expect(line).toMatchObject({ scopeId: copy, outcome: 'inert', level: 'warn', threw: false });
165
+ expect(fixture.lines().filter((l) => l.eventId === line.eventId && l.operation === `executor:${EFFECTOR}`)).toEqual([
166
+ line,
167
+ ]);
168
+ });
169
+ it('a schedule run writes a schedule line under the run id its ctx.log lines carry, with its lateness', async () => {
170
+ const report = await host.runDueSchedules(ASYNC_MODULE, t, s);
171
+ expect(report.fired).toBe(1);
172
+ expect(report.failed).toBe(1);
173
+ const ok = fixture.lines().find((l) => l.kind === 'schedule' && l.scopeId === s && l.operation === 'asynclog/tick');
174
+ expect(ok).toMatchObject({
175
+ kind: 'schedule',
176
+ tenantId: t,
177
+ outcome: 'ok',
178
+ level: 'info',
179
+ threw: false,
180
+ principalKind: 'system',
181
+ // A first run has no due time to be late against.
182
+ dueAt: null,
183
+ latenessMs: null,
184
+ method: null,
185
+ path: null,
186
+ });
187
+ expect(fixture.logs().find((l) => l.template === 'ticked' && l.scopeId === s)?.invocationId).toBe(ok.invocationId);
188
+ // What the run emitted is counted by declared type; the entity — an email — is not named,
189
+ // on its line or on the line of the consumer it reached.
190
+ expect(ok).toMatchObject({ eventCount: 1, eventTypes: ['asynclog.acted'], entities: [] });
191
+ const ofRun = fixture.lines().filter((l) => l.invocationId === ok.invocationId);
192
+ expect(ofRun.length).toBeGreaterThanOrEqual(2);
193
+ expect(JSON.stringify(ofRun)).not.toContain(ASYNC_LOG_SECRET_ENTITY);
194
+ const failed = fixture
195
+ .lines()
196
+ .find((l) => l.kind === 'schedule' && l.scopeId === s && l.operation === 'asynclog/tick-fails');
197
+ expect(failed).toMatchObject({ outcome: 'failed', level: 'error', threw: true, problemCode: 'conflict' });
198
+ expect(JSON.stringify(failed)).not.toContain(ASYNC_LOG_SECRET_TEXT);
199
+ expect(fixture.logs().find((l) => l.template === 'ticking, about to refuse' && l.scopeId === s)?.invocationId).toBe(failed.invocationId);
200
+ // Not due again: a skipped schedule writes nothing.
201
+ const before = fixture.lines().length;
202
+ await host.runDueSchedules(ASYNC_MODULE, t, s);
203
+ expect(fixture.lines().slice(before).filter((l) => l.kind === 'schedule' && l.scopeId === s)).toEqual([]);
204
+ });
205
+ it(`a pass over its cap writes ${ASYNC_LINES_PER_PASS} lines and one that counts the rest by outcome`, async () => {
206
+ const capped = await scope();
207
+ const tags = Array.from({ length: ASYNC_LINES_PER_PASS + 5 }, (_, i) => `burst-${i}-${ulid()}`);
208
+ const invocationId = await act(capped, tags, true);
209
+ const ofModule = fixture
210
+ .lines()
211
+ .filter((l) => l.scopeId === capped && l.operation === '@test/asynclog' && l.invocationId === invocationId);
212
+ expect(ofModule).toHaveLength(ASYNC_LINES_PER_PASS);
213
+ const suppressed = fixture
214
+ .lines()
215
+ .filter((l) => l.scopeId === capped && l.outcome === 'suppressed' && l.invocationId === invocationId);
216
+ // One per pass that went over, each naming what it withheld by outcome — so the module
217
+ // consumers' dead-letter storm is named as one, at the worst level it withheld, and the
218
+ // executor's deliveries beside it stay info.
219
+ expect(suppressed.map((l) => [l.operation, l.suppressed, l.suppressedBy, l.level]).sort()).toEqual([
220
+ [null, 5, { 'consumer:dead-lettered': 5 }, 'error'],
221
+ [null, 5, { 'consumer:delivered': 5 }, 'info'],
222
+ ]);
223
+ });
224
+ });
225
+ }
226
+ //# sourceMappingURL=async-log-suite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"async-log-suite.js","sourceRoot":"","sources":["../src/async-log-suite.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"capability-suite.d.ts","sourceRoot":"","sources":["../src/capability-suite.ts"],"names":[],"mappings":"AAuDA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAkC9D,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,GAC3C,IAAI,CA0jBN"}
1
+ {"version":3,"file":"capability-suite.d.ts","sourceRoot":"","sources":["../src/capability-suite.ts"],"names":[],"mappings":"AAwDA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAkC9D,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,GAC3C,IAAI,CA45BN"}
@@ -514,6 +514,334 @@ export function capabilityContractSuite(adapterName, makeFixture) {
514
514
  expect(errorCodeOf(await refusal(host.admin.revokeCapability(staff, t1, s1, ulid())))).toBe('not_found');
515
515
  });
516
516
  });
517
+ // #1686: the operator's read. A staff actor has no operation to stand in, so
518
+ // `HostAdmin.listCapabilities` is the directory `ctx.capabilities.list` reads, for them.
519
+ // The property that matters is what it NEVER returns — a secret or a hash — and each such
520
+ // check below has its positive twin: the same record shows everything else it should.
521
+ describe('the operator read — HostAdmin.listCapabilities (#1686)', () => {
522
+ const HEX64 = /\b[0-9a-f]{64}\b/i;
523
+ let shareLink;
524
+ let narrowed;
525
+ let revoked;
526
+ let claim;
527
+ let expiry;
528
+ let sessionToken;
529
+ const entriesOf = async (t, sc, filter) => (await host.admin.listCapabilities(staff, t, sc, filter)).entries;
530
+ const own = (rows) => rows.filter((r) => [shareLink, narrowed, revoked, claim].some((m) => m.id === r.id));
531
+ beforeAll(async () => {
532
+ expiry = inFuture(3_600_000);
533
+ shareLink = await share(alice, { entity: folder('F'), permissions: [CAP_READ], label: 'ops-share' });
534
+ narrowed = await share(alice, {
535
+ entity: folder('G'),
536
+ permissions: [CAP_READ],
537
+ operations: ['cap/read'],
538
+ expiresAt: expiry,
539
+ maxUses: 3,
540
+ label: 'ops-narrowed',
541
+ });
542
+ revoked = await share(alice, { entity: folder('F2'), permissions: [CAP_READ], label: 'ops-revoked' });
543
+ await (await as(alice)).invoke('cap/unshare', { id: revoked.id });
544
+ claim = await host.admin.mintCapability(staff, t1, s1, {
545
+ principal: seat,
546
+ expiresAt: inFuture(3_600_000),
547
+ maxUses: 1,
548
+ label: 'ops-claim',
549
+ });
550
+ sessionToken = await sessionOf(narrowed.secret); // one counted use on `narrowed`
551
+ });
552
+ it('lists what the module-side list does, as records an operator can read', async () => {
553
+ const rows = own(await entriesOf(t1, s1, { includeRevoked: true, limit: 200 }));
554
+ expect(rows.map((r) => r.id).sort()).toEqual([shareLink.id, narrowed.id, revoked.id, claim.id].sort());
555
+ const byId = new Map(rows.map((r) => [r.id, r]));
556
+ expect(byId.get(shareLink.id)).toMatchObject({
557
+ mode: 'act',
558
+ label: 'ops-share',
559
+ entity: folder('F'),
560
+ permissions: [CAP_READ],
561
+ operations: null,
562
+ mintedBy: alice,
563
+ expiresAt: null,
564
+ maxUses: null,
565
+ uses: 0,
566
+ revokedAt: null,
567
+ revokedBy: null,
568
+ });
569
+ expect(byId.get(narrowed.id)).toMatchObject({
570
+ mode: 'act',
571
+ entity: folder('G'),
572
+ operations: ['cap/read'],
573
+ expiresAt: expiry,
574
+ maxUses: 3,
575
+ uses: 1,
576
+ });
577
+ expect(byId.get(narrowed.id).lastUsedAt).not.toBeNull();
578
+ expect(byId.get(revoked.id)).toMatchObject({ revokedBy: alice });
579
+ expect(byId.get(revoked.id).revokedAt).not.toBeNull();
580
+ expect(byId.get(claim.id)).toMatchObject({
581
+ mode: 'become',
582
+ principal: seat,
583
+ mintedBy: { platform: staff },
584
+ maxUses: 1,
585
+ });
586
+ // Every row is exactly the published record — the same schema ctx.capabilities.list is held to.
587
+ for (const r of rows)
588
+ expect(capabilityRecord.parse(r)).toEqual(r);
589
+ });
590
+ it('never returns a secret or a hash — not as a field, not anywhere in the text', async () => {
591
+ const rows = await entriesOf(t1, s1, { includeRevoked: true, limit: 200 });
592
+ const text = JSON.stringify(rows);
593
+ // The positive twin of everything below: the read is not empty and is a faithful one.
594
+ expect(own(rows)).toHaveLength(4);
595
+ for (const m of [shareLink, narrowed, revoked, claim]) {
596
+ expect(text).toContain(m.id);
597
+ expect(text).not.toContain(m.secret);
598
+ expect(text).not.toContain(await capabilityTokenHash(m.secret));
599
+ }
600
+ // A session token's hash is as live a credential as the capability's own.
601
+ expect(text).not.toContain(sessionToken);
602
+ expect(text).not.toContain(await capabilityTokenHash(sessionToken));
603
+ // And whatever else a column might carry: no 64-hex digest of anything.
604
+ expect(text).not.toMatch(HEX64);
605
+ // Field by field: a record has only the published keys, and none of them is a credential.
606
+ const allowed = new Set([
607
+ ...Object.keys(capabilityRecord.options[0].shape),
608
+ ...Object.keys(capabilityRecord.options[1].shape),
609
+ ]);
610
+ for (const r of rows) {
611
+ for (const key of Object.keys(r)) {
612
+ expect(allowed.has(key)).toBe(true);
613
+ expect(key).not.toMatch(/hash|secret|token/i);
614
+ }
615
+ }
616
+ });
617
+ it('is live-only by default, and shows the revoked one when asked', async () => {
618
+ const live = own(await entriesOf(t1, s1, { limit: 200 })).map((r) => r.id);
619
+ expect(live).toContain(shareLink.id);
620
+ expect(live).not.toContain(revoked.id);
621
+ const all = own(await entriesOf(t1, s1, { includeRevoked: true, limit: 200 }));
622
+ expect(all.map((r) => r.id)).toContain(revoked.id);
623
+ });
624
+ it('is newest first, narrows by entity, and is bounded', async () => {
625
+ const all = await entriesOf(t1, s1, { includeRevoked: true, limit: 200 });
626
+ const ids = all.map((r) => r.id);
627
+ expect(ids).toEqual([...ids].sort().reverse()); // ULIDs: id order IS mint order
628
+ const onG = await entriesOf(t1, s1, { entity: folder('G'), includeRevoked: true });
629
+ expect(onG.length).toBeGreaterThan(0);
630
+ expect(onG.every((r) => r.mode === 'act' && r.entity.entityId === 'G')).toBe(true);
631
+ expect(onG.map((r) => r.id)).toContain(narrowed.id);
632
+ expect(await entriesOf(t1, s1, { limit: 1 })).toHaveLength(1);
633
+ // The bound is the filter's: past it is refused, not silently clamped.
634
+ await expect(host.admin.listCapabilities(staff, t1, s1, { limit: 201 })).rejects.toThrow();
635
+ await expect(host.admin.listCapabilities(staff, t1, s1, { limit: 0 })).rejects.toThrow();
636
+ });
637
+ it('reads the scope it is asked about and no other (K-3), and each read leaves an access row', async () => {
638
+ const other = await entriesOf(t1, s2, { includeRevoked: true, limit: 200 });
639
+ expect(own(other)).toEqual([]);
640
+ // A scope of another tenant: the pair does not resolve, so no log of another tenant's is reachable.
641
+ const t2 = tenantId.parse(ulid());
642
+ const s3 = scopeId.parse(ulid());
643
+ await host.admin.createTenant(staff, { id: t2, slug: 'cap-tenant-2', name: 'Cap Tenant 2' });
644
+ await host.admin.grantEntitlement(staff, t2, 'cap');
645
+ await host.provisionScope(staff, { tenantId: t2, scopeId: s3, vertical: 'cap-vertical' });
646
+ await host.admin.activateScope(staff, t2, s3);
647
+ const foreign = await host.admin.mintCapability(staff, t2, s3, {
648
+ principal: seat,
649
+ expiresAt: inFuture(60_000),
650
+ maxUses: 1,
651
+ label: 'ops-foreign',
652
+ });
653
+ await expect(host.admin.listCapabilities(staff, t2, s1)).rejects.toThrow();
654
+ await expect(host.admin.listCapabilities(staff, t1, s3)).rejects.toThrow();
655
+ // The twin: the foreign tenant's own pair reads its own row, which t1's never showed.
656
+ const theirs = await entriesOf(t2, s3);
657
+ expect(theirs.map((r) => r.id)).toEqual([foreign.id]);
658
+ expect(JSON.stringify(await entriesOf(t1, s1, { includeRevoked: true, limit: 200 }))).not.toContain(foreign.id);
659
+ const logged = await host.admin.accessLog(staff, { tenantId: t1, method: 'listCapabilities' });
660
+ expect(logged.length).toBeGreaterThan(0);
661
+ expect(JSON.stringify(logged)).not.toContain(shareLink.secret);
662
+ });
663
+ it('is a read: the use counts it reports do not move because it was asked', async () => {
664
+ const a = await entriesOf(t1, s1, { entity: folder('G'), includeRevoked: true });
665
+ const b = await entriesOf(t1, s1, { entity: folder('G'), includeRevoked: true });
666
+ expect(b).toEqual(a);
667
+ });
668
+ // Paging. A scope with more capabilities than one page holds is walked to the end by
669
+ // the cursor each page hands back — the operator is never left with the newest 50 and no
670
+ // way to the rest. The walk runs on a scope of its own so its count is exact.
671
+ describe('paging', () => {
672
+ const TOTAL = 205; // past the filter's own ceiling of 200, so no single read can hold it
673
+ const walkScope = scopeId.parse(ulid());
674
+ const minted = []; // oldest first
675
+ const mintMore = async (n) => {
676
+ const stub = await as(alice, walkScope);
677
+ const ids = [];
678
+ for (let i = 0; i < n; i++) {
679
+ ids.push((await stub.invoke('cap/share', { entity: folder('F'), permissions: [CAP_READ] })).id);
680
+ }
681
+ return ids;
682
+ };
683
+ const walk = async (limit, between) => {
684
+ const seen = [];
685
+ const sizes = [];
686
+ let cursor;
687
+ for (let page = 0;; page++) {
688
+ const p = await host.admin.listCapabilities(staff, t1, walkScope, {
689
+ limit,
690
+ ...(cursor === undefined ? {} : { cursor: cursor }),
691
+ });
692
+ seen.push(...p.entries.map((r) => r.id));
693
+ sizes.push(p.entries.length);
694
+ if (p.nextCursor === null)
695
+ return { seen, sizes };
696
+ // The cursor is the last record of the page just read, and only a page with more behind it carries one.
697
+ expect(p.nextCursor).toBe(p.entries[p.entries.length - 1].id);
698
+ expect(p.entries).toHaveLength(limit);
699
+ cursor = p.nextCursor;
700
+ await between?.(page);
701
+ expect(page).toBeLessThan(TOTAL + 100); // a cursor that does not advance would loop for ever
702
+ }
703
+ };
704
+ beforeAll(async () => {
705
+ await host.provisionScope(staff, { tenantId: t1, scopeId: walkScope, vertical: 'cap-vertical' });
706
+ await host.admin.activateScope(staff, t1, walkScope);
707
+ minted.push(...(await mintMore(TOTAL)));
708
+ }, 120_000);
709
+ it('hands back a cursor exactly when more follow, and the whole set is reachable in pages', async () => {
710
+ const newestFirst = [...minted].reverse();
711
+ // The default page is not the whole set — and says so rather than looking complete.
712
+ const first = await host.admin.listCapabilities(staff, t1, walkScope);
713
+ expect(first.entries.map((r) => r.id)).toEqual(newestFirst.slice(0, 50));
714
+ expect(first.nextCursor).toBe(newestFirst[49]);
715
+ // Even the ceiling does not hold 205: the 5 oldest are behind the cursor, not dropped.
716
+ const max = await host.admin.listCapabilities(staff, t1, walkScope, { limit: 200 });
717
+ expect(max.entries).toHaveLength(200);
718
+ expect(max.nextCursor).toBe(newestFirst[199]);
719
+ const rest = await host.admin.listCapabilities(staff, t1, walkScope, { limit: 200, cursor: max.nextCursor });
720
+ expect(rest.entries.map((r) => r.id)).toEqual(newestFirst.slice(200));
721
+ expect(rest.nextCursor).toBeNull();
722
+ // The walk, at several page sizes: every record once, newest first, no gap, no repeat.
723
+ for (const limit of [1, 50, 73, 200]) {
724
+ const { seen } = await walk(limit);
725
+ expect([limit, seen]).toEqual([limit, newestFirst]);
726
+ }
727
+ }, 120_000);
728
+ it('a page that ends exactly at the end carries no cursor (no trailing empty page)', async () => {
729
+ // 205 = 5 × 41: five full pages of 41 end the walk exactly, with nothing after the fifth.
730
+ const { sizes } = await walk(41);
731
+ expect(sizes).toEqual([41, 41, 41, 41, 41]);
732
+ const { sizes: by5 } = await walk(5);
733
+ expect(by5).toEqual(Array(TOTAL / 5).fill(5));
734
+ });
735
+ it('a record minted mid-walk neither repeats nor drops one the walk had yet to reach', async () => {
736
+ const before = [...minted].reverse();
737
+ const added = [];
738
+ const { seen } = await walk(40, async () => {
739
+ added.push(...(await mintMore(1)));
740
+ });
741
+ // Every record that existed when the walk began, once and in order…
742
+ expect(seen).toEqual(before);
743
+ // …and the newcomers sort ahead of every cursor already handed out, so they are the
744
+ // next walk's first records rather than a hole in this one.
745
+ expect(added.length).toBeGreaterThan(0);
746
+ const fresh = await host.admin.listCapabilities(staff, t1, walkScope, { limit: added.length });
747
+ expect(fresh.entries.map((r) => r.id)).toEqual([...added].reverse());
748
+ minted.push(...added);
749
+ }, 120_000);
750
+ it('narrows and pages together: the cursor walks the filtered set', async () => {
751
+ const stub = await as(alice, walkScope);
752
+ const g = await stub.invoke('cap/share', { entity: folder('G'), permissions: [CAP_READ] });
753
+ const g2 = await stub.invoke('cap/share', { entity: folder('G'), permissions: [CAP_READ] });
754
+ minted.push(g.id, g2.id);
755
+ const one = await host.admin.listCapabilities(staff, t1, walkScope, { entity: folder('G'), limit: 1 });
756
+ expect(one.entries.map((r) => r.id)).toEqual([g2.id]);
757
+ expect(one.nextCursor).toBe(g2.id);
758
+ const two = await host.admin.listCapabilities(staff, t1, walkScope, {
759
+ entity: folder('G'),
760
+ limit: 1,
761
+ cursor: one.nextCursor,
762
+ });
763
+ expect(two.entries.map((r) => r.id)).toEqual([g.id]);
764
+ expect(two.nextCursor).toBeNull();
765
+ });
766
+ it('refuses a cursor that is not a capability id, and a limit past the bound', async () => {
767
+ for (const cursor of ['', 'not-an-id', 'sbcap_x', '01JZ0000000000000000000000'.toLowerCase()]) {
768
+ await expect(host.admin.listCapabilities(staff, t1, walkScope, { cursor: cursor })).rejects.toThrow();
769
+ }
770
+ await expect(host.admin.listCapabilities(staff, t1, walkScope, { limit: 201 })).rejects.toThrow();
771
+ // The twin: a well-formed cursor is accepted, even one that matches nothing.
772
+ const nothing = await host.admin.listCapabilities(staff, t1, walkScope, { cursor: ulid() });
773
+ expect(nothing.entries.length).toBeGreaterThanOrEqual(0);
774
+ });
775
+ it('pages carry the same records and no hash, however they are cut', async () => {
776
+ const p = await host.admin.listCapabilities(staff, t1, walkScope, { limit: 3 });
777
+ expect(JSON.stringify(p)).not.toMatch(HEX64);
778
+ expect(Object.keys(p).sort()).toEqual(['entries', 'nextCursor']);
779
+ });
780
+ });
781
+ });
782
+ // #1686: capability rows never cross a scope id. A link minted on production opens
783
+ // production, never a fork or a preview copy of it; a backup restored into the scope it
784
+ // came from keeps the links that were live. Every refusal on the copy is paired with the
785
+ // source still working, and the copy is shown to hold the rest of the data.
786
+ describe('forks and restores — a link never opens a copy (#1686)', () => {
787
+ let link;
788
+ let token;
789
+ const listed = async (sc) => (await host.admin.listCapabilities(staff, t1, sc, { includeRevoked: true, limit: 200 })).entries.map((r) => r.id);
790
+ const refusedAt = async (sc) => {
791
+ expect(await exchange(link.secret, sc)).toBeNull();
792
+ const stub = await host.getCapabilityScope(token, t1, sc);
793
+ expect(errorCodeOf(await refusal(stub.invoke('cap/read', { entity: folder('F') })))).toBe('unauthenticated');
794
+ expect(await listed(sc)).toEqual([]);
795
+ // The copy is a working copy of everything else: the owner reads the shared folder there.
796
+ await expect((await as(alice, sc)).invoke('cap/read', { entity: folder('F') })).resolves.toMatchObject({
797
+ read: folder('F'),
798
+ });
799
+ };
800
+ const worksAtSource = async () => {
801
+ expect((await exchange(link.secret, s1))?.kind).toBe('session');
802
+ const stub = await host.getCapabilityScope(token, t1, s1);
803
+ await expect(stub.invoke('cap/read', { entity: doc('d1') })).resolves.toMatchObject({ read: doc('d1') });
804
+ expect(await listed(s1)).toContain(link.id);
805
+ };
806
+ beforeAll(async () => {
807
+ link = await share(alice, { entity: folder('F'), permissions: [CAP_READ], label: 'fork-share' });
808
+ token = await sessionOf(link.secret);
809
+ });
810
+ it('the dump a copy is made from carries the capability row — what the loader leaves behind is real', async () => {
811
+ const dumped = await host.admin.exportScope(staff, t1, s1);
812
+ const caps = dumped.tables.find((t) => t.name === '_substrat_capabilities');
813
+ expect(JSON.stringify(caps?.rows)).toContain(await capabilityTokenHash(link.secret));
814
+ const sessions = dumped.tables.find((t) => t.name === '_substrat_capability_sessions');
815
+ expect(JSON.stringify(sessions?.rows)).toContain(await capabilityTokenHash(token));
816
+ });
817
+ it('a fork (importScope): the secret and the session are refused there, and the fork lists nothing', async () => {
818
+ const fork = scopeId.parse(ulid());
819
+ await host.importScope(staff, { tenantId: t1, scopeId: fork, vertical: 'cap-vertical' }, await host.admin.exportScope(staff, t1, s1));
820
+ await refusedAt(fork);
821
+ await worksAtSource();
822
+ });
823
+ it('a snapshot (snapshotScope): the same', async () => {
824
+ const snap = await host.snapshotScope(staff, t1, s1, { kind: 'preview' });
825
+ await refusedAt(snap);
826
+ await worksAtSource();
827
+ });
828
+ it("a restore of this scope's backup onto another scope: the same", async () => {
829
+ const other = scopeId.parse(ulid());
830
+ await host.provisionScope(staff, { tenantId: t1, scopeId: other, vertical: 'cap-vertical' });
831
+ await host.admin.activateScope(staff, t1, other);
832
+ await host.restoreScope(staff, t1, other, await host.admin.exportScope(staff, t1, s1));
833
+ await refusedAt(other);
834
+ await worksAtSource();
835
+ });
836
+ it('a restore into the scope the backup came from keeps the link, its session and its listing', async () => {
837
+ const backup = await host.admin.exportScope(staff, t1, s1);
838
+ await host.restoreScope(staff, t1, s1, backup);
839
+ await worksAtSource();
840
+ // Its standing came back as it was: still live, the uses it had.
841
+ const rec = (await host.admin.listCapabilities(staff, t1, s1, { limit: 200 })).entries.find((r) => r.id === link.id);
842
+ expect(rec).toMatchObject({ revokedAt: null, label: 'fork-share' });
843
+ });
844
+ });
517
845
  });
518
846
  }
519
847
  //# sourceMappingURL=capability-suite.js.map