Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.configuration_safety import classify_configuration_field, plan_configuration_change
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
from govoplan_campaign.backend.delivery_policy import SYNCHRONOUS_SEND_MAX_ENV, effective_synchronous_send_policy
|
||||
from govoplan_campaign.backend.routes import delivery_settings as routes
|
||||
from govoplan_campaign.backend.router import router as campaign_router
|
||||
|
||||
|
||||
def principal(*scopes, tenant="tenant-a"):
|
||||
actor = SimpleNamespace(id="admin-1")
|
||||
return ApiPrincipal(principal=PrincipalRef(account_id=actor.id, membership_id=actor.id, tenant_id=tenant, scopes=frozenset(scopes)), user=actor, account=actor)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def policy(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv(SYNCHRONOUS_SEND_MAX_ENV, raising=False)
|
||||
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'policy.db'}")
|
||||
for table in (SystemSettings.__table__, Tenant.__table__, ChangeSequenceEntry.__table__):
|
||||
table.create(engine)
|
||||
with Session(engine) as session:
|
||||
session.add_all([
|
||||
SystemSettings(id="global", settings={"unrelated": {"enabled": True}}),
|
||||
Tenant(id="tenant-a", slug="a", name="A", settings={"unrelated": "preserve"}),
|
||||
Tenant(id="tenant-b", slug="b", name="B", settings={}),
|
||||
])
|
||||
session.commit()
|
||||
with patch.object(routes, "audit_from_principal", autospec=True) as audit:
|
||||
yield SimpleNamespace(session=session, engine=engine, audit=audit,
|
||||
admin=principal("system:settings:read", "system:settings:write", "admin:policies:read", "admin:policies:write"))
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def state(policy, scope="system", actor=None):
|
||||
return routes.read_delivery_policy(scope, session=policy.session, principal=actor or policy.admin)
|
||||
|
||||
|
||||
def save(policy, value, scope="system", revision=None, actor=None):
|
||||
payload = routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision=revision or state(policy, scope)["revision"])
|
||||
return routes.update_delivery_policy(scope, payload, session=policy.session, principal=actor or policy.admin)
|
||||
|
||||
|
||||
def test_system_admin_can_raise_implicit_default_and_save_is_durable_audited_and_scoped(policy):
|
||||
before = state(policy)
|
||||
assert before["effective_max_recipients"] == 25 and before["max_configurable_recipients"] == 500
|
||||
result = save(policy, 200)
|
||||
assert result["effective_max_recipients"] == 200 and result["revision"] != before["revision"]
|
||||
with Session(policy.engine) as fresh:
|
||||
assert effective_synchronous_send_policy(fresh, tenant_id="tenant-a", environ={}).max_recipient_jobs == 200
|
||||
system = fresh.get(SystemSettings, "global")
|
||||
assert system.settings["unrelated"] == {"enabled": True}
|
||||
history = system.settings["_configuration_control"]["history"]
|
||||
assert len(history) == 1 and history[0]["key"] == "campaign_delivery_policy.system"
|
||||
assert history[0]["before"]["synchronous_send_max_recipients"] is None
|
||||
assert history[0]["after"]["synchronous_send_max_recipients"] == 200
|
||||
assert history[0]["actor_user_id"] == "admin-1"
|
||||
assert policy.audit.call_args.kwargs["commit"] is False
|
||||
assert policy.audit.call_args.kwargs["scope"] == "system"
|
||||
|
||||
|
||||
def test_tenant_can_only_narrow_and_reset_inherits_without_affecting_other_tenant(policy):
|
||||
save(policy, 200)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 201, "tenant")
|
||||
assert failure.value.status_code == 422
|
||||
save(policy, 183, "tenant")
|
||||
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-a").max_recipient_jobs == 183
|
||||
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-b").max_recipient_jobs == 200
|
||||
assert policy.session.get(Tenant, "tenant-a").settings["unrelated"] == "preserve"
|
||||
assert save(policy, None, "tenant")["effective_max_recipients"] == 200
|
||||
assert save(policy, None)["effective_max_recipients"] == 25
|
||||
|
||||
|
||||
def test_explicit_deployment_ceiling_remains_authoritative_and_zero_disables(policy, monkeypatch):
|
||||
save(policy, 200)
|
||||
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, "40")
|
||||
assert state(policy)["effective_max_recipients"] == 40
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 41)
|
||||
assert failure.value.status_code == 422
|
||||
assert save(policy, None)["effective_max_recipients"] == 40
|
||||
assert save(policy, 0)["effective_max_recipients"] == 0
|
||||
assert state(policy, "tenant")["max_configurable_recipients"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
|
||||
def test_blank_deployment_value_never_turns_default_into_absolute_maximum(policy, monkeypatch, blank):
|
||||
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, blank)
|
||||
assert state(policy)["effective_max_recipients"] == 25
|
||||
assert state(policy)["deployment_ceiling_explicit"] is False
|
||||
save(policy, 200)
|
||||
assert state(policy)["effective_max_recipients"] == 200
|
||||
save(policy, 183, "tenant")
|
||||
assert state(policy, "tenant")["effective_max_recipients"] == 183
|
||||
|
||||
|
||||
def test_stale_parent_own_or_aba_revision_never_overwrites_policy(policy):
|
||||
initial = state(policy)
|
||||
tenant = state(policy, "tenant")
|
||||
save(policy, 200)
|
||||
for scope, revision in (("system", initial["revision"]), ("tenant", tenant["revision"])):
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 10, scope, revision=revision)
|
||||
assert failure.value.status_code == 409
|
||||
save(policy, None)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
save(policy, 30, revision=initial["revision"])
|
||||
assert failure.value.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope,scopes,operation", [
|
||||
("system", ("admin:policies:read", "admin:policies:write"), "read"),
|
||||
("system", ("admin:policies:write",), "write"),
|
||||
("tenant", ("system:settings:read", "system:settings:write"), "read"),
|
||||
("tenant", ("system:settings:write",), "write"),
|
||||
("system", ("system:settings:read",), "write"),
|
||||
("tenant", ("admin:policies:read",), "write"),
|
||||
])
|
||||
def test_permissions_cannot_cross_scope_or_use_read_permission_to_write(policy, scope, scopes, operation):
|
||||
actor = principal(*scopes)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
if operation == "read": state(policy, scope, actor=actor)
|
||||
else: save(policy, 10, scope, actor=actor)
|
||||
assert failure.value.status_code == 403
|
||||
assert "_configuration_control" not in policy.session.get(SystemSettings, "global").settings
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, 1.5, "20", -1, 501])
|
||||
def test_schema_rejects_coercions_and_unbounded_values(value):
|
||||
with pytest.raises(ValidationError):
|
||||
routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision="a" * 64)
|
||||
|
||||
|
||||
def test_route_rolls_back_configuration_and_history_if_audit_fails(policy):
|
||||
policy.audit.side_effect = RuntimeError("Audit unavailable")
|
||||
with pytest.raises(RuntimeError, match="Audit unavailable"):
|
||||
save(policy, 200)
|
||||
policy.session.expire_all()
|
||||
assert policy.session.get(SystemSettings, "global").settings == {"unrelated": {"enabled": True}}
|
||||
|
||||
|
||||
def test_configuration_catalog_supports_only_known_scope_keys_and_scopes():
|
||||
for scope, permission in (("system", "system:settings:write"), ("tenant", "admin:policies:write")):
|
||||
key = f"campaign_delivery_policy.{scope}"
|
||||
field = classify_configuration_field(key)
|
||||
assert field.owner_module == "campaigns" and field.rollback_history_required
|
||||
assert plan_configuration_change(key, actor_scopes=(permission,), value={"synchronous_send_max_recipients": 200}).allowed
|
||||
assert not plan_configuration_change(key, actor_scopes=(), value={"synchronous_send_max_recipients": 200}).allowed
|
||||
assert classify_configuration_field("campaign_delivery_policy.unknown") is None
|
||||
|
||||
|
||||
def test_registered_http_routes_validate_scope_authorization_payload_and_revision(policy):
|
||||
app = FastAPI()
|
||||
app.include_router(campaign_router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_session] = lambda: policy.session
|
||||
actor = [policy.admin]
|
||||
app.dependency_overrides[get_api_principal] = lambda: actor[0]
|
||||
with TestClient(app) as client:
|
||||
path = "/api/v1/campaigns/settings/delivery-policy/system"
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200
|
||||
payload = {"synchronous_send_max_recipients": 200, "expected_revision": response.json()["revision"]}
|
||||
actor[0] = principal("system:settings:read")
|
||||
assert client.put(path, json=payload).status_code == 403
|
||||
actor[0] = principal("admin:policies:read", "admin:policies:write")
|
||||
assert client.get(path).status_code == 403
|
||||
assert client.put(path, json=payload).status_code == 403
|
||||
actor[0] = policy.admin
|
||||
assert client.put(path, json={**payload, "settings": {"unrelated": "overwrite"}}).status_code == 422
|
||||
assert client.put(path, json={**payload, "synchronous_send_max_recipients": True}).status_code == 422
|
||||
saved = client.put(path, json=payload)
|
||||
assert saved.status_code == 200 and saved.json()["effective_max_recipients"] == 200
|
||||
assert client.put(path, json=payload).status_code == 409
|
||||
assert client.get("/api/v1/campaigns/settings/delivery-policy/user").status_code == 422
|
||||
actor[0] = principal()
|
||||
assert client.get(path).status_code == 403
|
||||
assert policy.audit.call_args.kwargs["object_type"] == "campaign_delivery_policy"
|
||||
Reference in New Issue
Block a user