Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/api/src/auth/api-key.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
jest.mock('@comp/auth', () => ({
statement: {
organization: ['read', 'update', 'delete'],
member: ['create', 'read', 'update', 'delete'],
invitation: ['create', 'read', 'delete'],
team: ['create', 'read', 'update', 'delete'],
control: ['create', 'read', 'update', 'delete'],
evidence: ['create', 'read', 'update', 'delete'],
policy: ['create', 'read', 'update', 'delete'],
risk: ['create', 'read', 'update', 'delete'],
vendor: ['create', 'read', 'update', 'delete'],
task: ['create', 'read', 'update', 'delete'],
framework: ['create', 'read', 'update', 'delete'],
audit: ['create', 'read', 'update'],
finding: ['create', 'read', 'update', 'delete'],
questionnaire: ['create', 'read', 'update', 'delete'],
integration: ['create', 'read', 'update', 'delete'],
apiKey: ['create', 'read', 'delete'],
app: ['read'],
trust: ['read', 'update'],
pentest: ['create', 'read', 'delete'],
training: ['read', 'update'],
compliance: ['required'],
},
}));

import { ApiKeyService } from './api-key.service';

describe('ApiKeyService', () => {
let service: ApiKeyService;

beforeEach(() => {
service = new ApiKeyService();
});

describe('getAvailableScopes', () => {
let scopes: string[];

beforeEach(() => {
scopes = service.getAvailableScopes();
});

it('should not include any invitation:* scopes', () => {
const matches = scopes.filter((s) => s.startsWith('invitation:'));
expect(matches).toEqual([]);
});

it('should not include any team:* scopes', () => {
const matches = scopes.filter((s) => s.startsWith('team:'));
expect(matches).toEqual([]);
});

it('should not include any compliance:* scopes', () => {
const matches = scopes.filter((s) => s.startsWith('compliance:'));
expect(matches).toEqual([]);
});

it('should include expected public resources', () => {
const expected = [
'risk', 'vendor', 'task', 'control', 'policy',
'evidence', 'framework', 'audit', 'finding',
'questionnaire', 'integration', 'apiKey', 'pentest',
];
for (const resource of expected) {
const matching = scopes.filter((s) => s.startsWith(`${resource}:`));
expect(matching.length).toBeGreaterThan(0);
}
});

it('should return scopes in resource:action format', () => {
for (const scope of scopes) {
expect(scope).toMatch(/^[a-zA-Z]+:[a-zA-Z]+$/);
}
});

it('should not return an empty array', () => {
expect(scopes.length).toBeGreaterThan(0);
});
});
});
14 changes: 14 additions & 0 deletions apps/api/src/auth/api-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,26 @@ export class ApiKeyService {
}
}

/**
* Resources from better-auth that are not used by any API endpoint's @RequirePermission.
* These are handled internally by better-auth for session-based auth only.
*/
private static readonly INTERNAL_ONLY_RESOURCES = [
'invitation',
'team',
'compliance',
];

