@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,147 @@
1
+ /**
2
+ * Unit tests for authentication module
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import { hasRequiredScopes, CLIENT_ID, REQUIRED_SCOPES } from "../auth";
7
+ import {
8
+ setupMockTauri,
9
+ type MockTauriContext,
10
+ } from "@symbiosis-lab/moss-api/testing";
11
+
12
+ // Track httpPost calls for header verification
13
+ let httpPostCalls: Array<{ url: string; body: any; options: any }> = [];
14
+
15
+ // Mock the moss-api module to track httpPost calls
16
+ vi.mock("@symbiosis-lab/moss-api", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("@symbiosis-lab/moss-api")>();
18
+ return {
19
+ ...actual,
20
+ httpPost: vi.fn(async (url: string, body: any, options: any) => {
21
+ httpPostCalls.push({ url, body, options });
22
+ return actual.httpPost(url, body, options);
23
+ }),
24
+ };
25
+ });
26
+
27
+ // Mock the utils module to prevent actual IPC calls
28
+ vi.mock("../utils", () => ({
29
+ reportProgress: vi.fn().mockResolvedValue(undefined),
30
+ reportError: vi.fn().mockResolvedValue(undefined),
31
+ setCurrentHookName: vi.fn(),
32
+ sleep: vi.fn().mockResolvedValue(undefined),
33
+ }));
34
+
35
+ describe("auth", () => {
36
+ describe("configuration", () => {
37
+ it("has a valid client ID", () => {
38
+ expect(CLIENT_ID).toBeDefined();
39
+ expect(CLIENT_ID.length).toBeGreaterThan(10);
40
+ // GitHub OAuth client IDs start with "Ov23"
41
+ expect(CLIENT_ID).toMatch(/^Ov/);
42
+ });
43
+
44
+ it("requires repo scope for gh-pages deployment", () => {
45
+ // gh-pages deployment pushes directly - no GitHub Actions needed
46
+ expect(REQUIRED_SCOPES).toContain("repo");
47
+ expect(REQUIRED_SCOPES).not.toContain("workflow");
48
+ });
49
+ });
50
+
51
+ describe("hasRequiredScopes", () => {
52
+ it("returns true when all required scopes are present", () => {
53
+ const scopes = ["repo", "user"];
54
+ expect(hasRequiredScopes(scopes)).toBe(true);
55
+ });
56
+
57
+ it("returns true with exact required scopes", () => {
58
+ const scopes = ["repo"];
59
+ expect(hasRequiredScopes(scopes)).toBe(true);
60
+ });
61
+
62
+ it("returns false when repo scope is missing", () => {
63
+ const scopes = ["user", "gist"];
64
+ expect(hasRequiredScopes(scopes)).toBe(false);
65
+ });
66
+
67
+ it("returns true with additional scopes beyond repo", () => {
68
+ const scopes = ["repo", "workflow", "user"];
69
+ expect(hasRequiredScopes(scopes)).toBe(true);
70
+ });
71
+
72
+ it("returns false with empty scopes", () => {
73
+ expect(hasRequiredScopes([])).toBe(false);
74
+ });
75
+
76
+ it("returns false with unrelated scopes only", () => {
77
+ const scopes = ["user", "read:org", "gist"];
78
+ expect(hasRequiredScopes(scopes)).toBe(false);
79
+ });
80
+ });
81
+
82
+ // ==========================================================================
83
+ // Phase 1: Origin Header Tests
84
+ // ==========================================================================
85
+ describe("OAuth requests include Origin header", () => {
86
+ let ctx: MockTauriContext;
87
+
88
+ beforeEach(() => {
89
+ ctx = setupMockTauri();
90
+ httpPostCalls = []; // Reset tracking
91
+ });
92
+
93
+ afterEach(() => {
94
+ ctx.cleanup();
95
+ });
96
+
97
+ it("requestDeviceCode includes Origin header", async () => {
98
+ // Setup mock response
99
+ ctx.urlConfig.setResponse("https://github.com/login/device/code", {
100
+ status: 200,
101
+ ok: true,
102
+ bodyBase64: btoa(JSON.stringify({
103
+ device_code: "test-device-code",
104
+ user_code: "TEST-CODE",
105
+ verification_uri: "https://github.com/login/device",
106
+ expires_in: 900,
107
+ interval: 5,
108
+ })),
109
+ });
110
+
111
+ const { requestDeviceCode } = await import("../auth");
112
+ await requestDeviceCode();
113
+
114
+ // Find the call to device code URL
115
+ const deviceCodeCall = httpPostCalls.find(
116
+ call => call.url === "https://github.com/login/device/code"
117
+ );
118
+
119
+ expect(deviceCodeCall).toBeDefined();
120
+ expect(deviceCodeCall?.options?.headers?.Origin).toBe("https://github.com");
121
+ });
122
+
123
+ it("pollForToken includes Origin header", async () => {
124
+ // Setup mock response
125
+ ctx.urlConfig.setResponse("https://github.com/login/oauth/access_token", {
126
+ status: 200,
127
+ ok: true,
128
+ bodyBase64: btoa(JSON.stringify({
129
+ access_token: "gho_test_token",
130
+ token_type: "bearer",
131
+ scope: "repo",
132
+ })),
133
+ });
134
+
135
+ const { pollForToken } = await import("../auth");
136
+ await pollForToken("test-device-code", 5);
137
+
138
+ // Find the call to token URL
139
+ const tokenCall = httpPostCalls.find(
140
+ call => call.url === "https://github.com/login/oauth/access_token"
141
+ );
142
+
143
+ expect(tokenCall).toBeDefined();
144
+ expect(tokenCall?.options?.headers?.Origin).toBe("https://github.com");
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Tests for idempotent configure_domain hook
3
+ *
4
+ * The orchestrator calls configure_domain multiple times:
5
+ * 1. After DNS is configured (site may not be live yet)
6
+ * 2. After the site is verified live via HTTP 200
7
+ *
8
+ * The hook must check current state and do only the next needed step:
9
+ * - Phase 1: CNAME not set -> set it (without HTTPS enforcement)
10
+ * - Phase 2: CNAME set, no HTTPS -> enforce HTTPS
11
+ * - Phase 3: Fully configured -> no-op
12
+ */
13
+
14
+ import { describe, it, expect, vi, beforeEach } from "vitest";
15
+
16
+ // ============================================================================
17
+ // Hoisted setup — runs before vi.mock factories and module imports
18
+ // ============================================================================
19
+
20
+ const { mockGetPages, mockSetCustomDomain, mockEnforceHttps } = vi.hoisted(() => {
21
+ // Provide `window` for main.ts module-scope plugin registration
22
+ // (main.ts: window.GithubPlugin = GithubPlugin)
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ (globalThis as any).window = globalThis;
25
+
26
+ return {
27
+ mockGetPages: vi.fn(),
28
+ mockSetCustomDomain: vi.fn(),
29
+ mockEnforceHttps: vi.fn(),
30
+ };
31
+ });
32
+
33
+ // ============================================================================
34
+ // Mocks — must be declared before imports (vi.mock is hoisted)
35
+ // ============================================================================
36
+
37
+ vi.mock("../github-api", () => ({
38
+ getPages: (...args: unknown[]) => mockGetPages(...args),
39
+ setCustomDomain: (...args: unknown[]) => mockSetCustomDomain(...args),
40
+ enforceHttps: (...args: unknown[]) => mockEnforceHttps(...args),
41
+ // Stubs for other exports that main.ts imports
42
+ checkPagesStatus: vi.fn(),
43
+ ensurePagesSource: vi.fn(),
44
+ GITHUB_API_BASE: "https://api.github.com",
45
+ GITHUB_API_HEADERS: {},
46
+ }));
47
+
48
+ vi.mock("../github-deploy", () => ({
49
+ getOriginOwnerRepo: vi.fn().mockResolvedValue({ owner: "testuser", repo: "testrepo" }),
50
+ verifyRepoExists: vi.fn(),
51
+ deployViaGitPush: vi.fn(),
52
+ }));
53
+
54
+ vi.mock("../token", () => ({
55
+ getToken: vi.fn().mockResolvedValue("test-token-123"),
56
+ getTokenFromGit: vi.fn().mockResolvedValue(null),
57
+ storeToken: vi.fn(),
58
+ }));
59
+
60
+ vi.mock("../auth", () => ({
61
+ promptLogin: vi.fn(),
62
+ validateToken: vi.fn(),
63
+ hasRequiredScopes: vi.fn(),
64
+ }));
65
+
66
+ vi.mock("../repo-setup", () => ({
67
+ ensureGitHubRepo: vi.fn(),
68
+ }));
69
+
70
+ vi.mock("../git", () => ({
71
+ buildPagesUrl: vi.fn().mockReturnValue("https://testuser.github.io/testrepo"),
72
+ parseGitHubUrl: vi.fn(),
73
+ }));
74
+
75
+ vi.mock("../utils", () => ({
76
+ reportProgress: vi.fn(),
77
+ reportError: vi.fn(),
78
+ setCurrentHookName: vi.fn(),
79
+ showToast: vi.fn(),
80
+ closeBrowser: vi.fn(),
81
+ }));
82
+
83
+ vi.mock("@symbiosis-lab/moss-api", () => ({
84
+ getTauriCore: () => ({
85
+ invoke: vi.fn().mockResolvedValue("/usr/bin/git"),
86
+ }),
87
+ setMessageContext: vi.fn(),
88
+ reportProgress: vi.fn(),
89
+ reportError: vi.fn(),
90
+ showToast: vi.fn(),
91
+ dismissToast: vi.fn(),
92
+ closeBrowser: vi.fn(),
93
+ getPluginCookie: vi.fn(),
94
+ setPluginCookie: vi.fn(),
95
+ executeBinary: vi.fn(),
96
+ listSiteFilesWithSizes: vi.fn(),
97
+ }));
98
+
99
+ // ============================================================================
100
+ // Import the function under test (after mocks are set up)
101
+ // ============================================================================
102
+
103
+ import { configure_domain } from "../main";
104
+ import type { ConfigureDomainContext } from "../types";
105
+
106
+ // ============================================================================
107
+ // Test Helpers
108
+ // ============================================================================
109
+
110
+ function makeContext(domain: string): ConfigureDomainContext {
111
+ return {
112
+ domain,
113
+ deployment: {
114
+ method: "github-pages",
115
+ url: "https://testuser.github.io/testrepo",
116
+ deployed_at: "2026-01-01T00:00:00Z",
117
+ metadata: {},
118
+ },
119
+ project_info: {
120
+ name: "test-project",
121
+ version: "1.0.0",
122
+ } as ConfigureDomainContext["project_info"],
123
+ config: {},
124
+ };
125
+ }
126
+
127
+ // ============================================================================
128
+ // Tests
129
+ // ============================================================================
130
+
131
+ describe("configure_domain (idempotent)", () => {
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ // Default mock: setCustomDomain succeeds
135
+ mockSetCustomDomain.mockResolvedValue(true);
136
+ });
137
+
138
+ // --------------------------------------------------------------------------
139
+ // 1. Pages not enabled
140
+ // --------------------------------------------------------------------------
141
+ it("returns failure when GitHub Pages is not enabled", async () => {
142
+ mockGetPages.mockResolvedValue(null);
143
+
144
+ const result = await configure_domain(makeContext("example.com"));
145
+
146
+ expect(result.success).toBe(false);
147
+ expect(result.message).toContain("Deploy first");
148
+ expect(mockSetCustomDomain).not.toHaveBeenCalled();
149
+ expect(mockEnforceHttps).not.toHaveBeenCalled();
150
+ });
151
+
152
+ // --------------------------------------------------------------------------
153
+ // 2. CNAME not set
154
+ // --------------------------------------------------------------------------
155
+ it("sets CNAME when pages exist but cname is null", async () => {
156
+ mockGetPages.mockResolvedValue({ cname: null, https_enforced: false });
157
+
158
+ const result = await configure_domain(makeContext("example.com"));
159
+
160
+ expect(result.success).toBe(true);
161
+ expect(mockSetCustomDomain).toHaveBeenCalledWith("testuser", "testrepo", "test-token-123", "example.com");
162
+ expect(mockEnforceHttps).not.toHaveBeenCalled();
163
+ expect(result.message).toContain("example.com");
164
+ });
165
+
166
+ // --------------------------------------------------------------------------
167
+ // 3. CNAME wrong
168
+ // --------------------------------------------------------------------------
169
+ it("sets CNAME when current cname differs from requested domain", async () => {
170
+ mockGetPages.mockResolvedValue({ cname: "old.com", https_enforced: false });
171
+
172
+ const result = await configure_domain(makeContext("new.com"));
173
+
174
+ expect(result.success).toBe(true);
175
+ expect(mockSetCustomDomain).toHaveBeenCalledWith("testuser", "testrepo", "test-token-123", "new.com");
176
+ expect(mockEnforceHttps).not.toHaveBeenCalled();
177
+ });
178
+
179
+ // --------------------------------------------------------------------------
180
+ // 4. CNAME set, HTTPS not enforced, enforce succeeds
181
+ // --------------------------------------------------------------------------
182
+ it("enforces HTTPS when CNAME is set but HTTPS is not enforced", async () => {
183
+ mockGetPages.mockResolvedValue({ cname: "example.com", https_enforced: false });
184
+ mockEnforceHttps.mockResolvedValue(true);
185
+
186
+ const result = await configure_domain(makeContext("example.com"));
187
+
188
+ expect(result.success).toBe(true);
189
+ expect(result.message).toContain("HTTPS enforced");
190
+ expect(mockSetCustomDomain).not.toHaveBeenCalled();
191
+ expect(mockEnforceHttps).toHaveBeenCalledWith("testuser", "testrepo", "test-token-123");
192
+ });
193
+
194
+ // --------------------------------------------------------------------------
195
+ // 5. CNAME set, HTTPS not enforced, enforce fails (cert pending)
196
+ // --------------------------------------------------------------------------
197
+ it("returns success with pending message when HTTPS enforcement fails (cert not ready)", async () => {
198
+ mockGetPages.mockResolvedValue({ cname: "example.com", https_enforced: false });
199
+ mockEnforceHttps.mockResolvedValue(false);
200
+
201
+ const result = await configure_domain(makeContext("example.com"));
202
+
203
+ // This is NOT a failure — cert will come eventually, orchestrator will retry
204
+ expect(result.success).toBe(true);
205
+ expect(result.message).toContain("pending");
206
+ expect(mockSetCustomDomain).not.toHaveBeenCalled();
207
+ expect(mockEnforceHttps).toHaveBeenCalled();
208
+ });
209
+
210
+ // --------------------------------------------------------------------------
211
+ // 6. Fully configured — no-op
212
+ // --------------------------------------------------------------------------
213
+ it("returns success without making API calls when fully configured", async () => {
214
+ mockGetPages.mockResolvedValue({ cname: "example.com", https_enforced: true });
215
+
216
+ const result = await configure_domain(makeContext("example.com"));
217
+
218
+ expect(result.success).toBe(true);
219
+ expect(result.message).toContain("already configured");
220
+ expect(mockSetCustomDomain).not.toHaveBeenCalled();
221
+ expect(mockEnforceHttps).not.toHaveBeenCalled();
222
+ });
223
+
224
+ // --------------------------------------------------------------------------
225
+ // 7. Case-insensitive domain comparison
226
+ // --------------------------------------------------------------------------
227
+ it("treats domain comparison as case-insensitive", async () => {
228
+ mockGetPages.mockResolvedValue({ cname: "Example.COM", https_enforced: true });
229
+
230
+ const result = await configure_domain(makeContext("example.com"));
231
+
232
+ // Should NOT try to set CNAME — domain matches (case-insensitive)
233
+ expect(result.success).toBe(true);
234
+ expect(mockSetCustomDomain).not.toHaveBeenCalled();
235
+ expect(mockEnforceHttps).not.toHaveBeenCalled();
236
+ expect(result.message).toContain("already configured");
237
+ });
238
+
239
+ // --------------------------------------------------------------------------
240
+ // 8. setCustomDomain throws an error
241
+ // --------------------------------------------------------------------------
242
+ it("returns failure when setCustomDomain throws", async () => {
243
+ mockGetPages.mockResolvedValue({ cname: null, https_enforced: false });
244
+ mockSetCustomDomain.mockRejectedValue(new Error("GitHub Pages API error (500): Internal Server Error"));
245
+
246
+ const result = await configure_domain(makeContext("example.com"));
247
+
248
+ expect(result.success).toBe(false);
249
+ expect(result.message).toContain("GitHub Pages API error");
250
+ });
251
+
252
+ // --------------------------------------------------------------------------
253
+ // 9. getPages throws a network error
254
+ // --------------------------------------------------------------------------
255
+ it("returns failure when getPages throws a network error", async () => {
256
+ mockGetPages.mockRejectedValue(new Error("fetch failed"));
257
+
258
+ const result = await configure_domain(makeContext("example.com"));
259
+
260
+ expect(result.success).toBe(false);
261
+ expect(result.message).toContain("fetch failed");
262
+ });
263
+ });