From f0e161ba8b994acb5474821ddce270c1f1a9f7cc Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Apr 2026 02:05:59 -0300 Subject: [PATCH] test(ariadne): split oversized unit suites --- ci/loc_hygiene_waivers.tsv | 4 - tests/__init__.py | 0 tests/test_app.py | 1005 ---------- tests/test_provisioning.py | 1762 ----------------- tests/test_services.py | 1483 -------------- tests/unit/__init__.py | 0 tests/unit/app/__init__.py | 0 tests/unit/app/app_route_helpers.py | 27 + tests/unit/app/test_app_admin_routes.py | 286 +++ .../app/test_app_firefly_account_routes.py | 139 ++ tests/unit/app/test_app_lifecycle.py | 148 ++ .../unit/app/test_app_mailu_account_routes.py | 112 ++ .../app/test_app_nextcloud_account_routes.py | 107 + .../unit/app/test_app_wger_account_routes.py | 139 ++ tests/unit/manager/__init__.py | 0 tests/unit/manager/provisioning_helpers.py | 104 + .../test_provisioning_failure_modes.py | 336 ++++ .../test_provisioning_identity_flow.py | 480 +++++ .../manager/test_provisioning_run_loop.py | 269 +++ .../manager/test_provisioning_status_flow.py | 329 +++ .../test_provisioning_welcome_email.py | 132 ++ tests/unit/services/__init__.py | 0 tests/unit/services/keycloak_admin_helpers.py | 56 + tests/unit/services/service_helpers.py | 73 + tests/unit/services/test_firefly_service.py | 246 +++ .../services/test_keycloak_admin_groups.py | 94 + .../services/test_keycloak_admin_lifecycle.py | 58 + .../services/test_keycloak_admin_tokens.py | 54 + .../services/test_keycloak_admin_users.py} | 316 +-- tests/unit/services/test_mailu_service.py | 369 ++++ tests/unit/services/test_nextcloud_service.py | 99 + .../unit/services/test_vaultwarden_service.py | 355 ++++ tests/unit/services/test_wger_service.py | 307 +++ 33 files changed, 4320 insertions(+), 4569 deletions(-) create mode 100644 tests/__init__.py delete mode 100644 tests/test_app.py delete mode 100644 tests/test_provisioning.py delete mode 100644 tests/test_services.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/app/__init__.py create mode 100644 tests/unit/app/app_route_helpers.py create mode 100644 tests/unit/app/test_app_admin_routes.py create mode 100644 tests/unit/app/test_app_firefly_account_routes.py create mode 100644 tests/unit/app/test_app_lifecycle.py create mode 100644 tests/unit/app/test_app_mailu_account_routes.py create mode 100644 tests/unit/app/test_app_nextcloud_account_routes.py create mode 100644 tests/unit/app/test_app_wger_account_routes.py create mode 100644 tests/unit/manager/__init__.py create mode 100644 tests/unit/manager/provisioning_helpers.py create mode 100644 tests/unit/manager/test_provisioning_failure_modes.py create mode 100644 tests/unit/manager/test_provisioning_identity_flow.py create mode 100644 tests/unit/manager/test_provisioning_run_loop.py create mode 100644 tests/unit/manager/test_provisioning_status_flow.py create mode 100644 tests/unit/manager/test_provisioning_welcome_email.py create mode 100644 tests/unit/services/__init__.py create mode 100644 tests/unit/services/keycloak_admin_helpers.py create mode 100644 tests/unit/services/service_helpers.py create mode 100644 tests/unit/services/test_firefly_service.py create mode 100644 tests/unit/services/test_keycloak_admin_groups.py create mode 100644 tests/unit/services/test_keycloak_admin_lifecycle.py create mode 100644 tests/unit/services/test_keycloak_admin_tokens.py rename tests/{test_keycloak_admin.py => unit/services/test_keycloak_admin_users.py} (57%) create mode 100644 tests/unit/services/test_mailu_service.py create mode 100644 tests/unit/services/test_nextcloud_service.py create mode 100644 tests/unit/services/test_vaultwarden_service.py create mode 100644 tests/unit/services/test_wger_service.py diff --git a/ci/loc_hygiene_waivers.tsv b/ci/loc_hygiene_waivers.tsv index 6a95c1a..2c2cde6 100644 --- a/ci/loc_hygiene_waivers.tsv +++ b/ci/loc_hygiene_waivers.tsv @@ -1,5 +1 @@ # path reason -tests/test_provisioning.py test module split planned; broad provisioning coverage retained meanwhile -tests/test_services.py test module split planned; broad service contract coverage retained meanwhile -tests/test_app.py test module split planned; API coverage retained meanwhile -tests/test_keycloak_admin.py test module split planned; identity admin coverage retained meanwhile diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app.py b/tests/test_app.py deleted file mode 100644 index c73df20..0000000 --- a/tests/test_app.py +++ /dev/null @@ -1,1005 +0,0 @@ -from __future__ import annotations - -import dataclasses -from datetime import datetime, timezone - -from fastapi import HTTPException -from fastapi.testclient import TestClient - -from ariadne.auth.keycloak import AuthContext -import ariadne.app as app_module - - -def _client(monkeypatch, ctx: AuthContext) -> TestClient: - monkeypatch.setattr(app_module.authenticator, "authenticate", lambda token: ctx) - monkeypatch.setattr(app_module.provisioning, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "start", lambda: None) - monkeypatch.setattr(app_module.provisioning, "stop", lambda: None) - monkeypatch.setattr(app_module.scheduler, "stop", lambda: None) - monkeypatch.setattr(app_module.portal_db, "close", lambda: None) - monkeypatch.setattr(app_module.ariadne_db, "close", lambda: None) - monkeypatch.setattr(app_module.storage, "record_event", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.storage, "record_task_run", lambda *args, **kwargs: None) - return TestClient(app_module.app) - - -def test_health_ok(monkeypatch) -> None: - ctx = AuthContext(username="", email="", groups=[], claims={}) - client = _client(monkeypatch, ctx) - resp = client.get("/health") - assert resp.status_code == 200 - assert resp.json() == {"ok": True} - - -def test_startup_and_shutdown(monkeypatch) -> None: - monkeypatch.setattr(app_module.provisioning, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "add_task", lambda *args, **kwargs: None) - monkeypatch.setattr(app_module.scheduler, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "stop", lambda: None) - monkeypatch.setattr(app_module.provisioning, "stop", lambda: None) - monkeypatch.setattr(app_module.portal_db, "close", lambda: None) - monkeypatch.setattr(app_module.ariadne_db, "close", lambda: None) - - app_module._startup() - app_module._shutdown() - - -def test_startup_registers_metis_watch(monkeypatch) -> None: - tasks = [] - - monkeypatch.setattr(app_module.provisioning, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "start", lambda: None) - monkeypatch.setattr(app_module.scheduler, "stop", lambda: None) - monkeypatch.setattr(app_module.provisioning, "stop", lambda: None) - monkeypatch.setattr(app_module.portal_db, "close", lambda: None) - monkeypatch.setattr(app_module.ariadne_db, "close", lambda: None) - monkeypatch.setattr( - app_module.scheduler, - "add_task", - lambda name, cron_expr, runner: tasks.append((name, cron_expr)), - ) - - app_module._startup() - - assert any(name == "schedule.metis_sentinel_watch" for name, _cron in tasks) - assert any(name == "schedule.metis_k3s_token_sync" for name, _cron in tasks) - assert any(name == "schedule.platform_quality_suite_probe" for name, _cron in tasks) - assert any(name == "schedule.jenkins_build_weather" for name, _cron in tasks) - assert any(name == "schedule.jenkins_workspace_cleanup" for name, _cron in tasks) - - -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_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_provisioning.py b/tests/test_provisioning.py deleted file mode 100644 index 7879f47..0000000 --- a/tests/test_provisioning.py +++ /dev/null @@ -1,1762 +0,0 @@ -from __future__ import annotations - -from contextlib import contextmanager -from datetime import datetime, timezone -import types - -from ariadne.manager import provisioning as prov -from ariadne.services.vaultwarden import VaultwardenInvite, VaultwardenLookup - - -class DummyResult: - def __init__(self, row=None): - self._row = row - - def fetchone(self): - return self._row - - def fetchall(self): - return [] - - -class DummyConn: - def __init__(self, row, locked=True): - self._row = row - self._locked = locked - self.executed = [] - - def execute(self, query, params=None): - self.executed.append((query, params)) - if "pg_try_advisory_lock" in query: - return DummyResult({"locked": self._locked}) - if "SELECT username" in query: - return DummyResult(self._row) - return DummyResult() - - -class DummyDB: - def __init__(self, row, locked=True): - self._row = row - self._locked = locked - - @contextmanager - def connection(self): - yield DummyConn(self._row, locked=self._locked) - - def fetchone(self, query, params=None): - return None - - def fetchall(self, query, params=None): - return [] - - -class DummyStorage: - def record_task_run(self, *args, **kwargs): - return None - - def record_event(self, *args, **kwargs): - return None - - def mark_welcome_sent(self, *args, **kwargs): - return None - - def list_provision_candidates(self): - return [] - - -class DummyAdmin: - def __init__(self): - self.groups = [] - - def ready(self): - return True - - def find_user(self, username): - return {"id": "1"} - - def find_user_by_email(self, email): - return None - - def get_user(self, user_id): - return {"id": "1", "attributes": {}} - - def create_user(self, payload): - return "1" - - def update_user(self, user_id, payload): - return None - - def reset_password(self, user_id, password, temporary=False): - return None - - def set_user_attribute(self, username, key, value): - return None - - def get_group_id(self, group_name: str): - return group_name - - def add_user_to_group(self, user_id, group_id): - self.groups.append(group_id) - - -def _patch_mailu_ready(monkeypatch, settings, value=None) -> None: - if value is None: - value = bool(getattr(settings, "mailu_sync_url", "")) - monkeypatch.setattr(prov.mailu, "ready", lambda: value) - - -def test_provisioning_filters_flag_groups(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=["demo", "test"], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - admin = DummyAdmin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) - monkeypatch.setattr(prov.ProvisioningManager, "_send_welcome_email", lambda *args, **kwargs: None) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": None, - "status": "approved", - "initial_password": None, - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": ["demo", "admin"], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - manager.provision_access_request("REQ123") - - assert "dev" in admin.groups - assert "demo" in admin.groups - assert "admin" not in admin.groups - - -def test_provisioning_creates_user_and_password(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=["demo"], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def __init__(self): - super().__init__() - self.created_payload = None - self.reset_calls = [] - - def find_user(self, username): - return None - - def get_user(self, user_id): - return { - "id": user_id, - "username": "alice", - "requiredActions": ["CONFIGURE_TOTP"], - "attributes": {}, - } - - def create_user(self, payload): - self.created_payload = payload - return "user-123" - - def update_user(self, user_id, payload): - return None - - def reset_password(self, user_id, password, temporary=False): - self.reset_calls.append((user_id, temporary)) - - admin = Admin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) - monkeypatch.setattr(prov.ProvisioningManager, "_send_welcome_email", lambda *args, **kwargs: None) - - row = { - "username": "alice", - "first_name": "Alice", - "last_name": "Atlas", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "approved", - "initial_password": None, - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": ["demo"], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ124") - - assert outcome.status == "awaiting_onboarding" - assert admin.created_payload is not None - assert admin.created_payload.get("firstName") == "Alice" - assert admin.created_payload.get("lastName") == "Atlas" - assert admin.reset_calls - - -def test_provisioning_create_user_fallback(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=["demo"], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def __init__(self): - super().__init__() - self.find_calls = 0 - self.reset_calls = [] - - def find_user(self, username): - self.find_calls += 1 - if self.find_calls >= 2: - return {"id": "user-123", "username": username, "attributes": {}, "requiredActions": []} - return None - - def get_user(self, user_id): - return {"id": user_id, "username": "alice", "attributes": {}, "requiredActions": []} - - def create_user(self, payload): - raise RuntimeError("boom") - - def reset_password(self, user_id, password, temporary=False): - self.reset_calls.append((user_id, temporary)) - - admin = Admin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) - monkeypatch.setattr(prov.ProvisioningManager, "_send_welcome_email", lambda *args, **kwargs: None) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "approved", - "initial_password": None, - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": ["demo"], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ125") - - assert outcome.status == "awaiting_onboarding" - assert admin.find_calls >= 2 - assert admin.reset_calls - - -def test_extract_attr_variants() -> None: - assert prov._extract_attr("bad", "key") == "" - assert prov._extract_attr({"key": ["value"]}, "key") == "value" - assert prov._extract_attr({"key": ["", " "]}, "key") == "" - assert prov._extract_attr({"key": "value"}, "key") == "value" - assert prov._extract_attr({}, "key") == "" - - -def test_provisioning_empty_request_code(monkeypatch) -> None: - db = DummyDB({}) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("") - assert outcome.status == "unknown" - - -def test_provisioning_admin_not_ready(monkeypatch) -> None: - monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: False) - db = DummyDB({}) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ") - assert outcome.status == "accounts_building" - - -def test_provisioning_lock_not_acquired(monkeypatch) -> None: - monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "approved", - "initial_password": None, - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - db = DummyDB(row, locked=False) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ") - assert outcome.status == "accounts_building" - - -def test_provisioning_cooldown_short_circuit(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=9999.0, - default_user_groups=["dev"], - allowed_flag_groups=["demo"], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - provision_poll_interval_sec=1.0, - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": None, - "initial_password_revealed_at": None, - "provision_attempted_at": datetime.now(timezone.utc), - "approval_flags": [], - } - db = DummyDB(row, locked=True) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ") - assert outcome.status == "accounts_building" - - -def test_provisioning_mailu_sync_disabled(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=["demo"], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def __init__(self): - super().__init__() - self.updated_actions = [] - - def find_user(self, username): - return {"id": "user-1"} - - def get_user(self, user_id): - return { - "id": user_id, - "username": "alice", - "requiredActions": ["CONFIGURE_TOTP"], - "attributes": { - "mailu_email": ["alice@bstein.dev"], - "mailu_enabled": ["false"], - "wger_password": ["pw"], - "wger_password_updated_at": ["done"], - "firefly_password": ["pw"], - "firefly_password_updated_at": ["done"], - }, - } - - def update_user_safe(self, user_id, payload): - self.updated_actions.append(payload) - - admin = Admin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ125") - assert outcome.status == "accounts_building" - assert admin.updated_actions - - -def test_provisioning_sets_missing_email(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def __init__(self): - super().__init__() - self.updated_actions = [] - - def find_user(self, username): - return {"id": "user-1"} - - def get_user(self, user_id): - return {"id": user_id, "username": "alice", "email": None, "attributes": {}} - - def update_user_safe(self, user_id, payload): - self.updated_actions.append(payload) - - admin = Admin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ126") - assert outcome.status == "accounts_building" - assert any("email" in payload for payload in admin.updated_actions) - - -def test_provisioning_mailbox_not_ready(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - admin = DummyAdmin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: False) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "approved", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ126") - assert outcome.status == "accounts_building" - - -def test_provisioning_sync_errors(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - admin = DummyAdmin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "error"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "error"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "error"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(False, "error", "fail")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "approved", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ127") - assert outcome.status == "accounts_building" - - -def test_provisioning_run_loop_processes_candidates(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace(provision_poll_interval_sec=0.0) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) - - counts = [{"status": "pending", "count": 2}, {"status": "approved", "count": 1}] - seen_counts = {} - - class DB: - def fetchall(self, *_args, **_kwargs): - return counts - - class Storage: - def list_provision_candidates(self): - return [types.SimpleNamespace(request_code="REQ1"), types.SimpleNamespace(request_code="REQ2")] - - manager = prov.ProvisioningManager(DB(), Storage()) - calls: list[str] = [] - monkeypatch.setattr(manager, "provision_access_request", lambda code: calls.append(code)) - monkeypatch.setattr(prov, "set_access_request_counts", lambda payload: seen_counts.update(payload)) - - def fake_sleep(_): - manager._stop_event.set() - - monkeypatch.setattr(prov.time, "sleep", fake_sleep) - manager._stop_event.clear() - manager._run_loop() - - assert calls == ["REQ1", "REQ2"] - assert seen_counts.get("pending") == 2 - - -def test_provisioning_run_loop_waits_for_admin(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace(provision_poll_interval_sec=0.0) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: False) - - class DB: - def fetchall(self, *_args, **_kwargs): - return [] - - class Storage: - def list_provision_candidates(self): - raise AssertionError("should not list candidates") - - manager = prov.ProvisioningManager(DB(), Storage()) - - def fake_sleep(_): - manager._stop_event.set() - - monkeypatch.setattr(prov.time, "sleep", fake_sleep) - manager._stop_event.clear() - manager._run_loop() - - -def test_provisioning_missing_verified_email(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def find_user(self, username): - return None - - admin = Admin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": None, - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ200") - assert outcome.status == "accounts_building" - - -def test_provisioning_initial_password_missing(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - admin = DummyAdmin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "awaiting_onboarding", - "initial_password": None, - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ201") - assert outcome.status == "awaiting_onboarding" - - -def test_provisioning_group_and_mailu_errors(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def get_group_id(self, group_name: str): - return None - - def set_user_attribute(self, username: str, key: str, value: str): - if key == "mailu_app_password": - raise RuntimeError("fail") - return None - - admin = Admin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: (_ for _ in ()).throw(RuntimeError("fail"))) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ202") - assert outcome.status == "accounts_building" - - -def test_provisioning_task_helpers() -> None: - class Conn: - def execute(self, *_args, **_kwargs): - class Result: - def fetchall(self): - return [ - {"task": "a", "status": "ok"}, - {"task": "b", "status": "error"}, - "bad", - ] - - return Result() - - manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) - statuses = manager._task_statuses(Conn(), "REQ") - assert statuses == {"a": "ok", "b": "error"} - assert manager._all_tasks_ok(Conn(), "REQ", ["a"]) is True - assert manager._all_tasks_ok(Conn(), "REQ", ["b"]) is False - - -def test_provisioning_retryable_detail_detection() -> None: - manager = prov.ProvisioningManager(DummyDB({}, locked=True), DummyStorage()) - assert manager._is_retryable_detail("timeout") is True - assert manager._is_retryable_detail("http 503: service unavailable") is True - assert manager._is_retryable_detail("mailbox not ready") is True - assert manager._is_retryable_detail("invalid credentials") is False - assert manager._retryable_detail("timeout").startswith("retryable:") - - -def test_provisioning_ensure_task_rows_empty() -> None: - manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) - manager._ensure_task_rows(DummyConn({}, locked=True), "REQ", []) - - -def test_provisioning_record_task_ignores_storage_errors(monkeypatch) -> None: - class Storage: - def record_event(self, *args, **kwargs): - raise RuntimeError("fail") - - def record_task_run(self, *args, **kwargs): - raise RuntimeError("fail") - - db = DummyDB({}) - manager = prov.ProvisioningManager(db, Storage()) - manager._record_task("REQ", "task", "ok", None, datetime.now(timezone.utc)) - - -def test_provisioning_send_welcome_email_variants(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) - manager._send_welcome_email("REQ", "alice", "alice@example.com") - - dummy_settings = types.SimpleNamespace( - welcome_email_enabled=True, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - manager._send_welcome_email("REQ", "alice", "") - - class DB(DummyDB): - def fetchone(self, *_args, **_kwargs): - return None - - class Storage(DummyStorage): - def mark_welcome_sent(self, *args, **kwargs): - raise AssertionError("should not be called") - - manager = prov.ProvisioningManager(DB({}), Storage()) - monkeypatch.setattr( - prov.mailer, - "send_welcome", - lambda *args, **kwargs: (_ for _ in ()).throw(prov.MailerError("fail")), - ) - manager._send_welcome_email("REQ", "alice", "alice@example.com") - - -def test_provisioning_start_stop(monkeypatch) -> None: - class DummyThread: - def __init__(self, target=None, name=None, daemon=None): - self.started = False - self.joined = False - - def is_alive(self) -> bool: - return False - - def start(self) -> None: - self.started = True - - def join(self, timeout=None) -> None: - self.joined = True - - monkeypatch.setattr(prov.threading, "Thread", DummyThread) - manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) - manager.start() - assert manager._thread.started is True - manager.stop() - assert manager._thread.joined is True - - -def test_provisioning_start_skips_when_running() -> None: - class LiveThread: - def __init__(self): - self.started = False - - def is_alive(self) -> bool: - return True - - def start(self) -> None: - self.started = True - - manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) - manager._thread = LiveThread() - manager.start() - assert manager._thread.started is False - - -def test_provisioning_run_loop_skips_when_admin_not_ready(monkeypatch) -> None: - manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) - monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: False) - monkeypatch.setattr(manager, "_sync_status_metrics", lambda: (_ for _ in ()).throw(RuntimeError("fail"))) - monkeypatch.setattr( - prov.time, - "sleep", - lambda *_args, **_kwargs: manager._stop_event.set(), - ) - - manager._run_loop() - - -def test_provisioning_run_loop_processes_candidates(monkeypatch) -> None: - manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) - monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) - monkeypatch.setattr(manager._storage, "list_provision_candidates", lambda: [types.SimpleNamespace(request_code="REQ")]) - calls: list[str] = [] - monkeypatch.setattr(manager, "provision_access_request", lambda code: calls.append(code)) - monkeypatch.setattr( - prov.time, - "sleep", - lambda *_args, **_kwargs: manager._stop_event.set(), - ) - - manager._run_loop() - assert calls == ["REQ"] - - -def test_provisioning_sync_status_metrics(monkeypatch) -> None: - db = DummyDB({}) - db.fetchall = lambda *_args, **_kwargs: [{"status": "pending", "count": 2}] - manager = prov.ProvisioningManager(db, DummyStorage()) - captured = {} - monkeypatch.setattr(prov, "set_access_request_counts", lambda payload: captured.update(payload)) - manager._sync_status_metrics() - assert captured["pending"] == 2 - - -def test_provisioning_locked_returns_accounts_building(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - db = DummyDB({"username": "alice"}, locked=False) - manager = prov.ProvisioningManager(db, DummyStorage()) - outcome = manager.provision_access_request("REQ_LOCK") - assert outcome.status == "accounts_building" - - -def test_provisioning_missing_row_returns_unknown(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - db = DummyDB(None) - manager = prov.ProvisioningManager(db, DummyStorage()) - outcome = manager.provision_access_request("REQ_NONE") - assert outcome.status == "unknown" - - -def test_provisioning_denied_status_returns_denied(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "denied", - "initial_password": None, - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - db = DummyDB(row) - manager = prov.ProvisioningManager(db, DummyStorage()) - outcome = manager.provision_access_request("REQ_DENIED") - assert outcome.status == "denied" - - -def test_provisioning_respects_retry_cooldown(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=60.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": datetime.now(), - "approval_flags": [], - } - db = DummyDB(row) - manager = prov.ProvisioningManager(db, DummyStorage()) - outcome = manager.provision_access_request("REQ_COOLDOWN") - assert outcome.status == "accounts_building" - - -def test_provisioning_updates_existing_user_attrs(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def __init__(self): - super().__init__() - self.update_payloads = [] - self.attr_calls = [] - - def find_user(self, username): - return {"id": "1"} - - def get_user(self, user_id): - return { - "id": user_id, - "username": "alice", - "email": "", - "requiredActions": ["CONFIGURE_TOTP"], - "attributes": {"mailu_enabled": ["false"]}, - } - - def update_user_safe(self, user_id, payload): - self.update_payloads.append(payload) - - def set_user_attribute(self, username, key, value): - self.attr_calls.append((key, value)) - - admin = Admin() - monkeypatch.setattr(prov, "keycloak_admin", admin) - monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - storage = DummyStorage() - manager = prov.ProvisioningManager(db, storage) - outcome = manager.provision_access_request("REQ_ATTRS") - assert outcome.status == "accounts_building" - assert admin.update_payloads - assert any(key == "mailu_email" for key, _value in admin.attr_calls) - - -def test_provisioning_mailu_sync_failure(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="http://mailu", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "sync", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: False) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - manager = prov.ProvisioningManager(db, DummyStorage()) - outcome = manager.provision_access_request("REQ_MAILU") - assert outcome.status == "accounts_building" - - -def test_provisioning_nextcloud_sync_error(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "error"}) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - manager = prov.ProvisioningManager(db, DummyStorage()) - outcome = manager.provision_access_request("REQ_NC") - assert outcome.status == "accounts_building" - - -def test_provisioning_wger_firefly_errors(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "error"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "error"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - db = DummyDB(row) - manager = prov.ProvisioningManager(db, DummyStorage()) - outcome = manager.provision_access_request("REQ_WGER") - assert outcome.status == "accounts_building" - - -def test_provisioning_start_event_failure(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - class Storage(DummyStorage): - def record_event(self, *args, **kwargs): - raise RuntimeError("fail") - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - manager = prov.ProvisioningManager(DummyDB(row), Storage()) - outcome = manager.provision_access_request("REQ_EVENT") - assert outcome.status == "accounts_building" - - -def test_provisioning_missing_verified_email(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def find_user(self, username): - return None - - monkeypatch.setattr(prov, "keycloak_admin", Admin()) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": None, - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_MISSING") - assert outcome.status == "accounts_building" - - -def test_provisioning_email_conflict(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def find_user(self, username): - return None - - def find_user_by_email(self, email): - return {"username": "other"} - - monkeypatch.setattr(prov, "keycloak_admin", Admin()) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_CONFLICT") - assert outcome.status == "accounts_building" - - -def test_provisioning_missing_contact_email(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def find_user(self, username): - return None - - monkeypatch.setattr(prov, "keycloak_admin", Admin()) - - row = { - "username": "alice", - "contact_email": "", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_EMPTY") - assert outcome.status == "accounts_building" - - -def test_provisioning_user_id_missing(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def find_user(self, username): - return {"id": ""} - - monkeypatch.setattr(prov, "keycloak_admin", Admin()) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_ID") - assert outcome.status == "accounts_building" - - -def test_provisioning_initial_password_revealed(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": None, - "initial_password_revealed_at": datetime.now(timezone.utc), - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_REVEALED") - assert outcome.status == "accounts_building" - - -def test_provisioning_vaultwarden_attribute_failure(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class Admin(DummyAdmin): - def set_user_attribute(self, username, key, value): - raise RuntimeError("fail") - - monkeypatch.setattr(prov, "keycloak_admin", Admin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_VAULT") - assert outcome.status == "accounts_building" - - -def test_provisioning_vaultwarden_rate_limited(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - vaultwarden_admin_rate_limit_backoff_sec=600.0, - provision_retry_cooldown_sec=0.0, - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda *_args, **_kwargs: True) - monkeypatch.setattr( - prov.vaultwarden, - "invite_user", - lambda _email: VaultwardenInvite(False, "rate_limited", "vaultwarden rate limited"), - ) - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - manager = prov.ProvisioningManager(DummyDB(row), DummyStorage()) - ctx = prov.RequestContext( - request_code="REQ_RATE", - username="alice", - first_name="", - last_name="", - contact_email="alice@example.com", - email_verified_at=row["email_verified_at"], - status="accounts_building", - initial_password="temp", - revealed_at=None, - attempted_at=None, - approval_flags=[], - user_id="1", - mailu_email="alice@bstein.dev", - ) - conn = DummyConn(row) - manager._ensure_vaultwarden_invite(conn, ctx) - - inserts = [ - params - for query, params in conn.executed - if "INSERT INTO access_request_tasks" in query and isinstance(params, tuple) and len(params) >= 4 - ] - assert any(params[2] == "pending" and "rate limited until" in (params[3] or "") for params in inserts) - - -def test_provisioning_vaultwarden_grandfathered(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - vaultwarden_admin_rate_limit_backoff_sec=600.0, - provision_retry_cooldown_sec=0.0, - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr( - prov.vaultwarden, - "find_user_by_email", - lambda _email: VaultwardenLookup(True, "present", "user found"), - ) - monkeypatch.setattr( - prov.vaultwarden, - "invite_user", - lambda _email: (_ for _ in ()).throw(RuntimeError("invite should not run")), - ) - - row = { - "username": "legacy", - "contact_email": "legacy@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [prov.VAULTWARDEN_GRANDFATHERED_FLAG], - } - manager = prov.ProvisioningManager(DummyDB(row), DummyStorage()) - ctx = prov.RequestContext( - request_code="REQ_GRANDFATHER", - username="legacy", - first_name="", - last_name="", - contact_email="legacy@example.com", - email_verified_at=row["email_verified_at"], - status="accounts_building", - initial_password="temp", - revealed_at=None, - attempted_at=None, - approval_flags=row["approval_flags"], - user_id="1", - mailu_email="legacy@bstein.dev", - ) - conn = DummyConn(row) - manager._ensure_vaultwarden_invite(conn, ctx) - - inserts = [ - params - for query, params in conn.executed - if "INSERT INTO access_request_tasks" in query and isinstance(params, tuple) and len(params) >= 4 - ] - assert any(params[2] == "ok" and params[3] == "grandfathered" for params in inserts) - - -def test_provisioning_complete_event_failure(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) - - class Storage(DummyStorage): - def record_event(self, event_type, detail): - if event_type == "provision_complete": - raise RuntimeError("fail") - return None - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), Storage()).provision_access_request("REQ_DONE") - assert outcome.status == "awaiting_onboarding" - - -def test_provisioning_pending_event_failure(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_sync_url="", - mailu_mailbox_wait_timeout_sec=1.0, - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - provision_retry_cooldown_sec=0.0, - default_user_groups=["dev"], - allowed_flag_groups=[], - welcome_email_enabled=False, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) - monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) - monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) - monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) - monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) - - class Storage(DummyStorage): - def record_event(self, event_type, detail): - if event_type == "provision_pending": - raise RuntimeError("fail") - return None - - row = { - "username": "alice", - "contact_email": "alice@example.com", - "email_verified_at": datetime.now(timezone.utc), - "status": "accounts_building", - "initial_password": "temp", - "initial_password_revealed_at": None, - "provision_attempted_at": None, - "approval_flags": [], - } - - outcome = prov.ProvisioningManager(DummyDB(row), Storage()).provision_access_request("REQ_PENDING") - assert outcome.status == "accounts_building" - - -def test_send_welcome_email_already_sent(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - welcome_email_enabled=True, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class DB(DummyDB): - def fetchone(self, query, params=None): - return {"welcome_email_sent_at": datetime.now(timezone.utc)} - - sent = {"called": False} - - def mark_sent(_code): - sent["called"] = True - - manager = prov.ProvisioningManager(DB({}), DummyStorage()) - monkeypatch.setattr(manager._storage, "mark_welcome_sent", mark_sent) - monkeypatch.setattr(prov.mailer, "send_welcome", lambda *args, **kwargs: None) - manager._send_welcome_email("REQ_WELCOME", "alice", "alice@example.com") - assert sent["called"] is False - - -def test_send_welcome_email_marks_sent(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - welcome_email_enabled=True, - portal_public_base_url="https://bstein.dev", - ) - monkeypatch.setattr(prov, "settings", dummy_settings) - _patch_mailu_ready(monkeypatch, dummy_settings) - - class DB(DummyDB): - def fetchone(self, query, params=None): - return None - - sent = {"called": False} - - def mark_sent(_code): - sent["called"] = True - - manager = prov.ProvisioningManager(DB({}), DummyStorage()) - monkeypatch.setattr(manager._storage, "mark_welcome_sent", mark_sent) - monkeypatch.setattr(prov.mailer, "send_welcome", lambda *args, **kwargs: None) - manager._send_welcome_email("REQ_WELCOME", "alice", "alice@example.com") - assert sent["called"] is True diff --git a/tests/test_services.py b/tests/test_services.py deleted file mode 100644 index fc85b5a..0000000 --- a/tests/test_services.py +++ /dev/null @@ -1,1483 +0,0 @@ -from __future__ import annotations - -import time -import types - -import pytest - -from ariadne.services import mailu as mailu_module -from ariadne.services.firefly import FireflyService -from ariadne.services.mailu import MailuService -from ariadne.services.nextcloud import NextcloudService -from ariadne.services.wger import WgerService -from ariadne.services.vaultwarden import VaultwardenService - - -class DummyExecutor: - def __init__(self, stdout: str = "ok", stderr: str = "", exit_code: int = 0): - self.calls = [] - self._stdout = stdout - self._stderr = stderr - self._exit_code = exit_code - - def exec(self, command, env=None, timeout_sec=None, check=True): - self.calls.append((command, env, timeout_sec, check)) - return types.SimpleNamespace( - stdout=self._stdout, - stderr=self._stderr, - exit_code=self._exit_code, - ok=self._exit_code == 0, - ) - - -class DummyResponse: - def __init__(self, status_code=200, text="", json_data=None): - self.status_code = status_code - self.text = text - self._json_data = json_data - - def raise_for_status(self): - return None - - def json(self): - if self._json_data is None: - raise ValueError("no json data") - return self._json_data - - -class DummyVaultwardenClient: - def __init__(self): - self.calls = [] - self.responses = {} - - def post(self, path, json=None, data=None): - self.calls.append((path, json, data)) - resp = self.responses.get(path) - if resp is None: - resp = DummyResponse(200, "") - return resp - - def get(self, path): - self.calls.append((path, None, None)) - resp = self.responses.get(path) - if resp is None: - resp = DummyResponse(200, "", json_data=[]) - return resp - - def close(self): - return None - - -def test_nextcloud_sync_mail_no_user(monkeypatch) -> None: - dummy = types.SimpleNamespace( - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - nextcloud_mail_sync_wait_timeout_sec=90.0, - nextcloud_mail_sync_job_ttl_sec=3600, - nextcloud_pod_label="app=nextcloud", - nextcloud_container="nextcloud", - nextcloud_exec_timeout_sec=30.0, - nextcloud_db_host="", - nextcloud_db_port=5432, - nextcloud_db_name="nextcloud", - nextcloud_db_user="nextcloud", - nextcloud_db_password="", - mailu_domain="bstein.dev", - mailu_host="mail.bstein.dev", - ) - monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) - monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.ready", lambda: True) - monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.find_user", lambda *_args, **_kwargs: None) - - svc = NextcloudService() - result = svc.sync_mail("alice", wait=True) - assert result["status"] == "ok" - - -def test_firefly_check_rotation_marks_rotated(monkeypatch) -> None: - dummy = types.SimpleNamespace( - firefly_namespace="finance", - firefly_pod_label="app=firefly", - firefly_container="firefly", - firefly_user_sync_wait_timeout_sec=60.0, - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.firefly.settings", dummy) - - calls: list[tuple[str, str, str]] = [] - - class DummyAdmin: - def ready(self) -> bool: - return True - - def find_user(self, username: str): - return {"id": "1", "username": username, "attributes": {}} - - def get_user(self, user_id: str): - return { - "id": user_id, - "username": "alice", - "attributes": { - "mailu_email": ["alice@bstein.dev"], - "firefly_password": ["pw"], - }, - } - - def set_user_attribute(self, username: str, key: str, value: str) -> None: - calls.append((username, key, value)) - - monkeypatch.setattr("ariadne.services.firefly.keycloak_admin", DummyAdmin()) - - svc = FireflyService() - monkeypatch.setattr(svc, "check_password", lambda *_args, **_kwargs: {"status": "mismatch"}) - - result = svc.check_rotation_for_user("alice") - assert result["status"] == "ok" - assert result["rotated"] is True - assert any(key == "firefly_password_rotated_at" for _user, key, _value in calls) - - -def test_wger_sync_user_exec(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_cronjob="wger-user-sync", - wger_admin_cronjob="wger-admin-ensure", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - - calls: list[dict[str, str]] = [] - - class DummyExecutor: - def exec(self, _cmd, env=None, timeout_sec=None, check=True): - calls.append(env or {}) - return types.SimpleNamespace(stdout="ok", stderr="", exit_code=0, ok=True) - - monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = WgerService() - result = svc.sync_user("alice", "alice@bstein.dev", "pw", wait=True) - assert result["status"] == "ok" - assert calls[0]["WGER_USERNAME"] == "alice" - - -def test_wger_ensure_admin_exec(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_cronjob="wger-user-sync", - wger_admin_cronjob="wger-admin-ensure", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - - calls: list[dict[str, str]] = [] - - class DummyExecutor: - def exec(self, _cmd, env=None, timeout_sec=None, check=True): - calls.append(env or {}) - return types.SimpleNamespace(stdout="ok", stderr="", exit_code=0, ok=True) - - monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = WgerService() - result = svc.ensure_admin(wait=False) - assert result["status"] == "ok" - assert calls[0]["WGER_ADMIN_USERNAME"] == "admin" - - -def test_wger_sync_users(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - - calls: list[tuple[str, str, str]] = [] - - class DummyAdmin: - def ready(self) -> bool: - return True - - def iter_users(self, page_size=200, brief=False): - return [{"id": "1", "username": "alice", "attributes": {}}] - - def get_user(self, user_id: str): - return {"id": user_id, "username": "alice", "attributes": {}} - - def set_user_attribute(self, username: str, key: str, value: str) -> None: - calls.append((username, key, value)) - - monkeypatch.setattr("ariadne.services.wger.keycloak_admin", DummyAdmin()) - monkeypatch.setattr( - "ariadne.services.wger.mailu.resolve_mailu_email", - lambda *_args, **_kwargs: "alice@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.random_password", lambda *_args: "pw") - - def fake_sync_user(self, *_args, **_kwargs): - return {"status": "ok", "detail": "ok"} - - monkeypatch.setattr(WgerService, "sync_user", fake_sync_user) - - svc = WgerService() - result = svc.sync_users() - assert result["status"] == "ok" - assert any(key == "wger_password" for _user, key, _value in calls) - assert any(key == "wger_password_updated_at" for _user, key, _value in calls) - - -def test_wger_check_rotation_marks_rotated(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - - calls: list[tuple[str, str, str]] = [] - - class DummyAdmin: - def ready(self) -> bool: - return True - - def find_user(self, username: str): - return {"id": "1", "username": username, "attributes": {}} - - def get_user(self, user_id: str): - return { - "id": user_id, - "username": "alice", - "attributes": { - "mailu_email": ["alice@bstein.dev"], - "wger_password": ["pw"], - }, - } - - def set_user_attribute(self, username: str, key: str, value: str) -> None: - calls.append((username, key, value)) - - monkeypatch.setattr("ariadne.services.wger.keycloak_admin", DummyAdmin()) - - svc = WgerService() - monkeypatch.setattr(svc, "check_password", lambda *_args, **_kwargs: {"status": "mismatch"}) - - result = svc.check_rotation_for_user("alice") - assert result["status"] == "ok" - assert result["rotated"] is True - assert any(key == "wger_password_rotated_at" for _user, key, _value in calls) - - -def test_wger_sync_marks_rotated(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - - calls: list[tuple[str, str, str]] = [] - - class DummyAdmin: - def ready(self) -> bool: - return True - - def iter_users(self, page_size=200, brief=False): - return [ - { - "id": "1", - "username": "alice", - "attributes": { - "mailu_email": ["alice@bstein.dev"], - "wger_password": ["pw"], - "wger_password_updated_at": ["2025-01-01T00:00:00Z"], - }, - } - ] - - def get_user(self, user_id: str): - return { - "id": user_id, - "username": "alice", - "attributes": { - "mailu_email": ["alice@bstein.dev"], - "wger_password": ["pw"], - "wger_password_updated_at": ["2025-01-01T00:00:00Z"], - }, - } - - def set_user_attribute(self, username: str, key: str, value: str) -> None: - calls.append((username, key, value)) - - monkeypatch.setattr("ariadne.services.wger.keycloak_admin", DummyAdmin()) - monkeypatch.setattr( - "ariadne.services.wger.mailu.resolve_mailu_email", - lambda *_args, **_kwargs: "alice@bstein.dev", - ) - - def fake_check(self, *_args, **_kwargs): - return {"status": "mismatch", "detail": "mismatch"} - - monkeypatch.setattr(WgerService, "check_password", fake_check) - monkeypatch.setattr(WgerService, "sync_user", lambda *_args, **_kwargs: {"status": "ok"}) - - svc = WgerService() - result = svc.sync_users() - assert result["status"] == "ok" - assert any(key == "wger_password_rotated_at" for _user, key, _value in calls) - - -def test_firefly_sync_user_exec(monkeypatch) -> None: - dummy = types.SimpleNamespace( - firefly_namespace="finance", - firefly_user_sync_cronjob="firefly-user-sync", - firefly_user_sync_wait_timeout_sec=60.0, - firefly_pod_label="app=firefly", - firefly_container="firefly", - firefly_cron_base_url="http://firefly/cron", - firefly_cron_token="token", - firefly_cron_timeout_sec=10.0, - ) - monkeypatch.setattr("ariadne.services.firefly.settings", dummy) - - class DummyExecutor: - def exec(self, _cmd, env=None, timeout_sec=None, check=True): - return types.SimpleNamespace(stdout="ok", stderr="", exit_code=0, ok=True) - - monkeypatch.setattr("ariadne.services.firefly.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = FireflyService() - result = svc.sync_user("alice@bstein.dev", "pw", wait=True) - assert result["status"] == "ok" - - -def test_firefly_sync_missing_inputs(monkeypatch) -> None: - dummy = types.SimpleNamespace( - firefly_namespace="finance", - firefly_user_sync_cronjob="firefly-user-sync", - firefly_user_sync_wait_timeout_sec=60.0, - firefly_pod_label="app=firefly", - firefly_container="firefly", - firefly_cron_base_url="http://firefly/cron", - firefly_cron_token="token", - firefly_cron_timeout_sec=10.0, - ) - monkeypatch.setattr("ariadne.services.firefly.settings", dummy) - monkeypatch.setattr("ariadne.services.firefly.PodExecutor", lambda *_args, **_kwargs: None) - - svc = FireflyService() - with pytest.raises(RuntimeError): - svc.sync_user("", "pw", wait=True) - with pytest.raises(RuntimeError): - svc.sync_user("alice@bstein.dev", "", wait=True) - - -def test_firefly_sync_missing_config(monkeypatch) -> None: - dummy = types.SimpleNamespace( - firefly_namespace="", - firefly_user_sync_cronjob="", - firefly_user_sync_wait_timeout_sec=60.0, - firefly_pod_label="app=firefly", - firefly_container="firefly", - firefly_cron_base_url="http://firefly/cron", - firefly_cron_token="token", - firefly_cron_timeout_sec=10.0, - ) - monkeypatch.setattr("ariadne.services.firefly.settings", dummy) - svc = FireflyService() - with pytest.raises(RuntimeError): - svc.sync_user("alice@bstein.dev", "pw", wait=True) - - -def test_firefly_run_cron(monkeypatch) -> None: - dummy = types.SimpleNamespace( - firefly_namespace="finance", - firefly_user_sync_cronjob="firefly-user-sync", - firefly_user_sync_wait_timeout_sec=60.0, - firefly_pod_label="app=firefly", - firefly_container="firefly", - firefly_cron_base_url="http://firefly/cron", - firefly_cron_token="token", - firefly_cron_timeout_sec=10.0, - ) - monkeypatch.setattr("ariadne.services.firefly.settings", dummy) - monkeypatch.setattr("ariadne.services.firefly.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - class DummyHTTP: - def __init__(self): - self.calls = [] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def get(self, url): - self.calls.append(url) - return types.SimpleNamespace(status_code=200) - - monkeypatch.setattr("ariadne.services.firefly.httpx.Client", lambda *args, **kwargs: DummyHTTP()) - - svc = FireflyService() - result = svc.run_cron() - assert result["status"] == "ok" - - -def test_firefly_sync_users(monkeypatch) -> None: - dummy = types.SimpleNamespace( - firefly_namespace="finance", - firefly_user_sync_wait_timeout_sec=60.0, - firefly_pod_label="app=firefly", - firefly_container="firefly", - firefly_cron_base_url="http://firefly/cron", - firefly_cron_token="token", - firefly_cron_timeout_sec=10.0, - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.firefly.settings", dummy) - - calls: list[tuple[str, str, str]] = [] - - class DummyAdmin: - def ready(self) -> bool: - return True - - def iter_users(self, page_size=200, brief=False): - return [{"id": "1", "username": "alice", "attributes": {}}] - - def get_user(self, user_id: str): - return {"id": user_id, "username": "alice", "attributes": {}} - - def set_user_attribute(self, username: str, key: str, value: str) -> None: - calls.append((username, key, value)) - - monkeypatch.setattr("ariadne.services.firefly.keycloak_admin", DummyAdmin()) - monkeypatch.setattr( - "ariadne.services.firefly.mailu.resolve_mailu_email", - lambda *_args, **_kwargs: "alice@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.firefly.random_password", lambda *_args: "pw") - - def fake_sync_user(self, *_args, **_kwargs): - return {"status": "ok", "detail": "ok"} - - monkeypatch.setattr(FireflyService, "sync_user", fake_sync_user) - - svc = FireflyService() - result = svc.sync_users() - assert result["status"] == "ok" - assert any(key == "firefly_password" for _user, key, _value in calls) - assert any(key == "firefly_password_updated_at" for _user, key, _value in calls) - - -def test_firefly_sync_marks_rotated(monkeypatch) -> None: - dummy = types.SimpleNamespace( - firefly_namespace="finance", - firefly_user_sync_wait_timeout_sec=60.0, - firefly_pod_label="app=firefly", - firefly_container="firefly", - firefly_cron_base_url="http://firefly/cron", - firefly_cron_token="token", - firefly_cron_timeout_sec=10.0, - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.firefly.settings", dummy) - - calls: list[tuple[str, str, str]] = [] - - class DummyAdmin: - def ready(self) -> bool: - return True - - def iter_users(self, page_size=200, brief=False): - return [ - { - "id": "1", - "username": "alice", - "attributes": { - "mailu_email": ["alice@bstein.dev"], - "firefly_password": ["pw"], - "firefly_password_updated_at": ["2025-01-01T00:00:00Z"], - }, - } - ] - - def get_user(self, user_id: str): - return { - "id": user_id, - "username": "alice", - "attributes": { - "mailu_email": ["alice@bstein.dev"], - "firefly_password": ["pw"], - "firefly_password_updated_at": ["2025-01-01T00:00:00Z"], - }, - } - - def set_user_attribute(self, username: str, key: str, value: str) -> None: - calls.append((username, key, value)) - - monkeypatch.setattr("ariadne.services.firefly.keycloak_admin", DummyAdmin()) - monkeypatch.setattr( - "ariadne.services.firefly.mailu.resolve_mailu_email", - lambda *_args, **_kwargs: "alice@bstein.dev", - ) - - def fake_check(self, *_args, **_kwargs): - return {"status": "mismatch", "detail": "mismatch"} - - monkeypatch.setattr(FireflyService, "check_password", fake_check) - monkeypatch.setattr(FireflyService, "sync_user", lambda *_args, **_kwargs: {"status": "ok"}) - - svc = FireflyService() - result = svc.sync_users() - assert result["status"] == "ok" - assert any(key == "firefly_password_rotated_at" for _user, key, _value in calls) - - -def test_mailu_sync_updates_attrs(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_default_quota=20000000000, - mailu_system_users=[], - mailu_system_password="", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) - monkeypatch.setattr( - "ariadne.services.mailu.keycloak_admin.iter_users", - lambda *args, **kwargs: [ - { - "id": "1", - "username": "alice", - "enabled": True, - "email": "alice@example.com", - "attributes": {}, - "firstName": "Alice", - "lastName": "Example", - } - ], - ) - - updates: list[tuple[str, dict[str, object]]] = [] - monkeypatch.setattr( - "ariadne.services.mailu.keycloak_admin.update_user_safe", - lambda user_id, payload: updates.append((user_id, payload)), - ) - - mailbox_calls: list[tuple[str, str, str]] = [] - monkeypatch.setattr( - "ariadne.services.mailu.MailuService._ensure_mailbox", - lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, - ) - - class DummyConn: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) - - svc = MailuService() - summary = svc.sync("provision", force=True) - - assert summary.processed == 1 - assert summary.updated == 1 - assert mailbox_calls - assert updates - assert "mailu_email" in updates[0][1]["attributes"] - - -def test_mailu_sync_rotates_long_password(monkeypatch) -> None: - long_password = "x" * 100 - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_default_quota=20000000000, - mailu_system_users=[], - mailu_system_password="", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) - monkeypatch.setattr( - "ariadne.services.mailu.keycloak_admin.iter_users", - lambda *args, **kwargs: [ - { - "id": "1", - "username": "alice", - "enabled": True, - "email": "alice@example.com", - "attributes": {"mailu_app_password": [long_password]}, - "firstName": "Alice", - "lastName": "Example", - } - ], - ) - monkeypatch.setattr("ariadne.services.mailu.random_password", lambda *_args, **_kwargs: "short-pass-123") - - updates: list[tuple[str, dict[str, object]]] = [] - monkeypatch.setattr( - "ariadne.services.mailu.keycloak_admin.update_user_safe", - lambda user_id, payload: updates.append((user_id, payload)), - ) - - mailbox_calls: list[tuple[str, str, str]] = [] - monkeypatch.setattr( - "ariadne.services.mailu.MailuService._ensure_mailbox", - lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, - ) - - class DummyConn: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) - - svc = MailuService() - summary = svc.sync("provision", force=True) - - assert summary.processed == 1 - assert updates - attrs = updates[0][1]["attributes"] - assert attrs["mailu_app_password"] == ["short-pass-123"] - assert mailbox_calls - assert mailbox_calls[0][1] == "short-pass-123" - - -def test_mailu_sync_retries_on_password_limit(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_default_quota=20000000000, - mailu_system_users=[], - mailu_system_password="", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr(mailu_module.keycloak_admin, "ready", lambda: True) - update_calls: list[tuple[str, dict[str, object]]] = [] - monkeypatch.setattr( - mailu_module.keycloak_admin, - "update_user_safe", - lambda user_id, payload: update_calls.append((user_id, payload)), - ) - monkeypatch.setattr( - mailu_module.keycloak_admin, - "iter_users", - lambda *args, **kwargs: [ - { - "id": "1", - "username": "alice", - "enabled": True, - "email": "alice@example.com", - "attributes": {"mailu_app_password": ["short-pass"]}, - "firstName": "Alice", - "lastName": "Example", - } - ], - ) - monkeypatch.setattr("ariadne.services.mailu.random_password", lambda *_args, **_kwargs: "retry-pass-1") - - set_calls: list[tuple[str, str, str]] = [] - monkeypatch.setattr( - mailu_module.keycloak_admin, - "set_user_attribute", - lambda username, key, value: set_calls.append((username, key, value)), - ) - - call_count = {"count": 0} - - def fake_ensure(self, _conn, _email, password, _display): - call_count["count"] += 1 - if call_count["count"] == 1: - raise mailu_module.PasswordTooLongError("password cannot be longer than 72 bytes") - assert password == "retry-pass-1" - return True - - monkeypatch.setattr("ariadne.services.mailu.MailuService._ensure_mailbox", fake_ensure) - - class DummyConn: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) - - svc = MailuService() - summary = svc.sync("provision", force=True) - - assert summary.processed == 1 - assert update_calls - assert call_count["count"] == 2 - assert set_calls - - -def test_mailu_sync_skips_disabled(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_default_quota=20000000000, - mailu_system_users=[], - mailu_system_password="", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) - monkeypatch.setattr( - "ariadne.services.mailu.keycloak_admin.iter_users", - lambda *args, **kwargs: [ - { - "id": "1", - "username": "alice", - "enabled": True, - "attributes": {"mailu_enabled": ["false"]}, - } - ], - ) - - mailbox_calls: list[tuple[str, str, str]] = [] - monkeypatch.setattr( - "ariadne.services.mailu.MailuService._ensure_mailbox", - lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, - ) - - class DummyConn: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) - - svc = MailuService() - summary = svc.sync("provision") - - assert summary.skipped == 1 - assert mailbox_calls == [] - - -def test_mailu_sync_system_mailboxes(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_domain="bstein.dev", - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_default_quota=20000000000, - mailu_system_users=["no-reply-portal@bstein.dev"], - mailu_system_password="systempw", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) - monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.iter_users", lambda *args, **kwargs: []) - - mailbox_calls: list[tuple[str, str, str]] = [] - monkeypatch.setattr( - "ariadne.services.mailu.MailuService._ensure_mailbox", - lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, - ) - - class DummyConn: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) - - svc = MailuService() - summary = svc.sync("schedule") - - assert summary.system_mailboxes == 1 - assert mailbox_calls[0][0] == "no-reply-portal@bstein.dev" - - -def test_vaultwarden_invite_uses_admin_session(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - client = DummyVaultwardenClient() - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: "127.0.0.1"), - ) - - svc = VaultwardenService() - result = svc.invite_user("alice@bstein.dev") - - assert result.ok is True - assert any(call[0] == "/admin" for call in client.calls) - assert any(call[0] == "/admin/invite" for call in client.calls) - - -def test_vaultwarden_invite_handles_rate_limit(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - client = DummyVaultwardenClient() - client.responses["/admin/invite"] = DummyResponse(429, "rate limited") - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: "127.0.0.1"), - ) - - svc = VaultwardenService() - result = svc.invite_user("alice@bstein.dev") - assert result.status == "rate_limited" - - -def test_vaultwarden_invite_existing_user(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - client = DummyVaultwardenClient() - client.responses["/admin/invite"] = DummyResponse(409, "user already exists") - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: "127.0.0.1"), - ) - - svc = VaultwardenService() - result = svc.invite_user("alice@bstein.dev") - assert result.status == "already_present" - - -def test_vaultwarden_invite_rejects_invalid_email() -> None: - svc = VaultwardenService() - result = svc.invite_user("bad-email") - assert result.status == "invalid_email" - - -def test_vaultwarden_invite_rate_limited_short_circuit() -> None: - svc = VaultwardenService() - svc._rate_limited_until = time.time() + 60 - result = svc.invite_user("alice@bstein.dev") - assert result.status == "rate_limited" - - -def test_vaultwarden_lookup_user_present(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - client = DummyVaultwardenClient() - client.responses["/admin/users"] = DummyResponse(200, "", json_data=[{"email": "alice@bstein.dev"}]) - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: "127.0.0.1"), - ) - - svc = VaultwardenService() - result = svc.find_user_by_email("alice@bstein.dev") - assert result.status == "present" - - -def test_vaultwarden_lookup_user_missing(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - client = DummyVaultwardenClient() - client.responses["/admin/users"] = DummyResponse(200, "", json_data=[{"email": "bob@bstein.dev"}]) - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: "127.0.0.1"), - ) - - svc = VaultwardenService() - result = svc.find_user_by_email("alice@bstein.dev") - assert result.status == "missing" - - -def test_vaultwarden_invite_handles_admin_exception(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))), - ) - - svc = VaultwardenService() - monkeypatch.setattr( - svc, - "_admin_session", - lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("rate limited")), - ) - result = svc.invite_user("alice@bstein.dev") - assert result.status == "rate_limited" - - -def test_vaultwarden_invite_handles_bad_body(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - - class BadTextResponse: - def __init__(self, status_code=500): - self.status_code = status_code - - def raise_for_status(self): - return None - - @property - def text(self): - raise RuntimeError("boom") - - class BadTextClient(DummyVaultwardenClient): - def post(self, path, json=None, data=None): - self.calls.append((path, json, data)) - return BadTextResponse(500) - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: BadTextClient()) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: "127.0.0.1"), - ) - - svc = VaultwardenService() - result = svc.invite_user("alice@bstein.dev") - assert result.status == "error" - - -def test_vaultwarden_invite_handles_fallback_skip(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr( - "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", - staticmethod(lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))), - ) - - svc = VaultwardenService() - monkeypatch.setattr(svc, "_admin_session", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("nope"))) - result = svc.invite_user("alice@bstein.dev") - assert result.status == "error" - - -def test_vaultwarden_find_pod_ip(monkeypatch) -> None: - monkeypatch.setattr( - "ariadne.services.vaultwarden.get_json", - lambda *args, **kwargs: { - "items": [ - { - "status": { - "phase": "Running", - "podIP": "10.0.0.1", - "conditions": [{"type": "Ready", "status": "True"}], - } - } - ] - }, - ) - - assert VaultwardenService._find_pod_ip("ns", "app=vaultwarden") == "10.0.0.1" - - -def test_vaultwarden_find_pod_ip_skips_missing_ip(monkeypatch) -> None: - monkeypatch.setattr( - "ariadne.services.vaultwarden.get_json", - lambda *args, **kwargs: { - "items": [ - {"status": {"phase": "Running", "podIP": ""}}, - {"status": {"phase": "Running", "podIP": "10.0.0.2", "conditions": []}}, - ] - }, - ) - assert VaultwardenService._find_pod_ip("ns", "app=vaultwarden") == "10.0.0.2" - - -def test_vaultwarden_find_pod_ip_conditions_default_ready(monkeypatch) -> None: - monkeypatch.setattr( - "ariadne.services.vaultwarden.get_json", - lambda *args, **kwargs: { - "items": [ - {"status": {"phase": "Running", "podIP": "10.0.0.3", "conditions": ["bad"]}}, - ] - }, - ) - assert VaultwardenService._find_pod_ip("ns", "app=vaultwarden") == "10.0.0.3" - - -def test_vaultwarden_find_pod_ip_no_pods(monkeypatch) -> None: - monkeypatch.setattr("ariadne.services.vaultwarden.get_json", lambda *args, **kwargs: {"items": []}) - with pytest.raises(RuntimeError): - VaultwardenService._find_pod_ip("ns", "app=vaultwarden") - - -def test_vaultwarden_find_pod_ip_missing_ip(monkeypatch) -> None: - monkeypatch.setattr( - "ariadne.services.vaultwarden.get_json", - lambda *args, **kwargs: { - "items": [ - {"status": {"phase": "Pending", "conditions": ["bad"]}}, - ] - }, - ) - with pytest.raises(RuntimeError): - VaultwardenService._find_pod_ip("ns", "app=vaultwarden") - - -def test_vaultwarden_admin_session_rate_limit(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=1, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - client = DummyVaultwardenClient() - client.responses["/admin"] = DummyResponse(429, "") - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) - - svc = VaultwardenService() - with pytest.raises(RuntimeError): - svc._admin_session("http://vaultwarden") - - -def test_vaultwarden_admin_session_reuses_client() -> None: - svc = VaultwardenService() - svc._admin_client = DummyVaultwardenClient() - svc._admin_session_expires_at = time.time() + 60 - svc._admin_session_base_url = "http://vaultwarden" - - client = svc._admin_session("http://vaultwarden") - assert client is svc._admin_client - - -def test_vaultwarden_admin_session_rate_limited_until() -> None: - svc = VaultwardenService() - svc._rate_limited_until = time.time() + 60 - with pytest.raises(RuntimeError): - svc._admin_session("http://vaultwarden") - - -def test_vaultwarden_admin_session_closes_existing(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - vaultwarden_namespace="vaultwarden", - vaultwarden_admin_secret_name="vaultwarden-admin", - vaultwarden_admin_secret_key="ADMIN_TOKEN", - vaultwarden_admin_rate_limit_backoff_sec=600, - vaultwarden_admin_session_ttl_sec=900, - vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", - vaultwarden_pod_label="app=vaultwarden", - vaultwarden_pod_port=80, - ) - - class CloseFail: - def close(self): - raise RuntimeError("boom") - - monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") - monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: DummyVaultwardenClient()) - - svc = VaultwardenService() - svc._admin_client = CloseFail() - svc._admin_session_expires_at = time.time() - 10 - svc._admin_session_base_url = "http://old" - - assert svc._admin_session("http://vaultwarden") is not None - - -def test_nextcloud_missing_config(monkeypatch) -> None: - dummy = types.SimpleNamespace( - nextcloud_namespace="", - nextcloud_mail_sync_cronjob="", - nextcloud_mail_sync_wait_timeout_sec=90.0, - nextcloud_mail_sync_job_ttl_sec=3600, - nextcloud_pod_label="app=nextcloud", - nextcloud_container="nextcloud", - nextcloud_exec_timeout_sec=30.0, - nextcloud_db_host="", - nextcloud_db_port=5432, - nextcloud_db_name="nextcloud", - nextcloud_db_user="nextcloud", - nextcloud_db_password="", - mailu_domain="bstein.dev", - mailu_host="mail.bstein.dev", - ) - monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) - svc = NextcloudService() - with pytest.raises(RuntimeError): - svc.sync_mail("alice") - - -def test_wger_sync_missing_inputs(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_cronjob="wger-user-sync", - wger_admin_cronjob="wger-admin-ensure", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = WgerService() - with pytest.raises(RuntimeError): - svc.sync_user("", "email", "pw", wait=True) - with pytest.raises(RuntimeError): - svc.sync_user("alice", "email", "", wait=True) - - -def test_wger_sync_missing_config(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="", - wger_user_sync_cronjob="", - wger_admin_cronjob="wger-admin-ensure", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - svc = WgerService() - with pytest.raises(RuntimeError): - svc.sync_user("alice", "email", "pw", wait=True) - - -def test_wger_ensure_admin(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_cronjob="wger-user-sync", - wger_admin_cronjob="wger-admin-ensure", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = WgerService() - result = svc.ensure_admin(wait=True) - assert result["status"] == "ok" - - -def test_wger_ensure_admin_missing_creds(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="health", - wger_user_sync_cronjob="wger-user-sync", - wger_admin_cronjob="wger-admin-ensure", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="", - wger_admin_password="", - wger_admin_email="", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = WgerService() - result = svc.ensure_admin(wait=True) - assert result["status"] == "error" - - -def test_wger_ensure_admin_missing_config(monkeypatch) -> None: - dummy = types.SimpleNamespace( - wger_namespace="", - wger_user_sync_cronjob="wger-user-sync", - wger_admin_cronjob="", - wger_user_sync_wait_timeout_sec=60.0, - wger_pod_label="app=wger", - wger_container="wger", - wger_admin_username="admin", - wger_admin_password="pw", - wger_admin_email="admin@bstein.dev", - ) - monkeypatch.setattr("ariadne.services.wger.settings", dummy) - monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - svc = WgerService() - with pytest.raises(RuntimeError): - svc.ensure_admin(wait=True) - - -def test_mailu_mailbox_exists_handles_error(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_sync_url="", - mailu_sync_wait_timeout_sec=10.0, - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) - - svc = MailuService() - assert svc.mailbox_exists("alice@bstein.dev") is False - - -def test_mailu_mailbox_exists_success(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_sync_url="", - mailu_sync_wait_timeout_sec=10.0, - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - - class DummyCursor: - def execute(self, *_args, **_kwargs): - return None - - def fetchone(self): - return {"id": 1} - - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - class DummyConn: - def cursor(self): - return DummyCursor() - - def __enter__(self): - return self - - def __exit__(self, *_args): - return False - - monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) - - svc = MailuService() - assert svc.mailbox_exists("alice@bstein.dev") is True - - -def test_mailu_wait_for_mailbox(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_sync_url="", - mailu_sync_wait_timeout_sec=10.0, - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - monkeypatch.setattr(MailuService, "mailbox_exists", lambda self, email: True) - - svc = MailuService() - assert svc.wait_for_mailbox("alice@bstein.dev", timeout_sec=1.0) is True - - -def test_mailu_mailbox_exists_empty_email(monkeypatch) -> None: - dummy_settings = types.SimpleNamespace( - mailu_sync_url="", - mailu_sync_wait_timeout_sec=10.0, - mailu_db_host="localhost", - mailu_db_port=5432, - mailu_db_name="mailu", - mailu_db_user="mailu", - mailu_db_password="secret", - mailu_domain="bstein.dev", - ) - monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) - svc = MailuService() - assert svc.mailbox_exists("") is False - - -def test_nextcloud_sync_missing_username(monkeypatch) -> None: - dummy = types.SimpleNamespace( - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - nextcloud_mail_sync_wait_timeout_sec=90.0, - nextcloud_mail_sync_job_ttl_sec=3600, - nextcloud_pod_label="app=nextcloud", - nextcloud_container="nextcloud", - nextcloud_exec_timeout_sec=30.0, - nextcloud_db_host="", - nextcloud_db_port=5432, - nextcloud_db_name="nextcloud", - nextcloud_db_user="nextcloud", - nextcloud_db_password="", - mailu_domain="bstein.dev", - mailu_host="mail.bstein.dev", - ) - monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) - monkeypatch.setattr("ariadne.services.nextcloud.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = NextcloudService() - with pytest.raises(RuntimeError): - svc.sync_mail(" ", wait=True) - - -def test_nextcloud_sync_no_match(monkeypatch) -> None: - dummy = types.SimpleNamespace( - nextcloud_namespace="nextcloud", - nextcloud_mail_sync_cronjob="nextcloud-mail-sync", - nextcloud_mail_sync_wait_timeout_sec=90.0, - nextcloud_mail_sync_job_ttl_sec=3600, - nextcloud_pod_label="app=nextcloud", - nextcloud_container="nextcloud", - nextcloud_exec_timeout_sec=30.0, - nextcloud_db_host="", - nextcloud_db_port=5432, - nextcloud_db_name="nextcloud", - nextcloud_db_user="nextcloud", - nextcloud_db_password="", - mailu_domain="bstein.dev", - mailu_host="mail.bstein.dev", - ) - monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) - monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.ready", lambda: True) - monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.find_user", lambda *_args, **_kwargs: None) - monkeypatch.setattr("ariadne.services.nextcloud.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) - - svc = NextcloudService() - result = svc.sync_mail("alice", wait=False) - assert result["status"] == "ok" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/app/__init__.py b/tests/unit/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/app/app_route_helpers.py b/tests/unit/app/app_route_helpers.py new file mode 100644 index 0000000..384e07e --- /dev/null +++ b/tests/unit/app/app_route_helpers.py @@ -0,0 +1,27 @@ +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) + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/tests/unit/app/test_app_admin_routes.py b/tests/unit/app/test_app_admin_routes.py new file mode 100644 index 0000000..e5edc5c --- /dev/null +++ b/tests/unit/app/test_app_admin_routes.py @@ -0,0 +1,286 @@ +from tests.unit.app.app_route_helpers import * + + +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_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_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_require_admin_allows_group() -> None: + ctx = AuthContext(username="alice", email="", groups=["admin"], claims={}) + app_module._require_admin(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 diff --git a/tests/unit/app/test_app_firefly_account_routes.py b/tests/unit/app/test_app_firefly_account_routes.py new file mode 100644 index 0000000..f03abbe --- /dev/null +++ b/tests/unit/app/test_app_firefly_account_routes.py @@ -0,0 +1,139 @@ +from tests.unit.app.app_route_helpers import * + + +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_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 diff --git a/tests/unit/app/test_app_lifecycle.py b/tests/unit/app/test_app_lifecycle.py new file mode 100644 index 0000000..a1c2800 --- /dev/null +++ b/tests/unit/app/test_app_lifecycle.py @@ -0,0 +1,148 @@ +from tests.unit.app.app_route_helpers import * + + +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) + +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_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" diff --git a/tests/unit/app/test_app_mailu_account_routes.py b/tests/unit/app/test_app_mailu_account_routes.py new file mode 100644 index 0000000..188cfed --- /dev/null +++ b/tests/unit/app/test_app_mailu_account_routes.py @@ -0,0 +1,112 @@ +from tests.unit.app.app_route_helpers import * + + +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_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_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 diff --git a/tests/unit/app/test_app_nextcloud_account_routes.py b/tests/unit/app/test_app_nextcloud_account_routes.py new file mode 100644 index 0000000..079e978 --- /dev/null +++ b/tests/unit/app/test_app_nextcloud_account_routes.py @@ -0,0 +1,107 @@ +from tests.unit.app.app_route_helpers import * + + +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_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/unit/app/test_app_wger_account_routes.py b/tests/unit/app/test_app_wger_account_routes.py new file mode 100644 index 0000000..4b93a33 --- /dev/null +++ b/tests/unit/app/test_app_wger_account_routes.py @@ -0,0 +1,139 @@ +from tests.unit.app.app_route_helpers import * + + +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_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 diff --git a/tests/unit/manager/__init__.py b/tests/unit/manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/manager/provisioning_helpers.py b/tests/unit/manager/provisioning_helpers.py new file mode 100644 index 0000000..2a93d53 --- /dev/null +++ b/tests/unit/manager/provisioning_helpers.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from contextlib import contextmanager + +from datetime import datetime, timezone + +import types + +from ariadne.manager import provisioning as prov + +from ariadne.services.vaultwarden import VaultwardenInvite, VaultwardenLookup + +class DummyResult: + def __init__(self, row=None): + self._row = row + + def fetchone(self): + return self._row + + def fetchall(self): + return [] + +class DummyConn: + def __init__(self, row, locked=True): + self._row = row + self._locked = locked + self.executed = [] + + def execute(self, query, params=None): + self.executed.append((query, params)) + if "pg_try_advisory_lock" in query: + return DummyResult({"locked": self._locked}) + if "SELECT username" in query: + return DummyResult(self._row) + return DummyResult() + +class DummyDB: + def __init__(self, row, locked=True): + self._row = row + self._locked = locked + + @contextmanager + def connection(self): + yield DummyConn(self._row, locked=self._locked) + + def fetchone(self, query, params=None): + return None + + def fetchall(self, query, params=None): + return [] + +class DummyStorage: + def record_task_run(self, *args, **kwargs): + return None + + def record_event(self, *args, **kwargs): + return None + + def mark_welcome_sent(self, *args, **kwargs): + return None + + def list_provision_candidates(self): + return [] + +class DummyAdmin: + def __init__(self): + self.groups = [] + + def ready(self): + return True + + def find_user(self, username): + return {"id": "1"} + + def find_user_by_email(self, email): + return None + + def get_user(self, user_id): + return {"id": "1", "attributes": {}} + + def create_user(self, payload): + return "1" + + def update_user(self, user_id, payload): + return None + + def reset_password(self, user_id, password, temporary=False): + return None + + def set_user_attribute(self, username, key, value): + return None + + def get_group_id(self, group_name: str): + return group_name + + def add_user_to_group(self, user_id, group_id): + self.groups.append(group_id) + +def _patch_mailu_ready(monkeypatch, settings, value=None) -> None: + if value is None: + value = bool(getattr(settings, "mailu_sync_url", "")) + monkeypatch.setattr(prov.mailu, "ready", lambda: value) + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/tests/unit/manager/test_provisioning_failure_modes.py b/tests/unit/manager/test_provisioning_failure_modes.py new file mode 100644 index 0000000..9b73fa1 --- /dev/null +++ b/tests/unit/manager/test_provisioning_failure_modes.py @@ -0,0 +1,336 @@ +from tests.unit.manager.provisioning_helpers import * + + +def test_provisioning_missing_verified_email(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def find_user(self, username): + return None + + monkeypatch.setattr(prov, "keycloak_admin", Admin()) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": None, + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_MISSING") + assert outcome.status == "accounts_building" + +def test_provisioning_email_conflict(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def find_user(self, username): + return None + + def find_user_by_email(self, email): + return {"username": "other"} + + monkeypatch.setattr(prov, "keycloak_admin", Admin()) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_CONFLICT") + assert outcome.status == "accounts_building" + +def test_provisioning_missing_contact_email(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def find_user(self, username): + return None + + monkeypatch.setattr(prov, "keycloak_admin", Admin()) + + row = { + "username": "alice", + "contact_email": "", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_EMPTY") + assert outcome.status == "accounts_building" + +def test_provisioning_user_id_missing(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def find_user(self, username): + return {"id": ""} + + monkeypatch.setattr(prov, "keycloak_admin", Admin()) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_ID") + assert outcome.status == "accounts_building" + +def test_provisioning_initial_password_revealed(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": None, + "initial_password_revealed_at": datetime.now(timezone.utc), + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_REVEALED") + assert outcome.status == "accounts_building" + +def test_provisioning_vaultwarden_attribute_failure(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def set_user_attribute(self, username, key, value): + raise RuntimeError("fail") + + monkeypatch.setattr(prov, "keycloak_admin", Admin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), DummyStorage()).provision_access_request("REQ_VAULT") + assert outcome.status == "accounts_building" + +def test_provisioning_vaultwarden_rate_limited(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + vaultwarden_admin_rate_limit_backoff_sec=600.0, + provision_retry_cooldown_sec=0.0, + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda *_args, **_kwargs: True) + monkeypatch.setattr( + prov.vaultwarden, + "invite_user", + lambda _email: VaultwardenInvite(False, "rate_limited", "vaultwarden rate limited"), + ) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + manager = prov.ProvisioningManager(DummyDB(row), DummyStorage()) + ctx = prov.RequestContext( + request_code="REQ_RATE", + username="alice", + first_name="", + last_name="", + contact_email="alice@example.com", + email_verified_at=row["email_verified_at"], + status="accounts_building", + initial_password="temp", + revealed_at=None, + attempted_at=None, + approval_flags=[], + user_id="1", + mailu_email="alice@bstein.dev", + ) + conn = DummyConn(row) + manager._ensure_vaultwarden_invite(conn, ctx) + + inserts = [ + params + for query, params in conn.executed + if "INSERT INTO access_request_tasks" in query and isinstance(params, tuple) and len(params) >= 4 + ] + assert any(params[2] == "pending" and "rate limited until" in (params[3] or "") for params in inserts) + +def test_provisioning_vaultwarden_grandfathered(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + vaultwarden_admin_rate_limit_backoff_sec=600.0, + provision_retry_cooldown_sec=0.0, + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr( + prov.vaultwarden, + "find_user_by_email", + lambda _email: VaultwardenLookup(True, "present", "user found"), + ) + monkeypatch.setattr( + prov.vaultwarden, + "invite_user", + lambda _email: (_ for _ in ()).throw(RuntimeError("invite should not run")), + ) + + row = { + "username": "legacy", + "contact_email": "legacy@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [prov.VAULTWARDEN_GRANDFATHERED_FLAG], + } + manager = prov.ProvisioningManager(DummyDB(row), DummyStorage()) + ctx = prov.RequestContext( + request_code="REQ_GRANDFATHER", + username="legacy", + first_name="", + last_name="", + contact_email="legacy@example.com", + email_verified_at=row["email_verified_at"], + status="accounts_building", + initial_password="temp", + revealed_at=None, + attempted_at=None, + approval_flags=row["approval_flags"], + user_id="1", + mailu_email="legacy@bstein.dev", + ) + conn = DummyConn(row) + manager._ensure_vaultwarden_invite(conn, ctx) + + inserts = [ + params + for query, params in conn.executed + if "INSERT INTO access_request_tasks" in query and isinstance(params, tuple) and len(params) >= 4 + ] + assert any(params[2] == "ok" and params[3] == "grandfathered" for params in inserts) diff --git a/tests/unit/manager/test_provisioning_identity_flow.py b/tests/unit/manager/test_provisioning_identity_flow.py new file mode 100644 index 0000000..ededb5e --- /dev/null +++ b/tests/unit/manager/test_provisioning_identity_flow.py @@ -0,0 +1,480 @@ +from tests.unit.manager.provisioning_helpers import * + + +def test_provisioning_filters_flag_groups(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=["demo", "test"], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + admin = DummyAdmin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) + monkeypatch.setattr(prov.ProvisioningManager, "_send_welcome_email", lambda *args, **kwargs: None) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": None, + "status": "approved", + "initial_password": None, + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": ["demo", "admin"], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + manager.provision_access_request("REQ123") + + assert "dev" in admin.groups + assert "demo" in admin.groups + assert "admin" not in admin.groups + +def test_provisioning_creates_user_and_password(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=["demo"], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def __init__(self): + super().__init__() + self.created_payload = None + self.reset_calls = [] + + def find_user(self, username): + return None + + def get_user(self, user_id): + return { + "id": user_id, + "username": "alice", + "requiredActions": ["CONFIGURE_TOTP"], + "attributes": {}, + } + + def create_user(self, payload): + self.created_payload = payload + return "user-123" + + def update_user(self, user_id, payload): + return None + + def reset_password(self, user_id, password, temporary=False): + self.reset_calls.append((user_id, temporary)) + + admin = Admin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) + monkeypatch.setattr(prov.ProvisioningManager, "_send_welcome_email", lambda *args, **kwargs: None) + + row = { + "username": "alice", + "first_name": "Alice", + "last_name": "Atlas", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "approved", + "initial_password": None, + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": ["demo"], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ124") + + assert outcome.status == "awaiting_onboarding" + assert admin.created_payload is not None + assert admin.created_payload.get("firstName") == "Alice" + assert admin.created_payload.get("lastName") == "Atlas" + assert admin.reset_calls + +def test_provisioning_create_user_fallback(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=["demo"], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def __init__(self): + super().__init__() + self.find_calls = 0 + self.reset_calls = [] + + def find_user(self, username): + self.find_calls += 1 + if self.find_calls >= 2: + return {"id": "user-123", "username": username, "attributes": {}, "requiredActions": []} + return None + + def get_user(self, user_id): + return {"id": user_id, "username": "alice", "attributes": {}, "requiredActions": []} + + def create_user(self, payload): + raise RuntimeError("boom") + + def reset_password(self, user_id, password, temporary=False): + self.reset_calls.append((user_id, temporary)) + + admin = Admin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) + monkeypatch.setattr(prov.ProvisioningManager, "_send_welcome_email", lambda *args, **kwargs: None) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "approved", + "initial_password": None, + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": ["demo"], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ125") + + assert outcome.status == "awaiting_onboarding" + assert admin.find_calls >= 2 + assert admin.reset_calls + +def test_extract_attr_variants() -> None: + assert prov._extract_attr("bad", "key") == "" + assert prov._extract_attr({"key": ["value"]}, "key") == "value" + assert prov._extract_attr({"key": ["", " "]}, "key") == "" + assert prov._extract_attr({"key": "value"}, "key") == "value" + assert prov._extract_attr({}, "key") == "" + +def test_provisioning_empty_request_code(monkeypatch) -> None: + db = DummyDB({}) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("") + assert outcome.status == "unknown" + +def test_provisioning_admin_not_ready(monkeypatch) -> None: + monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: False) + db = DummyDB({}) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ") + assert outcome.status == "accounts_building" + +def test_provisioning_lock_not_acquired(monkeypatch) -> None: + monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "approved", + "initial_password": None, + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + db = DummyDB(row, locked=False) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ") + assert outcome.status == "accounts_building" + +def test_provisioning_cooldown_short_circuit(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=9999.0, + default_user_groups=["dev"], + allowed_flag_groups=["demo"], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + provision_poll_interval_sec=1.0, + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": None, + "initial_password_revealed_at": None, + "provision_attempted_at": datetime.now(timezone.utc), + "approval_flags": [], + } + db = DummyDB(row, locked=True) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ") + assert outcome.status == "accounts_building" + +def test_provisioning_mailu_sync_disabled(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=["demo"], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def __init__(self): + super().__init__() + self.updated_actions = [] + + def find_user(self, username): + return {"id": "user-1"} + + def get_user(self, user_id): + return { + "id": user_id, + "username": "alice", + "requiredActions": ["CONFIGURE_TOTP"], + "attributes": { + "mailu_email": ["alice@bstein.dev"], + "mailu_enabled": ["false"], + "wger_password": ["pw"], + "wger_password_updated_at": ["done"], + "firefly_password": ["pw"], + "firefly_password_updated_at": ["done"], + }, + } + + def update_user_safe(self, user_id, payload): + self.updated_actions.append(payload) + + admin = Admin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ125") + assert outcome.status == "accounts_building" + assert admin.updated_actions + +def test_provisioning_sets_missing_email(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def __init__(self): + super().__init__() + self.updated_actions = [] + + def find_user(self, username): + return {"id": "user-1"} + + def get_user(self, user_id): + return {"id": user_id, "username": "alice", "email": None, "attributes": {}} + + def update_user_safe(self, user_id, payload): + self.updated_actions.append(payload) + + admin = Admin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ126") + assert outcome.status == "accounts_building" + assert any("email" in payload for payload in admin.updated_actions) + +def test_provisioning_mailbox_not_ready(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + admin = DummyAdmin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: False) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "approved", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ126") + assert outcome.status == "accounts_building" + +def test_provisioning_sync_errors(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + admin = DummyAdmin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "error"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "error"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "error"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(False, "error", "fail")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "approved", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ127") + assert outcome.status == "accounts_building" diff --git a/tests/unit/manager/test_provisioning_run_loop.py b/tests/unit/manager/test_provisioning_run_loop.py new file mode 100644 index 0000000..0fba876 --- /dev/null +++ b/tests/unit/manager/test_provisioning_run_loop.py @@ -0,0 +1,269 @@ +from tests.unit.manager.provisioning_helpers import * + + +def test_provisioning_run_loop_waits_for_admin(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace(provision_poll_interval_sec=0.0) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: False) + + class DB: + def fetchall(self, *_args, **_kwargs): + return [] + + class Storage: + def list_provision_candidates(self): + raise AssertionError("should not list candidates") + + manager = prov.ProvisioningManager(DB(), Storage()) + + def fake_sleep(_): + manager._stop_event.set() + + monkeypatch.setattr(prov.time, "sleep", fake_sleep) + manager._stop_event.clear() + manager._run_loop() + +def test_provisioning_initial_password_missing(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + admin = DummyAdmin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "awaiting_onboarding", + "initial_password": None, + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ201") + assert outcome.status == "awaiting_onboarding" + +def test_provisioning_group_and_mailu_errors(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def get_group_id(self, group_name: str): + return None + + def set_user_attribute(self, username: str, key: str, value: str): + if key == "mailu_app_password": + raise RuntimeError("fail") + return None + + admin = Admin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: (_ for _ in ()).throw(RuntimeError("fail"))) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ202") + assert outcome.status == "accounts_building" + +def test_provisioning_task_helpers() -> None: + class Conn: + def execute(self, *_args, **_kwargs): + class Result: + def fetchall(self): + return [ + {"task": "a", "status": "ok"}, + {"task": "b", "status": "error"}, + "bad", + ] + + return Result() + + manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) + statuses = manager._task_statuses(Conn(), "REQ") + assert statuses == {"a": "ok", "b": "error"} + assert manager._all_tasks_ok(Conn(), "REQ", ["a"]) is True + assert manager._all_tasks_ok(Conn(), "REQ", ["b"]) is False + +def test_provisioning_retryable_detail_detection() -> None: + manager = prov.ProvisioningManager(DummyDB({}, locked=True), DummyStorage()) + assert manager._is_retryable_detail("timeout") is True + assert manager._is_retryable_detail("http 503: service unavailable") is True + assert manager._is_retryable_detail("mailbox not ready") is True + assert manager._is_retryable_detail("invalid credentials") is False + assert manager._retryable_detail("timeout").startswith("retryable:") + +def test_provisioning_ensure_task_rows_empty() -> None: + manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) + manager._ensure_task_rows(DummyConn({}, locked=True), "REQ", []) + +def test_provisioning_record_task_ignores_storage_errors(monkeypatch) -> None: + class Storage: + def record_event(self, *args, **kwargs): + raise RuntimeError("fail") + + def record_task_run(self, *args, **kwargs): + raise RuntimeError("fail") + + db = DummyDB({}) + manager = prov.ProvisioningManager(db, Storage()) + manager._record_task("REQ", "task", "ok", None, datetime.now(timezone.utc)) + +def test_provisioning_send_welcome_email_variants(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) + manager._send_welcome_email("REQ", "alice", "alice@example.com") + + dummy_settings = types.SimpleNamespace( + welcome_email_enabled=True, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + manager._send_welcome_email("REQ", "alice", "") + + class DB(DummyDB): + def fetchone(self, *_args, **_kwargs): + return None + + class Storage(DummyStorage): + def mark_welcome_sent(self, *args, **kwargs): + raise AssertionError("should not be called") + + manager = prov.ProvisioningManager(DB({}), Storage()) + monkeypatch.setattr( + prov.mailer, + "send_welcome", + lambda *args, **kwargs: (_ for _ in ()).throw(prov.MailerError("fail")), + ) + manager._send_welcome_email("REQ", "alice", "alice@example.com") + +def test_provisioning_start_stop(monkeypatch) -> None: + class DummyThread: + def __init__(self, target=None, name=None, daemon=None): + self.started = False + self.joined = False + + def is_alive(self) -> bool: + return False + + def start(self) -> None: + self.started = True + + def join(self, timeout=None) -> None: + self.joined = True + + monkeypatch.setattr(prov.threading, "Thread", DummyThread) + manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) + manager.start() + assert manager._thread.started is True + manager.stop() + assert manager._thread.joined is True + +def test_provisioning_start_skips_when_running() -> None: + class LiveThread: + def __init__(self): + self.started = False + + def is_alive(self) -> bool: + return True + + def start(self) -> None: + self.started = True + + manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) + manager._thread = LiveThread() + manager.start() + assert manager._thread.started is False + +def test_provisioning_run_loop_skips_when_admin_not_ready(monkeypatch) -> None: + manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) + monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: False) + monkeypatch.setattr(manager, "_sync_status_metrics", lambda: (_ for _ in ()).throw(RuntimeError("fail"))) + monkeypatch.setattr( + prov.time, + "sleep", + lambda *_args, **_kwargs: manager._stop_event.set(), + ) + + manager._run_loop() + +def test_provisioning_run_loop_processes_candidates(monkeypatch) -> None: + manager = prov.ProvisioningManager(DummyDB({}), DummyStorage()) + monkeypatch.setattr(prov.keycloak_admin, "ready", lambda: True) + monkeypatch.setattr(manager._storage, "list_provision_candidates", lambda: [types.SimpleNamespace(request_code="REQ")]) + calls: list[str] = [] + monkeypatch.setattr(manager, "provision_access_request", lambda code: calls.append(code)) + monkeypatch.setattr( + prov.time, + "sleep", + lambda *_args, **_kwargs: manager._stop_event.set(), + ) + + manager._run_loop() + assert calls == ["REQ"] + +def test_provisioning_sync_status_metrics(monkeypatch) -> None: + db = DummyDB({}) + db.fetchall = lambda *_args, **_kwargs: [{"status": "pending", "count": 2}] + manager = prov.ProvisioningManager(db, DummyStorage()) + captured = {} + monkeypatch.setattr(prov, "set_access_request_counts", lambda payload: captured.update(payload)) + manager._sync_status_metrics() + assert captured["pending"] == 2 diff --git a/tests/unit/manager/test_provisioning_status_flow.py b/tests/unit/manager/test_provisioning_status_flow.py new file mode 100644 index 0000000..bdf120a --- /dev/null +++ b/tests/unit/manager/test_provisioning_status_flow.py @@ -0,0 +1,329 @@ +from tests.unit.manager.provisioning_helpers import * + + +def test_provisioning_locked_returns_accounts_building(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + db = DummyDB({"username": "alice"}, locked=False) + manager = prov.ProvisioningManager(db, DummyStorage()) + outcome = manager.provision_access_request("REQ_LOCK") + assert outcome.status == "accounts_building" + +def test_provisioning_missing_row_returns_unknown(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + db = DummyDB(None) + manager = prov.ProvisioningManager(db, DummyStorage()) + outcome = manager.provision_access_request("REQ_NONE") + assert outcome.status == "unknown" + +def test_provisioning_denied_status_returns_denied(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "denied", + "initial_password": None, + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + db = DummyDB(row) + manager = prov.ProvisioningManager(db, DummyStorage()) + outcome = manager.provision_access_request("REQ_DENIED") + assert outcome.status == "denied" + +def test_provisioning_respects_retry_cooldown(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=60.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": datetime.now(), + "approval_flags": [], + } + db = DummyDB(row) + manager = prov.ProvisioningManager(db, DummyStorage()) + outcome = manager.provision_access_request("REQ_COOLDOWN") + assert outcome.status == "accounts_building" + +def test_provisioning_updates_existing_user_attrs(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class Admin(DummyAdmin): + def __init__(self): + super().__init__() + self.update_payloads = [] + self.attr_calls = [] + + def find_user(self, username): + return {"id": "1"} + + def get_user(self, user_id): + return { + "id": user_id, + "username": "alice", + "email": "", + "requiredActions": ["CONFIGURE_TOTP"], + "attributes": {"mailu_enabled": ["false"]}, + } + + def update_user_safe(self, user_id, payload): + self.update_payloads.append(payload) + + def set_user_attribute(self, username, key, value): + self.attr_calls.append((key, value)) + + admin = Admin() + monkeypatch.setattr(prov, "keycloak_admin", admin) + monkeypatch.setattr(prov.mailu, "sync", lambda reason, force=False: None) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda username, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + storage = DummyStorage() + manager = prov.ProvisioningManager(db, storage) + outcome = manager.provision_access_request("REQ_ATTRS") + assert outcome.status == "accounts_building" + assert admin.update_payloads + assert any(key == "mailu_email" for key, _value in admin.attr_calls) + +def test_provisioning_mailu_sync_failure(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="http://mailu", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "sync", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("fail"))) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: False) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + manager = prov.ProvisioningManager(db, DummyStorage()) + outcome = manager.provision_access_request("REQ_MAILU") + assert outcome.status == "accounts_building" + +def test_provisioning_nextcloud_sync_error(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.nextcloud, "sync_mail", lambda *args, **kwargs: {"status": "error"}) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + manager = prov.ProvisioningManager(db, DummyStorage()) + outcome = manager.provision_access_request("REQ_NC") + assert outcome.status == "accounts_building" + +def test_provisioning_wger_firefly_errors(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.wger, "sync_user", lambda username, email, password, wait=True: {"status": "error"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda email, password, wait=True: {"status": "error"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + db = DummyDB(row) + manager = prov.ProvisioningManager(db, DummyStorage()) + outcome = manager.provision_access_request("REQ_WGER") + assert outcome.status == "accounts_building" + +def test_provisioning_start_event_failure(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + class Storage(DummyStorage): + def record_event(self, *args, **kwargs): + raise RuntimeError("fail") + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + manager = prov.ProvisioningManager(DummyDB(row), Storage()) + outcome = manager.provision_access_request("REQ_EVENT") + assert outcome.status == "accounts_building" diff --git a/tests/unit/manager/test_provisioning_welcome_email.py b/tests/unit/manager/test_provisioning_welcome_email.py new file mode 100644 index 0000000..a6866db --- /dev/null +++ b/tests/unit/manager/test_provisioning_welcome_email.py @@ -0,0 +1,132 @@ +from tests.unit.manager.provisioning_helpers import * + + +def test_provisioning_complete_event_failure(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: True) + + class Storage(DummyStorage): + def record_event(self, event_type, detail): + if event_type == "provision_complete": + raise RuntimeError("fail") + return None + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), Storage()).provision_access_request("REQ_DONE") + assert outcome.status == "awaiting_onboarding" + +def test_provisioning_pending_event_failure(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_sync_url="", + mailu_mailbox_wait_timeout_sec=1.0, + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + provision_retry_cooldown_sec=0.0, + default_user_groups=["dev"], + allowed_flag_groups=[], + welcome_email_enabled=False, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + monkeypatch.setattr(prov, "keycloak_admin", DummyAdmin()) + monkeypatch.setattr(prov.mailu, "wait_for_mailbox", lambda email, timeout: True) + monkeypatch.setattr(prov.wger, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.firefly, "sync_user", lambda *args, **kwargs: {"status": "ok"}) + monkeypatch.setattr(prov.vaultwarden, "invite_user", lambda email: VaultwardenInvite(True, "invited")) + monkeypatch.setattr(prov.ProvisioningManager, "_all_tasks_ok", lambda *args, **kwargs: False) + + class Storage(DummyStorage): + def record_event(self, event_type, detail): + if event_type == "provision_pending": + raise RuntimeError("fail") + return None + + row = { + "username": "alice", + "contact_email": "alice@example.com", + "email_verified_at": datetime.now(timezone.utc), + "status": "accounts_building", + "initial_password": "temp", + "initial_password_revealed_at": None, + "provision_attempted_at": None, + "approval_flags": [], + } + + outcome = prov.ProvisioningManager(DummyDB(row), Storage()).provision_access_request("REQ_PENDING") + assert outcome.status == "accounts_building" + +def test_send_welcome_email_already_sent(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + welcome_email_enabled=True, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class DB(DummyDB): + def fetchone(self, query, params=None): + return {"welcome_email_sent_at": datetime.now(timezone.utc)} + + sent = {"called": False} + + def mark_sent(_code): + sent["called"] = True + + manager = prov.ProvisioningManager(DB({}), DummyStorage()) + monkeypatch.setattr(manager._storage, "mark_welcome_sent", mark_sent) + monkeypatch.setattr(prov.mailer, "send_welcome", lambda *args, **kwargs: None) + manager._send_welcome_email("REQ_WELCOME", "alice", "alice@example.com") + assert sent["called"] is False + +def test_send_welcome_email_marks_sent(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + welcome_email_enabled=True, + portal_public_base_url="https://bstein.dev", + ) + monkeypatch.setattr(prov, "settings", dummy_settings) + _patch_mailu_ready(monkeypatch, dummy_settings) + + class DB(DummyDB): + def fetchone(self, query, params=None): + return None + + sent = {"called": False} + + def mark_sent(_code): + sent["called"] = True + + manager = prov.ProvisioningManager(DB({}), DummyStorage()) + monkeypatch.setattr(manager._storage, "mark_welcome_sent", mark_sent) + monkeypatch.setattr(prov.mailer, "send_welcome", lambda *args, **kwargs: None) + manager._send_welcome_email("REQ_WELCOME", "alice", "alice@example.com") + assert sent["called"] is True diff --git a/tests/unit/services/__init__.py b/tests/unit/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/services/keycloak_admin_helpers.py b/tests/unit/services/keycloak_admin_helpers.py new file mode 100644 index 0000000..45fe960 --- /dev/null +++ b/tests/unit/services/keycloak_admin_helpers.py @@ -0,0 +1,56 @@ +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() + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/tests/unit/services/service_helpers.py b/tests/unit/services/service_helpers.py new file mode 100644 index 0000000..53ff667 --- /dev/null +++ b/tests/unit/services/service_helpers.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import time + +import types + +import pytest + +from ariadne.services import mailu as mailu_module + +from ariadne.services.firefly import FireflyService + +from ariadne.services.mailu import MailuService + +from ariadne.services.nextcloud import NextcloudService + +from ariadne.services.wger import WgerService + +from ariadne.services.vaultwarden import VaultwardenService + +class DummyExecutor: + def __init__(self, stdout: str = "ok", stderr: str = "", exit_code: int = 0): + self.calls = [] + self._stdout = stdout + self._stderr = stderr + self._exit_code = exit_code + + def exec(self, command, env=None, timeout_sec=None, check=True): + self.calls.append((command, env, timeout_sec, check)) + return types.SimpleNamespace( + stdout=self._stdout, + stderr=self._stderr, + exit_code=self._exit_code, + ok=self._exit_code == 0, + ) + +class DummyResponse: + def __init__(self, status_code=200, text="", json_data=None): + self.status_code = status_code + self.text = text + self._json_data = json_data + + def raise_for_status(self): + return None + + def json(self): + if self._json_data is None: + raise ValueError("no json data") + return self._json_data + +class DummyVaultwardenClient: + def __init__(self): + self.calls = [] + self.responses = {} + + def post(self, path, json=None, data=None): + self.calls.append((path, json, data)) + resp = self.responses.get(path) + if resp is None: + resp = DummyResponse(200, "") + return resp + + def get(self, path): + self.calls.append((path, None, None)) + resp = self.responses.get(path) + if resp is None: + resp = DummyResponse(200, "", json_data=[]) + return resp + + def close(self): + return None + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/tests/unit/services/test_firefly_service.py b/tests/unit/services/test_firefly_service.py new file mode 100644 index 0000000..b3704aa --- /dev/null +++ b/tests/unit/services/test_firefly_service.py @@ -0,0 +1,246 @@ +from tests.unit.services.service_helpers import * + + +def test_firefly_check_rotation_marks_rotated(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="finance", + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_user_sync_wait_timeout_sec=60.0, + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def find_user(self, username: str): + return {"id": "1", "username": username, "attributes": {}} + + def get_user(self, user_id: str): + return { + "id": user_id, + "username": "alice", + "attributes": { + "mailu_email": ["alice@bstein.dev"], + "firefly_password": ["pw"], + }, + } + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.firefly.keycloak_admin", DummyAdmin()) + + svc = FireflyService() + monkeypatch.setattr(svc, "check_password", lambda *_args, **_kwargs: {"status": "mismatch"}) + + result = svc.check_rotation_for_user("alice") + assert result["status"] == "ok" + assert result["rotated"] is True + assert any(key == "firefly_password_rotated_at" for _user, key, _value in calls) + +def test_firefly_sync_user_exec(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="finance", + firefly_user_sync_cronjob="firefly-user-sync", + firefly_user_sync_wait_timeout_sec=60.0, + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_cron_base_url="http://firefly/cron", + firefly_cron_token="token", + firefly_cron_timeout_sec=10.0, + ) + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + + class DummyExecutor: + def exec(self, _cmd, env=None, timeout_sec=None, check=True): + return types.SimpleNamespace(stdout="ok", stderr="", exit_code=0, ok=True) + + monkeypatch.setattr("ariadne.services.firefly.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = FireflyService() + result = svc.sync_user("alice@bstein.dev", "pw", wait=True) + assert result["status"] == "ok" + +def test_firefly_sync_missing_inputs(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="finance", + firefly_user_sync_cronjob="firefly-user-sync", + firefly_user_sync_wait_timeout_sec=60.0, + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_cron_base_url="http://firefly/cron", + firefly_cron_token="token", + firefly_cron_timeout_sec=10.0, + ) + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + monkeypatch.setattr("ariadne.services.firefly.PodExecutor", lambda *_args, **_kwargs: None) + + svc = FireflyService() + with pytest.raises(RuntimeError): + svc.sync_user("", "pw", wait=True) + with pytest.raises(RuntimeError): + svc.sync_user("alice@bstein.dev", "", wait=True) + +def test_firefly_sync_missing_config(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="", + firefly_user_sync_cronjob="", + firefly_user_sync_wait_timeout_sec=60.0, + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_cron_base_url="http://firefly/cron", + firefly_cron_token="token", + firefly_cron_timeout_sec=10.0, + ) + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + svc = FireflyService() + with pytest.raises(RuntimeError): + svc.sync_user("alice@bstein.dev", "pw", wait=True) + +def test_firefly_run_cron(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="finance", + firefly_user_sync_cronjob="firefly-user-sync", + firefly_user_sync_wait_timeout_sec=60.0, + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_cron_base_url="http://firefly/cron", + firefly_cron_token="token", + firefly_cron_timeout_sec=10.0, + ) + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + monkeypatch.setattr("ariadne.services.firefly.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + class DummyHTTP: + def __init__(self): + self.calls = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, url): + self.calls.append(url) + return types.SimpleNamespace(status_code=200) + + monkeypatch.setattr("ariadne.services.firefly.httpx.Client", lambda *args, **kwargs: DummyHTTP()) + + svc = FireflyService() + result = svc.run_cron() + assert result["status"] == "ok" + +def test_firefly_sync_users(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="finance", + firefly_user_sync_wait_timeout_sec=60.0, + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_cron_base_url="http://firefly/cron", + firefly_cron_token="token", + firefly_cron_timeout_sec=10.0, + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def iter_users(self, page_size=200, brief=False): + return [{"id": "1", "username": "alice", "attributes": {}}] + + def get_user(self, user_id: str): + return {"id": user_id, "username": "alice", "attributes": {}} + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.firefly.keycloak_admin", DummyAdmin()) + monkeypatch.setattr( + "ariadne.services.firefly.mailu.resolve_mailu_email", + lambda *_args, **_kwargs: "alice@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.firefly.random_password", lambda *_args: "pw") + + def fake_sync_user(self, *_args, **_kwargs): + return {"status": "ok", "detail": "ok"} + + monkeypatch.setattr(FireflyService, "sync_user", fake_sync_user) + + svc = FireflyService() + result = svc.sync_users() + assert result["status"] == "ok" + assert any(key == "firefly_password" for _user, key, _value in calls) + assert any(key == "firefly_password_updated_at" for _user, key, _value in calls) + +def test_firefly_sync_marks_rotated(monkeypatch) -> None: + dummy = types.SimpleNamespace( + firefly_namespace="finance", + firefly_user_sync_wait_timeout_sec=60.0, + firefly_pod_label="app=firefly", + firefly_container="firefly", + firefly_cron_base_url="http://firefly/cron", + firefly_cron_token="token", + firefly_cron_timeout_sec=10.0, + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.firefly.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def iter_users(self, page_size=200, brief=False): + return [ + { + "id": "1", + "username": "alice", + "attributes": { + "mailu_email": ["alice@bstein.dev"], + "firefly_password": ["pw"], + "firefly_password_updated_at": ["2025-01-01T00:00:00Z"], + }, + } + ] + + def get_user(self, user_id: str): + return { + "id": user_id, + "username": "alice", + "attributes": { + "mailu_email": ["alice@bstein.dev"], + "firefly_password": ["pw"], + "firefly_password_updated_at": ["2025-01-01T00:00:00Z"], + }, + } + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.firefly.keycloak_admin", DummyAdmin()) + monkeypatch.setattr( + "ariadne.services.firefly.mailu.resolve_mailu_email", + lambda *_args, **_kwargs: "alice@bstein.dev", + ) + + def fake_check(self, *_args, **_kwargs): + return {"status": "mismatch", "detail": "mismatch"} + + monkeypatch.setattr(FireflyService, "check_password", fake_check) + monkeypatch.setattr(FireflyService, "sync_user", lambda *_args, **_kwargs: {"status": "ok"}) + + svc = FireflyService() + result = svc.sync_users() + assert result["status"] == "ok" + assert any(key == "firefly_password_rotated_at" for _user, key, _value in calls) diff --git a/tests/unit/services/test_keycloak_admin_groups.py b/tests/unit/services/test_keycloak_admin_groups.py new file mode 100644 index 0000000..b2010b0 --- /dev/null +++ b/tests/unit/services/test_keycloak_admin_groups.py @@ -0,0 +1,94 @@ +from tests.unit.services.keycloak_admin_helpers import * + + +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_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_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" diff --git a/tests/unit/services/test_keycloak_admin_lifecycle.py b/tests/unit/services/test_keycloak_admin_lifecycle.py new file mode 100644 index 0000000..a3e31b9 --- /dev/null +++ b/tests/unit/services/test_keycloak_admin_lifecycle.py @@ -0,0 +1,58 @@ +from tests.unit.services.keycloak_admin_helpers import * + + +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_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) diff --git a/tests/unit/services/test_keycloak_admin_tokens.py b/tests/unit/services/test_keycloak_admin_tokens.py new file mode 100644 index 0000000..e887c9f --- /dev/null +++ b/tests/unit/services/test_keycloak_admin_tokens.py @@ -0,0 +1,54 @@ +from tests.unit.services.keycloak_admin_helpers import * + + +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_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_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" diff --git a/tests/test_keycloak_admin.py b/tests/unit/services/test_keycloak_admin_users.py similarity index 57% rename from tests/test_keycloak_admin.py rename to tests/unit/services/test_keycloak_admin_users.py index 69cf862..3f6fee8 100644 --- a/tests/test_keycloak_admin.py +++ b/tests/unit/services/test_keycloak_admin_users.py @@ -1,57 +1,4 @@ -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() +from tests.unit.services.keycloak_admin_helpers import * def test_set_user_attribute_preserves_profile(monkeypatch) -> None: @@ -97,7 +44,6 @@ def test_set_user_attribute_preserves_profile(monkeypatch) -> None: "mailu_app_password": ["secret"], } - def test_update_user_safe_merges_payload(monkeypatch) -> None: client = KeycloakAdminClient() captured: dict[str, Any] = {} @@ -127,25 +73,6 @@ def test_update_user_safe_merges_payload(monkeypatch) -> None: 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", @@ -165,7 +92,6 @@ def test_find_user_by_email_case_insensitive(monkeypatch) -> None: 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", @@ -183,12 +109,10 @@ def test_find_user_invalid_payload(monkeypatch) -> None: 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", @@ -206,26 +130,6 @@ def test_find_user_by_email_invalid_payload(monkeypatch) -> None: 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", @@ -243,26 +147,6 @@ def test_find_user_by_email_skips_non_dict(monkeypatch) -> None: 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", @@ -281,7 +165,6 @@ def test_update_user_calls_put(monkeypatch) -> None: 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] = {} @@ -298,18 +181,6 @@ def test_update_user_safe_handles_bad_attrs(monkeypatch) -> None: 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() @@ -325,63 +196,6 @@ def test_set_user_attribute_handles_bad_attrs(monkeypatch) -> 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", @@ -406,7 +220,6 @@ def test_iter_users_paginates(monkeypatch) -> None: 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", @@ -425,104 +238,6 @@ def test_iter_users_empty(monkeypatch) -> None: 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_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", @@ -540,7 +255,6 @@ def test_find_user_returns_none(monkeypatch) -> None: 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", @@ -560,7 +274,6 @@ def test_get_user_invalid_payload(monkeypatch) -> None: with pytest.raises(RuntimeError): client.get_user("id") - def test_get_user_success(monkeypatch) -> None: dummy_settings = types.SimpleNamespace( keycloak_admin_url="http://kc", @@ -579,41 +292,18 @@ def test_get_user_success(monkeypatch) -> None: 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", @@ -632,7 +322,6 @@ def test_get_user_raises_on_non_dict_payload(monkeypatch) -> None: 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"}) @@ -641,7 +330,6 @@ def test_update_user_safe_coerces_bad_attrs(monkeypatch) -> 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"}) @@ -651,14 +339,12 @@ def test_set_user_attribute_coerces_bad_attrs(monkeypatch) -> 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", diff --git a/tests/unit/services/test_mailu_service.py b/tests/unit/services/test_mailu_service.py new file mode 100644 index 0000000..cb2128f --- /dev/null +++ b/tests/unit/services/test_mailu_service.py @@ -0,0 +1,369 @@ +from tests.unit.services.service_helpers import * + + +def test_mailu_sync_updates_attrs(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=[], + mailu_system_password="", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.iter_users", + lambda *args, **kwargs: [ + { + "id": "1", + "username": "alice", + "enabled": True, + "email": "alice@example.com", + "attributes": {}, + "firstName": "Alice", + "lastName": "Example", + } + ], + ) + + updates: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.update_user_safe", + lambda user_id, payload: updates.append((user_id, payload)), + ) + + mailbox_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.MailuService._ensure_mailbox", + lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, + ) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("provision", force=True) + + assert summary.processed == 1 + assert summary.updated == 1 + assert mailbox_calls + assert updates + assert "mailu_email" in updates[0][1]["attributes"] + +def test_mailu_sync_rotates_long_password(monkeypatch) -> None: + long_password = "x" * 100 + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=[], + mailu_system_password="", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.iter_users", + lambda *args, **kwargs: [ + { + "id": "1", + "username": "alice", + "enabled": True, + "email": "alice@example.com", + "attributes": {"mailu_app_password": [long_password]}, + "firstName": "Alice", + "lastName": "Example", + } + ], + ) + monkeypatch.setattr("ariadne.services.mailu.random_password", lambda *_args, **_kwargs: "short-pass-123") + + updates: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.update_user_safe", + lambda user_id, payload: updates.append((user_id, payload)), + ) + + mailbox_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.MailuService._ensure_mailbox", + lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, + ) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("provision", force=True) + + assert summary.processed == 1 + assert updates + attrs = updates[0][1]["attributes"] + assert attrs["mailu_app_password"] == ["short-pass-123"] + assert mailbox_calls + assert mailbox_calls[0][1] == "short-pass-123" + +def test_mailu_sync_retries_on_password_limit(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=[], + mailu_system_password="", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr(mailu_module.keycloak_admin, "ready", lambda: True) + update_calls: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + mailu_module.keycloak_admin, + "update_user_safe", + lambda user_id, payload: update_calls.append((user_id, payload)), + ) + monkeypatch.setattr( + mailu_module.keycloak_admin, + "iter_users", + lambda *args, **kwargs: [ + { + "id": "1", + "username": "alice", + "enabled": True, + "email": "alice@example.com", + "attributes": {"mailu_app_password": ["short-pass"]}, + "firstName": "Alice", + "lastName": "Example", + } + ], + ) + monkeypatch.setattr("ariadne.services.mailu.random_password", lambda *_args, **_kwargs: "retry-pass-1") + + set_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + mailu_module.keycloak_admin, + "set_user_attribute", + lambda username, key, value: set_calls.append((username, key, value)), + ) + + call_count = {"count": 0} + + def fake_ensure(self, _conn, _email, password, _display): + call_count["count"] += 1 + if call_count["count"] == 1: + raise mailu_module.PasswordTooLongError("password cannot be longer than 72 bytes") + assert password == "retry-pass-1" + return True + + monkeypatch.setattr("ariadne.services.mailu.MailuService._ensure_mailbox", fake_ensure) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("provision", force=True) + + assert summary.processed == 1 + assert update_calls + assert call_count["count"] == 2 + assert set_calls + +def test_mailu_sync_skips_disabled(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=[], + mailu_system_password="", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.iter_users", + lambda *args, **kwargs: [ + { + "id": "1", + "username": "alice", + "enabled": True, + "attributes": {"mailu_enabled": ["false"]}, + } + ], + ) + + mailbox_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.MailuService._ensure_mailbox", + lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, + ) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("provision") + + assert summary.skipped == 1 + assert mailbox_calls == [] + +def test_mailu_sync_system_mailboxes(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=["no-reply-portal@bstein.dev"], + mailu_system_password="systempw", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.iter_users", lambda *args, **kwargs: []) + + mailbox_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.MailuService._ensure_mailbox", + lambda self, _conn, email, password, display: mailbox_calls.append((email, password, display)) or True, + ) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("schedule") + + assert summary.system_mailboxes == 1 + assert mailbox_calls[0][0] == "no-reply-portal@bstein.dev" + +def test_mailu_mailbox_exists_handles_error(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_sync_url="", + mailu_sync_wait_timeout_sec=10.0, + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) + + svc = MailuService() + assert svc.mailbox_exists("alice@bstein.dev") is False + +def test_mailu_mailbox_exists_success(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_sync_url="", + mailu_sync_wait_timeout_sec=10.0, + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + + class DummyCursor: + def execute(self, *_args, **_kwargs): + return None + + def fetchone(self): + return {"id": 1} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + class DummyConn: + def cursor(self): + return DummyCursor() + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + assert svc.mailbox_exists("alice@bstein.dev") is True + +def test_mailu_wait_for_mailbox(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_sync_url="", + mailu_sync_wait_timeout_sec=10.0, + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr(MailuService, "mailbox_exists", lambda self, email: True) + + svc = MailuService() + assert svc.wait_for_mailbox("alice@bstein.dev", timeout_sec=1.0) is True + +def test_mailu_mailbox_exists_empty_email(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_sync_url="", + mailu_sync_wait_timeout_sec=10.0, + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + svc = MailuService() + assert svc.mailbox_exists("") is False diff --git a/tests/unit/services/test_nextcloud_service.py b/tests/unit/services/test_nextcloud_service.py new file mode 100644 index 0000000..40e000e --- /dev/null +++ b/tests/unit/services/test_nextcloud_service.py @@ -0,0 +1,99 @@ +from tests.unit.services.service_helpers import * + + +def test_nextcloud_sync_mail_no_user(monkeypatch) -> None: + dummy = types.SimpleNamespace( + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + nextcloud_mail_sync_wait_timeout_sec=90.0, + nextcloud_mail_sync_job_ttl_sec=3600, + nextcloud_pod_label="app=nextcloud", + nextcloud_container="nextcloud", + nextcloud_exec_timeout_sec=30.0, + nextcloud_db_host="", + nextcloud_db_port=5432, + nextcloud_db_name="nextcloud", + nextcloud_db_user="nextcloud", + nextcloud_db_password="", + mailu_domain="bstein.dev", + mailu_host="mail.bstein.dev", + ) + monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) + monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.ready", lambda: True) + monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.find_user", lambda *_args, **_kwargs: None) + + svc = NextcloudService() + result = svc.sync_mail("alice", wait=True) + assert result["status"] == "ok" + +def test_nextcloud_missing_config(monkeypatch) -> None: + dummy = types.SimpleNamespace( + nextcloud_namespace="", + nextcloud_mail_sync_cronjob="", + nextcloud_mail_sync_wait_timeout_sec=90.0, + nextcloud_mail_sync_job_ttl_sec=3600, + nextcloud_pod_label="app=nextcloud", + nextcloud_container="nextcloud", + nextcloud_exec_timeout_sec=30.0, + nextcloud_db_host="", + nextcloud_db_port=5432, + nextcloud_db_name="nextcloud", + nextcloud_db_user="nextcloud", + nextcloud_db_password="", + mailu_domain="bstein.dev", + mailu_host="mail.bstein.dev", + ) + monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) + svc = NextcloudService() + with pytest.raises(RuntimeError): + svc.sync_mail("alice") + +def test_nextcloud_sync_missing_username(monkeypatch) -> None: + dummy = types.SimpleNamespace( + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + nextcloud_mail_sync_wait_timeout_sec=90.0, + nextcloud_mail_sync_job_ttl_sec=3600, + nextcloud_pod_label="app=nextcloud", + nextcloud_container="nextcloud", + nextcloud_exec_timeout_sec=30.0, + nextcloud_db_host="", + nextcloud_db_port=5432, + nextcloud_db_name="nextcloud", + nextcloud_db_user="nextcloud", + nextcloud_db_password="", + mailu_domain="bstein.dev", + mailu_host="mail.bstein.dev", + ) + monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) + monkeypatch.setattr("ariadne.services.nextcloud.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = NextcloudService() + with pytest.raises(RuntimeError): + svc.sync_mail(" ", wait=True) + +def test_nextcloud_sync_no_match(monkeypatch) -> None: + dummy = types.SimpleNamespace( + nextcloud_namespace="nextcloud", + nextcloud_mail_sync_cronjob="nextcloud-mail-sync", + nextcloud_mail_sync_wait_timeout_sec=90.0, + nextcloud_mail_sync_job_ttl_sec=3600, + nextcloud_pod_label="app=nextcloud", + nextcloud_container="nextcloud", + nextcloud_exec_timeout_sec=30.0, + nextcloud_db_host="", + nextcloud_db_port=5432, + nextcloud_db_name="nextcloud", + nextcloud_db_user="nextcloud", + nextcloud_db_password="", + mailu_domain="bstein.dev", + mailu_host="mail.bstein.dev", + ) + monkeypatch.setattr("ariadne.services.nextcloud.settings", dummy) + monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.ready", lambda: True) + monkeypatch.setattr("ariadne.services.nextcloud.keycloak_admin.find_user", lambda *_args, **_kwargs: None) + monkeypatch.setattr("ariadne.services.nextcloud.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = NextcloudService() + result = svc.sync_mail("alice", wait=False) + assert result["status"] == "ok" diff --git a/tests/unit/services/test_vaultwarden_service.py b/tests/unit/services/test_vaultwarden_service.py new file mode 100644 index 0000000..dd60546 --- /dev/null +++ b/tests/unit/services/test_vaultwarden_service.py @@ -0,0 +1,355 @@ +from tests.unit.services.service_helpers import * + + +def test_vaultwarden_invite_uses_admin_session(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + client = DummyVaultwardenClient() + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: "127.0.0.1"), + ) + + svc = VaultwardenService() + result = svc.invite_user("alice@bstein.dev") + + assert result.ok is True + assert any(call[0] == "/admin" for call in client.calls) + assert any(call[0] == "/admin/invite" for call in client.calls) + +def test_vaultwarden_invite_handles_rate_limit(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + client = DummyVaultwardenClient() + client.responses["/admin/invite"] = DummyResponse(429, "rate limited") + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: "127.0.0.1"), + ) + + svc = VaultwardenService() + result = svc.invite_user("alice@bstein.dev") + assert result.status == "rate_limited" + +def test_vaultwarden_invite_existing_user(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + client = DummyVaultwardenClient() + client.responses["/admin/invite"] = DummyResponse(409, "user already exists") + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: "127.0.0.1"), + ) + + svc = VaultwardenService() + result = svc.invite_user("alice@bstein.dev") + assert result.status == "already_present" + +def test_vaultwarden_invite_rejects_invalid_email() -> None: + svc = VaultwardenService() + result = svc.invite_user("bad-email") + assert result.status == "invalid_email" + +def test_vaultwarden_invite_rate_limited_short_circuit() -> None: + svc = VaultwardenService() + svc._rate_limited_until = time.time() + 60 + result = svc.invite_user("alice@bstein.dev") + assert result.status == "rate_limited" + +def test_vaultwarden_lookup_user_present(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + client = DummyVaultwardenClient() + client.responses["/admin/users"] = DummyResponse(200, "", json_data=[{"email": "alice@bstein.dev"}]) + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: "127.0.0.1"), + ) + + svc = VaultwardenService() + result = svc.find_user_by_email("alice@bstein.dev") + assert result.status == "present" + +def test_vaultwarden_lookup_user_missing(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + client = DummyVaultwardenClient() + client.responses["/admin/users"] = DummyResponse(200, "", json_data=[{"email": "bob@bstein.dev"}]) + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: "127.0.0.1"), + ) + + svc = VaultwardenService() + result = svc.find_user_by_email("alice@bstein.dev") + assert result.status == "missing" + +def test_vaultwarden_invite_handles_admin_exception(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))), + ) + + svc = VaultwardenService() + monkeypatch.setattr( + svc, + "_admin_session", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("rate limited")), + ) + result = svc.invite_user("alice@bstein.dev") + assert result.status == "rate_limited" + +def test_vaultwarden_invite_handles_bad_body(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + + class BadTextResponse: + def __init__(self, status_code=500): + self.status_code = status_code + + def raise_for_status(self): + return None + + @property + def text(self): + raise RuntimeError("boom") + + class BadTextClient(DummyVaultwardenClient): + def post(self, path, json=None, data=None): + self.calls.append((path, json, data)) + return BadTextResponse(500) + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: BadTextClient()) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: "127.0.0.1"), + ) + + svc = VaultwardenService() + result = svc.invite_user("alice@bstein.dev") + assert result.status == "error" + +def test_vaultwarden_invite_handles_fallback_skip(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr( + "ariadne.services.vaultwarden.VaultwardenService._find_pod_ip", + staticmethod(lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))), + ) + + svc = VaultwardenService() + monkeypatch.setattr(svc, "_admin_session", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("nope"))) + result = svc.invite_user("alice@bstein.dev") + assert result.status == "error" + +def test_vaultwarden_find_pod_ip(monkeypatch) -> None: + monkeypatch.setattr( + "ariadne.services.vaultwarden.get_json", + lambda *args, **kwargs: { + "items": [ + { + "status": { + "phase": "Running", + "podIP": "10.0.0.1", + "conditions": [{"type": "Ready", "status": "True"}], + } + } + ] + }, + ) + + assert VaultwardenService._find_pod_ip("ns", "app=vaultwarden") == "10.0.0.1" + +def test_vaultwarden_find_pod_ip_skips_missing_ip(monkeypatch) -> None: + monkeypatch.setattr( + "ariadne.services.vaultwarden.get_json", + lambda *args, **kwargs: { + "items": [ + {"status": {"phase": "Running", "podIP": ""}}, + {"status": {"phase": "Running", "podIP": "10.0.0.2", "conditions": []}}, + ] + }, + ) + assert VaultwardenService._find_pod_ip("ns", "app=vaultwarden") == "10.0.0.2" + +def test_vaultwarden_find_pod_ip_conditions_default_ready(monkeypatch) -> None: + monkeypatch.setattr( + "ariadne.services.vaultwarden.get_json", + lambda *args, **kwargs: { + "items": [ + {"status": {"phase": "Running", "podIP": "10.0.0.3", "conditions": ["bad"]}}, + ] + }, + ) + assert VaultwardenService._find_pod_ip("ns", "app=vaultwarden") == "10.0.0.3" + +def test_vaultwarden_find_pod_ip_no_pods(monkeypatch) -> None: + monkeypatch.setattr("ariadne.services.vaultwarden.get_json", lambda *args, **kwargs: {"items": []}) + with pytest.raises(RuntimeError): + VaultwardenService._find_pod_ip("ns", "app=vaultwarden") + +def test_vaultwarden_find_pod_ip_missing_ip(monkeypatch) -> None: + monkeypatch.setattr( + "ariadne.services.vaultwarden.get_json", + lambda *args, **kwargs: { + "items": [ + {"status": {"phase": "Pending", "conditions": ["bad"]}}, + ] + }, + ) + with pytest.raises(RuntimeError): + VaultwardenService._find_pod_ip("ns", "app=vaultwarden") + +def test_vaultwarden_admin_session_rate_limit(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=1, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + client = DummyVaultwardenClient() + client.responses["/admin"] = DummyResponse(429, "") + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: client) + + svc = VaultwardenService() + with pytest.raises(RuntimeError): + svc._admin_session("http://vaultwarden") + +def test_vaultwarden_admin_session_reuses_client() -> None: + svc = VaultwardenService() + svc._admin_client = DummyVaultwardenClient() + svc._admin_session_expires_at = time.time() + 60 + svc._admin_session_base_url = "http://vaultwarden" + + client = svc._admin_session("http://vaultwarden") + assert client is svc._admin_client + +def test_vaultwarden_admin_session_rate_limited_until() -> None: + svc = VaultwardenService() + svc._rate_limited_until = time.time() + 60 + with pytest.raises(RuntimeError): + svc._admin_session("http://vaultwarden") + +def test_vaultwarden_admin_session_closes_existing(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + vaultwarden_namespace="vaultwarden", + vaultwarden_admin_secret_name="vaultwarden-admin", + vaultwarden_admin_secret_key="ADMIN_TOKEN", + vaultwarden_admin_rate_limit_backoff_sec=600, + vaultwarden_admin_session_ttl_sec=900, + vaultwarden_service_host="vaultwarden-service.vaultwarden.svc.cluster.local", + vaultwarden_pod_label="app=vaultwarden", + vaultwarden_pod_port=80, + ) + + class CloseFail: + def close(self): + raise RuntimeError("boom") + + monkeypatch.setattr("ariadne.services.vaultwarden.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.vaultwarden.get_secret_value", lambda *args, **kwargs: "token") + monkeypatch.setattr("ariadne.services.vaultwarden.httpx.Client", lambda *args, **kwargs: DummyVaultwardenClient()) + + svc = VaultwardenService() + svc._admin_client = CloseFail() + svc._admin_session_expires_at = time.time() - 10 + svc._admin_session_base_url = "http://old" + + assert svc._admin_session("http://vaultwarden") is not None diff --git a/tests/unit/services/test_wger_service.py b/tests/unit/services/test_wger_service.py new file mode 100644 index 0000000..44bea6f --- /dev/null +++ b/tests/unit/services/test_wger_service.py @@ -0,0 +1,307 @@ +from tests.unit.services.service_helpers import * + + +def test_wger_sync_user_exec(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_cronjob="wger-user-sync", + wger_admin_cronjob="wger-admin-ensure", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + + calls: list[dict[str, str]] = [] + + class DummyExecutor: + def exec(self, _cmd, env=None, timeout_sec=None, check=True): + calls.append(env or {}) + return types.SimpleNamespace(stdout="ok", stderr="", exit_code=0, ok=True) + + monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = WgerService() + result = svc.sync_user("alice", "alice@bstein.dev", "pw", wait=True) + assert result["status"] == "ok" + assert calls[0]["WGER_USERNAME"] == "alice" + +def test_wger_ensure_admin_exec(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_cronjob="wger-user-sync", + wger_admin_cronjob="wger-admin-ensure", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + + calls: list[dict[str, str]] = [] + + class DummyExecutor: + def exec(self, _cmd, env=None, timeout_sec=None, check=True): + calls.append(env or {}) + return types.SimpleNamespace(stdout="ok", stderr="", exit_code=0, ok=True) + + monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = WgerService() + result = svc.ensure_admin(wait=False) + assert result["status"] == "ok" + assert calls[0]["WGER_ADMIN_USERNAME"] == "admin" + +def test_wger_sync_users(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def iter_users(self, page_size=200, brief=False): + return [{"id": "1", "username": "alice", "attributes": {}}] + + def get_user(self, user_id: str): + return {"id": user_id, "username": "alice", "attributes": {}} + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.wger.keycloak_admin", DummyAdmin()) + monkeypatch.setattr( + "ariadne.services.wger.mailu.resolve_mailu_email", + lambda *_args, **_kwargs: "alice@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.random_password", lambda *_args: "pw") + + def fake_sync_user(self, *_args, **_kwargs): + return {"status": "ok", "detail": "ok"} + + monkeypatch.setattr(WgerService, "sync_user", fake_sync_user) + + svc = WgerService() + result = svc.sync_users() + assert result["status"] == "ok" + assert any(key == "wger_password" for _user, key, _value in calls) + assert any(key == "wger_password_updated_at" for _user, key, _value in calls) + +def test_wger_check_rotation_marks_rotated(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def find_user(self, username: str): + return {"id": "1", "username": username, "attributes": {}} + + def get_user(self, user_id: str): + return { + "id": user_id, + "username": "alice", + "attributes": { + "mailu_email": ["alice@bstein.dev"], + "wger_password": ["pw"], + }, + } + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.wger.keycloak_admin", DummyAdmin()) + + svc = WgerService() + monkeypatch.setattr(svc, "check_password", lambda *_args, **_kwargs: {"status": "mismatch"}) + + result = svc.check_rotation_for_user("alice") + assert result["status"] == "ok" + assert result["rotated"] is True + assert any(key == "wger_password_rotated_at" for _user, key, _value in calls) + +def test_wger_sync_marks_rotated(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + mailu_domain="bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + + calls: list[tuple[str, str, str]] = [] + + class DummyAdmin: + def ready(self) -> bool: + return True + + def iter_users(self, page_size=200, brief=False): + return [ + { + "id": "1", + "username": "alice", + "attributes": { + "mailu_email": ["alice@bstein.dev"], + "wger_password": ["pw"], + "wger_password_updated_at": ["2025-01-01T00:00:00Z"], + }, + } + ] + + def get_user(self, user_id: str): + return { + "id": user_id, + "username": "alice", + "attributes": { + "mailu_email": ["alice@bstein.dev"], + "wger_password": ["pw"], + "wger_password_updated_at": ["2025-01-01T00:00:00Z"], + }, + } + + def set_user_attribute(self, username: str, key: str, value: str) -> None: + calls.append((username, key, value)) + + monkeypatch.setattr("ariadne.services.wger.keycloak_admin", DummyAdmin()) + monkeypatch.setattr( + "ariadne.services.wger.mailu.resolve_mailu_email", + lambda *_args, **_kwargs: "alice@bstein.dev", + ) + + def fake_check(self, *_args, **_kwargs): + return {"status": "mismatch", "detail": "mismatch"} + + monkeypatch.setattr(WgerService, "check_password", fake_check) + monkeypatch.setattr(WgerService, "sync_user", lambda *_args, **_kwargs: {"status": "ok"}) + + svc = WgerService() + result = svc.sync_users() + assert result["status"] == "ok" + assert any(key == "wger_password_rotated_at" for _user, key, _value in calls) + +def test_wger_sync_missing_inputs(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_cronjob="wger-user-sync", + wger_admin_cronjob="wger-admin-ensure", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = WgerService() + with pytest.raises(RuntimeError): + svc.sync_user("", "email", "pw", wait=True) + with pytest.raises(RuntimeError): + svc.sync_user("alice", "email", "", wait=True) + +def test_wger_sync_missing_config(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="", + wger_user_sync_cronjob="", + wger_admin_cronjob="wger-admin-ensure", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + svc = WgerService() + with pytest.raises(RuntimeError): + svc.sync_user("alice", "email", "pw", wait=True) + +def test_wger_ensure_admin(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_cronjob="wger-user-sync", + wger_admin_cronjob="wger-admin-ensure", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = WgerService() + result = svc.ensure_admin(wait=True) + assert result["status"] == "ok" + +def test_wger_ensure_admin_missing_creds(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="health", + wger_user_sync_cronjob="wger-user-sync", + wger_admin_cronjob="wger-admin-ensure", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="", + wger_admin_password="", + wger_admin_email="", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + + svc = WgerService() + result = svc.ensure_admin(wait=True) + assert result["status"] == "error" + +def test_wger_ensure_admin_missing_config(monkeypatch) -> None: + dummy = types.SimpleNamespace( + wger_namespace="", + wger_user_sync_cronjob="wger-user-sync", + wger_admin_cronjob="", + wger_user_sync_wait_timeout_sec=60.0, + wger_pod_label="app=wger", + wger_container="wger", + wger_admin_username="admin", + wger_admin_password="pw", + wger_admin_email="admin@bstein.dev", + ) + monkeypatch.setattr("ariadne.services.wger.settings", dummy) + monkeypatch.setattr("ariadne.services.wger.PodExecutor", lambda *_args, **_kwargs: DummyExecutor()) + svc = WgerService() + with pytest.raises(RuntimeError): + svc.ensure_admin(wait=True)