Patchpass V1
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
Reference in New Issue
Block a user