From 204e19e26e30ec3d0ebf27215d337854ceeb1283 Mon Sep 17 00:00:00 2001 From: finja Date: Tue, 14 Jul 2026 15:07:04 +0200 Subject: [PATCH 1/8] Add pluggable FindEmailAddressForUserServiceInterface for password reset Ports the extension point from the 7.0 branch so consuming applications can customize how the reset-password recipient address is resolved instead of assuming account identifier == e-mail address. Default behavior is unchanged. Co-Authored-By: Claude Sonnet 5 --- .../Controller/ResetPasswordController.php | 55 +++++++++++-------- ...dressForUserByAccountIdentifierService.php | 23 ++++++++ ...indEmailAddressForUserServiceInterface.php | 20 +++++++ Configuration/Objects.yaml | 2 + README.md | 10 ++++ 5 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 Classes/Domain/Service/FindEmailAddressForUserByAccountIdentifierService.php create mode 100644 Classes/Domain/Service/FindEmailAddressForUserServiceInterface.php diff --git a/Classes/Controller/ResetPasswordController.php b/Classes/Controller/ResetPasswordController.php index 8d3ae16..5e71c0b 100644 --- a/Classes/Controller/ResetPasswordController.php +++ b/Classes/Controller/ResetPasswordController.php @@ -5,6 +5,7 @@ use Neos\Flow\Property\TypeConverter\PersistentObjectConverter; use Sandstorm\UserManagement\Domain\Model\ResetPasswordFlow; use Sandstorm\UserManagement\Domain\Repository\ResetPasswordFlowRepository; +use Sandstorm\UserManagement\Domain\Service\FindEmailAddressForUserServiceInterface; use Sandstorm\UserManagement\Domain\Service\UserCreationServiceInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\Mvc\Controller\ActionController; @@ -40,6 +41,12 @@ class ResetPasswordController extends ActionController */ protected $emailService; + /** + * @Flow\Inject + * @var FindEmailAddressForUserServiceInterface + */ + protected $findEmailAddressForUserService; + /** * @Flow\Inject * @var Translator @@ -104,28 +111,32 @@ public function requestTokenAction(ResetPasswordFlow $resetPasswordFlow) } } - // Send out a confirmation mail - $resetPasswordLink = $this->uriBuilder->reset()->setCreateAbsoluteUri(true)->uriFor( - 'insertNewPassword', - ['token' => $resetPasswordFlow->getResetPasswordToken()], - 'ResetPassword'); - - $this->emailService->sendTemplateEmail( - 'ResetPasswordToken', - $this->getSubjectResetPassword(), - [$resetPasswordFlow->getEmail()], - [ - 'resetPasswordLink' => $resetPasswordLink, - 'resetPasswordFlow' => $resetPasswordFlow - ], - 'sandstorm_usermanagement_sender_email', - [], // cc - [], // bcc - [], // attachments - 'sandstorm_usermanagement_replyTo_email' - ); - - $this->resetPasswordFlowRepository->add($resetPasswordFlow); + $receiverMail = $this->findEmailAddressForUserService->getEmailAddressByAccount($account); + + if ($receiverMail !== null) { + // Send out a confirmation mail + $resetPasswordLink = $this->uriBuilder->reset()->setCreateAbsoluteUri(true)->uriFor( + 'insertNewPassword', + ['token' => $resetPasswordFlow->getResetPasswordToken()], + 'ResetPassword'); + + $this->emailService->sendTemplateEmail( + 'ResetPasswordToken', + $this->getSubjectResetPassword(), + [$receiverMail], + [ + 'resetPasswordLink' => $resetPasswordLink, + 'resetPasswordFlow' => $resetPasswordFlow + ], + 'sandstorm_usermanagement_sender_email', + [], // cc + [], // bcc + [], // attachments + 'sandstorm_usermanagement_replyTo_email' + ); + + $this->resetPasswordFlowRepository->add($resetPasswordFlow); + } } diff --git a/Classes/Domain/Service/FindEmailAddressForUserByAccountIdentifierService.php b/Classes/Domain/Service/FindEmailAddressForUserByAccountIdentifierService.php new file mode 100644 index 0000000..467a04c --- /dev/null +++ b/Classes/Domain/Service/FindEmailAddressForUserByAccountIdentifierService.php @@ -0,0 +1,23 @@ +getAccountIdentifier(); + } +} diff --git a/Classes/Domain/Service/FindEmailAddressForUserServiceInterface.php b/Classes/Domain/Service/FindEmailAddressForUserServiceInterface.php new file mode 100644 index 0000000..3e9f852 --- /dev/null +++ b/Classes/Domain/Service/FindEmailAddressForUserServiceInterface.php @@ -0,0 +1,20 @@ + Date: Tue, 14 Jul 2026 15:28:08 +0200 Subject: [PATCH 2/8] Add Playwright/BDD E2E test suite for Neos 9 (registration, login, reset-password, profile) Scaffolded via sandstorm/neos-init-e2e-tests-plugin (composer e2e:init) and then adapted from the generic Neos-backend-login sample to this package's own controllers/routes: - Mailpit catches outgoing mail so tests can read activation/reset-password links (Neos.SymfonyMailer.mailer.dsn routed to it in the E2E-SUT Flow context). - ProfileController has no standalone route in the package (only reachable via a Neos Fusion plugin), so the E2E-SUT context adds its own /profile routes instead of setting up a full Neos site just to host the plugin. - Fixtures are created through the package's own sandstormuser:* CLI rather than Flow's generic user:create, since registration/activation is this package's own domain concept. - Only the Neos 9 SUT variant is kept on this branch; the 7.0 branch gets an equivalent Neos 8 suite. Verified without Docker (blocked in this sandbox): PHP lint, TypeScript type-check, Gherkin step resolution via bddgen, `playwright test --list`, and YAML syntax of all new/changed config. The actual docker compose build + playwright run still needs to happen where Docker is available. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/e2e.yml | 56 ++ Tests/E2E/.gitignore | 7 + Tests/E2E/.nvmrc | 1 + Tests/E2E/.prettierrc.yaml | 1 + Tests/E2E/features/login/login-logout.feature | 17 + .../E2E/features/profile/edit-profile.feature | 16 + .../register-and-activate.feature | 31 ++ .../forgot-reset-password.feature | 25 + Tests/E2E/global-teardown.ts | 11 + Tests/E2E/helpers/mail.ts | 43 ++ Tests/E2E/helpers/pages.ts | 119 ++++ Tests/E2E/helpers/state.ts | 13 + Tests/E2E/helpers/system.ts | 21 + Tests/E2E/package-lock.json | 511 ++++++++++++++++++ Tests/E2E/package.json | 22 + Tests/E2E/playwright.config.ts | 42 ++ Tests/E2E/steps/hooks.ts | 18 + Tests/E2E/steps/login.steps.ts | 34 ++ Tests/E2E/steps/profile.steps.ts | 17 + Tests/E2E/steps/registration.steps.ts | 52 ++ Tests/E2E/steps/reset-password.steps.ts | 38 ++ Tests/E2E/tsconfig.json | 21 + Tests/Makefile | 71 +++ Tests/README.md | 149 +++++ Tests/system_under_test/Dockerfile | 56 ++ .../neos9/compose-overrides-neos9.yaml | 18 + .../neos9/docker-compose.yaml | 7 + Tests/system_under_test/neos9/entrypoint.sh | 29 + .../sut-base-docker-compose.yaml | 82 +++ .../Production/E2E-SUT/Caches.yaml | 56 ++ .../Production/E2E-SUT/Routes.yaml | 36 ++ .../Production/E2E-SUT/Settings.yaml | 38 ++ .../etc/bash.vips-arm64-hotfix.sh | 17 + .../etc/frankenphp/Caddyfile | 41 ++ .../etc/php/conf.d/php-ini-overrides.ini | 20 + composer.json | 5 + 36 files changed, 1741 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 Tests/E2E/.gitignore create mode 100644 Tests/E2E/.nvmrc create mode 100644 Tests/E2E/.prettierrc.yaml create mode 100644 Tests/E2E/features/login/login-logout.feature create mode 100644 Tests/E2E/features/profile/edit-profile.feature create mode 100644 Tests/E2E/features/registration/register-and-activate.feature create mode 100644 Tests/E2E/features/reset-password/forgot-reset-password.feature create mode 100644 Tests/E2E/global-teardown.ts create mode 100644 Tests/E2E/helpers/mail.ts create mode 100644 Tests/E2E/helpers/pages.ts create mode 100644 Tests/E2E/helpers/state.ts create mode 100644 Tests/E2E/helpers/system.ts create mode 100644 Tests/E2E/package-lock.json create mode 100644 Tests/E2E/package.json create mode 100644 Tests/E2E/playwright.config.ts create mode 100644 Tests/E2E/steps/hooks.ts create mode 100644 Tests/E2E/steps/login.steps.ts create mode 100644 Tests/E2E/steps/profile.steps.ts create mode 100644 Tests/E2E/steps/registration.steps.ts create mode 100644 Tests/E2E/steps/reset-password.steps.ts create mode 100644 Tests/E2E/tsconfig.json create mode 100644 Tests/Makefile create mode 100644 Tests/README.md create mode 100644 Tests/system_under_test/Dockerfile create mode 100644 Tests/system_under_test/neos9/compose-overrides-neos9.yaml create mode 100644 Tests/system_under_test/neos9/docker-compose.yaml create mode 100644 Tests/system_under_test/neos9/entrypoint.sh create mode 100644 Tests/system_under_test/sut-base-docker-compose.yaml create mode 100644 Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Caches.yaml create mode 100644 Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Routes.yaml create mode 100644 Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml create mode 100644 Tests/system_under_test/sut_file_system_overrides/etc/bash.vips-arm64-hotfix.sh create mode 100644 Tests/system_under_test/sut_file_system_overrides/etc/frankenphp/Caddyfile create mode 100644 Tests/system_under_test/sut_file_system_overrides/usr/local/etc/php/conf.d/php-ini-overrides.ini diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..5664e06 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,56 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + e2e: + name: E2E Tests (Neos 9) + 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/neos9/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/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..7460afa --- /dev/null +++ b/Tests/E2E/helpers/pages.ts @@ -0,0 +1,119 @@ +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() { + 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"]'); + await form.locator(USERNAME_FIELD).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..a943b48 --- /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 || "neos9"}-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..87d3ada --- /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.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-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.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "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..ea36194 --- /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=neos9 FLOW_CONTEXT=Production/E2E-SUT npx playwright test", + "test:registration": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @registration", + "test:login": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @login", + "test:reset-password": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @reset-password", + "test:profile": "npm run generate-tests && SUT=neos9 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..d09196c --- /dev/null +++ b/Tests/Makefile @@ -0,0 +1,71 @@ +NEOS9_COMPOSE = $(CURDIR)/system_under_test/neos9/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 $(NEOS9_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 $(NEOS9_COMPOSE) up -d --build + +## Follow logs of the SUT container +log-sut: + docker compose -f $(NEOS9_COMPOSE) logs -f + +## Open a bash shell inside the running SUT container +enter-sut: + docker compose -f $(NEOS9_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 $(NEOS9_COMPOSE) down -v diff --git a/Tests/README.md b/Tests/README.md new file mode 100644 index 0000000..5f0d744 --- /dev/null +++ b/Tests/README.md @@ -0,0 +1,149 @@ +# 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 (`main`) targets **Neos 9** (PHP 8.5, MariaDB 11.4). The `7.0` branch has an equivalent suite targeting Neos 8. + +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 9 +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. The first run may take a few minutes while Neos sets itself up inside the container (migrations, demo site import). + +### SUT and FLOW_CONTEXT + +Each npm test script sets two environment variables: + +- **`SUT`** — selects which Docker Compose environment to start (fixed to `neos9` 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. + +## 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 + ├── neos9/ + │ ├── 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/neos9/compose-overrides-neos9.yaml b/Tests/system_under_test/neos9/compose-overrides-neos9.yaml new file mode 100644 index 0000000..3133445 --- /dev/null +++ b/Tests/system_under_test/neos9/compose-overrides-neos9.yaml @@ -0,0 +1,18 @@ +services: + neos: + build: + args: + PHP_VERSION: '8.5' + NEOS_VERSION: '9' + ENTRY_POINT_FILE: 'Tests/system_under_test/neos9/entrypoint.sh' + + db: + image: mariadb:11.4 + +volumes: + db_neos_data: + name: db_neos9_data + +networks: + neos_SUT: + name: neos9_SUT diff --git a/Tests/system_under_test/neos9/docker-compose.yaml b/Tests/system_under_test/neos9/docker-compose.yaml new file mode 100644 index 0000000..52289f0 --- /dev/null +++ b/Tests/system_under_test/neos9/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-neos9.yaml diff --git a/Tests/system_under_test/neos9/entrypoint.sh b/Tests/system_under_test/neos9/entrypoint.sh new file mode 100644 index 0000000..4c21a09 --- /dev/null +++ b/Tests/system_under_test/neos9/entrypoint.sh @@ -0,0 +1,29 @@ +#!/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 cr:setup +./flow cr:status + +./flow site:importall --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..67fb68e --- /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:v1 + 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..68377fb --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Routes.yaml @@ -0,0 +1,36 @@ +# 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 + +- + 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..d7834d3 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml @@ -0,0 +1,38 @@ +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' + +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 ecc87e3..2743d0c 100644 --- a/composer.json +++ b/composer.json @@ -29,5 +29,10 @@ "psr-4": { "Sandstorm\\UserManagement\\": "Classes" } + }, + "extra": { + "neos": { + "package-key": "Sandstorm.UserManagement" + } } } From 2c5ee0a0d169e89cfd21574129924c6b4b84dc1c Mon Sep 17 00:00:00 2001 From: finja Date: Tue, 14 Jul 2026 15:35:42 +0200 Subject: [PATCH 3/8] Fix invalid mailpit image tag axllent/mailpit has no floating "v1" tag, only "latest" and versioned tags like "v1.30.4" - discovered when actually running docker compose up. Co-Authored-By: Claude Sonnet 5 --- Tests/system_under_test/sut-base-docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/system_under_test/sut-base-docker-compose.yaml b/Tests/system_under_test/sut-base-docker-compose.yaml index 67fb68e..3c20a93 100644 --- a/Tests/system_under_test/sut-base-docker-compose.yaml +++ b/Tests/system_under_test/sut-base-docker-compose.yaml @@ -66,7 +66,7 @@ services: # catches all outgoing mail (registration activation / password reset links) so tests can read it via its REST API mailpit: - image: axllent/mailpit:v1 + image: axllent/mailpit:latest restart: always networks: - neos_SUT From 8697e77e1e85accf386cc2cc2d84592b382b6506 Mon Sep 17 00:00:00 2001 From: finja Date: Tue, 14 Jul 2026 15:42:41 +0200 Subject: [PATCH 4/8] Lower Neos 9 SUT container to PHP 8.4 PHP 8.5 breaks composer resolution for sandstorm/templatemailer's dependency pelago/emogrifier, which only supports up to PHP 8.4 - discovered when actually booting the container. Co-Authored-By: Claude Sonnet 5 --- Tests/README.md | 2 +- Tests/system_under_test/neos9/compose-overrides-neos9.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/README.md b/Tests/README.md index 5f0d744..19ca371 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -2,7 +2,7 @@ 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 (`main`) targets **Neos 9** (PHP 8.5, MariaDB 11.4). The `7.0` branch has an equivalent suite targeting Neos 8. +This branch (`main`) targets **Neos 9** (PHP 8.4, MariaDB 11.4). The `7.0` branch has an equivalent suite targeting Neos 8. 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. diff --git a/Tests/system_under_test/neos9/compose-overrides-neos9.yaml b/Tests/system_under_test/neos9/compose-overrides-neos9.yaml index 3133445..16506bf 100644 --- a/Tests/system_under_test/neos9/compose-overrides-neos9.yaml +++ b/Tests/system_under_test/neos9/compose-overrides-neos9.yaml @@ -2,7 +2,7 @@ services: neos: build: args: - PHP_VERSION: '8.5' + PHP_VERSION: '8.4' NEOS_VERSION: '9' ENTRY_POINT_FILE: 'Tests/system_under_test/neos9/entrypoint.sh' From 27897d0bb790f3f441b3d906195fd677449f48ab Mon Sep 17 00:00:00 2001 From: finja Date: Tue, 14 Jul 2026 16:03:22 +0200 Subject: [PATCH 5/8] Fix profile role assignment and registration email field selector - rolesForNewUsers was empty by default, so freshly activated test users lacked the Neos.Neos:FrontendUser/Editor role that Sandstorm.UserManagement:Profile requires (Policy.yaml), causing /profile to bounce back to /login. - The registration email field renders as `registrationFlow[email]` at runtime, not the auth-token username field the template's `name` attribute suggests - Fluid's form.textfield ignores an explicit `name` override once `property` is set. Found by inspecting the actual rendered HTML. Co-Authored-By: Claude Sonnet 5 --- Tests/E2E/helpers/pages.ts | 5 ++++- .../app/Configuration/Production/E2E-SUT/Settings.yaml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/E2E/helpers/pages.ts b/Tests/E2E/helpers/pages.ts index 7460afa..a3e6f61 100644 --- a/Tests/E2E/helpers/pages.ts +++ b/Tests/E2E/helpers/pages.ts @@ -41,7 +41,10 @@ export class RegistrationPage { async register(email: string, password: string, passwordConfirmation: string, firstName: string, lastName: string) { const form = this.page.locator('form[action="/account/signup/submit"]'); - await form.locator(USERNAME_FIELD).fill(email); + // 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); 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 index d7834d3..f24b141 100644 --- 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 @@ -4,6 +4,9 @@ Sandstorm: # 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: From 46571b5eedd3d77edd3674f3dd6e8efeccc0488a Mon Sep 17 00:00:00 2001 From: finja Date: Tue, 14 Jul 2026 17:33:10 +0200 Subject: [PATCH 6/8] Fix profile redirect resolution and logout helper navigation - Add a second, plainer /profile route (no __show*/__enable* plugin-argument defaults) so ProfileController's internal `$this->redirect('index')` calls can resolve - Flow's Router::resolve() requires an exact match of a route's static defaults, and redirect('index') doesn't supply those keys. Confirmed via `./flow routing:resolve`, which reproduces the same "Router resolve(): Could not resolve a route..." failure without needing a browser. - Fix LoginPage.logout() to navigate to /login first: the logout form only renders there (via the ifAuthenticated viewhelper), so calling it from /profile timed out looking for a form that isn't on that page. - Document the rebuild-after-config-change requirement and add a troubleshooting section (routing:list/routing:resolve, log locations, manual user creation) to Tests/README.md, based on what it took to track these two issues down. Co-Authored-By: Claude Sonnet 5 --- Tests/E2E/helpers/pages.ts | 3 ++ Tests/README.md | 41 ++++++++++++++++++- .../Production/E2E-SUT/Routes.yaml | 18 ++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/Tests/E2E/helpers/pages.ts b/Tests/E2E/helpers/pages.ts index a3e6f61..de4b0a0 100644 --- a/Tests/E2E/helpers/pages.ts +++ b/Tests/E2E/helpers/pages.ts @@ -20,6 +20,9 @@ export class LoginPage { } 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(); } diff --git a/Tests/README.md b/Tests/README.md index 19ca371..882d82e 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -40,7 +40,17 @@ make test-reset-password make test-profile ``` -Playwright starts the Docker containers automatically before each run and stops them afterwards. The first run may take a few minutes while Neos sets itself up inside the container (migrations, demo site import). +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, `cr:setup`, 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-*.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 @@ -69,6 +79,35 @@ 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/neos9/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/neos9/docker-compose.yaml exec neos bash -c "./flow routing:list" + docker compose -f system_under_test/neos9/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/neos9/docker-compose.yaml exec neos bash -c "ls -t Data/Logs/Exceptions/ | head -3" # uncaught exceptions + docker compose -f system_under_test/neos9/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 ``` 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 index 68377fb..466a90c 100644 --- 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 @@ -17,6 +17,24 @@ '__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' From 324eaf76030bf92420654207ea1515d4971ff989 Mon Sep 17 00:00:00 2001 From: finja Date: Mon, 27 Jul 2026 09:40:02 +0200 Subject: [PATCH 7/8] Pin Neos 9 base distribution to 9.0.x to avoid broken buildessentials 9.1 metadata --- Tests/system_under_test/Dockerfile | 7 ++++++- Tests/system_under_test/neos9/compose-overrides-neos9.yaml | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Tests/system_under_test/Dockerfile b/Tests/system_under_test/Dockerfile index 1f902e9..723fe7d 100644 --- a/Tests/system_under_test/Dockerfile +++ b/Tests/system_under_test/Dockerfile @@ -33,9 +33,14 @@ RUN \ RUN echo '. /etc/bash.vips-arm64-hotfix.sh' >> /etc/bash.bashrc # Install Neos base distribution +# NEOS_VERSION is a full composer version constraint (not just a major version number) so each +# variant can pin as tightly as it needs to - a floating "^9" constraint previously picked up +# neos-base-distribution 9.1.7, whose own composer.json requires neos/buildessentials ~9.1.0, +# but that package was never stable-tagged past 9.0.0 (only a 9.1.x-dev branch exists), breaking +# the build with a minimum-stability error. ARG NEOS_VERSION RUN rm -rf /app \ - && composer create-project neos/neos-base-distribution:^${NEOS_VERSION} /app + && composer create-project neos/neos-base-distribution:${NEOS_VERSION} /app RUN composer require rokka/imagine-vips:0.* diff --git a/Tests/system_under_test/neos9/compose-overrides-neos9.yaml b/Tests/system_under_test/neos9/compose-overrides-neos9.yaml index 16506bf..8ec29ef 100644 --- a/Tests/system_under_test/neos9/compose-overrides-neos9.yaml +++ b/Tests/system_under_test/neos9/compose-overrides-neos9.yaml @@ -3,7 +3,7 @@ services: build: args: PHP_VERSION: '8.4' - NEOS_VERSION: '9' + NEOS_VERSION: '9.0.*' ENTRY_POINT_FILE: 'Tests/system_under_test/neos9/entrypoint.sh' db: From 1326160a20189d50c846b17294fa3a33c7140088 Mon Sep 17 00:00:00 2001 From: finja Date: Mon, 27 Jul 2026 09:51:36 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=93=9D=20update=20readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c7c5ea..015ab8b 100644 --- a/README.md +++ b/README.md @@ -381,9 +381,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 :)