Skip to content

Commit 483e1a0

Browse files
authored
Merge pull request #3140 from trycompai/main
[comp] Production Deploy
2 parents bea22d8 + 381f7f3 commit 483e1a0

6 files changed

Lines changed: 266 additions & 10 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Mock @db so importing the DTO (which pulls enums from @db) doesn't spin up a
2+
// real Prisma client. The mocked enums are what @IsEnum validates against, so
3+
// the payloads below use these values.
4+
jest.mock('@db', () => ({
5+
Frequency: { monthly: 'monthly', yearly: 'yearly' },
6+
Departments: { it: 'it', admin: 'admin' },
7+
EvidenceFormType: { policy: 'policy' },
8+
TaskAutomationStatus: { manual: 'manual' },
9+
}));
10+
11+
import { plainToInstance } from 'class-transformer';
12+
import { validate, type ValidationError } from 'class-validator';
13+
import { Departments, Frequency } from '@db';
14+
import { ImportFrameworkDto } from './import-framework.dto';
15+
16+
function collectMessages(errors: ValidationError[]): string[] {
17+
const out: string[] = [];
18+
for (const e of errors) {
19+
if (e.constraints) out.push(...Object.values(e.constraints));
20+
if (e.children?.length) out.push(...collectMessages(e.children));
21+
}
22+
return out;
23+
}
24+
25+
const FREQUENCY = Object.values(Frequency)[0] as Frequency;
26+
const DEPARTMENT = Object.values(Departments)[0] as Departments;
27+
28+
function basePayload(
29+
overrides: { requirementDescription?: string; policyContent?: unknown } = {},
30+
) {
31+
return {
32+
version: '1',
33+
framework: { name: 'NIST SP800-53', version: '5', description: 'Low impact', visible: false },
34+
requirements: [
35+
{
36+
name: 'System Security and Privacy Plans',
37+
identifier: 'PL-2',
38+
description: overrides.requirementDescription ?? 'Develop security and privacy plans.',
39+
requirementFamily: 'PL - Planning',
40+
},
41+
],
42+
policyTemplates: [
43+
{
44+
name: 'Access Control Policy',
45+
description: 'Policy',
46+
frequency: FREQUENCY,
47+
department: DEPARTMENT,
48+
content:
49+
'policyContent' in overrides ? overrides.policyContent : { type: 'doc', content: [] },
50+
},
51+
],
52+
controlTemplates: [],
53+
taskTemplates: [],
54+
};
55+
}
56+
57+
async function validatePayload(plain: Record<string, unknown>) {
58+
const dto = plainToInstance(ImportFrameworkDto, plain, { enableImplicitConversion: true });
59+
return collectMessages(await validate(dto, { whitelist: true }));
60+
}
61+
62+
describe('ImportFrameworkDto', () => {
63+
it('accepts a valid payload', async () => {
64+
expect(await validatePayload(basePayload())).toHaveLength(0);
65+
});
66+
67+
// Bug A — the import path must allow the same 10,000-char requirement
68+
// descriptions the standalone requirement editor allows (NIST PL-2 > 6000).
69+
it('accepts a 10,000-char requirement description', async () => {
70+
expect(
71+
await validatePayload(basePayload({ requirementDescription: 'x'.repeat(10_000) })),
72+
).toHaveLength(0);
73+
});
74+
75+
it('rejects a requirement description longer than 10,000 chars', async () => {
76+
const messages = await validatePayload(
77+
basePayload({ requirementDescription: 'x'.repeat(10_001) }),
78+
);
79+
expect(messages.some((m) => m.includes('10000'))).toBe(true);
80+
});
81+
82+
// Import is the bulk-load path for externally-authored frameworks (NIST), so
83+
// every description field accepts 10,000 — not just requirements.
84+
it('accepts 10,000-char control / policy / task descriptions', async () => {
85+
const long = 'x'.repeat(10_000);
86+
const payload = {
87+
...basePayload(),
88+
controlTemplates: [{ name: 'C', description: long, controlFamily: 'AC' }],
89+
policyTemplates: [
90+
{
91+
name: 'P',
92+
description: long,
93+
frequency: FREQUENCY,
94+
department: DEPARTMENT,
95+
content: { type: 'doc', content: [] },
96+
},
97+
],
98+
taskTemplates: [{ name: 'T', description: long, frequency: FREQUENCY, department: DEPARTMENT }],
99+
};
100+
expect(await validatePayload(payload)).toHaveLength(0);
101+
});
102+
103+
// Bug B — policy content may be a doc object OR a bare node array.
104+
it('accepts policy content as a bare node array', async () => {
105+
expect(
106+
await validatePayload(basePayload({ policyContent: [{ type: 'paragraph', content: [] }] })),
107+
).toHaveLength(0);
108+
});
109+
110+
it('accepts policy content as a doc object', async () => {
111+
expect(
112+
await validatePayload(
113+
basePayload({ policyContent: { type: 'doc', content: [{ type: 'paragraph' }] } }),
114+
),
115+
).toHaveLength(0);
116+
});
117+
118+
it('rejects policy content that is a primitive', async () => {
119+
const messages = await validatePayload(basePayload({ policyContent: 'not-json' }));
120+
expect(messages.some((m) => m.toLowerCase().includes('object or an array'))).toBe(true);
121+
});
122+
123+
it('still rejects oversized policy content (size guard kept)', async () => {
124+
const huge = [{ type: 'text', text: 'x'.repeat(520_000) }];
125+
const messages = await validatePayload(basePayload({ policyContent: huge }));
126+
expect(messages.some((m) => m.toLowerCase().includes('maximum allowed size'))).toBe(true);
127+
});
128+
});

