import { afterEach, describe, expect, test } from 'bun:test'; import { OAuthCallbackServer } from '../server/callback.js'; describe('OAuthCallbackServer', () => { let server: OAuthCallbackServer; let testPort = 9876; const testHostname = 'localhost'; const startServer = async () => { if (server) { await server.stop(); } // Use a new port for each test to avoid port conflicts testPort++; server = new OAuthCallbackServer(); return server.start({ port: testPort, hostname: testHostname, }); }; afterEach(async () => { if (server) { try { await server.stop(); } catch (error) { // Ignore "Server stopped" errors from rejected promises if ( error instanceof Error && !error.message.includes('Server stopped') ) { throw error; } } } }); describe('server lifecycle', () => { test('should start server successfully', async () => { await startServer(); // Verify server is running by making a request const response = await fetch( `http://${testHostname}:${testPort}/unknown` ); expect(response.status).toBe(404); }); test('should stop server successfully', async () => { await startServer(); expect(server.isRunning()).toBe(true); await server.stop(); expect(server.isRunning()).toBe(false); }); test('should throw error when starting already running server', async () => { await startServer(); await expect( server.start({ port: testPort + 1, hostname: testHostname, }) ).rejects.toThrow('Server is already running'); }); test('should handle abort signal during start', async () => { const abortController = new AbortController(); abortController.abort(); await expect( server.start({ port: testPort, hostname: testHostname, signal: abortController.signal, }) ).rejects.toThrow('Operation aborted'); }); test('should stop server when abort signal is triggered', async () => { const abortController = new AbortController(); await server.start({ port: testPort, hostname: testHostname, signal: abortController.signal, }); expect(server.isRunning()).toBe(true); // Trigger abort abortController.abort(); // Give it a moment to cleanup await new Promise(resolve => setTimeout(resolve, 100)); expect(server.isRunning()).toBe(false); }); }); describe('callback handling', () => { test('should handle successful OAuth callback', async () => { await startServer(); const callbackPath = '/callback/success'; const expectedCode = 'test-auth-code-12345'; const expectedState = 'test-state-67890'; // Start waiting for callback first const callbackPromise = server.waitForCallback(callbackPath, 5000); // Delay to ensure listener registration completes await new Promise(resolve => setTimeout(resolve, 10)); // Make the HTTP request const fetchPromise = fetch( `http://${testHostname}:${testPort}${callbackPath}?code=${expectedCode}&state=${expectedState}` ); const [result, response] = await Promise.all([ callbackPromise, fetchPromise, ]); // Should get success page expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('Authorization Successful'); // Should resolve with callback data expect(result).toMatchObject({ code: expectedCode, state: expectedState, }); }); test('should handle OAuth error callback', async () => { await startServer(); const callbackPath = '/callback/error'; const expectedError = 'access_denied'; const expectedDescription = 'User denied authorization'; const callbackPromise = server.waitForCallback(callbackPath, 5000); await new Promise(resolve => setTimeout(resolve, 10)); const fetchPromise = fetch( `http://${testHostname}:${testPort}${callbackPath}?error=${expectedError}&error_description=${encodeURIComponent(expectedDescription)}` ); const [result, response] = await Promise.all([ callbackPromise, fetchPromise, ]); expect(response.status).toBe(400); const html = await response.text(); expect(html).toContain('Authorization Failed'); expect(result).toMatchObject({ error: expectedError, error_description: expectedDescription, }); }); test('should timeout when callback takes too long', async () => { await startServer(); const callbackPath = '/callback/timeout'; // Wait with very short timeout await expect(server.waitForCallback(callbackPath, 100)).rejects.toThrow( 'OAuth callback timeout' ); }); test('should handle multiple callback parameters', async () => { await startServer(); const callbackPath = '/oauth/callback'; const params = { code: 'auth-code', state: 'state-value', scope: 'openid profile email', session_state: 'session-123', }; const queryString = new URLSearchParams(params).toString(); const callbackPromise = server.waitForCallback(callbackPath, 5000); await new Promise(resolve => setTimeout(resolve, 10)); const fetchPromise = fetch( `http://${testHostname}:${testPort}${callbackPath}?${queryString}` ); const [result] = await Promise.all([callbackPromise, fetchPromise]); expect(result).toMatchObject(params); }); test('should return 404 for unknown paths', async () => { await startServer(); const response = await fetch( `http://${testHostname}:${testPort}/unknown-path` ); expect(response.status).toBe(404); const text = await response.text(); expect(text).toBe('Not Found'); }); test('should cleanup listeners after successful callback', async () => { await startServer(); const callbackPath = '/callback/cleanup'; // First callback await Promise.all([ server.waitForCallback(callbackPath, 5000), fetch( `http://${testHostname}:${testPort}${callbackPath}?code=code1&state=state1` ), ]); // Second callback to same path should work const [result2] = await Promise.all([ server.waitForCallback(callbackPath, 5000), fetch( `http://${testHostname}:${testPort}${callbackPath}?code=code2&state=state2` ), ]); expect(result2.code).toBe('code2'); }); test('should use custom success HTML template', async () => { await startServer(); const customServer = new OAuthCallbackServer(); await customServer.start({ hostname: testHostname, port: testPort + 1, successHtml: 'Custom Success!', }); const callbackPath = '/callback/custom-success'; const [, response] = await Promise.all([ customServer.waitForCallback(callbackPath, 5000), fetch( `http://${testHostname}:${testPort + 1}${callbackPath}?code=test` ), ]); const html = await response.text(); expect(html).toContain('Custom Success!'); await customServer.stop(); }); test('should use custom error HTML template', async () => { await startServer(); const customServer = new OAuthCallbackServer(); await customServer.start({ hostname: testHostname, port: testPort + 2, errorHtml: 'Custom Error!', }); const callbackPath = '/callback/custom-error'; const [, response] = await Promise.all([ customServer.waitForCallback(callbackPath, 5000), fetch( `http://${testHostname}:${testPort + 2}${callbackPath}?error=test` ), ]); const html = await response.text(); expect(html).toContain('Custom Error!'); await customServer.stop(); }); test('should call onRequest callback', async () => { await startServer(); await server.stop(); const requests: Request[] = []; // Use a new port for this second server instance testPort++; await server.start({ port: testPort, hostname: testHostname, onRequest: req => requests.push(req), }); await fetch(`http://${testHostname}:${testPort}/test`); expect(requests).toHaveLength(1); expect(requests[0]).toBeInstanceOf(Request); }); }); describe('edge cases', () => { test('should handle callback with no query parameters', async () => { await startServer(); const callbackPath = '/callback/no-params'; // Start waiting for callback first to ensure listener is registered const callbackPromise = server.waitForCallback(callbackPath, 5000); // Small delay to ensure listener registration completes await new Promise(resolve => setImmediate(resolve)); // Now make the HTTP request const fetchPromise = fetch( `http://${testHostname}:${testPort}${callbackPath}` ); const [result] = await Promise.all([callbackPromise, fetchPromise]); expect(result).toEqual({}); }); test('should handle special characters in parameters', async () => { await startServer(); const callbackPath = '/callback/special-chars'; const specialState = 'state-with-special-chars_123'; const callbackPromise = server.waitForCallback(callbackPath, 5000); await new Promise(resolve => setTimeout(resolve, 10)); const fetchPromise = fetch( `http://${testHostname}:${testPort}${callbackPath}?code=test&state=${encodeURIComponent(specialState)}` ); const [result] = await Promise.all([callbackPromise, fetchPromise]); expect(result.state).toBe(specialState); }); test('should handle concurrent callbacks to different paths', async () => { await startServer(); const path1 = '/callback1'; const path2 = '/callback2'; // Register both listeners first const promise1 = server.waitForCallback(path1, 5000); const promise2 = server.waitForCallback(path2, 5000); // Use a longer delay to ensure both listeners are registered await new Promise(resolve => setTimeout(resolve, 10)); // Now make both HTTP requests const fetch1 = fetch( `http://${testHostname}:${testPort}${path1}?code=code1&state=state1` ); const fetch2 = fetch( `http://${testHostname}:${testPort}${path2}?code=code2&state=state2` ); const [result1, result2] = await Promise.all([ promise1, promise2, fetch1, fetch2, ]); expect(result1.code).toBe('code1'); expect(result2.code).toBe('code2'); }); }); });