Skip to content

Commit 113c239

Browse files
authored
Merge pull request #2988 from trycompai/tofik/fix-onboarding-stale-manifest-fk
fix(frameworks): prune deleted editor templates during org initialization
2 parents 6e1ee8e + 88702f9 commit 113c239

4 files changed

Lines changed: 555 additions & 18 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// loadFrameworkSources operates purely on the injected `tx`; its only @db
2+
// imports are `import type`, which are erased at runtime, so a no-op mock keeps
3+
// jest from initialising a real PrismaClient.
4+
jest.mock('@db', () => ({}));
5+
6+
import { loadFrameworkSources } from './frameworks-source-loader.helper';
7+
import type { FrameworkManifest } from './framework-versioning/manifest.types';
8+
9+
type LoaderTx = Parameters<typeof loadFrameworkSources>[0]['tx'];
10+
11+
function manifest(overrides: Partial<FrameworkManifest> = {}): FrameworkManifest {
12+
return {
13+
framework: { id: 'frk_pci', name: 'PCI DSS', catalogVersion: '1', description: null },
14+
requirements: [],
15+
controls: [],
16+
policies: [],
17+
tasks: [],
18+
...overrides,
19+
};
20+
}
21+
22+
/**
23+
* A manifest with one control wiring up one requirement, one policy and one
24+
* task. Callers extend it to introduce ids that no longer exist live.
25+
*/
26+
function fullManifest(): FrameworkManifest {
27+
return manifest({
28+
requirements: [{ id: 'req_live', identifier: 'R1', name: 'Req', description: null }],
29+
controls: [
30+
{
31+
id: 'ct_live',
32+
name: 'Control',
33+
description: 'd',
34+
requirementIds: ['req_live'],
35+
policyIds: ['pt_live'],
36+
taskIds: ['tt_live'],
37+
documentTypes: [],
38+
},
39+
],
40+
policies: [
41+
{ id: 'pt_live', name: 'Policy', description: null, content: [], frequency: null, department: null },
42+
],
43+
tasks: [{ id: 'tt_live', name: 'Task', description: 'd', frequency: null, department: null }],
44+
});
45+
}
46+
47+
function mockTx({
48+
versions,
49+
liveControlIds,
50+
livePolicyIds,
51+
liveTasks,
52+
liveRequirementIds,
53+
}: {
54+
versions: Array<{ id: string; frameworkId: string; manifest: FrameworkManifest }>;
55+
liveControlIds: string[];
56+
livePolicyIds: string[];
57+
liveTasks: Array<{ id: string; automationStatus: string }>;
58+
liveRequirementIds: string[];
59+
}): LoaderTx {
60+
return {
61+
frameworkVersion: { findMany: jest.fn().mockResolvedValue(versions) },
62+
frameworkEditorControlTemplate: {
63+
findMany: jest.fn().mockResolvedValue(liveControlIds.map((id) => ({ id }))),
64+
},
65+
frameworkEditorPolicyTemplate: {
66+
findMany: jest.fn().mockResolvedValue(livePolicyIds.map((id) => ({ id }))),
67+
},
68+
frameworkEditorTaskTemplate: {
69+
findMany: jest.fn().mockResolvedValue(liveTasks),
70+
},
71+
frameworkEditorRequirement: {
72+
findMany: jest.fn().mockResolvedValue(liveRequirementIds.map((id) => ({ id }))),
73+
},
74+
} as unknown as LoaderTx;
75+
}
76+
77+
function ids<T extends { id: string }>(rows: T[]): string[] {
78+
return rows.map((r) => r.id);
79+
}
80+
81+
describe('loadFrameworkSources — stale-manifest reconciliation', () => {
82+
const frameworkEditorIds = ['frk_pci'];
83+
84+
it('drops a manifest TASK whose live template was hard-deleted (the reported Task_taskTemplateId_fkey bug)', async () => {
85+
const m = fullManifest();
86+
m.controls[0].taskIds = ['tt_live', 'tt_dead'];
87+
m.tasks.push({ id: 'tt_dead', name: 'Deleted Task', description: 'd', frequency: null, department: null });
88+
89+
const tx = mockTx({
90+
versions: [{ id: 'fv_1', frameworkId: 'frk_pci', manifest: m }],
91+
liveControlIds: ['ct_live'],
92+
livePolicyIds: ['pt_live'],
93+
liveTasks: [{ id: 'tt_live', automationStatus: 'AUTOMATED' }], // tt_dead absent
94+
liveRequirementIds: ['req_live'],
95+
});
96+
97+
const result = await loadFrameworkSources({ frameworkEditorIds, frameworkEditorFrameworks: [], tx });
98+
99+
// tt_dead must never reach task.createMany — it would FK-fail on insert.
100+
expect(ids(result.taskTemplates)).toEqual(['tt_live']);
101+
});
102+
103+
it('drops a manifest CONTROL whose live template was hard-deleted', async () => {
104+
const m = fullManifest();
105+
m.controls.push({
106+
id: 'ct_dead',
107+
name: 'Deleted Control',
108+
description: 'd',
109+
requirementIds: [],
110+
policyIds: [],
111+
taskIds: [],
112+
documentTypes: [],
113+
});
114+
115+
const tx = mockTx({
116+
versions: [{ id: 'fv_1', frameworkId: 'frk_pci', manifest: m }],
117+
liveControlIds: ['ct_live'], // ct_dead absent
118+
livePolicyIds: ['pt_live'],
119+
liveTasks: [{ id: 'tt_live', automationStatus: 'AUTOMATED' }],
120+
liveRequirementIds: ['req_live'],
121+
});
122+
123+
const result = await loadFrameworkSources({ frameworkEditorIds, frameworkEditorFrameworks: [], tx });
124+
125+
expect(ids(result.controlTemplates)).toEqual(['ct_live']);
126+
});
127+
128+
it('drops a manifest POLICY whose live template was hard-deleted', async () => {
129+
const m = fullManifest();
130+
m.controls[0].policyIds = ['pt_live', 'pt_dead'];
131+
m.policies.push({
132+
id: 'pt_dead',
133+
name: 'Deleted Policy',
134+
description: null,
135+
content: [],
136+
frequency: null,
137+
department: null,
138+
});
139+
140+
const tx = mockTx({
141+
versions: [{ id: 'fv_1', frameworkId: 'frk_pci', manifest: m }],
142+
liveControlIds: ['ct_live'],
143+
livePolicyIds: ['pt_live'], // pt_dead absent
144+
liveTasks: [{ id: 'tt_live', automationStatus: 'AUTOMATED' }],
145+
liveRequirementIds: ['req_live'],
146+
});
147+
148+
const result = await loadFrameworkSources({ frameworkEditorIds, frameworkEditorFrameworks: [], tx });
149+
150+
expect(ids(result.policyTemplates)).toEqual(['pt_live']);
151+
});
152+
153+
it('drops a dead REQUIREMENT from groupedRelations (RequirementMap.requirementId has no downstream guard)', async () => {
154+
const m = fullManifest();
155+
m.controls[0].requirementIds = ['req_live', 'req_dead'];
156+
m.requirements.push({ id: 'req_dead', identifier: 'R2', name: 'Deleted Req', description: null });
157+
158+
const tx = mockTx({
159+
versions: [{ id: 'fv_1', frameworkId: 'frk_pci', manifest: m }],
160+
liveControlIds: ['ct_live'],
161+
livePolicyIds: ['pt_live'],
162+
liveTasks: [{ id: 'tt_live', automationStatus: 'AUTOMATED' }],
163+
liveRequirementIds: ['req_live'], // req_dead absent
164+
});
165+
166+
const result = await loadFrameworkSources({ frameworkEditorIds, frameworkEditorFrameworks: [], tx });
167+
168+
const rel = result.groupedRelations.find((r) => r.controlTemplateId === 'ct_live');
169+
expect(rel?.requirementTemplateIds).toEqual(['req_live']);
170+
});
171+
172+
it('passes everything through unchanged and resolves automationStatus when all templates are live', async () => {
173+
const tx = mockTx({
174+
versions: [{ id: 'fv_1', frameworkId: 'frk_pci', manifest: fullManifest() }],
175+
liveControlIds: ['ct_live'],
176+
livePolicyIds: ['pt_live'],
177+
liveTasks: [{ id: 'tt_live', automationStatus: 'MANUAL' }],
178+
liveRequirementIds: ['req_live'],
179+
});
180+
181+
const result = await loadFrameworkSources({ frameworkEditorIds, frameworkEditorFrameworks: [], tx });
182+
183+
expect(ids(result.controlTemplates)).toEqual(['ct_live']);
184+
expect(ids(result.policyTemplates)).toEqual(['pt_live']);
185+
expect(ids(result.taskTemplates)).toEqual(['tt_live']);
186+
// automationStatus is not in the manifest — it must come from the live row.
187+
expect(result.taskTemplates[0].automationStatus).toBe('MANUAL');
188+
const rel = result.groupedRelations.find((r) => r.controlTemplateId === 'ct_live');
189+
expect(rel?.requirementTemplateIds).toEqual(['req_live']);
190+
expect(rel?.policyTemplateIds).toEqual(['pt_live']);
191+
expect(rel?.taskTemplateIds).toEqual(['tt_live']);
192+
});
193+
});

