A client that could not authorize against a foreign signal server was redirected into a dead end with no way to retry, so servers joined from another signal route became unreachable. The home server now stores a per-user provision secret, clients keep it in their own store, and a recovery service records why authorization failed per server URL. Invite, server browser, and chat room surface that reason and offer a retry instead of silently redirecting.
533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
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-<shortHomeId>` 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<void> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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);
|
|
}
|