Files
cinny/e2e/e2ee-composer.spec.ts
T

202 lines
8.6 KiB
TypeScript
Raw Normal View History

import { test, expect, Page, Request } from '@playwright/test';
import { collectConsole, generateJpeg } from './helpers';
// Tier 2 — E2EE composer smoke (Gitea #90). Needs a real homeserver account
// that supports `m.login.password`, supplied via env (CI secrets, see
// LOTUS_TESTING.md). Skips cleanly when unset so the boot tier still gates CI.
//
// E2E_HOMESERVER server name as typed in the login page, e.g. matrix.example.org
// E2E_USER localpart or full MXID
// E2E_PASSWORD password
//
// Every run logs in as a fresh device (fresh browser context), so the account
// accumulates one device per run — use a throwaway test account.
const HOMESERVER = process.env.E2E_HOMESERVER;
const USER = process.env.E2E_USER;
const PASSWORD = process.env.E2E_PASSWORD;
const HAS_CREDENTIALS = Boolean(HOMESERVER && USER && PASSWORD);
type SentEvent = { url: string; body: Record<string, unknown> };
/** Records every `PUT .../send/<type>/<txn>` the client makes. */
function recordSentEvents(page: Page): SentEvent[] {
const sent: SentEvent[] = [];
page.on('request', (req: Request) => {
if (
req.method() !== 'PUT' ||
!/\/_matrix\/client\/[^/]+\/rooms\/[^/]+\/send\//.test(req.url())
) {
return;
}
let body: Record<string, unknown> = {};
try {
body = JSON.parse(req.postData() ?? '{}');
} catch {
// leave empty; the assertion below will surface it
}
sent.push({ url: req.url(), body });
});
return sent;
}
const eventTypeOf = (url: string): string =>
decodeURIComponent(url.match(/\/send\/([^/]+)\//)?.[1] ?? '');
test.describe('E2EE composer', () => {
test.skip(!HAS_CREDENTIALS, 'needs E2E_HOMESERVER / E2E_USER / E2E_PASSWORD');
// The three scenarios build on one another (login → room → messages), so
// share a single page and run them in order.
test.describe.configure({ mode: 'serial' });
test.setTimeout(120_000);
let page: Page;
let sentEvents: SentEvent[];
let consoleLog: ReturnType<typeof collectConsole>;
let roomUrl: string;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
consoleLog = collectConsole(page);
sentEvents = recordSentEvents(page);
});
test.afterAll(async () => {
await page?.close();
});
test('logs in with a password and reaches the client', async () => {
await page.goto(`/login/${encodeURIComponent(HOMESERVER as string)}/`);
await page.getByLabel('Username or email').fill(USER as string);
await page.getByLabel('Password', { exact: true }).fill(PASSWORD as string);
await page.getByRole('button', { name: 'Login' }).click();
// Leaving /login/ means the session was stored and the client mounted.
await expect(page).not.toHaveURL(/\/login\//, { timeout: 60_000 });
// The client shell mounts at /home/ (or the last-visited space) once the
// session is restored and initial sync starts.
await expect(page).toHaveURL(/\/(home|direct|explore|inbox|!|#)/, { timeout: 60_000 });
await expect(page.locator('#root > *').first()).toBeAttached();
expect(consoleLog.pageErrors, 'uncaught page errors during login').toEqual([]);
});
test('creates a private encrypted room and sends a text message', async () => {
const roomName = `e2e-smoke-${Date.now()}`;
const createRoomRequest = page.waitForRequest(
(req) => req.method() === 'POST' && /\/_matrix\/client\/[^/]+\/createRoom/.test(req.url()),
);
await page.goto('/home/create/');
const form = page.locator('form').filter({ has: page.locator('input[name="nameInput"]') });
await expect(form).toBeVisible();
await form.locator('input[name="nameInput"]').fill(roomName);
// Default access is Private (or Restricted, which also allows E2EE); the
// encryption switch lives in the "End-to-End Encryption" setting tile.
const encryptionSwitch = form
.getByText('End-to-End Encryption', { exact: true })
.locator('xpath=ancestor::div[.//*[@role="switch"]][1]')
.getByRole('switch');
await expect(encryptionSwitch).toBeVisible();
if ((await encryptionSwitch.getAttribute('aria-checked')) !== 'true') {
await encryptionSwitch.click();
}
await expect(encryptionSwitch).toHaveAttribute('aria-checked', 'true');
await form.getByRole('button', { name: 'Create' }).click();
// The createRoom request itself must ask for encryption up front.
const createBody = JSON.parse((await createRoomRequest).postData() ?? '{}') as {
initial_state?: { type: string; content?: { algorithm?: string } }[];
};
const encryptionState = createBody.initial_state?.find((s) => s.type === 'm.room.encryption');
expect(encryptionState?.content?.algorithm, 'createRoom initial_state m.room.encryption').toBe(
'm.megolm.v1.aes-sha2',
);
// Landed in the new room.
await expect(page).toHaveURL(/\/home\/!/, { timeout: 30_000 });
roomUrl = page.url();
await expect(page.getByText(roomName, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const text = `hello from playwright ${Date.now()}`;
const composer = page.getByRole('textbox', { name: 'Send a message...' });
await expect(composer).toBeVisible();
await composer.click();
await composer.fill(text);
await composer.press('Enter');
await expect(page.getByText(text, { exact: true })).toBeVisible({ timeout: 30_000 });
const messageSends = sentEvents.filter((e) => eventTypeOf(e.url).startsWith('m.room.'));
expect(messageSends.length, 'at least one room event sent').toBeGreaterThan(0);
for (const e of messageSends) {
expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted');
expect(e.body).toHaveProperty('ciphertext');
expect(e.body).not.toHaveProperty('body');
expect(JSON.stringify(e.body)).not.toContain(text);
}
expect(consoleLog.pageErrors, 'uncaught page errors while sending text').toEqual([]);
});
test('attaches a compressed image and it is sent encrypted', async () => {
await expect(page).toHaveURL(roomUrl);
const fileName = `lotus-e2e-${Date.now()}.jpg`;
const jpeg = await generateJpeg(page);
const sentBefore = sentEvents.length;
// The composer opens a detached <input type=file> via selectFile(); the
// file chooser event is the hook Playwright gives us for that.
const fileChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Attach file' }).click();
await (await fileChooser).setFiles({ name: fileName, mimeType: 'image/jpeg', buffer: jpeg });
// Upload board: tick "Compress image before uploading", then Send.
const compressSwitch = page
.getByText('Compress image before uploading', { exact: true })
.locator('xpath=ancestor::div[.//*[@role="switch"]][1]')
.getByRole('switch');
await expect(compressSwitch).toBeVisible({ timeout: 30_000 });
if ((await compressSwitch.getAttribute('aria-checked')) !== 'true') {
await compressSwitch.click();
}
await expect(compressSwitch).toHaveAttribute('aria-checked', 'true');
// compressImage() runs asynchronously once ticked; wait for it to settle
// so the Send picks up the compressed result.
await expect(page.getByText('compressing…')).toHaveCount(0, { timeout: 30_000 });
await page.getByRole('button', { name: 'Send', exact: true }).click();
// The timeline shows the image (alt/title = file body; compression
// renames to .jpg which our name already is).
const image = page.locator(`img[alt="${fileName}"]`);
const viewButton = page.getByRole('button', { name: 'View', exact: true });
await expect(image.or(viewButton).first()).toBeVisible({ timeout: 60_000 });
if (!(await image.count())) {
// Media auto-load disabled — click through and wait for the image.
await viewButton.first().click();
}
await expect(image.first()).toBeVisible({ timeout: 60_000 });
// Every room event sent for the image was encrypted: no plaintext
// m.room.message with a `url`/`file`/`body`.
const newSends = sentEvents
.slice(sentBefore)
.filter((e) => eventTypeOf(e.url).startsWith('m.room.'));
expect(newSends.length, 'image produced at least one room event').toBeGreaterThan(0);
for (const e of newSends) {
expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted');
expect(e.body).toHaveProperty('ciphertext');
expect(e.body).not.toHaveProperty('url');
expect(e.body).not.toHaveProperty('file');
expect(e.body).not.toHaveProperty('body');
expect(JSON.stringify(e.body)).not.toContain('mxc://');
}
expect(consoleLog.pageErrors, 'uncaught page errors while sending image').toEqual([]);
});
});