test_logging.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """Logging is tested against isolated engines, ASGI apps and temporary files only."""
  2. import tests
  3. import ast
  4. import asyncio
  5. from datetime import datetime, timedelta, timezone
  6. import io
  7. import json
  8. import logging
  9. import multiprocessing
  10. import pyodbc
  11. from pathlib import Path
  12. import sqlite3
  13. import tempfile
  14. import unittest
  15. from types import SimpleNamespace
  16. from unittest.mock import MagicMock, patch
  17. import httpx
  18. from fastapi import FastAPI, HTTPException, Request
  19. from fastapi.responses import HTMLResponse, StreamingResponse
  20. from fastapi.staticfiles import StaticFiles
  21. from fastapi.testclient import TestClient
  22. from sqlalchemy import Column, Integer, create_engine, text
  23. from sqlalchemy.exc import DBAPIError
  24. from sqlalchemy.orm import declarative_base
  25. from sqlalchemy.pool import StaticPool
  26. from app.auth import AuthenticationMiddleware
  27. from app.config import settings, Settings
  28. from app.db import LoggingSession, install_engine_logging
  29. from app.http_logging import install_request_logging
  30. from app.logging_setup import (ApplicationFileHandler, configure_logging, request_context,
  31. run_cli, sanitize_sql)
  32. from app.oauth_token import create_access_token
  33. from app.permissions import Entitlement
  34. import app.logging_setup as setup
  35. SECRET = 'DO_NOT_LOG_SECRET_93'
  36. def config(directory, **kwargs):
  37. values = dict(LOG_DIR=str(directory), LOG_LEVEL='INFO', LOG_TO_FILE=True, LOG_TO_CONSOLE=True,
  38. LOG_RETENTION_DAYS=30, LOG_SQL_STATEMENT=True)
  39. values.update(kwargs)
  40. return SimpleNamespace(**values)
  41. def process_writer(directory, worker):
  42. configure_logging(config(directory, LOG_TO_CONSOLE=False))
  43. for index in range(35):
  44. logging.getLogger('app.worker').info('Worker event', extra={'event':'worker', 'count':worker*1000+index})
  45. for handler in list(logging.getLogger().handlers):
  46. if getattr(handler, '_gc_handler', False): handler.close()
  47. class LoggingTests(unittest.TestCase):
  48. def setUp(self):
  49. self.temp = tempfile.TemporaryDirectory(prefix='gcepic-logging-')
  50. self.root = Path(self.temp.name)
  51. root = logging.getLogger()
  52. self.old_handlers, self.old_level = root.handlers[:], root.level
  53. root.handlers.clear()
  54. self.old_config = setup._configuration
  55. self.logger_states = {name:(logger.level, logger.disabled, logger.propagate, logger.handlers[:])
  56. for name,logger in root.manager.loggerDict.items() if isinstance(logger, logging.Logger)}
  57. self.console = io.StringIO()
  58. self.configuration = config(self.root)
  59. configure_logging(self.configuration, console_stream=self.console)
  60. self.patch = patch.object(settings, 'LOG_SQL_STATEMENT', True)
  61. self.patch.start()
  62. def tearDown(self):
  63. self.patch.stop()
  64. root = logging.getLogger()
  65. for handler in list(root.handlers):
  66. root.removeHandler(handler)
  67. if getattr(handler, '_gc_handler', False): handler.close()
  68. root.handlers[:] = self.old_handlers; root.setLevel(self.old_level)
  69. for name, state in self.logger_states.items():
  70. logger = logging.getLogger(name)
  71. logger.setLevel(state[0]); logger.disabled=state[1]; logger.propagate=state[2]; logger.handlers[:]=state[3]
  72. setup._configuration = self.old_config
  73. self.temp.cleanup()
  74. def records(self):
  75. file = self.root/'application.log'
  76. return [json.loads(line) for line in file.read_text(encoding='utf-8').splitlines()] if file.exists() else []
  77. def engine(self, **kwargs):
  78. engine = create_engine('sqlite://', poolclass=StaticPool, connect_args={'check_same_thread':False}, hide_parameters=True, **kwargs)
  79. install_engine_logging(engine)
  80. self.addCleanup(engine.dispose)
  81. return engine
  82. def test_literals_comments_and_identifiers_are_sanitized(self):
  83. sql = "SELECT [col1], :bind2, N'O''Brien', 123, -1.23e+4, 0xAFF, .5, \"private\" /* private /* nested */ end */ -- private\nFROM [table2]"
  84. safe = sanitize_sql(sql)
  85. self.assertNotIn('private', safe); self.assertNotIn('Brien', safe)
  86. for value in ['123','1.23','0xAFF','.5']: self.assertNotIn(value, safe)
  87. for name in ['[col1]', ':bind2', '[table2]']: self.assertIn(name, safe)
  88. def test_idempotent_initialization_and_configured_outputs(self):
  89. root = logging.getLogger()
  90. count = len(root.handlers)
  91. configure_logging(self.configuration, console_stream=self.console)
  92. self.assertEqual(len(root.handlers), count)
  93. logging.getLogger('app.test').info('Started', extra={'event':'started'})
  94. self.assertEqual(len(self.records()), 1)
  95. self.assertEqual(self.console.getvalue().count('Started'), 1)
  96. self.assertRegex(self.records()[0]['timestamp'], r'\.\d{3}[+-]\d{2}:\d{2}$')
  97. self.assertEqual(Settings(_env_file=None, LOG_LEVEL='debug').LOG_LEVEL, 'DEBUG')
  98. def test_sql_error_logged_once_even_when_caught_and_logged_again(self):
  99. engine = self.engine()
  100. with LoggingSession(engine) as db:
  101. for value in ['first-secret', 'second-secret']:
  102. try:
  103. db.execute(text("SELECT :value FROM missing_table WHERE 91827=1 AND 'private-value'='private-value'").execution_options(query_name='test.missing'), {'value':value})
  104. except Exception:
  105. logging.getLogger('app.service').exception('Operation failed')
  106. records = self.records()
  107. self.assertEqual(len(records), 2)
  108. self.assertTrue(all(row['event']=='database_error' for row in records))
  109. self.assertEqual(records[0]['query_fingerprint'], records[1]['query_fingerprint'])
  110. self.assertEqual(records[0]['query_name'], 'test.missing')
  111. self.assertIn('missing_table', records[0]['sql'])
  112. self.assertTrue(records[0]['exceptions'][0]['frames'])
  113. combined=json.dumps(records)+self.console.getvalue()
  114. for value in ['first-secret','second-secret','private-value','91827','[parameters:']:
  115. self.assertNotIn(value, combined)
  116. def test_connect_and_fetch_errors_are_logged_without_driver_messages(self):
  117. def unavailable(): raise sqlite3.OperationalError(SECRET)
  118. engine = self.engine(creator=unavailable)
  119. with LoggingSession(engine) as db, self.assertRaises(Exception):
  120. db.execute(text('SELECT 1').execution_options(query_name='connect.test'))
  121. self.assertEqual(len(self.records()), 1)
  122. self.assertEqual(self.records()[0]['query_name'], 'connect.test')
  123. class Cursor(sqlite3.Cursor):
  124. def execute(self, sql, params=()):
  125. self.bad_fetch = 'fetch_marker' in sql
  126. return super().execute(sql, params)
  127. def fetchall(self):
  128. if getattr(self,'bad_fetch',False): raise sqlite3.OperationalError(SECRET)
  129. return super().fetchall()
  130. class Connection(sqlite3.Connection):
  131. def cursor(self,*args,**kwargs): return super().cursor(factory=Cursor)
  132. engine = self.engine(creator=lambda: sqlite3.connect(':memory:',factory=Connection))
  133. with LoggingSession(engine) as db, self.assertRaises(Exception):
  134. db.execute(text('SELECT 1 AS fetch_marker')).fetchall()
  135. self.assertEqual(len(self.records()), 2)
  136. self.assertEqual(self.records()[-1]['operation'], 'fetch')
  137. self.assertNotIn(SECRET, json.dumps(self.records())+self.console.getvalue())
  138. def test_sql_server_diagnostics_include_codes_and_redacted_descriptions(self):
  139. driver=pyodbc.ProgrammingError('42S22', "[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Invalid column name 'PrivateColumn'. (207) (SQLExecDirectW); [42000] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Statement(s) could not be prepared. (8180)")
  140. error=DBAPIError("SELECT :value, 'inline-private' FROM sample", {'value':SECRET}, driver)
  141. setup.log_query_error(error,error.statement,'test.sql_server')
  142. logging.getLogger('app.service').error('Handled',exc_info=(type(error),error,None))
  143. rows=self.records()
  144. self.assertEqual(len(rows),1)
  145. self.assertEqual(rows[0]['db_code'],207)
  146. self.assertEqual(rows[0]['sqlstate'],'42S22')
  147. self.assertIn('Invalid column name',rows[0]['db_message'])
  148. self.assertIn("'PrivateColumn'",rows[0]['db_message'])
  149. self.assertIn("'PrivateColumn'",self.console.getvalue())
  150. self.assertEqual([item['code'] for item in rows[0]['db_diagnostics']],[207,8180])
  151. self.assertEqual(rows[0]['db_diagnostics'][1]['sqlstate'],'42000')
  152. self.assertIn('could not be prepared',self.console.getvalue())
  153. for secret in [SECRET,'inline-private','[parameters:']:
  154. self.assertNotIn(secret,json.dumps(rows)+self.console.getvalue())
  155. def test_invalid_column_names_are_visible_only_in_canonical_error_207(self):
  156. with patch.object(settings,'LOG_SQL_STATEMENT',False):
  157. for description in ["Ungültiger Spaltenname 'Kunde_Größe_2'.", "Invalid column name 'Customer''s (2026)'.", 'Ungültiger Spaltenname "Kunde_Gesamt_Offen".', 'Ungültiger Spaltenname „Kunde_Gesamt_Offen“.']:
  158. driver=pyodbc.ProgrammingError('42S22','[Microsoft][ODBC Driver 18 for SQL Server][SQL Server]'+description+' (207) (SQLExecDirectW)')
  159. setup.log_query_error(driver,parameters={'value':SECRET})
  160. row=self.records()[-1]
  161. self.assertEqual(row['db_message'],description)
  162. self.assertEqual(row['db_diagnostics'][0]['message'],description)
  163. self.assertNotIn('sql',row)
  164. for code in [207,245]:
  165. description=f"Invalid column name 'PrivateColumn'. Extra value '{SECRET}'. ({code}) (SQLExecDirectW)"
  166. setup.log_query_error(pyodbc.ProgrammingError('42S22',description))
  167. self.assertNotIn('PrivateColumn',self.records()[-1]['db_message'])
  168. self.assertNotIn(SECRET,json.dumps(self.records())+self.console.getvalue())
  169. def test_german_binding_errors_keep_each_invalid_column_name(self):
  170. raw=('\r\n[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Ungültiger Spaltenname "Spalte_A". (207) (SQLExecDirectW);\r\n'
  171. '[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Ungültiger Spaltenname "Spalte_B". (207); '
  172. '[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Die Sicht oder Funktion "dbo.PrivateView" konnte aufgrund von Bindungsfehlern nicht verwendet werden. (4413); '
  173. '[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Anweisung(en) konnte(n) nicht vorbereitet werden. (8180)')
  174. setup.log_query_error(pyodbc.ProgrammingError('42S22',raw),parameters={'token':SECRET})
  175. row=self.records()[-1]
  176. self.assertIn('"Spalte_A"',row['db_message'])
  177. self.assertIn('"Spalte_B"',row['db_diagnostics'][1]['message'])
  178. self.assertEqual([item['code'] for item in row['db_diagnostics']],[207,207,4413,8180])
  179. self.assertIn('Spalte_A',self.console.getvalue())
  180. self.assertIn('Spalte_B',self.console.getvalue())
  181. self.assertNotIn('PrivateView',json.dumps(row)+self.console.getvalue())
  182. self.assertNotIn(SECRET,json.dumps(row)+self.console.getvalue())
  183. def test_sql_server_diagnostics_redact_echoed_values_and_work_without_sql_logging(self):
  184. descriptions=[
  185. ('23000',f"Violation of UNIQUE KEY constraint 'key'. The duplicate key value is ({SECRET}, 12345). (2627) (SQLExecDirectW)",2627),
  186. ('22018',f"Conversion failed when converting the nvarchar value '{SECRET}' to data type int. (245) (SQLExecDirectW)",245),
  187. ('42000',f"Custom validation failed: {SECRET}; inline-private; password=credential-private (50001) (SQLExecDirectW)",50001),
  188. ('HYT00',"Login timeout expired (0) (SQLDriverConnect)",0),
  189. ]
  190. with patch.object(settings,'LOG_SQL_STATEMENT',False):
  191. for state,description,code in descriptions:
  192. driver=pyodbc.Error(state,'[Microsoft][ODBC Driver 18 for SQL Server][SQL Server]'+description)
  193. error=DBAPIError("SELECT 'inline-private'",{'value':SECRET},driver)
  194. setup.log_query_error(error,error.statement,'test.redaction')
  195. self.assertEqual(self.records()[-1]['db_code'],code)
  196. self.assertNotIn('sql',self.records()[-1])
  197. combined=json.dumps(self.records())+self.console.getvalue()
  198. for secret in [SECRET,'12345','inline-private','credential-private']:
  199. self.assertNotIn(secret,combined)
  200. self.assertIn('UNIQUE KEY',combined)
  201. self.assertIn('Conversion failed',combined)
  202. self.assertIn('Login timeout expired',combined)
  203. def test_commit_rollback_and_flush_errors_are_captured(self):
  204. class Connection(sqlite3.Connection):
  205. fail_commit=False; fail_rollback=False
  206. def commit(self):
  207. if self.fail_commit:
  208. self.fail_commit=False; raise sqlite3.OperationalError(SECRET)
  209. return super().commit()
  210. def rollback(self):
  211. if self.fail_rollback:
  212. self.fail_rollback=False; raise sqlite3.OperationalError(SECRET)
  213. return super().rollback()
  214. engine=self.engine(creator=lambda:sqlite3.connect(':memory:',factory=Connection))
  215. with LoggingSession(engine) as db:
  216. db.execute(text('CREATE TABLE sample (id INTEGER PRIMARY KEY)')); db.commit()
  217. db.execute(text('INSERT INTO sample VALUES (1)'))
  218. db.connection().connection.driver_connection.fail_commit=True
  219. with self.assertRaises(Exception): db.commit()
  220. db.rollback()
  221. db.execute(text('SELECT 1'))
  222. db.connection().connection.driver_connection.fail_rollback=True
  223. with self.assertRaises(Exception): db.rollback()
  224. Base=declarative_base()
  225. class Model(Base):
  226. __tablename__='unique_sample'
  227. id=Column(Integer, primary_key=True)
  228. Base.metadata.create_all(engine)
  229. with LoggingSession(engine) as db:
  230. db.add_all([Model(id=1),Model(id=1)])
  231. with self.assertRaises(Exception): db.flush()
  232. db.rollback()
  233. operations=[row['operation'] for row in self.records() if row.get('event')=='database_error']
  234. self.assertIn('commit',operations); self.assertIn('rollback',operations); self.assertIn('flush',operations)
  235. self.assertEqual(len(operations),3)
  236. self.assertNotIn(SECRET, json.dumps(self.records())+self.console.getvalue())
  237. def app(self):
  238. app=FastAPI()
  239. @app.get('/status/{code}')
  240. async def response(code:int): return HTMLResponse(SECRET,status_code=code)
  241. @app.get('/http-error')
  242. async def http_error(): raise HTTPException(400, SECRET)
  243. @app.get('/number')
  244. async def number(value:int): return value
  245. @app.get('/ok')
  246. async def ok(request:Request):
  247. request.state.principal=SimpleNamespace(username=request.headers.get('x-test-user','tester'))
  248. logging.getLogger('app.service').info('Service event')
  249. from starlette.concurrency import run_in_threadpool
  250. await run_in_threadpool(lambda:logging.getLogger('app.service').info('Thread event'))
  251. await asyncio.sleep(.01)
  252. return {'ok':True}
  253. @app.get('/health')
  254. async def health(): return {'ok':True}
  255. failure=RuntimeError(SECRET)
  256. @app.get('/crash')
  257. async def crash(): raise failure
  258. @app.get('/stream')
  259. async def stream():
  260. async def chunks():
  261. yield b'first'
  262. raise RuntimeError(SECRET)
  263. return StreamingResponse(chunks())
  264. app.mount('/static',StaticFiles(directory=self.root),name='static')
  265. install_request_logging(app)
  266. return app, failure
  267. def test_http_codes_validation_static_and_exception_deduplication(self):
  268. app,failure=self.app()
  269. with TestClient(app,raise_server_exceptions=False) as client:
  270. for code in [400,401,403,404,405,422,500,503]:
  271. response=client.get('/status/'+str(code)+'?token='+SECRET,headers={'Authorization':SECRET,'Cookie':'token='+SECRET})
  272. self.assertEqual(response.status_code,code)
  273. row=self.records()[-1]
  274. self.assertEqual(row['level'],'ERROR' if code>=500 else 'WARNING')
  275. self.assertEqual(row['request_id'],response.headers['X-Request-ID'])
  276. self.assertNotIn('?',row['path'])
  277. self.assertEqual(client.get('/number?value='+SECRET).status_code,422)
  278. self.assertEqual(client.post('/ok',json={'password':SECRET}).status_code,405)
  279. self.assertEqual(client.get('/static/missing.txt').status_code,404)
  280. self.assertEqual(client.get('/http-error').status_code,400)
  281. crash=client.get('/crash')
  282. self.assertEqual(crash.status_code,500)
  283. self.assertTrue(crash.headers['X-Request-ID'])
  284. repeated=client.get('/crash')
  285. self.assertNotEqual(crash.headers['X-Request-ID'],repeated.headers['X-Request-ID'])
  286. logging.getLogger('uvicorn.error').error('Exception in ASGI application',exc_info=(RuntimeError,failure,failure.__traceback__))
  287. technical=[row for row in self.records() if row.get('event')=='unhandled_exception']
  288. self.assertEqual(len(technical),2)
  289. self.assertNotIn(SECRET,json.dumps(self.records())+self.console.getvalue())
  290. def test_caught_query_error_in_http_200_keeps_request_context(self):
  291. app=FastAPI()
  292. engine=self.engine()
  293. @app.get('/caught-query')
  294. def caught(request:Request):
  295. request.state.principal=SimpleNamespace(username='tester')
  296. with LoggingSession(engine) as db:
  297. try:
  298. db.execute(text('SELECT :secret FROM missing_http_table').execution_options(query_name='http.caught'), {'secret':SECRET})
  299. except Exception:
  300. logging.getLogger('app.service').exception('Query handled')
  301. return HTMLResponse('Formularfehler',status_code=200)
  302. install_request_logging(app)
  303. with TestClient(app) as client:
  304. response=client.get('/caught-query')
  305. rows=self.records()
  306. self.assertEqual([row['event'] for row in rows], ['database_error','http_completed'])
  307. self.assertTrue(all(row['request_id']==response.headers['x-request-id'] for row in rows))
  308. self.assertTrue(all(row['user']=='tester' for row in rows))
  309. self.assertEqual(rows[-1]['status'],200)
  310. self.assertNotIn(SECRET,json.dumps(rows)+self.console.getvalue())
  311. def test_stream_error_keeps_sent_status_and_health_success_is_debug(self):
  312. app,_=self.app()
  313. with TestClient(app,raise_server_exceptions=False) as client:
  314. client.get('/health'); self.assertEqual(self.records(),[])
  315. response=client.get('/stream')
  316. self.assertEqual(response.status_code,200)
  317. summary=next(row for row in self.records() if row.get('event')=='http_completed')
  318. self.assertEqual(summary['status'],200); self.assertEqual(summary['outcome'],'stream_failed')
  319. self.assertEqual(summary['level'],'ERROR')
  320. self.assertNotIn(SECRET,json.dumps(self.records()))
  321. def test_parallel_request_and_thread_contexts_are_isolated(self):
  322. app,_=self.app()
  323. async def requests():
  324. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),base_url='http://test') as client:
  325. return await asyncio.gather(*[client.get('/ok?secret='+SECRET,headers={'x-test-user':user,'X-Request-ID':SECRET}) for user in ['alpha','beta']])
  326. responses=asyncio.run(requests())
  327. ids={response.headers['x-request-id'] for response in responses}
  328. self.assertEqual(len(ids),2); self.assertNotIn(SECRET,ids)
  329. users={row['request_id']:row['user'] for row in self.records() if row.get('event')=='http_completed'}
  330. self.assertEqual(set(users.values()),{'alpha','beta'})
  331. for row in self.records(): self.assertEqual(row['user'],users[row['request_id']])
  332. self.assertIsNone(request_context.get())
  333. def test_early_auth_errors_and_htmx_login_are_observed(self):
  334. app=FastAPI()
  335. @app.post('/write')
  336. def write(): return {'ok':True}
  337. app.add_middleware(AuthenticationMiddleware)
  338. install_request_logging(app)
  339. entitlement=(Entitlement('1','10','SC','Mitarbeiter',100),)
  340. with patch.multiple(settings,TRUSTED_PROXY_IPS=['testclient'],DEBUG_AUTH=False,COOKIE_SECURE=False), \
  341. patch('app.auth.SessionLocal',return_value=MagicMock()), \
  342. patch('app.auth.load_entitlements',return_value=entitlement), \
  343. patch('app.auth.lookup_directory_user',return_value=None), TestClient(app) as client:
  344. denied=client.post('/write',headers={'X-Remote-User':'invalid'})
  345. self.assertEqual(denied.status_code,401)
  346. login=client.post('/write',headers={'HX-Request':'true'})
  347. self.assertEqual(login.status_code,200); self.assertIn('HX-Redirect',login.headers)
  348. token=create_access_token(username='tester',name='Tester',email='',role='Mitarbeiter',auth_source='ldap',csrf='csrf')
  349. client.cookies.set(settings.SESSION_COOKIE_NAME,token)
  350. self.assertEqual(client.post('/write').status_code,403)
  351. statuses=[row['status'] for row in self.records() if row.get('event')=='http_completed']
  352. self.assertEqual(statuses,[401,200,403])
  353. self.assertTrue(any(row.get('event')=='auth_login_required' for row in self.records()))
  354. def test_retention_rotation_parallel_processes_and_fallback(self):
  355. old=self.root/'application.log.2001-01-01'; old.write_text('expired',encoding='utf-8')
  356. preserved=self.root/'webservice.log'; preserved.write_text('keep',encoding='utf-8')
  357. handler=next(handler for handler in logging.getLogger().handlers if isinstance(handler,ApplicationFileHandler))
  358. handler.cleanup_archives(); self.assertFalse(old.exists()); self.assertTrue(preserved.exists())
  359. logging.getLogger('app.test').info('before rotation')
  360. handler.clh._do_lock()
  361. try: handler.doRollover()
  362. finally: handler.clh._do_unlock()
  363. logging.getLogger('app.test').info('after rotation')
  364. self.assertEqual(self.records()[-1]['message'],'after rotation')
  365. with patch.object(handler.clh,'do_write',side_effect=PermissionError(SECRET)), patch('sys.stderr',new=io.StringIO()) as fallback:
  366. logging.getLogger('app.test').error('safe failure')
  367. self.assertIn('Konsolenfallback',fallback.getvalue()); self.assertNotIn(SECRET,fallback.getvalue())
  368. directory=self.root/'workers'; directory.mkdir()
  369. seed=ApplicationFileHandler(str(directory/'application.log'))
  370. seed.setFormatter(setup.JsonFormatter()); seed.addFilter(setup.ContextFilter())
  371. seed.handle(logging.LogRecord('app.worker',logging.INFO,__file__,0,'Before shared rotation',(),None))
  372. seed.clh._do_lock()
  373. try:
  374. seed.rolloverAt=int(datetime.now(timezone.utc).timestamp())-10
  375. seed.write_rollover_time()
  376. finally:
  377. seed.clh._do_unlock()
  378. seed.close()
  379. processes=[multiprocessing.get_context('spawn').Process(target=process_writer,args=(str(directory),n)) for n in range(3)]
  380. for process in processes: process.start()
  381. for process in processes:
  382. process.join(20)
  383. if process.is_alive(): process.terminate(); self.fail('Logging worker timed out')
  384. self.assertEqual(process.exitcode,0)
  385. rows=[json.loads(line) for line in (directory/'application.log').read_text(encoding='utf-8').splitlines()]
  386. self.assertEqual(len(rows),105); self.assertEqual(len({row['count'] for row in rows}),105)
  387. archived=[json.loads(line) for archive in directory.glob('application.log.*') for line in archive.read_text(encoding='utf-8').splitlines()]
  388. self.assertEqual([row['message'] for row in archived],['Before shared rotation'])
  389. def test_cli_failure_and_disabled_sql_text(self):
  390. with patch('app.logging_setup.configure_logging'), self.assertRaises(SystemExit):
  391. run_cli(lambda: (_ for _ in ()).throw(RuntimeError(SECRET)))
  392. self.assertEqual(self.records()[-1]['event'],'cli_failed')
  393. with patch.object(settings,'LOG_SQL_STATEMENT',False), LoggingSession(self.engine()) as db:
  394. with self.assertRaises(Exception): db.execute(text('SELECT * FROM missing'))
  395. self.assertNotIn('sql',self.records()[-1]); self.assertNotIn(SECRET,self.console.getvalue())
  396. def test_project_python_has_no_print_calls(self):
  397. root=Path(__file__).resolve().parent.parent
  398. paths=list((root/'app').rglob('*.py'))+list(root.glob('*.py'))+list((root/'tests').glob('*.py'))
  399. for path in paths:
  400. tree=ast.parse(path.read_text(encoding='utf-8-sig'))
  401. for node in ast.walk(tree):
  402. self.assertFalse(isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id=='print',str(path))
  403. def test_file_configuration_failure_falls_back_without_exposing_exception(self):
  404. output=io.StringIO()
  405. with patch('app.logging_setup.ApplicationFileHandler',side_effect=PermissionError(SECRET)):
  406. configure_logging(config(self.root/'denied',LOG_TO_CONSOLE=False),console_stream=output)
  407. logging.getLogger('app.service').error('Still available')
  408. self.assertIn('Konsolenfallback',output.getvalue())
  409. self.assertIn('Still available',output.getvalue())
  410. self.assertNotIn(SECRET,output.getvalue())
  411. def test_cleanup_queries_are_parameterized_and_cli_imports_work(self):
  412. import app.cleanup_comments as cleanup
  413. import filter_export
  414. rows=[SimpleNamespace(Client_DB='1',Beleg_Nr='R1',Timestamp=datetime(2026,1,1),
  415. Benutzer_ID='tester',Kommentar='AZ: 123/45 Schaden '+SECRET+' bei '+SECRET+' eingereicht')]
  416. db=MagicMock()
  417. db.execute.return_value.fetchall.return_value=rows
  418. with patch('app.cleanup_comments.SessionLocal',return_value=db):
  419. db.__enter__.return_value=db
  420. cleanup.main()
  421. cleanup.get_details_from_comments()
  422. writes=[call for call in db.execute.call_args_list if 'INSERT' in str(call.args[0]) or 'UPDATE' in str(call.args[0])]
  423. self.assertEqual(len(writes),2)
  424. for call in writes:
  425. self.assertNotIn(SECRET,str(call.args[0]))
  426. self.assertIn(SECRET,str(call.args[1]))
  427. filter_export.import_filter_config2()
  428. self.assertNotIn(SECRET,self.console.getvalue())
  429. def test_mail_and_docuware_metadata_never_dump_payloads(self):
  430. import app.imap as imap
  431. import app.docuware_search as docuware_search
  432. payload=SECRET.encode()
  433. message=SimpleNamespace(subject='WRG123456 '+SECRET,
  434. attachments=[SimpleNamespace(filename=SECRET+'.pdf',payload=payload,size=len(payload))])
  435. db=MagicMock(); db.__enter__.return_value=db
  436. db.execute.return_value.fetchall.return_value=[('1','WRG123456')]
  437. mailbox=MagicMock(); mailbox.login.return_value.__enter__.return_value=mailbox
  438. mailbox.fetch.return_value=[message]
  439. with patch.dict('os.environ',{'LOCAL_STORAGE':str(self.root/'files')}), \
  440. patch('app.imap.SessionLocal',return_value=db),patch('app.imap.MailBox',return_value=mailbox):
  441. imap.main()
  442. connection=SimpleNamespace(organizations=[SimpleNamespace(file_cabinets=[SECRET],baskets=[SECRET])])
  443. with patch('app.docuware_search.docuware.connect',return_value=connection):
  444. docuware_search.main()
  445. self.assertTrue(any(row.get('event')=='mail_attachment_saved' and row['size_bytes']==len(payload) for row in self.records()))
  446. self.assertNotIn(SECRET,json.dumps(self.records())+self.console.getvalue())
  447. if __name__=='__main__': unittest.main()