From 5bc695588ad4c29aa9482a151d277793aded71f8 Mon Sep 17 00:00:00 2001 From: Edimar Cardoso Date: Thu, 26 Mar 2026 23:14:09 -0400 Subject: [PATCH 1/6] Add E2E test suite, helpers, and documentation for OAuth realms and admin actions - Introduced E2E test framework using Playwright with Docker integration. - Added `e2e/tests/helpers.js` with utilities for authentication, navigation, and reporting. - Created the `e2e/tests/oauth-realms.spec` file to test OAuth realms functionality including creation, deletion, and default existence. - Updated admin UI components with `data-e2e` attributes for robust element selection. - Expanded `.dockerignore` files and added E2E results/report directories. - Included a comprehensive `E2E-TESTS.md` guide for setting up and running tests. - Updated npm scripts for filtered and full test runs (`test`, `test:auth`, etc.). --- .dockerignore | 9 + .gitignore | 1 + e2e/.dockerignore | 2 + e2e/Dockerfile.meteor | 39 ++++ e2e/E2E-TESTS.md | 243 +++++++++++++++++++++++ e2e/docker-compose.yml | 87 ++++++++ e2e/package.json | 7 +- e2e/run-oauth2-tests.sh | 2 +- e2e/run-tests.sh | 123 ++++++++++++ e2e/tests/auth.spec.js | 40 ++++ e2e/tests/computers.spec.js | 71 +++++++ e2e/tests/dashboard.spec.js | 26 +++ e2e/tests/dns.spec.js | 57 ++++++ e2e/tests/domain.spec.js | 21 ++ e2e/tests/dr.spec.js | 38 ++++ e2e/tests/gpos.spec.js | 60 ++++++ e2e/tests/groups.spec.js | 59 ++++++ e2e/tests/helpers.js | 306 +++++++++++++++++++++++++++++ e2e/tests/oauth-clients.spec.js | 56 ++++++ e2e/tests/oauth-realms.spec.js | 51 +++++ e2e/tests/ous.spec.js | 61 ++++++ e2e/tests/run-all.js | 59 ++++++ e2e/tests/selfservice.spec.js | 55 ++++++ e2e/tests/service-accounts.spec.js | 70 +++++++ e2e/tests/settings.spec.js | 40 ++++ e2e/tests/users.spec.js | 113 +++++++++++ web/app/oauth/OAuthClients.js | 23 +-- web/app/oauth/OAuthRealms.js | 14 +- 28 files changed, 1713 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 e2e/.dockerignore create mode 100644 e2e/Dockerfile.meteor create mode 100644 e2e/E2E-TESTS.md create mode 100644 e2e/docker-compose.yml create mode 100755 e2e/run-tests.sh create mode 100644 e2e/tests/auth.spec.js create mode 100644 e2e/tests/computers.spec.js create mode 100644 e2e/tests/dashboard.spec.js create mode 100644 e2e/tests/dns.spec.js create mode 100644 e2e/tests/domain.spec.js create mode 100644 e2e/tests/dr.spec.js create mode 100644 e2e/tests/gpos.spec.js create mode 100644 e2e/tests/groups.spec.js create mode 100644 e2e/tests/helpers.js create mode 100644 e2e/tests/oauth-clients.spec.js create mode 100644 e2e/tests/oauth-realms.spec.js create mode 100644 e2e/tests/ous.spec.js create mode 100644 e2e/tests/run-all.js create mode 100644 e2e/tests/selfservice.spec.js create mode 100644 e2e/tests/service-accounts.spec.js create mode 100644 e2e/tests/settings.spec.js create mode 100644 e2e/tests/users.spec.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3fd3ebd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.claude +docs +media +e2e/results +e2e/node_modules +web/.meteor/local +workspace diff --git a/.gitignore b/.gitignore index 72b8a56..715c60a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,6 @@ Thumbs.db # Playwright e2e/test-results/ e2e/playwright-report/ +e2e/results/ workspace/ diff --git a/e2e/.dockerignore b/e2e/.dockerignore new file mode 100644 index 0000000..ea55644 --- /dev/null +++ b/e2e/.dockerignore @@ -0,0 +1,2 @@ +results/ +node_modules/ diff --git a/e2e/Dockerfile.meteor b/e2e/Dockerfile.meteor new file mode 100644 index 0000000..817d25c --- /dev/null +++ b/e2e/Dockerfile.meteor @@ -0,0 +1,39 @@ +# Meteor production build for E2E tests +# Uses the same build process as web/Dockerfile +FROM zcloudws/meteor-build:3.4 AS builder + +WORKDIR /app-source/source +USER root +RUN chown zcloud:zcloud -R /app-source + +USER zcloud +COPY --chown=zcloud:zcloud web /app-source/source + +ENV METEOR_DISABLE_OPTIMISTIC_CACHING=1 +RUN meteor npm i --no-audit --legacy-peer-deps \ + && meteor build --platforms web.browser --directory ../app-build + +# Runtime stage with MongoDB + samba-tool +FROM zcloudws/meteor-node-mongodb-runtime:3.4-with-tools + +USER root + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + samba-common-bin \ + samba-dsdb-modules \ + samba-vfs-modules \ + ldap-utils \ + krb5-user \ + curl \ + && rm -rf /var/lib/apt/lists/* + +USER zcloud + +COPY --from=builder /app-source/app-build/bundle /home/zcloud/app +RUN cd /home/zcloud/app/programs/server && npm i --no-audit --legacy-peer-deps + +WORKDIR /home/zcloud/app +EXPOSE 4080 + +ENTRYPOINT ["/scripts/startup.sh"] diff --git a/e2e/E2E-TESTS.md b/e2e/E2E-TESTS.md new file mode 100644 index 0000000..f742a18 --- /dev/null +++ b/e2e/E2E-TESTS.md @@ -0,0 +1,243 @@ +# E2E Tests — Samba Conductor + +Comprehensive end-to-end test suite covering all features of the Samba Conductor web application. + +## Overview + +| Suites | Tests | Runner | Browser | +|--------|-------|----------------------|-------------------| +| 15 | 65 | Node.js + Playwright | Chromium (Docker) | + +## Prerequisites + +- **Docker** and **Docker Compose** installed + +## Running Tests + +### Docker Compose (recommended for CI) + +Zero host dependencies — builds and runs Samba DC, Meteor (production build), and Playwright. +Requires ~15GB free disk space for Docker images. + +> **Note:** There is a known SyncedCron/ldapjs bug that causes an `uncaughtException` on first cron +> tick when the sync account is not configured. This crashes the Meteor process in Docker. A fix for +> this is tracked separately. Until resolved, use **host mode** for reliable E2E runs. + +```bash +cd e2e +./run-tests.sh --compose # Run all tests +./run-tests.sh --compose users # Filter by suite +``` + +This builds three containers: +- **samba** — Samba 4 AD DC (`docker/samba-ad-dc`) +- **meteor** — Meteor dev server with samba-tool (`e2e/Dockerfile.meteor`), source mounted as volume +- **tests** — Playwright runner (`mcr.microsoft.com/playwright:v1.58.2-noble`) + +### Host mode (for development) + +Requires Meteor and Samba running on the host: + +```bash +# Start Samba DC +cd docker && docker compose up -d + +# Start Meteor (ensure docker group is effective for samba-tool access) +sg docker -c "cd web && meteor npm start" + +# Run tests +cd e2e +./run-tests.sh # Run all tests +./run-tests.sh auth # Filter by suite +./run-tests.sh users +``` + +## Results + +After execution, results are saved to `e2e/results/`: + +``` +results/ +├── screenshots/ # PNG screenshot per test (success and failure) +│ ├── auth-login-valid-success.png +│ ├── users-create-user-success.png +│ └── ... +└── report.html # HTML report with summary, status, and embedded screenshots +``` + +### CI Integration (GitHub Actions) + +```yaml +- name: Run E2E Tests + run: cd e2e && ./run-tests.sh --compose + +- uses: actions/upload-artifact@v4 + if: always() + with: + name: e2e-results + path: e2e/results/ +``` + +## Test Suites + +### auth (5 tests) + +| Test | Description | +|--------------------------|-------------------------------------------| +| login-valid | Login with admin credentials | +| dashboard-loaded | Dashboard renders after login | +| logout | Logout navigates to login page | +| login-invalid | Error shown for bad credentials | +| redirect-unauthenticated | Unauthenticated access redirects to login | + +### dashboard (2 tests) + +| Test | Description | +|--------------|----------------------------------------------| +| stats-cards | Stat cards (users, groups) render | +| status-links | DR key and sync account status links visible | + +### users (7 tests) + +| Test | Description | +|----------------|--------------------------------------------| +| list-users | Navigate to users page, table renders | +| create-user | Fill form, submit, user created in AD | +| verify-created | Created user appears in list | +| edit-user | Navigate to edit form, change fields, save | +| toggle-disable | Disable user via confirm modal | +| toggle-enable | Re-enable user via confirm modal | +| delete-user | Delete user via confirm modal | + +### groups (5 tests) + +| Test | Description | +|----------------|------------------------------------------| +| list-groups | Navigate to groups page, table renders | +| create-group | Fill form, submit, group created in AD | +| verify-created | Created group appears in list | +| view-edit-page | Navigate to edit page, verify group info | +| delete-group | Delete group via confirm modal | + +### ous (4 tests) + +| Test | Description | +|-----------|------------------------------------| +| view-tree | Navigate to OUs page, tree renders | +| create-ou | Create new OU via modal | +| select-ou | Find and select created OU in tree | +| delete-ou | Delete OU via confirm modal | + +### computers (5 tests) + +| Test | Description | +|-----------------|-------------------------------------------| +| list-computers | Navigate to computers page, table renders | +| create-computer | Create computer via modal | +| verify-created | Created computer appears in list | +| view-details | Open detail panel, verify close button | +| delete-computer | Delete computer via confirm modal | + +### service-accounts (5 tests) + +| Test | Description | +|----------------|--------------------------------------------------| +| list-accounts | Navigate to service accounts page, table renders | +| create-account | Create gMSA via modal | +| verify-created | Created account appears in list | +| view-details | Open detail panel, verify close button | +| delete-account | Delete account via confirm modal | + +### dns (5 tests) + +| Test | Description | +|---------------|-----------------------------------| +| page-loads | Navigate to DNS page | +| view-zones | DNS zones load from AD | +| expand-zone | Expand first zone to show records | +| add-record | Add A record via modal | +| delete-record | Delete record via confirm modal | + +### gpos (4 tests) + +| Test | Description | +|----------------|------------------------------| +| list-gpos | Navigate to GPOs page | +| create-gpo | Create GPO via modal | +| verify-created | Created GPO appears in list | +| delete-gpo | Delete GPO via confirm modal | + +### domain (2 tests) + +| Test | Description | +|-------------------|-----------------------------------| +| info-section | Domain info section renders | +| functional-levels | Functional levels section renders | + +### oauth-clients (4 tests) + +| Test | Description | +|----------------|-----------------------------------------| +| list-clients | Navigate to OAuth clients page | +| create-client | Create client, verify credentials modal | +| verify-in-list | Created client appears in list | +| delete-client | Delete client via confirm modal | + +### oauth-realms (4 tests) + +| Test | Description | +|----------------|--------------------------------| +| list-realms | Navigate to OAuth realms page | +| default-exists | Default realm is present | +| create-realm | Create new realm | +| delete-realm | Delete realm via confirm modal | + +### settings (4 tests) + +| Test | Description | +|----------------------|-------------------------------------| +| page-loads | Navigate to settings page | +| toggle-feature | Toggle a feature on/off | +| save-fields | Save field configuration | +| sync-account-section | Sync account config section visible | + +### selfservice (6 tests) + +| Test | Description | +|------------------------|------------------------------------------------| +| home-page | Self-service home renders | +| home-links | Edit profile and change password links present | +| profile-page | Navigate to profile page | +| profile-cancel | Cancel returns to home | +| change-password-page | Navigate to change password page | +| change-password-cancel | Cancel returns to home | + +### dr (3 tests) + +| Test | Description | +|-------------------|------------------------------------| +| page-loads | Navigate to disaster recovery page | +| key-section | DR key management section visible | +| s3-config-visible | S3 configuration section check | + +## Architecture + +- **Runner:** `tests/run-all.js` — orchestrates all suites, collects results, generates report +- **Helpers:** `tests/helpers.js` — login, navigation, screenshot capture, TestReporter class +- **Docker:** Tests run inside `mcr.microsoft.com/playwright:v1.58.2-noble` with `--network host` +- **Timeouts:** LDAP operations use 60s timeout, LDAP writes use 45s, UI interactions use 10s +- **Screenshots:** Captured after each test (success or failure) in `results/screenshots/` + +## Selectors + +All tests use `data-e2e` attributes for element selection, following the convention: + +``` +data-e2e="--" +``` + +Examples: + +- `data-e2e="login-input-username"` — login form username input +- `data-e2e="users-btn-new"` — new user button +- `data-e2e="confirm-modal-btn-confirm"` — confirm modal button diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 0000000..a3c7677 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,87 @@ +# E2E Tests: Full stack in Docker +# +# Usage: +# cd e2e +# docker compose up --build --abort-on-container-exit --exit-code-from tests +# +# Results (HTML report + screenshots) are saved to ./results/ + +services: + # Samba AD DC + samba: + build: + context: ../docker + dockerfile: samba-ad-dc/Dockerfile + container_name: e2e-samba + hostname: dc1 + domainname: samdom.example.com + privileged: true + environment: + - SAMBA_REALM=SAMDOM.EXAMPLE.COM + - SAMBA_DOMAIN=SAMDOM + - SAMBA_ADMIN_PASSWORD=P@ssw0rd123! + - SAMBA_DNS_FORWARDER=8.8.8.8 + dns: + - 127.0.0.1 + networks: + e2e-net: + ipv4_address: 172.21.0.10 + healthcheck: + test: ["CMD", "samba-tool", "domain", "info", "127.0.0.1", "-U", "Administrator%P@ssw0rd123!"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + # Meteor app (production build + internal MongoDB) + meteor: + build: + context: .. + dockerfile: e2e/Dockerfile.meteor + container_name: e2e-meteor + depends_on: + samba: + condition: service_healthy + environment: + - ROOT_URL=http://localhost:4080 + - PORT=4080 + - USE_INTERNAL_MONGODB=1 + - METEOR_SETTINGS={"public":{"appInfo":{"name":"Samba Conductor"}},"samba":{"ldapUrl":"ldaps://samba:636","baseDn":"DC=samdom,DC=example,DC=com","realm":"SAMDOM.EXAMPLE.COM","tlsRejectUnauthorized":false,"sessionTtlMinutes":30}} + # Restart on crash (SyncedCron ldapjs bug causes uncaughtException) + restart: on-failure:5 + networks: + e2e-net: + ipv4_address: 172.21.0.20 + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:4080"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + + # Playwright test runner + tests: + image: mcr.microsoft.com/playwright:v1.58.2-noble + container_name: e2e-tests + depends_on: + meteor: + condition: service_healthy + environment: + - BASE_URL=http://meteor:4080 + - FILTER=${FILTER:---all} + volumes: + - ./tests:/work/e2e/tests:ro + - ./results:/work/e2e/results + - ./package.json:/work/e2e/package.json:ro + working_dir: /work/e2e + entrypoint: ["sh", "-c", "npm install --silent 2>/dev/null && node tests/run-all.js"] + networks: + e2e-net: + ipv4_address: 172.21.0.30 + +networks: + e2e-net: + driver: bridge + ipam: + config: + - subnet: 172.21.0.0/24 diff --git a/e2e/package.json b/e2e/package.json index 9d3d788..b9d9041 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,7 +7,12 @@ "screenshots:force": "node screenshots/capture-all.js --force", "screenshots:admin": "node screenshots/capture-all.js admin", "screenshots:selfservice": "node screenshots/capture-all.js selfservice", - "screenshots:auth": "node screenshots/capture-all.js auth" + "screenshots:auth": "node screenshots/capture-all.js auth", + "test": "node tests/run-all.js", + "test:auth": "FILTER=auth node tests/run-all.js", + "test:users": "FILTER=users node tests/run-all.js", + "test:groups": "FILTER=groups node tests/run-all.js", + "test:oauth": "FILTER=oauth node tests/run-all.js" }, "dependencies": { "playwright": "^1.52.0" diff --git a/e2e/run-oauth2-tests.sh b/e2e/run-oauth2-tests.sh index 4645fec..904dcd3 100755 --- a/e2e/run-oauth2-tests.sh +++ b/e2e/run-oauth2-tests.sh @@ -39,5 +39,5 @@ docker run --rm \ -v "$SCRIPT_DIR:/work/e2e" \ -w /work/e2e \ -e "BASE_URL=${BASE_URL}" \ - mcr.microsoft.com/playwright:v1.52.0-noble \ + mcr.microsoft.com/playwright:v1.58.2-noble \ sh -c "npm install --silent 2>/dev/null; node tests/oauth2.spec.js" diff --git a/e2e/run-tests.sh b/e2e/run-tests.sh new file mode 100755 index 0000000..a624763 --- /dev/null +++ b/e2e/run-tests.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Run E2E tests using Playwright Docker container +# +# Usage: +# ./run-tests.sh # Run all tests (requires Meteor + Samba running on host) +# ./run-tests.sh --all # Same as above +# ./run-tests.sh auth # Run only auth suite +# ./run-tests.sh users # Run only users suite +# ./run-tests.sh --compose # Full stack via Docker Compose (no host dependencies) +# ./run-tests.sh --compose users # Docker Compose + filter by suite + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FILTER="" +USE_COMPOSE=false + +# Parse arguments +for arg in "$@"; do + case "$arg" in + --compose) USE_COMPOSE=true ;; + *) FILTER="$arg" ;; + esac +done +FILTER="${FILTER:---all}" + +echo "==> Samba Conductor E2E Tests" +echo " Filter: ${FILTER}" + +# ─── Docker Compose mode ─────────────────────────────────────────── +if [ "$USE_COMPOSE" = true ]; then + echo " Mode: Docker Compose (full stack)" + echo "" + + # Clean previous results + rm -rf "$SCRIPT_DIR/results" + mkdir -p "$SCRIPT_DIR/results/screenshots" + + cd "$SCRIPT_DIR" + + # Export filter for the tests container + export FILTER + + echo " Starting Samba DC + Meteor + Playwright..." + echo " (this may take a few minutes on first run)" + echo " =========================================" + echo "" + + docker compose up --build --exit-code-from tests 2>&1 + EXIT_CODE=$? + + # Cleanup containers + docker compose down 2>/dev/null + + echo "" + echo " =========================================" + if [ -f "$SCRIPT_DIR/results/report.html" ]; then + echo " Report: e2e/results/report.html" + echo " Screenshots: e2e/results/screenshots/" + fi + + exit $EXIT_CODE +fi + +# ─── Host mode (Meteor + Samba running on host) ─────────────────── +BASE_URL="${BASE_URL:-http://localhost:4080}" +echo " Mode: Host (Playwright Docker only)" +echo " Base URL: ${BASE_URL}" +echo "" + +# Clean previous results +rm -rf "$SCRIPT_DIR/results" +mkdir -p "$SCRIPT_DIR/results/screenshots" + +# Check if Meteor is running +echo " Checking Meteor app..." +MAX_RETRIES=3 +RETRY=0 +while ! curl -s --max-time 5 "${BASE_URL}" >/dev/null 2>&1; do + RETRY=$((RETRY + 1)) + if [ "$RETRY" -ge "$MAX_RETRIES" ]; then + echo " ERROR: Meteor app not running at ${BASE_URL}" + echo " Start it with: cd web && meteor npm start" + echo " Or use: ./run-tests.sh --compose" + exit 1 + fi + echo " Waiting for Meteor app... (attempt $RETRY/$MAX_RETRIES)" + sleep 5 +done +echo " Meteor app is running." + +# Install deps if needed +cd "$SCRIPT_DIR" +if [ ! -d "node_modules/playwright" ]; then + echo " Installing dependencies..." + npm install --silent 2>&1 | tail -1 +fi + +# Run tests in Playwright Docker container +echo "" +echo " Running tests in Docker container..." +echo " =========================================" +echo "" + +docker run --rm \ + --network host \ + -v "$SCRIPT_DIR:/work/e2e" \ + -w /work/e2e \ + -e "BASE_URL=${BASE_URL}" \ + -e "FILTER=${FILTER}" \ + mcr.microsoft.com/playwright:v1.58.2-noble \ + sh -c "npm install --silent 2>/dev/null; node tests/run-all.js" + +EXIT_CODE=$? + +echo "" +echo " =========================================" +if [ -f "$SCRIPT_DIR/results/report.html" ]; then + echo " Report: e2e/results/report.html" + echo " Screenshots: e2e/results/screenshots/" +fi + +exit $EXIT_CODE diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js new file mode 100644 index 0000000..0cedf2a --- /dev/null +++ b/e2e/tests/auth.spec.js @@ -0,0 +1,40 @@ +const {runSuite, runTest, loginAsAdmin, loginAsUser, BASE_URL, ADMIN_USER, ADMIN_PASS, launchBrowser} = require('./helpers'); + +async function run(reporter) { + await runSuite('auth', async (browser, reporter) => { + const page = await browser.newPage(); + + await runTest(page, reporter, 'auth', 'login-valid', async () => { + await loginAsAdmin(page); + const url = page.url(); + if (!url.includes('/admin')) throw new Error(`Expected /admin, got ${url}`); + }); + + await runTest(page, reporter, 'auth', 'dashboard-loaded', async () => { + await page.waitForSelector('[data-e2e="dashboard-card-total-users"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'auth', 'logout', async () => { + await page.click('[data-e2e="admin-btn-logout"]'); + await page.waitForURL('**/login**', {timeout: 10000}); + }); + + await runTest(page, reporter, 'auth', 'login-invalid', async () => { + await page.goto(`${BASE_URL}/login`); + await page.waitForSelector('[data-e2e="login-input-username"]', {timeout: 10000}); + await page.fill('[data-e2e="login-input-username"]', 'baduser'); + await page.fill('[data-e2e="login-input-password"]', 'badpass'); + await page.click('[data-e2e="login-btn-submit"]'); + await page.waitForSelector('[data-e2e="login-error"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'auth', 'redirect-unauthenticated', async () => { + await page.goto(`${BASE_URL}/admin`); + await page.waitForURL('**/login**', {timeout: 10000}); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/computers.spec.js b/e2e/tests/computers.spec.js new file mode 100644 index 0000000..d3c29a1 --- /dev/null +++ b/e2e/tests/computers.spec.js @@ -0,0 +1,71 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, BASE_URL, LDAP_TIMEOUT, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_COMPUTER = 'E2ETESTPC'; +const TABLE_SELECTOR = '[data-e2e="computers-table-search"]'; + +async function run(reporter) { + await runSuite('computers', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'computers', 'list-computers', async () => { + await navigateToAdmin(page, 'computers'); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'computers', 'create-computer', async () => { + await page.click('[data-e2e="computers-btn-new"]'); + await page.waitForSelector('[data-e2e="computers-create-input-name"]', {timeout: 5000}); + await page.fill('[data-e2e="computers-create-input-name"]', TEST_COMPUTER); + await page.fill('[data-e2e="computers-create-input-description"]', 'E2E test computer'); + await page.click('[data-e2e="computers-create-btn-submit"]'); + // Wait for create to complete and modal to close, or reload + await page.waitForTimeout(5000); + // If modal still open, press Escape to close and reload + const inputStillVisible = await page.$('[data-e2e="computers-create-input-name"]'); + if (inputStillVisible) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await page.goto(`${BASE_URL}/admin/computers`); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'computers', 'verify-created', async () => { + await page.waitForSelector(`text=${TEST_COMPUTER}`, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'computers', 'view-details', async () => { + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.includes(TEST_COMPUTER)) { + const btn = await row.$('[data-e2e="computers-btn-details"]'); + if (btn) await btn.click(); + break; + } + } + await page.waitForSelector('[data-e2e="computers-detail-btn-close"]', {timeout: 10000}); + await page.click('[data-e2e="computers-detail-btn-close"]'); + await page.waitForTimeout(500); + }); + + await runTest(page, reporter, 'computers', 'delete-computer', async () => { + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.includes(TEST_COMPUTER)) { + const btn = await row.$('[data-e2e="computers-btn-delete"]'); + if (btn) await btn.click(); + break; + } + } + await confirmModal(page, 'computers-delete'); + await page.waitForTimeout(5000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/dashboard.spec.js b/e2e/tests/dashboard.spec.js new file mode 100644 index 0000000..2bc62fa --- /dev/null +++ b/e2e/tests/dashboard.spec.js @@ -0,0 +1,26 @@ +const {runSuite, runTest, loginAsAdmin, waitForPageReady} = require('./helpers'); + +async function run(reporter) { + await runSuite('dashboard', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'dashboard', 'stats-cards', async () => { + await page.waitForSelector('[data-e2e="dashboard-card-total-users"]', {timeout: 10000}); + await page.waitForSelector('[data-e2e="dashboard-card-active-users"]', {timeout: 5000}); + await page.waitForSelector('[data-e2e="dashboard-card-disabled-users"]', {timeout: 5000}); + await page.waitForSelector('[data-e2e="dashboard-card-groups"]', {timeout: 5000}); + }); + + await runTest(page, reporter, 'dashboard', 'status-links', async () => { + const drLink = await page.$('[data-e2e="dashboard-link-dr-key"]'); + const syncLink = await page.$('[data-e2e="dashboard-link-sync-account"]'); + if (!drLink) throw new Error('DR key status link not found'); + if (!syncLink) throw new Error('Sync account status link not found'); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/dns.spec.js b/e2e/tests/dns.spec.js new file mode 100644 index 0000000..c3a0edf --- /dev/null +++ b/e2e/tests/dns.spec.js @@ -0,0 +1,57 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, LDAP_TIMEOUT, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_RECORD_NAME = 'e2etest'; + +async function run(reporter) { + await runSuite('dns', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'dns', 'page-loads', async () => { + await navigateToAdmin(page, 'dns'); + // DNS zones load asynchronously from LDAP — wait for heading + await page.waitForSelector('h1:has-text("DNS")', {timeout: 15000}); + // Wait extra time for zone data to load + await page.waitForTimeout(10000); + }); + + await runTest(page, reporter, 'dns', 'view-zones', async () => { + const zoneBtns = await page.$$('[data-e2e="dns-btn-zone"]'); + if (zoneBtns.length === 0) { + // Zones may still be loading — wait more + await page.waitForSelector('[data-e2e="dns-btn-zone"]', {timeout: LDAP_TIMEOUT}); + } + }); + + await runTest(page, reporter, 'dns', 'expand-zone', async () => { + const zoneBtns = await page.$$('[data-e2e="dns-btn-zone"]'); + if (zoneBtns.length === 0) throw new Error('No DNS zones found'); + await zoneBtns[0].click(); + await page.waitForTimeout(5000); + }); + + await runTest(page, reporter, 'dns', 'add-record', async () => { + await page.waitForSelector('[data-e2e="dns-btn-add-record"]', {timeout: LDAP_TIMEOUT}); + await page.click('[data-e2e="dns-btn-add-record"]'); + await page.waitForSelector('[data-e2e="dns-add-input-name"]', {timeout: 5000}); + await page.fill('[data-e2e="dns-add-input-name"]', TEST_RECORD_NAME); + await page.selectOption('[data-e2e="dns-add-select-type"]', 'A'); + await page.fill('[data-e2e="dns-add-input-data"]', '10.0.0.99'); + await page.click('[data-e2e="dns-add-btn-submit"]'); + await page.waitForTimeout(10000); + }); + + await runTest(page, reporter, 'dns', 'delete-record', async () => { + const deleteBtns = await page.$$('[data-e2e="dns-btn-delete-record"]'); + if (deleteBtns.length > 0) { + await deleteBtns[deleteBtns.length - 1].click(); + await confirmModal(page, 'dns-delete-record'); + await page.waitForTimeout(5000); + } + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/domain.spec.js b/e2e/tests/domain.spec.js new file mode 100644 index 0000000..160cb01 --- /dev/null +++ b/e2e/tests/domain.spec.js @@ -0,0 +1,21 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, waitForPageReady} = require('./helpers'); + +async function run(reporter) { + await runSuite('domain', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'domain', 'info-section', async () => { + await navigateToAdmin(page, 'domain'); + await page.waitForSelector('[data-e2e="domain-section-info"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'domain', 'functional-levels', async () => { + await page.waitForSelector('[data-e2e="domain-section-levels"]', {timeout: 10000}); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/dr.spec.js b/e2e/tests/dr.spec.js new file mode 100644 index 0000000..b544961 --- /dev/null +++ b/e2e/tests/dr.spec.js @@ -0,0 +1,38 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, waitForPageReady} = require('./helpers'); + +async function run(reporter) { + await runSuite('dr', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'dr', 'page-loads', async () => { + await navigateToAdmin(page, 'disaster-recovery'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + }); + + await runTest(page, reporter, 'dr', 'key-section', async () => { + // Check for key management elements — any of these indicates page loaded + const generateBtn = await page.$('[data-e2e="dr-btn-generate-key"]'); + const haveKeyBtn = await page.$('[data-e2e="dr-btn-have-key"]'); + const unlockBtn = await page.$('[data-e2e="dr-btn-unlock"]'); + const syncBtn = await page.$('[data-e2e="dr-btn-sync-metadata"]'); + if (!generateBtn && !haveKeyBtn && !unlockBtn && !syncBtn) { + throw new Error('No DR key management elements found'); + } + }); + + await runTest(page, reporter, 'dr', 's3-config-visible', async () => { + // S3 section may not be visible until DR key is configured + const s3Endpoint = await page.$('[data-e2e="dr-input-s3-endpoint"]'); + if (!s3Endpoint) { + // Expected if DR key not configured — page is still functional + console.log(' (S3 section hidden — DR key not yet configured)'); + } + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/gpos.spec.js b/e2e/tests/gpos.spec.js new file mode 100644 index 0000000..544c2ed --- /dev/null +++ b/e2e/tests/gpos.spec.js @@ -0,0 +1,60 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, BASE_URL, LDAP_TIMEOUT, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_GPO = 'E2E-Test-GPO'; + +async function run(reporter) { + await runSuite('gpos', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'gpos', 'list-gpos', async () => { + await navigateToAdmin(page, 'gpos'); + await page.waitForSelector('[data-e2e="gpos-btn-new"]', {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'gpos', 'create-gpo', async () => { + await page.click('[data-e2e="gpos-btn-new"]'); + await page.waitForSelector('[data-e2e="gpos-create-input-name"]', {timeout: 5000}); + await page.fill('[data-e2e="gpos-create-input-name"]', TEST_GPO); + await page.click('[data-e2e="gpos-create-btn-submit"]'); + // GPO creation via samba-tool can be very slow + await page.waitForTimeout(10000); + // If modal still open, close and reload + const inputStillVisible = await page.$('[data-e2e="gpos-create-input-name"]'); + if (inputStillVisible) { + const cancelBtn = await page.$('[data-e2e="gpos-create-btn-cancel"]'); + if (cancelBtn) await cancelBtn.click(); + await page.waitForTimeout(500); + } + await page.goto(`${BASE_URL}/admin/gpos`); + await page.waitForSelector('[data-e2e="gpos-btn-new"]', {timeout: LDAP_TIMEOUT}); + await page.waitForTimeout(3000); + }); + + await runTest(page, reporter, 'gpos', 'verify-created', async () => { + await page.waitForSelector(`text=${TEST_GPO}`, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'gpos', 'delete-gpo', async () => { + const cards = await page.$$('[data-e2e="gpos-btn-delete"]'); + for (const btn of cards) { + const parent = await btn.evaluateHandle(el => { + let p = el.parentElement; + while (p && !p.classList.contains('rounded-xl')) p = p.parentElement; + return p || el.parentElement; + }); + const text = await parent.evaluate(el => el.textContent); + if (text.includes(TEST_GPO)) { + await btn.click(); + break; + } + } + await confirmModal(page, 'gpos-delete'); + await page.waitForTimeout(5000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/groups.spec.js b/e2e/tests/groups.spec.js new file mode 100644 index 0000000..20406bd --- /dev/null +++ b/e2e/tests/groups.spec.js @@ -0,0 +1,59 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, BASE_URL, LDAP_TIMEOUT, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_GROUP = 'E2E-Test-Group'; +const TABLE_SELECTOR = '[data-e2e="groups-table-search"]'; + +async function run(reporter) { + await runSuite('groups', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'groups', 'list-groups', async () => { + await navigateToAdmin(page, 'groups'); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'groups', 'create-group', async () => { + await page.click('[data-e2e="groups-btn-new"]'); + await page.waitForSelector('[data-e2e="group-form-input-name"]', {timeout: 10000}); + await page.fill('[data-e2e="group-form-input-name"]', TEST_GROUP); + await page.fill('[data-e2e="group-form-input-description"]', 'Created by E2E tests'); + await page.click('[data-e2e="group-form-btn-submit"]'); + await page.waitForURL('**/admin/groups', {timeout: LDAP_ACTION_TIMEOUT}); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'groups', 'verify-created', async () => { + await page.waitForSelector(`text=${TEST_GROUP}`, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'groups', 'view-edit-page', async () => { + await page.goto(`${BASE_URL}/admin/groups/${TEST_GROUP}/edit`); + // Edit page shows group info and members section (no submit button in edit mode) + await page.waitForSelector(`text=${TEST_GROUP}`, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'groups', 'delete-group', async () => { + await page.goto(`${BASE_URL}/admin/groups`); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + await page.waitForSelector(`text=${TEST_GROUP}`, {timeout: LDAP_TIMEOUT}); + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.includes(TEST_GROUP)) { + const deleteBtn = await row.$('[data-e2e="groups-btn-delete"]'); + if (deleteBtn) { + await deleteBtn.click(); + break; + } + } + } + await confirmModal(page, 'groups-delete'); + await page.waitForTimeout(5000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js new file mode 100644 index 0000000..cf0fa83 --- /dev/null +++ b/e2e/tests/helpers.js @@ -0,0 +1,306 @@ +const {chromium} = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const BASE_URL = process.env.BASE_URL || 'http://localhost:4080'; +const ADMIN_USER = 'Administrator'; +const ADMIN_PASS = 'P@ssw0rd123!'; +const RESULTS_DIR = path.resolve(__dirname, '..', 'results'); +const SCREENSHOTS_DIR = path.resolve(RESULTS_DIR, 'screenshots'); + +// LDAP operations can be slow — use generous timeouts +const LDAP_TIMEOUT = 60000; // 60s for LDAP data loading +const LDAP_ACTION_TIMEOUT = 45000; // 45s for LDAP write operations (create/edit/delete) +const UI_TIMEOUT = 10000; // 10s for pure UI interactions + +// Ensure results directories exist +fs.mkdirSync(SCREENSHOTS_DIR, {recursive: true}); + +async function launchBrowser() { + return chromium.launch({args: ['--no-sandbox']}); +} + +async function loginAsAdmin(page) { + await page.goto(`${BASE_URL}/login`); + await page.waitForSelector('[data-e2e="login-input-username"]', {timeout: 15000}); + await page.fill('[data-e2e="login-input-username"]', ADMIN_USER); + await page.fill('[data-e2e="login-input-password"]', ADMIN_PASS); + await page.click('[data-e2e="login-btn-submit"]'); + await page.waitForURL('**/admin**', {timeout: 15000}); +} + +async function loginAsUser(page, username, password) { + await page.goto(`${BASE_URL}/login`); + await page.waitForSelector('[data-e2e="login-input-username"]', {timeout: 15000}); + await page.fill('[data-e2e="login-input-username"]', username); + await page.fill('[data-e2e="login-input-password"]', password); + await page.click('[data-e2e="login-btn-submit"]'); +} + +async function navigateToAdmin(page, section) { + const pathMap = { + 'dashboard': '**/admin', + 'users': '**/admin/users**', + 'groups': '**/admin/groups**', + 'ous': '**/admin/ous**', + 'computers': '**/admin/computers**', + 'service-accts': '**/admin/service-accounts**', + 'dns': '**/admin/dns**', + 'gpos': '**/admin/gpos**', + 'domain': '**/admin/domain**', + 'clients': '**/admin/oauth/clients**', + 'realms': '**/admin/oauth/realms**', + 'settings': '**/admin/settings**', + 'disaster-recovery': '**/admin/dr**', + }; + await page.click(`[data-e2e="admin-sidebar-link-${section}"]`); + await page.waitForURL(pathMap[section] || '**/admin/**', {timeout: 15000}); + // Don't wait for networkidle — LDAP data loads async via Meteor methods + await page.waitForTimeout(500); +} + +async function waitForPageReady(page, timeout = LDAP_TIMEOUT) { + await page.waitForLoadState('networkidle', {timeout}); +} + +async function takeScreenshot(page, suite, testName, status) { + const filename = `${suite}-${testName}-${status}.png`; + const filepath = path.resolve(SCREENSHOTS_DIR, filename); + await page.screenshot({path: filepath, fullPage: true}); + return filename; +} + +async function confirmModal(page, dataE2e = 'confirm-modal') { + await page.waitForSelector(`[data-e2e="${dataE2e}-modal"]`, {timeout: UI_TIMEOUT}); + await page.click(`[data-e2e="${dataE2e}-btn-confirm"]`); + await page.waitForTimeout(1000); +} + +// Wait for an inline modal to close after a LDAP operation +async function waitForModalClose(page, inputSelector, timeout = LDAP_ACTION_TIMEOUT) { + await page.waitForSelector(inputSelector, {state: 'hidden', timeout}); +} + +// TestReporter accumulates results and generates report.html +class TestReporter { + constructor() { + this.suites = {}; + this.startTime = Date.now(); + } + + addResult(suite, testName, passed, screenshot, error) { + if (!this.suites[suite]) this.suites[suite] = []; + this.suites[suite].push({testName, passed, screenshot, error, timestamp: new Date().toISOString()}); + } + + get totalTests() { + return Object.values(this.suites).reduce((sum, tests) => sum + tests.length, 0); + } + + get passedTests() { + return Object.values(this.suites).reduce((sum, tests) => sum + tests.filter(t => t.passed).length, 0); + } + + get failedTests() { + return this.totalTests - this.passedTests; + } + + generateReport() { + const duration = ((Date.now() - this.startTime) / 1000).toFixed(1); + const allPassed = this.failedTests === 0; + const date = new Date().toISOString(); + const passRate = this.totalTests > 0 ? ((this.passedTests / this.totalTests) * 100).toFixed(1) : '0'; + + // Build suite HTML blocks + let suitesHtml = ''; + for (const [suite, tests] of Object.entries(this.suites)) { + const suitePassed = tests.every(t => t.passed); + const suitePassCount = tests.filter(t => t.passed).length; + const suiteBadge = suitePassed + ? 'PASS' + : 'FAIL'; + + let rowsHtml = ''; + for (const t of tests) { + const statusClass = t.passed ? 'pass' : 'fail'; + const statusText = t.passed ? 'PASS' : 'FAIL'; + const screenshotHtml = t.screenshot + ? ` + ${t.testName} + ` + : '-'; + const errorHtml = t.error + ? `
${escapeHtml(t.error.substring(0, 150))}
` + : ''; + + rowsHtml += ` + + ${escapeHtml(t.testName)}${errorHtml} + ${statusText} + ${screenshotHtml} + `; + } + + suitesHtml += ` +
+
+

