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(); }
});