test_file_validation.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Upload preflight uses real synthetic documents, without LLM or callbacks."""
  2. import hashlib
  3. from io import BytesIO
  4. import json
  5. from pathlib import Path
  6. import tempfile
  7. import unittest
  8. from unittest.mock import patch
  9. import zipfile
  10. import pymupdf
  11. from fastapi.testclient import TestClient
  12. from fastapi import HTTPException
  13. import http_api
  14. from scripts.tests.test_api import _docx_bytes, _pdf_bytes
  15. class FileValidationTests(unittest.TestCase):
  16. def setUp(self):
  17. temp = tempfile.TemporaryDirectory()
  18. self.addCleanup(temp.cleanup)
  19. self.root = Path(temp.name)
  20. self.log = self.root / "requests.log"
  21. for target, value in [
  22. ("request_logging.LOG_PATH", self.log),
  23. ("http_api.tempfile.tempdir", temp.name),
  24. ]:
  25. patcher = patch(target, value)
  26. patcher.start()
  27. self.addCleanup(patcher.stop)
  28. self.client = TestClient(http_api.app)
  29. def post(self, name, content, mime="application/octet-stream"):
  30. jobs = dict(http_api._JOBS)
  31. with patch.object(http_api._JOB_EXECUTOR, "submit") as submit:
  32. result = self.client.post("/api/v1/files/validate", files={"file": (name, content, mime)})
  33. submit.assert_not_called()
  34. self.assertEqual(http_api._JOBS, jobs, "检测接口不得创建任务")
  35. self.assertEqual(list(self.root.glob("proposa-validate-*")), [], "检测副本必须清理")
  36. return result
  37. def test_valid_docx_and_text_pdf_metadata_and_audit(self):
  38. for name, content, kind in [("文件.DOCX", _docx_bytes(), "docx"), ("文件.PDF", _pdf_bytes(), "pdf")]:
  39. with self.subTest(kind=kind):
  40. response = self.post(name, content, "image/png")
  41. self.assertEqual(response.status_code, 200, response.text)
  42. data = response.json()
  43. self.assertTrue(data["valid"])
  44. self.assertEqual(data["file_type"], kind)
  45. self.assertEqual(data["filename"], name)
  46. self.assertEqual(data["size"], len(content))
  47. self.assertEqual(data["sha256"], hashlib.sha256(content).hexdigest())
  48. self.assertIs(data["is_text_pdf"], True if kind == "pdf" else None)
  49. records = [json.loads(line) for line in self.log.read_text(encoding="utf-8").splitlines()]
  50. request = next(record for record in records if record["event"] == "request")
  51. self.assertEqual(request["body"][0]["name"], "file")
  52. self.assertNotIn("tender text", self.log.read_text(encoding="utf-8"))
  53. def test_invalid_empty_disguised_and_unsupported(self):
  54. for name, content in [
  55. ("a.doc", _docx_bytes()), ("a.txt", b"text"),
  56. ("a.pdf", b""), ("a.docx", b""),
  57. ("a.pdf", b"broken"), ("a.docx", b"broken"),
  58. ("a.pdf", _docx_bytes()), ("a.docx", _pdf_bytes()),
  59. ]:
  60. with self.subTest(name=name, size=len(content)):
  61. response = self.post(name, content)
  62. self.assertEqual(response.status_code, 400, response.text)
  63. self.assertIn("detail", response.json())
  64. def test_pdf_blank_image_only_and_encrypted(self):
  65. with pymupdf.open() as doc:
  66. page = doc.new_page()
  67. pixmap = pymupdf.Pixmap(pymupdf.csRGB, (0, 0, 20, 20), False)
  68. pixmap.clear_with(255)
  69. page.insert_image(page.rect, pixmap=pixmap)
  70. scanned = doc.tobytes()
  71. with pymupdf.open(stream=_pdf_bytes(), filetype="pdf") as doc:
  72. encrypted = doc.tobytes(encryption=pymupdf.PDF_ENCRYPT_AES_256,
  73. owner_pw="owner", user_pw="user")
  74. for content in [_pdf_bytes(""), scanned, encrypted]:
  75. self.assertEqual(self.post("a.pdf", content).status_code, 400)
  76. def test_mixed_pdf_uses_existing_at_least_one_text_page_rule(self):
  77. with pymupdf.open() as doc:
  78. doc.new_page()
  79. doc.new_page().insert_text((72, 72), "extractable text")
  80. content = doc.tobytes()
  81. self.assertEqual(self.post("mixed.pdf", content).status_code, 200)
  82. def test_docx_container_xml_content_type_and_relationships(self):
  83. for part, replacement in [
  84. ("word/document.xml", b"<root/>"),
  85. ("word/document.xml", b"<broken"),
  86. ("[Content_Types].xml", b"<Types/>"),
  87. ("_rels/.rels", b"<Relationships/>"),
  88. ("word/document.xml", None),
  89. ]:
  90. with self.subTest(part=part, replacement=replacement):
  91. output = BytesIO()
  92. with zipfile.ZipFile(BytesIO(_docx_bytes())) as source, zipfile.ZipFile(output, "w") as target:
  93. for name in source.namelist():
  94. if name == part:
  95. if replacement is not None:
  96. target.writestr(name, replacement)
  97. else:
  98. target.writestr(name, source.read(name))
  99. self.assertEqual(self.post("a.docx", output.getvalue()).status_code, 400)
  100. def test_size_limit_and_exact_boundary(self):
  101. content = _pdf_bytes()
  102. with patch.object(http_api, "MAX_UPLOAD_BYTES", len(content)):
  103. self.assertEqual(self.post("a.pdf", content).status_code, 200)
  104. with patch.object(http_api, "MAX_UPLOAD_BYTES", len(content) - 1):
  105. self.assertEqual(self.post("a.pdf", content).status_code, 413)
  106. def test_request_contract_and_openapi(self):
  107. url = "/api/v1/files/validate"
  108. self.assertEqual(self.client.post(url, content="text", headers={"Content-Type": "text/plain"}).status_code, 415)
  109. for files in [
  110. [("other", ("a.pdf", _pdf_bytes()))],
  111. [("file", (None, "not an upload"))],
  112. [("file", ("a.pdf", _pdf_bytes())), ("file", ("b.pdf", _pdf_bytes()))],
  113. [("file", ("a.pdf", _pdf_bytes())), ("other", ("b.pdf", _pdf_bytes()))],
  114. ]:
  115. self.assertEqual(self.client.post(url, files=files).status_code, 422)
  116. schema = self.client.get("/openapi.json").json()["paths"][url]["post"]
  117. self.assertIn("multipart/form-data", schema["requestBody"]["content"])
  118. self.assertEqual(schema["requestBody"]["content"]["application/json"]["schema"]["required"],
  119. ["file_path", "file_role", "is_absolute"])
  120. def post_path(self, path, role="TENDER_FILE", absolute=False):
  121. jobs = dict(http_api._JOBS)
  122. with patch.object(http_api._JOB_EXECUTOR, "submit") as submit:
  123. response = self.client.post("/api/v1/files/validate", json={
  124. "file_path": str(path), "file_role": role, "is_absolute": absolute,
  125. })
  126. submit.assert_not_called()
  127. self.assertEqual(http_api._JOBS, jobs)
  128. self.assertEqual(list(self.root.glob("proposa-validate-*")), [])
  129. return response
  130. def test_json_tpc_roles_and_absolute_static_paths(self):
  131. for role in ["TENDER_FILE", "PROCUREMENT_FILE", "CLA_FILE"]:
  132. with self.subTest(role=role), patch.object(http_api, "TPC_DIR", str(self.root)):
  133. path = self.root / "files" / "a.pdf"
  134. path.parent.mkdir(exist_ok=True)
  135. content = _pdf_bytes()
  136. path.write_bytes(content)
  137. response = self.post_path("/static/files/a.pdf", role)
  138. self.assertEqual(response.status_code, 200, response.text)
  139. self.assertEqual(response.json()["resolved_path"], str(path.resolve()))
  140. self.assertEqual(response.json()["file_role"], role)
  141. self.assertEqual(path.read_bytes(), content)
  142. self.assertEqual(self.post_path(path.parent / "static" / path.name, role, True).status_code, 200)
  143. def test_json_reference_fallback_and_priority(self):
  144. first = self.root / "first"
  145. second = self.root / "second"
  146. first.mkdir()
  147. second.mkdir()
  148. content = _docx_bytes()
  149. with patch.object(http_api, "REFERENCE_DIR", str(first)), patch.object(http_api, "REFERENCE_DIR1", str(second)):
  150. response = self.post_path("static/missing.docx", "REFERENCE_BID")
  151. self.assertEqual(response.status_code, 400)
  152. expected = [str((directory / "missing.docx").resolve()) for directory in (first, second)]
  153. self.assertEqual(response.json()["candidate_paths"], expected)
  154. self.assertIsNone(response.json()["resolved_path"])
  155. for path in expected:
  156. self.assertIn(path, response.json()["message"])
  157. (second / "a.docx").write_bytes(content)
  158. response = self.post_path("static/a.docx", "REFERENCE_BID")
  159. self.assertEqual(response.status_code, 200, response.text)
  160. self.assertEqual(response.json()["resolved_path"], str((second / "a.docx").resolve()))
  161. (first / "a.docx").write_bytes(b"broken")
  162. response = self.post_path("static/a.docx", "REFERENCE_BID")
  163. self.assertEqual(response.status_code, 400)
  164. self.assertIn(str((first / "a.docx").resolve()), response.json()["message"])
  165. self.assertNotIn(str(second), response.json()["message"])
  166. self.assertEqual(self.post_path(second / "a.docx", "REFERENCE_BID", True).status_code, 200)
  167. def test_json_all_file_failures_include_source_absolute_path(self):
  168. for name, content in [("missing.pdf", None), ("empty.pdf", b""),
  169. ("bad.pdf", b"broken"), ("scan.pdf", _pdf_bytes("")),
  170. ("bad.docx", b"broken"), ("wrong.txt", b"text")]:
  171. with self.subTest(name=name), patch.object(http_api, "TPC_DIR", str(self.root)):
  172. path = self.root / name
  173. if content is not None:
  174. path.write_bytes(content)
  175. response = self.post_path("static/" + name)
  176. self.assertEqual(response.status_code, 400, response.text)
  177. body = response.json()
  178. self.assertFalse(body["valid"])
  179. self.assertIn(str(path.resolve()), body["message"])
  180. self.assertEqual(body["resolved_path"], str(path.resolve()))
  181. self.assertNotIn("proposa-validate-", body["message"])
  182. path = self.root / "large.pdf"
  183. path.write_bytes(_pdf_bytes())
  184. with patch.object(http_api, "MAX_UPLOAD_BYTES", 1):
  185. response = self.post_path(path, absolute=True)
  186. self.assertEqual(response.status_code, 413)
  187. self.assertIn(str(path.resolve()), response.json()["message"])
  188. with patch.object(http_api, "_save_local_path", side_effect=HTTPException(400, "无法读取")):
  189. response = self.post_path(path, absolute=True)
  190. self.assertIn(str(path.resolve()), response.json()["message"])
  191. def test_json_invalid_parameters_and_unconfigured_roots(self):
  192. url = "/api/v1/files/validate"
  193. valid = {"file_path": "a.pdf", "file_role": "TENDER_FILE", "is_absolute": False}
  194. for body in [None, [], {}, {**valid, "file_path": 3}, {**valid, "file_path": " "},
  195. {**valid, "file_role": "unknown"}, {**valid, "is_absolute": "false"},
  196. {key: value for key, value in valid.items() if key != "is_absolute"}]:
  197. response = self.client.post(url, content=json.dumps(body), headers={"Content-Type": "application/json"})
  198. self.assertEqual(response.status_code, 422, response.text)
  199. self.assertIn("message", response.json())
  200. response = self.client.post(url, content="{", headers={"Content-Type": "application/json"})
  201. self.assertEqual(response.status_code, 400)
  202. with patch.object(http_api, "TPC_DIR", ""), patch.object(http_api, "REFERENCE_DIR", ""), patch.object(http_api, "REFERENCE_DIR1", ""):
  203. for role in ("TENDER_FILE", "REFERENCE_BID"):
  204. response = self.post_path("a.pdf", role)
  205. self.assertEqual(response.status_code, 400)
  206. self.assertIn("未配置", response.json()["message"])
  207. self.assertEqual(response.json()["candidate_paths"], [])
  208. if __name__ == "__main__":
  209. unittest.main()