feat(files): surface configured handbook tasks

This commit is contained in:
2026-07-21 18:52:39 +02:00
parent 1444ba80a0
commit 4722161592
6 changed files with 889 additions and 114 deletions

View File

@@ -56,6 +56,14 @@ The main domain objects are:
The Files page is available at `/files`. Actions appear only when the current The Files page is available at `/files`. Actions appear only when the current
principal has the required permission and resource access. principal has the required permission and resource access.
The configured Help Center projects these sections as independently authorized
tasks, so upload, ZIP import, organization, download, sharing, and deletion do
not disappear merely because an unrelated permission is absent. It states the
deployment's actual upload limits. External import appears as a task only when
the actor has the required permissions and at least one actor-visible profile
is eligible for the current fail-closed browse/import path; endpoint, path, and
item policy are still enforced when the operation runs.
### Choose a space ### Choose a space
Every file user sees **My files**. Group spaces are added for active groups of Every file user sees **My files**. Group spaces are added for active groups of

View File

@@ -0,0 +1,421 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_ACCESS,
CampaignAccessProvider,
)
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationContext,
DocumentationLink,
DocumentationTopic,
)
from govoplan_files.backend.storage.archives import ZIP_UPLOAD_MAX_FILES
from govoplan_files.backend.storage.connector_visibility import (
connector_profile_usable_for_import,
visible_connector_profiles_for_actor,
)
_DEFAULT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024
_DEFAULT_ZIP_MAX_BYTES = 250 * 1024 * 1024
_FILES_READ_SCOPE = "files:file:read"
_FILES_UPLOAD_SCOPE = "files:file:upload"
def documentation_topics(
context: DocumentationContext,
) -> tuple[DocumentationTopic, ...]:
if context.documentation_type != "user":
return ()
upload_limit = _configured_positive_int(
context.settings,
"file_upload_max_bytes",
default=_DEFAULT_UPLOAD_MAX_BYTES,
)
zip_limit = _configured_positive_int(
context.settings,
"file_upload_zip_max_bytes",
default=_DEFAULT_ZIP_MAX_BYTES,
)
topics: list[DocumentationTopic] = []
if upload_limit is not None:
topics.append(_upload_topic(upload_limit))
if upload_limit is not None and zip_limit is not None:
topics.append(_zip_topic(upload_limit, zip_limit))
topics.append(_connector_import_topic(context))
return tuple(topics)
def _upload_topic(max_bytes: int) -> DocumentationTopic:
limit = _format_byte_limit(max_bytes)
return DocumentationTopic(
id="files.workflow.upload-managed-files",
title="Upload managed files",
summary=f"Upload files to a personal or accessible group space; each uploaded file may contain at most {limit}.",
body=(
f"The current deployment accepts at most {limit} for each ordinary upload. "
"A name conflict is never resolved silently: reject stops the upload, rename chooses a copy name, and overwrite retires the old asset before creating a new one."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"),
order=39,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(
label="Files handbook",
href="govoplan-files/docs/FILES_HANDBOOK.md",
kind="repository",
),
),
related_modules=("campaigns",),
unlocks=(
"Users and connected processes can place governed content in managed storage.",
),
source_module_id="files",
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [
"You may view and upload managed files.",
"You can access the destination personal or group space.",
],
"steps": [
"Open Files and choose My files or an accessible group space.",
"Open the intended destination folder and choose Upload, or drag files into the file list.",
"For every name conflict, explicitly reject, rename, overwrite, or skip the affected item.",
"Wait for the upload to finish, then open the resulting file details.",
],
"outcome": "Each accepted file is stored as a governed managed asset in the selected space.",
"verification": "Confirm the owner, logical path, size, checksum, and current version in Files.",
"constraints": [
{
"id": "ordinary-upload-size",
"label": "Maximum size per file",
"description": f"The current safe upload limit is {limit} per file.",
"values": [limit],
},
],
"related_topic_ids": [
"files.workflow.upload-and-unpack-zip",
"files.workflow.organize-managed-files",
"files.workflow.find-and-download-files",
],
},
)
def _zip_topic(max_file_bytes: int, max_zip_bytes: int) -> DocumentationTopic:
member_limit = _format_byte_limit(max_file_bytes)
archive_limit = _format_byte_limit(max_zip_bytes)
return DocumentationTopic(
id="files.workflow.upload-and-unpack-zip",
title="Upload and unpack a ZIP archive",
summary=(
f"Safely unpack up to {ZIP_UPLOAD_MAX_FILES:,} files from a ZIP whose request and extracted total are each limited to {archive_limit}."
),
body=(
f"The current deployment limits the ZIP request and actual extracted total to {archive_limit}, each member to {member_limit}, "
f"and the archive to {ZIP_UPLOAD_MAX_FILES:,} non-directory members. Encrypted archives and unsafe member paths are rejected. "
"Actual extracted bytes are counted instead of trusting ZIP headers."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"),
order=40,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(
label="Files handbook",
href="govoplan-files/docs/FILES_HANDBOOK.md",
kind="repository",
),
),
unlocks=(
"A bounded archive can become governed managed files without trusting archive paths or size declarations.",
),
source_module_id="files",
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [
"You may view and upload managed files.",
"The archive is not encrypted and fits the current configured limits.",
],
"steps": [
"Open Files and choose the managed destination space and folder.",
"Enable Unpack ZIP uploads, then choose or drag the ZIP archive.",
"Resolve every destination conflict explicitly.",
"Wait for extraction and finalization to finish before leaving the page.",
],
"outcome": "Accepted archive members are stored as separate governed managed assets below the selected folder.",
"verification": "Confirm the expected member paths and inspect representative file sizes, checksums, and versions.",
"constraints": [
{
"id": "zip-request-and-total",
"label": "Maximum ZIP request and extracted total",
"description": f"Both the compressed request and the actual extracted total are limited to {archive_limit}.",
"values": [archive_limit],
},
{
"id": "zip-member-size",
"label": "Maximum extracted member size",
"description": f"Each extracted file is limited to {member_limit}.",
"values": [member_limit],
},
{
"id": "zip-member-count",
"label": "Maximum file count",
"description": f"A ZIP may contain at most {ZIP_UPLOAD_MAX_FILES:,} non-directory members.",
"values": [f"{ZIP_UPLOAD_MAX_FILES:,} files"],
},
],
"related_topic_ids": [
"files.workflow.upload-managed-files",
"files.workflow.organize-managed-files",
"files.workflow.find-and-download-files",
],
},
)
def _connector_import_topic(context: DocumentationContext) -> DocumentationTopic:
principal = context.principal
if not _has_all_scopes(principal, (_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE)):
return _connector_import_limitation(
"Connector import is not available to this account because both permission to view Files and permission to upload managed files are required."
)
tenant_id = _safe_text_attribute(principal, "tenant_id")
user_id = _safe_text_attribute(getattr(principal, "user", None), "id")
session = context.session
if not tenant_id or not user_id or not isinstance(session, Session):
return _connector_import_limitation(
"Connector import availability could not be safely evaluated for this request. Try again, or ask a Files administrator to verify an actor-visible connection."
)
try:
group_ids = _principal_group_ids(principal)
profiles = visible_connector_profiles_for_actor(
session,
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
settings=context.settings,
campaign_visible=_campaign_visibility(
context,
session=session,
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
),
include_effective_policy=True,
)
usable_profiles = tuple(
profile
for profile in profiles
if connector_profile_usable_for_import(profile)
)
except Exception:
return _connector_import_limitation(
"Connector import availability could not be safely evaluated for this request. Try again, or ask a Files administrator to verify an actor-visible connection."
)
if not usable_profiles:
return _connector_import_limitation(
"No connection visible to this account is currently eligible to offer an enabled, credential-ready, policy-allowed browse/import path through a pinning-safe provider. Ask a Files administrator to configure or authorize one."
)
return DocumentationTopic(
id="files.workflow.import-managed-snapshot",
title="Import an external file as a governed snapshot",
summary="Browse an authorized connection read-only and import one selected file into managed storage as a frozen, traceable snapshot.",
body=(
"At least one connection visible to this account is currently eligible to offer the governed browse/import path. "
"The selected remote path and item are re-authorized when used, and browse, import, and sync never mutate the remote source. "
"The managed snapshot stays unchanged until an explicit manual sync."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "campaign_manager", "report_author"),
order=41,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(
label="Files handbook",
href="govoplan-files/docs/FILES_HANDBOOK.md",
kind="repository",
),
),
related_modules=("campaigns",),
unlocks=(
"Campaigns, reports, and workflows can consume a managed snapshot with stable source evidence.",
),
source_module_id="files",
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list", "files.connector-import"],
"prerequisites": [
"You may view and upload managed files.",
"At least one enabled, credential-ready, policy-allowed connection using a pinning-safe provider is visible to this account.",
"The selected remote path and item must pass their operation-time policy checks.",
],
"steps": [
"Open Files and choose a managed destination space.",
"Choose Sync from connection, select an available connection, and browse to the permitted remote file.",
"Import the selected file and resolve any destination conflict explicitly.",
"Review the managed file's source and current-version details before using it in another task.",
],
"current_configuration": [
"At least one actor-visible connection is eligible to offer a safe browse/import operation; the selected endpoint, path, and item are still checked when used.",
"Every selected remote path and item is re-authorized at operation time.",
],
"outcome": "The external content is a tenant-managed snapshot with a checksum, exact version, and recorded source context.",
"verification": "Reopen the managed file and confirm its recorded source context, source revision when available, checksum, and current version.",
"related_topic_ids": [
"files.governed-connectors-and-provenance",
"files.reference.integrity-recovery-and-fail-closed-transports",
"files.reference.snapshot-provenance-and-capabilities",
],
},
)
def _connector_import_limitation(message: str) -> DocumentationTopic:
return DocumentationTopic(
id="files.connector-import-unavailable",
title="External file import is not currently available",
summary=message,
body=(
f"{message} Files never exposes connection endpoints, storage paths, credential references, or raw connector policies in user documentation."
),
layer="available",
documentation_types=("user",),
order=41,
conditions=(
DocumentationCondition(
required_modules=("files",),
any_scopes=(_FILES_READ_SCOPE, _FILES_UPLOAD_SCOPE),
),
),
links=(DocumentationLink(label="Files", href="/files", kind="runtime"),),
source_module_id="files",
metadata={
"kind": "reference",
"screen": "Files",
"help_contexts": ["files.list", "files.connector-import"],
"limitations": [message],
},
)
def _configured_positive_int(
settings: object | None, name: str, *, default: int
) -> int | None:
raw_value = getattr(settings, name, default) if settings is not None else default
try:
value = int(raw_value)
except (TypeError, ValueError):
return None
return value if value > 0 else None
def _format_byte_limit(value: int) -> str:
units = ((1024**3, "GiB"), (1024**2, "MiB"), (1024, "KiB"))
for divisor, label in units:
if value % divisor == 0:
return f"{value // divisor:,} {label} ({value:,} bytes)"
return f"{value:,} bytes"
def _has_all_scopes(principal: object | None, scopes: tuple[str, ...]) -> bool:
checker = getattr(principal, "has", None)
if callable(checker):
try:
return all(bool(checker(scope)) for scope in scopes)
except Exception:
return False
granted = {str(scope) for scope in getattr(principal, "scopes", ())}
return all(scope in granted for scope in scopes)
def _safe_text_attribute(value: object | None, name: str) -> str:
try:
result = getattr(value, name, "")
except Exception:
return ""
return str(result or "")
def _principal_group_ids(principal: object) -> tuple[str, ...]:
try:
values = getattr(principal, "group_ids", ())
except Exception:
return ()
return tuple(str(value) for value in values if str(value))
def _campaign_visibility(
context: DocumentationContext,
*,
session: Session,
tenant_id: str,
user_id: str,
group_ids: tuple[str, ...],
):
principal = context.principal
registry = context.registry
if not _has_all_scopes(principal, ("campaigns:campaign:read",)):
return None
try:
if not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
except Exception:
return None
if not isinstance(capability, CampaignAccessProvider):
return None
def campaign_visible(campaign_id: str) -> bool:
try:
return capability.campaign_exists(
session, tenant_id=tenant_id, campaign_id=campaign_id
) and capability.can_read_campaign(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
user_id=user_id,
group_ids=group_ids,
tenant_admin=_has_all_scopes(principal, ("tenant:*",)),
)
except Exception:
return False
return campaign_visible

