@oxyhq/core 5.0.0 → 5.1.1

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.
@@ -176,57 +176,100 @@ describe('OxyServices.accounts', () => {
176
176
  });
177
177
 
178
178
  describe('switchToAccount', () => {
179
+ // The switch route NO LONGER returns `authuser` or sets the device cookie —
180
+ // it can't (it's at /accounts/*, outside the Path=/auth cookie scope). The SDK
181
+ // establishes the cookie + resolves the slot via a follow-up POST /auth/session.
179
182
  const switchResponse: SwitchAccountResult = {
180
183
  sessionId: 'sess_switch',
181
184
  deviceId: 'dev_switch',
182
185
  expiresAt: '2026-06-30T01:00:00.000Z',
183
186
  accessToken: 'access_switch',
184
187
  user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } },
185
- authuser: 2,
186
188
  };
189
+ // POST /auth/session establishes the cookie in a correctly-allocated NEW slot
190
+ // and returns that slot + a fresh access token off the same session.
191
+ const sessionResponse = { accessToken: 'access_session', authuser: 1 };
192
+
193
+ // Route makeRequest by path: the switch call vs the /auth/session establishment.
194
+ const routeByPath = (response = switchResponse) =>
195
+ makeRequestSpy.mockImplementation((_method: string, path: string) =>
196
+ Promise.resolve(path === '/auth/session' ? sessionResponse : response),
197
+ );
187
198
 
