181 lines
5.5 KiB
Python
181 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from govoplan_core.core.tenant_erasure import (
|
|
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
|
TenantErasurePreview,
|
|
TenantErasureResource,
|
|
TenantErasureStep,
|
|
TenantErasureStepResult,
|
|
collect_tenant_erasure_inventory,
|
|
tenant_erasure_providers,
|
|
)
|
|
|
|
|
|
class _Provider:
|
|
module_id = "files"
|
|
|
|
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
|
|
del session
|
|
assert tenant_id == "tenant-1"
|
|
return TenantErasurePreview(
|
|
module_id=self.module_id,
|
|
complete=True,
|
|
resources=(
|
|
TenantErasureResource(
|
|
resource_type="file_blobs",
|
|
count=2,
|
|
disposition="erase",
|
|
summary="Two tenant-owned file blobs will be erased.",
|
|
),
|
|
),
|
|
steps=(
|
|
TenantErasureStep(
|
|
step_id="erase-blobs",
|
|
kind="erase",
|
|
summary="Erase tenant-owned file blobs.",
|
|
destructive=True,
|
|
irreversible=True,
|
|
),
|
|
),
|
|
)
|
|
|
|
def execute_tenant_erasure_step(
|
|
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
|
) -> TenantErasureStepResult:
|
|
del session, tenant_id, step_id, idempotency_key
|
|
return TenantErasureStepResult(
|
|
state="completed",
|
|
summary="Tenant file blobs erased.",
|
|
metrics={"deleted": 2},
|
|
)
|
|
|
|
def reconcile_tenant_erasure_step(
|
|
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
|
) -> TenantErasureStepResult:
|
|
return self.execute_tenant_erasure_step(
|
|
session, tenant_id, step_id, idempotency_key
|
|
)
|
|
|
|
|
|
class _Registry:
|
|
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
|
|
self._provider = provider
|
|
self._counts = counts
|
|
|
|
def manifests(self):
|
|
return (
|
|
SimpleNamespace(id="core"),
|
|
SimpleNamespace(id="files"),
|
|
SimpleNamespace(id="wiki"),
|
|
)
|
|
|
|
def capability_names(self):
|
|
if self._provider is None:
|
|
return ()
|
|
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
|
|
|
|
def capability(self, name: str):
|
|
assert name.endswith("files")
|
|
return self._provider
|
|
|
|
def tenant_summary_providers(self):
|
|
if self._counts is None:
|
|
return {}
|
|
return {"files": lambda _session, _tenant_id: self._counts}
|
|
|
|
|
|
def test_contract_rejects_unsafe_irreversible_step() -> None:
|
|
with pytest.raises(ValueError, match="must be destructive"):
|
|
TenantErasureStep(
|
|
step_id="unsafe",
|
|
kind="erase",
|
|
summary="Invalid step.",
|
|
destructive=False,
|
|
irreversible=True,
|
|
)
|
|
|
|
|
|
def test_contract_rejects_cyclic_step_dependencies() -> None:
|
|
with pytest.raises(ValueError, match="contain a cycle"):
|
|
TenantErasurePreview(
|
|
module_id="files",
|
|
complete=True,
|
|
steps=(
|
|
TenantErasureStep(
|
|
step_id="first",
|
|
kind="erase",
|
|
summary="First.",
|
|
destructive=True,
|
|
irreversible=True,
|
|
depends_on=("second",),
|
|
),
|
|
TenantErasureStep(
|
|
step_id="second",
|
|
kind="verify",
|
|
summary="Second.",
|
|
destructive=False,
|
|
irreversible=False,
|
|
depends_on=("first",),
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
|
|
resource = TenantErasureResource(
|
|
resource_type="files",
|
|
count=1,
|
|
disposition="erase",
|
|
summary="One file exists.",
|
|
)
|
|
with pytest.raises(ValueError, match="steps or an explicit blocker"):
|
|
TenantErasurePreview(
|
|
module_id="files",
|
|
complete=True,
|
|
resources=(resource,),
|
|
)
|
|
|
|
|
|
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
|
|
inventory = collect_tenant_erasure_inventory(
|
|
_Registry(provider=_Provider()),
|
|
object(),
|
|
"tenant-1",
|
|
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
|
|
)
|
|
|
|
assert inventory.complete
|
|
assert inventory.allowed
|
|
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
|
|
assert inventory.modules[1].steps[0].irreversible
|
|
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
|
|
|
|
|
|
def test_summary_fallback_blocks_when_data_exists() -> None:
|
|
inventory = collect_tenant_erasure_inventory(
|
|
_Registry(counts={"file_blobs": 3}),
|
|
object(),
|
|
"tenant-1",
|
|
)
|
|
|
|
files = next(item for item in inventory.modules if item.module_id == "files")
|
|
assert inventory.complete
|
|
assert not inventory.allowed
|
|
assert files.resources[0].disposition == "unavailable"
|
|
assert files.blockers == (
|
|
"Tenant-owned data exists but the module has no erasure provider.",
|
|
)
|
|
|
|
|
|
def test_provider_identity_must_match_capability_suffix() -> None:
|
|
provider = _Provider()
|
|
provider.module_id = "mail"
|
|
|
|
with pytest.raises(ValueError, match="identity"):
|
|
tenant_erasure_providers(_Registry(provider=provider))
|