diff --git a/ariadne/app.py b/ariadne/app.py index b460f46..34872a5 100644 --- a/ariadne/app.py +++ b/ariadne/app.py @@ -1,57 +1,75 @@ from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timezone import json -import sys -from typing import Any +import secrets +import threading +from typing import Any, Callable 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 .auth.keycloak import AuthContext, authenticator from .db.database import Database, DatabaseConfig -from .db.storage import Storage +from .db.storage import Storage, TaskRunRecord 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.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.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.vault import vault 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 +from .utils.logging import LogConfig, configure_logging, get_logger, task_context 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( @@ -79,7 +97,6 @@ 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: @@ -98,6 +115,9 @@ 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: @@ -139,6 +159,22 @@ 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 [] @@ -153,8 +189,112 @@ def _allowed_flag_groups() -> list[str]: return settings.allowed_flag_groups -def _app_module() -> Any: - return sys.modules[__name__] +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) @app.on_event("startup") @@ -162,35 +302,123 @@ 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.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.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.start() logger.info( "ariadne started", @@ -216,7 +444,6 @@ 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, @@ -225,6 +452,7 @@ def _startup() -> None: "comms_seed_room_cron": settings.comms_seed_room_cron, "keycloak_profile_cron": settings.keycloak_profile_cron, "cluster_state_cron": settings.cluster_state_cron, + "wolf_oidc_cron": settings.wolf_oidc_cron, }, ) @@ -253,13 +481,742 @@ 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) diff --git a/ariadne/k8s/client.py b/ariadne/k8s/client.py index c7dca62..ab5cb74 100644 --- a/ariadne/k8s/client.py +++ b/ariadne/k8s/client.py @@ -24,10 +24,17 @@ 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) -> 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}"} + if extra_headers: + headers.update(extra_headers) with httpx.Client(verify=ca_path, timeout=settings.k8s_api_timeout_sec, headers=headers) as client: resp = client.request(method, url, json=payload) resp.raise_for_status() @@ -52,6 +59,20 @@ def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]: return data +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"}, + ) + if not isinstance(data, dict): + raise RuntimeError("unexpected kubernetes response") + return data + + def delete_json(path: str) -> dict[str, Any]: """Delete a Kubernetes API resource and return the response payload.""" diff --git a/ariadne/metrics/metrics.py b/ariadne/metrics/metrics.py index e51db46..cb08fa9 100644 --- a/ariadne/metrics/metrics.py +++ b/ariadne/metrics/metrics.py @@ -69,6 +69,26 @@ CLUSTER_STATE_KUSTOMIZATIONS_NOT_READY = Gauge( "ariadne_cluster_kustomizations_not_ready", "Flux kustomizations not Ready", ) +GAME_MODE_ACTIVE = Gauge( + "ariadne_game_mode_active", + "Ariadne game mode state (1=active,0=idle)", + ["node", "game"], +) +GAME_MODE_TRANSITIONS_TOTAL = Counter( + "ariadne_game_mode_transitions_total", + "Ariadne game mode transitions by action and status", + ["action", "status", "game"], +) +GAME_MODE_MANAGED_REPLICAS = Gauge( + "ariadne_game_mode_managed_replicas", + "Replicas for workloads managed by Ariadne game mode", + ["namespace", "deployment"], +) +GAME_MODE_LAST_TRANSITION_TS = Gauge( + "ariadne_game_mode_last_transition_timestamp_seconds", + "Last Ariadne game mode transition timestamp", + ["action", "status", "game"], +) def record_task_run(task: str, status: str, duration_sec: float | None) -> None: @@ -79,7 +99,13 @@ 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: @@ -101,7 +127,13 @@ 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()) @@ -113,3 +145,24 @@ def set_cluster_state_metrics(collected_at: datetime, nodes_total: int | None, n CLUSTER_STATE_PODS_RUNNING.set(pods_running) if kustomizations_not_ready is not None: CLUSTER_STATE_KUSTOMIZATIONS_NOT_READY.set(kustomizations_not_ready) + + +def set_game_mode_state(node: str, game: str, active: bool) -> None: + """Publish the current game-mode state.""" + + GAME_MODE_ACTIVE.labels(node=node or "unknown", game=game or "unknown").set(1 if active else 0) + + +def record_game_mode_transition(action: str, status: str, game: str) -> None: + """Increment game-mode transition metrics.""" + + labels = {"action": action or "unknown", "status": status or "unknown", "game": game or "unknown"} + GAME_MODE_TRANSITIONS_TOTAL.labels(**labels).inc() + GAME_MODE_LAST_TRANSITION_TS.labels(**labels).set(datetime.now().timestamp()) + + +def set_game_mode_managed_replicas(namespace: str, deployment: str, replicas: int | None) -> None: + """Publish desired replicas for a workload that game mode can move.""" + + if replicas is not None: + GAME_MODE_MANAGED_REPLICAS.labels(namespace=namespace, deployment=deployment).set(replicas) diff --git a/ariadne/services/game_mode.py b/ariadne/services/game_mode.py new file mode 100644 index 0000000..96f93af --- /dev/null +++ b/ariadne/services/game_mode.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from dataclasses import dataclass +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 ..settings import settings +from ..utils.logging import get_logger + + +logger = get_logger(__name__) + + +@dataclass(frozen=True) +class ManagedWorkload: + kind: str + namespace: str + name: str + restore_replicas: int + + +class GameModeService: + """Move shared titan-24 GPU resources between infrastructure and gaming.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._current_game = "" + + def _workloads(self) -> list[ManagedWorkload]: + workloads: list[ManagedWorkload] = [] + for item in settings.game_mode_displace_workloads: + namespace = str(item.get("namespace") or "").strip() + name = str(item.get("name") or "").strip() + kind = str(item.get("kind") or "Deployment").strip() or "Deployment" + replicas = item.get("restoreReplicas", item.get("restore_replicas", 1)) + if not namespace or not name: + continue + try: + 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), + ) + ) + return workloads + + @staticmethod + def _game_name(game: str | None) -> str: + normalized = (game or "wolf").strip().lower().replace(" ", "-") + return normalized[:64] or "wolf" + + @staticmethod + def _scale_path(workload: ManagedWorkload) -> str: + resource = { + "deployment": "deployments", + "deployments": "deployments", + "statefulset": "statefulsets", + "statefulsets": "statefulsets", + }.get(workload.kind.lower()) + if not resource: + raise ValueError(f"unsupported game-mode workload kind: {workload.kind}") + return f"/apis/apps/v1/namespaces/{workload.namespace}/{resource}/{workload.name}/scale" + + def _replicas(self, workload: ManagedWorkload) -> tuple[int | None, int | None]: + payload = get_json(self._scale_path(workload)) + spec = payload.get("spec") if isinstance(payload.get("spec"), dict) else {} + status = payload.get("status") if isinstance(payload.get("status"), dict) else {} + desired = spec.get("replicas") + current = status.get("replicas") + return ( + int(desired) if isinstance(desired, int) else None, + int(current) if isinstance(current, int) else None, + ) + + def _set_replicas(self, workload: ManagedWorkload, replicas: int) -> dict[str, Any]: + payload = patch_json(self._scale_path(workload), {"spec": {"replicas": replicas}}) + set_game_mode_managed_replicas(workload.namespace, workload.name, replicas) + return payload + + def status(self) -> dict[str, Any]: + workloads: list[dict[str, Any]] = [] + for workload in self._workloads(): + desired, current = self._replicas(workload) + set_game_mode_managed_replicas(workload.namespace, workload.name, desired) + workloads.append( + { + "kind": workload.kind, + "namespace": workload.namespace, + "name": workload.name, + "desired_replicas": desired, + "current_replicas": current, + "restore_replicas": workload.restore_replicas, + } + ) + + 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, + } + + def start(self, game: str | None = None, note: str | None = None) -> dict[str, Any]: + game_name = self._game_name(game) + with self._lock: + try: + for workload in self._workloads(): + self._set_replicas(workload, 0) + 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 ""}, + ) + result = self.status() + result["action"] = "start" + return result + except Exception: + record_game_mode_transition("start", "error", game_name) + logger.exception("game mode start failed", extra={"event": "game_mode_start", "game": game_name}) + raise + + def stop(self, game: str | None = None, note: str | None = None) -> dict[str, Any]: + game_name = self._game_name(game or self._current_game or "wolf") + with self._lock: + try: + for workload in self._workloads(): + self._set_replicas(workload, workload.restore_replicas) + 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 ""}, + ) + result = self.status() + result["action"] = "stop" + result["game"] = game_name + return result + except Exception: + record_game_mode_transition("stop", "error", game_name) + logger.exception("game mode stop failed", extra={"event": "game_mode_stop", "game": game_name}) + raise + + +game_mode = GameModeService() diff --git a/ariadne/services/game_stream_profiles.py b/ariadne/services/game_stream_profiles.py new file mode 100644 index 0000000..29120e9 --- /dev/null +++ b/ariadne/services/game_stream_profiles.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import re +from typing import Any + +from ..settings import settings + + +_SLUG_RE = re.compile(r"[^a-z0-9_-]+") + + +def _slug(value: str, fallback: str) -> str: + normalized = _SLUG_RE.sub("-", value.strip().lower()).strip("-") + return normalized[:64] or fallback + + +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("/")) + return values + + +class GameStreamProfileService: + """Map Keycloak users and groups into Wolf profile policy.""" + + def profile_for(self, username: str, groups: list[str]) -> dict[str, Any]: + clean_username = _slug(username, "user") + group_values = _group_values(groups) + user_group = settings.game_stream_user_group + admin_group = settings.game_stream_admin_group + prefix = settings.game_stream_profile_group_prefix + allowed = user_group in group_values or admin_group in group_values + + profile_group = "" + profile_id = f"user-{clean_username}" + for group in sorted(group_values): + 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 + + return { + "username": username, + "allowed": allowed, + "profile_id": profile_id, + "profile_group": profile_group, + "user_group": user_group, + "admin_group": admin_group, + "profile_group_prefix": prefix, + } + + +game_stream_profiles = GameStreamProfileService() diff --git a/ariadne/services/keycloak_admin.py b/ariadne/services/keycloak_admin.py index 1d926b5..b7d0b75 100644 --- a/ariadne/services/keycloak_admin.py +++ b/ariadne/services/keycloak_admin.py @@ -158,6 +158,66 @@ class KeycloakAdminClient: return location.rstrip("/").split("/")[-1] raise RuntimeError("failed to determine created user id") + 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.raise_for_status() + payload = resp.json() + if not isinstance(payload, list) or not payload: + return None + item = payload[0] + return item if isinstance(item, dict) else None + + def create_client(self, payload: dict[str, Any]) -> None: + url = f"{settings.keycloak_admin_url}/admin/realms/{settings.keycloak_realm}/clients" + with httpx.Client(timeout=10.0) as client: + resp = client.post(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload) + resp.raise_for_status() + + def update_client(self, client_uuid: str, payload: dict[str, Any]) -> None: + url = f"{settings.keycloak_admin_url}/admin/realms/{settings.keycloak_realm}/clients/{client_uuid}" + with httpx.Client(timeout=10.0) as client: + resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload) + resp.raise_for_status() + + def get_client_secret(self, client_uuid: str) -> str: + url = f"{settings.keycloak_admin_url}/admin/realms/{settings.keycloak_realm}/clients/{client_uuid}/client-secret" + with httpx.Client(timeout=10.0) as client: + resp = client.get(url, headers=self._headers()) + resp.raise_for_status() + payload = resp.json() + secret = payload.get("value") if isinstance(payload, dict) else None + if not isinstance(secret, str) or not secret: + raise RuntimeError("client secret missing") + return secret + + 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.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"): + return str(item["id"]) + return None + + def attach_optional_client_scope(self, client_uuid: str, scope_id: str) -> None: + url = ( + f"{settings.keycloak_admin_url}/admin/realms/{settings.keycloak_realm}" + f"/clients/{client_uuid}/optional-client-scopes/{scope_id}" + ) + with httpx.Client(timeout=10.0) as client: + resp = client.put(url, headers=self._headers()) + resp.raise_for_status() + def reset_password(self, user_id: str, password: str, temporary: bool = False) -> None: url = ( f"{settings.keycloak_admin_url}/admin/realms/{settings.keycloak_realm}" diff --git a/ariadne/services/oauth2_proxy.py b/ariadne/services/oauth2_proxy.py new file mode 100644 index 0000000..88119c2 --- /dev/null +++ b/ariadne/services/oauth2_proxy.py @@ -0,0 +1,91 @@ +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 + + +logger = get_logger(__name__) + + +def _oauth_client_payload(client_id: str, base_url: str) -> dict[str, Any]: + return { + "clientId": client_id, + "enabled": True, + "protocol": "openid-connect", + "publicClient": False, + "standardFlowEnabled": True, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "serviceAccountsEnabled": False, + "redirectUris": [f"{base_url}/oauth2/callback"], + "webOrigins": [base_url], + "rootUrl": base_url, + "baseUrl": "/", + } + + +def _valid_cookie_secret(value: Any) -> str: + if not isinstance(value, str): + return "" + stripped = value.strip() + return stripped if len(stripped) in {16, 24, 32} else "" + + +class OAuth2ProxyService: + """Ensure Keycloak and Vault state for oauth2-proxy frontends.""" + + def ensure_wolf(self) -> dict[str, Any]: + if not keycloak_admin.ready(): + return {"status": "error", "detail": "keycloak admin client not configured"} + + client_id = settings.wolf_oidc_client_id + base_url = settings.wolf_oidc_base_url + client = keycloak_admin.find_client(client_id) + payload = _oauth_client_payload(client_id, base_url) + if not client: + keycloak_admin.create_client(payload) + client = keycloak_admin.find_client(client_id) + if not client: + raise RuntimeError(f"keycloak client {client_id} not found") + + client_uuid = str(client.get("id") or "") + if not client_uuid: + raise RuntimeError("keycloak client id missing") + + keycloak_admin.update_client(client_uuid, payload) + scope_id = keycloak_admin.find_client_scope_id("groups") + if scope_id: + keycloak_admin.attach_optional_client_scope(client_uuid, scope_id) + + client_secret = keycloak_admin.get_client_secret(client_uuid) + existing = vault.read_kv_secret(settings.wolf_oidc_vault_path) or {} + 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, + }, + ) + 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, + } + + def ensure_sunshine(self) -> dict[str, Any]: + return self.ensure_wolf() + + +oauth2_proxy = OAuth2ProxyService() diff --git a/ariadne/services/vault.py b/ariadne/services/vault.py index c2e4d5b..98e053f 100644 --- a/ariadne/services/vault.py +++ b/ariadne/services/vault.py @@ -8,12 +8,10 @@ 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__) +HTTP_NOT_FOUND = 404 @dataclass(frozen=True) @@ -48,6 +46,270 @@ 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.""" @@ -108,6 +370,21 @@ class VaultService: token = self._ensure_token() return VaultClient(settings.vault_addr, token) + def read_kv_secret(self, path: str) -> dict[str, Any] | None: + clean_path = path.strip("/") + resp = self._client().request("GET", f"/v1/kv/data/atlas/{clean_path}") + if resp.status_code == HTTP_NOT_FOUND: + return None + resp.raise_for_status() + payload = resp.json() + data = payload.get("data", {}).get("data") if isinstance(payload, dict) else None + return data if isinstance(data, dict) else {} + + def write_kv_secret(self, path: str, data: dict[str, Any]) -> None: + clean_path = path.strip("/") + resp = self._client().request("POST", f"/v1/kv/data/atlas/{clean_path}", json={"data": data}) + resp.raise_for_status() + def _ensure_auth_enabled(self, client: VaultClient, auth_name: str, auth_type: str) -> None: resp = client.request("GET", "/v1/sys/auth") resp.raise_for_status() diff --git a/ariadne/settings.py b/ariadne/settings.py index 4322b3a..1ac539f 100644 --- a/ariadne/settings.py +++ b/ariadne/settings.py @@ -1,29 +1,47 @@ from __future__ import annotations from dataclasses import dataclass +import json +import os +from typing import Any -from .settings_env import _env, _env_bool, _env_float, _env_int -from .settings_sections import ( - _cluster_state_config, - _comms_config, - _firefly_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, -) + +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)] @dataclass(frozen=True) @@ -173,9 +191,6 @@ 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 @@ -228,6 +243,19 @@ class Settings: keycloak_profile_cron: str cluster_state_cron: str 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 + wolf_oidc_vault_path: str + wolf_oidc_cron: str + game_stream_user_group: str + game_stream_admin_group: str + game_stream_profile_group_prefix: str metis_base_url: str metis_watch_url: str metis_timeout_sec: float @@ -244,7 +272,6 @@ 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 @@ -253,27 +280,394 @@ 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 = _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() - metis_cfg = _metis_config() - opensearch_cfg = _opensearch_config() + 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() portal_db = _env("PORTAL_DATABASE_URL", "") ariadne_db = _env("ARIADNE_DATABASE_URL", portal_db) @@ -311,10 +705,10 @@ 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, **metis_cfg, **opensearch_cfg, ) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..b87c045 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,1094 @@ +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 new file mode 100644 index 0000000..6fe62e0 --- /dev/null +++ b/tests/test_game_mode.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ariadne.services import game_mode as game_mode_module +from ariadne.services.game_mode import GameModeService + + +def _settings() -> SimpleNamespace: + return SimpleNamespace( + game_mode_node_name="titan-24", + game_mode_displace_workloads=[ + { + "kind": "Deployment", + "namespace": "openclaw", + "name": "openclaw-ollama", + "restoreReplicas": 1, + } + ], + ) + + +def test_game_mode_start_and_stop_patch_scale(monkeypatch) -> None: + monkeypatch.setattr(game_mode_module, "settings", _settings()) + calls: list[tuple[str, dict]] = [] + replicas = {"desired": 1, "current": 1} + + def fake_get_json(_path): + return {"spec": {"replicas": replicas["desired"]}, "status": {"replicas": replicas["current"]}} + + def fake_patch_json(path, payload): + calls.append((path, payload)) + replicas["desired"] = payload["spec"]["replicas"] + replicas["current"] = payload["spec"]["replicas"] + return {"ok": True} + + monkeypatch.setattr(game_mode_module, "get_json", fake_get_json) + monkeypatch.setattr(game_mode_module, "patch_json", fake_patch_json) + 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) + + svc = GameModeService() + started = svc.start("Arc Raiders") + assert started["active"] is True + assert calls[-1][1] == {"spec": {"replicas": 0}} + + stopped = svc.stop() + assert stopped["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", + } + ], + ), + ) + 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, "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"] diff --git a/tests/test_game_stream_profiles.py b/tests/test_game_stream_profiles.py new file mode 100644 index 0000000..fcdbc01 --- /dev/null +++ b/tests/test_game_stream_profiles.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ariadne.services import game_stream_profiles as profile_module +from ariadne.services.game_stream_profiles import GameStreamProfileService + + +def _settings() -> SimpleNamespace: + return SimpleNamespace( + game_stream_user_group="game-stream-users", + game_stream_admin_group="admin", + game_stream_profile_group_prefix="game-stream-profile-", + ) + + +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" + + +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 4a0d13c..1aed85d 100644 --- a/tests/test_k8s_client.py +++ b/tests/test_k8s_client.py @@ -70,6 +70,30 @@ def test_post_json_success(monkeypatch) -> None: assert result == {"ok": True} +def test_patch_json_success(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace(k8s_api_timeout_sec=5.0) + 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) + + result = k8s_client.patch_json("/api/test", {"spec": {"replicas": 0}}) + assert result == {"ok": True} + assert client.calls[0][0] == "PATCH" + + +def test_patch_json_rejects_non_dict(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace(k8s_api_timeout_sec=5.0) + monkeypatch.setattr(k8s_client, "settings", dummy_settings) + monkeypatch.setattr(k8s_client, "_read_service_account", lambda: ("token", "/tmp/ca")) + client = DummyClient() + client.payload = ["bad"] + monkeypatch.setattr(k8s_client.httpx, "Client", lambda *args, **kwargs: client) + + with pytest.raises(RuntimeError): + k8s_client.patch_json("/api/test", {"spec": {"replicas": 0}}) + + def test_get_json_rejects_non_dict(monkeypatch) -> None: dummy_settings = types.SimpleNamespace(k8s_api_timeout_sec=5.0) monkeypatch.setattr(k8s_client, "settings", dummy_settings) diff --git a/tests/test_keycloak_admin.py b/tests/test_keycloak_admin.py new file mode 100644 index 0000000..4503fab --- /dev/null +++ b/tests/test_keycloak_admin.py @@ -0,0 +1,768 @@ +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 new file mode 100644 index 0000000..3b48d14 --- /dev/null +++ b/tests/test_oauth2_proxy.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from ariadne.services import oauth2_proxy as oauth_module +from ariadne.services.oauth2_proxy import OAuth2ProxyService, _oauth_client_payload, _valid_cookie_secret + + +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: + assert _valid_cookie_secret("x" * 32) == "x" * 32 + assert _valid_cookie_secret("short") == "" + assert _valid_cookie_secret(None) == "" + + +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", + ), + ) + calls: list[str] = [] + written = {} + + class DummyKeycloak: + def __init__(self) -> None: + self.created = False + + def ready(self): + return True + + def find_client(self, client_id): + if not self.created: + return None + return {"id": "client-uuid", "clientId": client_id} + + def create_client(self, _payload): + self.created = True + calls.append("create") + + def update_client(self, _client_uuid, _payload): + calls.append("update") + + def find_client_scope_id(self, name): + assert name == "groups" + return "scope-uuid" + + def attach_optional_client_scope(self, _client_uuid, _scope_id): + calls.append("scope") + + def get_client_secret(self, _client_uuid): + return "client-secret" + + class DummyVault: + def read_kv_secret(self, path): + assert path == "game-stream/wolf-oidc" + return {"cookie_secret": "a" * 32} + + def write_kv_secret(self, path, data): + written["path"] = path + written["data"] = data + + monkeypatch.setattr(oauth_module, "keycloak_admin", DummyKeycloak()) + monkeypatch.setattr(oauth_module, "vault", DummyVault()) + + result = OAuth2ProxyService().ensure_wolf() + assert result["status"] == "ok" + assert calls == ["create", "update", "scope"] + assert written["data"]["client_secret"] == "client-secret" + assert written["data"]["cookie_secret"] == "a" * 32 + + +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", + ), + ) + monkeypatch.setattr(oauth_module, "keycloak_admin", SimpleNamespace(ready=lambda: False)) + + assert OAuth2ProxyService().ensure_wolf()["status"] == "error" diff --git a/tests/test_vault.py b/tests/test_vault.py index dc776f5..0c0804e 100644 --- a/tests/test_vault.py +++ b/tests/test_vault.py @@ -186,3 +186,52 @@ def test_vault_ensure_token_login(monkeypatch) -> None: svc = VaultService() assert svc._ensure_token() == "tok" + + +def test_vault_read_write_kv(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) + 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({}) + + 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