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: [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
55 changes: 33 additions & 22 deletions Classes/Controller/ResetPasswordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,6 +41,12 @@ class ResetPasswordController extends ActionController
*/
protected $emailService;

/**
* @Flow\Inject
* @var FindEmailAddressForUserServiceInterface
*/
protected $findEmailAddressForUserService;

/**
* @Flow\Inject
* @var Translator
Expand Down Expand Up @@ -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);
}
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php


namespace Sandstorm\UserManagement\Domain\Service;

use Neos\Flow\Annotations as Flow;
use Neos\Flow\Security\Account;

/**
* @api
* @Flow\Scope("singleton")
*/
class FindEmailAddressForUserByAccountIdentifierService implements FindEmailAddressForUserServiceInterface
{
/**
* @param Account $account
* @return string|null
*/
public function getEmailAddressByAccount(Account $account)
{
return $account->getAccountIdentifier();
}
}
20 changes: 20 additions & 0 deletions Classes/Domain/Service/FindEmailAddressForUserServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php


namespace Sandstorm\UserManagement\Domain\Service;


use Neos\Flow\Annotations as Flow;
use Neos\Flow\Security\Account;

/**
* @api
*/
interface FindEmailAddressForUserServiceInterface
{
/**
* @param Account $account
* @return string|null
*/
public function getEmailAddressByAccount(Account $account);
}
2 changes: 2 additions & 0 deletions Configuration/Objects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ Sandstorm\UserManagement\Domain\Service\RedirectTargetServiceInterface:
className: 'Sandstorm\UserManagement\Domain\Service\Flow\FlowRedirectTargetService'
Sandstorm\UserManagement\Domain\Service\UserCreationServiceInterface:
className: 'Sandstorm\UserManagement\Domain\Service\Flow\FlowUserCreationService'
Sandstorm\UserManagement\Domain\Service\FindEmailAddressForUserServiceInterface:
className: 'Sandstorm\UserManagement\Domain\Service\FindEmailAddressForUserByAccountIdentifierService'
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ Sandstorm\UserManagement\Domain\Service\UserCreationServiceInterface:
className: 'Your\Package\Domain\Service\YourCustomUserCreationService'
```

## Customizing how the reset-password e-mail address is resolved
By default, the "forgot password" flow sends the reset link to the account identifier the user entered (i.e. username
and e-mail address are assumed to be identical). If your application decouples usernames from e-mail addresses, you
can override how the recipient address is resolved by implementing `FindEmailAddressForUserServiceInterface` and
wiring it up via `Objects.yaml`:
```YAML
Sandstorm\UserManagement\Domain\Service\FindEmailAddressForUserServiceInterface:
className: 'Your\Package\Domain\Service\YourCustomFindEmailAddressForUserService'
```

## Hooking into the login/logout process
The UserManagement package emits three signals during the login and logout process, into which you can hook
using Flows [Signals and Slots](http://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/SignalsAndSlots.html)
Expand Down Expand Up @@ -371,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 :)
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" });
}
Loading