524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
chromium,
|
|
type BrowserContext,
|
|
type Page
|
|
} from '@playwright/test';
|
|
import { test, expect } from '../../fixtures/multi-client';
|
|
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
|
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
|
import { LoginPage } from '../../pages/login.page';
|
|
import { RegisterPage } from '../../pages/register.page';
|
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
|
|
|
interface TestUser {
|
|
username: string;
|
|
displayName: string;
|
|
password: string;
|
|
}
|
|
|
|
interface PersistentClient {
|
|
context: BrowserContext;
|
|
page: Page;
|
|
userDataDir: string;
|
|
}
|
|
|
|
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
|
|
|
test.describe('User session data isolation', () => {
|
|
test.describe.configure({ timeout: 240_000 });
|
|
|
|
test('preserves a user saved rooms and local history across app restarts', async ({ testServer }) => {
|
|
const suffix = uniqueName('persist');
|
|
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-persist-'));
|
|
const alice: TestUser = {
|
|
username: `alice_${suffix}`,
|
|
displayName: 'Alice',
|
|
password: 'TestPass123!'
|
|
};
|
|
const aliceServerName = `Alice Session Server ${suffix}`;
|
|
const aliceMessage = `Alice persisted message ${suffix}`;
|
|
|
|
let client: PersistentClient | null = null;
|
|
|
|
try {
|
|
client = await launchPersistentClient(userDataDir, testServer.port);
|
|
|
|
await test.step('Alice registers and creates local chat history', async () => {
|
|
await registerUser(client.page, alice);
|
|
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
|
});
|
|
|
|
await test.step('Alice sees the same saved room and message after a full restart', async () => {
|
|
await restartPersistentClient(client, testServer.port);
|
|
await openApp(client.page);
|
|
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
|
});
|
|
} finally {
|
|
await closePersistentClient(client);
|
|
await rm(userDataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('gives a new user a blank slate and restores only that user local data after account switches', async ({ testServer }) => {
|
|
const suffix = uniqueName('isolation');
|
|
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-auth-isolation-'));
|
|
const alice: TestUser = {
|
|
username: `alice_${suffix}`,
|
|
displayName: 'Alice',
|
|
password: 'TestPass123!'
|
|
};
|
|
const bob: TestUser = {
|
|
username: `bob_${suffix}`,
|
|
displayName: 'Bob',
|
|
password: 'TestPass123!'
|
|
};
|
|
const aliceServerName = `Alice Private Server ${suffix}`;
|
|
const bobServerName = `Bob Private Server ${suffix}`;
|
|
const aliceMessage = `Alice history ${suffix}`;
|
|
const bobMessage = `Bob history ${suffix}`;
|
|
|
|
let client: PersistentClient | null = null;
|
|
|
|
try {
|
|
client = await launchPersistentClient(userDataDir, testServer.port);
|
|
|
|
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
|
|
await registerUser(client.page, alice);
|
|
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
|
|
|
await restartPersistentClient(client, testServer.port);
|
|
await openApp(client.page);
|
|
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
|
});
|
|
|
|
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
|
await logoutUser(client.page);
|
|
await registerUser(client.page, bob);
|
|
await expectBlankSlate(client.page, [aliceServerName]);
|
|
});
|
|
|
|
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
|
await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
|
|
|
|
await restartPersistentClient(client, testServer.port);
|
|
await openApp(client.page);
|
|
await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
|
|
await expectSavedRoomHidden(client.page, aliceServerName);
|
|
});
|
|
|
|
await test.step('When Alice logs back in she sees only Alice local data, not Bob data', async () => {
|
|
await logoutUser(client.page);
|
|
await restartPersistentClient(client, testServer.port);
|
|
await loginUser(client.page, alice);
|
|
|
|
await expectSavedRoomVisible(client.page, aliceServerName);
|
|
await expectSavedRoomHidden(client.page, bobServerName);
|
|
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
|
});
|
|
} finally {
|
|
await closePersistentClient(client);
|
|
await rm(userDataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
async function launchPersistentClient(userDataDir: string, testServerPort: number): Promise<PersistentClient> {
|
|
const context = await chromium.launchPersistentContext(userDataDir, {
|
|
args: CLIENT_LAUNCH_ARGS,
|
|
baseURL: 'http://localhost:4200',
|
|
permissions: ['microphone', 'camera']
|
|
});
|
|
|
|
await installTestServerEndpoint(context, testServerPort);
|
|
|
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
|
|
return {
|
|
context,
|
|
page,
|
|
userDataDir
|
|
};
|
|
}
|
|
|
|
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
|
|
await client.context.close();
|
|
|
|
const restartedClient = await launchPersistentClient(client.userDataDir, testServerPort);
|
|
|
|
client.context = restartedClient.context;
|
|
client.page = restartedClient.page;
|
|
}
|
|
|
|
async function closePersistentClient(client: PersistentClient | null): Promise<void> {
|
|
if (!client) {
|
|
return;
|
|
}
|
|
|
|
await client.context.close().catch(() => {});
|
|
}
|
|
|
|
async function openApp(page: Page): Promise<void> {
|
|
await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' }));
|
|
}
|
|
|
|
async function registerUser(page: Page, user: TestUser): Promise<void> {
|
|
const registerPage = new RegisterPage(page);
|
|
|
|
await retryTransientNavigation(() => registerPage.goto());
|
|
await registerPage.register(user.username, user.displayName, user.password);
|
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
|
}
|
|
|
|
async function loginUser(page: Page, user: TestUser): Promise<void> {
|
|
const loginPage = new LoginPage(page);
|
|
|
|
await retryTransientNavigation(() => loginPage.goto());
|
|
await loginPage.login(user.username, user.password);
|
|
await expect(page).toHaveURL(/\/(dashboard|room)(\/|$)/, { timeout: 15_000 });
|
|
}
|
|
|
|
async function logoutUser(page: Page): Promise<void> {
|
|
const menuButton = page.getByRole('button', { name: 'Menu' });
|
|
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
|
const loginPage = new LoginPage(page);
|
|
|
|
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
|
await menuButton.click();
|
|
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
|
await logoutButton.click();
|
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
|
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
async function createServerAndSendMessage(page: Page, user: TestUser, serverName: string, messageText: string): Promise<void> {
|
|
const searchPage = new ServerSearchPage(page);
|
|
const messagesPage = new ChatMessagesPage(page);
|
|
|
|
await loginIfNeeded(page, user);
|
|
await ensureCurrentUserScope(page, user);
|
|
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
|
|
|
if (await waitForLoginForm(page, 5_000)) {
|
|
await loginUser(page, user);
|
|
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
|
}
|
|
|
|
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
|
|
await searchPage.serverNameInput.fill(serverName);
|
|
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
|
|
await searchPage.createSubmitButton.click();
|
|
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
|
|
|
await messagesPage.sendMessage(messageText);
|
|
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
|
await expectMessagePersistedInIndexedDb(page, messageText);
|
|
}
|
|
|
|
async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
|
|
if (await waitForVisibleText(page, messageText, 5_000)) {
|
|
return;
|
|
}
|
|
|
|
if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
|
|
await loginUser(page, user);
|
|
}
|
|
|
|
await expectMessagePersistedInIndexedDb(page, messageText);
|
|
|
|
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
|
|
|
|
if (persistedRoomId) {
|
|
await openPersistedRoomById(page, user, persistedRoomId);
|
|
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
|
return;
|
|
}
|
|
|
|
if (await openSavedRoomFromRail(page, roomName)) {
|
|
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
|
return;
|
|
}
|
|
|
|
await joinServerFromSearchAfterLogin(page, user, roomName);
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
|
}
|
|
|
|
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
|
|
|
for (const roomName of hiddenRoomNames) {
|
|
await expectSavedRoomHidden(page, roomName);
|
|
}
|
|
}
|
|
|
|
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
|
if (await page.getByText(roomName, { exact: false }).first()
|
|
.isVisible()
|
|
.catch(() => false)) {
|
|
return;
|
|
}
|
|
|
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
|
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
|
}
|
|
|
|
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
|
if (!page.url().includes('/servers')) {
|
|
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
|
}
|
|
|
|
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
|
|
}
|
|
|
|
function getSearchSavedRoomButton(page: Page, roomName: string) {
|
|
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
|
|
}
|
|
|
|
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
|
|
try {
|
|
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
|
|
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
|
|
const expectedPrefix = expectedName.slice(0, 24);
|
|
const button = buttons.find((candidate) => {
|
|
const title = (candidate as HTMLButtonElement).title;
|
|
|
|
return title === expectedName || title.startsWith(expectedPrefix);
|
|
}) as HTMLButtonElement | undefined;
|
|
|
|
button?.click();
|
|
return !!button;
|
|
}, roomName);
|
|
|
|
if (!clicked) {
|
|
return await openSavedRoomFromDashboard(page, roomName);
|
|
}
|
|
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
return true;
|
|
} catch {
|
|
return await openSavedRoomFromDashboard(page, roomName);
|
|
}
|
|
}
|
|
|
|
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
|
|
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
|
|
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
|
|
|
|
try {
|
|
await expect(roomButton).toBeVisible({ timeout: 10_000 });
|
|
await roomButton.click();
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
return true;
|
|
} catch {
|
|
return await joinVisibleServerFromDashboard(page, roomNamePattern);
|
|
}
|
|
}
|
|
|
|
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
|
|
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
|
|
has: page.getByRole('button', { name: 'Join' })
|
|
})
|
|
.last();
|
|
const joinButton = serverRow.getByRole('button', { name: 'Join' });
|
|
|
|
try {
|
|
await expect(joinButton).toBeVisible({ timeout: 10_000 });
|
|
await joinButton.click();
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
|
|
const searchPage = new ServerSearchPage(page);
|
|
|
|
await loginIfNeeded(page, user);
|
|
await searchPage.goto();
|
|
|
|
if (!await waitForServerSearch(page, 5_000)) {
|
|
await loginUser(page, user);
|
|
await searchPage.goto();
|
|
}
|
|
|
|
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
|
|
await searchPage.searchInput.fill(roomName);
|
|
|
|
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
|
|
|
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
await serverCard.dblclick();
|
|
}
|
|
|
|
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
|
|
const loginPage = new LoginPage(page);
|
|
|
|
if (page.url().includes('/login')) {
|
|
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
|
|
await loginUser(page, user);
|
|
return;
|
|
}
|
|
|
|
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
|
|
await loginUser(page, user);
|
|
}
|
|
}
|
|
|
|
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
|
|
if (await hasCurrentUserScope(page)) {
|
|
return;
|
|
}
|
|
|
|
await loginUser(page, user);
|
|
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
|
|
}
|
|
|
|
async function hasCurrentUserScope(page: Page): Promise<boolean> {
|
|
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
|
|
}
|
|
|
|
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
|
|
|
if (await waitForLoginForm(page, 5_000)) {
|
|
await loginUser(page, user);
|
|
continue;
|
|
}
|
|
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
|
|
if (!await waitForLoginForm(page, 2_000)) {
|
|
return;
|
|
}
|
|
|
|
await loginUser(page, user);
|
|
}
|
|
|
|
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
|
}
|
|
|
|
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
|
|
try {
|
|
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
|
|
try {
|
|
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
|
|
try {
|
|
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
|
|
await expect.poll(
|
|
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
|
|
{ timeout: 10_000 }
|
|
).toBe(true);
|
|
}
|
|
|
|
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
|
|
return page.evaluate(async (expectedContent) => {
|
|
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
|
|
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
|
|
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
|
|
? (await indexedDB.databases())
|
|
.map((database) => database.name)
|
|
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
|
|
: null;
|
|
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
|
|
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
|
|
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
|
|
? [preferredDatabaseName].concat(remainingDatabaseNames)
|
|
: remainingDatabaseNames;
|
|
|
|
for (const databaseName of orderedDatabaseNames) {
|
|
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open(databaseName);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
});
|
|
|
|
try {
|
|
if (!database.objectStoreNames.contains('messages')) {
|
|
continue;
|
|
}
|
|
|
|
const transaction = database.transaction('messages', 'readonly');
|
|
const request = transaction.objectStore('messages').getAll();
|
|
const roomId = await new Promise<string | null>((resolve, reject) => {
|
|
request.onerror = () => reject(request.error);
|
|
|
|
request.onsuccess = () => {
|
|
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
|
|
.find((message) => message.content === expectedContent);
|
|
|
|
resolve(match?.roomId ?? null);
|
|
};
|
|
});
|
|
|
|
if (roomId) {
|
|
return roomId;
|
|
}
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}, messageText);
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
try {
|
|
return await navigate();
|
|
} catch (error) {
|
|
lastError = error;
|
|
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
|
|
|
|
if (!isTransientNavigationError || attempt === attempts) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`);
|
|
}
|
|
|
|
function uniqueName(prefix: string): string {
|
|
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
|
|
.slice(2, 8)}`;
|
|
}
|