Files
wyndham-ARR/tests/test_arr_web.py
2026-08-02 12:56:22 +08:00

878 lines
34 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
from pathlib import Path
from typing import Any, Dict, List
from arr_web.app import PortalApplication, RuntimeHealth, SessionLedger
from arr_web.auth import LoginCredentials
from arr_web.downloads import (
ArtifactDescriptor,
ControlledProjectArtifactReader,
ManagedObjectArtifactReader,
RoutedArtifactReader,
)
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"
TEST_CREDENTIALS = LoginCredentials(
username="arr-test-operator",
password="arr-test-password",
)
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 = 50,
offset: int = 0,
) -> tuple[List[Dict[str, Any]], int]:
return [
{"job_id": "job-1", "status": "succeeded", "filename": "ARR.XML"}
], 1
def get_job_trace(self, job_id: str) -> Dict[str, Any]:
return {
"trace_version": "arr-job-trace-2",
"job": {
"job_id": job_id,
"status": "succeeded",
"active": False,
"current_stage": "database",
},
"evidence": {"attempts": 1},
"logs": [
{
"id": "run.received",
"timestamp": "2026-07-29T11:00:00+00:00",
"stage": "upload",
"level": "success",
"code": "UPLOAD_REGISTERED",
"title": "上传已安全登记",
"message": "任务已创建",
}
],
}
def list_monthly_runs(
self,
month_key: str,
limit: int = 50,
offset: int = 0,
) -> tuple[List[Dict[str, Any]], int]:
return [
{
"report_id": 3,
"version_no": 3,
"status": "active",
"max_arrival_date": "2026-07-26",
}
], 1
def list_history_month_counts(self) -> List[Dict[str, Any]]:
return [
{
"month_key": "2026-07",
"daily_count": 3,
"monthly_count": 2,
}
]
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",
"min_arrival_date": "2026-07-01",
"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 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"))
def login(
app: PortalApplication,
) -> tuple[Any, Dict[str, str]]:
response = app.handle(
"POST",
"/api/login",
{"Content-Type": "application/json"},
json.dumps(
{
"username": TEST_CREDENTIALS.username,
"password": TEST_CREDENTIALS.password,
}
).encode("utf-8"),
)
if response.status != 200:
raise AssertionError(f"test login failed with HTTP {response.status}")
payload = decoded(response)
cookie = response.headers["Set-Cookie"].split(";", 1)[0]
return response, {
"Cookie": cookie,
"X-ARR-CSRF": payload["data"]["csrf_token"],
}
class PortalApplicationTests(unittest.TestCase):
def setUp(self) -> None:
self.upload = FakeUpload()
self.monthly = FakeMonthly()
self.app = PortalApplication(
repository=FakeRepository(),
upload=self.upload,
monthly=self.monthly,
health=RuntimeHealth(True, True, True),
sessions=SessionLedger(),
credentials=TEST_CREDENTIALS,
)
_, self.auth_headers = login(self.app)
def test_daily_history_hides_internal_filename_when_provenance_is_missing(self) -> None:
script = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
self.assertIn('job.filename || ""', script)
def session_headers(self) -> Dict[str, str]:
return dict(self.auth_headers)
def test_session_cookie_is_secure_only_when_explicitly_enabled(self) -> None:
default_cookie = login(self.app)[0].headers["Set-Cookie"]
secure_app = PortalApplication(
sessions=SessionLedger(),
secure_cookies=True,
credentials=TEST_CREDENTIALS,
)
secure_cookie = login(secure_app)[0].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_content(self) -> None:
desktop = self.app.handle("GET", "/", self.auth_headers)
mobile = self.app.handle("GET", "/h5", self.auth_headers)
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 (
'<span class="brand-name">ARR Report</span>',
'<h2 id="daily-history-title">Daily Report</h2>',
"ARRIVAL DATE",
"NO. OF ROOM",
"月报",
"月报处理",
"渠道BI",
"公司渠道明细",
"各公司本月销售情况",
"渠道 × 房型售出房数",
"任务日志",
"arr-task.log",
"$ arr trace",
):
self.assertIn(expected, desktop_text)
for removed in (
"标准月报",
"VERSION HISTORY",
"标准月报版本记录",
"公司渠道明细按 C/O离店日所属期间分页",
):
self.assertNotIn(removed, desktop_text)
self.assertNotIn(
'<table class="monthly-table">\n <thead><tr><th>版本</th>',
desktop_text,
)
for required_id in (
'id="process-log"',
'id="trace-live-state"',
'id="refresh-trace"',
'id="copy-trace"',
'id="jobs-prev"',
'id="jobs-next"',
'id="monthly-prev"',
'id="monthly-next"',
'id="company-prev"',
'id="company-next"',
'id="company-excel-file"',
'id="company-upload-button"',
'id="company-review-panel"',
'id="company-review-filename"',
'id="company-review-body"',
'id="company-review-activate"',
'id="company-review-discard"',
'id="company-review-delete-selected"',
'id="company-review-select-page"',
'id="company-review-confirm-dialog"',
'id="company-report-confirm-dialog"',
'id="company-report-confirm-cancel"',
'id="company-report-confirm-submit"',
):
self.assertIn(required_id, desktop_text)
self.assertIn("复制全部日志", desktop_text)
self.assertIn('<pre class="trace-log"', desktop_text)
self.assertNotIn('id="trace-stage-filter"', desktop_text)
self.assertNotIn('id="trace-level-filter"', desktop_text)
self.assertNotIn('id="trace-search"', desktop_text)
self.assertNotIn("process-orbit", desktop_text)
self.assertNotIn('class="pipeline"', desktop_text)
self.assertNotIn("brand-mark", desktop_text)
self.assertNotIn("DAILY ARRIVAL", desktop_text)
self.assertNotIn(
"上传 Opera ARR.XML处理结果验收通过后写入业务数据库。",
desktop_text,
)
self.assertNotIn("DATABASE REPORT", desktop_text)
self.assertNotIn(
"按数据库中已验收的日报事实生成月报,版本与文件月内持续留痕。",
desktop_text,
)
self.assertNotIn("自动发布", desktop_text)
self.assertNotIn(
"日报入库后自动生成;“更新至”取本月数据中的最新 ARRIVAL。"
"发布后列表自动新增,无需手动刷新。",
desktop_text,
)
for removed in (
"COMPANY DETAIL REPORTS",
"基于 finance 已校验数据,按 C/O离店日所属期间生成五家公司 Excel。",
"FINANCE DATA",
"选择月份和本月生成期间",
"点击任一期间后,一次固定生成 LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour 五份工作簿。",
"本月生成期间(一次生成五家公司)",
"同月重跑会生成新的正式版本并保留历史归档;浏览器只提交月份和期间,不提交公司名单、数据库参数或文件路径。",
"ROOMS SOLD",
"支持已开放期间重跑和历史月份补录。",
"页面与期间时区",
"公司已生成",
"TASK HISTORY",
"公司渠道明细生成记录",
"程序读取 Tour Code 与 โรงแรม,自动拆分房型和数量;异常项目可在下方人工确认。",
"提取草稿",
"核对房型记录",
"还有 12 条待人工记录;填写房型并保存,或删除不需要的记录。",
"当前数据源",
):
self.assertNotIn(removed, desktop_text)
self.assertNotIn("ROOMS SOLD", mobile_text)
self.assertNotIn("company-timezone", desktop_text)
styles = (STATIC_ROOT / "styles.css").read_text(encoding="utf-8")
self.assertIn("background: #080b10", styles)
self.assertIn("[hidden] { display: none !important; }", styles)
self.assertIn(".company-period-button:active:not(:disabled)", styles)
self.assertIn(".company-period-button:disabled .company-period-cta", styles)
self.assertIn("width: clamp(72px, 34%, 94px)", styles)
self.assertIn(".company-report-card-row", styles)
self.assertIn("grid-template-columns: minmax(320px, 1.35fr) repeat(3", styles)
self.assertIn(".company-setup-heading", styles)
self.assertIn(".company-upload-button:active:not(:disabled)", styles)
self.assertIn("min-width: 104px", styles)
self.assertIn("align-self: flex-end", styles)
self.assertIn(".company-page-title", styles)
self.assertIn(".company-fixed-companies", styles)
self.assertIn(".company-source-action", styles)
self.assertIn(".company-review-file", styles)
self.assertIn("overflow-wrap: anywhere", styles)
self.assertIn(".company-review-row.is-pending", styles)
self.assertIn(".company-review-input:focus-visible", styles)
self.assertIn(".company-review-delete-selected", styles)
self.assertIn(".company-review-confirm-dialog::backdrop", styles)
self.assertIn(".company-report-confirm-dialog::backdrop", styles)
script = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
self.assertIn('navigator.clipboard?.writeText', script)
self.assertIn('document.execCommand("copy")', script)
self.assertIn('$("#copy-trace").addEventListener("click", copyAllTraceLogs)', script)
self.assertIn("运行固定处理器", script)
self.assertIn("saveCompanyReviewItem", script)
self.assertIn("deleteCompanyReviewItem", script)
self.assertIn("deleteSelectedCompanyReviewItems", script)
self.assertIn("const COMPANY_REVIEW_PAGE_SIZE = 50", script)
self.assertIn('api("/api/company-reports/source/draft/items"', script)
self.assertIn('if (event.key !== "Escape") return;', script)
self.assertIn("activateCompanyReviewDraft", script)
self.assertNotIn("window.confirm", script)
self.assertIn("companyReportConfirmIsOpen", script)
self.assertIn("confirmCompanyReportAction", script)
self.assertIn("state.companyReportConfirmRequest", script)
self.assertNotIn("company-review-guidance", script)
self.assertNotIn("company-review-description", script)
self.assertNotIn("还有 ${formatInteger(pending)} 条待人工记录", script)
self.assertNotIn("历史数据源已就绪", script)
self.assertNotIn("Excel 已校验", script)
self.assertNotIn('id="company-source-state"', desktop_text)
self.assertNotIn('id="company-source-summary"', desktop_text)
self.assertIn('String(summary.filename || "").trim()', script)
self.assertIn('$("#company-review-filename").textContent = filename || "未记录文件名"', script)
self.assertLess(
desktop_text.index('id="company-review-title"'),
desktop_text.index('id="company-review-filename"'),
)
self.assertNotIn("派发远端处理", script)
self.assertNotIn("尚未开放:", script)
self.assertNotIn("开放", desktop_text)
self.assertNotIn("开放", script)
self.assertIn("周期结束:", desktop_text)
self.assertIn("周期未结束", script)
self.assertIn("周期已结束", script)
self.assertIn("当前已入库数据", script)
self.assertIn("monthFuture", script)
self.assertIn("!monthValid || monthFuture", script)
self.assertIn(".company-period-state.is-complete", styles)
self.assertIn(".company-period-state.is-in-progress", styles)
self.assertNotIn("TASK FINISHED", script)
self.assertNotIn("company-period-availability", desktop_text)
self.assertIn('<span class="brand-name">ARR Report</span>', mobile_text)
self.assertNotIn("<span aria-hidden=\"true\"><i></i><i></i></span>", mobile_text)
self.assertIn("各公司本月销售情况", mobile_text)
self.assertIn("泰国曼谷UTC+7", mobile_text)
self.assertNotIn('id="generate-monthly"', desktop_text)
self.assertNotIn('id="monthly-as-of"', desktop_text)
self.assertNotIn('id="monthly-month"', desktop_text)
self.assertNotIn('id="refresh-monthly"', desktop_text)
self.assertNotIn('$("#generate-monthly")', script)
self.assertNotIn('$("#refresh-monthly")', script)
self.assertIn("const MONTHLY_POLL_INTERVAL = 4000", script)
self.assertIn("const HISTORY_PAGE_SIZE = 50", script)
self.assertIn("function renderPagination", script)
self.assertIn("function changeHistoryPage", script)
self.assertIn("page.total > previousTotal", script)
self.assertIn("state.monthlyOffset = 0", script)
self.assertNotIn("limit=100", script)
self.assertIn("function scheduleMonthlyPoll", script)
self.assertIn("loadMonthly(false, true)", script)
self.assertIn("自动更新重试中", script)
self.assertIn('api("/api/company-reports/source"', script)
self.assertIn("handleCompanySourceUpload", script)
self.assertNotIn("company-chip-list", desktop_text)
self.assertIn(
'<p class="company-fixed-companies">固定生成LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour</p>',
desktop_text,
)
self.assertNotIn('class="company-scope"', desktop_text)
self.assertNotIn("固定生成公司", desktop_text)
self.assertIn('title="重新读取服务状态、Excel 来源和生成记录"', desktop_text)
self.assertIn('title="选择 C/O 所属的生成月份"', desktop_text)
self.assertIn('aria-label="生成月份"', desktop_text)
self.assertIn('id="company-history-month"', desktop_text)
self.assertNotIn("生成截至", desktop_text)
self.assertEqual(desktop_text.count('<span>生成</span>'), 3)
self.assertIn("生成时间", desktop_text)
self.assertNotIn('data-label="版本"', script)
self.assertIn("function uniqueCompanyProblems", script)
self.assertIn("companyProblemsForResult(result)", script)
self.assertIn("job.finished_at || job.created_at", script)
for period_label in ("C/O:01-10", "C/O:11-20", "C/O:21-30"):
self.assertIn(f">{period_label}</strong>", desktop_text)
self.assertIn("function companyPeriodDisplayLabel(reportMonth, period)", script)
self.assertIn('return monthEnd ? `C/O:21-${monthEnd}` : "C/O:21-30";', script)
self.assertIn("companyPeriodDisplayLabel(reportMonth, period)", script)
for removed_period_name in ("第一期", "第二期", "第三期"):
self.assertNotIn(removed_period_name, desktop_text)
self.assertNotIn('class="company-generator-section"', desktop_text)
self.assertNotIn('class="company-source-section"', desktop_text)
self.assertLess(
desktop_text.index('id="company-report-month"'),
desktop_text.index('id="company-excel-file"'),
)
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", self.auth_headers)
)
trace = decoded(
self.app.handle("GET", "/api/jobs/job-1/trace", self.auth_headers)
)
monthly = decoded(
self.app.handle(
"GET",
"/api/monthly-runs?month=2026-07",
self.auth_headers,
)
)
history_months = decoded(
self.app.handle("GET", "/api/history-months", self.auth_headers)
)
analytics = decoded(
self.app.handle(
"GET",
"/api/analytics?month=2026-07",
self.auth_headers,
)
)
h5_months = decoded(
self.app.handle("GET", "/api/h5/months", self.auth_headers)
)
compatible_analytics = decoded(
self.app.handle(
"GET",
"/api/monthly/2026-07/analytics",
self.auth_headers,
)
)
self.assertEqual(jobs["data"][0]["job_id"], "job-1")
self.assertEqual(
history_months["data"],
[
{
"month_key": "2026-07",
"daily_count": 3,
"monthly_count": 2,
"company_count": 0,
}
],
)
self.assertEqual(
jobs["pagination"],
{
"total": 1,
"limit": 50,
"offset": 0,
"has_previous": False,
"has_next": False,
},
)
self.assertEqual(trace["data"]["job"]["job_id"], "job-1")
self.assertEqual(trace["data"]["logs"][0]["code"], "UPLOAD_REGISTERED")
self.assertEqual(monthly["data"][0]["version_no"], 3)
self.assertEqual(monthly["data"][0]["max_arrival_date"], "2026-07-26")
self.assertEqual(monthly["pagination"]["total"], 1)
self.assertEqual(monthly["pagination"]["limit"], 50)
self.assertEqual(analytics["data"]["min_arrival_date"], "2026-07-01")
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_health_exposes_only_programmatic_processing_readiness(self) -> None:
payload = decoded(
self.app.handle("GET", "/api/health", self.auth_headers)
)["data"]
self.assertTrue(payload["database_ready"])
self.assertTrue(payload["processing_ready"])
self.assertNotIn("agent_writeback_ready", payload)
def test_legacy_h5_analytics_route_preserves_status_contract(self) -> None:
invalid = self.app.handle(
"GET",
"/api/monthly/2026-13/analytics",
self.auth_headers,
)
missing = self.app.handle(
"GET",
"/api/monthly/2026-06/analytics",
self.auth_headers,
)
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_csrf = self.app.handle(
"POST",
"/api/jobs",
{
"Cookie": self.auth_headers["Cookie"],
"X-ARR-Filename-B64": encoded,
},
b"<root />",
)
self.assertEqual(no_csrf.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_not_exposed_in_programmatic_runtime(self) -> None:
response = self.app.handle(
"POST",
"/api/integrations/super-agent/results",
{
**self.session_headers(),
"Content-Type": "application/json; charset=utf-8",
},
b"{}",
)
self.assertEqual(response.status, 404)
self.assertEqual(decoded(response)["error"]["code"], "ROUTE_NOT_FOUND")
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),
credentials=TEST_CREDENTIALS,
)
_, headers = login(app)
response = app.handle(
"GET",
"/api/download/monthly?report_id=3",
headers,
)
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.auth_headers,
)
self.assertEqual(response.status, 400)
self.assertEqual(decoded(response)["error"]["code"], "MONTH_INVALID")
invalid_job = self.app.handle(
"GET",
"/api/jobs/not%2Fsafe/trace",
self.auth_headers,
)
self.assertEqual(invalid_job.status, 400)
self.assertEqual(decoded(invalid_job)["error"]["code"], "JOB_ID_INVALID")
invalid_offset = self.app.handle(
"GET",
"/api/monthly-runs?month=2026-07&offset=-1",
self.auth_headers,
)
self.assertEqual(invalid_offset.status, 400)
self.assertEqual(decoded(invalid_offset)["error"]["code"], "QUERY_INVALID")
def test_history_routes_forward_limit_and_offset_with_exact_totals(self) -> None:
class PagingRepository(FakeRepository):
def __init__(self) -> None:
super().__init__()
self.job_request: tuple[str, int, int] | None = None
self.monthly_request: tuple[str, int, int] | None = None
def list_jobs(
self,
month_key: str,
limit: int = 50,
offset: int = 0,
) -> tuple[List[Dict[str, Any]], int]:
self.job_request = (month_key, limit, offset)
return [{"job_id": "job-51"}], 73
def list_monthly_runs(
self,
month_key: str,
limit: int = 50,
offset: int = 0,
) -> tuple[List[Dict[str, Any]], int]:
self.monthly_request = (month_key, limit, offset)
return [{"report_id": 51}], 101
repository = PagingRepository()
app = PortalApplication(
repository=repository,
credentials=TEST_CREDENTIALS,
)
_, headers = login(app)
jobs = decoded(
app.handle(
"GET",
"/api/jobs?month=2026-07&limit=50&offset=50",
headers,
)
)
monthly = decoded(
app.handle(
"GET",
"/api/monthly-runs?month=2026-07&limit=50&offset=50",
headers,
)
)
self.assertEqual(repository.job_request, ("2026-07", 50, 50))
self.assertEqual(repository.monthly_request, ("2026-07", 50, 50))
self.assertEqual(jobs["pagination"]["total"], 73)
self.assertTrue(jobs["pagination"]["has_previous"])
self.assertTrue(jobs["pagination"]["has_next"])
self.assertEqual(monthly["pagination"]["total"], 101)
self.assertTrue(monthly["pagination"]["has_previous"])
self.assertTrue(monthly["pagination"]["has_next"])
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 = 50,
offset: int = 0,
) -> tuple[List[Dict[str, Any]], int]:
raise PortalDataError("DATABASE_UNAVAILABLE", "数据库连接失败")
app = PortalApplication(
repository=Broken(),
credentials=TEST_CREDENTIALS,
)
_, headers = login(app)
response = app.handle(
"GET",
"/api/jobs?month=2026-07",
headers,
)
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)
def test_daily_download_reads_and_reverifies_committed_object(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "7.27.xlsx"
source.write_bytes(b"trusted-daily-xlsx")
store = ManagedObjectStore(
FilesystemObjectBackend(root / "objects", create=True)
)
stored = store.upload_committed(
job_id="arrjob-download-001",
attempt_no=1,
role="daily_report",
source=source,
original_filename=source.name,
)
descriptor = ArtifactDescriptor(
"daily_xlsx",
source.name,
stored.object_key,
stored.sha256,
stored.byte_size,
stored.mime_type,
)
reader = RoutedArtifactReader(
daily_reader=ManagedObjectArtifactReader(store),
local_reader=ControlledProjectArtifactReader(root),
)
self.assertEqual(reader.read(descriptor), source.read_bytes())
bad = ArtifactDescriptor(
descriptor.file_kind,
descriptor.original_filename,
descriptor.storage_key,
"0" * 64,
descriptor.byte_size,
descriptor.mime_type,
)
with self.assertRaisesRegex(Exception, "完整性"):
reader.read(bad)
if __name__ == "__main__":
unittest.main()