Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
119 changes: 76 additions & 43 deletions apps/api/src/knowledge-base/knowledge-base.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ jest.mock('../auth/auth.server', () => ({
auth: { api: { getSession: jest.fn() } },
}));

// The controller pulls in the auth guard (and therefore the Prisma client and
// the @trycompai/auth permission definitions) at import time. The service and
// guards are fully mocked/overridden below, so none of this is exercised — stub
// them out to keep this a hermetic unit test (matches the convention used by
// the other controller specs in this app).
jest.mock('@db', () => ({ db: {} }));
jest.mock('@trycompai/auth', () => ({
statement: {},
BUILT_IN_ROLE_PERMISSIONS: {},
}));

describe('KnowledgeBaseController', () => {
let controller: KnowledgeBaseController;
let service: jest.Mocked<KnowledgeBaseService>;
Expand Down Expand Up @@ -89,10 +100,16 @@ describe('KnowledgeBaseController', () => {
});
});

// These handlers scope to the caller's active organization from the auth
// context. Each test passes one org in the body and a different authenticated
// org, and asserts the authenticated org is what reaches the service.
const AUTH_ORG = 'org_authenticated';
const OTHER_ORG = 'org_supplied_in_body';

describe('uploadDocument', () => {
it('should delegate to service', async () => {
it('uses the authenticated organization, not the body organizationId', async () => {
const dto = {
organizationId: 'org_1',
organizationId: OTHER_ORG,
fileName: 'doc.pdf',
fileType: 'application/pdf',
fileData: 'base64',
Expand All @@ -103,50 +120,73 @@ describe('KnowledgeBaseController', () => {
s3Key: 'key',
});

const result = await controller.uploadDocument(dto as any);
const result = await controller.uploadDocument(AUTH_ORG, dto as any);

expect(result.id).toBe('d1');
expect(service.uploadDocument).toHaveBeenCalledWith(dto);
expect(service.uploadDocument).toHaveBeenCalledWith({
...dto,
organizationId: AUTH_ORG,
});
});
});

describe('getDownloadUrl', () => {
it('should merge documentId param with dto', async () => {
const dto = { organizationId: 'org_1' };
it('scopes to the authenticated organization and merges documentId', async () => {
const dto = { organizationId: OTHER_ORG };
mockService.getDownloadUrl.mockResolvedValue({
signedUrl: 'https://example.com/signed',
fileName: 'doc.pdf',
});

const result = await controller.getDownloadUrl('d1', dto as any);
const result = await controller.getDownloadUrl('d1', AUTH_ORG, dto as any);

expect(result.signedUrl).toBe('https://example.com/signed');
expect(service.getDownloadUrl).toHaveBeenCalledWith({
...dto,
documentId: 'd1',
organizationId: AUTH_ORG,
});
});
});

describe('getViewUrl', () => {
it('scopes to the authenticated organization and merges documentId', async () => {
const dto = { organizationId: OTHER_ORG };
mockService.getViewUrl.mockResolvedValue({
signedUrl: 'https://example.com/view',
fileName: 'doc.pdf',
fileType: 'application/pdf',
viewableInBrowser: true,
});

const result = await controller.getViewUrl('d1', AUTH_ORG, dto as any);

expect(result.signedUrl).toBe('https://example.com/view');
expect(service.getViewUrl).toHaveBeenCalledWith({
documentId: 'd1',
organizationId: AUTH_ORG,
});
});
});

describe('deleteDocument', () => {
it('should merge documentId param with dto', async () => {
const dto = { organizationId: 'org_1' };
it('scopes to the authenticated organization and merges documentId', async () => {
const dto = { organizationId: OTHER_ORG };
mockService.deleteDocument.mockResolvedValue({ success: true });

const result = await controller.deleteDocument('d1', dto as any);
const result = await controller.deleteDocument('d1', AUTH_ORG, dto as any);

expect(result).toEqual({ success: true });
expect(service.deleteDocument).toHaveBeenCalledWith({
...dto,
documentId: 'd1',
organizationId: AUTH_ORG,
});
});
});

describe('processDocuments', () => {
it('should delegate to service', async () => {
it('uses the authenticated organization, not the body organizationId', async () => {
const dto = {
organizationId: 'org_1',
organizationId: OTHER_ORG,
documentIds: ['d1', 'd2'],
};
mockService.processDocuments.mockResolvedValue({
Expand All @@ -155,56 +195,49 @@ describe('KnowledgeBaseController', () => {
message: 'Processing 2 documents in parallel...',
});

const result = await controller.processDocuments(dto as any);
const result = await controller.processDocuments(AUTH_ORG, dto as any);

expect(result.success).toBe(true);
expect(service.processDocuments).toHaveBeenCalledWith(dto);
});
});

describe('createRunToken', () => {
it('should return token when created', async () => {
mockService.createRunReadToken.mockResolvedValue('token_123');

const result = await controller.createRunToken('run_1');

expect(result).toEqual({ success: true, token: 'token_123' });
expect(service.createRunReadToken).toHaveBeenCalledWith('run_1');
});

it('should return success false when token creation fails', async () => {
mockService.createRunReadToken.mockResolvedValue(undefined);

const result = await controller.createRunToken('run_1');

expect(result).toEqual({ success: false, token: undefined });
expect(service.processDocuments).toHaveBeenCalledWith({
...dto,
organizationId: AUTH_ORG,
});
});
});

describe('deleteManualAnswer', () => {
it('should merge manualAnswerId param with dto', async () => {
const dto = { organizationId: 'org_1' };
it('scopes to the authenticated organization and merges manualAnswerId', async () => {
const dto = { organizationId: OTHER_ORG };
mockService.deleteManualAnswer.mockResolvedValue({ success: true });

const result = await controller.deleteManualAnswer('ma1', dto as any);
const result = await controller.deleteManualAnswer(
'ma1',
AUTH_ORG,
dto as any,
);

expect(result).toEqual({ success: true });
expect(service.deleteManualAnswer).toHaveBeenCalledWith({
...dto,
manualAnswerId: 'ma1',
organizationId: AUTH_ORG,
});
});
});

describe('deleteAllManualAnswers', () => {
it('should delegate to service', async () => {
const dto = { organizationId: 'org_1' };
it('uses the authenticated organization, not the body organizationId', async () => {
const dto = { organizationId: OTHER_ORG };
mockService.deleteAllManualAnswers.mockResolvedValue({ success: true });

const result = await controller.deleteAllManualAnswers(dto as any);
const result = await controller.deleteAllManualAnswers(
AUTH_ORG,
dto as any,
);

expect(result).toEqual({ success: true });
expect(service.deleteAllManualAnswers).toHaveBeenCalledWith(dto);
expect(service.deleteAllManualAnswers).toHaveBeenCalledWith({
organizationId: AUTH_ORG,
});
});
});
});
47 changes: 32 additions & 15 deletions apps/api/src/knowledge-base/knowledge-base.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,19 @@ export class KnowledgeBaseController {
});
}

// All handlers scope operations to the caller's active organization, resolved
// from the authenticated request context via @OrganizationId.

@Post('documents/upload')
@RequirePermission('questionnaire', 'create')
@ApiOperation({ summary: 'Upload a knowledge base document' })
@ApiConsumes('application/json')
@ApiOkResponse({ description: 'Document uploaded successfully' })
async uploadDocument(@Body() dto: UploadDocumentDto) {
return this.knowledgeBaseService.uploadDocument(dto);
async uploadDocument(
@OrganizationId() organizationId: string,
@Body() dto: UploadDocumentDto,
) {
return this.knowledgeBaseService.uploadDocument({ ...dto, organizationId });
}

@Post('documents/:documentId/download')
Expand All @@ -87,11 +93,13 @@ export class KnowledgeBaseController {
@ApiOkResponse({ description: 'Signed download URL generated' })
async getDownloadUrl(
@Param('documentId') documentId: string,
@OrganizationId() organizationId: string,
@Body() dto: Omit<GetDocumentUrlDto, 'documentId'>,
) {
return this.knowledgeBaseService.getDownloadUrl({
...dto,
documentId,
organizationId,
});
}

Expand All @@ -103,11 +111,13 @@ export class KnowledgeBaseController {
@ApiOkResponse({ description: 'Signed view URL generated' })
async getViewUrl(
@Param('documentId') documentId: string,
@OrganizationId() organizationId: string,
@Body() dto: Omit<GetDocumentUrlDto, 'documentId'>,
) {
return this.knowledgeBaseService.getViewUrl({
...dto,
documentId,
organizationId,
});
}

Expand All @@ -119,11 +129,13 @@ export class KnowledgeBaseController {
@ApiOkResponse({ description: 'Document deleted successfully' })
async deleteDocument(
@Param('documentId') documentId: string,
@OrganizationId() organizationId: string,
@Body() dto: Omit<DeleteDocumentDto, 'documentId'>,
) {
return this.knowledgeBaseService.deleteDocument({
...dto,
documentId,
organizationId,
});
}

Expand All @@ -132,17 +144,14 @@ export class KnowledgeBaseController {
@ApiOperation({ summary: 'Trigger processing of knowledge base documents' })
@ApiConsumes('application/json')
@ApiOkResponse({ description: 'Document processing triggered' })
async processDocuments(@Body() dto: ProcessDocumentsDto) {
return this.knowledgeBaseService.processDocuments(dto);
}

@Post('runs/:runId/token')
@RequirePermission('questionnaire', 'read')
@ApiOperation({ summary: 'Create a public access token for a run' })
@ApiOkResponse({ description: 'Public access token created' })
async createRunToken(@Param('runId') runId: string) {
const token = await this.knowledgeBaseService.createRunReadToken(runId);
return { success: !!token, token };
async processDocuments(
@OrganizationId() organizationId: string,
@Body() dto: ProcessDocumentsDto,
) {
return this.knowledgeBaseService.processDocuments({
...dto,
organizationId,
});
}

@Post('manual-answers/:manualAnswerId/delete')
Expand All @@ -153,11 +162,13 @@ export class KnowledgeBaseController {
@ApiOkResponse({ description: 'Manual answer deleted' })
async deleteManualAnswer(
@Param('manualAnswerId') manualAnswerId: string,
@OrganizationId() organizationId: string,
@Body() dto: DeleteManualAnswerDto,
) {
return this.knowledgeBaseService.deleteManualAnswer({
...dto,
manualAnswerId,
organizationId,
});
}

Expand All @@ -167,7 +178,13 @@ export class KnowledgeBaseController {
@ApiOperation({ summary: 'Delete all manual answers for an organization' })
@ApiConsumes('application/json')
@ApiOkResponse({ description: 'All manual answers deleted' })
async deleteAllManualAnswers(@Body() dto: DeleteAllManualAnswersDto) {
return this.knowledgeBaseService.deleteAllManualAnswers(dto);
async deleteAllManualAnswers(
@OrganizationId() organizationId: string,
@Body() dto: DeleteAllManualAnswersDto,
) {
return this.knowledgeBaseService.deleteAllManualAnswers({
...dto,
organizationId,
});
}
}
15 changes: 15 additions & 0 deletions apps/api/src/policies/dto/create-policy.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ValidationPipe } from '@nestjs/common';
import { CreatePolicyDto } from './create-policy.dto';
import { UpdatePolicyDto } from './update-policy.dto';
import { UpdateVersionContentDto } from './version.dto';

/**
* Regression test for the MCP/public-API policy content serialization bug.
Expand Down Expand Up @@ -62,6 +63,20 @@ describe('Policy DTO content serialization (ValidationPipe)', () => {
expect(result.content).not.toEqual([[], []]);
});

// PATCH /v1/policies/:id/versions/:versionId (MCP: update-policy-version-content).
// The controller reads req.body today, but this DTO is the published body
// schema — if it is ever wired to @Body() its transform must survive the pipe.
it('preserves structured TipTap content on UpdateVersionContentDto', async () => {
const result = await pipe.transform(
{ content: TIPTAP_NODES },
{ type: 'body', metatype: UpdateVersionContentDto },
);

expect(result.content).toEqual(TIPTAP_NODES);
expect(result.content[0]).toMatchObject({ type: 'heading' });
expect(result.content).not.toEqual([[], []]);
});

it('leaves content untouched when omitted on update', async () => {
const result = await pipe.transform(
{ name: 'Renamed only' },
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/policies/dto/version.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,13 @@ export class UpdateVersionContentDto {
type: 'array',
items: { type: 'object', additionalProperties: true },
})
@Transform(({ value }) => value) // Preserve raw JSON, don't let class-transformer mangle it
@IsArray()
@Transform(({ value }) => value)
// Return the raw source value. Under the global ValidationPipe's implicit
// conversion, class-transformer coerces each TipTap node toward the reflected
// Array design-type of `content`, mangling `[{...}, {...}]` into `[[], []]`.
// The transform runs after that coercion, so `value` is already mangled —
// `obj.content` is the untouched original. Do not revert this to `value`.
@Transform(({ obj }) => obj.content)
content: unknown[];
}

Expand Down
Loading
Loading