Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f0f6aeb
chore: merge release v3.94.2 back to main [skip ci]
github-actions[bot] Jul 1, 2026
4e3f9ac
feat(frameworks): allow editing custom framework name and description
tofikwest Jul 1, 2026
8db8ac5
fix(frameworks): reject whitespace-only custom framework name
tofikwest Jul 1, 2026
070a964
fix(tasks): bound sanitized inputs check to prevent timeout on manual…
tofikwest Jul 1, 2026
bec645c
Merge pull request #3319 from trycompai/tofik/cs-edit-custom-framewor…
tofikwest Jul 1, 2026
99f4eca
Merge branch 'main' into tofik/cs-689-bug-run-on-sanitized-inputs
tofikwest Jul 1, 2026
e5f847b
fix(frameworks): reject null fields and empty payload on custom frame…
tofikwest Jul 1, 2026
7eca843
Merge branch 'main' into tofik/cs-edit-custom-framework-validation
tofikwest Jul 1, 2026
e134859
Merge pull request #3321 from trycompai/tofik/cs-689-bug-run-on-sanit…
tofikwest Jul 1, 2026
26bafcb
Merge branch 'main' into tofik/cs-edit-custom-framework-validation
tofikwest Jul 1, 2026
8148a9a
Merge pull request #3323 from trycompai/tofik/cs-edit-custom-framewor…
tofikwest Jul 1, 2026
6050902
fix(integrations): use catalog-defined field names for fivetran basic…
tofikwest Jul 1, 2026
dcf3dc1
fix(frameworks): trim custom framework name/description in update DTO
tofikwest Jul 1, 2026
811e035
Merge pull request #3325 from trycompai/tofik/cs-custom-framework-dto…
tofikwest Jul 1, 2026
21259bc
Merge branch 'main' into tofik/cs-690-fivetran-integration-required-c…
tofikwest Jul 1, 2026
640a58d
Merge pull request #3324 from trycompai/tofik/cs-690-fivetran-integra…
tofikwest Jul 1, 2026
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
67 changes: 67 additions & 0 deletions apps/api/src/frameworks/dto/update-custom-framework.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { plainToInstance } from 'class-transformer';
import { validate, type ValidationError } from 'class-validator';
import { UpdateCustomFrameworkDto } from './update-custom-framework.dto';

function propsWithErrors(errors: ValidationError[]): string[] {
return errors.map((e) => e.property);
}

async function validatePayload(payload: Record<string, unknown>) {
const dto = plainToInstance(UpdateCustomFrameworkDto, payload);
return validate(dto, { whitelist: true, forbidNonWhitelisted: true });
}

function transform(payload: Record<string, unknown>) {
return plainToInstance(UpdateCustomFrameworkDto, payload);
}

describe('UpdateCustomFrameworkDto', () => {
it('accepts a name-only payload', async () => {
expect(await validatePayload({ name: 'Internal Controls' })).toHaveLength(0);
});

it('accepts a description-only payload', async () => {
expect(await validatePayload({ description: 'Covers X' })).toHaveLength(0);
});

it('accepts an empty payload (field-level; empty PATCH is rejected in the service)', async () => {
expect(await validatePayload({})).toHaveLength(0);
});

it('rejects an explicit null name', async () => {
const errors = await validatePayload({ name: null });
expect(propsWithErrors(errors)).toContain('name');
});

it('rejects an explicit null description', async () => {
const errors = await validatePayload({ description: null });
expect(propsWithErrors(errors)).toContain('description');
});

it('rejects a non-string name', async () => {
const errors = await validatePayload({ name: 42 });
expect(propsWithErrors(errors)).toContain('name');
});

it('rejects an empty-string name (MinLength)', async () => {
const errors = await validatePayload({ name: '' });
expect(propsWithErrors(errors)).toContain('name');
});

it('rejects a whitespace-only name (trimmed to empty)', async () => {
const errors = await validatePayload({ name: ' ' });
expect(propsWithErrors(errors)).toContain('name');
});

it('trims surrounding whitespace from a valid name', async () => {
const payload = { name: ' Internal Controls ' };
expect(await validatePayload(payload)).toHaveLength(0);
expect(transform(payload).name).toBe('Internal Controls');
});

it('trims the description', async () => {
expect(transform({ description: ' Covers X ' }).description).toBe(
'Covers X',
);
});
});
30 changes: 30 additions & 0 deletions apps/api/src/frameworks/dto/update-custom-framework.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';

// Trim strings so a whitespace-only value collapses to '' and is rejected by
// MinLength. Guard on typeof so null/non-strings pass through unchanged and are
// still rejected by @IsString (using value?.trim() would turn null into
// undefined, which ValidateIf would then treat as an omitted field).
const trimIfString = ({ value }: { value: unknown }) =>

