import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { Cache } from '../../../src/cache/index.js' import type { Logger } from '../../../src/lib/logger.js' import type { Env } from '../../../src/config/env.js' // Track constructor calls const constructorArgs: Record[] = [] const mockJwks = { keys: [] } vi.mock('@atproto/oauth-client-node', () => { return { NodeOAuthClient: class MockNodeOAuthClient { clientMetadata: Record jwks: { keys: unknown[] } constructor(options: { clientMetadata: Record }) { constructorArgs.push(options as Record) this.clientMetadata = options.clientMetadata this.jwks = mockJwks } }, } }) // Import after mock setup const { createOAuthClient } = await import('../../../src/auth/oauth-client.js') function createMockCache() { const setFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue('OK') const getFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(null) const delFn = vi.fn<(...args: unknown[]) => Promise>().mockResolvedValue(1) return { cache: { set: setFn, get: getFn, del: delFn } as unknown as Cache, setFn, getFn, delFn, } } function createMockLogger() { const infoFn = vi.fn() return { logger: { debug: vi.fn(), info: infoFn, warn: vi.fn(), error: vi.fn(), fatal: vi.fn(), trace: vi.fn(), child: vi.fn(), } as unknown as Logger, infoFn, } } function createMockEnv(overrides: Partial = {}): Env { return { DATABASE_URL: 'postgresql://localhost/barazo', VALKEY_URL: 'redis://localhost:6379', TAP_URL: 'https://tap.example.com', TAP_ADMIN_PASSWORD: 'test-password', HOST: '0.0.0.0', PORT: 3000, LOG_LEVEL: 'info', CORS_ORIGINS: 'http://localhost:3001', COMMUNITY_MODE: 'single', COMMUNITY_NAME: 'Barazo Community', RATE_LIMIT_AUTH: 10, RATE_LIMIT_WRITE: 10, RATE_LIMIT_READ_ANON: 100, RATE_LIMIT_READ_AUTH: 300, OAUTH_CLIENT_ID: 'http://localhost', OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', SESSION_SECRET: 'a'.repeat(32), OAUTH_SESSION_TTL: 604800, OAUTH_ACCESS_TOKEN_TTL: 900, ...overrides, } as Env } /** Get the most recent constructor options */ function getLastConstructorOptions(): Record { expect(constructorArgs.length).toBeGreaterThan(0) return constructorArgs[constructorArgs.length - 1] as Record } describe('createOAuthClient', () => { let cacheMocks: ReturnType let logMocks: ReturnType beforeEach(() => { constructorArgs.length = 0 vi.clearAllMocks() cacheMocks = createMockCache() logMocks = createMockLogger() }) describe('loopback mode detection', () => { it('detects loopback mode when OAUTH_CLIENT_ID starts with http://localhost', () => { const env = createMockEnv({ OAUTH_CLIENT_ID: 'http://localhost', OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', }) createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const metadata = options.clientMetadata as { client_id: string } const clientId = metadata.client_id // Loopback client_id encodes redirect_uri and scope as query params expect(clientId).toContain('http://localhost?') expect(clientId).toContain('redirect_uri=') expect(clientId).toContain('scope=') expect(clientId).toContain(encodeURIComponent('http://127.0.0.1:3000/api/auth/callback')) expect(clientId).toContain( encodeURIComponent( 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction' ) ) }) it('uses production client_id when not starting with http://localhost', () => { const env = createMockEnv({ OAUTH_CLIENT_ID: 'https://forum.barazo.forum/oauth-client-metadata.json', OAUTH_REDIRECT_URI: 'https://forum.barazo.forum/api/auth/callback', }) createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const metadata = options.clientMetadata as { client_id: string } expect(metadata.client_id).toBe('https://forum.barazo.forum/oauth-client-metadata.json') }) }) describe('client metadata', () => { it('sets required OAuth metadata fields', () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const metadata = options.clientMetadata as { client_name: string scope: string grant_types: string[] response_types: string[] application_type: string token_endpoint_auth_method: string dpop_bound_access_tokens: boolean } expect(metadata.client_name).toBe('Barazo Forum') expect(metadata.scope).toBe( 'atproto repo:forum.barazo.topic.post repo:forum.barazo.topic.reply repo:forum.barazo.interaction.reaction repo:forum.barazo.interaction.vote' ) expect(metadata.grant_types).toEqual(['authorization_code', 'refresh_token']) expect(metadata.response_types).toEqual(['code']) expect(metadata.application_type).toBe('web') expect(metadata.token_endpoint_auth_method).toBe('none') expect(metadata.dpop_bound_access_tokens).toBe(true) }) it('includes redirect_uris from env', () => { const env = createMockEnv({ OAUTH_REDIRECT_URI: 'http://127.0.0.1:3000/api/auth/callback', }) createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const metadata = options.clientMetadata as { redirect_uris: string[] } expect(metadata.redirect_uris).toEqual(['http://127.0.0.1:3000/api/auth/callback']) }) it('derives client_uri from OAUTH_CLIENT_ID in production mode', () => { const env = createMockEnv({ OAUTH_CLIENT_ID: 'https://forum.barazo.forum/oauth-client-metadata.json', }) createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const metadata = options.clientMetadata as { client_uri: string } expect(metadata.client_uri).toBe('https://forum.barazo.forum') }) it('uses http://localhost as client_uri in loopback mode', () => { const env = createMockEnv({ OAUTH_CLIENT_ID: 'http://localhost', }) createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const metadata = options.clientMetadata as { client_uri: string } expect(metadata.client_uri).toBe('http://localhost') }) }) describe('stores and lock', () => { it('provides stateStore, sessionStore, and requestLock', () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() expect(options.stateStore).toBeDefined() expect(options.sessionStore).toBeDefined() expect(options.requestLock).toBeDefined() expect(typeof options.requestLock).toBe('function') }) }) describe('session lifecycle hooks', () => { it('passes onUpdate and onDelete hooks in constructor options', () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() expect(typeof options.onUpdate).toBe('function') expect(typeof options.onDelete).toBe('function') }) it('onUpdate hook logs session update', () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const onUpdate = options.onUpdate as (sub: string) => void onUpdate('did:plc:test123') expect(logMocks.infoFn).toHaveBeenCalledWith( { sub: 'did:plc:test123' }, 'OAuth session updated' ) }) it('onDelete hook logs session deletion with cause', () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const onDelete = options.onDelete as (sub: string, cause: unknown) => void onDelete('did:plc:test123', new Error('token_revoked')) expect(logMocks.infoFn).toHaveBeenCalledWith( { sub: 'did:plc:test123', cause: 'Error: token_revoked' }, 'OAuth session deleted' ) }) }) describe('logging', () => { it('logs creation info in loopback mode', () => { const env = createMockEnv({ OAUTH_CLIENT_ID: 'http://localhost', }) createOAuthClient(env, cacheMocks.cache, logMocks.logger) expect(logMocks.infoFn).toHaveBeenCalledWith( { loopback: true, clientId: '(loopback)' }, 'Creating OAuth client' ) }) it('logs creation info in production mode', () => { const env = createMockEnv({ OAUTH_CLIENT_ID: 'https://forum.barazo.forum/oauth-client-metadata.json', }) createOAuthClient(env, cacheMocks.cache, logMocks.logger) expect(logMocks.infoFn).toHaveBeenCalledWith( { loopback: false, clientId: 'https://forum.barazo.forum/oauth-client-metadata.json', }, 'Creating OAuth client' ) }) }) }) describe('requestLock (via createOAuthClient internals)', () => { let cacheMocks: ReturnType let logMocks: ReturnType beforeEach(() => { vi.useFakeTimers() constructorArgs.length = 0 vi.clearAllMocks() cacheMocks = createMockCache() logMocks = createMockLogger() }) afterEach(() => { vi.useRealTimers() }) it('acquires lock, executes function, and releases lock', async () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, fn: () => T | PromiseLike ) => Promise // Mock successful lock acquisition cacheMocks.setFn.mockResolvedValueOnce('OK') const result = await requestLock('test-lock', () => 'test-result') expect(result).toBe('test-result') expect(cacheMocks.setFn).toHaveBeenCalledWith( 'barazo:oauth:lock:test-lock', '1', 'EX', 10, 'NX' ) // Lock released after function execution expect(cacheMocks.delFn).toHaveBeenCalledWith('barazo:oauth:lock:test-lock') }) it('releases lock even when function throws', async () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, fn: () => T | PromiseLike ) => Promise cacheMocks.setFn.mockResolvedValueOnce('OK') await expect( requestLock('test-lock', () => { throw new Error('function error') }) ).rejects.toThrow('function error') // Lock was still released expect(cacheMocks.delFn).toHaveBeenCalledWith('barazo:oauth:lock:test-lock') }) it('retries once when lock is not acquired', async () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, fn: () => T | PromiseLike ) => Promise // First attempt fails (null = not acquired), second succeeds cacheMocks.setFn.mockResolvedValueOnce(null as unknown as 'OK').mockResolvedValueOnce('OK') const promise = requestLock('test-lock', () => 42) await vi.advanceTimersByTimeAsync(1000) const result = await promise expect(result).toBe(42) expect(cacheMocks.setFn).toHaveBeenCalledTimes(2) }) it('throws when lock cannot be acquired after retry', async () => { const env = createMockEnv() createOAuthClient(env, cacheMocks.cache, logMocks.logger) const options = getLastConstructorOptions() const requestLock = options.requestLock as ( name: string, fn: () => T | PromiseLike ) => Promise // Both attempts fail cacheMocks.setFn .mockResolvedValueOnce(null as unknown as 'OK') .mockResolvedValueOnce(null as unknown as 'OK') const promise = requestLock('test-lock', () => 'should not run') // Attach rejection handler before advancing timers to avoid unhandled rejection const expectation = expect(promise).rejects.toThrow('Could not acquire OAuth lock: test-lock') await vi.advanceTimersByTimeAsync(1000) await expectation }) })