feat(files): surface configured handbook tasks
This commit is contained in:
@@ -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
|
||||||
|
|||||||
421
src/govoplan_files/backend/documentation.py
Normal file
421
src/govoplan_files/backend/documentation.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
190
tests/test_documentation.py
Normal 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()
|
||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user