@cubic-dev-ai cubic-dev-ai Bot Jul 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Numeric or boolean JSON values for name/description can be accepted as strings at runtime because the global ValidationPipe enables class-transformer's implicit conversion before @IsString() runs. That makes requests like { "name": 42 } update the framework name to "42" even though this DTO and its tests intend to reject non-strings; using the original plain-object value in the transform (or otherwise disabling implicit conversion for these fields) would keep the validation strict.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/frameworks/dto/update-custom-framework.dto.ts, line 9:

<comment>Numeric or boolean JSON values for `name`/`description` can be accepted as strings at runtime because the global `ValidationPipe` enables class-transformer's implicit conversion before `@IsString()` runs. That makes requests like `{ "name": 42 }` update the framework name to `"42"` even though this DTO and its tests intend to reject non-strings; using the original plain-object value in the transform (or otherwise disabling implicit conversion for these fields) would keep the validation strict.</comment>

<file context>
@@ -0,0 +1,30 @@
+// MinLength. Guard on typeof so null/non-strings pass through unchanged and are
+// still rejected by @IsString (using value?.trim() would turn null into
+// undefined, which ValidateIf would then treat as an omitted field).
+const trimIfString = ({ value }: { value: unknown }) =>
+  typeof value === 'string' ? value.trim() : value;
+
</file context>
Fix with cubic

typeof value === 'string' ? value.trim() : value;

