import { expect, type Page } from '@playwright/test'; import { test } from '../../fixtures/multi-client'; import { openSettingsFromMenu } from '../../helpers/app-menu'; import { expectDashboardReady } from '../../helpers/dashboard'; import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; import { authHeaders, readAuthTokenFromPage, readSignalServerCredentialFromPage, registerTestUser } from '../../helpers/auth-api'; import { expectServerPeerVisible } from '../../helpers/multi-device-session'; import { LoginPage } from '../../pages/login.page'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary'; const USER_PASSWORD = 'TestPass123!'; test.describe('Multi-signal-server authentication', () => { test.describe.configure({ timeout: 180_000 }); test('auto-provisions a foreign signal server when a new endpoint is added', async ({ createClient, request }) => { const primaryServer = await startTestServer(); const secondaryServer = await startTestServer(); try { const client = await createClient(); const suffix = `multi_auth_${Date.now()}`; const username = `user_${suffix}`; await installTestServerEndpoints(client.context, [ { id: PRIMARY_ENDPOINT_ID, name: 'E2E Primary Signal', url: primaryServer.url, isActive: true, status: 'online' } ]); await test.step('Register on the home signal server', async () => { const register = new RegisterPage(client.page); await register.goto(); await register.register(username, 'Multi Auth User', USER_PASSWORD); await expectDashboardReady(client.page); }); await test.step('Add a second signal server in network settings', async () => { await openSettingsFromMenu(client.page); await client.page.getByRole('button', { name: 'Network' }).click(); await client.page.getByPlaceholder('Server name').fill('E2E Secondary Signal'); await client.page.getByPlaceholder('Server URL (e.g., http://localhost:3001)').fill(secondaryServer.url); await client.page.getByTestId('add-signal-server-button').click(); await expect(client.page.getByText(secondaryServer.url)).toBeVisible({ timeout: 15_000 }); }); await test.step('Wait for auto-provisioned credentials on the secondary server', async () => { await expect.poll(async () => await readSignalServerCredentialFromPage(client.page, secondaryServer.url), { timeout: 30_000 } ).not.toBeNull(); const homeToken = await readAuthTokenFromPage(client.page, primaryServer.url); const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url); expect(homeToken).toBeTruthy(); expect(secondaryCredential?.username).toBe(username); expect(secondaryCredential?.token).toBeTruthy(); }); await test.step('Secondary credential can call authenticated APIs', async () => { const secondaryCredential = await readSignalServerCredentialFromPage(client.page, secondaryServer.url); if (!secondaryCredential) { throw new Error('Expected secondary signal-server credential to be provisioned'); } const response = await request.post(`${secondaryServer.url}/api/servers`, { headers: { Authorization: `Bearer ${secondaryCredential.token}`, 'Content-Type': 'application/json' }, data: { name: `Secondary Provisioned Server ${suffix}`, description: 'Created with auto-provisioned credentials', ownerId: secondaryCredential.userId, ownerPublicKey: 'e2e-secondary-owner-key' } }); expect(response.ok(), `POST /api/servers failed: ${response.status()} ${await response.text()}`).toBe(true); }); await test.step('Home registration still works independently on the secondary server', async () => { const otherUser = await registerTestUser( request, secondaryServer.url, `other_${suffix}`, USER_PASSWORD, 'Other User' ); expect(otherUser.username).toBe(`other_${suffix}`); }); } finally { await primaryServer.stop(); await secondaryServer.stop(); } }); test('restored session recreates a missing secret, provisions silently, and joins foreign presence', async ({ createClient }) => { const primaryServer = await startTestServer(); const secondaryServer = await startTestServer(); try { const alice = await createClient(); const bob = await createClient(); const suffix = `restore_auth_${Date.now()}`; const aliceUsername = `alice_${suffix}`; const bobUsername = `bob_${suffix}`; const serverName = `Foreign Restore ${suffix}`; await installTestServerEndpoints(alice.context, [ { id: PRIMARY_ENDPOINT_ID, name: 'E2E Primary Signal', url: primaryServer.url, isActive: true, status: 'online' } ]); await installTestServerEndpoints(bob.context, [ { id: 'e2e-multi-auth-secondary', name: 'E2E Secondary Signal', url: secondaryServer.url, isActive: true, status: 'online' } ]); await test.step('Bob creates the foreign-hosted server', async () => { const register = new RegisterPage(bob.page); await register.goto(); await register.register(bobUsername, 'Bob Restore', USER_PASSWORD); await expectDashboardReady(bob.page); await new ServerSearchPage(bob.page).createServer(serverName, { description: 'Restore-safe foreign authentication coverage' }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 }); }); await test.step('Alice registers only on her home signal server', async () => { const register = new RegisterPage(alice.page); await register.goto(); await register.register(aliceUsername, 'Alice Restore', USER_PASSWORD); await expectDashboardReady(alice.page); }); await test.step('A restored tab has no provision secret when the foreign endpoint appears', async () => { await alice.page.evaluate(() => { for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { const key = sessionStorage.key(index); if (key?.startsWith('metoyou.provisionSecret.')) { sessionStorage.removeItem(key); } } }); await installTestServerEndpoints(alice.context, [ { id: PRIMARY_ENDPOINT_ID, name: 'E2E Primary Signal', url: primaryServer.url, isActive: true, status: 'online' }, { id: 'e2e-multi-auth-secondary', name: 'E2E Secondary Signal', url: secondaryServer.url, isActive: true, status: 'online' } ]); await alice.page.reload({ waitUntil: 'domcontentloaded' }); await expectDashboardReady(alice.page); await expect(alice.page).not.toHaveURL(/\/login/); await expect.poll(async () => await readSignalServerCredentialFromPage(alice.page, secondaryServer.url), { timeout: 30_000 } ).not.toBeNull(); }); await test.step('Alice joins the foreign server and both users see mutual presence', async () => { await new ServerSearchPage(alice.page).joinServerFromSearch(serverName); await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expectServerPeerVisible(alice.page, 'Bob Restore'); await expectServerPeerVisible(bob.page, 'Alice Restore'); }); await test.step('Both users restore mutual presence after the foreign signal server restarts', async () => { await Promise.all([installRestartSignalingTrace(alice.page), installRestartSignalingTrace(bob.page)]); await secondaryServer.restart(); await expect.poll(async () => await hasRestartPresenceRecovery(alice.page, 'Bob Restore'), { timeout: 30_000 } ).toBe(true); await expect.poll(async () => await hasRestartPresenceRecovery(bob.page, 'Alice Restore'), { timeout: 30_000 } ).toBe(true); await expectServerPeerVisible(alice.page, 'Bob Restore'); await expectServerPeerVisible(bob.page, 'Alice Restore'); }); } finally { await primaryServer.stop(); await secondaryServer.stop(); } }); test('two devices of the same human share one account on a foreign signal server', async ({ createClient }) => { const primaryServer = await startTestServer(); const secondaryServer = await startTestServer(); try { const suffix = `one_identity_${Date.now()}`; const username = `alice_${suffix}`; const endpoints = [ { id: PRIMARY_ENDPOINT_ID, name: 'E2E Primary Signal', url: primaryServer.url, isActive: true, status: 'online' as const }, { id: 'e2e-multi-auth-secondary', name: 'E2E Secondary Signal', url: secondaryServer.url, isActive: true, status: 'online' as const } ]; const laptop = await createClient(); await installTestServerEndpoints(laptop.context, endpoints); await test.step('Alice signs in on her laptop and provisions the foreign server', async () => { const register = new RegisterPage(laptop.page); await register.goto(); await register.register(username, 'Alice One Identity', USER_PASSWORD); await expectDashboardReady(laptop.page); await restartApp(laptop.page); }); const laptopCredential = await waitForForeignCredential(laptop.page, secondaryServer.url); const phone = await createClient(); await installTestServerEndpoints(phone.context, endpoints); await test.step('Alice signs in on a second device with no shared local storage', async () => { const login = new LoginPage(phone.page); await login.goto(); await login.login(username, USER_PASSWORD); await expectDashboardReady(phone.page); await restartApp(phone.page); }); const phoneCredential = await waitForForeignCredential(phone.page, secondaryServer.url); // One human must be one actor on the foreign server. A per-device secret // made the second device register `alice-` instead, which is // what showed the same person twice to everybody else. expect(phoneCredential?.userId).toBe(laptopCredential?.userId); expect(phoneCredential?.username).toBe(username); expect(laptopCredential?.username).toBe(username); } finally { await primaryServer.stop(); await secondaryServer.stop(); } }); test('lost foreign secret shows contextual retry without logging out the home session', async ({ createClient, request }) => { const primaryServer = await startTestServer(); const secondaryServer = await startTestServer(); try { const alice = await createClient(); const suffix = `lost_secret_${Date.now()}`; const username = `alice_${suffix}`; await installTestServerEndpoints(alice.context, [ { id: PRIMARY_ENDPOINT_ID, name: 'E2E Primary Signal', url: primaryServer.url, isActive: true, status: 'online' } ]); const register = new RegisterPage(alice.page); await register.goto(); await register.register(username, 'Alice Lost Secret', USER_PASSWORD); await expectDashboardReady(alice.page); const homeUserId = await alice.page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ); if (!homeUserId) { throw new Error('Expected restored home user id'); } const shortHomeId = homeUserId.replace(/-/g, '').slice(0, 6) .toLowerCase(); const oldForeignAccount = await registerTestUser( request, secondaryServer.url, username, 'OldForeignSecret123!', 'Alice Lost Secret' ); await registerTestUser( request, secondaryServer.url, `${username}-${shortHomeId}`, 'OldForeignSecret123!', 'Alice Lost Secret' ); const serverName = `Lost Secret Recovery ${suffix}`; const createResponse = await request.post(`${secondaryServer.url}/api/servers`, { headers: authHeaders(oldForeignAccount.token), data: { name: serverName, description: 'Contextual auth recovery coverage', ownerId: oldForeignAccount.id, ownerPublicKey: oldForeignAccount.id } }); expect(createResponse.ok(), await createResponse.text()).toBe(true); await alice.page.evaluate(() => { for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { const key = sessionStorage.key(index); if (key?.startsWith('metoyou.provisionSecret.')) { sessionStorage.removeItem(key); } } }); await installTestServerEndpoints(alice.context, [ { id: PRIMARY_ENDPOINT_ID, name: 'E2E Primary Signal', url: primaryServer.url, isActive: true, status: 'online' }, { id: 'e2e-multi-auth-secondary', name: 'E2E Secondary Signal', url: secondaryServer.url, isActive: true, status: 'online' } ]); await alice.page.reload({ waitUntil: 'domcontentloaded' }); await expectDashboardReady(alice.page); await new ServerSearchPage(alice.page).joinServerFromSearch(serverName); const recovery = alice.page.getByTestId('signal-server-auth-recovery'); await expect(recovery).toBeVisible({ timeout: 20_000 }); await expect(recovery).toContainText('Reconnect to'); await expect(alice.page).not.toHaveURL(/\/login/); await recovery.getByTestId('signal-server-auth-retry').click(); await expect(recovery).toBeVisible({ timeout: 20_000 }); await expect(alice.page).not.toHaveURL(/\/login/); } finally { await primaryServer.stop(); await secondaryServer.stop(); } }); }); /** Foreign endpoints are provisioned on the bootstrap path, so reload to reach it. */ async function restartApp(page: Page): Promise { await page.reload({ waitUntil: 'domcontentloaded' }); await expectDashboardReady(page); } async function waitForForeignCredential(page: Page, serverUrl: string) { await expect.poll(async () => await readSignalServerCredentialFromPage(page, serverUrl), { timeout: 30_000 } ).not.toBeNull(); return await readSignalServerCredentialFromPage(page, serverUrl); } interface RestartSignalingTraceEvent { displayName?: string; direction: 'inbound' | 'outbound'; type: string; users?: string[]; } async function installRestartSignalingTrace(page: Page): Promise { await page.evaluate(() => { const tracedWindow = window as typeof window & { __restartSignalingTrace?: RestartSignalingTraceEvent[]; }; const OriginalWebSocket = window.WebSocket; const trace: RestartSignalingTraceEvent[] = []; const TrackedWebSocket = function( this: WebSocket, url: string | URL, protocols?: string | string[] ): WebSocket { const socket = protocols === undefined ? new OriginalWebSocket(url) : new OriginalWebSocket(url, protocols); const originalSend = socket.send.bind(socket); socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => { if (typeof data === 'string') { try { const message = JSON.parse(data) as { type?: unknown }; if (typeof message.type === 'string') { trace.push({ direction: 'outbound', type: message.type }); } } catch { // Ignore non-JSON websocket traffic. } } originalSend(data); }; socket.addEventListener('message', (event) => { if (typeof event.data !== 'string') { return; } try { const message = JSON.parse(event.data) as { displayName?: unknown; type?: unknown; users?: { displayName?: unknown }[]; }; if (typeof message.type === 'string') { trace.push({ displayName: typeof message.displayName === 'string' ? message.displayName : undefined, direction: 'inbound', type: message.type, users: Array.isArray(message.users) ? message.users .map((user) => user.displayName) .filter((displayName): displayName is string => typeof displayName === 'string') : undefined }); } } catch { // Ignore non-JSON websocket traffic. } }); return socket; }; Object.setPrototypeOf(TrackedWebSocket, OriginalWebSocket); TrackedWebSocket.prototype = OriginalWebSocket.prototype; tracedWindow.__restartSignalingTrace = trace; tracedWindow.WebSocket = TrackedWebSocket as unknown as typeof WebSocket; }); } async function hasRestartPresenceRecovery(page: Page, expectedPeerName: string): Promise { return await page.evaluate((peerName) => { const trace = (window as typeof window & { __restartSignalingTrace?: RestartSignalingTraceEvent[]; }).__restartSignalingTrace ?? []; const identifyIndex = trace.findIndex((event) => event.direction === 'outbound' && event.type === 'identify' ); const joinIndex = trace.findIndex((event) => event.direction === 'outbound' && event.type === 'join_server' ); const receivedPeerPresence = trace.some((event) => event.direction === 'inbound' && ( (event.type === 'server_users' && event.users?.includes(peerName)) || (event.type === 'user_joined' && event.displayName === peerName) ) ); return identifyIndex >= 0 && joinIndex > identifyIndex && receivedPeerPresence; }, expectedPeerName); }