fix(docs): bind user guidance to visible routes
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user