fix(test): Add ENCRYPTION_KEY to bridge.module.spec.ts and fix API lint errors
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
2026-02-07 17:33:32 -06:00
parent b9e1e3756e
commit 4552c2c460
6 changed files with 332 additions and 0 deletions

View File

@@ -61,6 +61,7 @@ describe("BridgeModule", () => {
process.env.DISCORD_BOT_TOKEN = "test-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// Clear ready callbacks
mockReadyCallbacks.length = 0;

View File

@@ -16,6 +16,7 @@ import {
UpdateCredentialDto,
RotateCredentialDto,
QueryCredentialDto,
QueryCredentialAuditDto,
} from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
@@ -57,6 +58,18 @@ export class CredentialsController {
return this.credentialsService.create(workspaceId, user.id, createDto);
}
/**
* GET /api/credentials/audit
* Get credential audit logs with optional filters
* Shows all credential-related activities for the workspace
* Requires: Any workspace member (including GUEST)
*/
@Get("audit")
@RequirePermission(Permission.WORKSPACE_ANY)
async getAuditLog(@Query() query: QueryCredentialAuditDto, @Workspace() workspaceId: string) {
return this.credentialsService.getAuditLog(workspaceId, query);
}
/**
* GET /api/credentials
* Get paginated credentials with optional filters

View File

@@ -24,6 +24,10 @@ describe("CredentialsService", () => {
update: vi.fn(),
delete: vi.fn(),
},
activityLog: {
findMany: vi.fn(),
count: vi.fn(),
},
};
const mockActivityService = {
@@ -479,4 +483,141 @@ describe("CredentialsService", () => {
);
});
});
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);
});
});
});

View File

@@ -9,6 +9,7 @@ import type {
CreateCredentialDto,
UpdateCredentialDto,
QueryCredentialDto,
QueryCredentialAuditDto,
CredentialResponseDto,
PaginatedCredentialsDto,
CredentialValueResponseDto,
@@ -367,6 +368,114 @@ export class CredentialsService {
});
}
/**
* Get credential audit logs with filters and pagination
* Returns all credential-related activities for the workspace
* RLS ensures users only see their own workspace activities
*/
async getAuditLog(
workspaceId: string,
query: QueryCredentialAuditDto
): Promise<{
data: {
id: string;
action: ActivityAction;
entityId: string;
createdAt: Date;
details: Record<string, unknown>;
user: {
id: string;
name: string | null;
email: string;
};
}[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
// Build where clause
const where: Prisma.ActivityLogWhereInput = {
workspaceId,
entityType: EntityType.CREDENTIAL,
};
// Filter by specific credential if provided
if (query.credentialId) {
where.entityId = query.credentialId;
}
// Filter by action if provided
if (query.action) {
where.action = query.action;
}
// Filter by date range if provided
if (query.startDate || query.endDate) {
where.createdAt = {};
if (query.startDate) {
where.createdAt.gte = query.startDate;
}
if (query.endDate) {
where.createdAt.lte = query.endDate;
}
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.activityLog.findMany({
where,
select: {
id: true,
action: true,
entityId: true,
createdAt: true,
details: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.activityLog.count({ where }),
]);
return {
data: data as {
id: string;
action: ActivityAction;
entityId: string;
createdAt: Date;
details: Record<string, unknown>;
user: {
id: string;
name: string | null;
email: string;
};
}[],
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get select fields for credential responses
* NEVER includes encryptedValue

View File

@@ -2,4 +2,5 @@ export * from "./create-credential.dto";
export * from "./update-credential.dto";
export * from "./rotate-credential.dto";
export * from "./query-credential.dto";
export * from "./query-credential-audit.dto";
export * from "./credential-response.dto";

View File

@@ -0,0 +1,67 @@
import { ActivityAction } from "@prisma/client";
import { IsOptional, IsEnum, IsInt, Min, Max, IsDateString, IsUUID } from "class-validator";
import { Type } from "class-transformer";
/**
* DTO for querying credential audit logs with filters and pagination
* All fields are optional - omitted filters are not applied
*/
export class QueryCredentialAuditDto {
/**
* Filter by specific credential ID
* Optional - if omitted, returns audit logs for all credentials in workspace
*/
@IsOptional()
@IsUUID("4", { message: "credentialId must be a valid UUID" })
credentialId?: string;
/**
* Filter by activity action type
* Optional - supported actions:
* - CREDENTIAL_CREATED
* - CREDENTIAL_ACCESSED
* - CREDENTIAL_ROTATED
* - CREDENTIAL_REVOKED
* - UPDATED (for metadata changes)
*/
@IsOptional()
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
action?: ActivityAction;
/**
* Filter by start date (inclusive)
* Optional ISO 8601 date string
*/
@IsOptional()
@IsDateString({}, { message: "startDate must be a valid ISO 8601 date string" })
startDate?: Date;
/**
* Filter by end date (inclusive)
* Optional ISO 8601 date string
*/
@IsOptional()
@IsDateString({}, { message: "endDate must be a valid ISO 8601 date string" })
endDate?: Date;
/**
* Page number (1-indexed)
* Optional - defaults to 1
*/
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number = 1;
/**
* Results per page
* Optional - defaults to 20, max 100
*/
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number = 20;
}