188
- it('posts to /:id/switch (no body, no cache), plants the token, sweeps the cache, and returns the session', async () => {
199
+ it('posts to /:id/switch then establishes the cookie via POST /auth/session, planting both tokens and sweeping the cache', async () => {
189
200
  const setTokensSpy = jest.spyOn(oxy, 'setTokens');
190
201
  const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
191
- makeRequestSpy.mockResolvedValue(switchResponse);
202
+ routeByPath();
192
203
 
193
204
  const result = await oxy.switchToAccount('acc1');
194
205
 
195
- // Request shape: POST, exact path, no body, cache disabled.
206
+ // Switch request shape: POST, exact path, no body, cache disabled.
196
207
  expect(makeRequestSpy).toHaveBeenCalledWith(
197
208
  'POST',
198
209
  '/accounts/acc1/switch',
199
210
  undefined,
200
211
  expect.objectContaining({ cache: false }),
201
212
  );
213
+ // Then the canonical refresh-cookie establishment under /auth (where the
214
+ // device's oxy_rt_* slots ARE visible, so a NEW slot is allocated).
215
+ expect(makeRequestSpy).toHaveBeenCalledWith(
216
+ 'POST',
217
+ '/auth/session',
218
+ undefined,
219
+ expect.objectContaining({ cache: false }),
220
+ );
202
221
 
203
- // Session planting: the access token from the body is installed as the
204
- // active token (mirrors claimSessionByToken / verifyChallenge).
205
- expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
206
- expect(oxy.getAccessToken()).toBe('access_switch');
222
+ // The switch token is planted first; /auth/session's fresh token re-planted.
223
+ expect(setTokensSpy).toHaveBeenNthCalledWith(1, 'access_switch');
224
+ expect(setTokensSpy).toHaveBeenNthCalledWith(2, 'access_session');
225
+ expect(oxy.getAccessToken()).toBe('access_session');
207
226
  expect(oxy.hasValidToken()).toBe(true);
208
227
 
209
- // Identity changed the whole GET cache is swept so reads refetch as the
210
- // new account, AND it happens AFTER the token is planted.
228
+ // Cache swept once, AFTER both tokens are planted.
211
229
  expect(clearCacheSpy).toHaveBeenCalledTimes(1);
212
- expect(setTokensSpy.mock.invocationCallOrder[0]).toBeLessThan(
230
+ expect(setTokensSpy.mock.invocationCallOrder[1]).toBeLessThan(
213
231
  clearCacheSpy.mock.invocationCallOrder[0],
214
232
  );
215
233
 
216
- // The returned session carries the target account (id-normalised) + authuser.
217
- expect(result).toEqual({ ...switchResponse, user: { id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } } });
218
- expect(result.authuser).toBe(2);
234
+ // The returned session carries the target account (id-normalised) and the
235
+ // slot resolved by /auth/session NEVER the clobbering slot 0.
236
+ expect(result.sessionId).toBe('sess_switch');
237
+ expect(result.user).toEqual({ id: 'acc1', username: 'oxy-org', name: { displayName: 'Oxy Org' } });
238
+ expect(result.authuser).toBe(1);
219
239
 
220
240
  setTokensSpy.mockRestore();
221
241
  clearCacheSpy.mockRestore();
222
242
  });
223
243
 
224
244
  it('URL-encodes the accountId path segment', async () => {
225
- makeRequestSpy.mockResolvedValue(switchResponse);
245
+ routeByPath();
226
246
  await oxy.switchToAccount('a b/c');
227
247
  expect(makeRequestSpy.mock.calls[0][1]).toBe('/accounts/a%20b%2Fc/switch');
228
248
  });
229
249
 
250
+ it('keeps the switch active in-session when /auth/session fails (best-effort cookie)', async () => {
251
+ const setTokensSpy = jest.spyOn(oxy, 'setTokens');
252
+ const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
253
+ makeRequestSpy.mockImplementation((_method: string, path: string) =>
254
+ path === '/auth/session'
255
+ ? Promise.reject(Object.assign(new Error('origin'), { response: { status: 403 } }))
256
+ : Promise.resolve(switchResponse),
257
+ );
258
+
259
+ const result = await oxy.switchToAccount('acc1');
260
+
261
+ // The in-session switch survives: the switch token stays planted and the
262
+ // cache is still swept. The switched account just won't survive a reload
263
+ // until the cookie is next established (no authuser resolved).
264
+ expect(setTokensSpy).toHaveBeenCalledWith('access_switch');
265
+ expect(oxy.getAccessToken()).toBe('access_switch');
266
+ expect(clearCacheSpy).toHaveBeenCalledTimes(1);
267
+ expect(result.authuser).toBeUndefined();
268
+
269
+ setTokensSpy.mockRestore();
270
+ clearCacheSpy.mockRestore();
271
+ });
272
+
230
273
  it('does NOT plant or sweep when the operator is not authorized (403 surfaces via handleError)', async () => {
231
274
  const setTokensSpy = jest.spyOn(oxy, 'setTokens');
232
275
  const clearCacheSpy = jest.spyOn(oxy, 'clearCache');
@@ -235,9 +278,16 @@ describe('OxyServices.accounts', () => {
235
278
  );
236
279
 
237
280
  await expect(oxy.switchToAccount('acc1')).rejects.toThrow();
238
- // A failed switch must NOT mutate session state.
281
+ // A failed switch must NOT mutate session state, and must NOT attempt the
282
+ // /auth/session establishment.
239
283
  expect(setTokensSpy).not.toHaveBeenCalled();
240
284
  expect(clearCacheSpy).not.toHaveBeenCalled();
285
+ expect(makeRequestSpy).not.toHaveBeenCalledWith(
286
+ 'POST',
287
+ '/auth/session',
288
+ undefined,
289
+ expect.anything(),
290
+ );
241
291
 
242
292
  setTokensSpy.mockRestore();
243
293
  clearCacheSpy.mockRestore();
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `getUsersByIds` dual-mode auth tests.
3
+ *
4
+ * `POST /users/by-ids` is `optionalUserOrServiceAuth` on oxy-api and returns
5
+ * the identical public `{ data: PublicUserProfile[] }` shape for both a service
6
+ * token and a user session. The SDK method must therefore:
7
+ * - use the bearer-SERVICE path (`makeServiceRequest`) when the instance was
8
+ * configured via `configureServiceAuth()` (the backend / hydration case), and
9
+ * - use the USER path (`makeRequest('POST', ..., { cache: false })`) otherwise
10
+ * (a browser / RN client with only a user session). Before the dual-mode fix
11
+ * the method always took the service path, so every client-side caller got
12
+ * `[]` because `getServiceToken()` had no credentials.
13
+ *
14
+ * Both paths must dedup + chunk, map every result through `normalizeUserIdentity`
15
+ * (so `name.displayName` is present), and tolerate a per-chunk failure without
16
+ * sinking the rest. `makeRequest` / `makeServiceRequest` are stubbed so the
17
+ * tests run with no network.
18
+ */
19
+
20
+ import { OxyServices } from '../../OxyServices';
21
+ import type { User } from '../../models/interfaces';
22
+
23
+ const makeRawUser = (id: string): User =>
24
+ // The wire payload is a PublicUserProfile with server-owned name.displayName.
25
+ // Cast through the User shape the API returns post-unwrap.
26
+ ({
27
+ id,
28
+ username: `user_${id}`,
29
+ name: { displayName: `Display ${id}` },
30
+ _count: { followers: 1, following: 2 },
31
+ }) as unknown as User;
32
+
33
+ describe('OxyServices.getUsersByIds — dual-mode auth', () => {
34
+ let oxy: OxyServices;
35
+ let makeRequestSpy: jest.SpyInstance;
36
+ let makeServiceRequestSpy: jest.SpyInstance;
37
+
38
+ beforeEach(() => {
39
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
40
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
41
+ makeServiceRequestSpy = jest.spyOn(
42
+ oxy as unknown as { makeServiceRequest: jest.Mock },
43
+ 'makeServiceRequest',
44
+ );
45
+ });
46
+
47
+ afterEach(() => {
48
+ jest.restoreAllMocks();
49
+ });
50
+
51
+ it('returns [] and performs no network call for empty / whitespace input', async () => {
52
+ await expect(oxy.getUsersByIds([])).resolves.toEqual([]);
53
+ await expect(oxy.getUsersByIds(['', ' '])).resolves.toEqual([]);
54
+ expect(makeRequestSpy).not.toHaveBeenCalled();
55
+ expect(makeServiceRequestSpy).not.toHaveBeenCalled();
56
+ });
57
+
58
+ describe('without service credentials (client / user-session path)', () => {
59
+ it('uses makeRequest with the user bearer and cache:false', async () => {
60
+ makeRequestSpy.mockResolvedValueOnce([makeRawUser('a'), makeRawUser('b')]);
61
+
62
+ const result = await oxy.getUsersByIds(['a', 'b']);
63
+
64
+ expect(makeServiceRequestSpy).not.toHaveBeenCalled();
65
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
66
+ expect(makeRequestSpy).toHaveBeenCalledWith(
67
+ 'POST',
68
+ '/users/by-ids',
69
+ { ids: ['a', 'b'] },
70
+ { cache: false },
71
+ );
72
+ // normalizeUserIdentity ran on every entry (display name preserved).
73
+ expect(result).toHaveLength(2);
74
+ expect(result[0].name.displayName).toBe('Display a');
75
+ expect(result[1].id).toBe('b');
76
+ });
77
+
78
+ it('de-duplicates ids and drops blanks before the request', async () => {
79
+ makeRequestSpy.mockResolvedValueOnce([makeRawUser('a')]);
80
+
81
+ await oxy.getUsersByIds(['a', 'a', ' ', 'a']);
82
+
83
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
84
+ expect(makeRequestSpy).toHaveBeenCalledWith(
85
+ 'POST',
86
+ '/users/by-ids',
87
+ { ids: ['a'] },
88
+ { cache: false },
89
+ );
90
+ });
91
+ });
92
+
93
+ describe('with service credentials (backend / hydration path)', () => {
94
+ beforeEach(() => {
95
+ oxy.configureServiceAuth('oxy_dk_key', 'secret');
96
+ });
97
+
98
+ it('uses makeServiceRequest and never the user path — unchanged behavior', async () => {
99
+ makeServiceRequestSpy.mockResolvedValueOnce([makeRawUser('a'), makeRawUser('b')]);
100
+
101
+ const result = await oxy.getUsersByIds(['a', 'b']);
102
+
103
+ expect(makeRequestSpy).not.toHaveBeenCalled();
104
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
105
+ expect(makeServiceRequestSpy).toHaveBeenCalledWith('POST', '/users/by-ids', {
106
+ ids: ['a', 'b'],
107
+ });
108
+ expect(result).toHaveLength(2);
109
+ expect(result[0].name.displayName).toBe('Display a');
110
+ });
111
+ });
112
+
113
+ describe('chunking + resilience (applies to both modes)', () => {
114
+ it('chunks at 100 ids per request on the user path and flattens results', async () => {
115
+ const ids = Array.from({ length: 250 }, (_, i) => `id-${i}`);
116
+ makeRequestSpy.mockImplementation(
117
+ async (
118
+ _method: string,
119
+ _url: string,
120
+ data?: { ids: string[] },
121
+ ): Promise<User[]> => (data?.ids ?? []).map((id) => makeRawUser(id)),
122
+ );
123
+
124
+ const result = await oxy.getUsersByIds(ids);
125
+
126
+ // 250 unique ids => 100 + 100 + 50 across three POSTs.
127
+ expect(makeRequestSpy).toHaveBeenCalledTimes(3);
128
+ const chunkSizes = makeRequestSpy.mock.calls.map(
129
+ (call) => (call[2] as { ids: string[] }).ids.length,
130
+ );
131
+ expect(chunkSizes).toEqual([100, 100, 50]);
132
+ expect(result).toHaveLength(250);
133
+ });
134
+
135
+ it('skips a failed chunk and returns the users that resolved (user path)', async () => {
136
+ const ids = Array.from({ length: 150 }, (_, i) => `id-${i}`);
137
+ makeRequestSpy
138
+ .mockResolvedValueOnce([makeRawUser('id-0')])
139
+ .mockRejectedValueOnce(new Error('chunk boom'));
140
+
141
+ const result = await oxy.getUsersByIds(ids);
142
+
143
+ expect(makeRequestSpy).toHaveBeenCalledTimes(2);
144
+ // The failed second chunk contributes nothing; the first survives.
145
+ expect(result).toHaveLength(1);
146
+ expect(result[0].id).toBe('id-0');
147
+ });
148
+ });
149
+ });
@@ -514,6 +514,25 @@ export interface AssetUpdateVisibilityResponse {
514
514
  };
515
515
  }
516
516
 
517
+ /**
518
+ * Minimal, service-token-scoped asset metadata returned by
519
+ * `POST /assets/service/by-ids`.
520
+ *
521
+ * Resolves an Oxy asset `id` to its content-addressed identity (`sha256`),
522
+ * MIME type, byte `size`, and storage `status`. Used by server-to-server
523
+ * callers (e.g. Mention's MTN Protocol blob-ref resolution) that hold a
524
+ * `files:read`-scoped service token rather than a user session. Unknown or
525
+ * deleted ids are omitted from the response (never error the whole batch),
526
+ * so the result may be shorter than the requested id list.
527
+ */
528
+ export interface ServiceAssetMetadata {
529
+ id: string;
530
+ sha256: string;
531
+ mime: string;
532
+ size: number;
533
+ status: 'active' | 'trash';
534
+ }
535
+
517
536
  /**
518
537
  * Account storage usage (server-side usage, not local AsyncStorage)
519
538
  */