Files
projectbrain-Public/backend/test/finding-reconciliation.test.ts

257 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const repositoryFinding = { findUnique: vi.fn(), findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), create: vi.fn() };
const findingOccurrence = { upsert: vi.fn() };
const giteaIssueLink = { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn() };
const outboxEvent = { create: vi.fn() };
const findingAutomationPolicy = { upsert: vi.fn() };
const tx = { repositoryFinding, findingOccurrence, giteaIssueLink, outboxEvent };
const transaction = vi.fn(async (operation: unknown) => typeof operation === "function" ? operation(tx) : Promise.all(operation as Promise<unknown>[]));
const issueClient = { createIssueComment: vi.fn(), editIssue: vi.fn() };
const analysisJobItem = { findMany: vi.fn() };
const findingInvestigation = { findMany: vi.fn(), update: vi.fn() };
return { repositoryFinding, findingOccurrence, giteaIssueLink, outboxEvent, findingAutomationPolicy, transaction, issueClient, analysisJobItem, findingInvestigation };
});
vi.mock("../src/db/prisma.js", () => ({ prisma: {
repositoryFinding: mocks.repositoryFinding,
findingOccurrence: mocks.findingOccurrence,
giteaIssueLink: mocks.giteaIssueLink,
outboxEvent: mocks.outboxEvent,
findingAutomationPolicy: mocks.findingAutomationPolicy,
$transaction: mocks.transaction,
analysisJobItem: mocks.analysisJobItem,
findingInvestigation: mocks.findingInvestigation,
} }));
vi.mock("../src/integrations/gitea/service.js", () => ({ getGiteaIssueClient: () => mocks.issueClient }));
import { loadEnv } from "../src/config/env.js";
import { closeAutomaticallyResolvedFindingIssues, finalizeFindingVerificationForJob, reconcileFindingIssueWebhook, reconcileRepositoryFindings } from "../src/repositories/findingRepository.js";
const env = loadEnv({
NODE_ENV: "test",
DATABASE_URL: "postgresql://example.invalid/projectbrain",
PROJECTBRAIN_DATA_SOURCE: "database",
PROJECTBRAIN_OWNER_USERNAME: "owner",
PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple",
PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters",
PROJECTBRAIN_GITEA_URL: "https://gitea.example.com",
PROJECTBRAIN_GITEA_TOKEN: "read-token-with-at-least-twenty-characters",
PROJECTBRAIN_GITEA_WRITE_TOKEN: "write-token-with-at-least-twenty-characters",
});
const legacyFinding = {
id: "finding-legacy",
fingerprint: "legacy-fingerprint",
projectId: "project-1",
repositoryLinkId: "link-1",
owner: "Acme",
repository: "Repo",
ruleCode: "LATEST_ACTION_FAILED",
subject: "repository",
title: "Legacy",
message: "Legacy action failure",
severity: "critical",
confidence: 95,
riskScore: 90,
status: "issue_open",
occurrenceCount: 10,
clearCount: 0,
firstSeenAt: new Date("2026-08-20T00:00:00Z"),
lastSeenAt: new Date("2026-08-27T00:00:00Z"),
resolvedAt: null,
suppressedAt: null,
suppressedUntil: null,
suppressionReason: null,
lastAnalysisJobId: "job-old",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.findingOccurrence.upsert.mockResolvedValue({ id: "occurrence-1" });
mocks.giteaIssueLink.upsert.mockResolvedValue({ id: "issue-link-1" });
mocks.outboxEvent.create.mockResolvedValue({ id: "outbox-1" });
});
describe("finding reconciliation lifecycle", () => {
it.each(["DEFAULT_BRANCH_ACTION_FAILED", "DEFAULT_BRANCH_UNPROTECTED", "OPEN_PR_BACKLOG", "TOPIC_BRANCH_ACTION_FAILED"])("does not clear an unevaluated %s finding", async (ruleCode) => {
mocks.repositoryFinding.findMany.mockResolvedValue([{ ...legacyFinding, ruleCode, clearCount: 1 }]);
for (const evaluatedRuleCodes of [undefined, ["REPOSITORY_ARCHIVED"]]) {
await reconcileRepositoryFindings({ jobId: "partial-job", analysisJobItemId: "partial-item",
owner: "Acme", repository: "Repo", defaultBranch: "main", findings: [], evaluatedRuleCodes });
}
expect(mocks.repositoryFinding.update).not.toHaveBeenCalled();
expect(mocks.findingOccurrence.upsert).not.toHaveBeenCalled();
});
it("still resolves after two distinct explicitly evaluated clean scans", async () => {
const current = { ...legacyFinding, ruleCode: "DEFAULT_BRANCH_ACTION_FAILED" };
mocks.repositoryFinding.findMany.mockImplementation(async () => [current]);
mocks.repositoryFinding.update.mockImplementation(async ({ data }) => Object.assign(current, data));
for (const jobId of ["clean-one", "clean-two"]) {
await reconcileRepositoryFindings({ jobId, analysisJobItemId: jobId, owner: "Acme", repository: "Repo",
defaultBranch: "main", findings: [], evaluatedRuleCodes: [current.ruleCode] });
expect(current.clearCount).toBe(jobId === "clean-one" ? 1 : 2);
expect(current.status).toBe(jobId === "clean-one" ? "issue_open" : "resolved");
}
expect(mocks.findingOccurrence.upsert).toHaveBeenCalledTimes(2);
});
it.each(["failed", "pending", "running", "cancelled"])("never verifies a %s analysis item even with a clean occurrence", async (status) => {
mocks.analysisJobItem.findMany.mockResolvedValue([{ status, findingOccurrences: [{ findingId: "finding-legacy", observed: false }] }]);
expect(await finalizeFindingVerificationForJob(env, "job")).toEqual([]);
expect(mocks.findingInvestigation.findMany).not.toHaveBeenCalled();
expect(mocks.repositoryFinding.update).not.toHaveBeenCalled();
});
it.each([false, true])("requires an explicit clean occurrence for the exact finding (present: %s)", async (present) => {
mocks.analysisJobItem.findMany.mockResolvedValue([{ status: "succeeded", owner: "Acme", repository: "Repo",
findingOccurrences: [{ findingId: present ? "finding-legacy" : "other-finding", observed: false }] }]);
mocks.findingInvestigation.findMany.mockResolvedValue([{ id: "investigation", findingId: "finding-legacy",
finding: { projectId: null, issueLink: null }, deliveryPlan: null }]);
mocks.repositoryFinding.update.mockResolvedValue({});
const result = await finalizeFindingVerificationForJob(env, "job");
expect(result).toEqual(present ? [{ findingId: "finding-legacy", resolved: true }] : []);
expect(mocks.repositoryFinding.update).toHaveBeenCalledTimes(present ? 1 : 0);
});
it("migrates an active legacy critical in place and preserves its history and issue identity", async () => {
mocks.repositoryFinding.findUnique.mockResolvedValueOnce(null);
mocks.repositoryFinding.findFirst.mockResolvedValueOnce(legacyFinding);
mocks.repositoryFinding.update
.mockImplementationOnce(async ({ data }: { data: Record<string, unknown> }) => ({ ...legacyFinding, ...data }))
.mockImplementationOnce(async ({ data }: { data: Record<string, unknown> }) => ({ ...legacyFinding, ...data, id: legacyFinding.id }));
mocks.repositoryFinding.findMany.mockResolvedValueOnce([]);
const reconciled = await reconcileRepositoryFindings({
jobId: "job-new",
analysisJobItemId: "item-new",
projectId: "project-1",
repositoryLinkId: "link-1",
owner: "Acme",
repository: "Repo",
defaultBranch: "main",
findings: [{
severity: "critical",
code: "DEFAULT_BRANCH_ACTION_FAILED",
subject: "default:ci.yml",
message: "CI failed",
evidence: { scope: "default_branch", runUrl: "https://gitea.example/actions/runs/7", consecutiveFailures: 2 },
}],
});
expect(reconciled[0].id).toBe("finding-legacy");
expect(mocks.repositoryFinding.update.mock.calls[0][0]).toMatchObject({
where: { id: "finding-legacy" },
data: { ruleCode: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", status: "triaged" },
});
expect(mocks.repositoryFinding.update.mock.calls[1][0]).toMatchObject({
where: { id: "finding-legacy" },
data: { occurrenceCount: { increment: 1 }, lastAnalysisJobId: "job-new" },
});
expect(mocks.findingOccurrence.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { findingId_analysisJobItemId: { findingId: "finding-legacy", analysisJobItemId: "item-new" } },
}));
});
it("does not increment occurrence or clear counters twice when an item is retried in the same job", async () => {
const current = { ...legacyFinding, fingerprint: "current", ruleCode: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", lastAnalysisJobId: "job-current" };
mocks.repositoryFinding.findUnique.mockResolvedValueOnce(current);
mocks.repositoryFinding.update.mockImplementationOnce(async ({ data }: { data: Record<string, unknown> }) => ({ ...current, ...data }));
mocks.repositoryFinding.findMany.mockResolvedValueOnce([{ ...legacyFinding, id: "missing", lastAnalysisJobId: "job-current" }]);
await reconcileRepositoryFindings({
jobId: "job-current",
analysisJobItemId: "item-current",
owner: "Acme",
repository: "Repo",
defaultBranch: "main",
findings: [{ severity: "critical", code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", message: "CI failed" }],
});
expect(mocks.repositoryFinding.update).toHaveBeenCalledTimes(1);
expect(mocks.repositoryFinding.update.mock.calls[0][0].data.occurrenceCount).toBe(10);
});
it("closes only resolved linked issues whose project policy enables automatic closure", async () => {
const resolved = {
...legacyFinding,
status: "resolved",
resolvedAt: new Date(),
issueLink: { id: "issue-link-1", state: "open", issueNumber: 7 },
project: { findingAutomationPolicy: { autoCloseResolved: true } },
};
const detailed = {
...resolved,
fingerprint: "resolved-fingerprint",
repositoryLink: { accessMode: "read_write" },
investigations: [],
occurrences: [{ observed: true, detailJson: { evidence: { runUrl: "https://gitea.example/actions/runs/7" } }, createdAt: new Date() }],
};
mocks.repositoryFinding.findMany.mockResolvedValueOnce([resolved]);
mocks.repositoryFinding.findUnique.mockResolvedValue(detailed);
mocks.repositoryFinding.update.mockResolvedValue(detailed);
mocks.issueClient.createIssueComment.mockResolvedValue({ id: 1 });
mocks.issueClient.editIssue.mockResolvedValue({ id: 7, number: 7, title: "Finding", body: "body", state: "closed", html_url: "https://gitea.example/issues/7", labels: [], created_at: "2026-08-20T00:00:00Z", updated_at: "2026-08-28T00:00:00Z", closed_at: "2026-08-28T00:00:00Z" });
await expect(closeAutomaticallyResolvedFindingIssues(env)).resolves.toEqual(["finding-legacy"]);
expect(mocks.issueClient.createIssueComment).toHaveBeenCalledOnce();
expect(mocks.issueClient.editIssue).toHaveBeenCalledWith("Acme", "Repo", 7, { state: "closed" });
vi.clearAllMocks();
mocks.repositoryFinding.findMany.mockResolvedValueOnce([{ ...resolved, project: { findingAutomationPolicy: { autoCloseResolved: false } } }]);
await expect(closeAutomaticallyResolvedFindingIssues(env)).resolves.toEqual([]);
expect(mocks.issueClient.editIssue).not.toHaveBeenCalled();
});
it("keeps an automatically resolved finding resolved when the issue-close webhook arrives later", async () => {
mocks.giteaIssueLink.findUnique.mockResolvedValueOnce({
id: "issue-link-1",
findingId: "finding-legacy",
finding: { ...legacyFinding, status: "suppressed", resolvedAt: new Date("2026-08-28T00:00:00Z") },
});
mocks.giteaIssueLink.update.mockResolvedValueOnce({ id: "issue-link-1", state: "closed" });
mocks.repositoryFinding.update.mockResolvedValueOnce({ ...legacyFinding, status: "resolved", projectId: "project-1" });
await reconcileFindingIssueWebhook({
owner: "Acme",
repository: "Repo",
issueNumber: 7,
state: "closed",
title: "Finding",
url: "https://gitea.example/issues/7",
actor: "projectbrain",
});
expect(mocks.repositoryFinding.update).toHaveBeenCalledWith({
where: { id: "finding-legacy" },
data: { status: "resolved", suppressedAt: null, suppressedUntil: null, suppressionReason: null },
});
});
it("still suppresses a genuinely active finding when its issue is manually closed", async () => {
mocks.giteaIssueLink.findUnique.mockResolvedValueOnce({
id: "issue-link-1",
findingId: "finding-legacy",
finding: { ...legacyFinding, status: "issue_open", resolvedAt: null },
});
mocks.giteaIssueLink.update.mockResolvedValueOnce({ id: "issue-link-1", state: "closed" });
mocks.repositoryFinding.update.mockResolvedValueOnce({ ...legacyFinding, status: "suppressed", projectId: "project-1" });
await reconcileFindingIssueWebhook({
owner: "Acme",
repository: "Repo",
issueNumber: 7,
state: "closed",
title: "Finding",
url: "https://gitea.example/issues/7",
actor: "jens",
});
expect(mocks.repositoryFinding.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: "finding-legacy" },
data: expect.objectContaining({ status: "suppressed", suppressionReason: "Gitea issue handmatig gesloten door jens" }),
}));
});
});