@oxyhq/core 3.11.0 → 3.13.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,1097 @@
1
+ /**
2
+ * Civic Mixin tests (Commons "Oxy ID" — Fase 1; anti-gaming — Fase 2;
3
+ * proof-of-personhood web-of-trust — Fase 3).
4
+ *
5
+ * Fase 3 covers the staked vouch: `vouchForPerson` fetches the caller's chain
6
+ * head, signs a self-issued `personhood_vouch` v2 envelope (about=subjectDid,
7
+ * stake from `stakeAmount`, collection `app.oxy.vouch`, rkey=subjectDid), POSTs
8
+ * it, and sweeps the personhood + `/users/me` GET caches; `withdrawVouch` DELETEs
9
+ * + sweeps; `getPersonhood`/`getMyPersonhood` shape the right cached GET.
10
+ *
11
+ * Stubs `makeRequest` so the tests run with no network, then asserts:
12
+ * - Fase 1: `getPublicCard` shapes the request (GET `/civic/:userId/card`,
13
+ * cached) and verifies a GENUINE Oxy signature over `canonicalize(card)` →
14
+ * `verified:true`; a tampered card / wrong key → `verified:false` (NO throw);
15
+ * a `null` attestation → `verified:false`; a transport failure still rejects;
16
+ * `getMyIdPayload` builds the exact `oxycommons://card?did=…&v=1` string and
17
+ * round-trips through `parseIdPayload`, which rejects garbage.
18
+ * - Fase 2: `buildAttestQrPayload` mints a fresh nonce + 10-min exp and encodes
19
+ * the context safely; `parseAttestPayload` round-trips + rejects garbage;
20
+ * `submitRealLifeAttestation` fetches the caller's chain head, signs a
21
+ * self-issued v2 envelope (about=subjectDid, seq=head+1, prev=head id,
22
+ * collection `app.oxy.attestation`, rkey=nonce) and POSTs it;
23
+ * `getValidatorInbox`/`submitValidationVote`/`denyValidation` shape their
24
+ * requests and the vote signs the right verdict envelope.
25
+ *
26
+ * The Fase 1 "genuine" card signatures use real secp256k1 keypairs (the same
27
+ * `ES256K-DER-SHA256` scheme the server uses). The Fase 2 write tests mock
28
+ * `SignatureService.signRecordV2` (asserting the exact record + chain coords) so
29
+ * they isolate the SDK's request shaping from native key storage.
30
+ */
31
+
32
+ import { ec as EC } from 'elliptic';
33
+ import type {
34
+ ExportAttestation,
35
+ PublicCard,
36
+ SignedRecordEnvelope,
37
+ VerifiableCredentialResponse,
38
+ } from '@oxyhq/contracts';
39
+ import { OxyServices } from '../../OxyServices';
40
+ import { canonicalize } from '../../crypto/canonicalJson';
41
+ import { SignatureService } from '../../crypto/signatureService';
42
+ import { parseAttestPayload, parseIdPayload, verifyPublicCardAttestation } from '../OxyServices.civic';
43
+
44
+ const ec = new EC('secp256k1');
45
+
46
+ const baseCard: PublicCard = {
47
+ did: 'did:web:oxy.so:u:user-123',
48
+ userId: 'user-123',
49
+ name: 'Nate',
50
+ username: 'nate',
51
+ avatarUrl: 'https://cloud.oxy.so/file-1',
52
+ trustTier: 'trusted',
53
+ personhoodStatus: 'unverified',
54
+ verifiedDomains: ['nate.com'],
55
+ credentialBadges: [],
56
+ issuedAt: 1700000000000,
57
+ };
58
+
59
+ /** Sign `canonicalize(card)` with a fresh keypair and return the sealed attestation. */
60
+ async function signCard(card: PublicCard): Promise<{ attestation: ExportAttestation; publicKey: string }> {
61
+ const keyPair = ec.genKeyPair();
62
+ const privateKey = keyPair.getPrivate('hex');
63
+ const publicKey = keyPair.getPublic('hex');
64
+ const signature = await SignatureService.signWithKey(canonicalize(card), privateKey);
65
+ return {
66
+ attestation: {
67
+ issuer: 'did:web:api.oxy.so',
68
+ publicKey,
69
+ alg: 'ES256K-DER-SHA256',
70
+ signature,
71
+ signedAt: 1700000000001,
72
+ },
73
+ publicKey,
74
+ };
75
+ }
76
+
77
+ describe('OxyServices.civic', () => {
78
+ let oxy: OxyServices;
79
+ let makeRequestSpy: jest.SpyInstance;
80
+
81
+ beforeEach(() => {
82
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
83
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
84
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue('user-123');
85
+ });
86
+
87
+ afterEach(() => {
88
+ jest.restoreAllMocks();
89
+ });
90
+
91
+ describe('getPublicCard', () => {
92
+ it('GETs /civic/:userId/card (cached) and verifies a genuine Oxy signature', async () => {
93
+ const { attestation } = await signCard(baseCard);
94
+ makeRequestSpy.mockResolvedValue({ card: baseCard, attestation });
95
+
96
+ const result = await oxy.getPublicCard('user-123');
97
+
98
+ expect(makeRequestSpy).toHaveBeenCalledWith(
99
+ 'GET',
100
+ '/civic/user-123/card',
101
+ undefined,
102
+ expect.objectContaining({ cache: true }),
103
+ );
104
+ expect(result.card).toEqual(baseCard);
105
+ expect(result.attestation).toEqual(attestation);
106
+ expect(result.verified).toBe(true);
107
+ });
108
+
109
+ it('URL-encodes the userId path segment', async () => {
110
+ makeRequestSpy.mockResolvedValue({ card: baseCard, attestation: null });
111
+ await oxy.getPublicCard('a/b');
112
+ expect(makeRequestSpy).toHaveBeenCalledWith(
113
+ 'GET',
114
+ '/civic/a%2Fb/card',
115
+ undefined,
116
+ expect.anything(),
117
+ );
118
+ });
119
+
120
+ it('returns verified:false (no throw) when the card was tampered after signing', async () => {
121
+ const { attestation } = await signCard(baseCard);
122
+ // Attestation covers the ORIGINAL card; serve a mutated one.
123
+ const tampered: PublicCard = { ...baseCard, name: 'Eve' };
124
+ makeRequestSpy.mockResolvedValue({ card: tampered, attestation });
125
+
126
+ const result = await oxy.getPublicCard('user-123');
127
+
128
+ expect(result.card).toEqual(tampered);
129
+ expect(result.verified).toBe(false);
130
+ });
131
+
132
+ it('returns verified:false (no throw) when the signature is from a different key', async () => {
133
+ const { attestation } = await signCard(baseCard);
134
+ const otherKey = ec.genKeyPair().getPublic('hex');
135
+ const forged: ExportAttestation = { ...attestation, publicKey: otherKey };
136
+ makeRequestSpy.mockResolvedValue({ card: baseCard, attestation: forged });
137
+
138
+ const result = await oxy.getPublicCard('user-123');
139
+
140
+ expect(result.verified).toBe(false);
141
+ });
142
+
143
+ it('returns verified:false (no throw) for an unsigned card (attestation null)', async () => {
144
+ makeRequestSpy.mockResolvedValue({ card: baseCard, attestation: null });
145
+
146
+ const result = await oxy.getPublicCard('user-123');
147
+
148
+ expect(result.attestation).toBeNull();
149
+ expect(result.verified).toBe(false);
150
+ });
151
+
152
+ it('rejects on a transport failure (the fetch itself)', async () => {
153
+ makeRequestSpy.mockRejectedValue(new Error('network down'));
154
+ await expect(oxy.getPublicCard('user-123')).rejects.toThrow();
155
+ });
156
+ });
157
+
158
+ describe('verifyPublicCardAttestation (pure helper)', () => {
159
+ it('verifies a genuine signature regardless of wire key order', async () => {
160
+ const { attestation } = await signCard(baseCard);
161
+ // A re-keyed object (different insertion order) must canonicalize identically.
162
+ const reordered: PublicCard = {
163
+ issuedAt: baseCard.issuedAt,
164
+ credentialBadges: baseCard.credentialBadges,
165
+ verifiedDomains: baseCard.verifiedDomains,
166
+ personhoodStatus: baseCard.personhoodStatus,
167
+ trustTier: baseCard.trustTier,
168
+ avatarUrl: baseCard.avatarUrl,
169
+ username: baseCard.username,
170
+ name: baseCard.name,
171
+ userId: baseCard.userId,
172
+ did: baseCard.did,
173
+ };
174
+ await expect(verifyPublicCardAttestation(reordered, attestation)).resolves.toBe(true);
175
+ });
176
+
177
+ it('returns false for a null attestation', async () => {
178
+ await expect(verifyPublicCardAttestation(baseCard, null)).resolves.toBe(false);
179
+ });
180
+
181
+ it('returns false when signature or publicKey is empty', async () => {
182
+ const empty: ExportAttestation = {
183
+ issuer: 'did:web:api.oxy.so',
184
+ publicKey: '',
185
+ alg: 'ES256K-DER-SHA256',
186
+ signature: '',
187
+ signedAt: 1,
188
+ };
189
+ await expect(verifyPublicCardAttestation(baseCard, empty)).resolves.toBe(false);
190
+ });
191
+ });
192
+
193
+ describe('getMyIdPayload', () => {
194
+ it('builds oxycommons://card?did=<did>&v=1 for the current user', () => {
195
+ expect(oxy.getMyIdPayload()).toBe('oxycommons://card?did=did:web:oxy.so:u:user-123&v=1');
196
+ });
197
+
198
+ it('throws when no user is authenticated', () => {
199
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
200
+ expect(() => oxy.getMyIdPayload()).toThrow(/No authenticated user/);
201
+ });
202
+ });
203
+
204
+ describe('parseIdPayload', () => {
205
+ it('round-trips the payload getMyIdPayload produces', () => {
206
+ const payload = oxy.getMyIdPayload();
207
+ expect(parseIdPayload(payload)).toEqual({ did: 'did:web:oxy.so:u:user-123' });
208
+ });
209
+
210
+ it('parses a percent-encoded DID', () => {
211
+ const payload = 'oxycommons://card?did=did%3Aweb%3Aoxy.so%3Au%3A42&v=1';
212
+ expect(parseIdPayload(payload)).toEqual({ did: 'did:web:oxy.so:u:42' });
213
+ });
214
+
215
+ it('tolerates a trailing slash before the query', () => {
216
+ expect(parseIdPayload('oxycommons://card/?did=did:web:oxy.so:u:7')).toEqual({
217
+ did: 'did:web:oxy.so:u:7',
218
+ });
219
+ });
220
+
221
+ it('rejects a non-card scheme', () => {
222
+ expect(parseIdPayload('https://evil.example/card?did=did:web:oxy.so:u:1')).toBeNull();
223
+ expect(parseIdPayload('oxycommons://approve?did=did:web:oxy.so:u:1')).toBeNull();
224
+ expect(parseIdPayload('oxycommons://attest?did=did:web:oxy.so:u:1')).toBeNull();
225
+ });
226
+
227
+ it('rejects a card payload with no did', () => {
228
+ expect(parseIdPayload('oxycommons://card?v=1')).toBeNull();
229
+ expect(parseIdPayload('oxycommons://card')).toBeNull();
230
+ });
231
+
232
+ it('rejects empty / non-string input', () => {
233
+ expect(parseIdPayload('')).toBeNull();
234
+ expect(parseIdPayload(' ')).toBeNull();
235
+ // Exercise the runtime guard for non-string callers (JS callers / scanners
236
+ // can pass anything) without an `as any` cast or a ts-ignore directive.
237
+ const notAString: unknown = undefined;
238
+ expect(parseIdPayload(notAString as string)).toBeNull();
239
+ });
240
+ });
241
+
242
+ // ===========================================================================
243
+ // FASE 2 — real-life attestation
244
+ // ===========================================================================
245
+
246
+ describe('buildAttestQrPayload', () => {
247
+ it('builds oxycommons://attest with a fresh nonce + 10-min exp for the current user', async () => {
248
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
249
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('deadbeefnonce');
250
+
251
+ const result = await oxy.buildAttestQrPayload({ context: 'payment-42' });
252
+
253
+ expect(result.nonce).toBe('deadbeefnonce');
254
+ expect(result.exp).toBe(1700000000000 + 10 * 60 * 1000);
255
+ expect(result.payload).toBe(
256
+ 'oxycommons://attest?subject=did:web:oxy.so:u:user-123&ctx=payment-42&nonce=deadbeefnonce&exp=1700000600000',
257
+ );
258
+ });
259
+
260
+ it('URL-encodes the context so a space / & cannot break the query', async () => {
261
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
262
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('n1');
263
+
264
+ const result = await oxy.buildAttestQrPayload({ context: 'a b&c' });
265
+
266
+ expect(result.payload).toContain('&ctx=a%20b%26c&');
267
+ // And it must round-trip back to the original context.
268
+ expect(parseAttestPayload(result.payload)?.context).toBe('a b&c');
269
+ });
270
+
271
+ it('throws when no user is authenticated', async () => {
272
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
273
+ await expect(oxy.buildAttestQrPayload({ context: 'x' })).rejects.toThrow(/No authenticated user/);
274
+ });
275
+ });
276
+
277
+ describe('parseAttestPayload', () => {
278
+ it('round-trips a payload built by buildAttestQrPayload', async () => {
279
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
280
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('abc123');
281
+ const { payload } = await oxy.buildAttestQrPayload({ context: 'ctx-1' });
282
+
283
+ expect(parseAttestPayload(payload)).toEqual({
284
+ subjectDid: 'did:web:oxy.so:u:user-123',
285
+ context: 'ctx-1',
286
+ nonce: 'abc123',
287
+ exp: 1700000600000,
288
+ });
289
+ });
290
+
291
+ it('defaults context to "" when ctx is omitted', () => {
292
+ expect(parseAttestPayload('oxycommons://attest?subject=did:web:oxy.so:u:7&nonce=n&exp=123')).toEqual({
293
+ subjectDid: 'did:web:oxy.so:u:7',
294
+ context: '',
295
+ nonce: 'n',
296
+ exp: 123,
297
+ });
298
+ });
299
+
300
+ it('rejects a non-attest scheme, missing fields, and a bad exp', () => {
301
+ expect(parseAttestPayload('oxycommons://card?did=did:web:oxy.so:u:1')).toBeNull();
302
+ expect(parseAttestPayload('oxycommons://attest?subject=did:web:oxy.so:u:1&exp=1')).toBeNull(); // no nonce
303
+ expect(parseAttestPayload('oxycommons://attest?nonce=n&exp=1')).toBeNull(); // no subject
304
+ expect(parseAttestPayload('oxycommons://attest?subject=d&nonce=n')).toBeNull(); // no exp
305
+ expect(parseAttestPayload('oxycommons://attest?subject=d&nonce=n&exp=notnum')).toBeNull();
306
+ expect(parseAttestPayload('')).toBeNull();
307
+ });
308
+ });
309
+
310
+ describe('submitRealLifeAttestation', () => {
311
+ it('signs a self-issued v2 envelope (about=subjectDid) on the caller chain and POSTs it', async () => {
312
+ const signedEnvelope: SignedRecordEnvelope = {
313
+ version: 2,
314
+ type: 'real_life_attestation',
315
+ subject: 'did:web:oxy.so:u:user-123',
316
+ issuer: 'did:web:oxy.so:u:user-123',
317
+ record: {},
318
+ issuedAt: 1700000000000,
319
+ seq: 4,
320
+ prev: 'rec-3',
321
+ collection: 'app.oxy.attestation',
322
+ rkey: 'nonce-xyz',
323
+ publicKey: 'pub',
324
+ alg: 'ES256K-DER-SHA256',
325
+ signature: 'sig',
326
+ };
327
+ const signV2Spy = jest
328
+ .spyOn(SignatureService, 'signRecordV2')
329
+ .mockResolvedValue(signedEnvelope);
330
+ // 1st makeRequest = chain head; 2nd = POST result.
331
+ makeRequestSpy
332
+ .mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 })
333
+ .mockResolvedValueOnce({
334
+ accepted: true,
335
+ recordId: 'rec-4',
336
+ subjectUserId: 'subject-1',
337
+ attestorUserId: 'user-123',
338
+ points: 25,
339
+ });
340
+
341
+ const result = await oxy.submitRealLifeAttestation({
342
+ subjectDid: 'did:web:oxy.so:u:subject-1',
343
+ context: 'payment-42',
344
+ nonce: 'nonce-xyz',
345
+ exp: 1700000600000,
346
+ geohash: 'u4pruyd',
347
+ biometricOk: true,
348
+ });
349
+
350
+ // Fetched the caller's chain head first (uncached).
351
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
352
+ 1,
353
+ 'GET',
354
+ '/identity/records/user-123/chain/head',
355
+ undefined,
356
+ expect.objectContaining({ cache: false }),
357
+ );
358
+ // Signed a self-issued v2 record: about=subjectDid, seq=head+1, prev=head id,
359
+ // collection app.oxy.attestation, rkey=nonce.
360
+ expect(signV2Spy).toHaveBeenCalledWith(
361
+ 'real_life_attestation',
362
+ 'did:web:oxy.so:u:user-123',
363
+ {
364
+ about: 'did:web:oxy.so:u:subject-1',
365
+ context: 'payment-42',
366
+ nonce: 'nonce-xyz',
367
+ exp: 1700000600000,
368
+ geohash: 'u4pruyd',
369
+ biometricOk: true,
370
+ },
371
+ { seq: 4, prev: 'rec-3', collection: 'app.oxy.attestation', rkey: 'nonce-xyz' },
372
+ );
373
+ // POSTed the signed envelope to /civic/attestations.
374
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
375
+ 2,
376
+ 'POST',
377
+ '/civic/attestations',
378
+ signedEnvelope,
379
+ expect.objectContaining({ cache: false }),
380
+ );
381
+ expect(result.points).toBe(25);
382
+ expect(result.subjectUserId).toBe('subject-1');
383
+ });
384
+
385
+ it('omits optional record keys (geohash/biometricOk) when not provided', async () => {
386
+ const signV2Spy = jest
387
+ .spyOn(SignatureService, 'signRecordV2')
388
+ .mockResolvedValue({} as SignedRecordEnvelope);
389
+ makeRequestSpy
390
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
391
+ .mockResolvedValueOnce({ accepted: true, recordId: 'r', subjectUserId: 's', attestorUserId: 'user-123', points: 25 });
392
+
393
+ await oxy.submitRealLifeAttestation({
394
+ subjectDid: 'did:web:oxy.so:u:s',
395
+ context: 'c',
396
+ nonce: 'n',
397
+ exp: 1700000600000,
398
+ });
399
+
400
+ // Genesis chain coords (no head yet) + a record with ONLY the required keys.
401
+ expect(signV2Spy).toHaveBeenCalledWith(
402
+ 'real_life_attestation',
403
+ 'did:web:oxy.so:u:user-123',
404
+ { about: 'did:web:oxy.so:u:s', context: 'c', nonce: 'n', exp: 1700000600000 },
405
+ { seq: 0, prev: null, collection: 'app.oxy.attestation', rkey: 'n' },
406
+ );
407
+ });
408
+
409
+ it('throws when no user is authenticated (before any network)', async () => {
410
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
411
+ await expect(
412
+ oxy.submitRealLifeAttestation({ subjectDid: 'd', context: 'c', nonce: 'n', exp: 1 }),
413
+ ).rejects.toThrow(/No authenticated user/);
414
+ expect(makeRequestSpy).not.toHaveBeenCalled();
415
+ });
416
+ });
417
+
418
+ // ===========================================================================
419
+ // FASE 2 — validator / jury
420
+ // ===========================================================================
421
+
422
+ describe('getValidatorInbox', () => {
423
+ it('GETs the inbox (uncached) and unwraps requests', async () => {
424
+ const request = {
425
+ id: 'req-1',
426
+ subjectUserId: 'subject-1',
427
+ actionType: 'event_check_in',
428
+ payload: { foo: 'bar' },
429
+ payloadHash: 'hash-1',
430
+ status: 'pending' as const,
431
+ highValue: false,
432
+ expiresAt: '2026-06-27T00:00:00.000Z',
433
+ };
434
+ makeRequestSpy.mockResolvedValue({ requests: [request] });
435
+
436
+ const result = await oxy.getValidatorInbox();
437
+
438
+ expect(result).toEqual([request]);
439
+ expect(makeRequestSpy).toHaveBeenCalledWith(
440
+ 'GET',
441
+ '/civic/validations/inbox',
442
+ undefined,
443
+ expect.objectContaining({ cache: false }),
444
+ );
445
+ });
446
+
447
+ it('defaults to an empty array when requests is absent', async () => {
448
+ makeRequestSpy.mockResolvedValue({});
449
+ await expect(oxy.getValidatorInbox()).resolves.toEqual([]);
450
+ });
451
+ });
452
+
453
+ describe('submitValidationVote', () => {
454
+ it('signs a self-issued verdict envelope bound to requestId+payloadHash and POSTs it', async () => {
455
+ const signedEnvelope: SignedRecordEnvelope = {
456
+ version: 2,
457
+ type: 'validation_verdict',
458
+ subject: 'did:web:oxy.so:u:user-123',
459
+ issuer: 'did:web:oxy.so:u:user-123',
460
+ record: { requestId: 'req-1', payloadHash: 'hash-1', verdict: 'valid' },
461
+ issuedAt: 1700000000000,
462
+ seq: 1,
463
+ prev: 'rec-0',
464
+ collection: 'app.oxy.validation',
465
+ rkey: 'req-1',
466
+ publicKey: 'pub',
467
+ alg: 'ES256K-DER-SHA256',
468
+ signature: 'sig',
469
+ };
470
+ const signV2Spy = jest
471
+ .spyOn(SignatureService, 'signRecordV2')
472
+ .mockResolvedValue(signedEnvelope);
473
+ makeRequestSpy
474
+ .mockResolvedValueOnce({ headRecordId: 'rec-0', seq: 0, recordCount: 1 })
475
+ .mockResolvedValueOnce({ recorded: true, requestId: 'req-1', verdict: 'valid', status: 'quorum_met' });
476
+
477
+ const result = await oxy.submitValidationVote('req-1', 'hash-1', 'valid');
478
+
479
+ expect(signV2Spy).toHaveBeenCalledWith(
480
+ 'validation_verdict',
481
+ 'did:web:oxy.so:u:user-123',
482
+ { requestId: 'req-1', payloadHash: 'hash-1', verdict: 'valid' },
483
+ { seq: 1, prev: 'rec-0', collection: 'app.oxy.validation', rkey: 'req-1' },
484
+ );
485
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
486
+ 2,
487
+ 'POST',
488
+ '/civic/validations/req-1/vote',
489
+ signedEnvelope,
490
+ expect.objectContaining({ cache: false }),
491
+ );
492
+ expect(result).toEqual({ recorded: true, requestId: 'req-1', verdict: 'valid', status: 'quorum_met' });
493
+ });
494
+
495
+ it('throws when no user is authenticated (before any network)', async () => {
496
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
497
+ await expect(oxy.submitValidationVote('req-1', 'hash-1', 'invalid')).rejects.toThrow(
498
+ /No authenticated user/,
499
+ );
500
+ expect(makeRequestSpy).not.toHaveBeenCalled();
501
+ });
502
+ });
503
+
504
+ describe('denyValidation', () => {
505
+ it('POSTs /civic/validations/:id/deny and returns the verdict', async () => {
506
+ makeRequestSpy.mockResolvedValue({ denied: true });
507
+
508
+ const result = await oxy.denyValidation('req-9');
509
+
510
+ expect(result).toEqual({ denied: true });
511
+ expect(makeRequestSpy).toHaveBeenCalledWith(
512
+ 'POST',
513
+ '/civic/validations/req-9/deny',
514
+ undefined,
515
+ expect.objectContaining({ cache: false }),
516
+ );
517
+ });
518
+ });
519
+
520
+ // ===========================================================================
521
+ // FASE 3 — proof-of-personhood web-of-trust (staked vouch)
522
+ // ===========================================================================
523
+
524
+ describe('vouchForPerson', () => {
525
+ it('signs a self-issued v2 vouch envelope (about=subjectDid, stake) on the caller chain and POSTs it', async () => {
526
+ const signedEnvelope: SignedRecordEnvelope = {
527
+ version: 2,
528
+ type: 'personhood_vouch',
529
+ subject: 'did:web:oxy.so:u:user-123',
530
+ issuer: 'did:web:oxy.so:u:user-123',
531
+ record: { about: 'did:web:oxy.so:u:subject-1', stake: 5, biometricOk: true },
532
+ issuedAt: 1700000000000,
533
+ seq: 4,
534
+ prev: 'rec-3',
535
+ collection: 'app.oxy.vouch',
536
+ rkey: 'did:web:oxy.so:u:subject-1',
537
+ publicKey: 'pub',
538
+ alg: 'ES256K-DER-SHA256',
539
+ signature: 'sig',
540
+ };
541
+ const signV2Spy = jest
542
+ .spyOn(SignatureService, 'signRecordV2')
543
+ .mockResolvedValue(signedEnvelope);
544
+ // 1st makeRequest = chain head; 2nd = POST result.
545
+ makeRequestSpy
546
+ .mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 })
547
+ .mockResolvedValueOnce({
548
+ accepted: true,
549
+ recordId: 'rec-4',
550
+ subjectUserId: 'subject-1',
551
+ voucherUserId: 'user-123',
552
+ stakeAmount: 5,
553
+ points: 30,
554
+ });
555
+
556
+ const result = await oxy.vouchForPerson({
557
+ subjectDid: 'did:web:oxy.so:u:subject-1',
558
+ stakeAmount: 5,
559
+ biometricOk: true,
560
+ });
561
+
562
+ // Fetched the caller's chain head first (uncached).
563
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
564
+ 1,
565
+ 'GET',
566
+ '/identity/records/user-123/chain/head',
567
+ undefined,
568
+ expect.objectContaining({ cache: false }),
569
+ );
570
+ // Signed a self-issued v2 record: about=subjectDid, stake=stakeAmount, seq=head+1,
571
+ // prev=head id, collection app.oxy.vouch, rkey=subjectDid.
572
+ expect(signV2Spy).toHaveBeenCalledWith(
573
+ 'personhood_vouch',
574
+ 'did:web:oxy.so:u:user-123',
575
+ { about: 'did:web:oxy.so:u:subject-1', stake: 5, biometricOk: true },
576
+ { seq: 4, prev: 'rec-3', collection: 'app.oxy.vouch', rkey: 'did:web:oxy.so:u:subject-1' },
577
+ );
578
+ // POSTed the signed envelope to /civic/personhood/vouch.
579
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
580
+ 2,
581
+ 'POST',
582
+ '/civic/personhood/vouch',
583
+ signedEnvelope,
584
+ expect.objectContaining({ cache: false }),
585
+ );
586
+ expect(result.stakeAmount).toBe(5);
587
+ expect(result.subjectUserId).toBe('subject-1');
588
+ expect(result.points).toBe(30);
589
+ });
590
+
591
+ it('omits the optional stake/biometricOk record keys when not provided', async () => {
592
+ const signV2Spy = jest
593
+ .spyOn(SignatureService, 'signRecordV2')
594
+ .mockResolvedValue({} as SignedRecordEnvelope);
595
+ makeRequestSpy
596
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
597
+ .mockResolvedValueOnce({
598
+ accepted: true,
599
+ recordId: 'r',
600
+ subjectUserId: 's',
601
+ voucherUserId: 'user-123',
602
+ stakeAmount: 10,
603
+ points: 30,
604
+ });
605
+
606
+ await oxy.vouchForPerson({ subjectDid: 'did:web:oxy.so:u:s' });
607
+
608
+ // Genesis chain coords (no head yet) + a record with ONLY `about`.
609
+ expect(signV2Spy).toHaveBeenCalledWith(
610
+ 'personhood_vouch',
611
+ 'did:web:oxy.so:u:user-123',
612
+ { about: 'did:web:oxy.so:u:s' },
613
+ { seq: 0, prev: null, collection: 'app.oxy.vouch', rkey: 'did:web:oxy.so:u:s' },
614
+ );
615
+ });
616
+
617
+ it('sweeps the personhood + /users/me GET caches after a successful vouch', async () => {
618
+ jest.spyOn(SignatureService, 'signRecordV2').mockResolvedValue({} as SignedRecordEnvelope);
619
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
620
+ makeRequestSpy
621
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
622
+ .mockResolvedValueOnce({
623
+ accepted: true,
624
+ recordId: 'r',
625
+ subjectUserId: 's',
626
+ voucherUserId: 'user-123',
627
+ stakeAmount: 10,
628
+ points: 30,
629
+ });
630
+
631
+ await oxy.vouchForPerson({ subjectDid: 'did:web:oxy.so:u:s' });
632
+
633
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/civic/personhood/');
634
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/users/me');
635
+ });
636
+
637
+ it('does NOT sweep caches when the POST fails', async () => {
638
+ jest.spyOn(SignatureService, 'signRecordV2').mockResolvedValue({} as SignedRecordEnvelope);
639
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
640
+ makeRequestSpy
641
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
642
+ .mockRejectedValueOnce(new Error('already_vouched'));
643
+
644
+ await expect(oxy.vouchForPerson({ subjectDid: 'did:web:oxy.so:u:s' })).rejects.toThrow();
645
+ expect(sweepSpy).not.toHaveBeenCalled();
646
+ });
647
+
648
+ it('throws when no user is authenticated (before any network)', async () => {
649
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
650
+ await expect(oxy.vouchForPerson({ subjectDid: 'did:web:oxy.so:u:s' })).rejects.toThrow(
651
+ /No authenticated user/,
652
+ );
653
+ expect(makeRequestSpy).not.toHaveBeenCalled();
654
+ });
655
+ });
656
+
657
+ describe('withdrawVouch', () => {
658
+ it('DELETEs /civic/personhood/vouch/:subjectUserId and sweeps caches', async () => {
659
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
660
+ makeRequestSpy.mockResolvedValue({ withdrawn: true });
661
+
662
+ const result = await oxy.withdrawVouch('subject-1');
663
+
664
+ expect(result).toEqual({ withdrawn: true });
665
+ expect(makeRequestSpy).toHaveBeenCalledWith(
666
+ 'DELETE',
667
+ '/civic/personhood/vouch/subject-1',
668
+ undefined,
669
+ expect.objectContaining({ cache: false }),
670
+ );
671
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/civic/personhood/');
672
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/users/me');
673
+ });
674
+
675
+ it('URL-encodes the subjectUserId path segment', async () => {
676
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
677
+ makeRequestSpy.mockResolvedValue({ withdrawn: true });
678
+
679
+ await oxy.withdrawVouch('a/b');
680
+
681
+ expect(makeRequestSpy).toHaveBeenCalledWith(
682
+ 'DELETE',
683
+ '/civic/personhood/vouch/a%2Fb',
684
+ undefined,
685
+ expect.anything(),
686
+ );
687
+ });
688
+ });
689
+
690
+ describe('getPersonhood', () => {
691
+ const status = {
692
+ userId: 'subject-1',
693
+ score: 0.82,
694
+ isRealPerson: true,
695
+ vouchCount: 3,
696
+ realLifeCount: 1,
697
+ biometricBound: true,
698
+ sybilPenalty: 0,
699
+ breakdown: {
700
+ vouchSignal: 0.7,
701
+ realLifeSignal: 0.5,
702
+ biometricSignal: 1,
703
+ evidence: 0.82,
704
+ sybilPenalty: 0,
705
+ seed: false,
706
+ },
707
+ updatedAt: '2026-06-27T00:00:00.000Z',
708
+ };
709
+
710
+ it('GETs /civic/personhood/:userId (cached) and returns the snapshot', async () => {
711
+ makeRequestSpy.mockResolvedValue(status);
712
+
713
+ const result = await oxy.getPersonhood('subject-1');
714
+
715
+ expect(result).toEqual(status);
716
+ expect(makeRequestSpy).toHaveBeenCalledWith(
717
+ 'GET',
718
+ '/civic/personhood/subject-1',
719
+ undefined,
720
+ expect.objectContaining({ cache: true }),
721
+ );
722
+ });
723
+
724
+ it('URL-encodes the userId path segment', async () => {
725
+ makeRequestSpy.mockResolvedValue(status);
726
+ await oxy.getPersonhood('a/b');
727
+ expect(makeRequestSpy).toHaveBeenCalledWith(
728
+ 'GET',
729
+ '/civic/personhood/a%2Fb',
730
+ undefined,
731
+ expect.anything(),
732
+ );
733
+ });
734
+ });
735
+
736
+ describe('getMyPersonhood', () => {
737
+ it('GETs the current user id', async () => {
738
+ makeRequestSpy.mockResolvedValue({
739
+ userId: 'user-123',
740
+ score: 0,
741
+ isRealPerson: false,
742
+ vouchCount: 0,
743
+ realLifeCount: 0,
744
+ biometricBound: false,
745
+ sybilPenalty: 0,
746
+ breakdown: null,
747
+ updatedAt: null,
748
+ });
749
+
750
+ const result = await oxy.getMyPersonhood();
751
+
752
+ expect(result.userId).toBe('user-123');
753
+ expect(makeRequestSpy).toHaveBeenCalledWith(
754
+ 'GET',
755
+ '/civic/personhood/user-123',
756
+ undefined,
757
+ expect.objectContaining({ cache: true }),
758
+ );
759
+ });
760
+
761
+ it('throws when no user is authenticated (before any network)', async () => {
762
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
763
+ await expect(oxy.getMyPersonhood()).rejects.toThrow(/No authenticated user/);
764
+ expect(makeRequestSpy).not.toHaveBeenCalled();
765
+ });
766
+ });
767
+
768
+ // ===========================================================================
769
+ // FASE 4 — verifiable credentials
770
+ // ===========================================================================
771
+
772
+ describe('issueCredential', () => {
773
+ const credentialResponse: VerifiableCredentialResponse = {
774
+ id: 'cred-1',
775
+ recordId: 'rec-4',
776
+ holderUserId: 'holder-1',
777
+ holderDid: 'did:web:oxy.so:u:holder-1',
778
+ issuerUserId: 'user-123',
779
+ issuerDid: 'did:web:oxy.so:u:user-123',
780
+ types: ['VerifiableCredential', 'EmploymentCredential'],
781
+ claims: { role: 'Engineer' },
782
+ status: 'active',
783
+ issuedAt: 1700000000000,
784
+ };
785
+
786
+ it('prepends the base type, signs a self-issued v2 record on the caller chain and POSTs it', async () => {
787
+ const signedEnvelope: SignedRecordEnvelope = {
788
+ version: 2,
789
+ type: 'credential',
790
+ subject: 'did:web:oxy.so:u:user-123',
791
+ issuer: 'did:web:oxy.so:u:user-123',
792
+ record: {
793
+ about: 'did:web:oxy.so:u:holder-1',
794
+ types: ['VerifiableCredential', 'EmploymentCredential'],
795
+ claims: { role: 'Engineer' },
796
+ },
797
+ issuedAt: 1700000000000,
798
+ seq: 4,
799
+ prev: 'rec-3',
800
+ collection: 'app.oxy.credential',
801
+ rkey: 'cred-rkey-1',
802
+ publicKey: 'pub',
803
+ alg: 'ES256K-DER-SHA256',
804
+ signature: 'sig',
805
+ };
806
+ const signV2Spy = jest
807
+ .spyOn(SignatureService, 'signRecordV2')
808
+ .mockResolvedValue(signedEnvelope);
809
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('cred-rkey-1');
810
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
811
+ // 1st makeRequest = chain head; 2nd = POST result.
812
+ makeRequestSpy
813
+ .mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 })
814
+ .mockResolvedValueOnce({ accepted: true, credential: credentialResponse });
815
+
816
+ const result = await oxy.issueCredential({
817
+ holderDid: 'did:web:oxy.so:u:holder-1',
818
+ types: ['EmploymentCredential'],
819
+ claims: { role: 'Engineer' },
820
+ });
821
+
822
+ // Fetched the caller's chain head first (uncached).
823
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
824
+ 1,
825
+ 'GET',
826
+ '/identity/records/user-123/chain/head',
827
+ undefined,
828
+ expect.objectContaining({ cache: false }),
829
+ );
830
+ // Signed a self-issued v2 record: about=holderDid, base type PREPENDED,
831
+ // claims verbatim, seq=head+1, prev=head id, collection app.oxy.credential,
832
+ // rkey=fresh nonce.
833
+ expect(signV2Spy).toHaveBeenCalledWith(
834
+ 'credential',
835
+ 'did:web:oxy.so:u:user-123',
836
+ {
837
+ about: 'did:web:oxy.so:u:holder-1',
838
+ types: ['VerifiableCredential', 'EmploymentCredential'],
839
+ claims: { role: 'Engineer' },
840
+ },
841
+ { seq: 4, prev: 'rec-3', collection: 'app.oxy.credential', rkey: 'cred-rkey-1' },
842
+ );
843
+ // POSTed the signed envelope to /civic/credentials.
844
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
845
+ 2,
846
+ 'POST',
847
+ '/civic/credentials',
848
+ signedEnvelope,
849
+ expect.objectContaining({ cache: false }),
850
+ );
851
+ // Swept the credential GET caches.
852
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/civic/credentials/');
853
+ expect(result).toEqual({ accepted: true, credential: credentialResponse });
854
+ });
855
+
856
+ it('does NOT duplicate the base type when the caller already includes it', async () => {
857
+ const signV2Spy = jest
858
+ .spyOn(SignatureService, 'signRecordV2')
859
+ .mockResolvedValue({} as SignedRecordEnvelope);
860
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('rk');
861
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
862
+ makeRequestSpy
863
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
864
+ .mockResolvedValueOnce({ accepted: true, credential: credentialResponse });
865
+
866
+ await oxy.issueCredential({
867
+ holderDid: 'did:web:oxy.so:u:holder-1',
868
+ types: ['VerifiableCredential', 'CourseCredential'],
869
+ claims: {},
870
+ });
871
+
872
+ // Genesis chain coords + the types passed through unchanged (no duplicate base).
873
+ expect(signV2Spy).toHaveBeenCalledWith(
874
+ 'credential',
875
+ 'did:web:oxy.so:u:user-123',
876
+ {
877
+ about: 'did:web:oxy.so:u:holder-1',
878
+ types: ['VerifiableCredential', 'CourseCredential'],
879
+ claims: {},
880
+ },
881
+ { seq: 0, prev: null, collection: 'app.oxy.credential', rkey: 'rk' },
882
+ );
883
+ });
884
+
885
+ it('converts an ISO expiresAt to epoch ms in the signed record', async () => {
886
+ const signV2Spy = jest
887
+ .spyOn(SignatureService, 'signRecordV2')
888
+ .mockResolvedValue({} as SignedRecordEnvelope);
889
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('rk');
890
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
891
+ makeRequestSpy
892
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
893
+ .mockResolvedValueOnce({ accepted: true, credential: credentialResponse });
894
+
895
+ await oxy.issueCredential({
896
+ holderDid: 'did:web:oxy.so:u:holder-1',
897
+ types: ['EmploymentCredential'],
898
+ claims: { role: 'Engineer' },
899
+ expiresAt: '2030-01-01T00:00:00.000Z',
900
+ });
901
+
902
+ expect(signV2Spy).toHaveBeenCalledWith(
903
+ 'credential',
904
+ 'did:web:oxy.so:u:user-123',
905
+ {
906
+ about: 'did:web:oxy.so:u:holder-1',
907
+ types: ['VerifiableCredential', 'EmploymentCredential'],
908
+ claims: { role: 'Engineer' },
909
+ expiresAt: Date.parse('2030-01-01T00:00:00.000Z'),
910
+ },
911
+ { seq: 0, prev: null, collection: 'app.oxy.credential', rkey: 'rk' },
912
+ );
913
+ });
914
+
915
+ it('throws on an unparseable expiresAt (before any signing or network)', async () => {
916
+ const signV2Spy = jest.spyOn(SignatureService, 'signRecordV2');
917
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('rk');
918
+
919
+ await expect(
920
+ oxy.issueCredential({
921
+ holderDid: 'did:web:oxy.so:u:holder-1',
922
+ types: ['EmploymentCredential'],
923
+ claims: {},
924
+ expiresAt: 'not-a-date',
925
+ }),
926
+ ).rejects.toThrow(/Invalid expiresAt/);
927
+ expect(signV2Spy).not.toHaveBeenCalled();
928
+ expect(makeRequestSpy).not.toHaveBeenCalled();
929
+ });
930
+
931
+ it('does NOT sweep caches when the POST fails', async () => {
932
+ jest.spyOn(SignatureService, 'signRecordV2').mockResolvedValue({} as SignedRecordEnvelope);
933
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('rk');
934
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
935
+ makeRequestSpy
936
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
937
+ .mockRejectedValueOnce(new Error('self_credential'));
938
+
939
+ await expect(
940
+ oxy.issueCredential({ holderDid: 'did:web:oxy.so:u:holder-1', types: ['X'], claims: {} }),
941
+ ).rejects.toThrow();
942
+ expect(sweepSpy).not.toHaveBeenCalled();
943
+ });
944
+
945
+ it('throws when no user is authenticated (before any network)', async () => {
946
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
947
+ jest.spyOn(SignatureService, 'generateChallenge').mockResolvedValue('rk');
948
+ await expect(
949
+ oxy.issueCredential({ holderDid: 'did:web:oxy.so:u:holder-1', types: ['X'], claims: {} }),
950
+ ).rejects.toThrow(/No authenticated user/);
951
+ expect(makeRequestSpy).not.toHaveBeenCalled();
952
+ });
953
+ });
954
+
955
+ describe('listCredentials', () => {
956
+ const listResult = { credentials: [] };
957
+
958
+ it('GETs /civic/credentials/:holderUserId (cached) with no status filter', async () => {
959
+ makeRequestSpy.mockResolvedValue(listResult);
960
+
961
+ const result = await oxy.listCredentials('holder-1');
962
+
963
+ expect(result).toEqual(listResult);
964
+ expect(makeRequestSpy).toHaveBeenCalledWith(
965
+ 'GET',
966
+ '/civic/credentials/holder-1',
967
+ undefined,
968
+ expect.objectContaining({ cache: true }),
969
+ );
970
+ });
971
+
972
+ it('appends the ?status= filter when provided', async () => {
973
+ makeRequestSpy.mockResolvedValue(listResult);
974
+
975
+ await oxy.listCredentials('holder-1', { status: 'revoked' });
976
+
977
+ expect(makeRequestSpy).toHaveBeenCalledWith(
978
+ 'GET',
979
+ '/civic/credentials/holder-1?status=revoked',
980
+ undefined,
981
+ expect.objectContaining({ cache: true }),
982
+ );
983
+ });
984
+
985
+ it('URL-encodes the holderUserId path segment', async () => {
986
+ makeRequestSpy.mockResolvedValue(listResult);
987
+ await oxy.listCredentials('a/b');
988
+ expect(makeRequestSpy).toHaveBeenCalledWith(
989
+ 'GET',
990
+ '/civic/credentials/a%2Fb',
991
+ undefined,
992
+ expect.anything(),
993
+ );
994
+ });
995
+ });
996
+
997
+ describe('listMyCredentials', () => {
998
+ it('lists the current user id, forwarding the status filter', async () => {
999
+ makeRequestSpy.mockResolvedValue({ credentials: [] });
1000
+
1001
+ await oxy.listMyCredentials({ status: 'active' });
1002
+
1003
+ expect(makeRequestSpy).toHaveBeenCalledWith(
1004
+ 'GET',
1005
+ '/civic/credentials/user-123?status=active',
1006
+ undefined,
1007
+ expect.objectContaining({ cache: true }),
1008
+ );
1009
+ });
1010
+
1011
+ it('throws when no user is authenticated (before any network)', async () => {
1012
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
1013
+ await expect(oxy.listMyCredentials()).rejects.toThrow(/No authenticated user/);
1014
+ expect(makeRequestSpy).not.toHaveBeenCalled();
1015
+ });
1016
+ });
1017
+
1018
+ describe('verifyCredential', () => {
1019
+ it('GETs the by-record verify endpoint (cached) and returns the verdict', async () => {
1020
+ const verdict = { valid: true, credential: null };
1021
+ makeRequestSpy.mockResolvedValue(verdict);
1022
+
1023
+ const result = await oxy.verifyCredential('rec-4');
1024
+
1025
+ expect(result).toEqual(verdict);
1026
+ expect(makeRequestSpy).toHaveBeenCalledWith(
1027
+ 'GET',
1028
+ '/civic/credentials/by-record/rec-4/verify',
1029
+ undefined,
1030
+ expect.objectContaining({ cache: true }),
1031
+ );
1032
+ });
1033
+
1034
+ it('URL-encodes the recordId path segment', async () => {
1035
+ makeRequestSpy.mockResolvedValue({ valid: false, reason: 'not_found', credential: null });
1036
+ await oxy.verifyCredential('a/b');
1037
+ expect(makeRequestSpy).toHaveBeenCalledWith(
1038
+ 'GET',
1039
+ '/civic/credentials/by-record/a%2Fb/verify',
1040
+ undefined,
1041
+ expect.anything(),
1042
+ );
1043
+ });
1044
+ });
1045
+
1046
+ describe('revokeCredential', () => {
1047
+ const credentialResponse: VerifiableCredentialResponse = {
1048
+ id: 'cred-1',
1049
+ recordId: 'rec-4',
1050
+ holderUserId: 'holder-1',
1051
+ holderDid: 'did:web:oxy.so:u:holder-1',
1052
+ issuerUserId: 'user-123',
1053
+ issuerDid: 'did:web:oxy.so:u:user-123',
1054
+ types: ['VerifiableCredential', 'EmploymentCredential'],
1055
+ claims: {},
1056
+ status: 'revoked',
1057
+ issuedAt: 1700000000000,
1058
+ revokedAt: 1700000600000,
1059
+ };
1060
+
1061
+ it('POSTs /civic/credentials/:id/revoke and sweeps caches', async () => {
1062
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
1063
+ makeRequestSpy.mockResolvedValue({ revoked: true, credential: credentialResponse });
1064
+
1065
+ const result = await oxy.revokeCredential('cred-1');
1066
+
1067
+ expect(result).toEqual({ revoked: true, credential: credentialResponse });
1068
+ expect(makeRequestSpy).toHaveBeenCalledWith(
1069
+ 'POST',
1070
+ '/civic/credentials/cred-1/revoke',
1071
+ undefined,
1072
+ expect.objectContaining({ cache: false }),
1073
+ );
1074
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/civic/credentials/');
1075
+ });
1076
+
1077
+ it('URL-encodes the id path segment', async () => {
1078
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
1079
+ makeRequestSpy.mockResolvedValue({ revoked: true, credential: credentialResponse });
1080
+ await oxy.revokeCredential('a/b');
1081
+ expect(makeRequestSpy).toHaveBeenCalledWith(
1082
+ 'POST',
1083
+ '/civic/credentials/a%2Fb/revoke',
1084
+ undefined,
1085
+ expect.anything(),
1086
+ );
1087
+ });
1088
+
1089
+ it('does NOT sweep caches when the POST fails', async () => {
1090
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
1091
+ makeRequestSpy.mockRejectedValue(new Error('not_issuer'));
1092
+
1093
+ await expect(oxy.revokeCredential('cred-1')).rejects.toThrow();
1094
+ expect(sweepSpy).not.toHaveBeenCalled();
1095
+ });
1096
+ });
1097
+ });