import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { CredentialsService } from "./credentials.service"; import { PrismaService } from "../prisma/prisma.service"; import { ActivityService } from "../activity/activity.service"; import { VaultService } from "../vault/vault.service"; import { CredentialType, CredentialScope } from "@prisma/client"; import { ActivityAction, EntityType } from "@prisma/client"; import { NotFoundException, BadRequestException } from "@nestjs/common"; import { TransitKey } from "../vault/vault.constants"; describe("CredentialsService", () => { let service: CredentialsService; let prisma: PrismaService; let activityService: ActivityService; let vaultService: VaultService; const mockPrismaService = { userCredential: { create: vi.fn(), findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn(), update: vi.fn(), delete: vi.fn(), }, activityLog: { findMany: vi.fn(), count: vi.fn(), }, }; const mockActivityService = { logActivity: vi.fn(), }; const mockVaultService = { encrypt: vi.fn(), decrypt: vi.fn(), }; const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; const mockCredentialId = "550e8400-e29b-41d4-a716-446655440003"; const mockCredential = { id: mockCredentialId, userId: mockUserId, workspaceId: mockWorkspaceId, name: "GitHub Token", provider: "github", type: CredentialType.API_KEY, scope: CredentialScope.USER, encryptedValue: "vault:v1:encrypted-data-here", maskedValue: "****3456", description: "My GitHub API key", expiresAt: null, lastUsedAt: null, metadata: {}, isActive: true, rotatedAt: null, createdAt: new Date(), updatedAt: new Date(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ CredentialsService, { provide: PrismaService, useValue: mockPrismaService, }, { provide: ActivityService, useValue: mockActivityService, }, { provide: VaultService, useValue: mockVaultService, }, ], }).compile(); service = module.get(CredentialsService); prisma = module.get(PrismaService); activityService = module.get(ActivityService); vaultService = module.get(VaultService); // Clear all mocks before each test vi.clearAllMocks(); }); it("should be defined", () => { expect(service).toBeDefined(); }); describe("create", () => { it("should create a credential with encrypted value and log activity", async () => { const createDto = { name: "GitHub Token", provider: "github", type: CredentialType.API_KEY, value: "ghp_1234567890abcdef", description: "My GitHub API key", }; mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted-data-here"); mockPrismaService.userCredential.create.mockResolvedValue(mockCredential); mockActivityService.logActivity.mockResolvedValue({}); const result = await service.create(mockWorkspaceId, mockUserId, createDto); expect(result).toEqual(mockCredential); expect(vaultService.encrypt).toHaveBeenCalledWith(createDto.value, TransitKey.CREDENTIALS); expect(prisma.userCredential.create).toHaveBeenCalledWith({ data: { userId: mockUserId, workspaceId: mockWorkspaceId, name: createDto.name, provider: createDto.provider, type: createDto.type, scope: CredentialScope.USER, encryptedValue: "vault:v1:encrypted-data-here", maskedValue: "****cdef", description: createDto.description, expiresAt: null, metadata: {}, }, select: expect.any(Object), }); expect(activityService.logActivity).toHaveBeenCalledWith({ workspaceId: mockWorkspaceId, userId: mockUserId, action: ActivityAction.CREDENTIAL_CREATED, entityType: EntityType.CREDENTIAL, entityId: mockCredential.id, details: { name: createDto.name, provider: createDto.provider, type: createDto.type, }, }); }); it("should create credential with custom scope", async () => { const createDto = { name: "Workspace API Key", provider: "custom", type: CredentialType.API_KEY, scope: CredentialScope.WORKSPACE, value: "ws_key_123", }; mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted"); mockPrismaService.userCredential.create.mockResolvedValue({ ...mockCredential, scope: CredentialScope.WORKSPACE, }); await service.create(mockWorkspaceId, mockUserId, createDto); expect(prisma.userCredential.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ scope: CredentialScope.WORKSPACE, }), }) ); }); it("should generate maskedValue from last 4 characters", async () => { const createDto = { name: "Short", provider: "test", type: CredentialType.SECRET, value: "abc", }; mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted"); mockPrismaService.userCredential.create.mockResolvedValue(mockCredential); await service.create(mockWorkspaceId, mockUserId, createDto); expect(prisma.userCredential.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ maskedValue: "****abc", }), }) ); }); }); describe("findAll", () => { it("should return paginated credentials without encryptedValue", async () => { const query = { page: 1, limit: 10 }; const credentials = [mockCredential]; mockPrismaService.userCredential.findMany.mockResolvedValue(credentials); mockPrismaService.userCredential.count.mockResolvedValue(1); const result = await service.findAll(mockWorkspaceId, mockUserId, query); expect(result).toEqual({ data: credentials, meta: { total: 1, page: 1, limit: 10, totalPages: 1, }, }); expect(prisma.userCredential.findMany).toHaveBeenCalledWith({ where: { userId: mockUserId, workspaceId: mockWorkspaceId, }, select: { id: true, userId: true, workspaceId: true, name: true, provider: true, type: true, scope: true, maskedValue: true, description: true, expiresAt: true, lastUsedAt: true, metadata: true, isActive: true, rotatedAt: true, createdAt: true, updatedAt: true, }, orderBy: { createdAt: "desc" }, skip: 0, take: 10, }); }); it("should filter by type", async () => { const query = { type: CredentialType.API_KEY }; mockPrismaService.userCredential.findMany.mockResolvedValue([]); mockPrismaService.userCredential.count.mockResolvedValue(0); await service.findAll(mockWorkspaceId, mockUserId, query); expect(prisma.userCredential.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ type: { in: [CredentialType.API_KEY] }, }), }) ); }); it("should filter by isActive", async () => { const query = { isActive: true }; mockPrismaService.userCredential.findMany.mockResolvedValue([]); mockPrismaService.userCredential.count.mockResolvedValue(0); await service.findAll(mockWorkspaceId, mockUserId, query); expect(prisma.userCredential.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ isActive: true, }), }) ); }); }); describe("findOne", () => { it("should return a single credential without encryptedValue", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); const result = await service.findOne(mockCredentialId, mockWorkspaceId, mockUserId); expect(result).toEqual(mockCredential); expect(prisma.userCredential.findUnique).toHaveBeenCalledWith({ where: { id: mockCredentialId, userId: mockUserId, workspaceId: mockWorkspaceId, }, select: expect.objectContaining({ id: true, maskedValue: true, }), }); }); it("should throw NotFoundException when credential not found", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(null); await expect(service.findOne(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( NotFoundException ); }); }); describe("getValue", () => { it("should decrypt and return credential value, update lastUsedAt, and log access", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); mockVaultService.decrypt.mockResolvedValue("ghp_1234567890abcdef"); mockPrismaService.userCredential.update.mockResolvedValue(mockCredential); mockActivityService.logActivity.mockResolvedValue({}); const result = await service.getValue(mockCredentialId, mockWorkspaceId, mockUserId); expect(result).toEqual({ value: "ghp_1234567890abcdef" }); expect(vaultService.decrypt).toHaveBeenCalledWith( mockCredential.encryptedValue, TransitKey.CREDENTIALS ); expect(prisma.userCredential.update).toHaveBeenCalledWith({ where: { id: mockCredentialId }, data: { lastUsedAt: expect.any(Date) }, }); expect(activityService.logActivity).toHaveBeenCalledWith({ workspaceId: mockWorkspaceId, userId: mockUserId, action: ActivityAction.CREDENTIAL_ACCESSED, entityType: EntityType.CREDENTIAL, entityId: mockCredentialId, details: { name: mockCredential.name, provider: mockCredential.provider, }, }); }); it("should throw NotFoundException when credential not found", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(null); await expect(service.getValue(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( NotFoundException ); }); it("should throw BadRequestException when credential is not active", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue({ ...mockCredential, isActive: false, }); await expect(service.getValue(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( BadRequestException ); }); }); describe("update", () => { it("should update credential metadata and log activity", async () => { const updateDto = { description: "Updated description", }; mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); mockPrismaService.userCredential.update.mockResolvedValue({ ...mockCredential, ...updateDto, }); mockActivityService.logActivity.mockResolvedValue({}); const result = await service.update(mockCredentialId, mockWorkspaceId, mockUserId, updateDto); expect(result.description).toBe(updateDto.description); expect(prisma.userCredential.update).toHaveBeenCalledWith({ where: { id: mockCredentialId }, data: updateDto, select: expect.any(Object), }); expect(activityService.logActivity).toHaveBeenCalledWith({ workspaceId: mockWorkspaceId, userId: mockUserId, action: ActivityAction.UPDATED, entityType: EntityType.CREDENTIAL, entityId: mockCredentialId, details: { description: updateDto.description, expiresAt: undefined, isActive: undefined, }, }); }); it("should throw NotFoundException when credential not found", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(null); await expect( service.update(mockCredentialId, mockWorkspaceId, mockUserId, { description: "test" }) ).rejects.toThrow(NotFoundException); }); }); describe("rotate", () => { it("should rotate credential value and log activity", async () => { const newValue = "ghp_new_token_12345"; mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); mockVaultService.encrypt.mockResolvedValue("vault:v1:new-encrypted"); mockPrismaService.userCredential.update.mockResolvedValue({ ...mockCredential, encryptedValue: "vault:v1:new-encrypted", maskedValue: "****2345", rotatedAt: new Date(), }); mockActivityService.logActivity.mockResolvedValue({}); const result = await service.rotate(mockCredentialId, mockWorkspaceId, mockUserId, newValue); expect(vaultService.encrypt).toHaveBeenCalledWith(newValue, TransitKey.CREDENTIALS); expect(prisma.userCredential.update).toHaveBeenCalledWith({ where: { id: mockCredentialId }, data: { encryptedValue: "vault:v1:new-encrypted", maskedValue: "****2345", rotatedAt: expect.any(Date), }, select: expect.any(Object), }); expect(activityService.logActivity).toHaveBeenCalledWith({ workspaceId: mockWorkspaceId, userId: mockUserId, action: ActivityAction.CREDENTIAL_ROTATED, entityType: EntityType.CREDENTIAL, entityId: mockCredentialId, details: { name: mockCredential.name, provider: mockCredential.provider, }, }); }); it("should throw NotFoundException when credential not found", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(null); await expect( service.rotate(mockCredentialId, mockWorkspaceId, mockUserId, "new_value") ).rejects.toThrow(NotFoundException); }); }); describe("remove", () => { it("should soft-delete credential and log activity", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); mockPrismaService.userCredential.update.mockResolvedValue({ ...mockCredential, isActive: false, }); mockActivityService.logActivity.mockResolvedValue({}); await service.remove(mockCredentialId, mockWorkspaceId, mockUserId); expect(prisma.userCredential.update).toHaveBeenCalledWith({ where: { id: mockCredentialId }, data: { isActive: false }, }); expect(activityService.logActivity).toHaveBeenCalledWith({ workspaceId: mockWorkspaceId, userId: mockUserId, action: ActivityAction.CREDENTIAL_REVOKED, entityType: EntityType.CREDENTIAL, entityId: mockCredentialId, details: { name: mockCredential.name, provider: mockCredential.provider, }, }); }); it("should throw NotFoundException when credential not found", async () => { mockPrismaService.userCredential.findUnique.mockResolvedValue(null); await expect(service.remove(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( NotFoundException ); }); }); describe("getAuditLog", () => { const mockAuditLogs = [ { id: "log-1", action: ActivityAction.CREDENTIAL_ACCESSED, entityId: mockCredentialId, createdAt: new Date("2024-01-15T10:00:00Z"), details: { name: "GitHub Token", provider: "github" }, user: { id: mockUserId, name: "John Doe", email: "john@example.com", }, }, { id: "log-2", action: ActivityAction.CREDENTIAL_CREATED, entityId: mockCredentialId, createdAt: new Date("2024-01-10T09:00:00Z"), details: { name: "GitHub Token", provider: "github" }, user: { id: mockUserId, name: "John Doe", email: "john@example.com", }, }, ]; it("should return paginated audit logs", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue(mockAuditLogs); mockPrismaService.activityLog.count.mockResolvedValue(2); const result = await service.getAuditLog(mockWorkspaceId, { page: 1, limit: 20, }); expect(result.data).toHaveLength(2); expect(result.meta.total).toBe(2); expect(result.meta.page).toBe(1); expect(result.meta.limit).toBe(20); expect(result.meta.totalPages).toBe(1); }); it("should filter by credentialId", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue([mockAuditLogs[0]]); mockPrismaService.activityLog.count.mockResolvedValue(1); await service.getAuditLog(mockWorkspaceId, { credentialId: mockCredentialId, page: 1, limit: 20, }); const callArgs = mockPrismaService.activityLog.findMany.mock.calls[0][0]; expect(callArgs.where.entityId).toBe(mockCredentialId); }); it("should filter by action type", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue([mockAuditLogs[0]]); mockPrismaService.activityLog.count.mockResolvedValue(1); await service.getAuditLog(mockWorkspaceId, { action: ActivityAction.CREDENTIAL_ACCESSED, page: 1, limit: 20, }); const callArgs = mockPrismaService.activityLog.findMany.mock.calls[0][0]; expect(callArgs.where.action).toBe(ActivityAction.CREDENTIAL_ACCESSED); }); it("should filter by date range", async () => { const startDate = new Date("2024-01-10T00:00:00Z"); const endDate = new Date("2024-01-15T23:59:59Z"); mockPrismaService.activityLog.findMany.mockResolvedValue(mockAuditLogs); mockPrismaService.activityLog.count.mockResolvedValue(2); await service.getAuditLog(mockWorkspaceId, { startDate, endDate, page: 1, limit: 20, }); const callArgs = mockPrismaService.activityLog.findMany.mock.calls[0][0]; expect(callArgs.where.createdAt.gte).toBe(startDate); expect(callArgs.where.createdAt.lte).toBe(endDate); }); it("should handle pagination correctly", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue([mockAuditLogs[0]]); mockPrismaService.activityLog.count.mockResolvedValue(25); const result = await service.getAuditLog(mockWorkspaceId, { page: 2, limit: 20, }); expect(result.meta.page).toBe(2); expect(result.meta.limit).toBe(20); expect(result.meta.totalPages).toBe(2); const callArgs = mockPrismaService.activityLog.findMany.mock.calls[0][0]; expect(callArgs.skip).toBe(20); // (2 - 1) * 20 expect(callArgs.take).toBe(20); }); it("should order by createdAt descending", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue(mockAuditLogs); mockPrismaService.activityLog.count.mockResolvedValue(2); await service.getAuditLog(mockWorkspaceId, { page: 1, limit: 20, }); const callArgs = mockPrismaService.activityLog.findMany.mock.calls[0][0]; expect(callArgs.orderBy).toEqual({ createdAt: "desc" }); }); it("should always filter by CREDENTIAL entityType", async () => { mockPrismaService.activityLog.findMany.mockResolvedValue([]); mockPrismaService.activityLog.count.mockResolvedValue(0); await service.getAuditLog(mockWorkspaceId, { page: 1, limit: 20, }); const callArgs = mockPrismaService.activityLog.findMany.mock.calls[0][0]; expect(callArgs.where.entityType).toBe(EntityType.CREDENTIAL); expect(callArgs.where.workspaceId).toBe(mockWorkspaceId); }); }); });