Reference source · 0.1.0-dev.11
refresh-recovery.test.ts
tests/refresh-recovery.test.ts · Download original file
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fixture, PATIENT } from '../src/reference/fixtures.ts';
import { SCOPES } from '../src/authorization.ts';
import { Client } from '../src/sdk.ts';
import type { ClientState } from '../src/sdk.ts';
import { ReferenceHost } from '../src/host.ts';
test('SDK recovers a lost refresh response after both client and Host restart', async () => {
const dir = mkdtempSync(join(tmpdir(), 'oprp-refresh-')); const path = join(dir, 'host.sqlite'); const f = fixture(path);
let restored: ReferenceHost | undefined, state: ClientState | undefined;
try {
f.client.persistState = async snapshot => { state = structuredClone(snapshot); };
const grant = await f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 900, refresh_requested: true });
if (grant.status !== 'granted') throw new Error('Expected grant');
f.advance(301);
f.client.transport = async envelope => { f.host.handle(envelope, f.clock()); throw new Error('response_lost_after_refresh_commit'); };
await assert.rejects(f.client.readDemographics(PATIENT), /response_lost/);
assert(state?.pendingRefresh);
const rotation = f.host.db.prepare('SELECT * FROM refresh_rotations').get() as any;
assert(rotation); assert(!JSON.stringify(rotation).includes(grant.refresh_token!));
f.host.close();
restored = new ReferenceHost(path, f.hostIdentity, f.hostCredential, f.trust, f.policy, f.representatives);
const client = new Client({ identity: f.appIdentity, credential: f.appCredential, hostCredential: f.hostCredential, hostId: f.hostIdentity.public.subject, trust: f.trust, transport: async envelope => restored!.handle(envelope, f.clock()), clock: f.clock, persistState: async snapshot => { state = structuredClone(snapshot); } });
client.restoreState(state); f.advance(10);
assert.equal((await client.readDemographics(PATIENT)).resource.id, PATIENT);
assert.equal(client.authorization?.grant_id, grant.grant_id); assert.equal(client.authorization?.authorized_until, grant.authorized_until);
assert.equal(state?.pendingRefresh, undefined);
assert.equal((restored.db.prepare('SELECT count(*) AS count FROM refresh_rotations').get() as any).count, 1);
assert(!JSON.stringify(rotation).includes(client.authorization!.refresh_token!));
} finally { if (restored) restored.close(); else { try { f.host.close(); } catch {} } rmSync(dir, { recursive: true, force: true }); }
});
test('identical refresh retry preserves the successor; stale retry and new-ID reuse are distinct', async () => {
const f = fixture();
try {
const r = await f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 900, refresh_requested: true });
if (r.status !== 'granted' || !r.refresh_token) throw new Error('Expected refresh');
const a = f.host.authorization.refresh(r.refresh_token, f.app, f.clock(), 'rotation-a');
const repeated = f.host.authorization.refresh(r.refresh_token, f.app, f.clock() + 1, 'rotation-a');
if (a.status !== 'granted' || repeated.status !== 'granted' || !a.refresh_token) throw new Error('Expected refresh');
assert.equal(a.refresh_token, repeated.refresh_token); assert.equal(a.authorized_until, repeated.authorized_until);
f.host.authorization.refresh(a.refresh_token, f.app, f.clock() + 2, 'rotation-b');
assert.throws(() => f.host.authorization.refresh(r.refresh_token!, f.app, f.clock() + 3, 'rotation-a'), /refresh_superseded/);
assert.equal((await f.client.readDemographics(PATIENT)).resource.id, PATIENT);
assert.throws(() => f.host.authorization.refresh(r.refresh_token!, f.app, f.clock() + 3, 'attack-other-id'), /refresh_reuse_detected/);
await assert.rejects(f.client.readDemographics(PATIENT), /authorization_expired_or_revoked/);
} finally { f.host.close(); }
});
test('refresh recovery expires and never overrides revocation or failed durable client persistence', async () => {
const f = fixture();
try {
const r = await f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 900, refresh_requested: true });
if (r.status !== 'granted' || !r.refresh_token) throw new Error('Expected refresh');
f.advance(301); f.client.persistState = async () => { throw new Error('storage_unavailable'); };
await assert.rejects(f.client.readDemographics(PATIENT), /storage_unavailable/);
assert.equal((f.host.db.prepare('SELECT count(*) AS count FROM refresh_rotations').get() as any).count, 0);
const pendingId = f.client.pendingRefresh!.requestId;
f.client.persistState = async () => {}; await f.client.readDemographics(PATIENT);
f.advance(120);
assert.throws(() => f.host.authorization.refresh(r.refresh_token!, f.app, f.clock(), pendingId), /refresh_recovery_expired/);
assert.equal(f.host.authorization.grant(r.grant_id).active, true);
assert.equal((f.host.db.prepare('SELECT receipt FROM refresh_rotations').get() as any).receipt, null);
f.host.authorization.revoke(r.grant_id);
assert.throws(() => f.host.authorization.refresh(r.refresh_token!, f.app, f.clock(), pendingId), /authorization_expired_or_revoked/);
} finally { f.host.close(); }
});