From 4ee2785299f0ab0dbdb5bb3590778a91eee20633 Mon Sep 17 00:00:00 2001 From: finja Date: Tue, 11 Aug 2026 22:16:04 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=85=20add=20frontend=20login=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/E2E/features/frontend-login.feature | 17 ++++++++++ Tests/E2E/helpers/pages/frontendLoginPage.ts | 34 ++++++++++++++++++++ Tests/E2E/helpers/state.ts | 13 ++++++++ Tests/E2E/helpers/system.ts | 20 +++++++++++- Tests/E2E/steps/frontend-login.steps.ts | 34 ++++++++++++++++++++ Tests/E2E/steps/hooks.ts | 8 ++++- 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 Tests/E2E/features/frontend-login.feature create mode 100644 Tests/E2E/helpers/pages/frontendLoginPage.ts create mode 100644 Tests/E2E/helpers/state.ts create mode 100644 Tests/E2E/steps/frontend-login.steps.ts diff --git a/Tests/E2E/features/frontend-login.feature b/Tests/E2E/features/frontend-login.feature new file mode 100644 index 0000000..2dae6ea --- /dev/null +++ b/Tests/E2E/features/frontend-login.feature @@ -0,0 +1,17 @@ +@frontend-login +Feature: Frontend login and logout + + Background: + Given an activated user "login-user@example.com" with password "Sup3rSecret!1" exists + + Scenario: A registered user can log in and log out + When I open the frontend login page + And I log in with email "login-user@example.com" and password "Sup3rSecret!1" + Then I should be logged in + When I log out via the frontend + Then I should be logged out + + Scenario: Logging in with a wrong password does not log the user in + When I open the frontend login page + And I log in with email "login-user@example.com" and password "wrong-password" + Then I should still see the login form diff --git a/Tests/E2E/helpers/pages/frontendLoginPage.ts b/Tests/E2E/helpers/pages/frontendLoginPage.ts new file mode 100644 index 0000000..772d5d8 --- /dev/null +++ b/Tests/E2E/helpers/pages/frontendLoginPage.ts @@ -0,0 +1,34 @@ +import type { Page } from "@playwright/test"; + +const USERNAME_FIELD = 'input[name="__authentication[Neos][Flow][Security][Authentication][Token][UsernamePassword][username]"]'; +const PASSWORD_FIELD = 'input[name="__authentication[Neos][Flow][Security][Authentication][Token][UsernamePassword][password]"]'; + +export default class FrontendLoginPage { + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto("/login"); + } + + async login(email: string, password: string) { + const form = this.page.locator('form[action="/login/authenticate"]'); + await form.locator(USERNAME_FIELD).fill(email); + await form.locator(PASSWORD_FIELD).fill(password); + await form.locator('input[type="submit"]').click(); + } + + async logout() { + // the logout form only renders on /login (via the ifAuthenticated viewhelper there) - navigate + // there first rather than assuming the caller is already on a page that has it + await this.goto(); + await this.page.locator('form[action="/logout"] input[type="submit"], form[action="/logout"] button[type="submit"]').click(); + } + + isLoggedIn() { + return this.page.locator('form[action="/logout"]'); + } + + isShowingLoginForm() { + return this.page.locator('form[action="/login/authenticate"]'); + } +} diff --git a/Tests/E2E/helpers/state.ts b/Tests/E2E/helpers/state.ts new file mode 100644 index 0000000..c92c56e --- /dev/null +++ b/Tests/E2E/helpers/state.ts @@ -0,0 +1,13 @@ +const createdEmails = new Set(); + +export function trackEmail(email: string) { + createdEmails.add(email); +} + +export function getTrackedEmails(): string[] { + return Array.from(createdEmails); +} + +export function clearTrackedEmails() { + createdEmails.clear(); +} diff --git a/Tests/E2E/helpers/system.ts b/Tests/E2E/helpers/system.ts index f61456d..395aa3a 100644 --- a/Tests/E2E/helpers/system.ts +++ b/Tests/E2E/helpers/system.ts @@ -1,6 +1,7 @@ import { execSync } from "node:child_process"; import { dirname } from "node:path"; import type { Page } from "@playwright/test"; +import { trackEmail } from "./state.ts"; const CONTAINER = `${process.env.SUT || "neos8"}-neos-1`; @@ -12,7 +13,9 @@ export function createUser(name: string, password: string, roles: string[]) { } export function removeAllUsers() { - execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:delete --assume-yes '*'"`, { + // `|| true`: exits non-zero when there's nothing to delete, which would otherwise abort the + // rest of the AfterScenario cleanup (e.g. scenarios that only create frontend/sandstorm users). + execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:delete --assume-yes '*' || true"`, { stdio: "ignore", cwd: dirname("."), }); @@ -21,3 +24,18 @@ export function removeAllUsers() { export async function logout(page: Page) { await page.context().request.post("/neos/logout"); } + +export function createActivatedUser(email: string, password: string) { + execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow sandstormuser:create '${email}' '${password}'"`, { + stdio: "ignore", + cwd: dirname("."), + }); + trackEmail(email); +} + +export function removeUser(email: string) { + execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow sandstormuser:remove '${email}' || true"`, { + stdio: "ignore", + cwd: dirname("."), + }); +} diff --git a/Tests/E2E/steps/frontend-login.steps.ts b/Tests/E2E/steps/frontend-login.steps.ts new file mode 100644 index 0000000..2cd160f --- /dev/null +++ b/Tests/E2E/steps/frontend-login.steps.ts @@ -0,0 +1,34 @@ +import { expect } from "@playwright/test"; +import { createBdd } from "playwright-bdd"; +import FrontendLoginPage from "../helpers/pages/frontendLoginPage.ts"; +import { createActivatedUser } from "../helpers/system.ts"; + +const { Given, When, Then } = createBdd(); + +Given("an activated user {string} with password {string} exists", async ({}, email: string, password: string) => { + createActivatedUser(email, password); +}); + +When("I open the frontend login page", async ({ page }) => { + await new FrontendLoginPage(page).goto(); +}); + +When("I log in with email {string} and password {string}", async ({ page }, email: string, password: string) => { + await new FrontendLoginPage(page).login(email, password); +}); + +When("I log out via the frontend", async ({ page }) => { + await new FrontendLoginPage(page).logout(); +}); + +Then("I should be logged in", async ({ page }) => { + await expect(new FrontendLoginPage(page).isLoggedIn()).toBeVisible(); +}); + +Then("I should be logged out", async ({ page }) => { + await expect(new FrontendLoginPage(page).isShowingLoginForm()).toBeVisible(); +}); + +Then("I should still see the login form", async ({ page }) => { + await expect(new FrontendLoginPage(page).isShowingLoginForm()).toBeVisible(); +}); diff --git a/Tests/E2E/steps/hooks.ts b/Tests/E2E/steps/hooks.ts index 4b85b1b..c0a49a2 100644 --- a/Tests/E2E/steps/hooks.ts +++ b/Tests/E2E/steps/hooks.ts @@ -1,5 +1,6 @@ import { createBdd } from "playwright-bdd"; -import { logout, removeAllUsers } from "../helpers/system.ts"; +import { logout, removeAllUsers, removeUser } from "../helpers/system.ts"; +import { getTrackedEmails, clearTrackedEmails } from "../helpers/state.ts"; const { AfterScenario } = createBdd(); @@ -8,4 +9,9 @@ AfterScenario(async ({ page }) => { await logout(page); removeAllUsers(); + + for (const email of getTrackedEmails()) { + removeUser(email); + } + clearTrackedEmails(); }); From 083cc8789b86ddf586900ce7f96563bb9064758b Mon Sep 17 00:00:00 2001 From: finja Date: Tue, 11 Aug 2026 22:51:18 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20add=20mail=20test=20setup=20wit?= =?UTF-8?q?h=20mail-related-tests=20(registration,=20activation,=20passwor?= =?UTF-8?q?d-reset)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/E2E/features/registration.feature | 31 +++++++++++ Tests/E2E/features/reset-password.feature | 25 +++++++++ Tests/E2E/helpers/mail.ts | 43 +++++++++++++++ Tests/E2E/helpers/pages/activationPage.ts | 17 ++++++ Tests/E2E/helpers/pages/registrationPage.ts | 30 +++++++++++ Tests/E2E/helpers/pages/resetPasswordPage.ts | 34 ++++++++++++ Tests/E2E/steps/hooks.ts | 4 ++ Tests/E2E/steps/registration.steps.ts | 54 +++++++++++++++++++ Tests/E2E/steps/reset-password.steps.ts | 38 +++++++++++++ .../sut-base-docker-compose.yaml | 10 ++++ .../Production/E2E-SUT/Settings.yaml | 12 +++++ 11 files changed, 298 insertions(+) create mode 100644 Tests/E2E/features/registration.feature create mode 100644 Tests/E2E/features/reset-password.feature create mode 100644 Tests/E2E/helpers/mail.ts create mode 100644 Tests/E2E/helpers/pages/activationPage.ts create mode 100644 Tests/E2E/helpers/pages/registrationPage.ts create mode 100644 Tests/E2E/helpers/pages/resetPasswordPage.ts create mode 100644 Tests/E2E/steps/registration.steps.ts create mode 100644 Tests/E2E/steps/reset-password.steps.ts diff --git a/Tests/E2E/features/registration.feature b/Tests/E2E/features/registration.feature new file mode 100644 index 0000000..839086f --- /dev/null +++ b/Tests/E2E/features/registration.feature @@ -0,0 +1,31 @@ +@registration +Feature: Registration and account activation + + Scenario: A new user can register and activate their account via the emailed link + When I open the registration form + And I register with email "newuser@example.com", password "Sup3rSecret!1", first name "Ada" and last name "Lovelace" + Then I should see the registration confirmation + When I open the activation link that was emailed to "newuser@example.com" + Then I should see the account activated + When I open the frontend login page + And I log in with email "newuser@example.com" and password "Sup3rSecret!1" + Then I should be logged in + + Scenario: Registering with mismatched password confirmation shows a validation error + When I open the registration form + And I register with email "mismatch@example.com", password "Sup3rSecret!1" and password confirmation "Different!2", first name "Ada" and last name "Lovelace" + Then I should still see the registration form + + Scenario: An already-used activation link no longer works + When I open the registration form + And I register with email "reused@example.com", password "Sup3rSecret!1", first name "Ada" and last name "Lovelace" + And I open the activation link that was emailed to "reused@example.com" + And I open the activation link that was emailed to "reused@example.com" + Then I should see that the activation link is not valid + + Scenario: An expired activation link no longer works + When I open the registration form + And I register with email "expired@example.com", password "Sup3rSecret!1", first name "Ada" and last name "Lovelace" + And I wait for the activation token to expire + And I open the activation link that was emailed to "expired@example.com" + Then I should see that the activation link is not valid diff --git a/Tests/E2E/features/reset-password.feature b/Tests/E2E/features/reset-password.feature new file mode 100644 index 0000000..661a1ea --- /dev/null +++ b/Tests/E2E/features/reset-password.feature @@ -0,0 +1,25 @@ +@reset-password +Feature: Forgot / reset password + + Background: + Given an activated user "reset-user@example.com" with password "OldSecret!1" exists + + Scenario: A user can reset their password via the emailed link + When I request a password reset for "reset-user@example.com" + Then I should see the password reset confirmation + When I open the password reset link that was emailed to "reset-user@example.com" + And I set a new password "NewSecret!2" + Then I should see the password was updated + When I open the frontend login page + And I log in with email "reset-user@example.com" and password "NewSecret!2" + Then I should be logged in + + Scenario: Requesting a reset for an unknown email does not reveal whether the account exists + When I request a password reset for "unknown@example.com" + Then I should see the password reset confirmation + + Scenario: An expired reset link no longer works + When I request a password reset for "reset-user@example.com" + And I wait for the reset token to expire + And I open the password reset link that was emailed to "reset-user@example.com" + Then I should see that the reset link is not valid diff --git a/Tests/E2E/helpers/mail.ts b/Tests/E2E/helpers/mail.ts new file mode 100644 index 0000000..58edd96 --- /dev/null +++ b/Tests/E2E/helpers/mail.ts @@ -0,0 +1,43 @@ +const MAILPIT_URL = process.env.MAILPIT_URL || "http://localhost:8025"; + +export type MailpitMessage = { + HTML: string; + Text: string; +}; + +type MailpitSearchResult = { + messages: { ID: string }[]; +}; + +/** + * Polls Mailpit for the most recent message sent to `recipient`. Mail delivery to Mailpit is + * asynchronous relative to the HTTP response that triggered it, so this needs to retry rather + * than assume the message is already there. + */ +export async function waitForEmailTo(recipient: string, { timeoutMs = 15_000, intervalMs = 500 } = {}): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const searchResponse = await fetch(`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${recipient}`)}`); + const searchResult = (await searchResponse.json()) as MailpitSearchResult; + const firstMessage = searchResult.messages?.[0]; + if (firstMessage) { + const messageResponse = await fetch(`${MAILPIT_URL}/api/v1/message/${firstMessage.ID}`); + return (await messageResponse.json()) as MailpitMessage; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`No email arrived for ${recipient} within ${timeoutMs}ms`); +} + +export function extractLink(message: MailpitMessage, pattern: RegExp): string { + const body = message.HTML || message.Text || ""; + const match = body.match(pattern); + if (!match) { + throw new Error(`No link matching ${pattern} found in email body:\n${body}`); + } + return match[0].replace(/&/g, "&"); +} + +export async function purgeMailbox() { + await fetch(`${MAILPIT_URL}/api/v1/messages`, { method: "DELETE" }); +} diff --git a/Tests/E2E/helpers/pages/activationPage.ts b/Tests/E2E/helpers/pages/activationPage.ts new file mode 100644 index 0000000..0064c40 --- /dev/null +++ b/Tests/E2E/helpers/pages/activationPage.ts @@ -0,0 +1,17 @@ +import type { Page } from "@playwright/test"; + +export default class ActivationPage { + constructor(private readonly page: Page) {} + + async open(link: string) { + await this.page.goto(link); + } + + isShowingSuccess() { + return this.page.locator(".callout.success"); + } + + isShowingError() { + return this.page.locator(".callout.alert"); + } +} diff --git a/Tests/E2E/helpers/pages/registrationPage.ts b/Tests/E2E/helpers/pages/registrationPage.ts new file mode 100644 index 0000000..73ef9f3 --- /dev/null +++ b/Tests/E2E/helpers/pages/registrationPage.ts @@ -0,0 +1,30 @@ +import type { Page } from "@playwright/test"; + +export default class RegistrationPage { + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto("/account/signup/index"); + } + + // NOTE: Index.html sets an explicit `name` override on the email field (to the Flow auth-token + // username field), but Fluid's form.textfield ignores that when `property` is also set - the + // field is actually submitted as `registrationFlow[email]`. + async register(email: string, password: string, firstName: string, lastName: string, passwordConfirmation = password) { + const form = this.page.locator('form[action="/account/signup/submit"]'); + await form.locator('[name="registrationFlow[email]"]').fill(email); + await form.locator('[name="registrationFlow[passwordDto][password]"]').fill(password); + await form.locator('[name="registrationFlow[passwordDto][passwordConfirmation]"]').fill(passwordConfirmation); + await form.locator('[name="registrationFlow[attributes][firstName]"]').fill(firstName); + await form.locator('[name="registrationFlow[attributes][lastName]"]').fill(lastName); + await form.locator('input[type="submit"]').click(); + } + + isShowingConfirmation() { + return this.page.locator(".callout.success"); + } + + isShowingForm() { + return this.page.locator('form[action="/account/signup/submit"]'); + } +} diff --git a/Tests/E2E/helpers/pages/resetPasswordPage.ts b/Tests/E2E/helpers/pages/resetPasswordPage.ts new file mode 100644 index 0000000..667ed96 --- /dev/null +++ b/Tests/E2E/helpers/pages/resetPasswordPage.ts @@ -0,0 +1,34 @@ +import type { Page } from "@playwright/test"; + +export default class ResetPasswordPage { + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto("/account/forgotpassword"); + } + + async requestReset(email: string) { + const form = this.page.locator('form[action="/account/requestpasswordtoken"]'); + await form.locator('[name="resetPasswordFlow[email]"]').fill(email); + await form.locator('input[type="submit"]').click(); + } + + async open(link: string) { + await this.page.goto(link); + } + + async setNewPassword(password: string) { + const form = this.page.locator('form[action="/account/updatepassword"]'); + await form.locator('[name="resetPasswordFlow[passwordDto][password]"]').fill(password); + await form.locator('[name="resetPasswordFlow[passwordDto][passwordConfirmation]"]').fill(password); + await form.locator('input[type="submit"]').click(); + } + + isShowingSuccess() { + return this.page.locator(".callout.success"); + } + + isShowingError() { + return this.page.locator(".callout.alert"); + } +} diff --git a/Tests/E2E/steps/hooks.ts b/Tests/E2E/steps/hooks.ts index c0a49a2..946ec94 100644 --- a/Tests/E2E/steps/hooks.ts +++ b/Tests/E2E/steps/hooks.ts @@ -1,6 +1,7 @@ import { createBdd } from "playwright-bdd"; import { logout, removeAllUsers, removeUser } from "../helpers/system.ts"; import { getTrackedEmails, clearTrackedEmails } from "../helpers/state.ts"; +import { purgeMailbox } from "../helpers/mail.ts"; const { AfterScenario } = createBdd(); @@ -14,4 +15,7 @@ AfterScenario(async ({ page }) => { removeUser(email); } clearTrackedEmails(); + + // so a later scenario's waitForEmailTo search can't pick up a stale message from this run + await purgeMailbox(); }); diff --git a/Tests/E2E/steps/registration.steps.ts b/Tests/E2E/steps/registration.steps.ts new file mode 100644 index 0000000..5e805d1 --- /dev/null +++ b/Tests/E2E/steps/registration.steps.ts @@ -0,0 +1,54 @@ +import { expect } from "@playwright/test"; +import { createBdd } from "playwright-bdd"; +import RegistrationPage from "../helpers/pages/registrationPage.ts"; +import ActivationPage from "../helpers/pages/activationPage.ts"; +import { waitForEmailTo, extractLink } from "../helpers/mail.ts"; +import { trackEmail } from "../helpers/state.ts"; + +const { When, Then } = createBdd(); + +When("I open the registration form", async ({ page }) => { + await new RegistrationPage(page).goto(); +}); + +When( + "I register with email {string}, password {string}, first name {string} and last name {string}", + async ({ page }, email: string, password: string, firstName: string, lastName: string) => { + await new RegistrationPage(page).register(email, password, firstName, lastName); + trackEmail(email); + }, +); + +When( + "I register with email {string}, password {string} and password confirmation {string}, first name {string} and last name {string}", + async ({ page }, email: string, password: string, passwordConfirmation: string, firstName: string, lastName: string) => { + await new RegistrationPage(page).register(email, password, firstName, lastName, passwordConfirmation); + trackEmail(email); + }, +); + +Then("I should see the registration confirmation", async ({ page }) => { + await expect(new RegistrationPage(page).isShowingConfirmation()).toBeVisible(); +}); + +Then("I should still see the registration form", async ({ page }) => { + await expect(new RegistrationPage(page).isShowingForm()).toBeVisible(); +}); + +When("I open the activation link that was emailed to {string}", async ({ page }, email: string) => { + const message = await waitForEmailTo(email); + const link = extractLink(message, /https?:\/\/[^"'\s]*\/account\/activate\/[^"'\s]+/); + await new ActivationPage(page).open(link); +}); + +Then("I should see the account activated", async ({ page }) => { + await expect(new ActivationPage(page).isShowingSuccess()).toBeVisible(); +}); + +Then("I should see that the activation link is not valid", async ({ page }) => { + await expect(new ActivationPage(page).isShowingError()).toBeVisible(); +}); + +When("I wait for the activation token to expire", async () => { + await new Promise((resolve) => setTimeout(resolve, 7_000)); +}); diff --git a/Tests/E2E/steps/reset-password.steps.ts b/Tests/E2E/steps/reset-password.steps.ts new file mode 100644 index 0000000..966d0d4 --- /dev/null +++ b/Tests/E2E/steps/reset-password.steps.ts @@ -0,0 +1,38 @@ +import { expect } from "@playwright/test"; +import { createBdd } from "playwright-bdd"; +import ResetPasswordPage from "../helpers/pages/resetPasswordPage.ts"; +import { waitForEmailTo, extractLink } from "../helpers/mail.ts"; + +const { When, Then } = createBdd(); + +When("I request a password reset for {string}", async ({ page }, email: string) => { + const resetPasswordPage = new ResetPasswordPage(page); + await resetPasswordPage.goto(); + await resetPasswordPage.requestReset(email); +}); + +Then("I should see the password reset confirmation", async ({ page }) => { + await expect(new ResetPasswordPage(page).isShowingSuccess()).toBeVisible(); +}); + +When("I open the password reset link that was emailed to {string}", async ({ page }, email: string) => { + const message = await waitForEmailTo(email); + const link = extractLink(message, /https?:\/\/[^"'\s]*\/account\/resetpassword\/[^"'\s]+/); + await new ResetPasswordPage(page).open(link); +}); + +When("I set a new password {string}", async ({ page }, password: string) => { + await new ResetPasswordPage(page).setNewPassword(password); +}); + +Then("I should see the password was updated", async ({ page }) => { + await expect(new ResetPasswordPage(page).isShowingSuccess()).toBeVisible(); +}); + +Then("I should see that the reset link is not valid", async ({ page }) => { + await expect(new ResetPasswordPage(page).isShowingError()).toBeVisible(); +}); + +When("I wait for the reset token to expire", async () => { + await new Promise((resolve) => setTimeout(resolve, 7_000)); +}); diff --git a/Tests/E2E/system_under_test/sut-base-docker-compose.yaml b/Tests/E2E/system_under_test/sut-base-docker-compose.yaml index 6341029..571577b 100644 --- a/Tests/E2E/system_under_test/sut-base-docker-compose.yaml +++ b/Tests/E2E/system_under_test/sut-base-docker-compose.yaml @@ -41,6 +41,7 @@ services: depends_on: - db - redis + - mailpit db: image: mariadb:10.11 @@ -65,6 +66,15 @@ services: networks: - neos_SUT + # catches all outgoing mail (registration activation / password reset links) so tests can read it via its REST API + mailpit: + image: axllent/mailpit:latest + restart: always + networks: + - neos_SUT + ports: + - "8025:8025" + volumes: db_neos_data: name: db_neos_data diff --git a/Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml b/Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml index 28bf875..0b39df9 100644 --- a/Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml +++ b/Tests/E2E/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml @@ -1,3 +1,10 @@ +Sandstorm: + UserManagement: + # shortened so the "expired link" scenarios don't have to wait for the production + # defaults (2 days / 4 hours) + activationTokenTimeout: '6 seconds' + resetPasswordTokenTimeout: '6 seconds' + Neos: Flow: persistence: @@ -12,5 +19,10 @@ Neos: cache: applicationIdentifier: 'app' + SymfonyMailer: + mailer: + # route all outgoing mail (activation / password reset links) to the Mailpit catcher + dsn: 'smtp://mailpit:1025' + Imagine: driver: Gd