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
17 changes: 17 additions & 0 deletions Tests/E2E/features/frontend-login.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@frontend-login
Feature: Frontend login and logout

Background:
Given an activated user "login-user@example.com" with password "Sup3rSecret!1" exists

Scenario: A registered user can log in and log out
When I open the frontend login page
And I log in with email "login-user@example.com" and password "Sup3rSecret!1"
Then I should be logged in
When I log out via the frontend
Then I should be logged out

Scenario: Logging in with a wrong password does not log the user in
When I open the frontend login page
And I log in with email "login-user@example.com" and password "wrong-password"
Then I should still see the login form
31 changes: 31 additions & 0 deletions Tests/E2E/features/registration.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 frontend login page
And I log in with email "newuser@example.com" and password "Sup3rSecret!1"
Then I should be logged in

Scenario: Registering with mismatched password confirmation shows a validation error
When I open the registration form
And I register with email "mismatch@example.com", password "Sup3rSecret!1" and password confirmation "Different!2", first name "Ada" and last name "Lovelace"
Then I should still see the registration form

Scenario: An already-used activation link no longer works
When I open the registration form
And I register with email "reused@example.com", password "Sup3rSecret!1", first name "Ada" and last name "Lovelace"
And I open the activation link that was emailed to "reused@example.com"
And I open the activation link that was emailed to "reused@example.com"
Then I should see that the activation link is not valid

Scenario: An expired activation link no longer works
When I open the registration form
And I register with email "expired@example.com", password "Sup3rSecret!1", first name "Ada" and last name "Lovelace"
And I wait for the activation token to expire
And I open the activation link that was emailed to "expired@example.com"
Then I should see that the activation link is not valid
25 changes: 25 additions & 0 deletions Tests/E2E/features/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 frontend login page
And I log in with email "reset-user@example.com" and password "NewSecret!2"
Then I should be logged in

Scenario: Requesting a reset for an unknown email does not reveal whether the account exists
When I request a password reset for "unknown@example.com"
Then I should see the password reset confirmation

Scenario: An expired reset link no longer works
When I request a password reset for "reset-user@example.com"
And I wait for the reset token to expire
And I open the password reset link that was emailed to "reset-user@example.com"
Then I should see that the reset link is not valid
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" });
}
17 changes: 17 additions & 0 deletions Tests/E2E/helpers/pages/activationPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Page } from "@playwright/test";

export default class ActivationPage {
constructor(private readonly page: Page) {}

async open(link: string) {
await this.page.goto(link);
}

isShowingSuccess() {
return this.page.locator(".callout.success");
}

isShowingError() {
return this.page.locator(".callout.alert");
}
}
34 changes: 34 additions & 0 deletions Tests/E2E/helpers/pages/frontendLoginPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";

const USERNAME_FIELD = 'input[name="__authentication[Neos][Flow][Security][Authentication][Token][UsernamePassword][username]"]';
const PASSWORD_FIELD = 'input[name="__authentication[Neos][Flow][Security][Authentication][Token][UsernamePassword][password]"]';

export default class FrontendLoginPage {
constructor(private readonly page: Page) {}

async goto() {
await this.page.goto("/login");
}

async login(email: string, password: string) {
const form = this.page.locator('form[action="/login/authenticate"]');
await form.locator(USERNAME_FIELD).fill(email);
await form.locator(PASSWORD_FIELD).fill(password);
await form.locator('input[type="submit"]').click();
}

async logout() {
// the logout form only renders on /login (via the ifAuthenticated viewhelper there) - navigate
// there first rather than assuming the caller is already on a page that has it
await this.goto();
await this.page.locator('form[action="/logout"] input[type="submit"], form[action="/logout"] button[type="submit"]').click();
}

isLoggedIn() {
return this.page.locator('form[action="/logout"]');
}

isShowingLoginForm() {
return this.page.locator('form[action="/login/authenticate"]');
}
}
30 changes: 30 additions & 0 deletions Tests/E2E/helpers/pages/registrationPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Page } from "@playwright/test";

export default class RegistrationPage {
constructor(private readonly page: Page) {}

async goto() {
await this.page.goto("/account/signup/index");
}

// NOTE: Index.html sets an explicit `name` override on the email field (to the Flow auth-token
// username field), but Fluid's form.textfield ignores that when `property` is also set - the
// field is actually submitted as `registrationFlow[email]`.
async register(email: string, password: string, firstName: string, lastName: string, passwordConfirmation = password) {
const form = this.page.locator('form[action="/account/signup/submit"]');
await form.locator('[name="registrationFlow[email]"]').fill(email);
await form.locator('[name="registrationFlow[passwordDto][password]"]').fill(password);
await form.locator('[name="registrationFlow[passwordDto][passwordConfirmation]"]').fill(passwordConfirmation);
await form.locator('[name="registrationFlow[attributes][firstName]"]').fill(firstName);
await form.locator('[name="registrationFlow[attributes][lastName]"]').fill(lastName);
await form.locator('input[type="submit"]').click();
}

isShowingConfirmation() {
return this.page.locator(".callout.success");
}

isShowingForm() {
return this.page.locator('form[action="/account/signup/submit"]');
}
}
34 changes: 34 additions & 0 deletions Tests/E2E/helpers/pages/resetPasswordPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Page } from "@playwright/test";