/**
* Returns all valid `resource:action` scope pairs derived from the permission statement.
* Excludes internal-only resources that no API endpoint uses via @RequirePermission.
*/
getAvailableScopes(): string[] {
const scopes: string[] = [];
for (const [resource, actions] of Object.entries(statement)) {
if (ApiKeyService.INTERNAL_ONLY_RESOURCES.includes(resource)) {
continue;
}
for (const action of actions) {
scopes.push(`${resource}:${action}`);
}
Expand Down
158 changes: 158 additions & 0 deletions apps/api/src/device-agent/device-agent.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
NotFoundException,
InternalServerErrorException,
} from '@nestjs/common';
import { Readable } from 'stream';

const mockSend = jest.fn();

jest.mock('@aws-sdk/client-s3', () => ({
S3Client: jest.fn().mockImplementation(() => ({ send: mockSend })),
GetObjectCommand: jest.fn().mockImplementation((input) => input),
}));

import { DeviceAgentService } from './device-agent.service';

describe('DeviceAgentService', () => {
let service: DeviceAgentService;

beforeAll(() => {
process.env.APP_AWS_BUCKET_NAME = 'test-bucket';
process.env.APP_AWS_REGION = 'us-east-1';
process.env.APP_AWS_ACCESS_KEY_ID = 'test-key';
process.env.APP_AWS_SECRET_ACCESS_KEY = 'test-secret';
});

beforeEach(() => {
jest.clearAllMocks();
service = new DeviceAgentService();
});

describe('downloadMacAgent', () => {
it('should return stream, filename, and contentType on success', async () => {
const mockStream = new Readable({ read() {} });
mockSend.mockResolvedValue({ Body: mockStream });

const result = await service.downloadMacAgent();

expect(result.stream).toBe(mockStream);
expect(result.filename).toBe('Comp AI Agent-1.0.0-arm64.dmg');
expect(result.contentType).toBe('application/x-apple-diskimage');
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
Bucket: 'test-bucket',
Key: 'macos/Comp AI Agent-1.0.0-arm64.dmg',
}),
);
});

it('should throw NotFoundException when S3 returns no body', async () => {
mockSend.mockResolvedValue({ Body: undefined });

await expect(service.downloadMacAgent()).rejects.toThrow(
NotFoundException,
);
await expect(service.downloadMacAgent()).rejects.toThrow(
'macOS agent DMG file not found in S3',
);
});

it('should throw NotFoundException when S3 throws NoSuchKey', async () => {
const error = new Error('Not found');
error.name = 'NoSuchKey';
mockSend.mockRejectedValue(error);

await expect(service.downloadMacAgent()).rejects.toThrow(
NotFoundException,
);
await expect(service.downloadMacAgent()).rejects.toThrow(
'macOS agent file not found',
);
});

it('should throw NotFoundException when S3 throws NotFound', async () => {
const error = new Error('Not found');
error.name = 'NotFound';
mockSend.mockRejectedValue(error);

await expect(service.downloadMacAgent()).rejects.toThrow(
NotFoundException,
);
});

it('should throw InternalServerErrorException on other S3 errors', async () => {
mockSend.mockRejectedValue(new Error('Network failure'));

await expect(service.downloadMacAgent()).rejects.toThrow(
InternalServerErrorException,
);
await expect(service.downloadMacAgent()).rejects.toThrow(
'Failed to download macOS agent',
);
});
});

describe('downloadWindowsAgent', () => {
it('should return stream, filename, and contentType on success', async () => {
const mockStream = new Readable({ read() {} });
mockSend.mockResolvedValue({ Body: mockStream });

const result = await service.downloadWindowsAgent();

expect(result.stream).toBe(mockStream);
expect(result.filename).toBe('Comp AI Agent 1.0.0.exe');
expect(result.contentType).toBe('application/octet-stream');
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
Bucket: 'test-bucket',
Key: 'windows/Comp AI Agent 1.0.0.exe',
}),
);
});

it('should throw NotFoundException when S3 returns no body', async () => {
mockSend.mockResolvedValue({ Body: undefined });

await expect(service.downloadWindowsAgent()).rejects.toThrow(
NotFoundException,
);
await expect(service.downloadWindowsAgent()).rejects.toThrow(
'Windows agent executable file not found in S3',
);
});

it('should throw NotFoundException when S3 throws NoSuchKey', async () => {
const error = new Error('Not found');
error.name = 'NoSuchKey';
mockSend.mockRejectedValue(error);

await expect(service.downloadWindowsAgent()).rejects.toThrow(
NotFoundException,
);
await expect(service.downloadWindowsAgent()).rejects.toThrow(
'Windows agent file not found',
);
});

it('should throw NotFoundException when S3 throws NotFound', async () => {
const error = new Error('Not found');
error.name = 'NotFound';
mockSend.mockRejectedValue(error);

await expect(service.downloadWindowsAgent()).rejects.toThrow(
NotFoundException,
);
});

it('should throw InternalServerErrorException on other S3 errors', async () => {
mockSend.mockRejectedValue(new Error('Network failure'));

await expect(service.downloadWindowsAgent()).rejects.toThrow(
InternalServerErrorException,
);
await expect(service.downloadWindowsAgent()).rejects.toThrow(
'Failed to download Windows agent',
);
});
});
});
23 changes: 20 additions & 3 deletions apps/api/src/device-agent/device-agent.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import {
Injectable,
InternalServerErrorException,
NotFoundException,
Logger,
} from '@nestjs/common';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'stream';

