forked from seba/servefile
428 lines
13 KiB
Python
428 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
import io
|
|
import os
|
|
import pytest
|
|
import requests
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import urllib3
|
|
|
|
# crudly written to learn more about pytest and to have a base for refactoring
|
|
|
|
|
|
if sys.version_info.major >= 3:
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
connrefused_exc = ConnectionRefusedError
|
|
else:
|
|
from pathlib2 import Path
|
|
from urllib import quote
|
|
connrefused_exc = socket.error
|
|
|
|
|
|
def _get_port_from_env(var_name, default):
|
|
port = int(os.environ.get(var_name, default))
|
|
if port == 0:
|
|
# do a one-time port selection for a free port, use it for all tests
|
|
s = socket.socket()
|
|
s.bind(('', 0))
|
|
port = s.getsockname()[1]
|
|
s.close()
|
|
return port
|
|
|
|
|
|
SERVEFILE_DEFAULT_PORT = _get_port_from_env('SERVEFILE_DEFAULT_PORT', 0)
|
|
SERVEFILE_SECONDARY_PORT = _get_port_from_env('SERVEFILE_SECONDARY_PORT', 0)
|
|
|
|
|
|
@pytest.fixture
|
|
def run_servefile():
|
|
instances = []
|
|
|
|
def _run_servefile(args, **kwargs):
|
|
if not isinstance(args, list):
|
|
args = [args]
|
|
if kwargs.pop('standalone', None):
|
|
# directly call servefile.py
|
|
servefile_path = [str(Path(__file__).parent.parent / 'servefile' / 'servefile.py')]
|
|
else:
|
|
# call servefile as python module
|
|
servefile_path = ['-m', 'servefile']
|
|
|
|
# use non-default default port, if one is given via env (and none via args)
|
|
if '-p' not in args and '--port' not in args:
|
|
args.extend(['-p', str(SERVEFILE_DEFAULT_PORT)])
|
|
|
|
print("running {} with args {}".format(", ".join(servefile_path), args))
|
|
p = subprocess.Popen([sys.executable] + servefile_path + args, **kwargs)
|
|
time.sleep(kwargs.get('timeout', 0.3))
|
|
instances.append(p)
|
|
|
|
return p
|
|
|
|
yield _run_servefile
|
|
|
|
for instance in instances:
|
|
try:
|
|
instance.terminate()
|
|
except OSError:
|
|
pass
|
|
instance.wait()
|
|
|
|
|
|
@pytest.fixture
|
|
def datadir(tmp_path):
|
|
def _datadir(data, path=None):
|
|
path = path or tmp_path
|
|
for k, v in data.items():
|
|
if isinstance(v, dict):
|
|
new_path = path / k
|
|
new_path.mkdir()
|
|
_datadir(v, new_path)
|
|
else:
|
|
if hasattr(v, 'decode'):
|
|
v = v.decode('utf-8') # python2 compability
|
|
(path / k).write_text(v)
|
|
|
|
return path
|
|
return _datadir
|
|
|
|
|
|
def make_request(path='/', host='localhost', port=SERVEFILE_DEFAULT_PORT, method='get', protocol='http', timeout=5,
|
|
**kwargs):
|
|
url = '{}://{}:{}{}'.format(protocol, host, port, path)
|
|
print('Calling {} on {} with {}'.format(method, url, kwargs))
|
|
r = getattr(requests, method)(url, **kwargs)
|
|
|
|
return r
|
|
|
|
|
|
def check_download(expected_data=None, path='/', fname=None, status_code=200, **kwargs):
|
|
if fname is None:
|
|
fname = os.path.basename(path)
|
|
r = make_request(path, **kwargs)
|
|
assert r.status_code == 200
|
|
assert r.text == expected_data
|
|
assert r.headers.get('Content-Type') == 'application/octet-stream'
|
|
if fname:
|
|
assert r.headers.get('Content-Disposition') == 'attachment; filename="{}"'.format(fname)
|
|
assert r.headers.get('Content-Transfer-Encoding') == 'binary'
|
|
|
|
return r # for additional tests
|
|
|
|
|
|
def _test_version(run_servefile, standalone):
|
|
# we expect the version on stdout (python3.4+) or stderr(python2.6-3.3)
|
|
s = run_servefile('--version', standalone=standalone, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
s.wait()
|
|
version = s.stdout.readline().decode().strip()
|
|
|
|
# python2 is deprecated, but we still want our tests to run for it
|
|
# CryptographyDeprecationWarnings get in the way for this
|
|
if 'CryptographyDeprecationWarning' in version:
|
|
s.stdout.readline() # ignore "from x import y" line
|
|
version = s.stdout.readline().decode().strip()
|
|
|
|
# hardcode version as string until servefile is a module
|
|
assert version == 'servefile 0.5.1'
|
|
|
|
|
|
def test_version(run_servefile):
|
|
_test_version(run_servefile, standalone=False)
|
|
|
|
|
|
def test_version_standalone(run_servefile):
|
|
# test if servefile also works by calling servefile.py directly
|
|
_test_version(run_servefile, standalone=True)
|
|
|
|
|
|
def test_correct_headers(run_servefile, datadir):
|
|
data = "NOOT NOOT"
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
run_servefile(str(p))
|
|
|
|
r = make_request()
|
|
assert r.status_code == 200
|
|
assert r.headers.get('Content-Type') == 'application/octet-stream'
|
|
assert r.headers.get('Content-Disposition') == 'attachment; filename="testfile"'
|
|
assert r.headers.get('Content-Transfer-Encoding') == 'binary'
|
|
|
|
|
|
def test_redirect_and_download(run_servefile, datadir):
|
|
data = "NOOT NOOT"
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
run_servefile(str(p))
|
|
|
|
# redirect
|
|
r = make_request(allow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers.get('Location') == '/testfile'
|
|
|
|
# normal download
|
|
check_download(data, fname='testfile')
|
|
|
|
|
|
def test_redirect_and_download_with_umlaut(run_servefile, datadir):
|
|
data = "NÖÖT NÖÖT"
|
|
filename = "tästføile"
|
|
p = datadir({filename: data}) / filename
|
|
run_servefile(str(p))
|
|
|
|
# redirect
|
|
r = make_request(allow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers.get('Location') == '/{}'.format(quote(filename))
|
|
|
|
# normal download
|
|
if sys.version_info.major < 3:
|
|
data = unicode(data, 'utf-8')
|
|
check_download(data, fname=filename)
|
|
|
|
|
|
def test_specify_port(run_servefile, datadir):
|
|
data = "NOOT NOOT"
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
run_servefile([str(p), '-p', str(SERVEFILE_SECONDARY_PORT)])
|
|
|
|
check_download(data, fname='testfile', port=SERVEFILE_SECONDARY_PORT)
|
|
|
|
|
|
def test_ipv4_only(run_servefile, datadir):
|
|
data = "NOOT NOOT"
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
run_servefile([str(p), '-4'])
|
|
|
|
check_download(data, fname='testfile', host='127.0.0.1')
|
|
|
|
sock = socket.socket(socket.AF_INET6)
|
|
with pytest.raises(connrefused_exc):
|
|
sock.connect(("::1", SERVEFILE_DEFAULT_PORT))
|
|
|
|
|
|
def test_big_download(run_servefile, datadir):
|
|
# test with about 10 mb of data
|
|
data = "x" * (10 * 1024 ** 2)
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
run_servefile(str(p))
|
|
|
|
check_download(data, fname='testfile')
|
|
|
|
|
|
def test_authentication(run_servefile, datadir):
|
|
data = "NOOT NOOT"
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
|
|
run_servefile([str(p), '-a', 'user:password'])
|
|
for auth in [('foo', 'bar'), ('user', 'wrong'), ('unknown', 'password')]:
|
|
r = make_request(auth=auth)
|
|
assert '401 - Unauthorized' in r.text
|
|
assert r.status_code == 401
|
|
|
|
check_download(data, fname='testfile', auth=('user', 'password'))
|
|
|
|
|
|
def test_serve_directory(run_servefile, datadir):
|
|
d = {
|
|
'foo': {'kratzbaum': 'cat', 'I like Cats!': 'kitteh', '&&&&&&&': 'wheee'},
|
|
'bar': {'thisisaverylongfilenamefortestingthatthisstillworksproperly': 'jup!'},
|
|
'noot': 'still data in here',
|
|
'bigfile': 'x' * (10 * 1024 ** 2),
|
|
'möwe': 'KRAKRAKRAKA',
|
|
}
|
|
p = datadir(d)
|
|
run_servefile([str(p), '-l'])
|
|
|
|
# check if all files are in directory listing
|
|
# (could be made more sophisticated with beautifulsoup)
|
|
for path in '/', '/../':
|
|
r = make_request(path)
|
|
for k in d:
|
|
assert quote(k) in r.text
|
|
|
|
for fname, content in d['foo'].items():
|
|
check_download(content, '/foo/' + fname)
|
|
|
|
r = make_request('/unknown')
|
|
assert r.status_code == 404
|
|
|
|
# download
|
|
check_download('jup!', '/bar/thisisaverylongfilenamefortestingthatthisstillworksproperly')
|
|
|
|
|
|
def test_serve_relative_directory(run_servefile, datadir):
|
|
d = {
|
|
'foo': {'kratzbaum': 'cat', 'I like Cats!': 'kitteh', '&&&&&&&': 'wheee'},
|
|
'bar': {'thisisaverylongfilenamefortestingthatthisstillworksproperly': 'jup!'},
|
|
'noot': 'still data in here',
|
|
'bigfile': 'x' * (10 * 1024 ** 2),
|
|
}
|
|
p = datadir(d)
|
|
run_servefile(['../', '-l'], cwd=os.path.join(str(p), 'foo'))
|
|
|
|
# check if all files are in directory listing
|
|
# (could be made more sophisticated with beautifulsoup)
|
|
for path in '/', '/../':
|
|
r = make_request(path)
|
|
for k in d:
|
|
assert k in r.text
|
|
|
|
for fname, content in d['foo'].items():
|
|
check_download(content, '/foo/' + fname)
|
|
|
|
r = make_request('/unknown')
|
|
assert r.status_code == 404
|
|
|
|
# download
|
|
check_download('jup!', '/bar/thisisaverylongfilenamefortestingthatthisstillworksproperly')
|
|
|
|
|
|
def test_upload(run_servefile, tmp_path):
|
|
data = ('this is my live now\n'
|
|
'uploading strings to servers\n'
|
|
'so very joyful')
|
|
uploaddir = tmp_path / 'upload'
|
|
# check that uploaddir does not exist before servefile is started
|
|
assert not uploaddir.is_dir()
|
|
|
|
run_servefile(['-u', str(uploaddir)])
|
|
|
|
# check that servefile created the directory
|
|
assert uploaddir.is_dir()
|
|
|
|
# check upload form present
|
|
r = make_request()
|
|
assert r.status_code == 200
|
|
assert 'multipart/form-data' in r.text
|
|
|
|
# upload file
|
|
files = {'file': ('haiku.txt', data)}
|
|
r = make_request(method='post', files=files)
|
|
assert 'Thanks' in r.text
|
|
assert r.status_code == 200
|
|
with open(str(uploaddir / 'haiku.txt')) as f:
|
|
assert f.read() == data
|
|
|
|
# upload file AGAIN!! (and check it is available unter a different name)
|
|
files = {'file': ('haiku.txt', data)}
|
|
r = make_request(method='post', files=files)
|
|
assert r.status_code == 200
|
|
with open(str(uploaddir / 'haiku.txt(1)')) as f:
|
|
assert f.read() == data
|
|
|
|
# upload file using PUT
|
|
r = make_request("/haiku.txt", method='put', data=data)
|
|
assert r.status_code == 201
|
|
assert 'OK!' in r.text
|
|
with open(str(uploaddir / 'haiku.txt(2)')) as f:
|
|
assert f.read() == data
|
|
|
|
|
|
def test_upload_size_limit(run_servefile, tmp_path):
|
|
uploaddir = tmp_path / 'upload'
|
|
run_servefile(['-s', '2kb', '-u', str(uploaddir)])
|
|
|
|
# upload file that is too big
|
|
files = {'file': ('toobig', "x" * 2049)}
|
|
r = make_request(method='post', files=files)
|
|
assert 'Your file was too big' in r.text
|
|
assert r.status_code == 413
|
|
assert not (uploaddir / 'toobig').exists()
|
|
|
|
# upload file that should fit
|
|
# the size has to be smaller than 2kb, as the sent size also includes mime-headers
|
|
files = {'file': ('justright', "x" * 1900)}
|
|
r = make_request(method='post', files=files)
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_tar_mode(run_servefile, datadir):
|
|
d = {
|
|
'foo': {
|
|
'bar': 'hello testmode my old friend',
|
|
'baz': 'you came to test me once again',
|
|
}
|
|
}
|
|
p = datadir(d)
|
|
run_servefile(['-t', str(p / 'foo')])
|
|
|
|
# test redirect?
|
|
|
|
# test contents of tar file
|
|
r = make_request()
|
|
assert r.status_code == 200
|
|
tar = tarfile.open(fileobj=io.BytesIO(r.content))
|
|
assert len(tar.getmembers()) == 3
|
|
assert tar.getmember('foo').isdir()
|
|
for filename, content in d['foo'].items():
|
|
info = tar.getmember('foo/{}'.format(filename))
|
|
assert info.isfile
|
|
assert tar.extractfile(info.path).read().decode() == content
|
|
|
|
|
|
def test_tar_compression(run_servefile, datadir):
|
|
d = {'foo': 'blubb'}
|
|
p = datadir(d)
|
|
run_servefile(['-c', 'gzip', '-t', str(p / 'foo')])
|
|
|
|
r = make_request()
|
|
assert r.status_code == 200
|
|
tar = tarfile.open(fileobj=io.BytesIO(r.content), mode='r:gz')
|
|
assert len(tar.getmembers()) == 1
|
|
|
|
|
|
def test_https(run_servefile, datadir):
|
|
data = "NOOT NOOT"
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
run_servefile(['--ssl', str(p)])
|
|
time.sleep(0.2) # time for generating ssl certificates
|
|
|
|
# fingerprint = None
|
|
# while not fingerprint:
|
|
# line = s.stdout.readline()
|
|
# print(line)
|
|
# # if we find this line we went too far...
|
|
# assert not line.startswith("Some addresses this file will be available at")
|
|
|
|
# if line.startswith("SHA1 fingerprint"):
|
|
# fingerprint = line.replace("SHA1 fingerprint: ", "").strip()
|
|
# break
|
|
|
|
# assert fingerprint
|
|
urllib3.disable_warnings()
|
|
check_download(data, protocol='https', verify=False)
|
|
|
|
|
|
def test_https_big_download(run_servefile, datadir):
|
|
# test with about 10 mb of data
|
|
data = "x" * (10 * 1024 ** 2)
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
run_servefile(['--ssl', str(p)])
|
|
time.sleep(0.2) # time for generating ssl certificates
|
|
|
|
urllib3.disable_warnings()
|
|
check_download(data, protocol='https', verify=False)
|
|
|
|
|
|
def test_abort_download(run_servefile, datadir):
|
|
data = "x" * (10 * 1024 ** 2)
|
|
p = datadir({'testfile': data}) / 'testfile'
|
|
env = os.environ.copy()
|
|
env['PYTHONUNBUFFERED'] = '1'
|
|
proc = run_servefile(str(p), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env)
|
|
|
|
# provoke a connection abort
|
|
# hopefully the buffers will not fill up with all of the 10mb
|
|
sock = socket.socket(socket.AF_INET)
|
|
sock.connect(("localhost", SERVEFILE_DEFAULT_PORT))
|
|
sock.send(b"GET /testfile HTTP/1.0\n\n")
|
|
resp = sock.recv(100)
|
|
assert resp != b''
|
|
sock.close()
|
|
time.sleep(0.1)
|
|
proc.kill()
|
|
out = proc.stdout.read().decode()
|
|
assert "127.0.0.1 ABORTED transmission" in out
|