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
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export class CreateFramework {
// compare like-for-like against the existing persisted entities' own
// .toJSON() output — comparing the raw POST payload directly against a
// .toJSON()'d bundle would false-positive on every save, since GET-only
// fields (CFDocumentURI, CFPackageURI) are legitimately absent from POST
// fields (CFDocumentURI) are legitimately absent from POST
// payloads by CASE spec convention but always present after fromRaw/toJSON.
const buildEntities = (docPayload: any, itemsPayload: any[], assocPayload: any[], rubricsPayload: any[], uriOptions?: { preserveUris: boolean }) => {
const doc = CFDocument.fromRaw(tenantId, caseVersion, docPayload, uriOptions)
Expand Down Expand Up @@ -321,7 +321,7 @@ export class CreateFramework {
CFItems: sortById(items.map(i => i.toJSON())),
CFAssociations: sortById(associations.map(a => a.toJSON())),
CFRubrics: sortById(rubrics.map(r => r.toJSON())),
CFDefinitions: definitions
CFDefinitions: pkg.definitions
}

if (stableStringify(existingBundle) === stableStringify(newBundle)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type CaseVersion, type TenantId } from '../../../domain/case/value-objects/Identifiers'
import { logger } from '../../../infrastructure/logging/Logger'
import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore'
import { type LinkData } from '../../../domain/case/value-objects/LinkData'
import { buildCFPackageURI } from '../services/packageUri'

export interface GetAllCFDocumentsQuery {
tenantId?: TenantId // Optional — when omitted, lists across ALL tenants (global catalog)
Expand Down Expand Up @@ -103,18 +103,12 @@ export class GetAllCFDocuments {
const isV1p0 = query.caseVersion === '1.0'

const cfDocuments = paginatedDocs.map(({ meta: docMeta, caseVersion }) => {
const packageURI: LinkData = {
title: 'CFPackage',
identifier: docMeta.sourcedId,
uri: `${requestedBasePath}/CFPackages/${docMeta.sourcedId}`
}

const doc: any = {
identifier: docMeta.sourcedId,
uri: `${requestedBasePath}/CFDocuments/${docMeta.sourcedId}`,
title: docMeta.title,
lastChangeDateTime: docMeta.lastChangeDateTime.toISOString(),
CFPackageURI: packageURI
CFPackageURI: buildCFPackageURI(docMeta.sourcedId, query.caseVersion, docMeta.openCaseExtensions)
}

// Add optional fields if present
Expand Down
19 changes: 6 additions & 13 deletions apps/opencase/src/application/case/endpoints/GetCFDocument.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { type CFPackageRepository } from '../ports/CFPackageRepository'
import { type CaseVersion, type SourcedId, type TenantId } from '../../../domain/case/value-objects/Identifiers'
import { logger } from '../../../infrastructure/logging/Logger'
import { type LinkData } from '../../../domain/case/value-objects/LinkData'
import { type FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore'
import { buildCFPackageURI, getOpenCaseExtension } from '../services/packageUri'

export interface GetCFDocumentQuery {
tenantId: TenantId
Expand Down Expand Up @@ -30,21 +30,14 @@ export class GetCFDocument {

// Note: Get by ID returns archived documents regardless - filtering only applies to list endpoints

// Generate CFPackageURI — uses caseVersion for URI generation
const basePath = query.caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'
const packageURI: LinkData = {
title: 'CFPackage',
identifier: query.sourcedId,
uri: `${basePath}/CFPackages/${query.sourcedId}`
}

// Pass caseVersion to toJSON for correct field stripping when downconverting
const serializeAs = query.loadVersion ? query.caseVersion : undefined
const documentJSON = pkg.document.toJSON(serializeAs)
// Add CFPackageURI if not already present
if (!documentJSON.CFPackageURI) {
documentJSON.CFPackageURI = packageURI
}
documentJSON.CFPackageURI = buildCFPackageURI(
query.sourcedId,
query.caseVersion,
getOpenCaseExtension(pkg.document.extensions)
)

return documentJSON
}
Expand Down
22 changes: 9 additions & 13 deletions apps/opencase/src/application/case/endpoints/GetCFPackage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { CFPackageRepository } from '../ports/CFPackageRepository'
import type { CaseVersion, SourcedId, TenantId } from '../../../domain/case/value-objects/Identifiers'
import { logger } from '../../../infrastructure/logging/Logger'
import { LinkData } from '../../../domain/case/value-objects/LinkData'
import type { FileFrameworkStore } from '../../../infrastructure/persistence/file/FileFrameworkStore'

export interface GetCFPackageQuery {
Expand Down Expand Up @@ -29,25 +28,22 @@ export class GetCFPackage {

// Note: Get by ID returns archived packages regardless - filtering only applies to list endpoints

// Generate CFPackageURI for the document — uses caseVersion for URI generation
const basePath = query.caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'
const packageURI: LinkData = {
title: 'CFPackage',
identifier: query.docId,
uri: `${basePath}/CFPackages/${query.docId}`
}

// Pass caseVersion to toJSON for correct field stripping (e.g. v1.0 omits v1.1-only fields)
const serializeAs = query.loadVersion ? query.caseVersion : undefined
// No CFPackageURI here: a CFPackage's CFDocument is a CFPckgDocument, which
// doesn't carry one (only getCFDocument(s) responses do).
const documentJSON = pkg.document.toJSON(serializeAs)
// Add CFPackageURI if not already present
if (!documentJSON.CFPackageURI) {
documentJSON.CFPackageURI = packageURI

// Likewise CFPckgItem has no CFDocumentURI — the package itself establishes
// the document; only getCFItem responses carry the back-reference.
const toPckgItem = (item: typeof pkg.items[number]) => {
const { CFDocumentURI: _documentUri, ...rest } = item.toJSON(serializeAs)
return rest
}

const result: any = {
CFDocument: documentJSON,
CFItems: pkg.items.map(i => i.toJSON(serializeAs)),
CFItems: pkg.items.map(toPckgItem),
CFAssociations: pkg.associations.map(a => a.toJSON(serializeAs))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,23 @@ describe('GetAllCFDocuments', () => {
expect(result.CFDocuments[0].extensions).toEqual({ 'ext:opencase': openCaseExtensions })
})

it('should derive CFPackageURI from mirror/fork state (CGE-59)', async () => {
const source = 'https://standards.example.org/ims/case/v1p1/CFPackages/mirror'
const documents = [
{ sourcedId: 'mirror', title: 'Mirror', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'a.json',
openCaseExtensions: { isModifiedFromSource: false, sourcePackageURI: source } },
{ sourcedId: 'fork', title: 'Fork', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'b.json',
openCaseExtensions: { isModifiedFromSource: true, sourcePackageURI: source } }
]
mockStore.getAllDocuments.mockImplementation((_: any, v: any) => (v === '1.1' ? documents as any : []))

const result = await getAllCFDocuments.execute({ tenantId, caseVersion })
const byId = Object.fromEntries(result.CFDocuments.map((d: any) => [d.identifier, d]))

expect(byId.mirror.CFPackageURI.uri).toBe(source)
expect(byId.fork.CFPackageURI.uri).toBe('/ims/case/v1p1/CFPackages/fork')
})

it('should omit the extensions block entirely for CASE 1.0', async () => {
const documents = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,42 @@ describe('GetCFDocument', () => {
}))
})

it('should point a pristine mirror\'s CFPackageURI at its import source, ignoring any stored value (CGE-59)', async () => {
const source = 'https://standards.example.org/ims/case/v1p1/CFPackages/doc-123'
const document = CFDocument.fromRaw(tenantId, caseVersion, {
identifier: docId,
uri: 'https://standards.example.org/doc-123',
creator: 'Source',
title: 'Mirrored',
lastChangeDateTime: '2024-01-01T00:00:00Z',
CFPackageURI: { title: 'Stale', identifier: 'other', uri: 'https://elsewhere.example.org/x' },
extensions: { 'ext:opencase': { isModifiedFromSource: false, sourcePackageURI: source } }
}, { preserveUris: true })
mockRepository.load.mockResolvedValue(new CFPackage({ document, items: [], associations: [] }))

const result = await getCFDocument.execute({ tenantId, caseVersion, sourcedId: docId })

expect(result.CFPackageURI).toEqual({ title: 'CFPackage', identifier: docId, uri: source })
})

it('should point a forked framework\'s CFPackageURI at this server (CGE-59)', async () => {
const document = CFDocument.create({
tenantId,
caseVersion,
sourcedId: docId,
uri: `/ims/case/v1p1/CFDocuments/${docId}`,
creator: 'Test Creator',
title: 'Forked',
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
extensions: { 'ext:opencase': { isModifiedFromSource: true, sourcePackageURI: 'https://standards.example.org/x' } }
})
mockRepository.load.mockResolvedValue(new CFPackage({ document, items: [], associations: [] }))

const result = await getCFDocument.execute({ tenantId, caseVersion, sourcedId: docId })

expect(result.CFPackageURI.uri).toBe(`/ims/case/v1p1/CFPackages/${docId}`)
})

it('should include caseVersion for a CASE 1.1 document', async () => {
const document = CFDocument.create({
tenantId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,7 @@ describe('GetCFPackage', () => {
expect(result).toEqual({
CFDocument: expect.objectContaining({
identifier: docId,
title: 'Test Document',
CFPackageURI: expect.objectContaining({
identifier: docId
})
title: 'Test Document'
}),
CFItems: expect.arrayContaining([
expect.objectContaining({ identifier: 'item-1' }),
Expand All @@ -149,6 +146,11 @@ describe('GetCFPackage', () => {
})
])
});
// CFPckgDocument has no CFPackageURI; CFPckgItem has no CFDocumentURI (CGE-59)
expect(result!.CFDocument).not.toHaveProperty('CFPackageURI');
for (const item of result!.CFItems) {
expect(item).not.toHaveProperty('CFDocumentURI');
}
});

it('should handle empty items and associations', async () => {
Expand Down Expand Up @@ -176,14 +178,13 @@ describe('GetCFPackage', () => {
expect(result).toEqual({
CFDocument: expect.objectContaining({
identifier: docId,
title: 'Test Document',
CFPackageURI: expect.objectContaining({
identifier: docId
})
title: 'Test Document'
}),
CFItems: [],
CFAssociations: []
});
// CFPckgDocument has no CFPackageURI (CGE-59)
expect(result!.CFDocument).not.toHaveProperty('CFPackageURI');
});

it('should return archived document when getting by ID (archived documents always returned)', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,51 @@ describe('ImportFramework', () => {
expect(savedPkg.document.toJSON().uri).toBe('https://source.example.org/ims/case/v1p1/CFDocuments/doc-123')
})

it('accepts a package with extra/unknown fields and does not save them (CGE-59)', async () => {
await importFramework.execute({
tenantId,
caseVersion,
cfPackage: {
CFPackage: {
CFDocument: {
...cfDocument,
CFPackageURI: { title: 'Pkg', identifier: 'doc-123', uri: 'https://source.example.org/ims/case/v1p1/CFPackages/doc-123' },
somethingBogus: 'x'
},
CFItems: [{
identifier: 'item-1',
uri: '/ims/case/v1p1/CFItems/item-1',
fullStatement: 'Item',
lastChangeDateTime: '2024-01-01T00:00:00Z',
notInSpec: 42
}],
CFAssociations: [],
CFDefinitions: {
CFSubjects: [{ identifier: 's-1', uri: 'u', title: 'Math', hierarchyCode: '1', lastChangeDateTime: 'd', vendorJunk: true }],
CFMadeUp: []
},
totallyUnknownRoot: { a: 1 }
}
}
})

const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2]
const doc = savedPkg.document.toJSON()
expect(doc).not.toHaveProperty('CFPackageURI')
expect(doc).not.toHaveProperty('somethingBogus')
expect(savedPkg.items[0].toJSON()).not.toHaveProperty('notInSpec')
expect(savedPkg.definitions).toEqual({
CFSubjects: [{ identifier: 's-1', uri: 'u', title: 'Math', hierarchyCode: '1', lastChangeDateTime: 'd' }]
})
})

it('does not add a CFPackageURI when the source has none (CGE-59)', async () => {
await importFramework.execute({ tenantId, caseVersion, cfPackage: { CFDocument: cfDocument } })

const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2]
expect(savedPkg.document.toJSON()).not.toHaveProperty('CFPackageURI')
})

it('throws when neither endpointUrl nor cfPackage is provided', async () => {
await expect(
importFramework.execute({ tenantId, caseVersion })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { buildCFPackageURI, getOpenCaseExtension } from '../packageUri'

describe('buildCFPackageURI (CGE-59)', () => {
const source = 'https://standards.example.org/ims/case/v1p1/CFPackages/doc-1'

it('points a pristine mirror imported by URL at its source', () => {
expect(buildCFPackageURI('doc-1', '1.1', { isModifiedFromSource: false, sourcePackageURI: source }))
.toEqual({ title: 'CFPackage', identifier: 'doc-1', uri: source })
})

it('points a forked framework at this server', () => {
expect(buildCFPackageURI('doc-2', '1.1', { isModifiedFromSource: true, sourcePackageURI: source }).uri)
.toBe('/ims/case/v1p1/CFPackages/doc-2')
})

it('points a mirror pasted as JSON (no known source) at this server', () => {
expect(buildCFPackageURI('doc-1', '1.1', { isModifiedFromSource: false }).uri)
.toBe('/ims/case/v1p1/CFPackages/doc-1')
})

it('points a locally authored framework at this server, using the requested CASE version', () => {
expect(buildCFPackageURI('doc-1', '1.0', undefined).uri).toBe('/ims/case/v1p0/CFPackages/doc-1')
})
})

describe('getOpenCaseExtension', () => {
it('returns the ext:opencase object when present', () => {
expect(getOpenCaseExtension({ 'ext:opencase': { a: 1 } })).toEqual({ a: 1 })
})

it('returns undefined when missing or not an object', () => {
expect(getOpenCaseExtension(undefined)).toBeUndefined()
expect(getOpenCaseExtension({ 'ext:opencase': 'x' })).toBeUndefined()
})
})
30 changes: 30 additions & 0 deletions apps/opencase/src/application/case/services/packageUri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { CaseVersion, SourcedId } from '../../../domain/case/value-objects/Identifiers'
import type { LinkData } from '../../../domain/case/value-objects/LinkData'

/**
* Derive the CFPackageURI returned on a CFDocument by getCFDocument/getCFDocuments.
* It is never stored — a CFPackage's CFDocument (CFPckgDocument) has no
* CFPackageURI, so it only exists in these responses.
*
* - Pristine mirror imported by URL: points at the source it was imported from.
* - Everything else (forks, locally authored, mirrors pasted as JSON with no
* known source): points at this server's CFPackages endpoint.
*/
export function buildCFPackageURI (
docId: SourcedId,
caseVersion: CaseVersion,
openCaseExtensions: Record<string, unknown> | undefined
): LinkData {
const sourcePackageURI = openCaseExtensions?.sourcePackageURI
const isPristineMirror = openCaseExtensions?.isModifiedFromSource === false
const uri = isPristineMirror && typeof sourcePackageURI === 'string' && sourcePackageURI.trim() !== ''
? sourcePackageURI.trim()
: `${caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0'}/CFPackages/${docId}`
return { title: 'CFPackage', identifier: docId, uri }
}

/** Read the `ext:opencase` object off a CFDocument's extensions, if present. */
export function getOpenCaseExtension (extensions: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
const ext = extensions?.['ext:opencase']
return ext && typeof ext === 'object' ? ext as Record<string, unknown> : undefined
}
9 changes: 5 additions & 4 deletions apps/opencase/src/domain/case/entities/CFDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export interface CFDocumentProps {
notes?: string;
statusStartDate?: string;
statusEndDate?: string;
CFPackageURI?: LinkData;
extensions?: Record<string, unknown>;
}

Expand Down Expand Up @@ -62,11 +61,13 @@ export class CFDocument {
}

// Rebase reference URIs onto the local host — these point at per-tenant
// definition entities (licenses, packages, subjects) that OpenCASE serves
// definition entities (licenses, subjects) that OpenCASE serves
// itself, so they must resolve locally rather than to the source host.
// Mirrored frameworks skip this and keep the source's reference URIs as-is.
const licenseURI = options?.preserveUris ? raw.licenseURI : LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses')
const CFPackageURI = options?.preserveUris ? raw.CFPackageURI : LinkDataHelper.rebaseLinkData(raw.CFPackageURI, caseVersion, 'CFPackages')
// CFPackageURI is intentionally not read: it isn't part of a CFPackage's
// CFDocument (CFPckgDocument), so an incoming one is ignored rather than
// stored. getCFDocument(s) derive it at response time (see packageUri.ts).
// subjectURI must use LinkURI format (UUID identifier required)
const subjectURI = options?.preserveUris
? raw.subjectURI
Expand Down Expand Up @@ -102,7 +103,6 @@ export class CFDocument {
notes: raw.notes,
statusStartDate: raw.statusStartDate,
statusEndDate: raw.statusEndDate,
CFPackageURI,
extensions: raw.extensions
});
}
Expand All @@ -116,6 +116,7 @@ export class CFDocument {
get tenantId(): TenantId { return this.props.tenantId; }
get caseVersion(): CaseVersion { return this.props.caseVersion; }
get sourcedId(): SourcedId { return this.props.sourcedId; }
get extensions(): Record<string, unknown> | undefined { return this.props.extensions; }

toJSON(serializeAs?: CaseVersion) {
const { tenantId, caseVersion, sourcedId, licenceUri, ...rest } = this.props;
Expand Down
Loading
Loading