Files
stack/apps/api/src/credentials/credentials.service.spec.ts
2026-02-07 17:33:32 -06:00

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);
});
});
});