Files
formbricks/apps/web/app/lib/api/with-api-logging.test.ts
victorvhs017 a9946737df feat: audit logs (#5866)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 19:31:39 +00:00

278 lines
9.6 KiB
TypeScript

import * as Sentry from "@sentry/nextjs";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { responses } from "./response";
import { ApiAuditLog } from "./with-api-logging";
// Mocks
// This top-level mock is crucial for the SUT (withApiLogging.ts)
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
error: mockContextualLoggerError,
warn: mockContextualLoggerWarn,
info: mockContextualLoggerInfo,
}));
return {
logger: {
withContext: mockWithContextInstance,
// These are for direct calls like logger.error(), logger.warn()
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
};
});
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
return {
method,
url,
headers: {
get: (key: string) => headers.get(key),
},
} as unknown as Request;
}
// Minimal valid ApiAuditLog
const baseAudit: ApiAuditLog = {
action: "created",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
status: "failure",
userType: "api",
};
describe("withApiLogging", () => {
beforeEach(() => {
vi.resetModules(); // Reset SUT and other potentially cached modules
// vi.doMock for constants if a specific test needs to override it
// The top-level mocks for audit-logs, sentry, logger should be re-applied implicitly
// or are already in place due to vi.mock hoisting.
// Restore the mock for constants to its default for most tests
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
vi.clearAllMocks(); // Clear call counts etc. for all vi.fn()
});
test("logs and audits on error response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.internalServerErrorResponse("fail"),
};
});
const req = createMockRequest({ headers: new Map([["x-request-id", "abc-123"]]) });
const { withApiLogging } = await import("./with-api-logging"); // SUT dynamically imported
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "abc-123",
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
);
});
test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.badRequestResponse("bad req"),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
});
test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
throw new Error("fail!");
});
const req = createMockRequest({ headers: new Map([["x-request-id", "err-1"]]) });
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({
code: "internal_server_error",
message: "An unexpected error occurred.",
details: {},
});
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "err-1",
userType: "api",
apiUrl: req.url,
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
);
});
test("does not log/audit on success response", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.successResponse({ ok: true }),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(logger.withContext).not.toHaveBeenCalled();
expect(mockContextualLoggerError).not.toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
apiUrl: req.url,
action: "created",
status: "success",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
organizationId: "org-1",
})
);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
// For this specific test, we override the AUDIT_LOG_ENABLED constant
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
}));
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { withApiLogging } = await import("./with-api-logging");
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
audit: { ...baseAudit },
});
const req = createMockRequest();
const wrapped = withApiLogging(handler, "created", "survey");
await wrapped(req, undefined);
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
});