Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(search): Log message when missing search parameter in MockClient, add tests #4604

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion examples/medplum-demo-bots/src/hl7-bot.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Hl7Message, indexSearchParameterBundle, indexStructureDefinitionBundle } from '@medplum/core';
import { SEARCH_PARAMETER_BUNDLE_FILES, readJson } from '@medplum/definitions';
import { Bot, Bundle, Reference, SearchParameter } from '@medplum/fhirtypes';
import { MockClient } from '@medplum/mock';
import { expect, test } from 'vitest';
import { handler } from './hl7-bot';
import { SEARCH_PARAMETER_BUNDLE_FILES, readJson } from '@medplum/definitions';

//To run these tests from the command line
//npm t src/hl7-bot.test.ts
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { indexSearchParameterBundle, indexStructureDefinitionBundle } from '@medplum/core';
import { SEARCH_PARAMETER_BUNDLE_FILES, readJson } from '@medplum/definitions';
import { Bundle, SearchParameter } from '@medplum/fhirtypes';
import { MockClient } from '@medplum/mock';
import { expect, test } from 'vitest';
import { handler } from './stripe-create-invoice';
Expand All @@ -6,6 +9,11 @@ const medplum = new MockClient();
// npm t src/examples/stripe-bots/stripe-create-invoice.test.ts

