@channel.io/app-sdk-server 0.2.0 → 0.3.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.
- package/dist/__tests__/native-client.test.d.ts +2 -0
- package/dist/__tests__/native-client.test.d.ts.map +1 -0
- package/dist/__tests__/native-client.test.js +256 -0
- package/dist/__tests__/native-client.test.js.map +1 -0
- package/dist/__tests__/proxy-api.test.d.ts +2 -0
- package/dist/__tests__/proxy-api.test.d.ts.map +1 -0
- package/dist/__tests__/proxy-api.test.js +273 -0
- package/dist/__tests__/proxy-api.test.js.map +1 -0
- package/dist/__tests__/signature.test.d.ts +2 -0
- package/dist/__tests__/signature.test.d.ts.map +1 -0
- package/dist/__tests__/signature.test.js +172 -0
- package/dist/__tests__/signature.test.js.map +1 -0
- package/dist/__tests__/token-manager.test.d.ts +2 -0
- package/dist/__tests__/token-manager.test.d.ts.map +1 -0
- package/dist/__tests__/token-manager.test.js +624 -0
- package/dist/__tests__/token-manager.test.js.map +1 -0
- package/dist/guards/signature.middleware.d.ts +25 -16
- package/dist/guards/signature.middleware.d.ts.map +1 -1
- package/dist/guards/signature.middleware.js +35 -19
- package/dist/guards/signature.middleware.js.map +1 -1
- package/dist/native/client.d.ts +55 -1
- package/dist/native/client.d.ts.map +1 -1
- package/dist/native/client.js +89 -0
- package/dist/native/client.js.map +1 -1
- package/dist/native/index.d.ts +2 -0
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +2 -0
- package/dist/native/index.js.map +1 -1
- package/dist/native/proxy-api.d.ts +110 -0
- package/dist/native/proxy-api.d.ts.map +1 -0
- package/dist/native/proxy-api.js +159 -0
- package/dist/native/proxy-api.js.map +1 -0
- package/dist/native/proxy-api.types.d.ts +232 -0
- package/dist/native/proxy-api.types.d.ts.map +1 -0
- package/dist/native/proxy-api.types.js +10 -0
- package/dist/native/proxy-api.types.js.map +1 -0
- package/dist/native/types.d.ts +108 -0
- package/dist/native/types.d.ts.map +1 -1
- package/dist/nestjs/channel-app.service.d.ts +21 -2
- package/dist/nestjs/channel-app.service.d.ts.map +1 -1
- package/dist/nestjs/channel-app.service.js +73 -36
- package/dist/nestjs/channel-app.service.js.map +1 -1
- package/dist/nestjs/channel-app.service.test.d.ts +2 -0
- package/dist/nestjs/channel-app.service.test.d.ts.map +1 -0
- package/dist/nestjs/channel-app.service.test.js +240 -0
- package/dist/nestjs/channel-app.service.test.js.map +1 -0
- package/dist/token/manager.d.ts +33 -1
- package/dist/token/manager.d.ts.map +1 -1
- package/dist/token/manager.js +75 -13
- package/dist/token/manager.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { TokenManager, TokenManagerError } from "../token/manager.js";
|
|
3
|
+
import { InMemoryTokenCache } from "../token/cache.js";
|
|
4
|
+
// ============================================
|
|
5
|
+
// Helpers
|
|
6
|
+
// ============================================
|
|
7
|
+
function makeTokenResponse(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
accessToken: overrides.accessToken ?? "access-token-123",
|
|
10
|
+
refreshToken: overrides.refreshToken ?? "refresh-token-456",
|
|
11
|
+
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
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function makeApiResponse(token) {
|
|
19
|
+
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,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function createMockFetch(responses) {
|
|
30
|
+
if (responses.length === 0) {
|
|
31
|
+
throw new Error("createMockFetch requires at least one response");
|
|
32
|
+
}
|
|
33
|
+
let callIndex = 0;
|
|
34
|
+
return vi.fn(async () => {
|
|
35
|
+
const resp = (responses[callIndex] ?? responses[responses.length - 1]);
|
|
36
|
+
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
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// ============================================
|
|
47
|
+
// InMemoryTokenCache Tests
|
|
48
|
+
// ============================================
|
|
49
|
+
describe("InMemoryTokenCache", () => {
|
|
50
|
+
let cache;
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
cache = new InMemoryTokenCache(60000);
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
cache.destroy();
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
});
|
|
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 () => {
|
|
72
|
+
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" });
|
|
91
|
+
expect(cache.getStats().size).toBe(2);
|
|
92
|
+
await cache.clear();
|
|
93
|
+
expect(cache.getStats().size).toBe(0);
|
|
94
|
+
});
|
|
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
|
+
});
|
|
113
|
+
// ============================================
|
|
114
|
+
// TokenManager Tests
|
|
115
|
+
// ============================================
|
|
116
|
+
describe("TokenManager", () => {
|
|
117
|
+
let originalFetch;
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
vi.useFakeTimers();
|
|
120
|
+
originalFetch = globalThis.fetch;
|
|
121
|
+
});
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
globalThis.fetch = originalFetch;
|
|
124
|
+
vi.useRealTimers();
|
|
125
|
+
});
|
|
126
|
+
function createManager(overrides = {}) {
|
|
127
|
+
return new TokenManager({
|
|
128
|
+
appId: "test-app",
|
|
129
|
+
appSecret: "test-secret",
|
|
130
|
+
debug: true,
|
|
131
|
+
...overrides,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
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();
|
|
182
|
+
});
|
|
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();
|
|
215
|
+
});
|
|
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();
|
|
234
|
+
});
|
|
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();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
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();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
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({
|
|
391
|
+
ok: true,
|
|
392
|
+
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
|
+
});
|
|
481
|
+
});
|
|
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
|
+
});
|
|
538
|
+
});
|
|
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();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
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",
|
|
569
|
+
},
|
|
570
|
+
},
|
|
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
|
+
});
|
|
581
|
+
});
|
|
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
|
+
});
|
|
601
|
+
});
|
|
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();
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
//# sourceMappingURL=token-manager.test.js.map
|