View File

@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_files.backend.change_tracking import register_files_change_tracking from govoplan_files.backend.change_tracking import register_files_change_tracking
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
from govoplan_files.backend.documentation import documentation_topics
register_files_change_tracking() register_files_change_tracking()
@@ -195,114 +196,196 @@ manifest = ModuleManifest(
), ),
documentation=( documentation=(
DocumentationTopic( DocumentationTopic(
id="files.workflow.upload-organize-and-share", id="files.workflow.organize-managed-files",
title="Upload, organize, and share managed files", title="Organize managed files and folders",
summary="Put files in a personal or group space, organize them with explicit conflict handling, and grant or update access through a supported integration or API client.", summary="Create folders and rename, move, or copy accessible managed content with explicit conflict handling.",
body=( body=(
"Work in My files or an accessible group space. Uploads, folders, renames, moves, and copies stay inside governed managed storage and require an explicit choice when a target path already exists. " "Organization stays inside governed personal or group spaces. Moves preserve the asset identity, while copies create new assets and versions that reuse immutable blob bytes. "
"A caller with share permission can grant or update read, write, or manage access for a user, group, tenant, or campaign without changing ownership. The Files page does not yet provide a general share editor, and the API has no share-revocation route; sharing is currently performed by a supporting workflow or API client." "Every target conflict must be rejected, renamed, overwritten, or skipped explicitly."
), ),
layer="configured", layer="configured",
documentation_types=("user",), documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"), audience=("file_user", "file_manager", "process_participant"),
order=39, order=42,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("files",), required_modules=("files",),
required_scopes=( required_scopes=("files:file:read", "files:file:organize"),
"files:file:read",
"files:file:upload",
"files:file:organize",
"files:file:share",
),
), ),
), ),
links=( links=(
DocumentationLink(label="Files", href="/files", kind="runtime"), DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Upload API", href="/api/v1/files/upload", kind="api"),
DocumentationLink(label="Move or copy API", href="/api/v1/files/transfer", kind="api"), DocumentationLink(label="Move or copy API", href="/api/v1/files/transfer", kind="api"),
DocumentationLink(label="Grant or update a share", href="/api/v1/files/{file_id}/shares", kind="api"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
), ),
related_modules=("campaigns",), unlocks=("Managed content can be placed at stable logical paths without bypassing space access.",),
unlocks=("Users and connected processes can exchange governed managed files without changing file ownership.",),
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/files", "route": "/files",
"screen": "Files", "screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [ "prerequisites": [
"Files is installed and you may read, upload, organize, and share managed files.", "You may view and organize managed files.",
"You can access the destination personal or group space.", "You can access every source and destination space used by the operation.",
"A supporting workflow or API client is available when a user, group, tenant, or campaign share must be granted or updated.",
], ],
"steps": [ "steps": [
"Open Files, choose My files or an accessible group space, and open the intended destination folder.", "Open Files and select the personal or group space to organize.",
"Create folders where needed, then upload or drag files into the destination.", "Create the required destination folders or select the files and folders to rename, move, or copy.",
"Resolve every name conflict explicitly by rejecting, renaming, overwriting, or skipping the affected item.", "Choose the destination and resolve each target conflict explicitly.",
"Use rename, move, or copy to place the managed items at their intended logical paths.", "Apply the operation and reopen the destination folder.",
"Review the current version, checksum, owner, and path before granting access.",
"Through the supporting workflow or API, grant or update the required read, write, or manage share; do not promise revocation because no revoke route exists yet.",
"Have the intended recipient verify access, and verify that an unrelated account remains denied.",
], ],
"outcome": "The content is stored as governed managed assets in the intended space and the requested access has been granted or updated without changing ownership.", "outcome": "The selected content has the intended governed owner and logical path.",
"verification": "Reopen the files to confirm owner, logical path, current version, and checksum, then test one intended and one denied access path. A share that must be removed requires an explicit future revocation capability.", "verification": "Confirm each resulting path and owner; for a move, also confirm the old path is gone, and for a copy, confirm the source remains.",
"related_topic_ids": [ "related_topic_ids": [
"files.workflow.import-managed-snapshot", "files.workflow.upload-managed-files",
"files.assurance.process-and-release-readiness", "files.workflow.find-and-download-files",
"files.workflow.delete-managed-files",
],
},
),
DocumentationTopic(
id="files.workflow.find-and-download-files",
title="Find and download managed files",
summary="Search accessible managed content and download a current file version or a ZIP archive of a selection.",
body=(
"Files can be sorted and searched by logical path or name pattern. A download always uses the accessible current version; a multi-file selection can be generated as a temporary ZIP archive. "
"There is no dedicated content-preview service yet."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"),
order=43,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=("files:file:read", "files:file:download"),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
unlocks=("Authorized users can retrieve governed current versions without gaining organization or sharing authority.",),
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": ["You may view and download managed files in the relevant space."],
"steps": [
"Open Files and select the relevant personal or group space.",
"Navigate folders, sort the list, or use a path/name pattern to find the intended content.",
"Review the displayed owner, path, size, checksum, and version details.",
"Download one current file version, or select several files and choose Download ZIP.",
],
"outcome": "The authorized current file bytes or generated archive are downloaded to the local device.",
"verification": "Confirm the downloaded names and, where integrity matters, compare the file bytes with the displayed checksum.",
"related_topic_ids": [
"files.workflow.organize-managed-files",
"files.reference.snapshot-provenance-and-capabilities", "files.reference.snapshot-provenance-and-capabilities",
], ],
}, },
), ),
DocumentationTopic( DocumentationTopic(
id="files.workflow.import-managed-snapshot", id="files.workflow.share-managed-files",
title="Import an external file as a governed snapshot", title="Grant or update access to managed files",
summary="Browse an authorized connection read-only, import one selected file into managed storage, and use the frozen version in another GovOPlaN task.", summary="Grant or update read, write, or manage access through a supporting workflow or API client without changing ownership.",
body=( body=(
"Choose a visible file connection in Files, browse only the permitted remote path, and import the selected content into your personal or group space. " "The Files service can grant or update a share for a user, group, tenant, or campaign. The Files page does not yet provide a general share editor, and the API has no share-revocation route. "
"The managed copy records source provenance and remains unchanged until an explicit manual sync. Browse, import, and sync never mutate the remote source." "Do not use this task when access must later be revoked until that explicit capability is implemented."
), ),
layer="configured", layer="available",
documentation_types=("user",), documentation_types=("user",),
audience=("file_user", "campaign_manager", "report_author"), audience=("file_manager", "process_participant"),
order=40, order=44,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("files",), required_modules=("files",),
required_scopes=("files:file:read", "files:file:upload"), required_scopes=("files:file:read", "files:file:share"),
), ),
), ),
links=( links=(
DocumentationLink(label="Files", href="/files", kind="runtime"), DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Visible connector profiles", href="/api/v1/files/connectors/profiles", kind="api"), DocumentationLink(label="Grant or update a share", href="/api/v1/files/{file_id}/shares", kind="api"),
DocumentationLink(
label="Connector import API",
href="/api/v1/files/connectors/profiles/{profile_id}/import",
kind="api",
),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
), ),
related_modules=("campaigns",), related_modules=("campaigns",),
unlocks=("Campaigns, reports, and workflows can consume a managed snapshot with stable source evidence.",), unlocks=("A supporting process can grant governed file access without changing file ownership.",),
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/files", "route": "/files",
"screen": "Files", "screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [ "prerequisites": [
"Files is installed and you may read and upload files.", "You may view and share the managed file.",
"An administrator has configured a connector profile visible in your current user, group, tenant, or campaign scope.", "A supporting workflow or API client is available; the Files page has no general share editor yet.",
"The process does not require share revocation, because no revocation route exists yet.",
], ],
"steps": [ "steps": [
"Open Files and choose a managed destination space.", "Open Files and verify the file owner, logical path, current version, and checksum.",
"Choose Sync from connection, select a visible profile, and browse to the permitted remote file.", "Through the supporting workflow, identify the intended user, group, tenant, or campaign and choose read, write, or manage access.",
"Import or sync the selected file and resolve any destination conflict explicitly.", "Grant or update the share without changing file ownership.",
"Review the managed file's source and current-version details before using it in another task.", "Have the intended recipient verify access and an unrelated account verify denial.",
], ],
"outcome": "The external content is a tenant-managed snapshot with a checksum, exact version, and recorded source context.", "limitations": [
"verification": "Reopen the managed file and confirm its connector/provider, remote identity or path, source revision when available, checksum, and current version.", "The Files page has no general share editor.",
"Shares can be granted or updated, but not revoked through the current API.",
],
"outcome": "The requested access is granted or updated while the managed asset keeps its owner.",
"verification": "Test one intended and one denied access path. Do not claim that the share can later be revoked.",
"related_topic_ids": [ "related_topic_ids": [
"files.governed-connectors-and-provenance", "files.workflow.find-and-download-files",
"files.reference.integrity-recovery-and-fail-closed-transports", "files.assurance.process-and-release-readiness",
"files.reference.snapshot-provenance-and-capabilities", ],
},
),
DocumentationTopic(
id="files.workflow.delete-managed-files",
title="Delete managed files and folders",
summary="Soft-delete accessible managed files or a folder tree where current policy allows it.",
body=(
"Deletion hides the selected managed assets rather than hard-purging their stored evidence. Folder deletion is recursive by default and includes child folders and files; a non-recursive request fails for a non-empty folder. "
"There is no self-service restore or hard-purge workflow today."
),
layer="configured",
documentation_types=("user",),
audience=("file_user", "file_manager", "process_participant"),
order=45,
conditions=(
DocumentationCondition(
required_modules=("files",),
required_scopes=("files:file:read", "files:file:delete"),
),
),
links=(
DocumentationLink(label="Files", href="/files", kind="runtime"),
DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"),
),
unlocks=("Authorized users can remove obsolete content from active file views while preserving the current soft-delete boundary.",),
metadata={
"kind": "workflow",
"route": "/files",
"screen": "Files",
"help_contexts": ["files.list"],
"prerequisites": [
"You may view and delete the selected managed content.",
"You have reviewed the complete folder tree when deleting recursively.",
],
"steps": [
"Open Files and select the files or folder to delete.",
"Review the selection and, for a folder, all content below it.",
"Confirm the delete action.",
"Refresh or reopen the space and verify that the selected paths are no longer active.",
],
"limitations": [
"Deletion is soft deletion, not a hard purge.",
"There is no self-service restore or hard-purge workflow.",
],
"outcome": "The selected content is hidden from active Files views under the current soft-delete model.",
"verification": "Confirm the deleted paths no longer appear in the active space; do not treat the action as physical erasure.",
"related_topic_ids": [
"files.workflow.organize-managed-files",
"files.assurance.process-and-release-readiness",
], ],
}, },
), ),
@@ -317,7 +400,7 @@ manifest = ModuleManifest(
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
audience=("file_admin", "tenant_admin", "system_admin", "security_auditor"), audience=("file_admin", "tenant_admin", "system_admin", "security_auditor"),
order=41, order=50,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("files",), required_modules=("files",),
@@ -374,7 +457,7 @@ manifest = ModuleManifest(
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
audience=("operator", "system_admin", "security_auditor"), audience=("operator", "system_admin", "security_auditor"),
order=42, order=51,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("files",), required_modules=("files",),
@@ -428,7 +511,7 @@ manifest = ModuleManifest(
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("module_integrator", "file_admin", "campaign_admin", "process_designer"), audience=("module_integrator", "file_admin", "campaign_admin", "process_designer"),
order=43, order=52,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("files",), required_modules=("files",),
@@ -467,7 +550,7 @@ manifest = ModuleManifest(
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("process_owner", "release_manager", "file_admin", "operator", "security_auditor"), audience=("process_owner", "release_manager", "file_admin", "operator", "security_auditor"),
order=44, order=53,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("files",), required_modules=("files",),
@@ -500,6 +583,7 @@ manifest = ModuleManifest(
"kind": "workflow", "kind": "workflow",
"route": "/files", "route": "/files",
"screen": "Files process and release assurance", "screen": "Files process and release assurance",
"help_contexts": ["files.list"],
"prerequisites": [ "prerequisites": [
"A named process owner has defined the intended users, data classification, retention expectations, and integrations.", "A named process owner has defined the intended users, data classification, retention expectations, and integrations.",
"A candidate release is installed on a clean database and an upgrade copy with aligned Python, root package, WebUI package, and module-manifest versions.", "A candidate release is installed on a clean database and an upgrade copy with aligned Python, root package, WebUI package, and module-manifest versions.",
@@ -517,7 +601,12 @@ manifest = ModuleManifest(
"outcome": "The process or release has an explicit approval record tied to aligned versions, representative evidence, known limitations, and owned residual risks.", "outcome": "The process or release has an explicit approval record tied to aligned versions, representative evidence, known limitations, and owned residual risks.",
"verification": "A reviewer can reproduce the recorded allow/deny, integrity, connector, and recovery checks and can trace each unmet requirement to a documented limitation or accepted compensating control.", "verification": "A reviewer can reproduce the recorded allow/deny, integrity, connector, and recovery checks and can trace each unmet requirement to a documented limitation or accepted compensating control.",
"related_topic_ids": [ "related_topic_ids": [
"files.workflow.upload-organize-and-share", "files.workflow.upload-managed-files",
"files.workflow.upload-and-unpack-zip",
"files.workflow.organize-managed-files",
"files.workflow.find-and-download-files",
"files.workflow.share-managed-files",
"files.workflow.delete-managed-files",
"files.workflow.import-managed-snapshot", "files.workflow.import-managed-snapshot",
"files.governed-connectors-and-provenance", "files.governed-connectors-and-provenance",
"files.reference.integrity-recovery-and-fail-closed-transports", "files.reference.integrity-recovery-and-fail-closed-transports",
@@ -526,6 +615,7 @@ manifest = ModuleManifest(
}, },
), ),
), ),
documentation_providers=(documentation_topics,),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="files", module_id="files",
metadata=Base.metadata, metadata=Base.metadata,

View File

@@ -17,6 +17,7 @@ from govoplan_files.backend.storage.paths import filename_from_path, normalize_f
_ZIP_READ_CHUNK_SIZE = 1024 * 1024 _ZIP_READ_CHUNK_SIZE = 1024 * 1024
ZIP_UPLOAD_MAX_FILES = 1000
def _read_zip_member( def _read_zip_member(
@@ -76,7 +77,7 @@ def extract_zip_upload(
conflict_resolutions: Iterable[FileConflictResolution] | None = None, conflict_resolutions: Iterable[FileConflictResolution] | None = None,
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
is_admin: bool = False, is_admin: bool = False,
max_files: int = 1000, max_files: int = ZIP_UPLOAD_MAX_FILES,
max_file_bytes: int = 50 * 1024 * 1024, max_file_bytes: int = 50 * 1024 * 1024,
max_total_bytes: int = 250 * 1024 * 1024, max_total_bytes: int = 250 * 1024 * 1024,
) -> list[UploadedStoredFile]: ) -> list[UploadedStoredFile]:

190
tests/test_documentation.py Normal file
View File

@@ -0,0 +1,190 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext
from govoplan_files.backend.documentation import documentation_topics
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
group_ids = frozenset({"group-1"})
def __init__(self, scopes: set[str]) -> None:
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes
class FilesRuntimeDocumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.session = Session()
def tearDown(self) -> None:
self.session.close()
def context(
self,
scopes: set[str],
*,
settings: object | None = None,
documentation_type: str = "user",
) -> DocumentationContext:
return DocumentationContext(
registry=object(),
principal=_Principal(scopes),
settings=settings,
session=self.session,
documentation_type=documentation_type, # type: ignore[arg-type]
)
def test_upload_tasks_state_exact_current_safe_limits(self) -> None:
settings = SimpleNamespace(
file_upload_max_bytes=7 * 1024 * 1024,
file_upload_zip_max_bytes=19 * 1024 * 1024,
)
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
return_value=[],
):
topics = {
topic.id: topic
for topic in documentation_topics(
self.context(
{"files:file:read", "files:file:upload"}, settings=settings
)
)
}
upload = topics["files.workflow.upload-managed-files"]
self.assertIn("7 MiB (7,340,032 bytes)", upload.body)
self.assertEqual(["files.list"], upload.metadata["help_contexts"])
self.assertEqual(
{"files:file:read", "files:file:upload"},
set(upload.conditions[0].required_scopes),
)
archive = topics["files.workflow.upload-and-unpack-zip"]
self.assertIn("19 MiB (19,922,944 bytes)", archive.body)
self.assertIn("7 MiB (7,340,032 bytes)", archive.body)
self.assertIn("1,000", archive.body)
self.assertIn("Actual extracted bytes are counted", archive.body)
def test_connector_task_requires_authority_and_a_visible_usable_profile(
self,
) -> None:
usable = ConnectorProfile(
id="private-profile-id",
label="Private profile label",
provider="webdav",
scope_type="tenant",
scope_id="tenant-1",
endpoint_url="https://private.example.invalid/secret-root",
base_path="/secret-root",
credential_mode="anonymous",
)
context = self.context({"files:file:read", "files:file:upload"})
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
return_value=[usable],
) as visible:
topics = {topic.id: topic for topic in documentation_topics(context)}
self.assertIn("files.workflow.import-managed-snapshot", topics)
self.assertNotIn("files.connector-import-unavailable", topics)
task = topics["files.workflow.import-managed-snapshot"]
self.assertEqual("configured", task.layer)
self.assertEqual(
["files.list", "files.connector-import"], task.metadata["help_contexts"]
)
self.assertIn("re-authorized", task.body)
self.assertNotIn("private-profile-id", repr(task))
self.assertNotIn("private.example.invalid", repr(task))
self.assertNotIn("secret-root", repr(task))
visible.assert_called_once()
self.assertEqual(("group-1",), tuple(visible.call_args.kwargs["group_ids"]))
without_authority = {
topic.id: topic
for topic in documentation_topics(self.context({"files:file:read"}))
}
self.assertNotIn("files.workflow.import-managed-snapshot", without_authority)
self.assertIn(
"both permission to view Files and permission to upload",
without_authority["files.connector-import-unavailable"].body,
)
def test_connector_limitation_is_precise_but_never_exposes_profile_internals(
self,
) -> None:
blocked = ConnectorProfile(
id="sensitive-profile-id",
label="Sensitive label",
provider="s3",
scope_type="tenant",
scope_id="tenant-1",
endpoint_url="https://internal.example.invalid/private",
base_path="/classified",
credential_mode="basic",
password_value="credential-secret",
)
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
return_value=[blocked],
):
topics = {
topic.id: topic
for topic in documentation_topics(
self.context({"files:file:read", "files:file:upload"})
)
}
self.assertNotIn("files.workflow.import-managed-snapshot", topics)
limitation = topics["files.connector-import-unavailable"]
self.assertEqual("available", limitation.layer)
self.assertIn("pinning-safe provider", limitation.body)
payload = repr(limitation)
for secret in (
"sensitive-profile-id",
"Sensitive label",
"internal.example.invalid",
"classified",
"credential-secret",
):
self.assertNotIn(secret, payload)
def test_connector_evaluation_errors_fail_closed_without_error_details(
self,
) -> None:
with patch(
"govoplan_files.backend.documentation.visible_connector_profiles_for_actor",
side_effect=RuntimeError("private endpoint /root credential-id"),
):
topics = {
topic.id: topic
for topic in documentation_topics(
self.context({"files:file:read", "files:file:upload"})
)
}
self.assertNotIn("files.workflow.import-managed-snapshot", topics)
limitation = topics["files.connector-import-unavailable"]
self.assertIn("could not be safely evaluated", limitation.body)
self.assertNotIn("private endpoint", repr(limitation))
self.assertNotIn("credential-id", repr(limitation))
def test_runtime_provider_only_emits_user_documentation(self) -> None:
self.assertEqual(
(), documentation_topics(self.context(set(), documentation_type="admin"))
)
if __name__ == "__main__":
unittest.main()

View File

@@ -3,14 +3,22 @@ from __future__ import annotations
import unittest import unittest
TOPIC_IDS = { STATIC_TOPIC_IDS = {
"files.workflow.upload-organize-and-share", "files.workflow.organize-managed-files",
"files.workflow.import-managed-snapshot", "files.workflow.find-and-download-files",
"files.workflow.share-managed-files",
"files.workflow.delete-managed-files",
"files.governed-connectors-and-provenance", "files.governed-connectors-and-provenance",
"files.reference.integrity-recovery-and-fail-closed-transports", "files.reference.integrity-recovery-and-fail-closed-transports",
"files.reference.snapshot-provenance-and-capabilities", "files.reference.snapshot-provenance-and-capabilities",
"files.assurance.process-and-release-readiness", "files.assurance.process-and-release-readiness",
} }
RUNTIME_TOPIC_IDS = {
"files.workflow.upload-managed-files",
"files.workflow.upload-and-unpack-zip",
"files.workflow.import-managed-snapshot",
"files.connector-import-unavailable",
}
HANDBOOK_HREF = "govoplan-files/docs/FILES_HANDBOOK.md" HANDBOOK_HREF = "govoplan-files/docs/FILES_HANDBOOK.md"
@@ -19,64 +27,98 @@ class FilesManifestDocumentationTests(unittest.TestCase):
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
from govoplan_files.backend.manifest import manifest from govoplan_files.backend.manifest import manifest
cls.manifest = manifest
cls.topics = {topic.id: topic for topic in manifest.documentation} cls.topics = {topic.id: topic for topic in manifest.documentation}
def topic(self, topic_id: str): def topic(self, topic_id: str):
return self.topics[topic_id] return self.topics[topic_id]
def test_adaptive_topics_have_role_scope_module_and_link_contracts(self) -> None: def test_static_topics_have_role_scope_module_and_link_contracts(self) -> None:
self.assertEqual(TOPIC_IDS, set(self.topics)) self.assertEqual(STATIC_TOPIC_IDS, set(self.topics))
self.assertEqual({"admin", "user"}, {item for topic in self.topics.values() for item in topic.documentation_types}) self.assertEqual(
{"admin", "user"},
{
item
for topic in self.topics.values()
for item in topic.documentation_types
},
)
for topic in self.topics.values(): for topic in self.topics.values():
with self.subTest(topic=topic.id): with self.subTest(topic=topic.id):
self.assertTrue(topic.audience) self.assertTrue(topic.audience)
self.assertTrue(topic.conditions) self.assertTrue(topic.conditions)
self.assertTrue(any("files" in condition.required_modules for condition in topic.conditions)) self.assertTrue(
self.assertTrue(any(condition.any_scopes or condition.required_scopes for condition in topic.conditions)) any(
"files" in condition.required_modules
for condition in topic.conditions
)
)
self.assertTrue(
any(
condition.any_scopes or condition.required_scopes
for condition in topic.conditions
)
)
self.assertIn("runtime", {link.kind for link in topic.links}) self.assertIn("runtime", {link.kind for link in topic.links})
self.assertIn("api", {link.kind for link in topic.links}) self.assertIn(
self.assertIn(HANDBOOK_HREF, {link.href for link in topic.links if link.kind == "repository"}) HANDBOOK_HREF,
{link.href for link in topic.links if link.kind == "repository"},
)
def test_import_topic_is_a_complete_user_workflow(self) -> None: self.assertIn(
topic = self.topic("files.workflow.import-managed-snapshot") "documentation_topics",
{provider.__name__ for provider in self.manifest.documentation_providers},
self.assertEqual(("user",), topic.documentation_types)
self.assertEqual("workflow", topic.metadata["kind"])
self.assertEqual("/files", topic.metadata["route"])
self.assertEqual("Files", topic.metadata["screen"])
self.assertEqual(
{"files:file:read", "files:file:upload"},
set(topic.conditions[0].required_scopes),
) )
for key in ("prerequisites", "steps", "outcome", "verification"):
self.assertTrue(topic.metadata[key])
self.assertIn("never mutate the remote source", topic.body)
self.assertIn("/api/v1/files/connectors/profiles/{profile_id}/import", {link.href for link in topic.links})
def test_managed_file_workflow_covers_upload_organization_and_current_share_boundary(self) -> None: def test_user_tasks_are_independently_authorized_and_contextual(self) -> None:
topic = self.topic("files.workflow.upload-organize-and-share") expected_scopes = {
"files.workflow.organize-managed-files": {
self.assertEqual(("user",), topic.documentation_types)
self.assertEqual("workflow", topic.metadata["kind"])
self.assertEqual(
{
"files:file:read", "files:file:read",
"files:file:upload",
"files:file:organize", "files:file:organize",
},
"files.workflow.find-and-download-files": {
"files:file:read",
"files:file:download",
},
"files.workflow.share-managed-files": {
"files:file:read",
"files:file:share", "files:file:share",
}, },
set(topic.conditions[0].required_scopes), "files.workflow.delete-managed-files": {
) "files:file:read",
for key in ("prerequisites", "steps", "outcome", "verification"): "files:file:delete",
self.assertTrue(topic.metadata[key]) },
self.assertIn("does not yet provide a general share editor", topic.body) }
self.assertIn("no share-revocation route", topic.body) for topic_id, scopes in expected_scopes.items():
self.assertIn("/api/v1/files/upload", {link.href for link in topic.links}) with self.subTest(topic=topic_id):
self.assertIn("/api/v1/files/transfer", {link.href for link in topic.links}) topic = self.topic(topic_id)
self.assertIn("/api/v1/files/{file_id}/shares", {link.href for link in topic.links}) self.assertEqual(("user",), topic.documentation_types)
self.assertEqual("workflow", topic.metadata["kind"])
self.assertEqual(scopes, set(topic.conditions[0].required_scopes))
self.assertEqual(["files.list"], topic.metadata["help_contexts"])
for key in ("prerequisites", "steps", "outcome", "verification"):
self.assertTrue(topic.metadata[key])
def test_admin_topic_covers_policy_redaction_and_atomic_credential_deletion(self) -> None: def test_share_and_delete_tasks_state_current_boundaries(self) -> None:
share = self.topic("files.workflow.share-managed-files")
self.assertEqual("available", share.layer)
self.assertIn("does not yet provide a general share editor", share.body)
self.assertIn("no share-revocation route", share.body)
self.assertIn(
"/api/v1/files/{file_id}/shares", {link.href for link in share.links}
)
delete = self.topic("files.workflow.delete-managed-files")
self.assertIn("Soft-delete", delete.summary)
self.assertIn("no self-service restore or hard-purge", delete.body)
self.assertTrue(
any("not a hard purge" in item for item in delete.metadata["limitations"])
)
def test_admin_topic_covers_policy_redaction_and_atomic_credential_deletion(
self,
) -> None:
topic = self.topic("files.governed-connectors-and-provenance") topic = self.topic("files.governed-connectors-and-provenance")
self.assertEqual(("admin",), topic.documentation_types) self.assertEqual(("admin",), topic.documentation_types)
@@ -86,12 +128,24 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn("deny rules win", topic.body) self.assertIn("deny rules win", topic.body)
self.assertIn("redact secret values", topic.body) self.assertIn("redact secret values", topic.body)
self.assertIn("same transaction", topic.body) self.assertIn("same transaction", topic.body)
self.assertTrue(any("non-owned external references" in item for item in topic.metadata["security_invariants"])) self.assertTrue(
self.assertIn("/api/v1/files/connectors/policies/tenant", {link.href for link in topic.links}) any(
self.assertIn("/api/v1/files/connectors/credentials", {link.href for link in topic.links}) "non-owned external references" in item
for item in topic.metadata["security_invariants"]
)
)
self.assertIn(
"/api/v1/files/connectors/policies/tenant",
{link.href for link in topic.links},
)
self.assertIn(
"/api/v1/files/connectors/credentials", {link.href for link in topic.links}
)
def test_operator_topic_covers_recovery_and_fail_closed_s3_smb(self) -> None: def test_operator_topic_covers_recovery_and_fail_closed_s3_smb(self) -> None:
topic = self.topic("files.reference.integrity-recovery-and-fail-closed-transports") topic = self.topic(
"files.reference.integrity-recovery-and-fail-closed-transports"
)
self.assertEqual(("admin",), topic.documentation_types) self.assertEqual(("admin",), topic.documentation_types)
self.assertEqual("reference", topic.metadata["kind"]) self.assertEqual("reference", topic.metadata["kind"])
@@ -102,7 +156,9 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn("does not remove backend blob objects", topic.body) self.assertIn("does not remove backend blob objects", topic.body)
self.assertIn("MASTER_KEY_B64", topic.metadata["recovery_unit"]) self.assertIn("MASTER_KEY_B64", topic.metadata["recovery_unit"])
self.assertTrue(topic.metadata["verification"]) self.assertTrue(topic.metadata["verification"])
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", topic.configuration_keys) self.assertIn(
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS", topic.configuration_keys
)
def test_integrator_topic_exposes_capability_and_provenance_boundary(self) -> None: def test_integrator_topic_exposes_capability_and_provenance_boundary(self) -> None:
topic = self.topic("files.reference.snapshot-provenance-and-capabilities") topic = self.topic("files.reference.snapshot-provenance-and-capabilities")
@@ -117,11 +173,14 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn("exact asset, version, blob, checksum", topic.body) self.assertIn("exact asset, version, blob, checksum", topic.body)
self.assertIn("collaboration", topic.body) self.assertIn("collaboration", topic.body)
def test_process_and_release_assurance_exposes_gates_evidence_and_limits(self) -> None: def test_process_and_release_assurance_exposes_gates_evidence_and_limits(
self,
) -> None:
topic = self.topic("files.assurance.process-and-release-readiness") topic = self.topic("files.assurance.process-and-release-readiness")
self.assertEqual(("admin", "user"), topic.documentation_types) self.assertEqual(("admin", "user"), topic.documentation_types)
self.assertEqual("workflow", topic.metadata["kind"]) self.assertEqual("workflow", topic.metadata["kind"])
self.assertEqual(["files.list"], topic.metadata["help_contexts"])
self.assertIn("process_owner", topic.audience) self.assertIn("process_owner", topic.audience)
self.assertIn("release_manager", topic.audience) self.assertIn("release_manager", topic.audience)
self.assertIn("versions align", topic.body) self.assertIn("versions align", topic.body)
@@ -129,16 +188,22 @@ class FilesManifestDocumentationTests(unittest.TestCase):
self.assertIn("no enforced retention or legal hold", topic.body) self.assertIn("no enforced retention or legal hold", topic.body)
for key in ("prerequisites", "steps", "outcome", "verification"): for key in ("prerequisites", "steps", "outcome", "verification"):
self.assertTrue(topic.metadata[key]) self.assertTrue(topic.metadata[key])
self.assertTrue(any("meta-repository security" in step for step in topic.metadata["steps"])) self.assertTrue(
any("meta-repository security" in step for step in topic.metadata["steps"])
)
self.assertTrue(any("fails closed" in step for step in topic.metadata["steps"])) self.assertTrue(any("fails closed" in step for step in topic.metadata["steps"]))
self.assertTrue(any("SHA-256" in step for step in topic.metadata["steps"])) self.assertTrue(any("SHA-256" in step for step in topic.metadata["steps"]))
self.assertIn(HANDBOOK_HREF, {link.href for link in topic.links if link.kind == "repository"})
def test_internal_related_topic_ids_resolve(self) -> None: def test_internal_related_topic_ids_resolve(self) -> None:
known_ids = STATIC_TOPIC_IDS | RUNTIME_TOPIC_IDS
for topic in self.topics.values(): for topic in self.topics.values():
for related_id in topic.metadata.get("related_topic_ids", []): for related_id in topic.metadata.get("related_topic_ids", []):
if related_id.startswith("files."): if related_id.startswith("files."):
self.assertIn(related_id, TOPIC_IDS, f"{topic.id} refers to missing topic {related_id}") self.assertIn(
related_id,
known_ids,
f"{topic.id} refers to missing topic {related_id}",
)
if __name__ == "__main__": if __name__ == "__main__":