@channel.io/app-sdk-server 0.7.2 → 0.8.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 (50) hide show
  1. package/dist/__tests__/native-client.test.js +55 -1
  2. package/dist/__tests__/native-client.test.js.map +1 -1
  3. package/dist/__tests__/proxy-api.test.js +39 -3
  4. package/dist/__tests__/proxy-api.test.js.map +1 -1
  5. package/dist/__tests__/token-manager.test.js +330 -552
  6. package/dist/__tests__/token-manager.test.js.map +1 -1
  7. package/dist/appstore/client.d.ts +3 -0
  8. package/dist/appstore/client.d.ts.map +1 -1
  9. package/dist/appstore/client.js +3 -0
  10. package/dist/appstore/client.js.map +1 -1
  11. package/dist/native/client.d.ts +3 -1
  12. package/dist/native/client.d.ts.map +1 -1
  13. package/dist/native/client.js +0 -19
  14. package/dist/native/client.js.map +1 -1
  15. package/dist/native/proxy-api.d.ts +13 -1
  16. package/dist/native/proxy-api.d.ts.map +1 -1
  17. package/dist/native/proxy-api.js +18 -0
  18. package/dist/native/proxy-api.js.map +1 -1
  19. package/dist/native/proxy-api.types.d.ts +1 -231
  20. package/dist/native/proxy-api.types.d.ts.map +1 -1
  21. package/dist/native/proxy-api.types.js +0 -8
  22. package/dist/native/proxy-api.types.js.map +1 -1
  23. package/dist/nestjs/channel-app.module.d.ts.map +1 -1
  24. package/dist/nestjs/channel-app.module.js +51 -6
  25. package/dist/nestjs/channel-app.module.js.map +1 -1
  26. package/dist/nestjs/channel-app.service.d.ts +6 -14
  27. package/dist/nestjs/channel-app.service.d.ts.map +1 -1
  28. package/dist/nestjs/channel-app.service.js +52 -50
  29. package/dist/nestjs/channel-app.service.js.map +1 -1
  30. package/dist/nestjs/channel-app.service.test.js +121 -23
  31. package/dist/nestjs/channel-app.service.test.js.map +1 -1
  32. package/dist/nestjs/types.d.ts +10 -0
  33. package/dist/nestjs/types.d.ts.map +1 -1
  34. package/dist/nestjs/types.js.map +1 -1
  35. package/dist/simple/channel-app.d.ts +3 -0
  36. package/dist/simple/channel-app.d.ts.map +1 -1
  37. package/dist/simple/channel-app.js +11 -4
  38. package/dist/simple/channel-app.js.map +1 -1
  39. package/dist/simple/index.d.ts.map +1 -1
  40. package/dist/simple/index.js +1 -0
  41. package/dist/simple/index.js.map +1 -1
  42. package/dist/token/cache.js +3 -3
  43. package/dist/token/cache.js.map +1 -1
  44. package/dist/token/manager.d.ts +20 -114
  45. package/dist/token/manager.d.ts.map +1 -1
  46. package/dist/token/manager.js +151 -202
  47. package/dist/token/manager.js.map +1 -1
  48. package/dist/token/types.d.ts +13 -25
  49. package/dist/token/types.d.ts.map +1 -1
  50. package/package.json +2 -2
@@ -1,51 +1,43 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { TokenManager, TokenManagerError } from "../token/manager.js";
3
3
  import { InMemoryTokenCache } from "../token/cache.js";
4
- // ============================================
5
- // Helpers
6
- // ============================================
7
4
  function makeTokenResponse(overrides = {}) {
8
5
  return {
9
- accessToken: overrides.accessToken ?? "access-token-123",
10
- refreshToken: overrides.refreshToken ?? "refresh-token-456",
6
+ accessToken: overrides.accessToken ?? "access-token",
7
+ refreshToken: overrides.refreshToken ?? "refresh-token",
11
8
  expiresIn: overrides.expiresIn ?? 3600,
12
- expiresAt: overrides.expiresAt ?? Date.now() + 3600 * 1000,
13
- refreshTokenExpiresAt: overrides.refreshTokenExpiresAt ?? Date.now() + 604800 * 1000,
14
- tokenType: overrides.tokenType ?? "Bearer",
15
- scopes: overrides.scopes ?? [],
16
9
  };
17
10
  }
