Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/app/automation/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import { authedFetch } from "@/lib/client-auth";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -141,7 +142,7 @@ function RepoCard({ repo, onDelete }: { repo: RepoOverview; onDelete?: () => voi
variant="ghost"
size="sm"
onClick={() => {
fetch(`/api/automation/sync`, {
authedFetch(`/api/automation/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repo: repo.fullName }),
Expand Down Expand Up @@ -182,10 +183,10 @@ export default function AutomationOverview() {
const [addLoading, setAddLoading] = useState(false);

useEffect(() => {
fetch("/api/automation/sync")
authedFetch("/api/automation/sync")
.then((res) => res.json())
.catch(() => ({}));
fetch("/api/automation/repos")
authedFetch("/api/automation/repos")
.then((res) => res.json())
.then((data) => setRepos(data))
.catch(() => setRepos([]))
Expand All @@ -195,8 +196,8 @@ export default function AutomationOverview() {
async function syncAll() {
setSyncing(true);
try {
await fetch("/api/automation/sync", { method: "POST" });
const res = await fetch("/api/automation/repos");
await authedFetch("/api/automation/sync", { method: "POST" });
const res = await authedFetch("/api/automation/repos");
const data = await res.json();
setRepos(data);
} finally {
Expand All @@ -209,7 +210,7 @@ export default function AutomationOverview() {
setAddLoading(true);
setAddError("");
try {
const res = await fetch("/api/automation/repos", {
const res = await authedFetch("/api/automation/repos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullName: newRepo.trim() }),
Expand All @@ -221,7 +222,7 @@ export default function AutomationOverview() {
}
setNewRepo("");
setShowAddForm(false);
const res2 = await fetch("/api/automation/repos");
const res2 = await authedFetch("/api/automation/repos");
const data2 = await res2.json();
setRepos(data2);
} catch {
Expand All @@ -236,7 +237,7 @@ export default function AutomationOverview() {
return;
}
try {
const res = await fetch(`/api/automation/repos/${encodeURIComponent(fullName)}`, {
const res = await authedFetch(`/api/automation/repos/${encodeURIComponent(fullName)}`, {
method: "DELETE",
});
if (!res.ok) return;
Expand Down
7 changes: 4 additions & 3 deletions src/app/automation/repos/[...repo]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState, use } from "react";
import { authedFetch } from "@/lib/client-auth";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -107,7 +108,7 @@ export default function RepoDetailPage({ params }: { params: Promise<{ repo: str
useEffect(() => {
if (!repoFullName) return;
const decoded = decodeURIComponent(repoFullName);
fetch(`/api/automation/repos/${decoded}`)
authedFetch(`/api/automation/repos/${decoded}`)
.then((res) => {
if (!res.ok) throw new Error("Repo not found");
return res.json();
Expand Down Expand Up @@ -160,7 +161,7 @@ export default function RepoDetailPage({ params }: { params: Promise<{ repo: str
<Button
variant="outline"
onClick={() => {
fetch(`/api/automation/sync`, {
authedFetch(`/api/automation/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repo: repo.fullName }),
Expand Down Expand Up @@ -294,7 +295,7 @@ export default function RepoDetailPage({ params }: { params: Promise<{ repo: str
variant="ghost"
size="sm"
onClick={() => {
fetch(`/api/automation/runs/${run.runId}?repo=${repo.fullName}&action=rerun`, { method: "POST" })
authedFetch(`/api/automation/runs/${run.runId}?repo=${repo.fullName}&action=rerun`, { method: "POST" })
.then(() => window.location.reload());
}}
>
Expand Down
11 changes: 6 additions & 5 deletions src/components/issue-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
import { Issue, LABEL_COLORS, AGENT_PREFIX, OWNER_PREFIX, GROOM_ACTION_LABELS, GroomAction, isValidGroomAction } from "@/types";
import { GitPullRequest, MessageSquare, ExternalLink, MoreVertical, User, Users, X, Scissors, AlertTriangle, Info, Ban } from "lucide-react";
import { useState, useCallback } from "react";
import { authedFetch } from "@/lib/client-auth";

interface IssueCardProps {
issue: Issue;
Expand Down Expand Up @@ -67,7 +68,7 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
if (agents.length > 0 || fetchingAgents) return;
setFetchingAgents(true);
try {
const res = await fetch("/api/issues/actions/agents");
const res = await authedFetch("/api/issues/actions/agents");
if (res.ok) {
const data = await res.json();
if (Array.isArray(data.agents)) setAgents(data.agents);
Expand All @@ -93,7 +94,7 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)

// Trigger a sync to pick up label changes from GitHub
try {
await fetch("/api/sync", { method: "POST" });
await authedFetch("/api/sync", { method: "POST" });
} catch {
// Sync failure is non-blocking
}
Expand All @@ -116,7 +117,7 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
setError(null);
try {
const value = type === "agent" ? `${AGENT_PREFIX}${name}` : `${OWNER_PREFIX}${name}`;
const res = await fetch("/api/issues/actions", {
const res = await authedFetch("/api/issues/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down Expand Up @@ -145,7 +146,7 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
setLoadingAction(`unassign-${type}`);
setError(null);
try {
const res = await fetch("/api/issues/unassign", {
const res = await authedFetch("/api/issues/unassign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down Expand Up @@ -191,7 +192,7 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)
setLoadingAction(`groom-${action}`);
setError(null);
try {
const res = await fetch("/api/issues/groom", {
const res = await authedFetch("/api/issues/groom", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down
161 changes: 161 additions & 0 deletions src/lib/client-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";

const AUTH_SESSION_KEY = "dispatch-auth-credentials";

function clearSession(): void {
sessionStorage.clear();
}

describe("encodeBasicAuth", () => {
it("encodes username:password as Base64", () => {
const encoded = btoa("operator:s3cret");
expect(encoded).toBe("b3BlcmF0b3I6czNjcmV0");
});

it("handles special characters in password", () => {
const encoded = btoa("user:p@ss:w0rd!");
expect(encoded).toBe("dXNlcjpwQHNzOncwcmQh");
});
});

describe("storeBasicAuthCredentials / getStoredBasicAuthCredentials / clearBasicAuthCredentials", () => {
beforeEach(clearSession);
afterEach(clearSession);

it("stores and retrieves credentials", () => {
sessionStorage.setItem(AUTH_SESSION_KEY, "b3BlcmF0b3I6czNjcmV0");
expect(sessionStorage.getItem(AUTH_SESSION_KEY)).toBe("b3BlcmF0b3I6czNjcmV0");
});

it("returns null when no credentials stored", () => {
clearSession();
expect(sessionStorage.getItem(AUTH_SESSION_KEY)).toBeNull();
});

it("clears stored credentials", () => {
sessionStorage.setItem(AUTH_SESSION_KEY, "b3BlcmF0b3I6czNjcmV0");
sessionStorage.removeItem(AUTH_SESSION_KEY);
expect(sessionStorage.getItem(AUTH_SESSION_KEY)).toBeNull();
});
});

describe("hasBasicAuthCredentials", () => {
beforeEach(clearSession);
afterEach(clearSession);

it("returns true when credentials exist", () => {
sessionStorage.setItem(AUTH_SESSION_KEY, "stored");
expect(sessionStorage.getItem(AUTH_SESSION_KEY) !== null).toBe(true);
});

it("returns false when no credentials exist", () => {
clearSession();
expect(sessionStorage.getItem(AUTH_SESSION_KEY) !== null).toBe(false);
});
});

describe("authedFetch", () => {
beforeEach(() => {
clearSession();
// Mock global fetch
global.fetch = vi.fn();
});

afterEach(() => {
vi.restoreAllMocks();
clearSession();
});

it("attaches Basic Auth header when credentials are stored", async () => {
const { authedFetch } = await import("./client-auth");
sessionStorage.setItem(AUTH_SESSION_KEY, "b3BlcmF0b3I6czNjcmV0");

(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
await authedFetch("/api/test", { method: "POST" });

expect(global.fetch).toHaveBeenCalledWith(
"/api/test",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Basic b3BlcmF0b3I6czNjcmV0" }),
method: "POST",
})
);
});

it("does not override existing Authorization header", async () => {
const { authedFetch } = await import("./client-auth");
sessionStorage.setItem(AUTH_SESSION_KEY, "b3BlcmF0b3I6czNjcmV0");

(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
await authedFetch("/api/test", {
method: "POST",
headers: { Authorization: "Bearer existing-token" },
});

expect(global.fetch).toHaveBeenCalledWith(
"/api/test",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer existing-token" }),
})
);
});

it("does not add Authorization header when no credentials stored", async () => {
const { authedFetch } = await import("./client-auth");
clearSession();

(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
await authedFetch("/api/test");

const callArgs = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const headers = callArgs[1].headers as Record<string, string>;
// When no credentials stored, Authorization should not be present in headers
expect(headers.Authorization).toBeUndefined();
});

it("passes through other options unchanged", async () => {
const { authedFetch } = await import("./client-auth");
sessionStorage.setItem(AUTH_SESSION_KEY, "b3BlcmF0b3I6czNjcmV0");

(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
await authedFetch("/api/test", {
method: "PUT",
body: JSON.stringify({ key: "value" }),
headers: { "Content-Type": "application/json" },
});

expect(global.fetch).toHaveBeenCalledWith(
"/api/test",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ key: "value" }),
})
);
});

it("merges existing headers with Authorization header", async () => {
const { authedFetch } = await import("./client-auth");
sessionStorage.setItem(AUTH_SESSION_KEY, "b3BlcmF0b3I6czNjcmV0");

(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true });
await authedFetch("/api/test", {
headers: { "Content-Type": "application/json" },
});

const callArgs = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const headers = callArgs[1].headers as Record<string, string>;
expect(headers["Content-Type"]).toBe("application/json");
expect(headers["Authorization"]).toBe("Basic b3BlcmF0b3I6czNjcmV0");
});

it("returns the fetch response", async () => {
const { authedFetch } = await import("./client-auth");
sessionStorage.setItem(AUTH_SESSION_KEY, "b3BlcmF0b3I6czNjcmV0");

const mockResponse = { ok: true, status: 200 };
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);

const result = await authedFetch("/api/test");
expect(result).toBe(mockResponse);
});
});