B-42: 导入功能加固——逐工作表确认、流式上传与诊断证据

This commit is contained in:
腾讯WorkBuddy
2026-08-17 01:00:19 +08:00
parent 7f1a93f6a6
commit 486842963e
15 changed files with 2019 additions and 183 deletions
+587
View File
@@ -0,0 +1,587 @@
"""HTTP integration tests for the hardened import API (B-42).
Covers streaming multipart limits, per-worksheet parse results with the four
diagnostic evidence items, the confirm/ignore review lifecycle with tenant
scoping and idempotency, unconfirmed-sheet export gating, and concurrent
duplicate uploads. Uses a real ``ThreadingHTTPServer`` like
``test_server_auth``.
"""
from __future__ import annotations
import io
import itertools
import json
import os
from pathlib import Path
import tempfile
import threading
import unittest
from openpyxl import Workbook
from bank_importer import auth
from bank_importer.db import connect, migrate
import server
from test_server_auth import Client, as_json
ROOT = Path(__file__).resolve().parents[1]
SAMPLES = ROOT / "流水模板"
CCB_SAMPLE = SAMPLES / "中国建设银行账户流水.xls"
CITIC_SAMPLE = SAMPLES / "中信银行账户流水.xlsx"
BOOTSTRAP_PASSWORD = "BootAdmin123"
ADMIN_PASSWORD = "AdminPass123"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
CCB_HEADER = [
"客户账号", "账户名称", "交易时间", "借方发生额(支取)", "贷方发生额(收入)",
"余额", "币种", "对方户名", "对方账号", "对方开户机构", "摘要", "备注",
]
CCB_DATA = [
["6228480000000000", "测试公司", "2026-01-05 10:00:00", "100.00", "", "99900.00", "RMB", "供应商", "1002003004", "某银行", "货款", ""],
["6228480000000000", "测试公司", "2026-01-06 11:00:00", "", "200.00", "100100.00", "RMB", "客户", "2003004005", "某银行", "收款", ""],
]
def workbook_bytes(sheets):
"""Build an xlsx in memory.
``sheets`` is a list of ``(name, rows)``; an empty ``rows`` list means an
empty worksheet.
"""
workbook = Workbook()
workbook.remove(workbook.active)
for name, rows in sheets:
worksheet = workbook.create_sheet(name)
for row in rows:
worksheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
# Workbook fixtures are content-hashed for dedupe, so every helper produces
# distinct bytes per call (each test uploads a genuinely new file).
_counter = itertools.count()
def multi_sheet_bytes():
"""Valid CCB sheet + unknown-header sheet + empty sheet in one workbook."""
return workbook_bytes(
[
("正常流水", [CCB_HEADER, *CCB_DATA]),
("未知模板", [["日期", "金额", "备注"], ["2026-01-01", next(_counter), "x"]]),
("空表", []),
]
)
def empty_workbook_bytes():
return workbook_bytes([("Sheet1", [])])
def single_cell_bytes():
return workbook_bytes([("候选", [[f"只有一个单元格 {next(_counter)}"]])])
def unknown_header_bytes():
return workbook_bytes([("流水", [["日期", "金额", "备注"], ["2026-01-01", next(_counter), "x"]])])
# ---------------------------------------------------------------------------
# Test server
# ---------------------------------------------------------------------------
class ImportApiServerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temp_dir = tempfile.TemporaryDirectory()
root = Path(cls.temp_dir.name)
cls.db_path = root / "app.db"
cls.storage = root / "files"
cls._old_db_path = server.DB_PATH
cls._old_storage = server.STORAGE_DIR
server.DB_PATH = cls.db_path
server.STORAGE_DIR = cls.storage
os.environ["APP_BOOTSTRAP_ADMIN_PASSWORD"] = BOOTSTRAP_PASSWORD
connection = connect(cls.db_path)
migrate(connection)
generated = server.ensure_bootstrap_admin(connection)
assert generated is None
connection.close()
class QuietHandler(server.AppHandler):
def log_message(self, *args) -> None:
pass
cls.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), QuietHandler)
cls.port = cls.httpd.server_address[1]
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
cls.admin = Client("127.0.0.1", cls.port)
status, _, data = cls.admin.post_json(
"/api/login",
{"username": "group-admin", "password": BOOTSTRAP_PASSWORD, "portal": "admin"},
)
assert status == 200, data
status, _, data = cls.admin.post_json(
"/api/password/change",
{"old_password": BOOTSTRAP_PASSWORD, "new_password": ADMIN_PASSWORD},
)
assert status == 200, data
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "甲公司"})
assert status == 200, data
cls.company_a = as_json(data)["company_id"]
status, _, data = cls.admin.post_json("/api/admin/companies", {"name": "乙公司"})
assert status == 200, data
cls.company_b = as_json(data)["company_id"]
cls.cashier_a, cls.cashier_a_password = cls.create_company_user(
cls.admin, cls.port, "cashier-a"
)
cls.cashier_b, cls.cashier_b_password = cls.create_company_user(
cls.admin, cls.port, "cashier-b"
)
@classmethod
def create_company_user(cls, admin, port, username):
status, _, data = admin.post_json(
"/api/admin/users", {"username": username, "company_id": cls.company_a}
)
assert status == 200, data
initial = as_json(data)["initial_password"]
client = Client("127.0.0.1", port)
status, _, data = client.post_json(
"/api/login",
{"username": username, "password": initial, "portal": "company"},
)
assert status == 200, data
new_password = "Changed456"
status, _, data = client.post_json(
"/api/password/change",
{"old_password": initial, "new_password": new_password},
)
assert status == 200, data
return client, new_password
@classmethod
def tearDownClass(cls) -> None:
cls.httpd.shutdown()
cls.httpd.server_close()
server.DB_PATH = cls._old_db_path
server.STORAGE_DIR = cls._old_storage
os.environ.pop("APP_BOOTSTRAP_ADMIN_PASSWORD", None)
cls.temp_dir.cleanup()
def fresh_client(self) -> Client:
return Client("127.0.0.1", self.port)
def upload(self, client, content: bytes, filename: str = "语句.xlsx", fields=None):
return client.post_multipart(
"/api/parse", fields or {}, filename, content
)
def upload_for_company(self, content: bytes, company_id: int, filename: str = "语句.xlsx"):
return self.admin.post_multipart(
"/api/parse", {"company_id": str(company_id)}, filename, content
)
def upload_for_a(self, content: bytes, filename: str = "语句.xlsx"):
return self.cashier_a.post_multipart("/api/parse", {}, filename, content)
def batch_sheets(self, client, batch_id: int):
status, _, data = client.get(f"/api/batches/{batch_id}/sheets")
self.assertEqual(200, status, data)
return as_json(data)["sheets"]
def confirm(self, client, batch_id: int, sheets, reason=None):
payload = {"sheets": sheets}
if reason is not None:
payload["reason"] = reason
return client.post_json(f"/api/batches/{batch_id}/confirm", payload)
def ignore(self, client, batch_id: int, sheets, reason):
return client.post_json(
f"/api/batches/{batch_id}/ignore", {"sheets": sheets, "reason": reason}
)
# ------------------------------------------------------------------
# Multipart validation
# ------------------------------------------------------------------
def test_oversized_body_returns_stable_error(self) -> None:
content = b"x" * (server.MAX_UPLOAD_BYTES + 1)
status, _, data = self.upload_for_a(content)
self.assertEqual(422, status)
payload = as_json(data)
self.assertIn("20 MB", payload["message"])
self.assertNotIn(server.STORAGE_DIR.as_posix(), payload["message"])
self.assertNotIn(str(self.storage), payload["message"])
def test_missing_boundary_returns_stable_error(self) -> None:
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=b"whatever",
headers={"Content-Type": "multipart/form-data"},
)
self.assertEqual(422, status)
self.assertIn("边界", as_json(data)["message"])
def test_wrong_content_type_returns_stable_error(self) -> None:
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=CCB_SAMPLE.read_bytes(),
headers={"Content-Type": "application/octet-stream"},
)
self.assertEqual(422, status)
def test_missing_content_length_returns_stable_error(self) -> None:
boundary = "----missingcl"
body = f"--{boundary}--\r\n".encode()
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
self.assertEqual(422, status)
def test_bad_extension_returns_stable_error(self) -> None:
status, _, data = self.upload_for_a(CCB_SAMPLE.read_bytes(), filename="statement.pdf")
self.assertEqual(422, status)
self.assertIn(".xls", as_json(data)["message"])
def test_signature_mismatch_returns_stable_error(self) -> None:
# Valid .xls content renamed with a .xlsx extension must be rejected.
status, _, data = self.upload_for_a(CCB_SAMPLE.read_bytes(), filename="fake.xlsx")
self.assertEqual(422, status)
self.assertIn("损坏", as_json(data)["message"])
def test_corrupt_file_returns_stable_error_without_temp_path(self) -> None:
status, _, data = self.upload_for_a(b"PK\x03\x04 not a real zip at all")
self.assertEqual(422, status)
payload = as_json(data)
text = json.dumps(payload, ensure_ascii=False)
self.assertNotIn(str(self.storage), text)
self.assertNotIn("temp", text.lower())
self.assertNotIn("data/files", text)
def test_two_file_parts_rejected(self) -> None:
boundary = "----twofilesboundary"
content = CCB_SAMPLE.read_bytes()
crlf = b"\r\n"
body = (
b"--" + boundary.encode() + crlf
+ b'Content-Disposition: form-data; name="file"; filename="a.xls"' + crlf
+ b"Content-Type: application/octet-stream" + crlf + crlf
+ content + crlf
+ b"--" + boundary.encode() + crlf
+ b'Content-Disposition: form-data; name="file"; filename="b.xls"' + crlf
+ b"Content-Type: application/octet-stream" + crlf + crlf
+ content + crlf
+ b"--" + boundary.encode() + b"--" + crlf
)
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
self.assertEqual(422, status)
def test_no_file_part_rejected(self) -> None:
boundary = "----nofile"
crlf = b"\r\n"
body = (
b"--" + boundary.encode() + crlf
+ b'Content-Disposition: form-data; name="company_id"' + crlf + crlf
+ b"1" + crlf
+ b"--" + boundary.encode() + b"--" + crlf
)
status, _, data = self.cashier_a.request(
"POST",
"/api/parse",
body=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
self.assertEqual(422, status)
self.assertIn("文件", as_json(data)["message"])
def test_upload_trailing_bytes_preserved_exactly(self) -> None:
# The stored source file must be byte-identical to what was uploaded
# even when it ends with CRLF/LF; the parser must never trim tail
# bytes, and the upload temp must not appear anywhere in the payload.
content = CCB_SAMPLE.read_bytes()
uploaded = content + b"\r\n\r\n\x00\x01"
status, _, data = self.upload_for_a(uploaded, filename="尾部字节.xls")
self.assertEqual(200, status, data)
batch_id = as_json(data)["batch_id"]
connection = connect(self.db_path)
try:
row = connection.execute(
"""
SELECT f.storage_path FROM source_files f
JOIN import_batches b ON b.source_file_id = f.id
WHERE b.id = ?
""",
(batch_id,),
).fetchone()
finally:
connection.close()
self.assertIsNotNone(row)
self.assertEqual(uploaded, Path(row["storage_path"]).read_bytes())
self.assertNotIn("upload-", json.dumps(as_json(data), ensure_ascii=False))
# ------------------------------------------------------------------
# Per-sheet results and four-item evidence
# ------------------------------------------------------------------
def test_multi_sheet_workbook_returns_every_sheet_result(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="多工作表.xlsx")
self.assertEqual(200, status, data)
payload = as_json(data)
self.assertEqual("parsed", payload["status"])
self.assertEqual("多工作表.xlsx", payload.get("original_filename"))
sheets = payload["sheets"]
self.assertEqual(3, len(sheets))
by_name = {sheet["sheet_name"]: sheet for sheet in sheets}
self.assertEqual("parsed", by_name["正常流水"]["outcome"])
self.assertEqual("pending", by_name["正常流水"]["review_status"])
self.assertEqual("中国建设银行", by_name["正常流水"]["bank"])
self.assertEqual(2, by_name["正常流水"]["transactions"])
unknown = by_name["未知模板"]
self.assertEqual("exception", unknown["outcome"])
self.assertIn("未识别到受支持的银行表头", unknown["message"])
self.assertGreaterEqual(unknown["scanned_rows"], 2)
self.assertTrue(unknown["candidate_headers"])
self.assertIn("日期、金额、备注", "".join(unknown["candidate_headers"]))
empty = by_name["空表"]
self.assertEqual("ignored", empty["outcome"])
self.assertIn("为空", empty["message"])
self.assertEqual(0, empty["scanned_rows"])
def test_batch_detail_matches_parse_response(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="详情.xlsx")
batch_id = as_json(data)["batch_id"]
sheets = self.batch_sheets(self.cashier_a, batch_id)
self.assertEqual(3, len(sheets))
status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets")
self.assertEqual(200, status)
detail = as_json(data)
self.assertEqual("详情.xlsx", detail["original_filename"])
def test_unknown_header_file_returns_four_evidence_items(self) -> None:
status, _, data = self.upload_for_a(unknown_header_bytes(), filename="未知银行.xlsx")
self.assertEqual(422, status, data)
payload = as_json(data)
self.assertEqual("exception", payload["status"])
self.assertEqual("未知银行.xlsx", payload.get("original_filename"))
sheet = payload["sheets"][0]
self.assertEqual("exception", sheet["outcome"])
self.assertEqual("流水", sheet["sheet_name"])
self.assertGreaterEqual(sheet["scanned_rows"], 1)
self.assertTrue(sheet["candidate_headers"])
text = json.dumps(payload, ensure_ascii=False)
for evidence in (str(self.storage), "data/files", ".uploads", "upload-"):
self.assertNotIn(evidence, text)
def test_empty_workbook_returns_evidence(self) -> None:
status, _, data = self.upload_for_a(empty_workbook_bytes(), filename="空工作簿.xlsx")
self.assertEqual(422, status, data)
payload = as_json(data)
self.assertEqual("空工作簿.xlsx", payload.get("original_filename"))
sheet = payload["sheets"][0]
self.assertEqual("ignored", sheet["outcome"])
self.assertEqual("Sheet1", sheet["sheet_name"])
self.assertEqual(0, sheet["scanned_rows"])
def test_single_cell_sheet_returns_candidate_evidence(self) -> None:
status, _, data = self.upload_for_a(single_cell_bytes(), filename="单格.xlsx")
self.assertEqual(422, status, data)
sheet = as_json(data)["sheets"][0]
self.assertEqual("exception", sheet["outcome"])
self.assertEqual("候选", sheet["sheet_name"])
self.assertEqual(1, sheet["scanned_rows"])
self.assertTrue(sheet["candidate_headers"])
self.assertIn("只有一个单元格", "".join(sheet["candidate_headers"]))
# ------------------------------------------------------------------
# Confirm / ignore lifecycle
# ------------------------------------------------------------------
def test_confirm_and_ignore_lifecycle(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="确认.xlsx")
payload = as_json(data)
batch_id = payload["batch_id"]
names = [s["sheet_name"] for s in payload["sheets"]]
self.assertIn("正常流水", names)
# Confirm the parsed sheet; exception/ignored cannot be confirmed.
status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水"])
self.assertEqual(200, status, data)
result = as_json(data)
self.assertEqual(["正常流水"], result["updated"])
sheet = next(s for s in result["sheets"] if s["sheet_name"] == "正常流水")
self.assertEqual("confirmed", sheet["review_status"])
# Confirming an exception sheet is a conflict.
status, _, data = self.confirm(self.cashier_a, batch_id, ["未知模板"])
self.assertEqual(409, status, data)
# Ignore the exception sheet with a reason.
status, _, data = self.ignore(self.cashier_a, batch_id, ["未知模板"], "模板待补充")
self.assertEqual(200, status, data)
sheet = next(s for s in as_json(data)["sheets"] if s["sheet_name"] == "未知模板")
self.assertEqual("ignored", sheet["review_status"])
self.assertEqual("模板待补充", sheet["review_reason"])
# Idempotent repeat of an already-applied decision.
status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水"])
self.assertEqual(200, status, data)
result = as_json(data)
self.assertEqual([], result["updated"])
self.assertEqual(["正常流水"], result["already"])
# Changing a settled decision is a conflict.
status, _, data = self.ignore(self.cashier_a, batch_id, ["正常流水"], "改主意")
self.assertEqual(409, status, data)
def test_ignore_requires_reason(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="忽略原因.xlsx")
batch_id = as_json(data)["batch_id"]
status, _, data = self.cashier_a.post_json(
f"/api/batches/{batch_id}/ignore", {"sheets": ["未知模板"], "reason": " "}
)
self.assertEqual(400, status, data)
self.assertIn("原因", as_json(data)["message"])
def test_unknown_sheet_name_is_rejected_and_atomic(self) -> None:
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="原子.xlsx")
batch_id = as_json(data)["batch_id"]
# One valid + one unknown sheet in the same request must not leave a
# half-applied confirmation behind.
status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水", "不存在"])
self.assertEqual(400, status, data)
sheet = next(
s for s in self.batch_sheets(self.cashier_a, batch_id)
if s["sheet_name"] == "正常流水"
)
self.assertEqual("pending", sheet["review_status"])
def test_company_cannot_confirm_another_companys_batch(self) -> None:
status, _, data = self.upload_for_company(multi_sheet_bytes(), self.company_b)
batch_b = as_json(data)["batch_id"]
status, _, data = self.confirm(self.cashier_a, batch_b, ["正常流水"])
self.assertEqual(404, status, data)
status, _, data = self.cashier_a.get(f"/api/batches/{batch_b}/sheets")
self.assertEqual(404, status, data)
def test_export_excludes_unconfirmed_and_ignore_keeps_excluded(self) -> None:
def export_line_count() -> int:
status, _, data = self.cashier_a.get("/api/export.csv")
self.assertEqual(200, status)
return len([line for line in data.decode("utf-8-sig").splitlines() if line])
baseline = export_line_count()
# Unconfirmed upload: export stays unchanged (no downstream leak).
status, _, data = self.upload_for_a(CCB_SAMPLE.read_bytes(), filename="导出.xls")
batch_id = as_json(data)["batch_id"]
self.assertEqual("parsed", as_json(data)["status"])
self.assertEqual(baseline, export_line_count())
# Confirm → the sheet's rows enter the export.
sheets = self.batch_sheets(self.cashier_a, batch_id)
name = sheets[0]["sheet_name"]
status, _, data = self.confirm(self.cashier_a, batch_id, [name])
self.assertEqual(200, status, data)
self.assertGreater(export_line_count(), baseline)
# A confirmed sheet cannot be silently revoked: the settled decision
# is a conflict, not a silent data removal.
status, _, data = self.ignore(self.cashier_a, batch_id, [name], "取消")
self.assertEqual(409, status, data)
self.assertGreater(export_line_count(), baseline)
# A sheet that was only ever ignored never enters the export.
status, _, data = self.upload_for_a(multi_sheet_bytes(), filename="忽略.xlsx")
batch2 = as_json(data)["batch_id"]
parsed = [
sheet for sheet in self.batch_sheets(self.cashier_a, batch2)
if sheet["outcome"] == "parsed"
]
self.assertTrue(parsed)
before_ignore = export_line_count()
status, _, data = self.ignore(
self.cashier_a, batch2, [parsed[0]["sheet_name"]], "该表复核后不采用"
)
self.assertEqual(200, status, data)
self.assertEqual(before_ignore, export_line_count())
# ------------------------------------------------------------------
# Concurrency
# ------------------------------------------------------------------
def test_concurrent_duplicate_upload_keeps_one_fact_set(self) -> None:
content = CITIC_SAMPLE.read_bytes()
results: list[tuple[int, dict]] = []
errors: list[Exception] = []
barrier = threading.Barrier(2)
def upload() -> None:
try:
client = self.fresh_client()
client.cookies.update(self.cashier_a.cookies)
barrier.wait(timeout=10)
status, _, data = client.post_multipart(
"/api/parse", {}, "并发.xlsx", content
)
results.append((status, as_json(data)))
except Exception as exc: # pragma: no cover
errors.append(exc)
threads = [threading.Thread(target=upload) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
self.assertEqual([], errors)
self.assertEqual(2, len(results))
statuses = sorted(payload["status"] for _, payload in results)
self.assertEqual(["duplicate", "parsed"], statuses)
connection = connect(self.db_path)
try:
# The concurrent upload adds exactly one source file: identical
# bytes never create a second fact set.
count = connection.execute(
"SELECT COUNT(*) AS n FROM source_files WHERE original_filename = '并发.xlsx'"
).fetchone()["n"]
self.assertEqual(1, count)
duplicates = connection.execute(
"SELECT COUNT(*) AS n FROM import_batches WHERE status = 'duplicate'"
).fetchone()["n"]
self.assertGreaterEqual(duplicates, 1)
finally:
connection.close()
if __name__ == "__main__":
unittest.main()