diff --git a/README.md b/README.md index c5f9dccb..01445997 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple - **Pull Requests Tab** — Open PRs with CI check status indicators (green/yellow/red dots). Draft badges, reviewer names. - **Actions Tab** — GHA workflow runs grouped by repo and workflow. Accordion collapse, PR run toggle. - **Onboarding Wizard** — Two-step org/repo selection with search filtering and bulk select. -- **Settings Page** — Refresh interval, notification preferences, theme (light/dark/system), density, GitHub Actions limits. +- **PAT Authentication** — Optional Personal Access Token login as alternative to OAuth. Client-side format validation, detailed token creation instructions for classic and fine-grained PATs. +- **Settings Page** — Refresh interval, notification preferences, theme (light/dark/system), density, GitHub Actions limits. Shows current auth method and hides OAuth-specific options for PAT users. - **Desktop Notifications** — New item alerts with per-type toggles and batching. - **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover. - **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash. @@ -30,7 +31,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple ```sh pnpm install pnpm run dev # Start Vite dev server -pnpm test # Run browser tests (130 tests) +pnpm test # Run unit/component tests pnpm run typecheck # TypeScript check pnpm run build # Production build (~241KB JS, ~31KB CSS) ``` @@ -57,6 +58,7 @@ src/ config.ts # Zod v4-validated config with localStorage persistence view.ts # View state (tabs, sorting, ignored items, filters) lib/ + pat.ts # PAT format validation and token creation instruction constants notifications.ts # Desktop notification permission, detection, and dispatch worker/ index.ts # OAuth token exchange endpoint, CORS, security headers @@ -72,6 +74,7 @@ tests/ ## Security - Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only) +- PAT tokens stored in `localStorage` (same key as OAuth tokens) — single-user personal dashboard threat model - OAuth CSRF protection via `crypto.getRandomValues` state parameter - CORS locked to exact origin (strict equality, no substring matching) - Access token stored in `localStorage` under app-specific key; CSP prevents XSS token theft diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index d073f978..c02fcb0b 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -260,26 +260,28 @@ export default function SettingsPage() { -
-
-
-

- Organization Access -

-

- Request access for restricted orgs on GitHub — new orgs sync when you return -

+ +
+
+
+

+ Organization Access +

+

+ Request access for restricted orgs on GitHub — new orgs sync when you return +

