fix(test): Add ENCRYPTION_KEY to bridge.module.spec.ts and fix API lint errors
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
67
apps/api/src/credentials/dto/query-credential-audit.dto.ts
Normal file
67
apps/api/src/credentials/dto/query-credential-audit.dto.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user