624 lines
20 KiB
TypeScript
624 lines
20 KiB
TypeScript
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>(CredentialsService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
activityService = module.get<ActivityService>(ActivityService);
|
|
vaultService = module.get<VaultService>(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);
|
|
});
|
|
});
|
|
});
|