mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
* feat: enforce RBAC and harden collaboration features - Mount requirePermission on hosts/snippets/credentials/automations/AI routes - Seed and backfill system role permissions on every dialect at startup - Support personal credential overrides for RDP/VNC/Telnet shared hosts - Broadcast participant presence in shared terminal sessions - Make audit log forwarding configurable from the admin panel - Add role members endpoint and snippet folder sharing * fix: enforce RBAC across split routes
170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
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" }),
|
|
);
|
|
});
|
|
});
|