202 lines
8.6 KiB
TypeScript
202 lines
8.6 KiB
TypeScript
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([]);
|
||
|
|
});
|
||
|
|
});
|