Files
PS5_RetroArch/tests/test_webui.py
T
Mihawk f25de5852e Tests: a WebUI server whose port was taken starts again on another
The host tests found a free port by binding port 0 and closing it, then started the
WebUI's server on it. In between, any connection on the machine can take that port as
its own (the same ephemeral range): the server then exits at once, "Failed to bind to
port N: Address already in use", and the test waited out its window and failed with
"the server did not start" (test_scraper, test_scraper_launchbox) or "HTTP server
exited" (test_webui). It happened in about one gate run in three, under JOBS=4 or not.

tests/webui_build.py start() starts the server, waits for its status and returns the
process, port and session token; a server that exits before answering is started again
on another port (five tries). Every harness that started the server itself uses it:
test_scraper, test_scraper_launchbox, test_scraper_screenscraper, test_webui (its start
and two restarts), test_webui_language, test_webui_transfer_browser. test_scraper's
free_port() had no other user and is gone.

Evidence:
- the cause: 40 sequential starts with each server's stderr kept: 1 failed, "Failed to
  bind to port 44505: Address already in use" (exit 1, after 0.02 s); through start(),
  40 of 40 started.
- python3 -m unittest tests.test_scraper tests.test_scraper_launchbox
  tests.test_scraper_screenscraper: OK; tests.test_webui tests.test_webui_language
  tests.test_webui_transfer_browser: 21 tests OK.
- gates on the tree with the next commit: format, unit (210 tests), build, integration
  (210 tests), evidence - PASS.
2026-10-18 07:39:56 -04:00

442 lines
30 KiB
Python

