From 5444478f1834f993d6da4a6f2e2d3f3eb47f6bc0 Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Mon, 19 Oct 2026 13:36:29 -0400 Subject: [PATCH] Convert numbers to strings on import where the schema expects a string (CGE-183) Imports are only loosely validated, but saves are strictly validated, so a framework with mistyped values (e.g. WIDA's numeric hierarchyCodes) imported fine but could never be saved. Before saving, the import now checks the payload against the CFPackage schema and turns numbers into strings wherever the schema expects a string. It still counts as a mirror, not a fork. --- .../case/endpoints/ImportFramework.ts | 11 ++++ .../__tests__/ImportFramework.test.ts | 52 ++++++++++++++++ .../__tests__/coerceMistypedStrings.test.ts | 58 ++++++++++++++++++ .../case/services/coerceMistypedStrings.ts | 59 +++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 apps/opencase/src/application/case/services/__tests__/coerceMistypedStrings.test.ts create mode 100644 apps/opencase/src/application/case/services/coerceMistypedStrings.ts 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] +}