LibreChat/e2e/specs/mock/auth.spec.ts
Danny Avila 753e53eddd
🛬 fix: Coalesce Auth Recovery into a Single Refresh Flight (#13618)
* fix auth recovery singleflight

* add auth recovery e2e coverage

* handle invalid auth redirect timestamp
2026-06-09 12:04:12 -04:00

181 lines
5.4 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import type { User } from '../../types';
import { getSecondaryE2EUser } from '../../setup/users.mock';
import cleanupUser from '../../setup/cleanupUser';
import { NEW_CHAT_PATH } from './helpers';
type AuthRecoveryTestEvent = {
type: string;
detail: unknown;
};
type AuthRecoveryTestWindow = Window & {
__authRecoveryTestEvents: AuthRecoveryTestEvent[];
};
type RefreshTokenBody = {
token?: string;
};
function createJwt(expiresAtMs: number) {
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(expiresAtMs / 1000) })).toString(
'base64url',
);
return `header.${payload}.signature`;
}
async function getIsolatedStorageState(request: APIRequestContext, user: User) {
await cleanupUser(user);
const registerResponse = await request.post('/api/auth/register', {
data: {
email: user.email,
name: user.name,
password: user.password,
confirm_password: user.password,
},
});
expect(registerResponse.ok()).toBeTruthy();
const loginResponse = await request.post('/api/auth/login', {
data: {
email: user.email,
password: user.password,
},
});
expect(loginResponse.ok()).toBeTruthy();
return request.storageState();
}
test.describe('auth session', () => {
test('session persists across a full page reload', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByTestId('nav-user')).toBeVisible();
await page.reload({ timeout: 10000 });
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByTestId('nav-user')).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible();
});
test('logout ends the session and protects authenticated routes', async ({
request,
browser,
baseURL,
}) => {
test.setTimeout(90000);
if (typeof baseURL !== 'string') {
throw new Error('baseURL must be configured for mock auth tests');
}
const user = getSecondaryE2EUser();
const context = await browser.newContext({
storageState: await getIsolatedStorageState(request, user),
baseURL,
});
await context.addInitScript(() => {
localStorage.setItem('navVisible', 'true');
});
const page = await context.newPage();
try {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await expect(page).not.toHaveURL(/\/login/);
await page.getByTestId('nav-user').click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await page.waitForURL(/\/login/, { timeout: 10000 });
await expect(page.getByLabel('Email')).toBeVisible();
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await expect(page).toHaveURL(/\/login/);
} finally {
await context.close().catch(() => undefined);
await cleanupUser(user);
}
});
test('recovers from an expired bearer during app bootstrap without redirect looping', async ({
page,
}) => {
test.setTimeout(30000);
const expiredToken = createJwt(Date.now() - 60_000);
const expiredBearerPaths: string[] = [];
let refreshCalls = 0;
await page.addInitScript(() => {
const testWindow = window as AuthRecoveryTestWindow;
testWindow.__authRecoveryTestEvents = [];
window.addEventListener('authRecovery', (event) => {
testWindow.__authRecoveryTestEvents.push({
type: 'authRecovery',
detail: (event as CustomEvent).detail,
});
});
window.addEventListener('authRedirectStarted', (event) => {
testWindow.__authRecoveryTestEvents.push({
type: 'authRedirectStarted',
detail: (event as CustomEvent).detail,
});
});
});
await page.route('**/api/**', async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/auth/refresh') {
refreshCalls += 1;
const response = await route.fetch();
if (refreshCalls === 1) {
const body = (await response.json()) as RefreshTokenBody;
await route.fulfill({
response,
json: {
...body,
token: expiredToken,
},
});
return;
}
await route.fulfill({ response });
return;
}
if (request.headers().authorization === `Bearer ${expiredToken}`) {
expiredBearerPaths.push(pathname);
await route.fulfill({
status: 401,
contentType: 'application/json',
json: { message: 'jwt expired' },
});
return;
}
await route.continue();
});
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible();
await expect.poll(() => refreshCalls).toBe(2);
expect(expiredBearerPaths.length).toBeGreaterThan(0);
const events = await page.evaluate(
() => (window as AuthRecoveryTestWindow).__authRecoveryTestEvents,
);
expect(events.filter((event) => event.type === 'authRedirectStarted')).toHaveLength(0);
expect(
events.filter((event) => event.type === 'authRecovery').map((event) => event.detail),
).toEqual([{ state: 'started' }, { state: 'finished' }]);
});
});