Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
be00202
feat(ocl): add $resolveReference client (global namespace)
italomacedo Jul 15, 2026
9097c9f
feat(ocl): resolve ConceptMap source canonicals via $resolveReference
italomacedo Jul 15, 2026
aa9fadc
feat(ocl): resolve ValueSet collections and compose sources via $reso…
italomacedo Jul 15, 2026
165785d
feat(ocl): enforce org-only visibility policy
italomacedo Jul 15, 2026
6a90e6a
perf(ocl): discover sources and collections via the global listings
italomacedo Jul 15, 2026
0549620
fix(ocl): fetch source mappings directly instead of walking every con…
italomacedo Jul 15, 2026
e35f6f1
feat(ocl): batch mapping source canonicals via $resolveReference
italomacedo Jul 15, 2026
23053e7
feat(ocl): serve the released version as the default, with HEAD as |HEAD
italomacedo Jul 15, 2026
b9a2d05
test(ocl): close the coverage gaps found by auditing implementation v…
italomacedo Jul 15, 2026
0b2f351
refactor(ocl): route canonical-resolution logging through the module …
italomacedo Aug 31, 2026
26b34e4
harden($resolveReference): address review findings #2–#5
italomacedo Oct 6, 2026
faa712e
fix(ocl): silence no-control-regex in safeForLog (intentional control…
italomacedo Oct 7, 2026
6656901
feat(ocl): public-access gate + least-privilege guidance (review find…
italomacedo Oct 7, 2026
030bbf0
Merge remote-tracking branch 'fhirsmith/main' into fix/ocl-canonical-…
italomacedo Oct 7, 2026
a52c7fc
Merge remote-tracking branch 'fhirsmith/main' into fix/ocl-canonical-…
italomacedo Oct 8, 2026
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
184 changes: 184 additions & 0 deletions tests/ocl/ocl-cm-provider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ describe('OCLConceptMapProvider', () => {
];

const getMock = jest.fn().mockImplementation((url) => {
// {source}/mappings/ — one request returns every mapping the source owns.
// Must precede the '/sources/' branch below, which would otherwise swallow it.
if (url.endsWith('/mappings/') && !url.includes('/concepts/')) {
return Promise.resolve({ data: mappings });
}
// source search — resolve canonical for SourceA
if (url.includes('/sources/') && !url.includes('/concepts/')) {
return Promise.resolve({
Expand Down Expand Up @@ -417,3 +422,182 @@ describe('OCLConceptMapProvider', () => {
});
});
});

// ---------------------------------------------------------------
// $resolveReference integration
// ---------------------------------------------------------------
describe('OCLConceptMapProvider $resolveReference integration', () => {
const { OclReferenceResolver } = require('../../tx/ocl/resolve/reference-resolver');

const SEARCH_ENDPOINT = '/orgs/TestOrg/sources/';

function makeProvider({ token = 'Token abc', post } = {}) {
const provider = new OCLConceptMapProvider({ org: 'TestOrg', token });
const httpClient = {
get: jest.fn(async () => ({ data: [] })),
post: post || jest.fn(async () => ({ data: [] }))
};
// The provider captures httpClient at construction, so rebuild the resolver
// on the mock the same way the other tests swap httpClient.
provider.httpClient = httpClient;
provider.referenceResolver = new OclReferenceResolver({
httpClient,
token,
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }
});
return { provider, httpClient };
}

function resolveReferenceReply(url) {
return jest.fn(async () => ({
data: [
{
reference_type: 'canonical',
resolved: true,
request: null,
resolution_url: url,
url_registry_entry: null,
result: { type: 'Source', short_code: 'S', url, canonical_url: 'http://x.org/cs', owner_type: 'Organization', public_access: 'View' }
}
]
}));
}

function getPaths(httpClient) {
return httpClient.get.mock.calls.map(call => call[0]);
}

const searchParams = [{ name: 'source-system', value: 'http://x.org/cs' }];

it('uses the resolved repo and skips the heuristic source search', async () => {
const { provider, httpClient } = makeProvider({
post: resolveReferenceReply('/orgs/OtherOrg/sources/S/')
});

await provider.searchConceptMaps(searchParams);

expect(httpClient.post).toHaveBeenCalledTimes(1);
// The authoritative answer makes the q= text search unnecessary.
expect(getPaths(httpClient)).not.toContain(SEARCH_ENDPOINT);
expect(getPaths(httpClient)).toContain('/orgs/OtherOrg/sources/S/mappings/');
});

it('falls back to the source search when the canonical resolves to a user-owned repo', async () => {
// Org-only policy: user artifacts are experimental and not visible through
// the terminology service, so the resolver reports them as unresolved.
const post = jest.fn(async () => ({
data: [{
reference_type: 'canonical',
resolved: true,
result: { url: '/users/joe/sources/S/', owner_type: 'User', canonical_url: 'http://x.org/cs' }
}]
}));
const { provider, httpClient } = makeProvider({ post });

await provider.searchConceptMaps(searchParams);

expect(getPaths(httpClient)).not.toContain('/users/joe/sources/S/mappings/');
expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('falls back to the source search when no token is configured', async () => {
const { provider, httpClient } = makeProvider({ token: null });

await provider.searchConceptMaps(searchParams);

expect(httpClient.post).not.toHaveBeenCalled();
expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('falls back to the source search when OCL cannot resolve the canonical', async () => {
const post = jest.fn(async () => ({
data: [{ reference_type: 'canonical', resolved: false, result: null }]
}));
const { provider, httpClient } = makeProvider({ post });

await provider.searchConceptMaps(searchParams);

expect(post).toHaveBeenCalledTimes(1);
expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('falls back to the source search when $resolveReference is unavailable', async () => {
const error = new Error('Request failed with status code 404');
error.response = { status: 404 };
const { provider, httpClient } = makeProvider({ post: jest.fn().mockRejectedValue(error) });

await provider.searchConceptMaps(searchParams);

expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT);
});

it('resolves mapping source canonicals in one batch, not one GET per source', async () => {
// Flow: resolve source-system (1 ref) -> fetch {source}/mappings/ -> resolve
// the from/to source canonicals of the mappings in a single batched POST.
const post = jest.fn(async (path, body) => ({
data: body.map(ref => {
const url = typeof ref === 'string' ? ref : ref.url;
const repo = url.startsWith('/') ? url : '/orgs/TestOrg/sources/A/';
return {
reference_type: url.startsWith('/') ? 'relative' : 'canonical',
resolved: true,
result: {
url: repo,
owner_type: 'Organization',
public_access: 'View',
type: 'Source',
canonical_url: `http://canon.example.org${repo}`
}
};
})
}));
const get = jest.fn(async (url) => {
if (url.endsWith('/mappings/')) {
return {
data: [makeMapping({
from_source_url: '/orgs/TestOrg/sources/SourceA/',
to_source_url: '/orgs/TestOrg/sources/SourceB/'
})]
};
}
return { data: [] };
});
const { provider, httpClient } = makeProvider({ post });
httpClient.get = get;
provider.httpClient.get = get;

const results = await provider.searchConceptMaps(searchParams);

// POST #1 resolves the source-system; POST #2 is the batch with BOTH
// mapping source paths in one request.
expect(post).toHaveBeenCalledTimes(2);
expect(post.mock.calls[1][1]).toEqual([
'/orgs/TestOrg/sources/SourceA/',
'/orgs/TestOrg/sources/SourceB/'
]);
// No per-source detail GETs: only the mappings listing was fetched.
const detailGets = get.mock.calls.filter(c => /\/sources\/Source[AB]\/$/.test(c[0]));
expect(detailGets).toHaveLength(0);
// Aggregation used the canonicals from the batch.
expect(results).toHaveLength(1);
expect(results[0].jsonObj.group[0].source).toBe('http://canon.example.org/orgs/TestOrg/sources/SourceA/');
});

// Regression: searchConceptMaps used to lower-case every param value. The old
// text search tolerated it (#norm lower-cases anyway), but $resolveReference
// matches the canonical exactly, so a lower-cased URL never resolved.
it('sends the canonical to $resolveReference with its original casing', async () => {
const { provider, httpClient } = makeProvider({
post: resolveReferenceReply('/orgs/Mangara/sources/S/')
});

await provider.searchConceptMaps([
{ name: 'source-system', value: 'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara' }
]);

expect(httpClient.post).toHaveBeenCalledTimes(1);
expect(httpClient.post.mock.calls[0][1]).toEqual([
'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara'
]);
});
});
142 changes: 142 additions & 0 deletions tests/ocl/ocl-cs-default-version.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Default-version resolution for OCL CodeSystems: OCL's own resolution says the
// latest RELEASE is a source's default version (HEAD only when nothing is
// released), but discovery listings only ever report HEAD. With a token, the
// provider batch-resolves every canonical via $resolveReference and registers
// BOTH versions — the release as the default (what a versionless request gets)
// and HEAD as an explicit |HEAD variant.

const { OCLCodeSystemProvider } = require('../../tx/ocl/cs-ocl');
const { OclReferenceResolver } = require('../../tx/ocl/resolve/reference-resolver');

const CANONICAL = 'https://gov.br/anvisa/fhir/CodeSystem/cmed';

function cmedSource() {
return {
id: 'cmed',
short_code: 'cmed',
owner: 'ANVISA',
owner_type: 'Organization',
url: '/orgs/ANVISA/sources/cmed/',
canonical_url: CANONICAL,
version: 'HEAD',
concepts_url: '/orgs/ANVISA/sources/cmed/concepts/',
checksums: { standard: 'x' },
updated_at: '2026-01-01T00:00:00Z'
};
}

// $resolveReference reply: default version is the released 20230109.
function releaseReply() {
return {
reference_type: 'canonical',
resolved: true,
result: {
url: '/orgs/ANVISA/sources/cmed/',
owner: 'ANVISA',
owner_type: 'Organization',
public_access: 'View',
version: '20230109',
type: 'Source Version',
canonical_url: CANONICAL
}
};
}

function makeProvider({ token = 'Token x', post } = {}) {
const provider = new OCLCodeSystemProvider({ baseUrl: 'https://ocl.example.org', token });
const httpClient = {
get: jest.fn(async (url) => {
if (url === '/sources/') {
return { data: { results: [cmedSource()], num_found: 1 } };
}
return { data: [] };
}),
post: post || jest.fn(async () => ({ data: [releaseReply()] }))
};
provider.httpClient = httpClient;
provider.referenceResolver = new OclReferenceResolver({
httpClient, token, logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }
});
return { provider, httpClient };
}

describe('OCL CodeSystem default-version resolution', () => {
it('registers the release as default and keeps HEAD as an explicit variant', async () => {
const { provider, httpClient } = makeProvider();

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

// One batched $resolveReference for the discovered canonicals.
expect(httpClient.post).toHaveBeenCalledTimes(1);
expect(httpClient.post.mock.calls[0][1]).toEqual([CANONICAL]);

// The listed CodeSystem is the release, not the HEAD draft.
expect(listed).toHaveLength(1);
expect(listed[0].jsonObj.version).toBe('20230109');

// Both versions exist; the release comes FIRST so registerProvider's
// first-wins unversioned key makes it the versionless default.
expect(metas.map(m => m.version)).toEqual(['20230109', 'HEAD']);
expect(metas[0].conceptsUrl).toBe('/orgs/ANVISA/sources/cmed/20230109/concepts/');
expect(metas[1].conceptsUrl).toBe('/orgs/ANVISA/sources/cmed/concepts/');
expect(metas[0].canonicalUrl).toBe(CANONICAL);
});

it('stays HEAD-only without a token (behaviour unchanged)', async () => {
const { provider, httpClient } = makeProvider({ token: null });

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

expect(httpClient.post).not.toHaveBeenCalled();
expect(listed[0].jsonObj.version).toBe('HEAD');
expect(metas.map(m => m.version)).toEqual(['HEAD']);
});

it('stays HEAD-only when HEAD is the default (nothing released)', async () => {
const post = jest.fn(async () => ({
data: [{
reference_type: 'canonical',
resolved: true,
result: { url: '/orgs/ANVISA/sources/cmed/', owner_type: 'Organization', public_access: 'View', version: 'HEAD', type: 'Source', canonical_url: CANONICAL }
}]
}));
const { provider } = makeProvider({ post });

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

expect(listed[0].jsonObj.version).toBe('HEAD');
expect(metas.map(m => m.version)).toEqual(['HEAD']);
});

it('does not re-resolve unchanged canonicals on refresh (steady state costs nothing)', async () => {
const { provider, httpClient } = makeProvider();

await provider.listCodeSystems('5.0', null);
expect(httpClient.post).toHaveBeenCalledTimes(1);

// Minute refresh with an unchanged listing: same checksum, no new resolve.
provider.getCodeSystemChanges('5.0', null);
await new Promise(resolve => setTimeout(resolve, 150));

expect(httpClient.get.mock.calls.filter(c => c[0] === '/sources/').length).toBeGreaterThanOrEqual(2);
expect(httpClient.post).toHaveBeenCalledTimes(1);
// The release default survived the refresh.
expect(provider.getSourceMetas().map(m => m.version)).toEqual(['20230109', 'HEAD']);
});

it('keeps discovery working when $resolveReference is unavailable', async () => {
const error = new Error('Request failed with status code 404');
error.response = { status: 404 };
const { provider } = makeProvider({ post: jest.fn().mockRejectedValue(error) });

const listed = await provider.listCodeSystems('5.0', null);
const metas = provider.getSourceMetas();

// Falls back to HEAD-only — never blocks discovery.
expect(listed).toHaveLength(1);
expect(metas.map(m => m.version)).toEqual(['HEAD']);
});
});
Loading
Loading