257 lines
13 KiB
TypeScript
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" }),
|
|
}));
|
|
});
|
|
});
|