diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..84423cc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git/ +.idea/ +node_modules/ +vendor/ +Packages +.github +.claude +.idea +composer.lock diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..2e4f165 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,60 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + e2e: + name: E2E Tests (${{ matrix.neos }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + neos: [neos8, neos9] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + 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/${{ matrix.neos }}/docker-compose.yaml build --pull + + - name: Test - defaults + working-directory: Tests/E2E + run: npm run test:${{ matrix.neos }}:defaults + + - name: Test - enforce-all + working-directory: Tests/E2E + run: npm run test:${{ matrix.neos }}:enforce-all + + - name: Test - enforce-role + working-directory: Tests/E2E + run: npm run test:${{ matrix.neos }}:enforce-role + + - name: Test - enforce-provider + working-directory: Tests/E2E + run: npm run test:${{ matrix.neos }}:enforce-provider + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ matrix.neos }} + path: Tests/E2E/playwright-report/ + retention-days: 7 diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index 5e99390..000462a 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -137,7 +137,7 @@ public function newAction(): void * @throws IllegalObjectTypeException * @throws StopActionException */ - public function createAction(string $secret, string $secondFactorFromApp): void + public function createAction(string $secret, string $secondFactorFromApp, string $name = ''): void { $isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp); @@ -157,7 +157,7 @@ public function createAction(string $secret, string $secondFactorFromApp): void $this->redirect('new'); } - $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount()); + $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount(), $name); $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); @@ -189,7 +189,9 @@ public function deleteAction(SecondFactor $secondFactor): void if ($isAdministrator || ($isOwner && $this->secondFactorService->canOneSecondFactorBeDeletedForAccount($account))) { // User is admin or has more than one second factor $this->secondFactorRepository->remove($secondFactor); - $this->persistenceManager->persistAll(); + // neos8 backwards compatibility + $this->persistenceManager?->persistAll(); + $this->addFlashMessage( $this->translator->translateById( 'module.index.delete.flashMessage.secondFactorDeleted', diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php index 33c7ebc..2a59f6c 100644 --- a/Classes/Controller/LoginController.php +++ b/Classes/Controller/LoginController.php @@ -177,12 +177,13 @@ public function setupSecondFactorAction(?string $username = null): void /** * @param string $secret * @param string $secondFactorFromApp + * @param string $name * @return void * @throws IllegalObjectTypeException * @throws SessionNotStartedException * @throws StopActionException */ - public function createSecondFactorAction(string $secret, string $secondFactorFromApp): void + public function createSecondFactorAction(string $secret, string $secondFactorFromApp, string $name = ''): void { $isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp); @@ -204,7 +205,7 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro $account = $this->securityContext->getAccount(); - $this->secondFactorRepository->createSecondFactorForAccount($secret, $account); + $this->secondFactorRepository->createSecondFactorForAccount($secret, $account, $name); $this->addFlashMessage( $this->translator->translateById( diff --git a/Classes/Domain/Model/SecondFactor.php b/Classes/Domain/Model/SecondFactor.php index 569d9d4..bbde9d5 100644 --- a/Classes/Domain/Model/SecondFactor.php +++ b/Classes/Domain/Model/SecondFactor.php @@ -8,6 +8,8 @@ use Doctrine\ORM\Mapping as ORM; use Neos\Flow\Annotations as Flow; +// TODO: refactor to PHP8 code + /** * Store the secrets needed for two factor authentication * @@ -39,6 +41,11 @@ class SecondFactor */ protected string $secret; + /** + * @var string + */ + protected string $name; + /** * Introduced with version 1.4.0 * Nullable for backwards compatibility. Null values will be shown as '-' in backend module. @@ -73,6 +80,7 @@ public function getType(): int } /** + * Used in Fusion rendering * @return string */ public function getTypeAsName(): string @@ -104,6 +112,19 @@ public function setSecret(string $secret): void $this->secret = $secret; } + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + /** + * Used in Fusion rendering + */ public function getCreationDate(): DateTime|null { return $this->creationDate; diff --git a/Classes/Domain/Repository/SecondFactorRepository.php b/Classes/Domain/Repository/SecondFactorRepository.php index 08aa1fe..8e076b4 100644 --- a/Classes/Domain/Repository/SecondFactorRepository.php +++ b/Classes/Domain/Repository/SecondFactorRepository.php @@ -24,12 +24,13 @@ class SecondFactorRepository extends Repository /** * @throws IllegalObjectTypeException */ - public function createSecondFactorForAccount(string $secret, Account $account): void + public function createSecondFactorForAccount(string $secret, Account $account, string $name): void { $secondFactor = new SecondFactor(); $secondFactor->setAccount($account); $secondFactor->setSecret($secret); $secondFactor->setType(SecondFactor::TYPE_TOTP); + $secondFactor->setName($name); $secondFactor->setCreationDate(new \DateTime()); $this->add($secondFactor); $this->persistenceManager->persistAll(); diff --git a/Concept_Composer_Package.md b/Concept_Composer_Package.md new file mode 100644 index 0000000..1478939 --- /dev/null +++ b/Concept_Composer_Package.md @@ -0,0 +1,169 @@ +# Concept: Reusable E2E Infrastructure as a Composer Package + +## Goal + +Extract the E2E test infrastructure from this plugin into a standalone Composer package +(`sandstorm/neos-e2e-testing`) that any Neos plugin can install to get a working +end-to-end test scaffold with minimal effort. + +**Scope:** Infrastructure only — Docker/SUT setup, Playwright project scaffold (config, +package.json, tsconfig, teardown), Makefile, and GitHub Actions workflow. Step definitions, +helpers (system, pages, totp, etc.), and feature files are entirely the plugin developer's +responsibility. + +--- + +## Package Structure + +### Type: `library` + +Not `neos-package` — this is a dev tool. It belongs in `vendor/`, not `Packages/`, and +has no Flow/Neos runtime dependency. + +``` +sandstorm/neos-e2e-testing/ +├── composer.json +├── bin/ +│ └── neos-e2e-setup ← self-contained PHP CLI script +└── templates/ + ├── Tests/ + │ ├── E2E/ + │ │ ├── package.json + │ │ ├── playwright.config.ts + │ │ ├── tsconfig.json + │ │ ├── global-teardown.ts + │ │ └── .nvmrc + │ └── system_under_test/ + │ ├── neos8/ + │ │ ├── Dockerfile + │ │ ├── docker-compose.yaml + │ │ └── sut-files/ + │ │ ├── entrypoint.sh + │ │ ├── etc/caretakerd.yaml + │ │ ├── etc/frankenphp/Caddyfile + │ │ └── usr/local/etc/php/conf.d/php-ini-overrides.ini + │ └── neos9/ + │ └── (same) + ├── .dockerignore + ├── Makefile + └── .github/ + └── workflows/ + └── e2e.yml +``` + +### `composer.json` + +```json +{ + "name": "sandstorm/neos-e2e-testing", + "type": "library", + "description": "Scaffolds E2E test infrastructure for Neos plugins", + "require": { "php": "^8.1" }, + "bin": ["bin/neos-e2e-setup"] +} +``` + +--- + +## Usage + +```sh +composer require --dev sandstorm/neos-e2e-testing +vendor/bin/neos-e2e-setup --plugin-package sandstorm/neostwofactorauthentication +``` + +The script copies templates into the plugin root, substitutes tokens, and skips files that +already exist (safe to re-run). + +--- + +## Template Tokens + +Only two things vary between plugins: + +| Token | Example | Used in | +|---|---|---| +| `{{PLUGIN_PACKAGE}}` | `sandstorm/neostwofactorauthentication` | Dockerfile, docker-compose project name, Makefile, GH Actions | +| `{{PLUGIN_SLUG}}` | `sandstorm-2fa` | Docker container/volume names | + +The slug is derived automatically from the package name (everything after `/`, hyphens +preserved), so the developer only needs to provide `--plugin-package`. + +--- + +## The Dockerfile Template + +The only plugin-specific lines in the Dockerfile are the `composer require` call. These +are made generic via a build arg: + +```dockerfile +ARG PLUGIN_PACKAGE={{PLUGIN_PACKAGE}} + +COPY . /tmp/plugin/ +RUN --mount=type=cache,target=/root/.composer \ + composer config repositories.plugin \ + '{"type":"path","url":"/tmp/plugin","options":{"symlink":false}}' \ + && composer require ${PLUGIN_PACKAGE}:@dev +``` + +Everything else (PHP extensions, caretakerd, FrankenPHP, Neos base distribution, config +copy) is already generic across plugins. + +--- + +## `.dockerignore` (generated at plugin root) + +Without this, `COPY . /tmp/plugin/` in the Dockerfile would include the test +infrastructure itself inside the image: + +``` +Tests/E2E/node_modules +Tests/E2E/.playwright +Tests/system_under_test +``` + +--- + +## The `bin/neos-e2e-setup` Script + +A self-contained PHP script — no autoloading, no framework. It: + +1. Parses `--plugin-package` from `$argv` +2. Derives `{{PLUGIN_SLUG}}` from the package name +3. Iterates over `templates/` recursively +4. For each template file: substitutes tokens, copies to the target path +5. Skips files that already exist (idempotent) + +--- + +## What the Developer Adds After Scaffolding + +The scaffolded layout has intentional empty directories (with `.gitkeep`) for the +plugin-specific content: + +``` +Tests/E2E/features/ + ← write .feature files here + +Tests/E2E/steps/ + ← write step definitions here + +Tests/system_under_test/neos8/sut-files/app/Configuration/Production/E2E-SUT/ +Tests/system_under_test/neos9/sut-files/app/Configuration/Production/E2E-SUT/ + ← plugin-specific Settings.yaml, Policy.yaml, context subdirectories, etc. +``` + +The scaffold writes the base `Configuration/Production/E2E-SUT/Settings.yaml` (DB + Redis +connection) and `Caches.yaml`. The plugin only adds its own configuration on top. + +--- + +## Open Question: Container Name Convention + +The `SUT` environment variable (used in step definitions as `${SUT}-neos-1` to target +`docker exec`) is a contract between the scaffolded `docker-compose.yaml` and the +developer's step helpers. Since step helpers are entirely the plugin developer's business, +this convention needs to be documented somewhere visible — either in a generated `README` +in `Tests/E2E/`, or as a comment in the scaffolded `playwright.config.ts`. + +A decision is needed on where this contract lives before the package is built. diff --git a/Configuration/Settings.2FA.yaml b/Configuration/Settings.2FA.yaml new file mode 100644 index 0000000..3ee0629 --- /dev/null +++ b/Configuration/Settings.2FA.yaml @@ -0,0 +1,10 @@ +Sandstorm: + NeosTwoFactorAuthentication: + # enforce 2FA for all users + enforceTwoFactorAuthentication: false + # enforce 2FA for specific authentication providers (e.g. Neos.Neos:Backend) + enforce2FAForAuthenticationProviders : [] + # enforce 2FA for specific roles (e.g. Neos.Neos:Administrator) + enforce2FAForRoles: [] + # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used + issuerName: '' diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 559bc32..a5d32b1 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -48,14 +48,3 @@ Neos: pattern: 'ControllerObjectName' patternOptions: controllerObjectNamePattern: 'Sandstorm\NeosTwoFactorAuthentication\Controller\(LoginController|BackendController)' - -Sandstorm: - NeosTwoFactorAuthentication: - # enforce 2FA for all users - enforceTwoFactorAuthentication: false - # enforce 2FA for specific authentication providers - enforce2FAForAuthenticationProviders : [] - # enforce 2FA for specific roles - enforce2FAForRoles: [] - # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used - issuerName: '' diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2e9cf6f --- /dev/null +++ b/Makefile @@ -0,0 +1,101 @@ +NEOS8_COMPOSE = $(CURDIR)/Tests/system_under_test/neos8/docker-compose.yaml +NEOS9_COMPOSE = $(CURDIR)/Tests/system_under_test/neos9/docker-compose.yaml +E2E_DIR = $(CURDIR)/Tests/E2E + +.SILENT: +.PHONY: setup setup-sut setup-test generate-bdd-files test test-neos8 test-neos9 \ + test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider test-neos8-issuer-name \ + test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider test-neos9-issuer-name \ + start-sut-neos8 start-sut-neos9 log-sut-neos8 log-sut-neos9 enter-sut-neos8 enter-sut-neos9 sut-down + +# COLORS +GREEN := $(shell tput -Txterm setaf 2) +YELLOW := $(shell tput -Txterm setaf 3) +RESET := $(shell tput -Txterm sgr0) + +# initial setup +setup: setup-sut setup-test + +setup-sut: + docker compose -f $(NEOS8_COMPOSE) build --pull + 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: test-neos8 test-neos9 + +## Run all neos8 E2E tests +test-neos8: test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider + +## Run all neos9 E2E tests +test-neos9: test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider + +test-neos8-defaults: + cd $(E2E_DIR) && npm run test:neos8:defaults + +test-neos8-enforce-all: + cd $(E2E_DIR) && npm run test:neos8:enforce-all + +test-neos8-enforce-role: + cd $(E2E_DIR) && npm run test:neos8:enforce-role + +test-neos8-enforce-provider: + cd $(E2E_DIR) && npm run test:neos8:enforce-provider + +test-neos8-issuer-name: + cd $(E2E_DIR) && npm run test:neos8:issuer-name + +test-neos9-defaults: + cd $(E2E_DIR) && npm run test:neos9:defaults + +test-neos9-enforce-all: + cd $(E2E_DIR) && npm run test:neos9:enforce-all + +test-neos9-enforce-role: + cd $(E2E_DIR) && npm run test:neos9:enforce-role + +test-neos9-enforce-provider: + cd $(E2E_DIR) && npm run test:neos9:enforce-provider + +test-neos9-issuer-name: + cd $(E2E_DIR) && npm run test:neos9:issuer-name + +## Start SUT containers +start-sut-neos8: + docker compose -f $(NEOS8_COMPOSE) up -d --build + +start-sut-neos9: + docker compose -f $(NEOS9_COMPOSE) up -d --build + +## Follow logs of SUT containers +log-sut-neos8: + docker compose -f $(NEOS8_COMPOSE) logs -f + +log-sut-neos9: + docker compose -f $(NEOS9_COMPOSE) logs -f + +## Enter SUT containers +enter-sut-neos8: + docker compose -f $(NEOS8_COMPOSE) exec neos bash + +enter-sut-neos9: + docker compose -f $(NEOS9_COMPOSE) exec neos bash + +## Tear down all docker compose environments and remove volumes +sut-down: + echo "${YELLOW}Shutting down all SUTs and removing their volumes.${RESET}" + docker compose -f $(NEOS8_COMPOSE) down -v + docker compose -f $(NEOS9_COMPOSE) down -v diff --git a/Migrations/Mysql/Version20260325141345.php b/Migrations/Mysql/Version20260325141345.php new file mode 100644 index 0000000..121dabf --- /dev/null +++ b/Migrations/Mysql/Version20260325141345.php @@ -0,0 +1,41 @@ +abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." + ); + + $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor ADD name VARCHAR(255) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." + ); + + $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor DROP name'); + } +} diff --git a/README.md b/README.md index 4573608..2c1f8bf 100644 --- a/README.md +++ b/README.md @@ -177,3 +177,91 @@ causes the same exception again. We get caught in an endless redirect. The [Neos Flow Security Documentation](https://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/Security.html#multi-factor-authentication-strategy) suggests how to implement a multi-factor-authentication, but this method seems like it was never tested. At the moment of writing it seems like the `authenticationStrategy: allTokens` flag is broken and not usable. + +## Contributing + +### Testing + +The package ships with end-to-end tests built on [Playwright](https://playwright.dev) and written in Gherkin syntax via [playwright-bdd](https://vitalets.github.io/playwright-bdd/). + +#### Running the tests + +Tests require Docker and Node.js. Install dependencies once (if [nvm](https://github.com/nvm-sh/nvm) is available it will automatically switch to the Node version from `.nvmrc`): + +```bash +make setup-test +``` + +Re-generate Playwright spec files whenever a `.feature` file changes: +```bash +make generate-bdd-files +``` + +Use the Makefile targets from the repository root: + +```bash +make test # run all tests (neos8 + neos9, all configurations) + +make test-neos8 # run all neos8 tests +make test-neos8-defaults # default configuration only +make test-neos8-enforce-all # enforceTwoFactorAuthentication: true +make test-neos8-enforce-role +make test-neos8-enforce-provider +make test-neos8-issuer-name + +make test-neos9 # same targets for neos9 / PHP 8.3 + +make down # tear down all docker compose environments and remove volumes +``` + +#### Debugging tests +To debug a test, run the test from `Tests/E2E/` with flags like this: + +- `npm run test:neos8:enforce-all -- --debug` - to run the test in headed mode with Playwright Inspector +- `npm run test:neos8:enforce-all -- --ui` - to run the test in headed mode with Playwright Test Runner UI + +If you just want to see the test running in the browser just `npm run test:neos8:enforce-all -- --headed`. + +> While debugging you can also enter the SUT with `make enter-neos8` and `make enter-neos9` respectively. +> +> You can even the tests you want to debug with `npm run test:neos8:enforce-all -- --grep @debug` and adding the `@debug` tag to the scenario you want to debug. But using the --ui flag is usually more convenient for debugging. + +#### System under test (SUT) + +There are two docker compose environments in `Tests/system_under_test/`: + +- `neos8/` — Neos with PHP 8.2 +- `neos9/` — Neos with PHP 8.5 + +Both are built from the repository root as the Docker build context, so the local package source is copied into the container and installed via a Composer path repository. This means every test run tests the _current working tree_ of the package, not a published version. + +#### Configuration variants + +The `FLOW_CONTEXT` environment variable is passed into the docker compose environment via variable substitution, and Flow's hierarchical configuration loading picks up the corresponding `Settings.yaml` from the SUT: + +| Playwright tag | `FLOW_CONTEXT` | What is tested | +|---|---|---| +| `@default-context` | `Production/E2E-SUT` | No enforcement — 2FA is optional | +| `@enforce-for-all` | `Production/E2E-SUT/EnforceForAll` | `enforceTwoFactorAuthentication: true` | +| `@enforce-for-role` | `Production/E2E-SUT/EnforceForRole` | Enforcement scoped to `Neos.Neos:Administrator` | +| `@enforce-for-provider` | `Production/E2E-SUT/EnforceForProvider` | Enforcement scoped to an authentication provider | +| `@issuer-name-change` | `Production/E2E-SUT/IssuerNameChange` | Custom `issuerName` setting | + +#### Test isolation + +Each scenario starts with a clean state. An `AfterScenario` hook runs after every scenario to: + +1. Log the browser out via a POST to `/neos/logout` +2. Delete all Neos users (`./flow user:delete --assume-yes '*'`) + +Deleting all users also cascades to their 2FA devices, so no separate cleanup step is needed. Users and devices are re-created by the Background steps at the start of each scenario. + +#### Design decisions + +**Gherkin / BDD over plain Playwright specs** — the feature files document the intended behaviour of each configuration variant at a level that is readable without knowing the implementation. The generated Playwright spec files (`.features-gen/`) are not committed; they are re-generated by `bddgen` before each test run. + +**UI-only device enrolment** — 2FA devices are enrolled through the browser UI (the backend module or the setup page) rather than a dedicated CLI command. This avoids coupling the tests to internal persistence details and exercises the same enrolment path a real user would take. The `deviceNameSecretMap` in `helpers/state.ts` carries TOTP secrets across steps within a scenario (e.g. from the enrolment step to the OTP entry step). + +**Sequential execution** — tests run with `workers: 1` and `fullyParallel: false` because all scenarios share a single running SUT container and a single database. Running them in parallel would cause interference between scenarios. + +**User creation via `docker exec`** — Neos user creation is done through the Flow CLI (`./flow user:create`) rather than the UI because the UI path is not part of what this package tests, and using the CLI is faster and more reliable for setup. diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion index 16ed82a..e573c51 100644 --- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion +++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion @@ -87,10 +87,18 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF attributes.aria-label={I18n.id('otp-placeholder').package('Sandstorm.NeosTwoFactorAuthentication')} attributes.autocomplete="off" /> +
- + {I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}
diff --git a/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion b/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion index f7275d2..f3f65a5 100644 --- a/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion +++ b/Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion @@ -5,8 +5,9 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList) < pr - + + @@ -43,9 +44,10 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry +
{I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}{I18n.id('module.index.list.header.username').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} {I18n.id('module.index.list.header.type').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}{I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} {I18n.id('module.index.list.header.creationDate').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}  
{props.factorAndPerson.user.name.fullName} ({props.factorAndPerson.secondFactor.account.accountIdentifier}) {props.factorAndPerson.secondFactor.typeAsName}{props.factorAndPerson.secondFactor.name == null ? '-' : props.factorAndPerson.secondFactor.name} {props.factorAndPerson.secondFactor.creationDate == null ? '-' : Date.format(props.factorAndPerson.secondFactor.creationDate, 'Y-m-d H:i')} - @@ -71,7 +73,7 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry {I18n.id('module.index.delete.cancel').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} - + {I18n.id('module.index.delete.confirm').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} diff --git a/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion b/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion index 33f7c09..dd6259d 100644 --- a/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion +++ b/Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion @@ -156,6 +156,13 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Page.SetupSecondFactorPage) < pr attributes.aria-label={I18n.id('otp-placeholder').package('Sandstorm.NeosTwoFactorAuthentication')} attributes.autocomplete="off" /> +
diff --git a/Resources/Private/Translations/de/Backend.xlf b/Resources/Private/Translations/de/Backend.xlf index 9ba96b8..29881a1 100644 --- a/Resources/Private/Translations/de/Backend.xlf +++ b/Resources/Private/Translations/de/Backend.xlf @@ -15,14 +15,18 @@ List of all registered second factors Liste aller registrierten zweiten Faktoren - - Name - Name + + User + Nutzer Type Typ + + Name + Name + Creation Date Erstellungsdatum diff --git a/Resources/Private/Translations/de/Main.xlf b/Resources/Private/Translations/de/Main.xlf index ece9013..e515f73 100644 --- a/Resources/Private/Translations/de/Main.xlf +++ b/Resources/Private/Translations/de/Main.xlf @@ -34,6 +34,10 @@ Close Schließen + + Name (optional) + Name (optional) + diff --git a/Resources/Private/Translations/en/Backend.xlf b/Resources/Private/Translations/en/Backend.xlf index 4c0f5e4..e3f2ba1 100644 --- a/Resources/Private/Translations/en/Backend.xlf +++ b/Resources/Private/Translations/en/Backend.xlf @@ -12,12 +12,15 @@ List of all registered second factors - - Name + + User Type + + Name + Creation Date diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index 6a1a249..63654b7 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -26,6 +26,9 @@ Close + + Name (optional) + diff --git a/Resources/Public/Styles/Login.css b/Resources/Public/Styles/Login.css index 28d9b6f..0634492 100644 --- a/Resources/Public/Styles/Login.css +++ b/Resources/Public/Styles/Login.css @@ -159,3 +159,7 @@ .neos-two-factor__secret-wrapper ::-webkit-scrollbar-track { background-color: initial; } + +.neos-control-group > * + * { + margin-top: 8px; +} diff --git a/Tests/E2E/.gitignore b/Tests/E2E/.gitignore new file mode 100644 index 0000000..896c157 --- /dev/null +++ b/Tests/E2E/.gitignore @@ -0,0 +1,6 @@ +# 3rd party sources +node_modules/ + +# transient test files +.features-gen/ +test-results/ 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/features/default/backend-module.feature b/Tests/E2E/features/default/backend-module.feature new file mode 100644 index 0000000..6e96e07 --- /dev/null +++ b/Tests/E2E/features/default/backend-module.feature @@ -0,0 +1,46 @@ +@default-context +Feature: Backend module for two-factor authentication management with default settings + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" exists + + Scenario: Admin user can add a 2FA in backend module + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + Then There should be 1 enrolled 2FA device + And There should be a 2FA device with the name "Admin Test Device" + + Scenario: Admin user can remove his own, last 2FA when 2FA is not enforced + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + And I remove the 2FA device with the name "Admin Test Device" + Then There should be 0 enrolled 2FA devices + And There should be no 2FA device with the name "Admin Test Device" + + Scenario: Editor user can add a 2FA in backend module + When I log in with username "editor" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Editor Test Device" + Then There should be 1 enrolled 2FA device + And There should be a 2FA device with the name "Editor Test Device" + + Scenario: Editor user can remove his own, last 2FA when 2FA is not enforced + When I log in with username "editor" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Editor Test Device" + And I remove the 2FA device with the name "Editor Test Device" + Then There should be 0 enrolled 2FA devices + And There should be no 2FA device with the name "Editor Test Device" + + Scenario: Admin user can remove another user's 2FA when 2FA is not enforced + When I log in with username "editor" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Editor Test Device" + And I log out + And I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I remove the 2FA device with the name "Editor Test Device" + Then There should be no 2FA device with the name "Editor Test Device" diff --git a/Tests/E2E/features/default/login.feature b/Tests/E2E/features/default/login.feature new file mode 100644 index 0000000..12f8440 --- /dev/null +++ b/Tests/E2E/features/default/login.feature @@ -0,0 +1,31 @@ +@default-context +Feature: Login flow with default settings + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" exists + + Scenario: Admin user can log in without 2FA when 2FA is not enforced + When I log in with username "admin" and password "password" + Then I should see the Neos content page + + Scenario: Editor user can log in without 2FA when 2FA is not enforced + When I log in with username "editor" and password "password" + Then I should see the Neos content page + + Scenario: User has to enter 2FA code when a TOTP 2FA device is added to his account + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + And I log out + And I log in with username "admin" and password "password" + Then I should see the 2FA verification page + + Scenario: User can log in with 2FA when a TOTP device is added to his account + When I log in with username "admin" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + And I log out + And I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Test Device" + Then I should see the Neos content page diff --git a/Tests/E2E/features/enforce-for-all/backend-module.feature b/Tests/E2E/features/enforce-for-all/backend-module.feature new file mode 100644 index 0000000..fb0de4e --- /dev/null +++ b/Tests/E2E/features/enforce-for-all/backend-module.feature @@ -0,0 +1,52 @@ +@enforce-for-all +Feature: Backend module for two-factor authentication management with default settings + + # Requires FLOW_CONTEXT=Production/E2E-SUT/EnforceForAll + # Config: enforceTwoFactorAuthentication: true + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" with enrolled 2FA device with name "Admin Initial Device" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" with enrolled 2FA device with name "Editor Initial Device" exists + + Scenario: Admin user can add a 2FA in backend module + When I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Initial Device" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + Then There should be a 2FA device with the name "Admin Test Device" + And There should be a 2FA device with the name "Admin Initial Device" + + Scenario: Admin user can remove a 2FA device when there are more than 1 enrolled devices + When I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Initial Device" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Admin Test Device" + And I remove the 2FA device with the name "Admin Initial Device" + And There should be a 2FA device with the name "Admin Test Device" + And There should be no 2FA device with the name "Admin Initial Device" + + Scenario: Editor user can add a 2FA in backend module + When I log in with username "editor" and password "password" + And I enter a valid TOTP for device "Editor Initial Device" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Editor Test Device" + Then There should be 2 enrolled 2FA device + And There should be a 2FA device with the name "Editor Test Device" + And There should be a 2FA device with the name "Editor Initial Device" + + Scenario: Editor user can remove a 2FA device when there are more than 1 enrolled devices + When I log in with username "editor" and password "password" + And I enter a valid TOTP for device "Editor Initial Device" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Editor Test Device" + And I remove the 2FA device with the name "Editor Initial Device" + Then There should be 1 enrolled 2FA devices + And There should be a 2FA device with the name "Editor Test Device" + And There should be no 2FA device with the name "Editor Initial Device" + + Scenario: Admin user can remove another user's last 2FA, even when 2FA is enforced + When I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Initial Device" + And I navigate to the 2FA management page + And I remove the 2FA device with the name "Editor Initial Device" + Then There should be no 2FA device with the name "Editor Initial Device" diff --git a/Tests/E2E/features/enforce-for-all/login.feature b/Tests/E2E/features/enforce-for-all/login.feature new file mode 100644 index 0000000..31d469f --- /dev/null +++ b/Tests/E2E/features/enforce-for-all/login.feature @@ -0,0 +1,21 @@ +@enforce-for-all +Feature: Login flow with 2FA enforced for all users + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" exists + + Scenario: Admin has to setup a 2FA when 2FA is enforced for all users, even without a device + When I log in with username "admin" and password "password" + Then I should see the 2FA setup page + And I cannot access the Neos content page + + Scenario: Editor has to setup a 2FA when 2FA is enforced for all users, even without a device + When I log in with username "editor" and password "password" + Then I should see the 2FA setup page + And I cannot access the Neos content page + + Scenario: User can log in when after setting up a 2FA device + When I log in with username "editor" and password "password" + And I set up a 2FA device with name "Editor Test Device" + Then I should see the Neos content page diff --git a/Tests/E2E/features/enforce-for-provider/backend-module.feature b/Tests/E2E/features/enforce-for-provider/backend-module.feature new file mode 100644 index 0000000..65095c0 --- /dev/null +++ b/Tests/E2E/features/enforce-for-provider/backend-module.feature @@ -0,0 +1,24 @@ +@enforce-for-provider +Feature: Backend module with 2FA enforced for the Neos.Neos:Backend authentication provider + + # Requires FLOW_CONTEXT=Production/E2E-SUT/EnforceForProvider + # Config: enforce2FAForAuthenticationProviders: ['Neos.Neos:Backend'] + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" with enrolled 2FA device with name "Admin Initial Device" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" with enrolled 2FA device with name "Editor Initial Device" exists + + Scenario: User cannot remove their last 2FA device when 2FA is enforced for their provider + When I log in with username "editor" and password "password" + And I enter a valid TOTP for device "Editor Initial Device" + And I navigate to the 2FA management page + And I remove the 2FA device with the name "Editor Initial Device" + Then There should be 1 enrolled 2FA device + And There should be a 2FA device with the name "Editor Initial Device" + + Scenario: Admin user can remove another user's last 2FA device, even when 2FA is enforced for their provider + When I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Initial Device" + And I navigate to the 2FA management page + And I remove the 2FA device with the name "Editor Initial Device" + Then There should be no 2FA device with the name "Editor Initial Device" diff --git a/Tests/E2E/features/enforce-for-provider/login.feature b/Tests/E2E/features/enforce-for-provider/login.feature new file mode 100644 index 0000000..09eced6 --- /dev/null +++ b/Tests/E2E/features/enforce-for-provider/login.feature @@ -0,0 +1,31 @@ +@enforce-for-provider +Feature: Login flow with 2FA enforced for the Neos.Neos:Backend authentication provider + + # Requires FLOW_CONTEXT=Production/E2E-SUT/EnforceForProvider + # Config: enforce2FAForAuthenticationProviders: ['Neos.Neos:Backend'] + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" exists + + Scenario: Administrator is redirected to 2FA setup when no device is enrolled + When I log in with username "admin" and password "password" + Then I should see the 2FA setup page + And I cannot access the Neos content page + + Scenario: Editor is redirected to 2FA setup when no device is enrolled + When I log in with username "editor" and password "password" + Then I should see the 2FA setup page + And I cannot access the Neos content page + + Scenario: User can log in after setting up a 2FA device + When I log in with username "editor" and password "password" + And I set up a 2FA device with name "Editor Test Device" + Then I should see the Neos content page + + Scenario: User still has to enter a TOTP code when a device is enrolled + When I log in with username "admin" and password "password" + And I set up a 2FA device with name "Admin Test Device" + And I log out + And I log in with username "admin" and password "password" + Then I should see the 2FA verification page diff --git a/Tests/E2E/features/enforce-for-role/backend-module.feature b/Tests/E2E/features/enforce-for-role/backend-module.feature new file mode 100644 index 0000000..ae52cfc --- /dev/null +++ b/Tests/E2E/features/enforce-for-role/backend-module.feature @@ -0,0 +1,40 @@ +@enforce-for-role +Feature: Backend module with 2FA enforced for administrators only + + # Requires FLOW_CONTEXT=Production/E2E-SUT/EnforceForRole + # Config: enforce2FAForRoles: ['Neos.Neos:Administrator', 'Neos.Neos:SecondFactorUser'] + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" with enrolled 2FA device with name "Admin Initial Device" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" exists + And A user with username "secondFactorUser", password "password" and role "Neos.Neos:SecondFactorUser" with enrolled 2FA device with name "2FA-User Initial Device" exists + + Scenario: Editor can remove their own last 2FA device when 2FA is not enforced for their role + When I log in with username "editor" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Editor Test Device" + And I remove the 2FA device with the name "Editor Test Device" + Then There should be 0 enrolled 2FA devices + + Scenario: Administrator can remove another user's last 2FA device, even when 2FA is enforced for their role + When I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Initial Device" + And I navigate to the 2FA management page + And I remove the 2FA device with the name "2FA-User Initial Device" + Then There should be no 2FA device with the name "2FA-User Initial Device" + + Scenario: Administrator can remove their own last 2FA device even when 2FA is enforced for their role + When I log in with username "admin" and password "password" + And I enter a valid TOTP for device "Admin Initial Device" + And I navigate to the 2FA management page + And I remove the 2FA device with the name "Admin Initial Device" + Then There should be 1 enrolled 2FA device + And There should be no 2FA device with the name "Admin Initial Device" + + Scenario: User can not remove their own last 2FA device when 2FA is enforced for their role + When I log in with username "secondFactorUser" and password "password" + And I enter a valid TOTP for device "2FA-User Initial Device" + And I navigate to the 2FA management page + And I remove the 2FA device with the name "2FA-User Initial Device" + Then There should be 1 enrolled 2FA device + And There should be a 2FA device with the name "2FA-User Initial Device" diff --git a/Tests/E2E/features/enforce-for-role/login.feature b/Tests/E2E/features/enforce-for-role/login.feature new file mode 100644 index 0000000..f69c672 --- /dev/null +++ b/Tests/E2E/features/enforce-for-role/login.feature @@ -0,0 +1,31 @@ +@enforce-for-role +Feature: Login flow with 2FA enforced for administrators only + + # Requires FLOW_CONTEXT=Production/E2E-SUT/EnforceForRole + # Config: enforce2FAForRoles: ['Neos.Neos:Administrator'] + + Background: + Given A user with username "admin", password "password" and role "Neos.Neos:Administrator" exists + And A user with username "editor", password "password" and role "Neos.Neos:Editor" exists + + Scenario: Administrator is redirected to 2FA setup when no device is enrolled + When I log in with username "admin" and password "password" + Then I should see the 2FA setup page + And I cannot access the Neos content page + + Scenario: Administrator can log in after setting up a 2FA device + When I log in with username "admin" and password "password" + And I set up a 2FA device with name "Admin Test Device" + Then I should see the Neos content page + + Scenario: Editor can log in without 2FA when no device is enrolled + When I log in with username "editor" and password "password" + Then I should see the Neos content page + + Scenario: Editor still has to enter a TOTP code when a device is enrolled + When I log in with username "editor" and password "password" + And I navigate to the 2FA management page + And I add a new TOTP 2FA device with name "Editor Test Device" + And I log out + And I log in with username "editor" and password "password" + Then I should see the 2FA verification page diff --git a/Tests/E2E/global-teardown.ts b/Tests/E2E/global-teardown.ts new file mode 100644 index 0000000..cc788d6 --- /dev/null +++ b/Tests/E2E/global-teardown.ts @@ -0,0 +1,10 @@ +import { execSync } from 'node:child_process'; +import { dirname } from 'node:path'; + +export default async function globalTeardown() { + const sut = process.env.SUT || 'neos8'; + execSync( + `docker compose -f ../system_under_test/${sut}/docker-compose.yaml down -v`, + { stdio: 'inherit', cwd: dirname('.') } + ); +} diff --git a/Tests/E2E/helpers/2fa-pages.ts b/Tests/E2E/helpers/2fa-pages.ts new file mode 100644 index 0000000..909a16d --- /dev/null +++ b/Tests/E2E/helpers/2fa-pages.ts @@ -0,0 +1,120 @@ +import type { Page } from '@playwright/test'; +import { generateOtp } from './totp.js'; + +export class SecondFactorLoginPage { + constructor(private readonly page: Page) {} + + async waitForPage() { + await this.page.waitForURL('**/neos/second-factor-login'); + } + + async enterOtp(otp: string) { + await this.page.locator('input#secondFactor').fill(otp); + await this.page.locator('.neos-login-btn:not(.neos-disabled):not(.neos-hidden)').first().click(); + } + + async getErrorMessage(): Promise { + const el = this.page.locator('.neos-tooltip-error .neos-tooltip-inner'); + await el.waitFor(); + return el.innerText(); + } +} + +export class SecondFactorSetupPage { + constructor(private readonly page: Page) {} + + async waitForPage() { + await this.page.waitForURL('**/neos/second-factor-setup'); + } + + async getSecret(): Promise { + const secret = await this.page.locator('input#secret').getAttribute('value'); + if (!secret) throw new Error('Secret not found on setup page'); + return secret; + } + + async submitOtp(secret: string, name?: string) { + if (name) { + await this.page.fill('input#name', name); + } + const otp = generateOtp(secret); + await this.page.locator('input#secondFactorFromApp').fill(otp); + await this.page.locator('button[type="submit"]').click(); + } +} + +export class BackendModulePage { + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto('/neos/management/twoFactorAuthentication'); + } + + async waitForPage() { + await this.page.waitForURL('**/neos/management/twoFactorAuthentication**'); + } + + async getDeviceCount(): Promise { + return this.page.locator('.neos-table tbody tr').count(); + } + + async deleteFirstDevice() { + await this.page.locator('button.neos-button-danger').first().click(); + // Confirm in modal + await this.page.locator('.neos-modal-footer button.neos-button-danger').click(); + } + + /** Navigate to the new-device form, complete setup, and return the TOTP secret. */ + async addDevice(name: string): Promise { + await this.page.goto('/neos/management/twoFactorAuthentication/new'); + + const secretInput = this.page.locator('input#secret'); + const secret = await secretInput.getAttribute('value'); + if (!secret) throw new Error('TOTP secret not found on new-device page'); + + await this.page.fill('input#name', name); + await this.page.fill('input#secondFactorFromApp', generateOtp(secret)); + await this.page.locator('button[data-test-id="create-second-factor-submit-button"]').click(); + + // Wait for redirect back to the index (table appears) + await this.page.locator('.neos-table').waitFor(); + + return secret; + } + + /** Find the table row for the named device and click the delete button, then confirm. */ + async deleteDeviceByName(name: string): Promise { + const row = this.locatorForDeviceRow(name); + await row.locator('button[data-test-id="delete-second-factor-button"]').click(); + await this.page.locator('button[data-test-id="confirm-delete"]:visible').click(); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Attempt to delete a device by name without assuming success. + * If the delete button is disabled or no confirmation modal appears, the step passes silently. + * Use this when the deletion may be blocked (e.g. last device with enforcement active). + */ + async tryDeleteDeviceByName(name: string): Promise { + const row = this.locatorForDeviceRow(name); + const deleteButton = row.locator('button[data-test-id="delete-second-factor-button"]'); + + if (await deleteButton.isDisabled()) { + return; + } + + await deleteButton.click(); + + const confirmButton = this.page.locator('button[data-test-id="confirm-delete"]:visible'); + if (await confirmButton.isVisible({ timeout: 1000 })) { + await confirmButton.click(); + } + + await this.page.waitForLoadState('networkidle'); + } + + /** Locator for the table row matching a device name (for assertions). */ + locatorForDeviceRow(name: string) { + return this.page.locator('.neos-table tbody tr').filter({ hasText: name }); + } +} diff --git a/Tests/E2E/helpers/general-pages.ts b/Tests/E2E/helpers/general-pages.ts new file mode 100644 index 0000000..9ff8832 --- /dev/null +++ b/Tests/E2E/helpers/general-pages.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test'; + +export class NeosLoginPage { + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto('/neos/login'); + } + + async login(username: string, password: string) { + await this.page.locator('input[type="text"]').fill(username); + await this.page.locator('input[type="password"]').fill(password); + await this.page.locator('.neos-login-btn:not(.neos-disabled):not(.neos-hidden)').click(); + } +} + +export class NeosContentPage { + public readonly URL_REGEX = /neos\/content/; + + constructor(private readonly page: Page) {} + + async goto() { + await this.page.goto('/neos/content'); + } +} diff --git a/Tests/E2E/helpers/state.ts b/Tests/E2E/helpers/state.ts new file mode 100644 index 0000000..9d7b9ac --- /dev/null +++ b/Tests/E2E/helpers/state.ts @@ -0,0 +1,4 @@ +export const state = { + // used to track otp secrets (for testing deviceName-secret is enough) - when writing a test with multiple equal device names this would not suffice anymore + deviceNameSecretMap: new Map(), +} diff --git a/Tests/E2E/helpers/system.ts b/Tests/E2E/helpers/system.ts new file mode 100644 index 0000000..1c47385 --- /dev/null +++ b/Tests/E2E/helpers/system.ts @@ -0,0 +1,23 @@ +import { execSync } from 'node:child_process'; +import { dirname } from 'node:path'; +import { type Page } from "@playwright/test"; + +const CONTAINER = `${process.env.SUT || 'neos8'}-neos-1`; + +export function createUser(name: string, password: string, roles: string[]) { + execSync( + `docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:create ${name} ${password} Test${name} User${name} --roles ${roles.join(',')}"`, + { stdio: 'ignore', cwd: dirname('.') } + ) +} + +export function removeAllUsers() { + execSync( + `docker exec -u www-data -w /app ${CONTAINER} bash -c "./flow user:delete --assume-yes '*'"`, + { stdio: 'ignore', cwd: dirname('.') } + ) +} + +export async function logout(page: Page) { + await page.context().request.post('/neos/logout'); +} diff --git a/Tests/E2E/helpers/totp.ts b/Tests/E2E/helpers/totp.ts new file mode 100644 index 0000000..4436e28 --- /dev/null +++ b/Tests/E2E/helpers/totp.ts @@ -0,0 +1,5 @@ +import { generateSync } from 'otplib'; + +export function generateOtp(secret: string): string { + return generateSync({ secret, strategy: 'totp' }); +} diff --git a/Tests/E2E/package-lock.json b/Tests/E2E/package-lock.json new file mode 100644 index 0000000..7d8a918 --- /dev/null +++ b/Tests/E2E/package-lock.json @@ -0,0 +1,796 @@ +{ + "name": "sandstorm-neostwofactorauthentication-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sandstorm-neostwofactorauthentication-e2e", + "dependencies": { + "@playwright/test": "^1.58.2", + "otplib": "^13.4.0", + "playwright-bdd": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "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/cucumber-expressions": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-18.0.1.tgz", + "integrity": "sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==", + "license": "MIT", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "32.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz", + "integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==", + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <28" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz", + "integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==", + "license": "MIT", + "dependencies": { + "@cucumber/gherkin": "^31.0.0", + "@cucumber/messages": "^27.0.0", + "@teppeis/multimaps": "3.0.0", + "commander": "13.1.0", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz", + "integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==", + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=26" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", + "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "10.0.0" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "21.15.1", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.15.1.tgz", + "integrity": "sha512-tjxEpP161sQ7xc3VREc94v1ymwIckR3ySViy7lTvfi1jUpyqy2Hd/p4oE3YT1kQ9fFDvUflPwu5ugK5mA7BQLA==", + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/junit-xml-formatter": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.7.1.tgz", + "integrity": "sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==", + "license": "MIT", + "dependencies": { + "@cucumber/query": "^13.0.2", + "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", + "xmlbuilder": "^15.1.1" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/messages": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", + "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "11.0.5" + } + }, + "node_modules/@cucumber/messages/node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@cucumber/query": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz", + "integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==", + "license": "MIT", + "dependencies": { + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.2.0.tgz", + "integrity": "sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==", + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz", + "integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz", + "integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz", + "integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz", + "integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.4.0" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz", + "integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz", + "integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "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/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "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/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.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/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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/otplib": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz", + "integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/plugin-base32-scure": "13.4.0", + "@otplib/plugin-crypto-noble": "13.4.0", + "@otplib/totp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-bdd": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-8.5.0.tgz", + "integrity": "sha512-w/Bd5C1d6Xe5e1oREsbt2rDN0/Mcp+J2OjQwSl49/mroa2K6UZU33P7v91pLQPl1otDitMwJOaOeJsqaj7WU7w==", + "license": "MIT", + "dependencies": { + "@cucumber/cucumber-expressions": "18.0.1", + "@cucumber/gherkin": "^32.1.2", + "@cucumber/gherkin-utils": "^9.2.0", + "@cucumber/html-formatter": "^21.11.0", + "@cucumber/junit-xml-formatter": "^0.7.1", + "@cucumber/messages": "^27.2.0", + "@cucumber/tag-expressions": "^6.2.0", + "cli-table3": "0.6.5", + "commander": "^13.1.0", + "fast-glob": "^3.3.3", + "mime-types": "^3.0.2", + "xmlbuilder": "15.1.1" + }, + "bin": { + "bddgen": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/vitalets" + }, + "peerDependencies": { + "@playwright/test": ">=1.44" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "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/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "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..c54f6cf --- /dev/null +++ b/Tests/E2E/package.json @@ -0,0 +1,29 @@ +{ + "name": "sandstorm-neostwofactorauthentication-e2e", + "private": true, + "type": "module", + "scripts": { + "generate-tests": "SUT=notRelevantForBddgen FLOW_CONTEXT=notRelevantForBddgen npx bddgen", + + "test:neos8:defaults": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @default-context", + "test:neos8:enforce-all": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/EnforceForAll npx playwright test --grep @enforce-for-all", + "test:neos8:enforce-role": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/EnforceForRole npx playwright test --grep @enforce-for-role", + "test:neos8:enforce-provider": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/EnforceForProvider npx playwright test --grep @enforce-for-provider", + "test:neos8:issuer-name": "npm run generate-tests && SUT=neos8 FLOW_CONTEXT=Production/E2E-SUT/IssuerNameChange npx playwright test --grep @issuer-name-change", + + "test:neos9:defaults": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT npx playwright test --grep @default-context", + "test:neos9:enforce-all": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/EnforceForAll npx playwright test --grep @enforce-for-all", + "test:neos9:enforce-role": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/EnforceForRole npx playwright test --grep @enforce-for-role", + "test:neos9:enforce-provider": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/EnforceForProvider npx playwright test --grep @enforce-for-provider", + "test:neos9:issuer-name": "npm run generate-tests && SUT=neos9 FLOW_CONTEXT=Production/E2E-SUT/IssuerNameChange npx playwright test --grep @issuer-name-change" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^6.0.2" + }, + "dependencies": { + "@playwright/test": "^1.58.2", + "playwright-bdd": "^8.0.0", + "otplib": "^13.4.0" + } +} diff --git a/Tests/E2E/playwright.config.ts b/Tests/E2E/playwright.config.ts new file mode 100644 index 0000000..1e2c799 --- /dev/null +++ b/Tests/E2E/playwright.config.ts @@ -0,0 +1,41 @@ +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'] }, + }, + ], +}); diff --git a/Tests/E2E/steps/2fa-login.steps.ts b/Tests/E2E/steps/2fa-login.steps.ts new file mode 100644 index 0000000..bcc5f49 --- /dev/null +++ b/Tests/E2E/steps/2fa-login.steps.ts @@ -0,0 +1,74 @@ +import { expect } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; +import { BackendModulePage, SecondFactorLoginPage, SecondFactorSetupPage } from '../helpers/2fa-pages.ts'; +import { NeosLoginPage } from "../helpers/general-pages.ts"; +import { generateOtp } from '../helpers/totp.ts'; +import { createUser, logout } from '../helpers/system.ts'; +import { state } from "../helpers/state.ts"; + +const { Given, When, Then } = createBdd(); + +// ── Background / Given ──────────────────────────────────────────────────────── + +Given('A user with username {string}, password {string} and role {string} with enrolled 2FA device with name {string} exists', + async ({ page }, username: string, password: string, role: string, deviceName: string) => { + createUser(username, password, [role]); + + const loginPage = new NeosLoginPage(page); + await loginPage.goto(); + await loginPage.login(username, password); + await page.waitForLoadState('networkidle'); + + let secret: string; + + if (page.url().includes('second-factor-setup')) { + const setupPage = new SecondFactorSetupPage(page); + secret = await setupPage.getSecret(); + await setupPage.submitOtp(secret, deviceName); + await page.waitForLoadState('networkidle'); + } else { + const modulePage = new BackendModulePage(page); + await modulePage.goto(); + await modulePage.waitForPage(); + secret = await modulePage.addDevice(deviceName); + } + + state.deviceNameSecretMap.set(deviceName, secret); + + await logout(page); + }, +); + +// ── When ────────────────────────────────────────────────────────────────────── + +When('I set up a 2FA device with name {string}', async ({ page }, deviceName: string) => { + const setupPage = new SecondFactorSetupPage(page); + await setupPage.waitForPage(); + const secret = await setupPage.getSecret(); + await setupPage.submitOtp(secret, deviceName); + + state.deviceNameSecretMap.set(deviceName, secret); + + await page.waitForLoadState('networkidle'); +}); + +When('I enter a valid TOTP for device {string}', async ({ page }, deviceName: string) => { + const secret = state.deviceNameSecretMap.get(deviceName); + if (!secret) throw new Error(`No enrolled TOTP secret found for device "${deviceName}"`); + + const otpPage = new SecondFactorLoginPage(page); + await otpPage.waitForPage(); + await otpPage.enterOtp(generateOtp(secret)); + + await page.waitForLoadState('networkidle'); +}); + +// ── Then ────────────────────────────────────────────────────────────────────── + +Then('I should see the 2FA verification page', async ({ page }) => { + await expect(page).toHaveURL(/second-factor-login/); +}); + +Then('I should see the 2FA setup page', async ({ page }) => { + await expect(page).toHaveURL(/second-factor-setup/); +}); diff --git a/Tests/E2E/steps/backend-module.steps.ts b/Tests/E2E/steps/backend-module.steps.ts new file mode 100644 index 0000000..ce85224 --- /dev/null +++ b/Tests/E2E/steps/backend-module.steps.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; +import { BackendModulePage } from '../helpers/2fa-pages.ts'; +import { state } from '../helpers/state.js'; + +const { When, Then } = createBdd(); + +When('I navigate to the 2FA management page', async ({ page }) => { + const modulePage = new BackendModulePage(page); + await modulePage.goto(); + await modulePage.waitForPage(); +}); + +When('I add a new TOTP 2FA device with name {string}', + async ({ page }, deviceName: string) => { + const modulePage = new BackendModulePage(page); + const secret = await modulePage.addDevice(deviceName); + + state.deviceNameSecretMap.set(deviceName, secret); + }, +); + +When('I try to remove the 2FA device with the name {string}', + async ({ page }, name: string) => { + const modulePage = new BackendModulePage(page); + await page.pause(); + await modulePage.tryDeleteDeviceByName(name); + }, +); + +// "with name" and "with the name" are both used in feature files +When('I remove the 2FA device with the name {string}', + async ({ page }, name: string) => { + const modulePage = new BackendModulePage(page); + await modulePage.deleteDeviceByName(name); + }, +); + +Then('There should be {int} enrolled 2FA device(s)', + async ({ page }, countStr: string) => { + await expect(page.locator('.neos-table tbody tr')).toHaveCount(parseInt(countStr, 10)); + }, +); + +Then('There should be a 2FA device with the name {string}', + async ({ page }, name: string) => { + const modulePage = new BackendModulePage(page); + await expect(modulePage.locatorForDeviceRow(name)).toBeVisible(); + }, +); + +Then('There should be no 2FA device with the name {string}', + async ({ page }, name: string) => { + const modulePage = new BackendModulePage(page); + await expect(modulePage.locatorForDeviceRow(name)).toHaveCount(0); + }, +); diff --git a/Tests/E2E/steps/general-login.steps.ts b/Tests/E2E/steps/general-login.steps.ts new file mode 100644 index 0000000..ccde9b5 --- /dev/null +++ b/Tests/E2E/steps/general-login.steps.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import { createBdd } from 'playwright-bdd'; +import { NeosLoginPage, NeosContentPage } from '../helpers/general-pages.ts'; +import { createUser, logout } from '../helpers/system.ts'; + +const { Given, When, Then } = createBdd(); + +// ── Background / Given ──────────────────────────────────────────────────────── + +Given('A user with username {string}, password {string} and role {string} exists', + async ({}, username: string, password: string, role: string) => { + createUser(username, password, [role]); + }, +); + +// ── When ────────────────────────────────────────────────────────────────────── + +When('I log in with username {string} and password {string}', + async ({ page }, username: string, password: string) => { + const loginPage = new NeosLoginPage(page); + await loginPage.goto(); + await loginPage.login(username, password); + await page.waitForLoadState('networkidle'); + }, +); + +When('I log out', async ({ page }) => { + await logout(page); +}); + + +// ── Then ────────────────────────────────────────────────────────────────────── + +Then('I should see the Neos content page', async ({ page }) => { + const neosContentPage = new NeosContentPage(page); + await expect(page).toHaveURL(neosContentPage.URL_REGEX); +}); + +Then('I cannot access the Neos content page', async ({ page }) => { + const neosContentPage = new NeosContentPage(page); + await neosContentPage.goto(); + + // expecting to be redirected (e.g. to /neos/login) + await expect(page).not.toHaveURL(neosContentPage.URL_REGEX) +}); diff --git a/Tests/E2E/steps/hooks.ts b/Tests/E2E/steps/hooks.ts new file mode 100644 index 0000000..2761ee0 --- /dev/null +++ b/Tests/E2E/steps/hooks.ts @@ -0,0 +1,14 @@ +import { createBdd } from 'playwright-bdd'; +import { state } from "../helpers/state.ts"; +import { logout, removeAllUsers } from "../helpers/system.ts"; + +const { AfterScenario } = createBdd(); + +// reset db after each scenario +AfterScenario(async ({ page }) => { + await logout(page) + removeAllUsers(); + + // clear state + state.deviceNameSecretMap.clear(); +}); diff --git a/Tests/E2E/tsconfig.json b/Tests/E2E/tsconfig.json new file mode 100644 index 0000000..2393546 --- /dev/null +++ b/Tests/E2E/tsconfig.json @@ -0,0 +1,23 @@ +{ + // 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/system_under_test/Dockerfile b/Tests/system_under_test/Dockerfile new file mode 100644 index 0000000..4c79f1d --- /dev/null +++ b/Tests/system_under_test/Dockerfile @@ -0,0 +1,57 @@ +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 + +# TODO: still needed? +# HOTFIX for ARM64 Architectures and VIPS (see https://github.com/opencv/opencv/issues/14884#issuecomment-706725583 for details) +# only needed for development on Apple Silicon Macs +RUN echo '. /etc/bash.vips-arm64-hotfix.sh' >> /etc/bash.bashrc + +# Install Neos base distribution +ARG NEOS_VERSION +RUN rm -rf /app \ + && composer create-project neos/neos-base-distribution:^${NEOS_VERSION} /app + +RUN composer require rokka/imagine-vips:0.* + +# Add config files +COPY Tests/system_under_test/sut_file_system_overrides/ / + +ARG ENTRY_POINT_FILE +COPY ${ENTRY_POINT_FILE} /entrypoint.sh + +# chown for neos data folder and Resources ONLY +RUN mkdir -p /app/Data /app/Web/_Resources \ + && chown -R ${USER} /app \ + && chmod +x /entrypoint.sh + +WORKDIR /app +USER ${USER} + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Tests/system_under_test/neos8/docker-compose.yaml b/Tests/system_under_test/neos8/docker-compose.yaml new file mode 100644 index 0000000..254b5d7 --- /dev/null +++ b/Tests/system_under_test/neos8/docker-compose.yaml @@ -0,0 +1,19 @@ +include: + - path: + - ../sut-base-docker-compose.yaml + +services: + neos: + build: + args: + PHP_VERSION: '8.2' + NEOS_VERSION: '8' + ENTRY_POINT_FILE: 'Tests/system_under_test/neos8/entrypoint.sh' + +volumes: + db_neos_data: + name: db_neos8_data + +networks: + neos_SUT: + name: neos8_SUT diff --git a/Tests/system_under_test/neos8/entrypoint.sh b/Tests/system_under_test/neos8/entrypoint.sh new file mode 100755 index 0000000..26e3e7d --- /dev/null +++ b/Tests/system_under_test/neos8/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -eou pipefail + +# Register local path repository and require local package +# The code will be mounted into the container by docker-compose, so we can use it as a path repository +composer config repositories.sandstorm-2fa \ + '{"type":"path","url":"/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication","options":{"symlink":true}}' \ + && composer require sandstorm/neostwofactorauthentication:@dev + +echo "Waiting for database..." +until mariadb -h"${DB_NEOS_HOST}" -P"${DB_NEOS_PORT}" -u"${DB_NEOS_USER}" -p"${DB_NEOS_PASSWORD}" -D"${DB_NEOS_DATABASE}" --disable-ssl --silent -e "SELECT 1;" 1>/dev/null 2>/dev/null; do + sleep 2 +done +echo "Database is ready." + +./flow flow:cache:flush + +./flow doctrine:migrate + +yes y | ./flow resource:clean || true + +./flow site:import --package-key Neos.Demo + +./flow resource:publish --collection static + +frankenphp run --config /etc/frankenphp/Caddyfile diff --git a/Tests/system_under_test/neos9/docker-compose.yaml b/Tests/system_under_test/neos9/docker-compose.yaml new file mode 100644 index 0000000..1a521b8 --- /dev/null +++ b/Tests/system_under_test/neos9/docker-compose.yaml @@ -0,0 +1,22 @@ +include: + - path: + - ../sut-base-docker-compose.yaml + +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/entrypoint.sh b/Tests/system_under_test/neos9/entrypoint.sh new file mode 100755 index 0000000..093df44 --- /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.NeosTwoFactorAuthentication","options":{"symlink":true}}' \ + && composer require sandstorm/neostwofactorauthentication:@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..704af70 --- /dev/null +++ b/Tests/system_under_test/sut-base-docker-compose.yaml @@ -0,0 +1,72 @@ +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.NeosTwoFactorAuthentication/Classes:cached + - ../../Configuration:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/Configuration:cached + - ../../Migrations:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/Migrations:cached + - ../../Resources:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/Resources:cached + - ../../composer.json:/app/DistributionPackages/Sandstorm.NeosTwoFactorAuthentication/composer.json:cached + networks: + - neos_SUT + ports: + - 8081:8081 + depends_on: + - db + - redis + + 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 + +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/EnforceForAll/Settings.2FA.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForAll/Settings.2FA.yaml new file mode 100644 index 0000000..fcc6568 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForAll/Settings.2FA.yaml @@ -0,0 +1,4 @@ +Sandstorm: + NeosTwoFactorAuthentication: + # enforce 2FA for all users + enforceTwoFactorAuthentication: true diff --git a/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForProvider/Settings.2FA.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForProvider/Settings.2FA.yaml new file mode 100644 index 0000000..1921041 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForProvider/Settings.2FA.yaml @@ -0,0 +1,4 @@ +Sandstorm: + NeosTwoFactorAuthentication: + # enforce 2FA for specific authentication providers (e.g. Neos.Neos:Backend) + enforce2FAForAuthenticationProviders : ['Neos.Neos:Backend'] diff --git a/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForRole/Policy.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForRole/Policy.yaml new file mode 100644 index 0000000..0eba65e --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForRole/Policy.yaml @@ -0,0 +1,5 @@ +roles: + 'Neos.Neos:SecondFactorUser': + label: 2FA Required User + description: Just for testing role based 2FA enforcement + parentRoles: ['Neos.Neos:Editor'] diff --git a/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForRole/Settings.2FA.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForRole/Settings.2FA.yaml new file mode 100644 index 0000000..e4a4aca --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/EnforceForRole/Settings.2FA.yaml @@ -0,0 +1,4 @@ +Sandstorm: + NeosTwoFactorAuthentication: + # enforce 2FA for specific roles (e.g. Neos.Neos:Administrator) + enforce2FAForRoles: ['Neos.Neos:Administrator', 'Neos.Neos:SecondFactorUser'] diff --git a/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/IssuerNameChange/Settings.2FA.yaml b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/IssuerNameChange/Settings.2FA.yaml new file mode 100644 index 0000000..44336f1 --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/IssuerNameChange/Settings.2FA.yaml @@ -0,0 +1,4 @@ +Sandstorm: + NeosTwoFactorAuthentication: + # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used + issuerName: 'Test Issuer' 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..27101ea --- /dev/null +++ b/Tests/system_under_test/sut_file_system_overrides/app/Configuration/Production/E2E-SUT/Settings.yaml @@ -0,0 +1,26 @@ +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' + + 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..c757047 --- /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 6ee86e9..ed9b83f 100644 --- a/composer.json +++ b/composer.json @@ -42,5 +42,10 @@ "Neos.Flow-20220318174300", "Neos.Fusion-20220326120900" ] + }, + "config": { + "allow-plugins": { + "neos/composer-plugin": true + } } }