feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

75
e2e/helpers/auth-api.ts Normal file
View File

@@ -0,0 +1,75 @@
import { type APIRequestContext, type Page } from '@playwright/test';
export const AUTH_TOKENS_STORAGE_KEY = 'metoyou.authTokens';
export interface AuthSession {
id: string;
username: string;
displayName: string;
token: string;
expiresAt: number;
}
export function authHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
export async function registerTestUser(
request: APIRequestContext,
baseUrl: string,
username: string,
password: string,
displayName?: string
): Promise<AuthSession> {
const response = await request.post(`${baseUrl}/api/users/register`, {
data: {
username,
password,
displayName: displayName ?? username
}
});
if (!response.ok()) {
throw new Error(`Failed to register test user ${username}: ${response.status()} ${await response.text()}`);
}
return await response.json() as AuthSession;
}
export async function loginTestUser(
request: APIRequestContext,
baseUrl: string,
username: string,
password: string
): Promise<AuthSession> {
const response = await request.post(`${baseUrl}/api/users/login`, {
data: { username, password }
});
if (!response.ok()) {
throw new Error(`Failed to login test user ${username}: ${response.status()} ${await response.text()}`);
}
return await response.json() as AuthSession;
}
export async function readAuthTokenFromPage(page: Page, serverUrl: string): Promise<string | null> {
return await page.evaluate(({ storageKey, url }) => {
try {
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
const normalizedUrl = url.trim().replace(/\/+$/, '');
const entry = store[normalizedUrl];
if (!entry || entry.expiresAt <= Date.now()) {
return null;
}
return entry.token;
} catch {
return null;
}
}, { storageKey: AUTH_TOKENS_STORAGE_KEY, url: serverUrl });
}

View File

