Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
.idea
# 3rd party sources
Packages/
vendor/

# composer
composer.lock

# IDEs
.idea/
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 :)
Expand Down
7 changes: 7 additions & 0 deletions Tests/E2E/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 3rd party sources
node_modules/

# transient test files
.features-gen/
test-results/
playwright-report/
1 change: 1 addition & 0 deletions Tests/E2E/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v24.14.1
1 change: 1 addition & 0 deletions Tests/E2E/.prettierrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# using prettier defaults
17 changes: 17 additions & 0 deletions Tests/E2E/features/login/login-logout.feature
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions Tests/E2E/features/profile/edit-profile.feature
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions Tests/E2E/features/registration/register-and-activate.feature
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions Tests/E2E/features/reset-password/forgot-reset-password.feature
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions Tests/E2E/global-teardown.ts
Original file line number Diff line number Diff line change
@@ -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("."),
});
}
43 changes: 43 additions & 0 deletions Tests/E2E/helpers/mail.ts
Original file line number Diff line number Diff line change
@@ -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<MailpitMessage> {
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(/&amp;/g, "&");
}

export async function purgeMailbox() {
await fetch(`${MAILPIT_URL}/api/v1/messages`, { method: "DELETE" });
}
125 changes: 125 additions & 0 deletions Tests/E2E/helpers/pages.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
13 changes: 13 additions & 0 deletions Tests/E2E/helpers/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const createdEmails = new Set<string>();

export function trackEmail(email: string) {
createdEmails.add(email);
}

export function getTrackedEmails(): string[] {
return Array.from(createdEmails);
}

export function clearTrackedEmails() {
createdEmails.clear();
}
Loading
Loading