Expand Down Expand Up @@ -62,7 +67,13 @@ export class DeviceAgentService {
throw error;
}
this.logger.error('Failed to download macOS agent from S3:', error);
throw error;
const s3Error = error as { name?: string };
if (s3Error.name === 'NoSuchKey' || s3Error.name === 'NotFound') {
throw new NotFoundException('macOS agent file not found');
}
throw new InternalServerErrorException(
'Failed to download macOS agent. The agent file may not be available in this environment.',
);
}
}

Expand Down Expand Up @@ -107,7 +118,13 @@ export class DeviceAgentService {
throw error;
}
this.logger.error('Failed to download Windows agent from S3:', error);
throw error;
const s3Error = error as { name?: string };
if (s3Error.name === 'NoSuchKey' || s3Error.name === 'NotFound') {
throw new NotFoundException('Windows agent file not found');
}
throw new InternalServerErrorException(
'Failed to download Windows agent. The agent file may not be available in this environment.',
);
}
}
}
7 changes: 4 additions & 3 deletions apps/api/src/frameworks/frameworks.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';
import { RequirePermission } from '../auth/require-permission.decorator';
import { SkipOrgCheck } from '../auth/skip-org-check.decorator';
import { OrganizationId, UserId } from '../auth/auth-context.decorator';
import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
import type { AuthContext as AuthContextType } from '../auth/types';
import { FrameworksService } from './frameworks.service';
import { AddFrameworksDto } from './dto/add-frameworks.dto';

Expand Down Expand Up @@ -54,9 +55,9 @@ export class FrameworksController {
@ApiOperation({ summary: 'Get overview compliance scores' })
async getScores(
@OrganizationId() organizationId: string,
@UserId() userId: string,
@AuthContext() authContext: AuthContextType,
) {
return this.frameworksService.getScores(organizationId, userId);
return this.frameworksService.getScores(organizationId, authContext.userId);
}

@Get(':id')
Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/frameworks/frameworks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ jest.mock('@trycompai/db', () => ({
},
}));

jest.mock('./frameworks-scores.helper', () => ({
getOverviewScores: jest.fn(),
getCurrentMember: jest.fn(),
computeFrameworkComplianceScore: jest.fn(),
}));

import { db } from '@trycompai/db';
import {
getOverviewScores,
getCurrentMember,
} from './frameworks-scores.helper';

const mockDb = db as jest.Mocked<typeof db>;

Expand Down Expand Up @@ -89,4 +99,38 @@ describe('FrameworksService', () => {
expect(mockDb.frameworkInstance.delete).not.toHaveBeenCalled();
});
});

describe('getScores', () => {
it('should call getOverviewScores and getCurrentMember when userId is provided', async () => {
const mockScores = { policies: 10, tasks: 5 };
const mockMember = { id: 'mem_1', userId: 'user_1' };

(getOverviewScores as jest.Mock).mockResolvedValue(mockScores);
(getCurrentMember as jest.Mock).mockResolvedValue(mockMember);

const result = await service.getScores('org_1', 'user_1');

expect(getOverviewScores).toHaveBeenCalledWith('org_1');
expect(getCurrentMember).toHaveBeenCalledWith('org_1', 'user_1');
expect(result).toEqual({
...mockScores,
currentMember: mockMember,
});
});

it('should call getOverviewScores but NOT getCurrentMember when userId is undefined', async () => {
const mockScores = { policies: 10, tasks: 5 };

(getOverviewScores as jest.Mock).mockResolvedValue(mockScores);

const result = await service.getScores('org_1');

expect(getOverviewScores).toHaveBeenCalledWith('org_1');
expect(getCurrentMember).not.toHaveBeenCalled();
expect(result).toEqual({
...mockScores,
currentMember: null,
});
});
});
});
4 changes: 2 additions & 2 deletions apps/api/src/frameworks/frameworks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ export class FrameworksService {
return frameworks;
}

async getScores(organizationId: string, userId: string) {
async getScores(organizationId: string, userId?: string) {
const [scores, currentMember] = await Promise.all([
getOverviewScores(organizationId),
getCurrentMember(organizationId, userId),
userId ? getCurrentMember(organizationId, userId) : Promise.resolve(null),
]);
return { ...scores, currentMember };
}
Expand Down
Loading
Loading