feat: expose immutable postbox file references
This commit is contained in:
@@ -9,14 +9,18 @@ from sqlalchemy import or_
|
|||||||
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
|
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
|
||||||
from govoplan_core.core.files import FileAccessProvider
|
from govoplan_core.core.files import FileAccessProvider
|
||||||
from govoplan_core.core.files import (
|
from govoplan_core.core.files import (
|
||||||
|
PostboxFileReferenceRef,
|
||||||
|
PostboxFileReferenceRequest,
|
||||||
|
PostboxFileReferenceProvider,
|
||||||
ManagedArtifactRef,
|
ManagedArtifactRef,
|
||||||
ManagedArtifactStore,
|
ManagedArtifactStore,
|
||||||
ManagedArtifactWriteRequest,
|
ManagedArtifactWriteRequest,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||||
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
from govoplan_files.backend.db.models import FileAsset, FileBlob, FileFolder, FileShare, FileVersion
|
||||||
from govoplan_files.backend.runtime import configure_runtime
|
from govoplan_files.backend.runtime import configure_runtime
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
from govoplan_files.backend.storage.campaign_attachments import (
|
from govoplan_files.backend.storage.campaign_attachments import (
|
||||||
annotate_built_messages_with_managed_files,
|
annotate_built_messages_with_managed_files,
|
||||||
managed_match_payloads,
|
managed_match_payloads,
|
||||||
@@ -28,6 +32,7 @@ from govoplan_files.backend.storage.campaign_usage import record_campaign_attach
|
|||||||
from govoplan_files.backend.storage.files import (
|
from govoplan_files.backend.storage.files import (
|
||||||
create_file_asset,
|
create_file_asset,
|
||||||
current_version_and_blob,
|
current_version_and_blob,
|
||||||
|
get_asset_for_user,
|
||||||
sync_file_asset_from_source,
|
sync_file_asset_from_source,
|
||||||
)
|
)
|
||||||
from govoplan_files.backend.storage.paths import normalize_folder
|
from govoplan_files.backend.storage.paths import normalize_folder
|
||||||
@@ -146,6 +151,137 @@ def artifact_store_capability(context: ModuleContext) -> FilesArtifactStore:
|
|||||||
return FilesArtifactStore()
|
return FilesArtifactStore()
|
||||||
|
|
||||||
|
|
||||||
|
class FilesPostboxReferenceService(PostboxFileReferenceProvider):
|
||||||
|
"""Resolve Postbox evidence without turning message access into file access."""
|
||||||
|
|
||||||
|
def resolve_postbox_references(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
requests: tuple[PostboxFileReferenceRequest, ...],
|
||||||
|
) -> tuple[PostboxFileReferenceRef, ...]:
|
||||||
|
if not hasattr(session, "get"):
|
||||||
|
raise TypeError("Postbox file resolution requires a SQLAlchemy session.")
|
||||||
|
principal_tenant = str(getattr(principal, "tenant_id", "") or "")
|
||||||
|
can_download = bool(
|
||||||
|
hasattr(principal, "has") and principal.has("files:file:download")
|
||||||
|
)
|
||||||
|
user = getattr(principal, "user", None)
|
||||||
|
user_id = str(getattr(user, "id", "") or "")
|
||||||
|
is_admin = bool(
|
||||||
|
hasattr(principal, "has") and principal.has("files:file:admin")
|
||||||
|
)
|
||||||
|
results: list[PostboxFileReferenceRef] = []
|
||||||
|
for request in requests:
|
||||||
|
if principal_tenant != tenant_id:
|
||||||
|
results.append(_unavailable_postbox_reference(request, "tenant_mismatch"))
|
||||||
|
continue
|
||||||
|
if not can_download or not user_id:
|
||||||
|
results.append(
|
||||||
|
_unavailable_postbox_reference(
|
||||||
|
request,
|
||||||
|
"download_permission_missing",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
reference_type = request.reference_type.strip().casefold()
|
||||||
|
version: FileVersion | None = None
|
||||||
|
if reference_type in {"file", "file_asset", "files:file"}:
|
||||||
|
results.append(
|
||||||
|
_unavailable_postbox_reference(request, "exact_version_required")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
elif reference_type in {"file_version", "files:file_version"}:
|
||||||
|
version = session.get(FileVersion, request.reference_id) # type: ignore[attr-defined]
|
||||||
|
asset = (
|
||||||
|
session.get(FileAsset, version.file_asset_id) # type: ignore[attr-defined]
|
||||||
|
if version is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results.append(
|
||||||
|
_unavailable_postbox_reference(
|
||||||
|
request,
|
||||||
|
"unsupported_reference_type",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
asset is None
|
||||||
|
or version is None
|
||||||
|
or asset.tenant_id != tenant_id
|
||||||
|
or version.tenant_id != tenant_id
|
||||||
|
or asset.deleted_at is not None
|
||||||
|
):
|
||||||
|
results.append(_unavailable_postbox_reference(request, "file_not_found"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
get_asset_for_user(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
except FileStorageError:
|
||||||
|
results.append(
|
||||||
|
_unavailable_postbox_reference(request, "file_access_denied")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
blob = session.get(FileBlob, version.blob_id) # type: ignore[attr-defined]
|
||||||
|
if blob is None or blob.tenant_id != tenant_id:
|
||||||
|
results.append(
|
||||||
|
_unavailable_postbox_reference(request, "file_payload_missing")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
PostboxFileReferenceRef(
|
||||||
|
reference_type=request.reference_type,
|
||||||
|
reference_id=request.reference_id,
|
||||||
|
available=True,
|
||||||
|
reason_code="available",
|
||||||
|
file_asset_id=asset.id,
|
||||||
|
file_version_id=version.id,
|
||||||
|
filename=version.filename_at_upload or asset.filename,
|
||||||
|
content_type=version.content_type or blob.content_type,
|
||||||
|
size_bytes=version.size_bytes,
|
||||||
|
sha256=version.checksum_sha256,
|
||||||
|
download_path=(
|
||||||
|
f"/api/v1/files/{asset.id}/versions/{version.id}/download"
|
||||||
|
),
|
||||||
|
provenance={
|
||||||
|
"module": "files",
|
||||||
|
"postbox_id": request.postbox_id,
|
||||||
|
"message_id": request.message_id,
|
||||||
|
"display_path": asset.display_path,
|
||||||
|
"exact_version": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable_postbox_reference(
|
||||||
|
request: PostboxFileReferenceRequest,
|
||||||
|
reason_code: str,
|
||||||
|
) -> PostboxFileReferenceRef:
|
||||||
|
return PostboxFileReferenceRef(
|
||||||
|
reference_type=request.reference_type,
|
||||||
|
reference_id=request.reference_id,
|
||||||
|
available=False,
|
||||||
|
reason_code=reason_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_reference_capability(
|
||||||
|
context: ModuleContext,
|
||||||
|
) -> FilesPostboxReferenceService:
|
||||||
|
configure_runtime(registry=context.registry, settings=context.settings)
|
||||||
|
return FilesPostboxReferenceService()
|
||||||
|
|
||||||
|
|
||||||
class FilesAccessService(FileAccessProvider):
|
class FilesAccessService(FileAccessProvider):
|
||||||
def explain_resource_provenance(
|
def explain_resource_provenance(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
|
|||||||
from govoplan_core.core.files import (
|
from govoplan_core.core.files import (
|
||||||
CAPABILITY_FILES_ACCESS,
|
CAPABILITY_FILES_ACCESS,
|
||||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||||
|
CAPABILITY_FILES_POSTBOX_REFERENCES,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.module_guards import (
|
from govoplan_core.core.module_guards import (
|
||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
@@ -424,11 +425,12 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
),
|
),
|
||||||
optional_dependencies=("campaigns", "encryption", "records", "search"),
|
optional_dependencies=("campaigns", "encryption", "postbox", "records", "search"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="files.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="files.access", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"),
|
ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_FILES_ARTIFACT_STORE, version="0.1.14"),
|
ModuleInterfaceProvider(name=CAPABILITY_FILES_ARTIFACT_STORE, version="0.1.14"),
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_FILES_POSTBOX_REFERENCES, version="1.0.0"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_FILES, version="1.0.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_FILES, version="1.0.0"),
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_FORM_EVIDENCE_FILES, version="1.0.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_FORM_EVIDENCE_FILES, version="1.0.0"),
|
||||||
),
|
),
|
||||||
@@ -580,6 +582,51 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="files.postbox.exact-version-references",
|
||||||
|
title="Resolve exact file versions for Postbox evidence",
|
||||||
|
summary="Keep Files authorization authoritative when Postbox displays a typed file reference.",
|
||||||
|
body=(
|
||||||
|
"Files exposes an optional capability that resolves file-asset and file-version references for an already authorized Postbox message. "
|
||||||
|
"The capability returns immutable version metadata and a version-specific download route only when the current principal has Files download "
|
||||||
|
"permission and direct resource access. It does not turn Postbox access into a file share."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("administrator", "user", "campaign_manager"),
|
||||||
|
related_modules=("postbox", "audit"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("files",),
|
||||||
|
any_scopes=("files:file:download", "files:file:admin"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Files",
|
||||||
|
href="/files",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Files handbook",
|
||||||
|
href="govoplan-files/docs/FILES_HANDBOOK.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Exakte Dateiversionen für Postfachnachweise auflösen",
|
||||||
|
"summary": "Die Files-Berechtigungsprüfung bleibt maßgeblich, wenn Postbox einen typisierten Dateiverweis anzeigt.",
|
||||||
|
"body": (
|
||||||
|
"Files stellt eine optionale Capability bereit, die Datei- und Dateiversionsverweise einer bereits autorisierten Postfachnachricht auflöst. "
|
||||||
|
"Sie liefert unveränderliche Versionsmetadaten und einen versionsgenauen Download nur, wenn die aktuelle Person die Files-Downloadberechtigung "
|
||||||
|
"und direkten Ressourcenzugriff besitzt. Postfachzugriff wird nicht automatisch zu einer Dateifreigabe."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={"kind": "reference", "help_contexts": ["files.postbox-reference"]},
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="files.configuration-package.managed-storage",
|
id="files.configuration-package.managed-storage",
|
||||||
title="Validate deployment-managed file storage",
|
title="Validate deployment-managed file storage",
|
||||||
@@ -1478,6 +1525,10 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_files.backend.capabilities",
|
"govoplan_files.backend.capabilities",
|
||||||
fromlist=["artifact_store_capability"],
|
fromlist=["artifact_store_capability"],
|
||||||
).artifact_store_capability(context),
|
).artifact_store_capability(context),
|
||||||
|
CAPABILITY_FILES_POSTBOX_REFERENCES: lambda context: __import__(
|
||||||
|
"govoplan_files.backend.capabilities",
|
||||||
|
fromlist=["postbox_reference_capability"],
|
||||||
|
).postbox_reference_capability(context),
|
||||||
"files.campaign_attachments": lambda context: __import__(
|
"files.campaign_attachments": lambda context: __import__(
|
||||||
"govoplan_files.backend.capabilities", fromlist=["campaign_capability"]
|
"govoplan_files.backend.capabilities", fromlist=["campaign_capability"]
|
||||||
).campaign_capability(context),
|
).campaign_capability(context),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from govoplan_files.backend.storage.common import FileStorageError
|
|||||||
from govoplan_files.backend.storage.files import (
|
from govoplan_files.backend.storage.files import (
|
||||||
get_asset_for_user,
|
get_asset_for_user,
|
||||||
read_asset_bytes,
|
read_asset_bytes,
|
||||||
|
read_asset_version_bytes,
|
||||||
soft_delete_assets,
|
soft_delete_assets,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,46 @@ def download_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_id}/versions/{version_id}/download")
|
||||||
|
def download_file_version(
|
||||||
|
file_id: str,
|
||||||
|
version_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("files:file:download")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
asset = get_asset_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
asset_id=file_id,
|
||||||
|
is_admin=_is_admin(principal),
|
||||||
|
)
|
||||||
|
data, version, blob = read_asset_version_bytes(session, asset, version_id)
|
||||||
|
_audit_connector_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="files.connector.accessed",
|
||||||
|
asset=asset,
|
||||||
|
version=version,
|
||||||
|
blob=blob,
|
||||||
|
operation="download-version",
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
except FileStorageError as exc:
|
||||||
|
raise _http_error(exc, not_found=True) from exc
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": _attachment_disposition(
|
||||||
|
version.filename_at_upload or asset.filename
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return StreamingResponse(
|
||||||
|
BytesIO(data),
|
||||||
|
media_type=blob.content_type or "application/octet-stream",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{file_id}", response_model=BulkDeleteResponse)
|
@router.delete("/{file_id}", response_model=BulkDeleteResponse)
|
||||||
def delete_file(
|
def delete_file(
|
||||||
file_id: str,
|
file_id: str,
|
||||||
|
|||||||
@@ -731,6 +731,21 @@ def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVer
|
|||||||
return read_verified_blob_bytes(blob, backend=backend), version, blob
|
return read_verified_blob_bytes(blob, backend=backend), version, blob
|
||||||
|
|
||||||
|
|
||||||
|
def read_asset_version_bytes(
|
||||||
|
session: Session,
|
||||||
|
asset: FileAsset,
|
||||||
|
version_id: str,
|
||||||
|
) -> tuple[bytes, FileVersion, FileBlob]:
|
||||||
|
version = session.get(FileVersion, version_id)
|
||||||
|
if version is None or version.file_asset_id != asset.id:
|
||||||
|
raise FileStorageError("File version not found")
|
||||||
|
blob = session.get(FileBlob, version.blob_id)
|
||||||
|
if blob is None or blob.tenant_id != asset.tenant_id:
|
||||||
|
raise FileStorageError("File blob not found")
|
||||||
|
backend = get_storage_backend()
|
||||||
|
return read_verified_blob_bytes(blob, backend=backend), version, blob
|
||||||
|
|
||||||
|
|
||||||
def share_file(
|
def share_file(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ STATIC_TOPIC_IDS = {
|
|||||||
"files.reference.snapshot-provenance-and-capabilities",
|
"files.reference.snapshot-provenance-and-capabilities",
|
||||||
"files.records.exact-version-source",
|
"files.records.exact-version-source",
|
||||||
"files.forms-runtime.managed-evidence",
|
"files.forms-runtime.managed-evidence",
|
||||||
|
"files.postbox.exact-version-references",
|
||||||
"files.assurance.process-and-release-readiness",
|
"files.assurance.process-and-release-readiness",
|
||||||
}
|
}
|
||||||
RUNTIME_TOPIC_IDS = {
|
RUNTIME_TOPIC_IDS = {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.core.files import PostboxFileReferenceRequest
|
||||||
|
from govoplan_files.backend.capabilities import FilesPostboxReferenceService
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileBlob, FileVersion
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
def __init__(self, *, tenant_id: str = "tenant-1", scopes: set[str] | None = None):
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.user = SimpleNamespace(id="user-1")
|
||||||
|
self._scopes = {"files:file:download"} if scopes is None else scopes
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self._scopes
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self, values: dict[tuple[type[object], str], object]):
|
||||||
|
self.values = values
|
||||||
|
|
||||||
|
def get(self, model: type[object], resource_id: str):
|
||||||
|
return self.values.get((model, resource_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _request(reference_type: str = "file_version", reference_id: str = "version-1"):
|
||||||
|
return PostboxFileReferenceRequest(
|
||||||
|
postbox_id="postbox-1",
|
||||||
|
message_id="message-1",
|
||||||
|
reference_type=reference_type,
|
||||||
|
reference_id=reference_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _objects():
|
||||||
|
asset = SimpleNamespace(
|
||||||
|
id="file-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
current_version_id="version-2",
|
||||||
|
deleted_at=None,
|
||||||
|
filename="current.txt",
|
||||||
|
display_path="Evidence/current.txt",
|
||||||
|
)
|
||||||
|
version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
file_asset_id="file-1",
|
||||||
|
blob_id="blob-1",
|
||||||
|
filename_at_upload="evidence.txt",
|
||||||
|
content_type="text/plain",
|
||||||
|
size_bytes=8,
|
||||||
|
checksum_sha256="a" * 64,
|
||||||
|
)
|
||||||
|
blob = SimpleNamespace(
|
||||||
|
id="blob-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
return asset, version, blob
|
||||||
|
|
||||||
|
|
||||||
|
class FilesPostboxReferenceTests(unittest.TestCase):
|
||||||
|
def test_resolves_the_referenced_version_instead_of_the_current_version(self) -> None:
|
||||||
|
asset, version, blob = _objects()
|
||||||
|
session = _Session(
|
||||||
|
{
|
||||||
|
(FileAsset, asset.id): asset,
|
||||||
|
(FileVersion, version.id): version,
|
||||||
|
(FileBlob, blob.id): blob,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_files.backend.capabilities.get_asset_for_user",
|
||||||
|
return_value=asset,
|
||||||
|
):
|
||||||
|
result = FilesPostboxReferenceService().resolve_postbox_references(
|
||||||
|
session,
|
||||||
|
_Principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
requests=(_request(),),
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertTrue(result.available)
|
||||||
|
self.assertEqual("version-1", result.file_version_id)
|
||||||
|
self.assertEqual(
|
||||||
|
"/api/v1/files/file-1/versions/version-1/download",
|
||||||
|
result.download_path,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.provenance["exact_version"])
|
||||||
|
|
||||||
|
def test_fails_closed_for_tenant_permission_and_file_access_mismatches(self) -> None:
|
||||||
|
service = FilesPostboxReferenceService()
|
||||||
|
empty = _Session({})
|
||||||
|
|
||||||
|
tenant_result = service.resolve_postbox_references(
|
||||||
|
empty,
|
||||||
|
_Principal(tenant_id="tenant-2"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
requests=(_request(),),
|
||||||
|
)[0]
|
||||||
|
permission_result = service.resolve_postbox_references(
|
||||||
|
empty,
|
||||||
|
_Principal(scopes=set()),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
requests=(_request(),),
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
asset, version, blob = _objects()
|
||||||
|
populated = _Session(
|
||||||
|
{
|
||||||
|
(FileAsset, asset.id): asset,
|
||||||
|
(FileVersion, version.id): version,
|
||||||
|
(FileBlob, blob.id): blob,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_files.backend.capabilities.get_asset_for_user",
|
||||||
|
side_effect=FileStorageError("denied"),
|
||||||
|
):
|
||||||
|
access_result = service.resolve_postbox_references(
|
||||||
|
populated,
|
||||||
|
_Principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
requests=(_request(),),
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual("tenant_mismatch", tenant_result.reason_code)
|
||||||
|
self.assertEqual("download_permission_missing", permission_result.reason_code)
|
||||||
|
self.assertEqual("file_access_denied", access_result.reason_code)
|
||||||
|
self.assertFalse(access_result.available)
|
||||||
|
|
||||||
|
def test_asset_reference_does_not_drift_to_a_later_current_version(self) -> None:
|
||||||
|
asset, version, blob = _objects()
|
||||||
|
session = _Session(
|
||||||
|
{
|
||||||
|
(FileAsset, asset.id): asset,
|
||||||
|
(FileVersion, version.id): version,
|
||||||
|
(FileBlob, blob.id): blob,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = FilesPostboxReferenceService().resolve_postbox_references(
|
||||||
|
session,
|
||||||
|
_Principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
requests=(_request("file", asset.id),),
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertFalse(result.available)
|
||||||
|
self.assertEqual("exact_version_required", result.reason_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -55,7 +55,7 @@ class FilesRouterContractTests(unittest.TestCase):
|
|||||||
actual = self._operation_keys(router)
|
actual = self._operation_keys(router)
|
||||||
|
|
||||||
self.assertEqual(expected, actual)
|
self.assertEqual(expected, actual)
|
||||||
self.assertEqual(53, len(actual))
|
self.assertEqual(54, len(actual))
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
[operation for operation, count in Counter(actual).items() if count > 1]
|
[operation for operation, count in Counter(actual).items() if count > 1]
|
||||||
)
|
)
|
||||||
@@ -107,6 +107,16 @@ class FilesRouterContractTests(unittest.TestCase):
|
|||||||
self.assertIn((("DELETE",), "/files/{file_id}/shares/{share_id}"), routes)
|
self.assertIn((("DELETE",), "/files/{file_id}/shares/{share_id}"), routes)
|
||||||
self.assertIn((("GET",), "/files/{file_id}/share-target-options"), routes)
|
self.assertIn((("GET",), "/files/{file_id}/share-target-options"), routes)
|
||||||
|
|
||||||
|
def test_exact_version_download_route_is_exposed(self) -> None:
|
||||||
|
routes = {
|
||||||
|
(tuple(sorted(route.methods or ())), route.path) for route in router.routes
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
(("GET",), "/files/{file_id}/versions/{version_id}/download"),
|
||||||
|
routes,
|
||||||
|
)
|
||||||
|
|
||||||
def test_file_listing_exposes_structured_property_filters(self) -> None:
|
def test_file_listing_exposes_structured_property_filters(self) -> None:
|
||||||
route = next(
|
route = next(
|
||||||
route
|
route
|
||||||
|
|||||||
Reference in New Issue
Block a user