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 { 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 { 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 { if (!client) { return; } await client.context.close().catch(() => {}); } async function openApp(page: Page): Promise { await retryTransientNavigation(() => page.goto('/', { waitUntil: 'domcontentloaded' })); } async function registerUser(page: Page, user: TestUser): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim()); } async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise { 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 { try { await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout }); return true; } catch { return false; } } async function waitForServerSearch(page: Page, timeout: number): Promise { try { await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout }); return true; } catch { return false; } } async function waitForVisibleText(page: Page, text: string, timeout: number): Promise { try { await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout }); return true; } catch { return false; } } async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise { await expect.poll( () => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId), { timeout: 10_000 } ).toBe(true); } async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise { 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((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((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(navigate: () => Promise, attempts = 4): Promise { 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)}`; }