tlc-claude-code 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dashboard/dist/components/AuditPane.d.ts +30 -0
  2. package/dashboard/dist/components/AuditPane.js +127 -0
  3. package/dashboard/dist/components/AuditPane.test.d.ts +1 -0
  4. package/dashboard/dist/components/AuditPane.test.js +339 -0
  5. package/dashboard/dist/components/CompliancePane.d.ts +39 -0
  6. package/dashboard/dist/components/CompliancePane.js +96 -0
  7. package/dashboard/dist/components/CompliancePane.test.d.ts +1 -0
  8. package/dashboard/dist/components/CompliancePane.test.js +183 -0
  9. package/dashboard/dist/components/SSOPane.d.ts +36 -0
  10. package/dashboard/dist/components/SSOPane.js +71 -0
  11. package/dashboard/dist/components/SSOPane.test.d.ts +1 -0
  12. package/dashboard/dist/components/SSOPane.test.js +155 -0
  13. package/dashboard/dist/components/WorkspaceDocsPane.js +0 -16
  14. package/dashboard/dist/components/WorkspacePane.d.ts +1 -1
  15. package/dashboard/dist/components/ZeroRetentionPane.d.ts +44 -0
  16. package/dashboard/dist/components/ZeroRetentionPane.js +83 -0
  17. package/dashboard/dist/components/ZeroRetentionPane.test.d.ts +1 -0
  18. package/dashboard/dist/components/ZeroRetentionPane.test.js +160 -0
  19. package/package.json +1 -1
  20. package/server/lib/access-control-doc.js +541 -0
  21. package/server/lib/access-control-doc.test.js +672 -0
  22. package/server/lib/adr-generator.js +423 -0
  23. package/server/lib/adr-generator.test.js +586 -0
  24. package/server/lib/agent-progress-monitor.js +223 -0
  25. package/server/lib/agent-progress-monitor.test.js +202 -0
  26. package/server/lib/audit-attribution.js +191 -0
  27. package/server/lib/audit-attribution.test.js +359 -0
  28. package/server/lib/audit-classifier.js +202 -0
  29. package/server/lib/audit-classifier.test.js +209 -0
  30. package/server/lib/audit-command.js +275 -0
  31. package/server/lib/audit-command.test.js +325 -0
  32. package/server/lib/audit-exporter.js +380 -0
  33. package/server/lib/audit-exporter.test.js +464 -0
  34. package/server/lib/audit-logger.js +236 -0
  35. package/server/lib/audit-logger.test.js +364 -0
  36. package/server/lib/audit-query.js +257 -0
  37. package/server/lib/audit-query.test.js +352 -0
  38. package/server/lib/audit-storage.js +269 -0
  39. package/server/lib/audit-storage.test.js +272 -0
  40. package/server/lib/bulk-repo-init.js +342 -0
  41. package/server/lib/bulk-repo-init.test.js +388 -0
  42. package/server/lib/compliance-checklist.js +866 -0
  43. package/server/lib/compliance-checklist.test.js +476 -0
  44. package/server/lib/compliance-command.js +616 -0
  45. package/server/lib/compliance-command.test.js +551 -0
  46. package/server/lib/compliance-reporter.js +692 -0
  47. package/server/lib/compliance-reporter.test.js +707 -0
  48. package/server/lib/data-flow-doc.js +665 -0
  49. package/server/lib/data-flow-doc.test.js +659 -0
  50. package/server/lib/ephemeral-storage.js +249 -0
  51. package/server/lib/ephemeral-storage.test.js +254 -0
  52. package/server/lib/evidence-collector.js +627 -0
  53. package/server/lib/evidence-collector.test.js +901 -0
  54. package/server/lib/flow-diagram-generator.js +474 -0
  55. package/server/lib/flow-diagram-generator.test.js +446 -0
  56. package/server/lib/idp-manager.js +626 -0
  57. package/server/lib/idp-manager.test.js +587 -0
  58. package/server/lib/memory-exclusion.js +326 -0
  59. package/server/lib/memory-exclusion.test.js +241 -0
  60. package/server/lib/mfa-handler.js +452 -0
  61. package/server/lib/mfa-handler.test.js +490 -0
  62. package/server/lib/oauth-flow.js +375 -0
  63. package/server/lib/oauth-flow.test.js +487 -0
  64. package/server/lib/oauth-registry.js +190 -0
  65. package/server/lib/oauth-registry.test.js +306 -0
  66. package/server/lib/readme-generator.js +490 -0
  67. package/server/lib/readme-generator.test.js +493 -0
  68. package/server/lib/repo-dependency-tracker.js +261 -0
  69. package/server/lib/repo-dependency-tracker.test.js +350 -0
  70. package/server/lib/retention-policy.js +281 -0
  71. package/server/lib/retention-policy.test.js +486 -0
  72. package/server/lib/role-mapper.js +236 -0
  73. package/server/lib/role-mapper.test.js +395 -0
  74. package/server/lib/saml-provider.js +765 -0
  75. package/server/lib/saml-provider.test.js +643 -0
  76. package/server/lib/security-policy-generator.js +682 -0
  77. package/server/lib/security-policy-generator.test.js +544 -0
  78. package/server/lib/sensitive-detector.js +112 -0
  79. package/server/lib/sensitive-detector.test.js +209 -0
  80. package/server/lib/service-interaction-diagram.js +700 -0
  81. package/server/lib/service-interaction-diagram.test.js +638 -0
  82. package/server/lib/service-summary.js +553 -0
  83. package/server/lib/service-summary.test.js +619 -0
  84. package/server/lib/session-purge.js +460 -0
  85. package/server/lib/session-purge.test.js +312 -0
  86. package/server/lib/sso-command.js +544 -0
  87. package/server/lib/sso-command.test.js +552 -0
  88. package/server/lib/sso-session.js +492 -0
  89. package/server/lib/sso-session.test.js +670 -0
  90. package/server/lib/workspace-command.js +249 -0
  91. package/server/lib/workspace-command.test.js +264 -0
  92. package/server/lib/workspace-config.js +270 -0
  93. package/server/lib/workspace-config.test.js +312 -0
  94. package/server/lib/workspace-docs-command.js +547 -0
  95. package/server/lib/workspace-docs-command.test.js +692 -0
  96. package/server/lib/workspace-memory.js +451 -0
  97. package/server/lib/workspace-memory.test.js +403 -0
  98. package/server/lib/workspace-scanner.js +452 -0
  99. package/server/lib/workspace-scanner.test.js +677 -0
  100. package/server/lib/workspace-test-runner.js +315 -0
  101. package/server/lib/workspace-test-runner.test.js +294 -0
  102. package/server/lib/zero-retention-command.js +439 -0
  103. package/server/lib/zero-retention-command.test.js +448 -0
  104. package/server/lib/zero-retention.js +322 -0
  105. package/server/lib/zero-retention.test.js +258 -0