export default class ResetPasswordPage {
constructor(private readonly page: Page) {}

async goto() {
await this.page.goto("/account/forgotpassword");
}

async requestReset(email: string) {
const form = this.page.locator('form[action="/account/requestpasswordtoken"]');
await form.locator('[name="resetPasswordFlow[email]"]').fill(email);
await form.locator('input[type="submit"]').click();
}

async open(link: string) {
await this.page.goto(link);
}

async setNewPassword(password: string) {
const form = this.page.locator('form[action="/account/updatepassword"]');
await form.locator('[name="resetPasswordFlow[passwordDto][password]"]').fill(password);
await form.locator('[name="resetPasswordFlow[passwordDto][passwordConfirmation]"]').fill(password);
await form.locator('input[type="submit"]').click();
}

isShowingSuccess() {
return this.page.locator(".callout.success");
}

isShowingError() {
return this.page.locator(".callout.alert");
}
}
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();
}
20 changes: 19 additions & 1 deletion Tests/E2E/helpers/system.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execSync } from "node:child_process";
import { dirname } from "node:path";
import type { Page } from "@playwright/test";
import { trackEmail } from "./state.ts";

const CONTAINER = `${process.env.SUT || "neos8"}-neos-1`;

Expand All @@ -12,7 +13,9 @@ export function createUser(name: string, password: string, roles: string[]) {
}

export function removeAllUsers() {
execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:delete --assume-yes '*'"`, {
// `|| true`: exits non-zero when there's nothing to delete, which would otherwise abort the
// rest of the AfterScenario cleanup (e.g. scenarios that only create frontend/sandstorm users).
execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:delete --assume-yes '*' || true"`, {
stdio: "ignore",
cwd: dirname("."),
});
Expand All @@ -21,3 +24,18 @@ export function removeAllUsers() {
export async function logout(page: Page) {
await page.context().request.post("/neos/logout");
}

export function createActivatedUser(email: string, password: string) {
execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow sandstormuser:create '${email}' '${password}'"`, {
stdio: "ignore",
cwd: dirname("."),
});
trackEmail(email);
}

export function removeUser(email: string) {
execSync(`docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow sandstormuser:remove '${email}' || true"`, {
stdio: "ignore",
cwd: dirname("."),
});
}
34 changes: 34 additions & 0 deletions Tests/E2E/steps/frontend-login.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect } from "@playwright/test";
import { createBdd } from "playwright-bdd";
import FrontendLoginPage from "../helpers/pages/frontendLoginPage.ts";
import { createActivatedUser } from "../helpers/system.ts";

const { Given, When, Then } = createBdd();

Given("an activated user {string} with password {string} exists", async ({}, email: string, password: string) => {
createActivatedUser(email, password);
});

When("I open the frontend login page", async ({ page }) => {
await new FrontendLoginPage(page).goto();
});

When("I log in with email {string} and password {string}", async ({ page }, email: string, password: string) => {
await new FrontendLoginPage(page).login(email, password);
});

When("I log out via the frontend", async ({ page }) => {
await new FrontendLoginPage(page).logout();
});

Then("I should be logged in", async ({ page }) => {
await expect(new FrontendLoginPage(page).isLoggedIn()).toBeVisible();
});

Then("I should be logged out", async ({ page }) => {
await expect(new FrontendLoginPage(page).isShowingLoginForm()).toBeVisible();
});

Then("I should still see the login form", async ({ page }) => {
await expect(new FrontendLoginPage(page).isShowingLoginForm()).toBeVisible();
});
12 changes: 11 additions & 1 deletion Tests/E2E/steps/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createBdd } from "playwright-bdd";
import { logout, removeAllUsers } from "../helpers/system.ts";
import { logout, removeAllUsers, removeUser } from "../helpers/system.ts";
import { getTrackedEmails, clearTrackedEmails } from "../helpers/state.ts";
import { purgeMailbox } from "../helpers/mail.ts";

const { AfterScenario } = createBdd();

Expand All @@ -8,4 +10,12 @@ AfterScenario(async ({ page }) => {
await logout(page);

removeAllUsers();

for (const email of getTrackedEmails()) {
removeUser(email);
}
clearTrackedEmails();

// so a later scenario's waitForEmailTo search can't pick up a stale message from this run
await purgeMailbox();
});
Loading