mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user