feat: Security
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user