Files
extension/packages/epic/test/server.test.ts
T
Hanzo Dev 73370ce288 feat(epic): Hanzo AI for Epic (SMART on FHIR)
Read+assist clinical copilot embedded in the EHR via SMART on FHIR. Loads the
launched patient's chart over FHIR R4 (problems, meds, allergies, labs/vitals,
notes) and runs four actions — summarize patient, draft SOAP note, extract
problems & meds, ask — through the api.hanzo.ai gateway via @hanzo/ai@^0.2.0.

PHI posture: the FHIR/PHI access token is confined server-side (server.ts);
the browser holds only an opaque HttpOnly session cookie and a Hanzo gateway
key. No PHI is ever logged; SMART is auth-code + PKCE (S256) with the
confidential secret read from the environment; the proxy is scoped to the six
read resources and the session's own FHIR base (SSRF/cross-tenant guards).
Read + assist only — no clinical write-back, no *.write scope.

Tests: 109 vitest across config/smart-oauth/fhir-client/chart/hanzo/auth/server.
Workspace-wired like every sibling: test/ dir, root pnpm-lock importer, no
package-level lock.
2026-07-04 01:45:59 -07:00

186 lines
7.1 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import {
readServerConfig,
memoryStore,
discoverSmart,
exchangeCode,
ensureFreshToken,
resolveProxyTarget,
readSid,
type ServerConfig,
} from '../src/server.js';
import type { SmartContext } from '../src/smart-oauth.js';
function res(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
const CFG: ServerConfig = {
clientId: 'my-client',
clientSecret: 'shh',
redirectUri: 'https://epic.hanzo.ai/oauth/callback',
sessionTtlMs: 3_600_000,
port: 8791,
};
const BASE = 'https://fhir.example.org/r4';
describe('readServerConfig', () => {
it('requires client id + redirect, secret optional', () => {
const cfg = readServerConfig({
SMART_CLIENT_ID: 'id',
SMART_REDIRECT_URI: 'https://x/cb',
});
expect(cfg.clientId).toBe('id');
expect(cfg.clientSecret).toBe(''); // public app
expect(cfg.port).toBe(8791);
});
it('reads the confidential secret when present', () => {
const cfg = readServerConfig({
SMART_CLIENT_ID: 'id',
SMART_REDIRECT_URI: 'https://x/cb',
SMART_CLIENT_SECRET: 's',
PORT: '9000',
SESSION_TTL_SECONDS: '600',
});
expect(cfg.clientSecret).toBe('s');
expect(cfg.port).toBe(9000);
expect(cfg.sessionTtlMs).toBe(600_000);
});
it('throws listing every missing required var', () => {
expect(() => readServerConfig({})).toThrow(/SMART_CLIENT_ID.*SMART_REDIRECT_URI/);
});
});
describe('discoverSmart', () => {
it('fetches the well-known and returns the endpoints', async () => {
const doFetch = vi.fn().mockResolvedValue(
res(200, { authorization_endpoint: `${BASE}/auth`, token_endpoint: `${BASE}/token` }),
);
const cfg = await discoverSmart(BASE, doFetch);
expect(cfg.authorizationEndpoint).toBe(`${BASE}/auth`);
expect(doFetch.mock.calls[0][0]).toBe(`${BASE}/.well-known/smart-configuration`);
});
it('rejects a non-http(s) iss (SSRF guard)', async () => {
await expect(discoverSmart('file:///etc/passwd')).rejects.toThrow(/http/);
await expect(discoverSmart('not-a-url')).rejects.toThrow(/valid URL/);
});
it('surfaces a discovery failure', async () => {
const doFetch = vi.fn().mockResolvedValue(res(404, {}));
await expect(discoverSmart(BASE, doFetch)).rejects.toThrow(/discovery 404/);
});
});
describe('exchangeCode (server-side secret + PKCE)', () => {
it('POSTs the exchange and parses the SMART context', async () => {
const doFetch = vi.fn().mockResolvedValue(
res(200, { access_token: 'AT', refresh_token: 'RT', expires_in: 3600, patient: 'P-1', scope: 's' }),
);
const pending = { state: 'st', codeVerifier: 'ver', tokenEndpoint: `${BASE}/token`, fhirBase: BASE };
const ctx = await exchangeCode(CFG, pending, 'the-code', doFetch);
expect(ctx.accessToken).toBe('AT');
expect(ctx.patient).toBe('P-1');
const [url, init] = doFetch.mock.calls[0];
expect(url).toBe(`${BASE}/token`);
const body = new URLSearchParams(init.body);
expect(body.get('code')).toBe('the-code');
expect(body.get('code_verifier')).toBe('ver');
expect(body.get('client_secret')).toBe('shh'); // confidential
});
it('surfaces an OAuth error from the token endpoint', async () => {
const doFetch = vi.fn().mockResolvedValue(res(400, { error: 'invalid_grant', error_description: 'bad code' }));
const pending = { state: 'st', codeVerifier: 'ver', tokenEndpoint: `${BASE}/token`, fhirBase: BASE };
await expect(exchangeCode(CFG, pending, 'x', doFetch)).rejects.toThrow(/bad code/);
});
});
describe('ensureFreshToken', () => {
const base = (expiresAt: number, refresh = 'RT'): SmartContext => ({
accessToken: 'OLD',
refreshToken: refresh,
tokenType: 'Bearer',
expiresAt,
patient: 'P-1',
encounter: '',
scope: 's',
idToken: '',
fhirBase: '',
});
it('returns the live token when not near expiry', async () => {
const store = memoryStore();
const sess = { ctx: base(Date.now() + 3_600_000), fhirBase: BASE, tokenEndpoint: `${BASE}/token` };
const doFetch = vi.fn();
expect(await ensureFreshToken(CFG, store, 'sid', sess, doFetch)).toBe('OLD');
expect(doFetch).not.toHaveBeenCalled();
});
it('refreshes an expired token and keeps the old refresh_token/patient', async () => {
const store = memoryStore();
store.putSession('sid', base(0), BASE, `${BASE}/token`);
const sess = store.getSession('sid')!;
const doFetch = vi.fn().mockResolvedValue(res(200, { access_token: 'NEW', expires_in: 3600 }));
const token = await ensureFreshToken(CFG, store, 'sid', sess, doFetch);
expect(token).toBe('NEW');
const updated = store.getSession('sid')!;
expect(updated.ctx.accessToken).toBe('NEW');
expect(updated.ctx.refreshToken).toBe('RT'); // preserved
expect(updated.ctx.patient).toBe('P-1'); // preserved
});
it('does not refresh when there is no refresh_token', async () => {
const store = memoryStore();
const sess = { ctx: base(0, ''), fhirBase: BASE, tokenEndpoint: `${BASE}/token` };
const doFetch = vi.fn();
expect(await ensureFreshToken(CFG, store, 'sid', sess, doFetch)).toBe('OLD');
expect(doFetch).not.toHaveBeenCalled();
});
});
describe('resolveProxyTarget (the PHI proxy guard)', () => {
it('resolves a relative allowed resource under the session base', () => {
expect(resolveProxyTarget(BASE, `Condition?patient=P-1&_count=50`)).toBe(
`${BASE}/Condition?patient=P-1&_count=50`,
);
expect(resolveProxyTarget(BASE, 'Patient/P-1')).toBe(`${BASE}/Patient/P-1`);
});
it('accepts an absolute next-page URL under the same base', () => {
const next = `${BASE}/Observation?patient=P-1&_getpages=abc`;
expect(resolveProxyTarget(BASE, next)).toBe(next);
});
it('refuses a resource type outside the read scopes', () => {
expect(() => resolveProxyTarget(BASE, 'Appointment?patient=P-1')).toThrow(/not permitted/);
expect(() => resolveProxyTarget(BASE, 'metadata')).toThrow(/not permitted/);
});
it('refuses a path that escapes the session FHIR base (SSRF / cross-tenant)', () => {
expect(() => resolveProxyTarget(BASE, 'https://evil.example.com/Condition')).toThrow(/escapes/);
expect(() => resolveProxyTarget(BASE, 'https://fhir.example.org/OTHER/Condition')).toThrow(/escapes/);
});
it('refuses a malformed path', () => {
expect(() => resolveProxyTarget(BASE, 'http://')).toThrow();
});
});
describe('session store + cookie', () => {
it('takePending is single-use (defends against state replay)', () => {
const store = memoryStore();
store.putPending({ state: 'st', codeVerifier: 'v', tokenEndpoint: 't', fhirBase: BASE });
expect(store.takePending('st')).toBeDefined();
expect(store.takePending('st')).toBeUndefined(); // replay yields nothing
});
it('readSid extracts the opaque session id from the cookie header', () => {
expect(readSid('foo=bar; epic_sid=abc123; baz=qux')).toBe('abc123');
expect(readSid('epic_sid=only')).toBe('only');
expect(readSid(undefined)).toBe('');
expect(readSid('no_session_here=1')).toBe('');
});
});