feat(#356): Build credential CRUD API endpoints
Implement comprehensive CRUD API for managing user credentials with encryption, RLS, and audit logging following TDD methodology. Features: - POST /api/credentials - Create encrypted credential - GET /api/credentials - List credentials (masked values only) - GET /api/credentials/:id - Get single credential (masked) - GET /api/credentials/:id/value - Decrypt plaintext (rate limited 10/min) - PATCH /api/credentials/:id - Update metadata - POST /api/credentials/:id/rotate - Rotate credential value - DELETE /api/credentials/:id - Soft delete Security: - All values encrypted via VaultService (TransitKey.CREDENTIALS) - List/Get endpoints NEVER return plaintext (only maskedValue) - getValue endpoint rate limited to 10 requests/minute per user - All operations audit-logged with CREDENTIAL_* ActivityAction - RLS enforces per-user isolation via getRlsClient() pattern - Input validation via class-validator DTOs Testing: - 26/26 unit tests passing - 95.71% code coverage (exceeds 85% requirement) - Service: 95.16% - Controller: 100% - TypeScript checks pass Files created: - apps/api/src/credentials/credentials.service.ts - apps/api/src/credentials/credentials.service.spec.ts - apps/api/src/credentials/credentials.controller.ts - apps/api/src/credentials/credentials.controller.spec.ts - apps/api/src/credentials/credentials.module.ts - apps/api/src/credentials/dto/*.dto.ts (5 DTOs) Files modified: - apps/api/src/app.module.ts - imported CredentialsModule Note: Admin credentials endpoints deferred to future issue. Current implementation covers all user credential endpoints. Refs #346 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
482
apps/api/src/credentials/credentials.service.spec.ts
Normal file
482
apps/api/src/credentials/credentials.service.spec.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
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(),
|
||||
},
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user