test_forderung_history.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. """Exercise production SQL predicates on SQLite, adapting only SQL Server TOP syntax.
  2. This does not replace verification against the deployed SQL Server schema.
  3. """
  4. import re
  5. import sqlite3
  6. import unittest
  7. from datetime import date, datetime
  8. from html.parser import HTMLParser
  9. from unittest.mock import patch
  10. from urllib.parse import parse_qs, urlsplit
  11. from fastapi import FastAPI
  12. from fastapi.testclient import TestClient
  13. from app.auth import AuthenticationMiddleware
  14. from app.config import settings
  15. from app.db import get_session
  16. from app.forderung_history import history_context
  17. from app.oauth_token import create_access_token
  18. from app.permissions import Entitlement
  19. from app.routes import router
  20. class Record(dict):
  21. def __getattr__(self, name):
  22. try:
  23. return self[name]
  24. except KeyError:
  25. raise AttributeError(name) from None
  26. class Result:
  27. def __init__(self, cursor):
  28. self.rows = []
  29. if cursor.description:
  30. for values in cursor.fetchall():
  31. row = Record(zip((c[0] for c in cursor.description), values))
  32. if row.get("Timestamp"):
  33. row["Timestamp"] = datetime.fromisoformat(row["Timestamp"])
  34. if row.get("Beleg_Datum"):
  35. row["Beleg_Datum"] = date.fromisoformat(row["Beleg_Datum"])
  36. self.rows.append(row)
  37. def fetchall(self):
  38. return self.rows
  39. def first(self):
  40. return next(iter(self.rows), None)
  41. def mappings(self):
  42. return self
  43. class FixtureDB:
  44. def __init__(self):
  45. self.connection = sqlite3.connect(":memory:", check_same_thread=False)
  46. self.connection.execute("ATTACH DATABASE ':memory:' AS dbo")
  47. self.calls = []
  48. self.commits = 0
  49. def __enter__(self):
  50. return self
  51. def __exit__(self, *args):
  52. pass
  53. def execute(self, query, values=None):
  54. sql = str(query)
  55. self.calls.append((sql, dict(values or {})))
  56. top = re.search(r"SELECT TOP (\d+)", sql, re.I)
  57. if top:
  58. sql = re.sub(r"SELECT TOP \d+", "SELECT", sql, count=1, flags=re.I).rstrip() + " LIMIT " + top[1]
  59. return Result(self.connection.execute(sql, values or {}))
  60. def commit(self):
  61. self.commits += 1
  62. self.connection.commit()
  63. def seed(db):
  64. db.connection.executescript("""
  65. CREATE TABLE dbo.Forderungen_Kopf (
  66. Client_DB TEXT, Beleg_Nr TEXT, Kunde_Nr TEXT, Fahrzeug_Nr TEXT, Beleg_Offen REAL,
  67. Hauptbetrieb_ID TEXT, Standort_ID TEXT, Abteilung TEXT, Kunde_Text TEXT, Beleg_Datum TEXT
  68. );
  69. CREATE TABLE dbo.Forderungen_Historie (Client_DB TEXT, Beleg_Nr TEXT, Kunde_Nr TEXT, Fahrzeug_Nr TEXT);
  70. CREATE TABLE dbo.Forderungen_Buchungen (
  71. Client_DB TEXT, Kunde_Nr TEXT, Fahrzeug_Nr TEXT, Beleg_Nr TEXT, Bezug_Beleg_Nr TEXT,
  72. Beleg_Datum TEXT, Beleg_Beschreibung TEXT, Betrag REAL, Benutzer_ID TEXT, Bezahlt TEXT, Beleg_Art TEXT
  73. );
  74. CREATE TABLE dbo.Forderungen_Kommentar (
  75. Client_DB TEXT, Beleg_Nr TEXT, Timestamp TEXT, Benutzer_ID TEXT, Kommentar TEXT
  76. );
  77. CREATE VIEW dbo.Forderungen_Kommentar_Benutzer AS
  78. SELECT *, 'Test User' AS Name, 'MA' AS Rolle_ID, 'Mitarbeiter' AS Rolle_Name
  79. FROM Forderungen_Kommentar;
  80. CREATE TABLE dbo.Benutzer_Rechte (
  81. Benutzer_ID TEXT, Hauptbetrieb_ID TEXT, Standort_ID TEXT, Abteilung TEXT
  82. );
  83. INSERT INTO dbo.Benutzer_Rechte VALUES ('tester', '1', '10', 'Service');
  84. """)
  85. for tenant, number, customer, vehicle, balance, branch in [
  86. ("1", "R1", "D1", "V1", 100, "10"),
  87. ("1", "R2", "D1", "V2", 20, "20"),
  88. ("1", "R3", "D1", "V1", 0, "10"),
  89. ("1", "X1", "D2", "V1", 90, "10"),
  90. ("2", "R1", "D1", "V1", 100, "10"),
  91. ("1", "NV", "D3", None, 50, "10"),
  92. ]:
  93. db.connection.execute(
  94. "INSERT INTO dbo.Forderungen_Kopf VALUES (?, ?, ?, ?, ?, '1', ?, 'Service', 'Test debtor', '2026-01-01')",
  95. (tenant, number, customer, vehicle, balance, branch),
  96. )
  97. for number, vehicle in [("H1", "V1"), ("H2", "V2"), ("R3", "V1")]:
  98. db.connection.execute("INSERT INTO dbo.Forderungen_Historie VALUES ('1', ?, 'D1', ?)", (number, vehicle))
  99. for tenant, customer, vehicle, number, reference in [
  100. ("1", "D1", "V1", "R1", "R1"),
  101. ("1", "D1", None, "P1", "R1"),
  102. ("1", "D1", "V2", "R2", None),
  103. ("1", "D1", "V1", "R3", None),
  104. ("1", "D1", "V1", "H1", None),
  105. ("1", "D1", "V2", "H2", None),
  106. ("1", "D1", "V1", "LOOSE", None),
  107. ("1", "D2", "V1", "X1", "R1"),
  108. ("2", "D1", "V1", "R1", "R1"),
  109. ]:
  110. db.connection.execute(
  111. "INSERT INTO dbo.Forderungen_Buchungen VALUES (?, ?, ?, ?, ?, '2026-01-02', ?, 10, 'tester', 'N', 'RE')",
  112. (tenant, customer, vehicle, number, reference, "Booking " + number),
  113. )
  114. for tenant, number in [("1", "R1"), ("1", "R2"), ("1", "R3"), ("1", "H1"), ("1", "H2"), ("1", "X1"), ("2", "R1")]:
  115. db.connection.execute(
  116. "INSERT INTO dbo.Forderungen_Kommentar VALUES (?, ?, '2026-01-02T12:00:00', 'tester', ?)",
  117. (tenant, number, f"Comment {tenant}/{number}"),
  118. )
  119. db.connection.commit()
  120. class Tags(HTMLParser):
  121. def __init__(self, html):
  122. super().__init__()
  123. self.tags = []
  124. self.feed(html)
  125. def handle_starttag(self, tag, attrs):
  126. self.tags.append((tag, dict(attrs)))
  127. class HistoryTests(unittest.TestCase):
  128. def setUp(self):
  129. self.db = FixtureDB()
  130. seed(self.db)
  131. self.addCleanup(self.db.connection.close)
  132. self.application = FastAPI()
  133. self.application.include_router(router)
  134. self.application.dependency_overrides[get_session] = lambda: self.db
  135. self.application.add_middleware(AuthenticationMiddleware)
  136. self.entitlements = (Entitlement("1", "10", "Service", "Mitarbeiter", 100),)
  137. for target, kwargs in [
  138. ("app.auth.SessionLocal", {"return_value": self.db}),
  139. ("app.auth.load_entitlements", {"return_value": self.entitlements}),
  140. ]:
  141. mocked = patch(target, **kwargs)
  142. mocked.start()
  143. self.addCleanup(mocked.stop)
  144. config = patch.multiple(settings, COOKIE_SECURE=False, TRUSTED_PROXY_IPS=[])
  145. config.start()
  146. self.addCleanup(config.stop)
  147. self.client = TestClient(self.application)
  148. self.addCleanup(self.client.close)
  149. self.token = create_access_token(
  150. username="tester", name="Tester", email="", role="Mitarbeiter", auth_source="ldap", csrf="csrf"
  151. )
  152. self.client.cookies.set(settings.SESSION_COOKIE_NAME, self.token)
  153. self.pkey = {"Client_DB": "1", "Beleg_Nr": "R1"}
  154. def test_all_scopes_apply_to_real_sql_without_duplicate_bookings(self):
  155. expected_comments = {
  156. "invoice": {"R1"},
  157. "open": {"R1", "R2"},
  158. "vehicle": {"R1", "R3", "H1"},
  159. "debtor": {"R1", "R2", "R3", "H1", "H2"},
  160. }
  161. expected_bookings = {
  162. "invoice": {"R1", "P1"},
  163. "open": {"R1", "P1", "R2"},
  164. "vehicle": {"R1", "P1", "R3", "H1", "LOOSE"},
  165. "debtor": {"R1", "P1", "R2", "R3", "H1", "H2", "LOOSE"},
  166. }
  167. for section, expectations in [("kommentare", expected_comments), ("buchungen", expected_bookings)]:
  168. for scope, expected in expectations.items():
  169. with self.subTest(section=section, scope=scope):
  170. rows = history_context(self.db, self.pkey, section, scope)["forderungen_" + section]
  171. self.assertEqual({r.Beleg_Nr for r in rows}, expected)
  172. self.assertEqual(len(rows), len(expected))
  173. if section == "kommentare":
  174. self.assertTrue(all(r.Kommentar.startswith("Comment 1/") for r in rows))
  175. else:
  176. self.assertTrue(all(r.Client_DB == "1" and r.Kunde_Nr == "D1" for r in rows))
  177. def test_routes_render_isolated_fragments_and_default_to_invoice(self):
  178. for section, target in [("kommentare", "comments-history"), ("buchungen", "bookings-history")]:
  179. for scope in ["invoice", "open", "vehicle", "debtor"]:
  180. with self.subTest(section=section, scope=scope):
  181. response = self.client.get(f"/app/forderungen/details/1_R1/{section}?scope={scope}")
  182. self.assertEqual(response.status_code, 200)
  183. tags = Tags(response.text).tags
  184. self.assertIn(("div", next(a for t, a in tags if a.get("id") == target)), tags)
  185. buttons = [a for t, a in tags if t == "button"]
  186. self.assertEqual(len(buttons), 4)
  187. self.assertEqual(sum(b["aria-pressed"] == "true" for b in buttons), 1)
  188. self.assertTrue(all(b["hx-target"] == "#" + target for b in buttons))
  189. selected = next(b for b in buttons if b["aria-pressed"] == "true")
  190. self.assertTrue(selected["hx-get"].endswith("scope=" + scope))
  191. self.assertNotIn("<html", response.text)
  192. self.assertNotIn('id="formular"', response.text)
  193. self.assertNotIn('name="Kommentar"', response.text)
  194. response = self.client.get(f"/app/forderungen/details/1_R1/{section}")
  195. self.assertIn('aria-pressed="true"', response.text)
  196. self.assertNotIn("Booking R2" if section == "buchungen" else "Comment 1/R2", response.text)
  197. def test_expansion_permits_other_branch_but_direct_access_remains_denied(self):
  198. expanded = self.client.get("/app/forderungen/details/1_R1/kommentare?scope=debtor")
  199. self.assertIn("Comment 1/R2", expanded.text)
  200. for section in ["kommentare", "buchungen"]:
  201. self.db.calls.clear()
  202. response = self.client.get(f"/app/forderungen/details/1_R2/{section}?scope=debtor")
  203. self.assertEqual(response.status_code, 404)
  204. self.assertEqual(len(self.db.calls), 1)
  205. def test_invalid_scope_does_not_query_history(self):
  206. for section in ["kommentare", "buchungen"]:
  207. self.db.calls.clear()
  208. response = self.client.get(f"/app/forderungen/details/1_R1/{section}?scope=invalid")
  209. self.assertEqual(response.status_code, 422)
  210. self.assertFalse(self.db.calls)
  211. def test_missing_vehicle_and_empty_results_leave_filters_available(self):
  212. for section in ["kommentare", "buchungen"]:
  213. for scope in ["invoice", "vehicle"]:
  214. response = self.client.get(f"/app/forderungen/details/1_NV/{section}?scope={scope}")
  215. self.assertEqual(response.status_code, 200)
  216. buttons = [a for t, a in Tags(response.text).tags if t == "button"]
  217. vehicle = next(b for b in buttons if b["hx-get"].endswith("scope=vehicle"))
  218. self.assertIn("disabled", vehicle)
  219. self.assertIn("Keine Kommentare" if section == "kommentare" else "Keine Buchungsbelege", response.text)
  220. if scope == "vehicle":
  221. self.assertIn("kein Fahrzeug hinterlegt", response.text)
  222. def test_comment_post_preserves_scope_and_immutable_invoice_identity(self):
  223. for scope in ["invoice", "open", "vehicle", "debtor"]:
  224. response = self.client.post(
  225. "/app/forderungen/details/1_R1",
  226. data={
  227. "source": "comments",
  228. "_csrf": "csrf",
  229. "comment_scope": scope,
  230. "Kommentar": "New comment " + scope,
  231. "Client_DB": "2",
  232. "Beleg_Nr": "X1",
  233. "Benutzer_ID": "intruder",
  234. },
  235. )
  236. self.assertEqual(response.status_code, 200)
  237. self.assertIn("New comment " + scope, response.text)
  238. self.assertIn(f'name="comment_scope" value="{scope}"', response.text)
  239. self.assertNotIn('id="bookings-history"', response.text)
  240. self.assertNotIn('id="comment-form"', response.text)
  241. rows = self.db.connection.execute(
  242. "SELECT Client_DB, Beleg_Nr, Benutzer_ID FROM dbo.Forderungen_Kommentar WHERE Kommentar LIKE 'New comment%'"
  243. ).fetchall()
  244. self.assertEqual(rows, [("1", "R1", "tester")] * 4)
  245. self.assertEqual(self.db.commits, 4)
  246. def test_post_rejects_invalid_csrf_scope_and_unauthorized_invoice(self):
  247. for suffix, extra, expected in [
  248. ("1_R1", {"_csrf": "wrong"}, 403),
  249. ("1_R1", {"comment_scope": "invalid"}, 422),
  250. ("1_R2", {}, 404),
  251. ]:
  252. data = {"source": "comments", "_csrf": "csrf", "Kommentar": "Rejected"}
  253. data.update(extra)
  254. response = self.client.post("/app/forderungen/details/" + suffix, data=data)
  255. self.assertEqual(response.status_code, expected)
  256. self.assertEqual(self.db.commits, 0)
  257. def test_booking_limit_is_enforced_and_disclosed(self):
  258. self.db.connection.executemany(
  259. "INSERT INTO dbo.Forderungen_Buchungen VALUES ('1', 'D1', 'V1', ?, 'R1', '2026-02-01', 'Bulk', 1, 'tester', 'N', 'RE')",
  260. [(f"B{i:04}",) for i in range(1001)],
  261. )
  262. rows = history_context(self.db, self.pkey, "buchungen")["forderungen_buchungen"]
  263. self.assertEqual(len(rows), 1000)
  264. self.assertEqual(rows[0].Beleg_Nr, "P1")
  265. response = self.client.get("/app/forderungen/details/1_R1/buchungen")
  266. self.assertIn("ersten 1.000 Buchungsbelege", response.text)
  267. def test_comment_order_and_escaping(self):
  268. self.db.connection.execute(
  269. "INSERT INTO dbo.Forderungen_Kommentar VALUES ('1', 'R1', '2025-01-01T12:00:00', 'tester', '<script>old</script>')"
  270. )
  271. response = self.client.get("/app/forderungen/details/1_R1/kommentare?scope=debtor")
  272. self.assertIn("&lt;script&gt;old&lt;/script&gt;", response.text)
  273. self.assertLess(response.text.index("&lt;script&gt;old"), response.text.index("Comment 1/R1"))
  274. self.assertIn("Rechnung H1", response.text)
  275. def test_full_detail_page_keeps_filters_and_comment_form_when_lists_are_empty(self):
  276. context = history_context(self.db, self.pkey, "kommentare")
  277. context.update(history_context(self.db, self.pkey, "buchungen"))
  278. invoice = dict(context["forderungen_details"][0])
  279. invoice.update(
  280. Kunde_offen_gesamt=120,
  281. Mwst_Betrag=0,
  282. Mwst_Betrag_berechnet=19,
  283. Fahrzeug_Erstzulassung=None,
  284. Fahrzeug_Kaufdatum=None,
  285. Wiedervorlage=date(2026, 11, 1),
  286. )
  287. context.update(
  288. forderungen_details=[invoice],
  289. forderungen_kommentare=[],
  290. forderungen_buchungen=[],
  291. files=[],
  292. auftraege_positionen=[],
  293. versicherungen=[],
  294. summary=False,
  295. )
  296. with patch("app.routes.get_template_context", return_value=context):
  297. response = self.client.get("/app/forderungen/details/1_R1")
  298. self.assertEqual(response.status_code, 200)
  299. tags = Tags(response.text).tags
  300. selected = [a for t, a in tags if t == "button" and a.get("aria-pressed") == "true"]
  301. self.assertEqual(len(selected), 2)
  302. self.assertTrue(all(a["hx-get"].endswith("scope=invoice") for a in selected))
  303. form = next(a for t, a in tags if a.get("id") == "comment-form")
  304. self.assertEqual(urlsplit(form["hx-post"]).path, "/app/forderungen/details/1_R1")
  305. self.assertEqual(parse_qs(urlsplit(form["hx-post"]).query)["return_to"], ["/app/forderungen/liste?focus=1_R1"])
  306. self.assertEqual(form["hx-target"], "#comments-history")
  307. self.assertIn("Keine Kommentare", response.text)
  308. self.assertIn("Keine Buchungsbelege", response.text)
  309. if __name__ == "__main__":
  310. unittest.main()