Upload an application's image and installer instead of typing paths
Adding an application meant typing an image FILENAME and trusting someone had dropped the file into the frontend's own directory by hand, and typing an install path from memory. Both are uploads now, following the model-image trio that models and part photos already use. The two differ deliberately. The image is public, because application tiles render before anything is authenticated. The installer is not: it is licensed vendor software, an open URL would publish it to anything that can reach the site, and it is always sent as an attachment rather than rendered. Installers are capped at 500MB and the size is measured by seeking the stream rather than trusting Content-Length, which a chunked upload does not send and a client can understate. Anything larger belongs on the share, and the error says so rather than just refusing. Files are chosen before a new application exists, so they are held and uploaded once there is an id to attach them to. A failed upload leaves the saved record alone and reports, rather than losing what saved fine. Removing an installer only clears installpath when it pointed at the upload - a share path was typed by a person and is not ours to wipe. The detail page reads both shapes, since entries from the classic site hold a bare filename that is still served from /images/applications/.
This commit is contained in:
165
tests/test_core/test_application_uploads.py
Normal file
165
tests/test_core/test_application_uploads.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Tests for application image and installer uploads.
|
||||
|
||||
The form used to take an image FILENAME and expect someone to drop the file into
|
||||
the frontend's own directory by hand, and an install path typed from memory.
|
||||
Both are uploads now, following the model-image trio.
|
||||
|
||||
The two differ deliberately and that is most of what is pinned here: the image
|
||||
is public because application tiles render before anything is authenticated, the
|
||||
installer is not, because it is licensed vendor software.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
|
||||
def _app(db, name='Test App'):
|
||||
app = Application(appname=name)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _png():
|
||||
# 1x1 PNG; enough to exercise the path without a fixture file.
|
||||
return io.BytesIO(
|
||||
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
||||
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
|
||||
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82')
|
||||
|
||||
|
||||
def test_image_upload_sets_the_served_url(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (_png(), 'logo.png')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['image'] == \
|
||||
f'/api/applications/image/application-{app.appid}.png'
|
||||
|
||||
|
||||
def test_image_is_readable_without_a_token(client, db, auth_headers):
|
||||
"""Tiles and lists render the image before anything is authenticated."""
|
||||
app = _app(db)
|
||||
client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (_png(), 'logo.png')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
anon = client.get(f'/api/applications/image/application-{app.appid}.png')
|
||||
|
||||
assert anon.status_code == 200
|
||||
assert anon.mimetype == 'image/png'
|
||||
|
||||
|
||||
def test_image_rejects_a_non_image(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (io.BytesIO(b'MZ'), 'payload.exe')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Unsupported image type' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_installer_upload_sets_the_install_path(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()['data']
|
||||
assert body['installpath'] == \
|
||||
f'/api/applications/package/application-{app.appid}.msi'
|
||||
# The name the vendor shipped is reported back, since the stored copy is
|
||||
# renamed and the uploader needs to recognise what they sent.
|
||||
assert body['uploadedfilename'] == 'setup.msi'
|
||||
|
||||
|
||||
def test_installer_download_needs_a_token(client, db, auth_headers):
|
||||
"""Licensed software: an open URL would publish it to the whole network."""
|
||||
app = _app(db)
|
||||
client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = f'/api/applications/package/application-{app.appid}.msi'
|
||||
|
||||
assert client.get(path).status_code == 401
|
||||
|
||||
authed = client.get(path, headers=auth_headers)
|
||||
assert authed.status_code == 200
|
||||
assert authed.data == b'fake installer'
|
||||
# Sent as an attachment so a browser saves it rather than rendering it.
|
||||
assert 'attachment' in authed.headers.get('Content-Disposition', '')
|
||||
|
||||
|
||||
def test_installer_rejects_an_unlisted_type(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'x'), 'driver.dll')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Unsupported installer type' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_installer_rejects_an_oversize_file(client, db, auth_headers, monkeypatch):
|
||||
"""The cap keeps an ISO out of the instance directory."""
|
||||
from shopdb.core.api import applications as applications_api
|
||||
monkeypatch.setattr(applications_api, 'MAX_PACKAGE_BYTES', 1024)
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'x' * 4096), 'big.msi')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'the limit is' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_removing_an_installer_clears_only_an_uploaded_path(client, db, auth_headers):
|
||||
"""A share path was typed by a person and is not ours to wipe."""
|
||||
app = _app(db)
|
||||
client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'installer'), 'setup.msi')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
cleared = client.delete(f'/api/applications/{app.appid}/package',
|
||||
headers=auth_headers)
|
||||
assert cleared.get_json()['data']['installpath'] is None
|
||||
|
||||
app.installpath = r'\\share\installers\vendor.msi'
|
||||
db.session.commit()
|
||||
again = client.delete(f'/api/applications/{app.appid}/package',
|
||||
headers=auth_headers)
|
||||
assert again.get_json()['data']['installpath'] == r'\\share\installers\vendor.msi'
|
||||
|
||||
|
||||
def test_replacing_an_image_does_not_orphan_the_old_extension(client, db, auth_headers):
|
||||
"""One image per application, whatever the extension arrives as."""
|
||||
import glob
|
||||
import os
|
||||
from flask import current_app
|
||||
app = _app(db)
|
||||
|
||||
client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (_png(), 'logo.png')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (io.BytesIO(b'<svg/>'), 'logo.svg')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
imagedir = os.path.join(current_app.instance_path, 'applicationimages')
|
||||
files = glob.glob(os.path.join(imagedir, f'application-{app.appid}.*'))
|
||||
assert len(files) == 1 and files[0].endswith('.svg')
|
||||
Reference in New Issue
Block a user