feat: harden shared platform contracts

This commit is contained in:
2026-07-30 14:26:36 +02:00
parent 6970bf7457
commit 9b88ae388b
24 changed files with 2034 additions and 57 deletions

View File

@@ -132,6 +132,24 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(response.status_code, 201, response.text)
return str(response.json()["id"])
def _campaign_version_precondition(
self,
headers: dict[str, str],
campaign_id: str,
version_id: str,
) -> tuple[dict[str, str], int]:
response = self.client.get(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
)
self.assertEqual(response.status_code, 200, response.text)
etag = response.headers.get("etag")
self.assertIsNotNone(etag)
return (
{**headers, "If-Match": str(etag)},
int(response.json()["edit_revision"]),
)
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
headers, _ = self._login()
payload = {
@@ -950,6 +968,33 @@ class ApiSmokeTests(unittest.TestCase):
user_always = {item["id"]: item for item in user_docs_payload["layers"]["always"]["documentation"]}
self.assertEqual(user_always["docs.configured-system-documentation"]["translation_locale"], "de")
sources = self.client.get("/api/v1/docs/sources", headers=headers)
self.assertEqual(sources.status_code, 200, sources.text)
source_payload = sources.json()
self.assertEqual(source_payload["total"], len(source_payload["items"]))
manifest_source = next(
item
for item in source_payload["items"]
if item["id"] == "docs.manifest"
)
self.assertEqual(manifest_source["kind"], "manifest")
self.assertNotIn("inspection", manifest_source)
inspected_source = self.client.get(
manifest_source["inspection_url"],
headers=headers,
)
self.assertEqual(inspected_source.status_code, 200, inspected_source.text)
self.assertEqual(
inspected_source.json()["inspection"]["module_id"],
"docs",
)
unknown_source = self.client.get(
"/api/v1/docs/sources/docs.unknown",
headers=headers,
)
self.assertEqual(unknown_source.status_code, 404, unknown_source.text)
platform_status = self.client.get("/api/v1/platform/status", headers=headers)
self.assertEqual(platform_status.status_code, 200, platform_status.text)
i18n_status = platform_status.json()["i18n"]
@@ -2747,7 +2792,10 @@ class ApiSmokeTests(unittest.TestCase):
archive.writestr("reports/february.txt", "February report")
payload.seek(0)
with patch("govoplan_files.backend.router._read_limited_upload", side_effect=AssertionError("ZIP upload should not be fully buffered")):
with patch(
"govoplan_files.backend.routes.uploads._read_limited_upload",
side_effect=AssertionError("ZIP upload should not be fully buffered"),
):
response = self.client.post(
"/api/v1/files/upload-zip",
headers=headers,
@@ -2890,10 +2938,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertTrue(full_payload["full"])
self.assertEqual(full_payload["current_version"]["id"], version_id)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
autosaved = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/autosave",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(autosaved.status_code, 200, autosaved.text)
self.assertEqual(autosaved.json()["current_step"], "fields")
@@ -3038,10 +3094,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(temporary.json()["user_lock_state"], "temporary")
self.assertTrue(temporary.json()["user_locked_at"])
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
blocked_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(blocked_update.status_code, 409, blocked_update.text)
@@ -3052,10 +3116,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(unlocked.status_code, 200, unlocked.text)
self.assertIsNone(unlocked.json()["user_lock_state"])
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
updated = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(updated.status_code, 200, updated.text)
self.assertEqual(updated.json()["current_step"], "fields")
@@ -3116,18 +3188,34 @@ class ApiSmokeTests(unittest.TestCase):
self.assertNotEqual(second_version_id, first_version_id)
self.assertEqual(copied.json()["campaign"]["current_version_id"], second_version_id)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
first_version_id,
)
historical_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(historical_update.status_code, 409, historical_update.text)
self.assertIn("Historical campaign versions are read-only", historical_update.text)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
second_version_id,
)
second_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(second_update.status_code, 200, second_update.text)
@@ -4613,8 +4701,14 @@ class ApiSmokeTests(unittest.TestCase):
)
updated = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
headers=headers,
json={"campaign_json": campaign_json},
headers={
**headers,
"If-Match": str(version_response.headers["etag"]),
},
json={
"campaign_json": campaign_json,
"base_revision": version_response.json()["edit_revision"],
},
)
self.assertEqual(updated.status_code, 200, updated.text)
validated = self.client.post(
@@ -5883,10 +5977,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(allowed_write.status_code, 200, allowed_write.text)
changed_config = version_detail.json()["raw_json"]
changed_config["entries"]["inline"][0]["to"][0]["email"] = "changed@example.org"
mutation_headers, base_revision = self._campaign_version_precondition(
reader_headers,
campaign_id,
version_id,
)
denied_recipient_edit = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=reader_headers,
json={"campaign_json": changed_config},
headers=mutation_headers,
json={
"campaign_json": changed_config,
"base_revision": base_revision,
},
)
self.assertEqual(denied_recipient_edit.status_code, 403, denied_recipient_edit.text)
self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403)
@@ -6030,18 +6132,29 @@ class ApiSmokeTests(unittest.TestCase):
}
blocked_inline = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": inline_json},
headers={**headers, "If-Match": str(detail.headers["etag"])},
json={
"campaign_json": inline_json,
"base_revision": detail.json()["edit_revision"],
},
)
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
self.assertIn("may only reference", blocked_inline.json()["detail"])
reusable_json = detail.json()["raw_json"]
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
allowed_reusable = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": reusable_json},
headers=mutation_headers,
json={
"campaign_json": reusable_json,
"base_revision": base_revision,
},
)
self.assertEqual(allowed_reusable.status_code, 200, allowed_reusable.text)
@@ -6080,8 +6193,11 @@ class ApiSmokeTests(unittest.TestCase):
raw_json["server"] = {"mail_profile_id": profile_id}
denied_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": raw_json},
headers={**headers, "If-Match": str(detail.headers["etag"])},
json={
"campaign_json": raw_json,
"base_revision": detail.json()["edit_revision"],
},
)
self.assertEqual(denied_update.status_code, 422, denied_update.text)
@@ -6091,10 +6207,18 @@ class ApiSmokeTests(unittest.TestCase):
json={"policy": {"allowed_profile_ids": [profile_id]}},
)
self.assertEqual(allowed_policy.status_code, 200, allowed_policy.text)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
allowed_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": raw_json},
headers=mutation_headers,
json={
"campaign_json": raw_json,
"base_revision": base_revision,
},
)
self.assertEqual(allowed_update.status_code, 200, allowed_update.text)

