import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createOAuthClient } from '../src/index.js'; class StorageMock { values = new Map(); /** @param {string} key */ getItem(key) { return this.values.get(key) ?? null; } /** @param {string} key @param {string} value */ setItem(key, value) { this.values.set(key, String(value)); } /** @param {string} key */ removeItem(key) { this.values.delete(key); } } /** @type {ReturnType} */ let fetchMock; /** @returns {import('../src/oauth.js').OAuthClient} */ function client() { const dpop = { dpopKeyPair: vi.fn(), forgetDpopKey: vi.fn(async () => {}), sha256Base64Url: vi.fn(async () => 'challenge'), dpopProof: vi.fn(async () => 'proof'), nonceFor: vi.fn(() => undefined), rememberNonce: vi.fn(), }; return createOAuthClient({ dpop: /** @type {any} */ (dpop), resolveIdentity: async () => ({ did: 'did:plc:alice', handle: 'alice.example', pds: 'https://pds.example', }), scope: 'atproto', storagePrefix: 'oauth-client-test', }); } beforeEach(() => { vi.stubGlobal('window', { location: { origin: 'http://localhost:5173', hostname: 'localhost', assign: vi.fn(() => { throw new Error('navigation'); }), }, }); vi.stubGlobal('localStorage', new StorageMock()); vi.stubGlobal('sessionStorage', new StorageMock()); fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); }); describe('createOAuthClient', () => { it('creates a loopback client id from the requested scope', () => { const oauth = client(); expect(oauth.redirectUri()).toBe('http://localhost:5173/callback'); expect(oauth.clientId('atproto read')).toBe( 'http://localhost?redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fcallback&scope=atproto+read', ); }); it('isolates and reads its configured session storage', () => { const oauth = client(); localStorage.setItem( 'oauth-client-test:session', JSON.stringify({ did: 'did:plc:alice' }), ); localStorage.setItem( 'other-app:session', JSON.stringify({ did: 'did:plc:other' }), ); expect(oauth.currentSession()).toEqual({ did: 'did:plc:alice' }); }); it('stores the exact client id and scope used by a sign-in', async () => { fetchMock .mockResolvedValueOnce( new Response( JSON.stringify({ authorization_servers: ['https://auth.example'] }), { headers: { 'content-type': 'application/json' } }, ), ) .mockResolvedValueOnce( new Response( JSON.stringify({ issuer: 'https://auth.example', pushed_authorization_request_endpoint: 'https://auth.example/oauth/par', authorization_endpoint: 'https://auth.example/oauth/authorize', token_endpoint: 'https://auth.example/oauth/token', }), { headers: { 'content-type': 'application/json' } }, ), ) .mockResolvedValueOnce( new Response(JSON.stringify({ request_uri: 'request-1' }), { headers: { 'content-type': 'application/json' }, }), ); const oauth = client(); await expect( oauth.beginSignIn('alice.example', { scope: 'atproto read', returnTo: '/records', }), ).rejects.toThrow('navigation'); const pending = JSON.parse( sessionStorage.getItem('oauth-client-test:pending') || '', ); expect(pending.scope).toBe('atproto read'); expect(pending.clientId).toBe(oauth.clientId('atproto read')); expect(pending.returnTo).toBe('/records'); }); });