diff --git a/apps/opencase/src/application/case/endpoints/ImportFramework.ts b/apps/opencase/src/application/case/endpoints/ImportFramework.ts index 783c335..33fb320 100644 --- a/apps/opencase/src/application/case/endpoints/ImportFramework.ts +++ b/apps/opencase/src/application/case/endpoints/ImportFramework.ts @@ -11,6 +11,7 @@ import { CFRubric } from '../../../domain/case/entities/CFRubric' import { CFPackage } from '../../../domain/case/entities/CFPackage' import { logger } from '../../../infrastructure/logging/Logger' import { OPENCASE_EXT_KEY, stripOpenCaseExtensions } from '../services/openCaseExtensions' +import { coerceMistypedStrings } from '../services/coerceMistypedStrings' import type { ImportDocumentFlags } from '../types/ImportDocumentFlags' export interface ImportFrameworkCommand { @@ -124,6 +125,16 @@ export class ImportFramework { extensions: sourceCFPackage.extensions } + // Saves are strictly validated against the CFPackage schema, so numbers the + // publisher sent where a string belongs are turned into strings now — otherwise + // the framework imports fine but can never be saved (CGE-183). This is a type + // correction of the source's own values, so the import is still a pristine mirror. + const packageSchemaName = caseVersion === '1.1' ? 'case-v1p1-cfpackage' : 'case-v1p0-cfpackage' + const coercedPaths = coerceMistypedStrings(this.validator, packageSchemaName, payload) + if (coercedPaths.length > 0) { + logger.info({ tenantId, caseVersion, coercedPaths }, 'Coerced numeric values to strings during import') + } + // Collect validation warnings (non-fatal) instead of hard failing const validationWarnings: string[] = [] diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts index 4b2f4e6..a114071 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { ImportFramework } from '../ImportFramework' import { CFPackageRepository } from '../../ports/CFPackageRepository' import { CaseApiClient } from '../../../../infrastructure/http/CaseApiClient' @@ -239,6 +241,56 @@ describe('ImportFramework', () => { ).rejects.toThrow('Invalid CFPackage data: missing CFDocument') }) + describe('mistyped string fields (CGE-183)', () => { + // A real validator with the official schema — the same check every later save must pass. + const realValidator = () => { + const v = new JsonSchemaValidator() + const schemaPath = join(__dirname, '../../../../../schemas/official/case-v1p1-cfpackage.json') + v.addSchema('case-v1p1-cfpackage', JSON.parse(readFileSync(schemaPath, 'utf-8'))) + return v + } + + const itemType = (n: number, hierarchyCode: unknown) => ({ + identifier: `00000000-0000-4000-8000-00000000000${n}`, + uri: `https://example.org/ims/case/v1p1/CFItemTypes/${n}`, + title: `Type ${n}`, + description: `Type ${n}`, + hierarchyCode, + lastChangeDateTime: '2024-01-01T00:00:00+00:00' + }) + + it('saves numeric hierarchyCodes as strings and still treats the import as a pristine mirror', async () => { + importFramework = new ImportFramework(mockRepository, mockApiClient, realValidator()) + + await importFramework.execute({ + tenantId, + caseVersion, + cfPackage: { + CFDocument: cfDocument, + CFDefinitions: { CFItemTypes: [itemType(1, '1'), itemType(2, 2), itemType(3, 3)] } + } + }) + + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + expect(savedPkg.definitions?.CFItemTypes?.map((t: any) => t.hierarchyCode)).toEqual(['1', '2', '3']) + expect(savedPkg.document.toJSON().extensions['ext:opencase'].isModifiedFromSource).toBe(false) + }) + + it('no longer reports the coerced fields as validation warnings', async () => { + importFramework = new ImportFramework(mockRepository, mockApiClient, realValidator()) + + const result = await importFramework.execute({ + tenantId, + caseVersion, + cfPackage: { CFDocument: cfDocument, CFDefinitions: { CFItemTypes: [itemType(1, 1)] } }, + validateSchema: true, + schemaName: 'case-v1p1-cfpackage' + }) + + expect((result.validationWarnings ?? []).join()).not.toMatch(/hierarchyCode/) + }) + }) + describe('extensions', () => { const providerPackage = () => ({ CFDocument: { diff --git a/apps/opencase/src/application/case/services/__tests__/coerceMistypedStrings.test.ts b/apps/opencase/src/application/case/services/__tests__/coerceMistypedStrings.test.ts new file mode 100644 index 0000000..e030ca8 --- /dev/null +++ b/apps/opencase/src/application/case/services/__tests__/coerceMistypedStrings.test.ts @@ -0,0 +1,58 @@ +import { coerceMistypedStrings } from '../coerceMistypedStrings' +import { JsonSchemaValidator } from '../../../../infrastructure/validation/JsonSchemaValidator' + +describe('coerceMistypedStrings', () => { + let validator: JsonSchemaValidator + + beforeEach(() => { + validator = new JsonSchemaValidator() + validator.addSchema('test', { + type: 'object', + properties: { + code: { type: 'string' }, + count: { type: 'integer' }, + flag: { type: 'string' }, + 'a/b': { type: 'string' }, + types: { + type: 'array', + items: { type: 'object', properties: { hierarchyCode: { type: 'string' } } } + } + } + }) + }) + + it('turns numbers into strings where the schema expects a string, including in arrays', () => { + const payload: any = { code: 7, types: [{ hierarchyCode: '1' }, { hierarchyCode: 2 }, { hierarchyCode: 3.5 }] } + + const coerced = coerceMistypedStrings(validator, 'test', payload) + + expect(payload).toEqual({ code: '7', types: [{ hierarchyCode: '1' }, { hierarchyCode: '2' }, { hierarchyCode: '3.5' }] }) + expect(coerced.sort()).toEqual(['/code', '/types/1/hierarchyCode', '/types/2/hierarchyCode']) + expect(() => validator.validate('test', payload)).not.toThrow() + }) + + it('leaves fields the schema types as non-strings alone', () => { + const payload: any = { count: 3 } + expect(coerceMistypedStrings(validator, 'test', payload)).toEqual([]) + expect(payload).toEqual({ count: 3 }) + }) + + it('only coerces numbers — other mistyped values are left for validation to report', () => { + const payload: any = { code: true, flag: { nested: 1 }, types: [{ hierarchyCode: null }] } + expect(coerceMistypedStrings(validator, 'test', payload)).toEqual([]) + expect(payload).toEqual({ code: true, flag: { nested: 1 }, types: [{ hierarchyCode: null }] }) + }) + + it('resolves escaped JSON Pointer segments', () => { + const payload: any = { 'a/b': 9 } + coerceMistypedStrings(validator, 'test', payload) + expect(payload).toEqual({ 'a/b': '9' }) + }) + + it('is a no-op when there is no validator or the schema is not registered', () => { + const payload: any = { code: 7 } + expect(coerceMistypedStrings(undefined, 'test', payload)).toEqual([]) + expect(coerceMistypedStrings(validator, 'missing', payload)).toEqual([]) + expect(payload).toEqual({ code: 7 }) + }) +}) diff --git a/apps/opencase/src/application/case/services/coerceMistypedStrings.ts b/apps/opencase/src/application/case/services/coerceMistypedStrings.ts new file mode 100644 index 0000000..b55f382 --- /dev/null +++ b/apps/opencase/src/application/case/services/coerceMistypedStrings.ts @@ -0,0 +1,59 @@ +import type { JsonSchemaValidator } from '../../../infrastructure/validation/JsonSchemaValidator' + +/** Resolve the parent container and key a JSON Pointer (AJV `instancePath`) refers to. */ +function resolvePointer (root: any, pointer: string): { parent: any, key: string } | null { + const segments = pointer.split('/').slice(1).map(s => s.replace(/~1/g, '/').replace(/~0/g, '~')) + if (segments.length === 0) return null + let parent = root + for (const segment of segments.slice(0, -1)) { + if (parent === null || typeof parent !== 'object') return null + parent = parent[segment] + } + if (parent === null || typeof parent !== 'object') return null + return { parent, key: segments[segments.length - 1] } +} + +/** + * Gently fix publisher data where the schema expects a string but the source + * sent a number (e.g. `"hierarchyCode": 1` instead of `"1"`). Import validation + * is lenient, but every later save is strictly validated against the same + * schema, so a mistyped value saved as-is would leave the framework unsaveable. + * + * Driven by the schema's own `type` errors, so only fields the schema declares + * as strings are touched; nothing else about the payload is changed. Mutates + * `payload` in place and returns the instance paths that were coerced. A + * missing validator or unregistered schema is a no-op. + */ +export function coerceMistypedStrings ( + validator: JsonSchemaValidator | undefined, + schemaName: string, + payload: unknown +): string[] { + if (!validator?.hasSchema(schemaName)) return [] + + let errors: any[] + try { + validator.validate(schemaName, payload) + return [] + } catch (error: any) { + errors = Array.isArray(error?.details) ? error.details : [] + } + + const coerced = new Set() + for (const err of errors) { + if (err?.keyword !== 'type' || typeof err.instancePath !== 'string') continue + const expected = err.params?.type + const expectsString = Array.isArray(expected) ? expected.includes('string') : expected === 'string' + if (!expectsString || coerced.has(err.instancePath)) continue + + const target = resolvePointer(payload, err.instancePath) + if (!target) continue + const value = target.parent[target.key] + if (typeof value !== 'number' || !Number.isFinite(value)) continue + + target.parent[target.key] = String(value) + coerced.add(err.instancePath) + } + + return [...coerced] +}