"""Docs Gatekeeper API tests.""" import re from pathlib import Path import pytest from httpx import ASGITransport, AsyncClient from app.api.v1 import docs as docs_api from app.main import app from app.models.user import User from app.services.docs_gatekeeper import DOCS_METADATA def make_user(role: str = "viewer", groups: list[str] | None = None) -> User: user = User( id=1, username="docs-user", email="docs@example.com", password_hash="x", role=role, is_active=True, ) user.gatekeeper_groups = groups or [] return user async def get_json(path: str, user: User | None = None): if user is not None: async def override_user(): return user app.dependency_overrides[docs_api.get_optional_current_user] = override_user transport = ASGITransport(app=app) try: async with AsyncClient(transport=transport, base_url="http://test") as client: return await client.get(path) finally: app.dependency_overrides.clear() @pytest.mark.asyncio async def test_public_catalog_only_for_anonymous_user(): response = await get_json("/api/v1/docs/catalog") assert response.status_code == 200 items = response.json()["items"] assert {item["access"] for item in items} == {"public"} zh_items = [item for item in items if item["lang"] == "zh"] assert [item["slug"] for item in zh_items] == [ "overview", "manual", "quickstart", "faq", ] @pytest.mark.asyncio async def test_developer_catalog_includes_architecture_and_frontend_reference_docs(): response = await get_json( "/api/v1/docs/catalog", make_user(role="viewer", groups=["docs_developer"]), ) assert response.status_code == 200 zh_slugs = {item["slug"] for item in response.json()["items"] if item["lang"] == "zh"} zh_items = [item for item in response.json()["items"] if item["lang"] == "zh"] assert [item["group"] for item in zh_items[:4]] == ["Overview", "Manual", "Manual", "Manual"] assert zh_items[4]["group"] == "Architecture" assert "platform-data-flows" in zh_slugs assert "naming-glossary" in zh_slugs assert "tactile-ui-components" in zh_slugs @pytest.mark.asyncio async def test_anonymous_can_read_public_doc(): response = await get_json("/api/v1/docs/zh/quickstart") manual_response = await get_json("/api/v1/docs/zh/manual") overview_response = await get_json("/api/v1/docs/zh/overview") assert response.status_code == 200 assert response.json()["access"] == "public" assert "快速开始" in response.json()["markdown"] assert manual_response.status_code == 200 assert manual_response.json()["access"] == "public" assert overview_response.status_code == 200 assert overview_response.json()["access"] == "public" @pytest.mark.asyncio async def test_anonymous_protected_doc_requires_authentication(): response = await get_json("/api/v1/docs/zh/backend-collectors") assert response.status_code == 401 @pytest.mark.asyncio async def test_viewer_without_group_cannot_read_developer_doc(): response = await get_json( "/api/v1/docs/zh/backend-collectors", make_user(role="viewer"), ) assert response.status_code == 403 @pytest.mark.asyncio async def test_developer_group_can_read_developer_but_not_admin_doc(): user = make_user(role="viewer", groups=["docs_developer"]) developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user) tactile_response = await get_json("/api/v1/docs/zh/tactile-ui-components", user) glossary_response = await get_json("/api/v1/docs/zh/naming-glossary", user) admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user) assert developer_response.status_code == 200 assert developer_response.json()["access"] == "docs_developer" assert tactile_response.status_code == 200 assert tactile_response.json()["access"] == "docs_developer" assert glossary_response.status_code == 200 assert glossary_response.json()["access"] == "docs_developer" assert admin_response.status_code == 403 @pytest.mark.asyncio async def test_admin_and_super_admin_can_read_admin_docs(): admin_response = await get_json( "/api/v1/docs/zh/backend-system-service-control", make_user(role="admin"), ) super_admin_response = await get_json( "/api/v1/docs/zh/backend-system-service-control", make_user(role="super_admin"), ) assert admin_response.status_code == 200 assert super_admin_response.status_code == 200 @pytest.mark.asyncio async def test_unknown_language_slug_and_path_traversal_do_not_read_files(): bad_lang = await get_json("/api/v1/docs/fr/quickstart") bad_slug = await get_json("/api/v1/docs/zh/not-a-doc") traversal = await get_json("/api/v1/docs/zh/..%2Fmanual") assert bad_lang.status_code == 404 assert bad_slug.status_code == 404 assert traversal.status_code == 404 def test_public_docs_markdown_links_do_not_create_missing_docs_routes(): repo_root = Path(__file__).resolve().parents[2] technical_root = repo_root / "docs" / "technical" registered_filenames = {entry.filename for entry in DOCS_METADATA} problems: list[str] = [] for markdown_path in sorted(technical_root.glob("*/*.md")): markdown = markdown_path.read_text(encoding="utf-8") for match in re.finditer(r"\[([^\]]+)]\(([^)]+\.md(?:#[^)]+)?)\)", markdown): label, href = match.group(1), match.group(2) if href.startswith(("http://", "https://", "mailto:")): continue href_without_hash = href.split("#", 1)[0].replace("\\", "/") filename = Path(href_without_hash).name if "/docs/technical/" in href_without_hash: if filename not in registered_filenames: problems.append(f"{markdown_path.relative_to(repo_root)} links unregistered public doc {href!r} ({label})") continue if href_without_hash.endswith(".md"): problems.append(f"{markdown_path.relative_to(repo_root)} links non-public markdown {href!r} ({label})") assert problems == []