from __future__ import annotations import base64 import hashlib import json import tempfile import unittest from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List from types import SimpleNamespace from arr_web.app import PortalApplication, RuntimeHealth, SessionLedger from arr_web.downloads import ArtifactDescriptor, ControlledProjectArtifactReader from arr_web.services import ObjectStoreUploadCoordinator from arr_ingestion.direct_contracts import SubmissionGrant from arr_web.repository import PortalDataError from arr_storage.filesystem import FilesystemObjectBackend from arr_storage.store import ManagedObjectStore PROJECT_ROOT = Path(__file__).resolve().parents[1] STATIC_ROOT = PROJECT_ROOT / "arr_web" / "static" class FakeRepository: def __init__(self) -> None: self.months = [ { "month_key": "2026-07", "max_arrival_date": "2026-07-26", "updated_at": "2026-07-28T12:00:00+08:00", "filename": "monthly.xlsx", "source_monthly_sha256": "a" * 64, "channel_count": 2, "row_count": 3, } ] def list_jobs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]: return [{"job_id": "job-1", "status": "succeeded", "filename": "ARR.XML"}] def list_monthly_runs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]: return [{"report_id": 3, "version_no": 3, "status": "active"}] def list_months(self) -> List[Dict[str, Any]]: return self.months def read_dashboard(self, month_key: str) -> Dict[str, Any]: if month_key == "2026-06": raise PortalDataError( "ANALYTICS_MONTH_NOT_FOUND", "current monthly database projection was not found", ) return { "version": "1.2", "month_key": month_key, "updated_at": "2026-07-28T12:00:00+08:00", "max_arrival_date": "2026-07-26", "source_monthly_sha256": "a" * 64, "overall": { "totals": { "rooms_sold": 3, "total_price": 1200, "room_nights": 5, "reservation_rows": 3, "room_type_count": 1, "channel_count": 2, }, "room_types": [{"room_type": "KING", "rooms_sold": 3}], }, "channels": [ { "worksheet": "COMPANY-A", "totals": {"rooms_sold": 2, "total_price": 800}, "room_types": [{"room_type": "KING", "rooms_sold": 2}], }, { "worksheet": "COMPANY-B", "totals": {"rooms_sold": 1, "total_price": 400}, "room_types": [{"room_type": "KING", "rooms_sold": 1}], }, ], } def read_channel_detail( self, month_key: str, worksheet: str, limit: int, offset: int, ) -> Dict[str, Any]: return {"month_key": month_key, "worksheet": worksheet, "rows": []} def resolve_daily_download(self, job_id: str) -> ArtifactDescriptor: return ArtifactDescriptor( "daily_xlsx", "7.26.xlsx", "private/daily.xlsx", "a" * 64, 4, "application/xlsx" ) def resolve_monthly_download(self, report_id: int) -> ArtifactDescriptor: return ArtifactDescriptor( "monthly_xlsx", "月报.xlsx", "private/monthly.xlsx", "b" * 64, 4, "application/xlsx" ) class FakeUpload: def __init__(self) -> None: self.calls: list[tuple[str, bytes]] = [] def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]: self.calls.append((original_filename, payload)) return {"job_id": "arr-job-1", "status": "queued"} class FakeMonthly: def __init__(self) -> None: self.calls: list[tuple[str, date]] = [] def generate(self, month_key: str, as_of_date: date) -> Dict[str, Any]: self.calls.append((month_key, as_of_date)) return {"status": "success", "month_key": month_key} class FakeAgentResults: def __init__(self) -> None: self.calls: list[bytes] = [] def accept(self, raw_signed_result: bytes) -> Dict[str, Any]: self.calls.append(raw_signed_result) return { "status": "committed", "job_id": "arr-job-1", "business_date": "2026-07-27", "daily_version_id": 9, "version_no": 2, "callback_replayed": False, } class FakeArtifactReader: def read(self, descriptor: ArtifactDescriptor) -> bytes: return b"xlsx" def decoded(response: Any) -> Dict[str, Any]: return json.loads(response.body.decode("utf-8")) class PortalApplicationTests(unittest.TestCase): def setUp(self) -> None: self.upload = FakeUpload() self.monthly = FakeMonthly() self.agent_results = FakeAgentResults() self.app = PortalApplication( repository=FakeRepository(), upload=self.upload, agent_results=self.agent_results, monthly=self.monthly, health=RuntimeHealth(True, True, True, agent_writeback_ready=True), sessions=SessionLedger(), ) def session_headers(self) -> Dict[str, str]: response = self.app.handle("GET", "/api/session", {}) payload = decoded(response) cookie = response.headers["Set-Cookie"].split(";", 1)[0] return {"Cookie": cookie, "X-ARR-CSRF": payload["data"]["csrf_token"]} def test_session_cookie_is_secure_only_when_explicitly_enabled(self) -> None: default_cookie = self.app.handle("GET", "/api/session", {}).headers[ "Set-Cookie" ] secure_cookie = PortalApplication( sessions=SessionLedger(), secure_cookies=True, ).handle("GET", "/api/session", {}).headers["Set-Cookie"] self.assertNotIn("; Secure", default_cookie) self.assertIn("; Secure", secure_cookie) self.assertIn("; HttpOnly", secure_cookie) self.assertIn("; SameSite=Strict", secure_cookie) def test_fixed_static_routes_and_required_copy(self) -> None: desktop = self.app.handle("GET", "/", {}) mobile = self.app.handle("GET", "/h5", {}) self.assertEqual(desktop.status, 200) self.assertEqual(mobile.status, 200) desktop_text = desktop.body.decode("utf-8") mobile_text = mobile.body.decode("utf-8") for expected in ( "ARRIVAL DATE", "NO. OF ROOM", "标准月报", "渠道BI", "公司渠道明细", "一次生成五家公司", "Asia/Bangkok", "各公司本月销售情况", "渠道 × 房型售出房数", ): self.assertIn(expected, desktop_text) self.assertIn("各公司本月销售情况", mobile_text) self.assertIn("泰国曼谷(UTC+7)", mobile_text) def test_all_public_assets_exclude_removed_copy(self) -> None: content = "\n".join( path.read_text(encoding="utf-8") for path in STATIC_ROOT.iterdir() if path.suffix in {".html", ".css", ".js"} ).lower() for removed in ( "codex", "本地报表处理", "业务月份", "渠道工作表", "所有渠道工作表", "room mix", "channel mix", "房型售卖排行", "下载后更新月报", "源记录", "白名单删除", "重复删除", "最终输出", ): self.assertNotIn(removed, content) self.assertIn("正在处理arr.xml文件", content) def test_read_apis_return_wrapped_database_data(self) -> None: jobs = decoded(self.app.handle("GET", "/api/jobs?month=2026-07", {})) monthly = decoded(self.app.handle("GET", "/api/monthly-runs?month=2026-07", {})) analytics = decoded(self.app.handle("GET", "/api/analytics?month=2026-07", {})) h5_months = decoded(self.app.handle("GET", "/api/h5/months", {})) compatible_analytics = decoded( self.app.handle("GET", "/api/monthly/2026-07/analytics", {}) ) self.assertEqual(jobs["data"][0]["job_id"], "job-1") self.assertEqual(monthly["data"][0]["version_no"], 3) self.assertEqual(analytics["data"]["channels"][0]["worksheet"], "COMPANY-A") self.assertEqual( h5_months, { "months": [ { "month_key": "2026-07", "updated_at": "2026-07-28T12:00:00+08:00", "max_arrival_date": "2026-07-26", } ] }, ) self.assertNotIn("ok", compatible_analytics) self.assertEqual(compatible_analytics["version"], "1.2") self.assertEqual( compatible_analytics["channels"][0]["worksheet"], "COMPANY-A", ) def test_legacy_h5_analytics_route_preserves_status_contract(self) -> None: invalid = self.app.handle( "GET", "/api/monthly/2026-13/analytics", {}, ) missing = self.app.handle( "GET", "/api/monthly/2026-06/analytics", {}, ) self.assertEqual(invalid.status, 422) self.assertEqual(decoded(invalid)["error"]["code"], "MONTH_KEY_INVALID") self.assertEqual(missing.status, 404) self.assertEqual(decoded(missing)["error"]["code"], "MONTHLY_NOT_FOUND") def test_upload_requires_csrf_and_accepts_raw_xml_only(self) -> None: encoded = base64.urlsafe_b64encode("到店明细.XML".encode("utf-8")).decode("ascii").rstrip("=") no_session = self.app.handle( "POST", "/api/jobs", {"X-ARR-Filename-B64": encoded}, b"", ) self.assertEqual(no_session.status, 403) response = self.app.handle( "POST", "/api/jobs", {**self.session_headers(), "X-ARR-Filename-B64": encoded}, b"\xef\xbb\xbf ", ) self.assertEqual(response.status, 202) self.assertEqual(self.upload.calls, [("到店明细.XML", b"\xef\xbb\xbf ")]) def test_upload_rejects_non_xml_bytes_before_coordinator(self) -> None: encoded = base64.urlsafe_b64encode(b"report.xml").decode("ascii").rstrip("=") response = self.app.handle( "POST", "/api/jobs", {**self.session_headers(), "X-ARR-Filename-B64": encoded}, b"not xml", ) self.assertEqual(response.status, 400) self.assertEqual(decoded(response)["error"]["code"], "UPLOAD_CONTENT_INVALID") self.assertEqual(self.upload.calls, []) def test_monthly_generation_is_csrf_protected_and_typed(self) -> None: response = self.app.handle( "POST", "/api/monthly-runs", {**self.session_headers(), "Content-Type": "application/json"}, b'{"month":"2026-07","as_of_date":"2026-07-26"}', ) self.assertEqual(response.status, 202) self.assertEqual(self.monthly.calls, [("2026-07", date(2026, 7, 26))]) def test_agent_result_callback_is_machine_authenticated_not_csrf_bound(self) -> None: signed = b'{"signed_envelope_version":"arr-processing-signed-1"}' response = self.app.handle( "POST", "/api/integrations/super-agent/results", {"Content-Type": "application/json; charset=utf-8"}, signed, ) self.assertEqual(response.status, 200) payload = decoded(response) self.assertEqual(payload["data"]["status"], "committed") self.assertEqual(payload["data"]["daily_version_id"], 9) self.assertEqual(self.agent_results.calls, [signed]) wrong_type = self.app.handle( "POST", "/api/integrations/super-agent/results", {"Content-Type": "text/plain"}, signed, ) self.assertEqual(wrong_type.status, 415) self.assertEqual(len(self.agent_results.calls), 1) def test_download_uses_internal_descriptor_without_exposing_storage_key(self) -> None: app = PortalApplication( repository=FakeRepository(), artifact_reader=FakeArtifactReader(), health=RuntimeHealth(True, True, True, True), ) response = app.handle("GET", "/api/download/monthly?report_id=3", {}) self.assertEqual(response.status, 200) self.assertEqual(response.body, b"xlsx") self.assertIn("filename*=UTF-8''", response.headers["Content-Disposition"]) self.assertEqual(response.headers["Cache-Control"], "private, no-store") self.assertNotIn("private/monthly.xlsx", str(response.headers)) def test_query_validation_fails_without_repository_call(self) -> None: response = self.app.handle("GET", "/api/jobs?month=2026-13", {}) self.assertEqual(response.status, 400) self.assertEqual(decoded(response)["error"]["code"], "MONTH_INVALID") class UnavailableDataTests(unittest.TestCase): def test_repository_failures_are_privacy_safe_503(self) -> None: class Broken(FakeRepository): def list_jobs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]: raise PortalDataError("DATABASE_UNAVAILABLE", "数据库连接失败") response = PortalApplication(repository=Broken()).handle( "GET", "/api/jobs?month=2026-07", {} ) self.assertEqual(response.status, 503) payload = decoded(response) self.assertEqual(payload["error"]["message"], "数据库连接失败") self.assertNotIn("password", response.body.decode("utf-8").lower()) class ControlledDownloadTests(unittest.TestCase): def test_reader_rehashes_controlled_file(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) path = root / "outputs" / "report.xlsx" path.parent.mkdir() path.write_bytes(b"trusted-xlsx") payload = path.read_bytes() descriptor = ArtifactDescriptor( "monthly_xlsx", "月报.xlsx", "outputs/report.xlsx", hashlib.sha256(payload).hexdigest(), len(payload), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) self.assertEqual(ControlledProjectArtifactReader(root).read(descriptor), payload) def test_reader_rejects_hash_mismatch_and_parent_escape(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) path = root / "report.xlsx" path.write_bytes(b"changed") bad_hash = ArtifactDescriptor( "daily_xlsx", "daily.xlsx", "report.xlsx", "0" * 64, len(b"changed"), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) with self.assertRaisesRegex(Exception, "完整性"): ControlledProjectArtifactReader(root).read(bad_hash) escaped = ArtifactDescriptor( "daily_xlsx", "daily.xlsx", "../report.xlsx", "0" * 64, 1, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) with self.assertRaisesRegex(Exception, "文件身份"): ControlledProjectArtifactReader(root).read(escaped) class ObjectStoreUploadCoordinatorTests(unittest.TestCase): def test_upload_commits_private_source_registers_job_then_dispatches_handle(self) -> None: events: list[str] = [] class Repository: registration: Any = None def register_job(self, registration: Any) -> None: events.append("register") self.registration = registration def issue_grant( self, job_id: str, attempt_no: int, *, ttl_seconds: int, ) -> SubmissionGrant: events.append("grant") self_outer.assertEqual(ttl_seconds, 900) return SubmissionGrant( "G" * 43, job_id, attempt_no, datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds), ) class Starter: request: Any = None def start(self, request: Any) -> Any: events.append("dispatch") self.request = request return SimpleNamespace(remote_status="queued") with tempfile.TemporaryDirectory() as temporary: self_outer = self backend = FilesystemObjectBackend(Path(temporary) / "objects", create=True) store = ManagedObjectStore(backend) repository = Repository() starter = Starter() coordinator = ObjectStoreUploadCoordinator( object_store=store, ingestion_repository=repository, processing_starter=starter, processor_version="3.0.0", rule_set_sha256="a" * 64, ) receipt = coordinator.submit("ARRIVAL.XML", b"") self.assertEqual(events, ["register", "grant", "dispatch"]) self.assertEqual(receipt["status"], "queued") self.assertNotIn("object_key", receipt) self.assertNotIn("ARRIVAL.XML", repository.registration.source.object_key) self.assertEqual(repository.registration.source.original_filename, "source.xml") committed = store.inspect_committed( repository.registration.source.object_key, "source.xml", ) self.assertEqual(committed.sha256, receipt["source_sha256"]) self.assertTrue(starter.request.source_file_id.startswith("arr_file_")) self.assertEqual(starter.request.submission_grant, "G" * 43) self.assertNotIn("G" * 43, starter.request.message()) self.assertNotIn("G" * 43, repr(starter.request)) self.assertNotIn(repository.registration.source.object_key, starter.request.message()) self.assertNotIn(str(Path(temporary)), starter.request.message()) self.assertEqual(len(repository.registration.idempotency_key), 64) if __name__ == "__main__": unittest.main()