test_documents.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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. def test_database_email_sources_enable_recipients_and_address_drafts(self):
  149. self.prepare()
  150. from app.documents import identity
  151. self.db.connection.executescript("""
  152. ALTER TABLE dbo.Benutzer_Rolle ADD Benutzer_Email TEXT;
  153. UPDATE dbo.Benutzer_Rolle SET Benutzer_Email=' me@example.com ' WHERE Benutzer_ID='tester';
  154. INSERT INTO dbo.Benutzer_Rolle (Benutzer_ID, Benutzer_Email) VALUES ('tester', 'me@example.com');
  155. INSERT INTO dbo.Benutzer_Rolle (Benutzer_ID, Benutzer_Email) VALUES ('other', 'wrong-user@example.com');
  156. CREATE TABLE dbo.Forderungen_Liste (Client_DB TEXT, Beleg_Nr TEXT, Verursacher_Email TEXT);
  157. INSERT INTO dbo.Forderungen_Liste VALUES ('1', 'R1', 'assignee@example.com');
  158. INSERT INTO dbo.Forderungen_Liste VALUES ('2', 'R1', 'wrong-tenant@example.com');
  159. """)
  160. execute = self.db.execute
  161. def details_and_queries(statement, values=None):
  162. if 'AS [Kunde_offen_gesamt]' in str(statement):
  163. result = Mock()
  164. result.mappings.return_value.first.return_value = dict(
  165. Verursacher_Aktuell_Benutzer_ID='assignee', Kunde_Email='', Vers_Email='')
  166. return result
  167. return execute(statement, values)
  168. token = identity('dw', 'sample', '1')
  169. with patch.object(self.db, 'execute', side_effect=details_and_queries), patch('app.documents.lookup_directory_user') as lookup:
  170. response = self.client.get(self.base + '/recipients/' + token)
  171. self.assertEqual(response.status_code, 200)
  172. self.assertIn('An mich: me@example.com', response.text)
  173. self.assertIn('Verursacher: assignee@example.com', response.text)
  174. for target, expected in [('self', 'me@example.com'), ('colleague', 'assignee@example.com')]:
  175. response = self.client.get(self.base + '/draft/' + token, params={'recipient': target})
  176. self.assertEqual(response.status_code, 200)
  177. message = BytesParser(policy=policy.default).parsebytes(response.content)
  178. self.assertEqual(message['To'], expected)
  179. self.assertEqual(len(list(message.iter_attachments())), 1)
  180. lookup.assert_not_called()
  181. class AdapterTests(unittest.TestCase):
  182. def test_live_exact_search_missing_fields_download_and_full_iteration(self):
  183. document = SimpleNamespace(id=1, title="Test", fields=[SimpleNamespace(name="Belegnummer", value="R1")])
  184. other = SimpleNamespace(id=2, title="Other", fields=[SimpleNamespace(name="Belegnummer", value="R10")])
  185. cabinet = Mock(id="archive")
  186. cabinet.search_dialog.return_value.search.return_value = iter(
  187. [SimpleNamespace(document=d) for d in [document, other, document]]
  188. )
  189. cabinet.get_document.return_value.download.return_value = (b"%PDF-test", "application/pdf", "test.pdf")
  190. with patch("app.docuware_search.get_file_cabinet", return_value=cabinet):
  191. adapter = LiveAdapter()
  192. docs = adapter.search("1", document_no="R1")
  193. self.assertEqual(len(docs), 2)
  194. self.assertEqual(docs[0]["date"], "")
  195. self.assertEqual(adapter.download("1", docs[0])[0], b"%PDF-test")
  196. cabinet.get_document.assert_called_with("1")
  197. def test_export_roundtrip_dedup_and_failure_report(self):
  198. adapter = Mock()
  199. doc = dict(archive="archive", id="1", number="R1", name="x.pdf", date="", kind="", editor="")
  200. adapter.search.return_value = [doc, doc]
  201. adapter.download.return_value = (b"%PDF-test", "application/pdf", "ä.pdf")
  202. with tempfile.TemporaryDirectory() as folder:
  203. result = export(adapter, "1", [dict(document_no="R1")], folder)
  204. self.assertEqual(len(result["documents"]), 1)
  205. with patch.object(settings, "DOCUWARE_MOCK_PATH", folder):
  206. mock = MockAdapter()
  207. docs = mock.search("1", document_no="R1")
  208. self.assertEqual(mock.download("1", docs[0])[0], b"%PDF-test")
  209. with self.assertRaises(ValueError):
  210. export(adapter, "1", [dict(document_no="R1")], folder)
  211. adapter.download.side_effect = ConnectionError("sensitive-token")
  212. with tempfile.TemporaryDirectory() as folder:
  213. result = export(adapter, "1", [dict(document_no="R1")], folder)
  214. self.assertEqual(result["errors"][0]["stage"], "download")
  215. self.assertNotIn("sensitive-token", json.dumps(result))
  216. def test_recipients_from_open_case_and_ad_override(self):
  217. request = SimpleNamespace(state=SimpleNamespace(principal=SimpleNamespace(username="tester", email="self@example.com")))
  218. db = Mock()
  219. db.execute.return_value.mappings.return_value.first.side_effect = [dict(
  220. Verursacher_Aktuell_Benutzer_ID="override",
  221. Kunde_Email="customer@example.com",
  222. Vers_Email="insurance@example.com",
  223. ), None]
  224. db.execute.return_value.mappings.return_value.fetchall.return_value = []
  225. with patch(
  226. "app.documents.lookup_directory_user",
  227. return_value=SimpleNamespace(email="colleague@example.com", disabled=False),
  228. ) as lookup:
  229. targets = recipients(request, "1_R1", db)
  230. lookup.assert_called_once_with("override")
  231. self.assertEqual(
  232. [t["email"] for t in targets],
  233. ["self@example.com", "colleague@example.com", "customer@example.com", "insurance@example.com"],
  234. )
  235. self.assertEqual(db.execute.call_args.args[1], dict(client_db="1", beleg_nr="R1"))
  236. def test_missing_or_invalid_database_emails_keep_valid_session_fallback(self):
  237. request = SimpleNamespace(state=SimpleNamespace(principal=SimpleNamespace(username='tester', email='session@example.com')))
  238. for database_emails in [[], [{'Benutzer_Email': None}], [{'Benutzer_Email': 'invalid'}],
  239. [{'Benutzer_Email': 'invalid'}, {'Benutzer_Email': 'db@example.com'}]]:
  240. with self.subTest(emails=database_emails):
  241. db = Mock()
  242. db.execute.return_value.mappings.return_value.first.side_effect = [dict(Kunde_Email='', Vers_Email=''), None]
  243. db.execute.return_value.mappings.return_value.fetchall.return_value = database_emails
  244. targets = recipients(request, '1_R1', db)
  245. expected = 'db@example.com' if len(database_emails) == 2 else 'session@example.com'
  246. self.assertEqual(targets[0]['email'], expected)
  247. self.assertEqual(targets[1]['email'], '')