@mondaydotcomorg/atp-providers 0.17.14
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/README.md +430 -0
- package/__tests__/oauth-integration.test.ts +457 -0
- package/__tests__/scope-checkers.test.ts +560 -0
- package/__tests__/token-expiration.test.ts +308 -0
- package/dist/audit/jsonl.d.ts +29 -0
- package/dist/audit/jsonl.d.ts.map +1 -0
- package/dist/audit/jsonl.js +163 -0
- package/dist/audit/jsonl.js.map +1 -0
- package/dist/audit/opentelemetry.d.ts +23 -0
- package/dist/audit/opentelemetry.d.ts.map +1 -0
- package/dist/audit/opentelemetry.js +240 -0
- package/dist/audit/opentelemetry.js.map +1 -0
- package/dist/audit/otel-metrics.d.ts +111 -0
- package/dist/audit/otel-metrics.d.ts.map +1 -0
- package/dist/audit/otel-metrics.js +115 -0
- package/dist/audit/otel-metrics.js.map +1 -0
- package/dist/auth/env.d.ts +21 -0
- package/dist/auth/env.d.ts.map +1 -0
- package/dist/auth/env.js +48 -0
- package/dist/auth/env.js.map +1 -0
- package/dist/cache/file.d.ts +47 -0
- package/dist/cache/file.d.ts.map +1 -0
- package/dist/cache/file.js +262 -0
- package/dist/cache/file.js.map +1 -0
- package/dist/cache/memory.d.ts +30 -0
- package/dist/cache/memory.d.ts.map +1 -0
- package/dist/cache/memory.js +81 -0
- package/dist/cache/memory.js.map +1 -0
- package/dist/cache/redis.d.ts +28 -0
- package/dist/cache/redis.d.ts.map +1 -0
- package/dist/cache/redis.js +124 -0
- package/dist/cache/redis.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/index.d.ts +2 -0
- package/dist/oauth/index.d.ts.map +1 -0
- package/dist/oauth/index.js +2 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/oauth/scope-checkers.d.ts +61 -0
- package/dist/oauth/scope-checkers.d.ts.map +1 -0
- package/dist/oauth/scope-checkers.js +166 -0
- package/dist/oauth/scope-checkers.js.map +1 -0
- package/package.json +26 -0
- package/project.json +31 -0
- package/src/audit/jsonl.ts +189 -0
- package/src/audit/opentelemetry.ts +286 -0
- package/src/audit/otel-metrics.ts +122 -0
- package/src/auth/env.ts +65 -0
- package/src/cache/file.ts +330 -0
- package/src/cache/memory.ts +105 -0
- package/src/cache/redis.ts +160 -0
- package/src/index.ts +32 -0
- package/src/oauth/index.ts +1 -0
- package/src/oauth/scope-checkers.ts +196 -0
- package/tsconfig.json +14 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ScopeCheckerRegistry } from '../src/oauth/scope-checkers.js';
|
|
3
|
+
import type { ScopeChecker } from '@mondaydotcomorg/atp-protocol';
|
|
4
|
+
|
|
5
|
+
// Mock implementations for testing
|
|
6
|
+
class MockGitHubScopeChecker implements ScopeChecker {
|
|
7
|
+
provider = 'github';
|
|
8
|
+
|
|
9
|
+
async check(token: string): Promise<string[]> {
|
|
10
|
+
const response = await fetch('https://api.github.com/user', {
|
|
11
|
+
headers: {
|
|
12
|
+
Authorization: `Bearer ${token}`,
|
|
13
|
+
Accept: 'application/vnd.github+json',
|
|
14
|
+
'User-Agent': 'agent-tool-protocol',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
if (response.status === 401) {
|
|
20
|
+
throw new Error('Invalid or expired GitHub token');
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const scopesHeader = response.headers.get('X-OAuth-Scopes');
|
|
26
|
+
if (!scopesHeader) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return scopesHeader
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter((s) => s.length > 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async validate(token: string): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch('https://api.github.com/user', {
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${token}`,
|
|
41
|
+
Accept: 'application/vnd.github+json',
|
|
42
|
+
'User-Agent': 'agent-tool-protocol',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return response.ok;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class MockGoogleScopeChecker implements ScopeChecker {
|
|
53
|
+
provider = 'google';
|
|
54
|
+
|
|
55
|
+
async check(token: string): Promise<string[]> {
|
|
56
|
+
const response = await fetch(
|
|
57
|
+
`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(token)}`
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
if (response.status === 400) {
|
|
62
|
+
throw new Error('Invalid or expired Google token');
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Google tokeninfo error: ${response.status} ${response.statusText}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = (await response.json()) as { scope?: string; exp?: number };
|
|
68
|
+
|
|
69
|
+
if (data.exp && data.exp < Math.floor(Date.now() / 1000)) {
|
|
70
|
+
throw new Error('Google token has expired');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!data.scope) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return data.scope.split(' ').filter((s) => s.length > 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async validate(token: string): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(
|
|
83
|
+
`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(token)}`
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const data = (await response.json()) as { exp?: number };
|
|
91
|
+
if (data.exp) {
|
|
92
|
+
const now = Math.floor(Date.now() / 1000);
|
|
93
|
+
return data.exp > now;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Mock fetch globally
|
|
104
|
+
global.fetch = vi.fn();
|
|
105
|
+
|
|
106
|
+
describe('MockGitHubScopeChecker', () => {
|
|
107
|
+
let checker: MockGitHubScopeChecker;
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
checker = new MockGitHubScopeChecker();
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('check()', () => {
|
|
115
|
+
it('should return scopes from X-OAuth-Scopes header', async () => {
|
|
116
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
117
|
+
ok: true,
|
|
118
|
+
headers: {
|
|
119
|
+
get: (name: string) => {
|
|
120
|
+
if (name === 'X-OAuth-Scopes') {
|
|
121
|
+
return 'repo, read:user, write:org';
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const scopes = await checker.check('gho_test_token');
|
|
129
|
+
|
|
130
|
+
expect(scopes).toEqual(['repo', 'read:user', 'write:org']);
|
|
131
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
132
|
+
'https://api.github.com/user',
|
|
133
|
+
expect.objectContaining({
|
|
134
|
+
headers: expect.objectContaining({
|
|
135
|
+
Authorization: 'Bearer gho_test_token',
|
|
136
|
+
}),
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should return empty array when no scopes header', async () => {
|
|
142
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
143
|
+
ok: true,
|
|
144
|
+
headers: {
|
|
145
|
+
get: () => null,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const scopes = await checker.check('gho_test_token');
|
|
150
|
+
|
|
151
|
+
expect(scopes).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should throw error on 401 unauthorized', async () => {
|
|
155
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
156
|
+
ok: false,
|
|
157
|
+
status: 401,
|
|
158
|
+
statusText: 'Unauthorized',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await expect(checker.check('invalid_token')).rejects.toThrow(
|
|
162
|
+
'Invalid or expired GitHub token'
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should throw error on other HTTP errors', async () => {
|
|
167
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
168
|
+
ok: false,
|
|
169
|
+
status: 500,
|
|
170
|
+
statusText: 'Internal Server Error',
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(checker.check('gho_test_token')).rejects.toThrow(
|
|
174
|
+
'GitHub API error: 500 Internal Server Error'
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('validate()', () => {
|
|
180
|
+
it('should return true for valid token', async () => {
|
|
181
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
182
|
+
ok: true,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const isValid = await checker.validate('gho_test_token');
|
|
186
|
+
|
|
187
|
+
expect(isValid).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should return false for invalid token', async () => {
|
|
191
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
192
|
+
ok: false,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const isValid = await checker.validate('invalid_token');
|
|
196
|
+
|
|
197
|
+
expect(isValid).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should return false on network error', async () => {
|
|
201
|
+
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
|
202
|
+
|
|
203
|
+
const isValid = await checker.validate('gho_test_token');
|
|
204
|
+
|
|
205
|
+
expect(isValid).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('MockGoogleScopeChecker', () => {
|
|
211
|
+
let checker: MockGoogleScopeChecker;
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
checker = new MockGoogleScopeChecker();
|
|
215
|
+
vi.clearAllMocks();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('check()', () => {
|
|
219
|
+
it('should return scopes from tokeninfo response', async () => {
|
|
220
|
+
const now = Math.floor(Date.now() / 1000);
|
|
221
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
222
|
+
ok: true,
|
|
223
|
+
json: async () => ({
|
|
224
|
+
scope:
|
|
225
|
+
'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/userinfo.email',
|
|
226
|
+
exp: now + 3600,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const scopes = await checker.check('ya29.test_token');
|
|
231
|
+
|
|
232
|
+
expect(scopes).toEqual([
|
|
233
|
+
'https://www.googleapis.com/auth/calendar',
|
|
234
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should throw error when token is expired', async () => {
|
|
239
|
+
const expiredTime = Math.floor(Date.now() / 1000) - 3600;
|
|
240
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
241
|
+
ok: true,
|
|
242
|
+
json: async () => ({
|
|
243
|
+
scope: 'email',
|
|
244
|
+
exp: expiredTime,
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await expect(checker.check('ya29.test_token')).rejects.toThrow('Google token has expired');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should throw error on 400 bad request', async () => {
|
|
252
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
253
|
+
ok: false,
|
|
254
|
+
status: 400,
|
|
255
|
+
statusText: 'Bad Request',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await expect(checker.check('invalid_token')).rejects.toThrow(
|
|
259
|
+
'Invalid or expired Google token'
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should return empty array when no scope field', async () => {
|
|
264
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
265
|
+
ok: true,
|
|
266
|
+
json: async () => ({}),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const scopes = await checker.check('ya29.test_token');
|
|
270
|
+
|
|
271
|
+
expect(scopes).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('validate()', () => {
|
|
276
|
+
it('should return true for non-expired token', async () => {
|
|
277
|
+
const now = Math.floor(Date.now() / 1000);
|
|
278
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
279
|
+
ok: true,
|
|
280
|
+
json: async () => ({
|
|
281
|
+
exp: now + 3600,
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const isValid = await checker.validate('ya29.test_token');
|
|
286
|
+
|
|
287
|
+
expect(isValid).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should return false for expired token', async () => {
|
|
291
|
+
const expiredTime = Math.floor(Date.now() / 1000) - 3600;
|
|
292
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
293
|
+
ok: true,
|
|
294
|
+
json: async () => ({
|
|
295
|
+
exp: expiredTime,
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const isValid = await checker.validate('ya29.test_token');
|
|
300
|
+
|
|
301
|
+
expect(isValid).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should return false on network error', async () => {
|
|
305
|
+
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
|
306
|
+
|
|
307
|
+
const isValid = await checker.validate('ya29.test_token');
|
|
308
|
+
|
|
309
|
+
expect(isValid).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('ScopeChecker (Microsoft example)', () => {
|
|
315
|
+
// Tests removed - vendor implementations moved to examples
|
|
316
|
+
it('should be tested in examples', () => {
|
|
317
|
+
expect(true).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('ScopeCheckerRegistry', () => {
|
|
322
|
+
let registry: ScopeCheckerRegistry;
|
|
323
|
+
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
registry = new ScopeCheckerRegistry();
|
|
326
|
+
vi.clearAllMocks();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
afterEach(() => {
|
|
330
|
+
registry.stopCleanup();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('register()', () => {
|
|
334
|
+
it('should register custom scope checker', () => {
|
|
335
|
+
const customChecker = {
|
|
336
|
+
provider: 'custom',
|
|
337
|
+
check: vi.fn(),
|
|
338
|
+
validate: vi.fn(),
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
registry.register(customChecker);
|
|
342
|
+
|
|
343
|
+
expect(registry.hasChecker('custom')).toBe(true);
|
|
344
|
+
expect(registry.getChecker('custom')).toBe(customChecker);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('hasChecker()', () => {
|
|
349
|
+
it('should return false for checkers not registered', () => {
|
|
350
|
+
expect(registry.hasChecker('github')).toBe(false);
|
|
351
|
+
expect(registry.hasChecker('google')).toBe(false);
|
|
352
|
+
expect(registry.hasChecker('unknown')).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should return true for registered checkers', () => {
|
|
356
|
+
registry.register(new MockGitHubScopeChecker());
|
|
357
|
+
expect(registry.hasChecker('github')).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('checkScopes()', () => {
|
|
362
|
+
it('should check scopes and cache result', async () => {
|
|
363
|
+
// Register checker first
|
|
364
|
+
registry.register(new MockGitHubScopeChecker());
|
|
365
|
+
|
|
366
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
367
|
+
ok: true,
|
|
368
|
+
headers: {
|
|
369
|
+
get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const scopes1 = await registry.checkScopes('github', 'gho_test_token');
|
|
374
|
+
expect(scopes1).toEqual(['repo']);
|
|
375
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
376
|
+
|
|
377
|
+
// Second call should use cache
|
|
378
|
+
const scopes2 = await registry.checkScopes('github', 'gho_test_token');
|
|
379
|
+
expect(scopes2).toEqual(['repo']);
|
|
380
|
+
expect(global.fetch).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should deduplicate concurrent requests', async () => {
|
|
384
|
+
registry.register(new MockGitHubScopeChecker());
|
|
385
|
+
|
|
386
|
+
(global.fetch as any).mockImplementation(
|
|
387
|
+
() =>
|
|
388
|
+
new Promise((resolve) =>
|
|
389
|
+
setTimeout(
|
|
390
|
+
() =>
|
|
391
|
+
resolve({
|
|
392
|
+
ok: true,
|
|
393
|
+
headers: {
|
|
394
|
+
get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
100
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// Make 3 concurrent requests
|
|
403
|
+
const promises = [
|
|
404
|
+
registry.checkScopes('github', 'gho_test_token'),
|
|
405
|
+
registry.checkScopes('github', 'gho_test_token'),
|
|
406
|
+
registry.checkScopes('github', 'gho_test_token'),
|
|
407
|
+
];
|
|
408
|
+
|
|
409
|
+
const results = await Promise.all(promises);
|
|
410
|
+
|
|
411
|
+
// All should return the same result
|
|
412
|
+
expect(results).toEqual([['repo'], ['repo'], ['repo']]);
|
|
413
|
+
|
|
414
|
+
// But only one API call should have been made
|
|
415
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('should throw error for unsupported provider', async () => {
|
|
419
|
+
await expect(registry.checkScopes('unsupported', 'token')).rejects.toThrow(
|
|
420
|
+
'No scope checker registered for provider: unsupported'
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('validateToken()', () => {
|
|
426
|
+
it('should validate token', async () => {
|
|
427
|
+
registry.register(new MockGitHubScopeChecker());
|
|
428
|
+
|
|
429
|
+
(global.fetch as any).mockResolvedValueOnce({
|
|
430
|
+
ok: true,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const isValid = await registry.validateToken('github', 'gho_test_token');
|
|
434
|
+
|
|
435
|
+
expect(isValid).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should return true for checker without validate method', async () => {
|
|
439
|
+
const checkerWithoutValidate = {
|
|
440
|
+
provider: 'custom',
|
|
441
|
+
check: vi.fn(),
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
registry.register(checkerWithoutValidate);
|
|
445
|
+
|
|
446
|
+
const isValid = await registry.validateToken('custom', 'token');
|
|
447
|
+
|
|
448
|
+
expect(isValid).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe('getTokenInfo()', () => {
|
|
453
|
+
it('should return token info with validity and scopes', async () => {
|
|
454
|
+
registry.register(new MockGitHubScopeChecker());
|
|
455
|
+
|
|
456
|
+
(global.fetch as any)
|
|
457
|
+
.mockResolvedValueOnce({
|
|
458
|
+
ok: true,
|
|
459
|
+
headers: {
|
|
460
|
+
get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
|
|
461
|
+
},
|
|
462
|
+
})
|
|
463
|
+
.mockResolvedValueOnce({
|
|
464
|
+
ok: true,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const tokenInfo = await registry.getTokenInfo('github', 'gho_test_token');
|
|
468
|
+
|
|
469
|
+
expect(tokenInfo).toEqual({
|
|
470
|
+
valid: true,
|
|
471
|
+
scopes: ['repo'],
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe('clearCache()', () => {
|
|
477
|
+
it('should clear all cache when no provider specified', async () => {
|
|
478
|
+
registry.register(new MockGitHubScopeChecker());
|
|
479
|
+
|
|
480
|
+
(global.fetch as any).mockResolvedValue({
|
|
481
|
+
ok: true,
|
|
482
|
+
headers: {
|
|
483
|
+
get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Cache some scopes
|
|
488
|
+
await registry.checkScopes('github', 'token1');
|
|
489
|
+
await registry.checkScopes('github', 'token2');
|
|
490
|
+
|
|
491
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
492
|
+
|
|
493
|
+
// Clear cache
|
|
494
|
+
registry.clearCache();
|
|
495
|
+
|
|
496
|
+
// Next call should hit API again
|
|
497
|
+
await registry.checkScopes('github', 'token1');
|
|
498
|
+
expect(global.fetch).toHaveBeenCalledTimes(3);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should clear cache for specific provider only', async () => {
|
|
502
|
+
registry.register(new MockGitHubScopeChecker());
|
|
503
|
+
|
|
504
|
+
(global.fetch as any).mockResolvedValue({
|
|
505
|
+
ok: true,
|
|
506
|
+
headers: {
|
|
507
|
+
get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Cache scopes for github
|
|
512
|
+
await registry.checkScopes('github', 'token1');
|
|
513
|
+
const callCount = (global.fetch as any).mock.calls.length;
|
|
514
|
+
|
|
515
|
+
// Clear github cache
|
|
516
|
+
registry.clearCache('github');
|
|
517
|
+
|
|
518
|
+
// GitHub should hit API again
|
|
519
|
+
await registry.checkScopes('github', 'token1');
|
|
520
|
+
expect((global.fetch as any).mock.calls.length).toBe(callCount + 1);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe('cache cleanup', () => {
|
|
525
|
+
it('should periodically clean expired entries', async () => {
|
|
526
|
+
registry.register(new MockGitHubScopeChecker());
|
|
527
|
+
|
|
528
|
+
vi.useFakeTimers();
|
|
529
|
+
|
|
530
|
+
(global.fetch as any).mockResolvedValue({
|
|
531
|
+
ok: true,
|
|
532
|
+
headers: {
|
|
533
|
+
get: (name: string) => (name === 'X-OAuth-Scopes' ? 'repo' : null),
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// Cache with short TTL
|
|
538
|
+
await registry.checkScopes('github', 'token', 1); // 1 second TTL
|
|
539
|
+
|
|
540
|
+
// Fast-forward 5 minutes (cleanup interval)
|
|
541
|
+
vi.advanceTimersByTime(5 * 60 * 1000);
|
|
542
|
+
|
|
543
|
+
// Cache should be cleaned
|
|
544
|
+
await registry.checkScopes('github', 'token');
|
|
545
|
+
|
|
546
|
+
// Should have made 2 API calls (one before, one after cleanup)
|
|
547
|
+
expect((global.fetch as any).mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
548
|
+
|
|
549
|
+
vi.useRealTimers();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('should stop cleanup on stopCleanup()', () => {
|
|
553
|
+
const spy = vi.spyOn(global, 'clearInterval');
|
|
554
|
+
|
|
555
|
+
registry.stopCleanup();
|
|
556
|
+
|
|
557
|
+
expect(spy).toHaveBeenCalled();
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
});
|