121 lines
7.0 KiB
Python
121 lines
7.0 KiB
Python
"""HTTP identity, authorization and no-side-effect boundaries for daily retirement."""
|
|
import json
|
|
from datetime import date
|
|
from unittest import TestCase
|
|
from unittest.mock import Mock
|
|
|
|
from arr_ingestion.contracts import IngestionError
|
|
from arr_web.app import PortalApplication
|
|
from arr_web.daily_reports import ProgramDailyReports
|
|
from arr_web.local_xml_replay import LocalReplayPortal
|
|
from tests.test_arr_web import TEST_CREDENTIALS, decoded, login
|
|
|
|
|
|
class DailyReportApiTests(TestCase):
|
|
def setUp(self):
|
|
self.repository = Mock()
|
|
self.retirement = Mock()
|
|
self.app = PortalApplication(repository=self.repository, daily_reports=self.retirement,
|
|
credentials=TEST_CREDENTIALS)
|
|
_, self.headers = login(self.app)
|
|
self.day = "2026-09-16"
|
|
self.group = {"business_date": self.day, "current_job": None,
|
|
"latest_job": None, "history_count": 0}
|
|
self.repository.list_daily_reports.return_value = ([self.group], 1)
|
|
self.repository.get_daily_report.return_value = self.group
|
|
self.repository.list_daily_report_history.return_value = ([], 0)
|
|
|
|
def delete(self, payload=None, headers=None):
|
|
return self.app.handle("DELETE", "/api/jobs/arrjob-synthetic",
|
|
self.headers if headers is None else headers,
|
|
json.dumps(payload or {"business_date": self.day}).encode())
|
|
|
|
def test_grouped_pagination_counts_dates_and_preserves_single_day_context(self):
|
|
response = self.app.handle("GET", "/api/daily-reports?month=2026-09&limit=2&offset=4", self.headers)
|
|
self.assertEqual(response.status, 200)
|
|
self.assertEqual(decoded(response)["data"], [self.group])
|
|
self.repository.list_daily_reports.assert_called_once_with("2026-09", 2, 4)
|
|
result = self.app.handle("GET", "/api/daily-reports/2026-09-16", self.headers)
|
|
self.assertEqual(decoded(result)["data"], self.group)
|
|
history = self.app.handle("GET", "/api/daily-reports/2026-09-16/history?limit=5&offset=10", self.headers)
|
|
self.assertEqual(history.status, 200)
|
|
self.repository.list_daily_report_history.assert_called_once_with(self.day, 5, 10)
|
|
|
|
def test_all_new_reads_and_delete_require_login(self):
|
|
for path in ("/api/daily-reports", "/api/daily-reports/2026-09-16",
|
|
"/api/daily-reports/2026-09-16/history"):
|
|
self.assertEqual(self.app.handle("GET", path, {}).status, 401)
|
|
self.assertEqual(self.delete(headers={}).status, 401)
|
|
self.retirement.retire.assert_not_called()
|
|
|
|
def test_delete_requires_csrf_and_exact_date_body(self):
|
|
headers = {key: value for key, value in self.headers.items() if key.lower() != "x-arr-csrf"}
|
|
self.assertEqual(self.delete(headers=headers).status, 403)
|
|
for payload in ({"business_date": self.day, "all": True}, {"job_id": "other"},
|
|
{"business_date": "2026-09-31"}, {"business_date": None}):
|
|
self.assertGreaterEqual(self.delete(payload).status, 400)
|
|
self.retirement.retire.assert_not_called()
|
|
|
|
def test_delete_binds_route_job_date_and_authenticated_actor(self):
|
|
self.retirement.retire.return_value = {"job_id": "arrjob-synthetic", "business_date": self.day,
|
|
"status": "deleted", "removed_from_month": True}
|
|
result = self.delete()
|
|
self.assertEqual(result.status, 200)
|
|
self.retirement.retire.assert_called_once_with("arrjob-synthetic", self.day, TEST_CREDENTIALS.username)
|
|
self.assertTrue(decoded(result)["data"]["removed_from_month"])
|
|
|
|
def test_unconfigured_mutation_is_unavailable_without_affecting_reads(self):
|
|
app = PortalApplication(repository=self.repository, credentials=TEST_CREDENTIALS)
|
|
_, headers = login(app)
|
|
result = app.handle("DELETE", "/api/jobs/arrjob-synthetic", headers,
|
|
json.dumps({"business_date": self.day}).encode())
|
|
self.assertEqual(result.status, 503)
|
|
self.assertEqual(decoded(result)["error"]["code"], "DAILY_RETIREMENT_UNAVAILABLE")
|
|
|
|
def test_get_requests_never_retire_and_invalid_reads_are_rejected(self):
|
|
for path in ("/api/daily-reports?month=wrong", "/api/daily-reports/2026-09-31",
|
|
"/api/daily-reports/2026-09-16/history?limit=0"):
|
|
self.assertGreaterEqual(self.app.handle("GET", path, self.headers).status, 400)
|
|
self.retirement.retire.assert_not_called()
|
|
|
|
def test_deleted_ohip_job_converges_without_requery_or_retry(self):
|
|
self.repository.get_job_trace.return_value = {"job": {"status": "deleted"}}
|
|
for status in ("needs_review", "succeeded"):
|
|
task = {"request_id": "a" * 32, "report_date": self.day,
|
|
"job_id": "arrjob-synthetic", "status": status, "can_retry": True}
|
|
result = self.app._arr_download_snapshot(task)
|
|
self.assertEqual(result["status"], "deleted")
|
|
self.assertFalse(result["can_retry"])
|
|
self.retirement.retire.assert_not_called()
|
|
|
|
def test_local_wrapper_allows_only_exact_authenticated_delete_route(self):
|
|
portal = LocalReplayPortal(self.app, Mock(), 8875)
|
|
headers = {**self.headers, "host": "127.0.0.1:8875", "origin": "http://127.0.0.1:8875"}
|
|
headers["Cookie"] = headers["Cookie"].replace("arr_session=", portal.cookie_name + "=")
|
|
self.retirement.retire.return_value = {"status": "deleted"}
|
|
result = portal.handle("DELETE", "/api/jobs/arrjob-synthetic", headers,
|
|
json.dumps({"business_date": self.day}).encode())
|
|
self.assertEqual(result.status, 200)
|
|
self.assertEqual(portal.handle("DELETE", "/api/jobs", headers).status, 405)
|
|
|
|
|
|
class DailyRetirementCoordinatorTests(TestCase):
|
|
def test_receipt_reports_actual_current_removal_after_concurrent_replacement(self):
|
|
repository = Mock()
|
|
repository.retire_daily_job.return_value = {"current_removed": False, "is_retired": True}
|
|
result = ProgramDailyReports(repository).retire("job-synthetic", "2026-09-16", "operator")
|
|
repository.retire_daily_job.assert_called_once_with("job-synthetic", date(2026, 9, 16), "operator")
|
|
self.assertEqual(result["status"], "deleted")
|
|
self.assertFalse(result["removed_from_month"])
|
|
|
|
def test_busy_or_changed_job_is_conflict_missing_job_404_database_503(self):
|
|
for code, status in (("DAILY_JOB_BUSY", 409), ("DAILY_DATE_CONFLICT", 409),
|
|
("DAILY_JOB_NOT_FOUND", 404), ("DATABASE_WRITE_FAILED", 503)):
|
|
with self.subTest(code=code):
|
|
repository = Mock()
|
|
repository.retire_daily_job.side_effect = IngestionError(code, "private detail")
|
|
with self.assertRaises(Exception) as caught:
|
|
ProgramDailyReports(repository).retire("job-synthetic", "2026-09-16", "operator")
|
|
self.assertEqual(caught.exception.status, status)
|
|
self.assertNotIn("private detail", str(caught.exception))
|