fix(docs): bind user guidance to visible routes

This commit is contained in:
2026-07-21 19:14:58 +02:00
parent e9f3106657
commit 7cdd1e1c78
3 changed files with 57 additions and 10 deletions

View File

@@ -43,16 +43,17 @@ def docs_context(
registry = _registry(request) registry = _registry(request)
resolved_locale = _preferred_locale(request, locale) resolved_locale = _preferred_locale(request, locale)
route_items = _route_items(registry.manifests(), principal) route_items = _route_items(registry.manifests(), principal)
visible_routes = [item for item in route_items if item["visible"]] visible_route_items = [item for item in route_items if item["visible"]]
documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale) documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale)
if documentation_type == "admin": if documentation_type == "admin":
visible_routes = visible_route_items
modules = [_module_payload(manifest, technical=True) for manifest in registry.manifests()] modules = [_module_payload(manifest, technical=True) for manifest in registry.manifests()]
permissions = [_permission_payload(permission, principal) for permission in registry.permissions()] permissions = [_permission_payload(permission, principal) for permission in registry.permissions()]
available_routes = [item for item in route_items if not item["visible"]] available_routes = [item for item in route_items if not item["visible"]]
optional_modules = _optional_module_evidence(registry.manifests()) optional_modules = _optional_module_evidence(registry.manifests())
else: else:
visible_module_ids = { visible_module_ids = {
*(str(item["module_id"]) for item in visible_routes), *(str(item["module_id"]) for item in visible_route_items),
*(str(topic["source_module_id"]) for topic in _all_documentation_topics(documentation_layers)), *(str(topic["source_module_id"]) for topic in _all_documentation_topics(documentation_layers)),
} }
modules = [ modules = [
@@ -61,7 +62,7 @@ def docs_context(
if manifest.id in visible_module_ids if manifest.id in visible_module_ids
] ]
permissions = [] permissions = []
visible_routes = [_user_route_payload(item) for item in visible_routes] visible_routes = _user_route_payloads(visible_route_items)
available_routes = [] available_routes = []
optional_modules = [] optional_modules = []
documentation_count = sum(len(layer) for layer in documentation_layers.values()) documentation_count = sum(len(layer) for layer in documentation_layers.values())
@@ -166,6 +167,18 @@ def _user_route_payload(item: Mapping[str, Any]) -> dict[str, Any]:
} }
def _user_route_payloads(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for item in items:
key = (str(item["module_id"]), str(item["path"]))
if key in seen:
continue
seen.add(key)
result.append(_user_route_payload(item))
return result
def _permission_payload(permission: PermissionDefinition, principal: ApiPrincipal) -> dict[str, Any]: def _permission_payload(permission: PermissionDefinition, principal: ApiPrincipal) -> dict[str, Any]:
return { return {
"scope": permission.scope, "scope": permission.scope,
@@ -305,6 +318,14 @@ def _classify_documentation(
) -> dict[str, list[dict[str, Any]]]: ) -> dict[str, list[dict[str, Any]]]:
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []} layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
installed = {manifest.id for manifest in registry.manifests()} installed = {manifest.id for manifest in registry.manifests()}
visible_runtime_paths = frozenset([
"/settings", # Authenticated shell route, not contributed by a module manifest.
*(
str(item["path"])
for item in _route_items(registry.manifests(), principal)
if item["visible"]
),
]) if documentation_type == "user" else frozenset()
topics = _collect_documentation_topics(registry, principal, settings=settings, session=session, documentation_type=documentation_type, locale=locale) topics = _collect_documentation_topics(registry, principal, settings=settings, session=session, documentation_type=documentation_type, locale=locale)
for source_module_id, topic in sorted(topics, key=lambda item: (item[1].order, item[0], item[1].id)): for source_module_id, topic in sorted(topics, key=lambda item: (item[1].order, item[0], item[1].id)):
if not _topic_matches_documentation_type(topic, documentation_type): if not _topic_matches_documentation_type(topic, documentation_type):
@@ -324,6 +345,7 @@ def _classify_documentation(
blockers=blockers, blockers=blockers,
locale=locale, locale=locale,
documentation_type=documentation_type, documentation_type=documentation_type,
visible_runtime_paths=visible_runtime_paths,
)) ))
return layers return layers
@@ -539,6 +561,7 @@ def _documentation_topic_payload(
blockers: dict[str, list[str]], blockers: dict[str, list[str]],
locale: str, locale: str,
documentation_type: DocumentationType, documentation_type: DocumentationType,
visible_runtime_paths: frozenset[str],
) -> dict[str, Any]: ) -> dict[str, Any]:
module_id = topic.source_module_id or source_module_id module_id = topic.source_module_id or source_module_id
translation_locale, translation = _translation_for_locale(topic, locale) translation_locale, translation = _translation_for_locale(topic, locale)
@@ -598,7 +621,7 @@ def _documentation_topic_payload(
"links": [ "links": [
_documentation_link_payload(link) _documentation_link_payload(link)
for link in topic.links for link in topic.links
if _user_link_allowed(link) if _user_link_allowed(link, visible_runtime_paths=visible_runtime_paths)
], ],
"related_modules": [], "related_modules": [],
"unlocks": list(topic.unlocks), "unlocks": list(topic.unlocks),
@@ -680,16 +703,22 @@ def _bounded_string_list(value: object, *, maximum_items: int, maximum_length: i
] ]
def _user_link_allowed(link: DocumentationLink) -> bool: def _user_link_allowed(link: DocumentationLink, *, visible_runtime_paths: frozenset[str]) -> bool:
href = link.href.strip() href = link.href.strip()
if link.kind == "runtime": if link.kind == "runtime":
if not href.startswith("/") or href.startswith("//") or "\\" in href: if not href.startswith("/") or href.startswith("//") or "\\" in href:
return False return False
try:
parsed = urlsplit(href) parsed = urlsplit(href)
return not parsed.scheme and not parsed.netloc except ValueError:
return False
return not parsed.scheme and not parsed.netloc and parsed.path in visible_runtime_paths
if link.kind != "public": if link.kind != "public":
return False return False
try:
parsed = urlsplit(href) parsed = urlsplit(href)
except ValueError:
return False
return parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password return parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -168,6 +169,11 @@ manifest = ModuleManifest(
audience=("tenant_admin", "access_admin", "operator", "user"), audience=("tenant_admin", "access_admin", "operator", "user"),
related_modules=("organizations", "identity", "idm", "access"), related_modules=("organizations", "identity", "idm", "access"),
order=21, order=21,
conditions=(
DocumentationCondition(
required_modules=("organizations", "identity", "idm", "access"),
),
),
links=( links=(
DocumentationLink(label="Organizations", href="/organizations", kind="runtime"), DocumentationLink(label="Organizations", href="/organizations", kind="runtime"),
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"), DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),

View File

@@ -8,7 +8,7 @@ from unittest.mock import patch
from fastapi import HTTPException from fastapi import HTTPException
from govoplan_access.backend.manifest import get_manifest as get_access_manifest from govoplan_access.backend.manifest import get_manifest as get_access_manifest
from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest, NavItem
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
from govoplan_docs.backend.api.v1.routes import ( from govoplan_docs.backend.api.v1.routes import (
@@ -92,6 +92,7 @@ class DocsContextTests(unittest.TestCase):
id="example", id="example",
name="Example", name="Example",
version="9.9.9", version="9.9.9",
nav_items=(NavItem(path="/example", label="Example", required_any=("docs:documentation:read",)),),
documentation=( documentation=(
DocumentationTopic( DocumentationTopic(
id="example.workflow.safe", id="example.workflow.safe",
@@ -103,7 +104,10 @@ class DocsContextTests(unittest.TestCase):
DocumentationLink(label="Open task", href="/example", kind="runtime"), DocumentationLink(label="Open task", href="/example", kind="runtime"),
DocumentationLink(label="Unsafe runtime", href="javascript:alert(1)", kind="runtime"), DocumentationLink(label="Unsafe runtime", href="javascript:alert(1)", kind="runtime"),
DocumentationLink(label="Backslash runtime", href="/\\evil.invalid", kind="runtime"), DocumentationLink(label="Backslash runtime", href="/\\evil.invalid", kind="runtime"),
DocumentationLink(label="Invisible runtime", href="/hidden", kind="runtime"),
DocumentationLink(label="Shell settings", href="/settings?section=example", kind="runtime"),
DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"), DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"),
DocumentationLink(label="Malformed public", href="https://[", kind="public"),
DocumentationLink(label="Repository", href="example/docs/SECRET.md", kind="repository"), DocumentationLink(label="Repository", href="example/docs/SECRET.md", kind="repository"),
DocumentationLink(label="API", href="/api/v1/example", kind="api"), DocumentationLink(label="API", href="/api/v1/example", kind="api"),
), ),
@@ -137,7 +141,10 @@ class DocsContextTests(unittest.TestCase):
) )
topic = layers["configured"][0] topic = layers["configured"][0]
self.assertEqual([link["href"] for link in topic["links"]], ["/example", "https://example.invalid/help"]) self.assertEqual(
[link["href"] for link in topic["links"]],
["/example", "/settings?section=example", "https://example.invalid/help"],
)
self.assertEqual(topic["conditions"], []) self.assertEqual(topic["conditions"], [])
self.assertEqual(topic["configuration_keys"], []) self.assertEqual(topic["configuration_keys"], [])
self.assertEqual(topic["blockers"], {"modules": [], "capabilities": [], "scopes": []}) self.assertEqual(topic["blockers"], {"modules": [], "capabilities": [], "scopes": []})
@@ -221,6 +228,11 @@ class DocsContextTests(unittest.TestCase):
self.assertEqual(payload["layers"]["configured"]["permissions"], []) self.assertEqual(payload["layers"]["configured"]["permissions"], [])
self.assertEqual(payload["layers"]["available"]["routes"], []) self.assertEqual(payload["layers"]["available"]["routes"], [])
self.assertEqual(payload["layers"]["evidence"]["sources"], []) self.assertEqual(payload["layers"]["evidence"]["sources"], [])
self.assertEqual(
[(item["module_id"], item["path"]) for item in payload["layers"]["configured"]["routes"]],
[("docs", "/docs")],
)
self.assertEqual(payload["summary"]["visible_route_count"], 1)
def test_topic_anchor_is_stable(self) -> None: def test_topic_anchor_is_stable(self) -> None:
self.assertEqual( self.assertEqual(