"""Real HTTP requests against the native WebUI handlers and streaming parser."""
import http.client
import json
from pathlib import Path
import socket
import subprocess
import tempfile
import time
import unittest
import sys
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
import webui_build # noqa: E402
from urllib.parse import quote
ROOT = Path(__file__).resolve().parent.parent
class WebUI(unittest.TestCase):
@classmethod
def setUpClass(cls):
archive = subprocess.check_output(['bash', 'tools/build-webui-http.sh', 'host'], cwd=ROOT, text=True).strip()
update = subprocess.check_output(['bash', 'tools/build-webui-update.sh', 'host'], cwd=ROOT, text=True).strip()
cls.temp = tempfile.TemporaryDirectory()
cls.root = Path(cls.temp.name)
for name in ('config', 'content', 'webui'):
(cls.root / name).mkdir()
(cls.root / 'webui/index.html').write_text('<!doctype html><title>RetroArch</title>')
cls.original = b'audio_volume = "-6"\ninput_rumble_gain = "75"\nunrelated = "preserve"\n'
(cls.root / 'config/retroarch.cfg').write_bytes(cls.original)
cls.binary = cls.root / 'server'
webui_build.build(cls.binary, 'tests/webui_server_main.cpp')
cls.process, cls.port, cls.token = webui_build.start(cls.binary, cls.root)
@classmethod
def tearDownClass(cls):
cls.process.terminate(); cls.process.wait(timeout=5)
cls.temp.cleanup()
@classmethod
def request(cls, method, path, body=None, headers=None):
conn = http.client.HTTPConnection('127.0.0.1', cls.port, timeout=5)
fields = {'X-RetroArch-Token': getattr(cls, 'token', '')}
fields.update(headers or {})
conn.request(method, path, body, fields)
response = conn.getresponse()
result = response.status, dict(response.getheaders()), response.read()
conn.close()
return result
def test_origin_session_and_assets(self):
status, headers, body = self.request('GET', '/')
self.assertEqual(status, 200)
self.assertIn(b'RetroArch', body)
self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy'])
self.assertEqual(self.request('GET', '/api/status', headers={'Host': 'attacker.example'})[0], 403)
self.assertEqual(self.request('POST', '/api/folder?path=nope', headers={'Origin': 'https://attacker.example'})[0], 403)
self.assertEqual(self.request('POST', '/api/folder?path=nope', headers={'X-RetroArch-Token': 'wrong'})[0], 403)
self.assertEqual(self.request('GET', '/config/retroarch.cfg')[0], 404)
def test_alerts_follow_installed_cores_and_custom_system_folder(self):
catalog = self.root / 'webui/core-metadata'
catalog.mkdir(exist_ok=True)
index = catalog / 'index.cfg'
previous = index.read_bytes() if index.exists() else None
for folder in ('cores', 'info', 'system/Saturn', 'config/Beetle Saturn'):
(self.root / folder).mkdir(parents=True, exist_ok=True)
index.write_text('mednafen_saturn_libretro.so = "Beetle Saturn"\n')
core = self.root / 'cores/mednafen_saturn_libretro.so'; core.write_bytes(b'fixture')
info = self.root / 'info/mednafen_saturn_libretro.info'
info.write_text('firmware0_path = "required.bin"\nfirmware0_opt = "false"\nfirmware0_desc = "Region BIOS"\nfirmware1_path = "optional.bin"\nfirmware1_opt = "true"\n')
cfg = self.root / 'config/Beetle Saturn/Beetle Saturn.cfg'
original_cfg = cfg.read_bytes() if cfg.exists() else None
cfg.unlink(missing_ok=True)
try:
# Saturn: one BIOS of either region is enough (its .info calls both required).
alerts = json.loads(self.request('GET', '/api/alerts')[2])['alerts']
self.assertEqual([(a['title'], a['level'], a['path']) for a in alerts],
[('A Saturn BIOS', 'required', str(self.root/'system/Saturn'))])
self.assertEqual(alerts[0]['any_of'], ['sega_101.bin', 'mpr-17933.bin'])
(self.root/'system/Saturn/mpr-17933.bin').write_bytes(b'present')
self.assertEqual(json.loads(self.request('GET', '/api/alerts')[2])['alerts'], [])
cfg.write_text(f'system_directory = "{self.root}/custom-bios"\n')
alerts = json.loads(self.request('GET', '/api/alerts')[2])['alerts']
self.assertEqual(alerts[0]['path'], str(self.root/'custom-bios'))
cfg.unlink()
core.unlink()
self.assertEqual(json.loads(self.request('GET', '/api/alerts')[2])['alerts'], [])
# Cores with no option catalog are checked too, by their own .info: a 3DO
# BIOS (any one), an Amiga Kickstart only for some games, and a core the
# rules do not know, by its .info's required firmware.
for stem, text in (('opera', 'display_name = "The 3DO Company - 3DO (Opera)"\ncorename = "Opera"\nfirmware0_path = "panafz10.bin"\nfirmware0_opt = "true"\n'),
('puae', 'display_name = "Commodore - Amiga (PUAE)"\ncorename = "PUAE"\nfirmware0_path = "kick34005.A500"\nfirmware0_opt = "false"\n'),
('other', 'display_name = "Other (Thing)"\ncorename = "Thing"\nfirmware0_path = "thing.bin"\nfirmware0_opt = "false"\nfirmware0_desc = "Thing BIOS"\n')):
(self.root/f'cores/{stem}_libretro.so').write_bytes(b'fixture')
(self.root/f'info/{stem}_libretro.info').write_text(text)
alerts = {a['core']: a for a in json.loads(self.request('GET', '/api/alerts')[2])['alerts']}
self.assertEqual(sorted(alerts), ['Commodore - Amiga (PUAE)', 'Other (Thing)', 'The 3DO Company - 3DO (Opera)'])
self.assertEqual((alerts['The 3DO Company - 3DO (Opera)']['title'], alerts['The 3DO Company - 3DO (Opera)']['level']), ('A 3DO BIOS', 'required'))
self.assertEqual(alerts['Commodore - Amiga (PUAE)']['level'], 'some')
self.assertEqual((alerts['Other (Thing)']['title'], alerts['Other (Thing)']['path']), ('Thing BIOS', str(self.root/'system/thing.bin')))
(self.root/'system/goldstar.bin').write_bytes(b'present')
alerts = {a['core'] for a in json.loads(self.request('GET', '/api/alerts')[2])['alerts']}
self.assertNotIn('The 3DO Company - 3DO (Opera)', alerts)
for stem in ('opera', 'puae', 'other'):
(self.root/f'cores/{stem}_libretro.so').unlink()
finally:
if previous is None: index.unlink(missing_ok=True)
else: index.write_bytes(previous)
if original_cfg is None: cfg.unlink(missing_ok=True)
else: cfg.write_bytes(original_cfg)
core.unlink(missing_ok=True); info.unlink(missing_ok=True)
def test_update_requests_require_session_and_ready_package(self):
self.assertEqual(json.loads(self.request('GET', '/api/update')[2])['state'], 'idle')
self.assertEqual(self.request('POST', '/api/update/download?tag=v1.0.0', headers={'X-RetroArch-Token':'wrong'})[0], 403)
self.assertEqual(self.request('POST', '/api/update/download?tag=../escape')[0], 409)
self.assertEqual(self.request('POST', '/api/update/install')[0], 409)
def test_registered_core_metadata(self):
status, _, body = self.request('GET', '/api/core-metadata?core=Metadata%20test')
self.assertEqual(status, 200)
data = json.loads(body)['runtime']
self.assertEqual(data['categories'][0]['label'], 'Video')
setting = data['settings'][0]
self.assertEqual(setting['label'], 'Resolution <test>')
self.assertIn('"quotes"', setting['description'])
self.assertEqual(setting['choices'], [['1', 'Native'], ['2', 'Double']])
legacy = json.loads(self.request('GET', '/api/core-metadata?core=Legacy%20test')[2])['runtime']
self.assertEqual(legacy['settings'][0]['choices'], [['normal', 'normal'], ['fast', 'fast']])
self.assertEqual(self.request('GET', '/api/core-metadata?core=../escape')[0], 400)
self.assertEqual(json.loads(self.request('GET', '/api/core-metadata?core=Unknown')[2])['runtime']['settings'], [])
self.assertFalse((self.root / 'config/escape.json').exists())
(self.root / 'config/webui-metadata/link.json').symlink_to(self.root / 'config/retroarch.cfg')
self.assertEqual(json.loads(self.request('GET', '/api/core-metadata?core=link')[2])['runtime']['settings'], [])
def test_installed_core_without_saved_profile(self):
catalog = self.root / 'webui/core-metadata'
catalog.mkdir(exist_ok=True)
(self.root / 'cores').mkdir(exist_ok=True)
(self.root / 'cores/fresh_libretro.so').write_bytes(b'installed core fixture')
(catalog / 'index.cfg').write_text('fresh_libretro.so = "Fresh Core"\nmissing_libretro.so = "Missing Core"\n')
metadata = {'categories': [{'key': 'video', 'label': 'Video', 'description': 'Picture'}],
'settings': [{'key': 'fresh_resolution', 'label': 'Resolution', 'description': 'Picture detail.', 'category': 'video', 'choices': [['1', 'Native'], ['2', 'Double']]}]}
(catalog / 'Fresh Core.json').write_text(json.dumps(metadata))
(catalog / 'Fresh Core.opt').write_text('fresh_resolution = "2"\n')
profiles = json.loads(self.request('GET', '/api/cores')[2])['cores']
self.assertIn('Fresh Core', profiles)
self.assertNotIn('Missing Core', profiles)
self.assertFalse((self.root / 'config/Fresh Core').exists())
response = json.loads(self.request('GET', '/api/core-metadata?core=Fresh%20Core')[2])
self.assertEqual(response['bundled'], metadata)
self.assertEqual(response['runtime']['settings'], [])
url = '/api/config?scope=core-options&core=Fresh%20Core'
state = json.loads(self.request('GET', url)[2])
self.assertEqual(state['settings'][0]['value'], '2')
status, _, _ = self.request('POST', url, b'fresh_resolution=1', {'X-RetroArch-Revision': state['revision']})
self.assertEqual(status, 200)
self.assertFalse((self.root / 'config/Fresh Core').exists())
# A new profile must survive these ports' first-use default migration.
for stem, core, key in [('ppsspp', 'PPSSPP', 'ppsspp_internal_resolution'),
('dolphin', 'dolphin-emu', 'dolphin_efb_scale')]:
(self.root / 'cores' / (stem + '_libretro.so')).write_bytes(b'core fixture')
with (catalog / 'index.cfg').open('a') as index:
index.write(stem + '_libretro.so = "' + core + '"\n')
(catalog / (core + '.opt')).write_text(key + ' = "original"\n')
endpoint = '/api/config?scope=core-options&core=' + quote(core)
current = json.loads(self.request('GET', endpoint)[2])
self.assertEqual(self.request('POST', endpoint, (key + '=chosen').encode(),
{'X-RetroArch-Revision': current['revision']})[0], 200)
self.process.terminate(); self.process.wait(timeout=5)
cls = self.__class__
cls.process, cls.port, cls.token = webui_build.start(self.binary, self.root)
self.assertEqual((self.root / 'config/Fresh Core/Fresh Core.opt').read_text(), 'fresh_resolution = "1"\n')
self.assertFalse((self.root / 'config/webui-cores/Fresh Core.opt').exists())
for core in ('PPSSPP', 'dolphin-emu'):
self.assertTrue((self.root / 'config' / core / 'ps5-default-profile-v1').is_file())
self.assertIn('"chosen"', (self.root / 'config' / core / (core + '.opt')).read_text())
self.assertEqual(json.loads(self.request('GET', url)[2])['settings'][0]['value'], '1')
def test_z_catalog_upgrade_ignores_stale_runtime(self):
catalog = self.root / 'webui/core-metadata'
catalog.mkdir(exist_ok=True)
path = '/api/core-metadata?core=Metadata%20test'
current = json.loads(self.request('GET', path)[2])
self.assertEqual(current['runtime']['settings'][0]['choices'], [['1', 'Native'], ['2', 'Double']])
# A new release's binary hash changes even if its static options do not.
upgraded = {'binary_sha256': 'new-core-build', 'categories': [], 'settings': [
{'key': 'test_resolution', 'label': 'Resolution', 'description': 'Updated choices',
'category': '', 'choices': [['1', 'Native'], ['3', 'Triple']]}]}
(catalog / 'Metadata test.json').write_text(json.dumps(upgraded))
after = json.loads(self.request('GET', path)[2])
self.assertEqual(after['bundled'], upgraded)
self.assertEqual(after['runtime']['settings'], [])
# An old defaults snapshot must not resurrect removed options either.
(self.root / 'config/Metadata test').mkdir(exist_ok=True)
(self.root / 'config/Metadata test/Metadata test.opt').write_text('saved_option = "preserve"\n')
state = json.loads(self.request('GET', '/api/config?scope=core-options&core=Metadata%20test')[2])
self.assertEqual([s['key'] for s in state['settings']], ['saved_option'])
def test_upload_download_and_collision(self):
self.assertEqual(self.request('POST', '/api/folder?path=PSP')[0], 201)
content = bytes(range(256)) * 8192
path = '/api/upload?path=' + quote('PSP/test & game.iso')
status, _, body = self.request('PUT', path, content)
self.assertEqual(status, 201, body)
self.assertEqual((self.root / 'content/PSP/test & game.iso').read_bytes(), content)
self.assertEqual(self.request('PUT', path, b'replace')[0], 409)
self.assertEqual(self.request('GET', path.replace('upload', 'download'))[2], content)
listing = json.loads(self.request('GET', '/api/content?path=PSP')[2])
self.assertEqual(listing['entries'][0]['name'], 'test & game.iso')
self.assertEqual(listing['entries'][0]['size'], len(content))
def test_traversal_symlinks_and_hidden_files(self):
(self.root / 'content/escape').symlink_to(self.root / 'config', target_is_directory=True)
for path in ('../config/retroarch.cfg', '/etc/passwd', 'escape/retroarch.cfg', 'a/../b', '.private'):
for verb, endpoint, body in [('GET', 'content', None), ('PUT', 'upload', b'bad')]:
status = self.request(verb, '/api/'+endpoint+'?path='+quote(path, safe=''), body)[0]
self.assertIn(status, (400, 409), path)
self.assertEqual((self.root / 'config/retroarch.cfg').read_bytes(), self.original)
self.assertNotIn('escape', [e['name'] for e in json.loads(self.request('GET', '/api/content')[2])['entries']])
def test_settings_preserve_original_and_validate(self):
fields = {s['key']: s['value'] for s in json.loads(self.request('GET', '/api/settings')[2])['settings']}
self.assertEqual(fields['audio_volume'], '-6')
self.assertEqual(self.request('POST', '/api/settings', b'audio_volume=-12\ninput_rumble_gain=50')[0], 200)
saved = (self.root / 'config/webui.cfg').read_bytes()
self.assertIn(b'audio_volume = "-12"', saved)
self.assertNotIn(b'video_vsync', saved)
self.assertEqual((self.root / 'config/retroarch.cfg').read_bytes(), self.original)
for bad in (b'audio_volume=999', b'input_rumble_gain=-1', b'menu_driver=evil', b'savefile_directory=/tmp', b'video_vsync=perhaps'):
self.assertEqual(self.request('POST', '/api/settings', bad)[0], 400)
self.assertEqual((self.root / 'config/webui.cfg').read_bytes(), saved)
self.assertEqual(self.request('POST', '/api/settings', b'a'*17000)[0], 413)
conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=5)
conn.request('POST', '/api/settings', [b'a'*9000, b'b'*9000],
{'X-RetroArch-Token': self.token}, encode_chunked=True)
self.assertEqual(conn.getresponse().status, 413)
conn.close()
self.assertEqual((self.root / 'config/webui.cfg').read_bytes(), saved)
def test_frontend_start_lives_in_its_own_file(self):
# The frontend the title starts with (src/ps5_frontend_choice.h): "ask" until one is
# chosen, kept in config/frontend.cfg and never in RetroArch's webui.cfg.
webui_cfg = self.root / 'config/webui.cfg'
before = webui_cfg.read_bytes() if webui_cfg.exists() else None
self.addCleanup(lambda: webui_cfg.write_bytes(before) if before is not None else webui_cfg.unlink(missing_ok=True))
self.addCleanup(lambda: (self.root / 'config/frontend.cfg').unlink(missing_ok=True))
fields = {s['key']: s for s in json.loads(self.request('GET', '/api/settings')[2])['settings']}
self.assertEqual((fields['frontend_start']['value'], fields['frontend_start']['kind']), ('ask', 'frontend'))
self.assertEqual(self.request('POST', '/api/settings', b'frontend_start=es-de')[0], 200)
self.assertEqual((self.root / 'config/frontend.cfg').read_text(), 'frontend_start = "es-de"\n')
self.assertEqual(webui_cfg.read_bytes() if webui_cfg.exists() else None, before)
fields = {s['key']: s['value'] for s in json.loads(self.request('GET', '/api/settings')[2])['settings']}
self.assertEqual(fields['frontend_start'], 'es-de')
for bad in (b'frontend_start=evil', b'frontend_start=', b'frontend_start=es-de"\ninjected=1'):
self.assertEqual(self.request('POST', '/api/settings', bad)[0], 400)
self.assertEqual((self.root / 'config/frontend.cfg').read_text(), 'frontend_start = "es-de"\n')
# With a RetroArch setting in the same request, both files take their part.
self.assertEqual(self.request('POST', '/api/settings', b'frontend_start=ask\naudio_volume=-3')[0], 200)
self.assertEqual((self.root / 'config/frontend.cfg').read_text(), 'frontend_start = "ask"\n')
webui = (self.root / 'config/webui.cfg').read_bytes()
self.assertIn(b'audio_volume = "-3"', webui)
self.assertNotIn(b'frontend_start', webui)
def test_advanced_global_and_per_core_persistence(self):
# Full saved profiles, including values not in the quick-settings list.
(self.root / 'retroarch.cfg').write_text('video_rotation = "0"\nvideo_vsync = "true"\n')
profile = self.root / 'config/Example Core'
profile.mkdir()
original = b'example_resolution = "6x"\nexample_filter = "nearest"\n'
(profile / 'Example Core.opt').write_bytes(original)
(self.root / 'config/not-a-core').symlink_to(self.root / 'content', target_is_directory=True)
cores = json.loads(self.request('GET', '/api/cores')[2])['cores']
self.assertIn('Example Core', cores)
self.assertNotIn('not-a-core', cores)
def read(url):
status, _, body = self.request('GET', url)
self.assertEqual(status, 200, body)
return json.loads(body)
def save(url, body, revision):
return self.request('POST', url, body, {'X-RetroArch-Revision': revision})[0]
global_url = '/api/config?scope=global'
state = read(global_url)
self.assertEqual(save(global_url, b'video_rotation=2', state['revision']), 200)
self.assertEqual(save(global_url, b'video_rotation=3', state['revision']), 409)
for invalid in (b'new_arbitrary_key=1', b'audio_volume=abc', b'video_vsync=yes', b'video_rotation=2"\ninjected=1', b'audio_volume=999'):
self.assertEqual(save(global_url, invalid, read(global_url)['revision']), 400)
# A quick setting must retain the advanced override.
self.assertEqual(self.request('POST', '/api/settings', b'input_rumble_gain=75')[0], 200)
self.assertIn(b'video_rotation = "2"', (self.root / 'config/webui.cfg').read_bytes())
# Unknown config fields and core enums remain strings across edits.
self.assertEqual(save(global_url, b'unrelated=1', read(global_url)['revision']), 200)
self.assertEqual(save(global_url, b'unrelated=preserve', read(global_url)['revision']), 200)
options_url = '/api/config?scope=core-options&core=Example%20Core'
self.assertEqual(save(options_url, b'example_resolution=1', read(options_url)['revision']), 200)
self.assertEqual(save(options_url, b'example_resolution=4x', read(options_url)['revision']), 200)
self.assertEqual((profile / 'Example Core.opt').read_bytes(), original, 'Running core profile is untouched')
override_url = '/api/config?scope=core-settings&core=Example%20Core'
self.assertEqual(save(override_url, b'video_vsync=false', read(override_url)['revision']), 200)
self.assertNotEqual(next(x['value'] for x in read(global_url)['settings'] if x['key'] == 'video_vsync'), 'false')
self.assertEqual(self.request('GET', '/api/config?scope=core-options&core=..%2Fconfig')[0], 404)
# Simulate the current core saving on exit after the browser edit.
(profile / 'Example Core.opt').write_bytes(original)
cls = self.__class__
cls.process.terminate(); cls.process.wait(timeout=5)
cls.process, cls.port, cls.token = webui_build.start(cls.binary, cls.root)
self.assertIn(b'example_resolution = "4x"', (profile / 'Example Core.opt').read_bytes())
self.assertIn(b'example_filter = "nearest"', (profile / 'Example Core.opt').read_bytes())
self.assertIn(b'video_vsync = "false"', (profile / 'Example Core.cfg').read_bytes())
self.assertFalse((self.root / 'config/webui-cores/Example Core.opt').exists())
self.assertFalse((self.root / 'config/webui-cores/Example Core.cfg').exists())
self.assertEqual((self.root / 'config/retroarch.cfg').read_bytes(), self.original)
# Leave the shared fixture's original quick-settings baseline intact.
(self.root / 'config/webui.cfg').unlink()
def test_interrupted_upload_is_removed(self):
with socket.create_connection(('127.0.0.1', self.port)) as s:
request = (f'PUT /api/upload?path=unfinished.iso HTTP/1.1\r\nHost: 127.0.0.1:{self.port}\r\n'
f'X-RetroArch-Token: {self.token}\r\nContent-Length: 1000000\r\n\r\n').encode()
s.sendall(request + b'a'*1000)
time.sleep(.05)
for _ in range(100):
if not list((self.root / 'content').glob('.upload-*')): break
time.sleep(.02)
self.assertFalse(list((self.root / 'content').glob('.upload-*')))
self.assertFalse((self.root / 'content/unfinished.iso').exists())
# The transfer engine (src/webui_transfer.h): batches, sessions, ranges, parallel.
@staticmethod
def batch(files, end=True):
out = b''
for name, data in files:
raw = name.encode()
out += len(raw).to_bytes(2, 'little') + raw + len(data).to_bytes(8, 'little') + data
return out + (b'\0\0' if end else b'')
def test_batch_upload_many_small_files(self):
files = [(f'media/{system}/{i:03}.png', bytes([i % 251]) * (i * 37 % 5000))
for system in ('snes', 'nes') for i in range(150)]
status, _, body = self.request('PUT', '/api/upload/batch?folder=batch-a', self.batch(files))
result = json.loads(body)
self.assertEqual((status, result['written'], result['skipped'], result['failed']), (200, 300, 0, []))
for name, data in files:
self.assertEqual((self.root / 'content/batch-a' / name).read_bytes(), data)
# Existing files are skipped unless replace; a replace writes them again.
changed = [(files[0][0], b'new'), ('media/snes/extra.png', b'x')]
result = json.loads(self.request('PUT', '/api/upload/batch?folder=batch-a', self.batch(changed))[2])
self.assertEqual((result['written'], result['skipped']), (1, 1))
self.assertEqual((self.root / 'content/batch-a' / files[0][0]).read_bytes(), files[0][1])
result = json.loads(self.request('PUT', '/api/upload/batch?folder=batch-a&existing=replace&sync=0',
self.batch(changed))[2])
self.assertEqual((result['written'], result['skipped']), (2, 0))
self.assertEqual((self.root / 'content/batch-a' / files[0][0]).read_bytes(), b'new')
self.assertFalse(list((self.root / 'content/batch-a').rglob('.upload-*')))
def test_batch_refuses_bad_names_and_bad_streams(self):
bad = [('../escape.png', b'a'), ('.hidden', b'b'), ('a//b', b'c'), ('ok/fine.png', b'd'), ('/abs', b'e')]
status, _, body = self.request('PUT', '/api/upload/batch?folder=batch-b', self.batch(bad))
result = json.loads(body)
self.assertEqual((status, result['written'], len(result['failed'])), (200, 1, 4))
self.assertFalse((self.root / 'escape.png').exists())
self.assertEqual((self.root / 'content/batch-b/ok/fine.png').read_bytes(), b'd')
# Cut short: the files before the cut stand, the one cut is not left behind.
stream = self.batch([('whole.png', b'1' * 100)], end=False)
stream += (8).to_bytes(2, 'little') + b'half.png' + (1000).to_bytes(8, 'little') + b'2' * 10
status, _, body = self.request('PUT', '/api/upload/batch?folder=batch-c', stream)
result = json.loads(body)
self.assertEqual((status, result['complete'], result['written']), (400, False, 1))
self.assertFalse((self.root / 'content/batch-c/half.png').exists())
self.assertFalse(list((self.root / 'content/batch-c').glob('.upload-*')))
# Data past the end marker, and a folder outside content, are refused.
self.assertEqual(self.request('PUT', '/api/upload/batch?folder=batch-d', self.batch([]) + b'junk')[0], 400)
self.assertEqual(self.request('PUT', '/api/upload/batch?folder=../x', self.batch([]))[0], 400)
self.assertEqual(self.request('PUT', '/api/upload/batch?folder=x', self.batch([]),
headers={'X-RetroArch-Token': 'wrong'})[0], 403)
def test_session_parts_in_parallel_any_order(self):
import os, random, threading
data = os.urandom(5 * 1024 * 1024 + 123)
status, _, body = self.request('POST', '/api/upload/session?path=' + quote('big/game.iso') + f'&size={len(data)}')
self.assertEqual(status, 201)
sid = json.loads(body)['id']
# Missing parts: the commit says what is covered and leaves the session open.
status, _, body = self.request('POST', f'/api/upload/commit?id={sid}')
self.assertEqual((status, json.loads(body)['covered']), (409, 0))
part = 700 * 1024
ranges = [(o, min(o + part + 4096, len(data))) for o in range(0, len(data), part)] # overlapping
random.Random(7).shuffle(ranges)
results = []
def send(chunk):
for start, end in chunk:
results.append(self.request('PUT', f'/api/upload/part?id={sid}&offset={start}', data[start:end])[0])
threads = [threading.Thread(target=send, args=(ranges[i::6],)) for i in range(6)]
[t.start() for t in threads]; [t.join() for t in threads]
self.assertEqual(set(results), {200})
self.assertFalse((self.root / 'content/big/game.iso').exists())
status, _, body = self.request('POST', f'/api/upload/commit?id={sid}')
self.assertEqual((status, json.loads(body)['bytes']), (201, len(data)))
self.assertEqual((self.root / 'content/big/game.iso').read_bytes(), data)
self.assertFalse(list((self.root / 'content/big').glob('.upload-*')))
# The session is gone; a second commit, or a part, finds nothing.
self.assertEqual(self.request('POST', f'/api/upload/commit?id={sid}')[0], 404)
self.assertEqual(self.request('PUT', f'/api/upload/part?id={sid}&offset=0', b'x')[0], 404)
# The name now exists: a new session for it is refused unless replace.
self.assertEqual(self.request('POST', '/api/upload/session?path=big/game.iso&size=5')[0], 409)
def test_session_bounds_abort_and_range_download(self):
status, _, body = self.request('POST', '/api/upload/session?path=abort.bin&size=10')
sid = json.loads(body)['id']
self.assertEqual(self.request('PUT', f'/api/upload/part?id={sid}&offset=8', b'abc')[0], 416)
self.assertEqual(self.request('PUT', f'/api/upload/part?id={sid}&offset=x', b'a')[0], 416)
self.assertEqual(self.request('PUT', f'/api/upload/part?id={sid}&offset=0', b'0123456789')[0], 200)
self.assertEqual(json.loads(self.request('DELETE', f'/api/upload/session?id={sid}')[2])['cancelled'], True)
self.assertFalse((self.root / 'content/abort.bin').exists())
self.assertFalse(list((self.root / 'content').glob('.upload-*')))
self.assertEqual(self.request('POST', '/api/upload/session?path=../x&size=1')[0], 400)
# Ranges of a download: a slice, an open end, the last bytes, and one past the end.
(self.root / 'content/range.bin').write_bytes(bytes(range(256)) * 4)
status, headers, body = self.request('GET', '/api/download?path=range.bin', headers={'Range': 'bytes=10-19'})
self.assertEqual((status, body, headers['Content-Range']), (206, bytes(range(10, 20)), 'bytes 10-19/1024'))
self.assertEqual(self.request('GET', '/api/download?path=range.bin', headers={'Range': 'bytes=1000-'})[2],
(bytes(range(256)) * 4)[1000:])
self.assertEqual(self.request('GET', '/api/download?path=range.bin', headers={'Range': 'bytes=-4'})[2],
bytes(range(252, 256)))
self.assertEqual(self.request('GET', '/api/download?path=range.bin', headers={'Range': 'bytes=2000-'})[0], 416)
status, headers, body = self.request('GET', '/api/download?path=range.bin')
self.assertEqual((status, len(body), headers['Accept-Ranges']), (200, 1024, 'bytes'))
def test_parallel_uploads_of_one_name_keep_one(self):
import threading
results = []
def send(i):
results.append(self.request('PUT', '/api/upload?path=race.bin', str(i).encode() * 100000)[0])
threads = [threading.Thread(target=send, args=(i,)) for i in range(8)]
[t.start() for t in threads]; [t.join() for t in threads]
self.assertEqual(results.count(201), 1)
self.assertEqual(results.count(409), 7)
content = (self.root / 'content/race.bin').read_bytes()
self.assertEqual(len(set(content)), 1)
self.assertFalse(list((self.root / 'content').glob('.upload-*')))