export class UpdateCustomFrameworkDto {
// ValidateIf (rather than @IsOptional) only skips validation when the field is
// omitted (undefined). An explicit `null` still runs @IsString and is rejected
// with a 400, instead of slipping through to a non-null DB column.
@ApiPropertyOptional({ description: 'Framework name', example: 'Internal Controls' })
@ValidateIf((_, value) => value !== undefined)
@Transform(trimIfString)
@IsString()
@MinLength(1)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
@MaxLength(120)
name?: string;

@ApiPropertyOptional({ description: 'Framework description' })
@ValidateIf((_, value) => value !== undefined)
@Transform(trimIfString)
@IsString()
@MaxLength(2000)
description?: string;
}
14 changes: 14 additions & 0 deletions apps/api/src/frameworks/frameworks.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('FrameworksController', () => {
const mockService = {
findAll: jest.fn(),
findAvailable: jest.fn(),
updateCustom: jest.fn(),
delete: jest.fn(),
getUpdateStatus: jest.fn(),
getUpdatePreview: jest.fn(),
Expand Down Expand Up @@ -158,6 +159,19 @@ describe('FrameworksController', () => {
});
});

describe('updateCustom', () => {
it('should delegate to service and return the updated framework', async () => {
const updated = { id: 'cfrm_A', name: 'CSC/CPRT' };
mockService.updateCustom.mockResolvedValue(updated);

const dto = { name: 'CSC/CPRT', description: 'Renamed' };
const result = await controller.updateCustom('org_1', 'fi1', dto);

expect(result).toEqual(updated);
expect(service.updateCustom).toHaveBeenCalledWith('fi1', 'org_1', dto);
});
});

describe('getUpdateStatus', () => {
it('should return update status with { data }', async () => {
const mockStatus = {
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/frameworks/frameworks.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
Expand All @@ -28,6 +30,7 @@ import type { AuthContext as AuthContextType } from '../auth/types';
import { FrameworksService } from './frameworks.service';
import { AddFrameworksDto } from './dto/add-frameworks.dto';
import { CreateCustomFrameworkDto } from './dto/create-custom-framework.dto';
import { UpdateCustomFrameworkDto } from './dto/update-custom-framework.dto';
import { CreateCustomRequirementDto } from './dto/create-custom-requirement.dto';
import { LinkRequirementsDto } from './dto/link-requirements.dto';
import { LinkControlsDto } from './dto/link-controls.dto';
Expand Down Expand Up @@ -144,6 +147,22 @@ export class FrameworksController {
return this.frameworksService.createCustom(organizationId, dto);
}

@Patch(':id/custom')
@RequirePermission('framework', 'update')
@ApiOperation({
summary: 'Update a custom framework',
description:
"Update the name and/or description of an organization's custom framework. Only custom frameworks are editable; platform frameworks return 400.",
})
@ApiBody({ type: UpdateCustomFrameworkDto })
async updateCustom(
@OrganizationId() organizationId: string,
@Param('id') id: string,
@Body() dto: UpdateCustomFrameworkDto,
) {
return this.frameworksService.updateCustom(id, organizationId, dto);
}

@Post(':id/requirements')
@RequirePermission('framework', 'update')
@ApiOperation({ summary: 'Add a custom requirement to a framework instance' })
Expand Down
76 changes: 75 additions & 1 deletion apps/api/src/frameworks/frameworks.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { FrameworksService } from './frameworks.service';
import { TimelinesService } from '../timelines/timelines.service';

Expand Down Expand Up @@ -35,6 +35,7 @@ jest.mock('@db', () => ({
},
customFramework: {
findMany: jest.fn(),
update: jest.fn(),
},
},
// The frameworks-timeline helper imports FindingType (a Prisma enum) at module
Expand Down Expand Up @@ -135,6 +136,79 @@ describe('FrameworksService', () => {
});
});

describe('updateCustom', () => {
it('should update the custom framework name and description', async () => {
(mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({
customFrameworkId: 'cfrm_A',
});
const updated = {
id: 'cfrm_A',
name: 'CSC/CPRT',
description: 'Renamed',
};
(mockDb.customFramework.update as jest.Mock).mockResolvedValue(updated);

const result = await service.updateCustom('fi1', 'org_1', {
name: 'CSC/CPRT',
description: 'Renamed',
});

expect(result).toEqual(updated);
expect(mockDb.frameworkInstance.findUnique).toHaveBeenCalledWith({
where: { id: 'fi1', organizationId: 'org_1' },
select: { customFrameworkId: true },
});
expect(mockDb.customFramework.update).toHaveBeenCalledWith({
where: { id: 'cfrm_A' },
data: { name: 'CSC/CPRT', description: 'Renamed' },
});
});

it('should only update the fields that are provided', async () => {
(mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({
customFrameworkId: 'cfrm_A',
});
(mockDb.customFramework.update as jest.Mock).mockResolvedValue({});

await service.updateCustom('fi1', 'org_1', { name: 'Just the name' });

expect(mockDb.customFramework.update).toHaveBeenCalledWith({
where: { id: 'cfrm_A' },
data: { name: 'Just the name' },
});
});

it('should throw BadRequestException when no fields are provided', async () => {
await expect(
service.updateCustom('fi1', 'org_1', {}),
).rejects.toThrow(BadRequestException);
expect(mockDb.frameworkInstance.findUnique).not.toHaveBeenCalled();
expect(mockDb.customFramework.update).not.toHaveBeenCalled();
});

it('should throw NotFoundException when instance not found', async () => {
(mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue(
null,
);

await expect(
service.updateCustom('missing', 'org_1', { name: 'x' }),
).rejects.toThrow(NotFoundException);
expect(mockDb.customFramework.update).not.toHaveBeenCalled();
});

it('should throw BadRequestException for a platform framework', async () => {
(mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({
customFrameworkId: null,
});

await expect(
service.updateCustom('fi_platform', 'org_1', { name: 'x' }),
).rejects.toThrow(BadRequestException);
expect(mockDb.customFramework.update).not.toHaveBeenCalled();
});
});

describe('getScores', () => {
it('should call getOverviewScores and getCurrentMember when userId is provided', async () => {
const mockScores = { policies: 10, tasks: 5 };
Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/frameworks/frameworks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,43 @@ export class FrameworksService {
});
}

async updateCustom(
frameworkInstanceId: string,
organizationId: string,
input: { name?: string; description?: string },
) {
const data: { name?: string; description?: string } = {};
if (input.name !== undefined) data.name = input.name;
if (input.description !== undefined) data.description = input.description;

// Reject an empty PATCH up front instead of issuing a no-op write.
if (Object.keys(data).length === 0) {
throw new BadRequestException('No fields to update');
}

const frameworkInstance = await db.frameworkInstance.findUnique({
where: { id: frameworkInstanceId, organizationId },
select: { customFrameworkId: true },
});

if (!frameworkInstance) {
throw new NotFoundException('Framework instance not found');
}

// Only org-authored custom frameworks carry editable metadata. Platform
// frameworks derive their name/description from the shared global template.
if (!frameworkInstance.customFrameworkId) {
throw new BadRequestException('Only custom frameworks can be edited');
}

// Ownership is already enforced by the org-scoped instance lookup above,
// so keying the update by the custom framework id is safe.
return db.customFramework.update({
where: { id: frameworkInstance.customFrameworkId },
data,
});
}

async createRequirement(
frameworkInstanceId: string,
organizationId: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { BasicAuthConfig } from '@trycompai/integration-platform';
import { buildBasicAuthCredentialFields } from './basic-auth-credential-fields';

describe('buildBasicAuthCredentialFields', () => {
it('maps usernameField/passwordField to labeled fields (Fivetran api_key/api_secret)', () => {
const config: BasicAuthConfig = {
usernameField: 'api_key',
passwordField: 'api_secret',
};

const fields = buildBasicAuthCredentialFields(config);

// Ids must equal the config field names so the runtime finds them when
// building the Basic auth header; labels must read as API Key / API Secret.
expect(fields).toEqual([
{
id: 'api_key',
label: 'API Key',
type: 'text',
required: true,
placeholder: 'Enter API Key',
},
{
id: 'api_secret',
label: 'API Secret',
type: 'password',
required: true,
placeholder: 'Enter API Secret',
},
]);
});

it('falls back to generic username/password when field names are unset', () => {
const fields = buildBasicAuthCredentialFields({} as BasicAuthConfig);

expect(fields.map((f) => f.id)).toEqual(['username', 'password']);
expect(fields.map((f) => f.label)).toEqual(['Username', 'Password']);
});
});
Loading
Loading