Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<img src="https://assets.trycomp.ai/logo.png" alt="Logo" width="10%">
</a>

<h3 align="center">Comp AI</h3>
<h3 align="center">Comp AI </h3>

<p align="center">
The open-source compliance platform.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ describe('AdminOrganizationsController', () => {

describe('getAuditLogs', () => {
it('should call service with org id and query params', async () => {
const mockResult = { data: [{ id: 'aud_1' }] };
const mockResult = { data: [{ id: 'aud_1' }], total: 1 };
mockService.getAuditLogs.mockResolvedValue(mockResult);

const result = await controller.getAuditLogs('org_1', 'policy', '50');
Expand All @@ -266,19 +266,34 @@ describe('AdminOrganizationsController', () => {
orgId: 'org_1',
entityType: 'policy',
take: '50',
offset: undefined,
});
expect(result).toEqual(mockResult);
});

it('should forward the offset query param', async () => {
mockService.getAuditLogs.mockResolvedValue({ data: [], total: 0 });

await controller.getAuditLogs('org_1', 'policy', '100', '200');

expect(mockService.getAuditLogs).toHaveBeenCalledWith({
orgId: 'org_1',
entityType: 'policy',
take: '100',
offset: '200',
});
});

it('should pass undefined for optional params', async () => {
mockService.getAuditLogs.mockResolvedValue({ data: [] });
mockService.getAuditLogs.mockResolvedValue({ data: [], total: 0 });

await controller.getAuditLogs('org_1');

expect(mockService.getAuditLogs).toHaveBeenCalledWith({
orgId: 'org_1',
entityType: undefined,
take: undefined,
offset: undefined,
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,20 @@ export class AdminOrganizationsController {
@ApiQuery({
name: 'take',
required: false,
description: 'Number of logs to return (max 100, default 100)',
description: 'Number of logs to return (max 200, default 100)',
})
@ApiQuery({
name: 'offset',
required: false,
description: 'Number of logs to skip (default 0)',
})
async getAuditLogs(
@Param('id') id: string,
@Query('entityType') entityType?: string,
@Query('take') take?: string,
@Query('offset') offset?: string,
) {
return this.service.getAuditLogs({ orgId: id, entityType, take });
return this.service.getAuditLogs({ orgId: id, entityType, take, offset });
}

@Get(':id/invitations')
Expand Down
151 changes: 151 additions & 0 deletions apps/api/src/admin-organizations/admin-organizations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ jest.mock('@db', () => ({
update: jest.fn(),
updateMany: jest.fn(),
},
auditLog: {
findMany: jest.fn(),
count: jest.fn(),
},
},
AuditLogEntityType: {
organization: 'organization',
framework: 'framework',
requirement: 'requirement',
control: 'control',
policy: 'policy',
task: 'task',
people: 'people',
risk: 'risk',
vendor: 'vendor',
tests: 'tests',
integration: 'integration',
trust: 'trust',
finding: 'finding',
pentest: 'pentest',
},
}));

Expand Down Expand Up @@ -441,4 +461,135 @@ describe('AdminOrganizationsService', () => {
).rejects.toThrow(NotFoundException);
});
});

describe('getAuditLogs', () => {
const mockLogs = [{ id: 'aud_1' }, { id: 'aud_2' }];

beforeEach(() => {
(mockDb.auditLog.findMany as jest.Mock).mockResolvedValue(mockLogs);
(mockDb.auditLog.count as jest.Mock).mockResolvedValue(42);
});

it('passes skip/take to findMany and returns { data, total }', async () => {
const result = await service.getAuditLogs({
orgId: 'org_1',
take: '25',
offset: '50',
});

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { organizationId: 'org_1' },
take: 25,
skip: 50,
orderBy: [{ timestamp: 'desc' }, { id: 'desc' }],
}),
);
expect(mockDb.auditLog.count).toHaveBeenCalledWith({
where: { organizationId: 'org_1' },
});
expect(result).toEqual({ data: mockLogs, total: 42 });
});

it('uses the SAME where for count and findMany', async () => {
await service.getAuditLogs({ orgId: 'org_1', entityType: 'policy' });

const findManyWhere = (mockDb.auditLog.findMany as jest.Mock).mock
.calls[0][0].where;
const countWhere = (mockDb.auditLog.count as jest.Mock).mock.calls[0][0]
.where;

expect(findManyWhere).toEqual(countWhere);
});

it('defaults take to 100 and offset to 0', async () => {
await service.getAuditLogs({ orgId: 'org_1' });

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 100, skip: 0 }),
);
});

