Skip to content

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