Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/opencase/src/application/case/endpoints/ImportFramework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = []

Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 })
})
})
Original file line number Diff line number Diff line change
@@ -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<string>()
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]
}
Loading