A social knowledge tool for researchers built on ATProto
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});