platform-codebase/@packages/@testing/e2e-auth/src/sso-api-client.ts

224 lines
5.9 KiB
TypeScript

/**
* SSO API Client for E2E testing.
*
* Direct HTTP client for SSO API operations.
* Used by test fixtures to perform auth operations without going through the UI.
*/
export interface User {
id: string;
email: string;
username: string;
accessLevel: string;
profiles: string[];
isActive: boolean;
emailVerified: boolean;
avatar?: string;
bio?: string;
createdAt: string;
updatedAt: string;
}
export interface LoginResponse {
success: boolean;
sessionId: string;
user: User;
mfaRequired?: boolean;
pendingSessionId?: string;
}
export interface RegisterData {
email: string;
username: string;
password: string;
registrationSelection?: string;
}
interface SSOApiClientOptions {
baseUrl: string;
timeout?: number;
}
/**
* HTTP client for direct SSO API calls.
*
* This allows tests to perform auth operations without going through the UI,
* making test setup faster and more reliable.
*/
export class SSOApiClient {
private readonly baseUrl: string;
private readonly timeout: number;
constructor(options: SSOApiClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.timeout = options.timeout || 10000;
}
/**
* Login with email and password.
* Includes retry logic for rate-limited responses.
*/
async login(credentials: { email: string; password: string }): Promise<LoginResponse> {
return this.withRateLimitRetry(async () => {
const response = await this.fetch('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Login failed' })) as { message?: string };
throw new Error(`Login failed: ${error.message || response.statusText}`);
}
return response.json() as Promise<LoginResponse>;
});
}
/**
* Register a new user.
*/
async register(data: RegisterData): Promise<LoginResponse> {
return this.withRateLimitRetry(async () => {
const response = await this.fetch('/auth/register', {
method: 'POST',
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Registration failed' })) as { message?: string };
throw new Error(`Registration failed: ${error.message || response.statusText}`);
}
return response.json() as Promise<LoginResponse>;
});
}
/**
* Logout and invalidate session.
*/
async logout(sessionId: string): Promise<void> {
const response = await this.fetch('/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${sessionId}`,
},
});
// Logout may return 401 if session already expired - that's fine
if (!response.ok && response.status !== 401) {
throw new Error(`Logout failed: ${response.statusText}`);
}
}
/**
* Get current user from session.
*
* SSO returns { authenticated: true, user } or { authenticated: false }
*/
async getCurrentUser(sessionId: string): Promise<User | null> {
const response = await this.fetch('/auth/me', {
headers: {
Authorization: `Bearer ${sessionId}`,
},
});
if (response.status === 401) {
return null;
}
if (!response.ok) {
throw new Error(`Failed to get user: ${response.statusText}`);
}
const data = await response.json() as { authenticated: boolean; user: User };
if (!data.authenticated) {
return null;
}
return data.user;
}
/**
* Validate a session token.
*/
async validateSession(sessionId: string): Promise<boolean> {
const user = await this.getCurrentUser(sessionId);
return user !== null;
}
/**
* Request password reset.
* SSO always returns success to prevent email enumeration.
*/
async requestPasswordReset(email: string): Promise<void> {
await this.withRateLimitRetry(async () => {
const response = await this.fetch('/auth/password-reset/request', {
method: 'POST',
body: JSON.stringify({ email }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' })) as { message?: string };
throw new Error(`Password reset request failed: ${error.message || response.statusText}`);
}
await response.json();
});
}
/**
* Health check for SSO service.
*/
async healthCheck(): Promise<boolean> {
try {
const response = await this.fetch('/health');
return response.ok;
} catch {
return false;
}
}
/**
* Retry a request if rate limited (429), with exponential backoff.
*/
private async withRateLimitRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const isRateLimited = message.includes('Too many requests') || message.includes('429');
if (isRateLimited && attempt < maxRetries) {
// Wait with exponential backoff: 15s, 30s, 60s
const delay = 15000 * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw new Error('Rate limit retry exhausted');
}
private async fetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
return await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
} finally {
clearTimeout(timeoutId);
}
}
}