@@ -7,16 +7,19 @@
*
* Cleanup: the temp directory is removed when the process exits.
*/
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
const { existsSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
const { join } = require('path');
const { tmpdir } = require('os');
const { spawn } = require('child_process');
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
const SERVER_DIR = join(__dirname, '..', '..', 'server');
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
const SERVER_DIST_ENTRY = join(SERVER_DIR, 'dist', 'index.js');
const SERVER_SRC_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_SRC_ENTRY;
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
// ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
@@ -45,7 +48,7 @@ console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
// and node_modules are found from the real server/ directory.
const child = spawn(
process.execPath,
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
USE_COMPILED_SERVER ? [SERVER_ENTRY] : [TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{
cwd: tmpDir,
env: {

View File

@@ -317,13 +317,22 @@ export class ChatRoomPage {
throw new Error('Missing room, user, or endpoint when persisting channels');
}
const authTokens = JSON.parse(localStorage.getItem('metoyou.authTokens') || '{}') as Record<string, { token: string; expiresAt: number }>;
const normalizedApiUrl = apiBaseUrl.trim().replace(/\/+$/, '');
const authEntry = authTokens[normalizedApiUrl];
const authToken = authEntry && authEntry.expiresAt > Date.now() ? authEntry.token : null;
if (!authToken) {
throw new Error('Missing session token for channel persistence');
}
const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
body: JSON.stringify({
currentOwnerId: currentUser.id,
channels: nextChannels
})
});

View File

@@ -1,6 +1,11 @@
import type { APIRequestContext, APIResponse } from '@playwright/test';
import WebSocket from 'ws';
import { expect, test } from '../../fixtures/multi-client';
import {
authHeaders,
registerTestUser,
type AuthSession
} from '../../helpers/auth-api';
import {
getPluginApiTestEvent,
readPluginApiTestManifest,
@@ -9,8 +14,6 @@ import {
TEST_PLUGIN_RELAY_EVENT
} from '../../helpers/plugin-api-test-fixture';
const OWNER_USER_ID = 'plugin-api-owner';
interface CreatedServerResponse {
id: string;
}
@@ -54,10 +57,25 @@ interface TestSocket {
test.describe('Plugin support API', () => {
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
const manifest = await readPluginApiTestManifest();
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
const owner = await registerTestUser(
request,
testServer.url,
`plugin-owner-${Date.now()}`,
'TestPass123!',
'Plugin Owner'
);
const peer = await registerTestUser(
request,
testServer.url,
`plugin-peer-${Date.now()}`,
'TestPass123!',
'Plugin Peer'
);
const server = await createServer(request, testServer.url, owner, `Plugin API ${Date.now()}`);
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
const ownerHeaders = authHeaders(owner.token);
await test.step('Initial snapshot is empty', async () => {
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
@@ -71,8 +89,8 @@ test.describe('Plugin support API', () => {
await test.step('Requirement API enforces server management permission', async () => {
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
headers: authHeaders(peer.token),
data: {
actorUserId: 'not-the-owner',
status: 'required'
}
});
@@ -83,8 +101,8 @@ test.describe('Plugin support API', () => {
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
headers: ownerHeaders,
data: {
actorUserId: OWNER_USER_ID,
reason: manifest.description,
status: 'required',
versionRange: `^${manifest.version}`
@@ -98,8 +116,8 @@ test.describe('Plugin support API', () => {
versionRange: `^${manifest.version}`
}));
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
const relayDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, relayEvent);
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, ownerHeaders, p2pEvent);
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
direction: 'serverRelay',
@@ -123,8 +141,8 @@ test.describe('Plugin support API', () => {
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
headers: ownerHeaders,
data: {
actorUserId: OWNER_USER_ID,
schemaVersion: 1,
scope: 'server',
value: {
@@ -140,15 +158,15 @@ test.describe('Plugin support API', () => {
params: {
key: 'settings',
scope: 'server',
userId: OWNER_USER_ID
userId: owner.id
}
}), 410);
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
headers: ownerHeaders,
data: {
actorUserId: OWNER_USER_ID,
scope: 'server'
}
}), 410);
@@ -161,8 +179,8 @@ test.describe('Plugin support API', () => {
const bob = await openTestSocket(testServer.url);
try {
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' });
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' });
await identifySocket(alice, owner.token, 'Plugin Owner');
await identifySocket(bob, peer.token, 'Plugin Peer');
alice.send({ type: 'join_server', serverId: server.id });
bob.send({ type: 'join_server', serverId: server.id });
@@ -193,7 +211,7 @@ test.describe('Plugin support API', () => {
pluginId: TEST_PLUGIN_ID,
serverId: server.id,
sourcePluginUserId: 'fixture-plugin-user',
sourceUserId: OWNER_USER_ID
sourceUserId: owner.id
}));
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
@@ -237,15 +255,15 @@ test.describe('Plugin support API', () => {
await test.step('Delete APIs remove event definitions and requirements', async () => {
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
data: { actorUserId: OWNER_USER_ID }
headers: ownerHeaders
}));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
data: { actorUserId: OWNER_USER_ID }
headers: ownerHeaders
}));
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
data: { actorUserId: OWNER_USER_ID }
headers: ownerHeaders
}));
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
@@ -259,9 +277,11 @@ test.describe('Plugin support API', () => {
async function createServer(
request: APIRequestContext,
baseUrl: string,
owner: AuthSession,
serverName: string
): Promise<CreatedServerResponse> {
const response = await request.post(`${baseUrl}/api/servers`, {
headers: authHeaders(owner.token),
data: {
channels: [
{
@@ -275,7 +295,7 @@ async function createServer(
id: `plugin-api-${Date.now()}`,
isPrivate: false,
name: serverName,
ownerId: OWNER_USER_ID,
ownerId: owner.id,
ownerPublicKey: 'plugin-api-owner-public-key',
tags: ['plugins']
}
@@ -287,13 +307,14 @@ async function createServer(
async function upsertEventDefinition(
request: APIRequestContext,
pluginsApi: string,
headers: Record<string, string>,
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
): Promise<PluginEventDefinitionResponse> {
return await expectJson<PluginEventDefinitionResponse>(await request.put(
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
{
headers,
data: {
actorUserId: OWNER_USER_ID,
direction: eventDefinition.direction,
maxPayloadBytes: eventDefinition.maxPayloadBytes,
schemaJson: '{"type":"object"}',
@@ -309,6 +330,20 @@ async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
return await response.json() as T;
}
async function identifySocket(socket: TestSocket, token: string, displayName: string): Promise<void> {
socket.send({ type: 'identify', token, displayName });
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
const authError = socket.messages.find((message) => message.type === 'auth_error');
if (authError) {
throw new Error(`WebSocket identify failed: ${JSON.stringify(authError)}`);
}
}
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
const socketUrl = baseUrl.replace(/^http/, 'ws');
const socket = new WebSocket(socketUrl);

View File

@@ -1,4 +1,8 @@
import { expect, type Page } from '@playwright/test';
import {
expect,
type APIRequestContext,
type Page
} from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint';
import { startTestServer } from '../../helpers/test-server';
@@ -11,6 +15,11 @@ import {
waitForConnectedPeerCount,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import {
authHeaders,
readAuthTokenFromPage,
registerTestUser
} from '../../helpers/auth-api';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
@@ -104,6 +113,7 @@ function endpointsForGroup(
test.describe('Mixed signal-config voice', () => {
test('8 users with different signal configs can voice, mute, deafen, and chat concurrently', async ({
createClient,
request,
testServer
}) => {
test.setTimeout(720_000);
@@ -144,6 +154,26 @@ test.describe('Mixed signal-config voice', () => {
await test.step('Create voice room on primary and chat room on secondary', async () => {
// Use a "both" user (client 0) to create both rooms
const searchPage = new ServerSearchPage(clients[0].page);
const secondarySession = await registerTestUser(
request,
secondaryServer.url,
clients[0].user.username,
clients[0].user.password,
clients[0].user.displayName
);
await clients[0].page.evaluate(({ serverUrl, token, expiresAt }) => {
const storageKey = 'metoyou.authTokens';
const store = JSON.parse(localStorage.getItem(storageKey) || '{}') as Record<string, { token: string; expiresAt: number }>;
const normalizedUrl = serverUrl.trim().replace(/\/+$/, '');
store[normalizedUrl] = { token, expiresAt };
localStorage.setItem(storageKey, JSON.stringify(store));
}, {
serverUrl: secondaryServer.url,
token: secondarySession.token,
expiresAt: secondarySession.expiresAt
});
await searchPage.createServer(VOICE_ROOM_NAME, {
description: 'Voice room on primary signal',
@@ -152,12 +182,14 @@ test.describe('Mixed signal-config voice', () => {
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await searchPage.createServer(SECONDARY_ROOM_NAME, {
description: 'Chat room on secondary signal',
sourceId: SECONDARY_SIGNAL_ID
});
const secondaryRoom = await createServerViaApi(
request,
secondaryServer.url,
secondarySession,
SECONDARY_ROOM_NAME
);
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
secondaryRoomId = secondaryRoom.id;
});
// ── Create invite links ─────────────────────────────────────
@@ -166,31 +198,39 @@ test.describe('Mixed signal-config voice', () => {
// Group D (secondary-only) needs invite to primary room.
let primaryRoomInviteUrl: string;
let secondaryRoomInviteUrl: string;
let secondaryRoomId = '';
await test.step('Create invite links for cross-signal rooms', async () => {
// Navigate to voice room to get its ID
await openSavedRoomByName(clients[0].page, VOICE_ROOM_NAME);
const primaryRoomId = await getCurrentRoomId(clients[0].page);
const userId = await getCurrentUserId(clients[0].page);
// Navigate to secondary room to get its ID
await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME);
const secondaryRoomId = await getCurrentRoomId(clients[0].page);
// Create invite for primary room (voice) via API
const primaryToken = await readAuthTokenFromPage(clients[0].page, testServer.url);
if (!primaryToken) {
throw new Error('Missing session token for primary signal invite creation');
}
const primaryInvite = await createInviteViaApi(
testServer.url,
primaryRoomId,
userId,
primaryToken,
clients[0].user.displayName
);
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
// Create invite for secondary room (chat) via API
const secondaryToken = await readAuthTokenFromPage(clients[0].page, secondaryServer.url);
if (!secondaryToken) {
throw new Error('Missing session token for secondary signal invite creation');
}
const secondaryInvite = await createInviteViaApi(
secondaryServer.url,
secondaryRoomId,
userId,
secondaryToken,
clients[0].user.displayName
);
@@ -463,17 +503,55 @@ function buildUsers(): TestUser[] {
// ── API helpers ──────────────────────────────────────────────────────
async function createServerViaApi(
request: APIRequestContext,
serverBaseUrl: string,
owner: { id: string; token: string },
serverName: string
): Promise<{ id: string }> {
const response = await request.post(`${serverBaseUrl}/api/servers`, {
headers: authHeaders(owner.token),
data: {
channels: [
{
id: 'general-text',
name: 'general',
position: 0,
type: 'text'
}
],
description: `E2E room on ${serverBaseUrl}`,
id: `mixed-signal-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
isPrivate: false,
name: serverName,
ownerId: owner.id,
ownerPublicKey: 'mixed-signal-owner-public-key',
tags: ['e2e']
}
});
if (!response.ok()) {
throw new Error(`Failed to create server via API: ${response.status()} ${await response.text()}`);
}
return await response.json() as { id: string };
}
async function createInviteViaApi(
serverBaseUrl: string,
roomId: string,
userId: string,
authToken: string,
displayName: string
): Promise<{ id: string }> {
const response = await fetch(`${serverBaseUrl}/api/servers/${roomId}/invites`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`
},
body: JSON.stringify({
requesterUserId: userId,
requesterDisplayName: displayName
})
});
@@ -510,34 +588,6 @@ async function getCurrentRoomId(page: Page): Promise<string> {
});
}
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface UserShape {
id: string;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API unavailable');
}
const component = debugApi.getComponent(host);
const user = (component['currentUser'] as (() => UserShape | null) | undefined)?.();
if (!user?.id) {
throw new Error('Current user not found');
}
return user.id;
});
}
// ── Navigation helpers ───────────────────────────────────────────────
async function installDeterministicVoiceSettings(page: Page): Promise<void> {