test_uploads.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import io
  2. import json
  3. import multiprocessing
  4. from pathlib import Path
  5. import re
  6. import tempfile
  7. import unittest
  8. from concurrent.futures import ThreadPoolExecutor
  9. from datetime import datetime
  10. from unittest.mock import patch
  11. from app.config import settings
  12. from app.file_storage import BERLIN, UploadError, get_file, get_files, save_upload
  13. from tests import test_forderung_history as history_fixture
  14. KEY = {'Client_DB': '1', 'Beleg_Nr': 'R1'}
  15. def worker(root, value):
  16. settings.LOCAL_STORAGE = root
  17. save_upload(KEY, 'same.txt', io.BytesIO(value), 100)
  18. class StorageTests(unittest.TestCase):
  19. def setUp(self):
  20. self.temp = tempfile.TemporaryDirectory()
  21. self.addCleanup(self.temp.cleanup)
  22. self.root = Path(self.temp.name)
  23. config = patch.object(settings, 'LOCAL_STORAGE', self.temp.name)
  24. config.start(); self.addCleanup(config.stop)
  25. def save(self, name='a.txt', data=b'new', limit=100):
  26. return save_upload(KEY, name, io.BytesIO(data), limit)
  27. def test_unicode_empty_and_exact_limit(self):
  28. for name, data in [('Rechnung ä + #%20.pdf', b'12345'), ('empty.bin', b'')]:
  29. relative = self.save(name, data, 5)
  30. self.assertEqual(get_file(relative).read_bytes(), data)
  31. self.assertEqual(len(get_files(KEY)), 2)
  32. def test_invalid_names_and_paths(self):
  33. for name in ['', '.', '..', '../escape', 'a/b', 'a\\b', 'CON.txt', 'LPT¹', 'a:', 'x?', 'x ', 'x.', 'a\x00b', 'a'*256]:
  34. with self.subTest(name=name), self.assertRaises(UploadError):
  35. self.save(name)
  36. self.assertFalse(list(self.root.iterdir()))
  37. for name in ['../outside', '/1_R1/a', '1_R1/../a', '.upload-staging/a']:
  38. with self.assertRaises(FileNotFoundError): get_file(name)
  39. def test_size_checked_before_archiving(self):
  40. self.save(data=b'old')
  41. with self.assertRaises(UploadError): self.save(data=b'123456', limit=5)
  42. self.assertEqual([f.name for f in get_files(KEY)], ['a.txt'])
  43. self.assertEqual(get_file('1_R1/a.txt').read_bytes(), b'old')
  44. self.assertEqual(list((self.root/'.upload-staging').iterdir()), [])
  45. def test_both_layouts_archive_and_timestamp_counter(self):
  46. self.save(data=b'first')
  47. legacy = self.root/'1'/'R1'; legacy.mkdir(parents=True)
  48. (legacy/'a.txt').write_bytes(b'legacy')
  49. with patch('app.file_storage.datetime') as clock:
  50. clock.now.return_value = datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=BERLIN)
  51. self.save(data=b'second'); self.save(data=b'third')
  52. clock.now.assert_called_with(BERLIN)
  53. files = get_files(KEY)
  54. self.assertEqual(len(files), 4)
  55. self.assertTrue((self.root/'1_R1/a_20260102_030405_123456_1.txt').exists())
  56. self.assertTrue((legacy/'a_20260102_030405_123456.txt').exists())
  57. self.assertEqual(get_file('1_R1/a.txt').read_bytes(), b'third')
  58. self.assertEqual([f.modified_at.timestamp() for f in files], sorted([f.modified_at.timestamp() for f in files], reverse=True))
  59. def test_failed_final_replace_restores_both_files(self):
  60. self.save(data=b'newfolder')
  61. legacy = self.root/'1/R1'; legacy.mkdir(parents=True); (legacy/'a.txt').write_bytes(b'legacy')
  62. with patch('app.file_storage.os.replace', side_effect=PermissionError('locked')):
  63. with self.assertRaises(PermissionError): self.save()
  64. self.assertEqual(get_file('1_R1/a.txt').read_bytes(), b'newfolder')
  65. self.assertEqual(get_file('1/R1/a.txt').read_bytes(), b'legacy')
  66. self.assertEqual(len(get_files(KEY)), 2)
  67. def test_parallel_threads_preserve_all_versions(self):
  68. with ThreadPoolExecutor(max_workers=6) as pool:
  69. list(pool.map(lambda n: self.save(data=str(n).encode()), range(10)))
  70. self.assertEqual({get_file(f.relative_path).read_bytes() for f in get_files(KEY)}, {str(n).encode() for n in range(10)})
  71. def test_parallel_processes_preserve_all_versions(self):
  72. processes = [multiprocessing.get_context('spawn').Process(target=worker, args=(self.temp.name, str(n).encode())) for n in range(4)]
  73. for process in processes: process.start()
  74. for process in processes:
  75. process.join(20)
  76. if process.is_alive(): process.terminate(); self.fail('Upload process timed out')
  77. self.assertEqual(process.exitcode, 0)
  78. self.assertEqual(len(get_files(KEY)), 4)
  79. class UploadRouteTests(unittest.TestCase):
  80. def setUp(self):
  81. self.fixture = history_fixture.HistoryTests()
  82. self.fixture.setUp()
  83. self.addCleanup(self.fixture.doCleanups)
  84. self.client, self.db = self.fixture.client, self.fixture.db
  85. self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
  86. config = patch.multiple(settings, LOCAL_STORAGE=self.temp.name, UPLOAD_MAX_FILE_SIZE_MB=1)
  87. config.start(); self.addCleanup(config.stop)
  88. def post(self, files=(), comment='', headers=None, key='1_R1', ids=None):
  89. return self.client.post('/app/forderungen/details/'+key,
  90. data={'source':'comments','Kommentar':comment,'attachment_ids': json.dumps(ids if ids is not None else [str(n) for n in range(len(files))]),'comment_scope':'debtor'},
  91. files=[('attachments', (name, body, 'application/octet-stream')) for name, body in files],
  92. headers={'X-CSRF-Token':'csrf'} if headers is None else headers)
  93. def result(self, response):
  94. self.assertEqual(response.status_code, 200, response.text)
  95. return json.loads(re.search(r'data-gc-send-result>(.*?)</script>', response.text, re.S)[1])
  96. def test_file_only_no_comment_and_first_list(self):
  97. self.db.connection.execute('UPDATE dbo.Forderungen_Kommentar SET Aktuell_Fibu = 1, Aktuell_Abteilung = 1')
  98. self.db.connection.commit()
  99. before = self.db.connection.execute('SELECT * FROM dbo.Forderungen_Kommentar').fetchall()
  100. response = self.post([('ä # + %20.txt', b'content')])
  101. result = self.result(response)
  102. self.assertFalse(result['comment_saved']); self.assertTrue(result['files'][0]['success'])
  103. self.assertEqual(self.db.commits, 0)
  104. self.assertEqual(self.db.connection.execute('SELECT * FROM dbo.Forderungen_Kommentar').fetchall(), before)
  105. self.assertIn('id="case-files"', response.text)
  106. self.assertIn('hx-swap-oob="outerHTML"', response.text)
  107. name = get_files(KEY)[0].relative_path
  108. download = self.client.get('/files/', params={'name': name})
  109. self.assertEqual(download.content, b'content'); self.assertEqual(download.status_code, 200)
  110. self.assertIn('filename*=', download.headers['content-disposition'])
  111. def test_combined_partial_and_comment_saved_once(self):
  112. result = self.result(self.post([('a.txt', b'ok'), ('CON.txt', b'bad')], '123,45 <Text>'))
  113. self.assertEqual([r['success'] for r in result['files']], [True, False])
  114. self.assertTrue(result['comment_saved'])
  115. row = self.db.connection.execute('SELECT Kommentar FROM dbo.Forderungen_Kommentar ORDER BY rowid DESC LIMIT 1').fetchone()
  116. self.assertEqual(row[0], '123,45 <Text>')
  117. retry = self.result(self.post([('CON.txt', b'bad')]))
  118. self.assertFalse(retry['comment_saved']); self.assertEqual(self.db.commits, 1)
  119. self.assertEqual(len(get_files(KEY)), 1)
  120. def test_same_filenames_are_matched_by_individual_id(self):
  121. original = save_upload
  122. calls = 0
  123. def save(key, name, stream, limit):
  124. nonlocal calls
  125. calls += 1
  126. if calls == 1: raise UploadError('Please retry this file')
  127. return original(key, name, stream, limit)
  128. with patch('app.comment_submission.save_upload', side_effect=save):
  129. result = self.result(self.post([('same.txt', b'first'), ('same.txt', b'second')], ids=['first', 'second']))
  130. self.assertEqual([(f['id'], f['success']) for f in result['files']], [('first', False), ('second', True)])
  131. self.assertEqual(get_file('1_R1/same.txt').read_bytes(), b'second')
  132. def test_comment_failure_keeps_successful_file(self):
  133. execute = self.db.execute
  134. def failing(query, values=None):
  135. if 'INSERT INTO' in str(query): raise RuntimeError('database unavailable')
  136. return execute(query, values)
  137. with self.assertLogs('app.comment_submission', level='ERROR'), patch.object(self.db, 'execute', side_effect=failing), patch.object(self.db, 'rollback', create=True):
  138. result = self.result(self.post([('a.txt', b'ok')], 'retain me'))
  139. self.assertTrue(result['files'][0]['success']); self.assertFalse(result['comment_saved'])
  140. self.assertTrue(result['messages']); self.assertEqual(len(get_files(KEY)), 1)
  141. self.assertTrue(self.result(self.post(comment='retain me'))['comment_saved'])
  142. def test_size_boundary_partial_success_and_empty(self):
  143. result = self.result(self.post([('exact.bin', b'a'*(1024*1024)), ('large.bin', b'a'*(1024*1024+1)), ('empty', b'')]))
  144. self.assertEqual([r['success'] for r in result['files']], [True, False, True])
  145. self.assertEqual(len(get_files(KEY)), 2)
  146. self.assertFalse(self.result(self.post(comment=' '))['comment_saved'])
  147. self.assertEqual(self.db.commits, 0)
  148. def test_refresh_failure_still_reports_persisted_results(self):
  149. with self.assertLogs('app.comment_submission', level='ERROR'), patch('app.comment_submission.history_context', side_effect=RuntimeError('history unavailable')), patch.object(self.db, 'rollback', create=True):
  150. response = self.post([('a.txt', b'ok')], 'saved text')
  151. result = self.result(response)
  152. self.assertEqual(response.headers['hx-reswap'], 'none')
  153. self.assertTrue(result['comment_saved']); self.assertTrue(result['files'][0]['success'])
  154. self.assertIn('case-files', response.text)
  155. self.assertTrue(result['messages'])
  156. def test_storage_permission_error_is_individual(self):
  157. with self.assertLogs('app.comment_submission', level='ERROR'), patch('app.comment_submission.save_upload', side_effect=PermissionError('denied')):
  158. result = self.result(self.post([('a.txt', b'ok')], 'save comment independently'))
  159. self.assertFalse(result['files'][0]['success']); self.assertIn('Zugriffsrechte', result['files'][0]['error'])
  160. self.assertTrue(result['comment_saved']); self.assertEqual(get_files(KEY), [])
  161. def test_security_manifest_and_legacy_download(self):
  162. self.assertEqual(self.post([('a.txt', b'x')], headers={}).status_code, 403)
  163. self.assertEqual(self.post([('a.txt', b'x')], key='1_R2').status_code, 404)
  164. self.assertEqual(self.post([('a.txt', b'x')], ids=[]).status_code, 400)
  165. self.assertFalse(list(Path(self.temp.name).iterdir()))
  166. legacy = Path(self.temp.name)/'1/R1'; legacy.mkdir(parents=True); (legacy/'old.txt').write_bytes(b'old')
  167. self.assertEqual(self.client.get('/files/', params={'name':'1/R1/old.txt'}).content, b'old')
  168. self.assertEqual(self.client.get('/files/', params={'name':'1/R2/old.txt'}).status_code, 404)
  169. self.client.cookies.clear()
  170. response = self.post([('a.txt', b'x')], headers={'HX-Request':'true','HX-Current-URL':'http://testserver/app/forderungen/details/1_R1'})
  171. self.assertIn('/login?', response.headers['hx-redirect'])
  172. self.assertEqual(len(get_files(KEY)), 1)
  173. if __name__ == '__main__': unittest.main()