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