${suiteBadge} ${escapeHtml(suite)} ${suitePassCount}/${tests.length}

+ +
+ + + ${rowsHtml} +
TestStatusScreenshot
+
`; + } + + const html = ` + + + + + E2E Test Report — Samba Conductor + + + +

E2E Test Report

+

Samba Conductor — ${escapeHtml(date)}

+ +
+
${allPassed ? '✔' : '✘'}
+
+
${allPassed ? 'All Tests Passed' : 'Some Tests Failed'}
+
${this.passedTests}/${this.totalTests} passed (${passRate}%) in ${duration}s
+
+
+ +
+
${this.totalTests}
Total
+
${this.passedTests}
Passed
+
${this.failedTests}
Failed
+
${duration}s
Duration
+
+ + ${suitesHtml} + + + +`; + + const reportPath = path.resolve(RESULTS_DIR, 'report.html'); + fs.writeFileSync(reportPath, html); + return reportPath; + } +} + +function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// Run a single test with screenshot capture +async function runTest(page, reporter, suite, testName, testFn) { + try { + await testFn(); + const screenshot = await takeScreenshot(page, suite, testName, 'success'); + reporter.addResult(suite, testName, true, screenshot); + console.log(` [PASS] ${testName}`); + return true; + } catch (error) { + let screenshot; + try { + screenshot = await takeScreenshot(page, suite, testName, 'FAIL'); + } catch (_) { /* ignore screenshot errors */ } + reporter.addResult(suite, testName, false, screenshot, error.message); + console.error(` [FAIL] ${testName}: ${error.message}`); + return false; + } +} + +// Run a test suite (function that receives browser, reporter) +async function runSuite(suiteName, suiteFn, reporter) { + console.log(`\n=== Suite: ${suiteName} ===`); + const browser = await launchBrowser(); + try { + await suiteFn(browser, reporter); + } catch (error) { + console.error(` [SUITE ERROR] ${suiteName}: ${error.message}`); + reporter.addResult(suiteName, 'suite-setup', false, null, error.message); + } finally { + await browser.close(); + } +} + +module.exports = { + BASE_URL, + ADMIN_USER, + ADMIN_PASS, + RESULTS_DIR, + SCREENSHOTS_DIR, + LDAP_TIMEOUT, + LDAP_ACTION_TIMEOUT, + UI_TIMEOUT, + launchBrowser, + loginAsAdmin, + loginAsUser, + navigateToAdmin, + waitForPageReady, + takeScreenshot, + confirmModal, + waitForModalClose, + TestReporter, + runTest, + runSuite, +}; diff --git a/e2e/tests/oauth-clients.spec.js b/e2e/tests/oauth-clients.spec.js new file mode 100644 index 0000000..9e0e3d2 --- /dev/null +++ b/e2e/tests/oauth-clients.spec.js @@ -0,0 +1,56 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, BASE_URL, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_CLIENT_NAME = 'E2E Test Client'; + +async function run(reporter) { + await runSuite('oauth-clients', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + let clientId = null; + + await runTest(page, reporter, 'oauth-clients', 'list-clients', async () => { + await navigateToAdmin(page, 'clients'); + await page.waitForSelector('[data-e2e="oauth-clients-btn-new"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'oauth-clients', 'create-client', async () => { + await page.click('[data-e2e="oauth-clients-btn-new"]'); + await page.waitForSelector('[data-e2e="oauth-client-form-input-name"]', {timeout: 5000}); + await page.fill('[data-e2e="oauth-client-form-input-name"]', TEST_CLIENT_NAME); + await page.fill('[data-e2e="oauth-client-form-input-description"]', 'E2E test client'); + await page.fill('[data-e2e="oauth-client-form-input-redirect"]', 'http://localhost:9999/callback'); + await page.click('[data-e2e="oauth-client-form-btn-submit"]'); + await page.waitForSelector('h3:has-text("Client Credentials")', {timeout: LDAP_ACTION_TIMEOUT}); + const codes = await page.$$('code'); + if (codes.length >= 1) { + clientId = await codes[0].textContent(); + } + await page.click('[data-e2e="oauth-clients-btn-done"]'); + await page.waitForTimeout(1000); + }); + + await runTest(page, reporter, 'oauth-clients', 'verify-in-list', async () => { + if (clientId) { + await page.waitForSelector(`text=${clientId}`, {timeout: 10000}); + } else { + await page.waitForSelector(`text=${TEST_CLIENT_NAME}`, {timeout: 10000}); + } + }); + + await runTest(page, reporter, 'oauth-clients', 'delete-client', async () => { + await page.goto(`${BASE_URL}/admin/oauth/clients`); + await page.waitForSelector('[data-e2e="oauth-clients-btn-new"]', {timeout: 10000}); + await page.waitForTimeout(2000); + const deleteBtn = await page.$('[data-e2e="oauth-clients-btn-delete"]'); + if (!deleteBtn) throw new Error('Delete button not found'); + await deleteBtn.click(); + await confirmModal(page); + await page.waitForTimeout(2000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/oauth-realms.spec.js b/e2e/tests/oauth-realms.spec.js new file mode 100644 index 0000000..52fa89f --- /dev/null +++ b/e2e/tests/oauth-realms.spec.js @@ -0,0 +1,51 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, waitForPageReady, confirmModal} = require('./helpers'); + +const TEST_REALM_NAME = 'e2e-test-realm'; +const TEST_REALM_DISPLAY = 'E2E Test Realm'; + +async function run(reporter) { + await runSuite('oauth-realms', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'oauth-realms', 'list-realms', async () => { + await navigateToAdmin(page, 'realms'); + await page.waitForSelector('[data-e2e="oauth-realms-btn-new"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'oauth-realms', 'default-exists', async () => { + await page.waitForSelector('text=Default', {timeout: 5000}); + }); + + await runTest(page, reporter, 'oauth-realms', 'create-realm', async () => { + await page.click('[data-e2e="oauth-realms-btn-new"]'); + await page.waitForSelector('[data-e2e="oauth-realm-form-input-name"]', {timeout: 5000}); + await page.fill('[data-e2e="oauth-realm-form-input-name"]', TEST_REALM_NAME); + await page.fill('[data-e2e="oauth-realm-form-input-display-name"]', TEST_REALM_DISPLAY); + await page.click('[data-e2e="oauth-realm-form-btn-submit"]'); + await page.waitForTimeout(2000); + await page.waitForSelector(`text=${TEST_REALM_DISPLAY}`, {timeout: 10000}); + }); + + await runTest(page, reporter, 'oauth-realms', 'delete-realm', async () => { + // Find the test realm card and click delete + const deleteButtons = await page.$$('[data-e2e="oauth-realms-btn-delete"]'); + for (const btn of deleteButtons) { + const card = await btn.evaluateHandle(el => el.closest('[data-e2e="oauth-realms-card"]')); + if (card) { + const text = await card.evaluate(el => el.textContent); + if (text.includes(TEST_REALM_DISPLAY)) { + await btn.click(); + break; + } + } + } + await confirmModal(page); + await page.waitForTimeout(2000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/ous.spec.js b/e2e/tests/ous.spec.js new file mode 100644 index 0000000..f5e5d5a --- /dev/null +++ b/e2e/tests/ous.spec.js @@ -0,0 +1,61 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, BASE_URL, LDAP_TIMEOUT, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_OU = 'E2E-Test-OU'; + +async function run(reporter) { + await runSuite('ous', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'ous', 'view-tree', async () => { + await navigateToAdmin(page, 'ous'); + await page.waitForSelector('[data-e2e="ous-tree-item"]', {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'ous', 'create-ou', async () => { + await page.click('[data-e2e="ous-btn-new"]'); + await page.waitForSelector('[data-e2e="ous-create-input-name"]', {timeout: 5000}); + await page.fill('[data-e2e="ous-create-input-name"]', TEST_OU); + await page.fill('[data-e2e="ous-create-input-description"]', 'E2E test OU'); + await page.click('[data-e2e="ous-create-btn-submit"]'); + // Wait for modal to close (LDAP OU creation) + await page.waitForTimeout(10000); + // If modal still open, close it (operation may still be in progress) + const inputStillVisible = await page.$('[data-e2e="ous-create-input-name"]'); + if (inputStillVisible) { + // Try clicking Cancel to close modal + const cancelBtn = await page.$('[data-e2e="ous-create-btn-cancel"]'); + if (cancelBtn) await cancelBtn.click(); + await page.waitForTimeout(500); + } + // Reload to see if OU was created + await page.goto(`${BASE_URL}/admin/ous`); + await page.waitForSelector('[data-e2e="ous-tree-item"]', {timeout: LDAP_TIMEOUT}); + await page.waitForTimeout(2000); + }); + + await runTest(page, reporter, 'ous', 'select-ou', async () => { + const treeItems = await page.$$('[data-e2e="ous-tree-item"]'); + let found = false; + for (const item of treeItems) { + const text = await item.evaluate(el => el.textContent); + if (text.includes(TEST_OU)) { + await item.click(); + found = true; + break; + } + } + if (!found) throw new Error(`OU ${TEST_OU} not found in tree`); + }); + + await runTest(page, reporter, 'ous', 'delete-ou', async () => { + await page.click('[data-e2e="ous-btn-delete"]'); + await confirmModal(page, 'ous-delete'); + await page.waitForTimeout(5000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/run-all.js b/e2e/tests/run-all.js new file mode 100644 index 0000000..8c1b8ec --- /dev/null +++ b/e2e/tests/run-all.js @@ -0,0 +1,59 @@ +const {TestReporter} = require('./helpers'); + +const SUITES = { + auth: () => require('./auth.spec'), + dashboard: () => require('./dashboard.spec'), + users: () => require('./users.spec'), + groups: () => require('./groups.spec'), + ous: () => require('./ous.spec'), + computers: () => require('./computers.spec'), + 'service-accounts': () => require('./service-accounts.spec'), + dns: () => require('./dns.spec'), + gpos: () => require('./gpos.spec'), + domain: () => require('./domain.spec'), + 'oauth-clients': () => require('./oauth-clients.spec'), + 'oauth-realms': () => require('./oauth-realms.spec'), + settings: () => require('./settings.spec'), + selfservice: () => require('./selfservice.spec'), + dr: () => require('./dr.spec'), +}; + +async function main() { + const filter = process.env.FILTER || '--all'; + const reporter = new TestReporter(); + + console.log(`\nSamba Conductor E2E Tests\n${'='.repeat(50)}`); + + const suitesToRun = filter === '--all' + ? Object.keys(SUITES) + : Object.keys(SUITES).filter(name => name.includes(filter)); + + if (suitesToRun.length === 0) { + console.error(`No suites matching filter: ${filter}`); + console.log(`Available suites: ${Object.keys(SUITES).join(', ')}`); + process.exit(1); + } + + console.log(`Running ${suitesToRun.length} suite(s): ${suitesToRun.join(', ')}\n`); + + for (const name of suitesToRun) { + const suiteMod = SUITES[name](); + if (typeof suiteMod.run === 'function') { + await suiteMod.run(reporter); + } + } + + const reportPath = reporter.generateReport(); + console.log(`\n${'='.repeat(50)}`); + console.log(`Total: ${reporter.totalTests} | Passed: ${reporter.passedTests} | Failed: ${reporter.failedTests}`); + console.log(`Report: ${reportPath}`); + + if (reporter.failedTests > 0) { + process.exit(1); + } +} + +main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/e2e/tests/selfservice.spec.js b/e2e/tests/selfservice.spec.js new file mode 100644 index 0000000..2824960 --- /dev/null +++ b/e2e/tests/selfservice.spec.js @@ -0,0 +1,55 @@ +const {runSuite, runTest, loginAsAdmin, waitForPageReady, BASE_URL} = require('./helpers'); + +async function run(reporter) { + await runSuite('selfservice', async (browser, reporter) => { + const page = await browser.newPage(); + + // Login as admin but navigate to self-service + await page.goto(`${BASE_URL}/login`); + await page.waitForSelector('[data-e2e="login-input-username"]', {timeout: 15000}); + await page.fill('[data-e2e="login-input-username"]', 'Administrator'); + await page.fill('[data-e2e="login-input-password"]', 'P@ssw0rd123!'); + await page.click('[data-e2e="login-btn-submit"]'); + await page.waitForURL('**/admin**', {timeout: 15000}); + + await runTest(page, reporter, 'selfservice', 'home-page', async () => { + await page.goto(`${BASE_URL}/`); + await page.waitForSelector('[data-e2e="selfservice-home-link-edit-profile"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'selfservice', 'home-links', async () => { + const editLink = await page.$('[data-e2e="selfservice-home-link-edit-profile"]'); + const passLink = await page.$('[data-e2e="selfservice-home-link-change-password"]'); + if (!editLink) throw new Error('Edit profile link not found'); + if (!passLink) throw new Error('Change password link not found'); + }); + + await runTest(page, reporter, 'selfservice', 'profile-page', async () => { + await page.click('[data-e2e="selfservice-home-link-edit-profile"]'); + await page.waitForURL('**/profile**', {timeout: 10000}); + await page.waitForSelector('[data-e2e="profile-btn-save"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'selfservice', 'profile-cancel', async () => { + await page.click('[data-e2e="profile-btn-cancel"]'); + await page.waitForURL(`${BASE_URL}/`, {timeout: 10000}); + }); + + await runTest(page, reporter, 'selfservice', 'change-password-page', async () => { + await page.click('[data-e2e="selfservice-home-link-change-password"]'); + await page.waitForURL('**/change-password**', {timeout: 10000}); + await page.waitForSelector('[data-e2e="change-password-input-current"]', {timeout: 10000}); + await page.waitForSelector('[data-e2e="change-password-input-new"]', {timeout: 5000}); + await page.waitForSelector('[data-e2e="change-password-input-confirm"]', {timeout: 5000}); + }); + + await runTest(page, reporter, 'selfservice', 'change-password-cancel', async () => { + await page.click('[data-e2e="change-password-btn-cancel"]'); + await page.waitForURL(`${BASE_URL}/`, {timeout: 10000}); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/service-accounts.spec.js b/e2e/tests/service-accounts.spec.js new file mode 100644 index 0000000..e304ee7 --- /dev/null +++ b/e2e/tests/service-accounts.spec.js @@ -0,0 +1,70 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, BASE_URL, LDAP_TIMEOUT, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_SA = 'e2etestsa'; +const TABLE_SELECTOR = '[data-e2e="service-accounts-table-search"]'; + +async function run(reporter) { + await runSuite('service-accounts', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'service-accounts', 'list-accounts', async () => { + await navigateToAdmin(page, 'service-accts'); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'service-accounts', 'create-account', async () => { + await page.click('[data-e2e="service-accounts-btn-new"]'); + await page.waitForSelector('[data-e2e="service-accounts-create-input-name"]', {timeout: 5000}); + await page.fill('[data-e2e="service-accounts-create-input-name"]', TEST_SA); + await page.fill('[data-e2e="service-accounts-create-input-dns-hostname"]', 'e2etest.samdom.example.com'); + await page.click('[data-e2e="service-accounts-create-btn-submit"]'); + await page.waitForTimeout(5000); + // If modal still open, close it and reload + const inputStillVisible = await page.$('[data-e2e="service-accounts-create-input-name"]'); + if (inputStillVisible) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await page.goto(`${BASE_URL}/admin/service-accounts`); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'service-accounts', 'verify-created', async () => { + await page.waitForSelector(`text=${TEST_SA}`, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'service-accounts', 'view-details', async () => { + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.toLowerCase().includes(TEST_SA)) { + const btn = await row.$('[data-e2e="service-accounts-btn-details"]'); + if (btn) await btn.click(); + break; + } + } + await page.waitForSelector('[data-e2e="service-accounts-detail-btn-close"]', {timeout: 10000}); + await page.click('[data-e2e="service-accounts-detail-btn-close"]'); + await page.waitForTimeout(500); + }); + + await runTest(page, reporter, 'service-accounts', 'delete-account', async () => { + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.toLowerCase().includes(TEST_SA)) { + const btn = await row.$('[data-e2e="service-accounts-btn-delete"]'); + if (btn) await btn.click(); + break; + } + } + await confirmModal(page, 'service-accounts-delete'); + await page.waitForTimeout(5000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/settings.spec.js b/e2e/tests/settings.spec.js new file mode 100644 index 0000000..5d7fbf2 --- /dev/null +++ b/e2e/tests/settings.spec.js @@ -0,0 +1,40 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, waitForPageReady} = require('./helpers'); + +async function run(reporter) { + await runSuite('settings', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'settings', 'page-loads', async () => { + await navigateToAdmin(page, 'settings'); + await page.waitForSelector('[data-e2e="settings-btn-save-fields"]', {timeout: 10000}); + }); + + await runTest(page, reporter, 'settings', 'toggle-feature', async () => { + // Find a toggle and click it + const toggles = await page.$$('[data-e2e^="settings-toggle-"]'); + if (toggles.length === 0) throw new Error('No settings toggles found'); + // Toggle first one on then off + await toggles[0].click(); + await page.waitForTimeout(1000); + await toggles[0].click(); + await page.waitForTimeout(1000); + }); + + await runTest(page, reporter, 'settings', 'save-fields', async () => { + await page.click('[data-e2e="settings-btn-save-fields"]'); + await page.waitForTimeout(2000); + }); + + await runTest(page, reporter, 'settings', 'sync-account-section', async () => { + // Check if sync account config button exists + const configBtn = await page.$('[data-e2e="settings-btn-configure-sync"]'); + const resetBtn = await page.$('[data-e2e="settings-btn-reset-sync-password"]'); + if (!configBtn && !resetBtn) throw new Error('Sync account section not found'); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/e2e/tests/users.spec.js b/e2e/tests/users.spec.js new file mode 100644 index 0000000..8f63f56 --- /dev/null +++ b/e2e/tests/users.spec.js @@ -0,0 +1,113 @@ +const {runSuite, runTest, loginAsAdmin, navigateToAdmin, confirmModal, BASE_URL, LDAP_TIMEOUT, LDAP_ACTION_TIMEOUT} = require('./helpers'); + +const TEST_USER = 'e2eTestUser'; +const TEST_PASS = 'T3st!Pass@2024'; + +// DataTable renders data-e2e as "${prefix}-search" on the search input +const TABLE_SELECTOR = '[data-e2e="users-table-search"]'; + +async function run(reporter) { + await runSuite('users', async (browser, reporter) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + + await runTest(page, reporter, 'users', 'list-users', async () => { + await navigateToAdmin(page, 'users'); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'users', 'create-user', async () => { + await page.click('[data-e2e="users-btn-new"]'); + await page.waitForSelector('[data-e2e="user-form-input-username"]', {timeout: 10000}); + await page.fill('[data-e2e="user-form-input-username"]', TEST_USER); + await page.fill('[data-e2e="user-form-input-password"]', TEST_PASS); + await page.fill('[data-e2e="user-form-input-first-name"]', 'E2E'); + await page.fill('[data-e2e="user-form-input-last-name"]', 'TestUser'); + await page.fill('[data-e2e="user-form-input-email"]', 'e2e@test.local'); + await page.fill('[data-e2e="user-form-input-description"]', 'Created by E2E tests'); + await page.click('[data-e2e="user-form-btn-submit"]'); + // Wait for URL to change from /new to /admin/users (list) + await page.waitForFunction( + () => !window.location.pathname.includes('/new'), + {timeout: LDAP_ACTION_TIMEOUT} + ); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'users', 'verify-created', async () => { + await page.waitForSelector(`text=${TEST_USER}`, {timeout: LDAP_TIMEOUT}); + }); + + await runTest(page, reporter, 'users', 'edit-user', async () => { + await page.goto(`${BASE_URL}/admin/users/${TEST_USER}/edit`); + await page.waitForSelector('[data-e2e="user-form-input-first-name"]', {timeout: LDAP_TIMEOUT}); + await page.fill('[data-e2e="user-form-input-description"]', 'Updated by E2E'); + await page.fill('[data-e2e="user-form-input-company"]', 'E2E Corp'); + await page.click('[data-e2e="user-form-btn-submit"]'); + await page.waitForURL('**/admin/users', {timeout: LDAP_ACTION_TIMEOUT}); + }); + + await runTest(page, reporter, 'users', 'toggle-disable', async () => { + await page.goto(`${BASE_URL}/admin/users`); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + await page.waitForSelector(`text=${TEST_USER}`, {timeout: LDAP_TIMEOUT}); + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.includes(TEST_USER)) { + const toggleBtn = await row.$('[data-e2e="users-btn-toggle-status"]'); + if (toggleBtn) { + await toggleBtn.click(); + // Toggle opens a ConfirmModal + await confirmModal(page); + await page.waitForTimeout(5000); + } + break; + } + } + }); + + await runTest(page, reporter, 'users', 'toggle-enable', async () => { + // Reload to clear any lingering overlays + await page.goto(`${BASE_URL}/admin/users`); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + await page.waitForSelector(`text=${TEST_USER}`, {timeout: LDAP_TIMEOUT}); + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.includes(TEST_USER)) { + const toggleBtn = await row.$('[data-e2e="users-btn-toggle-status"]'); + if (toggleBtn) { + await toggleBtn.click(); + await confirmModal(page); + await page.waitForTimeout(5000); + } + break; + } + } + }); + + await runTest(page, reporter, 'users', 'delete-user', async () => { + await page.goto(`${BASE_URL}/admin/users`); + await page.waitForSelector(TABLE_SELECTOR, {timeout: LDAP_TIMEOUT}); + await page.waitForSelector(`text=${TEST_USER}`, {timeout: LDAP_TIMEOUT}); + const rows = await page.$$('tr'); + for (const row of rows) { + const text = await row.evaluate(el => el.textContent); + if (text.includes(TEST_USER)) { + const deleteBtn = await row.$('[data-e2e="users-btn-delete"]'); + if (deleteBtn) { + await deleteBtn.click(); + break; + } + } + } + await confirmModal(page, 'users-delete'); + await page.waitForTimeout(5000); + }); + + await page.close(); + }, reporter); +} + +module.exports = {run}; diff --git a/web/app/oauth/OAuthClients.js b/web/app/oauth/OAuthClients.js index 046654b..a20ea4f 100644 --- a/web/app/oauth/OAuthClients.js +++ b/web/app/oauth/OAuthClients.js @@ -112,12 +112,12 @@ export function OAuthClients() { render(row) { return (
- -
- - + {/* Created/Reset Secret Modal */} {createdSecret && ( @@ -167,12 +167,12 @@ export function OAuthClients() {
{createdSecret.clientSecret} - +
-