View File

@@ -250,6 +250,38 @@ class CoreEventTests(unittest.TestCase):
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
def test_slow_request_log_excludes_query_headers_and_cookies(self) -> None:
router = APIRouter()
@router.get("/slow")
def slow() -> dict[str, bool]:
return {"ok": True}
with patch("govoplan_core.server.fastapi._slow_request_threshold_ms", return_value=0.0001):
app = create_govoplan_app(
title="request log redaction test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with self.assertLogs("govoplan.request", level="WARNING") as captured:
with TestClient(app) as client:
response = client.get(
"/slow?token=query-secret",
headers={
"Authorization": "Bearer header-secret",
"Cookie": "govoplan_session=cookie-secret",
},
)
self.assertEqual(200, response.status_code, response.text)
rendered = "\n".join(captured.output)
self.assertIn("path=/slow", rendered)
self.assertNotIn("query-secret", rendered)
self.assertNotIn("header-secret", rendered)
self.assertNotIn("cookie-secret", rendered)
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
RuntimeError,

View File

@@ -3,7 +3,10 @@ from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
DocumentationConfigurationDecision,
DocumentationConfigurationProviderRegistration,
DocumentationCondition,
DocumentationSourceDefinition,
DocumentationTopic,
ModuleManifest,
user_workflow_scope_condition_issues,
@@ -67,6 +70,52 @@ class DocumentationTopicContractTests(unittest.TestCase):
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
registry_for(scoped, admin_workflow, user_reference).validate()
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
resolver = lambda _context, keys: { # noqa: E731
key: DocumentationConfigurationDecision(key=key, state="enabled")
for key in keys
}
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
),
documentation_sources=(
DocumentationSourceDefinition(
id="example.handbook",
kind="repository",
label="Example handbook",
),
),
))
registry.validate()
duplicate = PlatformRegistry()
duplicate.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
),
))
with self.assertRaisesRegex(RegistryError, "duplicate documentation configuration key"):
duplicate.validate()
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
registry = PlatformRegistry()

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.celery_app import (
celery,
dispatch_mail_outbox,
purge_mail_outbox,
)
class _Provider:
def dispatch_due(self, session, **kwargs):
return {"session": session, **kwargs}
def purge_expired(self, session, **kwargs):
return {"session": session, **kwargs}
class MailDeliveryWorkerTests(unittest.TestCase):
def test_dispatch_uses_optional_provider_boundary(self) -> None:
session = object()
@contextmanager
def session_scope():
yield session
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch("govoplan_core.db.session.get_database", return_value=database),
patch(
"govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(),
),
):
result = dispatch_mail_outbox.run("tenant-1", 7)
self.assertIs(result["session"], session)
self.assertEqual(result["tenant_id"], "tenant-1")
self.assertEqual(result["limit"], 7)
def test_purge_uses_optional_provider_boundary(self) -> None:
session = object()
@contextmanager
def session_scope():
yield session
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch("govoplan_core.db.session.get_database", return_value=database),
patch(
"govoplan_core.celery_app._mail_delivery_outbox",
return_value=_Provider(),
),
):
result = purge_mail_outbox.run(19)
self.assertIs(result["session"], session)
self.assertEqual(result["limit"], 19)
def test_worker_routes_and_schedules_are_declared(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.mail.dispatch_outbox"],
{"queue": "mail"},
)
self.assertEqual(
celery.conf.task_routes["govoplan.mail.purge_outbox"],
{"queue": "mail"},
)
self.assertEqual(
celery.conf.beat_schedule["mail-outbox-every-five-seconds"]["task"],
"govoplan.mail.dispatch_outbox",
)
self.assertEqual(
celery.conf.beat_schedule["mail-outbox-retention-daily"]["task"],
"govoplan.mail.purge_outbox",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
import unittest
from sqlalchemy import Integer, String, create_engine
from sqlalchemy.orm import Mapped, Session, mapped_column, sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.core.concurrency import (
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
claim_revision,
if_match_matches,
strong_resource_etag,
three_way_merge,
)
from govoplan_core.db.base import Base
class _RevisionFixture(Base):
__tablename__ = "test_revision_fixture"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
edit_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
value: Mapped[str] = mapped_column(String(100), nullable=False)
class OptimisticConcurrencyTests(unittest.TestCase):
def test_strong_etags_and_if_match_use_strong_comparison(self) -> None:
etag = strong_resource_etag("campaign_version", "version-1", 3)
self.assertTrue(if_match_matches(etag, etag))
self.assertTrue(if_match_matches(f'"other", {etag}', etag))
self.assertFalse(if_match_matches(f"W/{etag}", etag))
self.assertNotEqual(
etag,
strong_resource_etag("campaign_version", "version-1", 4),
)
with self.assertRaises(MissingPreconditionError):
assert_revision_precondition(
None,
resource_type="campaign_version",
resource_id="version-1",
submitted_base_revision=3,
)
def test_compare_and_set_allows_only_one_writer_for_a_revision(self) -> None:
engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
_RevisionFixture.__table__.create(engine)
session_factory = sessionmaker(bind=engine, class_=Session)
with session_factory() as session:
session.add(_RevisionFixture(id="resource-1", value="base"))
session.commit()
next_revision = claim_revision(
session,
model=_RevisionFixture,
filters=(_RevisionFixture.id == "resource-1",),
revision_attribute="edit_revision",
expected_revision=1,
resource_type="fixture",
resource_id="resource-1",
)
session.commit()
self.assertEqual(next_revision, 2)
with self.assertRaises(RevisionConflictError) as raised:
claim_revision(
session,
model=_RevisionFixture,
filters=(_RevisionFixture.id == "resource-1",),
revision_attribute="edit_revision",
expected_revision=1,
resource_type="fixture",
resource_id="resource-1",
)
self.assertEqual(raised.exception.current_revision, 2)
engine.dispose()
def test_disjoint_map_and_stable_id_collection_changes_merge(self) -> None:
base = {
"campaign": {"name": "Original", "description": "Base"},
"entries": {
"inline": [
{"id": "one", "name": "One", "active": True},
{"id": "two", "name": "Two", "active": True},
]
},
}
local = {
**base,
"campaign": {"name": "Local", "description": "Base"},
"entries": {
"inline": [
{"id": "one", "name": "One", "active": False},
{"id": "two", "name": "Two", "active": True},
]
},
}
current = {
**base,
"campaign": {"name": "Original", "description": "Remote"},
"entries": {
"inline": [
{"id": "one", "name": "One", "active": True},
{"id": "two", "name": "Remote Two", "active": True},
]
},
}
result = three_way_merge(base, local, current)
self.assertTrue(result.merged)
self.assertEqual(result.value["campaign"], {"name": "Local", "description": "Remote"})
self.assertEqual(result.value["entries"]["inline"][0]["active"], False)
self.assertEqual(result.value["entries"]["inline"][1]["name"], "Remote Two")
def test_same_path_unkeyed_collection_and_protected_edits_conflict(self) -> None:
same_path = three_way_merge(
{"name": "Base"},
{"name": "Local"},
{"name": "Remote"},
)
self.assertEqual(same_path.conflicts[0].kind, "same_path_changed")
unkeyed = three_way_merge(
{"values": ["a"]},
{"values": ["a", "local"]},
{"values": ["a", "remote"]},
)
self.assertEqual(unkeyed.conflicts[0].kind, "unkeyed_collection")
protected = three_way_merge(
{"workflow_state": "draft", "name": "Base"},
{"workflow_state": "ready", "name": "Base"},
{"workflow_state": "draft", "name": "Remote"},
protected_paths=("/workflow_state",),
)
self.assertEqual(protected.conflicts[0].kind, "protected_path")
def test_delete_versus_edit_and_conflicting_reorder_remain_explicit(self) -> None:
base = [{"id": "one", "value": "1"}, {"id": "two", "value": "2"}]
delete_vs_edit = three_way_merge(
base,
[{"id": "two", "value": "2"}],
[{"id": "one", "value": "remote"}, {"id": "two", "value": "2"}],
)
self.assertEqual(delete_vs_edit.conflicts[0].kind, "delete_vs_edit")
reordered = three_way_merge(
[
{"id": "one"},
{"id": "two"},
{"id": "three"},
],
[
{"id": "two"},
{"id": "one"},
{"id": "three"},
],
[
{"id": "one"},
{"id": "three"},
{"id": "two"},
],
)
self.assertEqual(reordered.conflicts[0].kind, "collection_reorder")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.postbox import (
@@ -11,6 +12,7 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_MESSAGES,
PostboxAccessProvider,
PostboxDeliveryProvider,
PostboxDeliveryReceiptSummaryRef,
PostboxDirectoryProvider,
PostboxEvidenceProvider,
PostboxMessagesProvider,
@@ -69,6 +71,17 @@ class _PostboxProvider:
del session, tenant_id, message_id, attachment
return None
def delivery_receipt_summaries(
self,
session,
*,
tenant_id,
producer_module,
delivery_ids,
):
del session, tenant_id, producer_module, delivery_ids
return {}
class PostboxContractTests(unittest.TestCase):
def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None:
@@ -78,6 +91,16 @@ class PostboxContractTests(unittest.TestCase):
self.assertIsInstance(provider, PostboxMessagesProvider)
self.assertIsInstance(provider, PostboxDeliveryProvider)
self.assertIsInstance(provider, PostboxEvidenceProvider)
self.assertEqual(
"delivery-1",
PostboxDeliveryReceiptSummaryRef(
delivery_id="delivery-1",
message_id="message-1",
postbox_id="postbox-1",
delivery_status="accepted",
accepted_at=datetime.now(timezone.utc),
).delivery_id,
)
registry = PlatformRegistry()
registry.register(

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import unittest
from govoplan_core.security.redaction import contains_plain_secret, redact_secret_values
class SecurityRedactionTests(unittest.TestCase):
def test_structured_payload_redaction_covers_nested_container_shapes(self) -> None:
payload = {
"request": {
"Authorization": "Bearer must-not-leak",
"headers": [{"x-api-key": "must-not-leak"}],
},
"records": (
{"clientSecret": "must-not-leak", "status": "ok"},
{"credential_ref": "credential-1"},
),
}
redacted = redact_secret_values(payload)
self.assertEqual("<redacted>", redacted["request"]["Authorization"]) # type: ignore[index]
self.assertEqual("<redacted>", redacted["request"]["headers"][0]["x-api-key"]) # type: ignore[index]
self.assertEqual("<redacted>", redacted["records"][0]["clientSecret"]) # type: ignore[index]
self.assertEqual("<redacted>", redacted["records"][1]["credential_ref"]) # type: ignore[index]
self.assertNotIn("must-not-leak", repr(redacted))
def test_plain_secret_detection_descends_into_lists_and_tuples(self) -> None:
self.assertTrue(contains_plain_secret([{"metadata": ({"private-key": "secret"},)}]))
self.assertFalse(contains_plain_secret([{"credential_ref": "credential-1"}]))
if __name__ == "__main__":
unittest.main()