diff --git a/integration/tests/helpers/ent.ts b/integration/tests/helpers/ent.ts new file mode 100644 index 000000000..b0088c814 --- /dev/null +++ b/integration/tests/helpers/ent.ts @@ -0,0 +1,209 @@ +/** + * Helpers for the enterprise-pack specs (22-ent-*.spec.ts). They run against + * a local dev or production server on a VM 106 test Stalwart with the + * throwaway ent-* accounts only. They never reset an account and never touch + * usera/userb/userc, review, e2e-*, org-* or ha-* accounts. + * + * Start them with C:\Users\info\worktrees\enterprise-rig\run-spec.sh, which + * checks the ent-* accounts on the VM first and sets: + * IT_WEBMAIL_URL the Bulwark server (scratch ADMIN_CONFIG_DIR) + * IT_JMAP_URL the Stalwart server + * IT_ENT_PASSWORD password of the ent-* accounts (vault "Stalwart test – ent-* throwaway accounts") + * IT_ADMIN_PASSWORD the Bulwark server's admin password (vault "Bulwark dev rig – enterprise pack admin") + * IT_ENT_SERVER label for screenshots and logs: 0.16 or 1.0 + * IT_ACCOUNT_USERS ent-alice,ent-bob,ent-carol (the suite's global setup polls the first one) + * + * Setup goes through the admin API and JMAP, never through admin pages, so a + * spec also runs against a build of main to show it can fail there. + */ +import { type Browser, type BrowserContext, type Page, expect } from '@playwright/test'; +import { existsSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { WEBMAIL_URL, JMAP_URL } from './config'; +import { accountSwitcher, neutralizeDevOverlay } from './app'; +import { JmapClient } from './jmap'; + +export const ENT_PASSWORD = process.env.IT_ENT_PASSWORD ?? ''; +export const ADMIN_PASSWORD = process.env.IT_ADMIN_PASSWORD ?? ''; +export const ENT_SERVER = process.env.IT_ENT_SERVER ?? 'unknown'; + +export const ENT = { + alice: 'ent-alice@example.org', + bob: 'ent-bob@example.org', + carol: 'ent-carol@example.org', + dave: 'ent-dave@example.org', + sec: 'ent-sec@example.org', + team: 'ent-team@example.org', +} as const; + +/** + * Call at the top of every ent spec: refuses to run without the passwords + * or with the suite's accounts configured (the suite's global setup would + * poll an account that doesn't exist and get this PC banned). + */ +export function requireEntEnv(): void { + if (!ENT_PASSWORD || !ADMIN_PASSWORD) { + throw new Error('Set IT_ENT_PASSWORD and IT_ADMIN_PASSWORD (use enterprise-rig/run-spec.sh) to run the ent specs'); + } + const users = (process.env.IT_ACCOUNT_USERS ?? '').split(',').filter(Boolean); + if (users.length === 0 || users.some((u) => !u.startsWith('ent-'))) { + throw new Error('IT_ACCOUNT_USERS must name ent-* accounts only (use enterprise-rig/run-spec.sh)'); + } +} + +/** English UI (Playwright's default locale gives German) at desktop or phone width. */ +export function contextOptions(phone = false) { + return { + baseURL: WEBMAIL_URL, + locale: 'en-US', + viewport: phone ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ...(phone ? { isMobile: true, hasTouch: true } : {}), + }; +} + +// Not under integration/test-results: Playwright empties that folder on +// every run, which would cost an admin login per run (5 per 15 minutes). +const stateDir = path.join(tmpdir(), 'bulwark-ent-it'); +const adminStatePath = () => path.join(stateDir, `ent-admin-${new URL(WEBMAIL_URL).port || '80'}.json`); + +/** A browser context signed in to /admin through the API, stored and reused across runs. */ +export async function adminContext(browser: Browser, phone = false): Promise { + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + if (existsSync(adminStatePath())) { + const context = await browser.newContext({ ...contextOptions(phone), storageState: adminStatePath() }); + const res = await context.request.get('/api/admin/auth'); + if (res.ok() && (await res.json().catch(() => ({})))?.authenticated !== false) return context; + await context.close(); + } + const context = await browser.newContext(contextOptions(phone)); + const res = await context.request.post('/api/admin/auth', { + data: { password: ADMIN_PASSWORD }, + headers: { Origin: new URL(WEBMAIL_URL).origin }, + }); + expect(res.ok(), `admin sign-in: ${res.status()}`).toBe(true); + await context.storageState({ path: adminStatePath() }); + return context; +} + +/** The policy as the admin sees it. */ +export async function getPolicy(admin: BrowserContext): Promise> { + const res = await admin.request.get('/api/admin/policy'); + expect(res.ok()).toBe(true); + return res.json(); +} + +export async function putPolicy(admin: BrowserContext, policy: Record): Promise { + const res = await admin.request.put('/api/admin/policy', { data: policy }); + expect(res.ok(), await res.text()).toBe(true); +} + +/** + * Runs `fn` with `organisation` (and optionally `scopes`/`restrictions`) + * merged into the policy, and puts the saved policy back afterwards, also + * when `fn` fails. + */ +export async function withPolicy( + admin: BrowserContext, + patch: { organisation?: Record; scopes?: unknown[]; restrictions?: Record }, + fn: () => Promise, +): Promise { + const saved = await getPolicy(admin); + const next = { + ...saved, + ...(patch.organisation ? { organisation: { ...(saved.organisation as object ?? {}), ...patch.organisation } } : {}), + ...(patch.scopes ? { scopes: patch.scopes } : {}), + ...(patch.restrictions ? { restrictions: { ...(saved.restrictions as object ?? {}), ...patch.restrictions } } : {}), + }; + await putPolicy(admin, next); + try { + return await fn(); + } finally { + await putPolicy(admin, saved); + } +} + +/** Sign in a fresh context as one of the ent-* accounts. */ +export async function userPage(browser: Browser, email: string, phone = false): Promise { + if (!email.startsWith('ent-')) throw new Error(`userPage: ${email} is not an ent-* account`); + const context = await browser.newContext(contextOptions(phone)); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', email); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); + return page; +} + +/** Screenshots for local-data/enterprise-pack-screenshots/, named by package, feature and server. Standard theme. */ +export async function shot(page: Page, name: string): Promise { + const dir = process.env.IT_ENT_SCREENSHOTS; + if (!dir) return; + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + await page.screenshot({ path: path.join(dir, `${name}-${ENT_SERVER}.png`), fullPage: false }); +} + +const clients = new Map>(); + +/** A JMAP client for setup and server-side checks, one per account and run. */ +export function jmapAs(email: string): Promise { + if (!email.startsWith('ent-')) throw new Error(`jmapAs: ${email} is not an ent-* account`); + let client = clients.get(email); + if (!client) { + client = JmapClient.connect(email, ENT_PASSWORD); + clients.set(email, client); + } + return client; +} + +const SUBMISSION = 'urn:ietf:params:jmap:submission'; +const CORE = 'urn:ietf:params:jmap:core'; +const MAIL = 'urn:ietf:params:jmap:mail'; + +/** + * Delivers a message between ent-* accounts with JMAP EmailSubmission (SMTP + * on the VM 106 containers has no TLS, so the suite's sendMail times out). + * `headers` are raw header values, e.g. { 'SIO-Label': 'marking="INTERNAL"' }. + */ +export async function deliver(opts: { + from: string; to: string[]; subject: string; text?: string; html?: string; headers?: Record; +}): Promise { + const client = await jmapAs(opts.from); + const identities = await client.identities(); + const identity = identities.find((i) => i.email === opts.from) ?? identities[0]; + const drafts = await client.mailboxByRole('drafts'); + const sent = await client.mailboxByRole('sent'); + const create: Record = { + mailboxIds: { [drafts!.id]: true }, + keywords: { $draft: true, $seen: true }, + from: [{ email: opts.from }], + to: opts.to.map((email) => ({ email })), + subject: opts.subject, + bodyValues: { t: { value: opts.text ?? 'Test message' }, ...(opts.html ? { h: { value: opts.html } } : {}) }, + textBody: [{ partId: 't', type: 'text/plain' }], + ...(opts.html ? { htmlBody: [{ partId: 'h', type: 'text/html' }] } : {}), + }; + for (const [name, value] of Object.entries(opts.headers ?? {})) create[`header:${name}:asRaw`] = ` ${value}`; + const res = await client.request([ + ['Email/set', { accountId: client.accountId, create: { m: create } }, '0'], + ['EmailSubmission/set', { + accountId: client.accountId, + create: { s: { identityId: identity.id, emailId: '#m' } }, + onSuccessUpdateEmail: { '#s': { [`mailboxIds/${drafts!.id}`]: null, [`mailboxIds/${sent!.id}`]: true, 'keywords/$draft': null } }, + }, '1'], + ], [CORE, MAIL, SUBMISSION]); + const created = res.methodResponses?.[0]?.[1]?.created?.m; + const submitted = res.methodResponses?.[1]?.[1]?.created?.s; + if (!created || !submitted) throw new Error(`deliver failed: ${JSON.stringify(res.methodResponses).slice(0, 600)}`); +} + +/** The raw RFC 5322 text of a message, by blobId, from the account's download URL. */ +export async function rawMessage(email: string, blobId: string): Promise { + const client = await jmapAs(email); + const url = `${JMAP_URL}/jmap/download/${client.accountId}/${blobId}/m.eml?accept=message/rfc822`; + const res = await fetch(url, { headers: { Authorization: 'Basic ' + Buffer.from(`${email}:${ENT_PASSWORD}`).toString('base64') } }); + if (!res.ok) throw new Error(`download ${blobId}: ${res.status}`); + return res.text(); +}