Skip to main content
Glama
test.setup.ts11.6 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { WithId } from '@medplum/core'; import { createReference, getReferenceString, sleep } from '@medplum/core'; import type { AccessPolicy, AsyncJob, Bundle, BundleEntry, ClientApplication, Login, Project, ProjectMembership, Resource, } from '@medplum/fhirtypes'; import { randomUUID } from 'crypto'; import { setDefaultResultOrder } from 'dns'; import type { Express } from 'express'; import type Redis from 'ioredis'; import type internal from 'stream'; import request from 'supertest'; import type { ServerInviteResponse } from './admin/invite'; import { inviteUser } from './admin/invite'; import type { MedplumRedisConfig } from './config/types'; import { RequestContext } from './context'; import type { RepositoryContext } from './fhir/repo'; import { Repository, getSystemRepo } from './fhir/repo'; import { generateAccessToken } from './oauth/keys'; import { tryLogin } from './oauth/utils'; import { requestContextStore } from './request-context-store'; // supertest v7 can cause websocket tests to hang without this setDefaultResultOrder('ipv4first'); export interface TestProjectOptions { project?: Partial<Project>; accessPolicy?: Partial<AccessPolicy>; membership?: Partial<ProjectMembership>; superAdmin?: boolean; withClient?: boolean; withAccessToken?: boolean; withRepo?: boolean | Partial<RepositoryContext>; } type Exact<T, U extends T> = T & Record<Exclude<keyof U, keyof T>, never>; type StrictTestProjectOptions<T extends TestProjectOptions> = Exact<TestProjectOptions, T>; export type TestProjectResult<T extends TestProjectOptions> = { project: WithId<Project>; accessPolicy: T['accessPolicy'] extends Partial<AccessPolicy> ? WithId<AccessPolicy> : undefined; client: T['withClient'] extends true ? WithId<ClientApplication> : undefined; membership: T['withClient'] extends true ? WithId<ProjectMembership> : undefined; login: T['withAccessToken'] extends true ? WithId<Login> : undefined; accessToken: T['withAccessToken'] extends true ? string : undefined; repo: T['withRepo'] extends true | Partial<RepositoryContext> ? Repository : undefined; }; export async function createTestProject<T extends StrictTestProjectOptions<T> = TestProjectOptions>( options?: T ): Promise<TestProjectResult<T>> { const systemRepo = getSystemRepo(); const project = await systemRepo.createResource<Project>({ resourceType: 'Project', name: 'Test Project', owner: { reference: 'User/' + randomUUID(), }, strictMode: true, features: ['bots', 'email', 'graphql-introspection', 'cron'], secret: [ { name: 'foo', valueString: 'bar', }, ], superAdmin: options?.superAdmin, ...options?.project, }); let client: WithId<ClientApplication> | undefined; let accessPolicy: AccessPolicy | undefined; let membership: ProjectMembership | undefined; let login: WithId<Login> | undefined; let accessToken: string | undefined; let repo: Repository | undefined; if (options?.withClient || options?.withAccessToken || options?.withRepo) { client = await systemRepo.createResource<ClientApplication>({ resourceType: 'ClientApplication', secret: randomUUID(), redirectUris: ['https://example.com/'], meta: { project: project.id, }, name: 'Test Client Application', signInForm: { welcomeString: 'Test Welcome String', logo: { url: 'https://example.com/logo.png', }, }, }); if (options?.accessPolicy) { accessPolicy = await systemRepo.createResource<AccessPolicy>({ resourceType: 'AccessPolicy', meta: { project: project.id }, ...options.accessPolicy, }); } membership = await systemRepo.createResource<ProjectMembership>({ resourceType: 'ProjectMembership', user: createReference(client), profile: createReference(client), project: createReference(project), accessPolicy: accessPolicy ? createReference(accessPolicy) : undefined, ...options?.membership, }); if (options?.withAccessToken) { const scope = 'openid'; login = await systemRepo.createResource<Login>({ resourceType: 'Login', authMethod: 'client', user: createReference(client), client: createReference(client), membership: createReference(membership), authTime: new Date().toISOString(), scope, }); accessToken = await generateAccessToken({ login_id: login.id, sub: client.id, username: client.id, client_id: client.id, profile: client.resourceType + '/' + client.id, scope, }); } if (options?.withRepo) { const repoContext: RepositoryContext = { projects: [project], currentProject: project, author: createReference(client), superAdmin: options?.superAdmin, projectAdmin: options?.membership?.admin, accessPolicy, strictMode: project.strictMode, extendedMode: true, checkReferencesOnWrite: project.checkReferencesOnWrite, }; if (typeof options.withRepo === 'object') { Object.assign(repoContext, options.withRepo); } repo = new Repository(repoContext); } } return { project, accessPolicy, client, membership, login, accessToken, repo, } as TestProjectResult<T>; } export async function createTestClient(options?: TestProjectOptions): Promise<WithId<ClientApplication>> { return (await createTestProject({ ...options, withClient: true })).client; } export async function initTestAuth(options?: TestProjectOptions): Promise<string> { return (await createTestProject({ ...options, withAccessToken: true })).accessToken; } export async function addTestUser( project: WithId<Project>, accessPolicy?: AccessPolicy ): Promise<ServerInviteResponse & { accessToken: string }> { if (accessPolicy) { const systemRepo = getSystemRepo(); accessPolicy = await systemRepo.createResource<AccessPolicy>({ ...accessPolicy, meta: { project: project.id }, }); } const email = randomUUID() + '@example.com'; const password = randomUUID(); const inviteResponse = await inviteUser({ project, email, password, resourceType: 'Practitioner', firstName: 'Bob', lastName: 'Jones', sendEmail: false, membership: { accessPolicy: accessPolicy && createReference(accessPolicy), }, }); const { user, profile } = inviteResponse; const login = await tryLogin({ authMethod: 'password', email, password, scope: 'openid', nonce: 'nonce', }); const accessToken = await generateAccessToken({ login_id: login.id, sub: user.id, username: user.id, scope: login.scope as string, profile: getReferenceString(profile), }); return { ...inviteResponse, accessToken }; } /** * Sets up the pwnedPassword mock to handle "Have I Been Pwned" requests. * @param pwnedPassword - The pwnedPassword mock. * @param numPwns - The mock value to return. Zero is a safe password. */ export function setupPwnedPasswordMock(pwnedPassword: jest.Mock, numPwns: number): void { pwnedPassword.mockImplementation(async () => numPwns); } /** * Sets up the fetch mock to handle Recaptcha requests. * @param fetch - The fetch mock. * @param success - Whether the mock should return a successful response. */ export function setupRecaptchaMock(fetch: jest.Mock, success: boolean): void { fetch.mockImplementation(() => ({ status: 200, json: () => ({ success }), })); } /** * Returns true if the resource is in an entry in the bundle. * @param bundle - A bundle of resources. * @param resource - The resource to search for. * @returns The matching bundle entry, or undefined if not found */ export function bundleContains(bundle: Bundle, resource: Resource): BundleEntry | undefined { return bundle.entry?.find((entry) => entry.resource?.id === resource.id); } /** * Waits for a function to evaluate successfully. * Use this to wait for async behaviors without a handle. * @param fn - Function to call. */ export function waitFor(fn: () => Promise<void>): Promise<void> { return new Promise((resolve) => { const timer = setInterval(() => { fn() .then(() => { clearTimeout(timer); resolve(); }) .catch(() => { // ignore }); }, 100); }); } export async function waitForAsyncJob(contentLocation: string, app: Express, accessToken: string): Promise<AsyncJob> { for (let i = 0; i < 100; i++) { const res = await request(app) .get(new URL(contentLocation).pathname) .set('Authorization', 'Bearer ' + accessToken); if (res.status !== 202) { await sleep(500); // Buffer time to ensure that any remaining async processing has fully completed return res.body as AsyncJob; } await sleep(450); } throw new Error('Async Job did not complete'); } const DEFAULT_TEST_CONTEXT = { requestId: 'test-request-id', traceId: 'test-trace-id' }; export function withTestContext<T>(fn: () => T, ctx?: { requestId?: string; traceId?: string }): T { const defaults = ctx ?? DEFAULT_TEST_CONTEXT; const context = new RequestContext(defaults.requestId ?? '', defaults.traceId ?? ''); return requestContextStore.run(context, fn); } /** * Reads a stream into a string. * See: https://stackoverflow.com/a/49428486/2051724 * @param stream - The readable stream. * @returns The string contents. */ export function streamToString(stream: internal.Readable): Promise<string> { const chunks: Buffer[] = []; return new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); stream.on('error', (err) => reject(err)); stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); }); } export type TestRedisConfig = MedplumRedisConfig & { keyPrefix: string; }; /** * Deletes all keys from the given Redis instance that match the given prefix. This should be preferred to * `flushdb` when possible. * * @param redisInstance - The Redis instance to delete keys from. * @param prefix - The prefix to match against. * @returns The number of keys deleted. */ export async function deleteRedisKeys(redisInstance: Redis, prefix: string): Promise<number> { const stream = redisInstance.scanStream({ match: prefix + '*', count: 100, // Process 100 keys per batch }); let totalDeleted = 0; const deletePromises: Promise<number>[] = []; stream.on('data', (keys: string[]) => { if (keys.length > 0) { // ioredis does NOT include options.keyPrefix in the keys returned by `scanStream`, // so we need to remove it manually before calling del, where ioredis automatically // includes the keyPrefix in the keys passed to del const keysToDelete = redisInstance.options.keyPrefix ? keys.map((k) => (k.startsWith(prefix) ? k.replace(prefix, '') : k)) : keys; if (keysToDelete.length > 0) { deletePromises.push(redisInstance.del(keysToDelete)); } } }); await new Promise<void>((resolve, reject) => { stream.on('end', () => resolve()); stream.on('error', (err) => reject(err)); }); const deletedCounts = await Promise.all(deletePromises); totalDeleted = deletedCounts.reduce((sum, count) => sum + count, 0); return totalDeleted; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server