From dc0fccbbc6ffd03212e8370ab6ccc841b59087cc Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 21 May 2026 02:29:20 -0300 Subject: [PATCH] game-stream: align Ariadne Wolf support with current app --- ariadne/app.py | 1058 +--------------- ariadne/app_game_routes.py | 168 +++ ariadne/k8s/client.py | 14 +- ariadne/metrics/metrics.py | 16 +- ariadne/services/game_mode.py | 33 +- ariadne/services/game_stream_profiles.py | 18 +- ariadne/services/keycloak_admin.py | 10 +- ariadne/services/oauth2_proxy.py | 22 +- ariadne/services/testing_triage_diagnosis.py | 15 +- ariadne/services/vault.py | 267 +--- ariadne/services/vault_policies.py | 7 + ariadne/settings.py | 479 +------- ariadne/settings_sections.py | 25 + tests/test_app.py | 1094 ----------------- tests/test_game_mode.py | 106 +- tests/test_game_mode_metrics.py | 10 + tests/test_game_stream_profiles.py | 7 - tests/test_k8s_client.py | 17 +- tests/test_keycloak_admin.py | 768 ------------ tests/test_oauth2_proxy.py | 67 +- tests/test_settings.py | 23 + tests/test_testing_triage_diagnosis.py | 60 + tests/test_vault.py | 35 +- tests/unit/app/test_app_game_routes.py | 121 ++ tests/unit/app/test_app_lifecycle.py | 1 + .../services/test_keycloak_admin_lifecycle.py | 54 + 26 files changed, 716 insertions(+), 3779 deletions(-) create mode 100644 ariadne/app_game_routes.py delete mode 100644 tests/test_app.py create mode 100644 tests/test_game_mode_metrics.py delete mode 100644 tests/test_keycloak_admin.py create mode 100644 tests/unit/app/test_app_game_routes.py diff --git a/ariadne/app.py b/ariadne/app.py index 34872a5..8bc3984 100644 --- a/ariadne/app.py +++ b/ariadne/app.py @@ -1,75 +1,61 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime, timezone import json -import secrets -import threading -from typing import Any, Callable +import sys +from typing import Any from fastapi import Body, Depends, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from .app_account_routes import _register_account_routes +from .app_admin_routes import _register_admin_routes +from .app_game_routes import _register_game_routes from .auth.keycloak import AuthContext, authenticator from .db.database import Database, DatabaseConfig -from .db.storage import Storage, TaskRunRecord +from .db.storage import Storage from .manager.provisioning import ProvisioningManager from .metrics.metrics import record_task_run from .scheduler.cron import CronScheduler from .services.cluster_state import run_cluster_state from .services.comms import comms from .services.firefly import firefly +from .services.game_mode import game_mode +from .services.game_stream_profiles import game_stream_profiles +from .services.image_sweeper import image_sweeper +from .services.jenkins_build_weather import collect_jenkins_build_weather +from .services.jenkins_workspace_cleanup import cleanup_jenkins_workspace_storage from .services.keycloak_admin import keycloak_admin from .services.keycloak_profile import run_profile_sync from .services.mailu import mailu from .services.mailu_events import mailu_events -from .services.game_stream_profiles import game_stream_profiles -from .services.game_mode import game_mode -from .services.nextcloud import nextcloud -from .services.image_sweeper import image_sweeper -from .services.jenkins_build_weather import collect_jenkins_build_weather -from .services.jenkins_workspace_cleanup import cleanup_jenkins_workspace_storage from .services.metis import metis from .services.metis_token_sync import metis_token_sync +from .services.nextcloud import nextcloud from .services.oauth2_proxy import oauth2_proxy from .services.opensearch_prune import prune_indices from .services.platform_quality_probe import platform_quality_probe from .services.pod_cleaner import clean_finished_pods -from .services.vaultwarden_sync import run_vaultwarden_sync +from .services.testing_triage import ( + TRIAGE_EVENT_TYPE, + collect_testing_triage, + latest_testing_triage_bundle, + latest_testing_triage_diagnosis, + run_testing_triage, + run_testing_triage_diagnosis, +) from .services.vault import vault +from .services.vaultwarden_sync import run_vaultwarden_sync from .services.wger import wger from .settings import settings -from .utils.errors import safe_error_detail from .utils.http import extract_bearer_token -from .utils.logging import LogConfig, configure_logging, get_logger, task_context +from .utils.logging import LogConfig, configure_logging, get_logger from .utils.passwords import random_password configure_logging(LogConfig(level=settings.log_level)) logger = get_logger(__name__) - -@dataclass(frozen=True) -class AccountTaskContext: - task_name: str - username: str - started: datetime - extra: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class PasswordResetRequest: - task_name: str - service_label: str - username: str - mailu_email: str - password: str - sync_fn: Callable[[], dict[str, Any]] - password_attr: str - updated_attr: str - error_hint: str - portal_db = Database( settings.portal_database_url, DatabaseConfig( @@ -97,6 +83,7 @@ ariadne_db = Database( storage = Storage(ariadne_db, portal_db) provisioning = ProvisioningManager(portal_db, storage) scheduler = CronScheduler(storage, settings.schedule_tick_sec) +app = FastAPI(title=settings.app_name) def _record_event(event_type: str, detail: dict[str, Any] | str | None) -> None: @@ -115,9 +102,6 @@ def _parse_event_detail(detail: str | None) -> Any: return detail -app = FastAPI(title=settings.app_name) - - def _require_auth(request: Request) -> AuthContext: token = extract_bearer_token(request) if not token: @@ -159,22 +143,6 @@ def _note_from_payload(payload: dict[str, Any]) -> str | None: return str(note).strip() if isinstance(note, str) and note.strip() else None -def _game_from_payload(payload: dict[str, Any]) -> str: - game = payload.get("game") if isinstance(payload, dict) else None - return str(game).strip() if isinstance(game, str) and game.strip() else "wolf" - - -def _require_game_mode_hook(request: Request) -> None: - expected = settings.game_mode_hook_token - if not expected: - raise HTTPException(status_code=503, detail="game mode hook token not configured") - token = request.headers.get("x-ariadne-game-mode-token", "") - if not token and request.headers.get("authorization", "").lower().startswith("bearer "): - token = request.headers.get("authorization", "")[7:].strip() - if not secrets.compare_digest(token, expected): - raise HTTPException(status_code=401, detail="invalid game mode hook token") - - def _flags_from_payload(payload: dict[str, Any]) -> list[str]: flags_raw = payload.get("flags") if isinstance(payload, dict) else None return [flag for flag in flags_raw if isinstance(flag, str)] if isinstance(flags_raw, list) else [] @@ -189,112 +157,8 @@ def _allowed_flag_groups() -> list[str]: return settings.allowed_flag_groups -def _resolve_mailu_email(username: str) -> str: - mailu_email = f"{username}@{settings.mailu_domain}" - try: - user = keycloak_admin.find_user(username) or {} - attrs = user.get("attributes") if isinstance(user, dict) else None - if isinstance(attrs, dict): - raw_mailu = attrs.get("mailu_email") - if isinstance(raw_mailu, list) and raw_mailu: - return str(raw_mailu[0]) - if isinstance(raw_mailu, str) and raw_mailu: - return raw_mailu - except Exception: - return mailu_email - return mailu_email - - -def _record_account_task(ctx: AccountTaskContext, status: str, error_detail: str) -> None: - finished = datetime.now(timezone.utc) - duration_sec = (finished - ctx.started).total_seconds() - record_task_run(ctx.task_name, status, duration_sec) - try: - storage.record_task_run( - TaskRunRecord( - request_code=None, - task=ctx.task_name, - status=status, - detail=error_detail or None, - started_at=ctx.started, - finished_at=finished, - duration_ms=int(duration_sec * 1000), - ) - ) - except Exception: - pass - detail = {"username": ctx.username, "status": status, "error": error_detail} - if ctx.extra: - detail.update(ctx.extra) - _record_event(ctx.task_name, detail) - - -def _record_simple_task(task_name: str, started: datetime, status: str, detail: str | None = None) -> None: - finished = datetime.now(timezone.utc) - duration_sec = (finished - started).total_seconds() - record_task_run(task_name, status, duration_sec) - try: - storage.record_task_run( - TaskRunRecord( - request_code=None, - task=task_name, - status=status, - detail=detail, - started_at=started, - finished_at=finished, - duration_ms=int(duration_sec * 1000), - ) - ) - except Exception: - pass - - -def _run_password_reset(request: PasswordResetRequest) -> JSONResponse: - started = datetime.now(timezone.utc) - task_ctx = AccountTaskContext( - task_name=request.task_name, - username=request.username, - started=started, - extra={"mailu_email": request.mailu_email}, - ) - status = "ok" - error_detail = "" - logger.info( - f"{request.service_label} password reset requested", - extra={"event": request.task_name, "username": request.username}, - ) - try: - result = request.sync_fn() - status_val = result.get("status") if isinstance(result, dict) else "error" - if status_val != "ok": - raise RuntimeError(f"{request.service_label} sync {status_val}") - - keycloak_admin.set_user_attribute( - request.username, - request.password_attr, - request.password, - ) - keycloak_admin.set_user_attribute( - request.username, - request.updated_attr, - datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - ) - - logger.info( - f"{request.service_label} password reset completed", - extra={"event": request.task_name, "username": request.username}, - ) - return JSONResponse({"status": "ok", "password": request.password}) - except HTTPException as exc: - status = "error" - error_detail = str(exc.detail) - raise - except Exception as exc: - status = "error" - error_detail = safe_error_detail(exc, request.error_hint) - raise HTTPException(status_code=502, detail=error_detail) - finally: - _record_account_task(task_ctx, status, error_detail) +def _app_module() -> Any: + return sys.modules[__name__] @app.on_event("startup") @@ -302,123 +166,36 @@ def _startup() -> None: provisioning.start() scheduler.add_task("schedule.mailu_sync", settings.mailu_sync_cron, lambda: mailu.sync("ariadne_schedule")) - scheduler.add_task( - "schedule.nextcloud_sync", - settings.nextcloud_sync_cron, - lambda: nextcloud.sync_mail(wait=False), - ) - scheduler.add_task( - "schedule.nextcloud_cron", - settings.nextcloud_cron, - lambda: nextcloud.run_cron(), - ) - scheduler.add_task( - "schedule.nextcloud_maintenance", - settings.nextcloud_maintenance_cron, - lambda: nextcloud.run_maintenance(), - ) + scheduler.add_task("schedule.nextcloud_sync", settings.nextcloud_sync_cron, lambda: nextcloud.sync_mail(wait=False)) + scheduler.add_task("schedule.nextcloud_cron", settings.nextcloud_cron, lambda: nextcloud.run_cron()) + scheduler.add_task("schedule.nextcloud_maintenance", settings.nextcloud_maintenance_cron, lambda: nextcloud.run_maintenance()) scheduler.add_task("schedule.vaultwarden_sync", settings.vaultwarden_sync_cron, run_vaultwarden_sync) - scheduler.add_task( - "schedule.keycloak_profile", - settings.keycloak_profile_cron, - run_profile_sync, - ) - scheduler.add_task( - "schedule.wger_user_sync", - settings.wger_user_sync_cron, - lambda: wger.sync_users(), - ) + scheduler.add_task("schedule.keycloak_profile", settings.keycloak_profile_cron, run_profile_sync) + scheduler.add_task("schedule.wger_user_sync", settings.wger_user_sync_cron, lambda: wger.sync_users()) scheduler.add_task("schedule.wger_admin", settings.wger_admin_cron, lambda: wger.ensure_admin(wait=False)) - scheduler.add_task( - "schedule.firefly_user_sync", - settings.firefly_user_sync_cron, - lambda: firefly.sync_users(), - ) - scheduler.add_task( - "schedule.firefly_cron", - settings.firefly_cron, - lambda: firefly.run_cron(), - ) - scheduler.add_task( - "schedule.pod_cleaner", - settings.pod_cleaner_cron, - clean_finished_pods, - ) - scheduler.add_task( - "schedule.opensearch_prune", - settings.opensearch_prune_cron, - prune_indices, - ) - scheduler.add_task( - "schedule.image_sweeper", - settings.image_sweeper_cron, - lambda: image_sweeper.run(wait=True), - ) - scheduler.add_task( - "schedule.metis_sentinel_watch", - settings.metis_sentinel_watch_cron, - lambda: metis.watch_sentinel(), - ) - scheduler.add_task( - "schedule.metis_k3s_token_sync", - settings.metis_k3s_token_sync_cron, - lambda: metis_token_sync.run(wait=True), - ) + scheduler.add_task("schedule.firefly_user_sync", settings.firefly_user_sync_cron, lambda: firefly.sync_users()) + scheduler.add_task("schedule.firefly_cron", settings.firefly_cron, lambda: firefly.run_cron()) + scheduler.add_task("schedule.pod_cleaner", settings.pod_cleaner_cron, clean_finished_pods) + scheduler.add_task("schedule.opensearch_prune", settings.opensearch_prune_cron, prune_indices) + scheduler.add_task("schedule.image_sweeper", settings.image_sweeper_cron, lambda: image_sweeper.run(wait=True)) + scheduler.add_task("schedule.metis_sentinel_watch", settings.metis_sentinel_watch_cron, lambda: metis.watch_sentinel()) + scheduler.add_task("schedule.metis_k3s_token_sync", settings.metis_k3s_token_sync_cron, lambda: metis_token_sync.run(wait=True)) scheduler.add_task( "schedule.platform_quality_suite_probe", settings.platform_quality_suite_probe_cron, lambda: platform_quality_probe.run(wait=True), ) - scheduler.add_task( - "schedule.jenkins_build_weather", - settings.jenkins_build_weather_cron, - collect_jenkins_build_weather, - ) - scheduler.add_task( - "schedule.jenkins_workspace_cleanup", - settings.jenkins_workspace_cleanup_cron, - cleanup_jenkins_workspace_storage, - ) - scheduler.add_task( - "schedule.vault_k8s_auth", - settings.vault_k8s_auth_cron, - lambda: vault.sync_k8s_auth(wait=True), - ) - scheduler.add_task( - "schedule.vault_oidc", - settings.vault_oidc_cron, - lambda: vault.sync_oidc(wait=True), - ) - scheduler.add_task( - "schedule.comms_guest_name", - settings.comms_guest_name_cron, - lambda: comms.run_guest_name_randomizer(wait=True), - ) - scheduler.add_task( - "schedule.comms_pin_invite", - settings.comms_pin_invite_cron, - lambda: comms.run_pin_invite(wait=True), - ) - scheduler.add_task( - "schedule.comms_reset_room", - settings.comms_reset_room_cron, - lambda: comms.run_reset_room(wait=True), - ) - scheduler.add_task( - "schedule.comms_seed_room", - settings.comms_seed_room_cron, - lambda: comms.run_seed_room(wait=True), - ) - scheduler.add_task( - "schedule.cluster_state", - settings.cluster_state_cron, - lambda: run_cluster_state(storage), - ) - scheduler.add_task( - "schedule.wolf_oidc", - settings.wolf_oidc_cron, - lambda: oauth2_proxy.ensure_wolf(), - ) + scheduler.add_task("schedule.jenkins_build_weather", settings.jenkins_build_weather_cron, collect_jenkins_build_weather) + scheduler.add_task("schedule.jenkins_workspace_cleanup", settings.jenkins_workspace_cleanup_cron, cleanup_jenkins_workspace_storage) + scheduler.add_task("schedule.testing_triage", settings.testing_triage_cron, lambda: run_testing_triage(storage)) + scheduler.add_task("schedule.vault_k8s_auth", settings.vault_k8s_auth_cron, lambda: vault.sync_k8s_auth(wait=True)) + scheduler.add_task("schedule.vault_oidc", settings.vault_oidc_cron, lambda: vault.sync_oidc(wait=True)) + scheduler.add_task("schedule.comms_guest_name", settings.comms_guest_name_cron, lambda: comms.run_guest_name_randomizer(wait=True)) + scheduler.add_task("schedule.comms_pin_invite", settings.comms_pin_invite_cron, lambda: comms.run_pin_invite(wait=True)) + scheduler.add_task("schedule.comms_reset_room", settings.comms_reset_room_cron, lambda: comms.run_reset_room(wait=True)) + scheduler.add_task("schedule.comms_seed_room", settings.comms_seed_room_cron, lambda: comms.run_seed_room(wait=True)) + scheduler.add_task("schedule.cluster_state", settings.cluster_state_cron, lambda: run_cluster_state(storage)) + scheduler.add_task("schedule.wolf_oidc", settings.wolf_oidc_cron, lambda: oauth2_proxy.ensure_wolf()) scheduler.start() logger.info( "ariadne started", @@ -444,6 +221,7 @@ def _startup() -> None: "jenkins_workspace_cleanup_cron": settings.jenkins_workspace_cleanup_cron, "jenkins_workspace_cleanup_dry_run": settings.jenkins_workspace_cleanup_dry_run, "jenkins_workspace_cleanup_max_deletions_per_run": settings.jenkins_workspace_cleanup_max_deletions_per_run, + "testing_triage_cron": settings.testing_triage_cron, "vault_k8s_auth_cron": settings.vault_k8s_auth_cron, "vault_oidc_cron": settings.vault_oidc_cron, "comms_guest_name_cron": settings.comms_guest_name_cron, @@ -481,742 +259,14 @@ def metrics() -> Response: return Response(payload, media_type=CONTENT_TYPE_LATEST) -@app.get("/api/admin/access/requests") -def list_access_requests(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Return pending access requests for authenticated administrators.""" - - _require_admin(ctx) - logger.info( - "list access requests", - extra={"event": "access_requests_list", "actor": ctx.username or ""}, - ) - try: - rows = storage.list_pending_requests() - except Exception: - raise HTTPException(status_code=502, detail="failed to load requests") - - output: list[dict[str, Any]] = [] - for row in rows: - created_at = row.get("created_at") - output.append( - { - "id": row.get("request_code"), - "username": row.get("username"), - "email": row.get("contact_email") or "", - "first_name": row.get("first_name") or "", - "last_name": row.get("last_name") or "", - "request_code": row.get("request_code"), - "created_at": created_at.isoformat() if isinstance(created_at, datetime) else "", - "note": row.get("note") or "", - } - ) - return JSONResponse({"requests": output}) - - -@app.get("/api/admin/access/flags") -def list_access_flags(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Return Keycloak groups that can be applied as access-request flags.""" - - _require_admin(ctx) - flags = settings.allowed_flag_groups - if keycloak_admin.ready(): - try: - flags = keycloak_admin.list_group_names(exclude={"admin"}) - except Exception: - flags = settings.allowed_flag_groups - return JSONResponse({"flags": flags}) - - -@app.get("/api/admin/audit/events") -def list_audit_events( - limit: int = 200, - event_type: str | None = None, - ctx: AuthContext = Depends(_require_auth), -) -> JSONResponse: - """Return recent audit events with optional type filtering.""" - - _require_admin(ctx) - try: - rows = storage.list_events(limit=limit, event_type=event_type) - except Exception: - raise HTTPException(status_code=502, detail="failed to load audit events") - - output: list[dict[str, Any]] = [] - for row in rows: - created_at = row.get("created_at") - output.append( - { - "id": row.get("id"), - "event_type": row.get("event_type"), - "detail": _parse_event_detail(row.get("detail")), - "created_at": created_at.isoformat() if isinstance(created_at, datetime) else "", - } - ) - return JSONResponse({"events": output}) - - -@app.get("/api/admin/audit/task-runs") -def list_audit_task_runs( - limit: int = 200, - request_code: str | None = None, - task: str | None = None, - ctx: AuthContext = Depends(_require_auth), -) -> JSONResponse: - """Return recorded background task runs for admin audit views.""" - - _require_admin(ctx) - try: - rows = storage.list_task_runs(limit=limit, request_code=request_code, task=task) - except Exception: - raise HTTPException(status_code=502, detail="failed to load task runs") - - output: list[dict[str, Any]] = [] - for row in rows: - started_at = row.get("started_at") - finished_at = row.get("finished_at") - output.append( - { - "id": row.get("id"), - "request_code": row.get("request_code") or "", - "task": row.get("task") or "", - "status": row.get("status") or "", - "detail": _parse_event_detail(row.get("detail")), - "started_at": started_at.isoformat() if isinstance(started_at, datetime) else "", - "finished_at": finished_at.isoformat() if isinstance(finished_at, datetime) else "", - "duration_ms": row.get("duration_ms"), - } - ) - return JSONResponse({"task_runs": output}) - - -@app.get("/api/admin/cluster/state") -def get_cluster_state(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Return the latest cluster-state snapshot to authenticated administrators.""" - - _require_admin(ctx) - snapshot = storage.latest_cluster_state() - if not snapshot: - raise HTTPException(status_code=404, detail="cluster state unavailable") - return JSONResponse(snapshot) - - -@app.get("/api/internal/cluster/state") -def get_cluster_state_internal() -> JSONResponse: - """Return the latest cluster-state snapshot for trusted internal callers.""" - - snapshot = storage.latest_cluster_state() - if not snapshot: - raise HTTPException(status_code=404, detail="cluster state unavailable") - return JSONResponse(snapshot) - - -@app.get("/api/game-stream/me") -def get_game_stream_profile(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Return the Wolf profile policy for the authenticated Keycloak user.""" - - return JSONResponse(game_stream_profiles.profile_for(ctx.username or "", ctx.groups)) - - -@app.get("/api/admin/game-mode/status") -def get_game_mode_status(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Return the current game-mode state for authenticated administrators.""" - - _require_admin(ctx) - try: - return JSONResponse(game_mode.status()) - except Exception: - raise HTTPException(status_code=502, detail="failed to load game mode status") - - -async def _run_game_mode_action(action: str, payload: dict[str, Any], actor: str) -> JSONResponse: - started = datetime.now(timezone.utc) - status = "ok" - error_detail = "" - game = _game_from_payload(payload) - note = _note_from_payload(payload) - task_name = f"game_mode_{action}" - try: - if action == "start": - result = game_mode.start(game, note=note) - elif action == "stop": - result = game_mode.stop(game, note=note) - else: - raise HTTPException(status_code=400, detail="invalid action") - _record_event( - task_name, - { - "actor": actor, - "status": "ok", - "game": game, - "note": note or "", - "result": result, - }, - ) - return JSONResponse(result) - except HTTPException: - status = "error" - raise - except Exception as exc: - status = "error" - error_detail = safe_error_detail(exc, f"game mode {action} failed") - _record_event( - task_name, - {"actor": actor, "status": "error", "game": game, "note": note or "", "error": error_detail}, - ) - raise HTTPException(status_code=502, detail=error_detail) - finally: - _record_simple_task(task_name, started, status, error_detail or None) - - -@app.post("/api/admin/game-mode/start") -async def start_game_mode(request: Request, ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Scale infrastructure GPU workloads down for an administrator-triggered game session.""" - - _require_admin(ctx) - payload = await _read_json_payload(request) - return await _run_game_mode_action("start", payload, ctx.username or "admin") - - -@app.post("/api/admin/game-mode/stop") -async def stop_game_mode(request: Request, ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Restore infrastructure GPU workloads after an administrator-triggered game session.""" - - _require_admin(ctx) - payload = await _read_json_payload(request) - return await _run_game_mode_action("stop", payload, ctx.username or "admin") - - -@app.post("/api/game-mode/start") -async def start_game_mode_hook(request: Request) -> JSONResponse: - """Scale infrastructure GPU workloads down for a trusted game-stream hook.""" - - _require_game_mode_hook(request) - payload = await _read_json_payload(request) - return await _run_game_mode_action("start", payload, "game-stream-hook") - - -@app.post("/api/game-mode/stop") -async def stop_game_mode_hook(request: Request) -> JSONResponse: - """Restore infrastructure GPU workloads for a trusted game-stream hook.""" - - _require_game_mode_hook(request) - payload = await _read_json_payload(request) - return await _run_game_mode_action("stop", payload, "game-stream-hook") - - -@app.post("/api/admin/game-stream/wolf/oauth2/ensure") -def ensure_wolf_oauth2(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Ensure Keycloak and Vault state for the Wolf oauth2-proxy.""" - - _require_admin(ctx) - started = datetime.now(timezone.utc) - status = "ok" - detail = "" - try: - result = oauth2_proxy.ensure_wolf() - if result.get("status") != "ok": - status = "error" - detail = str(result.get("detail") or "wolf oauth2 ensure failed") - raise HTTPException(status_code=502, detail=detail) - _record_event("wolf_oidc_ensure", {"actor": ctx.username or "admin", **result}) - return JSONResponse(result) - except HTTPException: - raise - except Exception as exc: - status = "error" - detail = safe_error_detail(exc, "wolf oauth2 ensure failed") - _record_event("wolf_oidc_ensure", {"actor": ctx.username or "admin", "status": "error", "detail": detail}) - raise HTTPException(status_code=502, detail=detail) - finally: - _record_simple_task("wolf_oidc_ensure", started, status, detail or None) - - -@app.post("/api/admin/game-stream/sunshine/oauth2/ensure") -def ensure_sunshine_oauth2_alias(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Keep the old Sunshine route as a transition alias for Wolf.""" - - return ensure_wolf_oauth2(ctx) - - -@app.post("/api/admin/access/requests/{username}/approve") -async def approve_access_request( - username: str, - request: Request, - ctx: AuthContext = Depends(_require_auth), -) -> JSONResponse: - """Approve a verified access request and start account provisioning.""" - - _require_admin(ctx) - with task_context("admin.access.approve"): - payload = await _read_json_payload(request) - allowed_flags = _allowed_flag_groups() - flags = [flag for flag in _flags_from_payload(payload) if flag in allowed_flags] - note = _note_from_payload(payload) - - decided_by = ctx.username or "" - try: - row = portal_db.fetchone( - """ - UPDATE access_requests - SET status = 'approved', - decided_at = NOW(), - decided_by = %s, - approval_flags = %s, - approval_note = %s - WHERE username = %s - AND status = 'pending' - AND email_verified_at IS NOT NULL - RETURNING request_code - """, - (decided_by or None, flags or None, note, username), - ) - except Exception: - raise HTTPException(status_code=502, detail="failed to approve request") - - if not row: - logger.info( - "access request approval ignored", - extra={"event": "access_request_approve", "actor": decided_by, "username": username, "status": "skipped"}, - ) - _record_event( - "access_request_approve", - { - "actor": decided_by, - "username": username, - "status": "skipped", - }, - ) - return JSONResponse({"ok": True, "request_code": ""}) - - request_code = row.get("request_code") or "" - if request_code: - threading.Thread( - target=provisioning.provision_access_request, - args=(request_code,), - daemon=True, - ).start() - logger.info( - "access request approved", - extra={ - "event": "access_request_approve", - "actor": decided_by, - "username": username, - "request_code": request_code, - }, - ) - _record_event( - "access_request_approve", - { - "actor": decided_by, - "username": username, - "request_code": request_code, - "status": "ok", - "flags": flags, - "note": note or "", - }, - ) - return JSONResponse({"ok": True, "request_code": request_code}) - - -@app.post("/api/admin/access/requests/{username}/deny") -async def deny_access_request( - username: str, - request: Request, - ctx: AuthContext = Depends(_require_auth), -) -> JSONResponse: - """Deny a pending access request and record the administrator decision.""" - - _require_admin(ctx) - with task_context("admin.access.deny"): - payload = await _read_json_payload(request) - note = _note_from_payload(payload) - decided_by = ctx.username or "" - - try: - row = portal_db.fetchone( - """ - UPDATE access_requests - SET status = 'denied', - decided_at = NOW(), - decided_by = %s, - denial_note = %s - WHERE username = %s AND status = 'pending' - RETURNING request_code - """, - (decided_by or None, note, username), - ) - except Exception: - raise HTTPException(status_code=502, detail="failed to deny request") - - if not row: - logger.info( - "access request denial ignored", - extra={"event": "access_request_deny", "actor": decided_by, "username": username, "status": "skipped"}, - ) - _record_event( - "access_request_deny", - { - "actor": decided_by, - "username": username, - "status": "skipped", - }, - ) - return JSONResponse({"ok": True, "request_code": ""}) - logger.info( - "access request denied", - extra={ - "event": "access_request_deny", - "actor": decided_by, - "username": username, - "request_code": row.get("request_code") or "", - }, - ) - _record_event( - "access_request_deny", - { - "actor": decided_by, - "username": username, - "request_code": row.get("request_code") or "", - "status": "ok", - "note": note or "", - }, - ) - return JSONResponse({"ok": True, "request_code": row.get("request_code")}) - - -@app.post("/api/access/requests/{request_code}/retry") -def retry_access_request(request_code: str) -> JSONResponse: - """Reset failed provisioning tasks so an approved request can retry.""" - - code = (request_code or "").strip() - if not code: - raise HTTPException(status_code=400, detail="request_code is required") - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - try: - row = portal_db.fetchone( - "SELECT status FROM access_requests WHERE request_code = %s", - (code,), - ) - except Exception: - raise HTTPException(status_code=502, detail="failed to load request") - - if not row: - raise HTTPException(status_code=404, detail="not found") - - status = (row.get("status") or "").strip() - if status not in {"accounts_building", "approved"}: - raise HTTPException(status_code=409, detail="request not retryable") - - try: - portal_db.execute( - "UPDATE access_requests SET provision_attempted_at = NULL WHERE request_code = %s", - (code,), - ) - portal_db.execute( - """ - UPDATE access_request_tasks - SET status = 'pending', - detail = 'retry requested', - updated_at = NOW() - WHERE request_code = %s AND status = 'error' - """, - (code,), - ) - except Exception: - raise HTTPException(status_code=502, detail="failed to update retry state") - - threading.Thread( - target=provisioning.provision_access_request, - args=(code,), - daemon=True, - ).start() - _record_event( - "access_request_retry", - { - "request_code": code, - "status": "ok", - }, - ) - return JSONResponse({"ok": True, "request_code": code}) - - -@app.post("/api/account/mailu/rotate") -def rotate_mailu_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Rotate the caller's Mailu app password and trigger dependent syncs.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - with task_context("account.mailu_rotate"): - started = datetime.now(timezone.utc) - status = "ok" - error_detail = "" - sync_enabled = mailu.ready() - sync_ok = False - sync_error = "" - nextcloud_sync: dict[str, Any] = {"status": "skipped"} - - logger.info( - "mailu password rotate requested", - extra={"event": "mailu_rotate", "username": username}, - ) - try: - password = random_password() - keycloak_admin.set_user_attribute(username, "mailu_app_password", password) - - if sync_enabled: - try: - mailu.sync("ariadne_mailu_rotate") - sync_ok = True - except Exception as exc: - sync_error = safe_error_detail(exc, "sync request failed") - - try: - nextcloud_sync = nextcloud.sync_mail(username, wait=True) - except Exception as exc: - nextcloud_sync = {"status": "error", "detail": safe_error_detail(exc, "failed to sync nextcloud")} - - logger.info( - "mailu password rotate completed", - extra={ - "event": "mailu_rotate", - "username": username, - "sync_enabled": sync_enabled, - "sync_ok": sync_ok, - "nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "", - }, - ) - return JSONResponse( - { - "password": password, - "sync_enabled": sync_enabled, - "sync_ok": sync_ok, - "sync_error": sync_error, - "nextcloud_sync": nextcloud_sync, - } - ) - except HTTPException as exc: - status = "error" - error_detail = str(exc.detail) - raise - except Exception as exc: - status = "error" - error_detail = safe_error_detail(exc, "mailu rotate failed") - raise HTTPException(status_code=502, detail=error_detail) - finally: - finished = datetime.now(timezone.utc) - duration_sec = (finished - started).total_seconds() - record_task_run("mailu_rotate", status, duration_sec) - try: - storage.record_task_run( - TaskRunRecord( - request_code=None, - task="mailu_rotate", - status=status, - detail=error_detail or None, - started_at=started, - finished_at=finished, - duration_ms=int(duration_sec * 1000), - ) - ) - except Exception: - pass - _record_event( - "mailu_rotate", - { - "username": username, - "status": status, - "sync_enabled": sync_enabled, - "sync_ok": sync_ok, - "nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "", - "error": error_detail, - }, - ) - - -@app.post("/api/account/wger/reset") -def reset_wger_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Reset the caller's Wger password and synchronize the service account.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.wger_reset"): - mailu_email = _resolve_mailu_email(username) - password = random_password() - request = PasswordResetRequest( - task_name="wger_reset", - service_label="wger", - username=username, - mailu_email=mailu_email, - password=password, - sync_fn=lambda: wger.sync_user(username, mailu_email, password, wait=True), - password_attr="wger_password", - updated_attr="wger_password_updated_at", - error_hint="wger sync failed", - ) - return _run_password_reset(request) - - -@app.post("/api/account/firefly/reset") -def reset_firefly_password(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Reset the caller's Firefly password and synchronize the service account.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.firefly_reset"): - mailu_email = _resolve_mailu_email(username) - password = random_password(24) - request = PasswordResetRequest( - task_name="firefly_reset", - service_label="firefly", - username=username, - mailu_email=mailu_email, - password=password, - sync_fn=lambda: firefly.sync_user(mailu_email, password, wait=True), - password_attr="firefly_password", - updated_attr="firefly_password_updated_at", - error_hint="firefly sync failed", - ) - return _run_password_reset(request) - - -@app.post("/api/account/firefly/rotation/check") -def firefly_rotation_check(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Check whether the caller's Firefly password rotation is healthy.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.firefly_rotation_check"): - result = firefly.check_rotation_for_user(username) - if result.get("status") == "error": - raise HTTPException(status_code=502, detail=result.get("detail") or "firefly rotation check failed") - return JSONResponse(result) - - -@app.post("/api/account/wger/rotation/check") -def wger_rotation_check(ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Check whether the caller's Wger password rotation is healthy.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.wger_rotation_check"): - result = wger.check_rotation_for_user(username) - if result.get("status") == "error": - raise HTTPException(status_code=502, detail=result.get("detail") or "wger rotation check failed") - return JSONResponse(result) - - -@app.post("/api/account/nextcloud/mail/sync") -async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(_require_auth)) -> JSONResponse: - """Synchronize the caller's Mailu address into Nextcloud mail settings.""" - - _require_account_access(ctx) - if not keycloak_admin.ready(): - raise HTTPException(status_code=503, detail="server not configured") - - username = ctx.username or "" - if not username: - raise HTTPException(status_code=400, detail="missing username") - - with task_context("account.nextcloud_sync"): - try: - payload = await request.json() - except Exception: - payload = {} - wait = bool(payload.get("wait", True)) if isinstance(payload, dict) else True - - started = datetime.now(timezone.utc) - status = "ok" - error_detail = "" - logger.info( - "nextcloud mail sync requested", - extra={"event": "nextcloud_sync", "username": username, "wait": wait}, - ) - try: - result = nextcloud.sync_mail(username, wait=wait) - logger.info( - "nextcloud mail sync completed", - extra={ - "event": "nextcloud_sync", - "username": username, - "status": result.get("status") if isinstance(result, dict) else "", - }, - ) - return JSONResponse(result) - except HTTPException as exc: - status = "error" - error_detail = str(exc.detail) - raise - except Exception as exc: - status = "error" - error_detail = safe_error_detail(exc, "failed to sync nextcloud mail") - logger.info( - "nextcloud mail sync failed", - extra={"event": "nextcloud_sync", "username": username, "error": error_detail}, - ) - raise HTTPException(status_code=502, detail=error_detail) - finally: - finished = datetime.now(timezone.utc) - duration_sec = (finished - started).total_seconds() - record_task_run("nextcloud_sync", status, duration_sec) - try: - storage.record_task_run( - TaskRunRecord( - request_code=None, - task="nextcloud_sync", - status=status, - detail=error_detail or None, - started_at=started, - finished_at=finished, - duration_ms=int(duration_sec * 1000), - ) - ) - except Exception: - pass - _record_event( - "nextcloud_sync", - { - "username": username, - "status": status, - "wait": wait, - "error": error_detail, - }, - ) - - @app.post("/events") def mailu_event_listener(payload: dict[str, Any] | None = Body(default=None)) -> Response: """Accept Mailu webhook events and dispatch mapped account actions.""" status_code, response = mailu_events.handle_event(payload) return JSONResponse(response, status_code=status_code) + + +_register_admin_routes(app, _require_auth, _app_module) +_register_account_routes(app, _require_auth, _app_module) +_register_game_routes(app, _require_auth, _app_module) diff --git a/ariadne/app_game_routes.py b/ariadne/app_game_routes.py new file mode 100644 index 0000000..f842323 --- /dev/null +++ b/ariadne/app_game_routes.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import secrets +from typing import Any, Callable + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from .auth.keycloak import AuthContext +from .db.storage import TaskRunRecord +from .utils.errors import safe_error_detail + + +def _game_from_payload(payload: dict[str, Any]) -> str: + game = payload.get("game") if isinstance(payload, dict) else None + return str(game).strip() if isinstance(game, str) and game.strip() else "wolf" + + +def _record_simple_task(module: Any, task_name: str, started: datetime, status: str, detail: str | None = None) -> None: + finished = datetime.now(timezone.utc) + duration_sec = (finished - started).total_seconds() + module.record_task_run(task_name, status, duration_sec) + try: + module.storage.record_task_run( + TaskRunRecord( + request_code=None, + task=task_name, + status=status, + detail=detail, + started_at=started, + finished_at=finished, + duration_ms=int(duration_sec * 1000), + ) + ) + except Exception: + pass + + +def _require_game_mode_hook(module: Any, request: Request) -> None: + expected = module.settings.game_mode_hook_token + if not expected: + raise HTTPException(status_code=503, detail="game mode hook token not configured") + token = request.headers.get("x-ariadne-game-mode-token", "") + if not token and request.headers.get("authorization", "").lower().startswith("bearer "): + token = request.headers.get("authorization", "")[7:].strip() + if not secrets.compare_digest(token, expected): + raise HTTPException(status_code=401, detail="invalid game mode hook token") + + +async def _run_game_mode_action(module: Any, action: str, payload: dict[str, Any], actor: str) -> JSONResponse: + started = datetime.now(timezone.utc) + status = "ok" + error_detail = "" + game = _game_from_payload(payload) + note = module._note_from_payload(payload) + task_name = f"game_mode_{action}" + try: + if action == "start": + result = module.game_mode.start(game, note=note) + elif action == "stop": + result = module.game_mode.stop(game, note=note) + else: + raise HTTPException(status_code=400, detail="invalid action") + module._record_event(task_name, {"actor": actor, "status": "ok", "game": game, "note": note or "", "result": result}) + return JSONResponse(result) + except HTTPException: + status = "error" + raise + except Exception as exc: + status = "error" + error_detail = safe_error_detail(exc, f"game mode {action} failed") + module._record_event(task_name, {"actor": actor, "status": "error", "game": game, "error": error_detail}) + raise HTTPException(status_code=502, detail=error_detail) + finally: + _record_simple_task(module, task_name, started, status, error_detail or None) + + +def _ensure_wolf_oauth2(module: Any, ctx: AuthContext) -> JSONResponse: + module._require_admin(ctx) + started = datetime.now(timezone.utc) + status = "ok" + detail = "" + try: + result = module.oauth2_proxy.ensure_wolf() + if result.get("status") != "ok": + status = "error" + detail = str(result.get("detail") or "wolf oauth2 ensure failed") + raise HTTPException(status_code=502, detail=detail) + module._record_event("wolf_oidc_ensure", {"actor": ctx.username or "admin", **result}) + return JSONResponse(result) + except HTTPException: + raise + except Exception as exc: + status = "error" + detail = safe_error_detail(exc, "wolf oauth2 ensure failed") + module._record_event("wolf_oidc_ensure", {"actor": ctx.username or "admin", "status": "error", "detail": detail}) + raise HTTPException(status_code=502, detail=detail) + finally: + _record_simple_task(module, "wolf_oidc_ensure", started, status, detail or None) + + +def _register_game_routes(app: FastAPI, require_auth: Callable, deps: Callable[[], Any]) -> None: + @app.get("/api/game-stream/me") + def get_game_stream_profile(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Return the Wolf profile policy for the authenticated Keycloak user.""" + + module = deps() + return JSONResponse(module.game_stream_profiles.profile_for(ctx.username or "", ctx.groups)) + + @app.get("/api/admin/game-mode/status") + def get_game_mode_status(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Return the current game-mode state for authenticated administrators.""" + + module = deps() + module._require_admin(ctx) + try: + return JSONResponse(module.game_mode.status()) + except Exception: + raise HTTPException(status_code=502, detail="failed to load game mode status") + + @app.post("/api/admin/game-mode/start") + async def start_game_mode(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Scale infrastructure GPU workloads down for an administrator-triggered game session.""" + + module = deps() + module._require_admin(ctx) + payload = await module._read_json_payload(request) + return await _run_game_mode_action(module, "start", payload, ctx.username or "admin") + + @app.post("/api/admin/game-mode/stop") + async def stop_game_mode(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Restore infrastructure GPU workloads after an administrator-triggered game session.""" + + module = deps() + module._require_admin(ctx) + payload = await module._read_json_payload(request) + return await _run_game_mode_action(module, "stop", payload, ctx.username or "admin") + + @app.post("/api/game-mode/start") + async def start_game_mode_hook(request: Request) -> JSONResponse: + """Scale infrastructure GPU workloads down for a trusted game-stream hook.""" + + module = deps() + _require_game_mode_hook(module, request) + payload = await module._read_json_payload(request) + return await _run_game_mode_action(module, "start", payload, "game-stream-hook") + + @app.post("/api/game-mode/stop") + async def stop_game_mode_hook(request: Request) -> JSONResponse: + """Restore infrastructure GPU workloads for a trusted game-stream hook.""" + + module = deps() + _require_game_mode_hook(module, request) + payload = await module._read_json_payload(request) + return await _run_game_mode_action(module, "stop", payload, "game-stream-hook") + + @app.post("/api/admin/game-stream/wolf/oauth2/ensure") + def ensure_wolf_oauth2(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Ensure Keycloak and Vault state for the Wolf oauth2-proxy.""" + + return _ensure_wolf_oauth2(deps(), ctx) + + @app.post("/api/admin/game-stream/sunshine/oauth2/ensure") + def ensure_sunshine_oauth2_alias(ctx: AuthContext = Depends(require_auth)) -> JSONResponse: + """Keep the old Sunshine route as a transition alias for Wolf.""" + + return _ensure_wolf_oauth2(deps(), ctx) diff --git a/ariadne/k8s/client.py b/ariadne/k8s/client.py index ab5cb74..f1c4186 100644 --- a/ariadne/k8s/client.py +++ b/ariadne/k8s/client.py @@ -24,12 +24,7 @@ def _read_service_account() -> tuple[str, str]: return token, str(ca_path) -def _k8s_request( - method: str, - path: str, - payload: dict[str, Any] | None = None, - extra_headers: dict[str, str] | None = None, -) -> Any: +def _k8s_request(method: str, path: str, payload: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None) -> Any: token, ca_path = _read_service_account() url = f"{_K8S_BASE_URL}{path}" headers = {"Authorization": f"Bearer {token}"} @@ -62,12 +57,7 @@ def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]: def patch_json(path: str, payload: dict[str, Any]) -> dict[str, Any]: """Patch a Kubernetes API resource with a JSON merge patch.""" - data = _k8s_request( - "PATCH", - path, - payload, - {"Content-Type": "application/merge-patch+json"}, - ) + data = _k8s_request("PATCH", path, payload, {"Content-Type": "application/merge-patch+json"}) if not isinstance(data, dict): raise RuntimeError("unexpected kubernetes response") return data diff --git a/ariadne/metrics/metrics.py b/ariadne/metrics/metrics.py index cb08fa9..b363889 100644 --- a/ariadne/metrics/metrics.py +++ b/ariadne/metrics/metrics.py @@ -99,13 +99,7 @@ def record_task_run(task: str, status: str, duration_sec: float | None) -> None: TASK_DURATION_SECONDS.labels(task=task, status=status).observe(duration_sec) -def record_schedule_state( - task: str, - last_run_ts: float | None, - last_success_ts: float | None, - next_run_ts: float | None, - ok: bool | None, -) -> None: +def record_schedule_state(task: str, last_run_ts: float | None, last_success_ts: float | None, next_run_ts: float | None, ok: bool | None) -> None: """Publish the latest scheduler timestamps and status for a task.""" if last_run_ts: @@ -127,13 +121,7 @@ def set_access_request_counts(counts: dict[str, int]) -> None: ACCESS_REQUESTS.labels(status=status).set(count) -def set_cluster_state_metrics( - collected_at: datetime, - nodes_total: int | None, - nodes_ready: int | None, - pods_running: float | None, - kustomizations_not_ready: int | None, -) -> None: +def set_cluster_state_metrics(collected_at: datetime, nodes_total: int | None, nodes_ready: int | None, pods_running: float | None, kustomizations_not_ready: int | None) -> None: """Set cluster-state gauges from the most recent collector snapshot.""" CLUSTER_STATE_LAST_TS.set(collected_at.timestamp()) diff --git a/ariadne/services/game_mode.py b/ariadne/services/game_mode.py index 96f93af..fa43f1c 100644 --- a/ariadne/services/game_mode.py +++ b/ariadne/services/game_mode.py @@ -5,11 +5,7 @@ import threading from typing import Any from ..k8s.client import get_json, patch_json -from ..metrics.metrics import ( - record_game_mode_transition, - set_game_mode_managed_replicas, - set_game_mode_state, -) +from ..metrics.metrics import record_game_mode_transition, set_game_mode_managed_replicas, set_game_mode_state from ..settings import settings from ..utils.logging import get_logger @@ -45,14 +41,7 @@ class GameModeService: restore_replicas = int(replicas) except (TypeError, ValueError): restore_replicas = 1 - workloads.append( - ManagedWorkload( - kind=kind, - namespace=namespace, - name=name, - restore_replicas=max(0, restore_replicas), - ) - ) + workloads.append(ManagedWorkload(kind, namespace, name, max(0, restore_replicas))) return workloads @staticmethod @@ -107,13 +96,7 @@ class GameModeService: active = bool(workloads) and all(item["desired_replicas"] == 0 for item in workloads) game = self._current_game or "unknown" set_game_mode_state(settings.game_mode_node_name, game, active) - return { - "status": "active" if active else "idle", - "active": active, - "node": settings.game_mode_node_name, - "game": game, - "workloads": workloads, - } + return {"status": "active" if active else "idle", "active": active, "node": settings.game_mode_node_name, "game": game, "workloads": workloads} def start(self, game: str | None = None, note: str | None = None) -> dict[str, Any]: game_name = self._game_name(game) @@ -124,10 +107,7 @@ class GameModeService: self._current_game = game_name set_game_mode_state(settings.game_mode_node_name, game_name, True) record_game_mode_transition("start", "ok", game_name) - logger.info( - "game mode started", - extra={"event": "game_mode_start", "game": game_name, "note": note or ""}, - ) + logger.info("game mode started", extra={"event": "game_mode_start", "game": game_name, "note": note or ""}) result = self.status() result["action"] = "start" return result @@ -145,10 +125,7 @@ class GameModeService: self._current_game = "" set_game_mode_state(settings.game_mode_node_name, game_name, False) record_game_mode_transition("stop", "ok", game_name) - logger.info( - "game mode stopped", - extra={"event": "game_mode_stop", "game": game_name, "note": note or ""}, - ) + logger.info("game mode stopped", extra={"event": "game_mode_stop", "game": game_name, "note": note or ""}) result = self.status() result["action"] = "stop" result["game"] = game_name diff --git a/ariadne/services/game_stream_profiles.py b/ariadne/services/game_stream_profiles.py index 29120e9..5cb7015 100644 --- a/ariadne/services/game_stream_profiles.py +++ b/ariadne/services/game_stream_profiles.py @@ -18,10 +18,9 @@ def _group_values(groups: list[str]) -> set[str]: values: set[str] = set() for group in groups: stripped = group.strip() - if not stripped: - continue - values.add(stripped) - values.add(stripped.lstrip("/")) + if stripped: + values.add(stripped) + values.add(stripped.lstrip("/")) return values @@ -42,12 +41,11 @@ class GameStreamProfileService: if not group.startswith(prefix): continue suffix = group[len(prefix) :] - if not suffix: - continue - profile_group = group - profile_id = _slug(suffix, profile_id) - allowed = True - break + if suffix: + profile_group = group + profile_id = _slug(suffix, profile_id) + allowed = True + break return { "username": username, diff --git a/ariadne/services/keycloak_admin.py b/ariadne/services/keycloak_admin.py index b7d0b75..f10100f 100644 --- a/ariadne/services/keycloak_admin.py +++ b/ariadne/services/keycloak_admin.py @@ -160,9 +160,8 @@ class KeycloakAdminClient: def find_client(self, client_id: str) -> dict[str, Any] | None: url = f"{settings.keycloak_admin_url}/admin/realms/{settings.keycloak_realm}/clients" - params = {"clientId": client_id, "max": "1"} with httpx.Client(timeout=10.0) as client: - resp = client.get(url, params=params, headers=self._headers()) + resp = client.get(url, params={"clientId": client_id, "max": "1"}, headers=self._headers()) resp.raise_for_status() payload = resp.json() if not isinstance(payload, list) or not payload: @@ -195,17 +194,14 @@ class KeycloakAdminClient: def find_client_scope_id(self, scope_name: str) -> str | None: url = f"{settings.keycloak_admin_url}/admin/realms/{settings.keycloak_realm}/client-scopes" - params = {"search": scope_name} with httpx.Client(timeout=10.0) as client: - resp = client.get(url, params=params, headers=self._headers()) + resp = client.get(url, params={"search": scope_name}, headers=self._headers()) resp.raise_for_status() payload = resp.json() if not isinstance(payload, list): return None for item in payload: - if not isinstance(item, dict): - continue - if item.get("name") == scope_name and item.get("id"): + if isinstance(item, dict) and item.get("name") == scope_name and item.get("id"): return str(item["id"]) return None diff --git a/ariadne/services/oauth2_proxy.py b/ariadne/services/oauth2_proxy.py index 88119c2..85e09af 100644 --- a/ariadne/services/oauth2_proxy.py +++ b/ariadne/services/oauth2_proxy.py @@ -3,10 +3,10 @@ from __future__ import annotations import secrets from typing import Any -from .keycloak_admin import keycloak_admin -from .vault import vault from ..settings import settings from ..utils.logging import get_logger +from .keycloak_admin import keycloak_admin +from .vault import vault logger = get_logger(__name__) @@ -67,22 +67,10 @@ class OAuth2ProxyService: cookie_secret = _valid_cookie_secret(existing.get("cookie_secret")) or secrets.token_hex(16) vault.write_kv_secret( settings.wolf_oidc_vault_path, - { - "client_id": client_id, - "client_secret": client_secret, - "cookie_secret": cookie_secret, - }, + {"client_id": client_id, "client_secret": client_secret, "cookie_secret": cookie_secret}, ) - logger.info( - "wolf oauth2 proxy secret ensured", - extra={"event": "wolf_oidc_ensure", "client_id": client_id, "vault_path": settings.wolf_oidc_vault_path}, - ) - return { - "status": "ok", - "client_id": client_id, - "base_url": base_url, - "vault_path": settings.wolf_oidc_vault_path, - } + logger.info("wolf oauth2 proxy secret ensured", extra={"event": "wolf_oidc_ensure", "client_id": client_id}) + return {"status": "ok", "client_id": client_id, "base_url": base_url, "vault_path": settings.wolf_oidc_vault_path} def ensure_sunshine(self) -> dict[str, Any]: return self.ensure_wolf() diff --git a/ariadne/services/testing_triage_diagnosis.py b/ariadne/services/testing_triage_diagnosis.py index 630c5ab..4e74108 100644 --- a/ariadne/services/testing_triage_diagnosis.py +++ b/ariadne/services/testing_triage_diagnosis.py @@ -167,12 +167,7 @@ def _parse_model_response(raw: str) -> tuple[dict[str, Any], str | None]: return (parsed if isinstance(parsed, dict) else {}, None) -def _diagnosis_from_model( - bundle: dict[str, Any], - parsed: dict[str, Any], - raw: str, - parse_error: str | None, -) -> dict[str, Any]: +def _diagnosis_from_model(bundle: dict[str, Any], parsed: dict[str, Any], raw: str, parse_error: str | None) -> dict[str, Any]: summary = bundle.get("summary") if isinstance(bundle.get("summary"), dict) else {} unknowns = list(bundle.get("unknowns") or []) if isinstance(bundle.get("unknowns"), list) else [] if parse_error: @@ -264,13 +259,7 @@ def _text_value(value: Any, default: str) -> str: return default -def _safe_text_value( - value: Any, - default: str, - unknowns: list[Any], - field: str, - blocked_jobs: set[str], -) -> str: +def _safe_text_value(value: Any, default: str, unknowns: list[Any], field: str, blocked_jobs: set[str]) -> str: text = _text_value(value, default) if not _english_ascii(text): unknowns.append(f"model_{field}_non_english") diff --git a/ariadne/services/vault.py b/ariadne/services/vault.py index 98e053f..d8c29ec 100644 --- a/ariadne/services/vault.py +++ b/ariadne/services/vault.py @@ -8,6 +8,9 @@ import httpx from ..settings import settings from ..utils.logging import get_logger +from .vault_policies import DEV_KV_POLICY as _DEV_KV_POLICY +from .vault_policies import K8S_ROLES as _K8S_ROLES +from .vault_policies import VAULT_ADMIN_POLICY as _VAULT_ADMIN_POLICY logger = get_logger(__name__) @@ -46,270 +49,6 @@ def _build_policy(read_paths: str, write_paths: str) -> str: ) return "\n".join(policy_parts).strip() + "\n" - -_K8S_ROLES: list[dict[str, str]] = [ - { - "role": "outline", - "namespace": "outline", - "service_accounts": "outline-vault", - "read_paths": "outline/* shared/postmark-relay", - "write_paths": "", - }, - { - "role": "planka", - "namespace": "planka", - "service_accounts": "planka-vault", - "read_paths": "planka/* shared/postmark-relay", - "write_paths": "", - }, - { - "role": "bstein-dev-home", - "namespace": "bstein-dev-home", - "service_accounts": "bstein-dev-home,bstein-dev-home-vault-sync", - "read_paths": "portal/* shared/chat-ai-keys-runtime shared/portal-e2e-client shared/postmark-relay " - "mailu/mailu-initial-account-secret shared/harbor-pull", - "write_paths": "", - }, - { - "role": "gitea", - "namespace": "gitea", - "service_accounts": "gitea-vault", - "read_paths": "gitea/*", - "write_paths": "", - }, - { - "role": "vaultwarden", - "namespace": "vaultwarden", - "service_accounts": "vaultwarden-vault", - "read_paths": "vaultwarden/* mailu/mailu-initial-account-secret", - "write_paths": "", - }, - { - "role": "sso", - "namespace": "sso", - "service_accounts": "sso-vault,sso-vault-sync,mas-secrets-ensure", - "read_paths": "sso/* portal/bstein-dev-home-keycloak-admin shared/keycloak-admin " - "shared/portal-e2e-client shared/postmark-relay shared/harbor-pull", - "write_paths": "", - }, - { - "role": "mailu-mailserver", - "namespace": "mailu-mailserver", - "service_accounts": "mailu-vault-sync", - "read_paths": "mailu/* shared/postmark-relay shared/harbor-pull", - "write_paths": "", - }, - { - "role": "harbor", - "namespace": "harbor", - "service_accounts": "harbor-vault-sync", - "read_paths": "harbor/* shared/harbor-pull", - "write_paths": "", - }, - { - "role": "nextcloud", - "namespace": "nextcloud", - "service_accounts": "nextcloud-vault", - "read_paths": "nextcloud/* shared/keycloak-admin shared/postmark-relay", - "write_paths": "", - }, - { - "role": "comms", - "namespace": "comms", - "service_accounts": "comms-vault,atlasbot", - "read_paths": "comms/* shared/chat-ai-keys-runtime shared/harbor-pull", - "write_paths": "", - }, - { - "role": "jenkins", - "namespace": "jenkins", - "service_accounts": "jenkins", - "read_paths": "jenkins/*", - "write_paths": "", - }, - { - "role": "monitoring", - "namespace": "monitoring", - "service_accounts": "monitoring-vault-sync", - "read_paths": "monitoring/* shared/postmark-relay shared/harbor-pull", - "write_paths": "", - }, - { - "role": "logging", - "namespace": "logging", - "service_accounts": "logging-vault-sync", - "read_paths": "logging/* shared/harbor-pull", - "write_paths": "", - }, - { - "role": "pegasus", - "namespace": "jellyfin", - "service_accounts": "pegasus-vault-sync", - "read_paths": "pegasus/* shared/harbor-pull", - "write_paths": "", - }, - { - "role": "crypto", - "namespace": "crypto", - "service_accounts": "crypto-vault-sync", - "read_paths": "crypto/* shared/harbor-pull", - "write_paths": "", - }, - { - "role": "health", - "namespace": "health", - "service_accounts": "health-vault-sync", - "read_paths": "health/*", - "write_paths": "", - }, - { - "role": "game-stream", - "namespace": "game-stream", - "service_accounts": "game-stream-vault", - "read_paths": "game-stream/*", - "write_paths": "", - }, - { - "role": "maintenance", - "namespace": "maintenance", - "service_accounts": "ariadne,maintenance-vault-sync", - "read_paths": "maintenance/ariadne-db portal/bstein-dev-home-keycloak-admin mailu/mailu-db-secret " - "mailu/mailu-initial-account-secret comms/synapse-admin shared/harbor-pull", - "write_paths": "", - }, - { - "role": "finance", - "namespace": "finance", - "service_accounts": "finance-vault", - "read_paths": "finance/* shared/postmark-relay", - "write_paths": "", - }, - { - "role": "finance-secrets", - "namespace": "finance", - "service_accounts": "finance-secrets-ensure", - "read_paths": "", - "write_paths": "finance/*", - }, - { - "role": "longhorn", - "namespace": "longhorn-system", - "service_accounts": "longhorn-vault,longhorn-vault-sync", - "read_paths": "longhorn/* shared/harbor-pull", - "write_paths": "", - }, - { - "role": "postgres", - "namespace": "postgres", - "service_accounts": "postgres-vault", - "read_paths": "postgres/postgres-db", - "write_paths": "", - }, - { - "role": "vault", - "namespace": "vault", - "service_accounts": "vault", - "read_paths": "vault/*", - "write_paths": "", - }, - { - "role": "sso-secrets", - "namespace": "sso", - "service_accounts": "mas-secrets-ensure", - "read_paths": "shared/keycloak-admin", - "write_paths": "harbor/harbor-oidc vault/vault-oidc-config comms/synapse-oidc " - "logging/oauth2-proxy-logs-oidc finance/actual-oidc", - }, - { - "role": "crypto-secrets", - "namespace": "crypto", - "service_accounts": "crypto-secrets-ensure", - "read_paths": "", - "write_paths": "crypto/wallet-monero-temp-rpc-auth", - }, - { - "role": "comms-secrets", - "namespace": "comms", - "service_accounts": "comms-secrets-ensure,mas-db-ensure,mas-admin-client-secret-writer,othrys-synapse-signingkey-job", - "read_paths": "", - "write_paths": "comms/turn-shared-secret comms/livekit-api comms/synapse-redis comms/synapse-macaroon " - "comms/atlasbot-credentials-runtime comms/synapse-db comms/synapse-admin comms/synapse-registration " - "comms/mas-db comms/mas-admin-client-runtime comms/mas-secrets-runtime comms/othrys-synapse-signingkey", - }, -] - - -_VAULT_ADMIN_POLICY = """ -path "sys/auth" { - capabilities = ["read"] -} -path "sys/auth/*" { - capabilities = ["create", "update", "delete", "sudo", "read"] -} -path "auth/kubernetes/*" { - capabilities = ["create", "update", "read"] -} -path "auth/oidc/*" { - capabilities = ["create", "update", "read"] -} -path "sys/policies/acl" { - capabilities = ["list"] -} -path "sys/policies/acl/*" { - capabilities = ["create", "update", "read"] -} -path "sys/internal/ui/mounts" { - capabilities = ["read"] -} -path "sys/mounts" { - capabilities = ["read"] -} -path "sys/mounts/auth/*" { - capabilities = ["read", "update", "sudo"] -} -path "kv/data/atlas/vault/*" { - capabilities = ["read"] -} -path "kv/metadata/atlas/vault/*" { - capabilities = ["list"] -} -path "kv/data/*" { - capabilities = ["create", "update", "read", "delete", "patch"] -} -path "kv/metadata" { - capabilities = ["list"] -} -path "kv/metadata/*" { - capabilities = ["read", "list", "delete"] -} -path "kv/data/atlas/shared/*" { - capabilities = ["create", "update", "read", "patch"] -} -path "kv/metadata/atlas/shared/*" { - capabilities = ["list"] -} -""".strip() - - -_DEV_KV_POLICY = """ -path "kv/metadata" { - capabilities = ["list"] -} -path "kv/metadata/atlas" { - capabilities = ["list"] -} -path "kv/metadata/atlas/shared" { - capabilities = ["list"] -} -path "kv/metadata/atlas/shared/*" { - capabilities = ["list"] -} -path "kv/data/atlas/shared/*" { - capabilities = ["read"] -} -""".strip() - - class VaultClient: """Minimal HTTP client for Vault API requests.""" diff --git a/ariadne/services/vault_policies.py b/ariadne/services/vault_policies.py index 39f144a..4cbd98e 100644 --- a/ariadne/services/vault_policies.py +++ b/ariadne/services/vault_policies.py @@ -117,6 +117,13 @@ K8S_ROLES: list[dict[str, str]] = [ "read_paths": "health/*", "write_paths": "", }, + { + "role": "game-stream", + "namespace": "game-stream", + "service_accounts": "game-stream-vault", + "read_paths": "game-stream/*", + "write_paths": "", + }, { "role": "maintenance", "namespace": "maintenance", diff --git a/ariadne/settings.py b/ariadne/settings.py index 1ac539f..5b50908 100644 --- a/ariadne/settings.py +++ b/ariadne/settings.py @@ -1,47 +1,30 @@ from __future__ import annotations from dataclasses import dataclass -import json -import os -from typing import Any - -def _env(name: str, default: str = "") -> str: - value = os.getenv(name, default) - return value.strip() if isinstance(value, str) else default - - -def _env_bool(name: str, default: str = "false") -> bool: - return _env(name, default).lower() in {"1", "true", "yes", "y", "on"} - - -def _env_int(name: str, default: int) -> int: - raw = _env(name, str(default)) - try: - return int(raw) - except ValueError: - return default - - -def _env_float(name: str, default: float) -> float: - raw = _env(name, str(default)) - try: - return float(raw) - except ValueError: - return default - - -def _env_json_list(name: str, default: list[dict[str, Any]]) -> list[dict[str, Any]]: - raw = _env(name, "") - if not raw: - return default - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - return default - if not isinstance(parsed, list): - return default - return [item for item in parsed if isinstance(item, dict)] +from .settings_env import _env, _env_bool, _env_float, _env_int +from .settings_sections import ( + _cluster_state_config, + _comms_config, + _firefly_config, + _game_stream_config, + _image_sweeper_config, + _jenkins_build_weather_config, + _jenkins_workspace_cleanup_config, + _keycloak_config, + _mailu_config, + _metis_config, + _nextcloud_config, + _opensearch_config, + _platform_quality_probe_config, + _portal_group_config, + _schedule_config, + _smtp_config, + _testing_triage_config, + _vault_config, + _vaultwarden_config, + _wger_config, +) @dataclass(frozen=True) @@ -191,6 +174,9 @@ class Settings: jenkins_workspace_cleanup_min_age_hours: float jenkins_workspace_cleanup_dry_run: bool jenkins_workspace_cleanup_max_deletions_per_run: int + testing_triage_model_url: str + testing_triage_model: str + testing_triage_model_timeout_sec: float vaultwarden_namespace: str vaultwarden_pod_label: str @@ -245,9 +231,6 @@ class Settings: cluster_state_keep: int game_mode_node_name: str game_mode_displace_workloads: list[dict[str, Any]] - game_mode_ollama_namespace: str - game_mode_ollama_deployment: str - game_mode_ollama_restore_replicas: int game_mode_hook_token: str wolf_oidc_client_id: str wolf_oidc_base_url: str @@ -272,6 +255,7 @@ class Settings: platform_quality_suite_probe_cron: str jenkins_build_weather_cron: str jenkins_workspace_cleanup_cron: str + testing_triage_cron: str opensearch_url: str opensearch_limit_bytes: int @@ -280,394 +264,28 @@ class Settings: metrics_path: str - @classmethod - def _keycloak_config(cls) -> dict[str, Any]: - keycloak_url = _env("KEYCLOAK_URL", "https://sso.bstein.dev").rstrip("/") - keycloak_realm = _env("KEYCLOAK_REALM", "atlas") - keycloak_client_id = _env("KEYCLOAK_CLIENT_ID", "bstein-dev-home") - keycloak_issuer = _env("KEYCLOAK_ISSUER", f"{keycloak_url}/realms/{keycloak_realm}").rstrip("/") - keycloak_jwks_url = _env("KEYCLOAK_JWKS_URL", f"{keycloak_issuer}/protocol/openid-connect/certs").rstrip("/") - return { - "keycloak_url": keycloak_url, - "keycloak_realm": keycloak_realm, - "keycloak_client_id": keycloak_client_id, - "keycloak_issuer": keycloak_issuer, - "keycloak_jwks_url": keycloak_jwks_url, - "keycloak_admin_url": _env("KEYCLOAK_ADMIN_URL", keycloak_url).rstrip("/"), - "keycloak_admin_realm": _env("KEYCLOAK_ADMIN_REALM", keycloak_realm), - "keycloak_admin_client_id": _env("KEYCLOAK_ADMIN_CLIENT_ID", ""), - "keycloak_admin_client_secret": _env("KEYCLOAK_ADMIN_CLIENT_SECRET", ""), - } - - @classmethod - def _portal_group_config(cls) -> dict[str, Any]: - return { - "portal_admin_users": [u for u in (_env("PORTAL_ADMIN_USERS", "bstein")).split(",") if u.strip()], - "portal_admin_groups": [g for g in (_env("PORTAL_ADMIN_GROUPS", "admin")).split(",") if g.strip()], - "account_allowed_groups": [ - g for g in (_env("ACCOUNT_ALLOWED_GROUPS", "dev,admin")).split(",") if g.strip() - ], - "allowed_flag_groups": [g for g in (_env("ALLOWED_FLAG_GROUPS", "demo,test")).split(",") if g.strip()], - "default_user_groups": [g for g in (_env("DEFAULT_USER_GROUPS", "dev")).split(",") if g.strip()], - } - - @classmethod - def _mailu_config(cls) -> dict[str, Any]: - mailu_domain = _env("MAILU_DOMAIN", "bstein.dev") - return { - "mailu_domain": mailu_domain, - "mailu_sync_url": _env( - "MAILU_SYNC_URL", - "http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events", - ).rstrip("/"), - "mailu_event_min_interval_sec": _env_float("MAILU_EVENT_MIN_INTERVAL_SEC", 10.0), - "mailu_sync_wait_timeout_sec": _env_float("MAILU_SYNC_WAIT_TIMEOUT_SEC", 60.0), - "mailu_mailbox_wait_timeout_sec": _env_float("MAILU_MAILBOX_WAIT_TIMEOUT_SEC", 60.0), - "mailu_db_host": _env("MAILU_DB_HOST", "postgres-service.postgres.svc.cluster.local"), - "mailu_db_port": _env_int("MAILU_DB_PORT", 5432), - "mailu_db_name": _env("MAILU_DB_NAME", "mailu"), - "mailu_db_user": _env("MAILU_DB_USER", "mailu"), - "mailu_db_password": _env("MAILU_DB_PASSWORD", ""), - "mailu_host": _env("MAILU_HOST", f"mail.{mailu_domain}"), - "mailu_default_quota": _env_int("MAILU_DEFAULT_QUOTA", 20000000000), - "mailu_system_users": [u for u in _env("MAILU_SYSTEM_USERS", "").split(",") if u.strip()], - "mailu_system_password": _env("MAILU_SYSTEM_PASSWORD", ""), - } - - @classmethod - def _smtp_config(cls, mailu_domain: str) -> dict[str, Any]: - return { - "smtp_host": _env("SMTP_HOST", ""), - "smtp_port": _env_int("SMTP_PORT", 25), - "smtp_username": _env("SMTP_USERNAME", ""), - "smtp_password": _env("SMTP_PASSWORD", ""), - "smtp_starttls": _env_bool("SMTP_STARTTLS", "false"), - "smtp_use_tls": _env_bool("SMTP_USE_TLS", "false"), - "smtp_from": _env("SMTP_FROM", f"postmaster@{mailu_domain}"), - "smtp_timeout_sec": _env_float("SMTP_TIMEOUT_SEC", 10.0), - "welcome_email_enabled": _env_bool("WELCOME_EMAIL_ENABLED", "true"), - } - - @classmethod - def _nextcloud_config(cls) -> dict[str, Any]: - return { - "nextcloud_namespace": _env("NEXTCLOUD_NAMESPACE", "nextcloud"), - "nextcloud_pod_label": _env("NEXTCLOUD_POD_LABEL", "app=nextcloud"), - "nextcloud_container": _env("NEXTCLOUD_CONTAINER", "nextcloud"), - "nextcloud_exec_timeout_sec": _env_float("NEXTCLOUD_EXEC_TIMEOUT_SEC", 120.0), - "nextcloud_db_host": _env("NEXTCLOUD_DB_HOST", "postgres-service.postgres.svc.cluster.local"), - "nextcloud_db_port": _env_int("NEXTCLOUD_DB_PORT", 5432), - "nextcloud_db_name": _env("NEXTCLOUD_DB_NAME", "nextcloud"), - "nextcloud_db_user": _env("NEXTCLOUD_DB_USER", "nextcloud"), - "nextcloud_db_password": _env("NEXTCLOUD_DB_PASSWORD", ""), - "nextcloud_url": _env("NEXTCLOUD_URL", "https://cloud.bstein.dev").rstrip("/"), - "nextcloud_admin_user": _env("NEXTCLOUD_ADMIN_USER", ""), - "nextcloud_admin_password": _env("NEXTCLOUD_ADMIN_PASSWORD", ""), - } - - @classmethod - def _wger_config(cls) -> dict[str, Any]: - return { - "wger_namespace": _env("WGER_NAMESPACE", "health"), - "wger_user_sync_wait_timeout_sec": _env_float("WGER_USER_SYNC_WAIT_TIMEOUT_SEC", 60.0), - "wger_pod_label": _env("WGER_POD_LABEL", "app=wger"), - "wger_container": _env("WGER_CONTAINER", "wger"), - "wger_admin_username": _env("WGER_ADMIN_USERNAME", ""), - "wger_admin_password": _env("WGER_ADMIN_PASSWORD", ""), - "wger_admin_email": _env("WGER_ADMIN_EMAIL", ""), - } - - @classmethod - def _firefly_config(cls) -> dict[str, Any]: - return { - "firefly_namespace": _env("FIREFLY_NAMESPACE", "finance"), - "firefly_user_sync_wait_timeout_sec": _env_float("FIREFLY_USER_SYNC_WAIT_TIMEOUT_SEC", 90.0), - "firefly_pod_label": _env("FIREFLY_POD_LABEL", "app=firefly"), - "firefly_container": _env("FIREFLY_CONTAINER", "firefly"), - "firefly_cron_base_url": _env( - "FIREFLY_CRON_BASE_URL", - "http://firefly.finance.svc.cluster.local/api/v1/cron", - ), - "firefly_cron_token": _env("FIREFLY_CRON_TOKEN", ""), - "firefly_cron_timeout_sec": _env_float("FIREFLY_CRON_TIMEOUT_SEC", 30.0), - } - - @classmethod - def _vault_config(cls) -> dict[str, Any]: - return { - "vault_namespace": _env("VAULT_NAMESPACE", "vault"), - "vault_addr": _env("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200").rstrip("/"), - "vault_token": _env("VAULT_TOKEN", ""), - "vault_k8s_role": _env("VAULT_K8S_ROLE", "vault"), - "vault_k8s_role_ttl": _env("VAULT_K8S_ROLE_TTL", "1h"), - "vault_k8s_token_reviewer_jwt": _env("VAULT_K8S_TOKEN_REVIEWER_JWT", ""), - "vault_k8s_token_reviewer_jwt_file": _env("VAULT_K8S_TOKEN_REVIEWER_JWT_FILE", ""), - "vault_oidc_discovery_url": _env("VAULT_OIDC_DISCOVERY_URL", ""), - "vault_oidc_client_id": _env("VAULT_OIDC_CLIENT_ID", ""), - "vault_oidc_client_secret": _env("VAULT_OIDC_CLIENT_SECRET", ""), - "vault_oidc_default_role": _env("VAULT_OIDC_DEFAULT_ROLE", "admin"), - "vault_oidc_scopes": _env("VAULT_OIDC_SCOPES", "openid profile email groups"), - "vault_oidc_user_claim": _env("VAULT_OIDC_USER_CLAIM", "preferred_username"), - "vault_oidc_groups_claim": _env("VAULT_OIDC_GROUPS_CLAIM", "groups"), - "vault_oidc_token_policies": _env("VAULT_OIDC_TOKEN_POLICIES", ""), - "vault_oidc_admin_group": _env("VAULT_OIDC_ADMIN_GROUP", "admin"), - "vault_oidc_admin_policies": _env("VAULT_OIDC_ADMIN_POLICIES", "default,vault-admin"), - "vault_oidc_dev_group": _env("VAULT_OIDC_DEV_GROUP", "dev"), - "vault_oidc_dev_policies": _env("VAULT_OIDC_DEV_POLICIES", "default,dev-kv"), - "vault_oidc_user_group": _env("VAULT_OIDC_USER_GROUP", ""), - "vault_oidc_user_policies": _env("VAULT_OIDC_USER_POLICIES", ""), - "vault_oidc_redirect_uris": _env( - "VAULT_OIDC_REDIRECT_URIS", - "https://secret.bstein.dev/ui/vault/auth/oidc/oidc/callback", - ), - "vault_oidc_bound_audiences": _env("VAULT_OIDC_BOUND_AUDIENCES", ""), - "vault_oidc_bound_claims_type": _env("VAULT_OIDC_BOUND_CLAIMS_TYPE", "string"), - } - - @classmethod - def _comms_config(cls) -> dict[str, Any]: - return { - "comms_namespace": _env("COMMS_NAMESPACE", "comms"), - "comms_synapse_base": _env( - "COMMS_SYNAPSE_BASE", - "http://othrys-synapse-matrix-synapse:8008", - ).rstrip("/"), - "comms_auth_base": _env( - "COMMS_AUTH_BASE", - "http://matrix-authentication-service:8080", - ).rstrip("/"), - "comms_mas_admin_api_base": _env( - "COMMS_MAS_ADMIN_API_BASE", - "http://matrix-authentication-service:8081/api/admin/v1", - ).rstrip("/"), - "comms_mas_token_url": _env( - "COMMS_MAS_TOKEN_URL", - "http://matrix-authentication-service:8080/oauth2/token", - ), - "comms_mas_admin_client_id": _env("COMMS_MAS_ADMIN_CLIENT_ID", "01KDXMVQBQ5JNY6SEJPZW6Z8BM"), - "comms_mas_admin_client_secret": _env("COMMS_MAS_ADMIN_CLIENT_SECRET", ""), - "comms_server_name": _env("COMMS_SERVER_NAME", "live.bstein.dev"), - "comms_room_alias": _env("COMMS_ROOM_ALIAS", "#othrys:live.bstein.dev"), - "comms_room_name": _env("COMMS_ROOM_NAME", "Othrys"), - "comms_pin_message": _env( - "COMMS_PIN_MESSAGE", - "Invite guests: share https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join and choose 'Continue' -> 'Join as guest'.", - ), - "comms_seeder_user": _env("COMMS_SEEDER_USER", "othrys-seeder"), - "comms_seeder_password": _env("COMMS_SEEDER_PASSWORD", ""), - "comms_bot_user": _env("COMMS_BOT_USER", "atlasbot"), - "comms_bot_password": _env("COMMS_BOT_PASSWORD", ""), - "comms_synapse_db_host": _env( - "COMMS_SYNAPSE_DB_HOST", - "postgres-service.postgres.svc.cluster.local", - ), - "comms_synapse_db_port": _env_int("COMMS_SYNAPSE_DB_PORT", 5432), - "comms_synapse_db_name": _env("COMMS_SYNAPSE_DB_NAME", "synapse"), - "comms_synapse_db_user": _env("COMMS_SYNAPSE_DB_USER", "synapse"), - "comms_synapse_db_password": _env("COMMS_SYNAPSE_DB_PASSWORD", ""), - "comms_synapse_admin_token": _env("COMMS_SYNAPSE_ADMIN_TOKEN", ""), - "comms_timeout_sec": _env_float("COMMS_TIMEOUT_SEC", 30.0), - "comms_guest_stale_days": _env_int("COMMS_GUEST_STALE_DAYS", 14), - } - - @classmethod - def _image_sweeper_config(cls) -> dict[str, Any]: - return { - "image_sweeper_namespace": _env("IMAGE_SWEEPER_NAMESPACE", "maintenance"), - "image_sweeper_service_account": _env("IMAGE_SWEEPER_SERVICE_ACCOUNT", "node-image-sweeper"), - "image_sweeper_job_ttl_sec": _env_int("IMAGE_SWEEPER_JOB_TTL_SEC", 3600), - "image_sweeper_wait_timeout_sec": _env_float("IMAGE_SWEEPER_WAIT_TIMEOUT_SEC", 1200.0), - } - - @classmethod - def _platform_quality_probe_config(cls) -> dict[str, Any]: - return { - "platform_quality_probe_namespace": _env("PLATFORM_QUALITY_PROBE_NAMESPACE", "monitoring"), - "platform_quality_probe_script_configmap": _env( - "PLATFORM_QUALITY_PROBE_SCRIPT_CONFIGMAP", - "platform-quality-suite-probe-script", - ), - "platform_quality_probe_image": _env("PLATFORM_QUALITY_PROBE_IMAGE", "curlimages/curl:8.12.1"), - "platform_quality_probe_job_ttl_sec": _env_int("PLATFORM_QUALITY_PROBE_JOB_TTL_SEC", 1800), - "platform_quality_probe_wait_timeout_sec": _env_float("PLATFORM_QUALITY_PROBE_WAIT_TIMEOUT_SEC", 180.0), - "platform_quality_probe_pushgateway_url": _env( - "PLATFORM_QUALITY_PROBE_PUSHGATEWAY_URL", - "http://platform-quality-gateway.monitoring.svc.cluster.local:9091", - ).rstrip("/"), - "platform_quality_probe_http_timeout_sec": _env_int("PLATFORM_QUALITY_PROBE_HTTP_TIMEOUT_SECONDS", 12), - } - - @classmethod - def _jenkins_build_weather_config(cls) -> dict[str, Any]: - return { - "jenkins_base_url": _env("JENKINS_BASE_URL", "https://ci.bstein.dev").rstrip("/"), - "jenkins_api_user": _env("JENKINS_API_USER", ""), - "jenkins_api_token": _env("JENKINS_API_TOKEN", ""), - "jenkins_api_timeout_sec": _env_float("JENKINS_API_TIMEOUT_SEC", 10.0), - } - - @classmethod - def _jenkins_workspace_cleanup_config(cls) -> dict[str, Any]: - return { - "jenkins_workspace_namespace": _env("JENKINS_WORKSPACE_NAMESPACE", "jenkins"), - "jenkins_workspace_pvc_prefix": _env("JENKINS_WORKSPACE_PVC_PREFIX", "pvc-workspace-"), - "jenkins_workspace_cleanup_min_age_hours": _env_float("JENKINS_WORKSPACE_CLEANUP_MIN_AGE_HOURS", 12.0), - "jenkins_workspace_cleanup_dry_run": _env_bool("JENKINS_WORKSPACE_CLEANUP_DRY_RUN", "false"), - "jenkins_workspace_cleanup_max_deletions_per_run": _env_int( - "JENKINS_WORKSPACE_CLEANUP_MAX_DELETIONS_PER_RUN", - 20, - ), - } - - @classmethod - def _vaultwarden_config(cls) -> dict[str, Any]: - return { - "vaultwarden_namespace": _env("VAULTWARDEN_NAMESPACE", "vaultwarden"), - "vaultwarden_pod_label": _env("VAULTWARDEN_POD_LABEL", "app=vaultwarden"), - "vaultwarden_pod_port": _env_int("VAULTWARDEN_POD_PORT", 80), - "vaultwarden_service_host": _env( - "VAULTWARDEN_SERVICE_HOST", - "vaultwarden-service.vaultwarden.svc.cluster.local", - ), - "vaultwarden_admin_secret_name": _env("VAULTWARDEN_ADMIN_SECRET_NAME", "vaultwarden-admin"), - "vaultwarden_admin_secret_key": _env("VAULTWARDEN_ADMIN_SECRET_KEY", "ADMIN_TOKEN"), - "vaultwarden_admin_session_ttl_sec": _env_float("VAULTWARDEN_ADMIN_SESSION_TTL_SEC", 300.0), - "vaultwarden_admin_rate_limit_backoff_sec": _env_float("VAULTWARDEN_ADMIN_RATE_LIMIT_BACKOFF_SEC", 600.0), - "vaultwarden_retry_cooldown_sec": _env_float("VAULTWARDEN_RETRY_COOLDOWN_SEC", 1800.0), - "vaultwarden_failure_bailout": _env_int("VAULTWARDEN_FAILURE_BAILOUT", 2), - "vaultwarden_invite_refresh_sec": _env_float("VAULTWARDEN_INVITE_REFRESH_SEC", 86400.0), - } - - @classmethod - def _schedule_config(cls) -> dict[str, Any]: - return { - "mailu_sync_cron": _env("ARIADNE_SCHEDULE_MAILU_SYNC", "30 4 * * *"), - "nextcloud_sync_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_SYNC", "0 5 * * *"), - "nextcloud_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_CRON", "*/5 * * * *"), - "nextcloud_maintenance_cron": _env("ARIADNE_SCHEDULE_NEXTCLOUD_MAINTENANCE", "30 4 * * *"), - "vaultwarden_sync_cron": _env("ARIADNE_SCHEDULE_VAULTWARDEN_SYNC", "0 * * * *"), - "wger_user_sync_cron": _env("ARIADNE_SCHEDULE_WGER_USER_SYNC", "0 5 * * *"), - "wger_admin_cron": _env("ARIADNE_SCHEDULE_WGER_ADMIN", "15 3 * * *"), - "firefly_user_sync_cron": _env("ARIADNE_SCHEDULE_FIREFLY_USER_SYNC", "0 6 * * *"), - "firefly_cron": _env("ARIADNE_SCHEDULE_FIREFLY_CRON", "0 3 * * *"), - "pod_cleaner_cron": _env("ARIADNE_SCHEDULE_POD_CLEANER", "0 * * * *"), - "opensearch_prune_cron": _env("ARIADNE_SCHEDULE_OPENSEARCH_PRUNE", "23 3 * * *"), - "image_sweeper_cron": _env("ARIADNE_SCHEDULE_IMAGE_SWEEPER", "30 4 * * 0"), - "vault_k8s_auth_cron": _env("ARIADNE_SCHEDULE_VAULT_K8S_AUTH", "0 * * * *"), - "vault_oidc_cron": _env("ARIADNE_SCHEDULE_VAULT_OIDC", "0 * * * *"), - "comms_guest_name_cron": _env("ARIADNE_SCHEDULE_COMMS_GUEST_NAME", "*/5 * * * *"), - "comms_pin_invite_cron": _env("ARIADNE_SCHEDULE_COMMS_PIN_INVITE", "*/30 * * * *"), - "comms_reset_room_cron": _env("ARIADNE_SCHEDULE_COMMS_RESET_ROOM", "0 0 1 1 *"), - "comms_seed_room_cron": _env("ARIADNE_SCHEDULE_COMMS_SEED_ROOM", "*/10 * * * *"), - "keycloak_profile_cron": _env("ARIADNE_SCHEDULE_KEYCLOAK_PROFILE", "0 */6 * * *"), - "metis_k3s_token_sync_cron": _env("ARIADNE_SCHEDULE_METIS_K3S_TOKEN_SYNC", "11 */6 * * *"), - "platform_quality_suite_probe_cron": _env( - "ARIADNE_SCHEDULE_PLATFORM_QUALITY_SUITE_PROBE", - "*/15 * * * *", - ), - "jenkins_build_weather_cron": _env( - "ARIADNE_SCHEDULE_JENKINS_BUILD_WEATHER", - "*/10 * * * *", - ), - "jenkins_workspace_cleanup_cron": _env( - "ARIADNE_SCHEDULE_JENKINS_WORKSPACE_CLEANUP", - "45 */6 * * *", - ), - } - - @classmethod - def _cluster_state_config(cls) -> dict[str, Any]: - return { - "vm_url": _env( - "ARIADNE_VM_URL", - "http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428", - ).rstrip("/"), - "cluster_state_vm_timeout_sec": _env_float("ARIADNE_CLUSTER_STATE_VM_TIMEOUT_SEC", 5.0), - "alertmanager_url": _env("ARIADNE_ALERTMANAGER_URL", "").rstrip("/"), - "cluster_state_cron": _env("ARIADNE_SCHEDULE_CLUSTER_STATE", "*/15 * * * *"), - "cluster_state_keep": _env_int("ARIADNE_CLUSTER_STATE_KEEP", 168), - } - - @classmethod - def _game_mode_config(cls) -> dict[str, Any]: - legacy_ollama = { - "kind": "Deployment", - "namespace": _env("GAME_MODE_OLLAMA_NAMESPACE", "openclaw"), - "name": _env("GAME_MODE_OLLAMA_DEPLOYMENT", "openclaw-ollama"), - "restoreReplicas": _env_int("GAME_MODE_OLLAMA_RESTORE_REPLICAS", 1), - } - return { - "game_mode_node_name": _env("GAME_MODE_NODE_NAME", "titan-24"), - "game_mode_displace_workloads": _env_json_list("GAME_MODE_DISPLACE_WORKLOADS", [legacy_ollama]), - "game_mode_ollama_namespace": legacy_ollama["namespace"], - "game_mode_ollama_deployment": legacy_ollama["name"], - "game_mode_ollama_restore_replicas": legacy_ollama["restoreReplicas"], - "game_mode_hook_token": _env("GAME_MODE_HOOK_TOKEN", ""), - "wolf_oidc_client_id": _env("WOLF_OIDC_CLIENT_ID", _env("SUNSHINE_OIDC_CLIENT_ID", "wolf")), - "wolf_oidc_base_url": _env( - "WOLF_OIDC_BASE_URL", - _env("SUNSHINE_OIDC_BASE_URL", "https://wolf.bstein.dev"), - ).rstrip("/"), - "wolf_oidc_vault_path": _env("WOLF_OIDC_VAULT_PATH", _env("SUNSHINE_OIDC_VAULT_PATH", "game-stream/wolf-oidc")), - "wolf_oidc_cron": _env("ARIADNE_SCHEDULE_WOLF_OIDC", _env("ARIADNE_SCHEDULE_SUNSHINE_OIDC", "17 */6 * * *")), - "game_stream_user_group": _env("GAME_STREAM_USER_GROUP", "game-stream-users"), - "game_stream_admin_group": _env("GAME_STREAM_ADMIN_GROUP", "admin"), - "game_stream_profile_group_prefix": _env("GAME_STREAM_PROFILE_GROUP_PREFIX", "game-stream-profile-"), - } - - @classmethod - def _metis_config(cls) -> dict[str, Any]: - return { - "metis_base_url": _env("METIS_BASE_URL", "http://metis.maintenance.svc.cluster.local").rstrip("/"), - "metis_watch_url": _env("METIS_WATCH_URL", "").rstrip("/"), - "metis_timeout_sec": _env_float("METIS_TIMEOUT_SEC", 10.0), - "metis_sentinel_watch_cron": _env("ARIADNE_SCHEDULE_METIS_SENTINEL_WATCH", "*/15 * * * *"), - "metis_token_sync_namespace": _env("METIS_TOKEN_SYNC_NAMESPACE", "maintenance"), - "metis_token_sync_service_account": _env("METIS_TOKEN_SYNC_SERVICE_ACCOUNT", "metis-token-sync"), - "metis_token_sync_node_name": _env("METIS_TOKEN_SYNC_NODE_NAME", "titan-0a"), - "metis_token_sync_image": _env("METIS_TOKEN_SYNC_IMAGE", "hashicorp/vault:1.17.6"), - "metis_token_sync_job_ttl_sec": _env_int("METIS_TOKEN_SYNC_JOB_TTL_SEC", 1800), - "metis_token_sync_wait_timeout_sec": _env_float("METIS_TOKEN_SYNC_WAIT_TIMEOUT_SEC", 180.0), - "metis_token_sync_vault_addr": _env( - "METIS_TOKEN_SYNC_VAULT_ADDR", - "http://vault.vault.svc.cluster.local:8200", - ).rstrip("/"), - "metis_token_sync_vault_k8s_role": _env("METIS_TOKEN_SYNC_VAULT_K8S_ROLE", "maintenance-metis-token-sync"), - } - - @classmethod - def _opensearch_config(cls) -> dict[str, Any]: - return { - "opensearch_url": _env( - "OPENSEARCH_URL", - "http://opensearch-master.logging.svc.cluster.local:9200", - ).rstrip("/"), - "opensearch_limit_bytes": _env_int("OPENSEARCH_LIMIT_BYTES", 1024**4), - "opensearch_index_patterns": _env("OPENSEARCH_INDEX_PATTERNS", "kube-*,journald-*"), - "opensearch_timeout_sec": _env_float("OPENSEARCH_TIMEOUT_SEC", 30.0), - } - @classmethod def from_env(cls) -> "Settings": - keycloak_cfg = cls._keycloak_config() - portal_cfg = cls._portal_group_config() - mailu_cfg = cls._mailu_config() - smtp_cfg = cls._smtp_config(mailu_cfg["mailu_domain"]) - nextcloud_cfg = cls._nextcloud_config() - wger_cfg = cls._wger_config() - firefly_cfg = cls._firefly_config() - vault_cfg = cls._vault_config() - comms_cfg = cls._comms_config() - image_cfg = cls._image_sweeper_config() - platform_quality_probe_cfg = cls._platform_quality_probe_config() - jenkins_build_weather_cfg = cls._jenkins_build_weather_config() - jenkins_workspace_cleanup_cfg = cls._jenkins_workspace_cleanup_config() - vaultwarden_cfg = cls._vaultwarden_config() - schedule_cfg = cls._schedule_config() - cluster_cfg = cls._cluster_state_config() - game_mode_cfg = cls._game_mode_config() - metis_cfg = cls._metis_config() - opensearch_cfg = cls._opensearch_config() + keycloak_cfg = _keycloak_config() + portal_cfg = _portal_group_config() + mailu_cfg = _mailu_config() + smtp_cfg = _smtp_config(mailu_cfg["mailu_domain"]) + nextcloud_cfg = _nextcloud_config() + wger_cfg = _wger_config() + firefly_cfg = _firefly_config() + vault_cfg = _vault_config() + comms_cfg = _comms_config() + image_cfg = _image_sweeper_config() + platform_quality_probe_cfg = _platform_quality_probe_config() + jenkins_build_weather_cfg = _jenkins_build_weather_config() + jenkins_workspace_cleanup_cfg = _jenkins_workspace_cleanup_config() + testing_triage_cfg = _testing_triage_config() + vaultwarden_cfg = _vaultwarden_config() + schedule_cfg = _schedule_config() + cluster_cfg = _cluster_state_config() + game_stream_cfg = _game_stream_config() + metis_cfg = _metis_config() + opensearch_cfg = _opensearch_config() portal_db = _env("PORTAL_DATABASE_URL", "") ariadne_db = _env("ARIADNE_DATABASE_URL", portal_db) @@ -705,10 +323,11 @@ class Settings: **platform_quality_probe_cfg, **jenkins_build_weather_cfg, **jenkins_workspace_cleanup_cfg, + **testing_triage_cfg, **vaultwarden_cfg, **schedule_cfg, **cluster_cfg, - **game_mode_cfg, + **game_stream_cfg, **metis_cfg, **opensearch_cfg, ) diff --git a/ariadne/settings_sections.py b/ariadne/settings_sections.py index 8cdb63b..393f01e 100644 --- a/ariadne/settings_sections.py +++ b/ariadne/settings_sections.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any from .settings_env import _env, _env_bool, _env_float, _env_int @@ -307,6 +308,7 @@ def _schedule_config() -> dict[str, Any]: "ARIADNE_SCHEDULE_TESTING_TRIAGE", "*/15 * * * *", ), + "wolf_oidc_cron": _env("ARIADNE_SCHEDULE_WOLF_OIDC", _env("ARIADNE_SCHEDULE_SUNSHINE_OIDC", "17 */6 * * *")), } @@ -323,6 +325,29 @@ def _cluster_state_config() -> dict[str, Any]: } +def _game_stream_config() -> dict[str, Any]: + raw_workloads = _env("GAME_MODE_DISPLACE_WORKLOADS", "[]") + try: + parsed_workloads = json.loads(raw_workloads) + except json.JSONDecodeError: + parsed_workloads = [] + workloads = parsed_workloads if isinstance(parsed_workloads, list) else [] + return { + "game_mode_node_name": _env("GAME_MODE_NODE_NAME", "titan-24"), + "game_mode_displace_workloads": [item for item in workloads if isinstance(item, dict)], + "game_mode_hook_token": _env("GAME_MODE_HOOK_TOKEN", ""), + "wolf_oidc_client_id": _env("WOLF_OIDC_CLIENT_ID", _env("SUNSHINE_OIDC_CLIENT_ID", "wolf")), + "wolf_oidc_base_url": _env( + "WOLF_OIDC_BASE_URL", + _env("SUNSHINE_OIDC_BASE_URL", "https://wolf.bstein.dev"), + ).rstrip("/"), + "wolf_oidc_vault_path": _env("WOLF_OIDC_VAULT_PATH", _env("SUNSHINE_OIDC_VAULT_PATH", "game-stream/wolf-oidc")), + "game_stream_user_group": _env("GAME_STREAM_USER_GROUP", "game-stream-users"), + "game_stream_admin_group": _env("GAME_STREAM_ADMIN_GROUP", "admin"), + "game_stream_profile_group_prefix": _env("GAME_STREAM_PROFILE_GROUP_PREFIX", "game-stream-profile-"), + } + + def _metis_config() -> dict[str, Any]: return { "metis_base_url": _env("METIS_BASE_URL", "http://metis.maintenance.svc.cluster.local").rstrip("/"), diff --git a/tests/test_app.py b/tests/test_app.py deleted file mode 100644 index b87c045..0000000 --- a/tests/test_app.py +++ /dev/null @@ -1,1094 +0,0 @@ -from __future__ import annotations - -import dataclasses -from datetime import datetime, timezone - -from fastapi import HTTPException -from fastapi.testclient import TestClient - -from ariadne.auth.keycloak import AuthContext -import ariadne.app as app_module - - -def _client(monkeypatch, ctx: AuthContext) -> TestClient: - monkeypatch.setattr(app_module.authenticator, "authenticate", lambda token: ctx) - monkeypatch.setattr(app_module.provisioning, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "start", lambda: None) - monkeypatch.setattr(app_module.provisioning, "stop", lambda: None) - monkeypatch.setattr(app_module.scheduler, "stop", lambda: None) - monkeypatch.setattr(app_module.portal_db, "close", lambda: None) - monkeypatch.setattr(app_module.ariadne_db, "close", lambda: None) - monkeypatch.setattr(app_module.storage, "record_event", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: None) - return TestClient(app_module.app) - - -def test_health_ok(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - resp = client.get("/health") - assert resp.status_code == 200 - assert resp.json() == {"ok": True} - - -def test_startup_and_shutdown(monkeypatch) -> None: - monkeypatch.setattr(app_module.provisioning, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "add_task", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.scheduler, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "stop", lambda: None) - monkeypatch.setattr(app_module.provisioning, "stop", lambda: None) - monkeypatch.setattr(app_module.portal_db, "close", lambda: None) - monkeypatch.setattr(app_module.ariadne_db, "close", lambda: None) - - app_module._startup() - app_module._shutdown() - - -def test_startup_registers_metis_watch(monkeypatch) -> None: - tasks = [] - - monkeypatch.setattr(app_module.provisioning, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "stop", lambda: None) - monkeypatch.setattr(app_module.provisioning, "stop", lambda: None) - monkeypatch.setattr(app_module.portal_db, "close", lambda: None) - monkeypatch.setattr(app_module.ariadne_db, "close", lambda: None) - monkeypatch.setattr( - app_module.scheduler, - "add_task", - lambda name, cron_expr, runner: tasks.append((name, cron_expr)), - ) - - app_module._startup() - - assert any(name == "schedule.metis_sentinel_watch" for name, _cron in tasks) - assert any(name == "schedule.metis_k3s_token_sync" for name, _cron in tasks) - assert any(name == "schedule.platform_quality_suite_probe" for name, _cron in tasks) - assert any(name == "schedule.jenkins_build_weather" for name, _cron in tasks) - assert any(name == "schedule.jenkins_workspace_cleanup" for name, _cron in tasks) - assert any(name == "schedule.wolf_oidc" for name, _cron in tasks) - - -def test_record_event_handles_exception(monkeypatch) -> None: - monkeypatch.setattr(app_module.storage, "record_event", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - app_module._record_event("event", {"ok": True}) - - -def test_parse_event_detail_variants() -> None: - assert app_module._parse_event_detail(None) == "" - assert app_module._parse_event_detail("not-json") == "not-json" - - -def test_missing_auth_header(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - - resp = client.get("/api/admin/access/requests") - assert resp.status_code == 401 - - -def test_invalid_token(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.authenticator, "authenticate", lambda token: (_ for _ in ()).throw(ValueError("bad"))) - - resp = client.get( - "/api/admin/access/requests", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 401 - - -def test_forbidden_admin(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - resp = client.get( - "/api/admin/access/requests", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 403 - - -def test_account_access_denied(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["guest"], claims={}) - client = _client(monkeypatch, ctx) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 403 - - -def test_account_access_allows_missing_groups(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code != 403 - - -def test_retry_access_request_ok(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - executed = [] - invoked = {} - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *_args, **_kwargs: {"status": "accounts_building"}) - monkeypatch.setattr(app_module.portal_db, "execute", lambda query, params=None: executed.append((query, params))) - monkeypatch.setattr(app_module.provisioning, "provision_access_request", lambda code: invoked.setdefault("code", code)) - monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) - - resp = client.post("/api/access/requests/REQ123/retry") - assert resp.status_code == 200 - assert resp.json()["request_code"] == "REQ123" - assert invoked["code"] == "REQ123" - assert any("provision_attempted_at" in query for query, _params in executed) - - -def test_retry_access_request_not_found(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *_args, **_kwargs: None) - - resp = client.post("/api/access/requests/REQ123/retry") - assert resp.status_code == 404 - - -def test_retry_access_request_not_retryable(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *_args, **_kwargs: {"status": "ready"}) - - resp = client.post("/api/access/requests/REQ123/retry") - assert resp.status_code == 409 - - -def test_metrics_endpoint(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - - resp = client.get("/metrics") - assert resp.status_code == 200 - - -def test_mailu_event_endpoint(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - - class DummyEvents: - def handle_event(self, payload): - assert payload == {"wait": False} - return 202, {"status": "accepted"} - - monkeypatch.setattr(app_module, "mailu_events", DummyEvents()) - - resp = client.post("/events", json={"wait": False}) - assert resp.status_code == 202 - assert resp.json()["status"] == "accepted" - - -def test_list_access_requests(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - now = datetime.now(timezone.utc) - monkeypatch.setattr( - app_module.storage, - "list_pending_requests", - lambda: [ - { - "request_code": "REQ1", - "username": "alice", - "contact_email": "alice@example.com", - "note": "hello", - "status": "pending", - "created_at": now, - } - ], - ) - - resp = client.get( - "/api/admin/access/requests", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["requests"][0]["username"] == "alice" - - -def test_list_access_requests_error(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.storage, "list_pending_requests", lambda: (_ for _ in ()).throw(RuntimeError("fail"))) - - resp = client.get( - "/api/admin/access/requests", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 502 - - -def test_list_audit_events(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - now = datetime.now(timezone.utc) - monkeypatch.setattr( - app_module.storage, - "list_events", - lambda **kwargs: [ - { - "id": 1, - "event_type": "mailu_rotate", - "detail": '{"status":"ok"}', - "created_at": now, - } - ], - ) - - resp = client.get( - "/api/admin/audit/events", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["events"][0]["detail"]["status"] == "ok" - - -def test_list_audit_events_error(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.storage, "list_events", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - resp = client.get( - "/api/admin/audit/events", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 502 - - -def test_list_audit_task_runs(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - now = datetime.now(timezone.utc) - monkeypatch.setattr( - app_module.storage, - "list_task_runs", - lambda **kwargs: [ - { - "id": 1, - "request_code": "REQ1", - "task": "mailu_sync", - "status": "ok", - "detail": "done", - "started_at": now, - "finished_at": now, - "duration_ms": 120, - } - ], - ) - - resp = client.get( - "/api/admin/audit/task-runs", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["task_runs"][0]["task"] == "mailu_sync" - - -def test_list_audit_task_runs_error(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.storage, "list_task_runs", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - resp = client.get( - "/api/admin/audit/task-runs", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 502 - - -def test_game_mode_admin_start(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr( - app_module.game_mode, - "start", - lambda game, note=None: {"status": "active", "active": True, "game": game, "note": note}, - ) - - resp = client.post( - "/api/admin/game-mode/start", - headers={"Authorization": "Bearer token"}, - json={"game": "arc raiders", "note": "play"}, - ) - assert resp.status_code == 200 - assert resp.json()["game"] == "arc raiders" - - -def test_game_mode_admin_status(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.game_mode, "status", lambda: {"status": "idle", "active": False}) - - resp = client.get( - "/api/admin/game-mode/status", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - assert resp.json()["active"] is False - - -def test_game_mode_hook_requires_token(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module, "settings", dataclasses.replace(app_module.settings, game_mode_hook_token="secret")) - - resp = client.post("/api/game-mode/start", json={"game": "satisfactory"}) - assert resp.status_code == 401 - - -def test_game_mode_hook_start(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module, "settings", dataclasses.replace(app_module.settings, game_mode_hook_token="secret")) - monkeypatch.setattr( - app_module.game_mode, - "start", - lambda game, note=None: {"status": "active", "active": True, "game": game}, - ) - - resp = client.post( - "/api/game-mode/start", - headers={"X-Ariadne-Game-Mode-Token": "secret"}, - json={"game": "satisfactory"}, - ) - assert resp.status_code == 200 - assert resp.json()["game"] == "satisfactory" - - -def test_game_stream_profile_me(monkeypatch) -> None: - ctx = AuthContext(username="player-one", email="", groups=["game-stream-users"], claims={}) - client = _client(monkeypatch, ctx) - - resp = client.get("/api/game-stream/me", headers={"Authorization": "Bearer token"}) - - assert resp.status_code == 200 - assert resp.json()["allowed"] is True - assert resp.json()["profile_id"] == "user-player-one" - - -def test_wolf_oauth2_ensure(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr( - app_module.oauth2_proxy, - "ensure_wolf", - lambda: {"status": "ok", "client_id": "wolf", "vault_path": "game-stream/wolf-oidc"}, - ) - - resp = client.post( - "/api/admin/game-stream/wolf/oauth2/ensure", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - assert resp.json()["client_id"] == "wolf" - - -def test_access_flags_from_keycloak(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "list_group_names", lambda **kwargs: ["demo", "test"]) - - resp = client.get( - "/api/admin/access/flags", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - assert resp.json()["flags"] == ["demo", "test"] - - -def test_access_flags_fallback(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: False) - monkeypatch.setattr( - app_module, - "settings", - dataclasses.replace(app_module.settings, allowed_flag_groups=["demo"]), - ) - - resp = client.get( - "/api/admin/access/flags", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - assert resp.json()["flags"] == ["demo"] - - -def test_access_request_approve(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - captured = {} - - def fake_fetchone(_query, params): - captured["flags"] = params[1] - return {"request_code": "REQ1"} - - monkeypatch.setattr(app_module.portal_db, "fetchone", fake_fetchone) - monkeypatch.setattr(app_module.provisioning, "provision_access_request", lambda code: None) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "list_group_names", lambda **kwargs: ["demo"]) - - resp = client.post( - "/api/admin/access/requests/alice/approve", - headers={"Authorization": "Bearer token"}, - json={"flags": ["demo", "test", "admin"], "note": "ok"}, - ) - assert resp.status_code == 200 - assert resp.json()["request_code"] == "REQ1" - assert captured["flags"] == ["demo"] - - -def test_access_request_approve_bad_json(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *args, **kwargs: {"request_code": "REQ1"}) - - resp = client.post( - "/api/admin/access/requests/alice/approve", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - data="{bad}", - ) - assert resp.status_code == 200 - - -def test_access_request_approve_db_error(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr( - app_module.portal_db, - "fetchone", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail")), - ) - - resp = client.post( - "/api/admin/access/requests/alice/approve", - headers={"Authorization": "Bearer token"}, - json={}, - ) - assert resp.status_code == 502 - - -def test_access_request_approve_skipped(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *args, **kwargs: None) - - resp = client.post( - "/api/admin/access/requests/alice/approve", - headers={"Authorization": "Bearer token"}, - json={"flags": ["demo"]}, - ) - assert resp.status_code == 200 - assert resp.json()["request_code"] == "" - - -def test_access_request_deny(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *args, **kwargs: {"request_code": "REQ2"}) - - resp = client.post( - "/api/admin/access/requests/alice/deny", - headers={"Authorization": "Bearer token"}, - json={"note": "no"}, - ) - assert resp.status_code == 200 - assert resp.json()["request_code"] == "REQ2" - - -def test_access_request_deny_db_error(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr( - app_module.portal_db, - "fetchone", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail")), - ) - - resp = client.post( - "/api/admin/access/requests/alice/deny", - headers={"Authorization": "Bearer token"}, - json={}, - ) - assert resp.status_code == 502 - - -def test_access_request_deny_skipped(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *args, **kwargs: None) - - resp = client.post( - "/api/admin/access/requests/alice/deny", - headers={"Authorization": "Bearer token"}, - json={"note": "no"}, - ) - assert resp.status_code == 200 - assert resp.json()["request_code"] == "" - - -def test_rotate_mailu_password(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.mailu, "ready", lambda: True) - monkeypatch.setattr(app_module.mailu, "sync", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "ok"}) - - resp = client.post( - "/api/account/mailu/rotate", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["sync_ok"] is True - assert payload["password"] - - -def test_rotate_mailu_password_missing_config(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: False) - - resp = client.post( - "/api/account/mailu/rotate", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 503 - - -def test_reset_wger_password(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["status"] == "ok" - - -def test_reset_firefly_password(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["status"] == "ok" - - -def test_nextcloud_mail_sync(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "ok"}) - - resp = client.post( - "/api/account/nextcloud/mail/sync", - headers={"Authorization": "Bearer token"}, - json={"wait": True}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["status"] == "ok" - - -def test_nextcloud_mail_sync_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.nextcloud, "sync_mail", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - - resp = client.post( - "/api/account/nextcloud/mail/sync", - headers={"Authorization": "Bearer token"}, - json={"wait": True}, - ) - assert resp.status_code == 502 - - -def test_require_admin_allows_group() -> None: - ctx = AuthContext(username="alice", email="", groups=["admin"], claims={}) - app_module._require_admin(ctx) - - -def test_require_account_access_allows_when_disabled(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=[], claims={}) - dummy_settings = type("S", (), {"account_allowed_groups": []})() - monkeypatch.setattr(app_module, "settings", dummy_settings) - app_module._require_account_access(ctx) - - -def test_access_request_deny_bad_json(monkeypatch) -> None: - ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.portal_db, "fetchone", lambda *args, **kwargs: {"request_code": "REQ2"}) - - resp = client.post( - "/api/admin/access/requests/alice/deny", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - data="{bad}", - ) - assert resp.status_code == 200 - - -def test_rotate_mailu_password_missing_username(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - - resp = client.post( - "/api/account/mailu/rotate", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 400 - - -def test_rotate_mailu_password_sync_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.mailu, "sync", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - monkeypatch.setattr(app_module.nextcloud, "sync_mail", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - - resp = client.post( - "/api/account/mailu/rotate", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - payload = resp.json() - assert payload["sync_ok"] is False - assert payload["nextcloud_sync"]["status"] == "error" - - -def test_rotate_mailu_password_handles_storage_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - - resp = client.post( - "/api/account/mailu/rotate", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - - -def test_rotate_mailu_password_failure(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - - resp = client.post( - "/api/account/mailu/rotate", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 502 - - -def test_rotate_mailu_password_http_exception(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr( - app_module.keycloak_admin, - "set_user_attribute", - lambda *args, **kwargs: (_ for _ in ()).throw(HTTPException(status_code=409, detail="conflict")), - ) - - resp = client.post( - "/api/account/mailu/rotate", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 409 - - -def test_wger_reset_missing_username(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 400 - - -def test_wger_reset_unconfigured(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: False) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 503 - - -def test_wger_reset_uses_mailu_string(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - captured = {} - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr( - app_module.keycloak_admin, - "find_user", - lambda username: {"attributes": {"mailu_email": "alias@bstein.dev"}}, - ) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - - def fake_sync_user(username, email, password, wait=True): - captured["email"] = email - return {"status": "ok"} - - monkeypatch.setattr(app_module.wger, "sync_user", fake_sync_user) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - assert captured["email"] == "alias@bstein.dev" - - -def test_wger_reset_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - monkeypatch.setattr(app_module.wger, "sync_user", lambda *args, **kwargs: {"status": "error"}) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 502 - - -def test_wger_reset_http_exception(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - - def raise_http(*_args, **_kwargs): - raise HTTPException(status_code=409, detail="conflict") - - monkeypatch.setattr(app_module.wger, "sync_user", raise_http) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 409 - - -def test_wger_reset_handles_storage_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr( - app_module.storage, - "record_task_run", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail")), - ) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - - -def test_wger_reset_handles_find_user_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr( - app_module.keycloak_admin, - "find_user", - lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("fail")), - ) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - - resp = client.post( - "/api/account/wger/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - - -def test_firefly_reset_missing_username(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 400 - - -def test_firefly_reset_unconfigured(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: False) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 503 - - -def test_firefly_reset_uses_mailu_string(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - captured = {} - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr( - app_module.keycloak_admin, - "find_user", - lambda username: {"attributes": {"mailu_email": "alias@bstein.dev"}}, - ) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - - def fake_sync_user(email, password, wait=True): - captured["email"] = email - return {"status": "ok"} - - monkeypatch.setattr(app_module.firefly, "sync_user", fake_sync_user) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - assert captured["email"] == "alias@bstein.dev" - - -def test_firefly_reset_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - monkeypatch.setattr(app_module.firefly, "sync_user", lambda *args, **kwargs: {"status": "error"}) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 502 - - -def test_firefly_reset_http_exception(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - - def raise_http(*_args, **_kwargs): - raise HTTPException(status_code=409, detail="conflict") - - monkeypatch.setattr(app_module.firefly, "sync_user", raise_http) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 409 - - -def test_firefly_reset_handles_storage_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.keycloak_admin, "find_user", lambda username: {"attributes": {"mailu_email": ["alice@bstein.dev"]}}) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr( - app_module.storage, - "record_task_run", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail")), - ) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - - -def test_firefly_reset_handles_find_user_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr( - app_module.keycloak_admin, - "find_user", - lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("fail")), - ) - monkeypatch.setattr(app_module.keycloak_admin, "set_user_attribute", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - - resp = client.post( - "/api/account/firefly/reset", - headers={"Authorization": "Bearer token"}, - ) - assert resp.status_code == 200 - - -def test_nextcloud_mail_sync_bad_json(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "ok"}) - - resp = client.post( - "/api/account/nextcloud/mail/sync", - headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, - data="{bad}", - ) - assert resp.status_code == 200 - - -def test_nextcloud_mail_sync_unconfigured(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: False) - - resp = client.post( - "/api/account/nextcloud/mail/sync", - headers={"Authorization": "Bearer token"}, - json={"wait": True}, - ) - assert resp.status_code == 503 - - -def test_nextcloud_mail_sync_missing_username(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - - resp = client.post( - "/api/account/nextcloud/mail/sync", - headers={"Authorization": "Bearer token"}, - json={"wait": True}, - ) - assert resp.status_code == 400 - - -def test_nextcloud_mail_sync_http_exception(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr( - app_module.nextcloud, - "sync_mail", - lambda *args, **kwargs: (_ for _ in ()).throw(HTTPException(status_code=409, detail="conflict")), - ) - - resp = client.post( - "/api/account/nextcloud/mail/sync", - headers={"Authorization": "Bearer token"}, - json={"wait": True}, - ) - assert resp.status_code == 409 - - -def test_nextcloud_mail_sync_handles_storage_error(monkeypatch) -> None: - ctx = AuthContext(username="alice", email="", groups=["dev"], claims={}) - client = _client(monkeypatch, ctx) - - monkeypatch.setattr(app_module.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(app_module.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr( - app_module.storage, - "record_task_run", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail")), - ) - - resp = client.post( - "/api/account/nextcloud/mail/sync", - headers={"Authorization": "Bearer token"}, - json={"wait": True}, - ) - assert resp.status_code == 200 diff --git a/tests/test_game_mode.py b/tests/test_game_mode.py index 6fe62e0..4161d50 100644 --- a/tests/test_game_mode.py +++ b/tests/test_game_mode.py @@ -6,17 +6,11 @@ from ariadne.services import game_mode as game_mode_module from ariadne.services.game_mode import GameModeService -def _settings() -> SimpleNamespace: +def _settings(workloads=None) -> SimpleNamespace: return SimpleNamespace( game_mode_node_name="titan-24", - game_mode_displace_workloads=[ - { - "kind": "Deployment", - "namespace": "openclaw", - "name": "openclaw-ollama", - "restoreReplicas": 1, - } - ], + game_mode_displace_workloads=workloads + or [{"kind": "Deployment", "namespace": "openclaw", "name": "openclaw-ollama", "restoreReplicas": 1}], ) @@ -41,62 +35,62 @@ def test_game_mode_start_and_stop_patch_scale(monkeypatch) -> None: monkeypatch.setattr(game_mode_module, "record_game_mode_transition", lambda *args, **kwargs: None) svc = GameModeService() - started = svc.start("Arc Raiders") - assert started["active"] is True + assert svc.start("Arc Raiders")["active"] is True assert calls[-1][1] == {"spec": {"replicas": 0}} - - stopped = svc.stop() - assert stopped["active"] is False + assert svc.stop()["active"] is False assert calls[-1][1] == {"spec": {"replicas": 1}} -def test_game_mode_status_reports_workload(monkeypatch) -> None: - monkeypatch.setattr(game_mode_module, "settings", _settings()) - monkeypatch.setattr( - game_mode_module, - "get_json", - lambda _path: {"spec": {"replicas": 0}, "status": {"replicas": 0}}, - ) - monkeypatch.setattr(game_mode_module, "set_game_mode_state", lambda *args, **kwargs: None) - monkeypatch.setattr(game_mode_module, "set_game_mode_managed_replicas", lambda *args, **kwargs: None) - - status = GameModeService().status() - assert status["status"] == "active" - assert status["workloads"][0]["name"] == "openclaw-ollama" - - def test_game_mode_supports_statefulset_workload(monkeypatch) -> None: - monkeypatch.setattr( - game_mode_module, - "settings", - SimpleNamespace( - game_mode_node_name="titan-24", - game_mode_displace_workloads=[ - { - "kind": "StatefulSet", - "namespace": "hermes", - "name": "hermes-llm", - "restoreReplicas": "2", - } - ], - ), - ) + workload = [{"kind": "StatefulSet", "namespace": "hermes", "name": "hermes-llm", "restoreReplicas": "2"}] + monkeypatch.setattr(game_mode_module, "settings", _settings(workload)) calls: list[str] = [] - - monkeypatch.setattr( - game_mode_module, - "get_json", - lambda _path: {"spec": {"replicas": 2}, "status": {"replicas": 2}}, - ) - - def fake_patch_json(path, _payload): - calls.append(path) - return {"ok": True} - - monkeypatch.setattr(game_mode_module, "patch_json", fake_patch_json) + monkeypatch.setattr(game_mode_module, "get_json", lambda _path: {"spec": {"replicas": 2}, "status": {"replicas": 2}}) + monkeypatch.setattr(game_mode_module, "patch_json", lambda path, _payload: calls.append(path) or {"ok": True}) monkeypatch.setattr(game_mode_module, "set_game_mode_state", lambda *args, **kwargs: None) monkeypatch.setattr(game_mode_module, "set_game_mode_managed_replicas", lambda *args, **kwargs: None) monkeypatch.setattr(game_mode_module, "record_game_mode_transition", lambda *args, **kwargs: None) GameModeService().start("wolf") assert calls == ["/apis/apps/v1/namespaces/hermes/statefulsets/hermes-llm/scale"] + + +def test_game_mode_ignores_invalid_workloads_and_fallback_replicas(monkeypatch) -> None: + workloads = [ + {"kind": "Deployment", "namespace": "", "name": "missing"}, + {"kind": "Deployment", "namespace": "openclaw", "name": "ollama", "restoreReplicas": "bad"}, + ] + monkeypatch.setattr(game_mode_module, "settings", _settings(workloads)) + monkeypatch.setattr(game_mode_module, "get_json", lambda _path: {"spec": {"replicas": None}, "status": {}}) + monkeypatch.setattr(game_mode_module, "set_game_mode_state", lambda *args, **kwargs: None) + monkeypatch.setattr(game_mode_module, "set_game_mode_managed_replicas", lambda *args, **kwargs: None) + + status = GameModeService().status() + assert status["workloads"][0]["restore_replicas"] == 1 + assert status["workloads"][0]["desired_replicas"] is None + + +def test_game_mode_rejects_unsupported_kind(monkeypatch) -> None: + monkeypatch.setattr(game_mode_module, "settings", _settings([{"kind": "Job", "namespace": "x", "name": "y"}])) + monkeypatch.setattr(game_mode_module, "record_game_mode_transition", lambda *args, **kwargs: None) + + try: + GameModeService().start("arc") + except ValueError as exc: + assert "unsupported" in str(exc) + else: + raise AssertionError("unsupported kind should fail") + + +def test_game_mode_records_stop_errors(monkeypatch) -> None: + monkeypatch.setattr(game_mode_module, "settings", _settings()) + transitions = [] + monkeypatch.setattr(game_mode_module, "record_game_mode_transition", lambda *args: transitions.append(args)) + monkeypatch.setattr(game_mode_module, "patch_json", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + + try: + GameModeService().stop("arc") + except RuntimeError: + pass + + assert transitions[-1] == ("stop", "error", "arc") diff --git a/tests/test_game_mode_metrics.py b/tests/test_game_mode_metrics.py new file mode 100644 index 0000000..2bc8d9e --- /dev/null +++ b/tests/test_game_mode_metrics.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from ariadne.metrics import metrics + + +def test_game_mode_metric_helpers() -> None: + metrics.set_game_mode_state("", "", True) + metrics.record_game_mode_transition("", "", "") + metrics.set_game_mode_managed_replicas("openclaw", "ollama", 0) + metrics.set_game_mode_managed_replicas("openclaw", "ollama", None) diff --git a/tests/test_game_stream_profiles.py b/tests/test_game_stream_profiles.py index fcdbc01..0c8ab66 100644 --- a/tests/test_game_stream_profiles.py +++ b/tests/test_game_stream_profiles.py @@ -16,19 +16,14 @@ def _settings() -> SimpleNamespace: def test_profile_defaults_to_user_profile(monkeypatch) -> None: monkeypatch.setattr(profile_module, "settings", _settings()) - profile = GameStreamProfileService().profile_for("Brad Stein", ["game-stream-users"]) - assert profile["allowed"] is True assert profile["profile_id"] == "user-brad-stein" - assert profile["profile_group"] == "" def test_profile_group_overrides_user_profile(monkeypatch) -> None: monkeypatch.setattr(profile_module, "settings", _settings()) - profile = GameStreamProfileService().profile_for("brad", ["/game-stream-profile-family"]) - assert profile["allowed"] is True assert profile["profile_id"] == "family" assert profile["profile_group"] == "game-stream-profile-family" @@ -36,8 +31,6 @@ def test_profile_group_overrides_user_profile(monkeypatch) -> None: def test_profile_denies_unlisted_user(monkeypatch) -> None: monkeypatch.setattr(profile_module, "settings", _settings()) - profile = GameStreamProfileService().profile_for("guest", ["other"]) - assert profile["allowed"] is False assert profile["profile_id"] == "user-guest" diff --git a/tests/test_k8s_client.py b/tests/test_k8s_client.py index 1aed85d..b21b78c 100644 --- a/tests/test_k8s_client.py +++ b/tests/test_k8s_client.py @@ -70,16 +70,23 @@ def test_post_json_success(monkeypatch) -> None: assert result == {"ok": True} -def test_patch_json_success(monkeypatch) -> None: +def test_patch_json_uses_merge_patch_header(monkeypatch) -> None: dummy_settings = types.SimpleNamespace(k8s_api_timeout_sec=5.0) + observed_headers = {} + monkeypatch.setattr(k8s_client, "settings", dummy_settings) monkeypatch.setattr(k8s_client, "_read_service_account", lambda: ("token", "/tmp/ca")) - client = DummyClient() - monkeypatch.setattr(k8s_client.httpx, "Client", lambda *args, **kwargs: client) + + class HeaderClient(DummyClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + observed_headers.update(kwargs.get("headers") or {}) + + monkeypatch.setattr(k8s_client.httpx, "Client", HeaderClient) result = k8s_client.patch_json("/api/test", {"spec": {"replicas": 0}}) assert result == {"ok": True} - assert client.calls[0][0] == "PATCH" + assert observed_headers["Content-Type"] == "application/merge-patch+json" def test_patch_json_rejects_non_dict(monkeypatch) -> None: @@ -91,7 +98,7 @@ def test_patch_json_rejects_non_dict(monkeypatch) -> None: monkeypatch.setattr(k8s_client.httpx, "Client", lambda *args, **kwargs: client) with pytest.raises(RuntimeError): - k8s_client.patch_json("/api/test", {"spec": {"replicas": 0}}) + k8s_client.patch_json("/api/test", {"payload": "ok"}) def test_get_json_rejects_non_dict(monkeypatch) -> None: diff --git a/tests/test_keycloak_admin.py b/tests/test_keycloak_admin.py deleted file mode 100644 index 4503fab..0000000 --- a/tests/test_keycloak_admin.py +++ /dev/null @@ -1,768 +0,0 @@ -from __future__ import annotations - -from typing import Any -import types - -import httpx -import pytest - -from ariadne.services.keycloak_admin import KeycloakAdminClient - - -class DummyResponse: - def __init__(self, payload=None, status_code=200, headers=None): - self._payload = payload - self.status_code = status_code - self.headers = headers or {} - - def json(self): - return self._payload - - def raise_for_status(self): - if self.status_code >= 400: - request = httpx.Request("GET", "https://example.com") - response = httpx.Response(self.status_code, request=request) - raise httpx.HTTPStatusError("error", request=request, response=response) - - -class DummyClient: - def __init__(self, responses): - self._responses = list(responses) - self.calls = [] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def _next(self): - if not self._responses: - raise RuntimeError("missing response") - return self._responses.pop(0) - - def get(self, url, params=None, headers=None): - self.calls.append(("get", url, params)) - return self._next() - - def post(self, url, data=None, json=None, headers=None): - self.calls.append(("post", url, data, json)) - return self._next() - - def put(self, url, headers=None, json=None): - self.calls.append(("put", url, json)) - return self._next() - - -def test_set_user_attribute_preserves_profile(monkeypatch) -> None: - client = KeycloakAdminClient() - captured: dict[str, Any] = {} - - def fake_find_user(username: str) -> dict[str, Any]: - return {"id": "user-123"} - - def fake_get_user(user_id: str) -> dict[str, Any]: - return { - "id": user_id, - "username": "alice", - "email": "alice@bstein.dev", - "emailVerified": True, - "enabled": True, - "firstName": "Alice", - "lastName": "Smith", - "requiredActions": ["UPDATE_PASSWORD", 123], - "attributes": {"existing": ["value"]}, - } - - def fake_update_user(user_id: str, payload: dict[str, Any]) -> None: - captured["user_id"] = user_id - captured["payload"] = payload - - monkeypatch.setattr(client, "find_user", fake_find_user) - monkeypatch.setattr(client, "get_user", fake_get_user) - monkeypatch.setattr(client, "update_user", fake_update_user) - - client.set_user_attribute("alice", "mailu_app_password", "secret") - - payload = captured.get("payload") or {} - assert payload.get("username") == "alice" - assert payload.get("email") == "alice@bstein.dev" - assert payload.get("emailVerified") is True - assert payload.get("enabled") is True - assert payload.get("firstName") == "Alice" - assert payload.get("lastName") == "Smith" - assert payload.get("requiredActions") == ["UPDATE_PASSWORD"] - assert payload.get("attributes") == { - "existing": ["value"], - "mailu_app_password": ["secret"], - } - - -def test_update_user_safe_merges_payload(monkeypatch) -> None: - client = KeycloakAdminClient() - captured: dict[str, Any] = {} - - def fake_get_user(user_id: str) -> dict[str, Any]: - return { - "id": user_id, - "username": "alice", - "enabled": True, - "attributes": {"existing": ["value"]}, - } - - def fake_update_user(user_id: str, payload: dict[str, Any]) -> None: - captured["user_id"] = user_id - captured["payload"] = payload - - monkeypatch.setattr(client, "get_user", fake_get_user) - monkeypatch.setattr(client, "update_user", fake_update_user) - - client.update_user_safe( - "user-123", - {"attributes": {"new": ["item"]}, "requiredActions": ["UPDATE_PASSWORD"]}, - ) - - payload = captured.get("payload") or {} - assert payload.get("username") == "alice" - assert payload.get("attributes") == {"existing": ["value"], "new": ["item"]} - assert payload.get("requiredActions") == ["UPDATE_PASSWORD"] - - -def test_get_token_fetches_once(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - dummy = DummyClient([DummyResponse({"access_token": "token", "expires_in": 120})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - assert client._get_token() == "token" - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("should not call"))) - assert client._get_token() == "token" - - -def test_find_user_by_email_case_insensitive(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse([{"email": "Alice@Example.com", "id": "1"}])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - user = client.find_user_by_email("alice@example.com") - assert user["id"] == "1" - - -def test_find_user_invalid_payload(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse(["bad"])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - assert client.find_user("alice") is None - - -def test_find_user_by_email_empty() -> None: - client = KeycloakAdminClient() - assert client.find_user_by_email("") is None - - -def test_find_user_by_email_invalid_payload(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({"bad": "payload"})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - assert client.find_user_by_email("alice@example.com") is None - - -def test_list_group_names_filters(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse([{"name": "demo"}, {"name": "admin"}, {"name": "test"}])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - assert client.list_group_names(exclude={"admin"}) == ["demo", "test"] - - -def test_find_user_by_email_skips_non_dict(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse(["bad"])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - assert client.find_user_by_email("alice@example.com") is None - - -def test_get_user_invalid_payload(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse("bad")]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - with pytest.raises(RuntimeError): - client.get_user("user-1") - - -def test_update_user_calls_put(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - client.update_user("user-1", {"enabled": True}) - assert dummy.calls - - -def test_update_user_safe_handles_bad_attrs(monkeypatch) -> None: - client = KeycloakAdminClient() - captured: dict[str, Any] = {} - - def fake_get_user(user_id: str) -> dict[str, Any]: - return {"id": user_id, "username": "alice", "attributes": "bad"} - - def fake_update_user(user_id: str, payload: dict[str, Any]) -> None: - captured["payload"] = payload - - monkeypatch.setattr(client, "get_user", fake_get_user) - monkeypatch.setattr(client, "update_user", fake_update_user) - - client.update_user_safe("user-1", {"attributes": {"new": ["item"]}}) - assert captured["payload"]["attributes"] == {"new": ["item"]} - - -def test_set_user_attribute_user_id_missing(monkeypatch) -> None: - client = KeycloakAdminClient() - - def fake_find_user(username: str) -> dict[str, Any]: - return {"id": ""} - - monkeypatch.setattr(client, "find_user", fake_find_user) - with pytest.raises(RuntimeError): - client.set_user_attribute("alice", "attr", "val") - - -def test_set_user_attribute_handles_bad_attrs(monkeypatch) -> None: - client = KeycloakAdminClient() - - def fake_find_user(username: str) -> dict[str, Any]: - return {"id": "user-1"} - - def fake_get_user(user_id: str) -> dict[str, Any]: - return {"id": user_id, "username": "alice", "attributes": "bad"} - - monkeypatch.setattr(client, "find_user", fake_find_user) - monkeypatch.setattr(client, "get_user", fake_get_user) - monkeypatch.setattr(client, "update_user", lambda *_args, **_kwargs: None) - - client.set_user_attribute("alice", "attr", "val") - - -def test_get_group_id_skips_non_dict(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse(["bad"])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - assert client.get_group_id("demo") is None -def test_get_group_id_cached(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse([{"name": "demo", "id": "gid"}])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - assert client.get_group_id("demo") == "gid" - - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("no call"))) - assert client.get_group_id("demo") == "gid" - - -def test_get_group_id_invalid_payload(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({"bad": "payload"})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - assert client.get_group_id("demo") is None - - -def test_iter_users_paginates(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient( - [ - DummyResponse([{"id": "1"}, {"id": "2"}]), - DummyResponse([{"id": "3"}]), - ] - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - users = client.iter_users(page_size=2, brief=True) - assert [u["id"] for u in users] == ["1", "2", "3"] - - -def test_iter_users_empty(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse([])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - assert client.iter_users(page_size=2) == [] - - -def test_create_user_parses_location(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({}, headers={"Location": "http://kc/admin/realms/atlas/users/abc"})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - assert client.create_user({"username": "alice"}) == "abc" - - -def test_create_user_missing_location(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({}, headers={})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - with pytest.raises(RuntimeError): - client.create_user({"username": "alice"}) - - -def test_find_client(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse([{"id": "abc", "clientId": "sunshine"}])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - assert client.find_client("sunshine") == {"id": "abc", "clientId": "sunshine"} - - -def test_find_client_missing(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse([])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - assert client.find_client("sunshine") is None - - -def test_client_crud_helpers(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient( - [ - DummyResponse({}), - DummyResponse({}), - DummyResponse({"value": "secret"}), - DummyResponse([{"name": "groups", "id": "scope-id"}]), - DummyResponse({}), - ] - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - client.create_client({"clientId": "sunshine"}) - client.update_client("uuid", {"clientId": "sunshine"}) - assert client.get_client_secret("uuid") == "secret" - assert client.find_client_scope_id("groups") == "scope-id" - client.attach_optional_client_scope("uuid", "scope-id") - - -def test_get_client_secret_missing(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - with pytest.raises(RuntimeError): - client.get_client_secret("uuid") - - -def test_get_token_missing_access_token(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - dummy = DummyClient([DummyResponse({"expires_in": 120})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - with pytest.raises(RuntimeError): - client._get_token() - - -def test_reset_password_raises_on_error(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({}, status_code=400)]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - with pytest.raises(httpx.HTTPStatusError): - client.reset_password("user", "pw", temporary=True) - - -def test_get_token_requires_config(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="", - keycloak_admin_client_secret="", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - with pytest.raises(RuntimeError): - client._get_token() - - -def test_headers_includes_bearer(monkeypatch) -> None: - client = KeycloakAdminClient() - monkeypatch.setattr(client, "_get_token", lambda: "token") - headers = client.headers() - assert headers["Authorization"] == "Bearer token" - - -def test_find_user_returns_none(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse([])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - assert client.find_user("alice") is None - - -def test_get_user_invalid_payload(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse(["bad"])]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - with pytest.raises(RuntimeError): - client.get_user("id") - - -def test_get_user_success(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({"id": "1"})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - user = client.get_user("id") - assert user["id"] == "1" - - -def test_set_user_attribute_user_missing(monkeypatch) -> None: - client = KeycloakAdminClient() - monkeypatch.setattr(client, "find_user", lambda username: None) - with pytest.raises(RuntimeError): - client.set_user_attribute("alice", "attr", "value") - - -def test_set_user_attribute_user_id_missing(monkeypatch) -> None: - client = KeycloakAdminClient() - monkeypatch.setattr(client, "find_user", lambda username: {}) - with pytest.raises(RuntimeError): - client.set_user_attribute("alice", "attr", "value") - - -def test_add_user_to_group(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse({})]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - - client.add_user_to_group("user", "group") - assert dummy.calls[0][0] == "put" - - -def test_get_user_raises_on_non_dict_payload(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse("bad")]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - with pytest.raises(RuntimeError): - client.get_user("user-1") - - -def test_update_user_safe_coerces_bad_attrs(monkeypatch) -> None: - client = KeycloakAdminClient() - monkeypatch.setattr(client, "get_user", lambda *_args, **_kwargs: {"id": "user-1"}) - monkeypatch.setattr(client, "_safe_update_payload", lambda *_args, **_kwargs: {"attributes": "bad"}) - monkeypatch.setattr(client, "update_user", lambda *_args, **_kwargs: None) - - client.update_user_safe("user-1", {"attributes": {"new": ["item"]}}) - - -def test_set_user_attribute_coerces_bad_attrs(monkeypatch) -> None: - client = KeycloakAdminClient() - monkeypatch.setattr(client, "find_user", lambda username: {"id": "user-1"}) - monkeypatch.setattr(client, "get_user", lambda *_args, **_kwargs: {"id": "user-1"}) - monkeypatch.setattr(client, "_safe_update_payload", lambda *_args, **_kwargs: {"attributes": "bad"}) - monkeypatch.setattr(client, "update_user", lambda *_args, **_kwargs: None) - - client.set_user_attribute("alice", "attr", "value") - - -def test_set_user_attribute_user_id_missing_raises(monkeypatch) -> None: - client = KeycloakAdminClient() - monkeypatch.setattr(client, "find_user", lambda username: {"id": ""}) - with pytest.raises(RuntimeError): - client.set_user_attribute("alice", "attr", "value") - - -def test_get_user_rejects_non_dict_payload(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - keycloak_admin_url="http://kc", - keycloak_admin_realm="atlas", - keycloak_admin_client_id="client", - keycloak_admin_client_secret="secret", - keycloak_realm="atlas", - ) - monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) - client = KeycloakAdminClient() - client._token = "token" - client._expires_at = 9999999999 - - dummy = DummyClient([DummyResponse(123)]) - monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) - with pytest.raises(RuntimeError) as exc: - client.get_user("user-1") - assert "unexpected user payload" in str(exc.value) diff --git a/tests/test_oauth2_proxy.py b/tests/test_oauth2_proxy.py index 3b48d14..020d400 100644 --- a/tests/test_oauth2_proxy.py +++ b/tests/test_oauth2_proxy.py @@ -10,7 +10,6 @@ def test_oauth_client_payload() -> None: payload = _oauth_client_payload("wolf", "https://wolf.bstein.dev") assert payload["clientId"] == "wolf" assert payload["redirectUris"] == ["https://wolf.bstein.dev/oauth2/callback"] - assert payload["webOrigins"] == ["https://wolf.bstein.dev"] def test_valid_cookie_secret() -> None: @@ -23,11 +22,7 @@ def test_ensure_wolf_creates_client_and_writes_vault(monkeypatch) -> None: monkeypatch.setattr( oauth_module, "settings", - SimpleNamespace( - wolf_oidc_client_id="wolf", - wolf_oidc_base_url="https://wolf.bstein.dev", - wolf_oidc_vault_path="game-stream/wolf-oidc", - ), + SimpleNamespace(wolf_oidc_client_id="wolf", wolf_oidc_base_url="https://wolf.bstein.dev", wolf_oidc_vault_path="game-stream/wolf-oidc"), ) calls: list[str] = [] written = {} @@ -40,9 +35,7 @@ def test_ensure_wolf_creates_client_and_writes_vault(monkeypatch) -> None: return True def find_client(self, client_id): - if not self.created: - return None - return {"id": "client-uuid", "clientId": client_id} + return {"id": "client-uuid", "clientId": client_id} if self.created else None def create_client(self, _payload): self.created = True @@ -51,8 +44,7 @@ def test_ensure_wolf_creates_client_and_writes_vault(monkeypatch) -> None: def update_client(self, _client_uuid, _payload): calls.append("update") - def find_client_scope_id(self, name): - assert name == "groups" + def find_client_scope_id(self, _name): return "scope-uuid" def attach_optional_client_scope(self, _client_uuid, _scope_id): @@ -62,8 +54,7 @@ def test_ensure_wolf_creates_client_and_writes_vault(monkeypatch) -> None: return "client-secret" class DummyVault: - def read_kv_secret(self, path): - assert path == "game-stream/wolf-oidc" + def read_kv_secret(self, _path): return {"cookie_secret": "a" * 32} def write_kv_secret(self, path, data): @@ -73,8 +64,7 @@ def test_ensure_wolf_creates_client_and_writes_vault(monkeypatch) -> None: monkeypatch.setattr(oauth_module, "keycloak_admin", DummyKeycloak()) monkeypatch.setattr(oauth_module, "vault", DummyVault()) - result = OAuth2ProxyService().ensure_wolf() - assert result["status"] == "ok" + assert OAuth2ProxyService().ensure_wolf()["status"] == "ok" assert calls == ["create", "update", "scope"] assert written["data"]["client_secret"] == "client-secret" assert written["data"]["cookie_secret"] == "a" * 32 @@ -84,12 +74,47 @@ def test_ensure_wolf_reports_missing_keycloak(monkeypatch) -> None: monkeypatch.setattr( oauth_module, "settings", - SimpleNamespace( - wolf_oidc_client_id="wolf", - wolf_oidc_base_url="https://wolf.bstein.dev", - wolf_oidc_vault_path="game-stream/wolf-oidc", - ), + SimpleNamespace(wolf_oidc_client_id="wolf", wolf_oidc_base_url="https://wolf.bstein.dev", wolf_oidc_vault_path="game-stream/wolf-oidc"), ) monkeypatch.setattr(oauth_module, "keycloak_admin", SimpleNamespace(ready=lambda: False)) - assert OAuth2ProxyService().ensure_wolf()["status"] == "error" + + +def test_ensure_wolf_rejects_missing_client_after_create(monkeypatch) -> None: + monkeypatch.setattr( + oauth_module, + "settings", + SimpleNamespace(wolf_oidc_client_id="wolf", wolf_oidc_base_url="https://wolf.bstein.dev", wolf_oidc_vault_path="game-stream/wolf-oidc"), + ) + monkeypatch.setattr( + oauth_module, + "keycloak_admin", + SimpleNamespace(ready=lambda: True, find_client=lambda _client_id: None, create_client=lambda _payload: None), + ) + + try: + OAuth2ProxyService().ensure_wolf() + except RuntimeError as exc: + assert "not found" in str(exc) + else: + raise AssertionError("missing client should fail") + + +def test_ensure_wolf_rejects_missing_client_uuid(monkeypatch) -> None: + monkeypatch.setattr( + oauth_module, + "settings", + SimpleNamespace(wolf_oidc_client_id="wolf", wolf_oidc_base_url="https://wolf.bstein.dev", wolf_oidc_vault_path="game-stream/wolf-oidc"), + ) + monkeypatch.setattr( + oauth_module, + "keycloak_admin", + SimpleNamespace(ready=lambda: True, find_client=lambda _client_id: {"id": ""}, create_client=lambda _payload: None), + ) + + try: + OAuth2ProxyService().ensure_sunshine() + except RuntimeError as exc: + assert "id missing" in str(exc) + else: + raise AssertionError("missing client id should fail") diff --git a/tests/test_settings.py b/tests/test_settings.py index e5f8c46..c42d3e6 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -48,3 +48,26 @@ def test_from_env_includes_jenkins_weather_settings(monkeypatch) -> None: assert cfg.testing_triage_model_url == "http://openclaw-ollama:11434" assert cfg.testing_triage_model == "local-model" assert cfg.testing_triage_model_timeout_sec == 33.5 + + +def test_from_env_includes_game_stream_settings(monkeypatch) -> None: + monkeypatch.setenv("GAME_MODE_NODE_NAME", "titan-24") + monkeypatch.setenv( + "GAME_MODE_DISPLACE_WORKLOADS", + '[{"kind":"StatefulSet","namespace":"hermes","name":"hermes-llm","restoreReplicas":2}]', + ) + monkeypatch.setenv("GAME_MODE_HOOK_TOKEN", "hook") + monkeypatch.setenv("WOLF_OIDC_CLIENT_ID", "wolf") + monkeypatch.setenv("WOLF_OIDC_BASE_URL", "https://wolf.bstein.dev/") + monkeypatch.setenv("WOLF_OIDC_VAULT_PATH", "game-stream/wolf-oidc") + monkeypatch.setenv("ARIADNE_SCHEDULE_WOLF_OIDC", "*/13 * * * *") + + cfg = Settings.from_env() + + assert cfg.game_mode_node_name == "titan-24" + assert cfg.game_mode_displace_workloads[0]["namespace"] == "hermes" + assert cfg.game_mode_hook_token == "hook" + assert cfg.wolf_oidc_client_id == "wolf" + assert cfg.wolf_oidc_base_url == "https://wolf.bstein.dev" + assert cfg.wolf_oidc_vault_path == "game-stream/wolf-oidc" + assert cfg.wolf_oidc_cron == "*/13 * * * *" diff --git a/tests/test_testing_triage_diagnosis.py b/tests/test_testing_triage_diagnosis.py index 2b5ef47..7d30a25 100644 --- a/tests/test_testing_triage_diagnosis.py +++ b/tests/test_testing_triage_diagnosis.py @@ -154,8 +154,38 @@ def test_diagnose_testing_triage_handles_disabled_and_bad_json(monkeypatch) -> N assert "model_json_parse_failed" in diagnosis["unknowns"][0] +def test_diagnose_testing_triage_handles_empty_model_response(monkeypatch) -> None: + class EmptyResponse: + def raise_for_status(self) -> None: + return None + + def json(self): # type: ignore[no-untyped-def] + return {"response": ""} + + class EmptyClient: + def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] + return None + + def __enter__(self): + return self + + def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] + return None + + def post(self, url, json=None): # type: ignore[no-untyped-def] + return EmptyResponse() + + monkeypatch.setattr(testing_triage_diagnosis, "settings", SettingsStub(testing_triage_model_url="http://ollama")) + monkeypatch.setattr(testing_triage_diagnosis.httpx, "Client", EmptyClient) + diagnosis = testing_triage_diagnosis.diagnose_testing_triage({"summary": {"status": "ok", "problem_count": 0}}) + + assert diagnosis["status"] == "ok" + assert "empty_model_response" in diagnosis["unknowns"] + + def test_latest_testing_triage_diagnosis_decodes_stored_json() -> None: storage = DummyStorage() + assert testing_triage_diagnosis.latest_testing_triage_diagnosis(storage) is None # type: ignore[arg-type] payload = {"kind": "testing_triage_diagnosis", "status": "ok"} storage.events.append((testing_triage_diagnosis.TRIAGE_DIAGNOSIS_EVENT_TYPE, json.dumps(payload))) @@ -167,6 +197,9 @@ def test_latest_testing_triage_diagnosis_decodes_stored_json() -> None: storage.events.append((testing_triage_diagnosis.TRIAGE_DIAGNOSIS_EVENT_TYPE, json.dumps(["not", "a", "dict"]))) assert testing_triage_diagnosis.latest_testing_triage_diagnosis(storage) is None # type: ignore[arg-type] + storage.events.append((testing_triage_diagnosis.TRIAGE_DIAGNOSIS_EVENT_TYPE, 7)) + assert testing_triage_diagnosis.latest_testing_triage_diagnosis(storage) is None # type: ignore[arg-type] + def test_model_configuration_helpers_normalize_settings(monkeypatch) -> None: monkeypatch.setattr( @@ -303,6 +336,33 @@ def test_diagnosis_from_model_rejects_non_english_and_out_of_scope_jobs(monkeypa assert "model_evidence_refs_out_of_scope" in diagnosis["unknowns"] +def test_diagnosis_helpers_cover_boolean_and_defaults() -> None: + assert testing_triage_diagnosis._bool_value("yes", False) is True # noqa: SLF001 + assert testing_triage_diagnosis._bool_value("no", True) is False # noqa: SLF001 + assert testing_triage_diagnosis._bool_value("maybe", True) is True # noqa: SLF001 + assert testing_triage_diagnosis._allowed_suite_jobs({"failed_suites": ["", "bstein_home", "data_prepper"]}) == { # noqa: SLF001 + "bstein_home", + "bstein-home", + "bstein-dev-home", + "data_prepper", + "data-prepper", + } + assert testing_triage_diagnosis._default_next_actions({"problem_count": 0}) == [ # noqa: SLF001 + "No action required unless a fresh bundle changes the status." + ] + assert testing_triage_diagnosis._default_next_actions({"problem_count": 1}) == [ # noqa: SLF001 + "Review the evidence bundle sections with non-empty problem lists.", + "Check the named Jenkins build logs and Flux Kustomizations before changing manifests.", + ] + + +def test_safe_evidence_refs_rejects_non_ascii_refs() -> None: + unknowns = [] + refs = testing_triage_diagnosis._safe_evidence_refs(["référence"], {}, unknowns) # noqa: SLF001 + assert refs == [] + assert unknowns == ["model_evidence_refs_non_english"] + + def test_default_evidence_refs_include_failed_suites() -> None: refs = testing_triage_diagnosis._default_evidence_refs( # noqa: SLF001 {"status": "needs_attention", "problem_count": 3, "failed_suites": ["a", "b", "c", "d", "e", "f", "g"]} diff --git a/tests/test_vault.py b/tests/test_vault.py index 0c0804e..c1f377e 100644 --- a/tests/test_vault.py +++ b/tests/test_vault.py @@ -188,7 +188,7 @@ def test_vault_ensure_token_login(monkeypatch) -> None: assert svc._ensure_token() == "tok" -def test_vault_read_write_kv(monkeypatch) -> None: +def test_vault_read_write_kv_secret(monkeypatch) -> None: dummy_settings = types.SimpleNamespace( vault_addr="http://vault", vault_token="token", @@ -197,41 +197,18 @@ def test_vault_read_write_kv(monkeypatch) -> None: vault_k8s_token_reviewer_jwt_file="", k8s_api_timeout_sec=5.0, ) + calls = [] monkeypatch.setattr(vault_module, "settings", dummy_settings) - calls: list[tuple[str, str, dict | None]] = [] def fake_request(self, method: str, path: str, json=None): calls.append((method, path, json)) if method == "GET": - return DummyResponse({"data": {"data": {"client_id": "sunshine"}}}) + return DummyResponse({"data": {"data": {"client_id": "wolf"}}}) return DummyResponse({}) monkeypatch.setattr(vault_module.VaultClient, "request", fake_request) svc = VaultService() - assert svc.read_kv_secret("game-stream/sunshine-oidc") == {"client_id": "sunshine"} - svc.write_kv_secret("game-stream/sunshine-oidc", {"client_id": "sunshine"}) - assert calls[-1] == ( - "POST", - "/v1/kv/data/atlas/game-stream/sunshine-oidc", - {"data": {"client_id": "sunshine"}}, - ) - - -def test_vault_read_kv_missing(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vault_addr="http://vault", - vault_token="token", - vault_k8s_role="vault", - vault_k8s_token_reviewer_jwt="jwt", - vault_k8s_token_reviewer_jwt_file="", - k8s_api_timeout_sec=5.0, - ) - monkeypatch.setattr(vault_module, "settings", dummy_settings) - monkeypatch.setattr( - vault_module.VaultClient, - "request", - lambda *args, **kwargs: DummyResponse({}, status_code=404), - ) - - assert VaultService().read_kv_secret("missing") is None + assert svc.read_kv_secret("game-stream/wolf-oidc") == {"client_id": "wolf"} + svc.write_kv_secret("game-stream/wolf-oidc", {"client_id": "wolf"}) + assert calls[-1] == ("POST", "/v1/kv/data/atlas/game-stream/wolf-oidc", {"data": {"client_id": "wolf"}}) diff --git a/tests/unit/app/test_app_game_routes.py b/tests/unit/app/test_app_game_routes.py new file mode 100644 index 0000000..6f8c40c --- /dev/null +++ b/tests/unit/app/test_app_game_routes.py @@ -0,0 +1,121 @@ +from tests.unit.app.app_route_helpers import * + + +def test_game_stream_profile_me(monkeypatch) -> None: + ctx = AuthContext(username="brad", email="", groups=["game-stream-users"], claims={}) + client = _client(monkeypatch, ctx) + + resp = client.get("/api/game-stream/me", headers={"Authorization": "Bearer token"}) + assert resp.status_code == 200 + assert resp.json()["allowed"] is True + + +def test_game_mode_admin_start_and_stop(monkeypatch) -> None: + ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) + client = _client(monkeypatch, ctx) + calls = [] + + monkeypatch.setattr(app_module.game_mode, "start", lambda game, note=None: calls.append(("start", game, note)) or {"status": "active"}) + monkeypatch.setattr(app_module.game_mode, "stop", lambda game, note=None: calls.append(("stop", game, note)) or {"status": "idle"}) + monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + start = client.post("/api/admin/game-mode/start", headers={"Authorization": "Bearer token"}, json={"game": "arc", "note": "now"}) + stop = client.post("/api/admin/game-mode/stop", headers={"Authorization": "Bearer token"}, json={"game": "arc"}) + assert start.status_code == 200 + assert stop.status_code == 200 + assert calls[0] == ("start", "arc", "now") + + +def test_game_mode_hook_requires_token(monkeypatch) -> None: + ctx = AuthContext(username="", email="", groups=[], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module, "settings", dataclasses.replace(app_module.settings, game_mode_hook_token="secret")) + + resp = client.post("/api/game-mode/start", json={"game": "arc"}) + assert resp.status_code == 401 + + +def test_game_mode_hook_requires_configured_token(monkeypatch) -> None: + ctx = AuthContext(username="", email="", groups=[], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module, "settings", dataclasses.replace(app_module.settings, game_mode_hook_token="")) + + resp = client.post("/api/game-mode/start", headers={"Authorization": "Bearer secret"}, json={"game": "arc"}) + assert resp.status_code == 503 + + +def test_game_mode_hook_start_and_stop(monkeypatch) -> None: + ctx = AuthContext(username="", email="", groups=[], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module, "settings", dataclasses.replace(app_module.settings, game_mode_hook_token="secret")) + monkeypatch.setattr(app_module.game_mode, "start", lambda game, note=None: {"status": "active", "game": game}) + monkeypatch.setattr(app_module.game_mode, "stop", lambda game, note=None: {"status": "idle", "game": game}) + monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + start = client.post("/api/game-mode/start", headers={"Authorization": "Bearer secret"}, json={"game": "arc"}) + stop = client.post("/api/game-mode/stop", headers={"x-ariadne-game-mode-token": "secret"}, json={"game": "arc"}) + assert start.status_code == 200 + assert stop.status_code == 200 + + +def test_game_mode_status_error(monkeypatch) -> None: + ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module.game_mode, "status", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + + resp = client.get("/api/admin/game-mode/status", headers={"Authorization": "Bearer token"}) + assert resp.status_code == 502 + + +def test_game_mode_action_error_records(monkeypatch) -> None: + ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) + client = _client(monkeypatch, ctx) + recorded = [] + monkeypatch.setattr(app_module.game_mode, "start", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: recorded.append(args)) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + resp = client.post("/api/admin/game-mode/start", headers={"Authorization": "Bearer token"}, json={"game": "arc"}) + assert resp.status_code == 502 + assert recorded + + +def test_wolf_oauth2_ensure(monkeypatch) -> None: + ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) + client = _client(monkeypatch, ctx) + + monkeypatch.setattr(app_module.oauth2_proxy, "ensure_wolf", lambda: {"status": "ok", "client_id": "wolf"}) + monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + resp = client.post("/api/admin/game-stream/wolf/oauth2/ensure", headers={"Authorization": "Bearer token"}) + assert resp.status_code == 200 + assert resp.json()["client_id"] == "wolf" + + +def test_wolf_oauth2_ensure_error_paths(monkeypatch) -> None: + ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: None) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + monkeypatch.setattr(app_module.oauth2_proxy, "ensure_wolf", lambda: {"status": "error", "detail": "missing"}) + resp = client.post("/api/admin/game-stream/wolf/oauth2/ensure", headers={"Authorization": "Bearer token"}) + assert resp.status_code == 502 + + monkeypatch.setattr(app_module.oauth2_proxy, "ensure_wolf", lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + alias = client.post("/api/admin/game-stream/sunshine/oauth2/ensure", headers={"Authorization": "Bearer token"}) + assert alias.status_code == 502 + + +def test_record_simple_task_swallows_storage_errors(monkeypatch) -> None: + ctx = AuthContext(username="bstein", email="", groups=["admin"], claims={}) + client = _client(monkeypatch, ctx) + monkeypatch.setattr(app_module.game_mode, "start", lambda game, note=None: {"status": "active", "game": game}) + monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("db"))) + monkeypatch.setattr(app_module, "_record_event", lambda *args, **kwargs: None) + + resp = client.post("/api/admin/game-mode/start", headers={"Authorization": "Bearer token"}, json={"game": "arc"}) + assert resp.status_code == 200 diff --git a/tests/unit/app/test_app_lifecycle.py b/tests/unit/app/test_app_lifecycle.py index e6e09f3..c562aa9 100644 --- a/tests/unit/app/test_app_lifecycle.py +++ b/tests/unit/app/test_app_lifecycle.py @@ -43,6 +43,7 @@ def test_startup_registers_metis_watch(monkeypatch) -> None: assert any(name == "schedule.jenkins_build_weather" for name, _cron in tasks) assert any(name == "schedule.jenkins_workspace_cleanup" for name, _cron in tasks) assert any(name == "schedule.testing_triage" for name, _cron in tasks) + assert any(name == "schedule.wolf_oidc" for name, _cron in tasks) def test_record_event_handles_exception(monkeypatch) -> None: monkeypatch.setattr(app_module.storage, "record_event", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) diff --git a/tests/unit/services/test_keycloak_admin_lifecycle.py b/tests/unit/services/test_keycloak_admin_lifecycle.py index a3e31b9..65d0c73 100644 --- a/tests/unit/services/test_keycloak_admin_lifecycle.py +++ b/tests/unit/services/test_keycloak_admin_lifecycle.py @@ -56,3 +56,57 @@ def test_reset_password_raises_on_error(monkeypatch) -> None: with pytest.raises(httpx.HTTPStatusError): client.reset_password("user", "pw", temporary=True) + + +def test_client_lifecycle_helpers(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + keycloak_admin_url="http://kc", + keycloak_admin_realm="atlas", + keycloak_admin_client_id="client", + keycloak_admin_client_secret="secret", + keycloak_realm="atlas", + ) + monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) + client = KeycloakAdminClient() + client._token = "token" + client._expires_at = 9999999999 + dummy = DummyClient( + [ + DummyResponse([{"id": "uuid", "clientId": "wolf"}]), + DummyResponse({}), + DummyResponse({}), + DummyResponse({"value": "secret"}), + DummyResponse([{"id": "scope", "name": "groups"}]), + DummyResponse({}), + ] + ) + monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) + + assert client.find_client("wolf")["id"] == "uuid" + client.create_client({"clientId": "wolf"}) + client.update_client("uuid", {"clientId": "wolf"}) + assert client.get_client_secret("uuid") == "secret" + assert client.find_client_scope_id("groups") == "scope" + client.attach_optional_client_scope("uuid", "scope") + assert dummy.calls[-1][0] == "put" + + +def test_client_helpers_handle_missing_payloads(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + keycloak_admin_url="http://kc", + keycloak_admin_realm="atlas", + keycloak_admin_client_id="client", + keycloak_admin_client_secret="secret", + keycloak_realm="atlas", + ) + monkeypatch.setattr("ariadne.services.keycloak_admin.settings", dummy_settings) + client = KeycloakAdminClient() + client._token = "token" + client._expires_at = 9999999999 + dummy = DummyClient([DummyResponse([]), DummyResponse([]), DummyResponse({})]) + monkeypatch.setattr("ariadne.services.keycloak_admin.httpx.Client", lambda *args, **kwargs: dummy) + + assert client.find_client("wolf") is None + assert client.find_client_scope_id("groups") is None + with pytest.raises(RuntimeError): + client.get_client_secret("uuid")