test_navigation.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import unittest
  2. from datetime import date, datetime
  3. from types import SimpleNamespace
  4. from unittest.mock import MagicMock, patch
  5. from urllib.parse import parse_qs, urlencode, urlsplit
  6. from zoneinfo import ZoneInfo
  7. from fastapi import FastAPI
  8. from fastapi.testclient import TestClient
  9. import tests # Initialize temporary logging before importing the application.
  10. from app.config import settings
  11. from app.db import get_session
  12. from app.ldap import DirectoryUser
  13. from app.main import app
  14. from app.navigation import LIST_PATH, list_return_url, normalize_page_url
  15. from app.permissions import Entitlement
  16. from app.routes import router
  17. class NavigationUrlTests(unittest.TestCase):
  18. def test_return_url_preserves_filters_and_replaces_transport_and_focus(self):
  19. value = LIST_PATH + "?Filiale=10&Kunde=A%26B&page=3&limit=100&scope=debtor&focus=2_OTHER"
  20. result = list_return_url(value, "1_R1")
  21. self.assertEqual(parse_qs(urlsplit(result).query), {"Filiale": ["10"], "Kunde": ["A&B"], "focus": ["1_R1"]})
  22. def test_invalid_return_destinations_fall_back_to_filtered_page_type(self):
  23. for value in [
  24. "https://evil.test/",
  25. "//evil.test",
  26. "/\\evil",
  27. "/%2f%2fevil",
  28. "/select",
  29. "/app/forderungen/liste\n",
  30. ]:
  31. with self.subTest(value=value):
  32. self.assertEqual(list_return_url(value, "1_R1"), LIST_PATH + "?focus=1_R1")
  33. def test_fragment_recovery_uses_only_matching_invoice_context(self):
  34. return_to = LIST_PATH + "?Filiale=10&focus=1_R1"
  35. current = "https://external-proxy.test/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to})
  36. for fragment in ["kommentare", "buchungen"]:
  37. result = normalize_page_url(
  38. "/app/forderungen/details/1_R1/" + fragment + "?scope=debtor", current_url=current
  39. )
  40. self.assertEqual(result, "/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to}))
  41. self.assertEqual(
  42. normalize_page_url("/app/forderungen/details/2_R1/kommentare", current_url=current),
  43. "/app/forderungen/details/2_R1",
  44. )
  45. def test_external_current_url_never_becomes_redirect_origin(self):
  46. result = normalize_page_url(
  47. "/app/forderungen/details/1_R1/buchungen",
  48. current_url="https://evil.test/app/forderungen/details/1_R1?return_to=https://evil.test",
  49. )
  50. self.assertTrue(result.startswith("/app/forderungen/details/1_R1?"))
  51. self.assertNotIn("evil", result)
  52. class LoginRecoveryTests(unittest.TestCase):
  53. def setUp(self):
  54. config = patch.multiple(settings, DEBUG_AUTH=False, TRUSTED_PROXY_IPS=[], COOKIE_SECURE=False)
  55. config.start()
  56. self.addCleanup(config.stop)
  57. session = patch("app.auth.SessionLocal", return_value=MagicMock())
  58. session.start()
  59. self.addCleanup(session.stop)
  60. self.client = TestClient(app)
  61. self.addCleanup(self.client.close)
  62. def login_target(self, response):
  63. self.assertEqual(response.status_code, 200)
  64. login = response.headers["HX-Redirect"]
  65. self.assertTrue(login.startswith("/login?next="))
  66. return parse_qs(urlsplit(login).query)["next"][0]
  67. def test_expired_list_paging_resumes_full_filtered_page(self):
  68. response = self.client.get(
  69. LIST_PATH + "?page=3&limit=100&Filiale=10",
  70. headers={
  71. "HX-Request": "true",
  72. "HX-Current-URL": "http://testserver" + LIST_PATH + "?Filiale=10&focus=1_R120",
  73. },
  74. follow_redirects=False,
  75. )
  76. self.assertEqual(self.login_target(response), LIST_PATH + "?Filiale=10&focus=1_R120")
  77. def test_fragment_and_save_expiry_resume_detail_without_replaying_post(self):
  78. return_to = LIST_PATH + "?Filiale=10&Kunde=Muster&focus=1_R1"
  79. current = "http://testserver/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to})
  80. headers = {"HX-Request": "true", "HX-Current-URL": current}
  81. for suffix in ["/kommentare?scope=debtor", "/buchungen?scope=open", ""]:
  82. with self.subTest(suffix=suffix):
  83. if suffix:
  84. response = self.client.get("/app/forderungen/details/1_R1" + suffix, headers=headers)
  85. else:
  86. response = self.client.post(
  87. "/app/forderungen/details/1_R1",
  88. headers=headers,
  89. data={"source": "details", "return_to": return_to},
  90. )
  91. self.assertEqual(self.login_target(response), current.removeprefix("http://testserver"))
  92. def test_login_post_normalizes_fragment_next_to_full_get(self):
  93. self.client.get("/login")
  94. csrf = self.client.cookies[settings.LOGIN_CSRF_COOKIE_NAME]
  95. user = DirectoryUser("tester", "CN=tester", "Tester", "")
  96. rights = (Entitlement("1", "10", "SC", "Mitarbeiter", 100),)
  97. with (
  98. patch("app.routes.ldap_authenticate", return_value=user),
  99. patch("app.routes.load_entitlements", return_value=rights),
  100. ):
  101. response = self.client.post(
  102. "/login",
  103. data={
  104. "username": "tester",
  105. "password": "test",
  106. "_csrf": csrf,
  107. "next": "/app/forderungen/details/1_R1/kommentare?scope=debtor",
  108. },
  109. follow_redirects=False,
  110. )
  111. self.assertEqual(response.status_code, 303)
  112. self.assertEqual(response.headers["location"], "/app/forderungen/details/1_R1")
  113. def test_native_page_request_uses_full_page_redirect(self):
  114. response = self.client.get(LIST_PATH + "?Filiale=10", follow_redirects=False)
  115. self.assertEqual(response.status_code, 303)
  116. self.assertEqual(parse_qs(urlsplit(response.headers["location"]).query)["next"], [LIST_PATH + "?Filiale=10"])
  117. class DetailReturnTests(unittest.TestCase):
  118. def setUp(self):
  119. self.db = MagicMock()
  120. self.db.execute.return_value.first.return_value = (1,)
  121. application = FastAPI()
  122. application.include_router(router)
  123. application.dependency_overrides[get_session] = lambda: self.db
  124. @application.middleware("http")
  125. async def identity(request, call_next):
  126. request.state.principal = SimpleNamespace(username="tester", name="Tester", role="Mitarbeiter", csrf="csrf")
  127. return await call_next(request)
  128. self.client = TestClient(application, raise_server_exceptions=False)
  129. self.addCleanup(self.client.close)
  130. self.row = dict(
  131. Client_DB="1",
  132. Beleg_Nr="R1",
  133. Beleg_Text="Rechnung R1",
  134. Beleg_Offen=10,
  135. Kunde_offen_gesamt=20,
  136. Beleg_Datum=date(2026, 10, 1),
  137. Fahrzeug_Erstzulassung=None,
  138. Fahrzeug_Kaufdatum=None,
  139. Wiedervorlage=date(2026, 11, 1),
  140. Mwst_Betrag=0,
  141. Mwst_Betrag_berechnet=1.9,
  142. )
  143. context = dict(
  144. forderungen_details=[self.row],
  145. forderungen_kommentare=[],
  146. forderungen_buchungen=[],
  147. auftraege_positionen=[],
  148. versicherungen=[],
  149. files=[],
  150. detail_url="/app/forderungen/details/1_R1",
  151. kommentare_scope="invoice",
  152. buchungen_scope="invoice",
  153. vehicle_missing=False,
  154. summary=False,
  155. )
  156. fixture = patch("app.routes.get_template_context", side_effect=lambda *args: dict(context))
  157. fixture.start()
  158. self.addCleanup(fixture.stop)
  159. self.return_url = LIST_PATH + "?Filiale=10&Kunde=Muster&focus=1_R1"
  160. def test_detail_customer_total_uses_database_column_alias(self):
  161. from contextlib import closing
  162. import sqlite3
  163. from app.jinja_templates import templates
  164. sql=templates.env.get_template('forderungen/details/queries/forderungen_details.sql').render()
  165. projection=next(line.strip().removeprefix(',').strip() for line in sql.splitlines() if 'AS [Kunde_offen_gesamt]' in line)
  166. with closing(sqlite3.connect(':memory:')) as connection:
  167. result=connection.execute('SELECT '+projection+' FROM (SELECT 12345.67 AS [Kunde_Gesamt_Offen]) AS [F]')
  168. self.row.pop('Kunde_offen_gesamt')
  169. self.row.update(dict(zip([column[0] for column in result.description],result.fetchone())))
  170. response=self.client.get('/app/forderungen/details/1_R1')
  171. self.assertEqual(response.status_code,200)
  172. self.assertIn('12.345,67',response.text)
  173. def test_detail_displays_missing_amounts_without_claiming_zero(self):
  174. self.row.update(Beleg_Offen=None,Kunde_offen_gesamt=None,Mwst_Betrag=None,Mwst_Betrag_berechnet=None)
  175. response=self.client.get('/app/forderungen/details/1_R1')
  176. self.assertEqual(response.status_code,200)
  177. self.assertIn('—',response.text)
  178. def test_detail_and_saved_form_keep_explicit_return_context(self):
  179. response = self.client.get("/app/forderungen/details/1_R1?" + urlencode({"return_to": self.return_url}))
  180. self.assertEqual(response.status_code, 200)
  181. self.assertIn("Filiale=10&Kunde=Muster&focus=1_R1", response.text)
  182. response = self.client.post(
  183. "/app/forderungen/details/1_R1",
  184. data={
  185. "source": "details",
  186. "return_to": self.return_url,
  187. },
  188. headers={"HX-Request": "true", "Referer": "http://testserver/app/forderungen/details/1_R1"},
  189. )
  190. self.assertEqual(response.status_code, 200)
  191. self.assertIn(
  192. 'name="return_to" value="/app/forderungen/liste?Filiale=10&Kunde=Muster&focus=1_R1"', response.text
  193. )
  194. self.assertEqual(self.db.commit.call_count, 1)
  195. def test_save_and_back_redirects_after_commit_to_full_list(self):
  196. response = self.client.post(
  197. "/app/forderungen/details/1_R1",
  198. data={
  199. "source": "details",
  200. "return_to": self.return_url,
  201. "redirect": "list",
  202. },
  203. headers={"HX-Request": "true"},
  204. )
  205. self.assertEqual(response.status_code, 200)
  206. self.assertEqual(response.headers["HX-Redirect"], self.return_url)
  207. self.db.commit.assert_called_once()
  208. native = self.client.post(
  209. "/app/forderungen/details/1_R1",
  210. data={
  211. "source": "details",
  212. "return_to": self.return_url,
  213. "redirect": "list",
  214. },
  215. follow_redirects=False,
  216. )
  217. self.assertEqual(native.status_code, 303)
  218. self.assertEqual(native.headers["location"], self.return_url)
  219. def test_rendered_save_and_back_supplies_all_sql_parameters(self):
  220. import json
  221. from html.parser import HTMLParser
  222. class DetailForm(HTMLParser):
  223. active = False
  224. def __init__(self):
  225. super().__init__()
  226. self.values = {}
  227. self.save_back = None
  228. self.textarea = None
  229. def handle_starttag(self, tag, attrs):
  230. attrs = dict(attrs)
  231. if tag == "form":
  232. self.active = attrs.get("id") == "formular"
  233. if not self.active:
  234. return
  235. if tag == "input" and attrs.get("name"):
  236. if attrs.get("type") != "checkbox" or "checked" in attrs:
  237. self.values[attrs["name"]] = attrs.get("value", "on")
  238. if tag == "button" and "hx-vals" in attrs:
  239. self.save_back = attrs
  240. if tag == "textarea" and attrs.get("name"):
  241. self.textarea = attrs["name"]
  242. self.values[self.textarea] = ''
  243. def handle_data(self, data):
  244. if self.textarea:
  245. self.values[self.textarea] += data
  246. def handle_endtag(self, tag):
  247. if tag == "textarea":
  248. self.textarea = None
  249. if tag == "form":
  250. self.active = False
  251. self.row['Versicherung_Schadennummer'] = 'SCH-123,45'
  252. self.row['Schaden_Details'] = 'Stoßfänger beschädigt <vorne> & rechts.\nLackierung nötig, 2 Teile.'
  253. response = self.client.get('/app/forderungen/details/1_R1', params={'return_to': self.return_url})
  254. self.assertEqual(response.status_code, 200)
  255. form = DetailForm()
  256. form.feed(response.text)
  257. self.assertIsNotNone(form.save_back)
  258. self.assertNotIn('"', form.save_back)
  259. submitted = {**form.values, **json.loads(form.save_back['hx-vals'])}
  260. submitted['Wiedervorlage'] = '2026-11-18'
  261. def check_bindings(statement, values):
  262. # Exercise SQLAlchemy's actual required-parameter validation; a plain
  263. # MagicMock accepted the missing Schadennummer parameter unnoticed.
  264. statement.compile().construct_params(values)
  265. return MagicMock(first=lambda: (1,))
  266. self.db.execute.side_effect = check_bindings
  267. response = self.client.post('/app/forderungen/details/1_R1', data=submitted, headers={'HX-Request': 'true'})
  268. self.assertEqual(response.status_code, 200)
  269. self.db.commit.assert_called_once()
  270. self.assertEqual(response.headers['HX-Redirect'], self.return_url)
  271. update = next(call for call in self.db.execute.call_args_list if str(call.args[0]).lstrip().startswith('UPDATE'))
  272. self.assertEqual(update.args[1]['Schadennummer'], 'SCH-123,45')
  273. self.assertEqual(update.args[1]['Schaden_Details'], self.row['Schaden_Details'])
  274. self.assertIn('[Schaden_Details] = :Schaden_Details', str(update.args[0]))
  275. self.assertEqual(update.args[1]['Wiedervorlage'], date(2026, 11, 18))
  276. def test_form_saves_stamp_authenticated_user_and_server_time(self):
  277. for redirect in ('', 'list'):
  278. with self.subTest(redirect=redirect):
  279. self.db.reset_mock()
  280. before = datetime.now(ZoneInfo('Europe/Berlin')).replace(tzinfo=None)
  281. response = self.client.post('/app/forderungen/details/1_R1', data={
  282. 'source': 'details', 'redirect': redirect, 'return_to': self.return_url,
  283. 'Schaden_Details': 'Neue Schadenbeschreibung',
  284. 'Bearbeitung_Benutzer_ID': 'forged-user',
  285. 'Bearbeitung_Timestamp': '2000-01-01T00:00:00',
  286. }, headers={'HX-Request': 'true'})
  287. after = datetime.now(ZoneInfo('Europe/Berlin')).replace(tzinfo=None)
  288. self.assertEqual(response.status_code, 200)
  289. update = next(call for call in self.db.execute.call_args_list
  290. if str(call.args[0]).lstrip().startswith('UPDATE'))
  291. statement, values = update.args
  292. statement.compile().construct_params(values)
  293. self.assertIn('[Bearbeitung_Benutzer_ID] = :Bearbeitung_Benutzer_ID', str(statement))
  294. self.assertIn('[Bearbeitung_Timestamp] = :Bearbeitung_Timestamp', str(statement))
  295. self.assertEqual(values['Bearbeitung_Benutzer_ID'], 'tester')
  296. self.assertEqual(values['Schaden_Details'], 'Neue Schadenbeschreibung')
  297. self.assertIs(type(values['Bearbeitung_Timestamp']), datetime)
  298. self.assertIsNone(values['Bearbeitung_Timestamp'].tzinfo)
  299. self.assertLessEqual(before, values['Bearbeitung_Timestamp'])
  300. self.assertLessEqual(values['Bearbeitung_Timestamp'], after)
  301. self.db.commit.assert_called_once()
  302. if redirect:
  303. self.assertEqual(response.headers['HX-Redirect'], self.return_url)
  304. def test_failed_save_does_not_return_and_external_target_is_ignored(self):
  305. self.db.commit.side_effect = RuntimeError("test database unavailable")
  306. response = self.client.post(
  307. "/app/forderungen/details/1_R1",
  308. data={
  309. "source": "details",
  310. "return_to": self.return_url,
  311. "redirect": "list",
  312. },
  313. headers={"HX-Request": "true"},
  314. )
  315. self.assertEqual(response.status_code, 500)
  316. self.assertNotIn("HX-Redirect", response.headers)
  317. self.db.commit.side_effect = None
  318. response = self.client.post(
  319. "/app/forderungen/details/1_R1",
  320. data={
  321. "source": "details",
  322. "redirect": "https://evil.test",
  323. },
  324. headers={"HX-Request": "true"},
  325. )
  326. self.assertEqual(response.headers["HX-Redirect"], LIST_PATH + "?focus=1_R1")