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.
-
-
-
-
+
+
-
-
- Sign in with GitHub
-
+
+ Sign in with GitHub
+
+
+
or
+
setShowPatForm(true)}
+ class="link link-primary text-sm"
+ >
+ Use a Personal Access Token
+
+
+
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", () => {