feat: move desktop, status, import, audit and branding features into plugins

This commit is contained in:
LukeGus committed 2026-10-18 21:17:54 -05:00
1 parent 9d93fb0893
commit 1cdbfc674c
166 files changed
+21298 -12041

No files matched your search

@@ -1,125 +0,0 @@
import { describe, expect, it } from "vitest";
import {
escapeCsvField,
exportFilename,
toCsv,
toNdjson,
} from "../../utils/audit-export.js";
import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js";
function entry(overrides: Partial<AuditLogRecord> = {}): AuditLogRecord {
return {
id: 1,
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
resourceName: "prod-db",
details: null,
ipAddress: "203.0.113.9",
userAgent: "Mozilla/5.0",
success: true,
errorMessage: null,
timestamp: "2026-07-28 10:00:00",
...overrides,
} as AuditLogRecord;
}
describe("escapeCsvField", () => {
it("leaves plain values alone", () => {
expect(escapeCsvField("prod-db")).toBe("prod-db");
expect(escapeCsvField(42)).toBe("42");
expect(escapeCsvField(true)).toBe("true");
});
it("renders null and undefined as empty", () => {
expect(escapeCsvField(null)).toBe("");
expect(escapeCsvField(undefined)).toBe("");
});
it("quotes and doubles embedded quotes", () => {
expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""');
});
it("quotes values containing commas or newlines", () => {
expect(escapeCsvField("a,b")).toBe('"a,b"');
expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"');
});
it("neutralises spreadsheet formulas", () => {
// An audit entry can carry an attacker-chosen resource name; without this
// the exported file executes it when opened.
expect(escapeCsvField("=1+1")).toBe("'=1+1");
expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)");
expect(escapeCsvField("-2+3")).toBe("'-2+3");
expect(escapeCsvField("@import")).toBe("'@import");
});
it("still quotes a formula that also contains a comma", () => {
expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`);
});
});
describe("toCsv", () => {
it("writes a header even with no rows", () => {
expect(toCsv([])).toBe(
"id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n",
);
});
it("writes one line per entry in column order", () => {
const lines = toCsv([entry(), entry({ id: 2, username: "bob" })])
.trim()
.split("\n");
expect(lines).toHaveLength(3);
expect(
lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"),
).toBe(true);
expect(lines[2].startsWith("2,")).toBe(true);
});
it("keeps a detached entry readable", () => {
const line = toCsv([entry({ userId: null })])
.trim()
.split("\n")[1];
// username survives so the row still names who acted.
expect(line).toContain("alice");
expect(line.split(",")[3]).toBe("");
});
});
describe("toNdjson", () => {
it("emits one parseable object per line", () => {
const out = toNdjson([entry(), entry({ id: 2 })]);
const parsed = out
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(parsed).toHaveLength(2);
expect(parsed[0].action).toBe("delete_host");
expect(parsed[1].id).toBe(2);
});
it("returns nothing for an empty set", () => {
expect(toNdjson([])).toBe("");
});
});
describe("exportFilename", () => {
it("is filesystem-safe and carries the timestamp", () => {
const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z"));
expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv");
expect(name).not.toMatch(/[:\s]/);
});
it("uses the ndjson extension for the streaming format", () => {
expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe(
"termix-audit-2026-07-28-10-11-12.ndjson",
);
});
});
@@ -1,169 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const safeFetch = vi.hoisted(() => vi.fn());
const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() }));
vi.mock("../../database/repositories/factory.js", () => ({
getCurrentSettingValue: () => null,
}));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: safeFetch,
}));
vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs }));
const {
auditForwardTarget,
forwardAuditEntry,
forwardPayload,
resetAuditForwarderState,
AUDIT_FORWARD_URL_ENV,
AUDIT_FORWARD_TOKEN_ENV,
} = await import("../../utils/audit-forwarder.js");
const ENTRY = {
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
success: true,
ipAddress: "203.0.113.9",
};
const NOW = new Date("2026-07-28T10:00:00.000Z");
beforeEach(() => {
safeFetch.mockReset();
logs.info.mockReset();
logs.warn.mockReset();
resetAuditForwarderState();
});
describe("auditForwardTarget", () => {
it("is off unless a URL is configured", () => {
expect(auditForwardTarget({})).toBeNull();
expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull();
});
it("carries an optional bearer token", () => {
expect(
auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }),
).toEqual({ url: "https://siem/ingest" });
expect(
auditForwardTarget({
[AUDIT_FORWARD_URL_ENV]: "https://siem/ingest",
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
}),
).toEqual({ url: "https://siem/ingest", token: "secret" });
});
});
describe("forwardPayload", () => {
it("matches the export shape, with absent fields as null", () => {
expect(forwardPayload(ENTRY, NOW)).toEqual({
timestamp: "2026-07-28T10:00:00.000Z",
userId: "u-1",
username: "alice",
action: "delete_host",
resourceType: "host",
resourceId: "9",
resourceName: null,
success: true,
ipAddress: "203.0.113.9",
userAgent: null,
errorMessage: null,
details: null,
});
});
});
describe("forwardAuditEntry", () => {
const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" };
it("does nothing when forwarding is not configured", async () => {
await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false);
expect(safeFetch).not.toHaveBeenCalled();
});
it("posts one NDJSON line through the SSRF-checked fetch", async () => {
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true);
const [url, init] = safeFetch.mock.calls[0];
expect(url).toBe("https://siem.example/ingest");
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/x-ndjson");
expect(init.headers.Authorization).toBeUndefined();
expect(JSON.parse(init.body.trim()).action).toBe("delete_host");
expect(init.body.endsWith("\n")).toBe(true);
});
it("sends the bearer token when one is set", async () => {
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await forwardAuditEntry(ENTRY, NOW, {
...env,
[AUDIT_FORWARD_TOKEN_ENV]: "secret",
});
expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe(
"Bearer secret",
);
});
it("reports a rejected delivery without throwing", async () => {
safeFetch.mockResolvedValueOnce({ ok: false, status: 503 });
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
expect(logs.warn).toHaveBeenCalledWith(
"Failed to forward audit entry",
expect.objectContaining({ reason: "collector returned 503" }),
);
});
it("swallows transport errors, a dead SIEM must not break auditing", async () => {
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
expect(logs.warn).toHaveBeenCalledWith(
"Failed to forward audit entry",
expect.objectContaining({ reason: "ECONNREFUSED" }),
);
});
it("stops repeating itself once the collector is persistently down", async () => {
safeFetch.mockResolvedValue({ ok: false, status: 500 });
for (let i = 0; i < 8; i++) {
await forwardAuditEntry(ENTRY, NOW, env);
}
// 5 per-entry warnings, then one suppression notice, not 8.
const perEntry = logs.warn.mock.calls.filter(
(call) => call[0] === "Failed to forward audit entry",
);
expect(perEntry).toHaveLength(5);
expect(
logs.warn.mock.calls.some((call) =>
String(call[0]).includes("suppressing further messages"),
),
).toBe(true);
// It keeps trying regardless.
expect(safeFetch).toHaveBeenCalledTimes(8);
});
it("announces recovery after a suppressed outage", async () => {
safeFetch.mockResolvedValue({ ok: false, status: 500 });
for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env);
safeFetch.mockResolvedValueOnce({ ok: true, status: 200 });
await forwardAuditEntry(ENTRY, NOW, env);
expect(logs.info).toHaveBeenCalledWith(
"Audit forwarding recovered",
expect.objectContaining({ operation: "audit_forward_recovered" }),
);
});
});