Patchpass V1
Deploy / Build (push) Successful in 28s
Deploy / Test & Lint (push) Failing after 29s
Deploy / Build and Push Docker Image (push) Has been skipped

This commit is contained in:
Space-Banane
2026-07-18 20:14:44 +02:00
commit 7e05dd918c
101 changed files with 15183 additions and 0 deletions
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
canonicalize,
computeContentHash,
normalizeChanges,
normalizeDiff,
} from "../lib/ChangeNormalization";
describe("normalizeDiff", () => {
it("converts CRLF and lone CR to LF", () => {
expect(normalizeDiff("a\r\nb\rc")).toBe("a\nb\nc\n");
});
it("collapses trailing newlines to exactly one", () => {
expect(normalizeDiff("line\n\n\n")).toBe("line\n");
});
it("returns empty string for whitespace-only content", () => {
expect(normalizeDiff("\n\n")).toBe("");
});
});
describe("normalizeChanges", () => {
it("normalizes diff content and fills config/custom defaults", () => {
const out = normalizeChanges([
{ type: "unified_diff", path: "a.txt", content: "x\r\n" },
{ type: "config", path: "k", after: 5 },
{ type: "custom", label: "L" },
]);
expect(out[0]).toEqual({ type: "unified_diff", path: "a.txt", content: "x\n" });
expect(out[1]).toMatchObject({ type: "config", path: "k", before: null, after: 5, content_type: null });
expect(out[2]).toMatchObject({ type: "custom", label: "L", before: null, after: null });
});
it("preserves the submitted order of changes", () => {
const out = normalizeChanges([
{ type: "custom", label: "first" },
{ type: "custom", label: "second" },
]);
expect(out.map((c) => (c as { label: string }).label)).toEqual(["first", "second"]);
});
});
describe("canonicalize", () => {
it("is independent of key insertion order", () => {
expect(canonicalize({ a: 1, b: 2 })).toBe(canonicalize({ b: 2, a: 1 }));
});
});
describe("computeContentHash", () => {
const base = {
title: "T",
description: "D",
changes: [{ type: "config" as const, path: "k", before: 1, after: 2, content_type: "integer" }],
};
it("is stable across equivalent inputs (CRLF vs LF, key order)", () => {
const a = computeContentHash({
title: "T",
description: "D",
changes: [{ type: "unified_diff", path: "f", content: "a\r\nb\r\n" }],
});
const b = computeContentHash({
title: "T",
description: "D",
changes: [{ type: "unified_diff", path: "f", content: "a\nb" }],
});
expect(a).toBe(b);
});
it("changes when the title changes", () => {
expect(computeContentHash(base)).not.toBe(computeContentHash({ ...base, title: "T2" }));
});
it("changes when a change value changes", () => {
const mutated = {
...base,
changes: [{ type: "config" as const, path: "k", before: 1, after: 3, content_type: "integer" }],
};
expect(computeContentHash(base)).not.toBe(computeContentHash(mutated));
});
it("is order-sensitive across changes", () => {
const reordered = {
...base,
changes: [
{ type: "custom" as const, label: "x", before: 1, after: 2 },
{ type: "custom" as const, label: "y", before: 3, after: 4 },
],
};
const swapped = {
...base,
changes: [
{ type: "custom" as const, label: "y", before: 3, after: 4 },
{ type: "custom" as const, label: "x", before: 1, after: 2 },
],
};
expect(computeContentHash(reordered)).not.toBe(computeContentHash(swapped));
});
});
@@ -0,0 +1,189 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
cancelChangeRequest,
consumeApproval,
createChangeRequest,
decideChangeRequest,
getChangeRequestForAgent,
updateChangeRequest,
} from "../lib/ChangeRequestService";
import { verifyReceipt } from "../lib/Signing";
import { prisma } from "../lib/db";
import { createAgent, createUser, resetDb, sampleChanges } from "./helpers";
async function freshAgentAndOwner() {
const owner = await createUser();
const agent = await createAgent(owner.id);
return { owner, agent };
}
const input = { title: "Deploy", description: "d", changes: sampleChanges };
describe("Change request lifecycle", () => {
beforeEach(async () => {
await resetDb();
});
it("creates a PENDING request with a content hash and approval url", async () => {
const { agent } = await freshAgentAndOwner();
const res = await createChangeRequest(agent, input);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.data.state).toBe("PENDING");
expect(res.data.content_hash).toHaveLength(64);
expect(res.data.approval_url).toContain(res.data.request_id);
expect(res.data.receipt).toBeNull();
});
it("approve → signed receipt that verifies and binds to the content hash", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error("create failed");
const decided = await decideChangeRequest(owner, created.data.request_id, "APPROVE", "ok");
expect(decided.ok).toBe(true);
if (!decided.ok) return;
expect(decided.data.state).toBe("APPROVED");
const receipt = decided.data.receipt!;
expect(receipt).not.toBeNull();
expect(receipt.payload.content_hash).toBe(created.data.content_hash);
expect(verifyReceipt(receipt.payload, receipt.signature)).toBe(true);
});
it("consume marks CONSUMED once and refuses a second time", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await decideChangeRequest(owner, created.data.request_id, "APPROVE");
const first = await consumeApproval(agent, created.data.request_id);
expect(first.ok).toBe(true);
if (first.ok) expect(first.data.state).toBe("CONSUMED");
const second = await consumeApproval(agent, created.data.request_id);
expect(second.ok).toBe(false);
if (!second.ok) expect(second.status).toBe(409);
});
it("cannot consume a request that was not approved", async () => {
const { agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const res = await consumeApproval(agent, created.data.request_id);
expect(res.ok).toBe(false);
});
it("reject is a terminal blocker; no receipt consumption possible", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const rejected = await decideChangeRequest(owner, created.data.request_id, "REJECT", "no");
expect(rejected.ok).toBe(true);
if (rejected.ok) expect(rejected.data.state).toBe("REJECTED");
const consumed = await consumeApproval(agent, created.data.request_id);
expect(consumed.ok).toBe(false);
});
it("request_changes requires a comment and enables agent update loop", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const noComment = await decideChangeRequest(owner, created.data.request_id, "REQUEST_CHANGES");
expect(noComment.ok).toBe(false);
const withComment = await decideChangeRequest(
owner,
created.data.request_id,
"REQUEST_CHANGES",
"please revise",
);
expect(withComment.ok).toBe(true);
if (withComment.ok) expect(withComment.data.state).toBe("CHANGES_REQUESTED");
const updated = await updateChangeRequest(agent, created.data.request_id, {
title: "Deploy v2",
changes: [{ type: "custom", label: "Command", before: "a", after: "c" }],
});
expect(updated.ok).toBe(true);
if (!updated.ok) return;
expect(updated.data.state).toBe("PENDING");
expect(updated.data.resubmitted).toBe(true);
expect(updated.data.update_count).toBe(1);
// content hash must change with new content
expect(updated.data.content_hash).not.toBe(created.data.content_hash);
});
it("no update is possible after approval", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await decideChangeRequest(owner, created.data.request_id, "APPROVE");
const res = await updateChangeRequest(agent, created.data.request_id, input);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.status).toBe(409);
});
it("no decision is possible after a terminal state", async () => {
const { owner, agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await decideChangeRequest(owner, created.data.request_id, "APPROVE");
const again = await decideChangeRequest(owner, created.data.request_id, "REJECT");
expect(again.ok).toBe(false);
});
it("agent can cancel before a decision", async () => {
const { agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
const cancelled = await cancelChangeRequest(agent, created.data.request_id);
expect(cancelled.ok).toBe(true);
if (cancelled.ok) expect(cancelled.data.state).toBe("CANCELLED");
});
it("expired-on-read: a past-expiry PENDING request becomes EXPIRED when fetched", async () => {
const { agent } = await freshAgentAndOwner();
const created = await createChangeRequest(agent, input);
if (!created.ok) throw new Error();
await prisma.changeRequest.update({
where: { publicId: created.data.request_id },
data: { expiresAt: new Date(Date.now() - 1000) },
});
const fetched = await getChangeRequestForAgent(agent, created.data.request_id);
expect(fetched.ok).toBe(true);
if (fetched.ok) expect(fetched.data.state).toBe("EXPIRED");
});
});
describe("Authorization", () => {
beforeEach(async () => {
await resetDb();
});
it("an agent cannot read another agent's request", async () => {
const a = await freshAgentAndOwner();
const b = await freshAgentAndOwner();
const created = await createChangeRequest(a.agent, input);
if (!created.ok) throw new Error();
const res = await getChangeRequestForAgent(b.agent, created.data.request_id);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.status).toBe(404);
});
it("a human cannot decide a request they do not own", async () => {
const a = await freshAgentAndOwner();
const otherHuman = await createUser();
const created = await createChangeRequest(a.agent, input);
if (!created.ok) throw new Error();
const res = await decideChangeRequest(otherHuman, created.data.request_id, "APPROVE");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.status).toBe(404);
});
it("an agent cannot cancel another agent's request", async () => {
const a = await freshAgentAndOwner();
const b = await freshAgentAndOwner();
const created = await createChangeRequest(a.agent, input);
if (!created.ok) throw new Error();
const res = await cancelChangeRequest(b.agent, created.data.request_id);
expect(res.ok).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
clampAutoDeleteDays,
clampExpirySeconds,
clampPendingLimit,
checkAgentCountLimit,
checkAgentHourlyLimit,
checkAgentPendingLimit,
DEFAULT_EXPIRY_SECONDS,
MAX_AGENTS_PER_USER,
MAX_EXPIRY_SECONDS,
MAX_REQUESTS_PER_HOUR,
} from "../lib/Limits";
import { prisma } from "../lib/db";
import { createAgent, createUser, resetDb } from "./helpers";
describe("Limits — pure clamps", () => {
it("clamps pending limit to 1..10", () => {
expect(clampPendingLimit(0)).toBe(1);
expect(clampPendingLimit(5)).toBe(5);
expect(clampPendingLimit(99)).toBe(10);
expect(clampPendingLimit(NaN)).toBe(5);
});
it("clamps expiry to bounds and defaults sensibly", () => {
expect(clampExpirySeconds(undefined)).toBe(DEFAULT_EXPIRY_SECONDS);
expect(clampExpirySeconds(10)).toBe(60);
expect(clampExpirySeconds(99999999)).toBe(MAX_EXPIRY_SECONDS);
expect(clampExpirySeconds(3600)).toBe(3600);
});
it("clamps auto-delete days to a 7-day minimum", () => {
expect(clampAutoDeleteDays(1)).toBe(7);
expect(clampAutoDeleteDays(30)).toBe(30);
});
});
describe("Limits — DB-backed", () => {
beforeEach(async () => {
await resetDb();
});
it("blocks agent creation past the per-user cap", async () => {
const user = await createUser();
for (let i = 0; i < MAX_AGENTS_PER_USER; i++) await createAgent(user.id);
const result = await checkAgentCountLimit(user.id);
expect(result.allowed).toBe(false);
});
it("allows agent creation below the cap", async () => {
const user = await createUser();
await createAgent(user.id);
expect((await checkAgentCountLimit(user.id)).allowed).toBe(true);
});
it("enforces the hourly request limit", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
await prisma.changeRequest.create({
data: {
title: `r${i}`,
changes: [],
rawChanges: [],
contentHash: "h",
expiresAt: new Date(Date.now() + 60000),
agentId: agent.id,
userId: user.id,
},
});
}
expect((await checkAgentHourlyLimit(agent.id)).allowed).toBe(false);
});
it("does not count requests older than an hour toward the hourly limit", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
await prisma.changeRequest.create({
data: {
title: `r${i}`,
changes: [],
rawChanges: [],
contentHash: "h",
expiresAt: new Date(Date.now() + 60000),
agentId: agent.id,
userId: user.id,
createdAt: old,
},
});
}
expect((await checkAgentHourlyLimit(agent.id)).allowed).toBe(true);
});
it("enforces the pending limit only against PENDING requests", async () => {
const user = await createUser();
const agent = await createAgent(user.id, { maxPendingRequests: 2 });
const mk = (state: "PENDING" | "APPROVED") =>
prisma.changeRequest.create({
data: {
title: "r",
changes: [],
rawChanges: [],
contentHash: "h",
state,
expiresAt: new Date(Date.now() + 60000),
agentId: agent.id,
userId: user.id,
},
});
await mk("PENDING");
await mk("APPROVED"); // should not count
expect((await checkAgentPendingLimit(agent.id, 2)).allowed).toBe(true);
await mk("PENDING");
expect((await checkAgentPendingLimit(agent.id, 2)).allowed).toBe(false);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, beforeEach } from "vitest";
import { validateIconUrl } from "../lib/IconValidation";
import { createChangeRequest } from "../lib/ChangeRequestService";
import { setRequestsEnabled } from "../lib/DataManager";
import { normalizeUsername } from "../lib/Authentication";
import { createAgent, createUser, resetDb, sampleChanges } from "./helpers";
describe("validateIconUrl (non-network branches)", () => {
it("rejects a non-URL string", async () => {
const res = await validateIconUrl("not a url");
expect(res.valid).toBe(false);
});
it("rejects non-http protocols", async () => {
const res = await validateIconUrl("ftp://example.com/x.png");
expect(res.valid).toBe(false);
});
});
describe("normalizeUsername", () => {
it("lowercases and trims for case-insensitive uniqueness", () => {
expect(normalizeUsername(" Alice ")).toBe("alice");
expect(normalizeUsername("BOB")).toBe("bob");
});
});
describe("requests_enabled global gate", () => {
beforeEach(async () => {
await resetDb();
});
it("blocks request creation when disabled and allows when enabled", async () => {
const owner = await createUser();
const agent = await createAgent(owner.id);
await setRequestsEnabled(false);
const blocked = await createChangeRequest(agent, {
title: "t",
changes: sampleChanges,
});
expect(blocked.ok).toBe(false);
if (!blocked.ok) expect(blocked.status).toBe(403);
await setRequestsEnabled(true);
const allowed = await createChangeRequest(agent, { title: "t", changes: sampleChanges });
expect(allowed.ok).toBe(true);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { ReceiptPayload, signReceipt, verifyReceipt } from "../lib/Signing";
const base: ReceiptPayload = {
request_id: "req-123",
decision: "APPROVED",
content_hash: "abc123",
approver_id: 7,
decided_at: "2026-01-01T00:00:00.000Z",
};
describe("Signing", () => {
it("produces a deterministic signature for the same payload", () => {
expect(signReceipt(base)).toBe(signReceipt({ ...base }));
});
it("verifies a genuine signature", () => {
expect(verifyReceipt(base, signReceipt(base))).toBe(true);
});
it("rejects a tampered content hash", () => {
const sig = signReceipt(base);
expect(verifyReceipt({ ...base, content_hash: "different" }, sig)).toBe(false);
});
it("rejects a decision swapped from APPROVED to REJECTED", () => {
const sig = signReceipt(base);
expect(verifyReceipt({ ...base, decision: "REJECTED" }, sig)).toBe(false);
});
it("rejects a signature bound to a different request", () => {
const sig = signReceipt(base);
expect(verifyReceipt({ ...base, request_id: "other" }, sig)).toBe(false);
});
it("rejects a malformed signature without throwing", () => {
expect(verifyReceipt(base, "not-hex")).toBe(false);
expect(verifyReceipt(base, "")).toBe(false);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it, beforeEach } from "vitest";
import { autoDeleteRequests, expireRequests } from "../lib/SystemCrons";
import { prisma } from "../lib/db";
import { createAgent, createUser, resetDb } from "./helpers";
async function makeRequest(
agentId: number,
userId: number,
opts: { state?: any; expiresAt?: Date; createdAt?: Date } = {},
) {
return prisma.changeRequest.create({
data: {
title: "r",
changes: [],
rawChanges: [],
contentHash: "h",
state: opts.state ?? "PENDING",
expiresAt: opts.expiresAt ?? new Date(Date.now() + 60000),
agentId,
userId,
...(opts.createdAt ? { createdAt: opts.createdAt } : {}),
},
});
}
describe("expireRequests cron", () => {
beforeEach(async () => {
await resetDb();
});
it("expires only open requests past their deadline", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
const past = new Date(Date.now() - 1000);
const overduePending = await makeRequest(agent.id, user.id, { state: "PENDING", expiresAt: past });
const overdueChanges = await makeRequest(agent.id, user.id, {
state: "CHANGES_REQUESTED",
expiresAt: past,
});
const futurePending = await makeRequest(agent.id, user.id, { state: "PENDING" });
const approvedPast = await makeRequest(agent.id, user.id, { state: "APPROVED", expiresAt: past });
const count = await expireRequests({ prisma });
expect(count).toBe(2);
expect((await prisma.changeRequest.findUnique({ where: { id: overduePending.id } }))!.state).toBe(
"EXPIRED",
);
expect((await prisma.changeRequest.findUnique({ where: { id: overdueChanges.id } }))!.state).toBe(
"EXPIRED",
);
expect((await prisma.changeRequest.findUnique({ where: { id: futurePending.id } }))!.state).toBe(
"PENDING",
);
// Terminal states are never touched by the expiry sweep.
expect((await prisma.changeRequest.findUnique({ where: { id: approvedPast.id } }))!.state).toBe(
"APPROVED",
);
});
it("creates a notification per expired request", async () => {
const user = await createUser();
const agent = await createAgent(user.id);
await makeRequest(agent.id, user.id, { state: "PENDING", expiresAt: new Date(Date.now() - 1000) });
await expireRequests({ prisma });
const notifs = await prisma.notification.findMany({ where: { userId: user.id } });
expect(notifs.some((n) => n.type === "REQUEST_EXPIRED")).toBe(true);
});
});
describe("autoDeleteRequests cron", () => {
beforeEach(async () => {
await resetDb();
});
it("deletes old requests only for opted-in users, respecting the window", async () => {
const optedIn = await prisma.user.update({
where: { id: (await createUser()).id },
data: { autoDeleteEnabled: true, autoDeleteDays: 7 },
});
const optedOut = await createUser();
const agentIn = await createAgent(optedIn.id);
const agentOut = await createAgent(optedOut.id);
const old = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
const recent = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000);
const oldForOptedIn = await makeRequest(agentIn.id, optedIn.id, {
state: "CONSUMED",
createdAt: old,
});
const recentForOptedIn = await makeRequest(agentIn.id, optedIn.id, {
state: "CONSUMED",
createdAt: recent,
});
const oldForOptedOut = await makeRequest(agentOut.id, optedOut.id, {
state: "CONSUMED",
createdAt: old,
});
const deleted = await autoDeleteRequests({ prisma });
expect(deleted).toBe(1);
expect(await prisma.changeRequest.findUnique({ where: { id: oldForOptedIn.id } })).toBeNull();
expect(await prisma.changeRequest.findUnique({ where: { id: recentForOptedIn.id } })).not.toBeNull();
expect(await prisma.changeRequest.findUnique({ where: { id: oldForOptedOut.id } })).not.toBeNull();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { execSync } from "child_process";
import { join } from "path";
/**
* Vitest global setup — bring the test database schema up to date via Prisma
* migrations before any test runs. Runs once for the whole suite.
*/
export default function setup() {
const backendRoot = join(__dirname, "../..");
const dbUrl =
process.env.TEST_DATABASE_URL ||
"postgresql://patchpass:patchpass@localhost:5433/patchpass_test";
execSync("npx prisma migrate deploy", {
cwd: backendRoot,
env: { ...process.env, DATABASE_URL: dbUrl },
stdio: "inherit",
});
}
+47
View File
@@ -0,0 +1,47 @@
import { prisma } from "../lib/db";
import { generateAgentApiKey } from "../lib/Authentication";
/** Wipe all data between tests. Order respects FK constraints (cascade handles rest). */
export async function resetDb() {
await prisma.auditLog.deleteMany();
await prisma.notification.deleteMany();
await prisma.changeRequest.deleteMany();
await prisma.agent.deleteMany();
await prisma.session.deleteMany();
await prisma.user.deleteMany();
await prisma.globalSetting.deleteMany();
}
let userCounter = 0;
export async function createUser(overrides: Partial<{ username: string; role: "ADMIN" | "USER" }> = {}) {
userCounter += 1;
const username = overrides.username ?? `user${userCounter}_${Date.now()}`;
return prisma.user.create({
data: {
username,
displayName: username,
password: "x", // not exercised in service-level tests
role: overrides.role ?? "USER",
},
});
}
export async function createAgent(
ownerId: number,
overrides: Partial<{ name: string; maxPendingRequests: number; disabled: boolean }> = {},
) {
return prisma.agent.create({
data: {
name: overrides.name ?? "Test Agent",
apiKey: generateAgentApiKey(),
ownerId,
maxPendingRequests: overrides.maxPendingRequests ?? 5,
disabled: overrides.disabled ?? false,
},
});
}
export const sampleChanges = [
{ type: "custom" as const, label: "Command", before: "a", after: "b" },
];