mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-29 10:47:56 +08:00
293 lines
9.2 KiB
TypeScript
293 lines
9.2 KiB
TypeScript
/// <reference types="cypress" />
|
|
|
|
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<AuthConfig>) {
|
|
// 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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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(/<a[^>]*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<string> {
|
|
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<void> {
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<void>;
|
|
generateSignInUrl(email: string): Chainable<string>;
|
|
simulateAuthentication(options?: { email?: string; userId?: string; expiresIn?: number }): Chainable<void>;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
});
|
|
}); |