‎apps/api/src/frameworks/frameworks-source-loader.helper.ts‎

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export async function loadFrameworkSources({
8989
const controlsMap = new Map<string, LoadedFrameworkSources['controlTemplates'][number]>();
9090
const policiesMap = new Map<string, LoadedFrameworkSources['policyTemplates'][number]>();
9191
const tasksMap = new Map<string, LoadedFrameworkSources['taskTemplates'][number]>();
92+
// Requirement ids referenced by manifest controls, validated against live
93+
// FrameworkEditorRequirement rows below. Dead ones are pruned from relations
94+
// so RequirementMap inserts never reference a deleted requirement.
95+
const manifestRequirementIds = new Set<string>();
96+
const deadRequirementIds = new Set<string>();
9297

9398
// groupedRelations accumulates per-framework control edges. A reusable
9499
// control can carry different policy/task/document links in each framework.
@@ -139,7 +144,10 @@ export async function loadFrameworkSources({
139144
});
140145
}
141146
const rel = getOrCreateRelation(frameworkId, c.id);
142-
for (const rid of c.requirementIds) rel.requirementTemplateIds.add(rid);
147+
for (const rid of c.requirementIds) {
148+
rel.requirementTemplateIds.add(rid);
149+
manifestRequirementIds.add(rid);
150+
}
143151
for (const pid of c.policyIds) rel.policyTemplateIds.add(pid);
144152
for (const tid of c.taskIds) rel.taskTemplateIds.add(tid);
145153
for (const formType of c.documentTypes ?? []) {
@@ -182,17 +190,74 @@ export async function loadFrameworkSources({
182190
void frameworkId; // keep loop structure tidy; id used above
183191
}
184192

185-
// Resolve automationStatus from live task templates for any manifest tasks
186-
const manifestTaskIds = Array.from(tasksMap.keys());
187-
if (manifestTaskIds.length > 0) {
188-
const liveTasks = await tx.frameworkEditorTaskTemplate.findMany({
189-
where: { id: { in: manifestTaskIds } },
190-
select: { id: true, automationStatus: true },
191-
});
193+
// Manifests are frozen snapshots: a control/policy/task/requirement they
194+
// reference may have been hard-deleted from the live framework-editor tables
195+
// since the version was published. Org rows FK to those live tables
196+
// (Control.controlTemplateId, Policy.policyTemplateId, Task.taskTemplateId,
197+
// RequirementMap.requirementId), so creating one that points at a deleted
198+
// template raises a P2003 FK violation and aborts onboarding. Reconcile
199+
// against the live tables: resolve task automationStatus (only carried live)
200+
// and drop any manifest reference whose live row is gone. Fallback-path ids
201+
// are read straight from live tables below, so they are never pruned here.
202+
if (manifestByFrameworkId.size > 0) {
203+
const manifestControlIds = Array.from(controlsMap.keys());
204+
const manifestPolicyIds = Array.from(policiesMap.keys());
205+
const manifestTaskIds = Array.from(tasksMap.keys());
206+
const manifestReqIds = Array.from(manifestRequirementIds);
207+
208+
const [liveControls, livePolicies, liveTasks, liveRequirements] = await Promise.all([
209+
tx.frameworkEditorControlTemplate.findMany({
210+
where: { id: { in: manifestControlIds } },
211+
select: { id: true },
212+
}),
213+
tx.frameworkEditorPolicyTemplate.findMany({
214+
where: { id: { in: manifestPolicyIds } },
215+
select: { id: true },
216+
}),
217+
tx.frameworkEditorTaskTemplate.findMany({
218+
where: { id: { in: manifestTaskIds } },
219+
select: { id: true, automationStatus: true },
220+
}),
221+
tx.frameworkEditorRequirement.findMany({
222+
where: { id: { in: manifestReqIds } },
223+
select: { id: true },
224+
}),
225+
]);
226+
227+
// automationStatus isn't carried in the manifest — copy it from the live row.
192228
for (const lt of liveTasks) {
193229
const existing = tasksMap.get(lt.id);
194230
if (existing) existing.automationStatus = lt.automationStatus;
195231
}
232+
233+
const liveControlIds = new Set(liveControls.map((c) => c.id));
234+
const livePolicyIds = new Set(livePolicies.map((p) => p.id));
235+
const liveTaskIds = new Set(liveTasks.map((t) => t.id));
236+
const liveRequirementIds = new Set(liveRequirements.map((r) => r.id));
237+
238+
const droppedControls = manifestControlIds.filter((id) => !liveControlIds.has(id));
239+
const droppedPolicies = manifestPolicyIds.filter((id) => !livePolicyIds.has(id));
240+
const droppedTasks = manifestTaskIds.filter((id) => !liveTaskIds.has(id));
241+
const droppedRequirements = manifestReqIds.filter((id) => !liveRequirementIds.has(id));
242+
243+
for (const id of droppedControls) controlsMap.delete(id);
244+
for (const id of droppedPolicies) policiesMap.delete(id);
245+
for (const id of droppedTasks) tasksMap.delete(id);
246+
for (const id of droppedRequirements) deadRequirementIds.add(id);
247+
248+
if (
249+
droppedControls.length ||
250+
droppedPolicies.length ||
251+
droppedTasks.length ||
252+
droppedRequirements.length
253+
) {
254+
console.warn(
255+
`loadFrameworkSources: pruned manifest references with no live framework-editor template ` +
256+
`(stale manifest — republish the affected framework version). ` +
257+
`controls=[${droppedControls.join(', ')}] policies=[${droppedPolicies.join(', ')}] ` +
258+
`tasks=[${droppedTasks.join(', ')}] requirements=[${droppedRequirements.join(', ')}]`,
259+
);
260+
}
196261
}
197262

198263
// Fallback: frameworks without a published version load from live tables.
@@ -327,7 +392,12 @@ export async function loadFrameworkSources({
327392
const groupedRelations = Array.from(relationsByControl.values()).map((rel) => ({
328393
frameworkId: rel.frameworkId,
329394
controlTemplateId: rel.controlTemplateId,
330-
requirementTemplateIds: Array.from(rel.requirementTemplateIds),
395+
// Dead manifest requirements are pruned here: RequirementMap.requirementId
396+
// has no downstream instance-map guard (unlike policy/task ids), so a stale
397+
// id would otherwise FK-fail on RequirementMap_requirementId_fkey.
398+
requirementTemplateIds: Array.from(rel.requirementTemplateIds).filter(
399+
(id) => !deadRequirementIds.has(id),
400+
),
331401
policyTemplateIds: Array.from(rel.policyTemplateIds),
332402
taskTemplateIds: Array.from(rel.taskTemplateIds),
333403
documentTypes: Array.from(rel.documentTypes),

0 commit comments

Comments
 (0)