diff --git a/apps/opencase/src/application/case/endpoints/ImportFramework.ts b/apps/opencase/src/application/case/endpoints/ImportFramework.ts index d789139..c8b7a81 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 { deriveSourcePackageURI } from '../services/packageUri' import { coerceMistypedStrings } from '../services/coerceMistypedStrings' import type { ImportDocumentFlags } from '../types/ImportDocumentFlags' @@ -38,8 +39,8 @@ export interface ImportFrameworkResult { * Create the `ext:opencase` extension on a CFDocument payload, marking it as a * pristine mirror of the source. The payload must already have had any incoming * `ext:opencase` discarded (see execute) — OpenCASE's own state is never taken - * from an import source. `endpointUrl` is only known when the framework was - * imported by URL (as opposed to a pasted JSON payload). + * from an import source. `endpointUrl` is the import URL, or for pasted JSON + * whatever source could be derived from the document (may be unknown). */ function injectSourceProvenance ( docPayload: any, @@ -49,9 +50,9 @@ function injectSourceProvenance ( const existing = docPayload.extensions ?? {} const extPatch: Record = { - // Only record a source URL when one is known — pasted JSON has no - // publisher endpoint, so the field is left off entirely rather than - // written as undefined. + // Only record a source URL when one is known — pasted JSON whose source + // can't be derived has none, so the field is left off entirely rather + // than written as undefined. ...(endpointUrl ? { sourcePackageURI: endpointUrl } : {}), isModifiedFromSource: false, importedAt: new Date().toISOString(), @@ -111,7 +112,8 @@ export class ImportFramework { } else { logger.info({ tenantId, caseVersion }, 'Importing framework from provided JSON') const normalized = stripOpenCaseExtensions(normalizeCfPackageData(cfPackage)) - const provenanceUri = (sourcePackageUri ?? endpointUrl ?? '').trim() + // With no import endpoint, fall back to the source the document itself points at. + const provenanceUri = (sourcePackageUri ?? endpointUrl ?? deriveSourcePackageURI(normalized.CFDocument) ?? '').trim() sourceCFPackage = { ...normalized, CFDocument: injectSourceProvenance(normalized.CFDocument, provenanceUri || undefined, documentFlags) 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 7af884c..d65611d 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ImportFramework.test.ts @@ -108,6 +108,21 @@ describe('ImportFramework', () => { expect(mockRepository.saveNewVersion).toHaveBeenCalledTimes(1) }) + it('derives the source CFPackage URL for pasted JSON from the document uri (CGE-45)', async () => { + await importFramework.execute({ + tenantId, + caseVersion, + cfPackage: { + CFPackage: { CFDocument: { ...cfDocument, uri: 'https://source.example.org/ims/case/v1p1/CFDocuments/doc-123' } } + } + }) + + const savedPkg = mockRepository.saveNewVersion.mock.calls[0][2] + const savedExtensions = savedPkg.document.toJSON().extensions + expect(savedExtensions['ext:opencase'].sourcePackageURI).toBe('https://source.example.org/ims/case/v1p1/CFPackages/doc-123') + expect(savedExtensions['ext:opencase'].isModifiedFromSource).toBe(false) + }) + it('marks pasted JSON as a pristine mirror without a sourcePackageURI (no known source URL)', async () => { await importFramework.execute({ tenantId, diff --git a/apps/opencase/src/application/case/services/__tests__/packageUri.test.ts b/apps/opencase/src/application/case/services/__tests__/packageUri.test.ts index 934aa08..83eed15 100644 --- a/apps/opencase/src/application/case/services/__tests__/packageUri.test.ts +++ b/apps/opencase/src/application/case/services/__tests__/packageUri.test.ts @@ -1,4 +1,4 @@ -import { buildCFPackageURI, getOpenCaseExtension } from '../packageUri' +import { buildCFPackageURI, deriveSourcePackageURI, getOpenCaseExtension } from '../packageUri' describe('buildCFPackageURI (CGE-59)', () => { const source = 'https://standards.example.org/ims/case/v1p1/CFPackages/doc-1' @@ -23,6 +23,29 @@ describe('buildCFPackageURI (CGE-59)', () => { }) }) +describe('deriveSourcePackageURI (CGE-45)', () => { + it('uses the CFPackageURI the publisher declared on the CFDocument', () => { + expect(deriveSourcePackageURI({ + uri: 'https://a.example.org/ims/case/v1p1/CFDocuments/doc-1', + CFPackageURI: { uri: 'https://b.example.org/ims/case/v1p1/CFPackages/doc-1' } + })).toBe('https://b.example.org/ims/case/v1p1/CFPackages/doc-1') + }) + + it('derives the CFPackages URL from a CASE API CFDocument uri, keeping any path prefix', () => { + expect(deriveSourcePackageURI({ uri: 'https://case.example.org/ims/case/v1p1/CFDocuments/doc-1' })) + .toBe('https://case.example.org/ims/case/v1p1/CFPackages/doc-1') + expect(deriveSourcePackageURI({ uri: 'https://example.org/api/ims/case/v1p0/CFDocuments/doc-1' })) + .toBe('https://example.org/api/ims/case/v1p0/CFPackages/doc-1') + }) + + it('returns undefined when no source can be determined', () => { + expect(deriveSourcePackageURI({ uri: '/ims/case/v1p1/CFDocuments/doc-1' })).toBeUndefined() + expect(deriveSourcePackageURI({ uri: 'https://example.org/uri/doc-1' })).toBeUndefined() + expect(deriveSourcePackageURI({ uri: 'urn:case:doc-1', CFPackageURI: { uri: 'urn:case:package:doc-1' } })).toBeUndefined() + expect(deriveSourcePackageURI(undefined)).toBeUndefined() + }) +}) + describe('getOpenCaseExtension', () => { it('returns the ext:opencase object when present', () => { expect(getOpenCaseExtension({ 'ext:opencase': { a: 1 } })).toEqual({ a: 1 }) diff --git a/apps/opencase/src/application/case/services/packageUri.ts b/apps/opencase/src/application/case/services/packageUri.ts index 1baf9cf..fcad55d 100644 --- a/apps/opencase/src/application/case/services/packageUri.ts +++ b/apps/opencase/src/application/case/services/packageUri.ts @@ -6,9 +6,10 @@ import type { LinkData } from '../../../domain/case/value-objects/LinkData' * 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. + * - Pristine mirror with a known source (imported by URL, or pasted as JSON + * whose source could be derived — see deriveSourcePackageURI): points at that source. * - Everything else (forks, locally authored, mirrors pasted as JSON with no - * known source): points at this server's CFPackages endpoint. + * derivable source): points at this server's CFPackages endpoint. */ export function buildCFPackageURI ( docId: SourcedId, @@ -23,6 +24,26 @@ export function buildCFPackageURI ( return { title: 'CFPackage', identifier: docId, uri } } +/** + * Work out the publisher's CFPackage URL for a framework pasted/uploaded as JSON, + * where there's no import endpoint to record. Uses the CFDocument's own + * CFPackageURI if the publisher included one, otherwise derives it from a + * CASE-API-shaped CFDocument uri (`…/ims/case/v1pX/CFDocuments/{id}` → + * `…/ims/case/v1pX/CFPackages/{id}`). Only absolute http(s) URLs are accepted; + * returns undefined when no source can be determined. + */ +export function deriveSourcePackageURI (cfDocument: any): string | undefined { + const isAbsoluteHttp = (v: unknown): v is string => typeof v === 'string' && /^https?:\/\//i.test(v.trim()) + + const declared = cfDocument?.CFPackageURI?.uri + if (isAbsoluteHttp(declared)) return declared.trim() + + const docUri = cfDocument?.uri + if (!isAbsoluteHttp(docUri)) return undefined + const match = docUri.trim().match(/^(.*\/ims\/case\/v1p[01])\/CFDocuments\/([^/?#]+)\/?$/i) + return match ? `${match[1]}/CFPackages/${match[2]}` : undefined +} + /** Read the `ext:opencase` object off a CFDocument's extensions, if present. */ export function getOpenCaseExtension (extensions: Record | undefined): Record | undefined { const ext = extensions?.['ext:opencase']