Files
SnapOtter/tests/unit/api/jobs/system-jobs.behavior.test.ts
T

196 lines
6.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
const getQueueMock = vi.hoisted(() => vi.fn());
const runSiemForwardMock = vi.hoisted(() => vi.fn());
const runAuditArchiveMock = vi.hoisted(() => vi.fn());
const dbExecuteMock = vi.hoisted(() => vi.fn());
const dbUpdateMock = vi.hoisted(() => vi.fn());
const storageReconciliationJobMock = vi.hoisted(() => vi.fn());
const gdprExportJobMock = vi.hoisted(() => vi.fn());
const evaluateAlertsMock = vi.hoisted(() => vi.fn());
async function loadSystemJobs(cleanupIntervalMinutes = 15) {
vi.resetModules();
getQueueMock.mockReset();
runSiemForwardMock.mockReset();
runAuditArchiveMock.mockReset();
dbExecuteMock.mockReset();
dbUpdateMock.mockReset();
storageReconciliationJobMock.mockReset();
gdprExportJobMock.mockReset();
evaluateAlertsMock.mockReset();
vi.doMock("drizzle-orm", () => ({
and: vi.fn(() => "and"),
eq: vi.fn(() => "eq"),
inArray: vi.fn(() => "inArray"),
isNotNull: vi.fn(() => "isNotNull"),
lt: vi.fn(() => "lt"),
sql: vi.fn(() => "sql"),
}));
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: {
CLEANUP_INTERVAL_MINUTES: cleanupIntervalMinutes,
JOBS_RETENTION_DAYS: 30,
AUDIT_RETENTION_DAYS: 90,
},
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
db: {
execute: dbExecuteMock.mockResolvedValue(undefined),
update: dbUpdateMock,
},
schema: {
jobs: {
id: "jobs.id",
},
},
}));
vi.doMock("../../../../apps/api/src/lib/cleanup.js", () => ({
getMaxAgeMs: vi.fn().mockResolvedValue(0),
}));
vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({
deletePrefix: vi.fn(),
listJobDirs: vi.fn().mockResolvedValue([]),
}));
vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({
getSettingNumber: vi.fn(),
}));
vi.doMock("../../../../apps/api/src/jobs/audit-archive.js", () => ({
runAuditArchive: runAuditArchiveMock,
}));
vi.doMock("../../../../apps/api/src/jobs/queues.js", () => ({
getQueue: getQueueMock,
}));
vi.doMock("../../../../apps/api/src/jobs/siem-forward.js", () => ({
runSiemForward: runSiemForwardMock,
}));
vi.doMock("../../../../apps/api/src/jobs/storage-reconciliation.js", () => ({
storageReconciliationJob: storageReconciliationJobMock,
}));
vi.doMock("../../../../apps/api/src/jobs/gdpr-export.js", () => ({
gdprExportJob: gdprExportJobMock,
}));
vi.doMock("../../../../apps/api/src/jobs/alert-evaluator.js", () => ({
evaluateAlerts: evaluateAlertsMock,
}));
return import("../../../../apps/api/src/jobs/system-jobs.js");
}
describe("system jobs behavior", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("decides expiry from local mtimes, S3 job rows, and rowless S3 directories", async () => {
const { decideExpiry } = await loadSystemJobs();
const cutoffMs = new Date("2026-06-29T12:00:00.000Z").getTime();
const rowsById = new Map([
[
"job-old",
{
createdAt: new Date("2026-06-20T12:00:00.000Z"),
completedAt: null,
},
],
[
"job-new",
{
createdAt: new Date("2026-06-20T12:00:00.000Z"),
completedAt: new Date("2026-06-29T12:01:00.000Z"),
},
],
]);
expect(decideExpiry({ key: "uploads/job-a", mtimeMs: cutoffMs - 1 }, cutoffMs, rowsById)).toBe(
"expired",
);
expect(decideExpiry({ key: "outputs/job-b", mtimeMs: cutoffMs }, cutoffMs, rowsById)).toBe(
"keep",
);
expect(decideExpiry({ key: "uploads/job-old", mtimeMs: 0 }, cutoffMs, rowsById)).toBe(
"expired",
);
expect(decideExpiry({ key: "outputs/job-new", mtimeMs: 0 }, cutoffMs, rowsById)).toBe("keep");
expect(decideExpiry({ key: "uploads/orphan", mtimeMs: 0 }, cutoffMs, rowsById)).toBe("skip");
});
it("schedules repeatable jobs and removes storage TTL scheduler when cleanup is disabled", async () => {
const queue = {
upsertJobScheduler: vi.fn().mockResolvedValue(undefined),
removeJobScheduler: vi.fn().mockResolvedValue(undefined),
};
const { SYSTEM_JOBS, scheduleSystemJobs } = await loadSystemJobs(0);
getQueueMock.mockReturnValue(queue);
await scheduleSystemJobs();
expect(queue.removeJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageTtl);
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.sessionPurge, {
every: 60 * 60_000,
});
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.retention, {
every: 6 * 60 * 60_000,
});
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.auditArchive, {
pattern: "0 2 1 * *",
});
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageReconciliation, {
pattern: "0 3 * * 0",
});
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.alertEvaluator, {
every: 60_000,
});
});
it("dispatches one-shot system jobs and updates GDPR export job rows", async () => {
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn(() => ({ where: updateWhere }));
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
dbUpdateMock.mockReturnValue({ set: updateSet });
runSiemForwardMock.mockResolvedValue({ forwarded: 2 });
gdprExportJobMock.mockResolvedValue({ outputRef: "outputs/export-job/gdpr-export.zip" });
storageReconciliationJobMock.mockResolvedValue(undefined);
evaluateAlertsMock.mockResolvedValue(undefined);
await expect(runSystemJob({ name: SYSTEM_JOBS.siemForward } as never)).resolves.toEqual({
forwarded: 2,
});
await expect(runSystemJob({ name: SYSTEM_JOBS.storageReconciliation } as never)).resolves.toBe(
undefined,
);
await expect(
runSystemJob({
name: SYSTEM_JOBS.gdprExport,
data: { userId: "user-1", jobId: "export-job" },
} as never),
).resolves.toEqual({ outputRef: "outputs/export-job/gdpr-export.zip" });
await expect(runSystemJob({ name: SYSTEM_JOBS.alertEvaluator } as never)).resolves.toBe(
undefined,
);
expect(gdprExportJobMock).toHaveBeenCalledWith("user-1", "export-job");
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
status: "completed",
outputRefs: ["outputs/export-job/gdpr-export.zip"],
}),
);
await expect(runSystemJob({ name: "system:unknown" } as never)).rejects.toThrow(
"Unknown system job: system:unknown",
);
});
});