@symbiosis-lab/moss-plugin-github 1.5.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 (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +3 -0
  4. package/assets/manifest.json +19 -0
  5. package/e2e/deploy-api.test.ts +1129 -0
  6. package/e2e/moss-cli.test.ts +478 -0
  7. package/features/auth/device-flow.feature +41 -0
  8. package/features/deploy/validation.feature +50 -0
  9. package/features/steps/auth.steps.ts +285 -0
  10. package/features/steps/deploy.steps.ts +354 -0
  11. package/package.json +51 -0
  12. package/src/__tests__/auth-flow.integration.test.ts +738 -0
  13. package/src/__tests__/auth.test.ts +147 -0
  14. package/src/__tests__/configure-domain.test.ts +263 -0
  15. package/src/__tests__/deploy.integration.test.ts +798 -0
  16. package/src/__tests__/git.test.ts +190 -0
  17. package/src/__tests__/github-api.test.ts +761 -0
  18. package/src/__tests__/github-deploy.test.ts +2411 -0
  19. package/src/__tests__/progress-timeout.test.ts +209 -0
  20. package/src/__tests__/repo-setup-progress.test.ts +367 -0
  21. package/src/__tests__/repo-setup.test.ts +370 -0
  22. package/src/__tests__/token.test.ts +152 -0
  23. package/src/__tests__/utils.test.ts +129 -0
  24. package/src/__tests__/workflow.test.ts +146 -0
  25. package/src/auth.ts +588 -0
  26. package/src/constants.ts +7 -0
  27. package/src/git.ts +60 -0
  28. package/src/github-api.ts +601 -0
  29. package/src/github-deploy.ts +593 -0
  30. package/src/main.ts +646 -0
  31. package/src/repo-setup.ts +685 -0
  32. package/src/token.ts +202 -0
  33. package/src/types.ts +91 -0
  34. package/src/utils.ts +108 -0
  35. package/src/workflow.ts +79 -0
  36. package/test-helpers/mock-github-api.ts +217 -0
  37. package/tsconfig.json +20 -0
  38. package/vitest.config.ts +50 -0
@@ -0,0 +1,738 @@
1
+ /**
2
+ * Integration tests for GitHub OAuth Device Flow authentication
3
+ *
4
+ * Tests the complete authentication flow including:
5
+ * - Device code request
6
+ * - Token polling with various response states
7
+ * - Token validation and scope checking
8
+ * - Credential storage
9
+ *
10
+ * Uses @symbiosis-lab/moss-api/testing to mock Tauri IPC commands
11
+ *
12
+ * NOTE: Some tests require moss-api >= 0.5.4 with browser/dialog tracking fixes
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
16
+ import {
17
+ setupMockTauri,
18
+ type MockTauriContext,
19
+ } from "@symbiosis-lab/moss-api/testing";
20
+
21
+ // Check if browser tracking with setters is available (requires moss-api >= 0.5.4)
22
+ // Try to set isOpen - if it fails, the setters aren't available
23
+ const testCtx = setupMockTauri();
24
+ let hasBrowserSetters = false;
25
+ try {
26
+ // @ts-ignore - testing if setter exists
27
+ testCtx.browserTracker.isOpen = true;
28
+ hasBrowserSetters = testCtx.browserTracker.isOpen === true;
29
+ } catch {
30
+ hasBrowserSetters = false;
31
+ }
32
+ testCtx.cleanup();
33
+
34
+ // Mock the utils module to prevent actual IPC calls
35
+ vi.mock("../utils", () => ({
36
+ reportProgress: vi.fn().mockResolvedValue(undefined),
37
+ reportError: vi.fn().mockResolvedValue(undefined),
38
+ setCurrentHookName: vi.fn(),
39
+ sleep: vi.fn().mockResolvedValue(undefined), // Don't actually wait
40
+ }));
41
+
42
+ // Import after mocking
43
+ import {
44
+ requestDeviceCode,
45
+ pollForToken,
46
+ validateToken,
47
+ checkAuthentication,
48
+ promptLogin,
49
+ hasRequiredScopes,
50
+ CLIENT_ID,
51
+ REQUIRED_SCOPES,
52
+ } from "../auth";
53
+
54
+ // Import token cache clear function
55
+ import { clearTokenCache } from "../token";
56
+
57
+ // Import the mock fetch helper for GitHub API
58
+ import {
59
+ createMockFetch,
60
+ setupGitHubApiMocks,
61
+ defaultDeviceCodeResponse,
62
+ defaultTokenResponse,
63
+ defaultUserResponse,
64
+ authorizationPendingResponse,
65
+ expiredTokenResponse,
66
+ accessDeniedResponse,
67
+ } from "../../test-helpers/mock-github-api";
68
+
69
+ // Store original fetch to restore later
70
+ const originalFetch = global.fetch;
71
+
72
+ // Mock fetch for individual tests
73
+ let mockFetch: ReturnType<typeof vi.fn>;
74
+
75
+ describe("GitHub OAuth Device Flow", () => {
76
+ let ctx: MockTauriContext;
77
+
78
+ beforeEach(() => {
79
+ ctx = setupMockTauri();
80
+ vi.clearAllMocks();
81
+ // Reset mockFetch to a vi.fn() for tests that need custom mock
82
+ mockFetch = vi.fn();
83
+ // Clear token cache to ensure test isolation
84
+ clearTokenCache();
85
+ });
86
+
87
+ afterEach(() => {
88
+ ctx.cleanup();
89
+ // Restore original fetch
90
+ global.fetch = originalFetch;
91
+ });
92
+
93
+ // ==========================================================================
94
+ // Device Code Request Tests
95
+ // ==========================================================================
96
+ // Uses ctx.urlConfig.setResponse() to mock httpPost Tauri IPC calls
97
+
98
+ describe("requestDeviceCode", () => {
99
+ it("successfully requests a device code from GitHub", async () => {
100
+ const mockResponse = {
101
+ device_code: "test-device-code-123",
102
+ user_code: "ABCD-1234",
103
+ verification_uri: "https://github.com/login/device",
104
+ expires_in: 900,
105
+ interval: 5,
106
+ };
107
+
108
+ ctx.urlConfig.setResponse("https://github.com/login/device/code", {
109
+ status: 200,
110
+ ok: true,
111
+ bodyBase64: btoa(JSON.stringify(mockResponse)),
112
+ });
113
+
114
+ const result = await requestDeviceCode();
115
+
116
+ expect(result.device_code).toBe("test-device-code-123");
117
+ expect(result.user_code).toBe("ABCD-1234");
118
+ expect(result.verification_uri).toBe("https://github.com/login/device");
119
+ });
120
+
121
+ // Note: "includes required scopes in request" test was removed because
122
+ // we can't verify request body content with Tauri IPC mocking.
123
+ // The scopes are verified by GitHub's actual API response.
124
+
125
+ it("throws error on HTTP failure", async () => {
126
+ ctx.urlConfig.setResponse("https://github.com/login/device/code", {
127
+ status: 500,
128
+ ok: false,
129
+ bodyBase64: btoa("Internal Server Error"),
130
+ });
131
+
132
+ await expect(requestDeviceCode()).rejects.toThrow("Failed to request device code");
133
+ });
134
+
135
+ it("throws error on GitHub API error response", async () => {
136
+ ctx.urlConfig.setResponse("https://github.com/login/device/code", {
137
+ status: 200,
138
+ ok: true,
139
+ bodyBase64: btoa(JSON.stringify({
140
+ error: "invalid_client",
141
+ error_description: "Client ID is invalid",
142
+ })),
143
+ });
144
+
145
+ await expect(requestDeviceCode()).rejects.toThrow("GitHub error: Client ID is invalid");
146
+ });
147
+ });
148
+
149
+ // ==========================================================================
150
+ // Token Polling Tests
151
+ // ==========================================================================
152
+ // Uses ctx.urlConfig.setResponse() to mock httpPost Tauri IPC calls
153
+
154
+ describe("pollForToken", () => {
155
+ it("returns access token on success", async () => {
156
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
157
+ status: 200,
158
+ ok: true,
159
+ bodyBase64: btoa(JSON.stringify({
160
+ access_token: "gho_xxxxxxxxxxxx",
161
+ token_type: "bearer",
162
+ scope: "repo,workflow",
163
+ })),
164
+ });
165
+
166
+ const result = await pollForToken("device-code-123", 5);
167
+
168
+ expect(result.access_token).toBe("gho_xxxxxxxxxxxx");
169
+ });
170
+
171
+ it("returns authorization_pending when user hasn't authorized", async () => {
172
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
173
+ status: 200,
174
+ ok: true,
175
+ bodyBase64: btoa(JSON.stringify({
176
+ error: "authorization_pending",
177
+ error_description: "The authorization request is still pending",
178
+ })),
179
+ });
180
+
181
+ const result = await pollForToken("device-code-123", 5);
182
+
183
+ expect(result.error).toBe("authorization_pending");
184
+ expect(result.access_token).toBeUndefined();
185
+ });
186
+
187
+ it("returns slow_down when polling too frequently", async () => {
188
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
189
+ status: 200,
190
+ ok: true,
191
+ bodyBase64: btoa(JSON.stringify({
192
+ error: "slow_down",
193
+ error_description: "Please slow down",
194
+ interval: 10,
195
+ })),
196
+ });
197
+
198
+ const result = await pollForToken("device-code-123", 5);
199
+
200
+ expect(result.error).toBe("slow_down");
201
+ });
202
+
203
+ it("returns expired_token when device code expires", async () => {
204
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
205
+ status: 200,
206
+ ok: true,
207
+ bodyBase64: btoa(JSON.stringify({
208
+ error: "expired_token",
209
+ error_description: "The device code has expired",
210
+ })),
211
+ });
212
+
213
+ const result = await pollForToken("device-code-123", 5);
214
+
215
+ expect(result.error).toBe("expired_token");
216
+ });
217
+
218
+ it("returns access_denied when user denies authorization", async () => {
219
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
220
+ status: 200,
221
+ ok: true,
222
+ bodyBase64: btoa(JSON.stringify({
223
+ error: "access_denied",
224
+ error_description: "The user denied the authorization request",
225
+ })),
226
+ });
227
+
228
+ const result = await pollForToken("device-code-123", 5);
229
+
230
+ expect(result.error).toBe("access_denied");
231
+ });
232
+
233
+ it("throws error on HTTP failure", async () => {
234
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
235
+ status: 503,
236
+ ok: false,
237
+ bodyBase64: btoa("Service Unavailable"),
238
+ });
239
+
240
+ await expect(pollForToken("device-code-123", 5)).rejects.toThrow(
241
+ "Failed to poll for token"
242
+ );
243
+ });
244
+ });
245
+
246
+ // ==========================================================================
247
+ // Token Validation Tests
248
+ // ==========================================================================
249
+
250
+ describe("validateToken", () => {
251
+ it("validates token and returns user info", async () => {
252
+ mockFetch.mockResolvedValueOnce({
253
+ ok: true,
254
+ json: async () => ({
255
+ login: "testuser",
256
+ id: 12345,
257
+ avatar_url: "https://github.com/avatars/testuser",
258
+ }),
259
+ headers: new Headers({
260
+ "X-OAuth-Scopes": "repo, workflow, user",
261
+ }),
262
+ });
263
+ global.fetch = mockFetch;
264
+
265
+ const result = await validateToken("gho_validtoken");
266
+
267
+ expect(result.valid).toBe(true);
268
+ expect(result.user?.login).toBe("testuser");
269
+ expect(result.scopes).toContain("repo");
270
+ expect(result.scopes).toContain("workflow");
271
+ });
272
+
273
+ it("returns invalid for unauthorized token", async () => {
274
+ mockFetch.mockResolvedValueOnce({
275
+ ok: false,
276
+ status: 401,
277
+ });
278
+ global.fetch = mockFetch;
279
+
280
+ const result = await validateToken("gho_invalidtoken");
281
+
282
+ expect(result.valid).toBe(false);
283
+ expect(result.user).toBeUndefined();
284
+ });
285
+
286
+ it("handles network errors gracefully", async () => {
287
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
288
+ global.fetch = mockFetch;
289
+
290
+ const result = await validateToken("gho_anytoken");
291
+
292
+ expect(result.valid).toBe(false);
293
+ });
294
+
295
+ it("parses empty scopes header correctly", async () => {
296
+ mockFetch.mockResolvedValueOnce({
297
+ ok: true,
298
+ json: async () => ({ login: "testuser" }),
299
+ headers: new Headers({}),
300
+ });
301
+ global.fetch = mockFetch;
302
+
303
+ const result = await validateToken("gho_validtoken");
304
+
305
+ expect(result.valid).toBe(true);
306
+ expect(result.scopes).toEqual([]);
307
+ });
308
+ });
309
+
310
+ // ==========================================================================
311
+ // Scope Validation Tests
312
+ // ==========================================================================
313
+
314
+ describe("hasRequiredScopes", () => {
315
+ it("returns true when repo scope is present", () => {
316
+ expect(hasRequiredScopes(["repo"])).toBe(true);
317
+ expect(hasRequiredScopes(["repo", "user"])).toBe(true);
318
+ expect(hasRequiredScopes(["repo", "workflow", "user"])).toBe(true);
319
+ });
320
+
321
+ it("returns false when repo scope is missing", () => {
322
+ expect(hasRequiredScopes(["workflow"])).toBe(false);
323
+ expect(hasRequiredScopes(["user", "gist"])).toBe(false);
324
+ });
325
+
326
+ it("returns false with empty scopes", () => {
327
+ expect(hasRequiredScopes([])).toBe(false);
328
+ });
329
+ });
330
+
331
+ // ==========================================================================
332
+ // Check Authentication Tests
333
+ // ==========================================================================
334
+
335
+ describe("checkAuthentication", () => {
336
+ it("returns authenticated state when valid token exists", async () => {
337
+ // Setup: Token exists in plugin cookie storage
338
+ // Note: setupMockTauri uses "test-plugin" and "/test/project" as defaults
339
+ ctx.cookieStorage.setCookies(ctx.pluginName, ctx.projectPath, [
340
+ { name: "__github_access_token", value: "gho_validtoken", domain: "github.com" },
341
+ ]);
342
+
343
+ // Mock token validation
344
+ mockFetch.mockResolvedValueOnce({
345
+ ok: true,
346
+ json: async () => ({ login: "testuser", id: 12345 }),
347
+ headers: new Headers({ "X-OAuth-Scopes": "repo, workflow" }),
348
+ });
349
+ global.fetch = mockFetch;
350
+
351
+ const result = await checkAuthentication();
352
+
353
+ expect(result.isAuthenticated).toBe(true);
354
+ expect(result.username).toBe("testuser");
355
+ expect(result.scopes).toContain("repo");
356
+ });
357
+
358
+ it("returns unauthenticated when no token exists", async () => {
359
+ // Setup: No token in cookie storage (default empty state)
360
+ const result = await checkAuthentication();
361
+
362
+ expect(result.isAuthenticated).toBe(false);
363
+ });
364
+
365
+ it("returns unauthenticated and clears invalid token", async () => {
366
+ // Setup: Token exists but is invalid
367
+ ctx.cookieStorage.setCookies(ctx.pluginName, ctx.projectPath, [
368
+ { name: "__github_access_token", value: "gho_expiredtoken", domain: "github.com" },
369
+ ]);
370
+
371
+ // Mock token validation failure
372
+ mockFetch.mockResolvedValueOnce({
373
+ ok: false,
374
+ status: 401,
375
+ });
376
+ global.fetch = mockFetch;
377
+
378
+ const result = await checkAuthentication();
379
+
380
+ expect(result.isAuthenticated).toBe(false);
381
+ });
382
+
383
+ it("returns unauthenticated when token lacks required scopes", async () => {
384
+ // Setup: Token exists with insufficient scopes
385
+ ctx.cookieStorage.setCookies(ctx.pluginName, ctx.projectPath, [
386
+ { name: "__github_access_token", value: "gho_limitedtoken", domain: "github.com" },
387
+ ]);
388
+
389
+ // Mock token validation - valid but missing repo scope
390
+ mockFetch.mockResolvedValueOnce({
391
+ ok: true,
392
+ json: async () => ({ login: "testuser" }),
393
+ headers: new Headers({ "X-OAuth-Scopes": "user, gist" }), // Missing repo
394
+ });
395
+ global.fetch = mockFetch;
396
+
397
+ const result = await checkAuthentication();
398
+
399
+ expect(result.isAuthenticated).toBe(false);
400
+ });
401
+ });
402
+
403
+ // ==========================================================================
404
+ // Full Login Flow Tests
405
+ // ==========================================================================
406
+
407
+ describe("promptLogin", () => {
408
+ // Requires moss-api >= 0.5.4 with browser tracking setters
409
+ it.skipIf(!hasBrowserSetters)("successfully completes device flow authentication", async () => {
410
+ // Setup GitHub API mocks using ctx.urlConfig (for httpPost)
411
+ setupGitHubApiMocks(ctx, {
412
+ deviceCodeResponse: {
413
+ device_code: "test-device-code",
414
+ user_code: "ABCD-1234",
415
+ verification_uri: "https://github.com/login/device",
416
+ expires_in: 900,
417
+ interval: 1, // Short interval for test
418
+ },
419
+ tokenResponse: {
420
+ access_token: "gho_newtoken",
421
+ token_type: "bearer",
422
+ scope: "repo,workflow",
423
+ },
424
+ });
425
+
426
+ // Token is stored in cookie storage (no git credential mock needed)
427
+
428
+ const result = await promptLogin();
429
+
430
+ expect(result).toBe(true);
431
+ // Now uses system browser instead of action panel (Bug 9 fix)
432
+ expect(ctx.browserTracker.systemBrowserUrls).toContain("https://github.com/login/device");
433
+ });
434
+
435
+ it("fails when user denies authorization", async () => {
436
+ // Mock device code request
437
+ mockFetch.mockResolvedValueOnce({
438
+ ok: true,
439
+ json: async () => ({
440
+ device_code: "test-device-code",
441
+ user_code: "ABCD-1234",
442
+ verification_uri: "https://github.com/login/device",
443
+ expires_in: 900,
444
+ interval: 1,
445
+ }),
446
+ });
447
+
448
+ // Mock token poll - access denied
449
+ mockFetch.mockResolvedValueOnce({
450
+ ok: true,
451
+ json: async () => ({ error: "access_denied" }),
452
+ });
453
+ global.fetch = mockFetch;
454
+
455
+ const result = await promptLogin();
456
+
457
+ expect(result).toBe(false);
458
+ });
459
+
460
+ it("fails when device code expires", async () => {
461
+ // Mock device code request
462
+ mockFetch.mockResolvedValueOnce({
463
+ ok: true,
464
+ json: async () => ({
465
+ device_code: "test-device-code",
466
+ user_code: "ABCD-1234",
467
+ verification_uri: "https://github.com/login/device",
468
+ expires_in: 900,
469
+ interval: 1,
470
+ }),
471
+ });
472
+
473
+ // Mock token poll - expired
474
+ mockFetch.mockResolvedValueOnce({
475
+ ok: true,
476
+ json: async () => ({ error: "expired_token" }),
477
+ });
478
+ global.fetch = mockFetch;
479
+
480
+ const result = await promptLogin();
481
+
482
+ expect(result).toBe(false);
483
+ });
484
+
485
+ it("opens system browser with verification_uri_complete when present", async () => {
486
+ setupGitHubApiMocks(ctx, {
487
+ deviceCodeResponse: {
488
+ device_code: "test-device-code",
489
+ user_code: "TEST-CODE",
490
+ verification_uri: "https://github.com/login/device",
491
+ verification_uri_complete: "https://github.com/login/device?user_code=TEST-CODE",
492
+ expires_in: 900,
493
+ interval: 1,
494
+ },
495
+ tokenResponse: {
496
+ access_token: "gho_newtoken",
497
+ scope: "repo,workflow",
498
+ },
499
+ });
500
+
501
+ await promptLogin();
502
+
503
+ expect(ctx.browserTracker.systemBrowserUrls).toHaveLength(1);
504
+ expect(ctx.browserTracker.systemBrowserUrls[0]).toBe(
505
+ "https://github.com/login/device?user_code=TEST-CODE"
506
+ );
507
+ });
508
+
509
+ // Requires moss-api >= 0.5.4 with browser tracking setters
510
+ it.skipIf(!hasBrowserSetters)("stores token in cookie storage on success", async () => {
511
+ // Setup GitHub API mocks using ctx.urlConfig (for httpPost)
512
+ setupGitHubApiMocks(ctx, {
513
+ deviceCodeResponse: {
514
+ device_code: "test-device-code",
515
+ user_code: "ABCD-1234",
516
+ verification_uri: "https://github.com/login/device",
517
+ expires_in: 900,
518
+ interval: 1,
519
+ },
520
+ tokenResponse: {
521
+ access_token: "gho_storedtoken",
522
+ scope: "repo,workflow",
523
+ },
524
+ });
525
+
526
+ const result = await promptLogin();
527
+
528
+ expect(result).toBe(true);
529
+
530
+ // Verify token was stored in cookie storage
531
+ const cookies = ctx.cookieStorage.getCookies(ctx.pluginName, ctx.projectPath);
532
+ const tokenCookie = cookies.find((c) => c.name === "__github_access_token");
533
+ expect(tokenCookie?.value).toBe("gho_storedtoken");
534
+ });
535
+
536
+ it("handles network errors during device code request", async () => {
537
+ // Setup URL to return error status (simulates network error)
538
+ ctx.urlConfig.setResponse("https://github.com/login/device/code", {
539
+ status: 0, // Network error
540
+ ok: false,
541
+ bodyBase64: "",
542
+ });
543
+
544
+ const result = await promptLogin();
545
+
546
+ expect(result).toBe(false);
547
+ });
548
+
549
+ it("opens auth UI panel with the user code", async () => {
550
+ setupGitHubApiMocks(ctx, {
551
+ deviceCodeResponse: {
552
+ device_code: "test-device-code",
553
+ user_code: "ABCD-1234",
554
+ verification_uri: "https://github.com/login/device",
555
+ verification_uri_complete: "https://github.com/login/device?user_code=ABCD-1234",
556
+ expires_in: 900,
557
+ interval: 1,
558
+ },
559
+ tokenResponse: {
560
+ access_token: "gho_token",
561
+ scope: "repo,workflow",
562
+ },
563
+ });
564
+
565
+ await promptLogin();
566
+
567
+ // Auth UI panel was opened via openBrowserWithHtml
568
+ expect(ctx.browserTracker.htmlContent).toHaveLength(1);
569
+ const html = ctx.browserTracker.htmlContent[0];
570
+ expect(html).toContain("ABCD-1234"); // User code displayed
571
+ expect(html).toContain("Authorize moss on GitHub");
572
+ expect(html).toContain("Copy code");
573
+ expect(html).toContain("github:auth-cancel");
574
+ expect(html).toContain("github:auth-state");
575
+ });
576
+
577
+ it("prefers verification_uri_complete for system browser URL", async () => {
578
+ setupGitHubApiMocks(ctx, {
579
+ deviceCodeResponse: {
580
+ device_code: "test-device-code",
581
+ user_code: "TEST-CODE",
582
+ verification_uri: "https://github.com/login/device",
583
+ verification_uri_complete: "https://github.com/login/device?user_code=TEST-CODE",
584
+ expires_in: 900,
585
+ interval: 1,
586
+ },
587
+ tokenResponse: {
588
+ access_token: "gho_newtoken",
589
+ scope: "repo,workflow",
590
+ },
591
+ });
592
+
593
+ await promptLogin();
594
+
595
+ expect(ctx.browserTracker.systemBrowserUrls).toHaveLength(1);
596
+ expect(ctx.browserTracker.systemBrowserUrls[0]).toBe(
597
+ "https://github.com/login/device?user_code=TEST-CODE"
598
+ );
599
+ });
600
+
601
+ it("falls back to verification_uri when complete is absent", async () => {
602
+ ctx.urlConfig.setResponse("https://github.com/login/device/code", {
603
+ status: 200,
604
+ ok: true,
605
+ bodyBase64: btoa(JSON.stringify({
606
+ device_code: "test-device-code",
607
+ user_code: "TEST-CODE",
608
+ verification_uri: "https://github.com/login/device",
609
+ // verification_uri_complete intentionally absent
610
+ expires_in: 900,
611
+ interval: 1,
612
+ })),
613
+ });
614
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
615
+ status: 200,
616
+ ok: true,
617
+ bodyBase64: btoa(JSON.stringify({
618
+ access_token: "gho_newtoken",
619
+ scope: "repo,workflow",
620
+ })),
621
+ });
622
+
623
+ await promptLogin();
624
+
625
+ expect(ctx.browserTracker.systemBrowserUrls[0]).toBe("https://github.com/login/device");
626
+ });
627
+
628
+ it("closes auth UI panel on success", async () => {
629
+ setupGitHubApiMocks(ctx, {
630
+ deviceCodeResponse: {
631
+ device_code: "test-device-code",
632
+ user_code: "ABCD-1234",
633
+ verification_uri: "https://github.com/login/device",
634
+ verification_uri_complete: "https://github.com/login/device?user_code=ABCD-1234",
635
+ expires_in: 900,
636
+ interval: 1,
637
+ },
638
+ tokenResponse: {
639
+ access_token: "gho_token",
640
+ scope: "repo,workflow",
641
+ },
642
+ });
643
+
644
+ await promptLogin();
645
+
646
+ // Auth UI panel should be closed after successful auth
647
+ expect(ctx.browserTracker.closeCount).toBeGreaterThanOrEqual(1);
648
+ });
649
+
650
+ });
651
+
652
+ // ==========================================================================
653
+ // Bug 8: Git credential helper integration
654
+ // ==========================================================================
655
+ describe("checkAuthentication with git credentials (Bug 8)", () => {
656
+ it("checks git credential helper when no plugin cookie exists", async () => {
657
+ // Setup: No token in cookie storage (default empty state)
658
+ // But git credential helper has a valid token
659
+ ctx.binaryConfig.setResult("git credential fill", {
660
+ success: true,
661
+ exitCode: 0,
662
+ stdout: "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_gittoken\n",
663
+ stderr: "",
664
+ });
665
+
666
+ // Mock token validation for the git token
667
+ mockFetch.mockResolvedValueOnce({
668
+ ok: true,
669
+ json: async () => ({ login: "gituser", id: 67890 }),
670
+ headers: new Headers({ "X-OAuth-Scopes": "repo, workflow" }),
671
+ });
672
+ global.fetch = mockFetch;
673
+
674
+ const result = await checkAuthentication();
675
+
676
+ // Should be authenticated using git token
677
+ expect(result.isAuthenticated).toBe(true);
678
+ expect(result.username).toBe("gituser");
679
+ });
680
+
681
+ it("stores git token in plugin cookies for faster future access", async () => {
682
+ // Setup: No token in cookie storage
683
+ // Git credential helper has a valid token
684
+ ctx.binaryConfig.setResult("git credential fill", {
685
+ success: true,
686
+ exitCode: 0,
687
+ stdout: "protocol=https\nhost=github.com\nusername=x-access-token\npassword=ghp_cached\n",
688
+ stderr: "",
689
+ });
690
+
691
+ // Mock token validation
692
+ mockFetch.mockResolvedValueOnce({
693
+ ok: true,
694
+ json: async () => ({ login: "testuser" }),
695
+ headers: new Headers({ "X-OAuth-Scopes": "repo, workflow" }),
696
+ });
697
+ global.fetch = mockFetch;
698
+
699
+ await checkAuthentication();
700
+
701
+ // Token should be stored in cookies for faster future access
702
+ const cookies = ctx.cookieStorage.getCookies(ctx.pluginName, ctx.projectPath);
703
+ const tokenCookie = cookies.find((c) => c.name === "__github_access_token");
704
+ expect(tokenCookie?.value).toBe("ghp_cached");
705
+ });
706
+
707
+ it("prefers plugin cookie token over git token when both exist", async () => {
708
+ // Setup: Token exists in cookie storage
709
+ ctx.cookieStorage.setCookies(ctx.pluginName, ctx.projectPath, [
710
+ { name: "__github_access_token", value: "gho_cookietoken", domain: "github.com" },
711
+ ]);
712
+
713
+ // Git credential helper also has a token (should not be checked)
714
+ ctx.binaryConfig.setResult("git credential fill", {
715
+ success: true,
716
+ exitCode: 0,
717
+ stdout: "protocol=https\nhost=github.com\npassword=ghp_gittoken\n",
718
+ stderr: "",
719
+ });
720
+
721
+ // Mock token validation - only cookie token validation should be called
722
+ mockFetch.mockResolvedValueOnce({
723
+ ok: true,
724
+ json: async () => ({ login: "cookieuser", id: 11111 }),
725
+ headers: new Headers({ "X-OAuth-Scopes": "repo, workflow" }),
726
+ });
727
+ global.fetch = mockFetch;
728
+
729
+ const result = await checkAuthentication();
730
+
731
+ // Should use cookie token, not git token
732
+ expect(result.isAuthenticated).toBe(true);
733
+ expect(result.username).toBe("cookieuser");
734
+ // Should NOT have called git credential fill (verify fetch was only called once)
735
+ expect(mockFetch).toHaveBeenCalledTimes(1);
736
+ });
737
+ });
738
+ });