mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
* fix auth recovery singleflight * add auth recovery e2e coverage * handle invalid auth redirect timestamp
181 lines
5.4 KiB
TypeScript
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' }]);
|
|
});
|
|
});
|