Skip to content

Reference source · 0.1.0-dev.11

archive.test.ts

tests/archive.test.ts · Download original file

import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { fixture, PATIENT, OTHER_PATIENT, lab } from '../src/reference/fixtures.ts';
import { SCOPES } from '../src/authorization.ts';
import { ReferenceHost } from '../src/host.ts';
import { Client } from '../src/sdk.ts';
import { TrustStore, makeIdentity, signCredential, signStatus } from '../src/identity.ts';
import { encryptBackup, decryptBackup, verifyArchive, signArchive, ARCHIVE_LIMIT } from '../src/archive.ts';
import { b64, unb64, jsonBytes, parseObject } from '../src/core.ts';

async function approve(f: ReturnType<typeof fixture>, scopes = Object.values(SCOPES)) {
  const decision = await f.client.requestAccess({ patient: PATIENT, scopes, requested_duration_seconds: 1000, refresh_requested: true });
  if (decision.status === 'pending') { f.host.authorization.approve(f.app, decision.request_id, PATIENT, f.clock()); await f.client.approvalStatus(decision.request_id); }
}

test('full export is a separate permission and the signed archive includes only the selected record', async () => {
  const f = fixture();
  try {
    await f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 1000, refresh_requested: false });
    await assert.rejects(f.client.exportRecord(PATIENT), /insufficient_scope/);
    await approve(f); await f.client.updateDemographics(PATIENT, 'Archive Example', 1, 'archive-update'); await f.client.createLab(PATIENT, lab(), 'archive-lab');
    const archive = await f.client.exportRecord(PATIENT), body = verifyArchive(archive, f.trust, f.clock(), PATIENT);
    assert.equal(body.resources.length, 2); assert.equal(body.receipts.length, 2); assert.equal(body.retained_events.length, 2);
    assert.equal(body.resources.find(entry => entry.resource.resourceType === 'Patient')!.version, 2);
    assert.equal(body.coverage.complete_history, false);
    const serialized = JSON.stringify(body);
    assert(!serialized.includes(OTHER_PATIENT)); assert(!serialized.includes(f.client.authorization!.access_token)); assert(!serialized.includes(f.client.authorization!.refresh_token!)); assert(!serialized.includes(f.host.feed.secret.toString('base64url')));
    await assert.rejects(f.client.exportRecord(OTHER_PATIENT), /invalid_token/);
  } finally { f.host.close(); f.trust.close(); }
});

test('independent recovery works after source shutdown and credential expiry and preserves IDs, versions, unknown fields and retry receipts', async () => {
  const f = fixture(); let sourceClosed = false; let destination: ReferenceHost | undefined; const offline = new TrustStore(f.trust.policies);
  try {
    const patient = JSON.parse((f.host.db.prepare('SELECT body FROM patients WHERE id=?').get(PATIENT) as any).body);
    patient.extension = [{ url: 'https://example.org/synthetic-extension', valueString: 'preserve me' }];
    f.host.db.prepare('UPDATE patients SET body=? WHERE id=?').run(JSON.stringify(patient), PATIENT);
    await approve(f); const created = await f.client.createLab(PATIENT, lab(), 'late-lab'); const updated = await f.client.updateDemographics(PATIENT, 'Portable Patient', 1, 'late-update');
    const sealed = encryptBackup(await f.client.exportRecord(PATIENT));
    assert(!JSON.stringify(sealed.backup).includes('Portable Patient')); f.host.close(); sourceClosed = true; f.advance(90000);
    const recovered = decryptBackup(sealed.backup, sealed.recoveryKey); sealed.recoveryKey.fill(0);
    assert.equal(verifyArchive(recovered, offline, f.clock(), PATIENT).resources.length, 2);
    const identity = makeIdentity('urn:oprp:demo:destination'), credential = signCredential(f.hostingAuthority, identity.public, 'host', f.clock());
    destination = new ReferenceHost(':memory:', identity, credential, offline, f.policy);
    assert.equal(destination.importRecord(recovered, PATIENT, f.clock()).imported, true);
    assert.equal(destination.importRecord(recovered, PATIENT, f.clock()).imported, false);
    assert.equal((destination.db.prepare('SELECT count(*) AS n FROM grants').get() as any).n, 0);
    for (const authority of [f.hostingAuthority, f.applicationAuthority]) offline.ingestStatus(signStatus(authority, 10, f.clock()), f.clock());
    const client = new Client({ identity: f.appIdentity, credential: signCredential(f.applicationAuthority, f.appIdentity.public, 'application', f.clock()), hostCredential: credential, hostId: identity.public.subject, trust: offline, transport: async message => destination!.handle(message, f.clock()), clock: f.clock });
    const decision = await client.requestAccess({ patient: PATIENT, scopes: Object.values(SCOPES), requested_duration_seconds: 1000, refresh_requested: false });
    if (decision.status !== 'pending') throw new Error('Expected destination authorization');
    destination.authorization.approve(offline.validate(client.credential, f.clock()), decision.request_id, PATIENT, f.clock()); await client.approvalStatus(decision.request_id);
    const current = await client.readDemographics(PATIENT); assert.equal(current.version, 2); assert.deepEqual(current.resource.extension, patient.extension);
    assert.deepEqual(await client.createLab(PATIENT, lab(), 'late-lab'), created); assert.deepEqual(await client.updateDemographics(PATIENT, 'Portable Patient', 1, 'late-update'), updated);
    assert.equal((await client.listLabs(PATIENT)).resource.total, 1);
    await assert.rejects(client.createLab(PATIENT, { ...lab(), status: 'amended' }, 'late-lab'), /operation_id_conflict/);
    const next = await client.exportRecord(PATIENT); const nextBody = verifyArchive(next, offline, f.clock()); assert.equal(nextBody.prior_archives.length, 1); assert.equal(nextBody.receipts.length, 2);
  } finally { if (!sourceClosed) f.host.close(); destination?.close(); offline.close(); f.trust.close(); }
});