@@ -0,0 +1,587 @@
1
+ /**
2
+ * Identity Provider Manager - Tests
3
+ * Unified interface for OAuth and SAML providers
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import { createIdPManager, PROVIDER_TYPES } from './idp-manager.js';
8
+
9
+ describe('IdP Manager', () => {
10
+ let manager;
11
+ let originalFetch;
12
+
13
+ beforeEach(() => {
14
+ originalFetch = global.fetch;
15
+ manager = createIdPManager({
16
+ baseUrl: 'https://app.example.com',
17
+ callbackPath: '/auth/callback',
18
+ });
19
+ });
20
+
21
+ afterEach(() => {
22
+ global.fetch = originalFetch;
23
+ });
24
+
25
+ describe('registerProvider', () => {
26
+ it('adds OAuth provider', () => {
27
+ // Register and verify we can retrieve the provider
28
+ manager.registerProvider('github', {
29
+ type: 'oauth',
30
+ clientId: 'client-123',
31
+ clientSecret: 'secret-456',
32
+ });
33
+
34
+ const provider = manager.getProvider('github');
35
+ expect(provider).not.toBeNull();
36
+ expect(provider.type).toBe('oauth');
37
+ expect(provider.config.clientId).toBe('client-123');
38
+ });
39
+
40
+ it('adds SAML provider', () => {
41
+ manager.registerProvider('company-idp', {
42
+ type: 'saml',
43
+ entityId: 'https://idp.company.com',
44
+ ssoUrl: 'https://idp.company.com/sso',
45
+ cert: 'MIIC...',
46
+ });
47
+
48
+ const provider = manager.getProvider('company-idp');
49
+ expect(provider).not.toBeNull();
50
+ expect(provider.type).toBe('saml');
51
+ expect(provider.config.entityId).toBe('https://idp.company.com');
52
+ });
53
+
54
+ it('throws on invalid provider type', () => {
55
+ expect(() => {
56
+ manager.registerProvider('invalid', {
57
+ type: 'ldap', // not supported
58
+ host: 'ldap.example.com',
59
+ });
60
+ }).toThrow(/unsupported provider type/i);
61
+ });
62
+
63
+ it('defaults to OAuth when type not specified but has clientId', () => {
64
+ manager.registerProvider('github', {
65
+ clientId: 'client-123',
66
+ clientSecret: 'secret-456',
67
+ });
68
+
69
+ const provider = manager.getProvider('github');
70
+ expect(provider.type).toBe('oauth');
71
+ });
72
+
73
+ it('defaults to SAML when type not specified but has entityId and ssoUrl', () => {
74
+ manager.registerProvider('company-idp', {
75
+ entityId: 'https://idp.company.com',
76
+ ssoUrl: 'https://idp.company.com/sso',
77
+ });
78
+
79
+ const provider = manager.getProvider('company-idp');
80
+ expect(provider.type).toBe('saml');
81
+ });
82
+ });
83
+
84
+ describe('getLoginUrl', () => {
85
+ it('returns OAuth authorization URL', () => {
86
+ manager.registerProvider('github', {
87
+ type: 'oauth',
88
+ clientId: 'client-123',
89
+ clientSecret: 'secret-456',
90
+ authUrl: 'https://github.com/login/oauth/authorize',
91
+ scopes: ['read:user', 'user:email'],
92
+ });
93
+
94
+ const result = manager.getLoginUrl('github', {
95
+ state: 'random-state',
96
+ redirectUri: 'https://app.example.com/auth/callback/github',
97
+ });
98
+
99
+ expect(result).toContain('https://github.com/login/oauth/authorize');
100
+ expect(result).toContain('client_id=client-123');
101
+ expect(result).toContain('state=random-state');
102
+ });
103
+
104
+ it('returns SAML redirect URL', () => {
105
+ manager.registerProvider('company-idp', {
106
+ type: 'saml',
107
+ entityId: 'https://idp.company.com',
108
+ ssoUrl: 'https://idp.company.com/sso',
109
+ });
110
+
111
+ const result = manager.getLoginUrl('company-idp', {
112
+ relayState: '/dashboard',
113
+ });
114
+
115
+ expect(result).toContain('https://idp.company.com/sso');
116
+ expect(result).toContain('SAMLRequest=');
117
+ });
118
+
119
+ it('throws for unknown provider', () => {
120
+ expect(() => {
121
+ manager.getLoginUrl('unknown');
122
+ }).toThrow(/provider not found/i);
123
+ });
124
+ });
125
+
126
+ describe('handleCallback', () => {
127
+ it('processes OAuth callback', async () => {
128
+ manager.registerProvider('github', {
129
+ type: 'oauth',
130
+ clientId: 'client-123',
131
+ clientSecret: 'secret-456',
132
+ tokenUrl: 'https://github.com/login/oauth/access_token',
133
+ userInfoUrl: 'https://api.github.com/user',
134
+ });
135
+
136
+ // Mock global fetch for OAuth token and user info
137
+ global.fetch = vi.fn()
138
+ .mockResolvedValueOnce({
139
+ ok: true,
140
+ json: () => Promise.resolve({
141
+ access_token: 'gho_abc123',
142
+ token_type: 'bearer',
143
+ }),
144
+ })
145
+ .mockResolvedValueOnce({
146
+ ok: true,
147
+ json: () => Promise.resolve({
148
+ id: 12345,
149
+ login: 'octocat',
150
+ email: 'octocat@github.com',
151
+ name: 'The Octocat',
152
+ avatar_url: 'https://avatars.githubusercontent.com/u/12345',
153
+ }),
154
+ });
155
+
156
+ const result = await manager.handleCallback('github', {
157
+ code: 'auth-code-123',
158
+ state: 'random-state',
159
+ redirectUri: 'https://app.example.com/auth/callback/github',
160
+ });
161
+
162
+ expect(result.success).toBe(true);
163
+ expect(result.profile).toBeDefined();
164
+ expect(result.profile.provider).toBe('github');
165
+ expect(result.profile.providerType).toBe('oauth');
166
+ });
167
+
168
+ it('processes SAML response', async () => {
169
+ manager.registerProvider('company-idp', {
170
+ type: 'saml',
171
+ entityId: 'https://idp.company.com',
172
+ ssoUrl: 'https://idp.company.com/sso',
173
+ skipSignatureValidation: true,
174
+ });
175
+
176
+ // Create a mock SAML response (base64 encoded)
177
+ const samlResponseXml = `<?xml version="1.0"?>
178
+ <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
179
+ <saml:Assertion>
180
+ <saml:Subject>
181
+ <saml:NameID>user@company.com</saml:NameID>
182
+ </saml:Subject>
183
+ <saml:AttributeStatement>
184
+ <saml:Attribute Name="email">
185
+ <saml:AttributeValue>user@company.com</saml:AttributeValue>
186
+ </saml:Attribute>
187
+ <saml:Attribute Name="firstName">
188
+ <saml:AttributeValue>John</saml:AttributeValue>
189
+ </saml:Attribute>
190
+ <saml:Attribute Name="lastName">
191
+ <saml:AttributeValue>Doe</saml:AttributeValue>
192
+ </saml:Attribute>
193
+ </saml:AttributeStatement>
194
+ </saml:Assertion>
195
+ </samlp:Response>`;
196
+ const samlResponse = Buffer.from(samlResponseXml).toString('base64');
197
+
198
+ const result = await manager.handleCallback('company-idp', {
199
+ SAMLResponse: samlResponse,
200
+ RelayState: '/dashboard',
201
+ });
202
+
203
+ expect(result.success).toBe(true);
204
+ expect(result.profile).toBeDefined();
205
+ expect(result.profile.provider).toBe('company-idp');
206
+ expect(result.profile.providerType).toBe('saml');
207
+ });
208
+
209
+ it('returns error for failed OAuth callback', async () => {
210
+ manager.registerProvider('github', {
211
+ type: 'oauth',
212
+ clientId: 'client-123',
213
+ clientSecret: 'secret-456',
214
+ tokenUrl: 'https://github.com/login/oauth/access_token',
215
+ });
216
+
217
+ global.fetch = vi.fn().mockResolvedValueOnce({
218
+ ok: false,
219
+ status: 401,
220
+ json: () => Promise.resolve({ error: 'invalid_grant' }),
221
+ });
222
+
223
+ const result = await manager.handleCallback('github', {
224
+ code: 'invalid-code',
225
+ });
226
+
227
+ expect(result.success).toBe(false);
228
+ expect(result.error).toBeDefined();
229
+ });
230
+
231
+ it('returns error for failed SAML response', async () => {
232
+ manager.registerProvider('company-idp', {
233
+ type: 'saml',
234
+ entityId: 'https://idp.company.com',
235
+ ssoUrl: 'https://idp.company.com/sso',
236
+ });
237
+
238
+ // Invalid/malformed SAML response
239
+ const result = await manager.handleCallback('company-idp', {
240
+ SAMLResponse: 'invalid-base64-data',
241
+ });
242
+
243
+ expect(result.success).toBe(false);
244
+ expect(result.error).toBeDefined();
245
+ });
246
+ });
247
+
248
+ describe('normalizeProfile', () => {
249
+ it('extracts email from GitHub', () => {
250
+ const profile = manager.normalizeProfile('github', 'oauth', {
251
+ id: 12345,
252
+ login: 'octocat',
253
+ email: 'octocat@github.com',
254
+ name: 'The Octocat',
255
+ avatar_url: 'https://avatars.githubusercontent.com/u/12345',
256
+ });
257
+
258
+ expect(profile).toEqual({
259
+ id: '12345',
260
+ email: 'octocat@github.com',
261
+ name: 'The Octocat',
262
+ firstName: 'The',
263
+ lastName: 'Octocat',
264
+ avatarUrl: 'https://avatars.githubusercontent.com/u/12345',
265
+ provider: 'github',
266
+ providerType: 'oauth',
267
+ raw: expect.any(Object),
268
+ });
269
+ });
270
+
271
+ it('extracts email from Google', () => {
272
+ const profile = manager.normalizeProfile('google', 'oauth', {
273
+ id: '118234567890123456789',
274
+ email: 'user@gmail.com',
275
+ verified_email: true,
276
+ name: 'John Doe',
277
+ given_name: 'John',
278
+ family_name: 'Doe',
279
+ picture: 'https://lh3.googleusercontent.com/a/photo',
280
+ });
281
+
282
+ expect(profile).toEqual({
283
+ id: '118234567890123456789',
284
+ email: 'user@gmail.com',
285
+ name: 'John Doe',
286
+ firstName: 'John',
287
+ lastName: 'Doe',
288
+ avatarUrl: 'https://lh3.googleusercontent.com/a/photo',
289
+ provider: 'google',
290
+ providerType: 'oauth',
291
+ raw: expect.any(Object),
292
+ });
293
+ });
294
+
295
+ it('extracts email from Azure AD', () => {
296
+ const profile = manager.normalizeProfile('azuread', 'oauth', {
297
+ id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
298
+ mail: 'john.doe@company.com',
299
+ displayName: 'John Doe',
300
+ givenName: 'John',
301
+ surname: 'Doe',
302
+ userPrincipalName: 'john.doe@company.onmicrosoft.com',
303
+ });
304
+
305
+ expect(profile).toEqual({
306
+ id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
307
+ email: 'john.doe@company.com',
308
+ name: 'John Doe',
309
+ firstName: 'John',
310
+ lastName: 'Doe',
311
+ avatarUrl: null,
312
+ provider: 'azuread',
313
+ providerType: 'oauth',
314
+ raw: expect.any(Object),
315
+ });
316
+ });
317
+
318
+ it('falls back to userPrincipalName for Azure AD email', () => {
319
+ const profile = manager.normalizeProfile('azuread', 'oauth', {
320
+ id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
321
+ displayName: 'John Doe',
322
+ userPrincipalName: 'john.doe@company.onmicrosoft.com',
323
+ });
324
+
325
+ expect(profile.email).toBe('john.doe@company.onmicrosoft.com');
326
+ });
327
+
328
+ it('extracts email from SAML assertion', () => {
329
+ const profile = manager.normalizeProfile('company-idp', 'saml', {
330
+ nameId: 'user@company.com',
331
+ email: 'user@company.com',
332
+ firstName: 'Jane',
333
+ lastName: 'Smith',
334
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'user@company.com',
335
+ });
336
+
337
+ expect(profile).toEqual({
338
+ id: 'user@company.com',
339
+ email: 'user@company.com',
340
+ name: 'Jane Smith',
341
+ firstName: 'Jane',
342
+ lastName: 'Smith',
343
+ avatarUrl: null,
344
+ provider: 'company-idp',
345
+ providerType: 'saml',
346
+ raw: expect.any(Object),
347
+ });
348
+ });
349
+
350
+ it('handles missing name fields gracefully', () => {
351
+ const profile = manager.normalizeProfile('github', 'oauth', {
352
+ id: 12345,
353
+ login: 'octocat',
354
+ email: 'octocat@github.com',
355
+ });
356
+
357
+ expect(profile.name).toBe('octocat');
358
+ expect(profile.firstName).toBe('octocat');
359
+ expect(profile.lastName).toBe('');
360
+ });
361
+ });
362
+
363
+ describe('cacheMetadata', () => {
364
+ it('stores provider metadata', () => {
365
+ const metadata = {
366
+ entityId: 'https://idp.company.com',
367
+ ssoUrl: 'https://idp.company.com/sso',
368
+ cert: 'MIIC...',
369
+ fetchedAt: new Date(),
370
+ };
371
+
372
+ manager.cacheMetadata('company-idp', metadata);
373
+ const cached = manager.getCachedMetadata('company-idp');
374
+
375
+ // The cached version includes a 'cachedAt' field added by the manager
376
+ expect(cached.entityId).toBe(metadata.entityId);
377
+ expect(cached.ssoUrl).toBe(metadata.ssoUrl);
378
+ expect(cached.cert).toBe(metadata.cert);
379
+ });
380
+
381
+ it('returns null for non-existent cache', () => {
382
+ const cached = manager.getCachedMetadata('non-existent');
383
+ expect(cached).toBeNull();
384
+ });
385
+
386
+ it('supports cache expiry check', () => {
387
+ const metadata = {
388
+ entityId: 'https://idp.company.com',
389
+ fetchedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25 hours ago
390
+ };
391
+
392
+ manager.cacheMetadata('company-idp', metadata);
393
+ const isExpired = manager.isMetadataExpired('company-idp', 24 * 60 * 60 * 1000);
394
+
395
+ expect(isExpired).toBe(true);
396
+ });
397
+
398
+ it('clears cached metadata', () => {
399
+ manager.cacheMetadata('company-idp', { entityId: 'test' });
400
+ manager.clearMetadataCache('company-idp');
401
+
402
+ expect(manager.getCachedMetadata('company-idp')).toBeNull();
403
+ });
404
+ });
405
+
406
+ describe('getProvider', () => {
407
+ it('returns correct provider type for OAuth', () => {
408
+ manager.registerProvider('github', {
409
+ type: 'oauth',
410
+ clientId: 'client-123',
411
+ clientSecret: 'secret-456',
412
+ });
413
+
414
+ const provider = manager.getProvider('github');
415
+
416
+ expect(provider).toEqual({
417
+ name: 'github',
418
+ type: 'oauth',
419
+ config: expect.objectContaining({
420
+ clientId: 'client-123',
421
+ }),
422
+ });
423
+ });
424
+
425
+ it('returns correct provider type for SAML', () => {
426
+ manager.registerProvider('company-idp', {
427
+ type: 'saml',
428
+ entityId: 'https://idp.company.com',
429
+ ssoUrl: 'https://idp.company.com/sso',
430
+ });
431
+
432
+ const provider = manager.getProvider('company-idp');
433
+
434
+ expect(provider).toEqual({
435
+ name: 'company-idp',
436
+ type: 'saml',
437
+ config: expect.objectContaining({
438
+ entityId: 'https://idp.company.com',
439
+ }),
440
+ });
441
+ });
442
+
443
+ it('returns null for unknown provider', () => {
444
+ const provider = manager.getProvider('unknown');
445
+ expect(provider).toBeNull();
446
+ });
447
+ });
448
+
449
+ describe('listProviders', () => {
450
+ it('lists all registered providers with types', () => {
451
+ manager.registerProvider('github', {
452
+ type: 'oauth',
453
+ clientId: 'gh-123',
454
+ clientSecret: 'secret',
455
+ });
456
+ manager.registerProvider('google', {
457
+ type: 'oauth',
458
+ clientId: 'ggl-456',
459
+ clientSecret: 'secret',
460
+ });
461
+ manager.registerProvider('company-idp', {
462
+ type: 'saml',
463
+ entityId: 'https://idp1.com',
464
+ ssoUrl: 'https://idp1.com/sso',
465
+ });
466
+ manager.registerProvider('partner-idp', {
467
+ type: 'saml',
468
+ entityId: 'https://idp2.com',
469
+ ssoUrl: 'https://idp2.com/sso',
470
+ });
471
+
472
+ const providers = manager.listProviders();
473
+
474
+ expect(providers).toContainEqual(
475
+ expect.objectContaining({ name: 'github', type: 'oauth' })
476
+ );
477
+ expect(providers).toContainEqual(
478
+ expect.objectContaining({ name: 'google', type: 'oauth' })
479
+ );
480
+ expect(providers).toContainEqual(
481
+ expect.objectContaining({ name: 'company-idp', type: 'saml' })
482
+ );
483
+ expect(providers).toContainEqual(
484
+ expect.objectContaining({ name: 'partner-idp', type: 'saml' })
485
+ );
486
+ });
487
+ });
488
+
489
+ describe('removeProvider', () => {
490
+ it('removes OAuth provider', () => {
491
+ manager.registerProvider('github', {
492
+ type: 'oauth',
493
+ clientId: 'client-123',
494
+ clientSecret: 'secret',
495
+ });
496
+
497
+ expect(manager.getProvider('github')).not.toBeNull();
498
+
499
+ manager.removeProvider('github');
500
+
501
+ expect(manager.getProvider('github')).toBeNull();
502
+ });
503
+
504
+ it('removes SAML provider', () => {
505
+ manager.registerProvider('company-idp', {
506
+ type: 'saml',
507
+ entityId: 'https://idp.company.com',
508
+ ssoUrl: 'https://idp.company.com/sso',
509
+ });
510
+
511
+ expect(manager.getProvider('company-idp')).not.toBeNull();
512
+
513
+ manager.removeProvider('company-idp');
514
+
515
+ expect(manager.getProvider('company-idp')).toBeNull();
516
+ });
517
+ });
518
+
519
+ describe('fetchUserInfo (OAuth)', () => {
520
+ it('fetches user info from provider-specific endpoint', async () => {
521
+ manager.registerProvider('github', {
522
+ type: 'oauth',
523
+ clientId: 'client-123',
524
+ clientSecret: 'secret',
525
+ userInfoUrl: 'https://api.github.com/user',
526
+ });
527
+
528
+ global.fetch = vi.fn().mockResolvedValueOnce({
529
+ ok: true,
530
+ json: () => Promise.resolve({
531
+ id: 12345,
532
+ login: 'octocat',
533
+ email: 'octocat@github.com',
534
+ }),
535
+ });
536
+
537
+ const userInfo = await manager.fetchUserInfo('github', 'gho_token123');
538
+
539
+ expect(global.fetch).toHaveBeenCalledWith(
540
+ 'https://api.github.com/user',
541
+ expect.objectContaining({
542
+ headers: expect.objectContaining({
543
+ Authorization: 'Bearer gho_token123',
544
+ }),
545
+ })
546
+ );
547
+ expect(userInfo.email).toBe('octocat@github.com');
548
+ });
549
+
550
+ it('handles GitHub email endpoint for private emails', async () => {
551
+ manager.registerProvider('github', {
552
+ type: 'oauth',
553
+ clientId: 'client-123',
554
+ clientSecret: 'secret',
555
+ userInfoUrl: 'https://api.github.com/user',
556
+ });
557
+
558
+ global.fetch = vi.fn()
559
+ .mockResolvedValueOnce({
560
+ ok: true,
561
+ json: () => Promise.resolve({
562
+ id: 12345,
563
+ login: 'octocat',
564
+ email: null, // Private email
565
+ }),
566
+ })
567
+ .mockResolvedValueOnce({
568
+ ok: true,
569
+ json: () => Promise.resolve([
570
+ { email: 'secondary@example.com', primary: false, verified: true },
571
+ { email: 'primary@github.com', primary: true, verified: true },
572
+ ]),
573
+ });
574
+
575
+ const userInfo = await manager.fetchUserInfo('github', 'gho_token123');
576
+
577
+ expect(userInfo.email).toBe('primary@github.com');
578
+ });
579
+ });
580
+
581
+ describe('PROVIDER_TYPES constant', () => {
582
+ it('exports OAuth and SAML types', () => {
583
+ expect(PROVIDER_TYPES.OAUTH).toBe('oauth');
584
+ expect(PROVIDER_TYPES.SAML).toBe('saml');
585
+ });
586
+ });
587
+ });