Files
stack/apps/api/src/credentials/credentials.controller.spec.ts
Jason Woltje 46d0a06ef5 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>
2026-02-07 16:50:02 -06:00

224 lines
6.5 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { CredentialsController } from "./credentials.controller";
import { CredentialsService } from "./credentials.service";
import { CredentialType, CredentialScope } from "@prisma/client";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard } from "../common/guards/workspace.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { ExecutionContext } from "@nestjs/common";
describe("CredentialsController", () => {
let controller: CredentialsController;
let service: CredentialsService;
const mockCredentialsService = {
create: vi.fn(),
findAll: vi.fn(),
findOne: vi.fn(),
getValue: vi.fn(),
update: vi.fn(),
rotate: vi.fn(),
remove: vi.fn(),
};
const mockAuthGuard = {
canActivate: vi.fn((context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
request.user = {
id: "550e8400-e29b-41d4-a716-446655440002",
workspaceId: "550e8400-e29b-41d4-a716-446655440001",
};
return true;
}),
};
const mockWorkspaceGuard = {
canActivate: vi.fn(() => true),
};
const mockPermissionGuard = {
canActivate: vi.fn(() => true),
};
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
const mockCredentialId = "550e8400-e29b-41d4-a716-446655440003";
const mockUser = {
id: mockUserId,
email: "test@example.com",
name: "Test User",
};
const mockCredential = {
id: mockCredentialId,
userId: mockUserId,
workspaceId: mockWorkspaceId,
name: "GitHub Token",
provider: "github",
type: CredentialType.API_KEY,
scope: CredentialScope.USER,
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({
controllers: [CredentialsController],
providers: [
{
provide: CredentialsService,
useValue: mockCredentialsService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.overrideGuard(WorkspaceGuard)
.useValue(mockWorkspaceGuard)
.overrideGuard(PermissionGuard)
.useValue(mockPermissionGuard)
.compile();
controller = module.get<CredentialsController>(CredentialsController);
service = module.get<CredentialsService>(CredentialsService);
// Clear all mocks before each test
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("create", () => {
it("should create a credential", async () => {
const createDto = {
name: "GitHub Token",
provider: "github",
type: CredentialType.API_KEY,
value: "ghp_1234567890abcdef",
description: "My GitHub API key",
};
mockCredentialsService.create.mockResolvedValue(mockCredential);
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
expect(result).toEqual(mockCredential);
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
});
});
describe("findAll", () => {
it("should return paginated credentials", async () => {
const query = { page: 1, limit: 10 };
const paginatedResult = {
data: [mockCredential],
meta: {
total: 1,
page: 1,
limit: 10,
totalPages: 1,
},
};
mockCredentialsService.findAll.mockResolvedValue(paginatedResult);
const result = await controller.findAll(query, mockWorkspaceId, mockUser);
expect(result).toEqual(paginatedResult);
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, query);
});
});
describe("findOne", () => {
it("should return a single credential", async () => {
mockCredentialsService.findOne.mockResolvedValue(mockCredential);
const result = await controller.findOne(mockCredentialId, mockWorkspaceId, mockUser);
expect(result).toEqual(mockCredential);
expect(service.findOne).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId);
});
});
describe("getValue", () => {
it("should return decrypted credential value", async () => {
const valueResponse = { value: "ghp_1234567890abcdef" };
mockCredentialsService.getValue.mockResolvedValue(valueResponse);
const result = await controller.getValue(mockCredentialId, mockWorkspaceId, mockUser);
expect(result).toEqual(valueResponse);
expect(service.getValue).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId);
});
});
describe("update", () => {
it("should update credential metadata", async () => {
const updateDto = { description: "Updated description" };
const updatedCredential = { ...mockCredential, ...updateDto };
mockCredentialsService.update.mockResolvedValue(updatedCredential);
const result = await controller.update(
mockCredentialId,
updateDto,
mockWorkspaceId,
mockUser
);
expect(result).toEqual(updatedCredential);
expect(service.update).toHaveBeenCalledWith(
mockCredentialId,
mockWorkspaceId,
mockUserId,
updateDto
);
});
});
describe("rotate", () => {
it("should rotate credential value", async () => {
const rotateDto = { newValue: "ghp_new_token_12345" };
const rotatedCredential = { ...mockCredential, rotatedAt: new Date() };
mockCredentialsService.rotate.mockResolvedValue(rotatedCredential);
const result = await controller.rotate(
mockCredentialId,
rotateDto,
mockWorkspaceId,
mockUser
);
expect(result).toEqual(rotatedCredential);
expect(service.rotate).toHaveBeenCalledWith(
mockCredentialId,
mockWorkspaceId,
mockUserId,
rotateDto.newValue
);
});
});
describe("remove", () => {
it("should soft-delete a credential", async () => {
mockCredentialsService.remove.mockResolvedValue(undefined);
await controller.remove(mockCredentialId, mockWorkspaceId, mockUser);
expect(service.remove).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId);
});
});
});