@oxyhq/core 3.10.0 → 3.11.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.
Files changed (105) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +9 -2
  3. package/dist/cjs/HttpService.js +27 -9
  4. package/dist/cjs/OxyServices.base.js +3 -2
  5. package/dist/cjs/crypto/canonicalJson.js +107 -0
  6. package/dist/cjs/crypto/keyManager.js +67 -8
  7. package/dist/cjs/crypto/signatureService.js +103 -0
  8. package/dist/cjs/i18n/locales/en-US.json +9 -0
  9. package/dist/cjs/i18n/locales/es-ES.json +9 -0
  10. package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
  11. package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
  12. package/dist/cjs/index.js +15 -5
  13. package/dist/cjs/mixins/OxyServices.assets.js +45 -7
  14. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  15. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  16. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  17. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  18. package/dist/cjs/mixins/OxyServices.utility.js +52 -23
  19. package/dist/cjs/mixins/index.js +3 -0
  20. package/dist/cjs/server/cors.js +20 -21
  21. package/dist/cjs/server/rateLimit.js +32 -8
  22. package/dist/cjs/utils/fapiAutoDetect.js +12 -42
  23. package/dist/cjs/utils/ssoReturn.js +1 -1
  24. package/dist/esm/.tsbuildinfo +1 -1
  25. package/dist/esm/AuthManager.js +9 -2
  26. package/dist/esm/HttpService.js +27 -9
  27. package/dist/esm/OxyServices.base.js +3 -2
  28. package/dist/esm/crypto/canonicalJson.js +104 -0
  29. package/dist/esm/crypto/keyManager.js +67 -8
  30. package/dist/esm/crypto/signatureService.js +102 -0
  31. package/dist/esm/i18n/locales/en-US.json +9 -0
  32. package/dist/esm/i18n/locales/es-ES.json +9 -0
  33. package/dist/esm/i18n/locales/locales/en-US.json +9 -0
  34. package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
  35. package/dist/esm/index.js +10 -2
  36. package/dist/esm/mixins/OxyServices.assets.js +45 -7
  37. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  38. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  39. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  40. package/dist/esm/mixins/OxyServices.user.js +1 -0
  41. package/dist/esm/mixins/OxyServices.utility.js +52 -23
  42. package/dist/esm/mixins/index.js +3 -0
  43. package/dist/esm/server/cors.js +20 -21
  44. package/dist/esm/server/rateLimit.js +32 -8
  45. package/dist/esm/utils/fapiAutoDetect.js +12 -41
  46. package/dist/esm/utils/ssoReturn.js +1 -1
  47. package/dist/types/.tsbuildinfo +1 -1
  48. package/dist/types/HttpService.d.ts +3 -0
  49. package/dist/types/OxyServices.d.ts +2 -2
  50. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  51. package/dist/types/crypto/keyManager.d.ts +7 -0
  52. package/dist/types/crypto/signatureService.d.ts +61 -0
  53. package/dist/types/index.d.ts +7 -3
  54. package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
  55. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  56. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  57. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  58. package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
  59. package/dist/types/mixins/index.d.ts +2 -1
  60. package/dist/types/models/interfaces.d.ts +3 -0
  61. package/dist/types/server/cors.d.ts +5 -5
  62. package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
  63. package/dist/types/utils/ssoReturn.d.ts +1 -1
  64. package/package.json +3 -2
  65. package/src/AuthManager.ts +8 -2
  66. package/src/HttpService.ts +36 -8
  67. package/src/OxyServices.base.ts +3 -2
  68. package/src/OxyServices.ts +1 -1
  69. package/src/__tests__/authManager.security.test.ts +31 -0
  70. package/src/__tests__/authSocket.test.ts +96 -0
  71. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  72. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  73. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  74. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  75. package/src/crypto/__tests__/signedRecord.test.ts +125 -0
  76. package/src/crypto/canonicalJson.ts +120 -0
  77. package/src/crypto/keyManager.ts +62 -12
  78. package/src/crypto/signatureService.ts +126 -0
  79. package/src/i18n/locales/en-US.json +9 -0
  80. package/src/i18n/locales/es-ES.json +9 -0
  81. package/src/index.ts +28 -3
  82. package/src/mixins/OxyServices.assets.ts +56 -7
  83. package/src/mixins/OxyServices.auth.ts +309 -1
  84. package/src/mixins/OxyServices.identity.ts +445 -0
  85. package/src/mixins/OxyServices.sso.ts +30 -1
  86. package/src/mixins/OxyServices.user.ts +1 -0
  87. package/src/mixins/OxyServices.utility.ts +57 -23
  88. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  89. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  90. package/src/mixins/__tests__/assetUpload.test.ts +191 -0
  91. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  92. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
  93. package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
  94. package/src/mixins/__tests__/sso.test.ts +31 -0
  95. package/src/mixins/index.ts +4 -0
  96. package/src/models/interfaces.ts +3 -0
  97. package/src/server/__tests__/cors.test.ts +5 -1
  98. package/src/server/__tests__/rateLimit.test.ts +116 -0
  99. package/src/server/cors.ts +25 -20
  100. package/src/server/rateLimit.ts +39 -8
  101. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  102. package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
  103. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  104. package/src/utils/fapiAutoDetect.ts +12 -39
  105. package/src/utils/ssoReturn.ts +2 -2
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Identity Mixin tests.
3
+ *
4
+ * Stubs `makeRequest` so the tests run with no network. We assert request shape
5
+ * (method, URL, body, cache options), response unwrapping, the DID derivation,
6
+ * the EXACT signed payload `linkIdentityKey` produces (it must match the
7
+ * server's `JSON.stringify({ action, userId, timestamp })` reconstruction
8
+ * byte-for-byte), and the cache sweep on every mutation.
9
+ */
10
+
11
+ import type { AuthMethodsResponse, DidDocument, SignedRecordEnvelope, VerifiedDomain } from '@oxyhq/contracts';
12
+ import { OxyServices } from '../../OxyServices';
13
+ import { KeyManager } from '../../crypto/keyManager';
14
+ import { SignatureService } from '../../crypto/signatureService';
15
+
16
+ const didDocFixture: DidDocument = {
17
+ '@context': ['https://www.w3.org/ns/did/v1'],
18
+ id: 'did:web:oxy.so:u:user-123',
19
+ controller: ['did:web:oxy.so:u:user-123', 'did:web:oxy.so'],
20
+ verificationMethod: [],
21
+ authentication: [],
22
+ assertionMethod: [],
23
+ alsoKnownAs: ['acct:nate@oxy.so'],
24
+ service: [],
25
+ };
26
+
27
+ const authMethodsFixture: AuthMethodsResponse = {
28
+ did: 'did:web:oxy.so:u:user-123',
29
+ methods: [{ type: 'identity', linkedAt: '2026-06-26T00:00:00.000Z', verificationMethodId: '#key-1' }],
30
+ };
31
+
32
+ const recordFixture: SignedRecordEnvelope = {
33
+ version: 1,
34
+ type: 'profile',
35
+ subject: 'did:web:oxy.so:u:user-123',
36
+ issuer: 'did:web:oxy.so:u:user-123',
37
+ record: { displayName: 'Nate' },
38
+ issuedAt: 1700000000000,
39
+ publicKey: 'pub-hex',
40
+ alg: 'ES256K-DER-SHA256',
41
+ signature: 'sig-hex',
42
+ };
43
+
44
+ const domainFixture: VerifiedDomain = {
45
+ domain: 'nate.com',
46
+ verifiedAt: '2026-06-26T00:00:00.000Z',
47
+ method: 'dns-txt',
48
+ };
49
+
50
+ describe('OxyServices.identity', () => {
51
+ let oxy: OxyServices;
52
+ let makeRequestSpy: jest.SpyInstance;
53
+ let clearPrefixSpy: jest.SpyInstance;
54
+ let clearEntrySpy: jest.SpyInstance;
55
+
56
+ beforeEach(() => {
57
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
58
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
59
+ clearPrefixSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
60
+ clearEntrySpy = jest.spyOn(oxy, 'clearCacheEntry').mockReturnValue(undefined);
61
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue('user-123');
62
+ });
63
+
64
+ afterEach(() => {
65
+ jest.restoreAllMocks();
66
+ });
67
+
68
+ describe('resolveDid', () => {
69
+ it('GETs the DID document and caches the read', async () => {
70
+ makeRequestSpy.mockResolvedValue(didDocFixture);
71
+
72
+ const result = await oxy.resolveDid('user-123');
73
+
74
+ expect(result).toEqual(didDocFixture);
75
+ expect(makeRequestSpy).toHaveBeenCalledWith(
76
+ 'GET',
77
+ '/u/user-123/did.json',
78
+ undefined,
79
+ expect.objectContaining({ cache: true }),
80
+ );
81
+ });
82
+
83
+ it('URL-encodes the userId path segment', async () => {
84
+ makeRequestSpy.mockResolvedValue(didDocFixture);
85
+ await oxy.resolveDid('a/b');
86
+ expect(makeRequestSpy).toHaveBeenCalledWith(
87
+ 'GET',
88
+ '/u/a%2Fb/did.json',
89
+ undefined,
90
+ expect.anything(),
91
+ );
92
+ });
93
+ });
94
+
95
+ describe('getMyDid / getMyDidDocument', () => {
96
+ it('derives the current user DID', () => {
97
+ expect(oxy.getMyDid()).toBe('did:web:oxy.so:u:user-123');
98
+ });
99
+
100
+ it('throws when no user is authenticated', () => {
101
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
102
+ expect(() => oxy.getMyDid()).toThrow(/No authenticated user/);
103
+ });
104
+
105
+ it('resolves the current user DID document', async () => {
106
+ makeRequestSpy.mockResolvedValue(didDocFixture);
107
+ const result = await oxy.getMyDidDocument();
108
+ expect(result).toEqual(didDocFixture);
109
+ expect(makeRequestSpy).toHaveBeenCalledWith(
110
+ 'GET',
111
+ '/u/user-123/did.json',
112
+ undefined,
113
+ expect.objectContaining({ cache: true }),
114
+ );
115
+ });
116
+ });
117
+
118
+ describe('listAuthMethods', () => {
119
+ it('GETs /auth/methods', async () => {
120
+ makeRequestSpy.mockResolvedValue(authMethodsFixture);
121
+ const result = await oxy.listAuthMethods();
122
+ expect(result).toEqual(authMethodsFixture);
123
+ expect(makeRequestSpy).toHaveBeenCalledWith(
124
+ 'GET',
125
+ '/auth/methods',
126
+ undefined,
127
+ expect.objectContaining({ cache: true }),
128
+ );
129
+ });
130
+ });
131
+
132
+ describe('linkIdentityKey', () => {
133
+ it('signs the exact server-expected payload and POSTs /auth/link', async () => {
134
+ jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
135
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
136
+ const signSpy = jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
137
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Identity linked successfully' });
138
+
139
+ const result = await oxy.linkIdentityKey();
140
+
141
+ // The signed message MUST match the server's reconstruction byte-for-byte:
142
+ // JSON.stringify({ action, userId, timestamp }) in that key order.
143
+ const expectedMessage = JSON.stringify({
144
+ action: 'link_identity',
145
+ userId: 'user-123',
146
+ timestamp: 1700000000000,
147
+ });
148
+ expect(signSpy).toHaveBeenCalledWith(expectedMessage);
149
+ expect(expectedMessage).toBe(
150
+ '{"action":"link_identity","userId":"user-123","timestamp":1700000000000}',
151
+ );
152
+
153
+ expect(makeRequestSpy).toHaveBeenCalledWith(
154
+ 'POST',
155
+ '/auth/link',
156
+ { type: 'identity', publicKey: 'pub-hex', signature: 'sig-hex', timestamp: 1700000000000 },
157
+ expect.objectContaining({ cache: false }),
158
+ );
159
+ expect(result).toEqual({ success: true, message: 'Identity linked successfully' });
160
+ });
161
+
162
+ it('sweeps /users/me, auth methods, domains and the DID cache after linking', async () => {
163
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
164
+ jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
165
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'ok' });
166
+
167
+ await oxy.linkIdentityKey();
168
+
169
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
170
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
171
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/identity/domains');
172
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
173
+ });
174
+
175
+ it('throws (no network) when the device has no identity', async () => {
176
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
177
+ await expect(oxy.linkIdentityKey()).rejects.toThrow(/No identity found/);
178
+ expect(makeRequestSpy).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it('throws when no user is authenticated', async () => {
182
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
183
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue('pub-hex');
184
+ await expect(oxy.linkIdentityKey()).rejects.toThrow(/No authenticated user/);
185
+ expect(makeRequestSpy).not.toHaveBeenCalled();
186
+ });
187
+ });
188
+
189
+ describe('linkPassword', () => {
190
+ it('POSTs /auth/link with the password method and sweeps cache', async () => {
191
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'Password auth linked successfully' });
192
+
193
+ await oxy.linkPassword('nate@oxy.so', 'sup3r-secret');
194
+
195
+ expect(makeRequestSpy).toHaveBeenCalledWith(
196
+ 'POST',
197
+ '/auth/link',
198
+ { type: 'password', email: 'nate@oxy.so', password: 'sup3r-secret' },
199
+ expect.objectContaining({ cache: false }),
200
+ );
201
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
202
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
203
+ });
204
+ });
205
+
206
+ describe('unlinkAuthMethod', () => {
207
+ it('DELETEs /auth/link/:type and sweeps cache', async () => {
208
+ makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
209
+
210
+ await oxy.unlinkAuthMethod('identity');
211
+
212
+ expect(makeRequestSpy).toHaveBeenCalledWith(
213
+ 'DELETE',
214
+ '/auth/link/identity',
215
+ undefined,
216
+ expect.objectContaining({ cache: false }),
217
+ );
218
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
219
+ });
220
+ });
221
+
222
+ describe('signRecord (client-only)', () => {
223
+ it('signs with the current user DID as subject and does not hit the network', async () => {
224
+ const signRecordSpy = jest
225
+ .spyOn(SignatureService, 'signRecord')
226
+ .mockResolvedValue(recordFixture);
227
+
228
+ const result = await oxy.signRecord('profile', { displayName: 'Nate' });
229
+
230
+ expect(signRecordSpy).toHaveBeenCalledWith('profile', 'did:web:oxy.so:u:user-123', {
231
+ displayName: 'Nate',
232
+ });
233
+ expect(result).toEqual(recordFixture);
234
+ expect(makeRequestSpy).not.toHaveBeenCalled();
235
+ });
236
+ });
237
+
238
+ describe('publishRecord', () => {
239
+ it('signs then POSTs the envelope to /identity/records', async () => {
240
+ jest.spyOn(SignatureService, 'signRecord').mockResolvedValue(recordFixture);
241
+ makeRequestSpy.mockResolvedValue({ envelope: recordFixture, verified: true });
242
+
243
+ const result = await oxy.publishRecord('profile', { displayName: 'Nate' });
244
+
245
+ expect(makeRequestSpy).toHaveBeenCalledWith(
246
+ 'POST',
247
+ '/identity/records',
248
+ recordFixture,
249
+ expect.objectContaining({ cache: false }),
250
+ );
251
+ expect(result).toEqual({ envelope: recordFixture, verified: true });
252
+ });
253
+ });
254
+
255
+ describe('getRecord / verifyRecord', () => {
256
+ it('GETs and unwraps the record', async () => {
257
+ makeRequestSpy.mockResolvedValue({ record: recordFixture });
258
+ const result = await oxy.getRecord('user-123', 'profile');
259
+ expect(result).toEqual(recordFixture);
260
+ expect(makeRequestSpy).toHaveBeenCalledWith(
261
+ 'GET',
262
+ '/identity/records/user-123/profile',
263
+ undefined,
264
+ expect.objectContaining({ cache: true }),
265
+ );
266
+ });
267
+
268
+ it('GETs the verify verdict', async () => {
269
+ makeRequestSpy.mockResolvedValue({ verified: true });
270
+ const result = await oxy.verifyRecord('user-123', 'identity');
271
+ expect(result).toEqual({ verified: true });
272
+ expect(makeRequestSpy).toHaveBeenCalledWith(
273
+ 'GET',
274
+ '/identity/records/user-123/identity/verify',
275
+ undefined,
276
+ expect.objectContaining({ cache: true }),
277
+ );
278
+ });
279
+ });
280
+
281
+ describe('exportMyData', () => {
282
+ it('GETs /users/me/export without caching', async () => {
283
+ const bundle = { $schema: 'x', did: 'did:web:oxy.so:u:user-123' };
284
+ makeRequestSpy.mockResolvedValue(bundle);
285
+ const result = await oxy.exportMyData();
286
+ expect(result).toEqual(bundle);
287
+ expect(makeRequestSpy).toHaveBeenCalledWith(
288
+ 'GET',
289
+ '/users/me/export',
290
+ undefined,
291
+ expect.objectContaining({ cache: false }),
292
+ );
293
+ });
294
+ });
295
+
296
+ describe('domains', () => {
297
+ it('requestDomainVerification POSTs the domain', async () => {
298
+ const instructions = {
299
+ domain: 'nate.com',
300
+ token: 'tok',
301
+ dns: { name: '_oxy-identity.nate.com', value: 'oxy-domain-verification=tok' },
302
+ wellKnown: { url: 'https://nate.com/.well-known/oxy-domain', body: 'tok' },
303
+ };
304
+ makeRequestSpy.mockResolvedValue(instructions);
305
+
306
+ const result = await oxy.requestDomainVerification('nate.com');
307
+
308
+ expect(result).toEqual(instructions);
309
+ expect(makeRequestSpy).toHaveBeenCalledWith(
310
+ 'POST',
311
+ '/identity/domains',
312
+ { domain: 'nate.com' },
313
+ expect.objectContaining({ cache: false }),
314
+ );
315
+ });
316
+
317
+ it('verifyDomain POSTs to the verify path and sweeps cache', async () => {
318
+ makeRequestSpy.mockResolvedValue({ verified: true, domain: domainFixture });
319
+
320
+ await oxy.verifyDomain('nate.com');
321
+
322
+ expect(makeRequestSpy).toHaveBeenCalledWith(
323
+ 'POST',
324
+ '/identity/domains/nate.com/verify',
325
+ undefined,
326
+ expect.objectContaining({ cache: false }),
327
+ );
328
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
329
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
330
+ });
331
+
332
+ it('listDomains GETs and unwraps domains', async () => {
333
+ makeRequestSpy.mockResolvedValue({ domains: [domainFixture] });
334
+ const result = await oxy.listDomains();
335
+ expect(result).toEqual([domainFixture]);
336
+ expect(makeRequestSpy).toHaveBeenCalledWith(
337
+ 'GET',
338
+ '/identity/domains',
339
+ undefined,
340
+ expect.objectContaining({ cache: true }),
341
+ );
342
+ });
343
+
344
+ it('listDomains defaults to an empty array when domains is absent', async () => {
345
+ makeRequestSpy.mockResolvedValue({});
346
+ await expect(oxy.listDomains()).resolves.toEqual([]);
347
+ });
348
+
349
+ it('removeDomain DELETEs and sweeps cache', async () => {
350
+ makeRequestSpy.mockResolvedValue({ success: true });
351
+
352
+ await oxy.removeDomain('nate.com');
353
+
354
+ expect(makeRequestSpy).toHaveBeenCalledWith(
355
+ 'DELETE',
356
+ '/identity/domains/nate.com',
357
+ undefined,
358
+ expect.objectContaining({ cache: false }),
359
+ );
360
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/users/me');
361
+ expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
362
+ });
363
+ });
364
+ });
@@ -0,0 +1,47 @@
1
+ import { OxyServices } from '../../OxyServices';
2
+
3
+ interface FetchCall {
4
+ url: string;
5
+ init: RequestInit | undefined;
6
+ }
7
+
8
+ describe('asset fetch credentials', () => {
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ afterEach(() => {
12
+ globalThis.fetch = originalFetch;
13
+ jest.restoreAllMocks();
14
+ });
15
+
16
+ it('omits credentials for arbitrary asset content URLs', async () => {
17
+ const calls: FetchCall[] = [];
18
+ globalThis.fetch = async (input, init) => {
19
+ calls.push({ url: String(input), init });
20
+ return new Response('asset-body', { status: 200 });
21
+ };
22
+
23
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
24
+
25
+ await oxy.fetchAssetContent('https://attacker.oxy.so/cdn/object.txt', 'text');
26
+
27
+ expect(calls).toHaveLength(1);
28
+ expect(calls[0].url).toBe('https://attacker.oxy.so/cdn/object.txt');
29
+ expect(calls[0].init?.credentials).toBe('omit');
30
+ });
31
+
32
+ it('includes credentials for asset content fetched from the configured API origin', async () => {
33
+ const calls: FetchCall[] = [];
34
+ globalThis.fetch = async (input, init) => {
35
+ calls.push({ url: String(input), init });
36
+ return new Response('asset-body', { status: 200 });
37
+ };
38
+
39
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
40
+
41
+ await oxy.fetchAssetContent('https://api.oxy.so/assets/private-file/stream', 'text');
42
+
43
+ expect(calls).toHaveLength(1);
44
+ expect(calls[0].url).toBe('https://api.oxy.so/assets/private-file/stream');
45
+ expect(calls[0].init?.credentials).toBe('include');
46
+ });
47
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `OxyServices.assetUpload()` multipart-body tests.
3
+ *
4
+ * `assetUpload` accepts three input shapes: a web `File`, a web `Blob`, or a
5
+ * React Native `{ uri, type?, name?, size? }` descriptor. The descriptor path
6
+ * is platform-sensitive:
7
+ *
8
+ * - React Native — RN's FormData reads the file from disk via the uri during
9
+ * the multipart request, so the descriptor is appended as-is.
10
+ * - Web (browser/Node) — the browser's FormData CANNOT read bytes from a plain
11
+ * `{ uri }` object (it would serialize `[object Object]` → the server stores
12
+ * a 0-byte asset). The uri must be materialized into a real `Blob` via
13
+ * `fetch` before appending. An empty fetched blob must throw instead of
14
+ * silently uploading an empty asset.
15
+ *
16
+ * These tests assert exactly which value lands in the FormData `file` part for
17
+ * each platform, and that an empty web source is rejected.
18
+ */
19
+
20
+ import { OxyServices } from '../../OxyServices';
21
+
22
+ /**
23
+ * Captures every `FormData.append` call so a test can inspect the multipart body
24
+ * that `assetUpload` built without sending a real network request.
25
+ */
26
+ function captureUpload(oxy: OxyServices) {
27
+ const appended: Array<{ name: string; value: unknown; fileName?: string }> = [];
28
+ // Capture-only: do NOT delegate to the real (undici) FormData.append. Node's
29
+ // undici rejects a plain { uri } object as not-a-Blob, but real React Native
30
+ // FormData accepts it — the test asserts on captured args, not a built body.
31
+ const appendSpy = jest
32
+ .spyOn(FormData.prototype, 'append')
33
+ .mockImplementation(function (this: FormData, name: string, value: unknown, fileName?: string) {
34
+ appended.push({ name, value, fileName });
35
+ });
36
+
37
+ const requestSpy = jest
38
+ .spyOn(oxy.getClient(), 'request')
39
+ .mockResolvedValue({ file: { id: 'asset123' } } as never);
40
+
41
+ return {
42
+ appended,
43
+ requestSpy,
44
+ restore: () => {
45
+ appendSpy.mockRestore();
46
+ requestSpy.mockRestore();
47
+ },
48
+ };
49
+ }
50
+
51
+ describe('OxyServices.assetUpload — uri descriptor', () => {
52
+ const originalNavigator = (globalThis as { navigator?: unknown }).navigator;
53
+ const originalFetch = globalThis.fetch;
54
+
55
+ afterEach(() => {
56
+ jest.restoreAllMocks();
57
+ if (originalNavigator === undefined) {
58
+ delete (globalThis as { navigator?: unknown }).navigator;
59
+ } else {
60
+ (globalThis as { navigator?: unknown }).navigator = originalNavigator;
61
+ }
62
+ globalThis.fetch = originalFetch;
63
+ });
64
+
65
+ describe('web (NOT React Native)', () => {
66
+ beforeEach(() => {
67
+ // Node/jsdom-like: no React Native navigator → isReactNative() === false.
68
+ delete (globalThis as { navigator?: unknown }).navigator;
69
+ });
70
+
71
+ it('materializes a blob: uri into a real, non-empty Blob before appending', async () => {
72
+ const bytes = new Blob([new Uint8Array([1, 2, 3, 4, 5])], { type: 'image/png' });
73
+ const fetchMock = jest
74
+ .fn()
75
+ .mockResolvedValue({ ok: true, status: 200, blob: async () => bytes });
76
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
77
+
78
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
79
+ const capture = captureUpload(oxy);
80
+
81
+ try {
82
+ await oxy.assetUpload({ uri: 'blob:https://app.test/abc', type: 'image/png', name: 'avatar.png' });
83
+
84
+ expect(fetchMock).toHaveBeenCalledWith('blob:https://app.test/abc');
85
+
86
+ const filePart = capture.appended.find((p) => p.name === 'file');
87
+ expect(filePart).toBeDefined();
88
+ // The appended value is the fetched Blob with real bytes — NOT the { uri } object.
89
+ expect(filePart?.value).toBeInstanceOf(Blob);
90
+ expect((filePart?.value as Blob).size).toBe(5);
91
+ expect((filePart?.value as { uri?: string }).uri).toBeUndefined();
92
+ expect(filePart?.fileName).toBe('avatar.png');
93
+ } finally {
94
+ capture.restore();
95
+ }
96
+ });
97
+
98
+ it('wraps a typeless fetched blob with the descriptor MIME type', async () => {
99
+ const typeless = new Blob([new Uint8Array([9, 9, 9])]); // type === ''
100
+ const fetchMock = jest
101
+ .fn()
102
+ .mockResolvedValue({ ok: true, status: 200, blob: async () => typeless });
103
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
104
+
105
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
106
+ const capture = captureUpload(oxy);
107
+
108
+ try {
109
+ await oxy.assetUpload({ uri: 'data:application/octet-stream;base64,CQkJ', type: 'image/jpeg', name: 'x.jpg' });
110
+
111
+ const filePart = capture.appended.find((p) => p.name === 'file');
112
+ expect(filePart?.value).toBeInstanceOf(Blob);
113
+ expect((filePart?.value as Blob).size).toBe(3);
114
+ expect((filePart?.value as Blob).type).toBe('image/jpeg');
115
+ } finally {
116
+ capture.restore();
117
+ }
118
+ });
119
+
120
+ it('throws "Cannot upload an empty file" when the fetched blob is empty', async () => {
121
+ const empty = new Blob([], { type: 'image/png' });
122
+ const fetchMock = jest
123
+ .fn()
124
+ .mockResolvedValue({ ok: true, status: 200, blob: async () => empty });
125
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
126
+
127
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
128
+ const capture = captureUpload(oxy);
129
+
130
+ try {
131
+ await expect(
132
+ oxy.assetUpload({ uri: 'blob:https://app.test/empty', type: 'image/png', name: 'empty.png' }),
133
+ ).rejects.toThrow('Cannot upload an empty file');
134
+
135
+ // Nothing was sent — the empty source surfaces instead of creating a 0-byte asset.
136
+ expect(capture.requestSpy).not.toHaveBeenCalled();
137
+ } finally {
138
+ capture.restore();
139
+ }
140
+ });
141
+
142
+ it('throws when the uri cannot be fetched (non-ok response)', async () => {
143
+ const fetchMock = jest
144
+ .fn()
145
+ .mockResolvedValue({ ok: false, status: 404, blob: async () => new Blob([]) });
146
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
147
+
148
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
149
+ const capture = captureUpload(oxy);
150
+
151
+ try {
152
+ await expect(
153
+ oxy.assetUpload({ uri: 'https://cdn.test/missing.png', type: 'image/png', name: 'missing.png' }),
154
+ ).rejects.toThrow('Failed to read file from uri (status 404)');
155
+ expect(capture.requestSpy).not.toHaveBeenCalled();
156
+ } finally {
157
+ capture.restore();
158
+ }
159
+ });
160
+ });
161
+
162
+ describe('React Native', () => {
163
+ beforeEach(() => {
164
+ // Make isReactNative() === true: navigator.product === 'ReactNative'.
165
+ (globalThis as { navigator?: unknown }).navigator = { product: 'ReactNative' };
166
+ });
167
+
168
+ it('appends the descriptor as-is and never calls fetch', async () => {
169
+ const fetchMock = jest.fn();
170
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
171
+
172
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
173
+ const capture = captureUpload(oxy);
174
+
175
+ const descriptor = { uri: 'file:///tmp/avatar.png', type: 'image/png', name: 'avatar.png', size: 1024 };
176
+
177
+ try {
178
+ await oxy.assetUpload(descriptor);
179
+
180
+ // RN path: the raw descriptor object lands in the multipart body unchanged.
181
+ const filePart = capture.appended.find((p) => p.name === 'file');
182
+ expect(filePart?.value).toBe(descriptor);
183
+ expect(filePart?.fileName).toBe('avatar.png');
184
+ // No in-JS materialization on RN — FormData reads the file from the uri.
185
+ expect(fetchMock).not.toHaveBeenCalled();
186
+ } finally {
187
+ capture.restore();
188
+ }
189
+ });
190
+ });
191
+ });