test: coverage campaign and mutation testing across five packages (#628)

Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
This commit is contained in:
SnapOtter
2026-07-24 17:36:57 +08:00
committed by GitHub
parent eee6f0d470
commit 301e6eb01a
129 changed files with 30900 additions and 276 deletions
@@ -17,7 +17,8 @@ function queryChain<T>(result: T) {
return chain;
}
async function loadAlertEvaluator() {
async function loadAlertEvaluator(options: { dataEncryptionKey?: string } = {}) {
const { dataEncryptionKey = "test-key" } = options;
vi.resetModules();
statfsMock.mockReset();
getSettingStringMock.mockReset();
@@ -43,7 +44,7 @@ async function loadAlertEvaluator() {
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: {
WORKSPACE_PATH: "/workspace",
DATA_ENCRYPTION_KEY: "test-key",
DATA_ENCRYPTION_KEY: dataEncryptionKey,
},
}));
@@ -165,4 +166,303 @@ describe("alert evaluator behavior", () => {
{ maxRetries: 1 },
);
});
it("treats a thrown isFeatureEnabled as unlicensed and returns early", async () => {
const { evaluateAlerts } = await loadAlertEvaluator();
isFeatureEnabledMock.mockImplementation(() => {
throw new Error("license subsystem exploded");
});
await expect(evaluateAlerts()).resolves.toBeUndefined();
expect(getSettingStringMock).not.toHaveBeenCalled();
expect(statfsMock).not.toHaveBeenCalled();
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("does not deliver when every condition is healthy", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
// webhook_destinations: one enabled alerts destination
.mockResolvedValueOnce(
JSON.stringify([
{ url: "https://example.test/alerts", authHeader: "", enabled: true, type: "alerts" },
]),
)
// backup_last_completed: fresh backup, 1 hour ago
.mockResolvedValueOnce(JSON.stringify({ timestamp: "2026-06-29T11:00:00.000Z" }));
// plenty of free disk (10 GB), so freeGb >= 1
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
// below the auth-anomaly threshold
selectMock.mockReturnValue(queryChain([{ count: 5 }]));
// license valid for well over 30 days
getActiveLicenseMock.mockReturnValue({ expiresAt: "2027-01-01T00:00:00.000Z" });
await evaluateAlerts();
// All checks ran, but nothing tripped, so no webhook delivery.
expect(statfsMock).toHaveBeenCalledTimes(1);
expect(selectMock).toHaveBeenCalledTimes(1);
expect(getActiveLicenseMock).toHaveBeenCalledTimes(1);
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("emits backup_never_run when no backup has ever completed", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{ url: "https://example.test/alerts", authHeader: "", enabled: true, type: "alerts" },
]),
)
// backup_last_completed empty -> never run
.mockResolvedValueOnce("");
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
getActiveLicenseMock.mockReturnValue(null);
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
expect(deliverWebhookMock).toHaveBeenCalledTimes(1);
const [, , alertsArg] = deliverWebhookMock.mock.calls[0];
expect(alertsArg).toEqual([{ condition: "backup_never_run" }]);
});
it("swallows a malformed backup timestamp payload and skips the backup alert", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{ url: "https://example.test/alerts", authHeader: "", enabled: true, type: "alerts" },
]),
)
// backup_last_completed is present but not valid JSON -> JSON.parse throws, caught
.mockResolvedValueOnce("{not-json");
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
getActiveLicenseMock.mockReturnValue(null);
await evaluateAlerts();
// Only trip was a parse error which is swallowed; nothing else fired, so no delivery.
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("skips checks that throw and still delivers the surviving alert", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{ url: "https://example.test/alerts", authHeader: "", enabled: true, type: "alerts" },
]),
)
// backup_last_completed empty -> backup_never_run is the surviving alert
.mockResolvedValueOnce("");
// statfs rejects -> disk check swallowed
statfsMock.mockRejectedValue(new Error("statfs unsupported"));
// empty result set -> recentFailures[0].count throws -> auth check swallowed
selectMock.mockReturnValue(queryChain([]));
// license lookup throws -> license check swallowed
getActiveLicenseMock.mockImplementation(() => {
throw new Error("enterprise unavailable");
});
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
expect(deliverWebhookMock).toHaveBeenCalledTimes(1);
const [, , alertsArg] = deliverWebhookMock.mock.calls[0];
expect(alertsArg).toEqual([{ condition: "backup_never_run" }]);
});
it("does not alert on license when there is no active license or no expiry", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{ url: "https://example.test/alerts", authHeader: "", enabled: true, type: "alerts" },
]),
)
.mockResolvedValueOnce(JSON.stringify({ timestamp: "2026-06-29T11:00:00.000Z" }));
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
// license present but without an expiresAt field
getActiveLicenseMock.mockReturnValue({});
await evaluateAlerts();
expect(getActiveLicenseMock).toHaveBeenCalledTimes(1);
expect(deliverWebhookMock).not.toHaveBeenCalled();
});
it("delivers a raw auth header when it is not encrypted", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{
url: "https://example.test/alerts",
authHeader: "Bearer plaintext",
enabled: true,
type: "alerts",
},
]),
)
.mockResolvedValueOnce("");
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
getActiveLicenseMock.mockReturnValue(null);
isEncryptedMock.mockReturnValue(false);
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
expect(decryptMock).not.toHaveBeenCalled();
expect(deliverWebhookMock).toHaveBeenCalledWith(
"https://example.test/alerts",
"Bearer plaintext",
[{ condition: "backup_never_run" }],
{ maxRetries: 1 },
);
});
it("falls back to an empty auth header when decryption returns null", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{
url: "https://example.test/alerts",
authHeader: "$ENC$blob",
enabled: true,
type: "alerts",
},
]),
)
.mockResolvedValueOnce("");
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
getActiveLicenseMock.mockReturnValue(null);
isEncryptedMock.mockReturnValue(true);
// decrypt resolves to null -> `decrypted ?? ""` yields ""
decryptMock.mockResolvedValue(null);
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
expect(decryptMock).toHaveBeenCalledTimes(1);
expect(deliverWebhookMock).toHaveBeenCalledWith(
"https://example.test/alerts",
"",
[{ condition: "backup_never_run" }],
{ maxRetries: 1 },
);
});
it("keeps the raw auth header when decryption throws", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{
url: "https://example.test/alerts",
authHeader: "$ENC$blob",
enabled: true,
type: "alerts",
},
]),
)
.mockResolvedValueOnce("");
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
getActiveLicenseMock.mockReturnValue(null);
isEncryptedMock.mockReturnValue(true);
decryptMock.mockRejectedValue(new Error("bad ciphertext"));
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
expect(deliverWebhookMock).toHaveBeenCalledWith(
"https://example.test/alerts",
"$ENC$blob",
[{ condition: "backup_never_run" }],
{ maxRetries: 1 },
);
});
it("does not decrypt an encrypted header when DATA_ENCRYPTION_KEY is unset", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator({ dataEncryptionKey: "" });
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{
url: "https://example.test/alerts",
authHeader: "$ENC$blob",
enabled: true,
type: "alerts",
},
]),
)
.mockResolvedValueOnce("");
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
getActiveLicenseMock.mockReturnValue(null);
isEncryptedMock.mockReturnValue(true);
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
// isEncrypted is true but the key guard is false, so decrypt is never reached.
expect(decryptMock).not.toHaveBeenCalled();
expect(deliverWebhookMock).toHaveBeenCalledWith(
"https://example.test/alerts",
"$ENC$blob",
[{ condition: "backup_never_run" }],
{ maxRetries: 1 },
);
});
it("delivers to every enabled alerts destination", async () => {
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime());
const { evaluateAlerts } = await loadAlertEvaluator();
getSettingStringMock
.mockResolvedValueOnce(
JSON.stringify([
{ url: "https://example.test/a", authHeader: "", enabled: true, type: "alerts" },
{ url: "https://example.test/b", authHeader: "", enabled: true, type: "alerts" },
{ url: "https://example.test/off", authHeader: "", enabled: false, type: "alerts" },
]),
)
.mockResolvedValueOnce("");
statfsMock.mockResolvedValue({ bfree: 10 * 1024 * 1024, bsize: 1024 });
selectMock.mockReturnValue(queryChain([{ count: 0 }]));
getActiveLicenseMock.mockReturnValue(null);
deliverWebhookMock.mockResolvedValue({ success: true });
await evaluateAlerts();
expect(deliverWebhookMock).toHaveBeenCalledTimes(2);
const urls = deliverWebhookMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual(["https://example.test/a", "https://example.test/b"]);
});
});
@@ -5,6 +5,9 @@ const selectMock = vi.hoisted(() => vi.fn());
const deleteMock = vi.hoisted(() => vi.fn());
const upsertSettingMock = vi.hoisted(() => vi.fn());
const mkdirMock = vi.hoisted(() => vi.fn());
const statMock = vi.hoisted(() => vi.fn());
const pipelineMock = vi.hoisted(() => vi.fn());
const createWriteStreamMock = vi.hoisted(() => vi.fn());
function queryChain<T>(result: T) {
const chain = {
@@ -14,6 +17,17 @@ function queryChain<T>(result: T) {
return chain;
}
function makeLogger() {
return {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
};
}
async function loadAuditArchive() {
vi.resetModules();
isFeatureEnabledMock.mockReset();
@@ -21,18 +35,28 @@ async function loadAuditArchive() {
deleteMock.mockReset();
upsertSettingMock.mockReset();
mkdirMock.mockReset();
statMock.mockReset();
pipelineMock.mockReset();
createWriteStreamMock.mockReset();
// Sensible defaults for the write path: mkdir/pipeline resolve, stat resolves.
mkdirMock.mockResolvedValue(undefined);
pipelineMock.mockResolvedValue(undefined);
statMock.mockResolvedValue({ size: 123 });
createWriteStreamMock.mockReturnValue({});
upsertSettingMock.mockResolvedValue(undefined);
vi.doMock("node:fs/promises", () => ({
mkdir: mkdirMock,
stat: vi.fn(),
stat: statMock,
}));
vi.doMock("node:fs", () => ({
createWriteStream: vi.fn(),
createWriteStream: createWriteStreamMock,
}));
vi.doMock("node:stream/promises", () => ({
pipeline: vi.fn(),
pipeline: pipelineMock,
}));
vi.doMock("drizzle-orm", () => ({
@@ -86,6 +110,34 @@ describe("audit archive job behavior", () => {
expect(upsertSettingMock).not.toHaveBeenCalled();
});
it("treats a thrown enterprise check as unlicensed and returns early", async () => {
const { runAuditArchive } = await loadAuditArchive();
isFeatureEnabledMock.mockImplementation(() => {
throw new Error("license subsystem exploded");
});
await runAuditArchive();
// The catch keeps featureEnabled false, so no settings are ever read.
expect(selectMock).not.toHaveBeenCalled();
expect(upsertSettingMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
});
it("returns when the auditArchiveMonths setting is absent (null value)", async () => {
const { runAuditArchive } = await loadAuditArchive();
isFeatureEnabledMock.mockReturnValue(true);
// readSettingValue returns null when there is no row.
selectMock.mockReturnValueOnce(queryChain([]));
await runAuditArchive();
// Only the archiveMonths read happened; state was never touched.
expect(selectMock).toHaveBeenCalledTimes(1);
expect(upsertSettingMock).not.toHaveBeenCalled();
expect(mkdirMock).not.toHaveBeenCalled();
});
it("returns when archive months is missing or disabled", async () => {
const { runAuditArchive } = await loadAuditArchive();
isFeatureEnabledMock.mockReturnValue(true);
@@ -97,6 +149,17 @@ describe("audit archive job behavior", () => {
expect(upsertSettingMock).not.toHaveBeenCalled();
});
it("returns when archive months parses to a negative number", async () => {
const { runAuditArchive } = await loadAuditArchive();
isFeatureEnabledMock.mockReturnValue(true);
selectMock.mockReturnValueOnce(queryChain([{ value: "-3" }]));
await runAuditArchive();
expect(mkdirMock).not.toHaveBeenCalled();
expect(upsertSettingMock).not.toHaveBeenCalled();
});
it("clears archival state when there are no rows older than the boundary", async () => {
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const { runAuditArchive } = await loadAuditArchive();
@@ -107,7 +170,8 @@ describe("audit archive job behavior", () => {
.mockReturnValueOnce(queryChain([]))
.mockReturnValueOnce(queryChain([]));
await runAuditArchive();
const log = makeLogger();
await runAuditArchive(log as never);
expect(upsertSettingMock).toHaveBeenCalledWith(
"audit_archival_state",
@@ -115,5 +179,320 @@ describe("audit archive job behavior", () => {
);
expect(mkdirMock).toHaveBeenCalledWith("/data/audit-archives", { recursive: true });
expect(deleteWhere).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith("No audit rows older than boundary, nothing to archive");
});
it("runs a full fresh archive: export, verify, purge, and complete", async () => {
const deleteWhere = vi.fn().mockResolvedValue({ rowCount: 2 });
const { runAuditArchive } = await loadAuditArchive();
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
selectMock
// auditArchiveMonths
.mockReturnValueOnce(queryChain([{ value: "6" }]))
// STATE_KEY (fresh, no existing state)
.mockReturnValueOnce(queryChain([]))
// audit rows older than boundary
.mockReturnValueOnce(queryChain([{ id: "a1" }, { id: "a2" }]));
const log = makeLogger();
await runAuditArchive(log as never);
// Wrote the archive through the gzip pipeline.
expect(mkdirMock).toHaveBeenCalledWith("/data/audit-archives", { recursive: true });
expect(createWriteStreamMock).toHaveBeenCalledTimes(1);
expect(pipelineMock).toHaveBeenCalledTimes(1);
// Progressed through EXPORTED, PURGING, COMPLETE state persistence.
const upsertStates = upsertSettingMock.mock.calls.map((c) => c[1] as string);
expect(upsertStates.some((s) => s.includes('"state":"EXPORTING"'))).toBe(true);
expect(upsertStates.some((s) => s.includes('"state":"EXPORTED"'))).toBe(true);
expect(upsertStates.some((s) => s.includes('"state":"PURGING"'))).toBe(true);
expect(upsertStates.some((s) => s.includes('"state":"COMPLETE"'))).toBe(true);
// Purge used the reported rowCount from the delete result.
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ purgedRows: 2 }),
"Purged archived audit rows",
);
// Verify + complete log lines fired.
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ rowCount: 2 }),
"Archive verified",
);
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ rowCount: 2 }),
"Audit archival complete",
);
// Final COMPLETE step deletes the state key.
expect(deleteMock).toHaveBeenCalled();
});
it("falls back to the recorded rowCount when the purge result omits rowCount", async () => {
const deleteWhere = vi.fn().mockResolvedValue({}); // no rowCount field
const { runAuditArchive } = await loadAuditArchive();
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([]))
.mockReturnValueOnce(queryChain([{ id: "a1" }, { id: "a2" }, { id: "a3" }]));
const log = makeLogger();
await runAuditArchive(log as never);
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ purgedRows: 3 }),
"Purged archived audit rows",
);
});
it("resumes from a persisted EXPORTED state and completes purge", async () => {
const deleteWhere = vi.fn().mockResolvedValue({ rowCount: 5 });
const { runAuditArchive } = await loadAuditArchive();
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
const persisted = JSON.stringify({
state: "EXPORTED",
dateBoundary: "2026-01-01T00:00:00.000Z",
outputPath: "/data/audit-archives/audit-archive-2026-01-01T00-00-00-000Z.ndjson.gz",
rowCount: 5,
checksum: "abc123",
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([{ value: persisted }]));
const log = makeLogger();
await runAuditArchive(log as never);
// Resumed rather than re-exporting.
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ state: "EXPORTED" }),
"Resuming audit archival from persisted state",
);
// Did NOT re-run the export path (no mkdir / pipeline / new file).
expect(mkdirMock).not.toHaveBeenCalled();
expect(pipelineMock).not.toHaveBeenCalled();
// Verified the existing archive file.
expect(statMock).toHaveBeenCalledWith(
"/data/audit-archives/audit-archive-2026-01-01T00-00-00-000Z.ndjson.gz",
);
// Purged and completed.
expect(deleteWhere).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ purgedRows: 5 }),
"Purged archived audit rows",
);
});
it("aborts when the archive file is missing at the EXPORTED verify step", async () => {
const { runAuditArchive } = await loadAuditArchive();
const deleteWhere = vi.fn().mockResolvedValue(undefined);
deleteMock.mockReturnValue({ where: deleteWhere });
statMock.mockRejectedValue(new Error("ENOENT"));
isFeatureEnabledMock.mockReturnValue(true);
const persisted = JSON.stringify({
state: "EXPORTED",
dateBoundary: "2026-01-01T00:00:00.000Z",
outputPath: "/data/audit-archives/missing.ndjson.gz",
rowCount: 5,
checksum: "abc123",
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([{ value: persisted }]));
const log = makeLogger();
await runAuditArchive(log as never);
expect(log.error).toHaveBeenCalledWith(
expect.objectContaining({ path: "/data/audit-archives/missing.ndjson.gz" }),
"Archive file missing after export, aborting",
);
// State key was cleared via db.delete (deleteSetting on the settings table).
expect(deleteMock).toHaveBeenCalledWith(expect.objectContaining({ key: "settings.key" }));
// Aborted before purging: never deleted from the audit log table.
expect(deleteMock).not.toHaveBeenCalledWith(
expect.objectContaining({ createdAt: "auditLog.createdAt" }),
);
// Never advanced to PURGING.
const upsertStates = upsertSettingMock.mock.calls.map((c) => c[1] as string);
expect(upsertStates.some((s) => s.includes('"state":"PURGING"'))).toBe(false);
});
it("aborts when a resumed EXPORTED state has no checksum", async () => {
const { runAuditArchive } = await loadAuditArchive();
const deleteWhere = vi.fn().mockResolvedValue(undefined);
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
const persisted = JSON.stringify({
state: "EXPORTED",
dateBoundary: "2026-01-01T00:00:00.000Z",
outputPath: "/data/audit-archives/present.ndjson.gz",
rowCount: 5,
checksum: "", // invalid: missing checksum
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([{ value: persisted }]));
const log = makeLogger();
await runAuditArchive(log as never);
expect(statMock).toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith("Invalid archival state: missing checksum or rowCount");
// Aborted before purge: state key deleted, audit table untouched.
expect(deleteMock).toHaveBeenCalledWith(expect.objectContaining({ key: "settings.key" }));
expect(deleteMock).not.toHaveBeenCalledWith(
expect.objectContaining({ createdAt: "auditLog.createdAt" }),
);
});
it("aborts when a resumed EXPORTED state has a non-positive rowCount", async () => {
const { runAuditArchive } = await loadAuditArchive();
const deleteWhere = vi.fn().mockResolvedValue(undefined);
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
const persisted = JSON.stringify({
state: "EXPORTED",
dateBoundary: "2026-01-01T00:00:00.000Z",
outputPath: "/data/audit-archives/present.ndjson.gz",
rowCount: 0, // invalid
checksum: "abc123",
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([{ value: persisted }]));
const log = makeLogger();
await runAuditArchive(log as never);
expect(log.error).toHaveBeenCalledWith("Invalid archival state: missing checksum or rowCount");
// Aborted before purge: audit table untouched.
expect(deleteMock).not.toHaveBeenCalledWith(
expect.objectContaining({ createdAt: "auditLog.createdAt" }),
);
});
it("resumes from a persisted PENDING state and progresses forward", async () => {
const deleteWhere = vi.fn().mockResolvedValue({ rowCount: 1 });
const { runAuditArchive } = await loadAuditArchive();
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
const persisted = JSON.stringify({
state: "PENDING",
dateBoundary: "2026-01-01T00:00:00.000Z",
outputPath: "",
rowCount: 0,
checksum: "",
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([{ value: persisted }]))
// rows to export
.mockReturnValueOnce(queryChain([{ id: "a1" }]));
const log = makeLogger();
await runAuditArchive(log as never);
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ state: "PENDING" }),
"Resuming audit archival from persisted state",
);
// PENDING flips to EXPORTING and the full pipeline runs.
expect(pipelineMock).toHaveBeenCalledTimes(1);
expect(deleteWhere).toHaveBeenCalled();
});
it("starts fresh when the persisted state JSON is corrupt", async () => {
const deleteWhere = vi.fn().mockResolvedValue({ rowCount: 1 });
const { runAuditArchive } = await loadAuditArchive();
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
// corrupt state -> JSON.parse throws -> deleteSetting + freshRun
.mockReturnValueOnce(queryChain([{ value: "{not valid json" }]))
.mockReturnValueOnce(queryChain([{ id: "a1" }]));
const log = makeLogger();
await runAuditArchive(log as never);
// Corrupt state key was deleted before starting fresh.
expect(deleteMock).toHaveBeenCalled();
// A fresh run starts at PENDING then flips to EXPORTING.
const upsertStates = upsertSettingMock.mock.calls.map((c) => c[1] as string);
expect(upsertStates.some((s) => s.includes('"state":"EXPORTING"'))).toBe(true);
// Never logged a resume because parse failed.
expect(log.info).not.toHaveBeenCalledWith(
expect.anything(),
"Resuming audit archival from persisted state",
);
});
it("resumes directly from a persisted PURGING state and completes", async () => {
const deleteWhere = vi.fn().mockResolvedValue({ rowCount: 4 });
const { runAuditArchive } = await loadAuditArchive();
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
const persisted = JSON.stringify({
state: "PURGING",
dateBoundary: "2026-01-01T00:00:00.000Z",
outputPath: "/data/audit-archives/present.ndjson.gz",
rowCount: 4,
checksum: "abc123",
});
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([{ value: persisted }]));
const log = makeLogger();
await runAuditArchive(log as never);
// Went straight to purge, skipping export + verify.
expect(pipelineMock).not.toHaveBeenCalled();
expect(statMock).not.toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ purgedRows: 4 }),
"Purged archived audit rows",
);
// COMPLETE clears the state key.
expect(log.info).toHaveBeenCalledWith(
expect.objectContaining({ rowCount: 4 }),
"Audit archival complete",
);
});
it("works without a logger argument on the full happy path", async () => {
const deleteWhere = vi.fn().mockResolvedValue({ rowCount: 1 });
const { runAuditArchive } = await loadAuditArchive();
deleteMock.mockReturnValue({ where: deleteWhere });
isFeatureEnabledMock.mockReturnValue(true);
selectMock
.mockReturnValueOnce(queryChain([{ value: "6" }]))
.mockReturnValueOnce(queryChain([]))
.mockReturnValueOnce(queryChain([{ id: "a1" }]));
// No log passed: every log?.xxx call must no-op without throwing.
await expect(runAuditArchive()).resolves.toBeUndefined();
expect(pipelineMock).toHaveBeenCalledTimes(1);
expect(deleteWhere).toHaveBeenCalled();
});
});
+174 -10
View File
@@ -1,22 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { RedisMock } = vi.hoisted(() => ({
RedisMock: vi.fn(function RedisMock() {
return {};
}),
}));
type RedisStub = {
ping: ReturnType<typeof vi.fn>;
quit: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
};
const { RedisMock, stubs } = vi.hoisted(() => {
const created: RedisStub[] = [];
const mock = vi.fn(function RedisMock() {
const stub: RedisStub = {
ping: vi.fn().mockResolvedValue("PONG"),
quit: vi.fn().mockResolvedValue("OK"),
info: vi.fn().mockResolvedValue("# Server\r\nredis_version:8.0.1\r\n"),
};
created.push(stub);
return stub;
});
return { RedisMock: mock, stubs: created };
});
vi.mock("ioredis", () => ({
default: RedisMock,
}));
// Fresh module graph per test so the module-level `_shared` singleton
// never leaks state across cases.
async function freshModule() {
vi.resetModules();
return import("../../../../apps/api/src/jobs/connection.js");
}
describe("Redis connection factory", () => {
beforeEach(() => {
RedisMock.mockClear();
stubs.length = 0;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("keeps ready checks enabled for command connections", async () => {
const { createRedisConnection } = await import("../../../../apps/api/src/jobs/connection.js");
const { createRedisConnection } = await freshModule();
createRedisConnection();
@@ -27,9 +53,7 @@ describe("Redis connection factory", () => {
});
it("disables ready checks for pub/sub-only subscriber connections", async () => {
const { createRedisSubscriberConnection } = await import(
"../../../../apps/api/src/jobs/connection.js"
);
const { createRedisSubscriberConnection } = await freshModule();
createRedisSubscriberConnection();
@@ -38,4 +62,144 @@ describe("Redis connection factory", () => {
maxRetriesPerRequest: null,
});
});
it("createBullMQConnection builds a command connection and returns the ioredis instance", async () => {
const { createBullMQConnection } = await freshModule();
const conn = createBullMQConnection();
// It constructs exactly one command-style connection (ready checks on)...
expect(RedisMock).toHaveBeenCalledTimes(1);
expect(RedisMock).toHaveBeenCalledWith(expect.any(String), {
enableReadyCheck: true,
maxRetriesPerRequest: null,
});
// ...and hands the raw instance straight through (cast only).
expect(conn).toBe(stubs[0]);
});
it("sharedRedis memoizes a single connection across calls", async () => {
const { sharedRedis } = await freshModule();
const first = sharedRedis();
const second = sharedRedis();
expect(first).toBe(second);
expect(RedisMock).toHaveBeenCalledTimes(1);
});
});
describe("pingRedis", () => {
beforeEach(() => {
RedisMock.mockClear();
stubs.length = 0;
});
it("returns true when the server answers PONG", async () => {
const { pingRedis } = await freshModule();
await expect(pingRedis()).resolves.toBe(true);
expect(stubs[0].ping).toHaveBeenCalledTimes(1);
});
it("returns false when the reply is not PONG", async () => {
const mod = await freshModule();
// The first sharedRedis() call inside pingRedis constructs the stub;
// force its ping to answer something other than PONG.
const spyPing = vi.fn().mockResolvedValue("LOADING");
// Replace the ping the next-constructed stub will use by patching the
// stub right after construction: prime it via a throwaway sharedRedis().
const stub = mod.sharedRedis();
stub.ping = spyPing as unknown as RedisStub["ping"];
await expect(mod.pingRedis()).resolves.toBe(false);
expect(spyPing).toHaveBeenCalledTimes(1);
});
});
describe("closeRedis", () => {
beforeEach(() => {
RedisMock.mockClear();
stubs.length = 0;
});
it("quits the live shared connection and clears the singleton", async () => {
const { sharedRedis, closeRedis } = await freshModule();
const before = sharedRedis();
await closeRedis();
expect(before.quit).toHaveBeenCalledTimes(1);
// Singleton was cleared: the next sharedRedis() constructs a NEW instance.
const after = sharedRedis();
expect(after).not.toBe(before);
expect(RedisMock).toHaveBeenCalledTimes(2);
});
it("is a no-op when no shared connection was ever created", async () => {
const { closeRedis } = await freshModule();
await expect(closeRedis()).resolves.toBeUndefined();
// Nothing was constructed, so no quit could have run.
expect(RedisMock).not.toHaveBeenCalled();
});
});
describe("assertRedisCompatible", () => {
beforeEach(() => {
RedisMock.mockClear();
stubs.length = 0;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("resolves silently when INFO reports a supported version", async () => {
const mod = await freshModule();
const stub = mod.sharedRedis();
stub.info = vi
.fn()
.mockResolvedValue("redis_version:8.0.1\r\n") as unknown as RedisStub["info"];
await expect(mod.assertRedisCompatible()).resolves.toBeUndefined();
expect(stub.info).toHaveBeenCalledWith("server");
});
it("throws a SafeError when INFO reports a too-old version", async () => {
const mod = await freshModule();
const stub = mod.sharedRedis();
stub.info = vi.fn().mockResolvedValue("redis_version:6.0.16") as unknown as RedisStub["info"];
await expect(mod.assertRedisCompatible()).rejects.toMatchObject({
name: "SafeError",
code: "redis-6.0",
message: "Redis 6.2 or newer is required. Point REDIS_URL at Redis 8.",
});
});
it("does not block boot when INFO is not permitted (warns and returns)", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await freshModule();
const stub = mod.sharedRedis();
stub.info = vi
.fn()
.mockRejectedValue(
new Error("NOPERM this user has no permissions to run INFO"),
) as unknown as RedisStub["info"];
await expect(mod.assertRedisCompatible()).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith("[redis] INFO not permitted; skipping version preflight");
});
it("does not throw when INFO is unparseable (managed Redis hides details)", async () => {
const mod = await freshModule();
const stub = mod.sharedRedis();
stub.info = vi
.fn()
.mockResolvedValue("garbage-without-version") as unknown as RedisStub["info"];
await expect(mod.assertRedisCompatible()).resolves.toBeUndefined();
});
});
+373 -20
View File
@@ -1,22 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToolJobData } from "../../../../apps/api/src/jobs/types.js";
const insertedValues = vi.hoisted(() => vi.fn());
const queueAdd = vi.hoisted(() => vi.fn());
const getJob = vi.hoisted(() => vi.fn());
const queueEventClose = vi.hoisted(() => vi.fn());
const flowProducerClose = vi.hoisted(() => vi.fn());
const assertAiJobQuotaMock = vi.hoisted(() => vi.fn());
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
const propagationInjectMock = vi.hoisted(() => vi.fn());
const selectMock = vi.hoisted(() => vi.fn());
const updateSetMock = vi.hoisted(() => vi.fn());
const updateMock = vi.hoisted(() => vi.fn());
async function loadEnqueueModule() {
// Chain builder for `db.select(...).from(...).where(...).limit(...)`.
function selectChain<T>(result: T) {
const chain = {
from: vi.fn(() => chain),
where: vi.fn(() => chain),
limit: vi.fn(() => Promise.resolve(result)),
};
return chain;
}
async function loadEnqueueModule(
options: {
/** Whether the enterprise team_retention_overrides feature is enabled. */
teamRetentionEnabled?: boolean;
/** Rows returned by the users-table select in computeDeleteAfter. */
userRow?: Array<{ team: string | null }>;
/** Rows returned by the teams-table select in computeDeleteAfter. */
teamRow?: Array<{ retentionHours: number | null }>;
/** Carrier populated by the mocked propagation.inject. */
injectCarrier?: Record<string, string>;
/** Force the dynamic enterprise import to throw. */
enterpriseImportThrows?: boolean;
} = {},
) {
vi.resetModules();
insertedValues.mockReset();
queueAdd.mockReset();
getJob.mockReset();
queueEventClose.mockReset();
flowProducerClose.mockReset();
assertAiJobQuotaMock.mockReset();
isFeatureEnabledMock.mockReset();
propagationInjectMock.mockReset();
selectMock.mockReset();
updateSetMock.mockReset();
updateMock.mockReset();
queueAdd.mockResolvedValue({ id: "job-1" });
queueEventClose.mockResolvedValue(undefined);
flowProducerClose.mockResolvedValue(undefined);
assertAiJobQuotaMock.mockResolvedValue(undefined);
isFeatureEnabledMock.mockReturnValue(options.teamRetentionEnabled ?? false);
// Default: two sequential selects (users then teams).
selectMock
.mockReturnValueOnce(selectChain(options.userRow ?? [{ team: null }]))
.mockReturnValueOnce(selectChain(options.teamRow ?? [{ retentionHours: null }]));
const updateWhere = vi.fn(() => Promise.resolve(undefined));
updateSetMock.mockReturnValue({ where: updateWhere });
updateMock.mockReturnValue({ set: updateSetMock });
// The mocked propagation.inject copies the requested carrier keys into the
// carrier object enqueueToolJob passes in.
propagationInjectMock.mockImplementation((_ctx: unknown, carrier: Record<string, string>) => {
const src = options.injectCarrier ?? {};
for (const [k, v] of Object.entries(src)) carrier[k] = v;
});
vi.doMock("bullmq", () => ({
QueueEvents: vi.fn(() => ({
@@ -28,8 +82,31 @@ async function loadEnqueueModule() {
})),
}));
vi.doMock("@opentelemetry/api", () => ({
context: { active: vi.fn(() => ({})) },
propagation: { inject: propagationInjectMock },
}));
vi.doMock("drizzle-orm", () => ({
eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })),
}));
if (options.enterpriseImportThrows) {
vi.doMock("@snapotter/enterprise", () => {
throw new Error("enterprise unavailable");
});
} else {
vi.doMock("@snapotter/enterprise", () => ({
isFeatureEnabled: isFeatureEnabledMock,
}));
}
vi.doMock("../../../../apps/api/src/lib/ai-quota.js", () => ({
assertAiJobQuota: assertAiJobQuotaMock,
}));
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: { SYNC_WAIT_MS: 50 },
env: { SYNC_WAIT_MS: 50, FILE_MAX_AGE_HOURS: 24 },
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
@@ -37,9 +114,13 @@ async function loadEnqueueModule() {
insert: vi.fn(() => ({
values: insertedValues.mockResolvedValue(undefined),
})),
select: selectMock,
update: updateMock,
},
schema: {
jobs: {},
jobs: { id: "jobs.id", deleteAfter: "jobs.deleteAfter" },
users: { id: "users.id", team: "users.team" },
teams: { id: "teams.id", retentionHours: "teams.retentionHours" },
},
}));
@@ -54,7 +135,20 @@ async function loadEnqueueModule() {
})),
}));
return import("../../../../apps/api/src/jobs/enqueue.js");
const mod = await import("../../../../apps/api/src/jobs/enqueue.js");
return { mod, updateWhere };
}
/**
* Yield the event loop so the fire-and-forget computeDeleteAfter chain can
* settle. The chain awaits a dynamic import plus up to three DB calls, so drain
* a batch of microtasks then a macrotask, twice, to cover import scheduling.
*/
async function flushMicrotasks(): Promise<void> {
for (let round = 0; round < 2; round++) {
for (let i = 0; i < 12; i++) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("job enqueue helpers", () => {
@@ -63,7 +157,7 @@ describe("job enqueue helpers", () => {
});
it("strips NUL bytes recursively before persisting settings but keeps queue data intact", async () => {
const { enqueueToolJob } = await loadEnqueueModule();
const { mod } = await loadEnqueueModule();
const data = {
jobId: "job-1",
userId: null,
@@ -79,7 +173,7 @@ describe("job enqueue helpers", () => {
},
} as never;
await enqueueToolJob(data);
await mod.enqueueToolJob(data);
expect(insertedValues).toHaveBeenCalledWith(
expect.objectContaining({
@@ -105,9 +199,9 @@ describe("job enqueue helpers", () => {
});
it("persists redacted dbSettings while enqueueing real settings", async () => {
const { enqueueToolJob } = await loadEnqueueModule();
const { mod } = await loadEnqueueModule();
await enqueueToolJob({
await mod.enqueueToolJob({
jobId: "job-2",
userId: null,
toolId: "ftp-upload",
@@ -130,39 +224,298 @@ describe("job enqueue helpers", () => {
});
it("waitForJob returns null when the job is missing or the sync window times out", async () => {
const { waitForJob } = await loadEnqueueModule();
const { mod } = await loadEnqueueModule();
getJob.mockResolvedValueOnce(undefined);
await expect(waitForJob("image", "missing")).resolves.toBeNull();
await expect(mod.waitForJob("image", "missing")).resolves.toBeNull();
getJob.mockResolvedValueOnce({
waitUntilFinished: vi.fn().mockRejectedValue(new Error("job timed out before finishing")),
});
await expect(waitForJob("image", "slow", 25)).resolves.toBeNull();
await expect(mod.waitForJob("image", "slow", 25)).resolves.toBeNull();
});
it("waitForJob rethrows real job failures", async () => {
const { waitForJob } = await loadEnqueueModule();
const { mod } = await loadEnqueueModule();
getJob.mockResolvedValueOnce({
waitUntilFinished: vi.fn().mockRejectedValue(new Error("processor failed")),
});
await expect(waitForJob("image", "failed")).rejects.toThrow("processor failed");
await expect(mod.waitForJob("image", "failed")).rejects.toThrow("processor failed");
});
it("waitForJob returns the job result on the success path", async () => {
const { mod } = await loadEnqueueModule();
const result = {
outputRefs: ["outputs/ok/out.png"],
filename: "out.png",
contentType: "image/png",
originalSize: 10,
processedSize: 8,
};
const waitUntilFinished = vi.fn().mockResolvedValue(result);
getJob.mockResolvedValueOnce({ waitUntilFinished });
await expect(mod.waitForJob("image", "ok")).resolves.toEqual(result);
// Uses the pool's queueEvents consumer and the default SYNC_WAIT_MS window.
expect(waitUntilFinished).toHaveBeenCalledWith(expect.anything(), 50);
});
it("waitForJob rethrows non-Error rejections stringified and non-timeout", async () => {
const { mod } = await loadEnqueueModule();
getJob.mockResolvedValueOnce({
waitUntilFinished: vi.fn().mockRejectedValue("kaboom"),
});
await expect(mod.waitForJob("image", "weird")).rejects.toBe("kaboom");
});
it("closes lazy QueueEvents and FlowProducer singletons", async () => {
const { closeFlowProducer, closeQueueEvents, getFlowProducer, warmQueueEvents, waitForJob } =
await loadEnqueueModule();
const { mod } = await loadEnqueueModule();
await warmQueueEvents();
getFlowProducer();
await mod.warmQueueEvents();
mod.getFlowProducer();
getJob.mockResolvedValueOnce(undefined);
await waitForJob("image", "job-1");
await mod.waitForJob("image", "job-1");
await closeQueueEvents();
await closeFlowProducer();
await mod.closeQueueEvents();
await mod.closeFlowProducer();
expect(queueEventClose).toHaveBeenCalled();
expect(flowProducerClose).toHaveBeenCalledTimes(1);
});
it("getFlowProducer returns the same cached instance on repeated calls", async () => {
const { mod } = await loadEnqueueModule();
const a = mod.getFlowProducer();
const b = mod.getFlowProducer();
expect(a).toBe(b);
});
it("closeFlowProducer is a no-op when no producer was created", async () => {
const { mod } = await loadEnqueueModule();
await expect(mod.closeFlowProducer()).resolves.toBeUndefined();
expect(flowProducerClose).not.toHaveBeenCalled();
});
});
describe("enqueueToolJob AI quota gate", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("checks the AI quota before inserting for ai-tool jobs", async () => {
const { mod } = await loadEnqueueModule();
await mod.enqueueToolJob({
jobId: "ai-1",
userId: "user-1",
toolId: "colorize",
pool: "ai",
kind: "ai-tool",
inputRefs: ["uploads/ai-1/x.png"],
filename: "x.png",
settings: {},
} as never);
expect(assertAiJobQuotaMock).toHaveBeenCalledWith("user-1");
expect(insertedValues).toHaveBeenCalled();
});
it("does not check the AI quota for non-ai-tool kinds", async () => {
const { mod } = await loadEnqueueModule();
await mod.enqueueToolJob({
jobId: "img-1",
userId: "user-1",
toolId: "resize",
pool: "image",
kind: "tool",
inputRefs: ["uploads/img-1/x.png"],
filename: "x.png",
settings: {},
} as never);
expect(assertAiJobQuotaMock).not.toHaveBeenCalled();
});
it("propagates the quota rejection and never inserts the job row", async () => {
const { mod } = await loadEnqueueModule();
const quotaErr = Object.assign(new Error("Too many concurrent AI jobs."), { statusCode: 429 });
assertAiJobQuotaMock.mockRejectedValueOnce(quotaErr);
await expect(
mod.enqueueToolJob({
jobId: "ai-2",
userId: "user-1",
toolId: "colorize",
pool: "ai",
kind: "ai-tool",
inputRefs: ["uploads/ai-2/x.png"],
filename: "x.png",
settings: {},
} as never),
).rejects.toThrow("Too many concurrent AI jobs.");
expect(insertedValues).not.toHaveBeenCalled();
expect(queueAdd).not.toHaveBeenCalled();
});
});
describe("injectTraceContext", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("sets _otel with traceparent and tracestate when both are present", async () => {
const { mod } = await loadEnqueueModule({
injectCarrier: { traceparent: "00-abc-def-01", tracestate: "vendor=1" },
});
const data = { jobId: "t-1" } as unknown as ToolJobData;
mod.injectTraceContext(data);
expect(data._otel).toEqual({ traceparent: "00-abc-def-01", tracestate: "vendor=1" });
});
it("sets _otel with an undefined tracestate when only traceparent is present", async () => {
const { mod } = await loadEnqueueModule({
injectCarrier: { traceparent: "00-abc-def-01" },
});
const data = { jobId: "t-2" } as unknown as ToolJobData;
mod.injectTraceContext(data);
expect(data._otel).toEqual({ traceparent: "00-abc-def-01", tracestate: undefined });
});
it("leaves _otel unset when no traceparent is produced", async () => {
const { mod } = await loadEnqueueModule({ injectCarrier: {} });
const data = { jobId: "t-3" } as unknown as ToolJobData;
mod.injectTraceContext(data);
expect(data._otel).toBeUndefined();
});
});
describe("computeDeleteAfter (via enqueueToolJob fire-and-forget)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
type EnqueueModule = Awaited<ReturnType<typeof loadEnqueueModule>>["mod"];
async function enqueueWithUser(mod: EnqueueModule): Promise<void> {
await mod.enqueueToolJob({
jobId: "ret-1",
userId: "user-1",
toolId: "resize",
pool: "image",
kind: "tool",
inputRefs: ["uploads/ret-1/x.png"],
filename: "x.png",
settings: {},
} as never);
await flushMicrotasks();
}
it("does nothing when team_retention_overrides is disabled", async () => {
const { mod, updateWhere } = await loadEnqueueModule({ teamRetentionEnabled: false });
await enqueueWithUser(mod);
expect(selectMock).not.toHaveBeenCalled();
expect(updateWhere).not.toHaveBeenCalled();
});
it("does nothing when the enterprise import throws (feature stays disabled)", async () => {
const { mod, updateWhere } = await loadEnqueueModule({ enterpriseImportThrows: true });
await enqueueWithUser(mod);
expect(selectMock).not.toHaveBeenCalled();
expect(updateWhere).not.toHaveBeenCalled();
});
it("returns early when the user has no team row", async () => {
const { mod, updateWhere } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [],
});
await enqueueWithUser(mod);
expect(selectMock).toHaveBeenCalledTimes(1); // only the users lookup ran
expect(updateWhere).not.toHaveBeenCalled();
});
it("returns early when the user's team is null", async () => {
const { mod, updateWhere } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: null }],
});
await enqueueWithUser(mod);
expect(selectMock).toHaveBeenCalledTimes(1);
expect(updateWhere).not.toHaveBeenCalled();
});
it("uses the team retentionHours when set", async () => {
const start = Date.now();
const { mod, updateWhere } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [{ retentionHours: 2 }],
});
await enqueueWithUser(mod);
expect(selectMock).toHaveBeenCalledTimes(2);
expect(updateSetMock).toHaveBeenCalledTimes(1);
const arg = updateSetMock.mock.calls[0][0] as { deleteAfter: Date };
expect(arg.deleteAfter).toBeInstanceOf(Date);
// 2 hours out, within a generous tolerance of the enqueue instant.
const expected = start + 2 * 60 * 60 * 1000;
expect(Math.abs(arg.deleteAfter.getTime() - expected)).toBeLessThan(5000);
expect(updateWhere).toHaveBeenCalledTimes(1);
});
it("falls back to FILE_MAX_AGE_HOURS when the team retentionHours is null", async () => {
const start = Date.now();
const { mod } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [{ retentionHours: null }],
});
await enqueueWithUser(mod);
const arg = updateSetMock.mock.calls[0][0] as { deleteAfter: Date };
const expected = start + 24 * 60 * 60 * 1000; // FILE_MAX_AGE_HOURS mocked to 24
expect(Math.abs(arg.deleteAfter.getTime() - expected)).toBeLessThan(5000);
});
it("falls back to FILE_MAX_AGE_HOURS when the teams lookup returns no row", async () => {
const start = Date.now();
const { mod } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [],
});
await enqueueWithUser(mod);
const arg = updateSetMock.mock.calls[0][0] as { deleteAfter: Date };
const expected = start + 24 * 60 * 60 * 1000;
expect(Math.abs(arg.deleteAfter.getTime() - expected)).toBeLessThan(5000);
});
it("swallows a computeDeleteAfter failure without rejecting enqueueToolJob", async () => {
const { mod } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [{ retentionHours: 5 }],
});
// Make the update leg throw; the .catch(() => {}) must absorb it.
updateSetMock.mockReturnValueOnce({
where: vi.fn(() => Promise.reject(new Error("db down"))),
});
await expect(
mod.enqueueToolJob({
jobId: "ret-err",
userId: "user-1",
toolId: "resize",
pool: "image",
kind: "tool",
inputRefs: ["uploads/ret-err/x.png"],
filename: "x.png",
settings: {},
} as never),
).resolves.toBeDefined();
await flushMicrotasks();
});
});
@@ -141,4 +141,115 @@ describe("GDPR export job behavior", () => {
expect(zip.readAsText("library-files/file-1_a.txt")).toBe("file contents");
expect(zip.getEntry("library-files/file-2_missing.txt")).toBeNull();
});
it("serializes null/undefined date fields as null across every export section", async () => {
const { gdprExportJob } = await loadGdprExport();
selectMock
.mockReturnValueOnce(
queryChain([
{
id: "user-2",
email: "grace@example.test",
passwordHash: "secret",
createdAt: new Date("2026-06-10T00:00:00.000Z"),
},
]),
)
// File with a null createdAt exercises the falsy side of f.createdAt?.
.mockReturnValueOnce(
queryChain([
{
id: "file-9",
userId: "user-2",
storedName: "stored/x",
originalName: "x.bin",
createdAt: null,
},
]),
)
// Job where createdAt and completedAt are null (the two date branches the
// first test left truthy), startedAt/deleteAfter undefined.
.mockReturnValueOnce(
queryChain([
{
id: "job-b",
userId: "user-2",
createdAt: null,
startedAt: undefined,
completedAt: null,
deleteAfter: undefined,
},
]),
)
// Audit entry with a null createdAt exercises the falsy side of a.createdAt?.
.mockReturnValueOnce(
queryChain([
{
id: "audit-9",
actorId: "user-2",
action: "EXPORT",
createdAt: null,
},
]),
);
// Stored file read succeeds so the library-copy append path still runs.
readStoredFileMock.mockResolvedValueOnce(Buffer.from("bin"));
await expect(gdprExportJob("user-2", "export-null")).resolves.toEqual({
outputRef: "outputs/export-null/gdpr-export.zip",
});
expect(putObjectMock).toHaveBeenCalledTimes(1);
const zipBuffer = putObjectMock.mock.calls[0][1];
const zip = new AdmZip(zipBuffer);
const files = JSON.parse(zip.readAsText("files.json"));
const jobs = JSON.parse(zip.readAsText("jobs.json"));
const audit = JSON.parse(zip.readAsText("audit-log.json"));
// A null date passes through `?.toISOString()` as undefined, so JSON.stringify
// drops the key entirely rather than serializing it as null.
expect(files[0]).not.toHaveProperty("createdAt");
expect(jobs[0]).not.toHaveProperty("createdAt");
expect(jobs[0]).not.toHaveProperty("completedAt");
expect(jobs[0]).not.toHaveProperty("startedAt");
expect(jobs[0]).not.toHaveProperty("deleteAfter");
expect(audit[0]).not.toHaveProperty("createdAt");
expect(zip.readAsText("library-files/file-9_x.bin")).toBe("bin");
});
it("produces empty JSON collections when the user has no files, jobs, or audit entries", async () => {
const { gdprExportJob } = await loadGdprExport();
selectMock
.mockReturnValueOnce(
queryChain([
{
id: "user-3",
email: "empty@example.test",
passwordHash: "secret",
createdAt: new Date("2026-06-20T00:00:00.000Z"),
},
]),
)
.mockReturnValueOnce(queryChain([]))
.mockReturnValueOnce(queryChain([]))
.mockReturnValueOnce(queryChain([]));
await expect(gdprExportJob("user-3", "export-empty")).resolves.toEqual({
outputRef: "outputs/export-empty/gdpr-export.zip",
});
// With no files, the library-copy loop never runs, so readStoredFile is untouched.
expect(readStoredFileMock).not.toHaveBeenCalled();
expect(putObjectMock).toHaveBeenCalledTimes(1);
const zipBuffer = putObjectMock.mock.calls[0][1];
const zip = new AdmZip(zipBuffer);
expect(JSON.parse(zip.readAsText("files.json"))).toEqual([]);
expect(JSON.parse(zip.readAsText("jobs.json"))).toEqual([]);
expect(JSON.parse(zip.readAsText("audit-log.json"))).toEqual([]);
const profile = JSON.parse(zip.readAsText("profile.json"));
expect(profile).toMatchObject({ id: "user-3", email: "empty@example.test" });
expect(profile).not.toHaveProperty("passwordHash");
});
});
@@ -39,6 +39,7 @@ describe("job queues", () => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -112,4 +113,91 @@ describe("job queues", () => {
expect(queueInstances[1].close).toHaveBeenCalledTimes(1);
await expect(queueCounts()).resolves.toEqual({ active: 0, waiting: 0, delayed: 0 });
});
it("treats missing count keys as zero when aggregating queueCounts", async () => {
const { getQueue, queueCounts } = await loadQueuesModule();
getQueue("image");
// BullMQ can return a counts object that omits states with zero jobs;
// every field must fall through the ?? 0 branch.
queueInstances[0].getJobCounts.mockResolvedValueOnce({});
await expect(queueCounts()).resolves.toEqual({ active: 0, waiting: 0, delayed: 0 });
});
it("treats missing count keys as zero in perPoolCounts", async () => {
const { getQueue, perPoolCounts } = await loadQueuesModule();
getQueue("system");
// active present, waiting omitted: exercises both sides of the ?? 0 pair.
queueInstances[0].getJobCounts.mockResolvedValueOnce({ active: 9 });
await expect(perPoolCounts()).resolves.toMatchObject({
system: { active: 9, waiting: 0 },
image: { active: 0, waiting: 0 },
});
});
it("returns oldestWaitingMs null in perPoolHealth when waiting count is missing", async () => {
const { getQueue, perPoolHealth } = await loadQueuesModule();
getQueue("docs");
// No waiting/active/failed keys: (counts.waiting ?? 0) > 0 is false, so
// getJobs is never consulted and every field falls back to zero/null.
queueInstances[0].getJobCounts.mockResolvedValueOnce({});
await expect(perPoolHealth()).resolves.toMatchObject({
docs: { active: 0, waiting: 0, failed: 0, oldestWaitingMs: null },
});
expect(queueInstances[0].getJobs).not.toHaveBeenCalled();
});
it("keeps oldestWaitingMs null when waiting is reported but no waiting jobs are returned", async () => {
const { getQueue, perPoolHealth } = await loadQueuesModule();
getQueue("media");
// Count claims a waiting job, but getJobs returns an empty page: the
// jobs.length > 0 guard must short-circuit and leave oldestWaitingMs null.
queueInstances[0].getJobCounts.mockResolvedValueOnce({ active: 0, waiting: 1, failed: 0 });
queueInstances[0].getJobs.mockResolvedValueOnce([]);
await expect(perPoolHealth()).resolves.toMatchObject({
media: { active: 0, waiting: 1, failed: 0, oldestWaitingMs: null },
});
expect(queueInstances[0].getJobs).toHaveBeenCalledTimes(1);
});
it("keeps oldestWaitingMs null when the returned waiting job slot is empty", async () => {
const { getQueue, perPoolHealth } = await loadQueuesModule();
getQueue("image");
// getJobs returns a page whose first slot is undefined (BullMQ can hand
// back holes for expired jobs): the jobs[0] guard must reject it.
queueInstances[0].getJobCounts.mockResolvedValueOnce({ active: 0, waiting: 1, failed: 0 });
queueInstances[0].getJobs.mockResolvedValueOnce([undefined]);
await expect(perPoolHealth()).resolves.toMatchObject({
image: { active: 0, waiting: 1, failed: 0, oldestWaitingMs: null },
});
expect(queueInstances[0].getJobs).toHaveBeenCalledTimes(1);
});
it("defaults missing active and failed counts to zero in perPoolHealth", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-29T12:00:00.000Z"));
const { getQueue, perPoolHealth } = await loadQueuesModule();
getQueue("ai");
// Only waiting is present; active and failed exercise their ?? 0 branches
// while a real waiting job still resolves oldestWaitingMs.
queueInstances[0].getJobCounts.mockResolvedValueOnce({ waiting: 1 });
queueInstances[0].getJobs.mockResolvedValueOnce([
{ timestamp: new Date("2026-06-29T11:59:55.000Z").getTime() },
]);
await expect(perPoolHealth()).resolves.toMatchObject({
ai: { active: 0, waiting: 1, failed: 0, oldestWaitingMs: 5_000 },
});
});
});
@@ -0,0 +1,746 @@
/**
* Mutation-hardening unit coverage for apps/api/src/jobs/system-jobs.ts.
*
* The sibling system-jobs.behavior / system-jobs.sweeps suites cover the sweep
* control flow; this file exists to pin the exact values and boundaries that
* Stryker mutants flip without those suites noticing:
* - scheduleSystemJobs: the queue name and every/pattern arithmetic.
* - MONITOR_CONFIG: each monitor's slug, schedule, checkinMargin, maxRuntime,
* and the Math.max(1, ...) floor -- all observed through Sentry.withMonitor.
* - cronMonitorsEnabled: which SENTRY_CRON_MONITORS values arm the monitor.
* - withCronMonitor: the ":"->"-" slug transform and the catch fallback.
* - dispatchSystemJob: the exact DELETE FROM sessions SQL text.
* - decideExpiry: the ageMs < cutoffMs boundary on the S3-row branch.
* - storageTtlSweep: empty-set early return dropping deleteAfterCleaned, the
* removed + deleteAfterCleaned sum, the deleteAfter/error/removed log
* guards, the held-team and held-user select gating, and listJobDirs args.
* - retentionSweep: the jobs/audit DELETE SQL text, the interpolated window
* day counts, the tamperResistantAudit key, and the "off when value=false"
* branch of the tamper guard.
*
* A capturing drizzle mock records every sql`` template (strings + interpolated
* values) and every eq() argument pair so string/arithmetic mutants inside the
* SQL become observable. The db.select() builder is fluent and queued per table.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
// -- Hoisted seam mocks -------------------------------------------------------
const getQueueMock = vi.hoisted(() => vi.fn());
const runSiemForwardMock = vi.hoisted(() => vi.fn());
const runAuditArchiveMock = vi.hoisted(() => vi.fn());
const getMaxAgeMsMock = vi.hoisted(() => vi.fn());
const deletePrefixMock = vi.hoisted(() => vi.fn());
const listJobDirsMock = vi.hoisted(() => vi.fn());
const getSettingNumberMock = vi.hoisted(() => vi.fn());
const analyticsEnabledMock = vi.hoisted(() => vi.fn());
const dbExecuteMock = vi.hoisted(() => vi.fn());
const dbSelectMock = vi.hoisted(() => vi.fn());
const withMonitorMock = vi.hoisted(() => vi.fn());
// Captured drizzle sql`` calls: each is { strings, values }.
interface SqlCall {
strings: readonly string[];
values: unknown[];
}
const sqlCalls = vi.hoisted(() => [] as SqlCall[]);
// Captured eq(col, value) argument pairs.
const eqCalls = vi.hoisted(() => [] as unknown[][]);
// -- Fluent, queued db.select() builder ---------------------------------------
type TableName = "users" | "teams" | "jobs" | "settings";
const selectQueues = vi.hoisted(() => ({
users: [] as unknown[][],
teams: [] as unknown[][],
jobs: [] as unknown[][],
settings: [] as unknown[][],
}));
const schemaMock = vi.hoisted(() => ({
users: { id: "users.id", legalHold: "users.legalHold", team: "users.team" },
teams: { id: "teams.id", legalHold: "teams.legalHold" },
jobs: {
id: "jobs.id",
userId: "jobs.userId",
createdAt: "jobs.createdAt",
completedAt: "jobs.completedAt",
deleteAfter: "jobs.deleteAfter",
},
settings: { key: "settings.key", value: "settings.value" },
}));
function tableNameOf(table: unknown): TableName {
if (table === schemaMock.users) return "users";
if (table === schemaMock.teams) return "teams";
if (table === schemaMock.jobs) return "jobs";
if (table === schemaMock.settings) return "settings";
throw new Error("Unexpected table passed to db.select().from()");
}
function makeBuilder(): Record<string, unknown> {
let resolved: unknown[] = [];
const builder: Record<string, unknown> = {
from(table: unknown) {
const name = tableNameOf(table);
const queue = selectQueues[name];
resolved = (queue.length > 0 ? queue.shift() : []) as unknown[];
return builder;
},
where() {
return builder;
},
limit() {
return builder;
},
// biome-ignore lint/suspicious/noThenProperty: intentional thenable mocking an awaitable Drizzle query builder
then(onFulfilled: (v: unknown[]) => unknown, onRejected?: (e: unknown) => unknown) {
return Promise.resolve(resolved).then(onFulfilled, onRejected);
},
};
return builder;
}
function queueSelect(table: TableName, rows: unknown[]): void {
selectQueues[table].push(rows);
}
function resetSelectQueues(): void {
selectQueues.users = [];
selectQueues.teams = [];
selectQueues.jobs = [];
selectQueues.settings = [];
}
/** Flatten a captured sql`` template back into a single comparable string. */
function sqlText(call: SqlCall): string {
return call.strings.join(" ");
}
/** The full concatenated text of every sql`` template captured this test. */
function allSqlText(): string {
return sqlCalls.map(sqlText).join(" || ");
}
async function loadSystemJobs(
envOverrides: Record<string, unknown> = {},
): Promise<typeof import("../../../../apps/api/src/jobs/system-jobs.js")> {
vi.resetModules();
resetSelectQueues();
sqlCalls.length = 0;
eqCalls.length = 0;
getQueueMock.mockReset();
runSiemForwardMock.mockReset();
runAuditArchiveMock.mockReset();
getMaxAgeMsMock.mockReset();
deletePrefixMock.mockReset();
listJobDirsMock.mockReset();
getSettingNumberMock.mockReset();
analyticsEnabledMock.mockReset();
dbExecuteMock.mockReset();
dbSelectMock.mockReset();
withMonitorMock.mockReset();
deletePrefixMock.mockResolvedValue(undefined);
listJobDirsMock.mockResolvedValue([]);
dbExecuteMock.mockResolvedValue(undefined);
analyticsEnabledMock.mockReturnValue(true);
dbSelectMock.mockImplementation(() => makeBuilder());
// Default: withMonitor just runs the supplied fn (like the real happy path).
withMonitorMock.mockImplementation((_slug: string, fn: () => Promise<unknown>) => fn());
vi.doMock("drizzle-orm", () => ({
and: vi.fn(() => "and"),
eq: vi.fn((...args: unknown[]) => {
eqCalls.push(args);
return "eq";
}),
inArray: vi.fn(() => "inArray"),
isNotNull: vi.fn(() => "isNotNull"),
lt: vi.fn(() => "lt"),
// Capturing tagged-template mock: records the literal strings and the
// interpolated values so SQL-text and window-arithmetic mutants are visible.
sql: vi.fn((strings: readonly string[], ...values: unknown[]) => {
sqlCalls.push({ strings, values });
return { __sql: true, strings, values };
}),
}));
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: {
CLEANUP_INTERVAL_MINUTES: 15,
JOBS_RETENTION_DAYS: 30,
AUDIT_RETENTION_DAYS: 90,
...envOverrides,
},
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
db: {
select: dbSelectMock,
execute: dbExecuteMock,
update: vi.fn(),
},
schema: schemaMock,
}));
vi.doMock("../../../../apps/api/src/lib/analytics-gate.js", () => ({
analyticsEnabled: analyticsEnabledMock,
}));
vi.doMock("../../../../apps/api/src/lib/cleanup.js", () => ({
getMaxAgeMs: getMaxAgeMsMock,
}));
vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({
deletePrefix: deletePrefixMock,
listJobDirs: listJobDirsMock,
}));
vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({
getSettingNumber: getSettingNumberMock,
}));
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,
}));
// The source does `await import("@sentry/node")` inside withCronMonitor.
// Aliased to apps/api's node_modules by vitest.config, so this mock intercepts.
vi.doMock("@sentry/node", () => ({
withMonitor: withMonitorMock,
}));
return import("../../../../apps/api/src/jobs/system-jobs.js");
}
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...ORIGINAL_ENV };
});
// -- scheduleSystemJobs -------------------------------------------------------
describe("scheduleSystemJobs value pinning", () => {
function makeQueue() {
return {
upsertJobScheduler: vi.fn().mockResolvedValue(undefined),
removeJobScheduler: vi.fn().mockResolvedValue(undefined),
};
}
it("pulls the 'system' queue and pins every repeatable schedule's interval/pattern", async () => {
const queue = makeQueue();
const { SYSTEM_JOBS, scheduleSystemJobs } = await loadSystemJobs({
CLEANUP_INTERVAL_MINUTES: 15,
});
getQueueMock.mockReturnValue(queue);
await scheduleSystemJobs();
// Kills StringLiteral "" on getQueue("system") (line 38).
expect(getQueueMock).toHaveBeenCalledWith("system");
// storageTtl: CLEANUP_INTERVAL_MINUTES * 60_000 = 900_000.
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageTtl, {
every: 900_000,
});
// sessionPurge: 60 * 60_000.
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.sessionPurge, {
every: 3_600_000,
});
// retention: 6 * 60 * 60_000.
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.retention, {
every: 21_600_000,
});
// siemForward: 30_000.
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.siemForward, {
every: 30_000,
});
// auditArchive + storageReconciliation crontab patterns.
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.auditArchive, {
pattern: "0 2 1 * *",
});
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageReconciliation, {
pattern: "0 3 * * 0",
});
// alertEvaluator: 60_000.
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.alertEvaluator, {
every: 60_000,
});
});
it("scales the storageTtl interval with a non-default CLEANUP_INTERVAL_MINUTES", async () => {
const queue = makeQueue();
const { SYSTEM_JOBS, scheduleSystemJobs } = await loadSystemJobs({
CLEANUP_INTERVAL_MINUTES: 5,
});
getQueueMock.mockReturnValue(queue);
await scheduleSystemJobs();
// 5 * 60_000 = 300_000 (distinguishes the * operator and the literal).
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageTtl, {
every: 300_000,
});
});
});
// -- cronMonitorsEnabled + withCronMonitor + MONITOR_CONFIG --------------------
describe("withCronMonitor: enabled path wires Sentry.withMonitor precisely", () => {
it("arms the monitor with the ':'->'-' slug and the exact sessionPurge config", async () => {
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
// BooleanLiteral / ConditionalExpression: with cfg present AND monitors
// enabled, withCronMonitor must NOT short-circuit -- it must call withMonitor.
expect(withMonitorMock).toHaveBeenCalledTimes(1);
const [slug, fn, cfg] = withMonitorMock.mock.calls[0];
// Slug transform: "system:session-purge" -> "system-session-purge".
expect(slug).toBe("system-session-purge");
expect(slug).not.toContain(":");
expect(typeof fn).toBe("function");
// sessionPurge MONITOR_CONFIG (lines 99-103).
expect(cfg).toEqual({
schedule: { type: "interval", value: 1, unit: "hour" },
checkinMargin: 10,
maxRuntime: 5,
});
// The wrapped job still executed its DELETE.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
it("passes the exact retention monitor config (interval 6 hours, margins)", async () => {
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(0).mockResolvedValueOnce(0);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
const [slug, , cfg] = withMonitorMock.mock.calls[0];
expect(slug).toBe("system-retention");
// retention MONITOR_CONFIG (lines 104-108).
expect(cfg).toEqual({
schedule: { type: "interval", value: 6, unit: "hour" },
checkinMargin: 15,
maxRuntime: 30,
});
});
it("floors the storageTtl monitor interval at 1 via Math.max (kills Math.min)", async () => {
// CLEANUP_INTERVAL_MINUTES=15 => Math.max(1,15)=15 (min would give 1).
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs({ CLEANUP_INTERVAL_MINUTES: 15 });
getMaxAgeMsMock.mockResolvedValue(0);
// storageTtl sweep first reads held users/teams, then deleteAfter jobs.
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []);
await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
const [slug, , cfg] = withMonitorMock.mock.calls[0];
expect(slug).toBe("system-storage-ttl");
// storageTtl MONITOR_CONFIG (lines 90-98): value is the Math.max floor.
expect(cfg).toEqual({
schedule: { type: "interval", value: 15, unit: "minute" },
checkinMargin: 5,
maxRuntime: 20,
});
});
it("still floors at 1 when CLEANUP_INTERVAL_MINUTES is 0 (disabled sweep)", async () => {
// Math.max(1, 0) = 1; a Math.min mutant would yield 0.
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs({ CLEANUP_INTERVAL_MINUTES: 0 });
getMaxAgeMsMock.mockResolvedValue(0);
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []);
await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
const [, , cfg] = withMonitorMock.mock.calls[0];
expect((cfg as { schedule: { value: number } }).schedule.value).toBe(1);
});
it('arms the monitor when SENTRY_CRON_MONITORS is exactly "1"', async () => {
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
// Kills `v === "1"` -> `v !== "1"` and the StringLiteral "" replacements.
expect(withMonitorMock).toHaveBeenCalledTimes(1);
});
it('arms the monitor when SENTRY_CRON_MONITORS is "true"', async () => {
process.env.SENTRY_CRON_MONITORS = "true";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
expect(withMonitorMock).toHaveBeenCalledTimes(1);
});
it("does NOT arm the monitor for an unrelated SENTRY_CRON_MONITORS value", async () => {
// "0" is neither "1" nor "true": the OR must stay false.
process.env.SENTRY_CRON_MONITORS = "0";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
expect(withMonitorMock).not.toHaveBeenCalled();
// Job still ran directly.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
it("does NOT arm the monitor when analytics egress is disabled", async () => {
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
analyticsEnabledMock.mockReturnValue(false);
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
// Kills the `&& analyticsEnabled()` guard: without egress, no monitor.
expect(analyticsEnabledMock).toHaveBeenCalled();
expect(withMonitorMock).not.toHaveBeenCalled();
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
it("falls back to running the job directly when Sentry.withMonitor throws", async () => {
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
// Force the try body to throw so the catch (line 137) re-runs fn().
withMonitorMock.mockImplementation(() => {
throw new Error("sentry boom");
});
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
// The catch must swallow and still run the underlying DELETE.
expect(withMonitorMock).toHaveBeenCalledTimes(1);
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
});
// -- dispatchSystemJob: sessionPurge SQL text ---------------------------------
describe("dispatchSystemJob sessionPurge SQL", () => {
it("issues the exact DELETE FROM sessions statement", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
// Kills the StringLiteral `` on the sessions delete template (line 154).
const text = allSqlText();
expect(text).toContain("DELETE FROM sessions");
expect(text).toContain("expires_at < now()");
});
});
// -- decideExpiry boundary (S3-row branch, line 221) --------------------------
describe("decideExpiry S3-row age boundary", () => {
it("keeps a row whose age exactly equals the cutoff (strict <, not <=)", async () => {
const { decideExpiry } = await loadSystemJobs();
const cutoffMs = new Date("2026-07-01T00:00:00.000Z").getTime();
// completedAt == cutoff exactly: `ageMs < cutoffMs` is false -> keep.
const rowsById = new Map([
["exact", { createdAt: new Date(cutoffMs - 1000), completedAt: new Date(cutoffMs) }],
]);
expect(decideExpiry({ key: "uploads/exact", mtimeMs: 0 }, cutoffMs, rowsById)).toBe("keep");
// One millisecond older -> expired (guards the operator direction).
const rowsOlder = new Map([
["older", { createdAt: new Date(cutoffMs - 1000), completedAt: new Date(cutoffMs - 1) }],
]);
expect(decideExpiry({ key: "uploads/older", mtimeMs: 0 }, cutoffMs, rowsOlder)).toBe("expired");
});
it("uses createdAt as the age basis when completedAt is null", async () => {
const { decideExpiry } = await loadSystemJobs();
const cutoffMs = new Date("2026-07-01T00:00:00.000Z").getTime();
// completedAt null -> falls back to createdAt (before cutoff) -> expired.
const rowsById = new Map([["c", { createdAt: new Date(cutoffMs - 1), completedAt: null }]]);
expect(decideExpiry({ key: "outputs/c", mtimeMs: 0 }, cutoffMs, rowsById)).toBe("expired");
});
});
// -- storageTtlSweep value/branch pinning -------------------------------------
describe("storageTtlSweep return arithmetic and empty-set handling", () => {
it("drops the deleteAfter count on the empty-dir early return (removed:0)", async () => {
// deleteAfterCleaned=2, maxAgeMs>0, but no dirs -> the allDirs.length===0
// early return intentionally reports removed:0 (NOT removed:2).
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", [
{ id: "d1", userId: null },
{ id: "d2", userId: null },
]);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
listJobDirsMock.mockResolvedValue([]); // both uploads + outputs empty
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills ConditionalExpression `false` on line 285 (fall-through would give
// removed:2 because deleteAfterCleaned would be added at line 343).
expect(result).toEqual({ removed: 0, failed: 0 });
// The two deleteAfter jobs were still cleaned (4 deletePrefix calls).
expect(deletePrefixMock).toHaveBeenCalledTimes(4);
});
it("returns removed = global-sweep removals + deleteAfterCleaned (sum, not diff)", async () => {
// deleteAfterCleaned=2 and one expired global dir -> 2 + 1 = 3.
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", [
{ id: "da1", userId: null },
{ id: "da2", userId: null },
]);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const oldMtime = Date.now() - 7_200_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") return [{ key: "uploads/stale", size: 0, mtimeMs: oldMtime }];
return [];
});
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills ArithmeticOperator `removed - deleteAfterCleaned` (line 343):
// the minus mutant would yield removed = 1 - 2 = -1.
expect(result).toEqual({ removed: 3, failed: 0 });
});
it("passes 'uploads' and 'outputs' to listJobDirs", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills StringLiteral "" on listJobDirs("outputs") (line 283) and its pair.
expect(listJobDirsMock).toHaveBeenCalledWith("uploads");
expect(listJobDirsMock).toHaveBeenCalledWith("outputs");
});
});
describe("storageTtlSweep log-guard boundaries", () => {
it("does NOT log the deleteAfter line when zero jobs were cleaned", async () => {
// No expired deleteAfter jobs -> deleteAfterCleaned stays 0.
delete process.env.SENTRY_CRON_MONITORS;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []); // no deleteAfter jobs
getMaxAgeMsMock.mockResolvedValue(0); // early-return after deleteAfter block
await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills `deleteAfterCleaned > 0` -> `>= 0` (line 270): the mutant would log
// "cleaned up 0 jobs by deleteAfter".
const logged = logSpy.mock.calls.map((c) => String(c[0]));
expect(logged.some((m) => m.includes("cleaned up"))).toBe(false);
logSpy.mockRestore();
});
it("does NOT log the removed line when nothing expired in the global sweep", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
// A fresh dir -> kept, removed stays 0.
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") return [{ key: "uploads/fresh", size: 0, mtimeMs: Date.now() }];
return [];
});
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills `removed > 0` -> `>= 0` (line 340): mutant logs "removed 0 expired".
const logged = logSpy.mock.calls.map((c) => String(c[0]));
expect(logged.some((m) => m.includes("removed"))).toBe(false);
expect(result).toEqual({ removed: 0, failed: 0 });
logSpy.mockRestore();
});
it("does NOT log the error line when every deletion succeeds", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const oldMtime = Date.now() - 7_200_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") return [{ key: "uploads/stale", size: 0, mtimeMs: oldMtime }];
return [];
});
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills `errors.length > 0` -> `>= 0` (line 337): mutant logs "0 dir(s) failed".
expect(errSpy).not.toHaveBeenCalled();
expect(result).toEqual({ removed: 1, failed: 0 });
errSpy.mockRestore();
});
});
describe("storageTtlSweep legal-hold select gating", () => {
it("skips the team-member lookup when no teams are under legal hold", async () => {
// heldTeamRows empty -> the `heldTeamRows.length > 0` guard must stay false,
// so only 3 selects run (held users, held teams, deleteAfter jobs).
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []); // held users
queueSelect("teams", []); // held teams (empty)
queueSelect("jobs", []); // deleteAfter jobs
getMaxAgeMsMock.mockResolvedValue(0); // stop after deleteAfter block
await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills `heldTeamRows.length > 0` -> `>= 0`/true (line 236): a true mutant
// would run a 4th select (team members) and drain a users-queue slot.
expect(dbSelectMock).toHaveBeenCalledTimes(3);
});
it("runs the team-member lookup exactly once when a team is under legal hold", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []); // held users (direct)
queueSelect("teams", [{ id: "t1" }]); // one held team
queueSelect("users", [{ id: "member-1" }]); // team members
queueSelect("jobs", []); // deleteAfter jobs
getMaxAgeMsMock.mockResolvedValue(0);
await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// 4 selects: held users, held teams, team members, deleteAfter jobs.
expect(dbSelectMock).toHaveBeenCalledTimes(4);
});
it("skips the per-dir jobUserMap lookup when no users are under legal hold", async () => {
// heldUserIds empty -> the `heldUserIds.size > 0 && ...` guard stays false,
// so the global sweep does NOT issue the jobUserMap select.
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []); // no held users
queueSelect("teams", []); // no held teams
queueSelect("jobs", []); // no deleteAfter jobs
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const oldMtime = Date.now() - 7_200_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") return [{ key: "uploads/stale", size: 0, mtimeMs: oldMtime }];
return [];
});
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// Kills the `&&` -> `||` / `size >= 0` mutants (line 308): a truthy guard
// would run a 4th select (jobUserMap). Only 3 selects should occur.
expect(dbSelectMock).toHaveBeenCalledTimes(3);
// And the dir is still deleted (no hold in effect).
expect(result).toEqual({ removed: 1, failed: 0 });
expect(deletePrefixMock).toHaveBeenCalledWith("uploads/stale");
});
});
// -- retentionSweep SQL text + window arithmetic ------------------------------
describe("retentionSweep SQL pinning", () => {
it("issues jobs + audit deletes with the exact SQL text and window day counts", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(30).mockResolvedValueOnce(90);
queueSelect("settings", []); // tamperResistantAudit unset -> not tamper-resistant
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
expect(dbExecuteMock).toHaveBeenCalledTimes(2);
const text = allSqlText();
// Kills StringLiteral `` on the heldUsersSubquery + both DELETE templates.
expect(text).toContain("DELETE FROM jobs");
expect(text).toContain("status IN ('completed', 'failed', 'canceled')");
expect(text).toContain("DELETE FROM audit_log");
expect(text).toContain("interval '1 day'");
// The retention day counts are interpolated (kills a mutant that drops the
// interpolation value from the template).
const interpolated = sqlCalls.flatMap((c) => c.values);
expect(interpolated).toContain(30);
expect(interpolated).toContain(90);
});
it("reads the exact settings key when checking tamper-resistant mode", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(30).mockResolvedValueOnce(90);
queueSelect("settings", []);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
// Kills StringLiteral "" on "tamperResistantAudit" (line 373): eq must have
// been called with the settings.key column and that literal.
expect(eqCalls).toContainEqual(["settings.key", "tamperResistantAudit"]);
});
it("deletes audit rows when the tamper setting exists but is 'false'", async () => {
// Row present, value "false" -> `length>0 && value==="true"` is false ->
// NOT tamper-resistant -> audit delete runs.
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(30).mockResolvedValueOnce(90);
queueSelect("settings", [{ value: "false" }]);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
// Kills the ConditionalExpression `true` on the tamper guard (line 376):
// a `true` mutant would treat this as tamper-resistant and skip the audit
// delete, leaving only 1 execute call.
expect(dbExecuteMock).toHaveBeenCalledTimes(2);
});
it("passes the env retention days as the getSettingNumber fallback defaults", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs({
JOBS_RETENTION_DAYS: 45,
AUDIT_RETENTION_DAYS: 120,
});
getSettingNumberMock.mockResolvedValueOnce(45).mockResolvedValueOnce(120);
queueSelect("settings", []);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
// Pins the second argument to each getSettingNumber call (the env fallback).
expect(getSettingNumberMock).toHaveBeenNthCalledWith(1, "jobsRetentionDays", 45);
expect(getSettingNumberMock).toHaveBeenNthCalledWith(2, "auditRetentionDays", 120);
});
});
@@ -0,0 +1,618 @@
/**
* Focused unit coverage for the system-jobs sweep, scheduling, and cron-monitor
* branches that the integration suite exercises only on the happy path:
* - storageTtlSweep: legal-hold users/teams, deleteAfter sweep (ok + error),
* maxAgeMs<=0 and empty-dir early returns, per-dir deletePrefix failure,
* legal-hold skip on expired dirs, and the console.log/error side effects.
* - retentionSweep: jobs/audit retention on and off, tamper-resistant guard.
* - scheduleSystemJobs: the CLEANUP_INTERVAL_MINUTES>0 upsert path.
* - enqueueSystemJob: one-shot enqueue.
* - withCronMonitor: monitor disabled/enabled, Sentry import failure fallback.
*
* A dedicated fluent db mock keyed by the `from` table drives the many chained
* select() calls in storageTtlSweep/retentionSweep deterministically.
*/
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 getMaxAgeMsMock = vi.hoisted(() => vi.fn());
const deletePrefixMock = vi.hoisted(() => vi.fn());
const listJobDirsMock = vi.hoisted(() => vi.fn());
const getSettingNumberMock = vi.hoisted(() => vi.fn());
const analyticsEnabledMock = vi.hoisted(() => vi.fn());
const dbExecuteMock = vi.hoisted(() => vi.fn());
const dbUpdateMock = vi.hoisted(() => vi.fn());
// -- Fluent db.select() mock --------------------------------------------------
// Each call to db.select(cols).from(table) pulls the next queued result for
// that table. .where() and .limit() are chainable and the builder is thenable
// so `await db.select()...where()` resolves to the queued rows.
type TableName = "users" | "teams" | "jobs" | "settings";
const selectQueues = vi.hoisted(() => ({
users: [] as unknown[][],
teams: [] as unknown[][],
jobs: [] as unknown[][],
settings: [] as unknown[][],
}));
const schemaMock = vi.hoisted(() => ({
users: { id: "users.id", legalHold: "users.legalHold", team: "users.team" },
teams: { id: "teams.id", legalHold: "teams.legalHold" },
jobs: {
id: "jobs.id",
userId: "jobs.userId",
createdAt: "jobs.createdAt",
completedAt: "jobs.completedAt",
deleteAfter: "jobs.deleteAfter",
},
settings: { key: "settings.key", value: "settings.value" },
}));
function tableNameOf(table: unknown): TableName {
if (table === schemaMock.users) return "users";
if (table === schemaMock.teams) return "teams";
if (table === schemaMock.jobs) return "jobs";
if (table === schemaMock.settings) return "settings";
throw new Error("Unexpected table passed to db.select().from()");
}
// Sentinel: a queued THROW marker makes the resolved builder reject, letting a
// test drive the outer try/catch around a select() call.
const THROW = vi.hoisted(() => ({ __throw: new Error("select failed") }));
function makeBuilder() {
let resolved: unknown[] = [];
let rejection: Error | null = null;
const builder: Record<string, unknown> = {
from(table: unknown) {
const name = tableNameOf(table);
const queue = selectQueues[name];
const next = queue.length > 0 ? queue.shift() : [];
if (next === THROW) {
rejection = THROW.__throw;
resolved = [];
} else {
rejection = null;
resolved = next as unknown[];
}
return builder;
},
where() {
return builder;
},
limit() {
return builder;
},
// biome-ignore lint/suspicious/noThenProperty: intentional thenable mocking an awaitable Drizzle query builder
then(onFulfilled: (v: unknown[]) => unknown, onRejected?: (e: unknown) => unknown) {
if (rejection) return Promise.reject(rejection).then(onFulfilled, onRejected);
return Promise.resolve(resolved).then(onFulfilled, onRejected);
},
};
return builder;
}
function queueSelect(table: TableName, rows: unknown[] | typeof THROW): void {
selectQueues[table].push(rows as unknown[]);
}
function resetSelectQueues(): void {
selectQueues.users = [];
selectQueues.teams = [];
selectQueues.jobs = [];
selectQueues.settings = [];
}
async function loadSystemJobs(
envOverrides: Record<string, unknown> = {},
): Promise<typeof import("../../../../apps/api/src/jobs/system-jobs.js")> {
vi.resetModules();
resetSelectQueues();
getQueueMock.mockReset();
runSiemForwardMock.mockReset();
runAuditArchiveMock.mockReset();
getMaxAgeMsMock.mockReset();
deletePrefixMock.mockReset();
listJobDirsMock.mockReset();
getSettingNumberMock.mockReset();
analyticsEnabledMock.mockReset();
dbExecuteMock.mockReset();
dbUpdateMock.mockReset();
// Sensible defaults; individual tests override.
deletePrefixMock.mockResolvedValue(undefined);
listJobDirsMock.mockResolvedValue([]);
dbExecuteMock.mockResolvedValue(undefined);
analyticsEnabledMock.mockReturnValue(true);
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: 15,
JOBS_RETENTION_DAYS: 30,
AUDIT_RETENTION_DAYS: 90,
...envOverrides,
},
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
db: {
select: vi.fn(() => makeBuilder()),
execute: dbExecuteMock,
update: dbUpdateMock,
},
schema: schemaMock,
}));
vi.doMock("../../../../apps/api/src/lib/analytics-gate.js", () => ({
analyticsEnabled: analyticsEnabledMock,
}));
vi.doMock("../../../../apps/api/src/lib/cleanup.js", () => ({
getMaxAgeMs: getMaxAgeMsMock,
}));
vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({
deletePrefix: deletePrefixMock,
listJobDirs: listJobDirsMock,
}));
vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({
getSettingNumber: getSettingNumberMock,
}));
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,
}));
return import("../../../../apps/api/src/jobs/system-jobs.js");
}
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...ORIGINAL_ENV };
});
// -- scheduleSystemJobs / enqueueSystemJob ------------------------------------
describe("scheduleSystemJobs (interval > 0)", () => {
it("upserts the storageTtl scheduler with the configured interval", async () => {
const queue = {
upsertJobScheduler: vi.fn().mockResolvedValue(undefined),
removeJobScheduler: vi.fn().mockResolvedValue(undefined),
};
const { SYSTEM_JOBS, scheduleSystemJobs } = await loadSystemJobs({
CLEANUP_INTERVAL_MINUTES: 15,
});
getQueueMock.mockReturnValue(queue);
await scheduleSystemJobs();
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageTtl, {
every: 15 * 60_000,
});
// The disabled-path removeJobScheduler must NOT run when interval > 0.
expect(queue.removeJobScheduler).not.toHaveBeenCalled();
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.siemForward, {
every: 30_000,
});
expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.alertEvaluator, {
every: 60_000,
});
});
});
describe("enqueueSystemJob", () => {
it("adds a payload-less job under the given name to the system queue", async () => {
const add = vi.fn().mockResolvedValue(undefined);
const { enqueueSystemJob } = await loadSystemJobs();
getQueueMock.mockReturnValue({ add });
await enqueueSystemJob("system:storage-ttl");
expect(getQueueMock).toHaveBeenCalledWith("system");
expect(add).toHaveBeenCalledWith("system:storage-ttl", {});
});
});
// -- withCronMonitor via runSystemJob -----------------------------------------
describe("withCronMonitor", () => {
it("runs the job directly when the job name has no monitor config", async () => {
// siemForward has no MONITOR_CONFIG entry, so withCronMonitor short-circuits.
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
runSiemForwardMock.mockResolvedValue({ forwarded: 3 });
await expect(runSystemJob({ name: SYSTEM_JOBS.siemForward } as never)).resolves.toEqual({
forwarded: 3,
});
});
it("runs the job directly when cron monitors are disabled", async () => {
// sessionPurge HAS a monitor config, but SENTRY_CRON_MONITORS is unset.
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
// sessionPurge executes a DELETE via db.execute.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
it("runs the job directly when analytics is disabled even if the env flag is set", async () => {
process.env.SENTRY_CRON_MONITORS = "true";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
analyticsEnabledMock.mockReturnValue(false);
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
expect(analyticsEnabledMock).toHaveBeenCalled();
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
it("falls back to running the job when the Sentry import path throws", async () => {
// Enabled path: cfg present + monitors enabled -> tries to import @sentry/node.
// We don't mock @sentry/node; whether the import resolves or the catch fires,
// the underlying job must still run. Assert on the job's observable effect.
process.env.SENTRY_CRON_MONITORS = "1";
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
await runSystemJob({ name: SYSTEM_JOBS.sessionPurge } as never);
// Whether withMonitor wraps fn or the catch fallback re-runs it, the
// underlying sessionPurge DELETE must have executed.
expect(dbExecuteMock).toHaveBeenCalled();
});
});
// -- dispatchSystemJob: retention + auditArchive ------------------------------
describe("dispatchSystemJob retention", () => {
it("deletes old jobs and old audit rows when both retentions are positive and tamper mode is off", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(30).mockResolvedValueOnce(90);
// tamperResistantAudit setting lookup returns no rows -> not tamper resistant.
queueSelect("settings", []);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
// One DELETE FROM jobs + one DELETE FROM audit_log.
expect(dbExecuteMock).toHaveBeenCalledTimes(2);
expect(getSettingNumberMock).toHaveBeenCalledWith("jobsRetentionDays", 30);
expect(getSettingNumberMock).toHaveBeenCalledWith("auditRetentionDays", 90);
});
it("skips the jobs delete when jobsRetentionDays is 0", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(0).mockResolvedValueOnce(90);
queueSelect("settings", []);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
// Only the audit_log delete runs.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
it("skips the audit delete when auditRetentionDays is 0", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(30).mockResolvedValueOnce(0);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
// Only the jobs delete runs; the settings lookup for tamper mode is skipped.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
it("preserves audit rows when tamper-resistant mode is on", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
getSettingNumberMock.mockResolvedValueOnce(30).mockResolvedValueOnce(90);
queueSelect("settings", [{ value: "true" }]);
await runSystemJob({ name: SYSTEM_JOBS.retention } as never);
// Jobs delete runs, but the audit_log delete is suppressed.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
});
});
describe("dispatchSystemJob auditArchive", () => {
it("delegates to runAuditArchive", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
runAuditArchiveMock.mockResolvedValue(undefined);
await runSystemJob({ name: SYSTEM_JOBS.auditArchive } as never);
expect(runAuditArchiveMock).toHaveBeenCalledTimes(1);
});
});
// -- storageTtlSweep ----------------------------------------------------------
describe("storageTtlSweep", () => {
// Helper: minimal held-user/held-team select seeding for a sweep with no
// legal holds (the common case).
function seedNoLegalHold(): void {
queueSelect("users", []); // held direct users
queueSelect("teams", []); // held teams (length 0 -> team-user lookup skipped)
}
it("cleans jobs by deleteAfter, then early-returns when maxAgeMs <= 0", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
seedNoLegalHold();
// Two jobs past their deleteAfter deadline.
queueSelect("jobs", [
{ id: "job-a", userId: null },
{ id: "job-b", userId: "user-x" },
]);
getMaxAgeMsMock.mockResolvedValue(0);
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
const logCalls = logSpy.mock.calls.map((c) => c[0]);
expect(result).toEqual({ removed: 2, failed: 0 });
// Two prefixes per job (uploads + outputs) x 2 jobs.
expect(deletePrefixMock).toHaveBeenCalledWith("uploads/job-a");
expect(deletePrefixMock).toHaveBeenCalledWith("outputs/job-a");
expect(deletePrefixMock).toHaveBeenCalledTimes(4);
expect(logCalls).toContain("Storage TTL: cleaned up 2 jobs by deleteAfter");
logSpy.mockRestore();
});
it("skips deleteAfter jobs belonging to a legal-hold user", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", [{ id: "held-user" }]); // one held user
queueSelect("teams", []); // no held teams
// First deleteAfter job is held (skipped), second is deletable.
queueSelect("jobs", [
{ id: "job-held", userId: "held-user" },
{ id: "job-free", userId: "other" },
]);
getMaxAgeMsMock.mockResolvedValue(0);
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
expect(result).toEqual({ removed: 1, failed: 0 });
expect(deletePrefixMock).toHaveBeenCalledWith("uploads/job-free");
expect(deletePrefixMock).not.toHaveBeenCalledWith("uploads/job-held");
});
it("expands legal hold through team membership", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []); // no direct held users
queueSelect("teams", [{ id: "team-1" }]); // one held team
queueSelect("users", [{ id: "team-user" }]); // members of held teams
// deleteAfter job belongs to the team member -> must be skipped.
queueSelect("jobs", [{ id: "job-team", userId: "team-user" }]);
getMaxAgeMsMock.mockResolvedValue(0);
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
expect(result).toEqual({ removed: 0, failed: 0 });
expect(deletePrefixMock).not.toHaveBeenCalled();
});
it("swallows a deleteAfter deletePrefix failure without incrementing the counter", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", [{ id: "boom", userId: null }]);
deletePrefixMock.mockRejectedValue(new Error("S3 down"));
getMaxAgeMsMock.mockResolvedValue(0);
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// The try/catch swallows the failure; deleteAfterCleaned stays 0.
expect(result).toEqual({ removed: 0, failed: 0 });
});
it("recovers when the whole deleteAfter query throws (best-effort), then runs the global sweep", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
// The deleteAfter select rejects; the outer try/catch must swallow it.
queueSelect("jobs", THROW);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const oldMtime = Date.now() - 7_200_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") return [{ key: "uploads/after-throw", size: 0, mtimeMs: oldMtime }];
return [];
});
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
// deleteAfterCleaned stayed 0 (query threw); the global sweep still removed 1.
expect(result).toEqual({ removed: 1, failed: 0 });
expect(deletePrefixMock).toHaveBeenCalledWith("uploads/after-throw");
});
it("expires stale local dirs in the global sweep and logs the removal", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []); // no deleteAfter jobs
getMaxAgeMsMock.mockResolvedValue(3_600_000); // 1h max age
const now = Date.now();
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") {
return [
{ key: "uploads/stale", size: 0, mtimeMs: now - 7_200_000 }, // 2h old -> expired
{ key: "uploads/fresh", size: 0, mtimeMs: now }, // fresh -> kept
];
}
return [];
});
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
const logCalls = logSpy.mock.calls.map((c) => c[0]);
expect(result).toEqual({ removed: 1, failed: 0 });
expect(deletePrefixMock).toHaveBeenCalledWith("uploads/stale");
expect(deletePrefixMock).not.toHaveBeenCalledWith("uploads/fresh");
expect(logCalls).toContain("Storage TTL: removed 1 expired job dirs");
logSpy.mockRestore();
});
it("records failures and logs them when a global-sweep deletePrefix throws", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const oldMtime = Date.now() - 7_200_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") {
return [{ key: "uploads/broken", size: 0, mtimeMs: oldMtime }];
}
return [{ key: "outputs/okay", size: 0, mtimeMs: oldMtime }];
});
deletePrefixMock.mockImplementation(async (prefix: string) => {
if (prefix === "uploads/broken") throw new Error("perm denied");
});
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
const errCalls = errSpy.mock.calls.map((c) => c[0]);
expect(result).toEqual({ removed: 1, failed: 1 });
expect(errCalls.some((m) => String(m).includes("uploads/broken: perm denied"))).toBe(true);
errSpy.mockRestore();
});
it("stringifies non-Error rejections from a failed deletePrefix", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []);
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const oldMtime = Date.now() - 7_200_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") return [{ key: "uploads/weird", size: 0, mtimeMs: oldMtime }];
return [];
});
// Reject with a non-Error value to hit the String(err) branch.
deletePrefixMock.mockRejectedValue("plain string failure");
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
const errCalls = errSpy.mock.calls.map((c) => c[0]);
expect(result).toEqual({ removed: 0, failed: 1 });
expect(errCalls.some((m) => String(m).includes("uploads/weird: plain string failure"))).toBe(
true,
);
errSpy.mockRestore();
});
it("skips deleting expired dirs whose job belongs to a legal-hold user", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", [{ id: "vip" }]); // one held user -> triggers jobUserMap lookup
queueSelect("teams", []);
queueSelect("jobs", []); // no deleteAfter jobs
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const oldMtime = Date.now() - 7_200_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") {
return [
{ key: "uploads/held-dir", size: 0, mtimeMs: oldMtime },
{ key: "uploads/free-dir", size: 0, mtimeMs: oldMtime },
];
}
return [];
});
// jobUserMap lookup: held-dir -> vip (held), free-dir -> someone else.
queueSelect("jobs", [
{ id: "held-dir", userId: "vip" },
{ id: "free-dir", userId: "nobody" },
]);
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
expect(result).toEqual({ removed: 1, failed: 0 });
expect(deletePrefixMock).toHaveBeenCalledWith("uploads/free-dir");
expect(deletePrefixMock).not.toHaveBeenCalledWith("uploads/held-dir");
});
it("resolves S3 dirs (mtimeMs=0) against the batched jobs rows", async () => {
delete process.env.SENTRY_CRON_MONITORS;
const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs();
queueSelect("users", []);
queueSelect("teams", []);
queueSelect("jobs", []); // no deleteAfter jobs
getMaxAgeMsMock.mockResolvedValue(3_600_000);
const cutoff = Date.now() - 3_600_000;
listJobDirsMock.mockImplementation(async (prefix: "uploads" | "outputs") => {
if (prefix === "uploads") {
return [
{ key: "uploads/s3-old", size: 0, mtimeMs: 0 },
{ key: "uploads/s3-new", size: 0, mtimeMs: 0 },
];
}
return [];
});
// Batched rows lookup for the unknown-mtime dirs.
queueSelect("jobs", [
{ id: "s3-old", createdAt: new Date(cutoff - 10_000), completedAt: null },
{ id: "s3-new", createdAt: new Date(cutoff + 10_000), completedAt: null },
]);
const result = await runSystemJob({ name: SYSTEM_JOBS.storageTtl } as never);
expect(result).toEqual({ removed: 1, failed: 0 });
expect(deletePrefixMock).toHaveBeenCalledWith("uploads/s3-old");
expect(deletePrefixMock).not.toHaveBeenCalledWith("uploads/s3-new");
});
});