Add CalDAV outbox recovery UI

This commit is contained in:
2026-08-02 15:57:01 +02:00
parent 4e05ab2c3e
commit 77a40ec2f9
14 changed files with 737 additions and 14 deletions
+13
View File
@@ -7,6 +7,8 @@ from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from govoplan_calendar.backend.service import CalendarError, http_request
from govoplan_calendar.backend.router import _require_worker_principal
from fastapi import HTTPException
@contextlib.contextmanager
@@ -24,6 +26,17 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
class CalendarHttpSecurityTests(unittest.TestCase):
def test_worker_routes_reject_interactive_principals(self) -> None:
class Principal:
auth_method = "session"
with self.assertRaises(HTTPException) as raised:
_require_worker_principal(Principal()) # type: ignore[arg-type]
self.assertEqual(403, raised.exception.status_code)
Principal.auth_method = "service_account"
_require_worker_principal(Principal()) # type: ignore[arg-type]
def test_cross_origin_redirect_does_not_forward_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
+61
View File
@@ -18,6 +18,7 @@ from govoplan_calendar.backend.caldav import (
from govoplan_calendar.backend.db.models import CalendarOutboxOperation
from govoplan_calendar.backend.outbox import (
SqlCalendarOutboxProvider,
calendar_outbox_operation_action_states,
claim_next_calendar_outbox_operation,
discard_calendar_outbox_operation,
dispatch_calendar_outbox,
@@ -508,6 +509,66 @@ class CalendarOutboxTests(unittest.TestCase):
operation_id=first.id,
)
def test_recovery_action_state_is_batched_and_respects_source_state(self) -> None:
event = self.create_local_event(summary="First")
self.session.commit()
operation = self.session.query(CalendarOutboxOperation).one()
state = calendar_outbox_operation_action_states(
self.session,
[operation],
)[operation.id]
self.assertFalse(state["retry"]["allowed"])
self.assertTrue(state["reconcile"]["allowed"])
self.assertTrue(state["discard"]["allowed"])
operation.status = "conflict"
self.source.sync_enabled = False
self.session.flush()
disabled = calendar_outbox_operation_action_states(
self.session,
[operation],
)[operation.id]
self.assertFalse(disabled["retry"]["allowed"])
self.assertIn("disabled", str(disabled["retry"]["reason"]))
self.assertFalse(disabled["reconcile"]["allowed"])
self.assertTrue(disabled["discard"]["allowed"])
self.source.sync_enabled = True
update_event(
self.session,
tenant_id="tenant-1",
user_id=None,
event_id=event.id,
payload=CalendarEventUpdateRequest(summary="Second"),
)
self.session.flush()
stale = calendar_outbox_operation_action_states(
self.session,
[operation],
)[operation.id]
self.assertFalse(any(item["allowed"] for item in stale.values()))
self.assertIn("newer desired state", str(stale["discard"]["reason"]))
def test_manual_retry_cannot_be_repeated_while_pending(self) -> None:
self.create_local_event()
self.session.commit()
operation = self.session.query(CalendarOutboxOperation).one()
operation.status = "dead"
self.session.flush()
retry_calendar_outbox_operation(
self.session,
tenant_id="tenant-1",
operation_id=operation.id,
)
with self.assertRaisesRegex(ValueError, "pending"):
retry_calendar_outbox_operation(
self.session,
tenant_id="tenant-1",
operation_id=operation.id,
)
def test_discard_latest_cancels_attempted_predecessor_chain(self) -> None:
event = self.create_local_event(summary="First")
self.session.commit()