diff --git a/e2e/helpers/seed-test-endpoint.ts b/e2e/helpers/seed-test-endpoint.ts index 9713d6a..81332b6 100644 --- a/e2e/helpers/seed-test-endpoint.ts +++ b/e2e/helpers/seed-test-endpoint.ts @@ -69,6 +69,7 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat 'toju-primary', 'toju-sweden' ])); + storage.setItem('metoyou_general_settings', generalSettings); if (currentUserId) { diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts index 6d58d9c..992e753 100644 --- a/e2e/pages/login.page.ts +++ b/e2e/pages/login.page.ts @@ -10,7 +10,9 @@ export class LoginPage { readonly registerLink: Locator; constructor(private page: Page) { - this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]').first(); + this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]') + .first(); + this.usernameInput = page.locator('#login-username'); this.passwordInput = page.locator('#login-password'); this.serverSelect = page.locator('#login-server'); diff --git a/e2e/pages/server-search.page.ts b/e2e/pages/server-search.page.ts index 82fadb1..9c1777a 100644 --- a/e2e/pages/server-search.page.ts +++ b/e2e/pages/server-search.page.ts @@ -79,12 +79,19 @@ export class ServerSearchPage { await this.page.getByRole('button', { name }).click(); } - async joinServerFromSearch(name: string) { + async joinServerFromSearch(name: string, options: { acceptPluginDownloads?: boolean } = {}) { await this.searchInput.fill(name); const serverCard = this.page.locator('div[title]', { hasText: name }).first(); await expect(serverCard).toBeVisible({ timeout: 15_000 }); await serverCard.dblclick(); + + if (options.acceptPluginDownloads) { + const pluginConsentDialog = this.page.getByRole('dialog', { name: /uses plugins/ }); + + await expect(pluginConsentDialog).toBeVisible({ timeout: 20_000 }); + await pluginConsentDialog.getByRole('button', { name: 'Accept and join' }).click(); + } } } diff --git a/e2e/tests/auth/user-session-data-isolation.spec.ts b/e2e/tests/auth/user-session-data-isolation.spec.ts index e017969..1aa6424 100644 --- a/e2e/tests/auth/user-session-data-isolation.spec.ts +++ b/e2e/tests/auth/user-session-data-isolation.spec.ts @@ -25,10 +25,7 @@ interface PersistentClient { userDataDir: string; } -const CLIENT_LAUNCH_ARGS = [ - '--use-fake-device-for-media-stream', - '--use-fake-ui-for-media-stream' -]; +const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']; test.describe('User session data isolation', () => { test.describe.configure({ timeout: 240_000 }); @@ -43,6 +40,7 @@ test.describe('User session data isolation', () => { }; const aliceServerName = `Alice Session Server ${suffix}`; const aliceMessage = `Alice persisted message ${suffix}`; + let client: PersistentClient | null = null; try { @@ -82,6 +80,7 @@ test.describe('User session data isolation', () => { const bobServerName = `Bob Private Server ${suffix}`; const aliceMessage = `Alice history ${suffix}`; const bobMessage = `Bob history ${suffix}`; + let client: PersistentClient | null = null; try { @@ -136,7 +135,7 @@ async function launchPersistentClient(userDataDir: string, testServerPort: numbe await installTestServerEndpoint(context, testServerPort); - const page = context.pages()[0] ?? await context.newPage(); + const page = context.pages()[0] ?? (await context.newPage()); return { context, @@ -202,6 +201,7 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag await searchPage.createServer(serverName, { description: `User session isolation coverage for ${serverName}` }); + await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 }); await messagesPage.sendMessage(messageText); @@ -209,11 +209,15 @@ async function createServerAndSendMessage(page: Page, serverName: string, messag } async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise { - const roomButton = getSavedRoomButton(page, roomName); + const railRoomButton = getRailSavedRoomButton(page, roomName); const messagesPage = new ChatMessagesPage(page); - await expect(roomButton).toBeVisible({ timeout: 20_000 }); - await roomButton.click(); + await expect(railRoomButton).toBeVisible({ timeout: 20_000 }); + await page.goto('/search', { waitUntil: 'domcontentloaded' }); + const searchRoomButton = getSearchSavedRoomButton(page, roomName); + + await expect(searchRoomButton).toBeVisible({ timeout: 20_000 }); + await searchRoomButton.click(); await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 }); await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 }); } @@ -230,17 +234,29 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise< } async function expectSavedRoomVisible(page: Page, roomName: string): Promise { - await expect(getSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); + await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); + await page.goto('/search', { waitUntil: 'domcontentloaded' }); + await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 }); } async function expectSavedRoomHidden(page: Page, roomName: string): Promise { - await expect(getSavedRoomButton(page, roomName)).toHaveCount(0); + await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0); + + if (!page.url().includes('/search')) { + await page.goto('/search', { waitUntil: 'domcontentloaded' }); + } + + await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0); } -function getSavedRoomButton(page: Page, roomName: string) { +function getRailSavedRoomButton(page: Page, roomName: string) { return page.locator(`button[title="${roomName}"]`).first(); } +function getSearchSavedRoomButton(page: Page, roomName: string) { + return page.locator('app-server-search').getByRole('button', { name: roomName, exact: true }); +} + async function retryTransientNavigation(navigate: () => Promise, attempts = 4): Promise { let lastError: unknown; @@ -259,11 +275,10 @@ async function retryTransientNavigation(navigate: () => Promise, attempts } } - throw lastError instanceof Error - ? lastError - : new Error(`Navigation failed after ${attempts} attempts`); + throw lastError instanceof Error ? lastError : new Error(`Navigation failed after ${attempts} attempts`); } function uniqueName(prefix: string): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} \ No newline at end of file + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36) + .slice(2, 8)}`; +} diff --git a/e2e/tests/chat/server-icon-sync.spec.ts b/e2e/tests/chat/server-icon-sync.spec.ts index d01376a..752c010 100644 --- a/e2e/tests/chat/server-icon-sync.spec.ts +++ b/e2e/tests/chat/server-icon-sync.spec.ts @@ -1,7 +1,13 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { chromium, type BrowserContext, type Locator, type Page, type Route } from '@playwright/test'; +import { + chromium, + type BrowserContext, + type Locator, + type Page, + type Route +} from '@playwright/test'; import { test, expect } from '../../fixtures/multi-client'; import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint'; import { installWebRTCTracking } from '../../helpers/webrtc-helpers'; @@ -31,7 +37,11 @@ interface PersistentClient { } const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='; -const GIF_FRAME_MARKER = Buffer.from([0x21, 0xf9, 0x04]); +const GIF_FRAME_MARKER = Buffer.from([ + 0x21, + 0xf9, + 0x04 +]); const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']; const SERVER_ICON_SYNC_TIMEOUT_MS = 45_000; @@ -77,6 +87,7 @@ test.describe('Server icon sync', () => { await new ServerSearchPage(alice.page).createServer(serverName, { description: 'Server icon synchronization E2E coverage' }); + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); await joinServerFromSearch(bob.page, serverName); @@ -263,15 +274,15 @@ async function openServerSettings(page: Page, serverName: string): Promise async function openSettingsModalThroughAngularDevMode(page: Page): Promise { await page.evaluate(() => { - type SettingsModalComponentHandle = { + interface SettingsModalComponentHandle { modal?: { open: (page: string) => void; }; - }; - type AngularDebugApi = { + } + interface AngularDebugApi { getComponent: (element: Element) => SettingsModalComponentHandle; applyChanges?: (component: SettingsModalComponentHandle) => void; - }; + } const host = document.querySelector('app-settings-modal'); const debugApi = (window as Window & { ng?: AngularDebugApi }).ng; @@ -373,33 +384,33 @@ async function retryTransientNavigation(navigate: () => Promise, attempts async function expectServerSettingsIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const settingsPanel = page.locator('app-server-settings'); - const image = settingsPanel.locator(`img[alt="${serverName} icon"]`).first(); + const image = settingsPanel.locator('[style*="background-image"]').first(); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'settings server icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'settings server icon'); } async function expectRoomHeaderIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const channelsPanel = page.locator('app-rooms-side-panel').first(); - const image = channelsPanel.locator(`img[alt="${serverName} icon"]`).first(); + const image = channelsPanel.locator('[style*="background-image"]').first(); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'room header server icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'room header server icon'); } async function expectRailIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { - const image = page.locator(`app-servers-rail img[alt="${serverName} icon"]`).first(); + const image = page.locator(`app-servers-rail button[title="${serverName}"] [style*="background-image"]`).first(); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'servers rail icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'servers rail icon'); } async function expectSearchResultIcon(page: Page, serverName: string, expectedDataUrl: string): Promise { const serverCard = page.locator('app-server-search div[title]', { hasText: serverName }).first(); - const image = serverCard.locator(`img[alt="${serverName} icon"]`).first(); + const image = serverCard.locator('[style*="background-image"]').first(); await expect(serverCard).toBeVisible({ timeout: 20_000 }); - await expectImageLoadedWithSrc(image, expectedDataUrl, 'search result server icon'); + await expectBackgroundImageLoadedWithUrl(image, expectedDataUrl, 'search result server icon'); } -async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, label: string): Promise { +async function expectBackgroundImageLoadedWithUrl(image: Locator, expectedDataUrl: string, label: string): Promise { await expect .poll( async () => { @@ -407,14 +418,14 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, return null; } - return image.getAttribute('src'); + return image.evaluate((element) => getComputedStyle(element).backgroundImage); }, { timeout: SERVER_ICON_SYNC_TIMEOUT_MS, - message: `${label} src should update` + message: `${label} background should update` } ) - .toBe(expectedDataUrl); + .toContain(expectedDataUrl); await expect .poll( @@ -423,11 +434,23 @@ async function expectImageLoadedWithSrc(image: Locator, expectedDataUrl: string, return false; } - return image.evaluate((element) => { - const img = element as HTMLImageElement; + return image.evaluate( + (element) => + new Promise((resolve) => { + const backgroundImage = getComputedStyle(element).backgroundImage; + const match = /^url\("?(.*?)"?\)$/.exec(backgroundImage); + const img = new Image(); - return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0; - }); + if (!match?.[1]) { + resolve(false); + return; + } + + img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0); + img.onerror = () => resolve(false); + img.src = match[1]; + }) + ); }, { timeout: SERVER_ICON_SYNC_TIMEOUT_MS, @@ -448,8 +471,22 @@ function buildGifUpload(label: string): ImageUploadPayload { const header = baseGif.subarray(0, frameStart); const frame = baseGif.subarray(frameStart, baseGif.length - 1); const commentData = Buffer.from(label, 'ascii'); - const commentExtension = Buffer.concat([Buffer.from([0x21, 0xfe, commentData.length]), commentData, Buffer.from([0x00])]); - const buffer = Buffer.concat([header, commentExtension, frame, Buffer.from([0x3b])]); + const commentExtension = Buffer.concat([ + Buffer.from([ + 0x21, + 0xfe, + commentData.length + ]), + commentData, + Buffer.from([0x00]) + ]); + const buffer = Buffer.concat([ + header, + commentExtension, + frame, + frame, + Buffer.from([0x3b]) + ]); const base64 = buffer.toString('base64'); return { @@ -461,5 +498,6 @@ function buildGifUpload(label: string): ImageUploadPayload { } function uniqueName(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36) + .slice(2, 8)}`; } diff --git a/e2e/tests/plugins/plugin-api-two-users.spec.ts b/e2e/tests/plugins/plugin-api-two-users.spec.ts index ee26bc7..6a19c96 100644 --- a/e2e/tests/plugins/plugin-api-two-users.spec.ts +++ b/e2e/tests/plugins/plugin-api-two-users.spec.ts @@ -28,9 +28,7 @@ test.describe('Plugin API multi-user runtime', () => { test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => { const scenario = await createPluginApiScenario(createClient); - await test.step('Install the server plugin as Alice', async () => { - await installGrantAndActivatePlugin(scenario.alice.page, true); - await closeSettingsModal(scenario.alice.page); + await test.step('Alice has the server plugin active', async () => { await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 }); await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin'); @@ -101,10 +99,13 @@ async function createPluginApiScenario(createClient: () => Promise): Pro const aliceRoom = new ChatRoomPage(alice.page); await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL); + await installGrantAndActivatePlugin(alice.page, true); + await closeSettingsModal(alice.page); + await expect(soundboardComposerButton(alice.page)).toBeVisible({ timeout: 20_000 }); const bobSearch = new ServerSearchPage(bob.page); - await bobSearch.joinServerFromSearch(serverName); + await bobSearch.joinServerFromSearch(serverName, { acceptPluginDownloads: true }); await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 }); const bobRoom = new ChatRoomPage(bob.page); diff --git a/electron/README.md b/electron/README.md index e292d47..30834ab 100644 --- a/electron/README.md +++ b/electron/README.md @@ -28,6 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo ## Notes - When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together. -- Plugin client data is stored in the local Electron SQLite database in the dedicated `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence. +- Plugin client data is stored in the local Electron SQLite database in the dedicated user-scoped `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence. - Treat `dist/electron/` and `dist-electron/` as generated output. - See [AGENTS.md](AGENTS.md) for package-level editing rules. diff --git a/electron/api/auth-store.ts b/electron/api/auth-store.ts index 36d6878..792ed47 100644 --- a/electron/api/auth-store.ts +++ b/electron/api/auth-store.ts @@ -11,7 +11,6 @@ export interface IssuedToken { } const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; - const tokens = new Map(); export function issueToken(params: { diff --git a/electron/api/docs-html.ts b/electron/api/docs-html.ts index 6b7bf0d..405aa96 100644 --- a/electron/api/docs-html.ts +++ b/electron/api/docs-html.ts @@ -59,6 +59,17 @@ export function getDocsHtml(specUrl: string): string { disabled: true } }; + const contentSecurityPolicy = [ + "default-src 'none'", + "script-src 'self' 'nonce-metoyou-local-api-docs'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'" + ].join('; '); return ` @@ -67,7 +78,7 @@ export function getDocsHtml(specUrl: string): string { MetoYou Local API