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

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);