Add optional screenshots to bug reports

This commit is contained in:
2026-09-15 21:16:06 +02:00
parent 39af9ecdb4
commit 5d3ae10036
10 changed files with 263 additions and 12 deletions
+35
View File
@@ -234,6 +234,41 @@ class FeatureApiTests(unittest.TestCase):
self.assertEqual(self.client.get('/bug-report', follow_redirects=False).status_code, 303)
self.assertEqual(self.client.post('/bug-report', data=data, follow_redirects=False).status_code, 303)
def test_report_form_screenshot_is_optional_and_localized(self):
for language,label in (('de','Screenshot anhängen (optional)'),('en','Attach a screenshot (optional)')):
self.client.get('/language/'+language, follow_redirects=False)
page=self.client.get('/bug-report')
self.assertIn('enctype="multipart/form-data"',page.text)
self.assertIn('name="screenshot" type="file"',page.text)
self.assertIn(label,page.text)
def test_report_attaches_only_sanitized_image_bytes(self):
from io import BytesIO
from PIL import Image
image=Image.new('RGB',(20,15),(50,80,100)); exif=Image.Exif(); exif[270]='secret exif test fixture'
source=BytesIO(); image.save(source,format='PNG',exif=exif)
data=self.report()
with patch.object(GiteaService,'create_issue',return_value=123) as create:
response=self.client.post('/bug-report',data=data,
files={'screenshot':('../../payload.svg',source.getvalue()+b'<script>fixture</script>','image/svg+xml')})
self.assertEqual(response.status_code,200)
self.assertIn('#123',response.text)
create.assert_called_once()
filename,contents,media_type=create.call_args.kwargs['screenshot']
self.assertRegex(filename,r'^screenshot-[0-9a-f]{32}\.jpg$')
self.assertEqual(media_type,'image/jpeg')
self.assertNotIn(b'secret exif test fixture',contents)
self.assertNotIn(b'<script>',contents)
self.assertNotIn('../../payload.svg',str(create.call_args))
def test_report_rejects_non_image_upload_without_calling_gitea(self):
with patch.object(GiteaService,'create_issue') as create:
response=self.client.post('/bug-report',data=self.report(),
files={'screenshot':('malware.svg',b'<svg onload="alert(1)"></svg>','image/png')})
self.assertEqual(response.status_code,400)
self.assertIn('Bitte lade ein gültiges JPG-, PNG- oder WebP-Bild hoch.',response.text)
create.assert_not_called()
def test_report_validation(self):
for changes in ({'title':''}, {'description':''}, {'expected':''}, {'title':'x'*161},
{'description':'x'*5001}, {'expected':'x'*3001}, {'steps':'x'*3001},
+39
View File
@@ -29,6 +29,45 @@ class GiteaTests(unittest.TestCase):
self.assertEqual(requests[-1].url.path, '/api/v1/repos/kai/pingu-concerts/issues')
self.assertNotIn('test-secret', str(requests[-1].url))
def test_screenshot_is_uploaded_as_issue_asset_and_linked_into_issue_body(self):
requests = []
image = b"sanitized-raster-image-bytes"
def handler(request):
requests.append(request)
if request.url.path.endswith('/user'):
return httpx.Response(200, json={'login':'metalcircle-bot'})
if request.url.path.endswith('/labels'):
return httpx.Response(200, json=[])
if request.method == 'POST' and request.url.path.endswith('/issues'):
return httpx.Response(201, json={'number':123,'user':{'login':'metalcircle-bot'}})
if request.method == 'POST' and request.url.path.endswith('/issues/123/assets'):
self.assertEqual(request.url.params['name'], 'screenshot-safe.png')
self.assertIn('multipart/form-data', request.headers['content-type'])
self.assertIn(image, request.read())
self.assertIn('filename="screenshot-safe.png"', request.read().decode('latin1'))
return httpx.Response(201, json={'browser_download_url':'http://gitea.invalid/attachments/safe-id','id':5})
if request.method == 'PATCH' and request.url.path.endswith('/issues/123'):
self.assertIn('![Bug report screenshot](<http://gitea.invalid/attachments/safe-id>)', json.loads(request.content)['body'])
return httpx.Response(200, json={'number':123})
raise AssertionError(f'unexpected request {request.method} {request.url.path}')
result=self.service(handler).create_issue('Screenshot test','safe body','android',
screenshot=('screenshot-safe.png',image,'image/png'))
self.assertEqual(result,123)
self.assertEqual([request.method for request in requests],['GET','GET','POST','POST','PATCH'])
self.assertNotIn(b'test-secret-never-print', b''.join(request.content for request in requests))
def test_screenshot_upload_failure_is_uncertain_and_secret_free(self):
def handler(request):
if request.url.path.endswith('/user'): return httpx.Response(200,json={'login':'metalcircle-bot'})
if request.url.path.endswith('/labels'): return httpx.Response(200,json=[])
if request.method == 'POST' and request.url.path.endswith('/issues'): return httpx.Response(201,json={'number':9,'user':{'login':'metalcircle-bot'}})
return httpx.Response(413,text='private upload failure details')
with self.assertLogs('gitea_service','WARNING') as logs:
with self.assertRaises(GiteaError) as caught:
self.service(handler).create_issue('Title','Body','general',screenshot=('screenshot.jpg',b'image','image/jpeg'))
self.assertTrue(caught.exception.uncertain)
self.assertNotIn('private upload failure details','\n'.join(logs.output))
def test_missing_or_inaccessible_labels_do_not_block(self):
for response in (httpx.Response(200, json=[]), httpx.Response(403), httpx.Response(200, json={'invalid': True})):
def handler(request):
+50
View File
@@ -0,0 +1,50 @@
"""Image uploads are decoded and re-encoded before they leave the application."""
from io import BytesIO
import unittest
from PIL import Image
from safe_images import MAX_SCREENSHOT_BYTES, ScreenshotError, sanitize_screenshot
class SafeScreenshotTests(unittest.TestCase):
def png(self, size=(32, 24), exif=None):
image = Image.new('RGB', size, (32, 64, 128))
out = BytesIO()
image.save(out, format='PNG', exif=exif or b'')
return out.getvalue()
def test_valid_raster_is_reencoded_without_metadata_or_appended_payload(self):
exif = Image.Exif()
exif[270] = 'private camera metadata fixture'
source = self.png(exif=exif)
payload = b'<script>not part of screenshot</script>'
cleaned, extension, media_type = sanitize_screenshot(source + payload)
self.assertEqual((extension, media_type), ('jpg','image/jpeg'))
self.assertTrue(cleaned.startswith(bytes((0xff,0xd8))))
self.assertNotIn(b'private camera metadata fixture',cleaned)
self.assertNotIn(payload,cleaned)
with Image.open(BytesIO(cleaned)) as image:
self.assertEqual(image.size,(32,24))
def test_alpha_image_remains_png(self):
image=Image.new('RGBA',(10,12),(0,0,0,0)); out=BytesIO(); image.save(out,format='PNG')
cleaned,extension,media_type=sanitize_screenshot(out.getvalue())
self.assertEqual((extension,media_type),('png','image/png'))
with Image.open(BytesIO(cleaned)) as checked: self.assertEqual(checked.mode,'RGBA')
def test_rejects_non_image_svg_and_oversized_input(self):
for contents in (b'<svg onload="alert(1)"></svg>', b'%PDF-1.7 fake document'):
with self.subTest(contents=contents[:4]), self.assertRaises(ScreenshotError):
sanitize_screenshot(contents)
with self.assertRaisesRegex(ScreenshotError,'too_large'):
sanitize_screenshot(b'X'*(MAX_SCREENSHOT_BYTES+1))
def test_rejects_excessive_dimensions(self):
source=self.png((5,5))
from unittest.mock import patch
with patch('safe_images.MAX_SCREENSHOT_PIXELS',16), self.assertRaisesRegex(ScreenshotError,'too_large'):
sanitize_screenshot(source)
if __name__ == '__main__': unittest.main()