diff --git a/apps/api/src/activity/activity.service.spec.ts b/apps/api/src/activity/activity.service.spec.ts index 3c87822..3119cab 100644 --- a/apps/api/src/activity/activity.service.spec.ts +++ b/apps/api/src/activity/activity.service.spec.ts @@ -802,7 +802,7 @@ describe("ActivityService", () => { ); }); - it("should handle database errors gracefully when logging activity", async () => { + it("should handle database errors gracefully when logging activity (fire-and-forget)", async () => { const input: CreateActivityLogInput = { workspaceId: "workspace-123", userId: "user-123", @@ -814,7 +814,9 @@ describe("ActivityService", () => { const dbError = new Error("Database connection failed"); mockPrismaService.activityLog.create.mockRejectedValue(dbError); - await expect(service.logActivity(input)).rejects.toThrow("Database connection failed"); + // Activity logging is fire-and-forget - returns null on error instead of throwing + const result = await service.logActivity(input); + expect(result).toBeNull(); }); it("should handle extremely large details objects", async () => { @@ -1132,7 +1134,7 @@ describe("ActivityService", () => { }); describe("database error handling", () => { - it("should handle database connection failures in logActivity", async () => { + it("should handle database connection failures in logActivity (fire-and-forget)", async () => { const createInput: CreateActivityLogInput = { workspaceId: "workspace-123", userId: "user-123", @@ -1144,7 +1146,9 @@ describe("ActivityService", () => { const dbError = new Error("Connection refused"); mockPrismaService.activityLog.create.mockRejectedValue(dbError); - await expect(service.logActivity(createInput)).rejects.toThrow("Connection refused"); + // Activity logging is fire-and-forget - returns null on error instead of throwing + const result = await service.logActivity(createInput); + expect(result).toBeNull(); }); it("should handle Prisma timeout errors in findAll", async () => { diff --git a/apps/api/src/coordinator-integration/coordinator-integration.security.spec.ts b/apps/api/src/coordinator-integration/coordinator-integration.security.spec.ts index 8508f8f..e177dc3 100644 --- a/apps/api/src/coordinator-integration/coordinator-integration.security.spec.ts +++ b/apps/api/src/coordinator-integration/coordinator-integration.security.spec.ts @@ -53,79 +53,79 @@ describe("CoordinatorIntegrationController - Security", () => { expect(guards).toContain(ApiKeyGuard); }); - it("POST /coordinator/jobs should require authentication", async () => { + it("POST /coordinator/jobs should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); - it("PATCH /coordinator/jobs/:id/status should require authentication", async () => { + it("PATCH /coordinator/jobs/:id/status should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); - it("PATCH /coordinator/jobs/:id/progress should require authentication", async () => { + it("PATCH /coordinator/jobs/:id/progress should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); - it("POST /coordinator/jobs/:id/complete should require authentication", async () => { + it("POST /coordinator/jobs/:id/complete should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); - it("POST /coordinator/jobs/:id/fail should require authentication", async () => { + it("POST /coordinator/jobs/:id/fail should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); - it("GET /coordinator/jobs/:id should require authentication", async () => { + it("GET /coordinator/jobs/:id should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); - it("GET /coordinator/health should require authentication", async () => { + it("GET /coordinator/health should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); }); describe("Valid Authentication", () => { - it("should allow requests with valid API key", async () => { + it("should allow requests with valid API key", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ @@ -134,11 +134,11 @@ describe("CoordinatorIntegrationController - Security", () => { }), }; - const result = await guard.canActivate(mockContext as any); + const result = guard.canActivate(mockContext as never); expect(result).toBe(true); }); - it("should reject requests with invalid API key", async () => { + it("should reject requests with invalid API key", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ @@ -147,8 +147,8 @@ describe("CoordinatorIntegrationController - Security", () => { }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - await expect(guard.canActivate(mockContext as any)).rejects.toThrow("Invalid API key"); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow("Invalid API key"); }); }); }); diff --git a/apps/api/src/federation/federation.controller.spec.ts b/apps/api/src/federation/federation.controller.spec.ts index 320cbc1..b2551b8 100644 --- a/apps/api/src/federation/federation.controller.spec.ts +++ b/apps/api/src/federation/federation.controller.spec.ts @@ -2,7 +2,7 @@ * Federation Controller Tests */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { FederationController } from "./federation.controller"; import { FederationService } from "./federation.service"; @@ -11,6 +11,8 @@ import { ConnectionService } from "./connection.service"; import { FederationAgentService } from "./federation-agent.service"; import { AuthGuard } from "../auth/guards/auth.guard"; import { AdminGuard } from "../auth/guards/admin.guard"; +import { CsrfGuard } from "../common/guards/csrf.guard"; +import { WorkspaceGuard } from "../common/guards/workspace.guard"; import { FederationConnectionStatus } from "@prisma/client"; import type { PublicInstanceIdentity } from "./types/instance.types"; import type { ConnectionDetails } from "./types/connection.types"; @@ -60,7 +62,13 @@ describe("FederationController", () => { disconnectedAt: null, }; + // Store original env value + const originalDefaultWorkspaceId = process.env.DEFAULT_WORKSPACE_ID; + beforeEach(async () => { + // Set environment variable for tests that use getDefaultWorkspaceId() + process.env.DEFAULT_WORKSPACE_ID = "12345678-1234-4123-8123-123456789abc"; + const module: TestingModule = await Test.createTestingModule({ controllers: [FederationController], providers: [ @@ -103,6 +111,10 @@ describe("FederationController", () => { .useValue({ canActivate: () => true }) .overrideGuard(AdminGuard) .useValue({ canActivate: () => true }) + .overrideGuard(CsrfGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(WorkspaceGuard) + .useValue({ canActivate: () => true }) .compile(); controller = module.get(FederationController); @@ -111,6 +123,15 @@ describe("FederationController", () => { connectionService = module.get(ConnectionService); }); + afterEach(() => { + // Restore original env value + if (originalDefaultWorkspaceId !== undefined) { + process.env.DEFAULT_WORKSPACE_ID = originalDefaultWorkspaceId; + } else { + delete process.env.DEFAULT_WORKSPACE_ID; + } + }); + describe("GET /instance", () => { it("should return public instance identity", async () => { // Arrange diff --git a/apps/api/src/federation/federation.service.spec.ts b/apps/api/src/federation/federation.service.spec.ts index 3914270..b5efd27 100644 --- a/apps/api/src/federation/federation.service.spec.ts +++ b/apps/api/src/federation/federation.service.spec.ts @@ -178,7 +178,7 @@ describe("FederationService", () => { }); describe("generateKeypair", () => { - it("should generate valid RSA key pair", () => { + it("should generate valid RSA key pair", { timeout: 30000 }, () => { // Act const result = service.generateKeypair(); @@ -189,7 +189,7 @@ describe("FederationService", () => { expect(result.privateKey).toContain("BEGIN PRIVATE KEY"); }); - it("should generate different key pairs on each call", () => { + it("should generate different key pairs on each call", { timeout: 60000 }, () => { // Act const result1 = service.generateKeypair(); const result2 = service.generateKeypair(); @@ -199,7 +199,7 @@ describe("FederationService", () => { expect(result1.privateKey).not.toEqual(result2.privateKey); }); - it("should generate RSA-4096 key pairs for future-proof security", () => { + it("should generate RSA-4096 key pairs for future-proof security", { timeout: 30000 }, () => { // Act const result = service.generateKeypair(); diff --git a/apps/api/src/job-events/job-events.performance.spec.ts b/apps/api/src/job-events/job-events.performance.spec.ts index 2b4350a..dc2f4bb 100644 --- a/apps/api/src/job-events/job-events.performance.spec.ts +++ b/apps/api/src/job-events/job-events.performance.spec.ts @@ -13,7 +13,9 @@ import { JOB_CREATED, JOB_STARTED, STEP_STARTED } from "./event-types"; * NOTE: These tests require a real database connection with realistic data volume. * Run with: pnpm test:api -- job-events.performance.spec.ts */ -describe("JobEventsService Performance", () => { +const describeFn = process.env.DATABASE_URL ? describe : describe.skip; + +describeFn("JobEventsService Performance", () => { let service: JobEventsService; let prisma: PrismaService; let testJobId: string; diff --git a/apps/api/src/knowledge/services/fulltext-search.spec.ts b/apps/api/src/knowledge/services/fulltext-search.spec.ts index 36005b9..9e04b28 100644 --- a/apps/api/src/knowledge/services/fulltext-search.spec.ts +++ b/apps/api/src/knowledge/services/fulltext-search.spec.ts @@ -4,8 +4,13 @@ import { PrismaClient } from "@prisma/client"; /** * Integration tests for PostgreSQL full-text search setup * Tests the tsvector column, GIN index, and automatic trigger + * + * NOTE: These tests require a real database connection. + * Skip when DATABASE_URL is not set. */ -describe("Full-Text Search Setup (Integration)", () => { +const describeFn = process.env.DATABASE_URL ? describe : describe.skip; + +describeFn("Full-Text Search Setup (Integration)", () => { let prisma: PrismaClient; let testWorkspaceId: string; let testUserId: string; diff --git a/apps/api/src/prisma/prisma.service.spec.ts b/apps/api/src/prisma/prisma.service.spec.ts index 25d841c..d642636 100644 --- a/apps/api/src/prisma/prisma.service.spec.ts +++ b/apps/api/src/prisma/prisma.service.spec.ts @@ -15,6 +15,7 @@ describe("PrismaService", () => { afterEach(async () => { await service.$disconnect(); + vi.clearAllMocks(); }); it("should be defined", () => { @@ -126,14 +127,19 @@ describe("PrismaService", () => { const workspaceId = "workspace-456"; const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0); - await service.$transaction(async (tx) => { - await service.setWorkspaceContext(userId, workspaceId, tx); + // Mock $transaction to execute the callback with a mock tx client + const mockTx = { + $executeRaw: vi.fn().mockResolvedValue(0), + }; + vi.spyOn(service, "$transaction").mockImplementation(async (fn) => { + return fn(mockTx as never); }); - expect(executeRawSpy).toHaveBeenCalledTimes(2); - // Check that both session variables were set - expect(executeRawSpy).toHaveBeenNthCalledWith(1, expect.anything()); - expect(executeRawSpy).toHaveBeenNthCalledWith(2, expect.anything()); + await service.$transaction(async (tx) => { + await service.setWorkspaceContext(userId, workspaceId, tx as never); + }); + + expect(mockTx.$executeRaw).toHaveBeenCalledTimes(2); }); it("should work when called outside transaction using default client", async () => { @@ -151,20 +157,48 @@ describe("PrismaService", () => { it("should execute function with workspace context set", async () => { const userId = "user-123"; const workspaceId = "workspace-456"; - const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0); + + // Mock $transaction to execute the callback with a mock tx client that has $executeRaw + const mockTx = { + $executeRaw: vi.fn().mockResolvedValue(0), + }; + + // Mock both methods at the same time to avoid spy issues + const originalSetContext = service.setWorkspaceContext.bind(service); + const setContextCalls: [string, string, unknown][] = []; + service.setWorkspaceContext = vi.fn().mockImplementation((uid, wid, tx) => { + setContextCalls.push([uid, wid, tx]); + return Promise.resolve(); + }) as typeof service.setWorkspaceContext; + + service.$transaction = vi.fn().mockImplementation(async (fn) => { + return fn(mockTx as never); + }) as typeof service.$transaction; const result = await service.withWorkspaceContext(userId, workspaceId, async () => { return "test-result"; }); expect(result).toBe("test-result"); - expect(executeRawSpy).toHaveBeenCalledTimes(2); + expect(setContextCalls).toHaveLength(1); + expect(setContextCalls[0]).toEqual([userId, workspaceId, mockTx]); }); it("should pass transaction client to callback", async () => { const userId = "user-123"; const workspaceId = "workspace-456"; - vi.spyOn(service, "$executeRaw").mockResolvedValue(0); + + // Mock $transaction to execute the callback with a mock tx client + const mockTx = { + $executeRaw: vi.fn().mockResolvedValue(0), + }; + + service.setWorkspaceContext = vi + .fn() + .mockResolvedValue(undefined) as typeof service.setWorkspaceContext; + service.$transaction = vi.fn().mockImplementation(async (fn) => { + return fn(mockTx as never); + }) as typeof service.$transaction; let receivedClient: unknown = null; await service.withWorkspaceContext(userId, workspaceId, async (tx) => { @@ -179,7 +213,18 @@ describe("PrismaService", () => { it("should handle errors from callback", async () => { const userId = "user-123"; const workspaceId = "workspace-456"; - vi.spyOn(service, "$executeRaw").mockResolvedValue(0); + + // Mock $transaction to execute the callback with a mock tx client + const mockTx = { + $executeRaw: vi.fn().mockResolvedValue(0), + }; + + service.setWorkspaceContext = vi + .fn() + .mockResolvedValue(undefined) as typeof service.setWorkspaceContext; + service.$transaction = vi.fn().mockImplementation(async (fn) => { + return fn(mockTx as never); + }) as typeof service.$transaction; const error = new Error("Callback error"); await expect( @@ -192,13 +237,19 @@ describe("PrismaService", () => { describe("clearWorkspaceContext", () => { it("should clear workspace context variables", async () => { - const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0); - - await service.$transaction(async (tx) => { - await service.clearWorkspaceContext(tx); + // Mock $transaction to execute the callback with a mock tx client + const mockTx = { + $executeRaw: vi.fn().mockResolvedValue(0), + }; + vi.spyOn(service, "$transaction").mockImplementation(async (fn) => { + return fn(mockTx as never); }); - expect(executeRawSpy).toHaveBeenCalledTimes(2); + await service.$transaction(async (tx) => { + await service.clearWorkspaceContext(tx as never); + }); + + expect(mockTx.$executeRaw).toHaveBeenCalledTimes(2); }); }); }); diff --git a/apps/api/src/runner-jobs/runner-jobs.service.spec.ts b/apps/api/src/runner-jobs/runner-jobs.service.spec.ts index c53ace7..2632192 100644 --- a/apps/api/src/runner-jobs/runner-jobs.service.spec.ts +++ b/apps/api/src/runner-jobs/runner-jobs.service.spec.ts @@ -374,25 +374,32 @@ describe("RunnerJobsService", () => { id: jobId, workspaceId, status: RunnerJobStatus.PENDING, + version: 1, }; const mockUpdatedJob = { ...mockExistingJob, status: RunnerJobStatus.CANCELLED, completedAt: new Date(), + version: 2, }; - mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob); - mockPrismaService.runnerJob.update.mockResolvedValue(mockUpdatedJob); + // First findUnique returns existing job, second returns updated job + mockPrismaService.runnerJob.findUnique + .mockResolvedValueOnce(mockExistingJob) + .mockResolvedValueOnce(mockUpdatedJob); + // updateMany returns count for optimistic locking + mockPrismaService.runnerJob.updateMany.mockResolvedValue({ count: 1 }); const result = await service.cancel(jobId, workspaceId); expect(result).toEqual(mockUpdatedJob); - expect(prisma.runnerJob.update).toHaveBeenCalledWith({ - where: { id: jobId, workspaceId }, + expect(mockPrismaService.runnerJob.updateMany).toHaveBeenCalledWith({ + where: { id: jobId, workspaceId, version: mockExistingJob.version }, data: { status: RunnerJobStatus.CANCELLED, completedAt: expect.any(Date), + version: { increment: 1 }, }, }); }); @@ -405,17 +412,23 @@ describe("RunnerJobsService", () => { id: jobId, workspaceId, status: RunnerJobStatus.QUEUED, + version: 1, }; - mockPrismaService.runnerJob.findUnique.mockResolvedValue(mockExistingJob); - mockPrismaService.runnerJob.update.mockResolvedValue({ + const mockUpdatedJob = { ...mockExistingJob, status: RunnerJobStatus.CANCELLED, - }); + version: 2, + }; + + mockPrismaService.runnerJob.findUnique + .mockResolvedValueOnce(mockExistingJob) + .mockResolvedValueOnce(mockUpdatedJob); + mockPrismaService.runnerJob.updateMany.mockResolvedValue({ count: 1 }); await service.cancel(jobId, workspaceId); - expect(prisma.runnerJob.update).toHaveBeenCalled(); + expect(mockPrismaService.runnerJob.updateMany).toHaveBeenCalled(); }); it("should throw NotFoundException if job not found", async () => { diff --git a/apps/api/src/stitcher/stitcher.security.spec.ts b/apps/api/src/stitcher/stitcher.security.spec.ts index 9fbf738..fbfe892 100644 --- a/apps/api/src/stitcher/stitcher.security.spec.ts +++ b/apps/api/src/stitcher/stitcher.security.spec.ts @@ -48,29 +48,29 @@ describe("StitcherController - Security", () => { expect(guards).toContain(ApiKeyGuard); }); - it("POST /stitcher/webhook should require authentication", async () => { + it("POST /stitcher/webhook should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); - it("POST /stitcher/dispatch should require authentication", async () => { + it("POST /stitcher/dispatch should require authentication", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ headers: {} }), }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); }); describe("Valid Authentication", () => { - it("should allow requests with valid API key", async () => { + it("should allow requests with valid API key", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ @@ -79,11 +79,11 @@ describe("StitcherController - Security", () => { }), }; - const result = await guard.canActivate(mockContext as any); + const result = guard.canActivate(mockContext as never); expect(result).toBe(true); }); - it("should reject requests with invalid API key", async () => { + it("should reject requests with invalid API key", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ @@ -92,11 +92,11 @@ describe("StitcherController - Security", () => { }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - await expect(guard.canActivate(mockContext as any)).rejects.toThrow("Invalid API key"); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow("Invalid API key"); }); - it("should reject requests with empty API key", async () => { + it("should reject requests with empty API key", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ @@ -105,13 +105,13 @@ describe("StitcherController - Security", () => { }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - await expect(guard.canActivate(mockContext as any)).rejects.toThrow("No API key provided"); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow("No API key provided"); }); }); describe("Webhook Security", () => { - it("should prevent unauthorized webhook submissions", async () => { + it("should prevent unauthorized webhook submissions", () => { const mockContext = { switchToHttp: () => ({ getRequest: () => ({ @@ -125,7 +125,7 @@ describe("StitcherController - Security", () => { }), }; - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); + expect(() => guard.canActivate(mockContext as never)).toThrow(UnauthorizedException); }); }); }); diff --git a/apps/orchestrator/src/git/secret-scanner.service.spec.ts b/apps/orchestrator/src/git/secret-scanner.service.spec.ts index b211c4f..979d022 100644 --- a/apps/orchestrator/src/git/secret-scanner.service.spec.ts +++ b/apps/orchestrator/src/git/secret-scanner.service.spec.ts @@ -434,11 +434,28 @@ SECRET=replace-me // Remove read permissions await fs.chmod(testFile, 0o000); + // Check if we can still read the file (e.g., running as root) + let canReadAsRoot = false; + try { + await fs.readFile(testFile); + canReadAsRoot = true; + } catch { + // Expected behavior for non-root users + } + const result = await service.scanFile(testFile); - expect(result.scannedSuccessfully).toBe(false); - expect(result.scanError).toBeDefined(); - expect(result.hasSecrets).toBe(false); // Not "clean", just unscanned + if (canReadAsRoot) { + // Running as root - file is readable despite chmod 0o000 + // Scanner will successfully scan the file + expect(result.scannedSuccessfully).toBe(true); + expect(result.hasSecrets).toBe(true); // Contains AWS key + } else { + // Normal user - file is unreadable + expect(result.scannedSuccessfully).toBe(false); + expect(result.scanError).toBeDefined(); + expect(result.hasSecrets).toBe(false); // Not "clean", just unscanned + } // Cleanup - restore permissions first await fs.chmod(testFile, 0o644);