@substrat-run/contract-tests 0.88.0 → 0.90.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,3 @@
1
+ import type { ScopeHostFixture } from './scope-host-suite.js';
2
+ export declare function idempotencyContractSuite(adapterName: string, makeFixture: () => Promise<ScopeHostFixture>): void;
3
+ //# sourceMappingURL=idempotency-suite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-suite.d.ts","sourceRoot":"","sources":["../src/idempotency-suite.ts"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAU9D,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,GAC3C,IAAI,CA6PN"}
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Contract suite for request idempotency — `Idempotency-Key` (#116).
3
+ *
4
+ * What it pins: **a retry does not do the work twice, and the host is what makes
5
+ * that true.** Everything else about this feature is a projection — the header
6
+ * name is a wire detail `vertical-host` owns, the opt-out is a model detail
7
+ * `contracts` owns, and neither is what makes a retry free. This is: a recording
8
+ * written in the same transaction as the work, and a second request answered
9
+ * from it without running anything.
10
+ *
11
+ * **Every case counts executions rather than comparing responses.** A suite that
12
+ * asserted only on the returned value would pass against a host that re-ran the
13
+ * operation and produced the same answer again — which is exactly the bug, since
14
+ * a duplicated work order looks a great deal like the original. So the fixture
15
+ * appends a row per invocation and `idem/runs` reports the count, and that count
16
+ * is the assertion.
17
+ *
18
+ * The cases that matter are not the happy path:
19
+ *
20
+ * - **A failed request leaves no recording.** The row is written inside the
21
+ * operation's transaction, so a throw takes it with the writes it describes and
22
+ * the retry executes — correctly, because nothing happened the first time.
23
+ * - **A key belongs to the subject that sent it.** Two principals choosing `1`
24
+ * must each get their own execution; a lookup that crossed that boundary would
25
+ * replay one caller's response to another.
26
+ * - **A reused key is refused, never served.** Same key, different request means
27
+ * the client's assertion is false, and answering with the earlier response
28
+ * would be a lie it acts on.
29
+ * - **An unrecordable response fails closed.** A replay that cannot be answered
30
+ * is a 409, not a second execution.
31
+ */
32
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
33
+ import { errorCodeOf, permissionKey, platformActorId, principalId, scopeId, tenantId, IDEMPOTENCY_REPLAY_UNAVAILABLE, IDEMPOTENCY_REUSED, } from '@substrat-run/contracts';
34
+ import { ulid } from '@substrat-run/kernel';
35
+ import { idempotencyMod } from './modules.js';
36
+ const IDEM_USE = permissionKey.parse('idem:use');
37
+ /** The `reason` slug a refusal carried, or undefined — read off the thrown error. */
38
+ function reasonOf(err) {
39
+ return err?.extensions?.['reason'];
40
+ }
41
+ export function idempotencyContractSuite(adapterName, makeFixture) {
42
+ describe(`request idempotency (Idempotency-Key): ${adapterName}`, () => {
43
+ let fixture;
44
+ let host;
45
+ let stub;
46
+ /** A second principal, for the case that a key is one caller's and not another's. */
47
+ let otherStub;
48
+ const t1 = tenantId.parse(ulid());
49
+ const s1 = scopeId.parse(ulid());
50
+ const alice = principalId.parse(ulid());
51
+ const bob = principalId.parse(ulid());
52
+ const staff = platformActorId.parse(ulid());
53
+ /** Whether each invocation was answered from a recording, newest last. */
54
+ let replays = [];
55
+ const sink = () => {
56
+ let replayed = false;
57
+ return {
58
+ options: {
59
+ onIdempotentReplay: () => {
60
+ replayed = true;
61
+ },
62
+ },
63
+ done: () => replays.push(replayed),
64
+ };
65
+ };
66
+ const create = async (thingId, key, label, as = () => stub) => {
67
+ const probe = sink();
68
+ try {
69
+ return await as().invoke('idem/create', { thingId, ...(label === undefined ? {} : { label }) }, { ...probe.options, ...(key === undefined ? {} : { idempotencyKey: key }) });
70
+ }
71
+ finally {
72
+ probe.done();
73
+ }
74
+ };
75
+ /** How many times a handler in this module has actually run. */
76
+ const runs = async () => (await stub.invoke('idem/runs', {})).count;
77
+ beforeAll(async () => {
78
+ fixture = await makeFixture();
79
+ host = fixture.host;
80
+ host.registerModule(idempotencyMod);
81
+ await host.admin.createTenant(staff, { id: t1, slug: 'idem-tenant', name: 'Idem Tenant' });
82
+ await host.admin.grantEntitlement(staff, t1, 'idem');
83
+ await host.admin.defineRole(staff, t1, {
84
+ key: 'idem-admin',
85
+ permissions: [IDEM_USE],
86
+ source: 'vertical',
87
+ });
88
+ for (const principal of [alice, bob]) {
89
+ await host.admin.assignRole(staff, {
90
+ principalId: principal,
91
+ roleKey: 'idem-admin',
92
+ node: { tenantId: t1, scopeId: null },
93
+ });
94
+ }
95
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s1, vertical: 'idem-vertical' });
96
+ await host.admin.activateScope(staff, t1, s1);
97
+ stub = await host.getScope(alice, t1, s1);
98
+ otherStub = await host.getScope(bob, t1, s1);
99
+ });
100
+ afterAll(async () => {
101
+ await fixture.cleanup();
102
+ });
103
+ it('runs twice for two identical requests carrying no key', async () => {
104
+ const before = await runs();
105
+ await create('baseline');
106
+ await create('baseline');
107
+ // The baseline the whole feature is measured against: without a key there is
108
+ // no dedupe, and identical requests are two requests. A suite that skipped
109
+ // this could not tell a working replay from an operation that happens to be
110
+ // naturally idempotent.
111
+ expect(await runs()).toBe(before + 2);
112
+ });
113
+ it('runs once for a repeated request under one key, and replays the response', async () => {
114
+ const key = `k-${ulid()}`;
115
+ const before = await runs();
116
+ replays = [];
117
+ const first = await create('once', key, 'hello');
118
+ const second = await create('once', key, 'hello');
119
+ // The assertion that matters is the count, not the equality below it.
120
+ expect(await runs()).toBe(before + 1);
121
+ // `run` is a fresh ULID per execution, so identical values here mean the
122
+ // second response came from the recording rather than from a second run
123
+ // that agreed by luck.
124
+ expect(second).toEqual(first);
125
+ expect(replays).toEqual([false, true]);
126
+ });
127
+ it('scopes a key to the subject that sent it', async () => {
128
+ const key = `shared-${ulid()}`;
129
+ const before = await runs();
130
+ replays = [];
131
+ const mine = await create('theirs', key, 'a', () => stub);
132
+ const theirs = await create('theirs', key, 'a', () => otherStub);
133
+ // Two principals will choose the same key — `1` is a key someone sends. A
134
+ // lookup that found the other's row would replay a response across a
135
+ // principal boundary, which is a disclosure and not a convenience.
136
+ expect(await runs()).toBe(before + 2);
137
+ expect(theirs).not.toEqual(mine);
138
+ expect(replays).toEqual([false, false]);
139
+ });
140
+ it('refuses a key reused for a different request, and runs nothing', async () => {
141
+ const key = `reuse-${ulid()}`;
142
+ await create('reused', key, 'original');
143
+ const before = await runs();
144
+ await expect(create('reused', key, 'CHANGED')).rejects.toSatisfy((err) => {
145
+ return errorCodeOf(err) === 'conflict' && reasonOf(err) === IDEMPOTENCY_REUSED;
146
+ });
147
+ // Refused, and refused BEFORE anything ran. Serving the first request's
148
+ // response would be worse — the client would act on an answer to a question
149
+ // it did not ask.
150
+ expect(await runs()).toBe(before);
151
+ });
152
+ it('treats a different key as a different request', async () => {
153
+ const before = await runs();
154
+ await create('twice', `k-${ulid()}`, 'same input');
155
+ await create('twice', `k-${ulid()}`, 'same input');
156
+ // Dedupe is by KEY, never by content: two deliberate identical writes are
157
+ // two writes, and a client that wanted one sends one key.
158
+ expect(await runs()).toBe(before + 2);
159
+ });
160
+ it('retries — does not replay — a request that failed', async () => {
161
+ const key = `fail-${ulid()}`;
162
+ const before = await runs();
163
+ await expect(stub.invoke('idem/fails', { thingId: 'nope' }, { idempotencyKey: key })).rejects.toThrow();
164
+ // The handler wrote a row before throwing and the transaction rolled it
165
+ // back, so the count has not moved — which is also why there is nothing to
166
+ // replay.
167
+ expect(await runs()).toBe(before);
168
+ await expect(stub.invoke('idem/fails', { thingId: 'nope' }, { idempotencyKey: key })).rejects.toThrow();
169
+ // Executed again rather than replaying the failure. Recording failures would
170
+ // have meant deciding which are permanent, and a retry after a 500 is the
171
+ // most ordinary thing a client does.
172
+ expect(await runs()).toBe(before);
173
+ // The same key is still free for the request that eventually succeeds: the
174
+ // first two attempts left no row, so this is a first request.
175
+ const ok = await stub.invoke('idem/create', { thingId: 'recovered' }, { idempotencyKey: key });
176
+ expect(ok).toMatchObject({ id: 'recovered' });
177
+ });
178
+ it('refuses a replay it cannot answer rather than executing again', async () => {
179
+ const key = `big-${ulid()}`;
180
+ const before = await runs();
181
+ await stub.invoke('idem/big', { thingId: 'large' }, { idempotencyKey: key });
182
+ expect(await runs()).toBe(before + 1);
183
+ await expect(stub.invoke('idem/big', { thingId: 'large' }, { idempotencyKey: key })).rejects.toSatisfy((err) => {
184
+ return errorCodeOf(err) === 'conflict' && reasonOf(err) === IDEMPOTENCY_REPLAY_UNAVAILABLE;
185
+ });
186
+ // Fail closed. An error the caller can act on beats the duplicate execution
187
+ // the key was sent to prevent — and the original did complete, so re-running
188
+ // is the one answer that is certainly wrong.
189
+ expect(await runs()).toBe(before + 1);
190
+ });
191
+ it('refuses a key on an operation that declared `idempotency: false`', async () => {
192
+ const before = await runs();
193
+ await expect(stub.invoke('idem/secret', { thingId: 's1' }, { idempotencyKey: `s-${ulid()}` })).rejects.toThrow(/idempotency: false|cannot honour an Idempotency-Key/i);
194
+ // Refused rather than ignored: a caller who sent a key and got a 200 would
195
+ // believe the retry is safe, and here the response was never recorded — the
196
+ // second request would mint a second secret.
197
+ expect(await runs()).toBe(before);
198
+ // The operation itself still works; it is the header it refuses.
199
+ await expect(stub.invoke('idem/secret', { thingId: 's1' })).resolves.toMatchObject({
200
+ id: 's1',
201
+ });
202
+ });
203
+ it('refuses a malformed key', async () => {
204
+ const before = await runs();
205
+ for (const bad of ['', 'has space', 'x'.repeat(256)]) {
206
+ await expect(stub.invoke('idem/create', { thingId: 'bad' }, { idempotencyKey: bad })).rejects.toSatisfy((err) => errorCodeOf(err) === 'validation_failed');
207
+ }
208
+ expect(await runs()).toBe(before);
209
+ });
210
+ it('replays the entity version alongside the body', async () => {
211
+ const key = `guard-${ulid()}`;
212
+ const tags = [];
213
+ const options = {
214
+ idempotencyKey: key,
215
+ onEntityVersion: (v) => tags.push(v),
216
+ };
217
+ const first = await stub.invoke('idem/create-guarded', { thingId: 'g1' }, options);
218
+ const second = await stub.invoke('idem/create-guarded', { thingId: 'g1' }, options);
219
+ expect(second).toEqual(first);
220
+ // The two seams compose or neither works: a replayed response with no `ETag`
221
+ // leaves the client holding no validator, so its next conditional write has
222
+ // nothing to send and the lost-update protection quietly switches off.
223
+ expect(tags).toHaveLength(2);
224
+ expect(tags[1]).toBe(tags[0]);
225
+ expect(tags[0]).not.toBeNull();
226
+ });
227
+ it('does not report a version for a replay of an unguarded operation', async () => {
228
+ const key = `plain-${ulid()}`;
229
+ const tags = [];
230
+ const options = {
231
+ idempotencyKey: key,
232
+ onEntityVersion: (v) => tags.push(v),
233
+ };
234
+ await stub.invoke('idem/create', { thingId: 'p1' }, options);
235
+ await stub.invoke('idem/create', { thingId: 'p1' }, options);
236
+ // #129's rule survives the replay path: an operation that declared no
237
+ // `concurrency` never reports a tag, and a recording must not become the
238
+ // route by which one appears.
239
+ expect(tags).toEqual([]);
240
+ });
241
+ });
242
+ }
243
+ //# sourceMappingURL=idempotency-suite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency-suite.js","sourceRoot":"","sources":["../src/idempotency-suite.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { ScopeHostFixture } from './scope-host-suite.js';
2
+ export declare function impersonationContractSuite(adapterName: string, makeFixture: () => Promise<ScopeHostFixture>): void;
3
+ //# sourceMappingURL=impersonation-suite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"impersonation-suite.d.ts","sourceRoot":"","sources":["../src/impersonation-suite.ts"],"names":[],"mappings":"AA+CA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AA6B9D,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,GAC3C,IAAI,CA2WN"}
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Contract suite for impersonation — acting as a principal with the real actor
3
+ * preserved (K-42, #868).
4
+ *
5
+ * What it pins is one sentence: **the permission model answers as the
6
+ * impersonated principal, and every record keeps both actors.** Half of that is
7
+ * easy to get right and half of it is easy to get subtly wrong, and the ones
8
+ * that go wrong quietly are the ones this suite is mostly about.
9
+ *
10
+ * Four properties, and why each has to be a behavioural test rather than a note:
11
+ *
12
+ * 1. **The authority is the impersonated principal's, resolved the ordinary
13
+ * way.** A door that grants authority by opening is a door nobody can audit,
14
+ * so the assertion is that a session against a principal who holds NOTHING is
15
+ * refused exactly as that principal would be — with the denial recorded, and
16
+ * recorded against both actors.
17
+ *
18
+ * 2. **The stamp cannot be supplied or suppressed by module code.** It is absent
19
+ * from `DomainEventInput`, which is a compile-time fact; what a test can show
20
+ * is the runtime half — the same operation, invoked through the ordinary door,
21
+ * writes a null stamp, and through the impersonation door writes both actors,
22
+ * with nothing about the handler differing.
23
+ *
24
+ * 3. **Read-only is a mechanism, not a promise.** The test that matters is the
25
+ * one where the handler never calls an effecting verb at all: it writes a row
26
+ * with plain `ctx.sql.exec`. An adapter that only refused `ctx.emit` passes
27
+ * every other case here and commits that row.
28
+ *
29
+ * 4. **The time box is checked per invoke, not per stub.** A stub is a
30
+ * capability and nothing takes it away, so a session validated only at the
31
+ * door expires for everyone except the one caller holding it. `endImpersonation`
32
+ * is the same property from the other side.
33
+ */
34
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35
+ import { permissionKey, platformActorId, principalId, scopeId, tenantId, IMPERSONATION_MAX_MINUTES, } from '@substrat-run/contracts';
36
+ import { ulid } from '@substrat-run/kernel';
37
+ import { impersonationEchoMod, permMod } from './modules.js';
38
+ const PERM_USE = permissionKey.parse('perm:use');
39
+ export function impersonationContractSuite(adapterName, makeFixture) {
40
+ describe(`impersonation (K-42): ${adapterName}`, () => {
41
+ let fixture;
42
+ let host;
43
+ const t1 = tenantId.parse(ulid());
44
+ /** Holds `perm:use` through a role — the person support is helping. */
45
+ const anna = principalId.parse(ulid());
46
+ /** Holds nothing at all — the lever that proves the door grants no authority. */
47
+ const nobody = principalId.parse(ulid());
48
+ const staff = platformActorId.parse(ulid());
49
+ /**
50
+ * A FRESH scope per test. These assertions are about what a whole session
51
+ * left behind — rows, outbox, denials, intents — so a scope carrying another
52
+ * test's writes would make every count ambiguous.
53
+ */
54
+ const freshScope = async () => {
55
+ const s = scopeId.parse(ulid());
56
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: 'imp-vertical' });
57
+ await host.admin.activateScope(staff, t1, s);
58
+ return s;
59
+ };
60
+ const openSession = async (scope, overrides = {}) => host.admin.beginImpersonation(staff, {
61
+ tenantId: t1,
62
+ scopeId: scope,
63
+ principal: overrides.principal ?? anna,
64
+ reason: 'ticket #4182 — the invoice screen is empty',
65
+ ...(overrides.mode ? { mode: overrides.mode } : {}),
66
+ ...(overrides.minutes ? { minutes: overrides.minutes } : {}),
67
+ });
68
+ beforeAll(async () => {
69
+ fixture = await makeFixture();
70
+ host = fixture.host;
71
+ host.registerModule(permMod);
72
+ // K-42: the read-back half of the stamp — see the test that reads `imp-echo/seen`.
73
+ host.registerModule(impersonationEchoMod);
74
+ await host.admin.createTenant(staff, { id: t1, slug: 'imp-tenant', name: 'Imp Tenant' });
75
+ await host.admin.grantEntitlement(staff, t1, 'perm');
76
+ await host.admin.grantEntitlement(staff, t1, 'imp-echo');
77
+ await host.admin.defineRole(staff, t1, {
78
+ key: 'imp-user',
79
+ permissions: [PERM_USE],
80
+ source: 'vertical',
81
+ });
82
+ await host.admin.assignRole(staff, {
83
+ principalId: anna,
84
+ roleKey: 'imp-user',
85
+ node: { tenantId: t1, scopeId: null },
86
+ });
87
+ });
88
+ afterAll(async () => {
89
+ await fixture.cleanup();
90
+ });
91
+ // -- the session record ---------------------------------------------------
92
+ describe('a session is bounded, reason-carrying and recorded before it is usable', () => {
93
+ it('records the staff actor, the principal, the reason and an expiry', async () => {
94
+ const scope = await freshScope();
95
+ const session = await openSession(scope);
96
+ expect(session.actor).toBe(staff);
97
+ expect(session.principal).toBe(anna);
98
+ expect(session.tenantId).toBe(t1);
99
+ expect(session.scopeId).toBe(scope);
100
+ expect(session.reason).toContain('#4182');
101
+ expect(session.endedAt).toBeNull();
102
+ expect(session.expiresAt > session.startedAt).toBe(true);
103
+ });
104
+ it('is read-only unless the caller asked for otherwise', async () => {
105
+ const scope = await freshScope();
106
+ expect((await openSession(scope)).mode).toBe('read-only');
107
+ expect((await openSession(scope, { mode: 'write' })).mode).toBe('write');
108
+ });
109
+ it('refuses a session with no real reason', async () => {
110
+ const scope = await freshScope();
111
+ await expect(host.admin.beginImpersonation(staff, {
112
+ tenantId: t1,
113
+ scopeId: scope,
114
+ principal: anna,
115
+ reason: 'x',
116
+ })).rejects.toThrow();
117
+ });
118
+ /**
119
+ * REFUSED, not clamped. A caller silently handed a shorter session than it
120
+ * asked for believes it has one that is still open, which is the failure a
121
+ * time box exists to prevent, arrived at through the time box.
122
+ */
123
+ it('refuses an ask beyond the ceiling rather than shortening it', async () => {
124
+ const scope = await freshScope();
125
+ await expect(openSession(scope, { minutes: IMPERSONATION_MAX_MINUTES + 1 })).rejects.toThrow();
126
+ });
127
+ it('refuses a scope that does not exist — a session for nothing is not issued', async () => {
128
+ await expect(host.admin.beginImpersonation(staff, {
129
+ tenantId: t1,
130
+ scopeId: scopeId.parse(ulid()),
131
+ principal: anna,
132
+ reason: 'a scope that was never provisioned',
133
+ })).rejects.toThrow();
134
+ });
135
+ /**
136
+ * The admin-log entry PRECEDES the session being usable (K-33's failure
137
+ * ordering). Asserted by reading the log immediately after `begin` and
138
+ * before any invoke: if the row were written afterwards, or on first use,
139
+ * this is empty.
140
+ */
141
+ it('is in the admin log before a single operation has run', async () => {
142
+ const scope = await freshScope();
143
+ const session = await openSession(scope);
144
+ const log = await host.admin.auditLog(staff, { tenantId: t1, scopeId: scope });
145
+ const entry = log.find((e) => e.action === 'beginImpersonation');
146
+ expect(entry).toBeDefined();
147
+ expect(entry.actor).toBe(staff);
148
+ // The reason is IN the record — a log saying a session opened but not why
149
+ // is half a record, and the half an incident review reads is the why.
150
+ expect(JSON.stringify(entry.after)).toContain(session.reason);
151
+ });
152
+ it('reads back through the session log, and closes explicitly', async () => {
153
+ const scope = await freshScope();
154
+ const session = await openSession(scope);
155
+ const open = await host.admin.listImpersonations(staff, { scopeId: scope, active: true });
156
+ expect(open.map((s) => s.id)).toContain(session.id);
157
+ const ended = await host.admin.endImpersonation(staff, session.id);
158
+ expect(ended.endedAt).not.toBeNull();
159
+ // Idempotent: stopping a stopped session is not an error, and does not
160
+ // move the moment it stopped.
161
+ expect((await host.admin.endImpersonation(staff, session.id)).endedAt).toBe(ended.endedAt);
162
+ const stillOpen = await host.admin.listImpersonations(staff, { scopeId: scope, active: true });
163
+ expect(stillOpen.map((s) => s.id)).not.toContain(session.id);
164
+ });
165
+ });
166
+ // -- the authority --------------------------------------------------------
167
+ describe('the permission model answers about the IMPERSONATED principal', () => {
168
+ it('runs as that principal — `ctx.principal` is never the staff actor', async () => {
169
+ const scope = await freshScope();
170
+ const session = await openSession(scope);
171
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
172
+ await expect(stub.invoke('perm/whoami')).resolves.toBe(anna);
173
+ });
174
+ /**
175
+ * The door grants NOTHING. A session against a principal who holds no
176
+ * permission is refused exactly as that principal is — which is what makes
177
+ * "see what they see" true rather than "see everything, as them".
178
+ */
179
+ it('is refused wherever the impersonated principal would be refused', async () => {
180
+ const scope = await freshScope();
181
+ const session = await openSession(scope, { principal: nobody, mode: 'write' });
182
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
183
+ await expect(stub.invoke('perm/write-note', { note: 'nope' })).rejects.toThrow(/permission denied/);
184
+ });
185
+ it('records that denial against BOTH actors', async () => {
186
+ const scope = await freshScope();
187
+ const session = await openSession(scope, { principal: nobody, mode: 'write' });
188
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
189
+ await expect(stub.invoke('perm/write-note', { note: 'nope' })).rejects.toThrow();
190
+ // Read as a principal who may: the denial log is scope-local spine.
191
+ const asAnna = await host.getScope(anna, t1, scope);
192
+ const denials = await asAnna.invoke('perm/read-denials');
193
+ expect(denials).toHaveLength(1);
194
+ expect(JSON.parse(denials[0].actor)).toBe(nobody);
195
+ expect(denials[0].impersonation).not.toBeNull();
196
+ expect(JSON.parse(denials[0].impersonation)).toEqual({
197
+ session: session.id,
198
+ by: staff,
199
+ });
200
+ });
201
+ });
202
+ // -- the stamp ------------------------------------------------------------
203
+ describe('every record keeps both actors', () => {
204
+ it('stamps the emitted event with the session and the staff actor', async () => {
205
+ const scope = await freshScope();
206
+ const session = await openSession(scope, { mode: 'write' });
207
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
208
+ await stub.invoke('perm/authorized-emit', { permission: PERM_USE });
209
+ const asAnna = await host.getScope(anna, t1, scope);
210
+ const outbox = await asAnna.invoke('perm/read-outbox');
211
+ const acted = outbox.find((e) => e.type === 'perm.acted');
212
+ expect(acted).toBeDefined();
213
+ // The envelope's own `actor` stays the principal — K-34's authorization is
214
+ // untouched, and the domain fact is still theirs.
215
+ expect(JSON.parse(acted.authorization)).toEqual([{ permission: PERM_USE }]);
216
+ expect(JSON.parse(acted.impersonation)).toEqual({ session: session.id, by: staff });
217
+ });
218
+ /**
219
+ * The other half of "module code can neither supply it nor suppress it".
220
+ * The SAME operation through the ordinary door writes a null stamp — so a
221
+ * stamp is evidence of a session, never a default an adapter fills in.
222
+ */
223
+ it('writes no stamp when nobody is impersonating', async () => {
224
+ const scope = await freshScope();
225
+ const stub = await host.getScope(anna, t1, scope);
226
+ await stub.invoke('perm/authorized-emit', { permission: PERM_USE });
227
+ const outbox = await stub.invoke('perm/read-outbox');
228
+ expect(outbox.find((e) => e.type === 'perm.acted').impersonation).toBeNull();
229
+ });
230
+ /**
231
+ * The stamp is written by `ctx.emit` and read back by whatever turns a stored
232
+ * outbox row into a `DomainEvent`. Those are two different pieces of code in
233
+ * both adapters, and the tests above only exercise the first: they read the
234
+ * row with SQL. An adapter that stores the column and drops it on the way out
235
+ * passes every one of them while handing its consumers — and its executors,
236
+ * which is how an outbound effect gets made — an event with no administrative
237
+ * actor on it at all.
238
+ */
239
+ it('keeps the stamp on the event a CONSUMER receives, not only on the row', async () => {
240
+ const scope = await freshScope();
241
+ const session = await openSession(scope, { mode: 'write' });
242
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
243
+ await stub.invoke('perm/authorized-emit', { permission: PERM_USE });
244
+ const asAnna = await host.getScope(anna, t1, scope);
245
+ const seen = await asAnna.invoke('imp-echo/seen');
246
+ expect(seen).toHaveLength(1);
247
+ expect(JSON.parse(seen[0].impersonation)).toEqual({ session: session.id, by: staff });
248
+ });
249
+ /** And the null half, on the same reasoning as `writes no stamp when nobody is
250
+ * impersonating`: a stamp on the delivered event is evidence of a session. */
251
+ it('delivers no stamp to a consumer when nobody is impersonating', async () => {
252
+ const scope = await freshScope();
253
+ const stub = await host.getScope(anna, t1, scope);
254
+ await stub.invoke('perm/authorized-emit', { permission: PERM_USE });
255
+ const seen = await stub.invoke('imp-echo/seen');
256
+ expect(seen).toHaveLength(1);
257
+ expect(seen[0].impersonation).toBeNull();
258
+ });
259
+ it('stamps a platform intent the session raised', async () => {
260
+ const scope = await freshScope();
261
+ const session = await openSession(scope, { mode: 'write' });
262
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
263
+ await stub.invoke('perm/request-intent', { kind: 'test.impersonated' });
264
+ const asAnna = await host.getScope(anna, t1, scope);
265
+ const intents = await asAnna.invoke('perm/read-intents');
266
+ expect(intents).toHaveLength(1);
267
+ // `requested_by` is the principal, as it always was; the staff actor is
268
+ // the fact a drain operator could not otherwise recover.
269
+ expect(JSON.parse(intents[0].requested_by)).toBe(anna);
270
+ expect(JSON.parse(intents[0].impersonation)).toEqual({ session: session.id, by: staff });
271
+ });
272
+ });
273
+ // -- read-only ------------------------------------------------------------
274
+ describe('a read-only session cannot write, mechanically', () => {
275
+ it('refuses the effecting verbs by name', async () => {
276
+ const scope = await freshScope();
277
+ const session = await openSession(scope);
278
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
279
+ await expect(stub.invoke('perm/authorized-emit', { permission: PERM_USE })).rejects.toThrow(/read-only impersonation session/);
280
+ await expect(stub.invoke('perm/request-intent', { kind: 'test.refused' })).rejects.toThrow(/read-only impersonation session/);
281
+ });
282
+ /**
283
+ * THE test. The handler calls no effecting verb at all — it writes a row
284
+ * with plain `ctx.sql.exec`, which is what most of a vertical's code does.
285
+ * An adapter that enforced read-only by refusing `ctx.emit` alone passes
286
+ * every other case in this suite and commits this row.
287
+ */
288
+ it('discards a row written with plain SQL — the transaction never commits', async () => {
289
+ const scope = await freshScope();
290
+ const session = await openSession(scope);
291
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
292
+ // It SUCCEEDS: the operation ran, the read it performed is the answer, and
293
+ // the caller gets it back. What does not survive is the write.
294
+ await expect(stub.invoke('perm/write-note', { note: 'support was here' })).resolves.toEqual({
295
+ wrote: 'support was here',
296
+ });
297
+ const asAnna = await host.getScope(anna, t1, scope);
298
+ await expect(asAnna.invoke('perm/read-notes')).resolves.toEqual([]);
299
+ });
300
+ it('still answers reads', async () => {
301
+ const scope = await freshScope();
302
+ const asAnna = await host.getScope(anna, t1, scope);
303
+ await asAnna.invoke('perm/write-note', { note: 'annas own note' });
304
+ const session = await openSession(scope);
305
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
306
+ await expect(stub.invoke('perm/read-notes')).resolves.toEqual(['annas own note']);
307
+ });
308
+ /** A `write` session is an ordinary operation again — and still stamped. */
309
+ it('commits under a write session', async () => {
310
+ const scope = await freshScope();
311
+ const session = await openSession(scope, { mode: 'write' });
312
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
313
+ await stub.invoke('perm/write-note', { note: 'a fix, on purpose' });
314
+ const asAnna = await host.getScope(anna, t1, scope);
315
+ await expect(asAnna.invoke('perm/read-notes')).resolves.toEqual([
316
+ 'a fix, on purpose',
317
+ ]);
318
+ });
319
+ });
320
+ // -- the time box ---------------------------------------------------------
321
+ describe('the session is checked on every invoke, not once at the door', () => {
322
+ it('refuses a stub minted before the session was ended', async () => {
323
+ const scope = await freshScope();
324
+ const session = await openSession(scope, { mode: 'write' });
325
+ const stub = await host.getImpersonatedScope(session.id, t1, scope);
326
+ // It works…
327
+ await expect(stub.invoke('perm/whoami')).resolves.toBe(anna);
328
+ await host.admin.endImpersonation(staff, session.id);
329
+ // …and then it does not. Nothing took the stub away; the session is what
330
+ // was withdrawn, and that has to be enough.
331
+ await expect(stub.invoke('perm/whoami')).rejects.toThrow(/was ended/);
332
+ });
333
+ it('refuses a session pointed at another scope', async () => {
334
+ const scope = await freshScope();
335
+ const other = await freshScope();
336
+ const session = await openSession(scope);
337
+ await expect(host.getImpersonatedScope(session.id, t1, other)).rejects.toThrow(/is for \(/);
338
+ });
339
+ it('refuses a session nobody opened', async () => {
340
+ const scope = await freshScope();
341
+ await expect(host.getImpersonatedScope(ulid(), t1, scope)).rejects.toThrow(/unknown impersonation session/);
342
+ });
343
+ });
344
+ });
345
+ }
346
+ //# sourceMappingURL=impersonation-suite.js.map