test('Create Invoice', async () => {
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-types.json') as Bundle);
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-resources.json') as Bundle);
for (const filename of SEARCH_PARAMETER_BUNDLE_FILES) {
indexSearchParameterBundle(readJson(filename) as Bundle<SearchParameter>);
}
// This input is a Stripe Event object from https://stripe.com/docs/webhooks/stripe-events
const input = {
id: 'evt_1MqItaDlo6kh7lYQKFQhFJ2J',
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/resource/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('ProfilesPage', () => {
beforeAll(async () => {
const loadedProfileUrls: string[] = [];
for (const profile of [fishPatientProfile, FishPatientResources.getFishSpeciesExtensionSD()]) {
const sd = await medplum.createResourceIfNoneExist<StructureDefinition>(profile, `url:${profile.url}`);
const sd = await medplum.createResourceIfNoneExist<StructureDefinition>(profile, `url=${profile.url}`);
loadedProfileUrls.push(sd.url);
loadDataType(sd, sd.url);
}
Expand Down
24 changes: 22 additions & 2 deletions packages/core/src/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,27 @@ describe('Access', () => {
},
],
};
expect(matchesAccessPolicy(ap, { resourceType: 'Patient', meta: { compartment: [{ reference: '1' }] } }, true));
expect(matchesAccessPolicy(ap, { resourceType: 'Patient', meta: { compartment: [{ reference: '2' }] } }, false));
expect(
matchesAccessPolicy(ap, { resourceType: 'Patient', meta: { compartment: [{ reference: '1' }] } }, true)
).toEqual(true);
expect(
matchesAccessPolicy(ap, { resourceType: 'Patient', meta: { compartment: [{ reference: '2' }] } }, false)
).toEqual(false);
});

test('Invalid SearchParameter in criteria in AccessPolicy', () => {
const ap: AccessPolicy = {
resourceType: 'AccessPolicy',
resource: [
{
resourceType: 'Patient',
criteria: 'Patient?invalid=invalid',
},
],
};

expect(() =>
matchesAccessPolicy(ap, { resourceType: 'Patient', name: [{ given: ['John'], family: 'Doe' }] }, false)
).toThrow(/Unknown search parameter: invalid/);
});
});
4 changes: 2 additions & 2 deletions packages/core/src/search/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ describe('Search matching', () => {
});

test('Unknown filter', () => {
expect(
expect(() =>
matchesSearchRequest(
{ resourceType: 'Patient' },
{ resourceType: 'Patient', filters: [{ code: 'unknown', operator: Operator.EQUALS, value: 'xyz' }] }
)
).toBe(false);
).toThrow('Unknown search parameter: unknown for resource type Patient');
});

test('Boolean filter', () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/search/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export function matchesSearchRequest(resource: Resource, searchRequest: SearchRe
function matchesSearchFilter(resource: Resource, searchRequest: SearchRequest, filter: Filter): boolean {
const searchParam = globalSchema.types[searchRequest.resourceType]?.searchParams?.[filter.code];
if (!searchParam) {
return false;
throw new Error(`Unknown search parameter: ${filter.code} for resource type ${searchRequest.resourceType}`);
}
if (filter.operator === Operator.MISSING && searchParam) {
const values = evalFhirPath(searchParam.expression as string, resource);
return filter.value === 'true' ? !values.length : values.length > 0;
}
if (filter.operator === Operator.MISSING || filter.operator === Operator.PRESENT) {
return matchesMissingOrPresent(resource, filter, searchParam);
Expand All @@ -50,7 +54,7 @@ function matchesSearchFilter(resource: Resource, searchRequest: SearchRequest, f
case 'date':
return matchesDateFilter(resource, filter, searchParam);
default:
// Unknown search parameter or search parameter type
// Unknown search parameter type
// Default fail the check
return false;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/fhir-router/src/fhirrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
normalizeOperationOutcome,
notFound,
parseSearchRequest,
validateResourceType,
} from '@medplum/core';
import { OperationOutcome, Resource, ResourceType } from '@medplum/fhirtypes';
import type { IncomingHttpHeaders } from 'node:http';
Expand Down Expand Up @@ -45,6 +46,7 @@ async function batch(req: FhirRequest, repo: FhirRepository, router: FhirRouter)
// Search
async function search(req: FhirRequest, repo: FhirRepository): Promise<FhirResponse> {
const { resourceType } = req.params;
validateResourceType(resourceType);
const query = req.query as Record<string, string[] | string | undefined>;
const bundle = await repo.search(parseSearchRequest(resourceType as ResourceType, query));
return [allOk, bundle];
Expand Down
13 changes: 11 additions & 2 deletions packages/health-gorilla/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ContentType, allOk, append } from '@medplum/core';
import { RequestGroup } from '@medplum/fhirtypes';
import { ContentType, allOk, append, indexSearchParameterBundle, indexStructureDefinitionBundle } from '@medplum/core';
import { SEARCH_PARAMETER_BUNDLE_FILES, readJson } from '@medplum/definitions';
import { Bundle, RequestGroup, SearchParameter } from '@medplum/fhirtypes';
import { MockClient } from '@medplum/mock';
import {
HealthGorillaConfig,
Expand Down Expand Up @@ -29,6 +30,14 @@ const testConfig: HealthGorillaConfig = {
describe('Health Gorilla utils', () => {
const OLD_ENV = process.env;

beforeAll(() => {
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-types.json') as Bundle);
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-resources.json') as Bundle);
for (const filename of SEARCH_PARAMETER_BUNDLE_FILES) {
indexSearchParameterBundle(readJson(filename) as Bundle<SearchParameter>);
}
});

beforeEach(() => {
jest.resetModules();
process.env = { ...OLD_ENV };
Expand Down
39 changes: 39 additions & 0 deletions packages/mock/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,11 +693,50 @@ export class MockFetchClient {

const result = await this.router.handleRequest(request, this.repo);
if (result.length === 1) {
await this.logStructureDefinitionError(request.params.resourceType);
await this.logSearchParameterError(request);
return result[0];
} else {
return result[1];
}
}

private async logStructureDefinitionError(resourceType: string): Promise<void> {
const structureDefinitions = await this.repo.searchResources<StructureDefinition>({
resourceType: 'StructureDefinition',
});
const isMatch = structureDefinitions.some((sd) => sd.id === resourceType);
if (!isMatch) {
console.error(
`Unknown resource type: ${resourceType}. Please check whether it is defined in structuredefinitions.json.`
);
}
}

private async logSearchParameterError(request: FhirRequest): Promise<void> {
const {
method,
params: { resourceType },
query,
} = request;
const codes = Object.keys(query);
if (method !== 'GET' || codes.length === 0) {
return;
}
const searchParameters = await this.repo.searchResources<SearchParameter>({
resourceType: 'SearchParameter',
});
codes.forEach((code) => {
const isMatch = searchParameters.some(
(searchParameter) => searchParameter.code === code && searchParameter.base.some((r) => r === resourceType)
);
if (!isMatch) {
console.error(
`Unknown search parameter '${code}' for resource type '${resourceType}'. Please check whether it is defined in searchparameters.json.`
);
}
});
}
}

function base64Encode(str: string): string {
Expand Down
29 changes: 29 additions & 0 deletions packages/server/src/fhir/accesspolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2247,4 +2247,33 @@ describe('AccessPolicy', () => {
})
).rejects.toThrow();
}));

test.only('readResource with invalid access policy', async () =>
withTestContext(async () => {
const { repo, membership } = await createTestProject({
withRepo: true,
withClient: true,
});

const accessPolicy = await systemRepo.createResource<AccessPolicy>({
resourceType: 'AccessPolicy',
resource: [
{
resourceType: 'Patient',
criteria: `Patient?i=completed`,
readonly: true,
},
],
});

const patient = await repo.createResource<Patient>({ resourceType: 'Patient' });
await systemRepo.updateResource<ProjectMembership>({
...membership,
accessPolicy: createReference(accessPolicy),
});

await expect(
repo.readResource('Patient', patient.id as string, { allowReadFrom: ['database'] })
).rejects.toThrow();
}));
});
4 changes: 2 additions & 2 deletions packages/server/src/fhir/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ describe('FHIR Repo', () => {
}
});

test('Read invalid resource with `checkCacheOnly` set', async () => {
await expect(systemRepo.readResource('Subscription', randomUUID(), { checkCacheOnly: true })).rejects.toThrow(
test('Read invalid resource with `allowReadFrom` only including `cache`', async () => {
await expect(systemRepo.readResource('Subscription', randomUUID(), { allowReadFrom: ['cache'] })).rejects.toThrow(
new OperationOutcomeError(notFound)
);
});
Expand Down
32 changes: 19 additions & 13 deletions packages/server/src/fhir/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export interface CacheEntry<T extends Resource = Resource> {
}

export type ReadResourceOptions = {
checkCacheOnly?: boolean;
allowReadFrom?: ('cache' | 'database')[];
};

/**
Expand Down Expand Up @@ -254,21 +254,25 @@ export class Repository extends BaseRepository implements FhirRepository<PoolCli
throw new OperationOutcomeError(forbidden);
}

const cacheRecord = await getCacheEntry<T>(resourceType, id);
if (cacheRecord) {
// This is an optimization to avoid a database query.
// However, it depends on all values in the cache having "meta.compartment"
// Old versions of Medplum did not populate "meta.compartment"
// So this optimization is blocked until we add a migration.
// if (!this.canReadCacheEntry(cacheRecord)) {
// throw new OperationOutcomeError(notFound);
// }
if (this.canReadCacheEntry(cacheRecord)) {
return cacheRecord.resource;
const allowReadFrom = options?.allowReadFrom ?? (['cache', 'database'] as const);

if (allowReadFrom.includes('cache')) {
const cacheRecord = await getCacheEntry<T>(resourceType, id);
if (cacheRecord) {
// This is an optimization to avoid a database query.
// However, it depends on all values in the cache having "meta.compartment"
// Old versions of Medplum did not populate "meta.compartment"
// So this optimization is blocked until we add a migration.
// if (!this.canReadCacheEntry(cacheRecord)) {
// throw new OperationOutcomeError(notFound);
// }
if (this.canReadCacheEntry(cacheRecord)) {
return cacheRecord.resource;
}
}
}

if (options?.checkCacheOnly) {
if (!allowReadFrom.includes('database')) {
throw new OperationOutcomeError(notFound);
}

Expand Down Expand Up @@ -1156,6 +1160,7 @@ export class Repository extends BaseRepository implements FhirRepository<PoolCli

for (const policy of this.context.accessPolicy.resource) {
if (policy.resourceType === resourceType) {
console.log({ policy });
const policyCompartmentId = resolveId(policy.compartment);
if (policyCompartmentId) {
// Deprecated - to be removed
Expand All @@ -1164,6 +1169,7 @@ export class Repository extends BaseRepository implements FhirRepository<PoolCli
} else if (policy.criteria) {
// Add subquery for access policy criteria.
const searchRequest = parseSearchRequest(policy.criteria);
console.log({ searchRequest });
const accessPolicyExpression = buildSearchExpression(builder, searchRequest.resourceType, searchRequest);
if (accessPolicyExpression) {
expressions.push(accessPolicyExpression);
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/workers/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ async function tryGetSubscription(
): Promise<Subscription | undefined> {
try {
return await systemRepo.readResource<Subscription>('Subscription', subscriptionId, {
checkCacheOnly: channelType === 'websocket',
allowReadFrom: channelType === 'websocket' ? ['cache'] : ['cache', 'database'],
});
} catch (err) {
const outcome = normalizeOperationOutcome(err);
Expand Down