import { test } from 'node:test'; import assert from 'node:assert/strict'; import { getCachedClientId, cacheClientId, invalidateCachedClient, parseOidcCallbackParams, isStaleClientError, readStoredOidcIssuer, savePendingOidcLogin, takePendingOidcLogin, } from './oidcState'; const installStorage = (): Map => { const store = new Map(); (globalThis as { localStorage?: unknown }).localStorage = { getItem: (k: string) => (store.has(k) ? store.get(k) : null), setItem: (k: string, v: string) => { store.set(k, String(v)); }, removeItem: (k: string) => { store.delete(k); }, }; return store; }; const installSessionStorage = (): Map => { const store = new Map(); (globalThis as { sessionStorage?: unknown }).sessionStorage = { getItem: (k: string) => (store.has(k) ? store.get(k) : null), setItem: (k: string, v: string) => { store.set(k, String(v)); }, removeItem: (k: string) => { store.delete(k); }, }; return store; }; test('registration cache: get / put / invalidate, scoped by issuer + redirectUri', () => { installStorage(); assert.equal(getCachedClientId('iss', 'rd'), undefined); cacheClientId('iss', 'client-1', 'rd'); assert.equal(getCachedClientId('iss', 'rd'), 'client-1'); assert.equal(getCachedClientId('iss', 'other-redirect'), undefined); // redirect mismatch = miss assert.equal(getCachedClientId('iss2', 'rd'), undefined); // different issuer = miss invalidateCachedClient('iss'); assert.equal(getCachedClientId('iss', 'rd'), undefined); invalidateCachedClient('absent'); // no-op, no throw }); test('registration cache tolerates corrupt storage', () => { const store = installStorage(); store.set('cinny_oidc_dynamic_clients', '{ not json'); assert.equal(getCachedClientId('iss', 'rd'), undefined); cacheClientId('iss', 'c', 'rd'); assert.equal(getCachedClientId('iss', 'rd'), 'c'); }); test('parseOidcCallbackParams classifies success / error / invalid', () => { assert.deepEqual(parseOidcCallbackParams('?code=abc&state=xyz'), { kind: 'success', code: 'abc', state: 'xyz', }); assert.deepEqual(parseOidcCallbackParams('?error=access_denied&error_description=nope'), { kind: 'error', error: 'access_denied', errorDescription: 'nope', state: undefined, }); assert.deepEqual(parseOidcCallbackParams('?error=bad'), { kind: 'error', error: 'bad', errorDescription: undefined, state: undefined, }); // #102 — the provider echoes back `state` on an error redirect too; the // callback needs it to look up the pending sign-in's issuer. assert.deepEqual(parseOidcCallbackParams('?error=invalid_client&state=xyz'), { kind: 'error', error: 'invalid_client', errorDescription: undefined, state: 'xyz', }); assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' }); assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' }); }); test('isStaleClientError: only client-rejection shapes invalidate the cache', () => { // OAuth error responses naming the client (oidc-client-ts ErrorResponse shape). assert.equal(isStaleClientError({ error: 'invalid_client' }), true); assert.equal(isStaleClientError({ error: 'unauthorized_client' }), true); // HTTP 400/401 from the registration/authorize step (MatrixError-style). assert.equal(isStaleClientError({ httpStatus: 400 }), true); assert.equal(isStaleClientError({ httpStatus: 401 }), true); assert.equal(isStaleClientError({ status: 401 }), true); assert.equal(isStaleClientError({ httpStatus: 500 }), false); assert.equal(isStaleClientError({ httpStatus: 400, status: 200 }), true); // httpStatus wins }); test('isStaleClientError: transient / discovery / local failures keep the cache', () => { assert.equal(isStaleClientError(new TypeError('Failed to fetch')), false); assert.equal(isStaleClientError(new Error('Something went wrong with OIDC discovery')), false); assert.equal(isStaleClientError({ error: 'server_error' }), false); assert.equal(isStaleClientError(new Error('crypto.randomUUID is not a function')), false); assert.equal(isStaleClientError(undefined), false); assert.equal(isStaleClientError('invalid_client'), false); // bare strings are not error objects }); test('isStaleClientError: also recognises the OidcCallback error-redirect shape (#102)', () => { // Same field name (`error`) as the ErrorResponse shape, so no special-casing // is needed — but pin it down since the callback now depends on this. assert.equal( isStaleClientError({ kind: 'error', error: 'invalid_client', errorDescription: undefined, state: 'xyz', }), true, ); assert.equal( isStaleClientError({ kind: 'error', error: 'access_denied', errorDescription: undefined, state: 'xyz', }), false, ); }); const pending = { issuer: 'https://issuer.example/', clientId: 'client1', deviceId: 'DEVICEABCD', codeVerifier: 'v'.repeat(96), homeserverUrl: 'https://hs.example', redirectUri: 'https://app.example/oidc/callback', }; test('pending sign-in: saved by state, read for the issuer, consumed once', () => { installSessionStorage(); assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet savePendingOidcLogin('xyz', pending); assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example/'); assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss assert.deepEqual(takePendingOidcLogin('xyz'), pending); assert.equal(takePendingOidcLogin('xyz'), undefined, 'single use'); }); test('pending sign-in tolerates corrupt or incomplete storage', () => { const store = installSessionStorage(); store.set('lotus_oidc_pending_bad', '{ not json'); assert.equal(readStoredOidcIssuer('bad'), undefined); store.set('lotus_oidc_pending_partial', JSON.stringify({ ...pending, codeVerifier: '' })); assert.equal(takePendingOidcLogin('partial'), undefined); });