/// 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): string => { if (response.status === 200) { this.adminAccessToken = response.body.access_token as string; return this.adminAccessToken as string; } 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 locationHeader = (response.headers['location'] || response.headers['Location']) as string | string[] | undefined; const locationStr = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader; if (locationStr) { // 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(locationStr as string, 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)); cy.visit('/app'); // Wait for the app to initialize cy.wait(2000); // Verify we're logged in and on the app page cy.url().should('not.include', '/login'); cy.url().should('include', '/app'); }).then(() => undefined); }); }); }); } } /** * 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; } } } // 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); });