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:
2026-02-07 16:50:02 -06:00
parent aa2ee5aea3
commit 46d0a06ef5
14 changed files with 1566 additions and 0 deletions

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