test('backup authentication and archive verification reject tampering, wrong owner, untrusted roots and known revocation', async () => {
  const f = fixture(), unknown = new TrustStore({});
  try {
    await approve(f); const archive = await f.client.exportRecord(PATIENT), sealed = encryptBackup(archive);
    assert.throws(() => decryptBackup(sealed.backup, randomBytes(32)), /invalid_backup/);
    const changed = unb64(sealed.backup.ciphertext); changed[0] ^= 1;
    assert.throws(() => decryptBackup({ ...sealed.backup, ciphertext: b64(changed) }, sealed.recoveryKey), /invalid_backup/);
    const body = parseObject(unb64(archive.payload)); body.resources[0].resource.name = [{ text: 'Altered' }];
    assert.throws(() => verifyArchive({ ...archive, payload: b64(jsonBytes(body)) }, f.trust, f.clock()), /invalid_archive_signature/);
    assert.throws(() => verifyArchive(archive, f.trust, f.clock(), OTHER_PATIENT), /wrong_archive_profile_or_patient/);
    assert.throws(() => verifyArchive(archive, unknown, f.clock()), /untrusted_issuer/);
    f.trust.revoke(f.hostingAuthority.issuer, 'signing', f.hostIdentity.public.signingKid);
    assert.throws(() => verifyArchive(archive, f.trust, f.clock()), /revoked_credential/); sealed.recoveryKey.fill(0);
  } finally { unknown.close(); f.host.close(); f.trust.close(); }
});

test('import validates membership and rolls back on conflicts instead of silently overwriting destination records', async () => {
  const f = fixture(); const destination = new ReferenceHost(':memory:', f.hostIdentity, f.hostCredential, f.trust, f.policy);
  try {
    await approve(f); await f.client.createLab(PATIENT, lab(), 'receipt'); const archive = await f.client.exportRecord(PATIENT), body = verifyArchive(archive, f.trust, f.clock());
    const { content_digest, ...withoutDigest } = structuredClone(body); withoutDigest.receipts[0].app = 'urn:oprp:demo:other-app';
    const malformed = signArchive(withoutDigest, f.hostIdentity, f.hostCredential, archive.status_evidence);
    assert.throws(() => destination.importRecord(malformed, PATIENT, f.clock()), /invalid_archive_receipt/);
    assert.equal((destination.db.prepare('SELECT count(*) AS n FROM patients').get() as any).n, 0);
    const labEntry = body.resources.find(entry => entry.resource.resourceType === 'Observation')!;
    destination.db.prepare('INSERT INTO labs VALUES (?, ?, ?)').run(labEntry.resource.id, OTHER_PATIENT, JSON.stringify(lab(OTHER_PATIENT)));
    assert.throws(() => destination.importRecord(archive, PATIENT, f.clock()));
    assert.equal((destination.db.prepare('SELECT count(*) AS n FROM patients').get() as any).n, 0);
    destination.db.prepare('DELETE FROM labs').run(); destination.seed(PATIENT, { name: [{ text: 'Already Here' }] });
    assert.throws(() => destination.importRecord(archive, PATIENT, f.clock()), /destination_record_exists/);
  } finally { destination.close(); f.host.close(); f.trust.close(); }
});

test('export fails explicitly for missing legacy receipt context, unsupported attachments and oversized records', () => {
  const f = fixture();
  try {
    f.host.db.prepare('INSERT INTO receipts VALUES (?, ?, ?)').run('legacy', 'opaque', '{}');
    assert.throws(() => f.host.exportRecord(PATIENT, f.clock()), /legacy_receipt_context_missing/); f.host.db.prepare('DELETE FROM receipts').run();
    f.host.db.prepare('UPDATE patients SET body=? WHERE id=?').run(JSON.stringify({ resourceType: 'Patient', id: PATIENT, photo: [{ attachment: { url: 'https://example.org/photo' } }] }), PATIENT);
    assert.throws(() => f.host.exportRecord(PATIENT, f.clock()), /unsupported_archive_attachment/);
    f.host.db.prepare('UPDATE patients SET body=? WHERE id=?').run(JSON.stringify({ resourceType: 'Patient', id: PATIENT, extension: [{ valueString: 'x'.repeat(ARCHIVE_LIMIT) }] }), PATIENT);
    assert.throws(() => f.host.exportRecord(PATIENT, f.clock()), /archive_too_large/);
  } finally { f.host.close(); f.trust.close(); }
});

test('write-only responses and recovered receipts cannot expose other fields of an imported Patient', async () => {
  const f = fixture();
  try {
    f.host.db.prepare('UPDATE patients SET body=? WHERE id=?').run(JSON.stringify({ resourceType: 'Patient', id: PATIENT, name: [{ text: 'Before' }], telecom: [{ system: 'phone', value: 'synthetic-private-number' }] }), PATIENT);
    await approve(f, [SCOPES.demographicsUpdate]);
    const response = await f.client.updateDemographics(PATIENT, 'Submitted', 1, 'write-only'); assert.equal(response.resource.telecom, undefined);
    const retry = await f.client.updateDemographics(PATIENT, 'Submitted', 1, 'write-only'); assert.deepEqual(retry, response);
    assert(JSON.parse((f.host.db.prepare('SELECT body FROM receipts').get() as any).body).resource.telecom);
    await assert.rejects(f.client.readDemographics(PATIENT), /insufficient_scope/);
  } finally { f.host.close(); f.trust.close(); }
});