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
182 changes: 182 additions & 0 deletions src/app/api/agent-runs/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockToken = "test-agent-token";
process.env.DISPATCH_AGENT_TOKEN = mockToken;

vi.mock("@/lib/dispatch-env", () => ({
isAuthorizedAgentToken: vi.fn((token) => token === mockToken),
isAuthorizedBearerToken: vi.fn((token) => token === mockToken),
getAcceptedAgentTokens: vi.fn(() => [mockToken]),
resetCaches: vi.fn(),
}));

const { mocks } = vi.hoisted(() => ({
mocks: {
agentRunFindMany: vi.fn().mockResolvedValue([]),
agentRunCreate: vi.fn().mockResolvedValue({ id: "run-1" }),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
agentRun: {
findMany: mocks.agentRunFindMany,
create: mocks.agentRunCreate,
},
},
}));

import { GET, POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";

function getRequest(urlString: string, includeAuth = true) {
const headers: Record<string, string> = {};
if (includeAuth) headers.Authorization = `Bearer ${mockToken}`;
return new Request(urlString, { headers });
}

function postRequest(body: unknown, includeAuth = true) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (includeAuth) headers.Authorization = `Bearer ${mockToken}`;
return POST(
new Request("http://localhost/api/agent-runs", {
method: "POST",
headers,
body: JSON.stringify(body),
}),
);
}

describe("GET /api/agent-runs", () => {
// NOTE: This route is intentionally unauthenticated. It returns agent run
// history to any caller. In production deployments behind a firewall or auth
// gateway this is acceptable; in open deployments consider adding auth.
beforeEach(() => {
vi.clearAllMocks();
mocks.agentRunFindMany.mockResolvedValue([]);
});

it("returns agent runs without authentication", async () => {
mocks.agentRunFindMany.mockResolvedValue([
{ id: "run-1", agentName: "saffron", status: "completed" },
]);

const res = await GET(getRequest("http://localhost/api/agent-runs", false));

expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body[0]).toMatchObject({ id: "run-1" });
});

it("defaults to limit 50", async () => {
await GET(getRequest("http://localhost/api/agent-runs"));

const call = mocks.agentRunFindMany.mock.calls[0][0];
expect(call.take).toBe(50);
});

it("respects custom limit parameter", async () => {
await GET(getRequest("http://localhost/api/agent-runs?limit=10"));

const call = mocks.agentRunFindMany.mock.calls[0][0];
expect(call.take).toBe(10);
});

it("orders by createdAt descending", async () => {
await GET(getRequest("http://localhost/api/agent-runs"));

const call = mocks.agentRunFindMany.mock.calls[0][0];
expect(call.orderBy).toEqual({ createdAt: "desc" });
});

it("returns 500 on database error", async () => {
mocks.agentRunFindMany.mockRejectedValue(new Error("db connection lost"));

const res = await GET(getRequest("http://localhost/api/agent-runs"));

expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("Failed to fetch agent runs");
});
});

describe("POST /api/agent-runs", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
resetAuthCaches();
vi.clearAllMocks();
mocks.agentRunCreate.mockResolvedValue({
id: "run-1",
agentName: "saffron",
runType: "implement",
status: "completed",
startedAt: new Date(),
finishedAt: null,
summary: null,
errorMessage: null,
touchedIssueUrls: [],
issueId: null,
outcome: null,
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({ agentName: "saffron", runType: "implement", status: "completed", startedAt: new Date().toISOString() }, false);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});

it("returns 401 for bad bearer token", async () => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: "Bearer wrong-token",
};
const res = await POST(
new Request("http://localhost/api/agent-runs", {
method: "POST",
headers,
body: JSON.stringify({ agentName: "saffron", runType: "implement", status: "completed", startedAt: new Date().toISOString() }),
}),
);

expect(res.status).toBe(401);
});

it("returns 400 when required fields are missing", async () => {
const res = await postRequest({});

expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("Missing required fields");
});

it("creates agent run on success", async () => {
const res = await postRequest({
agentName: "saffron",
runType: "implement",
status: "completed",
startedAt: new Date().toISOString(),
});

expect(res.status).toBe(201);
expect(mocks.agentRunCreate).toHaveBeenCalled();
});

