From f8412ee0906746aa7731f2c53f6d115838dfee9d Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sun, 19 Jul 2026 01:28:28 +0300 Subject: [PATCH] test: add isolated browser end-to-end flow --- .dockerignore | 1 + .env.e2e.example | 45 +++++++++ .gitignore | 2 + Dockerfile | 3 + README.md | 17 ++++ compose.e2e.yaml | 36 +++++++ config/prod.exs | 10 +- docs/local-hardening-plan.md | 37 ++++++++ e2e/Dockerfile | 11 +++ e2e/package-lock.json | 78 ++++++++++++++++ e2e/package.json | 12 +++ e2e/playwright.config.ts | 42 +++++++++ e2e/tests/helpers.ts | 160 ++++++++++++++++++++++++++++++++ e2e/tests/mutual-aid.spec.ts | 109 ++++++++++++++++++++++ e2e/tests/public.spec.ts | 24 +++++ scripts/e2e-run.sh | 63 +++++++++++++ scripts/e2e-stop.sh | 26 ++++++ scripts/ensure-local-e2e-env.sh | 60 ++++++++++++ 18 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 .env.e2e.example create mode 100644 compose.e2e.yaml create mode 100644 docs/local-hardening-plan.md create mode 100644 e2e/Dockerfile create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/helpers.ts create mode 100644 e2e/tests/mutual-aid.spec.ts create mode 100644 e2e/tests/public.spec.ts create mode 100755 scripts/e2e-run.sh create mode 100755 scripts/e2e-stop.sh create mode 100755 scripts/ensure-local-e2e-env.sh diff --git a/.dockerignore b/.dockerignore index 418e1df..42d01ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -41,6 +41,7 @@ erl_crash.dump # Static artifacts - These should be fetched and built inside the Docker image # https://phoenix.hexdocs.pm/Mix.Tasks.Phx.Gen.Release.html#module-docker /assets/node_modules/ +/e2e/node_modules/ /priv/static/assets/ /priv/static/cache_manifest.json /android/.gradle/ diff --git a/.env.e2e.example b/.env.e2e.example new file mode 100644 index 0000000..b4fdb93 --- /dev/null +++ b/.env.e2e.example @@ -0,0 +1,45 @@ +# This file documents the isolated browser-E2E inputs. The executable workflow +# generates .env.e2e with independent local secrets and mode 0600. It never +# reuses the staging Compose project or database volume. +HTTP_PORT=0 +MAILPIT_PORT=0 +TRAEFIK_TRUSTED_IPS=127.0.0.1/32 +TRAEFIK_PROJECT_CONSTRAINT=GENERATED_UNIQUE_E2E_PROJECT +TRAEFIK_APP_NAME=GENERATED_UNIQUE_E2E_ROUTER +TRAEFIK_DOCKER_NETWORK=GENERATED_UNIQUE_E2E_NETWORK +TRAEFIK_ROUTER_RULE='PathPrefix(`/`)' + +PHX_HOST=proxy +PHX_SCHEME=http +PHX_URL_PORT=80 +MAP_TILE_URL=http://tiles.e2e.invalid/{z}/{x}/{y}.png + +POSTGRES_DB=who_need_help_e2e +POSTGRES_USER=postgres +POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD +DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e +POOL_SIZE=10 + +SECRET_KEY_BASE=GENERATE_INDEPENDENT_E2E_SECRET_KEY_BASE +HANDOVER_SECRET=GENERATE_INDEPENDENT_E2E_HANDOVER_SECRET +RELEASE_COOKIE=GENERATE_INDEPENDENT_E2E_RELEASE_COOKIE +METRICS_TOKEN=GENERATE_INDEPENDENT_E2E_METRICS_TOKEN + +SMTP_RELAY=mailpit +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_AUTH=never +SMTP_TLS=never +SMTP_SSL=false +EMAIL_FROM_NAME="Who Need Help E2E" +EMAIL_FROM_ADDRESS=e2e@example.invalid + +CODEX_SESSION_ID=local-e2e +RATE_LIMIT_POLICIES_JSON={} +GITHUB_OAUTH_CLIENT_ID= +GITHUB_OAUTH_CLIENT_SECRET= + +E2E_BASE_URL=http://proxy +E2E_MAILPIT_URL=http://mailpit:8025 +E2E_OUTPUT_DIR=GENERATED_ABSOLUTE_IGNORED_OUTPUT_DIRECTORY diff --git a/.gitignore b/.gitignore index 9abe519..753cb8f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ who_need_help-*.tar # In case you use Node.js/npm, you want to ignore these. npm-debug.log /assets/node_modules/ +/e2e/node_modules/ /.tools/ # Local environment files can contain deployment credentials. Keep only the @@ -44,6 +45,7 @@ npm-debug.log /.env.* !/.env.example !/.env.load.example +!/.env.e2e.example # Local browser automation state and generated verification artifacts. /.playwright-cli/ diff --git a/Dockerfile b/Dockerfile index aaf4265..5c6ccac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,9 @@ COPY mix.exs mix.lock ./ RUN mix deps.get --only $MIX_ENV RUN mkdir config +ARG WNH_E2E_SSL_EXCLUDE_HOST= +ENV WNH_E2E_SSL_EXCLUDE_HOST="${WNH_E2E_SSL_EXCLUDE_HOST}" + # copy compile-time config files before we compile dependencies # to ensure any relevant config change will trigger the dependencies # to be re-compiled. diff --git a/README.md b/README.md index 7c8ef1b..830b0ec 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,23 @@ project's PostGIS service: It creates/updates only the project-scoped `who_need_help_test` database. +The browser E2E command creates a uniquely named, isolated Compose project with +its own PostGIS volume, Mailpit instance, Traefik proxy, two web replicas, and +two worker replicas: + +```bash +./scripts/e2e-run.sh +``` + +On its first run it generates `.env.e2e` with independent random local secrets +and mode `0600`. The suite registers two users through real Mailpit messages, +completes an urgent medicine request through matching, realtime chat, handover, +and double-blind reviews, and checks public/authentication boundaries. It +retains traces, screenshots, video, and Compose logs under the ignored +`output/e2e/` directory on failure. The exact E2E project, database volume, and +networks are removed automatically; the normal `who_need_help` Compose project +is not recreated. + ## First administrator Register and confirm the first account, then explicitly bootstrap it: diff --git a/compose.e2e.yaml b/compose.e2e.yaml new file mode 100644 index 0000000..6eebb8d --- /dev/null +++ b/compose.e2e.yaml @@ -0,0 +1,36 @@ +services: + migrate: + image: who-need-help:e2e + pull_policy: never + build: + args: + WNH_E2E_SSL_EXCLUDE_HOST: proxy + + web: + image: who-need-help:e2e + pull_policy: never + + worker: + image: who-need-help:e2e + pull_policy: never + + e2e: + image: who-need-help-e2e-tests:local + build: + context: e2e + init: true + ipc: host + environment: + BASE_URL: ${E2E_BASE_URL:?Set E2E_BASE_URL in the generated E2E environment} + MAILPIT_URL: ${E2E_MAILPIT_URL:?Set E2E_MAILPIT_URL in the generated E2E environment} + CI: ${CI:-} + NO_PROXY: web,mailpit,db,proxy,localhost,127.0.0.1 + no_proxy: web,mailpit,db,proxy,localhost,127.0.0.1 + depends_on: + web: + condition: service_healthy + mailpit: + condition: service_started + volumes: + - ${E2E_OUTPUT_DIR:?Set an ignored E2E_OUTPUT_DIR}:/work/output + networks: [internal] diff --git a/config/prod.exs b/config/prod.exs index 0c0e59b..6451221 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -11,12 +11,20 @@ config :who_need_help, WhoNeedHelpWeb.Endpoint, # Force using SSL in production. This also sets the "strict-security-transport" header, # known as HSTS. If you have a health check endpoint, you may want to exclude it below. # Note `:force_ssl` is required to be set at compile-time. +force_ssl_hosts = + case System.get_env("WNH_E2E_SSL_EXCLUDE_HOST") do + nil -> ["localhost", "127.0.0.1"] + "" -> ["localhost", "127.0.0.1"] + "proxy" -> ["localhost", "127.0.0.1", "proxy"] + other -> raise "unsupported WNH_E2E_SSL_EXCLUDE_HOST: #{inspect(other)}" + end + config :who_need_help, WhoNeedHelpWeb.Endpoint, force_ssl: [ rewrite_on: [:x_forwarded_proto], exclude: [ paths: ["/healthz/live", "/healthz/ready"], - hosts: ["localhost", "127.0.0.1"] + hosts: force_ssl_hosts ] ] diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md new file mode 100644 index 0000000..47e8541 --- /dev/null +++ b/docs/local-hardening-plan.md @@ -0,0 +1,37 @@ +# Who Need Help — local hardening completion plan + +This plan deliberately excludes buying a domain, renting another server, and +real third-party credentials. The existing local Docker/kind environments and +the temporary `whoneedhelp.imalto.site` staging origin are sufficient for every +item below unless the evidence column explicitly describes a local mock. + +| Area | Observed baseline | Local completion evidence | +| --- | --- | --- | +| Browser E2E | Manual headed-Chrome scenarios exist; no committed browser suite | A fresh uniquely named Compose project runs two-user urgent help, Activity, moderation, privacy, and error paths; traces are retained on failure; its exact volume is removed | +| Android UI | Two JVM unit-test files; no `androidTest` source set | Emulator instrumentation covers deep links, permissions, foreground tracking, notification Stop, lifecycle, and network failure | +| CI and quality | No tracked CI workflow or static/security analysis dependencies | The same containerized gates pass locally and are represented in a validated CI workflow | +| Localization and accessibility | Navigation/error Gettext coverage exists, but most product copy is hard-coded English | EN/UK/RU catalogs cover product UI; extraction is current; automated accessibility, keyboard, responsive, and contrast checks pass | +| Database scale | Core discovery/chat/moderation lists call unbounded `Repo.all()` | Cursor-bounded queries pass behavior tests and measured `EXPLAIN ANALYZE` checks on an isolated generated dataset | +| Load and resilience | Public/readiness/heartbeat k6 profile exists | Authenticated writes, chat, tracking, reconnect, rolling replacement, and worker retry profiles pass without touching staging data | +| Observability | Protected Prometheus text endpoint exists | Local Prometheus/Grafana/Alertmanager profile scrapes every replica and an induced isolated failure exercises alert delivery | +| Backup | Validated local custom-format dump and restore drill exist | An encrypted artifact is uploaded to local S3-compatible MinIO and restored into a fresh database; corruption and interrupted-upload checks fail closed | +| External boundaries | Mailpit and a fake GitHub strategy cover parts of SMTP/OAuth | Local protocol-level SMTP/OAuth mocks and the applicable push adapter boundary cover success, rejection, retry, replay, and timeout | +| Final regression | 149 Phoenix tests and manual cross-client evidence | Browser, Android, API, DB, WebSocket, backup, monitoring, failure, cleanup, docs, and clean Git are verified from the final commits | + +The goal remains open while any row lacks reproducible local evidence. + +## Verified progress + +- The isolated browser harness now generates independent local secrets, starts + two web and two worker replicas behind Traefik, and removes only its uniquely + named Compose project and PostGIS volume. +- The browser suite passes public/authentication boundaries and the complete + two-user medicine flow: Mailpit confirmation, category-driven request, + matching, bidirectional realtime chat, handover verification, bilateral + completion, and double-blind review reveal. +- Browser console errors, page errors, and unexpected failed requests are + test failures. Playwright traces, screenshots, video, JSON/HTML reports, and + Compose logs are retained in ignored output on failure. +- Activity, moderation, privacy/error paths, Android instrumentation, and the + remaining rows above are still pending; this document is not a completion + claim. diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 0000000..76bbb79 --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,11 @@ +FROM mcr.microsoft.com/playwright:v1.61.1-noble@sha256:5b8f294aff9041b7191c34a4bab3ac270157a28774d4b0660e9743297b697e48 + +WORKDIR /work + +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY playwright.config.ts ./ +COPY tests tests + +CMD ["npx", "playwright", "test"] diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..b4644b8 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "who-need-help-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "who-need-help-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.61.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..fe49536 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "who-need-help-e2e", + "private": true, + "version": "0.1.0", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..62eda2e --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.BASE_URL; + +if (!baseURL) { + throw new Error("BASE_URL is required"); +} + +export default defineConfig({ + testDir: "./tests", + outputDir: "./output/test-results", + fullyParallel: false, + workers: 1, + forbidOnly: true, + retries: 0, + timeout: 90_000, + expect: { + timeout: 10_000, + }, + reporter: [ + ["line"], + ["html", { outputFolder: "./output/html-report", open: "never" }], + ["json", { outputFile: "./output/results.json" }], + ], + use: { + baseURL, + ...devices["Desktop Chrome"], + actionTimeout: 10_000, + navigationTimeout: 20_000, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { + browserName: "chromium", + }, + }, + ], +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 0000000..f93d514 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,160 @@ +import { + APIRequestContext, + Browser, + BrowserContext, + expect, + Page, +} from "@playwright/test"; + +const mailpitURL = process.env.MAILPIT_URL; +const baseURL = process.env.BASE_URL; + +if (!mailpitURL || !baseURL) { + throw new Error("MAILPIT_URL and BASE_URL are required"); +} + +const tilePng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAA1BMVEXu8vcBVSTwAAAAH0lEQVRo3u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABfxmcpwAAAABJRU5ErkJggg==", + "base64", +); + +export type AuthenticatedBrowser = { + context: BrowserContext; + page: Page; + email: string; +}; + +export async function newIsolatedContext(browser: Browser): Promise { + const context = await browser.newContext(); + + await context.route("http://tiles.e2e.invalid/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "image/png", + body: tilePng, + }); + }); + + return context; +} + +export function captureBrowserFailures(page: Page): () => void { + const failures: string[] = []; + + page.on("console", (message) => { + if (message.type() === "error") { + failures.push(`console: ${message.text()}`); + } + }); + + page.on("pageerror", (error) => { + failures.push(`pageerror: ${error.message}`); + }); + + page.on("requestfailed", (request) => { + const url = request.url(); + + if (!url.startsWith("http://tiles.e2e.invalid/")) { + failures.push(`requestfailed: ${request.method()} ${url} ${request.failure()?.errorText}`); + } + }); + + return () => expect(failures, failures.join("\n")).toEqual([]); +} + +async function waitForMagicLink(request: APIRequestContext, email: string): Promise { + let messageID: string | undefined; + + await expect + .poll( + async () => { + const response = await request.get(`${mailpitURL}/api/v1/search`, { + params: { + query: `to:"${email}"`, + limit: "10", + }, + }); + + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { messages?: Array<{ ID?: string }> }; + messageID = body.messages?.[0]?.ID; + return messageID; + }, + { + message: `waiting for the Mailpit login message for ${email}`, + timeout: 15_000, + }, + ) + .toBeTruthy(); + + const response = await request.get(`${mailpitURL}/api/v1/message/${messageID}`); + expect(response.ok()).toBeTruthy(); + + const message = (await response.json()) as { + Text?: string; + HTML?: string; + }; + const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`; + const match = content.match(/https?:\/\/[^\s"'<>]+\/users\/log-in\/[^\s"'<>]+/); + + if (!match) { + throw new Error(`No magic login link found for ${email}`); + } + + const link = new URL(match[0].replaceAll("&", "&")); + const applicationOrigin = new URL(baseURL); + link.protocol = applicationOrigin.protocol; + link.host = applicationOrigin.host; + + return link.toString(); +} + +export async function registerAndConfirm( + browser: Browser, + request: APIRequestContext, + email: string, + displayName: string, +): Promise { + const context = await newIsolatedContext(browser); + const page = await context.newPage(); + + await page.goto("/users/register"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Display name").fill(displayName); + await page + .getByLabel("I am 18 or older and accept the safety rules") + .check(); + await page.getByRole("button", { name: "Create an account" }).click(); + await expect(page).toHaveURL(/\/users\/log-in$/); + await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible(); + + await page.goto(await waitForMagicLink(request, email)); + await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible(); + await page + .getByRole("button", { name: "Confirm and log in only this time" }) + .click(); + await expect(page.getByText(email, { exact: true })).toBeVisible(); + + return { context, page, email }; +} + +export async function selectOptionContaining( + page: Page, + label: string, + expectedText: string, +): Promise { + const select = page.getByLabel(label); + const options = await select.locator("option").evaluateAll((nodes) => + nodes.map((node) => ({ + text: node.textContent?.trim() ?? "", + value: (node as HTMLOptionElement).value, + })), + ); + const option = options.find(({ text }) => text.includes(expectedText)); + + if (!option) { + throw new Error(`No ${label} option contains ${expectedText}`); + } + + await select.selectOption(option.value); +} diff --git a/e2e/tests/mutual-aid.spec.ts b/e2e/tests/mutual-aid.spec.ts new file mode 100644 index 0000000..4f89111 --- /dev/null +++ b/e2e/tests/mutual-aid.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from "@playwright/test"; +import { + captureBrowserFailures, + registerAndConfirm, + selectOptionContaining, +} from "./helpers"; + +test("two users complete a medicine handover with realtime chat and blind reviews", async ({ + browser, + request, +}) => { + const requester = await registerAndConfirm( + browser, + request, + "requester@example.invalid", + "E2E Requester", + ); + const helper = await registerAndConfirm( + browser, + request, + "helper@example.invalid", + "E2E Helper", + ); + const assertRequesterClean = captureBrowserFailures(requester.page); + const assertHelperClean = captureBrowserFailures(helper.page); + + await requester.page.goto("/requests/new"); + await selectOptionContaining(requester.page, "Category", "Medicine pickup"); + await requester.page.getByLabel("Medicine pickup status").selectOption("reserved"); + await requester.page.getByLabel("Short title").fill("E2E medicine pickup"); + await requester.page + .getByLabel("What help do you need?") + .fill("Please collect the legal medicine that is already reserved."); + await requester.page.getByLabel("Urgency").selectOption("now"); + const expiresAt = new Date(Date.now() + 3 * 60 * 60 * 1000) + .toISOString() + .slice(0, 16); + await requester.page.getByLabel("Request expires (UTC)").fill(expiresAt); + await requester.page.getByLabel("Approximate area").fill("E2E central district"); + await requester.page.getByLabel("Location visibility").selectOption("approximate_public"); + await requester.page.getByLabel("Latitude").fill("50.4501"); + await requester.page.getByLabel("Longitude").fill("30.5234"); + await requester.page.locator("#request-form input[type=checkbox]").check(); + await requester.page.getByRole("button", { name: "Publish request" }).click(); + await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/); + const requestURL = requester.page.url(); + await expect(requester.page.getByRole("heading", { name: "E2E medicine pickup" })).toBeVisible(); + + await helper.page.goto(requestURL); + await helper.page.getByRole("button", { name: "I can help" }).click(); + await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); + await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible(); + + await helper.page + .getByPlaceholder("Write a safe coordination message…") + .fill("I am on my way to the pharmacy."); + await helper.page.getByRole("button", { name: "Send", exact: true }).click(); + await expect(requester.page.getByText("I am on my way to the pharmacy.")).toBeVisible(); + + await requester.page + .getByPlaceholder("Write a safe coordination message…") + .fill("Thank you. The order is waiting at the counter."); + await requester.page.getByRole("button", { name: "Send", exact: true }).click(); + await expect(helper.page.getByText("Thank you. The order is waiting at the counter.")).toBeVisible(); + + await helper.page.getByRole("button", { name: "Start helping" }).click(); + const handoverCode = ( + await requester.page.locator("text=HANDOVER CODE FOR THE HELPER").locator("..").locator(".font-mono").innerText() + ).replace(/\s/g, ""); + expect(handoverCode).toMatch(/^\d{6}$/); + + await helper.page.getByLabel("Enter handover code").fill(handoverCode); + await helper.page.getByRole("button", { name: "Verify handover" }).click(); + await expect(helper.page.getByText("Handover code verified.")).toBeVisible(); + + await helper.page.getByRole("button", { name: "Confirm my part is complete" }).click(); + await requester.page.getByRole("button", { name: "Confirm my part is complete" }).click(); + await expect(requester.page.getByRole("heading", { name: "Double-blind review" })).toBeVisible(); + await expect(helper.page.getByRole("heading", { name: "Double-blind review" })).toBeVisible(); + + await helper.page.getByLabel("Rating").selectOption("5"); + await helper.page.getByLabel("Comment (optional)").fill("Clear and safe coordination."); + await helper.page.getByRole("button", { name: "Submit review" }).click(); + await expect( + helper.page.getByText("Review saved. It appears after both participants review."), + ).toBeVisible(); + + await requester.page.goto("/profile"); + await expect(requester.page.getByText("None revealed yet.")).toBeVisible(); + await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0); + await requester.page.goto(requestURL); + + await requester.page.getByLabel("Rating").selectOption("5"); + await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer."); + await requester.page.getByRole("button", { name: "Submit review" }).click(); + await expect( + requester.page.getByText("Review saved. It appears after both participants review."), + ).toBeVisible(); + + await requester.page.goto("/profile"); + await expect(requester.page.getByText("Clear and safe coordination.")).toBeVisible(); + await helper.page.goto("/profile"); + await expect(helper.page.getByText("Reliable volunteer.")).toBeVisible(); + + assertRequesterClean(); + assertHelperClean(); + await requester.context.close(); + await helper.context.close(); +}); diff --git a/e2e/tests/public.spec.ts b/e2e/tests/public.spec.ts new file mode 100644 index 0000000..1ddbc31 --- /dev/null +++ b/e2e/tests/public.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; +import { captureBrowserFailures } from "./helpers"; + +test("public safety pages load and private discovery requires authentication", async ({ + page, +}) => { + const assertNoBrowserFailures = captureBrowserFailures(page); + + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Help can be closer than you think." }), + ).toBeVisible(); + await expect(page.getByText("Not an emergency or medical service.")).toBeVisible(); + + await page.getByRole("link", { name: "Safety rules" }).click(); + await expect(page.getByRole("heading", { name: "Safety rules" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Medicine requests" })).toBeVisible(); + + await page.getByRole("link", { name: "Requests" }).click(); + await expect(page).toHaveURL(/\/users\/log-in$/); + await expect(page.getByText("You must log in to access this page.")).toBeVisible(); + + assertNoBrowserFailures(); +}); diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh new file mode 100755 index 0000000..45d39e1 --- /dev/null +++ b/scripts/e2e-run.sh @@ -0,0 +1,63 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +"$ROOT/scripts/ensure-local-e2e-env.sh" + +run_id="$(date -u +%Y%m%d%H%M%S)-$$" +project="who_need_help_e2e_$run_id" +output_dir="$ROOT/output/e2e/$run_id" + +case "$project" in + who_need_help_e2e_*) ;; + *) + echo "Refusing unexpected E2E project name: $project" >&2 + exit 1 + ;; +esac + +mkdir -p "$output_dir" + +export TRAEFIK_PROJECT_CONSTRAINT="$project" +export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id" +export TRAEFIK_DOCKER_NETWORK="${project}_internal" +export E2E_OUTPUT_DIR="$output_dir" +export CODEX_SESSION_ID=${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-local-e2e}} + +compose() { + docker compose \ + --project-name "$project" \ + --env-file "$ROOT/.env.e2e" \ + --file "$ROOT/compose.yaml" \ + --file "$ROOT/compose.e2e.yaml" \ + "$@" +} + +cleanup() { + status=$? + + if [ "$status" -ne 0 ]; then + compose ps --all >"$output_dir/compose-ps.txt" 2>&1 || true + compose logs --no-color >"$output_dir/compose.log" 2>&1 || true + fi + + if [ "${KEEP_E2E_STACK:-0}" = "1" ]; then + echo "Keeping isolated E2E project $project for inspection." + echo "Artifacts: $output_dir" + else + compose down --volumes --remove-orphans >/dev/null + fi + + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +compose config --quiet +compose up -d --build --wait db mailpit migrate proxy web worker +compose build e2e +compose run --rm --no-deps e2e + +echo "Isolated browser E2E passed." +echo "Artifacts: $output_dir" diff --git a/scripts/e2e-stop.sh b/scripts/e2e-stop.sh new file mode 100755 index 0000000..4c93e9a --- /dev/null +++ b/scripts/e2e-stop.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 who_need_help_e2e_RUN_ID" >&2 + exit 1 +fi + +project=$1 + +case "$project" in + who_need_help_e2e_*) ;; + *) + echo "Refusing to stop a non-E2E Compose project: $project" >&2 + exit 1 + ;; +esac + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +docker compose \ + --project-name "$project" \ + --env-file "$ROOT/.env.e2e" \ + --file "$ROOT/compose.yaml" \ + --file "$ROOT/compose.e2e.yaml" \ + down --volumes --remove-orphans diff --git a/scripts/ensure-local-e2e-env.sh b/scripts/ensure-local-e2e-env.sh new file mode 100755 index 0000000..727c728 --- /dev/null +++ b/scripts/ensure-local-e2e-env.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu +umask 077 + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +target="$ROOT/.env.e2e" + +if [ -f "$target" ]; then + chmod 600 "$target" + echo ".env.e2e already exists; no secret was changed." + exit 0 +fi + +postgres_password=$(openssl rand -hex 32) +secret_key_base=$(openssl rand -hex 64) +handover_secret=$(openssl rand -hex 64) +release_cookie=$(openssl rand -hex 64) +metrics_token=$(openssl rand -hex 32) + +cat >"$target" <