test: add enterprise feature test coverage

Tests for SCIM, GDPR lifecycle, legal hold, audit export/archival,
encryption, SIEM forwarding, MFA endpoints, IP allowlist, config
export/import, webhook management, and license validation.
This commit is contained in:
SnapOtter
2026-06-14 16:50:35 +08:00
parent 8403222d08
commit 112df13957
22 changed files with 3352 additions and 4 deletions
+60 -1
View File
@@ -73,7 +73,66 @@ describe("audit export", () => {
url: "/api/v1/enterprise/audit/export?format=json",
headers: { authorization: `Bearer ${userToken}` },
});
// Regular users lack audit:read, so they get 403 before the enterprise check
expect(res.statusCode).toBe(403);
});
it("returns 403 for valid json format without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/audit/export?format=json",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("returns 403 for valid csv format without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/audit/export?format=csv",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("returns 403 before validating invalid format parameter", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/audit/export?format=xml",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
});
it("accepts from/to ISO datetime query parameters", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/audit/export?format=json&from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("returns 403 with default format when format is omitted", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/audit/export",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
});
it("returns 401 with expired or invalid token", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/audit/export?format=json",
headers: { authorization: "Bearer invalid-token-value" },
});
expect(res.statusCode).toBe(401);
});
});
@@ -0,0 +1,346 @@
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("config export without enterprise license", () => {
it("returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/config/export",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/config/export",
});
expect(res.statusCode).toBe(401);
});
});
describe("config import without enterprise license", () => {
it("returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
dryRun: false,
config: { configSchemaVersion: 1 },
},
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
payload: {
dryRun: false,
config: { configSchemaVersion: 1 },
},
});
expect(res.statusCode).toBe(401);
});
});
describe("config export with enterprise license", () => {
let licensedApp: TestApp;
let licensedToken: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["config_export_import"]);
const { buildTestApp, loginAsAdmin } = await import("./test-server.js");
licensedApp = await buildTestApp();
licensedToken = await loginAsAdmin(licensedApp.app);
}, 30_000);
afterAll(async () => {
await licensedApp.cleanup();
vi.restoreAllMocks();
}, 10_000);
it("returns 200 with config object", async () => {
const res = await licensedApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/config/export",
headers: { authorization: `Bearer ${licensedToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body).toBeDefined();
expect(typeof body).toBe("object");
});
it("config has configSchemaVersion field", async () => {
const res = await licensedApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/config/export",
headers: { authorization: `Bearer ${licensedToken}` },
});
const body = JSON.parse(res.body);
expect(body.configSchemaVersion).toBe(1);
});
it("config has settings object", async () => {
const res = await licensedApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/config/export",
headers: { authorization: `Bearer ${licensedToken}` },
});
const body = JSON.parse(res.body);
expect(body.settings).toBeDefined();
expect(typeof body.settings).toBe("object");
});
it("redacted keys are not present in export", async () => {
const redactedKeys = [
"cookie_secret",
"instance_id",
"siem_config",
"scim_token_hash",
"oidc_client_secret",
"saml_idp_certificate",
"siem_last_forwarded_at",
"siem_consecutive_failures",
"audit_archival_state",
"backup_last_completed",
"webhook_destinations",
];
for (const key of redactedKeys) {
await db.insert(schema.settings).values({ key, value: "secret-value" }).onConflictDoNothing();
}
const res = await licensedApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/config/export",
headers: { authorization: `Bearer ${licensedToken}` },
});
const body = JSON.parse(res.body);
for (const key of redactedKeys) {
expect(body.settings[key]).toBeUndefined();
}
});
});
describe("config import with enterprise license", () => {
let licensedApp: TestApp;
let licensedToken: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["config_export_import"]);
const { buildTestApp, loginAsAdmin } = await import("./test-server.js");
licensedApp = await buildTestApp();
licensedToken = await loginAsAdmin(licensedApp.app);
}, 30_000);
afterAll(async () => {
await licensedApp.cleanup();
vi.restoreAllMocks();
}, 10_000);
it("returns 403 for non-admin users", async () => {
await licensedApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
username: "configimportuser",
password: "TestPass1",
role: "user",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "configimportuser"));
const loginRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "configimportuser", password: "TestPass1" },
});
const userToken = JSON.parse(loginRes.body).token;
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${userToken}` },
payload: {
dryRun: false,
config: { configSchemaVersion: 1 },
},
});
expect(res.statusCode).toBe(403);
});
it("dry-run mode returns changes without applying", async () => {
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
dryRun: true,
config: {
configSchemaVersion: 1,
settings: { testSetting: "hello" },
},
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.dryRun).toBe(true);
expect(body.changes).toBeDefined();
expect(body.changes.settings).toBeGreaterThanOrEqual(1);
expect(body.details).toBeDefined();
expect(body.details.settings).toEqual(
expect.arrayContaining([expect.objectContaining({ key: "testSetting" })]),
);
});
it("rejects future schema versions", async () => {
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
dryRun: false,
config: { configSchemaVersion: 999 },
},
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toContain("Unsupported config schema version");
});
it("empty config import succeeds with no changes", async () => {
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
dryRun: false,
config: { configSchemaVersion: 1 },
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.applied).toBe(true);
expect(body.changes.settings).toBe(0);
expect(body.changes.roles).toBe(0);
expect(body.changes.teams).toBe(0);
});
it("import with valid settings applies them", async () => {
const settingKey = "configImportTestKey";
const settingValue = "configImportTestValue";
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
dryRun: false,
config: {
configSchemaVersion: 1,
settings: { [settingKey]: settingValue },
},
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.applied).toBe(true);
expect(body.changes.settings).toBe(1);
const [row] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, settingKey));
expect(row).toBeDefined();
expect(row.value).toBe(settingValue);
});
});
describe("config round-trip", () => {
let licensedApp: TestApp;
let licensedToken: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["config_export_import"]);
const { buildTestApp, loginAsAdmin } = await import("./test-server.js");
licensedApp = await buildTestApp();
licensedToken = await loginAsAdmin(licensedApp.app);
}, 30_000);
afterAll(async () => {
await licensedApp.cleanup();
vi.restoreAllMocks();
}, 10_000);
it("export then dry-run import reports 0 changes", async () => {
const exportRes = await licensedApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/config/export",
headers: { authorization: `Bearer ${licensedToken}` },
});
expect(exportRes.statusCode).toBe(200);
const exported = JSON.parse(exportRes.body);
const importRes = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
dryRun: true,
config: {
configSchemaVersion: exported.configSchemaVersion,
settings: exported.settings,
roles: exported.roles,
teams: exported.teams,
},
},
});
expect(importRes.statusCode).toBe(200);
const body = JSON.parse(importRes.body);
expect(body.dryRun).toBe(true);
for (const detail of body.details.settings) {
expect(detail.action).toBe("update");
}
for (const detail of body.details.roles) {
expect(detail.action).toBe("update");
}
for (const detail of body.details.teams) {
expect(detail.action).toBe("update");
}
});
});
+101
View File
@@ -161,3 +161,104 @@ describe("GDPR data purge", () => {
expect(res.statusCode).toBe(401);
});
});
describe("GDPR export additional validation", () => {
it("returns 401 for POST export with invalid token", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/users/some-id/export",
headers: { authorization: "Bearer invalid-token-value" },
});
expect(res.statusCode).toBe(401);
});
it("returns 401 for GET export status with invalid token", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/users/some-id/export/some-job-id",
headers: { authorization: "Bearer invalid-token-value" },
});
expect(res.statusCode).toBe(401);
});
});
describe("GDPR purge body validation", () => {
it("rejects purge with confirm as string instead of boolean", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/users/some-id/purge",
headers: { authorization: `Bearer ${adminToken}` },
payload: { confirm: "true" },
});
expect([400, 403]).toContain(res.statusCode);
});
it("rejects purge with confirm: null", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/users/some-id/purge",
headers: { authorization: `Bearer ${adminToken}` },
payload: { confirm: null },
});
expect([400, 403]).toContain(res.statusCode);
});
it("rejects purge with no body at all", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/users/some-id/purge",
headers: { authorization: `Bearer ${adminToken}` },
});
expect([400, 403]).toContain(res.statusCode);
});
it("returns 403 for non-existent user ID without enterprise license", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/users/00000000-0000-0000-0000-000000000000/purge",
headers: { authorization: `Bearer ${adminToken}` },
payload: { confirm: true },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
});
describe("GDPR edge cases", () => {
it("sequential purge requests for same user return consistent results", async () => {
const first = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/users/some-id/purge",
headers: { authorization: `Bearer ${adminToken}` },
payload: { confirm: true },
});
const second = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/users/some-id/purge",
headers: { authorization: `Bearer ${adminToken}` },
payload: { confirm: true },
});
expect(first.statusCode).toBe(second.statusCode);
expect(first.statusCode).toBe(403);
});
it("export initiation rejects GET method", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/users/some-id/export",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
it("purge endpoint rejects POST method", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/users/some-id/purge",
headers: { authorization: `Bearer ${adminToken}` },
payload: { confirm: true },
});
expect(res.statusCode).toBe(404);
});
});
@@ -0,0 +1,222 @@
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("IP allowlist without enterprise license", () => {
it("GET returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("PUT returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${adminToken}` },
payload: { cidrs: ["10.0.0.0/8"] },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("GET returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/ip-allowlist",
});
expect(res.statusCode).toBe(401);
});
it("PUT returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
payload: { cidrs: ["10.0.0.0/8"] },
});
expect(res.statusCode).toBe(401);
});
});
describe("IP allowlist with enterprise license", () => {
let enterpriseApp: TestApp;
let entAdminToken: string;
let userToken: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["ip_allowlist"]);
const { buildTestApp, loginAsAdmin } = await import("./test-server.js");
enterpriseApp = await buildTestApp();
entAdminToken = await loginAsAdmin(enterpriseApp.app);
await enterpriseApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
username: "ipallowlistuser",
password: "TestPass1",
role: "user",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "ipallowlistuser"));
const loginRes = await enterpriseApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "ipallowlistuser", password: "TestPass1" },
});
userToken = JSON.parse(loginRes.body).token;
}, 30_000);
afterAll(async () => {
await enterpriseApp.cleanup();
vi.resetModules();
}, 10_000);
it("GET returns 200 with empty cidrs initially", async () => {
const res = await enterpriseApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.cidrs).toEqual([]);
});
it("GET returns 403 for non-admin users", async () => {
const res = await enterpriseApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${userToken}` },
});
expect(res.statusCode).toBe(403);
});
it("PUT returns 403 for non-admin users", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${userToken}` },
payload: { cidrs: ["10.0.0.0/8"] },
});
expect(res.statusCode).toBe(403);
});
it("PUT accepts empty cidrs array (clears allowlist)", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: { cidrs: [] },
});
expect(res.statusCode).toBe(200);
});
it("PUT rejects invalid CIDR values", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: { cidrs: ["not-a-cidr"] },
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.code).toBe("INVALID_CIDR");
});
it("PUT rejects entries longer than 45 characters", async () => {
const longEntry = "a".repeat(46);
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: { cidrs: [longEntry] },
});
expect(res.statusCode).toBe(400);
});
it("PUT rejects more than 1000 entries", async () => {
const cidrs = Array.from(
{ length: 1001 },
(_, i) => `10.0.${Math.floor(i / 256)}.${i % 256}/32`,
);
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: { cidrs },
});
expect(res.statusCode).toBe(400);
});
it("GET returns saved CIDRs after PUT", async () => {
const cidrs = ["10.0.0.0/8", "172.16.0.0/12", "127.0.0.0/8"];
const putRes = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: { cidrs },
});
expect(putRes.statusCode).toBe(200);
const getRes = await enterpriseApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(getRes.statusCode).toBe(200);
const body = JSON.parse(getRes.body);
expect(body.cidrs).toEqual(cidrs);
});
it("PUT returns SELF_LOCKOUT when CIDRs would block the admin IP", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: { cidrs: ["192.168.0.0/16"] },
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.code).toBe("SELF_LOCKOUT");
});
it("PUT allows CIDRs that include the admin IP", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/ip-allowlist",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: { cidrs: ["127.0.0.0/8"] },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
});
});
+116
View File
@@ -89,3 +89,119 @@ describe("legal hold", () => {
expect(res.statusCode).toBe(403);
});
});
describe("legal hold PUT validation", () => {
it("rejects missing targetType field", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${adminToken}` },
payload: { targetId: "some-id", hold: true },
});
expect([400, 403]).toContain(res.statusCode);
});
it("rejects invalid targetType value", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${adminToken}` },
payload: { targetType: "organization", targetId: "some-id", hold: true },
});
expect([400, 403]).toContain(res.statusCode);
});
it("rejects missing targetId field", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${adminToken}` },
payload: { targetType: "user", hold: true },
});
expect([400, 403]).toContain(res.statusCode);
});
it("rejects empty targetId string", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${adminToken}` },
payload: { targetType: "user", targetId: "", hold: true },
});
expect([400, 403]).toContain(res.statusCode);
});
it("rejects missing hold field", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${adminToken}` },
payload: { targetType: "user", targetId: "some-id" },
});
expect([400, 403]).toContain(res.statusCode);
});
});
describe("legal hold GET response structure", () => {
it("returns error object with enterprise message on 403", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body).toHaveProperty("error");
expect(typeof body.error).toBe("string");
});
});
describe("legal hold permission enforcement", () => {
it("returns 403 for editor role on PUT", async () => {
await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
username: "legalholdeditor",
password: "TestPass1",
role: "editor",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "legalholdeditor"));
const loginRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "legalholdeditor", password: "TestPass1" },
});
const editorToken = JSON.parse(loginRes.body).token;
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${editorToken}` },
payload: { targetType: "user", targetId: "some-id", hold: true },
});
expect(res.statusCode).toBe(403);
});
it("returns 403 for editor role on GET", async () => {
const loginRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "legalholdeditor", password: "TestPass1" },
});
const editorToken = JSON.parse(loginRes.body).token;
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/legal-hold",
headers: { authorization: `Bearer ${editorToken}` },
});
expect(res.statusCode).toBe(403);
});
});
+309
View File
@@ -0,0 +1,309 @@
import { eq } from "drizzle-orm";
import * as OTPAuth from "otpauth";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["mfa"]);
const { buildTestApp, loginAsAdmin } = await import("./test-server.js");
const { db, schema } = await import("../../apps/api/src/db/index.js");
import type { TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
function generateTotpCode(uri: string): string {
const totp = OTPAuth.URI.parse(uri) as OTPAuth.TOTP;
return totp.generate();
}
async function clearMfaState(username: string): Promise<void> {
await db
.update(schema.users)
.set({
totpSecret: null,
totpEnabled: false,
recoveryCodesHash: null,
updatedAt: new Date(),
})
.where(eq(schema.users.username, username));
}
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await clearMfaState("admin");
await testApp.cleanup();
}, 10_000);
describe("POST /api/auth/mfa/enroll", () => {
afterEach(async () => {
await clearMfaState("admin");
});
it("returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
});
expect(res.statusCode).toBe(401);
});
it("returns TOTP URI and recovery codes on success", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.uri).toBeDefined();
expect(body.recoveryCodes).toBeDefined();
expect(Array.isArray(body.recoveryCodes)).toBe(true);
});
it("recovery codes array has 8 entries", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
const body = JSON.parse(res.body);
expect(body.recoveryCodes).toHaveLength(8);
});
it("URI contains otpauth://totp/", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
const body = JSON.parse(res.body);
expect(body.uri).toContain("otpauth://totp/");
});
it("returns 409 when MFA is already enabled", async () => {
const enrollRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
const { uri } = JSON.parse(enrollRes.body);
const code = generateTotpCode(uri);
await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
headers: { authorization: `Bearer ${adminToken}` },
payload: { code },
});
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(409);
const body = JSON.parse(res.body);
expect(body.code).toBe("MFA_ALREADY_ENABLED");
});
});
describe("POST /api/auth/mfa/verify", () => {
afterEach(async () => {
await clearMfaState("admin");
});
it("returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
payload: { code: "123456" },
});
expect(res.statusCode).toBe(401);
});
it("returns 400 with missing code", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
headers: { authorization: `Bearer ${adminToken}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it("returns 401 with invalid code", async () => {
await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
headers: { authorization: `Bearer ${adminToken}` },
payload: { code: "000000" },
});
expect(res.statusCode).toBe(401);
const body = JSON.parse(res.body);
expect(body.code).toBe("INVALID_CODE");
});
it("successfully activates MFA with correct TOTP code", async () => {
const enrollRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
const { uri } = JSON.parse(enrollRes.body);
const code = generateTotpCode(uri);
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
headers: { authorization: `Bearer ${adminToken}` },
payload: { code },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.username, "admin"));
expect(dbUser.totpEnabled).toBe(true);
});
});
describe("POST /api/auth/mfa/disable", () => {
afterEach(async () => {
await clearMfaState("admin");
});
it("returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/disable",
payload: { code: "123456" },
});
expect(res.statusCode).toBe(401);
});
it("returns 400 with missing code", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/disable",
headers: { authorization: `Bearer ${adminToken}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
});
describe("POST /api/auth/users/:id/mfa/reset", () => {
it("returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/users/nonexistent-id/mfa/reset",
});
expect(res.statusCode).toBe(401);
});
it("returns 403 for non-admin users", async () => {
const regRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username: "mfa_regular_user", password: "TestPass1", role: "user" },
});
const userId = JSON.parse(regRes.body).id;
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "mfa_regular_user"));
const loginRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "mfa_regular_user", password: "TestPass1" },
});
const userToken = JSON.parse(loginRes.body).token;
const res = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${userId}/mfa/reset`,
headers: { authorization: `Bearer ${userToken}` },
});
expect(res.statusCode).toBe(403);
});
});
describe("MFA login flow", () => {
let totpUri: string;
beforeAll(async () => {
await clearMfaState("admin");
const enrollRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/enroll",
headers: { authorization: `Bearer ${adminToken}` },
});
totpUri = JSON.parse(enrollRes.body).uri;
const code = generateTotpCode(totpUri);
await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/verify",
headers: { authorization: `Bearer ${adminToken}` },
payload: { code },
});
});
afterAll(async () => {
await clearMfaState("admin");
});
it("login returns requiresMfa with mfaToken when MFA is enabled", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "admin", password: "Adminpass1" },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.requiresMfa).toBe(true);
expect(body.mfaToken).toBeDefined();
expect(typeof body.mfaToken).toBe("string");
expect(body.token).toBeUndefined();
});
it("POST /api/auth/mfa/complete with valid mfaToken and TOTP code creates session", async () => {
const loginRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "admin", password: "Adminpass1" },
});
const { mfaToken } = JSON.parse(loginRes.body);
const code = generateTotpCode(totpUri);
const res = await testApp.app.inject({
method: "POST",
url: "/api/auth/mfa/complete",
payload: { mfaToken, code },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.token).toBeDefined();
expect(typeof body.token).toBe("string");
expect(body.user).toBeDefined();
expect(body.user.username).toBe("admin");
expect(body.expiresAt).toBeDefined();
});
});
+165
View File
@@ -63,6 +63,60 @@ describe("SCIM 2.0 provisioning", () => {
expect(names).toContain("User");
expect(names).toContain("Group");
});
it("ServiceProviderConfig includes correct maxResults", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/ServiceProviderConfig",
});
const body = JSON.parse(res.body);
expect(body.filter.maxResults).toBe(200);
});
it("Schemas response has correct User schema attributes", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Schemas",
});
const body = JSON.parse(res.body);
const userSchema = body.Resources.find(
(r: { id: string }) => r.id === "urn:ietf:params:scim:schemas:core:2.0:User",
);
expect(userSchema).toBeDefined();
const attrNames = userSchema.attributes.map((a: { name: string }) => a.name);
expect(attrNames).toContain("userName");
expect(attrNames).toContain("name");
expect(attrNames).toContain("emails");
expect(attrNames).toContain("active");
expect(attrNames).toContain("externalId");
});
it("Schemas response has correct Group schema attributes", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Schemas",
});
const body = JSON.parse(res.body);
const groupSchema = body.Resources.find(
(r: { id: string }) => r.id === "urn:ietf:params:scim:schemas:core:2.0:Group",
);
expect(groupSchema).toBeDefined();
const attrNames = groupSchema.attributes.map((a: { name: string }) => a.name);
expect(attrNames).toContain("displayName");
expect(attrNames).toContain("members");
});
it("ResourceTypes have correct endpoints", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/ResourceTypes",
});
const body = JSON.parse(res.body);
const userType = body.Resources.find((r: { name: string }) => r.name === "User");
const groupType = body.Resources.find((r: { name: string }) => r.name === "Group");
expect(userType.endpoint).toBe("/api/v1/scim/v2/Users");
expect(groupType.endpoint).toBe("/api/v1/scim/v2/Groups");
});
});
// ── Auth ───────────────────────────────────────────────────────
@@ -94,6 +148,33 @@ describe("SCIM 2.0 provisioning", () => {
});
expect(res.statusCode).toBe(401);
});
it("rejects Bearer token with extra whitespace", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
});
expect(res.statusCode).toBe(401);
});
it("rejects lowercase bearer prefix", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `bearer ${SCIM_TOKEN}` },
});
expect(res.statusCode).toBe(401);
});
it("rejects empty Bearer token value", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Users",
headers: { authorization: "Bearer " },
});
expect(res.statusCode).toBe(401);
});
});
// ── Enterprise gate ────────────────────────────────────────────
@@ -155,5 +236,89 @@ describe("SCIM 2.0 provisioning", () => {
expect(body.status).toBe(401);
expect(typeof body.detail).toBe("string");
});
it("403 enterprise error includes SCIM error schema", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.schemas).toEqual(["urn:ietf:params:scim:api:messages:2.0:Error"]);
expect(body.status).toBe(403);
expect(typeof body.detail).toBe("string");
});
it("SCIM error responses include schemas, status, and detail fields", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Users",
});
expect(res.statusCode).toBe(401);
const body = JSON.parse(res.body);
expect(body).toHaveProperty("schemas");
expect(body).toHaveProperty("status");
expect(body).toHaveProperty("detail");
expect(Array.isArray(body.schemas)).toBe(true);
expect(typeof body.status).toBe("number");
expect(typeof body.detail).toBe("string");
});
it("POST Users with missing userName returns 403 from enterprise gate", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { active: true },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.schemas).toContain("urn:ietf:params:scim:api:messages:2.0:Error");
});
});
describe("POST Users validation (enterprise gate)", () => {
it("POST with empty body returns 403", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: {},
});
expect(res.statusCode).toBe(403);
});
it("POST with numeric userName returns 403", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { userName: 12345 },
});
expect(res.statusCode).toBe(403);
});
});
describe("POST Groups validation (enterprise gate)", () => {
it("POST with empty displayName returns 403", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/scim/v2/Groups",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { displayName: "" },
});
expect(res.statusCode).toBe(403);
});
it("POST with very long displayName returns 403", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/scim/v2/Groups",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { displayName: "x".repeat(10000) },
});
expect(res.statusCode).toBe(403);
});
});
});
+259
View File
@@ -0,0 +1,259 @@
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("SIEM config without enterprise license", () => {
it("GET returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("GET returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/siem/config",
});
expect(res.statusCode).toBe(401);
});
it("PUT returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
webhookUrl: "https://siem.example.com/input",
enabled: true,
flushIntervalSeconds: 30,
},
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("PUT returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
payload: {
webhookUrl: "https://siem.example.com/input",
enabled: true,
flushIntervalSeconds: 30,
},
});
expect(res.statusCode).toBe(401);
});
});
describe("SIEM config with enterprise license", () => {
let enterpriseApp: TestApp;
let entAdminToken: string;
let userToken: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["siem_forwarding"]);
const { buildTestApp, loginAsAdmin } = await import("./test-server.js");
enterpriseApp = await buildTestApp();
entAdminToken = await loginAsAdmin(enterpriseApp.app);
await enterpriseApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
username: "siemregularuser",
password: "TestPass1",
role: "user",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "siemregularuser"));
const loginRes = await enterpriseApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "siemregularuser", password: "TestPass1" },
});
userToken = JSON.parse(loginRes.body).token;
}, 30_000);
afterAll(async () => {
await enterpriseApp.cleanup();
vi.resetModules();
}, 10_000);
it("GET returns 200 with default config when none is saved", async () => {
const res = await enterpriseApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.webhookUrl).toBe("");
expect(body.enabled).toBe(false);
expect(body.flushIntervalSeconds).toBe(30);
});
it("GET returns 403 for non-admin users", async () => {
const res = await enterpriseApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${userToken}` },
});
expect(res.statusCode).toBe(403);
});
it("PUT saves config with valid data", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
webhookUrl: "https://siem.example.com/input",
authHeader: "Splunk secret-token",
flushIntervalSeconds: 60,
enabled: true,
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
});
it("PUT rejects invalid webhookUrl", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
webhookUrl: "not-a-url",
flushIntervalSeconds: 30,
enabled: true,
},
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toContain("Invalid");
});
it("PUT rejects flushIntervalSeconds below 10", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
webhookUrl: "https://siem.example.com/input",
flushIntervalSeconds: 5,
enabled: true,
},
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toContain("Invalid");
});
it("PUT rejects flushIntervalSeconds above 3600", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
webhookUrl: "https://siem.example.com/input",
flushIntervalSeconds: 7200,
enabled: true,
},
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toContain("Invalid");
});
it("PUT returns 403 for non-admin users", async () => {
const res = await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${userToken}` },
payload: {
webhookUrl: "https://siem.example.com/input",
flushIntervalSeconds: 30,
enabled: true,
},
});
expect(res.statusCode).toBe(403);
});
it("GET returns saved values after PUT", async () => {
await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
webhookUrl: "https://siem.corp.example.com/events",
authHeader: "Bearer my-secret-key",
flushIntervalSeconds: 120,
enabled: true,
},
});
const res = await enterpriseApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.webhookUrl).toBe("https://siem.corp.example.com/events");
expect(body.flushIntervalSeconds).toBe(120);
expect(body.enabled).toBe(true);
});
it("GET masks authHeader as *** instead of returning the actual value", async () => {
await enterpriseApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
webhookUrl: "https://siem.example.com/input",
authHeader: "Splunk super-secret-value",
flushIntervalSeconds: 30,
enabled: true,
},
});
const res = await enterpriseApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/siem/config",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.authHeader).toBe("***");
expect(body.authHeader).not.toContain("super-secret-value");
});
});
@@ -0,0 +1,257 @@
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("upgrade management - without enterprise license", () => {
it("GET /api/v1/admin/version returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/admin/version",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("upgrade_management");
});
it("GET /api/v1/admin/migrations/pending returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/admin/migrations/pending",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("upgrade_management");
});
it("GET /api/v1/admin/upgrade-check returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/admin/upgrade-check",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("upgrade_management");
});
it("GET /api/v1/admin/version returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/admin/version",
});
expect(res.statusCode).toBe(401);
});
it("GET /api/v1/admin/migrations/pending returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/admin/migrations/pending",
});
expect(res.statusCode).toBe(401);
});
it("GET /api/v1/admin/upgrade-check returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/admin/upgrade-check",
});
expect(res.statusCode).toBe(401);
});
});
describe("upgrade management - with enterprise license", () => {
let entApp: TestApp;
let entAdminToken: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["upgrade_management"]);
const { buildTestApp, loginAsAdmin } = await import("./test-server.js");
entApp = await buildTestApp();
entAdminToken = await loginAsAdmin(entApp.app);
}, 30_000);
afterAll(async () => {
await entApp.cleanup();
vi.restoreAllMocks();
}, 10_000);
it("GET /api/v1/admin/version returns 200 with version info", async () => {
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/version",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body).toHaveProperty("version");
expect(body).toHaveProperty("nodeVersion");
expect(typeof body.version).toBe("string");
expect(typeof body.nodeVersion).toBe("string");
expect(body.nodeVersion).toMatch(/^v\d+/);
});
it("GET /api/v1/admin/version includes build metadata fields", async () => {
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/version",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body).toHaveProperty("buildDate");
expect(body).toHaveProperty("schemaVersion");
expect(body).toHaveProperty("pendingMigrations");
expect(typeof body.pendingMigrations).toBe("number");
});
it("GET /api/v1/admin/version returns 403 for non-admin users", async () => {
await entApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
username: "upgradeuser1",
password: "TestPass1",
role: "user",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "upgradeuser1"));
const loginRes = await entApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "upgradeuser1", password: "TestPass1" },
});
const userToken = JSON.parse(loginRes.body).token;
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/version",
headers: { authorization: `Bearer ${userToken}` },
});
expect(res.statusCode).toBe(403);
});
it("GET /api/v1/admin/migrations/pending returns 500 when journal is not at expected path", async () => {
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/migrations/pending",
headers: { authorization: `Bearer ${entAdminToken}` },
});
// readJournal() looks for drizzle/meta/_journal.json relative to cwd,
// but in tests cwd is the repo root (journal is in apps/api/drizzle/)
expect(res.statusCode).toBe(500);
const body = JSON.parse(res.body);
expect(body.error).toContain("journal");
});
it("GET /api/v1/admin/migrations/pending returns 403 for non-admin users", async () => {
await entApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
username: "upgradeuser2",
password: "TestPass1",
role: "user",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "upgradeuser2"));
const loginRes = await entApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "upgradeuser2", password: "TestPass1" },
});
const userToken = JSON.parse(loginRes.body).token;
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/migrations/pending",
headers: { authorization: `Bearer ${userToken}` },
});
expect(res.statusCode).toBe(403);
});
it("GET /api/v1/admin/upgrade-check returns 200 with readiness check", async () => {
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/upgrade-check",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(typeof body.ready).toBe("boolean");
expect(body).toHaveProperty("checks");
});
it("GET /api/v1/admin/upgrade-check includes database and redis connectivity", async () => {
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/upgrade-check",
headers: { authorization: `Bearer ${entAdminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.checks).toHaveProperty("databaseConnected");
expect(body.checks).toHaveProperty("redisConnected");
expect(typeof body.checks.databaseConnected.ok).toBe("boolean");
expect(typeof body.checks.redisConnected.ok).toBe("boolean");
expect(body.checks).toHaveProperty("diskSpace");
expect(body.checks).toHaveProperty("inFlightJobs");
});
it("GET /api/v1/admin/upgrade-check returns 403 for non-admin users", async () => {
await entApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${entAdminToken}` },
payload: {
username: "upgradeuser3",
password: "TestPass1",
role: "user",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "upgradeuser3"));
const loginRes = await entApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "upgradeuser3", password: "TestPass1" },
});
const userToken = JSON.parse(loginRes.body).token;
const res = await entApp.app.inject({
method: "GET",
url: "/api/v1/admin/upgrade-check",
headers: { authorization: `Bearer ${userToken}` },
});
expect(res.statusCode).toBe(403);
});
});
@@ -0,0 +1,297 @@
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
describe("webhook management", () => {
describe("without enterprise license", () => {
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
it("GET /api/v1/enterprise/webhooks returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
});
it("POST /api/v1/enterprise/webhooks returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: "Test", url: "https://example.com/hook" },
});
expect(res.statusCode).toBe(403);
});
it("PUT /api/v1/enterprise/webhooks/:index returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/webhooks/0",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: "Test", url: "https://example.com/hook" },
});
expect(res.statusCode).toBe(403);
});
it("DELETE /api/v1/enterprise/webhooks/:index returns 403 without enterprise license", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/webhooks/0",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
});
it("GET /api/v1/enterprise/webhooks returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/webhooks",
});
expect(res.statusCode).toBe(401);
});
it("POST /api/v1/enterprise/webhooks returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/webhooks",
payload: { name: "Test", url: "https://example.com/hook" },
});
expect(res.statusCode).toBe(401);
});
it("PUT /api/v1/enterprise/webhooks/:index returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/webhooks/0",
payload: { name: "Test", url: "https://example.com/hook" },
});
expect(res.statusCode).toBe(401);
});
it("DELETE /api/v1/enterprise/webhooks/:index returns 401 without authentication", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/webhooks/0",
});
expect(res.statusCode).toBe(401);
});
});
describe("with enterprise license", () => {
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["admin_alerts", "webhooks"]);
const testServer = await import("./test-server.js");
testApp = await testServer.buildTestApp();
adminToken = await testServer.loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
it("POST validates name is required", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
payload: { url: "https://example.com/hook" },
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toBe("Invalid webhook destination");
});
it("POST validates url must be a valid URL", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: "Test", url: "not-a-url" },
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.error).toBe("Invalid webhook destination");
});
it("POST returns 403 for non-admin users without webhooks:manage", async () => {
await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username: "webhookuser", password: "TestPass1", role: "user" },
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "webhookuser"));
const loginRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "webhookuser", password: "TestPass1" },
});
const userToken = JSON.parse(loginRes.body).token;
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${userToken}` },
payload: { name: "Test", url: "https://example.com/hook" },
});
expect(res.statusCode).toBe(403);
});
it("GET returns empty array initially", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.destinations).toEqual([]);
});
it("POST creates a webhook destination with valid data", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "Test Webhook",
url: "https://example.com/webhook",
authHeader: "Bearer secret-token",
type: "alerts",
},
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
expect(body.index).toBe(0);
});
it("GET returns created webhooks after POST", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.destinations).toHaveLength(1);
expect(body.destinations[0].name).toBe("Test Webhook");
expect(body.destinations[0].url).toBe("https://example.com/webhook");
});
it("GET masks authHeader in response", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.destinations[0].authHeader).toBe("***");
});
it("PUT updates an existing webhook by index", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/webhooks/0",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "Updated Webhook",
url: "https://example.com/updated",
type: "siem",
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
const getRes = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
});
const getBody = JSON.parse(getRes.body);
expect(getBody.destinations[0].name).toBe("Updated Webhook");
expect(getBody.destinations[0].url).toBe("https://example.com/updated");
expect(getBody.destinations[0].type).toBe("siem");
});
it("PUT returns 404 for out-of-bounds index", async () => {
const res = await testApp.app.inject({
method: "PUT",
url: "/api/v1/enterprise/webhooks/99",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: "Test", url: "https://example.com/hook" },
});
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.error).toBe("Webhook destination not found");
});
it("DELETE returns 404 for out-of-bounds index", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/webhooks/99",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.error).toBe("Webhook destination not found");
});
it("POST test returns 404 for out-of-bounds index", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/webhooks/99/test",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.error).toBe("Webhook destination not found");
});
it("DELETE deletes a webhook by index", async () => {
const res = await testApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/webhooks/0",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.ok).toBe(true);
});
it("GET reflects removal after delete", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/webhooks",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.destinations).toEqual([]);
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const thisDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(
resolve(thisDir, "../../../apps/api/src/jobs/alert-evaluator.ts"),
"utf-8",
);
describe("alert-evaluator module exports", () => {
it("exports evaluateAlerts as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/alert-evaluator.js");
expect(typeof mod.evaluateAlerts).toBe("function");
});
it("evaluateAlerts is the only named export", async () => {
const mod = await import("../../../apps/api/src/jobs/alert-evaluator.js");
expect(Object.keys(mod)).toEqual(["evaluateAlerts"]);
});
});
describe("alert-evaluator thresholds", () => {
it("disk space threshold is 1 GB", () => {
expect(source).toMatch(/freeGb\s*<\s*1[^0-9]/);
});
it("disk space alert reports threshold of 1", () => {
expect(source).toMatch(/threshold:\s*1\b/);
});
it("auth anomaly threshold is 20 failures", () => {
expect(source).toMatch(/count\s*>\s*20/);
});
it("auth anomaly window is 5 minutes", () => {
expect(source).toMatch(/5\s*\*\s*60\s*\*\s*1000/);
});
it("auth anomaly alert reports windowMinutes of 5", () => {
expect(source).toMatch(/windowMinutes:\s*5/);
});
it("backup staleness threshold is 48 hours", () => {
expect(source).toMatch(/ageHours\s*>\s*48/);
});
it("backup staleness alert reports threshold of 48", () => {
expect(source).toMatch(/threshold:\s*48/);
});
it("license expiry warning threshold is 30 days", () => {
expect(source).toMatch(/daysLeft\s*<\s*30/);
});
});
describe("alert-evaluator conditions", () => {
it("checks disk_space_low condition", () => {
expect(source).toContain('"disk_space_low"');
});
it("checks auth_anomaly condition", () => {
expect(source).toContain('"auth_anomaly"');
});
it("checks backup_stale condition", () => {
expect(source).toContain('"backup_stale"');
});
it("checks backup_never_run condition", () => {
expect(source).toContain('"backup_never_run"');
});
it("checks license_expiring condition", () => {
expect(source).toContain('"license_expiring"');
});
it("only delivers to enabled webhooks of type alerts", () => {
expect(source).toContain('d.type === "alerts"');
});
});
+36
View File
@@ -37,4 +37,40 @@ describe("audit archival state machine", () => {
// If we exited because current is undefined (end of chain), no cycle
expect(current).toBeUndefined();
});
it("defines exactly 5 states", () => {
const states = new Set<string>();
for (const [from, to] of Object.entries(validTransitions)) {
states.add(from);
states.add(to);
}
expect(states.size).toBe(5);
expect(states).toEqual(new Set(["PENDING", "EXPORTING", "EXPORTED", "PURGING", "COMPLETE"]));
});
it("follows the exact sequence PENDING->EXPORTING->EXPORTED->PURGING->COMPLETE", () => {
const chain: string[] = ["PENDING"];
let current = "PENDING";
while (validTransitions[current]) {
current = validTransitions[current];
chain.push(current);
}
expect(chain).toEqual(["PENDING", "EXPORTING", "EXPORTED", "PURGING", "COMPLETE"]);
});
it("has exactly 4 transitions", () => {
expect(Object.keys(validTransitions).length).toBe(4);
});
it("visits each state exactly once in the transition chain", () => {
const visited: string[] = [];
let current: string | undefined = "PENDING";
while (current) {
visited.push(current);
current = validTransitions[current];
}
const unique = new Set(visited);
expect(visited.length).toBe(unique.size);
expect(visited.length).toBe(5);
});
});
+66
View File
@@ -49,4 +49,70 @@ describe("audit integrity", () => {
const data2 = { a: 1, b: 2 };
expect(computeHmac(data1, testKey)).toBe(computeHmac(data2, testKey));
});
it("canonicalizes deeply nested objects (3+ levels)", () => {
const input = { a: { b: { c: { d: 42 } } } };
expect(canonicalize(input)).toBe('{"a":{"b":{"c":{"d":42}}}}');
});
it("canonicalizes empty object", () => {
expect(canonicalize({})).toBe("{}");
});
it("canonicalizes empty array", () => {
expect(canonicalize({ list: [] })).toBe('{"list":[]}');
});
it("canonicalizes mixed types", () => {
const input = {
str: "hello",
num: 42,
bool: true,
nil: null,
arr: [1, "two"],
obj: { nested: true },
};
const result = canonicalize(input);
expect(result).toBe(
'{"arr":[1,"two"],"bool":true,"nil":null,"num":42,"obj":{"nested":true},"str":"hello"}',
);
});
it("serializes undefined values as null in canonicalization", () => {
const input = { a: 1, b: undefined, c: 3 };
const result = canonicalize(input);
expect(result).toBe('{"a":1,"b":null,"c":3}');
});
it("produces different HMACs for different keys", () => {
const data = { event: "TEST" };
const key1 = Buffer.from("a".repeat(64), "hex");
const key2 = Buffer.from("b".repeat(64), "hex");
expect(computeHmac(data, key1)).not.toBe(computeHmac(data, key2));
});
it("verifyHmac returns false for empty HMAC string", () => {
const data = { event: "TEST" };
expect(verifyHmac(data, "", testKey)).toBe(false);
});
it("verifyHmac returns false for malformed HMAC string", () => {
const data = { event: "TEST" };
expect(verifyHmac(data, "not-a-valid-hex-hmac", testKey)).toBe(false);
});
it("verifyHmac returns false for completely wrong HMAC", () => {
const data = { event: "TEST" };
const wrongHmac = "ff".repeat(32);
expect(verifyHmac(data, wrongHmac, testKey)).toBe(false);
});
it("key order does not matter with 5+ keys", () => {
const forward = { alpha: 1, bravo: 2, charlie: 3, delta: 4, echo: 5, foxtrot: 6 };
const reverse = { foxtrot: 6, echo: 5, delta: 4, charlie: 3, bravo: 2, alpha: 1 };
const scrambled = { charlie: 3, alpha: 1, foxtrot: 6, bravo: 2, echo: 5, delta: 4 };
const hmac = computeHmac(forward, testKey);
expect(computeHmac(reverse, testKey)).toBe(hmac);
expect(computeHmac(scrambled, testKey)).toBe(hmac);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { sanitizeAuditInput } from "../../../apps/api/src/lib/audit.js";
describe("sanitizeAuditInput", () => {
it("strips angle brackets", () => {
expect(sanitizeAuditInput("<script>alert(1)</script>")).toBe("scriptalert(1)/script");
});
it("strips ampersand", () => {
expect(sanitizeAuditInput("foo&bar")).toBe("foobar");
});
it("strips double quotes", () => {
expect(sanitizeAuditInput('hello"world')).toBe("helloworld");
});
it("strips single quotes", () => {
expect(sanitizeAuditInput("it's")).toBe("its");
});
it("strips all dangerous characters in one pass", () => {
expect(sanitizeAuditInput(`<>&"'`)).toBe("(empty)");
});
it("returns (empty) for empty string", () => {
expect(sanitizeAuditInput("")).toBe("(empty)");
});
it("returns (empty) when only special characters remain", () => {
expect(sanitizeAuditInput("<<>>&&''\"\"")).toBe("(empty)");
});
it("does not truncate a string at exactly 200 characters", () => {
const input = "x".repeat(200);
expect(sanitizeAuditInput(input)).toBe(input);
expect(sanitizeAuditInput(input).length).toBe(200);
});
it("truncates a 201-character string to 200", () => {
const input = "y".repeat(201);
const result = sanitizeAuditInput(input);
expect(result.length).toBe(200);
expect(result).toBe("y".repeat(200));
});
it("truncates long strings after stripping characters", () => {
const input = `${"a".repeat(198)}<>${"b".repeat(10)}`;
const result = sanitizeAuditInput(input);
expect(result.length).toBe(200);
expect(result).toBe(`${"a".repeat(198)}bb`);
});
it("preserves safe characters unchanged", () => {
const input = "hello world 123 @#$%^*()_+-=[]{}|;:,.?/~`!";
expect(sanitizeAuditInput(input)).toBe(input);
});
});
// deriveTargetType is not exported from audit.ts (private function).
// It is only callable via auditLog(), which requires a live database connection,
// making it unsuitable for a unit test. The mapping is covered by
// integration tests in audit-log-route.test.ts instead.
@@ -0,0 +1,88 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const thisDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(thisDir, "../../../apps/api/src/jobs/enqueue.ts"), "utf-8");
describe("enqueue module exports", () => {
it("exports enqueueToolJob as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/enqueue.js");
expect(typeof mod.enqueueToolJob).toBe("function");
});
it("exports waitForJob as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/enqueue.js");
expect(typeof mod.waitForJob).toBe("function");
});
it("exports closeQueueEvents as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/enqueue.js");
expect(typeof mod.closeQueueEvents).toBe("function");
});
it("exports getFlowProducer as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/enqueue.js");
expect(typeof mod.getFlowProducer).toBe("function");
});
it("exports closeFlowProducer as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/enqueue.js");
expect(typeof mod.closeFlowProducer).toBe("function");
});
it("does not export computeDeleteAfter (private function)", async () => {
const mod = await import("../../../apps/api/src/jobs/enqueue.js");
expect(mod).not.toHaveProperty("computeDeleteAfter");
});
});
// computeDeleteAfter is a private async function that cannot be imported
// directly. The following tests verify its logic by inspecting the source
// to confirm the correct feature gates, thresholds, and computation.
describe("computeDeleteAfter logic (source verification)", () => {
it("function is defined as async", () => {
expect(source).toMatch(/async function computeDeleteAfter\(/);
});
it("gates on team_retention_overrides enterprise feature", () => {
expect(source).toContain('isFeatureEnabled("team_retention_overrides")');
});
it("returns early when feature is not enabled", () => {
expect(source).toMatch(/if\s*\(!isTeamRetentionEnabled\)\s*return/);
});
it("looks up the user's team from the users table", () => {
expect(source).toContain("schema.users.team");
expect(source).toContain("eq(schema.users.id, userId)");
});
it("returns early when user has no team", () => {
expect(source).toMatch(/!userRow\[0\]\.team\)\s*return/);
});
it("reads retentionHours from the teams table", () => {
expect(source).toContain("schema.teams.retentionHours");
expect(source).toContain("eq(schema.teams.id, userRow[0].team)");
});
it("falls back to FILE_MAX_AGE_HOURS when team has no retentionHours", () => {
expect(source).toContain("env.FILE_MAX_AGE_HOURS");
});
it("computes deleteAfter as retentionHours converted to milliseconds from now", () => {
expect(source).toMatch(/retentionHours\s*\*\s*60\s*\*\s*60\s*\*\s*1000/);
expect(source).toMatch(/new Date\(Date\.now\(\)\s*\+\s*retentionHours/);
});
it("updates the job row with the computed deleteAfter", () => {
expect(source).toContain("db.update(schema.jobs).set({ deleteAfter })");
expect(source).toContain("eq(schema.jobs.id, jobId)");
});
it("is called fire-and-forget from enqueueToolJob", () => {
expect(source).toMatch(/void computeDeleteAfter\(data\.jobId, data\.userId\)\.catch/);
});
});
+67
View File
@@ -6,6 +6,8 @@ import {
isEncrypted,
} from "../../../apps/api/src/lib/encryption.js";
const PREFIX_LEN = "$ENC$".length;
describe("encryption", () => {
const testKey = "a".repeat(64); // 32 bytes hex-encoded
@@ -55,4 +57,69 @@ describe("encryption", () => {
expect(key).toBeInstanceOf(Buffer);
expect(key.length).toBe(32);
});
it("encrypts and decrypts an empty string", async () => {
const encrypted = await encrypt("", testKey);
expect(isEncrypted(encrypted)).toBe(true);
const decrypted = await decrypt(encrypted, testKey);
expect(decrypted).toBe("");
});
it("encrypts and decrypts a very long input", async () => {
const plaintext = "x".repeat(10_000);
const encrypted = await encrypt(plaintext, testKey);
const decrypted = await decrypt(encrypted, testKey);
expect(decrypted).toBe(plaintext);
});
it("encrypts and decrypts unicode and emoji characters", async () => {
const plaintext = "\u{1F9A6}\u{1F30A} Otter says: éàüß 你好 АБВ";
const encrypted = await encrypt(plaintext, testKey);
const decrypted = await decrypt(encrypted, testKey);
expect(decrypted).toBe(plaintext);
});
it("encrypts and decrypts special characters (newlines, tabs, null bytes)", async () => {
const plaintext = "line1\nline2\ttab\0null\r\nwindows";
const encrypted = await encrypt(plaintext, testKey);
const decrypted = await decrypt(encrypted, testKey);
expect(decrypted).toBe(plaintext);
});
it("isEncrypted returns true for $ENC$ prefix with invalid base64", () => {
expect(isEncrypted("$ENC$!!!not-valid-base64%%%")).toBe(true);
});
it("decrypt returns null for $ENC$ with truncated data", async () => {
const result = await decrypt("$ENC$AQID", testKey);
expect(result).toBeNull();
});
it("decrypt returns null for $ENC$ with corrupted ciphertext", async () => {
const encrypted = await encrypt("hello", testKey);
const corrupted = `${encrypted.slice(0, PREFIX_LEN + 10)}AAAA${encrypted.slice(PREFIX_LEN + 14)}`;
const result = await decrypt(corrupted, testKey);
expect(result).toBeNull();
});
it("deriveAuditHmacKey produces different keys for different master keys", async () => {
const keyA = await deriveAuditHmacKey("a".repeat(64));
const keyB = await deriveAuditHmacKey("b".repeat(64));
expect(keyA.equals(keyB)).toBe(false);
});
it("deriveAuditHmacKey is deterministic", async () => {
const first = await deriveAuditHmacKey(testKey);
const second = await deriveAuditHmacKey(testKey);
expect(first.equals(second)).toBe(true);
});
it("two encryptions of the same plaintext decrypt to the same value", async () => {
const plaintext = "roundtrip-check";
const encA = await encrypt(plaintext, testKey);
const encB = await encrypt(plaintext, testKey);
expect(encA).not.toBe(encB);
expect(await decrypt(encA, testKey)).toBe(plaintext);
expect(await decrypt(encB, testKey)).toBe(plaintext);
});
});
+123 -1
View File
@@ -1,4 +1,33 @@
import { describe, expect, it } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
// ── DB mock for findUniqueUsername tests ──────────────────────────
const dbMock = vi.hoisted(() => {
let idx = 0;
let results: unknown[][] = [];
return {
reset(r: unknown[][]) {
idx = 0;
results = r;
},
nextResult() {
return results[idx++] ?? [];
},
};
});
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {
select: () => ({
from: () => ({
where: () => Promise.resolve(dbMock.nextResult()),
}),
}),
},
schema: {
users: { username: "username" },
},
}));
// ── Pure function tests (no DB required) ─────────────────────────
@@ -69,4 +98,97 @@ describe("sanitizeUsername", () => {
const result = sanitizeUsername("___");
expect(result.length).toBeGreaterThanOrEqual(3);
});
it("handles pure whitespace input", () => {
const result = sanitizeUsername(" ");
expect(result).toBe("___");
});
it("handles numeric-only input", () => {
expect(sanitizeUsername("12345")).toBe("12345");
});
it("handles single character input by padding to 3", () => {
const result = sanitizeUsername("a");
expect(result).toBe("a__");
expect(result.length).toBe(3);
});
it("does not truncate input that is exactly 46 characters", () => {
const input = "a".repeat(46);
const result = sanitizeUsername(input);
expect(result).toBe(input);
expect(result.length).toBe(46);
});
it("truncates input that is exactly 47 characters to 46", () => {
const input = "a".repeat(47);
const result = sanitizeUsername(input);
expect(result.length).toBe(46);
});
it("preserves consecutive dots and hyphens", () => {
expect(sanitizeUsername("john..--..doe")).toBe("john..--..doe");
});
it("replaces unicode characters with underscores", () => {
expect(sanitizeUsername("café")).toBe("caf");
expect(sanitizeUsername("jöhn")).toBe("j_hn");
});
it("strips leading @ from input", () => {
expect(sanitizeUsername("@user")).toBe("user");
});
it("passes through admin as a valid username", () => {
expect(sanitizeUsername("admin")).toBe("admin");
});
it("allows input that starts with numbers", () => {
expect(sanitizeUsername("123abc")).toBe("123abc");
expect(sanitizeUsername("42user")).toBe("42user");
});
});
describe("findUniqueUsername", () => {
let findUniqueUsername: (base: string) => Promise<string>;
beforeAll(async () => {
const mod = await import("../../../apps/api/src/lib/external-auth-resolver.js");
findUniqueUsername = mod.findUniqueUsername;
});
beforeEach(() => {
dbMock.reset([]);
});
it("returns the base username when it is not taken", async () => {
dbMock.reset([[]]);
const result = await findUniqueUsername("newuser");
expect(result).toBe("newuser");
});
it("appends _2 when the base username is taken", async () => {
dbMock.reset([[{ username: "taken" }], []]);
const result = await findUniqueUsername("taken");
expect(result).toBe("taken_2");
});
it("appends _3 when both base and _2 are taken", async () => {
dbMock.reset([[{ username: "taken" }], [{ username: "taken_2" }], []]);
const result = await findUniqueUsername("taken");
expect(result).toBe("taken_3");
});
it("resolves collisions through the loop until a free slot is found", async () => {
dbMock.reset([
[{ username: "user" }],
[{ username: "user_2" }],
[{ username: "user_3" }],
[{ username: "user_4" }],
[],
]);
const result = await findUniqueUsername("user");
expect(result).toBe("user_5");
});
});
+272
View File
@@ -0,0 +1,272 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ENTERPRISE_FEATURES,
PLAN_FEATURES,
validateLicense,
} from "../../../packages/enterprise/src/license.js";
describe("validateLicense", () => {
it("returns null for undefined input", () => {
expect(validateLicense(undefined as unknown as string)).toBeNull();
});
it("returns null for empty string", () => {
expect(validateLicense("")).toBeNull();
});
it("returns null for input without a dot separator", () => {
expect(validateLicense("nodothere")).toBeNull();
});
it("returns null when dot is at position 0", () => {
expect(validateLicense(".signature")).toBeNull();
});
it("returns null for invalid base64url payload", () => {
expect(validateLicense("!!!invalid-base64.AAAA")).toBeNull();
});
it("returns null for valid base64url that is not JSON", () => {
const notJson = Buffer.from("not json at all").toString("base64url");
const fakeSig = Buffer.from("fakesig").toString("base64url");
expect(validateLicense(`${notJson}.${fakeSig}`)).toBeNull();
});
it("returns null for valid JSON payload with wrong signature", () => {
const payload = Buffer.from(
JSON.stringify({
org: "test",
plan: "team",
features: ["s3_storage"],
seats: 5,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
issuedAt: new Date().toISOString(),
}),
).toString("base64url");
const wrongSig = Buffer.from("this-is-not-a-valid-signature").toString("base64url");
expect(validateLicense(`${payload}.${wrongSig}`)).toBeNull();
});
it("returns null for payload with multiple dots", () => {
const payload = Buffer.from(JSON.stringify({ org: "test" })).toString("base64url");
const sig = Buffer.from("sig").toString("base64url");
expect(validateLicense(`${payload}.${sig}.extra`)).toBeNull();
});
});
describe("initEnterprise", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns valid:false and license:null when called with undefined", async () => {
const { initEnterprise } = await import("../../../packages/enterprise/src/index.js");
const result = initEnterprise(undefined);
expect(result).toEqual({ valid: false, license: null });
});
it("returns valid:false and license:null when called with empty string", async () => {
const { initEnterprise } = await import("../../../packages/enterprise/src/index.js");
const result = initEnterprise("");
expect(result).toEqual({ valid: false, license: null });
});
it("returns valid:false and license:null when called with an invalid key", async () => {
const { initEnterprise } = await import("../../../packages/enterprise/src/index.js");
const result = initEnterprise("bad-key-no-dot");
expect(result).toEqual({ valid: false, license: null });
});
it("returns valid:false for a key with wrong signature", async () => {
const { initEnterprise } = await import("../../../packages/enterprise/src/index.js");
const payload = Buffer.from(
JSON.stringify({
org: "test",
plan: "team",
features: [],
seats: 1,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
issuedAt: new Date().toISOString(),
}),
).toString("base64url");
const result = initEnterprise(`${payload}.badsig`);
expect(result).toEqual({ valid: false, license: null });
});
it("sets activeLicense to null for invalid keys", async () => {
const { initEnterprise, getActiveLicense } = await import(
"../../../packages/enterprise/src/index.js"
);
initEnterprise("garbage.data");
expect(getActiveLicense()).toBeNull();
});
});
describe("isFeatureEnabled (via mock)", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns false when no license is active", async () => {
const { mockNoEnterprise } = await import("../../helpers/enterprise-mock.js");
mockNoEnterprise();
const { isFeatureEnabled } = await import("@snapotter/enterprise");
expect(isFeatureEnabled("saml_sso")).toBe(false);
expect(isFeatureEnabled("s3_storage")).toBe(false);
});
it("returns true for features in the active license", async () => {
const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["saml_sso", "s3_storage", "mfa"]);
const { isFeatureEnabled } = await import("@snapotter/enterprise");
expect(isFeatureEnabled("saml_sso")).toBe(true);
expect(isFeatureEnabled("s3_storage")).toBe(true);
expect(isFeatureEnabled("mfa")).toBe(true);
});
it("returns false for features NOT in the active license", async () => {
const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["saml_sso"]);
const { isFeatureEnabled } = await import("@snapotter/enterprise");
expect(isFeatureEnabled("scim")).toBe(false);
expect(isFeatureEnabled("mfa")).toBe(false);
expect(isFeatureEnabled("webhooks")).toBe(false);
});
it("returns correct results when switching from active to no license", async () => {
const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["audit_export"]);
const mod1 = await import("@snapotter/enterprise");
expect(mod1.isFeatureEnabled("audit_export")).toBe(true);
vi.resetModules();
const { mockNoEnterprise } = await import("../../helpers/enterprise-mock.js");
mockNoEnterprise();
const mod2 = await import("@snapotter/enterprise");
expect(mod2.isFeatureEnabled("audit_export")).toBe(false);
});
});
describe("isFeatureEnabled (direct, no mock)", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns false for all features when initEnterprise was not called", async () => {
const { isFeatureEnabled } = await import("../../../packages/enterprise/src/index.js");
for (const feature of ENTERPRISE_FEATURES) {
expect(isFeatureEnabled(feature)).toBe(false);
}
});
it("returns false for all features after initEnterprise with undefined", async () => {
const { initEnterprise, isFeatureEnabled } = await import(
"../../../packages/enterprise/src/index.js"
);
initEnterprise(undefined);
for (const feature of ENTERPRISE_FEATURES) {
expect(isFeatureEnabled(feature)).toBe(false);
}
});
it("returns false for all features after initEnterprise with invalid key", async () => {
const { initEnterprise, isFeatureEnabled } = await import(
"../../../packages/enterprise/src/index.js"
);
initEnterprise("invalid.key");
for (const feature of ENTERPRISE_FEATURES) {
expect(isFeatureEnabled(feature)).toBe(false);
}
});
});
describe("PLAN_FEATURES", () => {
it("team plan has exactly 8 features", () => {
expect(PLAN_FEATURES.team).toHaveLength(8);
});
it("team plan contains the expected features", () => {
const expected = [
"saml_sso",
"s3_storage",
"multi_tenancy",
"audit_export",
"siem_forwarding",
"sso_enforcement",
"upgrade_management",
"admin_alerts",
];
for (const feature of expected) {
expect(PLAN_FEATURES.team).toContain(feature);
}
});
it("team plan does not include compliance-only features", () => {
expect(PLAN_FEATURES.team).not.toContain("scim");
expect(PLAN_FEATURES.team).not.toContain("webhooks");
expect(PLAN_FEATURES.team).not.toContain("mfa");
expect(PLAN_FEATURES.team).not.toContain("per_tool_permissions");
expect(PLAN_FEATURES.team).not.toContain("tamper_resistant_audit");
expect(PLAN_FEATURES.team).not.toContain("legal_hold");
expect(PLAN_FEATURES.team).not.toContain("gdpr_lifecycle");
expect(PLAN_FEATURES.team).not.toContain("team_retention_overrides");
expect(PLAN_FEATURES.team).not.toContain("ip_allowlist");
expect(PLAN_FEATURES.team).not.toContain("config_export_import");
});
it("enterprise plan has all 18 features", () => {
expect(PLAN_FEATURES.enterprise).toHaveLength(18);
});
it("enterprise plan is a superset of team plan", () => {
for (const feature of PLAN_FEATURES.team) {
expect(PLAN_FEATURES.enterprise).toContain(feature);
}
});
it("enterprise plan references the same array as ENTERPRISE_FEATURES", () => {
expect(PLAN_FEATURES.enterprise).toBe(ENTERPRISE_FEATURES);
});
});
describe("ENTERPRISE_FEATURES", () => {
it("contains exactly 18 features", () => {
expect(ENTERPRISE_FEATURES).toHaveLength(18);
});
it("has no duplicate entries", () => {
const unique = new Set(ENTERPRISE_FEATURES);
expect(unique.size).toBe(ENTERPRISE_FEATURES.length);
});
it("contains all expected feature strings", () => {
const expected = [
"saml_sso",
"s3_storage",
"scim",
"multi_tenancy",
"webhooks",
"audit_export",
"mfa",
"per_tool_permissions",
"siem_forwarding",
"tamper_resistant_audit",
"legal_hold",
"gdpr_lifecycle",
"team_retention_overrides",
"sso_enforcement",
"ip_allowlist",
"config_export_import",
"upgrade_management",
"admin_alerts",
];
expect([...ENTERPRISE_FEATURES].sort()).toEqual([...expected].sort());
});
it("every entry is a non-empty string", () => {
for (const feature of ENTERPRISE_FEATURES) {
expect(typeof feature).toBe("string");
expect(feature.length).toBeGreaterThan(0);
}
});
});
+119
View File
@@ -0,0 +1,119 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, describe, expect, it } from "vitest";
import { db, schema } from "../../../apps/api/src/db/index.js";
import {
getSettingNumber,
getSettingString,
upsertSetting,
} from "../../../apps/api/src/lib/settings-helpers.js";
const prefix = `test-sh-${randomUUID().slice(0, 8)}`;
let keyCounter = 0;
function uniqueKey(): string {
return `${prefix}-${++keyCounter}`;
}
async function cleanupKey(key: string): Promise<void> {
await db.delete(schema.settings).where(eq(schema.settings.key, key));
}
const keysToClean: string[] = [];
afterAll(async () => {
for (const key of keysToClean) {
await cleanupKey(key);
}
});
describe("upsertSetting", () => {
it("inserts a new setting", async () => {
const key = uniqueKey();
keysToClean.push(key);
await upsertSetting(key, "hello");
const rows = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe("hello");
});
it("updates an existing setting", async () => {
const key = uniqueKey();
keysToClean.push(key);
await upsertSetting(key, "first");
await upsertSetting(key, "second");
const rows = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe("second");
});
it("handles empty string value", async () => {
const key = uniqueKey();
keysToClean.push(key);
await upsertSetting(key, "");
const rows = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe("");
});
});
describe("getSettingNumber", () => {
it("returns the number when setting exists", async () => {
const key = uniqueKey();
keysToClean.push(key);
await upsertSetting(key, "42");
const result = await getSettingNumber(key);
expect(result).toBe(42);
});
it("returns defaultValue when setting does not exist", async () => {
const key = uniqueKey();
const result = await getSettingNumber(key, 99);
expect(result).toBe(99);
});
it("returns defaultValue when setting value is NaN", async () => {
const key = uniqueKey();
keysToClean.push(key);
await upsertSetting(key, "not-a-number");
const result = await getSettingNumber(key, 7);
expect(result).toBe(7);
});
it("defaults defaultValue to 0", async () => {
const key = uniqueKey();
const result = await getSettingNumber(key);
expect(result).toBe(0);
});
});
describe("getSettingString", () => {
it("returns the string when setting exists", async () => {
const key = uniqueKey();
keysToClean.push(key);
await upsertSetting(key, "stored-value");
const result = await getSettingString(key);
expect(result).toBe("stored-value");
});
it("returns defaultValue when setting does not exist", async () => {
const key = uniqueKey();
const result = await getSettingString(key, "fallback");
expect(result).toBe("fallback");
});
it("defaults defaultValue to empty string", async () => {
const key = uniqueKey();
const result = await getSettingString(key);
expect(result).toBe("");
});
});
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const thisDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(
resolve(thisDir, "../../../apps/api/src/jobs/siem-forward.ts"),
"utf-8",
);
describe("siem-forward module exports", () => {
it("exports runSiemForward as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/siem-forward.js");
expect(typeof mod.runSiemForward).toBe("function");
});
it("runSiemForward is the only named export", async () => {
const mod = await import("../../../apps/api/src/jobs/siem-forward.js");
expect(Object.keys(mod)).toEqual(["runSiemForward"]);
});
});
describe("siem-forward constants", () => {
it("circuit breaker threshold is 5", () => {
expect(source).toMatch(/CIRCUIT_BREAKER_THRESHOLD\s*=\s*5/);
});
it("batch limit is 500", () => {
expect(source).toMatch(/BATCH_LIMIT\s*=\s*500/);
});
it("cursor key is siem_last_forwarded_at", () => {
expect(source).toMatch(/CURSOR_KEY\s*=\s*"siem_last_forwarded_at"/);
});
it("failures key is siem_consecutive_failures", () => {
expect(source).toMatch(/FAILURES_KEY\s*=\s*"siem_consecutive_failures"/);
});
});
describe("siem-forward circuit breaker behavior", () => {
it("opens circuit when failures reach threshold", () => {
expect(source).toMatch(/failureCount\s*>=\s*CIRCUIT_BREAKER_THRESHOLD/);
});
it("resets failure counter on successful delivery", () => {
expect(source).toContain('upsertSetting(FAILURES_KEY, "0")');
});
it("increments failure counter on failed delivery", () => {
expect(source).toContain("upsertSetting(FAILURES_KEY, String(failureCount + 1))");
});
});
describe("siem-forward cursor behavior", () => {
it("updates cursor to the last forwarded row timestamp", () => {
expect(source).toContain("upsertSetting(CURSOR_KEY, lastRow.createdAt.toISOString())");
});
it("queries audit log ordered by createdAt ascending", () => {
expect(source).toContain("asc(schema.auditLog.createdAt)");
});
it("applies cursor filter using gte on createdAt", () => {
expect(source).toContain("gte(schema.auditLog.createdAt, cursorDate)");
});
});
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const thisDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(
resolve(thisDir, "../../../apps/api/src/jobs/storage-reconciliation.ts"),
"utf-8",
);
describe("storage-reconciliation module exports", () => {
it("exports storageReconciliationJob as a function", async () => {
const mod = await import("../../../apps/api/src/jobs/storage-reconciliation.js");
expect(typeof mod.storageReconciliationJob).toBe("function");
});
it("storageReconciliationJob is the only named export", async () => {
const mod = await import("../../../apps/api/src/jobs/storage-reconciliation.js");
expect(Object.keys(mod)).toEqual(["storageReconciliationJob"]);
});
});
describe("storage-reconciliation structure", () => {
it("sums file sizes per user from userFiles", () => {
expect(source).toContain("schema.userFiles.userId");
expect(source).toMatch(/sum\(\s*\$\{schema\.userFiles\.size\}/);
});
it("updates storageUsed on the users table", () => {
expect(source).toContain("schema.users");
expect(source).toContain("storageUsed");
});
it("groups file sizes by userId", () => {
expect(source).toContain("groupBy(schema.userFiles.userId)");
});
it("corrects only users whose storageUsed differs from actual", () => {
expect(source).toMatch(/storageUsed\}\s*!=\s*\$\{row\.totalSize\}/);
});
it("zeros out users with no files but nonzero storageUsed", () => {
expect(source).toContain("storageUsed: 0");
expect(source).toContain("notInArray(schema.users.id, usersWithFiles)");
});
it("handles case where no users have files", () => {
expect(source).toContain("// No users have files");
expect(source).toMatch(/storageUsed\}\s*>\s*0/);
});
});
+185 -2
View File
@@ -24,7 +24,7 @@ describe("webhook delivery", () => {
expect(fetchMock).toHaveBeenCalledOnce();
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("https://siem.example.com/input");
expect(opts.headers["Authorization"]).toBe("Bearer test-token");
expect(opts.headers.Authorization).toBe("Bearer test-token");
const body = JSON.parse(opts.body);
expect(body.source).toBe("snapotter");
expect(body.version).toBe("1");
@@ -105,6 +105,189 @@ describe("webhook delivery", () => {
await deliverWebhook("https://example.com", "", [{ event: "test" }]);
const headers = fetchMock.mock.calls[0][1].headers;
expect(headers["Authorization"]).toBeUndefined();
expect(headers.Authorization).toBeUndefined();
});
it("returns error when request times out", async () => {
const abortError = new Error("The operation was aborted");
abortError.name = "AbortError";
const fetchMock = vi.fn().mockRejectedValue(abortError);
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const result = await deliverWebhook("https://example.com", "", [{ event: "test" }], {
maxRetries: 0,
initialDelayMs: 1,
});
expect(result.success).toBe(false);
expect(result.error).toBe("The operation was aborted");
expect(result.attempts).toBe(1);
});
it("follows exponential backoff delay pattern", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn().mockRejectedValue(new Error("down"));
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const promise = deliverWebhook("https://example.com", "", [{ event: "test" }], {
maxRetries: 3,
initialDelayMs: 1000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
expect(fetchMock).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(4000);
expect(fetchMock).toHaveBeenCalledTimes(4);
const result = await promise;
expect(result.success).toBe(false);
expect(result.attempts).toBe(4);
vi.useRealTimers();
});
it("limits retries when maxRetries is 1", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("down"));
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const result = await deliverWebhook("https://example.com", "", [{ event: "test" }], {
maxRetries: 1,
initialDelayMs: 1,
});
expect(result.success).toBe(false);
expect(result.attempts).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("makes no retries when maxRetries is 0", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("down"));
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const result = await deliverWebhook("https://example.com", "", [{ event: "test" }], {
maxRetries: 0,
initialDelayMs: 1,
});
expect(result.success).toBe(false);
expect(result.attempts).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("applies custom initialDelayMs to backoff schedule", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn().mockRejectedValue(new Error("down"));
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const promise = deliverWebhook("https://example.com", "", [{ event: "test" }], {
maxRetries: 2,
initialDelayMs: 500,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(fetchMock).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1000);
expect(fetchMock).toHaveBeenCalledTimes(3);
const result = await promise;
expect(result.success).toBe(false);
expect(result.attempts).toBe(3);
vi.useRealTimers();
});
it("sends payload with source, version, and events fields", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const events = [
{ event: "FILE_UPLOADED", userId: "u1" },
{ event: "FILE_DELETED", userId: "u2" },
];
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
await deliverWebhook("https://example.com/webhook", "", events);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({
source: "snapotter",
version: "1",
events,
});
});
it("sends Content-Type application/json header", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
await deliverWebhook("https://example.com", "", [{ event: "test" }]);
const headers = fetchMock.mock.calls[0][1].headers;
expect(headers["Content-Type"]).toBe("application/json");
});
it("delivers empty events array", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const result = await deliverWebhook("https://example.com", "", []);
expect(result.success).toBe(true);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.events).toEqual([]);
});
it("handles very large events array", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const events = Array.from({ length: 1000 }, (_, i) => ({ event: "BULK_OP", index: i }));
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const result = await deliverWebhook("https://example.com", "", events);
expect(result.success).toBe(true);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.events).toHaveLength(1000);
});
it("sends to URL with path and query components", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
const result = await deliverWebhook(
"https://api.example.com/v2/webhooks/ingest?source=snapotter",
"",
[{ event: "test" }],
);
expect(result.success).toBe(true);
expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.example.com/v2/webhooks/ingest?source=snapotter",
);
});
it("passes through Authorization header with Bearer prefix", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const { deliverWebhook } = await import("../../../apps/api/src/lib/webhook-delivery.js");
await deliverWebhook("https://example.com", "Bearer sk-live-abc123", [{ event: "test" }]);
const headers = fetchMock.mock.calls[0][1].headers;
expect(headers.Authorization).toBe("Bearer sk-live-abc123");
});
});