Implement task 006 site config versioning and audit events
This commit is contained in:
@@ -121,6 +121,35 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase):
|
||||
version_activate_response.text,
|
||||
)
|
||||
|
||||
def test_editor_cannot_rollback_script_config_version(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("rollback_denied_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
first_version = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(),
|
||||
)
|
||||
self.assertEqual(201, first_version.status_code, first_version.text)
|
||||
version_id = first_version.json()["version"]["id"]
|
||||
|
||||
activate_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/activate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
rollback_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{version_id}/rollback",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(403, activate_response.status_code, activate_response.text)
|
||||
self.assertEqual(403, rollback_response.status_code, rollback_response.text)
|
||||
|
||||
def test_admin_can_create_and_activate_script_config_version(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
@@ -147,6 +176,121 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase):
|
||||
self.assertEqual(200, activate_response.status_code, activate_response.text)
|
||||
self.assertEqual("ACTIVE", activate_response.json()["version"]["status"])
|
||||
|
||||
def test_admin_can_get_site_by_id(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("admin_get_site_by_id_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
get_response = self.client.get(
|
||||
f"/api/sites/{site_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
|
||||
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||
self.assertEqual(site_id, get_response.json()["site"]["id"])
|
||||
|
||||
def test_admin_can_create_version_with_rollback_target(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("rollback_target_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
baseline = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(diff={"summary": "baseline"}),
|
||||
)
|
||||
self.assertEqual(201, baseline.status_code, baseline.text)
|
||||
baseline_id = baseline.json()["version"]["id"]
|
||||
|
||||
create_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(
|
||||
diff={"summary": "rollback-aware"},
|
||||
rollback_target_version_id=baseline_id,
|
||||
activate=True,
|
||||
),
|
||||
)
|
||||
self.assertEqual(201, create_response.status_code, create_response.text)
|
||||
created = create_response.json()["version"]
|
||||
|
||||
self.assertEqual("ACTIVE", created["status"])
|
||||
self.assertEqual("rollback-aware", created["diff"].get("summary"))
|
||||
self.assertEqual(baseline_id, created["rollback_target_version_id"])
|
||||
|
||||
versions_response = self.client.get(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, versions_response.status_code, versions_response.text)
|
||||
self.assertEqual(2, len(versions_response.json()["versions"]))
|
||||
|
||||
def test_admin_can_rollback_script_config_version(self) -> None:
|
||||
site_response = self.client.post(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._site_payload("rollback_action_blog"),
|
||||
)
|
||||
self.assertEqual(201, site_response.status_code, site_response.text)
|
||||
site_id = site_response.json()["site"]["id"]
|
||||
|
||||
active = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(
|
||||
diff={"summary": "initial active"},
|
||||
activate=True,
|
||||
),
|
||||
)
|
||||
self.assertEqual(201, active.status_code, active.text)
|
||||
active_id = active.json()["version"]["id"]
|
||||
|
||||
next_version = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json=self._script_version_payload(diff={"summary": "next version"}),
|
||||
)
|
||||
self.assertEqual(201, next_version.status_code, next_version.text)
|
||||
next_id = next_version.json()["version"]["id"]
|
||||
|
||||
self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{next_id}/activate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
|
||||
rollback_response = self.client.post(
|
||||
f"/api/sites/{site_id}/publishing-config/versions/{active_id}/rollback",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, rollback_response.status_code, rollback_response.text)
|
||||
self.assertEqual(active_id, rollback_response.json()["version"]["id"])
|
||||
self.assertEqual(
|
||||
"ACTIVE",
|
||||
rollback_response.json()["version"]["status"],
|
||||
)
|
||||
self.assertEqual(
|
||||
next_id,
|
||||
rollback_response.json()["version"]["rollback_target_version_id"],
|
||||
)
|
||||
|
||||
get_response = self.client.get(
|
||||
f"/api/sites/{site_id}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, get_response.status_code, get_response.text)
|
||||
self.assertEqual(
|
||||
active_id,
|
||||
get_response.json()["site"]["active_script_config_version_id"],
|
||||
)
|
||||
|
||||
def test_editor_can_create_articles_and_approve_plan_review(self) -> None:
|
||||
sites_response = self.client.get(
|
||||
"/api/sites",
|
||||
@@ -215,12 +359,22 @@ class AuthAuthorizationPublicApiTest(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
def _script_version_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
def _script_version_payload(
|
||||
self,
|
||||
diff: dict[str, object] | None = None,
|
||||
rollback_target_version_id: str | None = None,
|
||||
activate: bool = False,
|
||||
) -> dict[str, object]:
|
||||
payload = {
|
||||
"publishing_yaml": "target: editorial_ops\nrepository:\n branch: main\n",
|
||||
"transform_script": "export function transformArticle(article) { return article; }\n",
|
||||
"diff": {"summary": "Task 004 authorization fixture."},
|
||||
"diff": diff or {"summary": "Task 004 authorization fixture."},
|
||||
}
|
||||
if rollback_target_version_id is not None:
|
||||
payload["rollback_target_version_id"] = rollback_target_version_id
|
||||
if activate:
|
||||
payload["activate"] = True
|
||||
return payload
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from uuid import UUID
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def repository_dsn() -> Iterator[str]:
|
||||
configured = os.environ.get("PIPELINE_TEST_DATABASE_DSN")
|
||||
if configured:
|
||||
yield configured
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
yield f"sqlite:///{Path(directory) / 'pipeline-audit.db'}"
|
||||
|
||||
|
||||
class ScriptConfigAuditTest(unittest.TestCase):
|
||||
def test_rollback_writes_audit_event(self) -> None:
|
||||
with repository_dsn() as dsn:
|
||||
from src.application.auth import get_current_user_by_email
|
||||
from src.application.seed_data import seed_reference_data
|
||||
from src.application.site_config import (
|
||||
create_script_config_version,
|
||||
create_target_site,
|
||||
rollback_script_config_version,
|
||||
)
|
||||
from src.domain.contracts import (
|
||||
PublishingRules,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
TargetSiteConfigCreateRequest,
|
||||
)
|
||||
from src.infrastructure.repositories import open_backend_repository
|
||||
|
||||
repository = open_backend_repository(dsn)
|
||||
repository.setup()
|
||||
seed_reference_data(repository)
|
||||
admin = get_current_user_by_email(repository, "admin@example.com")
|
||||
site = create_target_site(
|
||||
repository,
|
||||
TargetSiteConfigCreateRequest(
|
||||
name="Audit Test Site",
|
||||
slug="audit-site",
|
||||
publishing_type="git_next",
|
||||
default_language="en",
|
||||
brand_voice="Audit-focused publishing profile.",
|
||||
audience="QA operations team.",
|
||||
seo_rules={"primary_keyword_required": True},
|
||||
visual_rules={"hero_style": "technical"},
|
||||
source_rules={"minimum_sources": 1},
|
||||
publishing_rules=PublishingRules(
|
||||
repository_url="git@github.com:example/audit-site.git",
|
||||
production_branch="main",
|
||||
content_format="mdx",
|
||||
content_path_template="content/articles/{slug}.mdx",
|
||||
asset_path_template="public/articles/{slug}/{filename}",
|
||||
frontmatter_mapping={"title": "title"},
|
||||
dry_run_renderer="next-mdx",
|
||||
),
|
||||
active_script_config_version_id=None,
|
||||
),
|
||||
).site
|
||||
|
||||
initial = create_script_config_version(
|
||||
repository,
|
||||
site_id=site.id,
|
||||
current_user=admin,
|
||||
request=self._script_version_payload(summary="initial", activate=True),
|
||||
).version
|
||||
candidate = create_script_config_version(
|
||||
repository,
|
||||
site_id=site.id,
|
||||
current_user=admin,
|
||||
request=self._script_version_payload(
|
||||
summary="candidate",
|
||||
activate=True,
|
||||
rollback_target_version_id=initial.id,
|
||||
),
|
||||
).version
|
||||
|
||||
rolled_back = rollback_script_config_version(
|
||||
repository,
|
||||
site_id=site.id,
|
||||
version_id=initial.id,
|
||||
current_user=admin,
|
||||
)
|
||||
self.assertEqual(str(initial.id), str(rolled_back.version.id))
|
||||
|
||||
rollback_events = [
|
||||
event
|
||||
for event in repository.script_config_version_events.list_for_site(site.id)
|
||||
if event["event_type"] == "SCRIPT_CONFIG_VERSION_ROLLBACK"
|
||||
]
|
||||
self.assertEqual(1, len(rollback_events))
|
||||
rollback_event = rollback_events[0]
|
||||
self.assertEqual(str(site.id), str(rollback_event["target_site_id"]))
|
||||
self.assertEqual(str(initial.id), str(rollback_event["version_id"]))
|
||||
self.assertEqual(str(admin.id), str(rollback_event["actor_user_id"]))
|
||||
self.assertEqual(
|
||||
str(candidate.id),
|
||||
rollback_event["payload"]["rollback_target_version_id"],
|
||||
)
|
||||
|
||||
active_site = repository.target_sites.get_by_id(site.id)
|
||||
self.assertEqual(initial.id, active_site.active_script_config_version_id)
|
||||
|
||||
def _script_version_payload(
|
||||
self,
|
||||
summary: str,
|
||||
activate: bool = False,
|
||||
rollback_target_version_id: UUID | None = None,
|
||||
) -> ScriptConfigVersionCreateRequest:
|
||||
payload = ScriptConfigVersionCreateRequest(
|
||||
publishing_yaml="target:\n repo: audit\n",
|
||||
transform_script="export function transformArticle(article) { return article; }",
|
||||
diff={"summary": summary},
|
||||
activate=activate,
|
||||
)
|
||||
if rollback_target_version_id is not None:
|
||||
payload.rollback_target_version_id = rollback_target_version_id
|
||||
return payload
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user