diff --git a/.dockerignore b/.dockerignore index fe21448..e757963 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ -vue/node_modules -vue/dist -vue/.vscode \ No newline at end of file +src/ui/node_modules +src/ui/dist +src/ui/.vscode +target \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2ccb255..ead456f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ centrifugo *.tar.gz *.env *.http -/static +/dist CLAUDE.md diff --git a/Cargo.toml b/Cargo.toml index 258107c..c08a651 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,74 +1,13 @@ -[package] +[workspace] +resolver = "2" +members = [ + "src/backend", +] + +[workspace.package] authors = ["omnect@conplement.de"] -description = "WebService providing access to omnect device features." edition = "2024" homepage = "https://www.omnect.io/home" license = "MIT OR Apache-2.0" -name = "omnect-ui" -readme = "README.md" repository = "git@github.com:omnect/omnect-ui.git" version = "1.1.0" -build = "src/build.rs" - -[dependencies] -actix-cors = { version = "0.7", default-features = false } -actix-files = { version = "0.6", default-features = false } -actix-multipart = { version = "0.7", default-features = false, features = [ - "tempfile", - "derive" -] } -actix-server = { version = "2.6", default-features = false } -actix-session = { version = "0.11", features = ["cookie-session"] } -actix-web = { version = "4.11", default-features = false, features = [ - "macros", - "rustls-0_23", -] } -actix-web-httpauth = { version = "0.8", default-features = false } -anyhow = { version = "1.0", default-features = false } -argon2 = { version = "0.5", default-features = false, features = ["password-hash", "alloc"] } -base64 = { version = "0.22", default-features = false } -env_logger = { version = "0.11", default-features = false } -jwt-simple = { version = "0.12", default-features = false, features = [ - "optimal", -] } -log = { version = "0.4", default-features = false } -log-panics = { version = "2.1", default-features = false, features = [ - "with-backtrace", -] } -mockall = { version = "0.13", optional = true, default-features = false } -rand_core = { version = "0.9", default-features = false, features = ["std"] } -reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls"] } -rust-ini = { version = "0.21", default-features = false } -rustls = { version = "0.23", default-features = false, features = [ - "aws_lc_rs", - "std", - "tls12", -] } -rustls-pemfile = { version = "2.2", default-features = false, features = [ - "std", -] } -semver = { version = "1.0", default-features = false } -serde = { version = "1.0", default-features = false, features = ["derive"] } -serde_json = { version = "1.0", default-features = false, features = [ - "raw_value", -] } -serde_repr = { version = "0.1", default-features = false } -serde_valid = { version = "2.0", default-features = false } -tokio = { version = "1.45", default-features = false, features = [ - "macros", - "net", - "process", -] } -trait-variant = { version = "0.1", default-features = false } -uuid = { version = "1.17", default-features = false, features = [ - "v4", -] } - -[features] -mock = ["dep:mockall"] - -[dev-dependencies] -actix-http = "3.11" -actix-service = "2.0" -mockall_double = "0.3" -tempfile = "3.20" diff --git a/Dockerfile b/Dockerfile index 1a76f5a..aa8df84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,13 @@ ARG DISTROLESS_IMAGE=gcr.io/distroless/base-debian12:nonroot FROM oven/bun AS vue-install RUN mkdir -p /tmp -COPY vue/package.json /tmp -COPY vue/bun.lock /tmp +COPY src/ui/package.json /tmp +COPY src/ui/bun.lock /tmp RUN cd /tmp && bun install --frozen-lockfile FROM oven/bun AS vue-build WORKDIR /usr/src/app -COPY vue . +COPY src/ui . COPY --from=vue-install /tmp/node_modules node_modules RUN bun run build @@ -34,22 +34,18 @@ RUN curl -sSLf https://centrifugal.dev/install.sh | sh COPY --from=distroless /var/lib/dpkg/status.d /distroless_pkgs -RUN cargo new /work/omnect-ui +COPY Cargo.lock ./Cargo.lock +COPY Cargo.toml ./Cargo.toml +COPY src ./src -COPY Cargo.lock ./omnect-ui/Cargo.lock -COPY Cargo.toml ./omnect-ui/Cargo.toml -COPY src/build.rs ./omnect-ui/src/build.rs +RUN --mount=type=cache,target=/usr/local/cargo/registry cargo build ${OMNECT_UI_BUILD_ARG} --release -p omnect-ui --target-dir ./build -RUN --mount=type=cache,target=/usr/local/cargo/registry cd omnect-ui && cargo build ${OMNECT_UI_BUILD_ARG} --release --target-dir ./build - -COPY src ./omnect-ui/src/ -COPY .git ./omnect-ui/.git +COPY .git ./.git RUN --mount=type=cache,target=/usr/local/cargo/registry < { + - test('initializes Core and loads WASM module') + - test('dispatches events to Core') + - test('processes HTTP effects') + - test('processes WebSocket effects') + - test('updates viewModel on Core changes') + - test('handles Core initialization errors') +}) + +// src/ui/src/composables/useCentrifugo.ts +describe('useCentrifugo', () => { + - test('establishes WebSocket connection') + - test('subscribes to channels') + - test('handles incoming messages') + - test('reconnects on connection loss') + - test('cleans up on unmount') +}) +``` + +#### Component Tests (Vue Test Utils) +```typescript +// src/ui/src/components/DeviceInfoCore.vue +describe('DeviceInfoCore', () => { + - test('renders device info correctly') + - test('displays online status') + - test('handles user interactions') + - test('dispatches correct events on button click') + - test('displays error states') + - test('shows loading state during initialization') +}) +``` + +### 12. HTTP Client Unit Tests +**Location:** `src/backend/src/http_client.rs` +**Current:** 2 tests exist for validation only + +```rust +#[cfg(test)] +mod tests { + // Existing: path validation tests + + // Add actual HTTP communication tests: + - test_get_request_with_query_parameters() + - test_post_request_with_large_json_payload() + - test_put_request() + - test_delete_request() + - test_timeout_handling() + - test_connection_refused_handling() + - test_malformed_response_handling() +} +``` + +--- + +## Missing Test Infrastructure + +### Currently Not Found: +- ❌ No test fixtures or test utilities +- ❌ No database/service mocking helpers +- ❌ No integration test harness +- ❌ No frontend test framework +- ❌ No CI/CD test configuration (GitHub Actions) +- ❌ No test coverage reporting tools +- ❌ No E2E test framework +- ❌ No performance/load testing + +### Build Features: +- `mock` feature exists (`Cargo.toml` line 69) but appears unused in test code +- Uses `mockall_double` for mocking in integration tests +- No consistent mocking strategy across codebase + +--- + +## Recommended Testing Strategy + +### Phase 1: Security & Stability (Week 1-2) +**Goal:** Prevent security vulnerabilities and auth bypasses + +1. Authorization service tests (#4) +2. Keycloak provider tests (#5) +3. Token/login endpoint tests (#7 - /api/token) +4. Password management tests (#8) + +**Deliverable:** All authentication/authorization paths tested + +### Phase 2: Core Device Operations (Week 3-4) +**Goal:** Prevent device bricking and operational failures + +1. Device service client tests (#3) +2. Network configuration tests (#1) +3. Firmware update tests (#2) +4. Certificate service tests (#6) + +**Deliverable:** All critical device operations tested + +### Phase 3: API Coverage (Week 5-6) +**Goal:** Comprehensive API testing + +1. Remaining API route integration tests (#7) +2. Middleware integration tests (#9) +3. Configuration loading tests (#10) + +**Deliverable:** All API endpoints tested with happy path + error cases + +### Phase 4: Frontend & E2E (Week 7-8) +**Goal:** End-to-end user flow validation + +1. Frontend unit tests (#11) +2. E2E tests for critical flows +3. Performance/load testing + +**Deliverable:** Full stack test coverage + +--- + +## Test Infrastructure Setup + +### 1. Mocking Helpers +Create reusable mocks in `src/backend/tests/common/mocks.rs`: +```rust +pub mod mocks { + pub fn mock_device_service_client() -> MockDeviceServiceClient { ... } + pub fn mock_keycloak_provider() -> MockKeycloakProvider { ... } + pub fn mock_authorization_service() -> MockAuthorizationService { ... } +} +``` + +### 2. Test Fixtures +Create fixtures in `src/backend/tests/fixtures/`: +``` +fixtures/ +├── config/ +│ ├── valid_config.toml +│ └── invalid_config.toml +├── tokens/ +│ ├── valid_jwt.txt +│ ├── expired_jwt.txt +│ └── invalid_jwt.txt +└── certs/ + ├── test_cert.pem + └── test_key.pem +``` + +### 3. Integration Test Utilities +Create test utilities in `src/backend/tests/common/utils.rs`: +```rust +pub async fn create_test_app() -> App { ... } +pub async fn authenticate_test_user() -> String { ... } +pub fn create_mock_request(path: &str, method: Method) -> TestRequest { ... } +``` + +### 4. CI/CD Integration +Create `.github/workflows/test.yml`: +```yaml +name: Tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: cargo test --features mock + - run: cargo test -p omnect-ui-core + - run: cargo clippy --all-targets +``` + +### 5. Coverage Reporting +Add to `Cargo.toml`: +```toml +[dev-dependencies] +tarpaulin = "0.27" +``` + +Run coverage: +```bash +cargo tarpaulin --out Html --output-dir coverage/ --features mock +``` + +### 6. Frontend Test Setup +Add to `src/ui/package.json`: +```json +{ + "devDependencies": { + "vitest": "^1.0.0", + "@vue/test-utils": "^2.4.0", + "@vitest/ui": "^1.0.0" + }, + "scripts": { + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" + } +} +``` + +--- + +## Files Currently With Tests + +### Backend Tests: +- ✅ `src/backend/tests/validate_portal_token.rs` (5 tests) +- ✅ `src/backend/tests/http_client.rs` (3 tests) +- ✅ `src/backend/src/middleware.rs` (11 tests) +- ✅ `src/backend/src/services/auth/token.rs` (3 tests) +- ✅ `src/backend/src/services/auth/password.rs` (2 tests) +- ✅ `src/backend/src/services/firmware.rs` (1 test) +- ✅ `src/backend/src/http_client.rs` (2 tests) + +### Files WITHOUT Tests (Critical): +- ❌ `src/backend/src/services/certificate.rs` (98 LOC) +- ❌ `src/backend/src/services/network.rs` (419 LOC) +- ❌ `src/backend/src/services/auth/authorization.rs` (77 LOC) +- ❌ `src/backend/src/omnect_device_service_client.rs` (353 LOC) +- ❌ `src/backend/src/keycloak_client.rs` (76 LOC) +- ❌ `src/backend/src/config.rs` (308 LOC) +- ❌ `src/backend/src/api.rs` (tested indirectly via 1 integration test only) +- ❌ `src/ui/**/*.ts` (entire frontend - 0 tests) +- ❌ `src/ui/**/*.vue` (entire frontend - 0 tests) + +--- + +## Risk Assessment + +| Component | Risk Level | Impact if Broken | Current Tests | +|-----------|------------|------------------|---------------| +| Network Config | 🔴 CRITICAL | Device unreachable | 0 | +| Firmware Update | 🔴 CRITICAL | Device bricked | 1 | +| Device Service Client | 🔴 CRITICAL | All operations fail | 0 | +| Authorization | 🔴 CRITICAL | Security breach | 0 | +| Keycloak SSO | 🔴 CRITICAL | Can't login | 0 | +| Certificate Service | 🟠 HIGH | HTTPS broken | 0 | +| Password Management | 🟠 HIGH | Can't authenticate | 0 | +| API Routes | 🟠 HIGH | Unknown failures | 1/17 | +| Middleware | 🟡 MEDIUM | Auth bypass risk | 11 (unit only) | +| Frontend | 🟡 MEDIUM | UX broken | 0 | + +--- + +## Summary & Recommendations + +### Current State: +- **27 total tests** covering ~1-2% of codebase +- **Critical gaps** in network config, firmware updates, device service client +- **Security risk** with untested authorization and authentication +- **Zero frontend tests** - entire UI untested + +### Immediate Actions Needed: +1. **This Week:** Add authorization and authentication tests (#4, #5) +2. **Next Week:** Add device service client tests (#3) +3. **Within Month:** Add network config and firmware tests (#1, #2) + +### Long-term Goals: +- Achieve 80%+ code coverage on critical paths +- Implement CI/CD automated testing +- Add E2E test suite +- Establish test-first development culture + +### Estimated Effort: +- **Phase 1 (Security):** 40-60 hours +- **Phase 2 (Core Operations):** 60-80 hours +- **Phase 3 (API Coverage):** 40-50 hours +- **Phase 4 (Frontend):** 50-70 hours +- **Total:** 190-260 hours (6-8 weeks with 1 developer) diff --git a/biome.json b/biome.json deleted file mode 100644 index ece0bd6..0000000 --- a/biome.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noExplicitAny": "off", - "noAsyncPromiseExecutor": "off" - }, - "style": { - "noParameterAssign": "off", - "useImportType": "off", - "useAsConstAssertion": "error", - "useDefaultParameterLast": "error", - "useEnumInitializers": "error", - "useSelfClosingElements": "error", - "useSingleVarDeclarator": "error", - "noUnusedTemplateLiteral": "error", - "useNumberNamespace": "error", - "noInferrableTypes": "error", - "noUselessElse": "error" - }, - "complexity": { - "noStaticOnlyClass": "off", - "useLiteralKeys": "off", - "noForEach": "off" - } - }, - "includes": ["**"] - }, - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "tab", - "indentWidth": 2, - "lineWidth": 150, - "includes": ["**"] - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "trailingCommas": "none", - "semicolons": "asNeeded" - }, - "parser": { - "unsafeParameterDecoratorsEnabled": true - } - }, - "files": { - "includes": ["**", "!.vscode*"], - "maxSize": 31457280 - } -} diff --git a/build-and-run-image.sh b/build-and-run-image.sh index 1193677..0c2860a 100755 --- a/build-and-run-image.sh +++ b/build-and-run-image.sh @@ -1,7 +1,7 @@ # file used for local development # local build and run -omnect_ui_version=$(toml get --raw Cargo.toml package.version) +omnect_ui_version=$(toml get --raw Cargo.toml workspace.package.version) omnect_ui_port="1977" centrifugo_port="8000" diff --git a/build-arm64-image.sh b/build-arm64-image.sh index ddf781d..6e9ce6f 100755 --- a/build-arm64-image.sh +++ b/build-arm64-image.sh @@ -1,7 +1,7 @@ # file used for local development # local arm64 build -omnect_ui_version=$(toml get --raw Cargo.toml package.version) +omnect_ui_version=$(toml get --raw Cargo.toml workspace.package.version) docker buildx build \ --platform linux/arm64 \ diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml new file mode 100644 index 0000000..539a964 --- /dev/null +++ b/src/backend/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "omnect-ui" +description = "WebService providing access to omnect device features." +readme = "../../README.md" +build = "src/build.rs" + +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +actix-cors = { version = "0.7", default-features = false } +actix-files = { version = "0.6", default-features = false } +actix-multipart = { version = "0.7", default-features = false, features = [ + "tempfile", + "derive" +] } +actix-server = { version = "2.6", default-features = false } +actix-session = { version = "0.11", features = ["cookie-session"] } +actix-web = { version = "4.11", default-features = false, features = [ + "macros", + "rustls-0_23", +] } +actix-web-httpauth = { version = "0.8", default-features = false } +anyhow = { version = "1.0", default-features = false } +argon2 = { version = "0.5", default-features = false, features = ["password-hash", "alloc"] } +base64 = { version = "0.22", default-features = false } +env_logger = { version = "0.11", default-features = false } +jwt-simple = { version = "0.12", default-features = false, features = [ + "optimal", +] } +log = { version = "0.4", default-features = false } +log-panics = { version = "2.1", default-features = false, features = [ + "with-backtrace", +] } +mockall = { version = "0.13", optional = true, default-features = false } +rand_core = { version = "0.9", default-features = false, features = ["std"] } +reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls"] } +rust-ini = { version = "0.21", default-features = false } +rustls = { version = "0.23", default-features = false, features = [ + "aws_lc_rs", + "std", + "tls12", +] } +rustls-pemfile = { version = "2.2", default-features = false, features = [ + "std", +] } +semver = { version = "1.0", default-features = false } +serde = { version = "1.0", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", default-features = false, features = [ + "raw_value", +] } +serde_repr = { version = "0.1", default-features = false } +serde_valid = { version = "2.0", default-features = false } +tokio = { version = "1.45", default-features = false, features = [ + "macros", + "net", + "process", +] } +trait-variant = { version = "0.1", default-features = false } +uuid = { version = "1.17", default-features = false, features = [ + "v4", +] } + +[features] +mock = ["dep:mockall"] + +[dev-dependencies] +actix-http = "3.11" +actix-service = "2.0" +mockall_double = "0.3" +tempfile = "3.20" diff --git a/config/centrifugo_config.json b/src/backend/config/centrifugo_config.json similarity index 100% rename from config/centrifugo_config.json rename to src/backend/config/centrifugo_config.json diff --git a/src/api.rs b/src/backend/src/api.rs similarity index 100% rename from src/api.rs rename to src/backend/src/api.rs diff --git a/src/build.rs b/src/backend/src/build.rs similarity index 100% rename from src/build.rs rename to src/backend/src/build.rs diff --git a/src/config.rs b/src/backend/src/config.rs similarity index 97% rename from src/config.rs rename to src/backend/src/config.rs index 87d7899..aa1eede 100644 --- a/src/config.rs +++ b/src/backend/src/config.rs @@ -266,13 +266,13 @@ impl PathConfig { let app_config_path = config_dir.join("app_config.js"); let host_data_dir = PathBuf::from(format!("/var/lib/{}/", env!("CARGO_PKG_NAME"))); - // In test/mock mode, use a dummy path since static/index.html won't exist + // In test/mock mode, use a dummy path since dist/index.html won't exist #[cfg(any(test, feature = "mock"))] let index_html = PathBuf::from("/dev/null"); #[cfg(not(any(test, feature = "mock")))] - let index_html = std::fs::canonicalize("static/index.html") - .context("failed to find static/index.html")?; + let index_html = std::fs::canonicalize("dist/index.html") + .context("failed to find dist/index.html")?; let password_file = config_dir.join("password"); let host_update_file = host_data_dir.join("update.tar"); diff --git a/src/http_client.rs b/src/backend/src/http_client.rs similarity index 100% rename from src/http_client.rs rename to src/backend/src/http_client.rs diff --git a/src/keycloak_client.rs b/src/backend/src/keycloak_client.rs similarity index 75% rename from src/keycloak_client.rs rename to src/backend/src/keycloak_client.rs index c2c7eb4..78bbad8 100644 --- a/src/keycloak_client.rs +++ b/src/backend/src/keycloak_client.rs @@ -74,3 +74,15 @@ impl SingleSignOnProvider for KeycloakProvider { Ok(claims.custom) } } + +// Note: Unit tests for KeycloakProvider are not included here because: +// - verify_token() requires mocking the HTTP client (reqwest) which is complex +// - verify_token() is already tested indirectly through AuthorizationService tests +// - verify_token() is tested in integration tests (tests/validate_portal_token.rs) +// - create_frontend_config_file() requires AppConfig setup with environment variables +// - The token verification logic itself is handled by jwt-simple library (well-tested) +// +// If direct unit tests are needed in the future, consider: +// - Using wiremock or similar to mock HTTP responses +// - Making realm_public_key() mockable via dependency injection +// - Creating integration tests with a test Keycloak instance diff --git a/src/lib.rs b/src/backend/src/lib.rs similarity index 100% rename from src/lib.rs rename to src/backend/src/lib.rs diff --git a/src/main.rs b/src/backend/src/main.rs similarity index 99% rename from src/main.rs rename to src/backend/src/main.rs index ed83516..aea5d92 100644 --- a/src/main.rs +++ b/src/backend/src/main.rs @@ -223,7 +223,7 @@ async fn run_server( let ui_port = config.ui.port; let session_key = Key::generate(); let token_manager = TokenManager::new(&config.centrifugo.client_token); - let static_path = std::fs::canonicalize("static").context("failed to find static folder")?; + let static_path = std::fs::canonicalize("dist").context("failed to find dist folder")?; let server = HttpServer::new(move || { App::new() diff --git a/src/middleware.rs b/src/backend/src/middleware.rs similarity index 100% rename from src/middleware.rs rename to src/backend/src/middleware.rs diff --git a/src/omnect_device_service_client.rs b/src/backend/src/omnect_device_service_client.rs similarity index 99% rename from src/omnect_device_service_client.rs rename to src/backend/src/omnect_device_service_client.rs index 7b90b23..5f6c2c6 100644 --- a/src/omnect_device_service_client.rs +++ b/src/backend/src/omnect_device_service_client.rs @@ -3,7 +3,6 @@ use crate::{ config::AppConfig, http_client::{handle_http_response, unix_socket_client}, - services::certificate::CreateCertPayload, }; use anyhow::{Context, Result, anyhow, bail}; use log::info; diff --git a/src/backend/src/services/auth/authorization.rs b/src/backend/src/services/auth/authorization.rs new file mode 100644 index 0000000..7ffd233 --- /dev/null +++ b/src/backend/src/services/auth/authorization.rs @@ -0,0 +1,336 @@ +//! Authorization service +//! +//! Handles token validation and role-based access control independent of HTTP concerns. + +use crate::{ + config::AppConfig, keycloak_client::SingleSignOnProvider, + omnect_device_service_client::DeviceServiceClient, +}; +use anyhow::{Result, bail, ensure}; + +/// Service for authorization operations +pub struct AuthorizationService; + +impl AuthorizationService { + /// Validate SSO token and check user claims for authorization + /// + /// Uses the tenant configuration from AppConfig. + /// + /// # Arguments + /// * `single_sign_on` - Single sign-on provider for token verification + /// * `service_client` - Device service client for fleet ID lookup + /// * `token` - The authentication token to validate + /// + /// # Returns + /// Result indicating success or authorization failure + /// + /// # Authorization Rules + /// - User must have tenant in their tenant_list + /// - FleetAdministrator role grants full access + /// - FleetOperator role requires fleet_id in fleet_list + pub async fn validate_token_and_claims( + single_sign_on: &SingleSignOn, + service_client: &ServiceClient, + token: &str, + ) -> Result<()> + where + ServiceClient: DeviceServiceClient, + SingleSignOn: SingleSignOnProvider, + { + let claims = single_sign_on.verify_token(token).await?; + let tenant = &AppConfig::get().tenant; + + // Validate tenant authorization + let Some(tenant_list) = &claims.tenant_list else { + bail!("failed to authorize user: no tenant list in token"); + }; + ensure!( + tenant_list.contains(tenant), + "failed to authorize user: insufficient permissions for tenant" + ); + + // Validate role-based authorization + let Some(roles) = &claims.roles else { + bail!("failed to authorize user: no roles in token"); + }; + + // FleetAdministrator has full access + if roles.iter().any(|r| r == "FleetAdministrator") { + return Ok(()); + } + + // FleetOperator requires fleet validation + if roles.iter().any(|r| r == "FleetOperator") { + let Some(fleet_list) = &claims.fleet_list else { + bail!("failed to authorize user: no fleet list in token"); + }; + let fleet_id = service_client.fleet_id().await?; + ensure!( + fleet_list.contains(&fleet_id), + "failed to authorize user: insufficient permissions for fleet" + ); + return Ok(()); + } + + 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, omnect_device_service_client::DeviceServiceClient, + }; + + fn make_claims(role: &str, tenant: &str, fleets: Option>) -> TokenClaims { + TokenClaims { + roles: Some(vec![role.to_string()]), + tenant_list: Some(vec![tenant.to_string()]), + fleet_list: fleets.map(|fs| fs.into_iter().map(|f| f.to_string()).collect()), + } + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_fleet_administrator_with_valid_tenant() { + let claims = make_claims("FleetAdministrator", "cp", None); + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token().returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_ok()); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_fleet_administrator_with_invalid_tenant() { + let claims = make_claims("FleetAdministrator", "wrong-tenant", None); + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("insufficient permissions for tenant")); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_fleet_operator_with_valid_fleet() { + let claims = make_claims("FleetOperator", "cp", Some(vec!["fleet1", "fleet2"])); + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_ok()); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_fleet_operator_with_invalid_fleet() { + let claims = make_claims("FleetOperator", "cp", Some(vec!["fleet2", "fleet3"])); + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("insufficient permissions for fleet")); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_fleet_operator_without_fleet_list() { + let mut claims = make_claims("FleetOperator", "cp", None); + claims.fleet_list = None; + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("no fleet list in token")); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_invalid_role() { + let claims = make_claims("FleetObserver", "cp", Some(vec!["fleet1"])); + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("insufficient role permissions")); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_missing_tenant_list() { + let mut claims = make_claims("FleetAdministrator", "cp", None); + claims.tenant_list = None; + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("no tenant list in token")); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_missing_roles() { + let mut claims = make_claims("FleetAdministrator", "cp", None); + claims.roles = None; + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("no roles in token")); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_sso_token_verification_error() { + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(|_| Box::pin(async { Err(anyhow::anyhow!("Token verification failed")) })); + + let mut device_client = DeviceServiceClient::default(); + device_client + .expect_fleet_id() + .returning(|| Box::pin(async { Ok("fleet1".to_string()) })); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Token verification failed")); + } + + #[tokio::test] + #[cfg(feature = "mock")] + async fn test_device_service_fleet_id_error() { + let claims = make_claims("FleetOperator", "cp", Some(vec!["fleet1"])); + let mut sso = SingleSignOnProvider::default(); + sso.expect_verify_token() + .returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + + let mut device_client = DeviceServiceClient::default(); + device_client.expect_fleet_id().returning(|| { + Box::pin(async { Err(anyhow::anyhow!("Device service unavailable")) }) + }); + + let result = + AuthorizationService::validate_token_and_claims(&sso, &device_client, "token").await; + assert!(result.is_err()); + } +} diff --git a/src/services/auth/mod.rs b/src/backend/src/services/auth/mod.rs similarity index 100% rename from src/services/auth/mod.rs rename to src/backend/src/services/auth/mod.rs diff --git a/src/services/auth/password.rs b/src/backend/src/services/auth/password.rs similarity index 100% rename from src/services/auth/password.rs rename to src/backend/src/services/auth/password.rs diff --git a/src/services/auth/token.rs b/src/backend/src/services/auth/token.rs similarity index 100% rename from src/services/auth/token.rs rename to src/backend/src/services/auth/token.rs diff --git a/src/services/certificate.rs b/src/backend/src/services/certificate.rs similarity index 100% rename from src/services/certificate.rs rename to src/backend/src/services/certificate.rs diff --git a/src/services/firmware.rs b/src/backend/src/services/firmware.rs similarity index 100% rename from src/services/firmware.rs rename to src/backend/src/services/firmware.rs diff --git a/src/services/mod.rs b/src/backend/src/services/mod.rs similarity index 100% rename from src/services/mod.rs rename to src/backend/src/services/mod.rs diff --git a/src/services/network.rs b/src/backend/src/services/network.rs similarity index 100% rename from src/services/network.rs rename to src/backend/src/services/network.rs diff --git a/src/backend/tests/README.md b/src/backend/tests/README.md new file mode 100644 index 0000000..c9735ba --- /dev/null +++ b/src/backend/tests/README.md @@ -0,0 +1,209 @@ +# Test Infrastructure + +This directory contains reusable test infrastructure for the omnect-ui backend. + +## Structure + +``` +tests/ +├── common/ # Reusable test utilities +│ ├── mod.rs # Module exports +│ ├── mocks.rs # Mock helpers for services +│ └── utils.rs # Test utilities (app setup, requests) +├── fixtures/ # Test fixture files +│ ├── config/ # Configuration files +│ ├── tokens/ # JWT tokens +│ ├── certs/ # Certificates and keys +│ └── README.md # Fixture documentation +├── http_client.rs # HTTP client integration tests +└── validate_portal_token.rs # Portal token validation tests +``` + +## Using Test Infrastructure + +### Mock Helpers + +The `common::mocks` module provides reusable mock constructors: + +```rust +use crate::common::mocks; + +// Create mock device service client +let device_client = mocks::mock_device_service_client_with_fleet_id("test-fleet"); + +// Create mock SSO provider with claims +let claims = mocks::make_token_claims("FleetAdministrator", "test-tenant", None); +let sso_provider = mocks::mock_sso_provider_with_claims(claims); + +// Create complete API instance with mocks +let api = mocks::make_api("test-fleet", claims); +``` + +Available mock helpers: +- `mock_device_service_client_with_fleet_id(fleet_id)` - Success case +- `mock_device_service_client_with_error()` - Error case +- `mock_sso_provider_with_claims(claims)` - Success case +- `mock_sso_provider_with_error(msg)` - Error case +- `make_token_claims(role, tenant, fleets)` - Create TokenClaims +- `make_api(fleet_id, claims)` - Create complete API instance + +### Test Utilities + +The `common::utils` module provides test utilities: + +```rust +use crate::common::utils; + +// Create test app +let app = utils::create_test_app(api); + +// Create test requests +let req = utils::create_post_request("/api/endpoint", "payload"); +let req = utils::create_get_request("/api/endpoint"); +let req = utils::create_authenticated_request("/api/endpoint", Method::GET, "token"); +let req = utils::create_basic_auth_request("/api/endpoint", Method::POST, "credentials"); + +// Load fixture files +let token = utils::load_fixture("tokens/valid_jwt.txt"); +``` + +### Fixtures + +Load test fixtures using the utility function: + +```rust +use crate::common::utils::load_fixture; + +let valid_token = load_fixture("tokens/valid_jwt.txt"); +let expired_token = load_fixture("tokens/expired_jwt.txt"); +let cert = load_fixture("certs/test_cert.pem"); +``` + +See `fixtures/README.md` for more details on available fixtures. + +## Running Tests + +```bash +# Run all tests with mock feature +cargo test --features mock + +# Run specific test file +cargo test --features mock --test validate_portal_token + +# Run specific test +cargo test --features mock validate_portal_token_fleet_admin_should_succeed + +# Run with output +cargo test --features mock -- --nocapture +``` + +## Writing New Tests + +### Integration Tests + +Create a new file in `tests/` directory: + +```rust +// tests/my_feature.rs + +mod common; // Import common test infrastructure + +use crate::common::{mocks, utils}; + +#[tokio::test] +async fn test_my_feature() { + // Use mocks and utilities + let claims = mocks::make_token_claims("FleetAdministrator", "test-tenant", None); + let api = mocks::make_api("test-fleet", claims); + + // Create test app and request + let app = actix_web::test::init_service(utils::create_test_app(api)).await; + let req = utils::create_get_request("/api/my-feature").to_request(); + + // Execute and assert + let resp = actix_web::test::call_service(&app, req).await; + assert_eq!(resp.status(), actix_web::http::StatusCode::OK); +} +``` + +### Unit Tests + +Add `#[cfg(test)]` module in source files: + +```rust +// src/services/my_service.rs + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_my_function() { + // Test implementation + } +} +``` + +## Test Patterns + +### Authentication Testing + +```rust +// Test with valid token +let req = utils::create_authenticated_request("/api/endpoint", Method::GET, "valid-token"); + +// Test with basic auth +let req = utils::create_basic_auth_request("/api/endpoint", Method::POST, "dXNlcjpwYXNz"); + +// Test unauthorized access +let req = utils::create_get_request("/api/endpoint"); // No auth header +let resp = test::call_service(&app, req.to_request()).await; +assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +``` + +### Mock Configuration + +```rust +// Configure mock with specific behavior +let mut mock = DeviceServiceClient::default(); +mock.expect_fleet_id() + .times(1) + .returning(|| Box::pin(async { Ok("test-fleet".to_string()) })); +``` + +### Error Testing + +```rust +// Test error handling +let device_client = mocks::mock_device_service_client_with_error(); +let sso_provider = mocks::mock_sso_provider_with_error("Invalid token"); +``` + +## Best Practices + +1. **Use reusable mocks** - Prefer `common::mocks` helpers over creating mocks inline +2. **Use test fixtures** - Load tokens, configs from fixtures instead of hardcoding +3. **Test happy path first** - Verify functionality works before testing edge cases +4. **Test error cases** - Always test failure scenarios +5. **Keep tests isolated** - Each test should be independent +6. **Use descriptive names** - Test names should describe what they test +7. **Follow naming convention** - `test_function_name_scenario_expected_result` + +## CI/CD Integration + +Tests run automatically in Concourse CI pipeline. Ensure: +- All tests pass: `cargo test --features mock` +- Code is formatted: `cargo fmt` +- Clippy succeeds: `cargo clippy --all-targets --features mock` + +## Coverage Goals + +Current: 27 tests (~1-2% coverage) + +Target coverage by phase: +- Phase 1 (Security): 13% coverage +- Phase 2 (Device Ops): 50% coverage +- Phase 3 (API): 72% coverage +- Phase 4 (Frontend): 85-90% coverage + +See TEST_COVERAGE_ANALYSIS.md in repository root for detailed coverage analysis. diff --git a/src/backend/tests/common/mocks.rs b/src/backend/tests/common/mocks.rs new file mode 100644 index 0000000..9e5c1f0 --- /dev/null +++ b/src/backend/tests/common/mocks.rs @@ -0,0 +1,114 @@ +use omnect_ui::{api::Api, keycloak_client::TokenClaims}; + +#[mockall_double::double] +use omnect_ui::{ + keycloak_client::SingleSignOnProvider, omnect_device_service_client::DeviceServiceClient, +}; + +/// Helper to create API instance with mocks +pub fn make_api( + fleet_id: &'static str, + claims: TokenClaims, +) -> Api { + let device_client = mock_device_service_client_with_fleet_id(fleet_id); + let sso_provider = mock_sso_provider_with_claims(claims); + + Api { + ods_client: device_client, + sso_provider, + } +} + +/// Creates a mock DeviceServiceClient with a fleet_id that returns the provided value +pub fn mock_device_service_client_with_fleet_id( + fleet_id: &'static str, +) -> DeviceServiceClient { + let mut mock = DeviceServiceClient::default(); + mock.expect_fleet_id() + .returning(move || Box::pin(async move { Ok(fleet_id.to_string()) })); + mock +} + +/// Creates a mock DeviceServiceClient that returns an error for fleet_id +pub fn mock_device_service_client_with_error() -> DeviceServiceClient { + let mut mock = DeviceServiceClient::default(); + mock.expect_fleet_id().returning(move || { + Box::pin(async move { Err("Device service unavailable".to_string()) }) + }); + mock +} + +/// Creates a mock SingleSignOnProvider that verifies tokens successfully with the provided claims +pub fn mock_sso_provider_with_claims(claims: TokenClaims) -> SingleSignOnProvider { + let mut mock = SingleSignOnProvider::default(); + mock.expect_verify_token().returning(move |_| { + let claims = claims.clone(); + Box::pin(async move { Ok(claims) }) + }); + mock +} + +/// Creates a mock SingleSignOnProvider that returns an error for token verification +pub fn mock_sso_provider_with_error(error_msg: &'static str) -> SingleSignOnProvider { + let mut mock = SingleSignOnProvider::default(); + mock.expect_verify_token() + .returning(move |_| Box::pin(async move { Err(error_msg.to_string()) })); + mock +} + +/// Helper to create TokenClaims for testing +pub fn make_token_claims(role: &str, tenant: &str, fleets: Option>) -> TokenClaims { + TokenClaims { + roles: Some(vec![role.to_string()]), + tenant_list: Some(vec![tenant.to_string()]), + fleet_list: fleets.map(|fs| fs.into_iter().map(|f| f.to_string()).collect()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_mock_device_service_client_with_fleet_id() { + let mock = mock_device_service_client_with_fleet_id("test-fleet"); + let result = mock.fleet_id().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test-fleet"); + } + + #[tokio::test] + async fn test_mock_device_service_client_with_error() { + let mock = mock_device_service_client_with_error(); + let result = mock.fleet_id().await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_mock_sso_provider_with_claims() { + let claims = make_token_claims("FleetAdministrator", "test-tenant", None); + let mock = mock_sso_provider_with_claims(claims.clone()); + let result = mock.verify_token("dummy-token").await; + assert!(result.is_ok()); + let verified_claims = result.unwrap(); + assert_eq!(verified_claims.roles, claims.roles); + } + + #[tokio::test] + async fn test_mock_sso_provider_with_error() { + let mock = mock_sso_provider_with_error("Invalid token"); + let result = mock.verify_token("dummy-token").await; + assert!(result.is_err()); + } + + #[test] + fn test_make_token_claims() { + let claims = make_token_claims("FleetOperator", "tenant1", Some(vec!["fleet1", "fleet2"])); + assert_eq!(claims.roles, Some(vec!["FleetOperator".to_string()])); + assert_eq!(claims.tenant_list, Some(vec!["tenant1".to_string()])); + assert_eq!( + claims.fleet_list, + Some(vec!["fleet1".to_string(), "fleet2".to_string()]) + ); + } +} diff --git a/src/backend/tests/common/mod.rs b/src/backend/tests/common/mod.rs new file mode 100644 index 0000000..97f0713 --- /dev/null +++ b/src/backend/tests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod mocks; +pub mod utils; diff --git a/src/backend/tests/common/utils.rs b/src/backend/tests/common/utils.rs new file mode 100644 index 0000000..ab58eea --- /dev/null +++ b/src/backend/tests/common/utils.rs @@ -0,0 +1,130 @@ +use actix_web::{App, body::MessageBody, dev::ServiceResponse, test, web}; +use omnect_ui::api::Api; + +#[mockall_double::double] +use omnect_ui::{ + keycloak_client::SingleSignOnProvider, omnect_device_service_client::DeviceServiceClient, +}; + +/// Creates a test app with the provided API instance +pub fn create_test_app( + api: Api, +) -> App< + impl actix_web::dev::ServiceFactory< + actix_web::dev::ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = actix_web::Error, + InitError = (), + >, +> { + App::new().app_data(web::Data::new(api)) +} + +/// Helper to create a basic POST request for testing +pub fn create_post_request(uri: &str, payload: &str) -> actix_web::test::TestRequest { + test::TestRequest::post() + .uri(uri) + .insert_header(actix_web::http::header::ContentType::plaintext()) + .set_payload(payload.to_string()) +} + +/// Helper to create a GET request for testing +pub fn create_get_request(uri: &str) -> actix_web::test::TestRequest { + test::TestRequest::get().uri(uri) +} + +/// Helper to create an authenticated request with Bearer token +pub fn create_authenticated_request( + uri: &str, + method: actix_web::http::Method, + token: &str, +) -> actix_web::test::TestRequest { + let mut req = test::TestRequest::default().uri(uri).method(method); + req = req.insert_header(( + actix_web::http::header::AUTHORIZATION, + format!("Bearer {token}"), + )); + req +} + +/// Helper to create a request with Basic authentication +pub fn create_basic_auth_request( + uri: &str, + method: actix_web::http::Method, + credentials: &str, +) -> actix_web::test::TestRequest { + let mut req = test::TestRequest::default().uri(uri).method(method); + req = req.insert_header(( + actix_web::http::header::AUTHORIZATION, + format!("Basic {credentials}"), + )); + req +} + +/// Load fixture file content as string +pub fn load_fixture(path: &str) -> String { + std::fs::read_to_string(format!("tests/fixtures/{path}")) + .unwrap_or_else(|_| panic!("Failed to load fixture: {path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::mocks; + use omnect_ui::keycloak_client::TokenClaims; + + #[test] + fn test_create_post_request() { + let req = create_post_request("/test", "payload"); + assert_eq!(req.to_request().uri(), "/test"); + assert_eq!(req.to_request().method(), actix_web::http::Method::POST); + } + + #[test] + fn test_create_get_request() { + let req = create_get_request("/test"); + assert_eq!(req.to_request().uri(), "/test"); + assert_eq!(req.to_request().method(), actix_web::http::Method::GET); + } + + #[test] + fn test_create_authenticated_request() { + let req = create_authenticated_request("/test", actix_web::http::Method::GET, "token123"); + let req = req.to_request(); + assert_eq!(req.uri(), "/test"); + assert!(req + .headers() + .get(actix_web::http::header::AUTHORIZATION) + .is_some()); + } + + #[test] + fn test_create_basic_auth_request() { + let req = create_basic_auth_request("/test", actix_web::http::Method::POST, "dXNlcjpwYXNz"); + let req = req.to_request(); + assert_eq!(req.uri(), "/test"); + assert!(req + .headers() + .get(actix_web::http::header::AUTHORIZATION) + .is_some()); + } + + #[test] + fn test_create_test_app() { + let claims = TokenClaims { + roles: Some(vec!["test".to_string()]), + tenant_list: None, + fleet_list: None, + }; + let device_client = mocks::mock_device_service_client_with_fleet_id("test-fleet"); + let sso_provider = mocks::mock_sso_provider_with_claims(claims); + + let api = Api { + ods_client: device_client, + sso_provider, + }; + + let _app = create_test_app(api); + } +} diff --git a/src/backend/tests/fixtures/README.md b/src/backend/tests/fixtures/README.md new file mode 100644 index 0000000..6d55890 --- /dev/null +++ b/src/backend/tests/fixtures/README.md @@ -0,0 +1,25 @@ +# Test Fixtures + +This directory contains test fixture files used across integration and unit tests. + +## Structure + +- `config/` - Configuration files for testing +- `tokens/` - JWT tokens for authentication testing +- `certs/` - Certificate and key files for TLS testing + +## Usage + +Load fixtures in tests using the utility function: + +```rust +use crate::common::utils::load_fixture; + +let token = load_fixture("tokens/valid_jwt.txt"); +``` + +## Important Notes + +- All certificates and keys in this directory are **for testing only** +- JWT tokens are sample tokens with fake signatures +- Configuration files use test values and should not be used in production diff --git a/src/backend/tests/fixtures/certs/test_cert.pem b/src/backend/tests/fixtures/certs/test_cert.pem new file mode 100644 index 0000000..a2b9ad8 --- /dev/null +++ b/src/backend/tests/fixtures/certs/test_cert.pem @@ -0,0 +1,6 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAX4CCQCKx2qN8VqseTANBgkqhkiG9w0BAQsFADANMQswCQYDVQQDDAJ0 +ZXN0MB4XDTI0MDEwMTAwMDAwMFoXDTI1MDEwMTAwMDAwMFowDTELMAkGA1UEAwwC +dGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMExample+Test+Cert +Data+Here+This+Is+Not+A+Real+Certificate+Just+For+Testing+Purposes+Only +-----END CERTIFICATE----- diff --git a/src/backend/tests/fixtures/certs/test_key.pem b/src/backend/tests/fixtures/certs/test_key.pem new file mode 100644 index 0000000..8f47f50 --- /dev/null +++ b/src/backend/tests/fixtures/certs/test_key.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDExample+Test+Key +Data+Here+This+Is+Not+A+Real+Private+Key+Just+For+Testing+Purposes+Only +-----END PRIVATE KEY----- diff --git a/src/backend/tests/fixtures/tokens/expired_jwt.txt b/src/backend/tests/fixtures/tokens/expired_jwt.txt new file mode 100644 index 0000000..19da3fb --- /dev/null +++ b/src/backend/tests/fixtures/tokens/expired_jwt.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJyb2xlcyI6WyJGbGVldEFkbWluaXN0cmF0b3IiXSwidGVuYW50X2xpc3QiOlsidGVzdC10ZW5hbnQiXSwiZXhwIjoxNTAwMDAwMDAwLCJpYXQiOjE0MDAwMDAwMDB9.test-signature-expired diff --git a/src/backend/tests/fixtures/tokens/invalid_jwt.txt b/src/backend/tests/fixtures/tokens/invalid_jwt.txt new file mode 100644 index 0000000..c81a5a6 --- /dev/null +++ b/src/backend/tests/fixtures/tokens/invalid_jwt.txt @@ -0,0 +1 @@ +invalid.token.format diff --git a/src/backend/tests/fixtures/tokens/valid_jwt.txt b/src/backend/tests/fixtures/tokens/valid_jwt.txt new file mode 100644 index 0000000..661a006 --- /dev/null +++ b/src/backend/tests/fixtures/tokens/valid_jwt.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJyb2xlcyI6WyJGbGVldEFkbWluaXN0cmF0b3IiXSwidGVuYW50X2xpc3QiOlsidGVzdC10ZW5hbnQiXSwiZmxlZXRfbGlzdCI6WyJ0ZXN0LWZsZWV0Il0sImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNzAwMDAwMDAwfQ.test-signature diff --git a/tests/http_client.rs b/src/backend/tests/http_client.rs similarity index 100% rename from tests/http_client.rs rename to src/backend/tests/http_client.rs diff --git a/tests/validate_portal_token.rs b/src/backend/tests/validate_portal_token.rs similarity index 100% rename from tests/validate_portal_token.rs rename to src/backend/tests/validate_portal_token.rs diff --git a/src/services/auth/authorization.rs b/src/services/auth/authorization.rs deleted file mode 100644 index 183a9b6..0000000 --- a/src/services/auth/authorization.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Authorization service -//! -//! Handles token validation and role-based access control independent of HTTP concerns. - -use crate::{ - config::AppConfig, keycloak_client::SingleSignOnProvider, - omnect_device_service_client::DeviceServiceClient, -}; -use anyhow::{Result, bail, ensure}; - -/// Service for authorization operations -pub struct AuthorizationService; - -impl AuthorizationService { - /// Validate SSO token and check user claims for authorization - /// - /// Uses the tenant configuration from AppConfig. - /// - /// # Arguments - /// * `single_sign_on` - Single sign-on provider for token verification - /// * `service_client` - Device service client for fleet ID lookup - /// * `token` - The authentication token to validate - /// - /// # Returns - /// Result indicating success or authorization failure - /// - /// # Authorization Rules - /// - User must have tenant in their tenant_list - /// - FleetAdministrator role grants full access - /// - FleetOperator role requires fleet_id in fleet_list - pub async fn validate_token_and_claims( - single_sign_on: &SingleSignOn, - service_client: &ServiceClient, - token: &str, - ) -> Result<()> - where - ServiceClient: DeviceServiceClient, - SingleSignOn: SingleSignOnProvider, - { - let claims = single_sign_on.verify_token(token).await?; - let tenant = &AppConfig::get().tenant; - - // Validate tenant authorization - let Some(tenant_list) = &claims.tenant_list else { - bail!("failed to authorize user: no tenant list in token"); - }; - ensure!( - tenant_list.contains(tenant), - "failed to authorize user: insufficient permissions for tenant" - ); - - // Validate role-based authorization - let Some(roles) = &claims.roles else { - bail!("failed to authorize user: no roles in token"); - }; - - // FleetAdministrator has full access - if roles.iter().any(|r| r == "FleetAdministrator") { - return Ok(()); - } - - // FleetOperator requires fleet validation - if roles.iter().any(|r| r == "FleetOperator") { - let Some(fleet_list) = &claims.fleet_list else { - bail!("failed to authorize user: no fleet list in token"); - }; - let fleet_id = service_client.fleet_id().await?; - ensure!( - fleet_list.contains(&fleet_id), - "failed to authorize user: insufficient permissions for fleet" - ); - return Ok(()); - } - - bail!("failed to authorize user: insufficient role permissions") - } -} diff --git a/vue/.gitignore b/src/ui/.gitignore similarity index 100% rename from vue/.gitignore rename to src/ui/.gitignore diff --git a/vue/README.md b/src/ui/README.md similarity index 100% rename from vue/README.md rename to src/ui/README.md diff --git a/vue/biome.json b/src/ui/biome.json similarity index 100% rename from vue/biome.json rename to src/ui/biome.json diff --git a/vue/bun.lock b/src/ui/bun.lock similarity index 100% rename from vue/bun.lock rename to src/ui/bun.lock diff --git a/vue/index.html b/src/ui/index.html similarity index 100% rename from vue/index.html rename to src/ui/index.html diff --git a/vue/package.json b/src/ui/package.json similarity index 100% rename from vue/package.json rename to src/ui/package.json diff --git a/vue/public/vite.svg b/src/ui/public/vite.svg similarity index 100% rename from vue/public/vite.svg rename to src/ui/public/vite.svg diff --git a/vue/src/App.vue b/src/ui/src/App.vue similarity index 100% rename from vue/src/App.vue rename to src/ui/src/App.vue diff --git a/vue/src/assets/favicon.ico b/src/ui/src/assets/favicon.ico similarity index 100% rename from vue/src/assets/favicon.ico rename to src/ui/src/assets/favicon.ico diff --git a/vue/src/assets/vue.svg b/src/ui/src/assets/vue.svg similarity index 100% rename from vue/src/assets/vue.svg rename to src/ui/src/assets/vue.svg diff --git a/vue/src/auth/auth-service.ts b/src/ui/src/auth/auth-service.ts similarity index 100% rename from vue/src/auth/auth-service.ts rename to src/ui/src/auth/auth-service.ts diff --git a/vue/src/auth/oidc.ts b/src/ui/src/auth/oidc.ts similarity index 100% rename from vue/src/auth/oidc.ts rename to src/ui/src/auth/oidc.ts diff --git a/vue/src/components/BaseSideBar.vue b/src/ui/src/components/BaseSideBar.vue similarity index 100% rename from vue/src/components/BaseSideBar.vue rename to src/ui/src/components/BaseSideBar.vue diff --git a/vue/src/components/DeviceActions.vue b/src/ui/src/components/DeviceActions.vue similarity index 100% rename from vue/src/components/DeviceActions.vue rename to src/ui/src/components/DeviceActions.vue diff --git a/vue/src/components/DeviceInfo.vue b/src/ui/src/components/DeviceInfo.vue similarity index 100% rename from vue/src/components/DeviceInfo.vue rename to src/ui/src/components/DeviceInfo.vue diff --git a/vue/src/components/DeviceNetworks.vue b/src/ui/src/components/DeviceNetworks.vue similarity index 100% rename from vue/src/components/DeviceNetworks.vue rename to src/ui/src/components/DeviceNetworks.vue diff --git a/vue/src/components/DialogContent.vue b/src/ui/src/components/DialogContent.vue similarity index 100% rename from vue/src/components/DialogContent.vue rename to src/ui/src/components/DialogContent.vue diff --git a/vue/src/components/Menu.vue b/src/ui/src/components/Menu.vue similarity index 100% rename from vue/src/components/Menu.vue rename to src/ui/src/components/Menu.vue diff --git a/vue/src/components/NetworkActions.vue b/src/ui/src/components/NetworkActions.vue similarity index 100% rename from vue/src/components/NetworkActions.vue rename to src/ui/src/components/NetworkActions.vue diff --git a/vue/src/components/NetworkSettings.vue b/src/ui/src/components/NetworkSettings.vue similarity index 100% rename from vue/src/components/NetworkSettings.vue rename to src/ui/src/components/NetworkSettings.vue diff --git a/vue/src/components/OmnectLogo.vue b/src/ui/src/components/OmnectLogo.vue similarity index 100% rename from vue/src/components/OmnectLogo.vue rename to src/ui/src/components/OmnectLogo.vue diff --git a/vue/src/components/OverlaySpinner.vue b/src/ui/src/components/OverlaySpinner.vue similarity index 100% rename from vue/src/components/OverlaySpinner.vue rename to src/ui/src/components/OverlaySpinner.vue diff --git a/vue/src/components/UpdateFileUpload.vue b/src/ui/src/components/UpdateFileUpload.vue similarity index 100% rename from vue/src/components/UpdateFileUpload.vue rename to src/ui/src/components/UpdateFileUpload.vue diff --git a/vue/src/components/UpdateInfo.vue b/src/ui/src/components/UpdateInfo.vue similarity index 100% rename from vue/src/components/UpdateInfo.vue rename to src/ui/src/components/UpdateInfo.vue diff --git a/vue/src/components/UserMenu.vue b/src/ui/src/components/UserMenu.vue similarity index 100% rename from vue/src/components/UserMenu.vue rename to src/ui/src/components/UserMenu.vue diff --git a/vue/src/components/ui-components/KeyValuePair.vue b/src/ui/src/components/ui-components/KeyValuePair.vue similarity index 100% rename from vue/src/components/ui-components/KeyValuePair.vue rename to src/ui/src/components/ui-components/KeyValuePair.vue diff --git a/vue/src/composables/useAwaitUpdate.ts b/src/ui/src/composables/useAwaitUpdate.ts similarity index 100% rename from vue/src/composables/useAwaitUpdate.ts rename to src/ui/src/composables/useAwaitUpdate.ts diff --git a/vue/src/composables/useCentrifugo.ts b/src/ui/src/composables/useCentrifugo.ts similarity index 100% rename from vue/src/composables/useCentrifugo.ts rename to src/ui/src/composables/useCentrifugo.ts diff --git a/vue/src/composables/useEventHook.ts b/src/ui/src/composables/useEventHook.ts similarity index 100% rename from vue/src/composables/useEventHook.ts rename to src/ui/src/composables/useEventHook.ts diff --git a/vue/src/composables/useOverlaySpinner.ts b/src/ui/src/composables/useOverlaySpinner.ts similarity index 100% rename from vue/src/composables/useOverlaySpinner.ts rename to src/ui/src/composables/useOverlaySpinner.ts diff --git a/vue/src/composables/useSnackbar.ts b/src/ui/src/composables/useSnackbar.ts similarity index 100% rename from vue/src/composables/useSnackbar.ts rename to src/ui/src/composables/useSnackbar.ts diff --git a/vue/src/composables/useWaitForNewIp.ts b/src/ui/src/composables/useWaitForNewIp.ts similarity index 100% rename from vue/src/composables/useWaitForNewIp.ts rename to src/ui/src/composables/useWaitForNewIp.ts diff --git a/vue/src/composables/useWaitReconnect.ts b/src/ui/src/composables/useWaitReconnect.ts similarity index 100% rename from vue/src/composables/useWaitReconnect.ts rename to src/ui/src/composables/useWaitReconnect.ts diff --git a/vue/src/enums/centrifuge-subscription-type.enum.ts b/src/ui/src/enums/centrifuge-subscription-type.enum.ts similarity index 100% rename from vue/src/enums/centrifuge-subscription-type.enum.ts rename to src/ui/src/enums/centrifuge-subscription-type.enum.ts diff --git a/vue/src/main.ts b/src/ui/src/main.ts similarity index 100% rename from vue/src/main.ts rename to src/ui/src/main.ts diff --git a/vue/src/pages/Callback.vue b/src/ui/src/pages/Callback.vue similarity index 100% rename from vue/src/pages/Callback.vue rename to src/ui/src/pages/Callback.vue diff --git a/vue/src/pages/DeviceOverview.vue b/src/ui/src/pages/DeviceOverview.vue similarity index 100% rename from vue/src/pages/DeviceOverview.vue rename to src/ui/src/pages/DeviceOverview.vue diff --git a/vue/src/pages/DeviceUpdate.vue b/src/ui/src/pages/DeviceUpdate.vue similarity index 100% rename from vue/src/pages/DeviceUpdate.vue rename to src/ui/src/pages/DeviceUpdate.vue diff --git a/vue/src/pages/Login.vue b/src/ui/src/pages/Login.vue similarity index 100% rename from vue/src/pages/Login.vue rename to src/ui/src/pages/Login.vue diff --git a/vue/src/pages/Network.vue b/src/ui/src/pages/Network.vue similarity index 100% rename from vue/src/pages/Network.vue rename to src/ui/src/pages/Network.vue diff --git a/vue/src/pages/SetPassword.vue b/src/ui/src/pages/SetPassword.vue similarity index 100% rename from vue/src/pages/SetPassword.vue rename to src/ui/src/pages/SetPassword.vue diff --git a/vue/src/pages/UpdatePassword.vue b/src/ui/src/pages/UpdatePassword.vue similarity index 100% rename from vue/src/pages/UpdatePassword.vue rename to src/ui/src/pages/UpdatePassword.vue diff --git a/vue/src/plugins/index.ts b/src/ui/src/plugins/index.ts similarity index 100% rename from vue/src/plugins/index.ts rename to src/ui/src/plugins/index.ts diff --git a/vue/src/plugins/router.ts b/src/ui/src/plugins/router.ts similarity index 100% rename from vue/src/plugins/router.ts rename to src/ui/src/plugins/router.ts diff --git a/vue/src/plugins/vuetify.ts b/src/ui/src/plugins/vuetify.ts similarity index 100% rename from vue/src/plugins/vuetify.ts rename to src/ui/src/plugins/vuetify.ts diff --git a/vue/src/style.css b/src/ui/src/style.css similarity index 100% rename from vue/src/style.css rename to src/ui/src/style.css diff --git a/vue/src/theme/theme.default.ts b/src/ui/src/theme/theme.default.ts similarity index 100% rename from vue/src/theme/theme.default.ts rename to src/ui/src/theme/theme.default.ts diff --git a/vue/src/types/factory-reset.ts b/src/ui/src/types/factory-reset.ts similarity index 100% rename from vue/src/types/factory-reset.ts rename to src/ui/src/types/factory-reset.ts diff --git a/vue/src/types/global.d.ts b/src/ui/src/types/global.d.ts similarity index 100% rename from vue/src/types/global.d.ts rename to src/ui/src/types/global.d.ts diff --git a/vue/src/types/healthcheck-response.ts b/src/ui/src/types/healthcheck-response.ts similarity index 100% rename from vue/src/types/healthcheck-response.ts rename to src/ui/src/types/healthcheck-response.ts diff --git a/vue/src/types/index.ts b/src/ui/src/types/index.ts similarity index 100% rename from vue/src/types/index.ts rename to src/ui/src/types/index.ts diff --git a/vue/src/types/network-status.ts b/src/ui/src/types/network-status.ts similarity index 100% rename from vue/src/types/network-status.ts rename to src/ui/src/types/network-status.ts diff --git a/vue/src/types/online-status.ts b/src/ui/src/types/online-status.ts similarity index 100% rename from vue/src/types/online-status.ts rename to src/ui/src/types/online-status.ts diff --git a/vue/src/types/system-info.ts b/src/ui/src/types/system-info.ts similarity index 100% rename from vue/src/types/system-info.ts rename to src/ui/src/types/system-info.ts diff --git a/vue/src/types/timeouts.ts b/src/ui/src/types/timeouts.ts similarity index 100% rename from vue/src/types/timeouts.ts rename to src/ui/src/types/timeouts.ts diff --git a/vue/src/types/update-manifest.ts b/src/ui/src/types/update-manifest.ts similarity index 100% rename from vue/src/types/update-manifest.ts rename to src/ui/src/types/update-manifest.ts diff --git a/vue/src/types/update-validation-status.ts b/src/ui/src/types/update-validation-status.ts similarity index 100% rename from vue/src/types/update-validation-status.ts rename to src/ui/src/types/update-validation-status.ts diff --git a/vue/src/uno.config.ts b/src/ui/src/uno.config.ts similarity index 100% rename from vue/src/uno.config.ts rename to src/ui/src/uno.config.ts diff --git a/vue/src/vite-env.d.ts b/src/ui/src/vite-env.d.ts similarity index 100% rename from vue/src/vite-env.d.ts rename to src/ui/src/vite-env.d.ts diff --git a/vue/tsconfig.app.json b/src/ui/tsconfig.app.json similarity index 100% rename from vue/tsconfig.app.json rename to src/ui/tsconfig.app.json diff --git a/vue/tsconfig.json b/src/ui/tsconfig.json similarity index 100% rename from vue/tsconfig.json rename to src/ui/tsconfig.json diff --git a/vue/tsconfig.node.json b/src/ui/tsconfig.node.json similarity index 100% rename from vue/tsconfig.node.json rename to src/ui/tsconfig.node.json diff --git a/vue/vite.config.ts b/src/ui/vite.config.ts similarity index 100% rename from vue/vite.config.ts rename to src/ui/vite.config.ts