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/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..f8d3514 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,34 @@ +name: E2E Tests + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + e2e: + name: Run E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run E2E tests + working-directory: e2e + run: ./run-tests.sh --compose + continue-on-error: true + id: tests + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-results + path: e2e/results/ + retention-days: 14 + + - name: Check test result + if: steps.tests.outcome == 'failure' + run: exit 1 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/CLAUDE.md b/CLAUDE.md index 3220259..4f1001c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,10 @@ samba-conductor/ │ └── screenshots/ # UI screenshots for documentation (generated via Playwright) ├── e2e/ # Playwright E2E tests and screenshot capture │ ├── screenshots/ # Capture scripts (use data-e2e selectors) +│ ├── tests/ # E2E test suites and helpers +│ ├── run-tests.sh # Run E2E tests (host or Docker Compose mode) │ └── update-screenshots.sh # Run to update docs screenshots (change detection) +├── .github/workflows/ # CI pipelines (E2E tests on PR) └── CLAUDE.md # This file ``` @@ -37,7 +40,9 @@ Each subdirectory has its own `CLAUDE.md` with specific instructions. - **Web UI:** Meteor 3.4 + React 19 + Tailwind CSS 4 (mobile-first, 3 themes: Wine/Classic/Light) - **Primary Data Source:** Samba 4 AD DC (LDAPS + samba-tool) - **Database:** MongoDB (app state, settings, DR snapshots — AD data is NOT stored here except for DR) -- **Deployment:** Docker (3 image variants, single `/data` volume) +- **Deployment:** Docker (all-in-one single container, standalone DC, or web + external DC) +- **Build:** Rspack + Babel (with `REMOVE_E2E_ATTRS=true` to strip test selectors in production) +- **CI:** GitHub Actions E2E workflow (`.github/workflows/e2e-tests.yml`) ## Architecture @@ -46,6 +51,7 @@ Each subdirectory has its own `CLAUDE.md` with specific instructions. - **Credential Store:** AES-256-GCM, random key per boot (session), PBKDF2-derived key (DR data) - **Sync Account:** Auto-created AD service account for background sync jobs - **S3 Backup:** Configurable mongodump + samba-tool domain backup → S3-compatible storage +- **Cron:** Background sync/backup jobs, disableable via `Meteor.settings.cron.enabled = false` ## Development Constraints diff --git a/docker/CLAUDE.md b/docker/CLAUDE.md index 20cbf2c..3c2a7d2 100644 --- a/docker/CLAUDE.md +++ b/docker/CLAUDE.md @@ -6,11 +6,11 @@ Docker images for the Samba Conductor infrastructure. Three variants, all using ## Images -| Image | Base | Contents | -|----------------------------|------------------------|-------------------------------| -| **samba-ad-dc** | Fedora minimal | Samba 4 AD DC only | -| **web** (`web/Dockerfile`) | zcloud runtime | Meteor + MongoDB + samba-tool | -| **all-in-one** | zcloud runtime + Samba | Everything in one container | +| Image | Base | Contents | +|----------------------------|----------------------------------------------|---------------------------------------| +| **samba-ad-dc** | Fedora minimal | Samba 4 AD DC only | +| **web** (`web/Dockerfile`) | zcloud runtime | Meteor + MongoDB + samba-tool | +| **all-in-one** | Fedora minimal + Node.js/MongoDB from Meteor | Samba + Meteor + MongoDB + Supervisor | ## Structure @@ -24,17 +24,21 @@ docker/ │ ├── entrypoint.sh # Sources samba-setup.sh │ └── supervisord.conf ├── all-in-one/ -│ ├── Dockerfile # zcloud runtime + Samba + Meteor app +│ ├── Dockerfile # Fedora minimal + Samba + Node.js + MongoDB + Meteor app │ ├── docker-compose.yml -│ ├── entrypoint.sh # Sources samba-setup.sh + mongo/app env -│ └── supervisord.conf +│ ├── entrypoint.sh # Sources samba-setup.sh, assembles supervisor config +│ └── supervisor/ # Supervisor config templates +│ ├── supervisord.conf # Base config with [include] +│ ├── samba.conf # Always active +│ ├── mongodb.conf # Active when internal MongoDB needed +│ └── webapp.conf # Active when ROOT_URL is set ├── README.md └── CLAUDE.md ``` ## Conventions -- Base image: Fedora minimal (samba-ad-dc) or zcloud runtime (web, all-in-one) +- Base image: Fedora minimal (samba-ad-dc, all-in-one) or zcloud runtime (web) - Use `microdnf` on Fedora, `apt-get` on Ubuntu (zcloud) - Always clean package caches after install - Single `/data` volume — standard paths symlinked into it @@ -44,3 +48,15 @@ docker/ - Shared scripts in `scripts/samba-setup.sh` used by both samba-ad-dc and all-in-one - Replica DC support via `SAMBA_JOIN_AS_DC=true` + `SAMBA_PRIMARY_DC=hostname` - Dev compose includes replica as optional profile (`--profile replica`) + +## All-in-One Service Management + +Entrypoint conditionally assembles supervisor config from templates based on environment: + +| `ROOT_URL` | `MONGO_URL` | Services started | +|------------|-------------|-----------------------------------| +| not set | — | Samba only | +| set | not set | Samba + internal MongoDB + webapp | +| set | set | Samba + webapp (external MongoDB) | + +If `METEOR_SETTINGS` env var is provided, it is used as-is. Otherwise, settings are generated from `SAMBA_REALM`. diff --git a/docker/all-in-one/Dockerfile b/docker/all-in-one/Dockerfile index cd56392..bfbafb7 100644 --- a/docker/all-in-one/Dockerfile +++ b/docker/all-in-one/Dockerfile @@ -1,74 +1,92 @@ -# ============================================================================= -# Samba Conductor — All-in-One Image -# Base: zcloud meteor runtime (Node.js + MongoDB) + Samba 4 AD DC -# Single volume (/data) for all persistent state -# ============================================================================= - -# Stage 1: Build Meteor application FROM zcloudws/meteor-build:3.4 AS builder WORKDIR /app-source/source USER root + RUN chown zcloud:zcloud -R /app-source +RUN mkdir -p /tmp/tools/{.node,.mongodb} && chown zcloud:zcloud -R /tmp/tools USER zcloud + +RUN export DEV_BUNDLE_DIR=/home/zcloud/.meteor/packages/meteor-tool/*.*.*/mt-os.linux.x86_64/dev_bundle && \ + cp -r $DEV_BUNDLE_DIR/{bin,lib} /tmp/tools/.node && \ + cp -r $DEV_BUNDLE_DIR/mongodb/bin /tmp/tools/.mongodb/ + 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 - -RUN cd /app-source/app-build/bundle/programs/server \ - && npm i --no-audit --legacy-peer-deps +# --legacy-peer-deps because of react-error-boundary +RUN meteor npm i --no-audit --legacy-peer-deps && \ + meteor build --platforms web.browser --directory ../app-build -# Stage 2: All-in-one runtime (Node.js + MongoDB from zcloud + Samba) -FROM zcloudws/meteor-node-mongodb-runtime:3.4-with-tools +# Samba 4 Active Directory Domain Controller (standalone) +# Based on Fedora minimal for smallest possible image +FROM registry.fedoraproject.org/fedora-minimal:42 LABEL maintainer="samba-conductor" -LABEL description="Samba Conductor All-in-One: Samba 4 AD DC + Web UI + MongoDB" +LABEL description="Samba 4 AD DC on Fedora minimal" -USER root - -# Install Samba AD DC and dependencies on Ubuntu 22.04 -RUN apt-get update && apt-get install -y --no-install-recommends \ +# Install Samba AD DC and required dependencies +RUN microdnf install -y \ samba \ - samba-dsdb-modules \ - samba-vfs-modules \ - winbind \ - krb5-user \ - ldap-utils \ + samba-dc \ + samba-client \ + samba-winbind \ + samba-winbind-clients \ + krb5-workstation \ + bind-utils \ supervisor \ + tdb-tools \ + ldb-tools \ + findutils \ + procps-ng \ openssl \ - && rm -rf /var/lib/apt/lists/* - -# Copy built Meteor app -COPY --from=builder --chown=zcloud:zcloud /app-source/app-build/bundle /home/zcloud/app - -# Shared samba scripts -COPY docker/scripts/samba-setup.sh /usr/local/lib/samba-setup.sh -RUN chmod +x /usr/local/lib/samba-setup.sh - -# Image-specific files -COPY docker/all-in-one/entrypoint.sh /usr/local/bin/entrypoint.sh -COPY docker/all-in-one/supervisord.conf /etc/supervisord.conf -RUN chmod +x /usr/local/bin/entrypoint.sh - -# Single persistent volume -VOLUME ["/data"] + && microdnf clean all \ + && rm -rf /var/cache/dnf # Samba ports EXPOSE 53/tcp 53/udp EXPOSE 88/tcp 88/udp EXPOSE 135/tcp +EXPOSE 137/udp +EXPOSE 138/udp +EXPOSE 139/tcp EXPOSE 389/tcp 389/udp EXPOSE 445/tcp EXPOSE 464/tcp 464/udp EXPOSE 636/tcp EXPOSE 3268/tcp EXPOSE 3269/tcp - -# Web UI EXPOSE 3000/tcp +EXPOSE 27017/tcp +EXPOSE 49152-65535/tcp + +# Non-root user for MongoDB +RUN useradd -r -s /sbin/nologin -d /opt/app conductor + +# Shared samba scripts +COPY docker/scripts/samba-setup.sh /usr/local/lib/samba-setup.sh +RUN chmod +x /usr/local/lib/samba-setup.sh + +# Image-specific files +COPY docker/all-in-one/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Supervisor config templates +COPY docker/all-in-one/supervisor/supervisord.conf /etc/supervisord.conf +COPY docker/all-in-one/supervisor/ /etc/supervisor/templates/ +RUN mkdir -p /etc/supervisor/conf.d + +# Meteor tools +COPY --from=builder /tmp/tools/.node /opt/nodejs/ +COPY --from=builder /tmp/tools/.mongodb /opt/mongodb/ +COPY --from=builder /app-source/app-build/bundle /opt/app + +# --legacy-peer-deps because of react-error-boundary +RUN cd /opt/app/programs/server && PATH=$PATH:/opt/nodejs/bin npm i --no-audit --legacy-peer-deps + +# Single persistent volume +VOLUME ["/data"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/all-in-one/entrypoint.sh b/docker/all-in-one/entrypoint.sh index 4d15401..44f3581 100644 --- a/docker/all-in-one/entrypoint.sh +++ b/docker/all-in-one/entrypoint.sh @@ -11,18 +11,43 @@ SAMBA_DNS_FORWARDER=${SAMBA_DNS_FORWARDER:-"8.8.8.8"} SAMBA_SERVER_ROLE=${SAMBA_SERVER_ROLE:-"dc"} DATA_DIR=${DATA_DIR:-"/data"} -ROOT_URL=${ROOT_URL:-"http://localhost:3000"} PORT=${PORT:-3000} +TEMPLATES_DIR="/etc/supervisor/templates" +CONF_DIR="/etc/supervisor/conf.d" + +# ============================================================================= +# Determine which services to start +# ============================================================================= +# ROOT_URL set → start webapp +# MONGO_URL set → use external MongoDB (skip internal) +# MONGO_URL unset → start internal MongoDB if webapp is enabled +ENABLE_WEBAPP=false +ENABLE_MONGODB=false + +if [ -n "${ROOT_URL}" ]; then + ENABLE_WEBAPP=true + if [ -z "${MONGO_URL}" ]; then + ENABLE_MONGODB=true + MONGO_URL="mongodb://127.0.0.1:27017/samba-conductor" + fi +fi + # ============================================================================= -# Setup MongoDB directories +# Setup directories # ============================================================================= -MONGO_DATA="${DATA_DIR}/mongodb" -MONGO_LOGS="${DATA_DIR}/logs/mongodb" -APP_LOGS="${DATA_DIR}/logs/app" +SAMBA_LOGS="${DATA_DIR}/logs/samba" +mkdir -p "${SAMBA_LOGS}" + +if [ "${ENABLE_MONGODB}" = true ]; then + mkdir -p "${DATA_DIR}/mongodb" "${DATA_DIR}/logs/mongodb" + chown -R conductor:conductor "${DATA_DIR}/mongodb" "${DATA_DIR}/logs/mongodb" +fi -mkdir -p "${MONGO_DATA}" "${MONGO_LOGS}" "${APP_LOGS}" -chown -R zcloud:zcloud "${MONGO_DATA}" "${MONGO_LOGS}" "${APP_LOGS}" +if [ "${ENABLE_WEBAPP}" = true ]; then + mkdir -p "${DATA_DIR}/logs/app" + chown -R conductor:conductor "${DATA_DIR}/logs/app" +fi # ============================================================================= # Run shared Samba setup (provision, kerberos, TLS) @@ -31,22 +56,43 @@ source /usr/local/lib/samba-setup.sh setup_samba # ============================================================================= -# Generate environment file for the web app +# Assemble supervisor config from templates # ============================================================================= -SAMBA_REALM_LOWER=$(echo "${SAMBA_REALM}" | tr '[:upper:]' '[:lower:]') -BASE_DN="DC=$(echo "${SAMBA_REALM_LOWER}" | sed 's/\./,DC=/g')" +rm -f "${CONF_DIR}"/*.conf -cat > /etc/samba-conductor.env < /etc/samba-conductor.env < /dev/tcp/127.0.0.1/636' 2>/dev/null; do sleep 1; done && source /etc/samba-conductor.env && exec node main.js" +directory=/opt/app +# Runs as root: samba-tool requires access to /var/lib/samba/private/ +autostart=true +autorestart=true +priority=30 +startretries=10 +startsecs=5 +environment=NODE_ENV="production",NODE_TLS_REJECT_UNAUTHORIZED="0",PATH="/opt/nodejs/bin:/opt/mongodb/bin:%(ENV_PATH)s" +stdout_logfile=/data/logs/app/webapp-stdout.log +stderr_logfile=/data/logs/app/webapp-stderr.log diff --git a/docker/all-in-one/supervisord.conf b/docker/all-in-one/supervisord.conf deleted file mode 100644 index 2822053..0000000 --- a/docker/all-in-one/supervisord.conf +++ /dev/null @@ -1,34 +0,0 @@ -[supervisord] -nodaemon=true -logfile=/data/logs/supervisord.log -childlogdir=/data/logs - -[program:samba] -command=/usr/sbin/samba --foreground --no-process-group -autostart=true -autorestart=true -priority=10 -stdout_logfile=/data/logs/samba/samba-stdout.log -stderr_logfile=/data/logs/samba/samba-stderr.log - -[program:mongodb] -command=/usr/local/bin/mongod --dbpath /data/mongodb --bind_ip 127.0.0.1 --port 27017 --logpath /data/logs/mongodb/mongod.log --quiet -user=zcloud -autostart=true -autorestart=true -priority=20 -stdout_logfile=/data/logs/mongodb/mongod-stdout.log -stderr_logfile=/data/logs/mongodb/mongod-stderr.log - -[program:webapp] -command=/bin/bash -c "source /etc/samba-conductor.env && exec node /home/zcloud/app/main.js" -directory=/home/zcloud/app -user=zcloud -autostart=true -autorestart=true -priority=30 -startretries=10 -startsecs=5 -environment=NODE_ENV="production" -stdout_logfile=/data/logs/app/webapp-stdout.log -stderr_logfile=/data/logs/app/webapp-stderr.log 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/CLAUDE.md b/e2e/CLAUDE.md index 7f65ebb..b613948 100644 --- a/e2e/CLAUDE.md +++ b/e2e/CLAUDE.md @@ -10,7 +10,13 @@ Playwright-based scripts to capture UI screenshots for documentation and run E2E e2e/ ├── package.json # Dependencies (playwright) ├── playwright.config.js # Base URL, screenshot dir, credentials, viewport +├── run-tests.sh # Run E2E tests (host or Docker Compose mode) +├── docker-compose.yml # All-in-one + Playwright test runner ├── update-screenshots.sh # Shell script to update screenshots (with change detection) +├── tests/ +│ ├── helpers.js # Login, navigation, screenshot capture, TestReporter +│ ├── run-all.js # Orchestrates all suites, generates HTML report +│ └── *.spec.js # Test suites (auth, users, groups, etc.) ├── screenshots/ │ └── capture-all.js # Captures all pages, compares with existing, updates only changed └── CLAUDE.md # This file @@ -38,9 +44,24 @@ npx playwright install chromium Screenshots are saved to `docs/screenshots/`. Only files that actually changed are overwritten. -## Prerequisites +## Running E2E Tests -- Samba DC + replica running: `cd docker && docker compose --profile replica up -d` +```bash +# Docker Compose — all-in-one image, zero host dependencies +./run-tests.sh --compose +./run-tests.sh --compose users # Filter by suite +./run-tests.sh --compose --no-cache # Rebuild without Docker cache + +# Host mode — requires Meteor + Samba running on host +./run-tests.sh # Run all tests +./run-tests.sh auth # Filter by suite +``` + +Docker Compose mode uses the all-in-one image (Samba + MongoDB + Meteor in a single container). + +## Prerequisites (host mode) + +- Samba DC running: `cd docker && docker compose up -d` - Meteor app running: `cd web && meteor npm start` - Default: `http://localhost:4080` (override with `BASE_URL` env var) 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..e777627 --- /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 all-in-one image (Samba DC + MongoDB + Meteor) and runs Playwright. + +```bash +cd e2e +./run-tests.sh --compose # Run all tests +./run-tests.sh --compose users # Filter by suite +./run-tests.sh --compose --no-cache # Rebuild without Docker cache +``` + +This builds two containers: + +- **app** — All-in-one image (`docker/all-in-one/Dockerfile`): Samba 4 AD DC + MongoDB + Meteor webapp, managed by + Supervisor +- **tests** — Playwright runner (`mcr.microsoft.com/playwright:v1.58.2-noble`) + +> **Note:** Cron jobs are disabled in E2E via `METEOR_SETTINGS` (`cron.enabled = false`) to prevent background tasks +> from interfering with tests. + +### 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:** All-in-one image for Samba+MongoDB+Meteor; Playwright in `mcr.microsoft.com/playwright:v1.58.2-noble` +- **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..9b1b196 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,65 @@ +# E2E Tests: Full stack in Docker (all-in-one image) +# +# Usage: +# cd e2e +# ./run-tests.sh --compose +# ./run-tests.sh --compose --no-cache +# ./run-tests.sh --compose users +# +# Results (HTML report + screenshots) are saved to ./results/ + +services: + # All-in-one: Samba AD DC + MongoDB + Meteor webapp + app: + build: + context: .. + dockerfile: docker/all-in-one/Dockerfile + container_name: e2e-app + 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 + - ROOT_URL=http://localhost:3000 + - METEOR_SETTINGS={"samba":{"ldapUrl":"ldaps://127.0.0.1:636","baseDn":"DC=samdom,DC=example,DC=com","realm":"SAMDOM.EXAMPLE.COM","tlsRejectUnauthorized":false,"sessionTtlMinutes":30},"cron":{"enabled":false},"public":{"appInfo":{"name":"Samba Conductor"}}} + dns: + - 127.0.0.1 + networks: + e2e-net: + ipv4_address: 172.21.0.10 + healthcheck: + test: [ "CMD", "curl", "-sf", "http://localhost:3000" ] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + + # Playwright test runner + tests: + image: mcr.microsoft.com/playwright:v1.58.2-noble + container_name: e2e-tests + depends_on: + app: + condition: service_healthy + environment: + - BASE_URL=http://app:3000 + - 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..b8659bf --- /dev/null +++ b/e2e/run-tests.sh @@ -0,0 +1,130 @@ +#!/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 +# ./run-tests.sh --compose --no-cache # Rebuild images without Docker cache + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FILTER="" +USE_COMPOSE=false +NO_CACHE="" + +# Parse arguments +for arg in "$@"; do + case "$arg" in + --compose) USE_COMPOSE=true ;; + --no-cache) NO_CACHE="--no-cache" ;; + *) 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 all-in-one (Samba + Meteor + MongoDB) + Playwright..." + echo " (this may take a few minutes on first run)" + echo " =========================================" + echo "" + + if [ -n "$NO_CACHE" ]; then + docker compose build --no-cache 2>&1 + fi + + 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/CLAUDE.md b/web/CLAUDE.md index 67fdcf1..f4a27a6 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -7,7 +7,7 @@ - **Styling**: Tailwind CSS 4 with semantic color tokens - **Database**: MongoDB (via Meteor) - **Schema**: SimpleSchema -- **Build Tools**: Rspack, PostCSS +- **Build Tools**: Rspack, Babel (babel.config.js), PostCSS - **Code Quality**: ESLint (@quave/eslint-config-quave), Prettier, Lefthook ## Code Standards @@ -88,6 +88,13 @@ Uses CSS custom properties via Tailwind CSS 4 `@theme`. Three themes: Wine (defa Themes switch by adding class to ``: `.wine`, `.classic`, `.light` +## Build Configuration + +- **`babel.config.js`** — React Compiler plugin + conditional `data-e2e` attribute removal +- **`rspack.config.js`** — CSS PostCSS loader, externals (`react-router-dom`, `ldapjs`) +- **`REMOVE_E2E_ATTRS=true`** — strips `data-e2e` attributes from production builds (via Babel plugin) +- **`ldapjs` as external** — prevents rspack from bundling ldapjs (fixes parser bugs in production builds) + ## Development ```bash diff --git a/web/app/infra/cron.js b/web/app/infra/cron.js index b8c25e5..8e5ef9c 100644 --- a/web/app/infra/cron.js +++ b/web/app/infra/cron.js @@ -1,97 +1,106 @@ +import { Meteor } from 'meteor/meteor'; import { SyncedCron } from 'meteor/quave:synced-cron'; import { isDrKeyUnlocked } from '../auth/drKeyStore'; import { getSyncCredentials } from '../settings/settingsMethods'; import { runFullSync, syncUserHashes } from '../dr/drSync'; -// Metadata sync: users, groups, domain — every 15 minutes -SyncedCron.add({ - name: 'DR: Sync AD metadata', - schedule(parser) { - return parser.text('every 15 minutes'); - }, - async job() { - const credentials = await getSyncCredentials(); - if (!credentials) { - console.log('[Cron] Sync skipped: sync account not configured'); - return; - } +function isCronEnabled() { + return Meteor.settings?.cron?.enabled !== false; +} - try { - const results = await runFullSync({ credentials }); - console.log('[Cron] AD metadata sync completed:', JSON.stringify(results)); - } catch (error) { - console.error('[Cron] AD metadata sync failed:', error.message); - } - }, -}); +if (!isCronEnabled()) { + console.log('[Cron] Disabled via Meteor settings (cron.enabled = false)'); +} else { + // Metadata sync: users, groups, domain — every 15 minutes + SyncedCron.add({ + name: 'DR: Sync AD metadata', + schedule(parser) { + return parser.text('every 15 minutes'); + }, + async job() { + const credentials = await getSyncCredentials(); + if (!credentials) { + console.log('[Cron] Sync skipped: sync account not configured'); + return; + } -// Hash sync: password hashes — every 6 hours -SyncedCron.add({ - name: 'DR: Sync password hashes', - schedule(parser) { - return parser.text('every 6 hours'); - }, - async job() { - if (!isDrKeyUnlocked()) { - console.log('[Cron] Hash sync skipped: DR Key not unlocked'); - return; - } + try { + const results = await runFullSync({ credentials }); + console.log('[Cron] AD metadata sync completed:', JSON.stringify(results)); + } catch (error) { + console.error('[Cron] AD metadata sync failed:', error.message); + } + }, + }); - const credentials = await getSyncCredentials(); - if (!credentials) { - console.log('[Cron] Hash sync skipped: sync account not configured'); - return; - } + // Hash sync: password hashes — every 6 hours + SyncedCron.add({ + name: 'DR: Sync password hashes', + schedule(parser) { + return parser.text('every 6 hours'); + }, + async job() { + if (!isDrKeyUnlocked()) { + console.log('[Cron] Hash sync skipped: DR Key not unlocked'); + return; + } - try { - const result = await syncUserHashes({ credentials }); - console.log(`[Cron] Hash sync completed: ${result.count} users`); - } catch (error) { - console.error('[Cron] Hash sync failed:', error.message); - } - }, -}); + const credentials = await getSyncCredentials(); + if (!credentials) { + console.log('[Cron] Hash sync skipped: sync account not configured'); + return; + } -// S3 Backup — configurable schedule, checks if enabled -SyncedCron.add({ - name: 'DR: S3 Backup', - schedule(parser) { - return parser.text('every 1 hour'); - }, - async job() { - const { SettingsCollection } = require('../settings/SettingsCollection'); - const setting = await SettingsCollection.findOneAsync({ key: 'backup.s3' }); - const config = setting?.value; + try { + const result = await syncUserHashes({ credentials }); + console.log(`[Cron] Hash sync completed: ${result.count} users`); + } catch (error) { + console.error('[Cron] Hash sync failed:', error.message); + } + }, + }); - if (!config?.configured || !config?.enabled) { - return; - } + // S3 Backup — configurable schedule, checks if enabled + SyncedCron.add({ + name: 'DR: S3 Backup', + schedule(parser) { + return parser.text('every 1 hour'); + }, + async job() { + const { SettingsCollection } = require('../settings/SettingsCollection'); + const setting = await SettingsCollection.findOneAsync({ key: 'backup.s3' }); + const config = setting?.value; - if (!isDrKeyUnlocked()) { - console.log('[Cron] S3 backup skipped: DR Key not unlocked'); - return; - } + if (!config?.configured || !config?.enabled) { + return; + } - // Check if enough time has passed since last backup - const lastRun = await SettingsCollection.findOneAsync({ key: 'backup.lastRun' }); - if (lastRun?.value?.timestamp) { - const hoursSinceLast = (Date.now() - new Date(lastRun.value.timestamp).getTime()) / (1000 * 60 * 60); - if (hoursSinceLast < (config.scheduleHours || 6)) { + if (!isDrKeyUnlocked()) { + console.log('[Cron] S3 backup skipped: DR Key not unlocked'); return; } - } - try { - const { runBackup } = require('../dr/drBackup'); - const result = await runBackup({ - includeMongo: config.includeMongoDump !== false, - includeSamba: config.includeSambaBackup !== false, - }); - console.log(`[Cron] S3 backup completed: ${result.uploads.length} files uploaded`); - } catch (error) { - console.error('[Cron] S3 backup failed:', error.message); - } - }, -}); + // Check if enough time has passed since last backup + const lastRun = await SettingsCollection.findOneAsync({ key: 'backup.lastRun' }); + if (lastRun?.value?.timestamp) { + const hoursSinceLast = (Date.now() - new Date(lastRun.value.timestamp).getTime()) / (1000 * 60 * 60); + if (hoursSinceLast < (config.scheduleHours || 6)) { + return; + } + } + + try { + const { runBackup } = require('../dr/drBackup'); + const result = await runBackup({ + includeMongo: config.includeMongoDump !== false, + includeSamba: config.includeSambaBackup !== false, + }); + console.log(`[Cron] S3 backup completed: ${result.uploads.length} files uploaded`); + } catch (error) { + console.error('[Cron] S3 backup failed:', error.message); + } + }, + }); -SyncedCron.start(); + SyncedCron.start(); +} 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} - +
-