‎apps/api/src/framework-editor/framework/dto/import-framework.dto.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
IsOptional,
88
IsArray,
99
IsInt,
10-
IsObject,
1110
IsEnum,
1211
MaxLength,
1312
ValidateNested,
@@ -21,6 +20,7 @@ import {
2120
TaskAutomationStatus,
2221
} from '@db';
2322
import { MaxJsonSize } from '../../validators/max-json-size.validator';
23+
import { IsObjectOrArray } from '../../validators/is-object-or-array.validator';
2424

2525
class ImportFrameworkMetaDto {
2626
@ApiProperty()
@@ -60,10 +60,12 @@ class ImportRequirementDto {
6060
@MaxLength(255)
6161
identifier?: string;
6262

63+
// Matches the standalone requirement DTOs (FRAME-2). NIST SP800-53 control
64+
// text routinely exceeds 5000 chars (e.g. PL-2 > 6000).
6365
@ApiProperty()
6466
@IsString()
6567
@IsNotEmpty()
66-
@MaxLength(5000)
68+
@MaxLength(10000)
6769
description: string;
6870

6971
@ApiPropertyOptional()
@@ -83,7 +85,7 @@ class ImportControlTemplateDto {
8385
@ApiProperty()
8486
@IsString()
8587
@IsNotEmpty()
86-
@MaxLength(5000)
88+
@MaxLength(10000)
8789
description: string;
8890

8991
@ApiPropertyOptional({ example: 'AC - Access Control' })
@@ -134,7 +136,7 @@ class ImportPolicyTemplateDto {
134136
@ApiProperty()
135137
@IsString()
136138
@IsNotEmpty()
137-
@MaxLength(5000)
139+
@MaxLength(10000)
138140
description: string;
139141

140142
@ApiProperty()
@@ -145,11 +147,13 @@ class ImportPolicyTemplateDto {
145147
@IsEnum(Departments)
146148
department: Departments;
147149

150+
// TipTap content is stored either as a `{ type: 'doc', … }` object or a bare
151+
// node array (legacy data) — accept both; it's normalized on persist.
148152
@ApiPropertyOptional()
149-
@IsObject()
153+
@IsObjectOrArray()
150154
@IsOptional()
151155
@MaxJsonSize()
152-
content?: Record<string, unknown>;
156+
content?: Record<string, unknown> | unknown[];
153157
}
154158

155159
class ImportTaskTemplateDto {
@@ -162,7 +166,7 @@ class ImportTaskTemplateDto {
162166
@ApiProperty()
163167
@IsString()
164168
@IsNotEmpty()
165-
@MaxLength(5000)
169+
@MaxLength(10000)
166170
description: string;
167171

168172
@ApiProperty()

‎apps/api/src/framework-editor/framework/framework-export.service.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@nestjs/common';
77
import { db, Prisma, EvidenceFormType } from '@db';
88
import type { ImportFrameworkDto } from './dto/import-framework.dto';
9+
import { normalizeTipTapDoc } from './normalize-tiptap-doc';
910

1011
export interface ExportedFramework {
1112
version: string;
@@ -36,7 +37,7 @@ export interface ExportedFramework {
3637
description: string;
3738
frequency: string;
3839
department: string;
39-
content: Record<string, unknown>;
40+
content: Prisma.InputJsonObject;
4041
}>;
4142
taskTemplates: Array<{
4243
name: string;
@@ -151,7 +152,9 @@ export class FrameworkExportService {
151152
description: p.description,
152153
frequency: p.frequency,
153154
department: p.department,
154-
content: p.content as Record<string, unknown>,
155+
// Emit a canonical doc node so every exported file is homogeneous,
156+
// regardless of how the policy's content was stored.
157+
content: normalizeTipTapDoc(p.content),
155158
})),
156159
taskTemplates: taskTemplates.map((t) => ({
157160
name: t.name,
@@ -204,7 +207,10 @@ export class FrameworkExportService {
204207
description: p.description,
205208
frequency: p.frequency,
206209
department: p.department,
207-
content: (p.content ?? {}) as Prisma.InputJsonValue,
210+
// Normalize array/object/empty content to a canonical doc node —
211+
// accepts the array shape legacy exports emit and avoids writing a
212+
// bare `{}` (which onboarding turns into an empty policy).
213+
content: normalizeTipTapDoc(p.content),
208214
},
209215
}),
210216
),
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { normalizeTipTapDoc } from './normalize-tiptap-doc';
2+
3+
describe('normalizeTipTapDoc', () => {
4+
it('wraps a bare node array into a doc', () => {
5+
const nodes = [{ type: 'paragraph', content: [] }];
6+
expect(normalizeTipTapDoc(nodes)).toEqual({ type: 'doc', content: nodes });
7+
});
8+
9+
it('passes through an existing doc node', () => {
10+
const doc = { type: 'doc', content: [{ type: 'paragraph' }] };
11+
expect(normalizeTipTapDoc(doc)).toEqual(doc);
12+
});
13+
14+
it('empties a doc node with a non-array content', () => {
15+
expect(normalizeTipTapDoc({ type: 'doc' })).toEqual({ type: 'doc', content: [] });
16+
});
17+
18+
it('wraps a single non-doc node', () => {
19+
const node = { type: 'paragraph', content: [{ type: 'text', text: 'hi' }] };
20+
expect(normalizeTipTapDoc(node)).toEqual({ type: 'doc', content: [node] });
21+
});
22+
23+
it('returns an empty doc for null / undefined', () => {
24+
expect(normalizeTipTapDoc(null)).toEqual({ type: 'doc', content: [] });
25+
expect(normalizeTipTapDoc(undefined)).toEqual({ type: 'doc', content: [] });
26+
});
27+
28+
it('returns an empty doc for a bare object (the {} footgun)', () => {
29+
expect(normalizeTipTapDoc({})).toEqual({ type: 'doc', content: [] });
30+
});
31+
32+
it('returns an empty doc for primitive values', () => {
33+
expect(normalizeTipTapDoc('a string')).toEqual({ type: 'doc', content: [] });
34+
expect(normalizeTipTapDoc(42)).toEqual({ type: 'doc', content: [] });
35+
});
36+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Type-only import: keeps this helper (and its unit test) from instantiating
2+
// the Prisma client at module load.
3+
import type { Prisma } from '@db';
4+
5+
/**
6+
* Normalize arbitrary stored/imported policy content into a `{ type: 'doc', … }`
7+
* document node.
8+
*
9+
* Policy content lives in an untyped `Json` column and legitimately appears in
10+
* several shapes: a proper doc object, a bare array of nodes (legacy/seeded
11+
* data), a single node, or nothing. Export passes whatever is stored through
12+
* verbatim, and the import DTO used to require an object — so array-shaped
13+
* content was rejected ("content must be an object").
14+
*
15+
* This mirrors the top-level branches of the editor's
16+
* `validateAndFixTipTapContent` (packages/ui) without the deep node repair —
17+
* onboarding/render consumers run their own sanitization. We only need a valid
18+
* top-level doc shape. Returning `{ type: 'doc', content: [] }` for empty input
19+
* (instead of a bare `{}`) also avoids the footgun where onboarding silently
20+
* turns `{}` into an empty policy.
21+
*/
22+
export function normalizeTipTapDoc(content: unknown): Prisma.InputJsonObject {
23+
// Bare array of nodes → wrap as a doc.
24+
if (Array.isArray(content)) {
25+
return { type: 'doc', content: content as Prisma.InputJsonValue[] };
26+
}
27+
28+
if (content !== null && typeof content === 'object') {
29+
const node = content as { type?: unknown; content?: unknown };
30+
31+
// Already a doc → keep its content array (or empty it if malformed).
32+
if (node.type === 'doc') {
33+
return {
34+
type: 'doc',
35+
content: Array.isArray(node.content) ? (node.content as Prisma.InputJsonValue[]) : [],
36+
};
37+
}
38+
39+
// A single non-doc node (has a string type) → wrap it.
40+
if (typeof node.type === 'string') {
41+
return { type: 'doc', content: [content as Prisma.InputJsonValue] };
42+
}
43+
}
44+
45+
// Missing / empty / non-node value → empty doc.
46+
return { type: 'doc', content: [] };
47+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {
2+
registerDecorator,
3+
ValidationOptions,
4+
ValidatorConstraint,
5+
ValidatorConstraintInterface,
6+
} from 'class-validator';
7+
8+
/**
9+
* Accepts any non-null object OR array. Unlike class-validator's `@IsObject`
10+
* (which explicitly rejects arrays), this allows both shapes — needed for
11+
* TipTap content, which is legitimately stored either as a `{ type: 'doc', … }`
12+
* object or as a bare array of ProseMirror nodes.
13+
*/
14+
@ValidatorConstraint({ name: 'isObjectOrArray', async: false })
15+
export class IsObjectOrArrayConstraint implements ValidatorConstraintInterface {
16+
validate(value: unknown): boolean {
17+
return typeof value === 'object' && value !== null;
18+
}
19+
20+
defaultMessage(): string {
21+
return 'value must be an object or an array';
22+
}
23+
}
24+
25+
export function IsObjectOrArray(validationOptions?: ValidationOptions) {
26+
return function (object: object, propertyName: string) {
27+
registerDecorator({
28+
target: object.constructor,
29+
propertyName,
30+
options: validationOptions,
31+
constraints: [],
32+
validator: IsObjectOrArrayConstraint,
33+
});
34+
};
35+
}

0 commit comments

Comments
 (0)