feat: orchestrate governed tenant erasure
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.tenant_erasure import (
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||
TenantErasurePreview,
|
||||
TenantErasureResource,
|
||||
TenantErasureStep,
|
||||
TenantErasureStepResult,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_tenancy.backend.db.models import TenantErasureOperation
|
||||
from govoplan_tenancy.backend.erasure import (
|
||||
TenantErasureConflict,
|
||||
TenantErasurePolicy,
|
||||
approve_tenant_erasure_operation,
|
||||
assert_tenant_erasure_executable,
|
||||
cancel_tenant_erasure_operation,
|
||||
create_tenant_erasure_operation,
|
||||
run_tenant_erasure_steps,
|
||||
tenant_erasure_recently_authenticated,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
module_id = "files"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.fail = False
|
||||
self.calls: list[str] = []
|
||||
|
||||
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
|
||||
del session, tenant_id
|
||||
return TenantErasurePreview(
|
||||
module_id="files",
|
||||
complete=True,
|
||||
resources=(
|
||||
TenantErasureResource(
|
||||
resource_type="files",
|
||||
count=1,
|
||||
disposition="erase",
|
||||
summary="One file is in scope.",
|
||||
),
|
||||
),
|
||||
steps=(
|
||||
TenantErasureStep(
|
||||
step_id="erase-files",
|
||||
kind="erase",
|
||||
summary="Erase files.",
|
||||
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, idempotency_key
|
||||
self.calls.append(f"execute:{step_id}")
|
||||
if self.fail:
|
||||
raise TimeoutError("provider timed out")
|
||||
return TenantErasureStepResult(
|
||||
state="completed",
|
||||
summary="Files erased.",
|
||||
metrics={"deleted": 1},
|
||||
)
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||
) -> TenantErasureStepResult:
|
||||
del session, tenant_id, idempotency_key
|
||||
self.calls.append(f"reconcile:{step_id}")
|
||||
return TenantErasureStepResult(
|
||||
state="completed",
|
||||
summary="File erasure reconciled.",
|
||||
metrics={"deleted": 1},
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: _Provider):
|
||||
self.provider = provider
|
||||
|
||||
def manifests(self):
|
||||
return (SimpleNamespace(id="files"),)
|
||||
|
||||
def capability_names(self):
|
||||
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):
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine, expire_on_commit=False) as current:
|
||||
yield current
|
||||
|
||||
|
||||
def _operation(
|
||||
session: Session,
|
||||
provider: _Provider,
|
||||
*,
|
||||
current_time: datetime,
|
||||
) -> TenantErasureOperation:
|
||||
with patch(
|
||||
"govoplan_tenancy.backend.erasure.tenant_erasure_policy",
|
||||
return_value=TenantErasurePolicy(
|
||||
production_profile=True,
|
||||
required_approvals=2,
|
||||
preview_ttl_seconds=900,
|
||||
recent_authentication_seconds=900,
|
||||
),
|
||||
):
|
||||
operation, replayed = create_tenant_erasure_operation(
|
||||
session,
|
||||
registry=_Registry(provider),
|
||||
tenant_id="tenant-1",
|
||||
idempotency_key="request-1234",
|
||||
requested_by_account_id="account-1",
|
||||
reason="Contract ended",
|
||||
current_time=current_time,
|
||||
)
|
||||
session.commit()
|
||||
assert not replayed
|
||||
return operation
|
||||
|
||||
|
||||
def test_operation_requires_distinct_multi_party_approval_and_typed_confirmation(
|
||||
session: Session,
|
||||
) -> None:
|
||||
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||
operation = _operation(session, _Provider(), current_time=now)
|
||||
|
||||
with pytest.raises(TenantErasureConflict, match="does not match"):
|
||||
approve_tenant_erasure_operation(
|
||||
operation,
|
||||
account_id="account-1",
|
||||
confirmation="wrong",
|
||||
tenant_slug="target",
|
||||
current_time=now,
|
||||
)
|
||||
assert not approve_tenant_erasure_operation(
|
||||
operation,
|
||||
account_id="account-1",
|
||||
confirmation="target",
|
||||
tenant_slug="target",
|
||||
current_time=now,
|
||||
)
|
||||
assert operation.state == "awaiting_approval"
|
||||
assert approve_tenant_erasure_operation(
|
||||
operation,
|
||||
account_id="account-1",
|
||||
confirmation="target",
|
||||
tenant_slug="target",
|
||||
current_time=now,
|
||||
)
|
||||
assert not approve_tenant_erasure_operation(
|
||||
operation,
|
||||
account_id="account-2",
|
||||
confirmation="target",
|
||||
tenant_slug="target",
|
||||
current_time=now,
|
||||
)
|
||||
assert operation.state == "ready"
|
||||
assert_tenant_erasure_executable(
|
||||
operation,
|
||||
confirmation="target",
|
||||
tenant_slug="target",
|
||||
current_time=now,
|
||||
)
|
||||
|
||||
|
||||
def test_preview_idempotency_replays_but_rejects_changed_request(
|
||||
session: Session,
|
||||
) -> None:
|
||||
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||
provider = _Provider()
|
||||
operation = _operation(session, provider, current_time=now)
|
||||
with patch(
|
||||
"govoplan_tenancy.backend.erasure.tenant_erasure_policy",
|
||||
return_value=TenantErasurePolicy(),
|
||||
):
|
||||
replay, replayed = create_tenant_erasure_operation(
|
||||
session,
|
||||
registry=_Registry(provider),
|
||||
tenant_id="tenant-1",
|
||||
idempotency_key="request-1234",
|
||||
requested_by_account_id="account-1",
|
||||
reason="Contract ended",
|
||||
current_time=now,
|
||||
)
|
||||
assert replayed
|
||||
assert replay.id == operation.id
|
||||
with pytest.raises(TenantErasureConflict, match="another request"):
|
||||
create_tenant_erasure_operation(
|
||||
session,
|
||||
registry=_Registry(provider),
|
||||
tenant_id="tenant-1",
|
||||
idempotency_key="request-1234",
|
||||
requested_by_account_id="account-1",
|
||||
reason="Changed",
|
||||
current_time=now,
|
||||
)
|
||||
|
||||
|
||||
def test_provider_timeout_requires_reconciliation_before_completion(
|
||||
session: Session,
|
||||
) -> None:
|
||||
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||
provider = _Provider()
|
||||
operation = _operation(session, provider, current_time=now)
|
||||
operation.state = "ready"
|
||||
operation.approvals = [
|
||||
{"account_id": "account-1"},
|
||||
{"account_id": "account-2"},
|
||||
]
|
||||
session.commit()
|
||||
provider.fail = True
|
||||
|
||||
assert not run_tenant_erasure_steps(
|
||||
session,
|
||||
registry=_Registry(provider),
|
||||
operation=operation,
|
||||
current_time=now,
|
||||
)
|
||||
assert operation.state == "reconciliation_required"
|
||||
assert operation.steps[0]["state"] == "outcome_unknown"
|
||||
assert operation.destructive_started
|
||||
|
||||
provider.fail = False
|
||||
assert run_tenant_erasure_steps(
|
||||
session,
|
||||
registry=_Registry(provider),
|
||||
operation=operation,
|
||||
current_time=now,
|
||||
)
|
||||
assert provider.calls == ["execute:erase-files", "reconcile:erase-files"]
|
||||
assert operation.steps[0]["state"] == "completed"
|
||||
|
||||
|
||||
def test_cancellation_stops_at_destructive_boundary(session: Session) -> None:
|
||||
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||
operation = _operation(session, _Provider(), current_time=now)
|
||||
cancel_tenant_erasure_operation(operation)
|
||||
assert operation.state == "cancelled"
|
||||
|
||||
operation.state = "running"
|
||||
operation.destructive_started = True
|
||||
with pytest.raises(TenantErasureConflict, match="destructive work"):
|
||||
cancel_tenant_erasure_operation(operation)
|
||||
|
||||
|
||||
def test_recent_authentication_is_policy_bounded() -> None:
|
||||
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||
policy = TenantErasurePolicy(recent_authentication_seconds=300)
|
||||
|
||||
assert tenant_erasure_recently_authenticated(
|
||||
SimpleNamespace(created_at=now - timedelta(minutes=4)),
|
||||
policy,
|
||||
current_time=now,
|
||||
)
|
||||
assert not tenant_erasure_recently_authenticated(
|
||||
SimpleNamespace(created_at=now - timedelta(minutes=6)),
|
||||
policy,
|
||||
current_time=now,
|
||||
)
|
||||
assert not tenant_erasure_recently_authenticated(None, policy, current_time=now)
|
||||
|
||||
|
||||
def test_production_policy_cannot_disable_multi_party_approval() -> None:
|
||||
with pytest.raises(ValueError, match="at least two approvals"):
|
||||
TenantErasurePolicy(production_profile=True, required_approvals=1)
|
||||
|
||||
non_production = TenantErasurePolicy(
|
||||
production_profile=False,
|
||||
required_approvals=1,
|
||||
)
|
||||
assert non_production.required_approvals == 1
|
||||
Reference in New Issue
Block a user