test_documents.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import io
  2. import json
  3. import tempfile
  4. import unittest
  5. from email import policy
  6. from email.parser import BytesParser
  7. from pathlib import Path
  8. from types import SimpleNamespace
  9. from unittest.mock import Mock, patch
  10. from app.config import settings
  11. from app.documents import collect, recipients
  12. from app.docuware_export import export
  13. from app.docuware_search import LiveAdapter, MockAdapter
  14. from app.file_storage import save_upload
  15. from tests import test_forderung_history as history_fixture
  16. class DocumentTests(unittest.TestCase):
  17. setUp = history_fixture.HistoryTests.setUp
  18. def prepare(self):
  19. temp = tempfile.TemporaryDirectory()
  20. self.addCleanup(temp.cleanup)
  21. self.root = Path(temp.name)
  22. self.db.connection.executescript(
  23. "CREATE TABLE dbo.Fahrzeuge (Client_DB TEXT, Fahrzeug_Nr TEXT, Fahrzeug_VIN TEXT); INSERT INTO dbo.Fahrzeuge VALUES ('1','V1','VIN1');"
  24. )
  25. config = patch.multiple(
  26. settings, DOCUWARE_MODE="mock", DOCUWARE_MOCK_PATH="data/docuware-sample", LOCAL_STORAGE=str(self.root)
  27. )
  28. config.start()
  29. self.addCleanup(config.stop)
  30. self.base = "/app/forderungen/documents/1_R1"
  31. def test_filters_local_history_and_customer_change(self):
  32. self.prepare()
  33. for number in ["R1", "R2", "R3", "H1", "H2", "X1"]:
  34. save_upload(dict(Client_DB="1", Beleg_Nr=number), number + ".txt", io.BytesIO(b"local"), 100)
  35. row = dict(Kunde_Nr="D1", Fahrzeug_Nr="V1")
  36. for scope, expected in [
  37. ("invoice", {"R1"}),
  38. ("open", {"R1", "R2"}),
  39. ("debtor", {"R1", "R2", "R3", "H1", "H2"}),
  40. ("vehicle", {"R1", "R3", "H1", "X1"}),
  41. ]:
  42. docs, _ = collect(self.db, self.pkey, row, scope)
  43. self.assertEqual({d["number"] for d in docs if d["source"] == "Lokal"}, expected)
  44. response = self.client.get(self.base + "?scope=vehicle&detail=true")
  45. self.assertEqual(response.status_code, 200)
  46. self.assertIn("Fahrzeugdokument", response.text)
  47. self.assertIn("data-document-preview", response.text)
  48. self.assertIn("X1.txt", response.text)
  49. def test_empty_connection_failure_and_targeted_pdf(self):
  50. self.prepare()
  51. adapter = MockAdapter()
  52. docs = adapter.search("1", document_no="R1")
  53. self.assertEqual(len(docs), 2)
  54. self.assertEqual(adapter.search("2", document_no="R1"), [])
  55. self.assertEqual(adapter.search("1", document_no="absent"), [])
  56. response = self.client.get(self.base)
  57. from app.documents import identity
  58. token = identity("dw", "sample", "2")
  59. response = self.client.get(self.base + "/content/" + token)
  60. self.assertEqual(response.status_code, 200)
  61. self.assertEqual(response.content, Path("data/docuware-sample/sample.pdf").read_bytes())
  62. self.assertIn("inline", response.headers["content-disposition"])
  63. save_upload(self.pkey, "available.txt", io.BytesIO(b"ok"), 100)
  64. with patch("app.documents.get_adapter") as factory:
  65. factory.return_value.search.side_effect = ConnectionError()
  66. with self.assertLogs("app.documents", level="ERROR"):
  67. response = self.client.get(self.base)
  68. self.assertIn("Keine Verbindung zu DocuWare", response.text)
  69. self.assertIn("available.txt", response.text)
  70. def test_unauthorized_tokens_scopes_and_local_paths(self):
  71. self.prepare()
  72. for suffix in ["", "/content/forged", "/recipients/forged", "/draft/forged?recipient=self"]:
  73. self.assertEqual(self.client.get("/app/forderungen/documents/1_R2" + suffix).status_code, 404)
  74. self.assertEqual(self.client.get(self.base + "/content/forged").status_code, 404)
  75. self.assertEqual(self.client.get(self.base + "?scope=invalid").status_code, 422)
  76. save_upload(dict(Client_DB="2", Beleg_Nr="R1"), "private.pdf", io.BytesIO(b"secret"), 100)
  77. from app.documents import identity
  78. token = identity("local", "2_R1/private.pdf")
  79. self.assertEqual(self.client.get(self.base + "/content/" + token).status_code, 404)
  80. def test_draft_exact_attachment_and_recipient_validation(self):
  81. self.prepare()
  82. from app.documents import identity
  83. token = identity("dw", "sample", "1")
  84. targets = [
  85. dict(id="self", label="An mich", email="me@example.com"),
  86. dict(id="insurance", label="Versicherung", email=""),
  87. ]
  88. with patch("app.documents.recipients", return_value=targets) as lookup:
  89. response = self.client.get(self.base + "/draft/" + token + "?recipient=self")
  90. self.assertEqual(response.status_code, 200)
  91. self.assertEqual(lookup.call_args.args[1], "1_R1")
  92. message = BytesParser(policy=policy.default).parsebytes(response.content)
  93. self.assertEqual(message["To"], "me@example.com")
  94. self.assertEqual(message["X-Unsent"], "1")
  95. attachments = list(message.iter_attachments())
  96. self.assertEqual(len(attachments), 1)
  97. self.assertEqual(attachments[0].get_filename(), "Testdokument-1.pdf")
  98. self.assertEqual(
  99. attachments[0].get_payload(decode=True), Path("data/docuware-sample/sample.pdf").read_bytes()
  100. )
  101. for recipient in ["insurance", "forged", "me@evil.com"]:
  102. self.assertEqual(
  103. self.client.get(self.base + "/draft/" + token + "?recipient=" + recipient).status_code, 422
  104. )
  105. html = self.client.get(self.base + "/recipients/" + token).text
  106. self.assertIn("disabled", html)
  107. def test_empty_metadata_dedup_sort_and_upload_refresh(self):
  108. self.prepare()
  109. from app.documents import identity
  110. first = dict(archive="a", id="1", number="R1", date="2026-01-01", name="z.pdf", kind="", editor="")
  111. second = dict(first, id="2", date="2026-02-01", name="b.pdf")
  112. with patch("app.documents.get_adapter") as factory:
  113. factory.return_value.search.return_value = [first, second, first]
  114. docs, errors = collect(self.db, self.pkey, dict(Kunde_Nr="D1", Fahrzeug_Nr="V1"), "invoice")
  115. self.assertEqual([d["id"] for d in docs], ["2", "1"])
  116. self.assertEqual(errors, [])
  117. with patch("app.documents.get_adapter") as factory:
  118. factory.return_value.search.return_value = []
  119. response = self.client.get(self.base)
  120. self.assertIn("Keine Dokumente für Beleg R1 gefunden", response.text)
  121. save_upload(self.pkey, "local.pdf", io.BytesIO(b"%PDF-local"), 100)
  122. token = identity("local", "1_R1/local.pdf")
  123. response = self.client.get(self.base + "/content/" + token)
  124. self.assertEqual(response.content, b"%PDF-local")
  125. self.assertIn("inline", response.headers["content-disposition"])
  126. panel = (
  127. __import__("app.jinja_templates", fromlist=["templates"])
  128. .templates.env.get_template("forderungen/details/files_panel.html")
  129. .render(forderungen_details=[self.pkey], files_oob=True)
  130. )
  131. self.assertIn('hx-swap-oob="outerHTML"', panel)
  132. self.assertIn('data-document-key="1_R1"', panel)
  133. self.assertIn('data-document-detail="true"', panel)
  134. def test_missing_file_and_mock_path_escape(self):
  135. self.prepare()
  136. manifest = dict(
  137. version=1,
  138. documents=[dict(client_db="1", archive="a", id="1", number="R1", file="../outside.pdf", name="bad.pdf")],
  139. )
  140. (self.root / "manifest.json").write_text(json.dumps(manifest))
  141. with patch.object(settings, "DOCUWARE_MOCK_PATH", str(self.root)):
  142. with self.assertRaises(FileNotFoundError):
  143. MockAdapter().download("1", manifest["documents"][0])
  144. manifest["unavailable"] = True
  145. (self.root / "manifest.json").write_text(json.dumps(manifest))
  146. with self.assertRaises(ConnectionError):
  147. MockAdapter().search("1", document_no="R1")
  148. class AdapterTests(unittest.TestCase):
  149. def test_live_exact_search_missing_fields_download_and_full_iteration(self):
  150. document = SimpleNamespace(id=1, title="Test", fields=[SimpleNamespace(name="Belegnummer", value="R1")])
  151. other = SimpleNamespace(id=2, title="Other", fields=[SimpleNamespace(name="Belegnummer", value="R10")])
  152. cabinet = Mock(id="archive")
  153. cabinet.search_dialog.return_value.search.return_value = iter(
  154. [SimpleNamespace(document=d) for d in [document, other, document]]
  155. )
  156. cabinet.get_document.return_value.download.return_value = (b"%PDF-test", "application/pdf", "test.pdf")
  157. with patch("app.docuware_search.get_file_cabinet", return_value=cabinet):
  158. adapter = LiveAdapter()
  159. docs = adapter.search("1", document_no="R1")
  160. self.assertEqual(len(docs), 2)
  161. self.assertEqual(docs[0]["date"], "")
  162. self.assertEqual(adapter.download("1", docs[0])[0], b"%PDF-test")
  163. cabinet.get_document.assert_called_with("1")
  164. def test_export_roundtrip_dedup_and_failure_report(self):
  165. adapter = Mock()
  166. doc = dict(archive="archive", id="1", number="R1", name="x.pdf", date="", kind="", editor="")
  167. adapter.search.return_value = [doc, doc]
  168. adapter.download.return_value = (b"%PDF-test", "application/pdf", "ä.pdf")
  169. with tempfile.TemporaryDirectory() as folder:
  170. result = export(adapter, "1", [dict(document_no="R1")], folder)
  171. self.assertEqual(len(result["documents"]), 1)
  172. with patch.object(settings, "DOCUWARE_MOCK_PATH", folder):
  173. mock = MockAdapter()
  174. docs = mock.search("1", document_no="R1")
  175. self.assertEqual(mock.download("1", docs[0])[0], b"%PDF-test")
  176. with self.assertRaises(ValueError):
  177. export(adapter, "1", [dict(document_no="R1")], folder)
  178. adapter.download.side_effect = ConnectionError("sensitive-token")
  179. with tempfile.TemporaryDirectory() as folder:
  180. result = export(adapter, "1", [dict(document_no="R1")], folder)
  181. self.assertEqual(result["errors"][0]["stage"], "download")
  182. self.assertNotIn("sensitive-token", json.dumps(result))
  183. def test_recipients_from_open_case_and_ad_override(self):
  184. request = SimpleNamespace(state=SimpleNamespace(principal=SimpleNamespace(email="self@example.com")))
  185. db = Mock()
  186. db.execute.return_value.mappings.return_value.first.return_value = dict(
  187. Verursacher_Aktuell_Benutzer_ID="override",
  188. Kunde_Email="customer@example.com",
  189. Vers_Email="insurance@example.com",
  190. )
  191. with patch(
  192. "app.documents.lookup_directory_user",
  193. return_value=SimpleNamespace(email="colleague@example.com", disabled=False),
  194. ) as lookup:
  195. targets = recipients(request, "1_R1", db)
  196. lookup.assert_called_once_with("override")
  197. self.assertEqual(
  198. [t["email"] for t in targets],
  199. ["self@example.com", "colleague@example.com", "customer@example.com", "insurance@example.com"],
  200. )
  201. self.assertEqual(db.execute.call_args.args[1], dict(client_db="1", beleg_nr="R1"))