test_navigation.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import unittest
  2. from datetime import date
  3. from types import SimpleNamespace
  4. from unittest.mock import MagicMock, patch
  5. from urllib.parse import parse_qs, urlencode, urlsplit
  6. from fastapi import FastAPI
  7. from fastapi.testclient import TestClient
  8. import tests # Initialize temporary logging before importing the application.
  9. from app.config import settings
  10. from app.db import get_session
  11. from app.ldap import DirectoryUser
  12. from app.main import app
  13. from app.navigation import LIST_PATH, list_return_url, normalize_page_url
  14. from app.permissions import Entitlement
  15. from app.routes import router
  16. class NavigationUrlTests(unittest.TestCase):
  17. def test_return_url_preserves_filters_and_replaces_transport_and_focus(self):
  18. value = LIST_PATH + "?Filiale=10&Kunde=A%26B&page=3&limit=100&scope=debtor&focus=2_OTHER"
  19. result = list_return_url(value, "1_R1")
  20. self.assertEqual(parse_qs(urlsplit(result).query), {"Filiale": ["10"], "Kunde": ["A&B"], "focus": ["1_R1"]})
  21. def test_invalid_return_destinations_fall_back_to_filtered_page_type(self):
  22. for value in [
  23. "https://evil.test/",
  24. "//evil.test",
  25. "/\\evil",
  26. "/%2f%2fevil",
  27. "/select",
  28. "/app/forderungen/liste\n",
  29. ]:
  30. with self.subTest(value=value):
  31. self.assertEqual(list_return_url(value, "1_R1"), LIST_PATH + "?focus=1_R1")
  32. def test_fragment_recovery_uses_only_matching_invoice_context(self):
  33. return_to = LIST_PATH + "?Filiale=10&focus=1_R1"
  34. current = "https://external-proxy.test/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to})
  35. for fragment in ["kommentare", "buchungen"]:
  36. result = normalize_page_url(
  37. "/app/forderungen/details/1_R1/" + fragment + "?scope=debtor", current_url=current
  38. )
  39. self.assertEqual(result, "/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to}))
  40. self.assertEqual(
  41. normalize_page_url("/app/forderungen/details/2_R1/kommentare", current_url=current),
  42. "/app/forderungen/details/2_R1",
  43. )
  44. def test_external_current_url_never_becomes_redirect_origin(self):
  45. result = normalize_page_url(
  46. "/app/forderungen/details/1_R1/buchungen",
  47. current_url="https://evil.test/app/forderungen/details/1_R1?return_to=https://evil.test",
  48. )
  49. self.assertTrue(result.startswith("/app/forderungen/details/1_R1?"))
  50. self.assertNotIn("evil", result)
  51. class LoginRecoveryTests(unittest.TestCase):
  52. def setUp(self):
  53. config = patch.multiple(settings, DEBUG_AUTH=False, TRUSTED_PROXY_IPS=[], COOKIE_SECURE=False)
  54. config.start()
  55. self.addCleanup(config.stop)
  56. session = patch("app.auth.SessionLocal", return_value=MagicMock())
  57. session.start()
  58. self.addCleanup(session.stop)
  59. self.client = TestClient(app)
  60. self.addCleanup(self.client.close)
  61. def login_target(self, response):
  62. self.assertEqual(response.status_code, 200)
  63. login = response.headers["HX-Redirect"]
  64. self.assertTrue(login.startswith("/login?next="))
  65. return parse_qs(urlsplit(login).query)["next"][0]
  66. def test_expired_list_paging_resumes_full_filtered_page(self):
  67. response = self.client.get(
  68. LIST_PATH + "?page=3&limit=100&Filiale=10",
  69. headers={
  70. "HX-Request": "true",
  71. "HX-Current-URL": "http://testserver" + LIST_PATH + "?Filiale=10&focus=1_R120",
  72. },
  73. follow_redirects=False,
  74. )
  75. self.assertEqual(self.login_target(response), LIST_PATH + "?Filiale=10&focus=1_R120")
  76. def test_fragment_and_save_expiry_resume_detail_without_replaying_post(self):
  77. return_to = LIST_PATH + "?Filiale=10&Kunde=Muster&focus=1_R1"
  78. current = "http://testserver/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to})
  79. headers = {"HX-Request": "true", "HX-Current-URL": current}
  80. for suffix in ["/kommentare?scope=debtor", "/buchungen?scope=open", ""]:
  81. with self.subTest(suffix=suffix):
  82. if suffix:
  83. response = self.client.get("/app/forderungen/details/1_R1" + suffix, headers=headers)
  84. else:
  85. response = self.client.post(
  86. "/app/forderungen/details/1_R1",
  87. headers=headers,
  88. data={"source": "details", "return_to": return_to},
  89. )
  90. self.assertEqual(self.login_target(response), current.removeprefix("http://testserver"))
  91. def test_login_post_normalizes_fragment_next_to_full_get(self):
  92. self.client.get("/login")
  93. csrf = self.client.cookies[settings.LOGIN_CSRF_COOKIE_NAME]
  94. user = DirectoryUser("tester", "CN=tester", "Tester", "")
  95. rights = (Entitlement("1", "10", "SC", "Mitarbeiter", 100),)
  96. with (
  97. patch("app.routes.ldap_authenticate", return_value=user),
  98. patch("app.routes.load_entitlements", return_value=rights),
  99. ):
  100. response = self.client.post(
  101. "/login",
  102. data={
  103. "username": "tester",
  104. "password": "test",
  105. "_csrf": csrf,
  106. "next": "/app/forderungen/details/1_R1/kommentare?scope=debtor",
  107. },
  108. follow_redirects=False,
  109. )
  110. self.assertEqual(response.status_code, 303)
  111. self.assertEqual(response.headers["location"], "/app/forderungen/details/1_R1")
  112. def test_native_page_request_uses_full_page_redirect(self):
  113. response = self.client.get(LIST_PATH + "?Filiale=10", follow_redirects=False)
  114. self.assertEqual(response.status_code, 303)
  115. self.assertEqual(parse_qs(urlsplit(response.headers["location"]).query)["next"], [LIST_PATH + "?Filiale=10"])
  116. class DetailReturnTests(unittest.TestCase):
  117. def setUp(self):
  118. self.db = MagicMock()
  119. self.db.execute.return_value.first.return_value = (1,)
  120. application = FastAPI()
  121. application.include_router(router)
  122. application.dependency_overrides[get_session] = lambda: self.db
  123. @application.middleware("http")
  124. async def identity(request, call_next):
  125. request.state.principal = SimpleNamespace(username="tester", name="Tester", role="Mitarbeiter", csrf="csrf")
  126. return await call_next(request)
  127. self.client = TestClient(application, raise_server_exceptions=False)
  128. self.addCleanup(self.client.close)
  129. self.row = dict(
  130. Client_DB="1",
  131. Beleg_Nr="R1",
  132. Beleg_Text="Rechnung R1",
  133. Beleg_Offen=10,
  134. Kunde_offen_gesamt=20,
  135. Beleg_Datum=date(2026, 10, 1),
  136. Fahrzeug_Erstzulassung=None,
  137. Fahrzeug_Kaufdatum=None,
  138. Wiedervorlage=date(2026, 11, 1),
  139. Mwst_Betrag=0,
  140. Mwst_Betrag_berechnet=1.9,
  141. )
  142. context = dict(
  143. forderungen_details=[self.row],
  144. forderungen_kommentare=[],
  145. forderungen_buchungen=[],
  146. auftraege_positionen=[],
  147. versicherungen=[],
  148. files=[],
  149. detail_url="/app/forderungen/details/1_R1",
  150. kommentare_scope="invoice",
  151. buchungen_scope="invoice",
  152. vehicle_missing=False,
  153. summary=False,
  154. )
  155. fixture = patch("app.routes.get_template_context", side_effect=lambda *args: dict(context))
  156. fixture.start()
  157. self.addCleanup(fixture.stop)
  158. self.return_url = LIST_PATH + "?Filiale=10&Kunde=Muster&focus=1_R1"
  159. def test_detail_customer_total_uses_database_column_alias(self):
  160. from contextlib import closing
  161. import sqlite3
  162. from app.jinja_templates import templates
  163. sql=templates.env.get_template('forderungen/details/queries/forderungen_details.sql').render()
  164. projection=next(line.strip().removeprefix(',').strip() for line in sql.splitlines() if 'AS [Kunde_offen_gesamt]' in line)
  165. with closing(sqlite3.connect(':memory:')) as connection:
  166. result=connection.execute('SELECT '+projection+' FROM (SELECT 12345.67 AS [Kunde_Gesamt_Offen]) AS [F]')
  167. self.row.pop('Kunde_offen_gesamt')
  168. self.row.update(dict(zip([column[0] for column in result.description],result.fetchone())))
  169. response=self.client.get('/app/forderungen/details/1_R1')
  170. self.assertEqual(response.status_code,200)
  171. self.assertIn('12.345,67',response.text)
  172. def test_detail_displays_missing_amounts_without_claiming_zero(self):
  173. self.row.update(Beleg_Offen=None,Kunde_offen_gesamt=None,Mwst_Betrag=None,Mwst_Betrag_berechnet=None)
  174. response=self.client.get('/app/forderungen/details/1_R1')
  175. self.assertEqual(response.status_code,200)
  176. self.assertIn('—',response.text)
  177. def test_detail_and_saved_form_keep_explicit_return_context(self):
  178. response = self.client.get("/app/forderungen/details/1_R1?" + urlencode({"return_to": self.return_url}))
  179. self.assertEqual(response.status_code, 200)
  180. self.assertIn("Filiale=10&Kunde=Muster&focus=1_R1", response.text)
  181. response = self.client.post(
  182. "/app/forderungen/details/1_R1",
  183. data={
  184. "source": "details",
  185. "return_to": self.return_url,
  186. },
  187. headers={"HX-Request": "true", "Referer": "http://testserver/app/forderungen/details/1_R1"},
  188. )
  189. self.assertEqual(response.status_code, 200)
  190. self.assertIn(
  191. 'name="return_to" value="/app/forderungen/liste?Filiale=10&Kunde=Muster&focus=1_R1"', response.text
  192. )
  193. self.assertEqual(self.db.commit.call_count, 1)
  194. def test_save_and_back_redirects_after_commit_to_full_list(self):
  195. response = self.client.post(
  196. "/app/forderungen/details/1_R1",
  197. data={
  198. "source": "details",
  199. "return_to": self.return_url,
  200. "redirect": "list",
  201. },
  202. headers={"HX-Request": "true"},
  203. )
  204. self.assertEqual(response.status_code, 200)
  205. self.assertEqual(response.headers["HX-Redirect"], self.return_url)
  206. self.db.commit.assert_called_once()
  207. native = self.client.post(
  208. "/app/forderungen/details/1_R1",
  209. data={
  210. "source": "details",
  211. "return_to": self.return_url,
  212. "redirect": "list",
  213. },
  214. follow_redirects=False,
  215. )
  216. self.assertEqual(native.status_code, 303)
  217. self.assertEqual(native.headers["location"], self.return_url)
  218. def test_failed_save_does_not_return_and_external_target_is_ignored(self):
  219. self.db.commit.side_effect = RuntimeError("test database unavailable")
  220. response = self.client.post(
  221. "/app/forderungen/details/1_R1",
  222. data={
  223. "source": "details",
  224. "return_to": self.return_url,
  225. "redirect": "list",
  226. },
  227. headers={"HX-Request": "true"},
  228. )
  229. self.assertEqual(response.status_code, 500)
  230. self.assertNotIn("HX-Redirect", response.headers)
  231. self.db.commit.side_effect = None
  232. response = self.client.post(
  233. "/app/forderungen/details/1_R1",
  234. data={
  235. "source": "details",
  236. "redirect": "https://evil.test",
  237. },
  238. headers={"HX-Request": "true"},
  239. )
  240. self.assertEqual(response.headers["HX-Redirect"], LIST_PATH + "?focus=1_R1")