488 lines
19 KiB
Python
488 lines
19 KiB
Python
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()
|