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_BOT_TOKEN = "test-token";
|
||||||
process.env.DISCORD_GUILD_ID = "test-guild-id";
|
process.env.DISCORD_GUILD_ID = "test-guild-id";
|
||||||
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
|
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
|
||||||
|
process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
// Clear ready callbacks
|
// Clear ready callbacks
|
||||||
mockReadyCallbacks.length = 0;
|
mockReadyCallbacks.length = 0;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
UpdateCredentialDto,
|
UpdateCredentialDto,
|
||||||
RotateCredentialDto,
|
RotateCredentialDto,
|
||||||
QueryCredentialDto,
|
QueryCredentialDto,
|
||||||
|
QueryCredentialAuditDto,
|
||||||
} from "./dto";
|
} from "./dto";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
@@ -57,6 +58,18 @@ export class CredentialsController {
|
|||||||
return this.credentialsService.create(workspaceId, user.id, createDto);
|
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 /api/credentials
|
||||||
* Get paginated credentials with optional filters
|
* Get paginated credentials with optional filters
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ describe("CredentialsService", () => {
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
},
|
},
|
||||||
|
activityLog: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockActivityService = {
|
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,
|
CreateCredentialDto,
|
||||||
UpdateCredentialDto,
|
UpdateCredentialDto,
|
||||||
QueryCredentialDto,
|
QueryCredentialDto,
|
||||||
|
QueryCredentialAuditDto,
|
||||||
CredentialResponseDto,
|
CredentialResponseDto,
|
||||||
PaginatedCredentialsDto,
|
PaginatedCredentialsDto,
|
||||||
CredentialValueResponseDto,
|
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
|
* Get select fields for credential responses
|
||||||
* NEVER includes encryptedValue
|
* NEVER includes encryptedValue
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from "./create-credential.dto";
|
|||||||
export * from "./update-credential.dto";
|
export * from "./update-credential.dto";
|
||||||
export * from "./rotate-credential.dto";
|
export * from "./rotate-credential.dto";
|
||||||
export * from "./query-credential.dto";
|
export * from "./query-credential.dto";
|
||||||
|
export * from "./query-credential-audit.dto";
|
||||||
export * from "./credential-response.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