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
26 changes: 14 additions & 12 deletions apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ export interface GetAllCFDocumentsQuery {
filter?: string
fields?: string[]
includeArchived?: boolean
/** When true, include OpenCASE-proprietary fields derived from ext:opencase (e.g. sourcePackageURI, isModifiedFromSource). Set when the request carries the X-CASE-EDITOR header. */
includeOpenCaseExtensions?: boolean
}

export class GetAllCFDocuments {
Expand Down Expand Up @@ -126,22 +124,26 @@ export class GetAllCFDocuments {
if (docMeta.subject) doc.subject = docMeta.subject
if (docMeta.version) doc.version = docMeta.version
if (docMeta.adoptionStatus) doc.adoptionStatus = docMeta.adoptionStatus
// sourcePackageURI/isModifiedFromSource are derived from the ext:opencase extension and
// are OpenCASE-proprietary — only surface them to callers that requested extensions.
if (query.includeOpenCaseExtensions) {
if (docMeta.sourcePackageURI) doc.sourcePackageURI = docMeta.sourcePackageURI
// isModifiedFromSource is set (true or false) on any mirrored/forked framework,
// even one imported without a known sourcePackageURI (e.g. pasted JSON) — so it
// must be surfaced even when false, since the frontend uses its presence to
// decide whether to show the Mirrored/Forked badge at all.
if (docMeta.isModifiedFromSource !== undefined) doc.isModifiedFromSource = docMeta.isModifiedFromSource
}
if (docMeta.archived) doc.archived = true

// CASE v1.1-only fields: only include when not serving via v1p0
if (!isV1p0) {
doc.caseVersion = caseVersion
if (docMeta.frameworkType) doc.frameworkType = docMeta.frameworkType

// sourcePackageURI/isModifiedFromSource are derived from the ext:opencase extension —
// keep them nested under `extensions` (not flattened to top-level fields) so they flow
// through the same X-CASE-EDITOR gating (stripExtensions) as every other extension.
const extOpencase: Record<string, unknown> = {}
if (docMeta.sourcePackageURI) extOpencase.sourcePackageURI = docMeta.sourcePackageURI
// isModifiedFromSource is set (true or false) on any mirrored/forked framework,
// even one imported without a known sourcePackageURI (e.g. pasted JSON) — so it
// must be surfaced even when false, since the frontend uses its presence to
// decide whether to show the Mirrored/Forked badge at all.
if (docMeta.isModifiedFromSource !== undefined) extOpencase.isModifiedFromSource = docMeta.isModifiedFromSource
if (Object.keys(extOpencase).length > 0) {
doc.extensions = { 'ext:opencase': extOpencase }
}
}

// Apply field selection if specified
Expand Down
11 changes: 5 additions & 6 deletions apps/opencase/src/application/case/endpoints/ListFrameworks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ export interface ListFrameworksQuery {
frameworkType?: string
/** When set, only alignment frameworks listing this docId as a participant are returned */
participantId?: string
/** When true, include OpenCASE-proprietary fields derived from ext:opencase (e.g. alignmentParticipants). Set when the request carries the X-CASE-EDITOR header. */
includeOpenCaseExtensions?: boolean
}

export class ListFrameworks {
Expand All @@ -30,7 +28,7 @@ export class ListFrameworks {
subject?: string
version?: string
lastChangeDateTime: string
alignmentParticipants?: Array<{ identifier?: string; uri: string }>
extensions?: { 'ext:opencase': { alignmentParticipants: Array<{ identifier?: string; uri: string }> } }
}> = []

for (const version of versions) {
Expand All @@ -52,9 +50,10 @@ export class ListFrameworks {
subject: doc.subject,
version: doc.version,
lastChangeDateTime: doc.lastChangeDateTime.toISOString(),
// alignmentParticipants is derived from the ext:opencase extension and is
// OpenCASE-proprietary — only surface it to callers that requested extensions.
...(query.includeOpenCaseExtensions && doc.alignmentParticipants ? { alignmentParticipants: doc.alignmentParticipants } : {})
// alignmentParticipants is derived from the ext:opencase extension — keep it nested
// under `extensions` (not a flat top-level field) so the caller (management controller)
// can strip it the same way the public API strips `extensions` when X-CASE-EDITOR is absent.
...(doc.alignmentParticipants ? { extensions: { 'ext:opencase': { alignmentParticipants: doc.alignmentParticipants } } } : {})
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ describe('GetAllCFDocuments', () => {
expect(result.CFDocuments).toHaveLength(2)
})

it('should omit sourcePackageURI and isModifiedFromSource when includeOpenCaseExtensions is not set', async () => {
it('should nest sourcePackageURI and isModifiedFromSource under extensions.ext:opencase for CASE 1.1', async () => {
const documents = [
{
sourcedId: 'doc-1',
Expand All @@ -286,26 +286,31 @@ describe('GetAllCFDocuments', () => {

expect(result.CFDocuments[0]).not.toHaveProperty('sourcePackageURI')
expect(result.CFDocuments[0]).not.toHaveProperty('isModifiedFromSource')
expect(result.CFDocuments[0].extensions).toEqual({
'ext:opencase': {
sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc',
isModifiedFromSource: true
}
})
})

it('should include sourcePackageURI and isModifiedFromSource when includeOpenCaseExtensions is true', async () => {
it('should omit the extensions block entirely for CASE 1.0', async () => {
const documents = [
{
sourcedId: 'doc-1',
title: 'Mirrored Document',
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'file1.json',
sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc',
isModifiedFromSource: false
isModifiedFromSource: true
}
]

mockStore.getAllDocuments.mockImplementation((_: any, v: any) => (v === '1.0' ? documents as any : []))

const result = await getAllCFDocuments.execute({ tenantId, caseVersion, includeOpenCaseExtensions: true })
const result = await getAllCFDocuments.execute({ tenantId, caseVersion: '1.0' })

expect(result.CFDocuments[0].sourcePackageURI).toBe('https://standards.example.org/ims/case/v1p1/CFPackages/abc')
expect(result.CFDocuments[0].isModifiedFromSource).toBe(false)
expect(result.CFDocuments[0]).not.toHaveProperty('extensions')
})
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ describe('ListFrameworks', () => {
expect(result.frameworks).toHaveLength(2)
})

it('should omit alignmentParticipants when includeOpenCaseExtensions is not set', async () => {
it('should not surface alignmentParticipants as a flat top-level field', async () => {
const docs: DocumentMetadata[] = [
{
sourcedId: 'doc-1',
Expand All @@ -225,7 +225,7 @@ describe('ListFrameworks', () => {
expect(result.frameworks[0]).not.toHaveProperty('alignmentParticipants')
})

it('should include alignmentParticipants when includeOpenCaseExtensions is true', async () => {
it('should nest alignmentParticipants under extensions.ext:opencase when present', async () => {
const participants = [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }]
const docs: DocumentMetadata[] = [
{
Expand All @@ -241,9 +241,30 @@ describe('ListFrameworks', () => {
.mockReturnValueOnce([]) // For 1.0
.mockReturnValueOnce(docs) // For 1.1

const result = await listFrameworks.execute({ tenantId, includeOpenCaseExtensions: true })
const result = await listFrameworks.execute({ tenantId })

expect(result.frameworks[0].extensions).toEqual({
'ext:opencase': { alignmentParticipants: participants }
})
})

it('should omit extensions entirely when there are no alignmentParticipants', async () => {
const docs: DocumentMetadata[] = [
{
sourcedId: 'doc-1',
title: 'Plain Framework',
lastChangeDateTime: new Date('2024-01-01T00:00:00Z'),
currentFile: 'frameworks/doc-1/doc-1_v0001.json'
}
]

mockStore.getAllDocuments
.mockReturnValueOnce([]) // For 1.0
.mockReturnValueOnce(docs) // For 1.1

const result = await listFrameworks.execute({ tenantId })

expect(result.frameworks[0].alignmentParticipants).toEqual(participants)
expect(result.frameworks[0]).not.toHaveProperty('extensions')
})
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@ export class CFPackagesManagementController {
const frameworkType = typeof req.query.frameworkType === 'string' ? req.query.frameworkType : undefined
const participantId = typeof req.query.participantId === 'string' ? req.query.participantId : undefined

const includeOpenCaseExtensions = req.header('X-CASE-EDITOR') !== undefined
const result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived, frameworkType, participantId, includeOpenCaseExtensions })
const result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived, frameworkType, participantId })

// OpenCASE-proprietary data (e.g. alignmentParticipants) is nested under `extensions` on
// each framework, same as the public CASE API — strip it unless the caller requested it.
if (req.header('X-CASE-EDITOR') === undefined && Array.isArray(result.frameworks)) {
result.frameworks = result.frameworks.map(({ extensions, ...rest }: any) => rest)
}

return res.status(200).json(result)
} catch (error: any) {
return res.status(400).json({ error: error.message || 'List failed' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,24 +62,41 @@ describe('CFPackagesManagementController', () => {
expect(mockListFrameworks.execute).toHaveBeenCalledWith({
tenantId: 'test-tenant',
caseVersion: '1.0',
includeArchived: false,
includeOpenCaseExtensions: false
includeArchived: false
})
expect(responseStatus).toHaveBeenCalledWith(200)
})

it('requests OpenCASE extensions when X-CASE-EDITOR header is present', async () => {
it('strips extensions from frameworks when X-CASE-EDITOR header is absent', async () => {
;(mockRequest as any).tenantId = 'test-tenant'
mockListFrameworks.execute.mockResolvedValueOnce({
frameworks: [{ sourcedId: 'doc-1', extensions: { 'ext:opencase': { alignmentParticipants: [] } } }],
total: 1,
tenantId: 'test-tenant'
} as any)

await (controller.list as any)(mockRequest as Request, mockResponse as Response, next)

const body = responseJson.mock.calls[0][0]
expect(body.frameworks[0]).not.toHaveProperty('extensions')
})

it('keeps extensions on frameworks when X-CASE-EDITOR header is present', async () => {
;(mockRequest as any).tenantId = 'test-tenant'
;(mockRequest.header as jest.Mock).mockImplementation((name: string) =>
name === 'X-CASE-EDITOR' ? 'true' : undefined
)
mockListFrameworks.execute.mockResolvedValueOnce({ frameworks: [], total: 0, tenantId: 'test-tenant' } as any)
const extensions = { 'ext:opencase': { alignmentParticipants: [] } }
mockListFrameworks.execute.mockResolvedValueOnce({
frameworks: [{ sourcedId: 'doc-1', extensions }],
total: 1,
tenantId: 'test-tenant'
} as any)

await (controller.list as any)(mockRequest as Request, mockResponse as Response, next)

expect(mockListFrameworks.execute).toHaveBeenCalledWith(
expect.objectContaining({ includeOpenCaseExtensions: true })
)
const body = responseJson.mock.calls[0][0]
expect(body.frameworks[0].extensions).toEqual(extensions)
})

it('archives a CFPackage by id (soft delete by default)', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ export class GetAllCFDocumentsControllerV1p0 {
orderBy,
filter,
fields,
includeArchived,
includeOpenCaseExtensions: wantsOpenCaseExtensions(req)
includeArchived
})

// Access control: unauthenticated requests only see public frameworks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ export class GetAllCFDocumentsControllerV1p1 {
orderBy,
filter,
fields,
includeArchived,
includeOpenCaseExtensions: wantsOpenCaseExtensions(req)
includeArchived
})

// Access control: unauthenticated requests only see public frameworks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ describe('GetAllCFDocumentsControllerV1p1', () => {
orderBy: undefined,
filter: undefined,
fields: undefined,
includeArchived: false,
includeOpenCaseExtensions: false
includeArchived: false
})
expect(responseStatus).toHaveBeenCalledWith(200)
expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost'))
Expand Down Expand Up @@ -103,14 +102,47 @@ describe('GetAllCFDocumentsControllerV1p1', () => {
orderBy: 'asc',
filter: 'test',
fields: ['title', 'identifier'],
includeArchived: false,
includeOpenCaseExtensions: false
includeArchived: false
})
expect(responseStatus).toHaveBeenCalledWith(200)
})

it('should pass includeOpenCaseExtensions=true when X-CASE-EDITOR header is present', async () => {
const result = { CFDocuments: [] }
it('should strip extensions from the response when X-CASE-EDITOR header is absent', async () => {
const result = {
CFDocuments: [
{
identifier: 'doc-123',
uri: '/ims/case/v1p1/CFDocuments/doc-123',
title: 'Mirrored Document',
lastChangeDateTime: '2024-01-01T00:00:00.000Z',
extensions: { 'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' } }
}
]
}

mockGetAllCFDocuments.execute.mockResolvedValue(result)
;(mockRequest as any).tenantId = 'test-tenant'
;(mockRequest as any).isAuthenticated = true

await controller.getAll(mockRequest as Request, mockResponse as Response)

const body = responseJson.mock.calls[0][0]
expect(body.CFDocuments[0]).not.toHaveProperty('extensions')
})

it('should keep extensions in the response when X-CASE-EDITOR header is present', async () => {
const result = {
CFDocuments: [
{
identifier: 'doc-123',
uri: '/ims/case/v1p1/CFDocuments/doc-123',
title: 'Mirrored Document',
lastChangeDateTime: '2024-01-01T00:00:00.000Z',
extensions: { 'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' } }
}
]
}

mockGetAllCFDocuments.execute.mockResolvedValue(result)
;(mockRequest as any).tenantId = 'test-tenant'
;(mockRequest as any).isAuthenticated = true
Expand All @@ -120,9 +152,10 @@ describe('GetAllCFDocumentsControllerV1p1', () => {

await controller.getAll(mockRequest as Request, mockResponse as Response)

expect(mockGetAllCFDocuments.execute).toHaveBeenCalledWith(
expect.objectContaining({ includeOpenCaseExtensions: true })
)
const body = responseJson.mock.calls[0][0]
expect(body.CFDocuments[0].extensions).toEqual({
'ext:opencase': { sourcePackageURI: 'https://example.org/ims/case/v1p1/CFPackages/abc' }
})
})

it('should return 400 for invalid limit', async () => {
Expand Down
Loading