Skip to content

Commit 3f681e5

Browse files
fix(framework-editor): stop auto-linking all framework requirements on control create (#2605)
Creating a control from a framework's controls tab auto-connected every requirement in that framework to the new control. Users expect to commit the empty control and then link requirements manually. Removed the requirement fetch/connect from ControlTemplateService.create, dropped the now-unused frameworkId query param from the POST endpoint, and stopped forwarding it from the frontend. Added a regression test that fails if the auto-link behavior is reintroduced (CS-271). Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent e4fbb8c commit 3f681e5

4 files changed

Lines changed: 92 additions & 24 deletions

File tree

apps/api/src/framework-editor/control-template/control-template.controller.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,8 @@ export class ControlTemplateController {
4444
@Post()
4545
@ApiOperation({ summary: 'Create a control template' })
4646
@UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
47-
async create(
48-
@Body() dto: CreateControlTemplateDto,
49-
@Query('frameworkId') frameworkId?: string,
50-
) {
51-
return this.service.create(dto, frameworkId);
47+
async create(@Body() dto: CreateControlTemplateDto) {
48+
return this.service.create(dto);
5249
}
5350

5451
@Patch(':id')
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
jest.mock('@db', () => ({
2+
db: {
3+
frameworkEditorControlTemplate: {
4+
create: jest.fn(),
5+
findUnique: jest.fn(),
6+
findMany: jest.fn(),
7+
update: jest.fn(),
8+
delete: jest.fn(),
9+
},
10+
frameworkEditorRequirement: {
11+
findMany: jest.fn(),
12+
},
13+
},
14+
Prisma: { PrismaClientKnownRequestError: class {} },
15+
}));
16+
17+
import { db } from '@db';
18+
import { ControlTemplateService } from './control-template.service';
19+
20+
const mockDb = db as jest.Mocked<typeof db>;
21+
22+
describe('ControlTemplateService', () => {
23+
let service: ControlTemplateService;
24+
25+
beforeEach(() => {
26+
service = new ControlTemplateService();
27+
jest.clearAllMocks();
28+
(mockDb.frameworkEditorControlTemplate.create as jest.Mock).mockResolvedValue({
29+
id: 'frk_ct_new',
30+
name: 'New Control',
31+
});
32+
});
33+
34+
describe('create', () => {
35+
const baseDto = {
36+
name: 'New Control',
37+
description: 'Some description',
38+
};
39+
40+
// Regression test for CS-271: creating a control used to auto-link every
41+
// requirement in the caller-supplied framework. The `frameworkId` parameter
42+
// has been removed, and this test guards against the behavior coming back
43+
// even if the requirements table is populated.
44+
it('never queries or auto-links framework requirements on create (CS-271)', async () => {
45+
(mockDb.frameworkEditorRequirement.findMany as jest.Mock).mockResolvedValue([
46+
{ id: 'frk_req_1' },
47+
{ id: 'frk_req_2' },
48+
]);
49+
50+
await service.create(baseDto);
51+
52+
expect(mockDb.frameworkEditorRequirement.findMany).not.toHaveBeenCalled();
53+
const createArgs = (mockDb.frameworkEditorControlTemplate.create as jest.Mock).mock
54+
.calls[0][0];
55+
expect(createArgs.data).not.toHaveProperty('requirements');
56+
});
57+
58+
it('persists name and description', async () => {
59+
await service.create(baseDto);
60+
61+
const createArgs = (mockDb.frameworkEditorControlTemplate.create as jest.Mock).mock
62+
.calls[0][0];
63+
expect(createArgs.data).toMatchObject({
64+
name: 'New Control',
65+
description: 'Some description',
66+
});
67+
});
68+
69+
it('persists documentTypes when provided', async () => {
70+
await service.create({ ...baseDto, documentTypes: ['penetration-test'] });
71+
72+
const createArgs = (mockDb.frameworkEditorControlTemplate.create as jest.Mock).mock
73+
.calls[0][0];
74+
expect(createArgs.data.documentTypes).toEqual(['penetration-test']);
75+
});
76+
77+
it('omits documentTypes when not provided', async () => {
78+
await service.create(baseDto);
79+
80+
const createArgs = (mockDb.frameworkEditorControlTemplate.create as jest.Mock).mock
81+
.calls[0][0];
82+
expect(createArgs.data).not.toHaveProperty('documentTypes');
83+
});
84+
});
85+
});

apps/api/src/framework-editor/control-template/control-template.service.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,26 +54,14 @@ export class ControlTemplateService {
5454
return ct;
5555
}
5656

57-
async create(dto: CreateControlTemplateDto, frameworkId?: string) {
58-
const requirementIds = frameworkId
59-
? await db.frameworkEditorRequirement
60-
.findMany({
61-
where: { frameworkId },
62-
select: { id: true },
63-
})
64-
.then((reqs) => reqs.map((r) => ({ id: r.id })))
65-
: [];
66-
57+
async create(dto: CreateControlTemplateDto) {
6758
const ct = await db.frameworkEditorControlTemplate.create({
6859
data: {
6960
name: dto.name,
7061
description: dto.description ?? '',
7162
...(dto.documentTypes && {
7263
documentTypes: dto.documentTypes as EvidenceFormType[],
7364
}),
74-
...(requirementIds.length > 0 && {
75-
requirements: { connect: requirementIds },
76-
}),
7765
},
7866
});
7967
this.logger.log(`Created control template: ${ct.name} (${ct.id})`);

apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,11 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId
9191
name: string | null;
9292
description: string | null;
9393
documentTypes: string[];
94-
}) => {
95-
const queryParam = frameworkId ? `?frameworkId=${frameworkId}` : '';
96-
return apiClient<{ id: string }>(`/control-template${queryParam}`, {
94+
}) =>
95+
apiClient<{ id: string }>('/control-template', {
9796
method: 'POST',
9897
body: JSON.stringify(data),
99-
});
100-
},
98+
}),
10199
updateControl: (
102100
id: string,
103101
data: { name: string; description: string; documentTypes: string[] },
@@ -111,7 +109,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId
111109
method: 'DELETE',
112110
}),
113111
}),
114-
[frameworkId],
112+
[],
115113
);
116114
const initialGridData: ControlsPageGridData[] = useMemo(
117115
() =>

0 commit comments

Comments
 (0)