diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..77bbe70 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,56 @@ +name: E2E Tests + +on: + push: + branches: [7.0] + pull_request: + branches: [7.0] + +jobs: + e2e: + name: E2E Tests (Neos 8) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version-file: Tests/E2E/.nvmrc + cache: npm + cache-dependency-path: Tests/E2E/package-lock.json + + - name: Install dependencies + working-directory: Tests/E2E + run: npm ci + + - name: Install Playwright browsers + working-directory: Tests/E2E + run: npx playwright install --with-deps chromium + + - name: Pre-build Docker image + run: docker compose -f Tests/system_under_test/neos8/docker-compose.yaml build --pull + + - name: Test - registration + working-directory: Tests/E2E + run: npm run test:registration + + - name: Test - login + working-directory: Tests/E2E + run: npm run test:login + + - name: Test - reset password + working-directory: Tests/E2E + run: npm run test:reset-password + + - name: Test - profile + working-directory: Tests/E2E + run: npm run test:profile + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: Tests/E2E/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 485dee6..e6abe13 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ -.idea +# 3rd party sources +Packages/ +vendor/ + +# composer +composer.lock + +# IDEs +.idea/ diff --git a/README.md b/README.md index b48092d..f0048a7 100644 --- a/README.md +++ b/README.md @@ -370,9 +370,11 @@ class RegistrationFlowValidationService implements RegistrationFlowValidationSer ``` # 4. Running Tests -Run all tests with: +Run all unit tests with: `./bin/phpunit -c ./Build/BuildEssentials/PhpUnit/UnitTests.xml Packages/Application/Sandstorm.UserManagement/Tests/Unit` +There is also a Playwright/BDD end-to-end test suite covering registration, login/logout, password reset and profile editing against a Dockerised Neos instance — see [`Tests/README.md`](Tests/README.md) for setup and usage. + # 5. Known issues Feel free to submit issues/PRs :) diff --git a/Tests/E2E/.gitignore b/Tests/E2E/.gitignore new file mode 100644 index 0000000..cb5de66 --- /dev/null +++ b/Tests/E2E/.gitignore @@ -0,0 +1,7 @@ +# 3rd party sources +node_modules/ + +# transient test files +.features-gen/ +test-results/ +playwright-report/ diff --git a/Tests/E2E/.nvmrc b/Tests/E2E/.nvmrc new file mode 100644 index 0000000..a3b7a31 --- /dev/null +++ b/Tests/E2E/.nvmrc @@ -0,0 +1 @@ +v24.14.1 diff --git a/Tests/E2E/.prettierrc.yaml b/Tests/E2E/.prettierrc.yaml new file mode 100644 index 0000000..0e3ebcd --- /dev/null +++ b/Tests/E2E/.prettierrc.yaml @@ -0,0 +1 @@ +# using prettier defaults diff --git a/Tests/E2E/features/login/login-logout.feature b/Tests/E2E/features/login/login-logout.feature new file mode 100644 index 0000000..eacee6d --- /dev/null +++ b/Tests/E2E/features/login/login-logout.feature @@ -0,0 +1,17 @@ +@login +Feature: 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 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 + Then I should be logged out + + Scenario: Logging in with a wrong password does not log the user in + When I open the 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/features/profile/edit-profile.feature b/Tests/E2E/features/profile/edit-profile.feature new file mode 100644 index 0000000..f389151 --- /dev/null +++ b/Tests/E2E/features/profile/edit-profile.feature @@ -0,0 +1,16 @@ +@profile +Feature: Edit profile + + Background: + Given an activated user "profile-user@example.com" with password "Sup3rSecret!1" exists + + Scenario: A logged-in user changes their password via the profile page and can log in with it + When I open the login page + And I log in with email "profile-user@example.com" and password "Sup3rSecret!1" + And I open the profile page + And I set a new profile password "Ch4ngedSecret!2" + Then I should be back on the profile page + When I log out + And I open the login page + And I log in with email "profile-user@example.com" and password "Ch4ngedSecret!2" + Then I should be logged in diff --git a/Tests/E2E/features/registration/register-and-activate.feature b/Tests/E2E/features/registration/register-and-activate.feature new file mode 100644 index 0000000..ed097f0 --- /dev/null +++ b/Tests/E2E/features/registration/register-and-activate.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 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/forgot-reset-password.feature b/Tests/E2E/features/reset-password/forgot-reset-password.feature new file mode 100644 index 0000000..69b5b46 --- /dev/null +++ b/Tests/E2E/features/reset-password/forgot-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 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/global-teardown.ts b/Tests/E2E/global-teardown.ts new file mode 100644 index 0000000..c4621fa --- /dev/null +++ b/Tests/E2E/global-teardown.ts @@ -0,0 +1,11 @@ +import { execSync } from "node:child_process"; +import { dirname } from "node:path"; + +const SUT = process.env.SUT; + +export default async function globalTeardown() { + execSync(`docker compose -f ../system_under_test/${SUT}/docker-compose.yaml down -v`, { + stdio: "inherit", + cwd: dirname("."), + }); +} 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.ts b/Tests/E2E/helpers/pages.ts new file mode 100644 index 0000000..de4b0a0 --- /dev/null +++ b/Tests/E2E/helpers/pages.ts @@ -0,0 +1,125 @@ +import type { Page } from "@playwright/test"; + +// Flow's default auth token field names - used by both the Login form and the Registration form +// (the Registration form's email field name is intentionally overridden to this, see Index.html) +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 class LoginPage { + 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 (e.g. /profile) + 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"]'); + } +} + +export class RegistrationPage { + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto("/account/signup/index"); + } + + async register(email: string, password: string, passwordConfirmation: string, firstName: string, lastName: string) { + const form = this.page.locator('form[action="/account/signup/submit"]'); + // NOTE: Index.html sets an explicit `name` override on this 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]`, confirmed via the rendered HTML. + 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(); + } + + isShowingForm() { + return this.page.locator('form[action="/account/signup/submit"]'); + } +} + +export class ActivationPage { + constructor(private readonly page: Page) {} + + async open(link: string) { + await this.page.goto(link); + } +} + +export 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); + } + + isShowingResetForm() { + return this.page.locator('form[action="/account/updatepassword"]'); + } + + 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(); + } +} + +export class ProfilePage { + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto("/profile"); + } + + async setNewPassword(password: string) { + const form = this.page.locator('form[action="/profile/password"]'); + await form.locator('#password\\[0\\]').fill(password); + await form.locator('#password\\[1\\]').fill(password); + await form.locator('button, input[type="submit"]').click(); + } +} + +export class SuccessOrAlertBanner { + constructor(private readonly page: Page) {} + + isSuccess() { + return this.page.locator(".callout.success"); + } + + isAlert() { + return this.page.locator(".callout.alert"); + } +} 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 new file mode 100644 index 0000000..630f084 --- /dev/null +++ b/Tests/E2E/helpers/system.ts @@ -0,0 +1,21 @@ +import { execSync } from "node:child_process"; +import { trackEmail } from "./state.ts"; + +const CONTAINER = `${process.env.SUT || "neos8"}-neos-1`; + +/** + * Creates and directly activates a user via the package's own CLI (Sandstorm.UserManagement's + * registration/activation flow is its own domain concept, not Flow's generic `user:create`). + */ +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", + }); + 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", + }); +} diff --git a/Tests/E2E/package-lock.json b/Tests/E2E/package-lock.json new file mode 100644 index 0000000..6f0022b --- /dev/null +++ b/Tests/E2E/package-lock.json @@ -0,0 +1,511 @@ +{ + "name": "sandstorm-usermanagement-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sandstorm-usermanagement-e2e", + "dependencies": { + "@playwright/test": "^1.58.2", + "playwright-bdd": "^9.2.0" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "prettier": "^3.8.1", + "typescript": "^6.0.2" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cucumber/ci-environment": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-13.0.0.tgz", + "integrity": "sha512-cs+3NzfNkGbcmHPddjEv4TKFiBpZRQ6WJEEufB9mw+ExS22V/4R/zpDSEG+fsJ/iSNCd6A2sATdY8PFOyY3YnA==", + "license": "MIT" + }, + "node_modules/@cucumber/cucumber-expressions": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-19.0.0.tgz", + "integrity": "sha512-4FKoOQh2Uf6F6/Ln+1OxuK8LkTg6PyAqekhf2Ix8zqV2M54sH+m7XNJNLhOFOAW/t9nxzRbw2CcvXbCLjcvHZg==", + "license": "MIT", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "39.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-39.1.0.tgz", + "integrity": "sha512-pqmSO2bUWxJm3TbNrKXlDaHjL6c77+ez9kWmfCd9oRPeTRPEVH3spZvpAqdXYWOZYSNYwWFCAAeZ4RGpkauNoQ==", + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=31.0.0 <33" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-11.0.0.tgz", + "integrity": "sha512-LJ+s4+TepHTgdKWDR4zbPyT7rQjmYIcukTwNbwNwgqr6i8Gjcmzf6NmtbYDA19m1ZFg6kWbFsmHnj37ZuX+kZA==", + "license": "MIT", + "dependencies": { + "@cucumber/gherkin": "^38.0.0", + "@cucumber/messages": "^32.0.0", + "@teppeis/multimaps": "3.0.0", + "commander": "14.0.2", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "38.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-38.0.0.tgz", + "integrity": "sha512-duEXK+KDfQUzu3vsSzXjkxQ2tirF5PRsc1Xrts6THKHJO6mjw4RjM8RV+vliuDasmhhrmdLcOcM7d9nurNTJKw==", + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=31.0.0 <33" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "23.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-23.1.0.tgz", + "integrity": "sha512-DcCSFoGs6jbwzXPgX1CwgJKEE+ZMcIEzq/0Memg0o24maNn9NJizBFHmoFWG4iv/OxHza+mvc+56cTHetfHndw==", + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/junit-xml-formatter": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.13.3.tgz", + "integrity": "sha512-w9ujOxiuKDtU6fLzJz+wp4Sgp5Xu6ba7ls00LHJccVmQU0Ba7zs+AHnv3iIgPjKZAQe1w8x93dr8Gaubh7Vqkg==", + "license": "MIT", + "dependencies": { + "@cucumber/query": "^15.0.1", + "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", + "xmlbuilder": "^15.1.1" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/messages": { + "version": "32.3.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz", + "integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==", + "license": "MIT", + "dependencies": { + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2" + } + }, + "node_modules/@cucumber/query": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-15.0.1.tgz", + "integrity": "sha512-FMfT3orJblRsOxvU2doECBvQmauizYlj+5JsM8atAKKPbnQTj7v2/OrnuykvQpfZNBf19DYbRq1e832vllRP/g==", + "license": "MIT", + "dependencies": { + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-9.1.0.tgz", + "integrity": "sha512-bvHjcRFZ+J1TqIa9eFNO1wGHqwx4V9ZKV3hYgkuK/VahHx73uiP4rKV3JVrvWSMrwrFvJG6C8aEwnCWSvbyFdQ==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@teppeis/multimaps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", + "integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-bdd": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-9.2.0.tgz", + "integrity": "sha512-1tBTmo4DpOhLsc+A6PB4isWO3DHKb4BQ3Tzw5+ze/PmgBW9W2m9c+nc0TPy2nByWJtw0gKSfMoexyBR+y82+pg==", + "license": "MIT", + "dependencies": { + "@cucumber/ci-environment": "^13.0.0", + "@cucumber/cucumber-expressions": "19.0.0", + "@cucumber/gherkin": "^39.1.0", + "@cucumber/gherkin-utils": "^11.0.0", + "@cucumber/html-formatter": "^23.1.0", + "@cucumber/junit-xml-formatter": "^0.13.3", + "@cucumber/messages": "^32.3.1", + "@cucumber/query": "^15.0.1", + "@cucumber/tag-expressions": "^9.1.0", + "cli-table3": "0.6.5", + "commander": "^13.1.0", + "mime-types": "^3.0.2", + "tinyglobby": "0.2.17" + }, + "bin": { + "bddgen": "dist/cli/index.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/vitalets" + }, + "peerDependencies": { + "@playwright/test": ">=1.44" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "license": "Apache-2.0", + "dependencies": { + "regexp-tree": "^0.1.11" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/Tests/E2E/package.json b/Tests/E2E/package.json new file mode 100644 index 0000000..58beca6 --- /dev/null +++ b/Tests/E2E/package.json @@ -0,0 +1,22 @@ +{ + "name": "sandstorm-usermanagement-e2e", + "private": true, + "type": "module", + "scripts": { + "generate-tests": "SUT=notRelevantForBddgen FLOW_CONTEXT=notRelevantForBddgen npx bddgen", + "test": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT npx playwright test", + "test:registration": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @registration", + "test:login": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @login", + "test:reset-password": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @reset-password", + "test:profile": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @profile" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^6.0.2", + "prettier": "^3.8.1" + }, + "dependencies": { + "@playwright/test": "^1.58.2", + "playwright-bdd": "^9.2.0" + } +} diff --git a/Tests/E2E/playwright.config.ts b/Tests/E2E/playwright.config.ts new file mode 100644 index 0000000..e7310cc --- /dev/null +++ b/Tests/E2E/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; +import { defineBddConfig } from "playwright-bdd"; + +// env API to select system under test (SUT) (neos8 | neos9) and flow context for the configuration to be used (default, enforce for all users, etc.) +const SUT = process.env.SUT; +const FLOW_CONTEXT = process.env.FLOW_CONTEXT; + +if (SUT == null || FLOW_CONTEXT == null) { + throw new Error("SUT and FLOW_CONTEXT environment variables must be set!"); +} + +const testDir = defineBddConfig({ + features: "features/**/*.feature", + steps: "steps/**/*.ts", +}); + +export default defineConfig({ + testDir, + fullyParallel: false, + workers: 1, + retries: 0, + use: { + baseURL: "http://localhost:8081", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + globalTeardown: "./global-teardown.ts", + webServer: { + command: `echo "starting SUT ${SUT} with context ${FLOW_CONTEXT}"; FLOW_CONTEXT=${FLOW_CONTEXT} docker compose -f ../system_under_test/${SUT}/docker-compose.yaml up`, + url: "http://localhost:8081/", + timeout: 600_000, + stdout: "pipe", + stderr: "pipe", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + reporter: process.env.CI ? "html" : "list", +}); diff --git a/Tests/E2E/steps/hooks.ts b/Tests/E2E/steps/hooks.ts new file mode 100644 index 0000000..f2d55f0 --- /dev/null +++ b/Tests/E2E/steps/hooks.ts @@ -0,0 +1,18 @@ +import { createBdd } from "playwright-bdd"; +import { removeUser } from "../helpers/system.ts"; +import { purgeMailbox } from "../helpers/mail.ts"; +import { getTrackedEmails, clearTrackedEmails } from "../helpers/state.ts"; + +const { AfterScenario } = createBdd(); + +// cleanup for each scenario: remove every user created (via fixture or through the registration +// UI) during the scenario, and clear Mailpit so the next scenario's mail search doesn't pick up +// a stale message. +AfterScenario(async () => { + for (const email of getTrackedEmails()) { + removeUser(email); + } + clearTrackedEmails(); + + await purgeMailbox(); +}); diff --git a/Tests/E2E/steps/login.steps.ts b/Tests/E2E/steps/login.steps.ts new file mode 100644 index 0000000..77abd6f --- /dev/null +++ b/Tests/E2E/steps/login.steps.ts @@ -0,0 +1,34 @@ +import { expect } from "@playwright/test"; +import { createBdd } from "playwright-bdd"; +import { LoginPage } from "../helpers/pages.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 login page", async ({ page }) => { + await new LoginPage(page).goto(); +}); + +When("I log in with email {string} and password {string}", async ({ page }, email: string, password: string) => { + await new LoginPage(page).login(email, password); +}); + +When("I log out", async ({ page }) => { + await new LoginPage(page).logout(); +}); + +Then("I should be logged in", async ({ page }) => { + await expect(new LoginPage(page).isLoggedIn()).toBeVisible(); +}); + +Then("I should be logged out", async ({ page }) => { + await expect(new LoginPage(page).isShowingLoginForm()).toBeVisible(); +}); + +Then("I should still see the login form", async ({ page }) => { + await expect(new LoginPage(page).isShowingLoginForm()).toBeVisible(); +}); diff --git a/Tests/E2E/steps/profile.steps.ts b/Tests/E2E/steps/profile.steps.ts new file mode 100644 index 0000000..883498f --- /dev/null +++ b/Tests/E2E/steps/profile.steps.ts @@ -0,0 +1,17 @@ +import { expect } from "@playwright/test"; +import { createBdd } from "playwright-bdd"; +import { ProfilePage } from "../helpers/pages.ts"; + +const { When, Then } = createBdd(); + +When("I open the profile page", async ({ page }) => { + await new ProfilePage(page).goto(); +}); + +When("I set a new profile password {string}", async ({ page }, password: string) => { + await new ProfilePage(page).setNewPassword(password); +}); + +Then("I should be back on the profile page", async ({ page }) => { + await expect(page).toHaveURL(/\/profile$/); +}); diff --git a/Tests/E2E/steps/registration.steps.ts b/Tests/E2E/steps/registration.steps.ts new file mode 100644 index 0000000..e16b109 --- /dev/null +++ b/Tests/E2E/steps/registration.steps.ts @@ -0,0 +1,52 @@ +import { expect } from "@playwright/test"; +import { createBdd } from "playwright-bdd"; +import { RegistrationPage, ActivationPage, SuccessOrAlertBanner } from "../helpers/pages.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, password, firstName, lastName); + }, +); + +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, passwordConfirmation, firstName, lastName); + }, +); + +Then("I should see the registration confirmation", async ({ page }) => { + await expect(new SuccessOrAlertBanner(page).isSuccess()).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); + trackEmail(email); +}); + +Then("I should see the account activated", async ({ page }) => { + await expect(new SuccessOrAlertBanner(page).isSuccess()).toBeVisible(); +}); + +Then("I should see that the activation link is not valid", async ({ page }) => { + await expect(new SuccessOrAlertBanner(page).isAlert()).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..61c672e --- /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, SuccessOrAlertBanner } from "../helpers/pages.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 SuccessOrAlertBanner(page).isSuccess()).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 SuccessOrAlertBanner(page).isSuccess()).toBeVisible(); +}); + +Then("I should see that the reset link is not valid", async ({ page }) => { + await expect(new SuccessOrAlertBanner(page).isAlert()).toBeVisible(); +}); + +When("I wait for the reset token to expire", async () => { + await new Promise((resolve) => setTimeout(resolve, 7_000)); +}); diff --git a/Tests/E2E/tsconfig.json b/Tests/E2E/tsconfig.json new file mode 100644 index 0000000..4bb79b3 --- /dev/null +++ b/Tests/E2E/tsconfig.json @@ -0,0 +1,21 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + "noEmit": true, + "module": "nodenext", + "target": "ESNext", + "lib": ["esnext"], + "types": ["node"], + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + // Recommended Options + "strict": true, + "verbatimModuleSyntax": false, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + "allowImportingTsExtensions": true + } +} diff --git a/Tests/Makefile b/Tests/Makefile new file mode 100644 index 0000000..37a3f06 --- /dev/null +++ b/Tests/Makefile @@ -0,0 +1,71 @@ +NEOS8_COMPOSE = $(CURDIR)/system_under_test/neos8/docker-compose.yaml +E2E_DIR = $(CURDIR)/E2E + +.SILENT: +.PHONY: setup setup-sut setup-test \ + generate-bdd-files \ + test test-registration test-login test-reset-password test-profile \ + start-sut log-sut enter-sut \ + sut-down + +# COLORS +GREEN := $(shell tput -Txterm setaf 2) +YELLOW := $(shell tput -Txterm setaf 3) +RESET := $(shell tput -Txterm sgr0) + +# initial setup +setup: setup-sut setup-test + +setup-sut: + docker compose -f $(NEOS8_COMPOSE) build --pull + +setup-test: + echo "${GREEN}Installing test setup.${RESET}" + cd $(E2E_DIR) && \ + if [ -s "$$NVM_DIR" ]; then \ + . "$$NVM_DIR/nvm.sh" && echo "${GREEN}Found nvm on system -> using it to install nodejs!${RESET}" && nvm install; \ + fi && \ + npm install && npx playwright install --with-deps chromium && \ + echo "" && echo "${GREEN}generate BDD files from feature files${RESET}" && npm run generate-tests + +# generate BDD files from feature files +generate-bdd-files: + echo "${GREEN}generate BDD files from feature files${RESET}"; \ + cd $(E2E_DIR) && npm run generate-tests + +## Run all E2E tests +test: + cd $(E2E_DIR) && npm run test + +## Run only the registration/activation scenarios +test-registration: + cd $(E2E_DIR) && npm run test:registration + +## Run only the login/logout scenarios +test-login: + cd $(E2E_DIR) && npm run test:login + +## Run only the forgot/reset-password scenarios +test-reset-password: + cd $(E2E_DIR) && npm run test:reset-password + +## Run only the profile scenarios +test-profile: + cd $(E2E_DIR) && npm run test:profile + +## Start the SUT container +start-sut: + docker compose -f $(NEOS8_COMPOSE) up -d --build + +## Follow logs of the SUT container +log-sut: + docker compose -f $(NEOS8_COMPOSE) logs -f + +## Open a bash shell inside the running SUT container +enter-sut: + docker compose -f $(NEOS8_COMPOSE) exec neos bash + +## Tear down the docker compose environment and remove volumes +sut-down: + echo "${YELLOW}Shutting down the SUT and removing its volumes.${RESET}" + docker compose -f $(NEOS8_COMPOSE) down -v diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 0000000..dacf992 --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,188 @@ +# E2E Tests + +End-to-end tests for `sandstorm/usermanagement`, using [Playwright](https://playwright.dev) with [playwright-bdd](https://vitalets.github.io/playwright-bdd/) for Gherkin-style BDD scenarios. Tests run against a Dockerised Neos instance (the *system under test*, SUT) — no local Neos installation required. + +This branch (`7.0`) targets **Neos 8** (PHP 8.2, MariaDB 10.11). The `main` branch has an equivalent suite targeting Neos 9. + +Outgoing mail (account activation / password reset links) is captured by [Mailpit](https://mailpit.axllent.org/) instead of being sent for real; tests read the emails via Mailpit's REST API. + +## Prerequisites + +- Docker +- Node.js >= 24 (or [nvm](https://github.com/nvm-sh/nvm) — the setup script uses it automatically if available) +- make + +## Setup + +Run once after cloning: + +```bash +cd Tests +make setup +``` + +This will: +1. Build the Docker image for Neos 8 +2. Install npm dependencies +3. Install the Playwright Chromium browser +4. Generate the Playwright test files from the Gherkin feature files + +## Running tests + +```bash +# Run all scenarios +make test + +# Run one feature group at a time +make test-registration +make test-login +make test-reset-password +make test-profile +``` + +Playwright starts the Docker containers automatically before each run and stops them afterwards (`globalTeardown` runs `docker compose down -v`). The first run may take a few minutes while Neos sets itself up inside the container (migrations, demo site import). + +**Rebuild after changing `system_under_test/`:** everything under `system_under_test/sut_file_system_overrides/` (Settings.yaml, Routes.yaml, ...) and the `Dockerfile`/`compose-overrides-neos8.yaml` files are baked into the image at **build time**, not bind-mounted. `make test*` only runs `docker compose up`, which reuses whatever image was last built — it will *not* pick up such changes on its own. After editing anything there, rebuild explicitly first: + +```bash +make sut-down # tear down any previous container/volumes +make setup-sut # docker compose build --pull +make test # or make test- +``` + +(`Classes/`, `Configuration/`, `Migrations/`, `Resources/` and `composer.json` *are* bind-mounted read/write into the container, so changes to the package's own code/config are picked up on the next container start without a rebuild.) + +### SUT and FLOW_CONTEXT + +Each npm test script sets two environment variables: + +- **`SUT`** — selects which Docker Compose environment to start (fixed to `neos8` on this branch). +- **`FLOW_CONTEXT`** — selects a Neos Flow configuration context, always `Production/E2E-SUT` here, which loads the configuration files in `system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/`. That override also adds `/profile`, `/profile/edit` and `/profile/password` routes for `ProfileController` — the package itself only exposes Profile through a Neos Fusion plugin, which the E2E app doesn't otherwise embed anywhere. + +## Container management + +When you need to inspect a running container or debug a failure: + +```bash +# Start the container in the background (without running tests) +make start-sut + +# Stream container logs +make log-sut + +# Open a bash shell inside a running container +make enter-sut + +# Stop the container and delete its volumes +make sut-down +``` + +Mailpit's web UI is available at http://localhost:8025 while the SUT is running — useful to inspect activation/reset emails by hand. + +## Troubleshooting + +`make test*` tears the SUT down again once Playwright finishes, so for manual poking start it standalone first: + +```bash +make start-sut # boots the container in the background and leaves it running +``` + +Then, from `Tests/`: + +- **Create a user to log in with manually** (there's no default one): + ```bash + docker compose -f system_under_test/neos8/docker-compose.yaml exec neos \ + bash -c "./flow sandstormuser:create 'someone@example.com' 'Sup3rSecret!1'" + ``` + Log in at http://localhost:8081/login. +- **List/check routes** — useful when a redirect or a form's `action` URL doesn't do what you expect: + ```bash + docker compose -f system_under_test/neos8/docker-compose.yaml exec neos bash -c "./flow routing:list" + docker compose -f system_under_test/neos8/docker-compose.yaml exec neos \ + bash -c "./flow routing:resolve Sandstorm.UserManagement --controller Profile --action index" + ``` + `routing:resolve` simulates exactly what `$this->redirect(...)` does internally — if it says "No route could resolve these values", a plain page load will 404 the same way. This is how we found that a route's static defaults (like the `__show*` plugin-argument ones on the `profile` route) must exactly match whatever a redirect call provides, or the redirect fails to resolve entirely. +- **Check what actually happened server-side** for a 404/error that doesn't show a stack trace in the browser: + ```bash + docker compose -f system_under_test/neos8/docker-compose.yaml exec neos bash -c "ls -t Data/Logs/Exceptions/ | head -3" # uncaught exceptions + docker compose -f system_under_test/neos8/docker-compose.yaml exec neos bash -c "grep -i -A15 'Could not resolve' Data/Logs/*.log" # router warnings (no exception thrown, so not in Exceptions/) + ``` + +## Directory structure + +``` +Tests/ +├── Makefile +├── README.md # this file +├── E2E/ +│ ├── features/ # Gherkin feature files (.feature), one folder per area +│ │ ├── registration/ +│ │ ├── login/ +│ │ ├── reset-password/ +│ │ └── profile/ +│ ├── steps/ # TypeScript step definitions +│ ├── helpers/ +│ │ ├── pages.ts # Page Object Model classes +│ │ ├── system.ts # Docker/Flow CLI utilities (package's own sandstormuser:* commands) +│ │ ├── mail.ts # Mailpit REST client +│ │ └── state.ts # tracks emails created during a scenario, for cleanup +│ ├── playwright.config.ts +│ ├── global-teardown.ts +│ ├── package.json +│ └── tsconfig.json +└── system_under_test/ + ├── Dockerfile + ├── sut-base-docker-compose.yaml # shared services: neos app, MariaDB, Redis, Mailpit + ├── neos8/ + │ ├── docker-compose.yaml + │ └── entrypoint.sh + └── sut_file_system_overrides/ # Neos/PHP/Caddy config mounted into the container +``` + +--- + +## Writing new tests + +Tests are written in two parts: a **feature file** (what to test, in plain language) and a **steps file** (how to do it, in TypeScript). + +### 1. Write a feature file + +Create a `.feature` file under `E2E/features//`. Tag it with the area (`@registration`, `@login`, `@reset-password`, `@profile`) so it can be run as its own npm script / CI step. + +### 2. Implement missing steps + +Reuse existing steps from `steps/` where possible (a step defined in any file is available in all feature files). Add new ones to a new or existing steps file. + +### 3. Add Page Objects for new pages + +Add a class to `helpers/pages.ts` if you're testing a page that doesn't have one yet. + +### 4. Regenerate test files + +playwright-bdd generates Playwright test files from your feature files. After adding or changing feature files run: + +```bash +make generate-bdd-files +``` + +This is done automatically by `make setup` and every `make test*` target, but you can run it manually during development. + +### 5. Use Flow CLI in steps + +`helpers/system.ts` exposes utilities that run the package's own CLI commands (`Classes/Command/SandstormUserCommandController`) inside the Docker container, e.g. `createActivatedUser(email, password)` (registers and immediately activates a user via `./flow sandstormuser:create`). Emails created this way — and emails opened via an activation link in a test — are tracked in `helpers/state.ts` and removed again in the `AfterScenario` hook (`steps/hooks.ts`), since the package has no bulk-delete command. + +### 6. Reading emails + +`helpers/mail.ts` polls Mailpit's REST API for a message to a given recipient and extracts a link from it via a regex, e.g.: + +```typescript +const message = await waitForEmailTo("someone@example.com"); +const link = extractLink(message, /https?:\/\/[^"'\s]*\/account\/activate\/[^"'\s]+/); +await page.goto(link); +``` + +The `AfterScenario` hook also purges Mailpit's mailbox after every scenario so a later scenario's mail search can't pick up a stale message. + +## Disclaimer + +This suite was bootstrapped with [Sandstorm.NeosInitE2ETestsPlugin](https://github.com/sandstorm/Sandstorm.NeosInitE2ETestsPlugin) and then adapted to this package's own controllers/routes. Feel free to modify the setup as needed — you can use all the usual Playwright features (`--ui`, `--debug`, `--grep`, etc.), the Makefile targets are just thin wrappers around `npx playwright test`. diff --git a/Tests/system_under_test/Dockerfile b/Tests/system_under_test/Dockerfile new file mode 100644 index 0000000..1f902e9 --- /dev/null +++ b/Tests/system_under_test/Dockerfile @@ -0,0 +1,56 @@ +ARG PHP_VERSION +FROM dunglas/frankenphp:1-php${PHP_VERSION}-trixie + +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +# reference: https://github.com/mlocati/docker-php-extension-installer +RUN install-php-extensions \ + intl \ + bcmath \ + opcache \ + pdo \ + pdo_mysql \ + xsl \ + ffi \ + vips \ + redis + +RUN apt update \ + && apt install -y git unzip mariadb-client \ + && apt clean \ + && rm -rf /var/lib/apt/lists/* + +ARG USER=www-data + +# Give write access to /config/caddy and /data/caddy +RUN \ + useradd ${USER}; \ + chown -R ${USER}:${USER} /config/caddy /data/caddy \ + && touch /var/run/caretakerd.key && chown ${USER}:${USER} /var/run/caretakerd.key + +# HOTFIX for ARM64 Architectures and VIPS (see https://github.com/opencv/opencv/issues/14884#issuecomment-706725583 for details) +# only needed for development on Apple Silicon Macs +RUN echo '. /etc/bash.vips-arm64-hotfix.sh' >> /etc/bash.bashrc + +# Install Neos base distribution +ARG NEOS_VERSION +RUN rm -rf /app \ + && composer create-project neos/neos-base-distribution:^${NEOS_VERSION} /app + +RUN composer require rokka/imagine-vips:0.* + +# Add config files +COPY Tests/system_under_test/sut_file_system_overrides/ / + +ARG ENTRY_POINT_FILE +COPY ${ENTRY_POINT_FILE} /entrypoint.sh + +# chown for neos data folder and Resources ONLY +RUN mkdir -p /app/Data /app/Web/_Resources \ + && chown -R ${USER} /app \ + && chmod +x /entrypoint.sh + +WORKDIR /app +USER ${USER} + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Tests/system_under_test/neos8/compose-overrides-neos8.yaml b/Tests/system_under_test/neos8/compose-overrides-neos8.yaml new file mode 100644 index 0000000..6184efd --- /dev/null +++ b/Tests/system_under_test/neos8/compose-overrides-neos8.yaml @@ -0,0 +1,15 @@ +services: + neos: + build: + args: + PHP_VERSION: '8.2' + NEOS_VERSION: '8' + ENTRY_POINT_FILE: 'Tests/system_under_test/neos8/entrypoint.sh' + +volumes: + db_neos_data: + name: db_neos8_data + +networks: + neos_SUT: + name: neos8_SUT diff --git a/Tests/system_under_test/neos8/docker-compose.yaml b/Tests/system_under_test/neos8/docker-compose.yaml new file mode 100644 index 0000000..0b7ca30 --- /dev/null +++ b/Tests/system_under_test/neos8/docker-compose.yaml @@ -0,0 +1,7 @@ +# WHY: GitHub actions does not support Docker Compose v5 yet - which in turn does not support overrides of included files. +# This is how it's supposed to be done anyway: https://docs.docker.com/compose/how-tos/multiple-compose-files/include/#using-overrides-with-included-compose-files + +include: + - path: + - ../sut-base-docker-compose.yaml + - ./compose-overrides-neos8.yaml diff --git a/Tests/system_under_test/neos8/entrypoint.sh b/Tests/system_under_test/neos8/entrypoint.sh new file mode 100644 index 0000000..3032ec7 --- /dev/null +++ b/Tests/system_under_test/neos8/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -eou pipefail + +# Register local path repository and require local package +# The code will be mounted into the container by docker-compose, so we can use it as a path repository +composer config repositories.sandstorm-2fa \ + '{"type":"path","url":"/app/DistributionPackages/Sandstorm.UserManagement","options":{"symlink":true}}' \ + && composer require sandstorm/usermanagement:@dev + +echo "Waiting for database..." +until mariadb -h"${DB_NEOS_HOST}" -P"${DB_NEOS_PORT}" -u"${DB_NEOS_USER}" -p"${DB_NEOS_PASSWORD}" -D"${DB_NEOS_DATABASE}" --disable-ssl --silent -e "SELECT 1;" 1>/dev/null 2>/dev/null; do + sleep 2 +done +echo "Database is ready." + +./flow flow:cache:flush + +./flow doctrine:migrate + +yes y | ./flow resource:clean || true + +./flow site:import --package-key Neos.Demo + +./flow resource:publish --collection static + +frankenphp run --config /etc/frankenphp/Caddyfile diff --git a/Tests/system_under_test/sut-base-docker-compose.yaml b/Tests/system_under_test/sut-base-docker-compose.yaml new file mode 100644 index 0000000..3c20a93 --- /dev/null +++ b/Tests/system_under_test/sut-base-docker-compose.yaml @@ -0,0 +1,82 @@ +services: + neos: + user: www-data:www-data + build: + context: ../../ + dockerfile: Tests/system_under_test/Dockerfile + args: + # minimum PHP version is '8.2' because that's the lowest version frankenPHP provides an image for + PHP_VERSION: '8.2' + # used to install the system under test + # which is a neos-base-distribution:^${NEOS_VERSION} + NEOS_VERSION: '8' + # docker will use this as entrypoint + ENTRY_POINT_FILE: 'Tests/system_under_test/neos8/entrypoint.sh' + environment: + FLOW_CONTEXT: "${FLOW_CONTEXT:-Production/E2E-SUT}" + # DB connection + DB_NEOS_HOST: 'db' + DB_NEOS_PORT: 3306 + DB_NEOS_USER: 'neos' + DB_NEOS_PASSWORD: 'neos' + DB_NEOS_DATABASE: 'neos' + # Redis connection + REDIS_HOST: 'redis' + REDIS_PORT: 6379 + # this is safe because the neos container port is only exposed to the local interface + # This means that the neos container is ALWAYS accessed through the front facing Ingress + FLOW_HTTP_TRUSTED_PROXIES: '*' + volumes: + - ../../Classes:/app/DistributionPackages/Sandstorm.UserManagement/Classes:cached + - ../../Configuration:/app/DistributionPackages/Sandstorm.UserManagement/Configuration:cached + - ../../Migrations:/app/DistributionPackages/Sandstorm.UserManagement/Migrations:cached + - ../../Resources:/app/DistributionPackages/Sandstorm.UserManagement/Resources:cached + - ../../composer.json:/app/DistributionPackages/Sandstorm.UserManagement/composer.json:cached + networks: + - neos_SUT + ports: + - 8081:8081 + depends_on: + - db + - redis + - mailpit + + db: + image: mariadb:10.11 + restart: always + ports: + - "13306:3306" + networks: + - neos_SUT + environment: + MARIADB_RANDOM_ROOT_PASSWORD: 'true' + MARIADB_DATABASE: 'neos' + MARIADB_USER: 'neos' + MARIADB_PASSWORD: 'neos' + MARIADB_AUTO_UPGRADE: 1 + volumes: + - db_neos_data:/var/lib/mysql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + + redis: + image: redis:7 + restart: always + 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 + +networks: + neos_SUT: + name: neos_SUT diff --git a/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Caches.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Caches.yaml new file mode 100644 index 0000000..3844103 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Caches.yaml @@ -0,0 +1,56 @@ +Flow_Mvc_Routing_Route: + backend: 'Neos\Cache\Backend\RedisBackend' + backendOptions: + hostname: '%env:REDIS_HOST%' + port: '%env:REDIS_PORT%' + # starting with database 2 here, since 0 and 1 are used and flushed by + # the core unit tests and should not be used if possible. + database: 2 + defaultLifetime: 0 + +Flow_Mvc_Routing_Resolve: + backend: 'Neos\Cache\Backend\RedisBackend' + backendOptions: + hostname: '%env:REDIS_HOST%' + port: '%env:REDIS_PORT%' + database: 2 + defaultLifetime: 0 + +# We want to test cache settings on Fusion components. +# Therefore, at one moment in the BDD test, we need to actively trigger the ContentCacheFlusher to invalidate the tags. +# Since we have multiple Flow contexts during tests, the cache settings are unified for all contexts. +# Explanation: +# - The system under test runs in Production/E2E-SUT; it writes cache entries +# - The Test Runner (behat) runs in Testing/Behat; it needs to invalidate cache for the SuT via service API call +# For now, we simply keep the cache settings in sync between those two profiles (like we do with the DB config). +Neos_Fusion_Content: + backend: 'Neos\Cache\Backend\RedisBackend' + backendOptions: + hostname: '%env:REDIS_HOST%' + port: '%env:REDIS_PORT%' + database: 2 + defaultLifetime: 0 + +Flow_Session_MetaData: + backend: 'Neos\Cache\Backend\RedisBackend' + backendOptions: + hostname: '%env:REDIS_HOST%' + port: '%env:REDIS_PORT%' + database: 2 + defaultLifetime: 0 + +Flow_Session_Storage: + backend: 'Neos\Cache\Backend\RedisBackend' + backendOptions: + hostname: '%env:REDIS_HOST%' + port: '%env:REDIS_PORT%' + database: 2 + defaultLifetime: 0 + +Neos_Media_ImageSize: + backend: 'Neos\Cache\Backend\RedisBackend' + backendOptions: + hostname: '%env:REDIS_HOST%' + port: '%env:REDIS_PORT%' + database: 2 + defaultLifetime: 0 diff --git a/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Routes.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Routes.yaml new file mode 100644 index 0000000..466a90c --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Routes.yaml @@ -0,0 +1,54 @@ +# Sandstorm.UserManagement's ProfileController is normally only reachable through a Neos +# Fusion plugin embedded on a page (Sandstorm.UserManagement:Profile NodeType), since the +# package itself defines no standalone route for it. For the E2E test application we add +# our own routes here instead of setting up a full Neos site/content just to host the plugin. +# The `__show*`/`__enable*` defaults replicate the plugin's default NodeType properties, which +# ProfileController reads via `$this->request->getInternalArguments()`. + +- + name: 'E2E: Profile' + uriPattern: 'profile' + defaults: + '@package': 'Sandstorm.UserManagement' + '@controller': 'Profile' + '@action': 'index' + '@format': 'html' + '__showPersonalInformation': true + '__showAccountInformation': true + '__enableNewPassword': true + +- + # ProfileController::editProfileAction()/setNewPasswordAction() both end with + # `$this->redirect('index')` (no explicit controller/package/argument overrides). Flow's + # Router::resolve() requires an EXACT match of a route's static defaults - including the + # `__show*`/`__enable*` ones above - so a plain redirect('index') call (which doesn't supply + # those keys) never matches the route above and fails with "Router resolve(): Could not + # resolve a route..." (confirmed via `./flow routing:resolve`). This second, plainer route + # exists purely so redirect('index') has something to match; real navigation to /profile + # still hits the route above first (declared earlier => tried first for incoming requests), + # so the plugin-argument defaults are still applied for the normal GET. + name: 'E2E: Profile (redirect target, no plugin-argument defaults)' + uriPattern: 'profile' + defaults: + '@package': 'Sandstorm.UserManagement' + '@controller': 'Profile' + '@action': 'index' + '@format': 'html' + +- + name: 'E2E: Profile edit personal data' + uriPattern: 'profile/edit' + defaults: + '@package': 'Sandstorm.UserManagement' + '@controller': 'Profile' + '@action': 'editProfile' + '@format': 'html' + +- + name: 'E2E: Profile set new password' + uriPattern: 'profile/password' + defaults: + '@package': 'Sandstorm.UserManagement' + '@controller': 'Profile' + '@action': 'setNewPassword' + '@format': 'html' diff --git a/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml new file mode 100644 index 0000000..f24b141 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml @@ -0,0 +1,41 @@ +Sandstorm: + UserManagement: + # shortened so the "expired link" scenarios don't have to wait for the production defaults (2 days / 4 hours); + # long enough that the happy-path scenarios (register/request -> poll Mailpit -> open link) don't race it + activationTokenTimeout: '6 seconds' + resetPasswordTokenTimeout: '6 seconds' + # Sandstorm.UserManagement:Profile requires Neos.Neos:FrontendUser or :Editor (see Policy.yaml); + # without this, freshly created/activated test users would get redirected back to /login + rolesForNewUsers: ['Neos.Neos:FrontendUser'] + +Neos: + Flow: + persistence: + backendOptions: + driver: 'pdo_mysql' + charset: 'utf8mb4' + host: '%env:DB_NEOS_HOST%' + port: '%env:DB_NEOS_PORT%' + password: '%env:DB_NEOS_PASSWORD%' + user: '%env:DB_NEOS_USER%' + dbname: '%env:DB_NEOS_DATABASE%' + cache: + applicationIdentifier: 'app' + + SymfonyMailer: + mailer: + # route all outgoing mail (activation / password reset links) to the Mailpit catcher instead of a real SMTP server + dsn: 'smtp://mailpit:1025' + + Imagine: + driver: 'Vips' + enabledDrivers: + Vips: true + Gd: true + Imagick: true + + Media: + image: + defaultOptions: + # The Vips driver does not support interlace + interlace: ~ diff --git a/Tests/system_under_test/sut_file_system_overrides/etc/bash.vips-arm64-hotfix.sh b/Tests/system_under_test/sut_file_system_overrides/etc/bash.vips-arm64-hotfix.sh new file mode 100644 index 0000000..9bb2110 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/etc/bash.vips-arm64-hotfix.sh @@ -0,0 +1,17 @@ +if [ "$(uname -m)" = "aarch64" ]; then + echo "Using LD_Preload workaround for gomp issue" + + # WORKAROUND for Apple M1 Chips. Without this line, we get the error message: + # + # Warning: PHP Startup: Unable to load dynamic library 'vips.so' (tried: + # /usr/local/lib/php/extensions/no-debug-non-zts-20200930/vips.so + # (/usr/lib/aarch64-linux-gnu/libgomp.so.1: cannot allocate memory in + # static TLS block), /usr/local/lib/php/extensions/no-debug-non-zts-20200930/vips.so.so + # (/usr/local/lib/php/extensions/no-debug-non-zts-20200930/vips.so.so: cannot open + # shared object file: No such file or directory)) in Unknown on line 0 + # + # This error seems to be related to some OpenCV bug or issue described at + # https://github.com/opencv/opencv/issues/14884#issuecomment-706725583 + # And the workaround is to ensure that libgomp is loaded first. + export LD_PRELOAD=/usr/lib/aarch64-linux-gnu/libgomp.so.1 +fi diff --git a/Tests/system_under_test/sut_file_system_overrides/etc/frankenphp/Caddyfile b/Tests/system_under_test/sut_file_system_overrides/etc/frankenphp/Caddyfile new file mode 100644 index 0000000..0a2d4bf --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/etc/frankenphp/Caddyfile @@ -0,0 +1,41 @@ +# The Caddyfile is an easy way to configure FrankenPHP and the Caddy web server. +# +# https://frankenphp.dev/docs/config +# https://caddyserver.com/docs/caddyfile +# https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile +{ + skip_install_trust + + # debug + + frankenphp { + # num_threads # Sets the number of PHP threads to start. Default: 2x the number of available CPUs. + #max_threads # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'. + #max_wait_time # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled. + #php_ini # Set a php.ini directive. Can be used several times to set multiple directives. + } +} + +:8081 { + # log + + root /app/Web + encode zstd br gzip + + request_body { + max_size 256MB + } + + # Block direct access to PHP files except index.php + @blockPhp { + path *.php + not path /index.php + } + + handle @blockPhp { + respond 404 + } + + + php_server +} diff --git a/Tests/system_under_test/sut_file_system_overrides/usr/local/etc/php/conf.d/php-ini-overrides.ini b/Tests/system_under_test/sut_file_system_overrides/usr/local/etc/php/conf.d/php-ini-overrides.ini new file mode 100644 index 0000000..1190ae0 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/usr/local/etc/php/conf.d/php-ini-overrides.ini @@ -0,0 +1,20 @@ +; ================== +; Various defaults +; ================== + +; for PHP >= 8.1, disable deprecations to temporarily make Neos/Flow work +error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT +memory_limit = 512M +upload_max_filesize = 256M +post_max_size = 256M +date.timezone = "Europe/Berlin" + +; ================== +; VIPS +; ================== +; required for VIPS +ffi.enable = true + +; for VIPS on PHP >= 8.3 the following line is required, +; see https://github.com/libvips/php-vips +zend.max_allowed_stack_size = -1 diff --git a/composer.json b/composer.json index e01fdb6..8ecbb5c 100644 --- a/composer.json +++ b/composer.json @@ -37,5 +37,10 @@ "psr-4": { "Sandstorm\\UserManagement\\": "Classes" } + }, + "extra": { + "neos": { + "package-key": "Sandstorm.UserManagement" + } } }