+
+
-
-
+
@@ -561,6 +563,14 @@ export default function SettingsPage() { {/* Section 7: Data */}
+ {/* Authentication method */} + + {config.authMethod === "pat" ? "Personal Access Token" : "OAuth"} + + {/* Clear cache */} (null); + const [submitting, setSubmitting] = createSignal(false); + function handleLogin() { window.location.href = buildAuthorizeUrl(); } + async function handlePatSubmit(e: Event) { + e.preventDefault(); + if (submitting()) return; + const validation = isValidPatFormat(patInput()); + if (!validation.valid) { + setPatError(validation.error); + return; + } + setSubmitting(true); + setPatError(null); + const trimmedToken = patInput().trim(); + try { + const resp = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${trimmedToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!resp.ok) { + setPatError( + resp.status === 401 + ? "Token is invalid — check that you entered it correctly" + : `GitHub returned ${resp.status} — try again later` + ); + return; + } + if (!showPatForm()) return; + const userData = (await resp.json()) as GitHubUser; + setAuthFromPat(trimmedToken, userData); + setPatInput(""); + navigate("/", { replace: true }); + } catch { + setPatError("Network error — please try again"); + } finally { + setSubmitting(false); + } + } + return (
-
-

- GitHub Tracker -

-

- Track issues, pull requests, and workflow runs across your GitHub - repositories. -

-
- - + +
+
+

+ + Classic token + + {" "}(recommended) — works across all orgs. Select these scopes: +

+
    +
  • repo
  • +
  • read:org (under admin:org)
  • +
  • notifications
  • +
+
+ +

+ + Fine-grained tokens + + {" "}also work, but only access one org at a time and do not support notifications. Add read-only permissions for Actions, Contents, Issues, and Pull requests. +

+
+ + + + } > -

+ GitHub Tracker +

+

+ Track issues, pull requests, and workflow runs across your GitHub + repositories. +

+
+ + + + Sign in with GitHub + + +
or
+ + +
diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 9cffb5ad..11535306 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -135,7 +135,9 @@ async function hasNotificationChanges(): Promise { (err as { status?: number }).status === 403 ) { console.warn("[poll] Notifications API returned 403 — disabling gate"); - pushNotification("notifications", "Notifications API returned 403 — check that the notifications scope is granted", "warning"); + pushNotification("notifications", config.authMethod === "pat" + ? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope" + : "Notifications API returned 403 — check that the notifications scope is granted", "warning"); _notifGateDisabled = true; } return true; diff --git a/src/app/stores/auth.ts b/src/app/stores/auth.ts index 26bdaa11..a0400a7e 100644 --- a/src/app/stores/auth.ts +++ b/src/app/stores/auth.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js"; import { clearCache } from "./cache"; -import { CONFIG_STORAGE_KEY, resetConfig } from "./config"; +import { CONFIG_STORAGE_KEY, resetConfig, updateConfig, config } from "./config"; import { VIEW_STORAGE_KEY, resetViewState } from "./view"; export const AUTH_STORAGE_KEY = "github-tracker:auth-token"; @@ -45,6 +45,12 @@ export function setAuth(response: TokenExchangeResponse): void { console.info("[auth] access token set (localStorage)"); } +export function setAuthFromPat(token: string, userData: GitHubUser): void { + setAuth({ access_token: token }); + setUser({ login: userData.login, avatar_url: userData.avatar_url, name: userData.name }); + updateConfig({ authMethod: "pat" }); +} + const _onClearCallbacks: (() => void)[] = []; /** Register a callback to run when auth is cleared. Avoids circular imports. */ @@ -107,8 +113,12 @@ export async function validateToken(): Promise { } if (resp.status === 401) { - // Permanent token is revoked — clear auth and redirect to login - console.info("[auth] access token invalid — clearing auth"); + const method = config.authMethod; + console.info( + method === "pat" + ? "[auth] PAT invalid or expired — clearing auth" + : "[auth] access token invalid — clearing auth" + ); clearAuth(); return false; } diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index 71ba5f96..21c5b36d 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -46,6 +46,7 @@ export const ConfigSchema = z.object({ defaultTab: z.enum(["issues", "pullRequests", "actions"]).default("issues"), rememberLastTab: z.boolean().default(true), onboardingComplete: z.boolean().default(false), + authMethod: z.enum(["oauth", "pat"]).default("oauth"), }); export type Config = z.infer; diff --git a/tests/components/LoginPage.test.tsx b/tests/components/LoginPage.test.tsx index e548fbfe..f702c2d1 100644 --- a/tests/components/LoginPage.test.tsx +++ b/tests/components/LoginPage.test.tsx @@ -1,123 +1,292 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; +import { render, screen, waitFor } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../src/app/stores/auth", () => ({ + setAuthFromPat: vi.fn(), +})); + +vi.mock("../../src/app/lib/pat", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isValidPatFormat: vi.fn(actual.isValidPatFormat), + }; +}); + +// Full router mock — per project convention (SolidJS useNavigate requires Route context; +// partial mocks of @solidjs/router render empty divs) +const mockNavigate = vi.fn(); +vi.mock("@solidjs/router", () => ({ + useNavigate: () => mockNavigate, + MemoryRouter: (props: { children: unknown }) => props.children, + Route: (props: { component: () => unknown }) => props.component(), +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── + import LoginPage from "../../src/app/pages/LoginPage"; -import { OAUTH_STATE_KEY } from "../../src/app/lib/oauth"; - -describe("LoginPage", () => { - beforeEach(() => { - // Allow setting window.location.href - Object.defineProperty(window, "location", { - configurable: true, - writable: true, - value: { href: "", origin: "http://localhost" }, - }); - sessionStorage.clear(); - // Stub the env var used by the component - vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id"); - }); +import * as authStore from "../../src/app/stores/auth"; +import * as patLib from "../../src/app/lib/pat"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const githubUser = { login: "testuser", avatar_url: "https://avatars.githubusercontent.com/u/1", name: "Test User" }; + +function mockFetchOk() { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(githubUser), + })); +} + +function mockFetch401() { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: false, + status: 401, + })); +} - afterEach(() => { - vi.unstubAllEnvs(); +function mockFetch503() { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: false, + status: 503, + })); +} + +function mockFetchNetworkError() { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + mockFetchOk(); + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: { href: "", origin: "http://localhost" }, }); + sessionStorage.clear(); + vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── - it("renders the app title", () => { +describe("LoginPage — OAuth view (default)", () => { + it("shows 'Sign in with GitHub' button", () => { render(() => ); - screen.getByText("GitHub Tracker"); + screen.getByText("Sign in with GitHub"); }); - it("renders the sign in button", () => { + it("shows 'Use a Personal Access Token' link", () => { render(() => ); - screen.getByText("Sign in with GitHub"); + screen.getByText("Use a Personal Access Token"); }); - it("shows app branding description", () => { + it("shows app title and description", () => { render(() => ); + screen.getByText("GitHub Tracker"); screen.getByText(/Track issues, pull requests/i); }); - it("clicking login sets window.location.href to GitHub OAuth URL", async () => { + it("clicking 'Sign in with GitHub' navigates to OAuth URL", async () => { const user = userEvent.setup(); render(() => ); - const button = screen.getByText("Sign in with GitHub"); - await user.click(button); + await user.click(screen.getByText("Sign in with GitHub")); expect(window.location.href).toContain("https://github.com/login/oauth/authorize"); }); +}); - it("OAuth URL includes correct client_id", async () => { +describe("LoginPage — PAT form navigation", () => { + it("clicking 'Use a Personal Access Token' shows PAT form", async () => { const user = userEvent.setup(); render(() => ); - const button = screen.getByText("Sign in with GitHub"); - await user.click(button); - const url = new URL(window.location.href); - // The component reads import.meta.env.VITE_GITHUB_CLIENT_ID at click-time - // It falls back to whatever is in the env — we verify it is present - expect(url.searchParams.get("client_id")).toBeTruthy(); + await user.click(screen.getByText("Use a Personal Access Token")); + screen.getByText("Sign in with Token"); + screen.getByLabelText("Personal access token"); }); - it("OAuth URL includes state param", async () => { + it("PAT form shows submit button and token creation links", async () => { const user = userEvent.setup(); render(() => ); - const button = screen.getByText("Sign in with GitHub"); - await user.click(button); - const url = new URL(window.location.href); - const state = url.searchParams.get("state"); - expect(state).toBeTruthy(); - expect(state!.length).toBeGreaterThan(0); + await user.click(screen.getByText("Use a Personal Access Token")); + screen.getByRole("button", { name: "Sign in" }); + screen.getByRole("link", { name: /Classic token/i }); + screen.getByRole("link", { name: /Fine-grained tokens/i }); }); - it("stores state in sessionStorage for CSRF protection", async () => { + it("shows classic token as recommended", async () => { const user = userEvent.setup(); render(() => ); - const button = screen.getByText("Sign in with GitHub"); - await user.click(button); - const stored = sessionStorage.getItem(OAUTH_STATE_KEY); - expect(stored).toBeTruthy(); + await user.click(screen.getByText("Use a Personal Access Token")); + screen.getByText(/recommended/i); }); - it("state in URL matches state in sessionStorage", async () => { + it("shows fine-grained limitations inline", async () => { const user = userEvent.setup(); render(() => ); - const button = screen.getByText("Sign in with GitHub"); - await user.click(button); - const url = new URL(window.location.href); - const urlState = url.searchParams.get("state"); - const storedState = sessionStorage.getItem(OAUTH_STATE_KEY); - expect(urlState).toBe(storedState); + await user.click(screen.getByText("Use a Personal Access Token")); + screen.getByText(/only access one org at a time/i); }); - it("OAuth URL includes redirect_uri with /oauth/callback", async () => { + it("clicking 'Use OAuth instead' returns to OAuth view and clears state", async () => { const user = userEvent.setup(); render(() => ); - const button = screen.getByText("Sign in with GitHub"); - await user.click(button); - const url = new URL(window.location.href); - expect(url.searchParams.get("redirect_uri")).toContain("/oauth/callback"); + await user.click(screen.getByText("Use a Personal Access Token")); + screen.getByText("Sign in with Token"); + await user.type(screen.getByLabelText("Personal access token"), "ghp_test"); + await user.click(screen.getByText("Use OAuth instead")); + screen.getByText("Sign in with GitHub"); + expect(screen.queryByText("Sign in with Token")).toBeNull(); }); +}); - it("OAuth URL includes scope parameter with required scopes", async () => { +describe("LoginPage — PAT form validation", () => { + async function openPatForm() { const user = userEvent.setup(); render(() => ); - const button = screen.getByText("Sign in with GitHub"); - await user.click(button); - const url = new URL(window.location.href); - expect(url.searchParams.get("scope")).toBe("repo read:org notifications"); + await user.click(screen.getByText("Use a Personal Access Token")); + return user; + } + + it("shows validation error for invalid token format", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ + valid: false, + error: "Token should start with ghp_ (classic) or github_pat_ (fine-grained)", + }); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "bad-token"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + screen.getByRole("alert"); + expect(screen.getByRole("alert").textContent).toContain("should start with ghp_"); }); - it("each login click generates a unique state", async () => { - // Render two separate instances to simulate two clicks - const { unmount } = render(() => ); - const user1 = userEvent.setup(); - await user1.click(screen.getByText("Sign in with GitHub")); - const state1 = new URL(window.location.href).searchParams.get("state"); - unmount(); + it("shows 'Token is invalid' error on 401 from GitHub", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + mockFetch401(); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("Token is invalid"); + }); + // Token was never stored — setAuthFromPat should NOT have been called + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + }); - render(() => ); - const user2 = userEvent.setup(); - await user2.click(screen.getByText("Sign in with GitHub")); - const state2 = new URL(window.location.href).searchParams.get("state"); + it("shows status-specific error on non-401 HTTP failure", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + mockFetch503(); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("503"); + }); + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + }); + + it("calls setAuthFromPat with token and user data, then navigates", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + mockFetchOk(); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true }); + }); + expect(authStore.setAuthFromPat).toHaveBeenCalledWith( + "ghp_" + "a".repeat(36), + githubUser, + ); + }); - // States should be random — extremely unlikely to collide - expect(state1).not.toBe(state2); + it("shows 'Verifying...' and disables button while submitting", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + let resolveFetch!: (v: Response) => void; + vi.stubGlobal("fetch", vi.fn().mockReturnValueOnce( + new Promise((r) => { resolveFetch = r; }) + )); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + const btn = screen.getByRole("button", { name: "Verifying..." }); + expect(btn.hasAttribute("disabled")).toBe(true); + }); + resolveFetch({ ok: true, status: 200, json: () => Promise.resolve(githubUser) } as Response); + }); + + it("re-enables button after successful submission", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + mockFetchOk(); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + // finally block should have run — button no longer disabled + const btn = screen.getByRole("button", { name: "Sign in" }); + expect(btn.hasAttribute("disabled")).toBe(false); + }); + + it("shows 'Network error' on fetch failure", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + mockFetchNetworkError(); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain("Network error"); + }); + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + }); + + it("re-enables button after network error", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + mockFetchNetworkError(); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + screen.getByRole("alert"); + }); + const btn = screen.getByRole("button", { name: "Sign in" }); + expect(btn.hasAttribute("disabled")).toBe(false); + }); + + it("does not navigate when user switches to OAuth during validation", async () => { + vi.mocked(patLib.isValidPatFormat).mockReturnValueOnce({ valid: true }); + let resolveFetch!: (v: Response) => void; + vi.stubGlobal("fetch", vi.fn().mockReturnValueOnce( + new Promise((r) => { resolveFetch = r; }) + )); + const user = await openPatForm(); + await user.type(screen.getByLabelText("Personal access token"), "ghp_" + "a".repeat(36)); + await user.click(screen.getByRole("button", { name: "Sign in" })); + await waitFor(() => { + screen.getByRole("button", { name: "Verifying..." }); + }); + // User switches back to OAuth view while fetch is in-flight + await user.click(screen.getByText("Use OAuth instead")); + // Resolve fetch as successful — but user already left + resolveFetch({ ok: true, status: 200, json: () => Promise.resolve(githubUser) } as Response); + // Wait for async handler to settle + await waitFor(() => { + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + }); + expect(mockNavigate).not.toHaveBeenCalled(); }); }); diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index 6b159774..8d96688a 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -115,6 +115,7 @@ beforeEach(() => { notifications: { enabled: false, issues: true, pullRequests: true, workflowRuns: true }, selectedOrgs: [], selectedRepos: [], + authMethod: "oauth" as const, }); sessionStorage.clear(); @@ -513,6 +514,30 @@ describe("SettingsPage — Data: Sign out", () => { // Theme application tests removed — theme is now handled by createEffect in App.tsx, not SettingsPage +describe("SettingsPage — Auth method display", () => { + it("shows 'OAuth' when authMethod is 'oauth'", () => { + renderSettings(); + screen.getByText("OAuth"); + }); + + it("shows 'Personal Access Token' when authMethod is 'pat'", () => { + updateConfig({ authMethod: "pat" }); + renderSettings(); + screen.getByText("Personal Access Token"); + }); + + it("shows 'Manage org access' when authMethod is 'oauth'", () => { + renderSettings(); + screen.getByRole("button", { name: "Manage org access" }); + }); + + it("hides 'Manage org access' when authMethod is 'pat'", () => { + updateConfig({ authMethod: "pat" }); + renderSettings(); + expect(screen.queryByRole("button", { name: "Manage org access" })).toBeNull(); + }); +}); + describe("SettingsPage — Manage org access button", () => { beforeEach(() => { vi.stubEnv("VITE_GITHUB_CLIENT_ID", "test-client-id"); diff --git a/tests/lib/pat.test.ts b/tests/lib/pat.test.ts new file mode 100644 index 00000000..e76c6c47 --- /dev/null +++ b/tests/lib/pat.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { isValidPatFormat } from "../../src/app/lib/pat"; + +/** Type-safe helper: asserts result is invalid and returns the error string */ +function expectInvalid(result: ReturnType): string { + expect(result.valid).toBe(false); + if (!result.valid) return result.error; + throw new Error("unreachable"); +} + +describe("isValidPatFormat", () => { + it("rejects empty string", () => { + expect(expectInvalid(isValidPatFormat(""))).toBe("Please enter a token"); + }); + + it("rejects whitespace-only string", () => { + expect(expectInvalid(isValidPatFormat(" "))).toBe("Please enter a token"); + }); + + it("rejects random string without valid prefix", () => { + expect(expectInvalid(isValidPatFormat("some_random_token_value"))).toContain("should start with ghp_"); + }); + + it("rejects ghp without underscore (not a truncation)", () => { + expect(expectInvalid(isValidPatFormat("ghp" + "a".repeat(37)))).toContain("should start with ghp_"); + }); + + it("rejects github_pat without trailing underscore", () => { + expect(expectInvalid(isValidPatFormat("github_pat" + "a".repeat(37)))).toContain("should start with ghp_"); + }); + + it("accepts valid classic PAT (ghp_ + 36 chars = 40 total)", () => { + const token = "ghp_" + "a".repeat(36); + expect(token.length).toBe(40); + expect(isValidPatFormat(token).valid).toBe(true); + }); + + it("rejects classic PAT one char too short (39 total)", () => { + const token = "ghp_" + "a".repeat(35); + expect(token.length).toBe(39); + expect(expectInvalid(isValidPatFormat(token))).toContain("truncated"); + }); + + it("rejects very short classic PAT", () => { + expect(expectInvalid(isValidPatFormat("ghp_abc"))).toContain("truncated"); + }); + + it("accepts valid fine-grained PAT (github_pat_ + 69 chars = 80 total)", () => { + const token = "github_pat_" + "a".repeat(69); + expect(token.length).toBe(80); + expect(isValidPatFormat(token).valid).toBe(true); + }); + + it("accepts realistic fine-grained PAT (~93 chars)", () => { + const token = "github_pat_" + "a1b2c3d4e5".repeat(8) + "ab"; + expect(token.length).toBe(93); + expect(isValidPatFormat(token).valid).toBe(true); + }); + + it("rejects fine-grained PAT one char too short (79 total)", () => { + const token = "github_pat_" + "a".repeat(68); + expect(token.length).toBe(79); + expect(expectInvalid(isValidPatFormat(token))).toContain("truncated"); + }); + + it("rejects very short fine-grained PAT", () => { + expect(expectInvalid(isValidPatFormat("github_pat_short"))).toContain("truncated"); + }); + + it("rejects truncated fine-grained PAT that would pass old minimum (47 chars)", () => { + const token = "github_pat_" + "a".repeat(36); + expect(token.length).toBe(47); + expect(expectInvalid(isValidPatFormat(token))).toContain("truncated"); + }); + + it("trims whitespace and validates underlying token", () => { + const token = " ghp_" + "a".repeat(36) + " "; + expect(isValidPatFormat(token).valid).toBe(true); + }); + + it("rejects bare prefix ghp_ as truncated (not invalid characters)", () => { + expect(expectInvalid(isValidPatFormat("ghp_"))).toContain("truncated"); + }); + + it("rejects token with invalid characters", () => { + const token = "ghp_" + "abc!def" + "a".repeat(29); + expect(expectInvalid(isValidPatFormat(token))).toContain("invalid characters"); + }); + + it("accepts fine-grained PAT with underscores in payload", () => { + const token = "github_pat_" + "abc_def_ghi_".repeat(6) + "abcdef"; + expect(token.length).toBeGreaterThanOrEqual(80); + expect(isValidPatFormat(token).valid).toBe(true); + }); + + it("returns discriminated union — no error key when valid", () => { + const result = isValidPatFormat("ghp_" + "a".repeat(36)); + expect(result.valid).toBe(true); + expect("error" in result).toBe(false); + }); + + it("returns discriminated union — error present when invalid", () => { + const result = isValidPatFormat("bad"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBeTruthy(); + } + }); +}); diff --git a/tests/services/poll-fetchAllData.test.ts b/tests/services/poll-fetchAllData.test.ts index 4f4e9e4b..fe8ffc06 100644 --- a/tests/services/poll-fetchAllData.test.ts +++ b/tests/services/poll-fetchAllData.test.ts @@ -547,6 +547,44 @@ describe("fetchAllData — notification gate 403 auto-disable", () => { expect(fetchIssuesAndPullRequests).toHaveBeenCalled(); expect(fetchWorkflowRuns).toHaveBeenCalled(); }); + + it("shows PAT-specific 403 notification when authMethod is 'pat'", async () => { + vi.resetModules(); + + // Override config mock to include authMethod: "pat" for this test + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + authMethod: "pat", + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const { pushNotification } = await import("../../src/app/lib/errors"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + + // First call — sets _lastSuccessfulFetch + await fetchAllData(); + + // Second call — gate fires a 403 + mockOctokit.request.mockRejectedValueOnce({ status: 403 }); + await fetchAllData(); + + // PAT-specific message should mention fine-grained tokens + expect(pushNotification).toHaveBeenCalledWith( + "notifications", + expect.stringContaining("fine-grained tokens do not support notifications"), + "warning" + ); + }); }); // ── qa-4: Concurrency verification ──────────────────────────────────────────── diff --git a/tests/stores/auth.test.ts b/tests/stores/auth.test.ts index 3afcf868..41ccb265 100644 --- a/tests/stores/auth.test.ts +++ b/tests/stores/auth.test.ts @@ -285,3 +285,53 @@ describe("validateToken", () => { expect(localStorageMock.getItem("github-tracker:auth-token")).toBe("ghs_permanent"); }); }); + +describe("setAuthFromPat", () => { + let mod: typeof import("../../src/app/stores/auth"); + let configMod: typeof import("../../src/app/stores/config"); + + const testUser = { login: "testuser", avatar_url: "https://avatars.githubusercontent.com/u/1", name: "Test User" }; + + beforeEach(async () => { + localStorageMock.clear(); + vi.resetModules(); + mod = await import("../../src/app/stores/auth"); + configMod = await import("../../src/app/stores/config"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("stores token in localStorage", () => { + mod.setAuthFromPat("ghp_testtoken123", testUser); + expect(localStorageMock.getItem("github-tracker:auth-token")).toBe("ghp_testtoken123"); + }); + + it("sets token signal", () => { + mod.setAuthFromPat("ghp_testtoken123", testUser); + expect(mod.token()).toBe("ghp_testtoken123"); + }); + + it("populates user signal", () => { + mod.setAuthFromPat("ghp_testtoken123", testUser); + expect(mod.user()).toEqual(testUser); + }); + + it("sets isAuthenticated to true", () => { + mod.setAuthFromPat("ghp_testtoken123", testUser); + expect(mod.isAuthenticated()).toBe(true); + }); + + it("sets config.authMethod to 'pat'", () => { + mod.setAuthFromPat("ghp_testtoken123", testUser); + expect(configMod.config.authMethod).toBe("pat"); + }); + + it("clearAuth resets authMethod to 'oauth'", () => { + mod.setAuthFromPat("ghp_testtoken123", testUser); + expect(configMod.config.authMethod).toBe("pat"); + mod.clearAuth(); + expect(configMod.config.authMethod).toBe("oauth"); + }); +}); diff --git a/tests/stores/config.test.ts b/tests/stores/config.test.ts index 5993d9ee..beda0895 100644 --- a/tests/stores/config.test.ts +++ b/tests/stores/config.test.ts @@ -45,6 +45,7 @@ describe("ConfigSchema", () => { expect(result.defaultTab).toBe("issues"); expect(result.rememberLastTab).toBe(true); expect(result.onboardingComplete).toBe(false); + expect(result.authMethod).toBe("oauth"); }); it("fills missing fields from defaults when partial input given", () => {