Files
wyndham-ARR/tests/test_arr_web.py
2026-07-29 16:38:05 +08:00

488 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"<root />",
)
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 <root />",
)
self.assertEqual(response.status, 202)
self.assertEqual(self.upload.calls, [("到店明细.XML", b"\xef\xbb\xbf <root />")])
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"<root><row /></root>")
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()