/// export interface AuthConfig { baseUrl: string; gotrueUrl: string; adminEmail: string; adminPassword: string; } /** * E2E test utility for authentication with GoTrue admin */ export class AuthTestUtils { private config: AuthConfig; private adminAccessToken?: string; constructor(config?: Partial) { // Use AF_GOTRUE_URL from environment if available, otherwise construct from AF_BASE_URL const baseUrl = config?.baseUrl || Cypress.env('AF_BASE_URL') || 'http://localhost:8000'; const gotrueUrl = config?.gotrueUrl || Cypress.env('AF_GOTRUE_URL') || `http://localhost:9999`; this.config = { baseUrl: baseUrl, gotrueUrl: gotrueUrl, adminEmail: config?.adminEmail || Cypress.env('GOTRUE_ADMIN_EMAIL') || 'admin@example.com', adminPassword: config?.adminPassword || Cypress.env('GOTRUE_ADMIN_PASSWORD') || 'password', }; } /** * Sign in as admin user to get access token */ signInAsAdmin(): Cypress.Chainable { if (this.adminAccessToken) { return cy.wrap(this.adminAccessToken); } // Try to sign in with existing admin account const url = `${this.config.gotrueUrl}/token?grant_type=password`; return cy.request({ method: 'POST', url: url, body: { email: this.config.adminEmail, password: this.config.adminPassword, }, headers: { 'Content-Type': 'application/json', }, failOnStatusCode: false, }).then((response) => { if (response.status === 200) { this.adminAccessToken = response.body.access_token; return this.adminAccessToken; } else { throw new Error(`Failed to sign in as admin: ${response.status} - ${JSON.stringify(response.body)}`); } }); } /** * Generate a sign-in action link for a specific email * Similar to admin_generate_link in Rust */ generateSignInActionLink(email: string): Cypress.Chainable { return this.signInAsAdmin().then((adminToken) => { return cy.request({ method: 'POST', url: `${this.config.gotrueUrl}/admin/generate_link`, headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json', }, body: { email: email, type: 'magiclink', redirect_to: Cypress.config('baseUrl'), }, }).then((response) => { if (response.status !== 200) { throw new Error(`Failed to generate action link: ${response.status}`); } return response.body.action_link; }); }); } /** * Extract sign-in URL from action link HTML * Similar to extract_sign_in_url in Rust */ extractSignInUrl(actionLink: string): Cypress.Chainable { return cy.request({ method: 'GET', url: actionLink, followRedirect: false, // Don't follow redirects automatically failOnStatusCode: false, // Don't fail on non-2xx status }).then((response) => { // Check if we got a redirect (3xx status) if (response.status >= 300 && response.status < 400) { const location = response.headers['location'] || response.headers['Location']; if (location) { // The redirect location contains the sign-in URL with tokens // It's in the format: http://localhost:9999/appflowy_web://#access_token=... // We need to extract the part after the host (appflowy_web://...) // Parse the redirect URL const redirectUrl = new URL(location, actionLink); // The path contains the actual redirect URL (appflowy_web://) const pathWithoutSlash = redirectUrl.pathname.substring(1); // Remove leading / const signInUrl = pathWithoutSlash + redirectUrl.hash; return signInUrl; } } // If no redirect, try to parse HTML for an anchor tag const html = response.body; // Use regex to extract href from the first anchor tag const hrefMatch = html.match(/]*href=["']([^"']+)["']/); if (!hrefMatch || !hrefMatch[1]) { throw new Error('Could not extract sign-in URL from action link'); } const signInUrl = hrefMatch[1]; // Decode HTML entities if present (e.g., & -> &) const decodedUrl = signInUrl.replace(/&/g, '&'); return decodedUrl; }); } /** * Generate a complete sign-in URL for a user email * Combines generateSignInActionLink and extractSignInUrl */ generateSignInUrl(email: string): Cypress.Chainable { return this.generateSignInActionLink(email).then((actionLink) => { return this.extractSignInUrl(actionLink); }); } /** * Sign in a user using the generated sign-in URL * Replicates the logic from APIService.signInWithUrl */ signInWithTestUrl(email: string): Cypress.Chainable { return this.generateSignInUrl(email).then((callbackLink) => { // Replicate signInWithUrl logic from http_api.ts // Extract hash from the callback link const hashIndex = callbackLink.indexOf('#'); if (hashIndex === -1) { throw new Error('No hash found in callback link'); } const hash = callbackLink.substring(hashIndex); const params = new URLSearchParams(hash.slice(1)); const accessToken = params.get('access_token'); const refreshToken = params.get('refresh_token'); if (!accessToken || !refreshToken) { throw new Error('No access token or refresh token found'); } // First verify the token (matching verifyToken from http_api.ts) return cy.request({ method: 'GET', url: `${this.config.baseUrl}/api/user/verify/${accessToken}`, failOnStatusCode: false, }).then((verifyResponse) => { cy.task('log', `Token verification response: ${JSON.stringify(verifyResponse)}`); if (verifyResponse.status !== 200) { throw new Error('Token verification failed'); } // Then refresh the token (matching refreshToken from gotrue.ts) return cy.request({ method: 'POST', url: `${this.config.gotrueUrl}/token?grant_type=refresh_token`, body: { refresh_token: refreshToken, }, headers: { 'Content-Type': 'application/json', }, failOnStatusCode: false, }).then((response) => { if (response.status !== 200) { throw new Error(`Failed to refresh token: ${response.status}`); } // Store the token in localStorage const tokenData = response.body; return cy.window().then((win) => { win.localStorage.setItem('token', JSON.stringify(tokenData)); // Now visit the app - we should be authenticated cy.visit('/app'); // Wait for the app to load and workspace to be created cy.wait(2000); // Reload the page to ensure workspace is loaded after verification cy.reload(); // Wait for the page to load after reload cy.wait(2000); // Verify we're logged in and on the app page cy.url().should('not.include', '/login'); cy.url().should('include', '/app'); }); }); }); }); } } /** * Cypress command to sign in a test user */ export function signInTestUser(email: string = 'test@example.com'): Cypress.Chainable { const authUtils = new AuthTestUtils(); return authUtils.signInWithTestUrl(email); } // Add custom Cypress commands declare global { namespace Cypress { interface Chainable { signIn(email?: string): Chainable; generateSignInUrl(email: string): Chainable; simulateAuthentication(options?: { email?: string; userId?: string; expiresIn?: number }): Chainable; } } } // Register the commands Cypress.Commands.add('signIn', (email: string = 'test@example.com') => { const authUtils = new AuthTestUtils(); return authUtils.signInWithTestUrl(email); }); Cypress.Commands.add('generateSignInUrl', (email: string) => { const authUtils = new AuthTestUtils(); return authUtils.generateSignInUrl(email); }); /** * Simulate an authenticated user by setting a valid token in localStorage. * This is useful for E2E tests that need to bypass the login flow. * * @param options - Configuration options * @param options.email - User email (defaults to random UUID@appflowy.io) * @param options.userId - User ID (defaults to random UUID) * @param options.expiresIn - Token expiration in seconds from now (defaults to 3600) * * @example * // Use with default values * cy.simulateAuthentication(); * * @example * // Use with custom email * cy.simulateAuthentication({ email: 'test@appflowy.io' }); * * @example * // Use in beforeEach hook for all tests * beforeEach(() => { * cy.simulateAuthentication(); * cy.visit('/app'); * }); */ Cypress.Commands.add('simulateAuthentication', (options = {}) => { const { email = `${Math.random().toString(36).substring(7)}@appflowy.io`, userId = `user-${Math.random().toString(36).substring(7)}`, expiresIn = 3600 } = options; const mockTokenData = { access_token: `mock-token-${Math.random().toString(36).substring(7)}`, refresh_token: 'mock-refresh-token', expires_at: Math.floor(Date.now() / 1000) + expiresIn, user: { id: userId, email: email, name: 'Test User' } }; cy.window().then((win) => { win.localStorage.setItem('token', JSON.stringify(mockTokenData)); }); });