From fe06015f9ce896e965a2243f00fc0775e02ad64f Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:51:54 +0100 Subject: [PATCH 1/5] docs: initialize E2E test strategy in FRONTEND_TEST_CONCEPT.md Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- FRONTEND_TEST_CONCEPT.md | 261 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 FRONTEND_TEST_CONCEPT.md diff --git a/FRONTEND_TEST_CONCEPT.md b/FRONTEND_TEST_CONCEPT.md new file mode 100644 index 0000000..687c134 --- /dev/null +++ b/FRONTEND_TEST_CONCEPT.md @@ -0,0 +1,261 @@ +# Testing Concept: Omnect-UI (Strategy: Core-First) + +## Strategy + +Leverage the Crux architecture's testability by design. The Core contains all business logic as pure, deterministic functions - making it the highest-ROI test target. The Shell is intentionally thin (renders ViewModel, executes effects) and needs minimal testing. + +**Approach:** Test the Core exhaustively (cheap, fast, deterministic), keep E2E minimal for regression safety. + +**Phase 1 Status:** ✅ **Complete** (92 tests across 4 PRs) +- PR #77: Authentication Tests (17 tests) +- PR #78: Device Tests (27 tests) +- PR #79: Network Tests (21 tests) +- PR #80: Reconnection Tests (27 tests) + +## Implementation Plan + +### Phase 1: Core State Transitions (Unit Tests) + +*Goal: Secure business logic and state machines with fast, deterministic tests.* + +#### PR 1.1: Authentication Tests ✅ +- [x] Test login flow (loading state, success, failure) +- [x] Test logout and session cleanup +- [x] Test token management state +- [x] Test password change flow + +#### PR 1.2: Device Tests ✅ +- [x] Test system info updates (WebSocket events) +- [x] Test online status transitions +- [x] Test factory reset state machine +- [x] Test reboot flows +- [x] Test firmware upload state transitions + +#### PR 1.3: Network Tests ✅ +- [x] Test network configuration updates +- [x] Test IP change detection and rollback state +- [x] Test DHCP/static switching logic +- [x] Test network form state management + +#### PR 1.4: Update/Reconnection Tests ✅ +- [x] Test reconnection state machine for all operations (reboot, factory reset, update) +- [x] Test reconnection timeout handling with operation-specific durations +- [x] Test update completion detection based on validation status +- [x] Test healthcheck response handling during reconnection +- [x] Test network IP reachability detection + +**Note:** Original PR 1.5 (WebSocket Tests) was merged into PR 1.2 as WebSocket event handling tests are naturally colocated with device state updates. + +### Phase 2: Core Effect Emissions + +**Status:** 🚫 **Skipped - Not Recommended** + +After implementing Phase 1, we've determined that effect emission testing provides minimal value: + +**Why Skip Effect Testing:** + +1. **Implementation Detail Testing**: Effects are how the Core communicates with the Shell, not what it does. Testing effect structure couples tests to implementation details. + +2. **Macros Handle Correctness**: The codebase uses well-tested macros (`auth_post!`, `http_get!`, `http_get_silent!`) that generate effects consistently. These macros are the single source of truth for effect creation. + +3. **Auto-Generated Types**: The `Effect` enum is auto-generated via `#[derive(crux_core::macros::Effect)]`. Testing against generated types is brittle and adds maintenance burden. + +4. **Response Testing is Sufficient**: Phase 1 already tests response handling (e.g., `LoginResponse`, `SetNetworkConfigResponse`), which validates the complete request/response cycle behavior from the user's perspective. + +5. **Integration Coverage**: E2E tests (Phase 3) will validate actual HTTP requests reach the backend correctly. + +**What We Test Instead:** +- ✅ State transitions (Phase 1) - validates business logic +- ✅ Response handling (Phase 1) - validates correct reactions to success/error +- ✅ Critical paths (Phase 3) - validates actual network communication + +**Original Phase 2 Tasks** (archived for reference): +- ~~Test login emits correct POST request~~ +- ~~Test authenticated requests include bearer token~~ +- ~~Test network config changes emit correct payloads~~ +- ~~Test Centrifugo connection/subscription effects~~ + +### Phase 3: E2E Regression Tests (Selective) + +*Goal: Guard critical user journeys against regression. Keep minimal. Run in a standardized Docker environment.* + +**Environment:** `omnectshareddevacr.azurecr.io/rust:bookworm` (includes Rust, Node, Playwright, Centrifugo). + +#### PR 3.1: E2E Infrastructure & Docker Integration +- [ ] **Dependencies:** Add `@playwright/test` to `src/ui/package.json` +- [ ] **Config:** Add `src/ui/playwright.config.ts` (Base URL, artifacts, projects) +- [ ] **Script 1 (`scripts/run-e2e-tests.sh`):** Internal script to run tests inside the container +- [ ] **Script 2 (`scripts/test-e2e-in-container.sh`):** Host script to: + - Launch the Docker container + - Build frontend (`scripts/build-frontend.sh`) if needed + - Execute `scripts/run-e2e-tests.sh` +- [ ] **Centrifugo Setup:** Ensure tests interact with the real Centrifugo instance (e.g., via `curl` to publish) rather than mocking + +#### PR 3.2: Critical Path Tests +- [ ] **Auth Flow:** + - Mock HTTP responses for Login/Logout + - Verify Login form → Dashboard transition + - Verify Logout action → Login form +- [ ] **Device Read-Only:** + - Inject System Info via real Centrifugo instance (publish via `curl`) + - Assert values appear on the Dashboard +- [ ] **Network Safety UI:** + - Simulate network change action + - Assert "Rollback Timer" overlay appears + +#### PR 3.3: CI Pipeline Integration +- [ ] Create GitHub Actions workflow +- [ ] Execute `scripts/test-e2e-in-container.sh` in CI + +## Test Patterns + +### State Transition Test +```rust +#[test] +fn test_login_sets_loading() { + let app = AppTester::::default(); + let mut model = Model::default(); + + app.update(Event::Login { password: "test".into() }, &mut model); + + assert!(model.is_loading); + assert!(model.error_message.is_none()); +} +``` + +### Effect Emission Test (Not Recommended - See Phase 2) +```rust +// ❌ NOT RECOMMENDED: Testing implementation details +// Effects are auto-generated and handled by macros +// This test is brittle and provides minimal value + +#[test] +fn test_login_emits_http_request() { + let app = AppTester::::default(); + let mut model = Model::default(); + + let effects = app.update(Event::Login { password: "test".into() }, &mut model); + + // This tests HOW the Core communicates, not WHAT it does + // Better to test state transitions and response handling instead +} +``` + +### Response Handling Test (✅ Recommended Pattern) +```rust +// ✅ RECOMMENDED: Test response handling and state changes +// This validates WHAT the Core does from the user's perspective + +#[test] +fn test_login_success_sets_authenticated() { + let app = AppTester::::default(); + let mut model = Model { + is_loading: true, + ..Default::default() + }; + + let _ = app.update( + Event::Auth(AuthEvent::LoginResponse(Ok(AuthToken { + token: "test_token_123".into(), + }))), + &mut model, + ); + + assert!(model.is_authenticated); + assert!(!model.is_loading); + assert_eq!(model.auth_token, Some("test_token_123".into())); +} + +#[test] +fn test_login_failure_sets_error() { + let app = AppTester::::default(); + let mut model = Model { + is_loading: true, + ..Default::default() + }; + + let _ = app.update( + Event::Auth(AuthEvent::LoginResponse(Err("Invalid password".into()))), + &mut model, + ); + + assert!(!model.is_authenticated); + assert!(!model.is_loading); + assert!(model.error_message.is_some()); +} +``` + +### Colocated Test Pattern (✅ Used in Phase 1) +```rust +// Tests are colocated with the code they test using #[cfg(test)] mod tests +// Example: src/app/src/update/auth.rs + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::{AuthEvent, Event}; + use crate::model::Model; + use crate::types::AuthToken; + use crate::App; + use crux_core::testing::AppTester; + + mod login { + use super::*; + + #[test] + fn success_sets_authenticated_and_stores_token() { + let app = AppTester::::default(); + let mut model = Model { + is_loading: true, + ..Default::default() + }; + + let _ = app.update( + Event::Auth(AuthEvent::LoginResponse(Ok(AuthToken { + token: "test_token_123".into(), + }))), + &mut model, + ); + + assert!(model.is_authenticated); + assert!(!model.is_loading); + assert_eq!(model.auth_token, Some("test_token_123".into())); + } + } +} +``` + +## Tools + +| Scope | Tool | Purpose | +|:------|:-----|:--------| +| **Core Logic** | `cargo test` + `crux_core::testing` | State transitions, effect emissions | +| **E2E** | Playwright | Critical user journey regression | + +## ROI Summary + +| Phase | Speed | Stability | Coverage | Priority | Status | +|:------|:------|:----------|:---------|:---------|:-------| +| Core State Tests | Fast (ms) | Deterministic | High | **High** | ✅ **Complete (92 tests)** | +| ~~Core Effect Tests~~ | ~~Fast (ms)~~ | ~~Deterministic~~ | ~~High~~ | **Skipped** | 🚫 **Not recommended** | +| E2E Tests | Slow (s) | Flaky-prone | Low | Low | ⏳ **Planned (Phase 3)** | + +## Lessons Learned + +### What Worked Well +1. **Colocated Tests**: Keeping tests next to the code they test (`#[cfg(test)] mod tests`) improves maintainability +2. **Domain Organization**: Organizing tests by domain (auth, device, network) mirrors code structure +3. **Response-Focused Testing**: Testing response handling validates behavior without coupling to implementation +4. **State Machine Validation**: Comprehensive state transition testing catches edge cases early + +### What to Avoid +1. **Effect Emission Testing**: Testing auto-generated effect structures is brittle and low-value +2. **Testing Macros**: Well-tested macros (`auth_post!`) don't need per-use validation +3. **Testing Request Events**: Events that trigger HTTP requests don't have immediate state changes to test + +### Key Patterns +- Use `let _ = app.update(...)` to ignore unused `Update` results +- Test response events (e.g., `LoginResponse`) not request events (e.g., `Login`) +- Organize tests in nested modules matching code structure +- Use helper functions to create test data (e.g., `create_healthcheck()`) +- Test state transitions, not implementation details From 1de57101a264920f726f0c5251b948291b0fc710 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:00:12 +0100 Subject: [PATCH 2/5] test: setup E2E infrastructure with Playwright and Docker Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- .gitignore | 4 +- scripts/run-e2e-tests.sh | 102 ++++++++++++++++++++++++++++++ scripts/test-e2e-in-container.sh | 23 +++++++ src/ui/bun.lock | 45 +++++++------ src/ui/package.json | 1 + src/ui/playwright.config.ts | 42 ++++++++++++ src/ui/tests/fixtures/mock-api.ts | 61 ++++++++++++++++++ src/ui/tests/smoke.spec.ts | 29 +++++++++ 8 files changed, 288 insertions(+), 19 deletions(-) create mode 100755 scripts/run-e2e-tests.sh create mode 100755 scripts/test-e2e-in-container.sh create mode 100644 src/ui/playwright.config.ts create mode 100644 src/ui/tests/fixtures/mock-api.ts create mode 100644 src/ui/tests/smoke.spec.ts diff --git a/.gitignore b/.gitignore index 2fd2271..b1ab659 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ src/ui/src/core/pkg/ # Research and documentation files (generated, not part of source) *.md -!README.md \ No newline at end of file +!README.mdsrc/ui/playwright-report/ +src/ui/test-results/ +temp/ diff --git a/scripts/run-e2e-tests.sh b/scripts/run-e2e-tests.sh new file mode 100755 index 0000000..635d771 --- /dev/null +++ b/scripts/run-e2e-tests.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -e + +# Internal script to run E2E tests inside the container + +echo "🔧 Setting up test environment..." + +# 0. Ensure bun is installed (needed for UI) +if ! command -v bun &> /dev/null; then + echo "⚠️ Bun not found, installing..." + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" +fi + +# 1. Ensure Centrifugo is available (using the tool script if needed) +if ! command -v centrifugo &> /dev/null; then + echo "⚠️ Centrifugo not found in PATH, checking tools directory..." + if [ ! -f "tools/centrifugo" ]; then + ./tools/setup-centrifugo.sh + fi + export PATH=$PATH:$(pwd)/tools +fi + +# 2. Start Centrifugo directly (Backend is mocked, but we need real WS) +echo "🚀 Starting Centrifugo..." +# Using the config from backend/config/centrifugo_config.json +CENTRIFUGO_CONFIG="src/backend/config/centrifugo_config.json" + +# Generate self-signed certs for testing if missing +mkdir -p temp/certs +if [ ! -f "temp/certs/server.cert.pem" ]; then + openssl req -newkey rsa:2048 -nodes -keyout temp/certs/server.key.pem -x509 -days 365 -out temp/certs/server.cert.pem -subj "/CN=localhost" 2>/dev/null +fi + +# Env vars for Centrifugo +export CENTRIFUGO_HTTP_SERVER_TLS_CERT_PEM="temp/certs/server.cert.pem" +export CENTRIFUGO_HTTP_SERVER_TLS_KEY_PEM="temp/certs/server.key.pem" +export CENTRIFUGO_HTTP_SERVER_PORT="8000" +export CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY="secret" +export CENTRIFUGO_HTTP_API_KEY="api_key" +export CENTRIFUGO_LOG_LEVEL="info" + +centrifugo -c "$CENTRIFUGO_CONFIG" > /tmp/centrifugo.log 2>&1 & +CENTRIFUGO_PID=$! + +echo "⏳ Waiting for Centrifugo..." +for i in {1..30}; do + if curl -k -s https://localhost:8000/health > /dev/null; then + echo "✅ Centrifugo is ready!" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Centrifugo failed to start." + cat /tmp/centrifugo.log + kill $CENTRIFUGO_PID || true + exit 1 + fi + sleep 1 +done + +# 3. Serve the Frontend +echo "🌐 Starting Frontend Dev Server..." +cd src/ui +# Install dependencies if needed (container might not have node_modules) +if [ ! -d "node_modules" ]; then + echo "📦 Installing UI dependencies..." + bun install +fi + +# Start vite dev server in background +bun run dev --port 5173 > /tmp/vite.log 2>&1 & +FRONTEND_PID=$! + +# Wait for Frontend +echo "⏳ Waiting for Frontend..." +for i in {1..30}; do + if curl -s http://localhost:5173 > /dev/null; then + echo "✅ Frontend is ready!" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Frontend failed to start." + cat /tmp/vite.log + kill $FRONTEND_PID || true + kill $CENTRIFUGO_PID || true + exit 1 + fi + sleep 1 +done + +# 4. Run Playwright Tests +echo "🧪 Running Playwright Tests..." +# BASE_URL is set for playwright.config.ts +export BASE_URL="http://localhost:5173" + +# Run tests +npx playwright test + +TEST_EXIT_CODE=$? + +exit $TEST_EXIT_CODE diff --git a/scripts/test-e2e-in-container.sh b/scripts/test-e2e-in-container.sh new file mode 100755 index 0000000..c6edfb0 --- /dev/null +++ b/scripts/test-e2e-in-container.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +# Host script to run E2E tests inside the docker container + +IMAGE="omnectshareddevacr.azurecr.io/rust:bookworm" + +echo "🐳 Launching test container..." + +# Check if we need to build the frontend first +if [ ! -d "src/ui/dist" ]; then + echo "📦 Building frontend..." + ./scripts/build-frontend.sh +fi + +# Run the test script inside the container +# We mount the current directory to /workspace +docker run --rm \ + -v $(pwd):/workspace \ + -w /workspace \ + --net=host \ + $IMAGE \ + /bin/bash -c "./scripts/run-e2e-tests.sh" diff --git a/src/ui/bun.lock b/src/ui/bun.lock index 57ddd81..3d8c737 100644 --- a/src/ui/bun.lock +++ b/src/ui/bun.lock @@ -4,26 +4,27 @@ "": { "name": "vue", "dependencies": { - "@mdi/font": "latest", - "@vueuse/core": "latest", - "axios": "latest", - "centrifuge": "latest", - "oidc-client-ts": "latest", - "vue": "latest", - "vue-imask": "latest", - "vuetify": "latest", + "@mdi/font": "^7.4.47", + "@vueuse/core": "^13.9.0", + "axios": "^1.12.2", + "centrifuge": "^5.4.0", + "oidc-client-ts": "^3.3.0", + "vue": "^3.5.22", + "vue-imask": "^7.6.1", + "vuetify": "^3.10.3", }, "devDependencies": { - "@biomejs/biome": "latest", - "@types/bun": "latest", - "@vitejs/plugin-vue": "latest", - "@vue/tsconfig": "latest", - "typescript": "latest", - "unocss": "latest", - "vite": "latest", - "vite-plugin-vuetify": "latest", - "vue-router": "latest", - "vue-tsc": "latest", + "@biomejs/biome": "2.2.4", + "@playwright/test": "^1.57.0", + "@types/bun": "^1.2.23", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.2", + "unocss": "^66.5.2", + "vite": "^7.1.7", + "vite-plugin-vuetify": "^2.1.2", + "vue-router": "^4.5.1", + "vue-tsc": "^3.1.0", }, }, }, @@ -134,6 +135,8 @@ "@mdi/font": ["@mdi/font@7.4.47", "", {}, "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw=="], + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@polka/url": ["@polka/url@1.0.0-next.28", "", {}, "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -462,6 +465,10 @@ "pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "protobufjs": ["protobufjs@7.4.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw=="], @@ -550,6 +557,8 @@ "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], diff --git a/src/ui/package.json b/src/ui/package.json index 29e5246..0411159 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@biomejs/biome": "2.2.4", + "@playwright/test": "^1.57.0", "@types/bun": "^1.2.23", "@vitejs/plugin-vue": "^6.0.1", "@vue/tsconfig": "^0.8.1", diff --git a/src/ui/playwright.config.ts b/src/ui/playwright.config.ts new file mode 100644 index 0000000..fa5d44a --- /dev/null +++ b/src/ui/playwright.config.ts @@ -0,0 +1,42 @@ + +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.BASE_URL || 'http://localhost:5173', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run dev', + // url: 'http://127.0.0.1:5173', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/src/ui/tests/fixtures/mock-api.ts b/src/ui/tests/fixtures/mock-api.ts new file mode 100644 index 0000000..618ced4 --- /dev/null +++ b/src/ui/tests/fixtures/mock-api.ts @@ -0,0 +1,61 @@ +import { Page } from '@playwright/test'; + +export async function mockConfig(page: Page) { + const config = { + KEYCLOAK_URL: 'http://localhost:8080', + REALM: 'omnect', + CLIENT_ID: 'omnect-ui', + CENTRIFUGO_URL: 'wss://localhost:8000/connection/websocket' + }; + + // Add as init script so it's available even before config.js loads + await page.addInitScript((cfg) => { + (window as any).__APP_CONFIG__ = cfg; + }, config); + + // Still mock the file request to avoid 404s + await page.route('**/config.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: `window.__APP_CONFIG__ = ${JSON.stringify(config)};`, + }); + }); +} + +export async function mockLoginSuccess(page: Page) { + await page.route('**/token/login', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/plain', + body: 'mock_token_123', + }); + }); +} + +export async function mockRequireSetPassword(page: Page) { + await page.route('**/require-set-password', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: 'false', + }); + }); +} + +export async function mockNetworkConfig(page: Page) { + await page.route('**/api/v1/network/config', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + interfaces: [ + { + name: 'eth0', + dhcp: true, + }, + ], + }), + }); + }); +} diff --git a/src/ui/tests/smoke.spec.ts b/src/ui/tests/smoke.spec.ts new file mode 100644 index 0000000..4f640c4 --- /dev/null +++ b/src/ui/tests/smoke.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +import { mockLoginSuccess, mockRequireSetPassword, mockConfig } from './fixtures/mock-api'; + +test('has title', async ({ page }) => { + await mockConfig(page); + await page.goto('/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/omnect/i); +}); + +test('login flow', async ({ page }) => { + // Listen for console logs + page.on('console', msg => console.log(`BROWSER LOG: ${msg.text()}`)); + page.on('pageerror', err => console.log(`BROWSER ERROR: ${err}`)); + + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + + await page.goto('/'); + + // Wait for the form to appear (it's hidden while checking password set requirement) + // Using placeholder as Vuetify labels can be tricky with getByLabel + await expect(page.getByPlaceholder(/enter your password/i)).toBeVisible({ timeout: 10000 }); + + // Check for the login button + await expect(page.getByRole('button', { name: /log in/i })).toBeVisible(); +}); From 2148d5b84680d02ba0ff9532f0805f7e554675bb Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:04:29 +0100 Subject: [PATCH 3/5] docs: update test concept with completed Phase 3 tasks Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- FRONTEND_TEST_CONCEPT.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/FRONTEND_TEST_CONCEPT.md b/FRONTEND_TEST_CONCEPT.md index 687c134..ead0b15 100644 --- a/FRONTEND_TEST_CONCEPT.md +++ b/FRONTEND_TEST_CONCEPT.md @@ -81,25 +81,25 @@ After implementing Phase 1, we've determined that effect emission testing provid **Environment:** `omnectshareddevacr.azurecr.io/rust:bookworm` (includes Rust, Node, Playwright, Centrifugo). -#### PR 3.1: E2E Infrastructure & Docker Integration -- [ ] **Dependencies:** Add `@playwright/test` to `src/ui/package.json` -- [ ] **Config:** Add `src/ui/playwright.config.ts` (Base URL, artifacts, projects) -- [ ] **Script 1 (`scripts/run-e2e-tests.sh`):** Internal script to run tests inside the container -- [ ] **Script 2 (`scripts/test-e2e-in-container.sh`):** Host script to: +#### PR 3.1: E2E Infrastructure & Docker Integration ✅ +- [x] **Dependencies:** Add `@playwright/test` to `src/ui/package.json` +- [x] **Config:** Add `src/ui/playwright.config.ts` (Base URL, artifacts, projects) +- [x] **Script 1 (`scripts/run-e2e-tests.sh`):** Internal script to run tests inside the container +- [x] **Script 2 (`scripts/test-e2e-in-container.sh`):** Host script to: - Launch the Docker container - Build frontend (`scripts/build-frontend.sh`) if needed - Execute `scripts/run-e2e-tests.sh` -- [ ] **Centrifugo Setup:** Ensure tests interact with the real Centrifugo instance (e.g., via `curl` to publish) rather than mocking +- [x] **Centrifugo Setup:** Ensure tests interact with the real Centrifugo instance (e.g., via `curl` to publish) rather than mocking -#### PR 3.2: Critical Path Tests -- [ ] **Auth Flow:** +#### PR 3.2: Critical Path Tests ✅ +- [x] **Auth Flow:** - Mock HTTP responses for Login/Logout - Verify Login form → Dashboard transition - Verify Logout action → Login form -- [ ] **Device Read-Only:** +- [x] **Device Read-Only:** - Inject System Info via real Centrifugo instance (publish via `curl`) - Assert values appear on the Dashboard -- [ ] **Network Safety UI:** +- [x] **Network Safety UI:** - Simulate network change action - Assert "Rollback Timer" overlay appears From 98956a3d01684f425be73b6303d36efca189ba5b Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Thu, 18 Dec 2025 20:08:48 +0100 Subject: [PATCH 4/5] test: implement critical path E2E tests and infrastructure fixes - Implemented auth.spec.ts, device.spec.ts, network.spec.ts - Added centrifugo fixture helper - Fixed Centrifugo subscription in App.vue - Improved test scripts for argument forwarding - Configured playwright to ignore HTTPS errors - Added jsonwebtoken for test token generation Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- .gitignore | 7 ++- scripts/run-e2e-tests.sh | 2 +- scripts/test-e2e-in-container.sh | 2 +- src/ui/bun.lock | 34 ++++++++++++ src/ui/package.json | 2 + src/ui/playwright.config.ts | 3 ++ src/ui/src/App.vue | 14 +++-- src/ui/tests/auth.spec.ts | 43 +++++++++++++++ src/ui/tests/device.spec.ts | 45 ++++++++++++++++ src/ui/tests/fixtures/centrifugo.ts | 26 +++++++++ src/ui/tests/fixtures/mock-api.ts | 35 +++++++----- src/ui/tests/network.spec.ts | 82 +++++++++++++++++++++++++++++ 12 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 src/ui/tests/auth.spec.ts create mode 100644 src/ui/tests/device.spec.ts create mode 100644 src/ui/tests/fixtures/centrifugo.ts create mode 100644 src/ui/tests/network.spec.ts diff --git a/.gitignore b/.gitignore index b1ab659..7f274e2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ src/ui/src/core/pkg/ # Research and documentation files (generated, not part of source) *.md -!README.mdsrc/ui/playwright-report/ +!README.md + +# E2E Test Artifacts +src/ui/playwright-report/ src/ui/test-results/ -temp/ +temp/ \ No newline at end of file diff --git a/scripts/run-e2e-tests.sh b/scripts/run-e2e-tests.sh index 635d771..46719ee 100755 --- a/scripts/run-e2e-tests.sh +++ b/scripts/run-e2e-tests.sh @@ -95,7 +95,7 @@ echo "🧪 Running Playwright Tests..." export BASE_URL="http://localhost:5173" # Run tests -npx playwright test +npx playwright test "$@" TEST_EXIT_CODE=$? diff --git a/scripts/test-e2e-in-container.sh b/scripts/test-e2e-in-container.sh index c6edfb0..8d4a84b 100755 --- a/scripts/test-e2e-in-container.sh +++ b/scripts/test-e2e-in-container.sh @@ -20,4 +20,4 @@ docker run --rm \ -w /workspace \ --net=host \ $IMAGE \ - /bin/bash -c "./scripts/run-e2e-tests.sh" + ./scripts/run-e2e-tests.sh "$@" diff --git a/src/ui/bun.lock b/src/ui/bun.lock index 3d8c737..4e8c6da 100644 --- a/src/ui/bun.lock +++ b/src/ui/bun.lock @@ -17,8 +17,10 @@ "@biomejs/biome": "2.2.4", "@playwright/test": "^1.57.0", "@types/bun": "^1.2.23", + "@types/jsonwebtoken": "^9.0.10", "@vitejs/plugin-vue": "^6.0.1", "@vue/tsconfig": "^0.8.1", + "jsonwebtoken": "^9.0.3", "typescript": "~5.9.2", "unocss": "^66.5.2", "vite": "^7.1.7", @@ -207,6 +209,10 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], @@ -315,6 +321,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -351,6 +359,8 @@ "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -415,12 +425,32 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="], "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "long": ["long@5.3.1", "", {}, "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], @@ -481,6 +511,10 @@ "rollup": ["rollup@4.44.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.44.1", "@rollup/rollup-android-arm64": "4.44.1", "@rollup/rollup-darwin-arm64": "4.44.1", "@rollup/rollup-darwin-x64": "4.44.1", "@rollup/rollup-freebsd-arm64": "4.44.1", "@rollup/rollup-freebsd-x64": "4.44.1", "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", "@rollup/rollup-linux-arm-musleabihf": "4.44.1", "@rollup/rollup-linux-arm64-gnu": "4.44.1", "@rollup/rollup-linux-arm64-musl": "4.44.1", "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-musl": "4.44.1", "@rollup/rollup-linux-s390x-gnu": "4.44.1", "@rollup/rollup-linux-x64-gnu": "4.44.1", "@rollup/rollup-linux-x64-musl": "4.44.1", "@rollup/rollup-win32-arm64-msvc": "4.44.1", "@rollup/rollup-win32-ia32-msvc": "4.44.1", "@rollup/rollup-win32-x64-msvc": "4.44.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/src/ui/package.json b/src/ui/package.json index 0411159..033e4ad 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -28,8 +28,10 @@ "@biomejs/biome": "2.2.4", "@playwright/test": "^1.57.0", "@types/bun": "^1.2.23", + "@types/jsonwebtoken": "^9.0.10", "@vitejs/plugin-vue": "^6.0.1", "@vue/tsconfig": "^0.8.1", + "jsonwebtoken": "^9.0.3", "typescript": "~5.9.2", "unocss": "^66.5.2", "vite": "^7.1.7", diff --git a/src/ui/playwright.config.ts b/src/ui/playwright.config.ts index fa5d44a..d7908d5 100644 --- a/src/ui/playwright.config.ts +++ b/src/ui/playwright.config.ts @@ -23,6 +23,9 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', + + /* Ignore HTTPS errors for self-signed certs */ + ignoreHTTPSErrors: true, }, /* Configure projects for major browsers */ diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index c2aad40..72f9289 100644 --- a/src/ui/src/App.vue +++ b/src/ui/src/App.vue @@ -15,7 +15,7 @@ import type { HealthcheckResponse } from "./types" axios.defaults.validateStatus = (_) => true const { snackbarState } = useSnackbar() -const { viewModel, ackRollback } = useCore() +const { viewModel, ackRollback, subscribeToChannels, unsubscribeFromChannels } = useCore() const { lgAndUp } = useDisplay() const router = useRouter() @@ -55,10 +55,16 @@ const acknowledgeRollback = () => { watch( () => viewModel.is_authenticated, async (isAuthenticated) => { - if (!isAuthenticated && route.meta.requiresAuth) { - await router.push("/login") + if (isAuthenticated) { + subscribeToChannels() + } else { + unsubscribeFromChannels() + if (route.meta.requiresAuth) { + await router.push("/login") + } } - } + }, + { immediate: true } ) onMounted(async () => { diff --git a/src/ui/tests/auth.spec.ts b/src/ui/tests/auth.spec.ts new file mode 100644 index 0000000..57a94c3 --- /dev/null +++ b/src/ui/tests/auth.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; +import { mockConfig, mockLoginSuccess, mockRequireSetPassword } from './fixtures/mock-api'; + +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + // Listen for console logs + page.on('console', msg => console.log(`BROWSER LOG: ${msg.text()}`)); + page.on('pageerror', err => console.log(`BROWSER ERROR: ${err}`)); + + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + + // Mock logout endpoint + await page.route('**/logout', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + }); + + test('can logout successfully', async ({ page }) => { + await page.goto('/'); + + // Login + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + + // Wait for dashboard + await expect(page.getByText('Common Info')).toBeVisible(); + + // Open user menu + await page.locator('[data-cy="user-menu"]').click(); + + // Click logout button + await page.getByRole('button', { name: /logout/i }).click(); + + // Assert redirect to login page + await expect(page.getByPlaceholder(/enter your password/i)).toBeVisible(); + }); +}); diff --git a/src/ui/tests/device.spec.ts b/src/ui/tests/device.spec.ts new file mode 100644 index 0000000..21502f2 --- /dev/null +++ b/src/ui/tests/device.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { mockConfig, mockLoginSuccess, mockRequireSetPassword } from './fixtures/mock-api'; +import { publishToCentrifugo } from './fixtures/centrifugo'; + +test.describe('Device Info', () => { + test.beforeEach(async ({ page }) => { + // Listen for console logs + page.on('console', msg => console.log(`BROWSER LOG: ${msg.text()}`)); + page.on('pageerror', err => console.log(`BROWSER ERROR: ${err}`)); + + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + await page.goto('/'); + + // Perform login + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + + // Wait for dashboard or successful login state + // We can wait for the side menu or a specific dashboard element + await expect(page.getByText('Common Info')).toBeVisible(); + }); + + test('displays system info from Centrifugo', async ({ page }) => { + const systemInfo = { + os: { + name: 'Omnect OS', + version: '1.2.3', + }, + azure_sdk_version: '0.1.0', + omnect_device_service_version: '4.5.6', + boot_time: new Date().toISOString(), + }; + + // Publish to Centrifugo + await publishToCentrifugo('SystemInfoV1', systemInfo); + + // Assert values appear on dashboard + // Adjust selectors based on actual UI + await expect(page.getByText('Omnect OS')).toBeVisible(); + await expect(page.getByText('1.2.3')).toBeVisible(); + await expect(page.getByText('4.5.6')).toBeVisible(); + }); +}); diff --git a/src/ui/tests/fixtures/centrifugo.ts b/src/ui/tests/fixtures/centrifugo.ts new file mode 100644 index 0000000..3e387e4 --- /dev/null +++ b/src/ui/tests/fixtures/centrifugo.ts @@ -0,0 +1,26 @@ +import { APIRequestContext, request } from '@playwright/test'; + +export async function publishToCentrifugo(channel: string, data: any) { + const context = await request.newContext({ + ignoreHTTPSErrors: true, + }); + const response = await context.post('https://localhost:8000/api', { + headers: { + 'Authorization': 'apikey api_key', + 'Content-Type': 'application/json', + }, + data: { + method: 'publish', + params: { + channel, + data, + }, + }, + }); + + if (!response.ok()) { + console.error(`Failed to publish to Centrifugo: ${response.status()} ${response.statusText()}`); + console.error(await response.text()); + throw new Error('Centrifugo publish failed'); + } +} diff --git a/src/ui/tests/fixtures/mock-api.ts b/src/ui/tests/fixtures/mock-api.ts index 618ced4..f5251ff 100644 --- a/src/ui/tests/fixtures/mock-api.ts +++ b/src/ui/tests/fixtures/mock-api.ts @@ -1,4 +1,5 @@ import { Page } from '@playwright/test'; +import jwt from 'jsonwebtoken'; export async function mockConfig(page: Page) { const config = { @@ -24,11 +25,12 @@ export async function mockConfig(page: Page) { } export async function mockLoginSuccess(page: Page) { + const token = jwt.sign({ sub: 'user123' }, 'secret', { expiresIn: '1h' }); await page.route('**/token/login', async (route) => { await route.fulfill({ status: 200, contentType: 'text/plain', - body: 'mock_token_123', + body: token, }); }); } @@ -44,18 +46,23 @@ export async function mockRequireSetPassword(page: Page) { } export async function mockNetworkConfig(page: Page) { - await page.route('**/api/v1/network/config', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - interfaces: [ - { - name: 'eth0', - dhcp: true, - }, - ], - }), - }); + // Mock the network configuration endpoint + // Note: The Core sends POST to /network, not api/v1/... + await page.route('**/network', async (route) => { + if (route.request().method() === 'POST') { + // Mock successful application of network config + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + rollbackTimeoutSeconds: 90, + uiPort: 5173, + rollbackEnabled: true + }), + }); + } else { + // Fallback for other methods if any + await route.continue(); + } }); } diff --git a/src/ui/tests/network.spec.ts b/src/ui/tests/network.spec.ts new file mode 100644 index 0000000..db65168 --- /dev/null +++ b/src/ui/tests/network.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; +import { mockConfig, mockLoginSuccess, mockRequireSetPassword, mockNetworkConfig } from './fixtures/mock-api'; +import { publishToCentrifugo } from './fixtures/centrifugo'; + +test.describe('Network Settings', () => { + test.beforeEach(async ({ page }) => { + // Listen for console logs + page.on('console', msg => console.log(`BROWSER LOG: ${msg.text()}`)); + page.on('pageerror', err => console.log(`BROWSER ERROR: ${err}`)); + + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + await mockNetworkConfig(page); + + await page.goto('/'); + + // Login + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible(); + + // Publish initial network status + await publishToCentrifugo('NetworkStatusV1', { + network_status: [ + { + name: 'eth0', + mac: '00:11:22:33:44:55', + online: true, + ipv4: { + addrs: [{ addr: 'localhost', dhcp: true, prefix_len: 24 }], + dns: ['8.8.8.8'], + gateways: ['192.168.1.1'], + }, + }, + ], + }); + }); + + test('shows rollback timer on configuration change', async ({ page }) => { + // Navigate to Network page + await page.getByText('Network').click(); + + // Wait for network list + await expect(page.getByText('eth0')).toBeVisible(); + + // Open the interface details + await page.getByText('eth0').click(); + + // Switch to Static IP + // It's a radio group, so we need to click the "Static" option + await page.getByLabel('Static').click({ force: true }); + + // Wait for IP Address field to be enabled/visible + // The name might be "IP Address IP Address" due to Vuetify structure, so use regex + const ipInput = page.getByRole('textbox', { name: /IP Address/i }).first(); + await expect(ipInput).toBeVisible(); + await expect(ipInput).toBeEditable(); + + // Fill in static IP details + await ipInput.fill('192.168.1.101'); + // Netmask is a dropdown, default is usually fine (24). Skipping interaction for simplicity. + // await page.getByRole('button', { name: /\/24/ }).click(); // Example if we needed to change it + + await page.getByRole('textbox', { name: /Gateway/i }).first().fill('192.168.1.1'); + + // Click Save (not Apply) + await page.getByRole('button', { name: /save/i }).click(); + + // Confirm dialog (title: Confirm Network Configuration Change) + // Button: Apply Changes + await expect(page.getByText('Confirm Network Configuration Change')).toBeVisible(); + await page.getByRole('button', { name: /apply changes/i }).click(); + + // Wait for modal to close + await expect(page.getByText('Confirm Network Configuration Change')).not.toBeVisible(); + + // Assert Rollback Overlay appears + // The text typically includes "Automatic rollback" + await expect(page.locator('#overlay').getByText(/Automatic rollback/i)).toBeVisible({ timeout: 10000 }); + }); +}); From 51bdf63190bcc6563dd55986c6e6dc9fab72b93a Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:08:09 +0100 Subject: [PATCH 5/5] test: add authorization service unit tests Add comprehensive unit tests for AuthorizationService covering: - FleetAdministrator role with valid/invalid tenants - FleetOperator role with matching/non-matching fleets - FleetObserver role rejection - Missing claims (roles, tenant_list, fleet_list) handling - Invalid SSO token verification Tests use mockall to mock SingleSignOnProvider and DeviceServiceClient. 12 tests added, bringing total backend tests from 28 to 40. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- BACKEND_TEST_CONCEPT.md | 427 ++++++++++++++++++ .../src/services/auth/authorization.rs | 388 ++++++++++++++++ 2 files changed, 815 insertions(+) create mode 100644 BACKEND_TEST_CONCEPT.md diff --git a/BACKEND_TEST_CONCEPT.md b/BACKEND_TEST_CONCEPT.md new file mode 100644 index 0000000..fbb917f --- /dev/null +++ b/BACKEND_TEST_CONCEPT.md @@ -0,0 +1,427 @@ +# Backend Test Concept: Omnect-UI + +## Current State Assessment + +### Existing Test Coverage + +The backend currently has **40 unit/integration tests** organized as follows: + +| Module | Test Count | Coverage | +|:-------|:-----------|:---------| +| `middleware` | 14 | Token validation, session auth, bearer auth, basic auth | +| `services::auth::authorization` | 12 | Role-based access control, tenant/fleet validation | +| `services::auth::token` | 3 | Token creation/verification | +| `services::auth::password` | 2 | Password hashing/storage | +| `services::firmware` | 1 | Data folder cleanup | +| `http_client` | 2 | Unix socket client validation | +| Integration (`tests/`) | 6 | HTTP client + portal token validation | + +**Total: 40 tests** (32 unit tests, 5 integration tests, 3 http_client integration tests) + +### Test Infrastructure + +| Component | Status | Notes | +|:----------|:-------|:------| +| Mock Feature Flag | ✅ Configured | `features = ["mock"]` enables `mockall` | +| `mockall` Integration | ✅ Used | Traits annotated with `#[cfg_attr(feature = "mock", automock)]` | +| Actix Test Utilities | ✅ Used | `actix_web::test` for HTTP handler testing | +| Test Config | ✅ Implemented | `AppConfig` uses temp directories in test mode | +| Password File Locking | ✅ Implemented | `PasswordService::lock_for_test()` prevents race conditions | + +### Covered Areas + +**Well-Tested:** +- Authentication middleware (session, bearer, basic) +- JWT token creation/verification +- Password hashing with Argon2 +- Portal token validation with role/fleet authorization +- Unix socket HTTP client + +**Not Tested:** +- API handlers (`api.rs`) +- Network configuration service (`network.rs`) +- Certificate service (production path in `certificate.rs`) +- Device service client operations +- Keycloak provider (production path) +- Error handling paths in HTTP client +- Configuration loading edge cases + +## Test Strategy + +### Principles + +1. **Service Layer Focus**: Test business logic in service modules independently of HTTP +2. **Mock External Dependencies**: Use `mockall` for device service, SSO provider, file system +3. **Integration for Critical Paths**: Test complete request/response cycles for key flows +4. **Avoid Flaky Tests**: No real network/socket tests; mock all I/O + +### Test Pyramid + +``` + /\ + / \ + / E2E \ ← Covered by frontend E2E tests + /------\ + / API \ ← Integration tests (handlers + middleware) + /----------\ + / Services \ ← Unit tests (business logic) + /--------------\ + / Utilities \ ← Unit tests (helpers, clients) + /------------------\ +``` + +## Implementation Plan + +### Phase 1: Service Layer Unit Tests + +*Goal: Test business logic in isolation without HTTP concerns.* + +#### PR 1.1: Authorization Service Tests ✅ +- [x] Test `validate_token_and_claims` with valid FleetAdministrator +- [x] Test `validate_token_and_claims` with valid FleetOperator + matching fleet +- [x] Test rejection of FleetOperator with non-matching fleet +- [x] Test rejection of invalid tenant +- [x] Test rejection of missing roles +- [x] Test rejection of FleetObserver role +- [x] Test FleetAdministrator with multiple tenants +- [x] Test FleetOperator with multiple fleets +- [x] Test FleetOperator without fleet_list in claims +- [x] Test missing tenant_list in claims +- [x] Test missing roles in claims +- [x] Test invalid SSO token verification + +**12 tests added** in [authorization.rs:79-457](src/backend/src/services/auth/authorization.rs#L79-L457) + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fleet_admin_with_valid_tenant_succeeds() { + let mut sso_mock = MockSingleSignOnProvider::new(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(TokenClaims { + roles: Some(vec!["FleetAdministrator".into()]), + tenant_list: Some(vec!["cp".into()]), + fleet_list: None, + }) + }) + }); + + let mut device_mock = MockDeviceServiceClient::new(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "token" + ).await; + + assert!(result.is_ok()); + } +} +``` + +#### PR 1.2: Network Configuration Service Tests +- [ ] Test `set_network_config` with valid DHCP config +- [ ] Test `set_network_config` with valid static IP config +- [ ] Test validation failure for invalid netmask +- [ ] Test validation failure for empty interface name +- [ ] Test rollback file creation/cancellation +- [ ] Test `rollback_exists()` and `cancel_rollback()` +- [ ] Test network config file generation (INI format) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn write_network_config_creates_valid_ini_for_dhcp() { + // Test INI file generation for DHCP mode + } + + #[test] + fn write_network_config_creates_valid_ini_for_static() { + // Test INI file generation for static IP with gateway/DNS + } + + #[tokio::test] + async fn set_network_config_validates_netmask_range() { + // Test validation rejects netmask > 32 + } +} +``` + +#### PR 1.3: Firmware Service Tests +- [ ] Test `clear_data_folder` with existing files +- [ ] Test `clear_data_folder` with empty directory +- [ ] Test `handle_uploaded_firmware` with mock temp file +- [ ] Test file permissions are set correctly (0o750) + +#### PR 1.4: Device Service Client Tests +- [ ] Test URL building (`build_url`) +- [ ] Test version requirement parsing +- [ ] Test version mismatch detection in healthcheck +- [ ] Test `healthcheck_info` response construction + +### Phase 2: API Handler Integration Tests + +*Goal: Test complete HTTP request/response cycles with mocked dependencies.* + +#### PR 2.1: Authentication Endpoints +- [ ] Test `POST /set_password` creates password on first call +- [ ] Test `POST /set_password` redirects if password exists +- [ ] Test `POST /update_password` with correct current password +- [ ] Test `POST /update_password` rejects incorrect current password +- [ ] Test `POST /logout` clears session +- [ ] Test `GET /require_set_password` returns correct boolean + +```rust +#[tokio::test] +async fn set_password_creates_password_and_returns_token() { + let _lock = PasswordService::lock_for_test(); + // Ensure no password file exists + + let app = test::init_service( + App::new() + .app_data(web::Data::new(token_manager)) + .wrap(session_middleware) + .route("/set_password", web::post().to(Api::set_password)) + ).await; + + let req = test::TestRequest::post() + .uri("/set_password") + .set_json(&SetPasswordPayload { password: "test123".into() }) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::OK); + assert!(PasswordService::password_exists()); +} +``` + +#### PR 2.2: Device Operation Endpoints +- [ ] Test `GET /healthcheck` with healthy device service +- [ ] Test `GET /healthcheck` with version mismatch +- [ ] Test `GET /healthcheck` when device service unavailable +- [ ] Test `POST /reboot` (mock device service) +- [ ] Test `POST /factory_reset` with valid payload +- [ ] Test `POST /factory_reset` clears session + +#### PR 2.3: Network Endpoints +- [ ] Test `POST /network` with valid config +- [ ] Test `POST /network` returns rollback info for IP changes +- [ ] Test `POST /ack_rollback` clears rollback marker +- [ ] Test network config validation errors return 400 + +#### PR 2.4: Firmware Endpoints +- [ ] Test `POST /upload_firmware_file` with multipart form +- [ ] Test `GET /load_update` (mock device service response) +- [ ] Test `POST /run_update` with validation flag + +### Phase 3: Error Handling & Edge Cases + +*Goal: Ensure robust error handling and edge case coverage.* + +#### PR 3.1: Error Path Testing +- [ ] Test HTTP client error handling (`handle_http_response`) +- [ ] Test service error propagation to HTTP 500 +- [ ] Test session insert failure handling +- [ ] Test token creation failure handling + +#### PR 3.2: Configuration Edge Cases +- [ ] Test config loading with missing optional env vars +- [ ] Test config loading with invalid port values +- [ ] Test path validation (e.g., missing /data directory) + +### Phase 4: Documentation & Maintenance + +#### PR 4.1: Test Documentation +- [ ] Add doc comments to test helper functions +- [ ] Document test fixtures and setup requirements +- [ ] Add examples to public API documentation + +## Test Patterns + +### Mocking Device Service Client + +```rust +use mockall_double::double; + +#[double] +use crate::omnect_device_service_client::DeviceServiceClient; + +fn create_mock_device_service() -> MockDeviceServiceClient { + let mut mock = MockDeviceServiceClient::new(); + mock.expect_fleet_id() + .returning(|| Box::pin(async { Ok("test-fleet".into()) })); + mock.expect_status() + .returning(|| Box::pin(async { Ok(test_status()) })); + mock +} +``` + +### Testing with Actix-Web + +```rust +use actix_web::{test, web, App}; +use actix_http::StatusCode; + +#[tokio::test] +async fn handler_returns_expected_response() { + let api = make_mock_api(); + + let app = test::init_service( + App::new() + .app_data(web::Data::new(api)) + .route("/endpoint", web::get().to(handler)) + ).await; + + let req = test::TestRequest::get() + .uri("/endpoint") + .to_request(); + + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status(), StatusCode::OK); +} +``` + +### Password File Test Isolation + +```rust +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn test_requiring_password_file() { + // Acquire lock to prevent concurrent password file access + let _lock = PasswordService::lock_for_test(); + + // Test logic here - lock automatically released on drop +} +``` + +### Temp Directory for File Operations + +```rust +use tempfile::TempDir; + +#[test] +fn test_file_operation() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let file_path = temp_dir.path().join("test.txt"); + + // Use file_path for testing + // Directory automatically cleaned up on drop +} +``` + +## Test Infrastructure Improvements + +### Recommended Additions + +1. **Test Fixtures Module**: Create `src/backend/src/test_fixtures.rs` with common test data builders +2. **Response Builders**: Helper functions to create mock HTTP responses +3. **Config Override**: Allow test-specific configuration injection + +### Example Test Fixtures + +```rust +// src/backend/src/test_fixtures.rs + +pub fn test_status() -> Status { + Status { + network_status: NetworkStatus { + network_interfaces: vec![test_network_interface()], + }, + system_info: SystemInfo { + fleet_id: Some("test-fleet".into()), + omnect_device_service_version: "0.40.0".into(), + }, + update_validation_status: UpdateValidationStatus { + status: "idle".into(), + }, + } +} + +pub fn test_network_interface() -> NetworkInterface { + NetworkInterface { + online: true, + ipv4: Ipv4Info { + addrs: vec![Ipv4AddrInfo { addr: "192.168.1.100".into() }], + }, + file: PathBuf::from("/network/10-eth0.network"), + name: "eth0".into(), + } +} + +pub fn test_token_claims(role: &str, tenant: &str) -> TokenClaims { + TokenClaims { + roles: Some(vec![role.into()]), + tenant_list: Some(vec![tenant.into()]), + fleet_list: None, + } +} +``` + +## Run Commands + +```bash +# Run all backend tests +cargo test --features mock + +# Run specific test module +cargo test --features mock middleware::tests + +# Run with verbose output +cargo test --features mock -- --nocapture + +# Run integration tests only +cargo test --features mock --test '*' + +# Check code coverage (requires cargo-tarpaulin) +cargo tarpaulin --features mock --out Html +``` + +## Coverage Goals + +| Module | Current | Target | Priority | +|:-------|:--------|:-------|:---------| +| `middleware` | 80% | 90% | High | +| `services::auth` | 60% | 85% | High | +| `services::network` | 0% | 75% | Medium | +| `services::firmware` | 20% | 70% | Medium | +| `services::certificate` | 0% | 50% | Low | +| `api` | 5% | 70% | High | +| `omnect_device_service_client` | 0% | 60% | Medium | +| `http_client` | 40% | 80% | Medium | +| `config` | 10% | 50% | Low | + +## Dependencies + +```toml +[dev-dependencies] +actix-http = "3.11" +actix-service = "2.0" +mockall_double = "0.3" +tempfile = "3.20" +``` + +## Summary + +The backend has a solid foundation for testing with: +- Mock infrastructure via `mockall` +- Actix test utilities for HTTP testing +- Test-mode configuration with temp directories + +Key gaps to address: +1. **Network service** has no tests despite complex rollback logic +2. **API handlers** are largely untested +3. **Error paths** lack systematic coverage + +Priority should be: +1. Add network service tests (high business value, complex logic) +2. Add API handler integration tests (validates request/response contracts) +3. Expand error handling tests (improves reliability) diff --git a/src/backend/src/services/auth/authorization.rs b/src/backend/src/services/auth/authorization.rs index 183a9b6..1ed6688 100644 --- a/src/backend/src/services/auth/authorization.rs +++ b/src/backend/src/services/auth/authorization.rs @@ -75,3 +75,391 @@ impl AuthorizationService { bail!("failed to authorize user: insufficient role permissions") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::keycloak_client::TokenClaims; + + #[cfg(feature = "mock")] + use mockall_double::double; + + #[cfg(feature = "mock")] + #[double] + use crate::keycloak_client::SingleSignOnProvider; + + #[cfg(feature = "mock")] + #[double] + use crate::omnect_device_service_client::DeviceServiceClient; + + fn create_claims( + roles: Option>, + tenant_list: Option>, + fleet_list: Option>, + ) -> TokenClaims { + TokenClaims { + roles: roles.map(|r| r.into_iter().map(String::from).collect()), + tenant_list: tenant_list.map(|t| t.into_iter().map(String::from).collect()), + fleet_list: fleet_list.map(|f| f.into_iter().map(String::from).collect()), + } + } + + mod fleet_administrator { + use super::*; + + #[tokio::test] + async fn with_valid_tenant_succeeds() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["cp"]), + None, + )) + }) + }); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn with_invalid_tenant_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["invalid_tenant"]), + None, + )) + }) + }); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("insufficient permissions for tenant") + ); + } + + #[tokio::test] + async fn with_multiple_tenants_including_valid_succeeds() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["other_tenant", "cp", "another_tenant"]), + None, + )) + }) + }); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_ok()); + } + } + + mod fleet_operator { + use super::*; + + #[tokio::test] + async fn with_matching_fleet_succeeds() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-123"]), + )) + }) + }); + + let mut device_mock = DeviceServiceClient::default(); + device_mock + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn with_non_matching_fleet_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-456"]), + )) + }) + }); + + let mut device_mock = DeviceServiceClient::default(); + device_mock + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("insufficient permissions for fleet") + ); + } + + #[tokio::test] + async fn with_multiple_fleets_including_match_succeeds() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-456", "fleet-123", "fleet-789"]), + )) + }) + }); + + let mut device_mock = DeviceServiceClient::default(); + device_mock + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn without_fleet_list_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + None, + )) + }) + }); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no fleet list in token") + ); + } + + #[tokio::test] + async fn with_invalid_tenant_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["invalid_tenant"]), + Some(vec!["fleet-123"]), + )) + }) + }); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("insufficient permissions for tenant") + ); + } + } + + mod fleet_observer { + use super::*; + + #[tokio::test] + async fn with_valid_tenant_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { + Ok(create_claims( + Some(vec!["FleetObserver"]), + Some(vec!["cp"]), + None, + )) + }) + }); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("insufficient role permissions") + ); + } + } + + mod missing_claims { + use super::*; + + #[tokio::test] + async fn without_tenant_list_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(|_| { + Box::pin(async { Ok(create_claims(Some(vec!["FleetAdministrator"]), None, None)) }) + }); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no tenant list in token") + ); + } + + #[tokio::test] + async fn without_roles_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock + .expect_verify_token() + .returning(|_| Box::pin(async { Ok(create_claims(None, Some(vec!["cp"]), None)) })); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "valid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("no roles in token") + ); + } + } + + mod token_verification { + use super::*; + + #[tokio::test] + async fn with_invalid_token_fails() { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock + .expect_verify_token() + .returning(|_| Box::pin(async { Err(anyhow::anyhow!("invalid token signature")) })); + + let device_mock = DeviceServiceClient::default(); + + let result = AuthorizationService::validate_token_and_claims( + &sso_mock, + &device_mock, + "invalid_token", + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid token signature") + ); + } + } +}