@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.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- 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
|
+
});
|