@openwop/openwop-conformance 2.46.42 → 2.46.44
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/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-loopback-refine-fail.json +92 -0
- package/fixtures/conformance-loopback-refine.json +91 -0
- package/fixtures.md +25 -0
- package/package.json +2 -2
- package/requirements.json +314 -20
- package/scenario-majors.json +8 -2
- package/schemas/CORPUS-STAMP.json +22 -21
- package/src/lib/bounded-loopback-witness.ts +125 -0
- package/src/scenarios/fixtures-valid.test.ts +14 -2
- package/src/scenarios/v2-bounded-loopback.test.ts +231 -0
- package/src/scenarios/v2-interrupt-resolved-via.test.ts +257 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — `interrupt.resolved` records the instrument of a token resolution
|
|
3
|
+
* (RFC 0259; the rules are `$defs/interruptResolved.resolvedBy` and
|
|
4
|
+
* `.resolvedVia` in `schemas/v2/run-event-payloads.schema.json`, and the
|
|
5
|
+
* `interrupt.resolvedVia` facet in `spec/v2/facets/interrupt.schema.json`).
|
|
6
|
+
*
|
|
7
|
+
* Server-free (always on):
|
|
8
|
+
* 1. `0259.shape` — `resolvedVia` is a closed object with a REQUIRED
|
|
9
|
+
* `scheme` from a closed vocabulary; `tokenRef` is opaque; `boundTo` is a
|
|
10
|
+
* Subject of kind `user` or `agent` with a non-legacy issuer.
|
|
11
|
+
*
|
|
12
|
+
* Host legs — each needs the resume token, which v2 gives no unaided way to
|
|
13
|
+
* read (interrupt.md: the signed-token surface is a SHOULD and the suite cannot
|
|
14
|
+
* mint a token). They are gated on the seams profile and the
|
|
15
|
+
* `readInterruptToken` seam (RFC 0259 §C.1): unadvertised → `inapplicable`; an
|
|
16
|
+
* advertised profile with the seam unwired → `blocked`.
|
|
17
|
+
* 2. `0259.token-resolve-names-no-subject` — UNCONDITIONAL (§A.1): an
|
|
18
|
+
* unauthenticated `POST /interrupts/{token}` records no `resolvedBy`.
|
|
19
|
+
* 3. `0259.resolved-via-recorded` — gated on `interrupt.resolvedVia: true`:
|
|
20
|
+
* the same resolution records `resolvedVia.scheme: "interrupt-token"`
|
|
21
|
+
* and, with no binding asked for, no `boundTo`.
|
|
22
|
+
* 4. `0259.token-ref-opaque` — gated as 3, and on `tokenRef` being present:
|
|
23
|
+
* it is not the token, contains no segment of it, and is not a prefix of
|
|
24
|
+
* a digest of the token or a segment in hex, base64 or base64url.
|
|
25
|
+
* 5. `0259.run-resolve-no-via` — gated as 3: a run-scoped resolution
|
|
26
|
+
* records no `resolvedVia`.
|
|
27
|
+
* 6. `0259.bound-to-caller` — gated as 3, and on the seam returning
|
|
28
|
+
* `resumeValueMembers` for `bind: true`: `boundTo` deep-equals the
|
|
29
|
+
* `run.started.owner.subject` of the run the caller created.
|
|
30
|
+
* 7. `0259.forged-binding-binds-no-one` — gated as 6: the same binding with
|
|
31
|
+
* every returned member altered by one character (a token bound to the
|
|
32
|
+
* caller, presented as bound to someone else) records no `boundTo`; the
|
|
33
|
+
* host may refuse the resolution instead.
|
|
34
|
+
*
|
|
35
|
+
* Fork copying `resolvedVia` verbatim (§A.5) is not a leg here: it is the
|
|
36
|
+
* general rule that a fork copies the recorded resolution, witnessed for
|
|
37
|
+
* `decision` / `action` / `reason` by `v2-approval-reject-disposition` leg 9.
|
|
38
|
+
*
|
|
39
|
+
* @see RFCS/0259-interrupt-resolved-via.md
|
|
40
|
+
* @see spec/v2/core/interrupt.md §Resolve surfaces
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
44
|
+
import { describe, it, expect } from 'vitest';
|
|
45
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
46
|
+
import { v2Discovery, v2RefValidator } from '../lib/v2.js';
|
|
47
|
+
import { SEAMS_PREFIX, seamsProfileAdvertised } from '../lib/seams.js';
|
|
48
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
49
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
50
|
+
import { seamAbsent, softSkip } from '../lib/soft-skip.js';
|
|
51
|
+
import { req } from '../lib/requirement-ids.js';
|
|
52
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
53
|
+
|
|
54
|
+
const DOC = 'run-event-payloads.schema.json $defs/interruptResolved.resolvedBy + .resolvedVia (RFC 0259)';
|
|
55
|
+
const R = (slug: string): string => `openwop.requirement.0259.${slug}`;
|
|
56
|
+
const FIXTURE = 'conformance-approval';
|
|
57
|
+
const NODE_ID = 'gate';
|
|
58
|
+
const SEAM = `${SEAMS_PREFIX}/sample/interrupts/token`;
|
|
59
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
60
|
+
|
|
61
|
+
interface Ev { type?: string; nodeId?: string; sequence?: number; payload?: Record<string, unknown> }
|
|
62
|
+
|
|
63
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
64
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
65
|
+
const obj = (x: unknown): Record<string, unknown> | undefined => (x !== null && typeof x === 'object' && !Array.isArray(x) ? (x as Record<string, unknown>) : undefined);
|
|
66
|
+
|
|
67
|
+
async function snapshot(runId: string): Promise<Record<string, unknown> | null> {
|
|
68
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
69
|
+
return res?.status === 200 ? (obj(res.json) ?? null) : null;
|
|
70
|
+
}
|
|
71
|
+
async function waitStatus(runId: string, wanted: ReadonlySet<string>): Promise<Record<string, unknown> | null> {
|
|
72
|
+
const deadline = Date.now() + scaledTimeoutMs(10_000);
|
|
73
|
+
for (;;) {
|
|
74
|
+
const s = await snapshot(runId);
|
|
75
|
+
if (s !== null && wanted.has(String(s['status']))) return s;
|
|
76
|
+
if (Date.now() > deadline) return s;
|
|
77
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function logOf(runId: string): Promise<Ev[]> {
|
|
81
|
+
const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
|
|
82
|
+
const events = obj(res?.json)?.['events'];
|
|
83
|
+
return Array.isArray(events) ? (events as Ev[]) : [];
|
|
84
|
+
}
|
|
85
|
+
async function resolvedPayload(runId: string): Promise<Record<string, unknown> | undefined> {
|
|
86
|
+
const deadline = Date.now() + scaledTimeoutMs(10_000);
|
|
87
|
+
for (;;) {
|
|
88
|
+
const e = (await logOf(runId)).find((x) => x.type === 'interrupt.resolved' && x.nodeId === NODE_ID);
|
|
89
|
+
if (e) return obj(e.payload);
|
|
90
|
+
if (Date.now() > deadline) return undefined;
|
|
91
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A suspended `conformance-approval` run, or why there is none. */
|
|
96
|
+
async function suspended(): Promise<{ runId: string } | { reason: string }> {
|
|
97
|
+
const res = await http(() => driver.post('/runs', { workflowId: FIXTURE }));
|
|
98
|
+
const runId = obj(res?.json)?.['runId'];
|
|
99
|
+
if (res === null || res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${FIXTURE}) answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''}`.trim() };
|
|
100
|
+
const s = await waitStatus(runId, new Set(['waiting-approval', ...TERMINAL]));
|
|
101
|
+
if (s?.['status'] !== 'waiting-approval') return { reason: `the ${FIXTURE} run did not suspend on its approval gate (status ${String(s?.['status'])})` };
|
|
102
|
+
return { runId };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type Gate = { inapplicable: string } | { blocked: string } | { ok: true; doc: Record<string, unknown> | null };
|
|
106
|
+
async function seamGate(): Promise<Gate> {
|
|
107
|
+
if (!isFixtureAdvertised(FIXTURE)) return { inapplicable: `fixture ${FIXTURE} is not advertised` };
|
|
108
|
+
const d = await v2Discovery().catch(() => null);
|
|
109
|
+
if (!seamsProfileAdvertised(d)) return { inapplicable: 'seams profile not advertised — v2 gives the suite no unaided way to read a resume token (interrupt.md §Resolve surfaces), so a token resolution is seam-gated' };
|
|
110
|
+
return { ok: true, doc: d };
|
|
111
|
+
}
|
|
112
|
+
const facetOn = (doc: Record<string, unknown> | null): boolean => obj(doc?.['interrupt'])?.['resolvedVia'] === true;
|
|
113
|
+
|
|
114
|
+
/** Suspend, read the token (and a binding when asked), resolve accept through the token surface unauthenticated. */
|
|
115
|
+
const alter = (v: unknown): unknown => (typeof v === 'string' && v.length > 0 ? `${v.slice(0, -1)}${v.endsWith('x') ? 'y' : 'x'}` : v);
|
|
116
|
+
|
|
117
|
+
async function tokenResolution(bind: boolean, forge = false): Promise<{ runId: string; token: string; bound: boolean; refused: boolean; payload: Record<string, unknown> | undefined } | { blocked: string } | { inapplicable: string }> {
|
|
118
|
+
const s = await suspended();
|
|
119
|
+
if ('reason' in s) return { blocked: s.reason };
|
|
120
|
+
const seam = await http(() => driver.post(SEAM, { runId: s.runId, nodeId: NODE_ID, ...(bind ? { bind: true } : {}) }));
|
|
121
|
+
const token = obj(seam?.json)?.['token'];
|
|
122
|
+
if (seam === null || seam.status !== 200 || typeof token !== 'string') return { blocked: `the readInterruptToken seam answered ${seam?.status ?? 'nothing'} (RFC 0259 §C.1) — the token resolution is unobserved` };
|
|
123
|
+
const members = obj(obj(seam.json)?.['resumeValueMembers']);
|
|
124
|
+
if (bind && members === undefined) return { inapplicable: 'the host returned no resumeValueMembers for bind: true — it binds no presentation to this caller' };
|
|
125
|
+
const presented = forge && members !== undefined ? Object.fromEntries(Object.entries(members).map(([k, v]) => [k, alter(v)])) : members;
|
|
126
|
+
const res = await http(() => driver.post(`/interrupts/${enc(token)}`, { resumeValue: { action: 'accept', ...(presented ?? {}) } }, { authenticated: false }));
|
|
127
|
+
if (forge && res !== null && (res.status === 400 || res.status === 403)) return { runId: s.runId, token, bound: false, refused: true, payload: undefined };
|
|
128
|
+
if (res === null || res.status < 200 || res.status >= 300) return { blocked: `POST /interrupts/{token} answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract` };
|
|
129
|
+
return { runId: s.runId, token, bound: members !== undefined, refused: false, payload: await resolvedPayload(s.runId) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let unbound: ReturnType<typeof tokenResolution> | undefined;
|
|
133
|
+
const unboundResolution = (): ReturnType<typeof tokenResolution> => (unbound ??= tokenResolution(false));
|
|
134
|
+
|
|
135
|
+
/** sha256 of `s` in hex, base64 and base64url — the bare digests a token-derived tokenRef would carry. */
|
|
136
|
+
function digests(s: string): string[] {
|
|
137
|
+
const d = createHash('sha256').update(s).digest();
|
|
138
|
+
return [d.toString('hex'), d.toString('base64').replace(/=+$/, ''), d.toString('base64url')];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Why `tokenRef` is not opaque, if it is not. A tokenRef carrying a bare digest
|
|
142
|
+
* of the token, or any prefix of one, shares an 8-character run with that
|
|
143
|
+
* digest; for an independent value the chance of that is negligible (an
|
|
144
|
+
* 8-character run over a 64-symbol alphabet), so the test has no practical
|
|
145
|
+
* false positive and catches the truncated-hash convention.
|
|
146
|
+
*/
|
|
147
|
+
function tokenRefFindings(tokenRef: string, token: string): string[] {
|
|
148
|
+
const out: string[] = [];
|
|
149
|
+
const segments = [token, ...token.split('.').filter((x) => x.length >= 8)];
|
|
150
|
+
const windows = Array.from({ length: Math.max(0, tokenRef.length - 7) }, (_, i) => tokenRef.slice(i, i + 8));
|
|
151
|
+
for (const seg of segments) {
|
|
152
|
+
if (tokenRef.includes(seg)) out.push('tokenRef MUST NOT equal or contain the token or any of its segments');
|
|
153
|
+
if (digests(seg).some((d) => windows.some((w) => d.includes(w)))) out.push('tokenRef MUST NOT be computable from the token without a host-held secret: it shares a run with a bare sha256 of the token or a segment');
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe('v2 RFC 0259 — resolvedVia wire shape (server-free)', () => {
|
|
159
|
+
it('resolvedVia is a closed object with a registered scheme; boundTo is a non-legacy user or agent Subject', () => {
|
|
160
|
+
const v = v2RefValidator('run-event-payloads.schema.json#/$defs/interruptResolved');
|
|
161
|
+
const base = { nodeId: 'gate', interruptId: 'acme/0123456789abcdef', kind: 'approval', decision: 'granted', action: 'accept' };
|
|
162
|
+
const person = { issuer: 'urn:example:session', subjectId: 'u_7f3a9c1e', tenant: 'acme', lane: 'session', kind: 'user' };
|
|
163
|
+
expect(v(base).ok, req(R('shape'), DOC, 'interrupt.resolved without resolvedVia MUST validate')).toBe(true);
|
|
164
|
+
expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token' } }).ok, req(R('shape'), DOC, 'a bare token resolution (no resolvedBy) with resolvedVia MUST validate')).toBe(true);
|
|
165
|
+
expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', tokenRef: 'tr_0000synthetic0000', boundTo: person } }).ok, req(R('shape'), DOC, 'tokenRef and a user boundTo MUST validate')).toBe(true);
|
|
166
|
+
expect(v({ ...base, resolvedVia: {} }).ok, req(R('shape'), DOC, 'scheme is REQUIRED')).toBe(false);
|
|
167
|
+
expect(v({ ...base, resolvedVia: { scheme: 'share-link' } }).ok, req(R('shape'), DOC, 'an unregistered scheme MUST fail')).toBe(false);
|
|
168
|
+
expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', token: 'ow2.hs256.k.p.m' } }).ok, req(R('shape'), DOC, 'resolvedVia is closed: a token member MUST fail')).toBe(false);
|
|
169
|
+
expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', tokenRef: 'ow2.hs256.k.p.m' } }).ok, req(R('shape'), DOC, 'tokenRef is opaque: a dotted token-shaped value MUST fail the grammar')).toBe(false);
|
|
170
|
+
expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', boundTo: { ...person, kind: 'anonymous', lane: 'anonymous' } } }).ok, req(R('shape'), DOC, 'boundTo of kind anonymous MUST fail')).toBe(false);
|
|
171
|
+
expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', boundTo: { ...person, kind: 'workload', lane: 'workload' } } }).ok, req(R('shape'), DOC, 'boundTo of kind workload MUST fail')).toBe(false);
|
|
172
|
+
expect(v({ ...base, resolvedVia: { scheme: 'interrupt-token', boundTo: { ...person, issuer: 'urn:openwop:legacy' } } }).ok, req(R('shape'), DOC, 'boundTo with the legacy issuer MUST fail')).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('v2 RFC 0259 — token resolutions (host, seam-gated)', () => {
|
|
177
|
+
it('an unauthenticated token resolution records no resolvedBy', async () => {
|
|
178
|
+
const g = await seamGate();
|
|
179
|
+
if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
|
|
180
|
+
if ('blocked' in g) return seamAbsent(g.blocked);
|
|
181
|
+
const r = await unboundResolution();
|
|
182
|
+
if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
|
|
183
|
+
if ('blocked' in r) return seamAbsent(r.blocked);
|
|
184
|
+
expect(r.payload, req(R('token-resolve-names-no-subject'), DOC, 'the token resolution logged no interrupt.resolved for the gate')).toBeDefined();
|
|
185
|
+
expect(r.payload?.['resolvedBy'], req(R('token-resolve-names-no-subject'), DOC, 'a resolution whose request authenticated nothing MUST NOT record resolvedBy (§A.1)')).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('a host advertising interrupt.resolvedVia records scheme interrupt-token and no boundTo on an unbound token resolution', async () => {
|
|
189
|
+
const g = await seamGate();
|
|
190
|
+
if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
|
|
191
|
+
if ('blocked' in g) return seamAbsent(g.blocked);
|
|
192
|
+
if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
|
|
193
|
+
const r = await unboundResolution();
|
|
194
|
+
if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
|
|
195
|
+
if ('blocked' in r) return seamAbsent(r.blocked);
|
|
196
|
+
const via = obj(r.payload?.['resolvedVia']);
|
|
197
|
+
expect(via?.['scheme'], req(R('resolved-via-recorded'), DOC, 'a host advertising interrupt.resolvedVia MUST record resolvedVia.scheme "interrupt-token" on a token resolution')).toBe('interrupt-token');
|
|
198
|
+
expect(via?.['boundTo'], req(R('resolved-via-recorded'), DOC, 'boundTo MUST be absent when the host bound the presentation to no one')).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('tokenRef, when present, is not the token and not computable from it', async () => {
|
|
202
|
+
const g = await seamGate();
|
|
203
|
+
if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
|
|
204
|
+
if ('blocked' in g) return seamAbsent(g.blocked);
|
|
205
|
+
if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
|
|
206
|
+
const r = await unboundResolution();
|
|
207
|
+
if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
|
|
208
|
+
if ('blocked' in r) return seamAbsent(r.blocked);
|
|
209
|
+
const tokenRef = obj(r.payload?.['resolvedVia'])?.['tokenRef'];
|
|
210
|
+
if (typeof tokenRef !== 'string') return softSkip('inapplicable', 'the host records no tokenRef (OPTIONAL)');
|
|
211
|
+
expect(tokenRefFindings(tokenRef, r.token), req(R('token-ref-opaque'), DOC, 'tokenRef MUST NOT equal, contain or be a bare digest of the token or its segments (§A.3)')).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('a run-scoped resolution records no resolvedVia', async () => {
|
|
215
|
+
const g = await seamGate();
|
|
216
|
+
if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
|
|
217
|
+
if ('blocked' in g) return seamAbsent(g.blocked);
|
|
218
|
+
if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
|
|
219
|
+
const s = await suspended();
|
|
220
|
+
if ('reason' in s) return seamAbsent(s.reason);
|
|
221
|
+
const res = await http(() => driver.post(`/runs/${enc(s.runId)}/interrupts/${enc(NODE_ID)}`, { resumeValue: { action: 'accept' } }));
|
|
222
|
+
if (res === null || res.status < 200 || res.status >= 300) return seamAbsent(`the run-scoped resolve answered ${res?.status ?? 'nothing'} — interrupt resolution owns that contract`);
|
|
223
|
+
const payload = await resolvedPayload(s.runId);
|
|
224
|
+
expect(payload, req(R('run-resolve-no-via'), DOC, 'the run-scoped resolution logged no interrupt.resolved for the gate')).toBeDefined();
|
|
225
|
+
expect(payload?.['resolvedVia'], req(R('run-resolve-no-via'), DOC, 'resolvedVia MUST NOT be recorded on a resolution that presented no token')).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('a binding the host made to the caller is recorded as boundTo, equal to the caller\'s owner subject', async () => {
|
|
229
|
+
const g = await seamGate();
|
|
230
|
+
if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
|
|
231
|
+
if ('blocked' in g) return seamAbsent(g.blocked);
|
|
232
|
+
if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
|
|
233
|
+
const r = await tokenResolution(true);
|
|
234
|
+
if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
|
|
235
|
+
if ('blocked' in r) return seamAbsent(r.blocked);
|
|
236
|
+
const owner = obj(obj((await logOf(r.runId)).find((e) => e.type === 'run.started')?.payload?.['owner'])?.['subject']);
|
|
237
|
+
if (owner === undefined) return seamAbsent('run.started carried no owner.subject to compare boundTo against');
|
|
238
|
+
expect(obj(r.payload?.['resolvedVia'])?.['boundTo'], req(R('bound-to-caller'), DOC, 'boundTo MUST be the Subject the host bound the presentation to, built with the owner.subject mapping (§A.4)')).toEqual(owner);
|
|
239
|
+
expect(r.payload?.['resolvedBy'], req(R('bound-to-caller'), DOC, 'a bound but unauthenticated presentation still MUST NOT record resolvedBy (§A.1)')).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('a binding altered to claim someone else binds no one', async () => {
|
|
243
|
+
const g = await seamGate();
|
|
244
|
+
if ('inapplicable' in g) return softSkip('inapplicable', g.inapplicable);
|
|
245
|
+
if ('blocked' in g) return seamAbsent(g.blocked);
|
|
246
|
+
if (!facetOn(g.doc)) return softSkip('inapplicable', 'interrupt.resolvedVia is not advertised');
|
|
247
|
+
const r = await tokenResolution(true, true);
|
|
248
|
+
if ('inapplicable' in r) return softSkip('inapplicable', r.inapplicable);
|
|
249
|
+
if ('blocked' in r) return seamAbsent(r.blocked);
|
|
250
|
+
if (r.refused) {
|
|
251
|
+
expect(r.refused, req(R('forged-binding-binds-no-one'), DOC, 'refusing a presentation whose binding does not verify satisfies §A.4')).toBe(true);
|
|
252
|
+
} else {
|
|
253
|
+
expect(obj(r.payload?.['resolvedVia'])?.['boundTo'], req(R('forged-binding-binds-no-one'), DOC, 'a host MUST NOT set boundTo from a binding it did not make (§A.4)')).toBeUndefined();
|
|
254
|
+
expect(r.payload?.['resolvedBy'], req(R('forged-binding-binds-no-one'), DOC, 'and MUST NOT record the claimed name in resolvedBy (§A.1)')).toBeUndefined();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|