Add optional screenshots to bug reports
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -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('', 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):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user