"""WP-230C regression tests: pagination, project detail, dashboard honesty. Reuses the same real-app-with-fakes construction pattern as tests/test_wp230_rbac.py (real PKCE/BFF login against a fake OIDC provider, a real build_app() instance, a throwaway SQLite engine) rather than importing that file's fixtures directly, since pytest only auto-discovers fixtures within the same file (or conftest.py, deliberately not added here to avoid affecting unrelated tests). """ from __future__ import annotations import base64 import os from collections.abc import Iterator from datetime import UTC, datetime, timedelta from http.cookies import SimpleCookie from pathlib import Path from urllib.parse import parse_qs, urlparse import pytest from fastapi import HTTPException from fastapi.testclient import TestClient from services.control_api.auth.config import OIDCSettings from services.control_api.auth.fake_provider import FakeOIDCProvider from services.control_api.auth.models import ManagementRole from services.control_api.contextforge.fake import FakeContextForge from services.control_api.contextforge.models import ContextForgeSettings from services.control_api.persistence import Base, create_engine_from_url, session_factory from services.runtime.control_api import build_app, decode_page_cursor, encode_page_cursor from services.runtime.state_sync import sync_runtime_state ROOT = Path(__file__).resolve().parents[1] ISSUER = "https://fake.oidc.test" @pytest.fixture(scope="module", autouse=True) def _client_bindings_env() -> Iterator[None]: previous = os.environ.get("MCP_CLIENT_BINDINGS_JSON") os.environ["MCP_CLIENT_BINDINGS_JSON"] = "[]" yield if previous is None: os.environ.pop("MCP_CLIENT_BINDINGS_JSON", None) else: os.environ["MCP_CLIENT_BINDINGS_JSON"] = previous @pytest.fixture(scope="module") def db_path(tmp_path_factory: pytest.TempPathFactory) -> Path: path = tmp_path_factory.mktemp("wp230-ui") / "ui.db" engine = create_engine_from_url(f"sqlite:///{path}") Base.metadata.create_all(engine) sessions = session_factory(engine) with sessions() as session, session.begin(): sync_runtime_state(session, ROOT / "catalog", ISSUER) engine.dispose() return path def _login(client: TestClient, provider: FakeOIDCProvider) -> str: login_response = client.get("/auth/login", follow_redirects=False) location = login_response.headers["location"] query = parse_qs(urlparse(location).query) code = provider.authorize(location) callback_response = client.get( "/auth/callback", params={"code": code, "state": query["state"][0]}, follow_redirects=False, ) cookie = SimpleCookie() cookie.load(callback_response.headers["set-cookie"]) cookie_name = next(iter(cookie)) client.cookies.set(cookie_name, cookie[cookie_name].value) session_response = client.get("/auth/session") return str(session_response.json()["csrf_token"]) @pytest.fixture(scope="module") def viewer_client(db_path: Path) -> Iterator[TestClient]: provider = FakeOIDCProvider(issuer=ISSUER, user_groups=("hub-viewer",)) oidc = OIDCSettings( issuer=ISSUER, audience=provider.audience, client_id="hub-bff", redirect_uri="https://hub.test/auth/callback", secure_cookie=True, role_group_map={"hub-viewer": ManagementRole.VIEWER}, ) contextforge_fake = FakeContextForge() os.environ["WP230UI_FAKE_CONTEXTFORGE_TOKEN"] = "fake-token" # noqa: S105 engine = create_engine_from_url(f"sqlite:///{db_path}") app = build_app( oidc=oidc, provider_transport=provider.transport(), contextforge_settings=ContextForgeSettings( base_url="https://contextforge.test", auth_token_env="WP230UI_FAKE_CONTEXTFORGE_TOKEN", # noqa: S106 ), contextforge_transport=contextforge_fake.transport(), engine=engine, catalog_root=ROOT / "catalog", ) client = TestClient(app) _login(client, provider) yield client client.close() # --- Cursor encode/decode: pure functions ------------------------------- def test_cursor_round_trips() -> None: cursor = encode_page_cursor(25) assert decode_page_cursor(cursor) == 25 def test_malformed_cursor_is_rejected_with_400() -> None: with pytest.raises(HTTPException) as excinfo: decode_page_cursor("not-a-real-cursor!!") assert excinfo.value.status_code == 400 def test_negative_offset_cursor_is_rejected() -> None: forged = base64.urlsafe_b64encode(b"-5:9999999999").decode("ascii") with pytest.raises(HTTPException) as excinfo: decode_page_cursor(forged) assert excinfo.value.status_code == 400 def test_expired_cursor_is_rejected_not_silently_reset() -> None: long_ago = int((datetime.now(UTC) - timedelta(hours=2)).timestamp()) forged = base64.urlsafe_b64encode(f"10:{long_ago}".encode()).decode("ascii") with pytest.raises(HTTPException) as excinfo: decode_page_cursor(forged) assert excinfo.value.status_code == 400 assert "expired" in str(excinfo.value.detail) # --- Pagination through a real endpoint ---------------------------------- def test_first_page_has_a_next_cursor_when_more_rows_exist(viewer_client: TestClient) -> None: response = viewer_client.get("/api/tools?limit=5") assert response.status_code == 200 body = response.json() assert len(body["rows"]) == 5 assert body["page"]["total"] > 5 assert body["page"]["nextCursor"] is not None def test_following_next_cursor_returns_a_disjoint_next_page(viewer_client: TestClient) -> None: first = viewer_client.get("/api/tools?limit=5").json() second = viewer_client.get(f"/api/tools?limit=5&cursor={first['page']['nextCursor']}").json() first_ids = {row["id"] for row in first["rows"]} second_ids = {row["id"] for row in second["rows"]} assert first_ids.isdisjoint(second_ids) def test_last_page_has_no_next_cursor(viewer_client: TestClient) -> None: page = viewer_client.get("/api/tools?limit=100").json() total = page["page"]["total"] row_count = len(page["rows"]) while page["page"]["nextCursor"] is not None: page = viewer_client.get(f"/api/tools?limit=100&cursor={page['page']['nextCursor']}").json() row_count += len(page["rows"]) assert page["page"]["nextCursor"] is None assert row_count == total def test_invalid_cursor_query_param_fails_safely_with_400(viewer_client: TestClient) -> None: response = viewer_client.get("/api/tools?cursor=not-valid-base64!!") assert response.status_code == 400 def test_page_total_reflects_full_match_count_not_just_this_page( viewer_client: TestClient, ) -> None: # This was a real, distinct bug: the old code computed "total" *after* # truncating rows to the page size, so it was always equal to the page # length, not the true total -- "N total" in the UI was never accurate # beyond a single page. full = viewer_client.get("/api/tools?limit=100").json() small_page = viewer_client.get("/api/tools?limit=1").json() assert small_page["page"]["total"] == full["page"]["total"] assert len(small_page["rows"]) == 1 # --- Real project detail -------------------------------------------------- def test_project_detail_returns_real_registered_environments(viewer_client: TestClient) -> None: response = viewer_client.get("/api/projects/ludarium") assert response.status_code == 200 body = response.json() assert body["slug"] == "ludarium" environment_names = {item["name"] for item in body["environments"]} assert "prod" in environment_names assert "staging" not in environment_names # ludarium never registered one def test_project_detail_unknown_slug_is_404_not_a_fabricated_default( viewer_client: TestClient, ) -> None: response = viewer_client.get("/api/projects/does-not-exist") assert response.status_code == 404 def test_project_detail_reflects_real_connector_bindings(viewer_client: TestClient) -> None: body = viewer_client.get("/api/projects/ludarium").json() connector_types = {item["connectorType"] for item in body["connectorBindings"]} assert "knowledge" in connector_types # --- Dashboard drift honesty ------------------------------------------------ def test_dashboard_drift_is_measured_against_managed_connector_gateways( viewer_client: TestClient, ) -> None: response = viewer_client.get("/api/dashboard") assert response.status_code == 200 drift = response.json()["drift"] assert drift["known"] is True assert drift["count"] == 9 assert drift["projects"] == [ "itworx-appops", "itworx-gitea", "itworx-knowledge", "itworx-mobilityops", "itworx-opencode", "itworx-opencode-readonly", "itworx-projectbrain", "itworx-stitch", "itworx-unraid", ] assert "connector gateway drift" in drift["detail"].lower() def test_dashboard_distinguishes_registration_from_connector_readiness( viewer_client: TestClient, ) -> None: """A valid registration is not evidence that a connector route is live.""" dashboard = viewer_client.get("/api/dashboard") assert dashboard.status_code == 200 body = dashboard.json() assert body["projects"], "expected at least one registered project in the test catalog" assert body["connectors"], "expected at least one registered connector in the test catalog" assert all(item["health"] == "healthy" for item in body["projects"]) assert all(item["state"] == "unknown" for item in body["connectors"]) assert all(item["registrationState"] == "registered" for item in body["connectors"]) assert all(item["readiness"]["source"] == "not-probed" for item in body["connectors"]) assert all(item["readiness"]["observedAt"] is None for item in body["connectors"]) projects_list = viewer_client.get("/api/projects") assert projects_list.status_code == 200 assert all(row["status"] == "healthy" for row in projects_list.json()["rows"])