"""Local OHIP configuration acceptance. Never contacts the hotel or real platform.""" from datetime import date import json import os from pathlib import Path import subprocess import tempfile import unittest from unittest.mock import Mock, patch from arr_web import local_ohip as local from arr_web.arr_downloads import DownloadOutcome from arr_web.contracts import PortalError from arr_web.local_xml_replay import document from integrations.ohip.capture_job import atomic_json PORT = 18876 HOST = {"Host": f"127.0.0.1:{PORT}", "Content-Type": "application/json"} class LocalOHIPTests(unittest.TestCase): def setUp(self): temp = tempfile.TemporaryDirectory(prefix="arr-local-ohip-test-") self.addCleanup(temp.cleanup) self.parent = Path(temp.name) self.credential = self.parent / "credential.json" atomic_json(self.credential, {"version": "ohip.application-credential/v1", "status": "ready", "service_url": local.SERVICE, "application_id": local.APPLICATION, "value": "fictional-offline-key", "issuance_id": "fictional-issuance"}, replace=False) self.root = local.create(self.parent, self.credential) self.access = local.AccessState(self.root) def grant(self, groups=None): atomic_json(self.root / "access.json", { "version": "arr-ohip-access/v1", "service_url": local.SERVICE, "application_id": local.APPLICATION, "hotel_id": local.HOTEL, "owner_user_id": local.OWNER, "automation_principal_id": local.PRINCIPAL, "enabled": True, "key_active": True, "capability_groups": sorted(local.REQUIRED if groups is None else groups), "checked_at": "2026-09-18T00:00:00+00:00"}, replace=True) def login(self, app): response = app.handle("POST", "/api/login", HOST, json.dumps(document(self.root / "login.json")).encode()) self.assertEqual(response.status, 200) self.assertTrue(response.headers["Set-Cookie"].startswith("arr_local_ohip_session=")) headers = {**HOST, "Cookie": response.headers["Set-Cookie"].split(";", 1)[0]} session = app.handle("GET", "/api/session", headers) headers["X-ARR-CSRF"] = json.loads(session.body)["data"]["csrf_token"] return headers def test_missing_or_corrupt_access_blocks_create_and_retry_without_queue_calls(self): queue = Mock(ready=True) downloads = local.PermissionDownloads(queue, self.access) for contents in (None, {"version": "wrong"}): if contents: atomic_json(self.root / "access.json", contents, replace=True) self.assertFalse(downloads.ready) with self.assertRaises(PortalError): downloads.create("2026-09-18", "a" * 32) with self.assertRaises(PortalError): downloads.retry("a" * 32) queue.create.assert_not_called(); queue.retry.assert_not_called() def test_grant_refresh_enables_arbitrary_selected_dates_without_fixed_fixture(self): queue = Mock(ready=True) downloads = local.PermissionDownloads(queue, self.access) self.grant({"profiles.read", "reservations.read", "configuration.read"}) self.assertFalse(downloads.ready) self.assertEqual(self.access.status()["missing_capabilities"], ["blocks.read"]) self.grant() self.assertTrue(downloads.ready) for day in ("2026-09-17", "2026-08-02"): downloads.create(day, "a" * 32) queue.create.assert_called_with(day, "a" * 32) self.grant({"profiles.read"}) executor = Mock() with self.assertRaises(PortalError): local.AuthorizedExecutor(executor, self.access).execute(request_id="a" * 32) executor.execute.assert_not_called() def test_pending_source_reviews_are_readable_without_new_access_or_acquisition(self): pending = [{"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_data_review"}] queue = Mock(ready=True) queue.pending_data_reviews.return_value = pending access = Mock() access.status.return_value = {"ready": False} downloads = local.PermissionDownloads(queue, access) self.assertFalse(downloads.ready) self.assertEqual(downloads.pending_data_reviews(), pending) queue.pending_data_reviews.assert_called_once_with() access.require_ready.assert_not_called() queue.create.assert_not_called() def test_source_policy_identity_cannot_be_rebound_to_demo_or_another_hotel(self): config = document(self.root / "instance.json") config["hotel_id"] = "OTHER" atomic_json(self.root / "instance.json", config, replace=True) with patch.object(local.ReplayDatabase, "start") as start: with self.assertRaisesRegex(ValueError, "identity_or_rules"): with local.open_portal(self.root, PORT): pass start.assert_not_called() def platform_replies(self, groups=None): def reply(args, **kwargs): tail = args[1:] self.assertFalse(kwargs["env"].get("OHIP_EDGE_AUTOMATION_TOKEN")) if tail == ["automation", "whoami"]: data = {"principal": {"id": local.PRINCIPAL, "owner_user_id": local.OWNER}} elif tail == ["integration", "list"]: data = {"items": [{"id": local.APPLICATION, "owner_user_id": local.OWNER, "automation_principal_id": local.PRINCIPAL, "enabled": True, "capability_groups": list(local.REQUIRED if groups is None else groups)}]} elif tail == ["integration", "key", "list", "--application-id", local.APPLICATION]: data = {"items": [{"id": "fictional-key-id", "issuance_id": "fictional-issuance"}]} else: self.fail(f"Unexpected command: {tail}") return subprocess.CompletedProcess(args, 0, json.dumps({"ok": True, "request_id": "offline-test", "data": data})) return reply def test_control_plane_check_does_not_issue_keys_apply_permissions_or_query_business(self): with patch.object(local.subprocess, "run", side_effect=self.platform_replies()) as run: status = local.check_access(self.root, self.parent / "automation.json", Path("/offline/ohipctl")) self.assertTrue(status["ready"]) self.assertEqual(run.call_count, 3) self.assertNotIn("fictional-offline-key", (self.root / "access.json").read_text()) self.assertEqual(document(self.root / "access.json")["key_id"], "fictional-key-id") def test_failed_control_plane_refresh_clears_previous_readiness(self): self.grant() with patch.object(local.subprocess, "run", side_effect=subprocess.TimeoutExpired("offline", 30)): with self.assertRaises(subprocess.TimeoutExpired): local.check_access(self.root, self.parent / "automation.json", Path("/offline/ohipctl")) self.assertFalse(self.access.status()["ready"]) @unittest.skipUnless(os.environ.get("ARR_TEST_LOCAL_POSTGRES") == "1", "owned local PostgreSQL opt-in required") def test_pending_runtime_login_page_upload_and_health_work_without_business_calls(self): self.grant({"reservations.read", "profiles.read", "configuration.read"}) with patch.object(local.ARRDataSource, "fetch", side_effect=AssertionError("business call forbidden")) as fetch: with local.open_portal(self.root, PORT) as runtime: runtime.start_monthly_worker() app = runtime.app headers = self.login(app) self.assertEqual(app.handle("GET", "/healthz", HOST).status, 200) self.assertEqual(app.handle("GET", "/api/arr-downloads", HOST).status, 401) config = json.loads(app.handle("GET", "/api/arr-downloads", headers).body)["data"] self.assertFalse(config["ready"]) self.assertIsNone(config["latest_task"]) self.assertEqual(config["source_kind"], "ohip_platform") self.assertEqual(config["hotel_id"], local.HOTEL) self.assertNotIn("source_records", config) page = app.handle("GET", "/", headers).body.decode() self.assertIn("等待平台补齐团队资料读取权限", page) self.assertNotIn("未连接 Oracle,处理结果仅用于测试", page) self.assertIn("上传", page) body = json.dumps({"report_date": "2026-08-02", "request_id": "a" * 32}).encode() self.assertEqual(app.handle("POST", "/api/arr-downloads", headers, body).status, 503) self.assertIsNone(runtime.queue.latest()) self.assertEqual(app.handle("GET", "/", {"Host": "outside"}).status, 403) self.assertEqual(app.handle("GET", "/", {**headers, "Origin": "https://outside"}).status, 403) self.grant() ready = json.loads(app.handle("GET", "/api/arr-downloads", headers).body)["data"] self.assertTrue(ready["ready"]) self.assertIn("选日期后点击下载才会查询订单", app.handle("GET", "/", headers).body.decode()) with local.open_portal(self.root, PORT) as restarted: self.assertIsNone(restarted.queue.latest()) fetch.assert_not_called() @unittest.skipUnless(os.environ.get("ARR_TEST_LOCAL_POSTGRES") == "1", "owned local PostgreSQL opt-in required") def test_selected_day_reaches_executor_only_after_explicit_submission(self): import time self.grant() with patch.object(local.ARRDataSource, "fetch", side_effect=AssertionError("hotel calls forbidden")) as fetch: with patch.object(local.DirectARRExecutor, "execute", return_value=DownloadOutcome("failed")) as execute: with local.open_portal(self.root, PORT) as runtime: headers = self.login(runtime.app) execute.assert_not_called() body = json.dumps({"report_date": "2026-08-02", "request_id": "b" * 32}).encode() self.assertEqual(runtime.app.handle("POST", "/api/arr-downloads", headers, body).status, 202) deadline = time.monotonic() + 5 while not execute.called and time.monotonic() < deadline: time.sleep(.02) execute.assert_called_once() self.assertEqual(execute.call_args.kwargs["from_date"], date(2026, 8, 2)) self.assertEqual(execute.call_args.kwargs["to_date"], date(2026, 8, 2)) fetch.assert_not_called() if __name__ == "__main__": unittest.main()