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[])); 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 }) => ({ ...legacyFinding, ...data })) .mockImplementationOnce(async ({ data }: { data: Record }) => ({ ...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 }) => ({ ...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" }), })); }); });