A social knowledge tool for researchers built on ATProto
at development 345 lines 12 kB view raw
1import { chromium, Browser, Page } from 'playwright'; 2import express from 'express'; 3import { Server } from 'http'; 4import path from 'path'; 5import dotenv from 'dotenv'; 6import { config } from 'dotenv'; 7import { AtProtoOAuthProcessor } from '../../../atproto/infrastructure/services/AtProtoOAuthProcessor'; 8import { InitiateOAuthSignInUseCase } from '../../application/use-cases/InitiateOAuthSignInUseCase'; 9import { CompleteOAuthSignInUseCase } from '../../application/use-cases/CompleteOAuthSignInUseCase'; 10import { OAuthClientFactory } from '../../infrastructure/services/OAuthClientFactory'; 11import { InMemoryUserRepository } from '../infrastructure/InMemoryUserRepository'; 12import { 13 JwtTokenService, 14 UserAuthenticationService, 15} from '../../infrastructure'; 16import { InMemoryTokenRepository } from '../infrastructure/InMemoryTokenRepository'; 17 18// Load environment variables 19dotenv.config(); 20// Load test environment variables 21config({ path: '.env.test' }); 22 23// Get test credentials from environment 24const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE; 25const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD; 26 27if (!TEST_HANDLE || !TEST_PASSWORD) { 28 console.error( 29 'TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env.test', 30 ); 31 process.exit(1); 32} 33 34describe('OAuth Sign-In Flow', () => { 35 let browser: Browser; 36 let page: Page; 37 let server: Server; 38 const PORT = 3001; 39 const BASE_URL = `http://127.0.0.1:${PORT}`; 40 41 // This test requires manual interaction 42 // Set a longer timeout for manual testing 43 jest.setTimeout(5 * 60 * 1000); // 5 minutes 44 let oauthTokens: any = null; 45 46 beforeAll(async () => { 47 // Create OAuth client using the factory with in-memory stores 48 const oauthClient = OAuthClientFactory.createInMemoryClient( 49 `${BASE_URL}/api/user`, 50 'Annos Test App', 51 ); 52 53 // Create OAuth processor with the client 54 const oauthProcessor = new AtProtoOAuthProcessor(oauthClient); 55 56 // Create in-memory services 57 const userRepository = new InMemoryUserRepository(); 58 const tokenRepository = new InMemoryTokenRepository(); 59 const tokenService = new JwtTokenService(tokenRepository, 'test-secret'); 60 const userAuthService = new UserAuthenticationService(userRepository); 61 62 // Create use cases 63 const initiateOAuthSignInUseCase = new InitiateOAuthSignInUseCase( 64 oauthProcessor, 65 ); 66 67 const completeOAuthSignInUseCase = new CompleteOAuthSignInUseCase( 68 oauthProcessor, 69 tokenService, 70 userRepository, 71 userAuthService, 72 ); 73 74 // Start a simple express server to serve our test page 75 const app = express(); 76 app.use(express.static(path.join(__dirname, 'public'))); 77 78 // Real API endpoints using our actual implementations 79 app.get('/api/user/login', async (req, res) => { 80 const handle = req.query.handle; 81 82 const result = await initiateOAuthSignInUseCase.execute({ 83 handle: handle as string | undefined, 84 }); 85 86 if (result.isErr()) { 87 res.status(400).json({ error: result.error }); 88 } 89 90 res.json({ authUrl: result.unwrap().authUrl }); 91 }); 92 93 // Store tokens globally so we can access them in the test 94 95 app.get('/api/user/oauth/callback', async (req, res) => { 96 const { code, state, iss } = req.query; 97 98 if (!code || !state || !iss) { 99 res.status(400).json({ error: 'Missing required parameters' }); 100 return; 101 } 102 103 // Use the CompleteOAuthSignInUseCase to process the callback 104 const result = await completeOAuthSignInUseCase.execute({ 105 code: code as string, 106 state: state as string, 107 iss: iss as string, 108 }); 109 110 if (result.isErr()) { 111 res.status(400).json({ error: result.error }); 112 return; 113 } 114 115 // Store tokens for test verification 116 oauthTokens = result.unwrap(); 117 118 // Return the tokens 119 res.json({ 120 message: 'Authentication successful', 121 tokens: oauthTokens, 122 testComplete: true, 123 }); 124 }); 125 126 server = app.listen(PORT); 127 128 // Launch browser 129 browser = await chromium.launch({ headless: false }); 130 page = await browser.newPage(); 131 }); 132 133 afterAll(async () => { 134 await browser.close(); 135 server.close(); 136 }); 137 138 it('should complete the OAuth sign-in flow automatically', async () => { 139 // Navigate to our test page 140 await page.goto(`${BASE_URL}/oauth-test.html`); 141 142 // Enter the Bluesky handle from environment variables 143 await page.fill('#handle-input', TEST_HANDLE); 144 145 // Click the login button 146 await page.click('#login-button'); 147 148 // Wait for navigation to the Bluesky OAuth page 149 await page.waitForNavigation(); 150 151 // We should now be on the Bluesky login page 152 // Wait for the login form to be fully loaded 153 await page.waitForLoadState('networkidle'); 154 155 // The page is a React app, so we need to wait for elements to be available 156 // Only wait for password field since the identifier is pre-filled 157 await page.waitForSelector('input[type="password"]', { timeout: 10000 }); 158 159 // Enter password (identifier is already filled) 160 await page.fill('input[type="password"]', TEST_PASSWORD); 161 162 // Click the login/authorize button - using a more specific selector 163 // In React apps, sometimes we need to wait a bit after filling inputs 164 await page.waitForTimeout(1000); 165 166 // Find and click the submit button 167 try { 168 // Take a screenshot before clicking to help with debugging 169 await page.screenshot({ path: 'before-login-click.png' }); 170 171 // Try different possible selectors for the submit button 172 const buttonSelectors = [ 173 'button[type="submit"]', 174 'button:has-text("Sign in")', 175 'button:has-text("Continue")', 176 'button:has-text("Authorize")', 177 'button.primary', 178 'button[aria-label="Sign in"]', 179 'form button', 180 // More specific selectors based on the HTML structure 181 'div > button', 182 'form > div > button', 183 ]; 184 185 let buttonClicked = false; 186 for (const selector of buttonSelectors) { 187 const button = await page.$(selector); 188 if (button) { 189 // Check if button is visible and enabled 190 const isVisible = await button.isVisible(); 191 if (isVisible) { 192 await button.click(); 193 console.log(`Clicked button with selector: ${selector}`); 194 buttonClicked = true; 195 break; 196 } 197 } 198 } 199 200 if (!buttonClicked) { 201 console.log( 202 'No button found with standard selectors, trying JavaScript click', 203 ); 204 // Try clicking any button that looks like a submit button using JavaScript 205 await page.evaluate(() => { 206 // @ts-ignore 207 const buttons = Array.from(document.querySelectorAll('button')); 208 const loginButton = buttons.find( 209 (button: any) => 210 button.innerText.includes('Sign in') || 211 button.innerText.includes('Continue') || 212 button.innerText.includes('Log in') || 213 button.innerText.includes('Authorize'), 214 ); 215 // @ts-ignore 216 if (loginButton) loginButton.click(); 217 }); 218 } 219 } catch (error) { 220 console.error('Failed to find or click the submit button:', error); 221 // Take a screenshot to debug 222 await page.screenshot({ path: 'login-page-debug.png' }); 223 throw error; 224 } 225 226 // Wait for redirect back to our callback page 227 // Use a more robust approach to handle potential redirects 228 try { 229 // Wait for any navigation to complete 230 await page.waitForNavigation({ timeout: 1000 }).catch((e) => { 231 console.log('Initial navigation timeout, continuing...'); 232 }); 233 234 // Take a screenshot after login attempt 235 await page.screenshot({ path: 'after-login-click.png' }); 236 237 // Check if we're on the right page 238 const currentUrl = page.url(); 239 console.log(`Current page URL: ${currentUrl}`); 240 241 // If we're not on our callback page yet, we might need to authorize 242 if (!currentUrl.includes('127.0.0.1:3001')) { 243 console.log( 244 'Not yet redirected to callback, looking for authorize button...', 245 ); 246 247 // Wait a shorter time for page transitions 248 await page.waitForTimeout(500); 249 250 // Take another screenshot to see the current state 251 await page.screenshot({ path: 'authorize-page.png' }); 252 253 // Wait for the authorization page to fully load, but with shorter timeouts 254 await page.waitForLoadState('networkidle', { timeout: 5000 }); 255 await page.waitForTimeout(1000); // Give React app time to render 256 257 // Try to find and click an authorize button if present 258 const authorizeSelectors = [ 259 'button:has-text("Authorize")', 260 'button:has-text("Allow")', 261 'button:has-text("Continue")', 262 'button.authorize', 263 'button[aria-label="Authorize"]', 264 // More generic selectors 265 'button.primary', 266 'form button', 267 ]; 268 269 let buttonClicked = false; 270 for (const selector of authorizeSelectors) { 271 try { 272 const button = await page.$(selector); 273 if (button && (await button.isVisible())) { 274 await button.click(); 275 console.log( 276 `Clicked authorize button with selector: ${selector}`, 277 ); 278 buttonClicked = true; 279 await page.waitForNavigation({ timeout: 1000 }).catch((e) => { 280 console.log('Navigation after authorize click timed out'); 281 }); 282 break; 283 } 284 } catch (e) { 285 console.log(`Error with selector ${selector}:`, e); 286 } 287 } 288 289 if (!buttonClicked) { 290 console.log( 291 'No standard authorize button found, trying JavaScript click', 292 ); 293 // Try clicking any button that looks like an authorize button using JavaScript 294 await page.evaluate(() => { 295 // @ts-ignore 296 const buttons = Array.from(document.querySelectorAll('button')); 297 const authorizeButton = buttons.find( 298 (button: any) => 299 button.innerText.includes('Authorize') || 300 button.innerText.includes('Allow') || 301 button.innerText.includes('Continue'), 302 ); 303 // @ts-ignore 304 if (authorizeButton) authorizeButton.click(); 305 }); 306 307 await page.waitForNavigation({ timeout: 1000 }).catch((e) => { 308 console.log('Navigation after JS authorize click timed out'); 309 }); 310 } 311 } 312 } catch (error) { 313 console.error('Navigation error:', error); 314 // Take a screenshot to debug 315 await page.screenshot({ path: 'navigation-debug.png' }); 316 throw error; 317 } 318 319 // Wait for the test-complete element to appear, which signals the callback was processed 320 console.log('Waiting for OAuth flow to complete...'); 321 322 try { 323 // Wait for the test-complete element that's added when the callback is successful 324 await page.waitForSelector('#test-complete', { timeout: 10000 }); 325 console.log('Test complete element found, OAuth flow completed!'); 326 327 // Take a final screenshot 328 await page.screenshot({ path: 'final-callback-page.png' }); 329 330 // Close the browser early since we're done 331 await browser.close(); 332 333 // Verify that we received tokens in the global variable 334 expect(oauthTokens).toBeTruthy(); 335 expect(oauthTokens).toHaveProperty('accessToken'); 336 expect(oauthTokens).toHaveProperty('refreshToken'); 337 338 console.log('OAuth flow completed successfully!'); 339 } catch (error) { 340 console.error('Error waiting for test completion:', error); 341 await page.screenshot({ path: 'test-completion-error.png' }); 342 throw error; 343 } 344 }); 345});