it("returns 500 on database error", async () => {
mocks.agentRunCreate.mockRejectedValue(new Error("db connection lost"));

const res = await postRequest({
agentName: "saffron",
runType: "implement",
status: "completed",
startedAt: new Date().toISOString(),
});

expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("Failed to create agent run");
});
});
107 changes: 107 additions & 0 deletions src/app/api/audit/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockToken = "test-agent-token";
process.env.DISPATCH_AGENT_TOKEN = mockToken;

vi.mock("@/lib/dispatch-env", () => ({
isAuthorizedAgentToken: vi.fn((token) => token === mockToken),
isAuthorizedBearerToken: vi.fn((token) => token === mockToken),
getAcceptedAgentTokens: vi.fn(() => [mockToken]),
resetCaches: vi.fn(),
}));

const { mocks } = vi.hoisted(() => ({
mocks: {
auditLogFindMany: vi.fn().mockResolvedValue([]),
},
}));

vi.mock("@/lib/prisma", () => ({
prisma: {
auditLog: { findMany: mocks.auditLogFindMany },
},
}));

import { GET } from "./route";

function request(urlString: string) {
return new Request(urlString, { headers: {} });
}

describe("GET /api/audit", () => {
// NOTE: This route is intentionally unauthenticated. It returns all AuditLog
// rows to any caller. In production deployments behind a firewall or auth
// gateway this is acceptable; in open deployments consider adding auth.
beforeEach(() => {
vi.clearAllMocks();
mocks.auditLogFindMany.mockResolvedValue([]);
});

it("returns audit logs without authentication", async () => {
mocks.auditLogFindMany.mockResolvedValue([
{ id: "log-1", actor: "agent", action: "move_issue", createdAt: new Date() },
]);

const res = await GET(request("http://localhost/api/audit"));

expect(res.status).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body[0]).toMatchObject({ id: "log-1" });
});

it("returns empty array when no logs exist", async () => {
mocks.auditLogFindMany.mockResolvedValue([]);

const res = await GET(request("http://localhost/api/audit"));

expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
});

it("defaults to limit 50", async () => {
await GET(request("http://localhost/api/audit"));

const call = mocks.auditLogFindMany.mock.calls[0][0];
expect(call.take).toBe(50);
});

it("respects custom limit parameter", async () => {
await GET(request("http://localhost/api/audit?limit=10"));

const call = mocks.auditLogFindMany.mock.calls[0][0];
expect(call.take).toBe(10);
});

it("filters by repo when repo param is provided", async () => {
await GET(request("http://localhost/api/audit?repo=org/repo"));

const call = mocks.auditLogFindMany.mock.calls[0][0];
expect(call.where).toMatchObject({ repoFullName: "org/repo" });
});

it("orders by createdAt descending", async () => {
await GET(request("http://localhost/api/audit"));

const call = mocks.auditLogFindMany.mock.calls[0][0];
expect(call.orderBy).toEqual({ createdAt: "desc" });
});

it("includes issue and repository relations", async () => {
await GET(request("http://localhost/api/audit"));

const call = mocks.auditLogFindMany.mock.calls[0][0];
expect(call.include).toEqual({ issue: { include: { repository: true } } });
});

it("returns 500 on database error", async () => {
mocks.auditLogFindMany.mockRejectedValue(new Error("db connection lost"));

const res = await GET(request("http://localhost/api/audit"));

expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("Failed to fetch audit logs");
});
});
45 changes: 45 additions & 0 deletions src/app/api/auth/logout/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockToken = "test-agent-token";
process.env.DISPATCH_AGENT_TOKEN = mockToken;

vi.mock("@/lib/dispatch-env", () => ({
isAuthorizedAgentToken: vi.fn((token) => token === mockToken),
isAuthorizedBearerToken: vi.fn((token) => token === mockToken),
getAcceptedAgentTokens: vi.fn(() => [mockToken]),
resetCaches: vi.fn(),
}));

const { mocks } = vi.hoisted(() => ({
mocks: {
signOut: vi.fn().mockResolvedValue(undefined),
},
}));

vi.mock("@/lib/auth-next", () => ({
signOut: mocks.signOut,
}));

import { POST } from "./route";

describe("POST /api/auth/logout", () => {
// NOTE: This route is intentionally unauthenticated. It clears the session
// and is designed to be called by logged-in users to end their session.
beforeEach(() => {
vi.clearAllMocks();
});

it("calls signOut without redirect", async () => {
const res = await POST();

expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
});

it("passes redirect: false to signOut", async () => {
await POST();

expect(mocks.signOut).toHaveBeenCalledWith({ redirect: false });
});
});
Loading