import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { ActivityLoggingInterceptor } from "./activity-logging.interceptor"; import { ActivityService } from "../activity.service"; import { ExecutionContext, CallHandler } from "@nestjs/common"; import { of } from "rxjs"; import { ActivityAction, EntityType } from "@prisma/client"; describe("ActivityLoggingInterceptor", () => { let interceptor: ActivityLoggingInterceptor; let activityService: ActivityService; const mockActivityService = { logActivity: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ActivityLoggingInterceptor, { provide: ActivityService, useValue: mockActivityService, }, ], }).compile(); interceptor = module.get(ActivityLoggingInterceptor); activityService = module.get(ActivityService); vi.clearAllMocks(); }); const createMockExecutionContext = ( method: string, params: any = {}, body: any = {}, user: any = null, ip = "127.0.0.1", userAgent = "test-agent" ): ExecutionContext => { return { switchToHttp: () => ({ getRequest: () => ({ method, params, body, user, ip, headers: { "user-agent": userAgent, }, }), }), getClass: () => ({ name: "TestController" }), getHandler: () => ({ name: "testMethod" }), } as any; }; const createMockCallHandler = (result: any = {}): CallHandler => { return { handle: () => of(result), } as any; }; describe("intercept", () => { it("should not log if user is not authenticated", async () => { const context = createMockExecutionContext("POST", {}, {}, null); const next = createMockCallHandler(); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); it("should log POST request as CREATE action", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { title: "New Task", }; const result = { id: "task-123", workspaceId: "workspace-123", title: "New Task", }; const context = createMockExecutionContext( "POST", {}, body, user, "127.0.0.1", "Mozilla/5.0" ); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-123", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).toHaveBeenCalledWith({ workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.CREATED, entityType: expect.any(String), entityId: "task-123", details: expect.objectContaining({ method: "POST", controller: "TestController", handler: "testMethod", }), ipAddress: "127.0.0.1", userAgent: "Mozilla/5.0", }); resolve(); }); }); }); it("should log PATCH request as UPDATE action", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const params = { id: "task-456", }; const body = { status: "IN_PROGRESS", }; const result = { id: "task-456", workspaceId: "workspace-123", status: "IN_PROGRESS", }; const context = createMockExecutionContext("PATCH", params, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-124", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).toHaveBeenCalledWith({ workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: expect.any(String), entityId: "task-456", details: expect.objectContaining({ method: "PATCH", changes: body, }), ipAddress: "127.0.0.1", userAgent: "test-agent", }); resolve(); }); }); }); it("should log PUT request as UPDATE action", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const params = { id: "event-789", }; const body = { title: "Updated Event", }; const result = { id: "event-789", workspaceId: "workspace-123", title: "Updated Event", }; const context = createMockExecutionContext("PUT", params, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-125", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).toHaveBeenCalledWith({ workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.UPDATED, entityType: expect.any(String), entityId: "event-789", details: expect.objectContaining({ method: "PUT", }), ipAddress: "127.0.0.1", userAgent: "test-agent", }); resolve(); }); }); }); it("should log DELETE request as DELETE action", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const params = { id: "project-999", }; const result = { id: "project-999", workspaceId: "workspace-123", }; const context = createMockExecutionContext("DELETE", params, {}, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-126", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).toHaveBeenCalledWith({ workspaceId: "workspace-123", userId: "user-123", action: ActivityAction.DELETED, entityType: expect.any(String), entityId: "project-999", details: expect.objectContaining({ method: "DELETE", }), ipAddress: "127.0.0.1", userAgent: "test-agent", }); resolve(); }); }); }); it("should not log GET requests", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("GET", {}, {}, user); const next = createMockCallHandler({ data: [] }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); it("should extract entity ID from result if not in params", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { title: "New Task", }; const result = { id: "task-new-123", workspaceId: "workspace-123", title: "New Task", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-127", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).toHaveBeenCalledWith( expect.objectContaining({ entityId: "task-new-123", }) ); resolve(); }); }); }); it("should handle errors gracefully", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("POST", {}, {}, user); const next = createMockCallHandler({ id: "test-123" }); mockActivityService.logActivity.mockRejectedValue(new Error("Logging failed")); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not throw error, just log it resolve(); }); }); }); }); describe("edge cases", () => { it("should handle POST request with no id field in response", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { title: "New Task", }; const result = { workspaceId: "workspace-123", title: "New Task", // No 'id' field in response }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-123", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not call logActivity when entityId is missing expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); it("should handle user object missing workspaceId", async () => { const user = { id: "user-123", // No workspaceId }; const body = { title: "New Task", }; const result = { id: "task-123", title: "New Task", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not call logActivity when workspaceId is missing expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); it("should handle body missing workspaceId when user also missing workspaceId", async () => { const user = { id: "user-123", // No workspaceId }; const body = { title: "New Task", // No workspaceId }; const result = { id: "task-123", title: "New Task", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not call logActivity when workspaceId is missing expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); it("should extract workspaceId from body when not in user object", async () => { const user = { id: "user-123", // No workspaceId }; const body = { workspaceId: "workspace-from-body", title: "New Task", }; const result = { id: "task-123", title: "New Task", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-123", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { expect(mockActivityService.logActivity).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: "workspace-from-body", }) ); resolve(); }); }); }); it("should handle null result from handler", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("DELETE", { id: "task-123" }, {}, user); const next = createMockCallHandler(null); mockActivityService.logActivity.mockResolvedValue({ id: "activity-123", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should still log activity with entityId from params expect(mockActivityService.logActivity).toHaveBeenCalledWith( expect.objectContaining({ entityId: "task-123", workspaceId: "workspace-123", }) ); resolve(); }); }); }); it("should handle undefined result from handler", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("POST", {}, { title: "New Task" }, user); const next = createMockCallHandler(undefined); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not log when entityId cannot be determined expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); it("should log warning when entityId is missing", async () => { const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { title: "New Task", }; const result = { workspaceId: "workspace-123", title: "New Task", // No 'id' field }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { resolve(); }); }); consoleSpy.mockRestore(); }); it("should log warning when workspaceId is missing", async () => { const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const user = { id: "user-123", // No workspaceId }; const body = { title: "New Task", }; const result = { id: "task-123", title: "New Task", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { resolve(); }); }); consoleSpy.mockRestore(); }); it("should handle activity service throwing an error", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("POST", {}, {}, user); const next = createMockCallHandler({ id: "test-123" }); const activityError = new Error("Activity logging failed"); mockActivityService.logActivity.mockRejectedValue(activityError); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not throw error, just log it resolve(); }); }); }); it("should handle OPTIONS requests", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("OPTIONS", {}, {}, user); const next = createMockCallHandler({}); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not log OPTIONS requests expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); it("should handle HEAD requests", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("HEAD", {}, {}, user); const next = createMockCallHandler({}); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { // Should not log HEAD requests expect(mockActivityService.logActivity).not.toHaveBeenCalled(); resolve(); }); }); }); }); describe("sensitive data sanitization", () => { it("should redact password field", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { username: "testuser", password: "secret123", email: "test@example.com", }; const result = { id: "user-456", workspaceId: "workspace-123", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-123", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { const logCall = mockActivityService.logActivity.mock.calls[0][0]; expect(logCall.details.data.password).toBe("[REDACTED]"); expect(logCall.details.data.username).toBe("testuser"); expect(logCall.details.data.email).toBe("test@example.com"); resolve(); }); }); }); it("should redact token field", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { title: "Integration", apiToken: "sk_test_1234567890", }; const result = { id: "integration-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-124", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { const logCall = mockActivityService.logActivity.mock.calls[0][0]; expect(logCall.details.data.apiToken).toBe("[REDACTED]"); expect(logCall.details.data.title).toBe("Integration"); resolve(); }); }); }); it("should redact sensitive fields in nested objects", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { title: "Config", settings: { apiKey: "secret_key", public: "visible_data", auth: { token: "auth_token_123", refreshToken: "refresh_token_456", }, }, }; const result = { id: "config-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-128", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { const logCall = mockActivityService.logActivity.mock.calls[0][0]; expect(logCall.details.data.title).toBe("Config"); expect(logCall.details.data.settings.apiKey).toBe("[REDACTED]"); expect(logCall.details.data.settings.public).toBe("visible_data"); expect(logCall.details.data.settings.auth.token).toBe("[REDACTED]"); expect(logCall.details.data.settings.auth.refreshToken).toBe("[REDACTED]"); resolve(); }); }); }); it("should not modify non-sensitive fields", async () => { const user = { id: "user-123", workspaceId: "workspace-123", }; const body = { title: "Safe Data", description: "This is public", count: 42, active: true, }; const result = { id: "item-123", workspaceId: "workspace-123", }; const context = createMockExecutionContext("POST", {}, body, user); const next = createMockCallHandler(result); mockActivityService.logActivity.mockResolvedValue({ id: "activity-130", }); await new Promise((resolve) => { interceptor.intercept(context, next).subscribe(() => { const logCall = mockActivityService.logActivity.mock.calls[0][0]; expect(logCall.details.data).toEqual(body); resolve(); }); }); }); }); });