it('clamps take to a max of 200', async () => {
await service.getAuditLogs({ orgId: 'org_1', take: '1000' });

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 200 }),
);
});

it('clamps take to a min of 1', async () => {
await service.getAuditLogs({ orgId: 'org_1', take: '0' });

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 100 }),
);
});

it('clamps a negative offset to 0', async () => {
await service.getAuditLogs({ orgId: 'org_1', offset: '-10' });

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 0 }),
);
});

it('clamps a NaN offset to 0', async () => {
await service.getAuditLogs({ orgId: 'org_1', offset: 'abc' });

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 0 }),
);
});

it('clamps an oversized offset to the maximum', async () => {
await service.getAuditLogs({ orgId: 'org_1', offset: '999999999' });

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 100_000 }),
);
});

it('caps the reported total at the reachable maximum', async () => {
(mockDb.auditLog.count as jest.Mock).mockResolvedValue(500_000);

const result = await service.getAuditLogs({ orgId: 'org_1' });

// Beyond the offset cap the window is unreachable, so total must not
// exceed it — otherwise the client pager loops load-more forever.
expect(result.total).toBe(100_000);
});

it('builds a single-value entityType filter', async () => {
await service.getAuditLogs({ orgId: 'org_1', entityType: 'policy' });

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { organizationId: 'org_1', entityType: 'policy' },
}),
);
});

it('builds an { in: [...] } filter for multiple entityTypes', async () => {
await service.getAuditLogs({
orgId: 'org_1',
entityType: 'policy, task',
});

expect(mockDb.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
organizationId: 'org_1',
entityType: { in: ['policy', 'task'] },
},
}),
);
});

it('throws BadRequestException for an invalid entityType', async () => {
await expect(
service.getAuditLogs({ orgId: 'org_1', entityType: 'not_a_type' }),
).rejects.toThrow(BadRequestException);
});
});
});
52 changes: 33 additions & 19 deletions apps/api/src/admin-organizations/admin-organizations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AuditLogEntityType, db } from '@db';
import { triggerEmail } from '../email/trigger-email';
import { InviteEmail } from '../email/templates/invite-member';
import { UpdateAdminOrganizationDto } from './dto/update-admin-organization.dto';
import { MAX_AUDIT_LOG_OFFSET } from '../audit/audit-log.pagination';

@Injectable()
export class AdminOrganizationsService {
Expand Down Expand Up @@ -423,8 +424,9 @@ export class AdminOrganizationsService {
orgId: string;
entityType?: string;
take?: string;
offset?: string;
}) {
const { orgId, entityType, take } = options;
const { orgId, entityType, take, offset } = options;

const where: Record<string, unknown> = { organizationId: orgId };

Expand All @@ -444,28 +446,40 @@ export class AdminOrganizationsService {
}

const parsedTake = take
? Math.min(100, Math.max(1, parseInt(take, 10) || 100))
? Math.min(200, Math.max(1, parseInt(take, 10) || 100))
: 100;
const parsedOffset = offset
? Math.min(MAX_AUDIT_LOG_OFFSET, Math.max(0, parseInt(offset, 10) || 0))
: 0;

const logs = await db.auditLog.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
const [logs, rawTotal] = await Promise.all([
db.auditLog.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
},
member: true,
organization: true,
},
member: true,
organization: true,
},
orderBy: { timestamp: 'desc' },
take: parsedTake,
});
orderBy: [{ timestamp: 'desc' }, { id: 'desc' }],
take: parsedTake,
skip: parsedOffset,
}),
db.auditLog.count({ where }),
]);

// Report only the reachable total (bounded by the offset cap) so the client
// pager stops at the accessible boundary instead of looping load-more over
// pages past MAX_AUDIT_LOG_OFFSET that can never be filled.
const total = Math.min(rawTotal, MAX_AUDIT_LOG_OFFSET);

return { data: logs };
return { data: logs, total };
}
}
Loading
Loading