"""HTTP tests for company intercompany events list + CSV export (HEL-176). Covers combined filters, keyset pagination, empty state, lateral access, forged company_id, ID guessing, export isolation and audit logging. """ from __future__ import annotations import io import json import os from pathlib import Path import tempfile import threading import unittest from openpyxl import Workbook from bank_importer.db import connect, migrate import server from test_server_auth import Client, as_json BOOTSTRAP_PASSWORD = "BootAdmin123" ADMIN_PASSWORD = "AdminPass123" CASHIER_PASSWORD = "Cashier123" CCB_HEADER = [ "客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)", "余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注", ] ACCOUNT_A = "6222000000000001" ACCOUNT_B = "6222000000000002" ACCOUNT_C = "6222000000000003" def workbook_bytes(rows) -> bytes: workbook = Workbook() sheet = workbook.active sheet.title = "正常流水" sheet.append(CCB_HEADER) for row in rows: sheet.append(row) buffer = io.BytesIO() workbook.save(buffer) return buffer.getvalue() def outgoing(own: str, cp: str, amount: str, at: str = "2026-01-05 10:00:00"): return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""] def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"): return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""] class CompanyIntercompanyEventsTests(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() self.addCleanup(self.temp_dir.cleanup) root = Path(self.temp_dir.name) self.db_path = root / "app.db" self.storage = root / "files" self._old_db_path = server.DB_PATH self._old_storage = server.STORAGE_DIR server.DB_PATH = self.db_path server.STORAGE_DIR = self.storage os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD connection = connect(self.db_path) migrate(connection) assert server.ensure_bootstrap_admin(connection) is None connection.close() class QuietHandler(server.AppHandler): def log_message(self, *args) -> None: pass self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler) self.port = self.httpd.server_address[1] self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) self.thread.start() self.admin = Client("127.0.0.1", self.port) status, _, data = self.admin.post_json( "/api/login", {"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"}, ) assert status == 200, data status, _, data = self.admin.post_json( "/api/password/change", {"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD}, ) assert status == 200, data self.initial_passwords: dict[str, str] = {} self.company_a = self._create_company("甲公司", "cashier-a") self.company_b = self._create_company("乙公司", "cashier-b") self.company_c = self._create_company("丙公司", "cashier-c") self.cashier_a = self._login_company("cashier-a") self.cashier_b = self._login_company("cashier-b") self.cashier_c = self._login_company("cashier-c") self._approve_account(self.company_a, ACCOUNT_A, self.cashier_a) self._approve_account(self.company_b, ACCOUNT_B, self.cashier_b) self._approve_account(self.company_c, ACCOUNT_C, self.cashier_c) def tearDown(self) -> None: self.httpd.shutdown() self.httpd.server_close() server.DB_PATH = self._old_db_path server.STORAGE_DIR = self._old_storage os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None) def _create_company(self, name: str, username: str) -> int: status, _, data = self.admin.post_json( "/api/admin/companies", {"name": name, "username": username} ) assert status == 200, data self.initial_passwords[username] = as_json(data)["initial_password"] return as_json(data)["company_id"] def _login_company(self, username: str) -> Client: client = Client("127.0.0.1", self.port) initial = self.initial_passwords[username] status, _, data = client.post_json( "/api/login", {"username": username, "password": initial, "portal": "company"} ) assert status == 200, data status, _, data = client.post_json( "/api/password/change", {"old_password": initial, "new_password": CASHIER_PASSWORD}, ) assert status == 200, data return client def _approve_account(self, company_id: int, number: str, client: Client) -> int: status, _, data = client.post_json( "/api/company/accounts", { "bank_name": "中信银行", "account_type": "基本户", "account_number": number, "start_date": "2026-01-01", }, ) assert status == 200, data account_id = as_json(data)["account"]["id"] status, _, data = self.admin.post_json( f"/api/admin/accounts/{account_id}/review", { "decision": "approve", "reason": "测试启用", "effective_from": "2026-01-01", }, ) assert status == 200, data return account_id def _upload_and_confirm(self, client: Client, company_id: int, rows) -> int: content = workbook_bytes(rows) status, _, data = self.admin.post_multipart( "/api/parse", {"company_id": str(company_id)}, "账单.xlsx", content ) assert status == 200, data batch_id = as_json(data)["batch_id"] status, _, data = client.get(f"/api/batches/{batch_id}/sheets") names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"] status, _, data = client.post_json( f"/api/batches/{batch_id}/confirm", {"sheets": names} ) assert status == 200, data return batch_id def _lock_single(self, amount: str, at: str = "2026-03-01 10:00:00") -> dict: self._upload_and_confirm( self.cashier_a, self.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, amount, at)], ) status, _, data = self.admin.get("/api/admin/transfer-events") self.assertEqual(200, status, data) single = next( e for e in as_json(data)["events"] if e["status"] == "internal_single" and e["amount"] == amount ) status, _, data = self.admin.get(f"/api/admin/transfer-events/{single['event_id']}") self.assertEqual(200, status, data) revision = as_json(data)["event"]["revision"] status, _, data = self.admin.post_json( f"/api/admin/transfer-events/{single['event_id']}/decisions", { "action": "assign_participant", "reason": "函证确认", "expected_revision": revision, "request_key": f"lock-{amount}-{at}", "participant": {"role": "payee", "company_id": self.company_b}, }, ) self.assertEqual(200, status, data) return as_json(data)["decision"] def _seed_mixed(self) -> None: """Paired A→B 100, B→A 40, locked A→B 25, pending A→B 7, B→C 200.""" self._upload_and_confirm( self.cashier_a, self.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, "100.00", "2026-01-05 10:00:00")], ) self._upload_and_confirm( self.cashier_b, self.company_b, [incoming(ACCOUNT_B, ACCOUNT_A, "100.00", "2026-01-05 11:00:00")], ) self._upload_and_confirm( self.cashier_b, self.company_b, [outgoing(ACCOUNT_B, ACCOUNT_A, "40.00", "2026-01-10 10:00:00")], ) self._upload_and_confirm( self.cashier_a, self.company_a, [incoming(ACCOUNT_A, ACCOUNT_B, "40.00", "2026-01-10 11:00:00")], ) self._lock_single("25.00", "2026-02-01 10:00:00") self._upload_and_confirm( self.cashier_a, self.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, "7.00", "2026-02-15 10:00:00")], ) self._upload_and_confirm( self.cashier_b, self.company_b, [outgoing(ACCOUNT_B, ACCOUNT_C, "200.00", "2026-01-20 10:00:00")], ) self._upload_and_confirm( self.cashier_c, self.company_c, [incoming(ACCOUNT_C, ACCOUNT_B, "200.00", "2026-01-20 11:00:00")], ) def _events(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"): status, _, data = client.get(f"/api/company/intercompany/events?{query}") return status, as_json(data) if data else {} def _export(self, client: Client, query: str = "from=2026-01-01&to=2026-12-31"): return client.get(f"/api/company/intercompany/export.csv?{query}") # ------------------------------------------------------------------ # Auth / parameter guards # ------------------------------------------------------------------ def test_requires_company_role(self) -> None: status, payload = self._events(self.admin) self.assertEqual(403, status, payload) def test_rejects_forged_company_id_on_events_and_export(self) -> None: status, payload = self._events( self.cashier_a, f"from=2026-01-01&to=2026-12-31&company_id={self.company_b}", ) self.assertEqual(400, status, payload) status, _, data = self._export( self.cashier_a, f"from=2026-01-01&to=2026-12-31&company_id={self.company_a}", ) self.assertEqual(400, status, data) def test_rejects_bad_filters(self) -> None: status, payload = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=sideways" ) self.assertEqual(400, status, payload) status, payload = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&state=maybe" ) self.assertEqual(400, status, payload) status, payload = self._events( self.cashier_a, "from=2026-13-40&to=2026-12-31" ) self.assertEqual(400, status, payload) def test_empty_window(self) -> None: status, payload = self._events(self.cashier_a) self.assertEqual(200, status, payload) self.assertEqual([], payload["events"]) self.assertFalse(payload["has_more"]) self.assertIsNone(payload["next_cursor"]) # ------------------------------------------------------------------ # Combined filters + confirmed/pending separation # ------------------------------------------------------------------ def test_combined_filters_and_state_split(self) -> None: self._seed_mixed() status, payload = self._events(self.cashier_a) self.assertEqual(200, status, payload) events = payload["events"] # A sees: out 100, in 40, locked out 25, pending out 7 — not B↔C 200 self.assertEqual(4, len(events)) amounts = {e["amount"] for e in events} self.assertEqual({"100.00", "40.00", "25.00", "7.00"}, amounts) for event in events: self.assertNotEqual(self.company_c, event["counterparty_company_id"]) self.assertIn(event["state"], ("confirmed", "pending")) self.assertIn(event["direction"], ("out", "in")) status, confirmed = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed" ) self.assertEqual(200, status, confirmed) self.assertEqual(3, len(confirmed["events"])) self.assertTrue(all(e["state"] == "confirmed" for e in confirmed["events"])) self.assertNotIn("7.00", {e["amount"] for e in confirmed["events"]}) status, pending = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending" ) self.assertEqual(200, status, pending) self.assertEqual(1, len(pending["events"])) self.assertEqual("7.00", pending["events"][0]["amount"]) self.assertEqual("pending", pending["events"][0]["state"]) status, outs = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&direction=out&state=confirmed" ) self.assertEqual(200, status, outs) self.assertEqual({"100.00", "25.00"}, {e["amount"] for e in outs["events"]}) self.assertTrue(all(e["direction"] == "out" for e in outs["events"])) status, by_cp = self._events( self.cashier_a, f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_b}&state=confirmed", ) self.assertEqual(200, status, by_cp) self.assertEqual(3, len(by_cp["events"])) # Date window excludes Feb locked/pending status, jan = self._events( self.cashier_a, "from=2026-01-01&to=2026-01-31&state=confirmed" ) self.assertEqual(200, status, jan) self.assertEqual({"100.00", "40.00"}, {e["amount"] for e in jan["events"]}) def test_keyset_pagination_no_dup_no_gap(self) -> None: # Three confirmed A→B outs on distinct days for i, amount in enumerate(("11.00", "12.00", "13.00", "14.00", "15.00")): day = 5 + i self._upload_and_confirm( self.cashier_a, self.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, amount, f"2026-01-{day:02d} 10:00:00")], ) self._upload_and_confirm( self.cashier_b, self.company_b, [incoming(ACCOUNT_B, ACCOUNT_A, amount, f"2026-01-{day:02d} 11:00:00")], ) status, page1 = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed&direction=out&limit=2", ) self.assertEqual(200, status, page1) self.assertEqual(2, len(page1["events"])) self.assertTrue(page1["has_more"]) self.assertIsNotNone(page1["next_cursor"]) status, page2 = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed&direction=out" f"&limit=2&cursor={page1['next_cursor']}", ) self.assertEqual(200, status, page2) self.assertEqual(2, len(page2["events"])) self.assertTrue(page2["has_more"]) status, page3 = self._events( self.cashier_a, "from=2026-01-01&to=2026-12-31&state=confirmed&direction=out" f"&limit=2&cursor={page2['next_cursor']}", ) self.assertEqual(200, status, page3) self.assertEqual(1, len(page3["events"])) self.assertFalse(page3["has_more"]) self.assertIsNone(page3["next_cursor"]) ids = [e["event_id"] for e in page1["events"] + page2["events"] + page3["events"]] self.assertEqual(5, len(ids)) self.assertEqual(len(ids), len(set(ids))) # Descending by effective_at then event_id amounts = [e["amount"] for e in page1["events"] + page2["events"] + page3["events"]] self.assertEqual(["15.00", "14.00", "13.00", "12.00", "11.00"], amounts) # ------------------------------------------------------------------ # Isolation / ID guess / detail reuse # ------------------------------------------------------------------ def test_lateral_isolation_and_id_guess_404(self) -> None: self._seed_mixed() status, payload_a = self._events(self.cashier_a) self.assertEqual(200, status, payload_a) a_ids = {e["event_id"] for e in payload_a["events"]} status, payload_c = self._events(self.cashier_c) self.assertEqual(200, status, payload_c) # C only participates in B↔C 200 self.assertTrue(payload_c["events"]) for event in payload_c["events"]: self.assertEqual("200.00", event["amount"]) self.assertNotIn(event["event_id"], a_ids) # C guessing A's event id via transfer-events detail → 404 a_event_id = next(iter(a_ids)) status, _, data = self.cashier_c.get( f"/api/company/transfer-events/{a_event_id}" ) self.assertEqual(404, status, data) # A can open own event; counterparty account masked; only own observations status, _, data = self.cashier_a.get( f"/api/company/transfer-events/{a_event_id}" ) self.assertEqual(200, status, data) detail = as_json(data)["event"] if detail.get("counterparty") and "account_number_masked" in detail["counterparty"]: masked = detail["counterparty"]["account_number_masked"] self.assertTrue(str(masked).startswith("****")) self.assertNotIn(ACCOUNT_B, masked) for obs in detail["observations"]: self.assertEqual(self.company_a, obs["batch_company_id"]) self.assertNotIn(ACCOUNT_B, json.dumps(obs, ensure_ascii=False)) # Filtering by counterparty C still cannot leak B↔C into A's list status, filtered = self._events( self.cashier_a, f"from=2026-01-01&to=2026-12-31&counterparty_id={self.company_c}", ) self.assertEqual(200, status, filtered) self.assertEqual([], filtered["events"]) def test_b44_events_path_still_works_with_cutoff(self) -> None: # Without HEL-176 discriminators, /events stays on B-44 ledger list. status, _, data = self.cashier_a.get( "/api/company/intercompany/events?from=2026-01-01&cutoff=2026-12-31" ) self.assertEqual(200, status, data) payload = as_json(data) self.assertIn("items", payload) self.assertNotIn("events", payload) # ------------------------------------------------------------------ # Export: confirmed only + audit + isolation # ------------------------------------------------------------------ def test_export_confirmed_only_isolated_and_audited(self) -> None: self._seed_mixed() status, headers, data = self._export(self.cashier_a) self.assertEqual(200, status, data) self.assertEqual("text/csv; charset=utf-8", headers.get("content-type")) text = data.decode("utf-8-sig") lines = [line for line in text.splitlines() if line] self.assertGreaterEqual(len(lines), 2) body = "\n".join(lines[1:]) self.assertIn("100.00", body) self.assertIn("40.00", body) self.assertIn("25.00", body) self.assertNotIn("7.00", body) # pending excluded self.assertNotIn("200.00", body) # B↔C excluded # Explicit pending state rejected status, _, data = self._export( self.cashier_a, "from=2026-01-01&to=2026-12-31&state=pending" ) self.assertEqual(400, status, data) # C export must not contain A's amounts status, _, data = self._export(self.cashier_c) self.assertEqual(200, status, data) text_c = data.decode("utf-8-sig") self.assertNotIn("100.00", text_c) self.assertNotIn("25.00", text_c) self.assertIn("200.00", text_c) status, _, data = self.admin.get("/api/admin/audit-log?limit=50") self.assertEqual(200, status, data) actions = [row["action"] for row in as_json(data)["entries"]] self.assertIn("export_intercompany_csv", actions) export_rows = [ row for row in as_json(data)["entries"] if row["action"] == "export_intercompany_csv" ] self.assertTrue(export_rows) self.assertTrue( any(f"company:{self.company_a}" == row.get("target") for row in export_rows) ) self.assertTrue(any("state:confirmed" in (row.get("detail") or "") for row in export_rows)) if __name__ == "__main__": unittest.main()