From cf43030104821a09bab2b8255d927d2e225b05e5 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sun, 19 Jul 2026 01:36:59 +0300 Subject: [PATCH] test: cover activity privacy and moderation e2e --- README.md | 12 +- docs/local-hardening-plan.md | 12 +- e2e/Dockerfile | 3 +- e2e/playwright.bootstrap.config.ts | 28 ++++ e2e/setup/admin-bootstrap.spec.ts | 18 +++ e2e/tests/activity-moderation.spec.ts | 200 ++++++++++++++++++++++++++ e2e/tests/helpers.ts | 62 ++++++-- scripts/e2e-run.sh | 14 ++ 8 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 e2e/playwright.bootstrap.config.ts create mode 100644 e2e/setup/admin-bootstrap.spec.ts create mode 100644 e2e/tests/activity-moderation.spec.ts diff --git a/README.md b/README.md index 830b0ec..8232b16 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,14 @@ two worker replicas: ``` 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 +and mode `0600`. The suite registers users through real Mailpit messages and +uses the audited one-time bootstrap command inside only the isolated E2E +database to create its moderator. It covers public/authentication boundaries; +the urgent medicine flow through matching, realtime chat, handover, and +double-blind reviews; and the Activity flow through join approval, private +group chat, message-scoped reporting, blocking, privacy defaults, an unverified +social link, category moderation, report resolution, and account restriction. +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. diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md index 47e8541..e37bb76 100644 --- a/docs/local-hardening-plan.md +++ b/docs/local-hardening-plan.md @@ -29,9 +29,15 @@ The goal remains open while any row lacks reproducible local evidence. two-user medicine flow: Mailpit confirmation, category-driven request, matching, bidirectional realtime chat, handover verification, bilateral completion, and double-blind review reveal. +- A separate browser scenario passes Activity creation, participant approval, + bidirectional private group chat, a report scoped to the selected message, + block/unblock, inherited location privacy, manual social-link removal, + category-proposal rejection, report resolution, account restriction, and + enforcement of that restriction. Its administrator is created in the + isolated database through the existing audited one-time bootstrap command. - 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. +- Android instrumentation and the remaining rows above are still pending. + Browser accessibility, responsive, offline, and reconnect checks remain part + of their respective later rows; this document is not a completion claim. diff --git a/e2e/Dockerfile b/e2e/Dockerfile index 76bbb79..8cfb497 100644 --- a/e2e/Dockerfile +++ b/e2e/Dockerfile @@ -5,7 +5,8 @@ WORKDIR /work COPY package.json package-lock.json ./ RUN npm ci --ignore-scripts -COPY playwright.config.ts ./ +COPY playwright.config.ts playwright.bootstrap.config.ts ./ COPY tests tests +COPY setup setup CMD ["npx", "playwright", "test"] diff --git a/e2e/playwright.bootstrap.config.ts b/e2e/playwright.bootstrap.config.ts new file mode 100644 index 0000000..81d3803 --- /dev/null +++ b/e2e/playwright.bootstrap.config.ts @@ -0,0 +1,28 @@ +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: "./setup", + outputDir: "./output/bootstrap-test-results", + workers: 1, + forbidOnly: true, + retries: 0, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: [["line"]], + use: { + baseURL, + ...devices["Desktop Chrome"], + actionTimeout: 10_000, + navigationTimeout: 20_000, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, +}); diff --git a/e2e/setup/admin-bootstrap.spec.ts b/e2e/setup/admin-bootstrap.spec.ts new file mode 100644 index 0000000..8e53dc1 --- /dev/null +++ b/e2e/setup/admin-bootstrap.spec.ts @@ -0,0 +1,18 @@ +import { test } from "@playwright/test"; +import { captureBrowserFailures, registerAndConfirm } from "../tests/helpers"; + +test("create the confirmed account used by the existing admin bootstrap", async ({ + browser, + request, +}) => { + const admin = await registerAndConfirm( + browser, + request, + "e2e-admin@example.invalid", + "E2E Administrator", + ); + const assertNoBrowserFailures = captureBrowserFailures(admin.page); + + assertNoBrowserFailures(); + await admin.context.close(); +}); diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts new file mode 100644 index 0000000..6424764 --- /dev/null +++ b/e2e/tests/activity-moderation.spec.ts @@ -0,0 +1,200 @@ +import { expect, test } from "@playwright/test"; +import { + captureBrowserFailures, + loginWithMagicLink, + registerAndConfirm, + selectOptionContaining, +} from "./helpers"; + +test("activity approval, privacy controls, reporting, and moderation work end to end", async ({ + browser, + request, +}) => { + const organizer = await registerAndConfirm( + browser, + request, + "activity-organizer@example.invalid", + "E2E Activity Organizer", + ); + const participant = await registerAndConfirm( + browser, + request, + "activity-participant@example.invalid", + "E2E Activity Participant", + ); + const assertOrganizerClean = captureBrowserFailures(organizer.page); + const assertParticipantClean = captureBrowserFailures(participant.page); + + await participant.page.goto("/moderation"); + await expect(participant.page).toHaveURL(/\/requests$/); + await expect(participant.page.getByText("Moderator access is required.")).toBeVisible(); + + await organizer.page.goto("/activities/new"); + await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea"); + await organizer.page.getByLabel("Preferred setting").selectOption("cafe"); + await organizer.page.getByLabel("Short title").fill("E2E public coffee meetup"); + await organizer.page + .getByLabel("Plan and expectations") + .fill("Meet at a public café for an hour of safe conversation."); + + const startsAt = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString().slice(0, 16); + const joinDeadline = new Date(Date.now() + 2 * 60 * 60 * 1000) + .toISOString() + .slice(0, 16); + await organizer.page.getByLabel("Starts at (UTC)").fill(startsAt); + await organizer.page.getByLabel("Join requests close (UTC)").fill(joinDeadline); + await organizer.page.getByLabel("Total group capacity").fill("3"); + await organizer.page + .getByLabel("Approximate public meeting area") + .fill("E2E central public café"); + await organizer.page.getByLabel("Public map visibility").selectOption("approximate_public"); + await organizer.page.getByLabel("Latitude").fill("50.4501"); + await organizer.page.getByLabel("Longitude").fill("30.5234"); + await organizer.page.locator("#activity-form input[type=checkbox]").check(); + await organizer.page.getByRole("button", { name: "Publish activity" }).click(); + await expect(organizer.page).toHaveURL(/\/activities\/[0-9a-f-]+$/); + const activityURL = organizer.page.url(); + + await participant.page.goto(activityURL); + await expect(participant.page.getByRole("heading", { name: "Approved group chat" })).toHaveCount( + 0, + ); + await participant.page.getByRole("button", { name: "Request to join" }).click(); + await expect(participant.page.getByText("Approval pending")).toBeVisible(); + await expect(organizer.page.getByText("E2E Activity Participant")).toBeVisible(); + + const organizerControls = organizer.page + .getByRole("heading", { name: "Organizer controls" }) + .locator(".."); + await organizerControls.getByRole("button", { name: "Approve" }).click(); + await expect( + participant.page.getByRole("heading", { name: "Approved group chat" }), + ).toBeVisible(); + + await participant.page + .getByPlaceholder("Message the approved group") + .fill("I will meet you by the public entrance."); + await participant.page.getByRole("button", { name: "Send", exact: true }).click(); + await expect(organizer.page.getByText("I will meet you by the public entrance.")).toBeVisible(); + + await organizer.page + .getByPlaceholder("Message the approved group") + .fill("Welcome to the E2E activity group."); + await organizer.page.getByRole("button", { name: "Send", exact: true }).click(); + await expect(participant.page.getByText("Welcome to the E2E activity group.")).toBeVisible(); + + const organizerMessage = participant.page + .locator("#activity-messages article") + .filter({ hasText: "Welcome to the E2E activity group." }); + await organizerMessage.getByRole("button", { name: "Report" }).click(); + const safetyControls = participant.page + .getByRole("heading", { name: "Safety controls" }) + .locator(".."); + await expect( + safetyControls.getByText("The report is scoped to the selected group message."), + ).toBeVisible(); + await safetyControls.getByLabel("Reason").selectOption("harassment"); + await safetyControls + .getByLabel("What happened?") + .fill("E2E moderation evidence for the selected activity message."); + await safetyControls.getByRole("button", { name: "Send report" }).click(); + await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible(); + + await participant.page.goto("/categories/proposals"); + await participant.page.getByLabel("Proposed category").fill("E2E quiet board games"); + await participant.page.getByLabel("Mode").selectOption("activity"); + await participant.page + .getByLabel("Why is this useful?") + .fill("A moderated public activity category for quiet board-game meetups."); + await participant.page.getByRole("button", { name: "Publish proposal" }).click(); + await expect( + participant.page.getByText("Proposal published for community voting."), + ).toBeVisible(); + + await participant.page.goto(activityURL); + await participant.page.getByRole("button", { name: "Block organizer" }).click(); + await expect(participant.page).toHaveURL(/\/activities$/); + await participant.page.goto("/profile"); + const blockedUsers = participant.page.getByRole("heading", { name: "Blocked users" }).locator(".."); + await expect(blockedUsers.getByText("E2E Activity Organizer")).toBeVisible(); + await blockedUsers.getByRole("button", { name: "Unblock" }).click(); + await expect(participant.page.getByText("User unblocked.")).toBeVisible(); + + await participant.page.getByLabel("Default location visibility").selectOption( + "exact_for_active_match", + ); + await participant.page + .getByLabel("Optional external thank-you link") + .fill("https://example.invalid/e2e-thanks"); + await participant.page.getByRole("button", { name: "Save profile" }).click(); + await expect(participant.page.getByText("Profile updated.")).toBeVisible(); + + await participant.page.getByLabel("Network").selectOption("telegram"); + await participant.page.getByLabel("Public profile URL").fill("https://t.me/e2e_participant"); + await participant.page.getByLabel("Handle (optional)").fill("@e2e_participant"); + await participant.page.getByRole("button", { name: "Add unverified link" }).click(); + await expect(participant.page.getByText("Social link added as unverified.")).toBeVisible(); + const socialSection = participant.page + .getByRole("heading", { name: "Social links" }) + .locator("xpath=ancestor::section"); + await expect(socialSection.getByText("telegram", { exact: true })).toBeVisible(); + await socialSection.getByRole("button", { name: "Remove" }).click(); + await expect(socialSection.getByText("No social links added.")).toBeVisible(); + + await participant.page.goto("/requests/new"); + await expect(participant.page.getByLabel("Location visibility")).toHaveValue( + "exact_for_active_match", + ); + + const admin = await loginWithMagicLink(browser, request, "e2e-admin@example.invalid"); + const assertAdminClean = captureBrowserFailures(admin.page); + await admin.page.goto("/moderation"); + await expect(admin.page.getByRole("heading", { name: "Moderation" })).toBeVisible(); + + const reportCard = admin.page + .locator("article") + .filter({ hasText: "E2E moderation evidence for the selected activity message." }); + await reportCard.getByRole("button", { name: "View scoped evidence" }).click(); + const evidence = admin.page + .getByRole("heading", { name: "Reported evidence" }) + .locator("xpath=ancestor::section"); + await expect(evidence.getByText("Welcome to the E2E activity group.")).toBeVisible(); + await evidence.getByRole("button", { name: "Close" }).click(); + await reportCard.getByRole("combobox").selectOption("resolved"); + await reportCard.getByPlaceholder("Resolution note").fill("Reviewed in isolated browser E2E."); + await reportCard.getByRole("button", { name: "Save" }).click(); + await expect(admin.page.getByText("Report updated.")).toBeVisible(); + + const proposalCard = admin.page + .locator("article") + .filter({ hasText: "E2E quiet board games" }); + await proposalCard.getByPlaceholder("Rejection note").fill("Covered by an existing category."); + await proposalCard.getByRole("button", { name: "Reject" }).click(); + await expect(admin.page.getByText("Proposal rejected.")).toBeVisible(); + + const participantRow = admin.page + .locator("tbody tr") + .filter({ hasText: "activity-participant@example.invalid" }); + await participantRow.getByRole("combobox").first().selectOption("restricted"); + await participantRow.getByPlaceholder("Internal note").fill("E2E restriction boundary."); + await participantRow.getByRole("button", { name: "Save" }).click(); + await expect(admin.page.getByText("User status updated.")).toBeVisible(); + + await participant.page.goto("/categories/proposals"); + await participant.page.getByLabel("Proposed category").fill("E2E restricted proposal"); + await participant.page.getByLabel("Mode").selectOption("help"); + await participant.page + .getByLabel("Why is this useful?") + .fill("This must be rejected because the account is restricted."); + await participant.page.getByRole("button", { name: "Publish proposal" }).click(); + await expect( + participant.page.getByText("Confirm your account and ensure it is active first."), + ).toBeVisible(); + + assertOrganizerClean(); + assertParticipantClean(); + assertAdminClean(); + await organizer.context.close(); + await participant.context.close(); + await admin.context.close(); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index f93d514..9d6f1ca 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -62,23 +62,34 @@ export function captureBrowserFailures(page: Page): () => void { return () => expect(failures, failures.join("\n")).toEqual([]); } -async function waitForMagicLink(request: APIRequestContext, email: string): Promise { +async function latestMessageID( + request: APIRequestContext, + email: string, +): Promise { + 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 }> }; + return body.messages?.[0]?.ID; +} + +async function waitForMagicLink( + request: APIRequestContext, + email: string, + previousMessageID?: 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; + messageID = await latestMessageID(request, email); + return messageID && messageID !== previousMessageID ? messageID : undefined; }, { message: `waiting for the Mailpit login message for ${email}`, @@ -138,6 +149,33 @@ export async function registerAndConfirm( return { context, page, email }; } +export async function loginWithMagicLink( + browser: Browser, + request: APIRequestContext, + email: string, +): Promise { + const context = await newIsolatedContext(browser); + const page = await context.newPage(); + const previousMessageID = await latestMessageID(request, email); + + await page.goto("/users/log-in"); + const form = page.locator("#login_form_magic"); + await form.getByLabel("Email").fill(email); + await form.getByRole("button", { name: "Log in with email" }).click(); + await expect( + page.getByText( + "If your email is in our system, you will receive instructions for logging in shortly.", + ), + ).toBeVisible(); + + await page.goto(await waitForMagicLink(request, email, previousMessageID)); + await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible(); + await page.getByRole("button", { name: "Log me 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, diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index 45d39e1..918f832 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -57,6 +57,20 @@ 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 \ + npx playwright test --config=playwright.bootstrap.config.ts + +admin_email="e2e-admin@example.invalid" +encoded_admin_email=$(printf %s "$admin_email" | base64 | tr -d '\n') +admin_expression="case Base.decode64!(\"$encoded_admin_email\") |> WhoNeedHelp.Release.bootstrap_admin() do {:ok, admin} -> IO.inspect({:ok, admin.email}); {:error, reason} -> raise \"Admin bootstrap failed: #{inspect(reason)}\" end" +web_container=$(compose ps -q web | head -n 1) + +if [ -z "$web_container" ]; then + echo "No E2E web replica was found for administrator bootstrap." >&2 + exit 1 +fi + +docker exec "$web_container" /app/bin/who_need_help rpc "$admin_expression" compose run --rm --no-deps e2e echo "Isolated browser E2E passed."