18
- function makeApiResponse(token) {
11
+ function makeNativeFunctionResponse(overrides = {}) {
19
12
  return {
20
- accessToken: token.accessToken,
21
- refreshToken: token.refreshToken,
22
- expiresIn: token.expiresIn,
23
- expiresAt: token.expiresAt,
24
- refreshTokenExpiresAt: token.refreshTokenExpiresAt,
25
- tokenType: token.tokenType,
26
- scopes: token.scopes,
13
+ result: {
14
+ accessToken: overrides.accessToken ?? "access-token",
15
+ refreshToken: overrides.refreshToken ?? "refresh-token",
16
+ expiresIn: overrides.expiresIn ?? 3600,
17
+ },
18
+ };
19
+ }
20
+ function createResponse(body, ok = true, status = 200) {
21
+ return {
22
+ ok,
23
+ status,
24
+ statusText: ok ? "OK" : "Error",
25
+ json: async () => body,
26
+ text: async () => JSON.stringify(body),
27
27
  };
28
28
  }
29
29
  function createMockFetch(responses) {
30
- if (responses.length === 0) {
31
- throw new Error("createMockFetch requires at least one response");
32
- }
33
30
  let callIndex = 0;
34
31
  return vi.fn(async () => {
35
32
  const resp = (responses[callIndex] ?? responses[responses.length - 1]);
36
33
  callIndex++;
37
- return {
38
- ok: resp.ok,
39
- status: resp.status,
40
- statusText: resp.ok ? "OK" : "Error",
41
- json: async () => resp.body,
42
- text: async () => JSON.stringify(resp.body),
43
- };
34
+ return createResponse(resp.body, resp.ok, resp.status);
44
35
  });
45
36
  }
46
- // ============================================
47
- // InMemoryTokenCache Tests
48
- // ============================================
37
+ function getRequest(index) {
38
+ const call = globalThis.fetch.mock.calls[index];
39
+ return JSON.parse(call[1].body);
40
+ }
49
41
  describe("InMemoryTokenCache", () => {
50
42
  let cache;
51
43
  beforeEach(() => {
@@ -56,63 +48,41 @@ describe("InMemoryTokenCache", () => {
56
48
  cache.destroy();
57
49
  vi.useRealTimers();
58
50
  });
59
- it("should return null for missing key", async () => {
60
- const result = await cache.get("nonexistent");
61
- expect(result).toBeNull();
62
- });
63
- it("should store and retrieve a cached token", async () => {
64
- const token = makeTokenResponse();
65
- const cached = { token, cachedAt: Date.now(), key: "test-key" };
66
- await cache.set("test-key", cached);
67
- const result = await cache.get("test-key");
68
- expect(result).not.toBeNull();
69
- expect(result.token.accessToken).toBe("access-token-123");
70
- });
71
- it("should respect TTL and expire entries", async () => {
51
+ it("stores, retrieves, expires, deletes, and clears cached tokens", async () => {
72
52
  const token = makeTokenResponse();
73
- const cached = { token, cachedAt: Date.now(), key: "ttl-key" };
74
- await cache.set("ttl-key", cached, 5000); // 5 second TTL
75
- expect(await cache.get("ttl-key")).not.toBeNull();
76
- vi.advanceTimersByTime(6000); // Advance past TTL
77
- expect(await cache.get("ttl-key")).toBeNull();
78
- });
79
- it("should delete a specific key", async () => {
80
- const token = makeTokenResponse();
81
- const cached = { token, cachedAt: Date.now(), key: "del-key" };
82
- await cache.set("del-key", cached);
83
- expect(await cache.get("del-key")).not.toBeNull();
84
- await cache.delete("del-key");
85
- expect(await cache.get("del-key")).toBeNull();
86
- });
87
- it("should clear all entries", async () => {
88
- const token = makeTokenResponse();
89
- await cache.set("key1", { token, cachedAt: Date.now(), key: "key1" });
90
- await cache.set("key2", { token, cachedAt: Date.now(), key: "key2" });
53
+ const cached = {
54
+ token,
55
+ cachedAt: Date.now(),
56
+ expiresAt: Date.now() + token.expiresIn * 1000,
57
+ key: "key",
58
+ };
59
+ expect(await cache.get("key")).toBeNull();
60
+ await cache.set("key", cached, 5000);
61
+ expect((await cache.get("key"))?.token.accessToken).toBe("access-token");
62
+ vi.advanceTimersByTime(6000);
63
+ expect(await cache.get("key")).toBeNull();
64
+ await cache.set("key", cached, 0);
65
+ expect(await cache.get("key")).toBeNull();
66
+ await cache.set("key", cached);
67
+ await cache.delete("key");
68
+ expect(await cache.get("key")).toBeNull();
69
+ await cache.set("a", {
70
+ token,
71
+ cachedAt: Date.now(),
72
+ expiresAt: cached.expiresAt,
73
+ key: "a",
74
+ });
75
+ await cache.set("b", {
76
+ token,
77
+ cachedAt: Date.now(),
78
+ expiresAt: cached.expiresAt,
79
+ key: "b",
80
+ });
91
81
  expect(cache.getStats().size).toBe(2);
92
82
  await cache.clear();
93
83
  expect(cache.getStats().size).toBe(0);
94
84
  });
95
- it("should periodically cleanup expired entries", async () => {
96
- const token = makeTokenResponse();
97
- const cached = { token, cachedAt: Date.now(), key: "cleanup-key" };
98
- await cache.set("cleanup-key", cached, 5000);
99
- expect(cache.getStats().size).toBe(1);
100
- vi.advanceTimersByTime(65000); // Past TTL + cleanup interval
101
- expect(cache.getStats().size).toBe(0);
102
- });
103
- it("should report stats correctly", async () => {
104
- const token = makeTokenResponse();
105
- await cache.set("a", { token, cachedAt: Date.now(), key: "a" });
106
- await cache.set("b", { token, cachedAt: Date.now(), key: "b" });
107
- const stats = cache.getStats();
108
- expect(stats.size).toBe(2);
109
- expect(stats.keys).toContain("a");
110
- expect(stats.keys).toContain("b");
111
- });
112
85
  });
113
- // ============================================
114
- // TokenManager Tests
115
- // ============================================
116
86
  describe("TokenManager", () => {
117
87
  let originalFetch;
118
88
  beforeEach(() => {
@@ -127,498 +97,306 @@ describe("TokenManager", () => {
127
97
  return new TokenManager({
128
98
  appId: "test-app",
129
99
  appSecret: "test-secret",
130
- debug: true,
131
100
  ...overrides,
132
101
  });
133
102
  }
134
- // ----------------------------------------
135
- // Token Issuance
136
- // ----------------------------------------
137
- describe("token issuance", () => {
138
- it("should issue a channel token on cache miss", async () => {
139
- const token = makeTokenResponse();
140
- globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
141
- const manager = createManager();
142
- const result = await manager.getChannelToken({ channelId: "ch-1" });
143
- expect(result.accessToken).toBe("access-token-123");
144
- expect(globalThis.fetch).toHaveBeenCalledTimes(1);
145
- manager.destroy();
146
- });
147
- it("should issue a manager token", async () => {
148
- const token = makeTokenResponse({ accessToken: "mgr-token" });
149
- globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
150
- const manager = createManager();
151
- const result = await manager.getManagerToken({ channelId: "ch-1", managerId: "mgr-1" });
152
- expect(result.accessToken).toBe("mgr-token");
153
- manager.destroy();
154
- });
155
- it("should issue a user token", async () => {
156
- const token = makeTokenResponse({ accessToken: "user-token" });
157
- globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
158
- const manager = createManager();
159
- const result = await manager.getUserToken({ channelId: "ch-1", userId: "usr-1" });
160
- expect(result.accessToken).toBe("user-token");
161
- manager.destroy();
162
- });
163
- it("should issue an app token (no channelId)", async () => {
164
- const token = makeTokenResponse({ accessToken: "app-token" });
165
- globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
166
- const manager = createManager();
167
- const result = await manager.getAppToken();
168
- expect(result.accessToken).toBe("app-token");
169
- // Verify the API was called with the correct URL (no channelId in path)
170
- const fetchCall = globalThis.fetch.mock.calls[0];
171
- const url = fetchCall[0];
172
- expect(url).toBe("https://api.channel.io/app/v1/token");
173
- manager.destroy();
174
- });
175
- it("should throw TokenManagerError on API failure", async () => {
176
- globalThis.fetch = createMockFetch([
177
- { ok: false, status: 401, body: { error: "Unauthorized" } },
178
- ]);
179
- const manager = createManager();
180
- await expect(manager.getChannelToken({ channelId: "ch-1" })).rejects.toThrow(TokenManagerError);
181
- manager.destroy();
103
+ it("issues app and channel tokens through AppStore native functions using PUT", async () => {
104
+ globalThis.fetch = createMockFetch([
105
+ { ok: true, status: 200, body: makeNativeFunctionResponse({ accessToken: "app-token" }) },
106
+ { ok: true, status: 200, body: makeNativeFunctionResponse({ accessToken: "channel-token" }) },
107
+ ]);
108
+ const manager = createManager();
109
+ const appToken = await manager.getAppToken();
110
+ const channelToken = await manager.getChannelToken({ channelId: "ch-1" });
111
+ expect(appToken).toEqual({
112
+ accessToken: "app-token",
113
+ refreshToken: "refresh-token",
114
+ expiresIn: 3600,
182
115
  });
183
- });
184
- // ----------------------------------------
185
- // Cache Hit
186
- // ----------------------------------------
187
- describe("cache hit", () => {
188
- it("should return cached token on second call without API request", async () => {
189
- const token = makeTokenResponse();
190
- globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
191
- const manager = createManager();
192
- // First call - cache miss
193
- await manager.getChannelToken({ channelId: "ch-1" });
194
- expect(globalThis.fetch).toHaveBeenCalledTimes(1);
195
- // Second call - cache hit
196
- const result = await manager.getChannelToken({ channelId: "ch-1" });
197
- expect(result.accessToken).toBe("access-token-123");
198
- expect(globalThis.fetch).toHaveBeenCalledTimes(1); // No additional API call
199
- manager.destroy();
200
- });
201
- it("should use separate cache entries for different channelIds", async () => {
202
- const token1 = makeTokenResponse({ accessToken: "token-ch1" });
203
- const token2 = makeTokenResponse({ accessToken: "token-ch2" });
204
- globalThis.fetch = createMockFetch([
205
- { ok: true, status: 200, body: makeApiResponse(token1) },
206
- { ok: true, status: 200, body: makeApiResponse(token2) },
207
- ]);
208
- const manager = createManager();
209
- const r1 = await manager.getChannelToken({ channelId: "ch-1" });
210
- const r2 = await manager.getChannelToken({ channelId: "ch-2" });
211
- expect(r1.accessToken).toBe("token-ch1");
212
- expect(r2.accessToken).toBe("token-ch2");
213
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
214
- manager.destroy();
116
+ expect(channelToken).toEqual({
117
+ accessToken: "channel-token",
118
+ refreshToken: "refresh-token",
119
+ expiresIn: 3600,
215
120
  });
216
- it("should cache app token separately from channel tokens", async () => {
217
- const appToken = makeTokenResponse({ accessToken: "app-token" });
218
- const channelToken = makeTokenResponse({ accessToken: "ch-token" });
219
- globalThis.fetch = createMockFetch([
220
- { ok: true, status: 200, body: makeApiResponse(appToken) },
221
- { ok: true, status: 200, body: makeApiResponse(channelToken) },
222
- ]);
223
- const manager = createManager();
224
- const r1 = await manager.getAppToken();
225
- const r2 = await manager.getChannelToken({ channelId: "ch-1" });
226
- expect(r1.accessToken).toBe("app-token");
227
- expect(r2.accessToken).toBe("ch-token");
228
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
229
- // Second call to getAppToken should be cached
230
- const r3 = await manager.getAppToken();
231
- expect(r3.accessToken).toBe("app-token");
232
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
233
- manager.destroy();
121
+ const calls = globalThis.fetch.mock.calls;
122
+ expect(calls[0][0]).toBe("https://app-store.channel.io/general/v1/native/functions");
123
+ expect(calls[0][1].method).toBe("PUT");
124
+ expect(getRequest(0)).toEqual({
125
+ method: "issueToken",
126
+ params: { secret: "test-secret" },
234
127
  });
235
- it("should use separate cache entries for different token types", async () => {
236
- const channelToken = makeTokenResponse({ accessToken: "ch-token" });
237
- const managerToken = makeTokenResponse({ accessToken: "mgr-token" });
238
- globalThis.fetch = createMockFetch([
239
- { ok: true, status: 200, body: makeApiResponse(channelToken) },
240
- { ok: true, status: 200, body: makeApiResponse(managerToken) },
241
- ]);
242
- const manager = createManager();
243
- const r1 = await manager.getChannelToken({ channelId: "ch-1" });
244
- const r2 = await manager.getManagerToken({ channelId: "ch-1", managerId: "mgr-1" });
245
- expect(r1.accessToken).toBe("ch-token");
246
- expect(r2.accessToken).toBe("mgr-token");
247
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
248
- manager.destroy();
128
+ expect(getRequest(1)).toEqual({
129
+ method: "issueToken",
130
+ params: { secret: "test-secret", channelId: "ch-1" },
249
131
  });
132
+ manager.destroy();
250
133
  });
251
- // ----------------------------------------
252
- // TTL Expiry & Auto-Refresh
253
- // ----------------------------------------
254
- describe("TTL expiry and auto-refresh", () => {
255
- it("should auto-refresh token within refresh buffer period", async () => {
256
- const now = Date.now();
257
- // Token that expires in 4 minutes (within 5-minute buffer)
258
- const expiringToken = makeTokenResponse({
259
- accessToken: "old-token",
260
- expiresAt: now + 4 * 60 * 1000,
261
- });
262
- const refreshedToken = makeTokenResponse({
263
- accessToken: "refreshed-token",
264
- expiresAt: now + 3600 * 1000,
265
- });
266
- let callCount = 0;
267
- globalThis.fetch = vi.fn(async (url) => {
268
- callCount++;
269
- const urlStr = typeof url === "string" ? url : url.toString();
270
- if (callCount === 1) {
271
- // Initial issue
272
- return {
273
- ok: true,
274
- status: 200,
275
- statusText: "OK",
276
- json: async () => makeApiResponse(expiringToken),
277
- text: async () => JSON.stringify(makeApiResponse(expiringToken)),
278
- };
279
- }
280
- if (urlStr.includes("/token/refresh")) {
281
- // Refresh call
282
- return {
283
- ok: true,
284
- status: 200,
285
- statusText: "OK",
286
- json: async () => makeApiResponse(refreshedToken),
287
- text: async () => JSON.stringify(makeApiResponse(refreshedToken)),
288
- };
289
- }
290
- // Fallback re-issue
291
- return {
292
- ok: true,
293
- status: 200,
294
- statusText: "OK",
295
- json: async () => makeApiResponse(refreshedToken),
296
- text: async () => JSON.stringify(makeApiResponse(refreshedToken)),
297
- };
298
- });
299
- const manager = createManager({ refreshBufferMs: 5 * 60 * 1000 });
300
- // First call issues token
301
- const first = await manager.getChannelToken({ channelId: "ch-1" });
302
- expect(first.accessToken).toBe("old-token");
303
- // Second call should trigger refresh because token expires within buffer
304
- const second = await manager.getChannelToken({ channelId: "ch-1" });
305
- expect(second.accessToken).toBe("refreshed-token");
306
- manager.destroy();
307
- });
308
- it("should re-issue token when refresh fails", async () => {
309
- const now = Date.now();
310
- // Token that expires within buffer
311
- const expiringToken = makeTokenResponse({
312
- accessToken: "old-token",
313
- expiresAt: now + 2 * 60 * 1000,
314
- });
315
- const newToken = makeTokenResponse({
316
- accessToken: "new-token",
317
- expiresAt: now + 3600 * 1000,
318
- });
319
- let callCount = 0;
320
- globalThis.fetch = vi.fn(async (url) => {
321
- callCount++;
322
- const urlStr = typeof url === "string" ? url : url.toString();
323
- if (callCount === 1) {
324
- // Initial issue
325
- return {
326
- ok: true,
327
- status: 200,
328
- statusText: "OK",
329
- json: async () => makeApiResponse(expiringToken),
330
- text: async () => JSON.stringify(makeApiResponse(expiringToken)),
331
- };
332
- }
333
- if (urlStr.includes("/token/refresh")) {
334
- // Refresh fails
335
- return {
336
- ok: false,
337
- status: 401,
338
- statusText: "Unauthorized",
339
- json: async () => ({}),
340
- text: async () => "Unauthorized",
341
- };
342
- }
343
- // Re-issue succeeds
344
- return {
345
- ok: true,
346
- status: 200,
347
- statusText: "OK",
348
- json: async () => makeApiResponse(newToken),
349
- text: async () => JSON.stringify(makeApiResponse(newToken)),
350
- };
351
- });
352
- const manager = createManager({ refreshBufferMs: 5 * 60 * 1000 });
353
- await manager.getChannelToken({ channelId: "ch-1" });
354
- const result = await manager.getChannelToken({ channelId: "ch-1" });
355
- expect(result.accessToken).toBe("new-token");
356
- manager.destroy();
134
+ it("uses cached tokens until they enter the refresh buffer", async () => {
135
+ globalThis.fetch = createMockFetch([
136
+ { ok: true, status: 200, body: makeNativeFunctionResponse() },
137
+ {
138
+ ok: true,
139
+ status: 200,
140
+ body: makeNativeFunctionResponse({ accessToken: "refreshed-token" }),
141
+ },
142
+ ]);
143
+ const manager = createManager();
144
+ await manager.getChannelToken({ channelId: "ch-1" });
145
+ const cached = await manager.getChannelToken({ channelId: "ch-1" });
146
+ expect(cached.accessToken).toBe("access-token");
147
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
148
+ vi.advanceTimersByTime(56 * 60 * 1000);
149
+ const refreshed = await manager.getChannelToken({ channelId: "ch-1" });
150
+ expect(refreshed.accessToken).toBe("refreshed-token");
151
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
152
+ expect(getRequest(1)).toEqual({
153
+ method: "refreshToken",
154
+ params: { refreshToken: "refresh-token" },
357
155
  });
156
+ manager.destroy();
358
157
  });
359
- // ----------------------------------------
360
- // Thundering Herd Prevention
361
- // ----------------------------------------
362
- describe("thundering herd prevention", () => {
363
- it("should deduplicate concurrent requests for the same token", async () => {
364
- let resolvePromise;
365
- const pendingPromise = new Promise((resolve) => {
366
- resolvePromise = resolve;
367
- });
368
- const token = makeTokenResponse({ accessToken: "deduped-token" });
369
- let callCount = 0;
370
- globalThis.fetch = vi.fn(() => {
371
- callCount++;
372
- if (callCount === 1) {
373
- return pendingPromise;
374
- }
375
- // Should never reach here for the same key
376
- return Promise.resolve({
377
- ok: true,
378
- status: 200,
379
- statusText: "OK",
380
- json: async () => makeApiResponse(makeTokenResponse({ accessToken: "second-token" })),
381
- text: async () => "",
382
- });
383
- });
384
- const manager = createManager();
385
- // Fire 3 concurrent requests for the same channel
386
- const p1 = manager.getChannelToken({ channelId: "ch-1" });
387
- const p2 = manager.getChannelToken({ channelId: "ch-1" });
388
- const p3 = manager.getChannelToken({ channelId: "ch-1" });
389
- // Resolve the single pending fetch
390
- resolvePromise({
158
+ it("falls back to issueToken when refreshToken fails", async () => {
159
+ globalThis.fetch = createMockFetch([
160
+ { ok: true, status: 200, body: makeNativeFunctionResponse() },
161
+ { ok: true, status: 200, body: { error: { code: 401, message: "Unauthorized" } } },
162
+ {
391
163
  ok: true,
392
164
  status: 200,
393
- statusText: "OK",
394
- json: async () => makeApiResponse(token),
395
- text: async () => JSON.stringify(makeApiResponse(token)),
396
- });
397
- const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
398
- // All should return the same token
399
- expect(r1.accessToken).toBe("deduped-token");
400
- expect(r2.accessToken).toBe("deduped-token");
401
- expect(r3.accessToken).toBe("deduped-token");
402
- // Only one fetch call should have been made
403
- expect(callCount).toBe(1);
404
- manager.destroy();
405
- });
406
- it("should not deduplicate requests for different keys", async () => {
407
- const token1 = makeTokenResponse({ accessToken: "token-1" });
408
- const token2 = makeTokenResponse({ accessToken: "token-2" });
409
- globalThis.fetch = createMockFetch([
410
- { ok: true, status: 200, body: makeApiResponse(token1) },
411
- { ok: true, status: 200, body: makeApiResponse(token2) },
412
- ]);
413
- const manager = createManager();
414
- const [r1, r2] = await Promise.all([
415
- manager.getChannelToken({ channelId: "ch-1" }),
416
- manager.getChannelToken({ channelId: "ch-2" }),
417
- ]);
418
- expect(r1.accessToken).toBe("token-1");
419
- expect(r2.accessToken).toBe("token-2");
420
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
421
- manager.destroy();
422
- });
423
- it("should allow new requests after in-flight promise resolves", async () => {
424
- const token1 = makeTokenResponse({ accessToken: "first" });
425
- const token2 = makeTokenResponse({ accessToken: "second" });
426
- let callCount = 0;
427
- globalThis.fetch = vi.fn(async () => {
428
- callCount++;
429
- const token = callCount === 1 ? token1 : token2;
430
- return {
431
- ok: true,
432
- status: 200,
433
- statusText: "OK",
434
- json: async () => makeApiResponse(token),
435
- text: async () => JSON.stringify(makeApiResponse(token)),
436
- };
437
- });
438
- const manager = createManager();
439
- // First request
440
- const r1 = await manager.getChannelToken({ channelId: "ch-1" });
441
- expect(r1.accessToken).toBe("first");
442
- // Invalidate cache to force new request
443
- await manager.invalidateToken("channel", "ch-1");
444
- // Second request should make a new API call
445
- const r2 = await manager.getChannelToken({ channelId: "ch-1" });
446
- expect(r2.accessToken).toBe("second");
447
- expect(callCount).toBe(2);
448
- manager.destroy();
449
- });
450
- it("should clean up in-flight promise on error", async () => {
451
- let callCount = 0;
452
- const token = makeTokenResponse();
453
- globalThis.fetch = vi.fn(async () => {
454
- callCount++;
455
- if (callCount === 1) {
456
- return {
457
- ok: false,
458
- status: 500,
459
- statusText: "Internal Server Error",
460
- json: async () => ({}),
461
- text: async () => "Server Error",
462
- };
463
- }
464
- return {
465
- ok: true,
466
- status: 200,
467
- statusText: "OK",
468
- json: async () => makeApiResponse(token),
469
- text: async () => JSON.stringify(makeApiResponse(token)),
470
- };
471
- });
472
- const manager = createManager();
473
- // First request fails
474
- await expect(manager.getChannelToken({ channelId: "ch-1" })).rejects.toThrow(TokenManagerError);
475
- // Second request should work (in-flight was cleaned up)
476
- const result = await manager.getChannelToken({ channelId: "ch-1" });
477
- expect(result.accessToken).toBe("access-token-123");
478
- expect(callCount).toBe(2);
479
- manager.destroy();
480
- });
165
+ body: makeNativeFunctionResponse({ accessToken: "reissued-token" }),
166
+ },
167
+ ]);
168
+ const manager = createManager();
169
+ await manager.getAppToken();
170
+ vi.advanceTimersByTime(56 * 60 * 1000);
171
+ const token = await manager.getAppToken();
172
+ expect(token.accessToken).toBe("reissued-token");
173
+ expect(getRequest(0).method).toBe("issueToken");
174
+ expect(getRequest(1).method).toBe("refreshToken");
175
+ expect(getRequest(2).method).toBe("issueToken");
176
+ manager.destroy();
481
177
  });
482
- // ----------------------------------------
483
- // Cache Invalidation
484
- // ----------------------------------------
485
- describe("cache invalidation", () => {
486
- it("should invalidate a specific token", async () => {
487
- const token = makeTokenResponse();
488
- globalThis.fetch = createMockFetch([
489
- { ok: true, status: 200, body: makeApiResponse(token) },
490
- { ok: true, status: 200, body: makeApiResponse(token) },
491
- ]);
492
- const manager = createManager();
493
- await manager.getChannelToken({ channelId: "ch-1" });
494
- expect(globalThis.fetch).toHaveBeenCalledTimes(1);
495
- await manager.invalidateToken("channel", "ch-1");
496
- // Should issue new token after invalidation
497
- await manager.getChannelToken({ channelId: "ch-1" });
498
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
499
- manager.destroy();
500
- });
501
- it("should invalidate an app token", async () => {
502
- const token1 = makeTokenResponse({ accessToken: "app-token-1" });
503
- const token2 = makeTokenResponse({ accessToken: "app-token-2" });
504
- globalThis.fetch = createMockFetch([
505
- { ok: true, status: 200, body: makeApiResponse(token1) },
506
- { ok: true, status: 200, body: makeApiResponse(token2) },
507
- ]);
508
- const manager = createManager();
509
- const r1 = await manager.getAppToken();
510
- expect(r1.accessToken).toBe("app-token-1");
511
- expect(globalThis.fetch).toHaveBeenCalledTimes(1);
512
- await manager.invalidateToken("app");
513
- // Should issue new token after invalidation
514
- const r2 = await manager.getAppToken();
515
- expect(r2.accessToken).toBe("app-token-2");
516
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
517
- manager.destroy();
518
- });
519
- it("should clear all cached tokens", async () => {
520
- const token = makeTokenResponse();
521
- globalThis.fetch = createMockFetch([
522
- { ok: true, status: 200, body: makeApiResponse(token) },
523
- { ok: true, status: 200, body: makeApiResponse(token) },
524
- { ok: true, status: 200, body: makeApiResponse(token) },
525
- { ok: true, status: 200, body: makeApiResponse(token) },
526
- ]);
527
- const manager = createManager();
528
- await manager.getChannelToken({ channelId: "ch-1" });
529
- await manager.getChannelToken({ channelId: "ch-2" });
530
- expect(globalThis.fetch).toHaveBeenCalledTimes(2);
531
- await manager.clearCache();
532
- // Both should require new API calls
533
- await manager.getChannelToken({ channelId: "ch-1" });
534
- await manager.getChannelToken({ channelId: "ch-2" });
535
- expect(globalThis.fetch).toHaveBeenCalledTimes(4);
536
- manager.destroy();
537
- });
178
+ it("deduplicates concurrent requests for the same cache key", async () => {
179
+ globalThis.fetch = vi.fn(() => new Promise((resolve) => {
180
+ setTimeout(() => {
181
+ resolve(createResponse(makeNativeFunctionResponse()));
182
+ }, 100);
183
+ }));
184
+ const manager = createManager();
185
+ const p1 = manager.getChannelToken({ channelId: "ch-1" });
186
+ const p2 = manager.getChannelToken({ channelId: "ch-1" });
187
+ await vi.advanceTimersByTimeAsync(100);
188
+ const [r1, r2] = await Promise.all([p1, p2]);
189
+ expect(r1.accessToken).toBe("access-token");
190
+ expect(r2.accessToken).toBe("access-token");
191
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
192
+ manager.destroy();
193
+ });
194
+ it("retries instead of returning an issued token invalidated during the request", async () => {
195
+ let requestCount = 0;
196
+ globalThis.fetch = vi.fn(() => new Promise((resolve) => {
197
+ requestCount++;
198
+ const accessToken = requestCount === 1 ? "stale-token" : "fresh-token";
199
+ const response = createResponse(makeNativeFunctionResponse({ accessToken }));
200
+ if (requestCount === 1) {
201
+ setTimeout(() => resolve(response), 100);
202
+ }
203
+ else {
204
+ resolve(response);
205
+ }
206
+ }));
207
+ const manager = createManager();
208
+ const tokenPromise = manager.getAppToken();
209
+ await Promise.resolve();
210
+ await Promise.resolve();
211
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
212
+ await vi.advanceTimersByTimeAsync(50);
213
+ await manager.invalidateAppToken();
214
+ await vi.advanceTimersByTimeAsync(100);
215
+ await expect(tokenPromise).resolves.toMatchObject({ accessToken: "fresh-token" });
216
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
217
+ manager.destroy();
538
218
  });
539
- // ----------------------------------------
540
- // Manual Refresh
541
- // ----------------------------------------
542
- describe("manual refresh", () => {
543
- it("should refresh a token manually", async () => {
544
- const refreshed = makeTokenResponse({ accessToken: "manually-refreshed" });
545
- globalThis.fetch = createMockFetch([
546
- { ok: true, status: 200, body: makeApiResponse(refreshed) },
547
- ]);
548
- const manager = createManager();
549
- const result = await manager.refreshToken("old-refresh-token");
550
- expect(result.accessToken).toBe("manually-refreshed");
551
- manager.destroy();
219
+ it("fails when token issuance keeps getting invalidated past the retry limit", async () => {
220
+ globalThis.fetch = vi.fn(() => new Promise((resolve) => {
221
+ setTimeout(() => {
222
+ resolve(createResponse(makeNativeFunctionResponse({ accessToken: "stale-token" })));
223
+ }, 100);
224
+ }));
225
+ const manager = createManager();
226
+ async function invalidateActiveRequest() {
227
+ await Promise.resolve();
228
+ await Promise.resolve();
229
+ await vi.advanceTimersByTimeAsync(50);
230
+ await manager.invalidateAppToken();
231
+ await vi.advanceTimersByTimeAsync(50);
232
+ await Promise.resolve();
233
+ await Promise.resolve();
234
+ }
235
+ const tokenPromise = manager.getAppToken();
236
+ await invalidateActiveRequest();
237
+ await invalidateActiveRequest();
238
+ const rejection = expect(tokenPromise).rejects.toThrow("retry limit exceeded");
239
+ await invalidateActiveRequest();
240
+ await rejection;
241
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
242
+ manager.destroy();
243
+ });
244
+ it("retries instead of returning a refreshed token invalidated during the request", async () => {
245
+ let issueCount = 0;
246
+ globalThis.fetch = vi.fn((_url, init) => {
247
+ const request = JSON.parse(init.body);
248
+ if (request.method === "refreshToken") {
249
+ return new Promise((resolve) => {
250
+ setTimeout(() => {
251
+ resolve(createResponse(makeNativeFunctionResponse({
252
+ accessToken: "stale-refreshed-token",
253
+ refreshToken: "stale-refresh-token",
254
+ })));
255
+ }, 100);
256
+ });
257
+ }
258
+ issueCount++;
259
+ const response = issueCount === 1
260
+ ? makeNativeFunctionResponse({
261
+ accessToken: "old-token",
262
+ refreshToken: "old-refresh-token",
263
+ })
264
+ : makeNativeFunctionResponse({
265
+ accessToken: "fresh-token",
266
+ refreshToken: "fresh-refresh-token",
267
+ });
268
+ return Promise.resolve(createResponse(response));
552
269
  });
270
+ const manager = createManager();
271
+ await expect(manager.getAppToken()).resolves.toMatchObject({ accessToken: "old-token" });
272
+ vi.advanceTimersByTime(56 * 60 * 1000);
273
+ const tokenPromise = manager.getAppToken();
274
+ await Promise.resolve();
275
+ await Promise.resolve();
276
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
277
+ await vi.advanceTimersByTimeAsync(50);
278
+ await manager.invalidateAppToken();
279
+ await vi.advanceTimersByTimeAsync(100);
280
+ await expect(tokenPromise).resolves.toMatchObject({ accessToken: "fresh-token" });
281
+ expect(globalThis.fetch).toHaveBeenCalledTimes(3);
282
+ expect(getRequest(1).method).toBe("refreshToken");
283
+ expect(getRequest(2).method).toBe("issueToken");
284
+ manager.destroy();
285
+ });
286
+ it("invalidates and clears cached tokens", async () => {
287
+ globalThis.fetch = createMockFetch([
288
+ { ok: true, status: 200, body: makeNativeFunctionResponse({ accessToken: "token-1" }) },
289
+ { ok: true, status: 200, body: makeNativeFunctionResponse({ accessToken: "token-2" }) },
290
+ { ok: true, status: 200, body: makeNativeFunctionResponse({ accessToken: "token-3" }) },
291
+ { ok: true, status: 200, body: makeNativeFunctionResponse({ accessToken: "token-4" }) },
292
+ { ok: true, status: 200, body: makeNativeFunctionResponse({ accessToken: "token-5" }) },
293
+ ]);
294
+ const manager = createManager();
295
+ await manager.getChannelToken({ channelId: "ch-1" });
296
+ await manager.invalidateChannelToken("ch-1");
297
+ expect((await manager.getChannelToken({ channelId: "ch-1" })).accessToken).toBe("token-2");
298
+ await manager.getAppToken();
299
+ await manager.invalidateAppToken();
300
+ expect((await manager.getAppToken()).accessToken).toBe("token-4");
301
+ await manager.clearCache();
302
+ expect((await manager.getAppToken()).accessToken).toBe("token-5");
303
+ manager.destroy();
553
304
  });
554
- // ----------------------------------------
555
- // API Response Normalization
556
- // ----------------------------------------
557
- describe("API response normalization", () => {
558
- it("should handle snake_case API responses", async () => {
559
- globalThis.fetch = createMockFetch([
560
- {
561
- ok: true,
562
- status: 200,
563
- body: {
564
- access_token: "snake-access",
565
- refresh_token: "snake-refresh",
566
- expires_in: 7200,
567
- token_type: "Bearer",
568
- scope: "read write",
305
+ it("does not log token-bearing native error bodies", async () => {
306
+ const logger = { debug: vi.fn() };
307
+ globalThis.fetch = createMockFetch([
308
+ { ok: true, status: 200, body: makeNativeFunctionResponse() },
309
+ {
310
+ ok: true,
311
+ status: 200,
312
+ body: {
313
+ error: {
314
+ code: 401,
315
+ message: "Unauthorized",
316
+ data: {
317
+ accessToken: "secret-access-token",
318
+ refreshToken: "secret-refresh-token",
319
+ },
569
320
  },
570
321
  },
571
- ]);
572
- const manager = createManager();
573
- const result = await manager.getChannelToken({ channelId: "ch-1" });
574
- expect(result.accessToken).toBe("snake-access");
575
- expect(result.refreshToken).toBe("snake-refresh");
576
- expect(result.expiresIn).toBe(7200);
577
- expect(result.tokenType).toBe("Bearer");
578
- expect(result.scopes).toEqual(["read", "write"]);
579
- manager.destroy();
580
- });
322
+ },
323
+ {
324
+ ok: true,
325
+ status: 200,
326
+ body: makeNativeFunctionResponse({ accessToken: "reissued-token" }),
327
+ },
328
+ ]);
329
+ const manager = createManager({ debug: true, logger });
330
+ await manager.getAppToken();
331
+ vi.advanceTimersByTime(56 * 60 * 1000);
332
+ await manager.getAppToken();
333
+ const logPayload = JSON.stringify(logger.debug.mock.calls);
334
+ expect(logPayload).toContain("Unauthorized");
335
+ expect(logPayload).not.toContain("secret-access-token");
336
+ expect(logPayload).not.toContain("secret-refresh-token");
337
+ manager.destroy();
581
338
  });
582
- // ----------------------------------------
583
- // Custom Cache Storage
584
- // ----------------------------------------
585
- describe("custom cache storage", () => {
586
- it("should use custom cache storage when provided", async () => {
587
- const storage = {
588
- get: vi.fn().mockResolvedValue(null),
589
- set: vi.fn().mockResolvedValue(undefined),
590
- delete: vi.fn().mockResolvedValue(undefined),
591
- clear: vi.fn().mockResolvedValue(undefined),
592
- };
593
- const token = makeTokenResponse();
594
- globalThis.fetch = createMockFetch([{ ok: true, status: 200, body: makeApiResponse(token) }]);
595
- const manager = createManager({ cacheStorage: storage });
596
- await manager.getChannelToken({ channelId: "ch-1" });
597
- expect(storage.get).toHaveBeenCalled();
598
- expect(storage.set).toHaveBeenCalled();
599
- manager.destroy();
600
- });
339
+ it("times out hung native function requests and clears in-flight state", async () => {
340
+ globalThis.fetch = vi
341
+ .fn()
342
+ .mockImplementationOnce((_url, init) => {
343
+ return new Promise((_resolve, reject) => {
344
+ const signal = init.signal;
345
+ signal?.addEventListener("abort", () => {
346
+ const error = new Error("The operation was aborted.");
347
+ error.name = "AbortError";
348
+ reject(error);
349
+ });
350
+ });
351
+ })
352
+ .mockResolvedValueOnce(createResponse(makeNativeFunctionResponse({ accessToken: "after-timeout" })));
353
+ const manager = createManager();
354
+ const timedOut = manager.getAppToken();
355
+ await Promise.resolve();
356
+ await Promise.resolve();
357
+ const rejection = expect(timedOut).rejects.toMatchObject({ name: "AbortError" });
358
+ await vi.advanceTimersByTimeAsync(10_000);
359
+ await rejection;
360
+ await expect(manager.getAppToken()).resolves.toMatchObject({ accessToken: "after-timeout" });
361
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
362
+ manager.destroy();
601
363
  });
602
- // ----------------------------------------
603
- // Destroy
604
- // ----------------------------------------
605
- describe("destroy", () => {
606
- it("should cleanup resources on destroy", () => {
607
- const manager = createManager();
608
- // Should not throw
609
- expect(() => manager.destroy()).not.toThrow();
610
- });
611
- it("should not throw when destroy is called with custom cache", () => {
612
- const storage = {
613
- get: vi.fn().mockResolvedValue(null),
614
- set: vi.fn().mockResolvedValue(undefined),
615
- delete: vi.fn().mockResolvedValue(undefined),
616
- clear: vi.fn().mockResolvedValue(undefined),
617
- };
618
- const manager = createManager({ cacheStorage: storage });
619
- // Should not throw even with custom storage (no destroy method)
620
- expect(() => manager.destroy()).not.toThrow();
364
+ it("throws TokenManagerError for transport and native function errors", async () => {
365
+ globalThis.fetch = createMockFetch([{ ok: false, status: 500, body: "server error" }]);
366
+ const manager = createManager();
367
+ await expect(manager.getAppToken()).rejects.toThrow(TokenManagerError);
368
+ manager.destroy();
369
+ globalThis.fetch = createMockFetch([
370
+ { ok: true, status: 200, body: { error: { code: 401, message: "Unauthorized" } } },
371
+ ]);
372
+ const errorManager = createManager();
373
+ await expect(errorManager.getAppToken()).rejects.toThrow(TokenManagerError);
374
+ errorManager.destroy();
375
+ globalThis.fetch = createMockFetch([
376
+ {
377
+ ok: true,
378
+ status: 200,
379
+ body: { result: { refreshToken: "refresh-token", expiresIn: 3600 } },
380
+ },
381
+ ]);
382
+ const invalidResponseManager = createManager();
383
+ await expect(invalidResponseManager.getAppToken()).rejects.toThrow("Invalid native token response");
384
+ invalidResponseManager.destroy();
385
+ });
386
+ it("uses custom AppStore URL and cache storage", async () => {
387
+ const storage = new InMemoryTokenCache();
388
+ globalThis.fetch = createMockFetch([
389
+ { ok: true, status: 200, body: makeNativeFunctionResponse() },
390
+ ]);
391
+ const manager = createManager({
392
+ appStoreUrl: "https://app-store.example.com",
393
+ cacheStorage: storage,
621
394
  });
395
+ await manager.getAppToken();
396
+ expect(globalThis.fetch.mock.calls[0][0]).toBe("https://app-store.example.com/general/v1/native/functions");
397
+ expect(storage.getStats().keys).toEqual(["test-app:app"]);
398
+ manager.destroy();
399
+ storage.destroy();
622
400
  });
623
401
  });
624
402
  //# sourceMappingURL=token-manager.test.js.map