first commit
This commit is contained in:
commit
58cfd95600
35 files changed
+5357
No files matched your search
@@ -0,0 +1,228 @@
|
||||
"""Multi-file Archive.org scheduling, fair budgets and safe restarts."""
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import archive_download, main, store
|
||||
|
||||
|
||||
class BytesStream(httpx.SyncByteStream):
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def __iter__(self):
|
||||
yield self.data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, 'DATA', tmp_path)
|
||||
monkeypatch.setattr(store, 'DB_PATH', tmp_path / 'queue.sqlite3')
|
||||
monkeypatch.setenv('ARCHIVE_CONNECTIONS', '50')
|
||||
monkeypatch.setenv('WEB_USER', 'admin')
|
||||
monkeypatch.setenv('WEB_PASSWORD', 'unit-test-password')
|
||||
monkeypatch.setattr(archive_download, 'MIN_SEGMENT_BYTES', 16)
|
||||
store.init_db()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def multi_job(names=('A.iso', 'B.iso', 'C.iso')):
|
||||
urls = [f'https://archive.org/download/test/{name}' for name in names]
|
||||
return main.new_job('archive_many', json.dumps(urls), 'Multi', 'raw')
|
||||
|
||||
|
||||
def mock_archive(monkeypatch, files, first_request_barrier=None, delay=0):
|
||||
lock = threading.Lock()
|
||||
active = 0
|
||||
max_active = 0
|
||||
seen = {}
|
||||
requests = []
|
||||
|
||||
def handler(req):
|
||||
nonlocal active, max_active
|
||||
name = req.url.path.rsplit('/', 1)[-1]
|
||||
data = files[name]
|
||||
with lock:
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
first = name not in seen
|
||||
seen[name] = seen.get(name, 0) + 1
|
||||
requests.append((name, req.headers.get('range')))
|
||||
try:
|
||||
if first and first_request_barrier:
|
||||
first_request_barrier.wait(timeout=5)
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
span = req.headers.get('range')
|
||||
if span:
|
||||
match = re.fullmatch(r'bytes=(\d+)-(\d*)', span)
|
||||
assert match, span
|
||||
begin = int(match.group(1))
|
||||
end = int(match.group(2)) if match.group(2) else len(data)-1
|
||||
if begin >= len(data):
|
||||
return httpx.Response(416, headers={'Content-Range': f'bytes */{len(data)}'})
|
||||
assert 0 <= begin <= end < len(data)
|
||||
return httpx.Response(206, stream=BytesStream(data[begin:end+1]), headers={
|
||||
'Content-Range': f'bytes {begin}-{end}/{len(data)}',
|
||||
'Content-Length': str(end - begin + 1), 'ETag': '"r1"'})
|
||||
return httpx.Response(200, stream=BytesStream(data), headers={'Content-Length': str(len(data))})
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(archive_download, '_client', lambda: httpx.Client(
|
||||
transport=httpx.MockTransport(handler), follow_redirects=False))
|
||||
return requests, lambda: max_active
|
||||
|
||||
|
||||
def test_three_files_start_together_and_share_fifty_connections(setup, monkeypatch):
|
||||
names = ('A.iso', 'B.iso', 'C.iso')
|
||||
contents = {name: bytes([i+10]) * 1024 for i, name in enumerate(names)}
|
||||
barrier = threading.Barrier(3)
|
||||
requests, max_connections = mock_archive(monkeypatch, contents, barrier, delay=0.005)
|
||||
job = multi_job(names)
|
||||
asyncio.run(archive_download.run_one(job))
|
||||
for name in names:
|
||||
assert (setup / 'raw' / job['id'] / name).read_bytes() == contents[name]
|
||||
data = store.get(job['id'])
|
||||
assert data['state'] == 'preparing'
|
||||
assert data['progress'] == 100
|
||||
assert data['part_index'] == 3
|
||||
assert data['total'] == 3 * 1024
|
||||
assert data['completed'] == 3 * 1024
|
||||
assert max_connections() <= 50
|
||||
assert max_connections() > 1
|
||||
assert sum(1 for _, item in requests if item == 'bytes=0-0') == 3
|
||||
log = (setup / 'logs' / f'{job["id"]}.download.log').read_text()
|
||||
assert 'start 3 plików równolegle' in log
|
||||
assert '17, 17, 16' in log
|
||||
|
||||
|
||||
def test_existing_fifty_segment_manifests_do_not_exceed_global_limit(setup, monkeypatch):
|
||||
files = {'A.iso': b'a' * 1024, 'B.iso': b'b' * 1024, 'C.iso': b'c' * 1024}
|
||||
requests, max_connections = mock_archive(monkeypatch, files, delay=0.002)
|
||||
job = multi_job()
|
||||
root = setup / 'raw' / job['id']
|
||||
root.mkdir(parents=True)
|
||||
for url in json.loads(job['source']):
|
||||
target = root / (url.rsplit('/', 1)[-1] + '.part')
|
||||
_, manifest = archive_download._prepare_segments(target, url, 1024, '"r1"', 50)
|
||||
assert len(manifest['ranges']) == 50
|
||||
archive_download._transfer_many(job)
|
||||
assert sum(bool(item and item != 'bytes=0-0') for _, item in requests) == 150
|
||||
assert max_connections() <= 50
|
||||
assert store.get(job['id'])['state'] == 'preparing'
|
||||
for name, data in files.items():
|
||||
assert (root / name).read_bytes() == data
|
||||
|
||||
|
||||
def test_skip_completed_files_and_resume_incomplete_file(setup, monkeypatch):
|
||||
files = {'A.iso': b'a' * 128, 'B.iso': b'b' * 128, 'C.iso': b'c' * 128}
|
||||
requests, _ = mock_archive(monkeypatch, files)
|
||||
job = multi_job()
|
||||
raw = setup / 'raw' / job['id']
|
||||
raw.mkdir(parents=True)
|
||||
(raw / 'A.iso').write_bytes(files['A.iso'])
|
||||
(raw / 'B.iso.part').write_bytes(files['B.iso'][:32])
|
||||
archive_download._transfer_many(job)
|
||||
assert store.get(job['id'])['state'] == 'preparing'
|
||||
assert not any(name == 'A.iso' for name, _ in requests)
|
||||
assert not any(name == 'B.iso' and value == 'bytes=0-31' for name, value in requests)
|
||||
for name, data in files.items():
|
||||
assert (raw / name).read_bytes() == data
|
||||
|
||||
|
||||
def test_pause_stops_all_files_and_resume_keeps_segments(setup, monkeypatch):
|
||||
files = {'A.iso': b'a' * 1024, 'B.iso': b'b' * 1024}
|
||||
gate = threading.Event()
|
||||
release = threading.Event()
|
||||
original = httpx.MockTransport
|
||||
|
||||
def handler(req):
|
||||
name = req.url.path.rsplit('/', 1)[-1]
|
||||
data = files[name]
|
||||
header = req.headers.get('range', '')
|
||||
match = re.fullmatch(r'bytes=(\d+)-(\d*)', header)
|
||||
assert match
|
||||
start = int(match.group(1))
|
||||
end = int(match.group(2)) if match.group(2) else len(data)-1
|
||||
if (start, end) != (0, 0) and not gate.is_set():
|
||||
gate.set()
|
||||
assert release.wait(5), 'download thread was not released'
|
||||
return httpx.Response(206, stream=BytesStream(data[start:end+1]), headers={
|
||||
'ETag': '"r1"', 'Content-Range': f'bytes {start}-{end}/{len(data)}'})
|
||||
|
||||
monkeypatch.setattr(archive_download, '_client', lambda: httpx.Client(
|
||||
transport=original(handler), follow_redirects=False))
|
||||
job = multi_job(('A.iso', 'B.iso'))
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
fut = executor.submit(archive_download._transfer_many, job)
|
||||
assert gate.wait(5)
|
||||
store.update(job['id'], state='paused')
|
||||
release.set()
|
||||
with pytest.raises(archive_download.ArchiveStopped):
|
||||
fut.result(timeout=10)
|
||||
assert store.get(job['id'])['state'] == 'paused'
|
||||
store.update(job['id'], state='queued')
|
||||
archive_download._transfer_many(store.get(job['id']))
|
||||
assert store.get(job['id'])['state'] == 'preparing'
|
||||
assert (setup / 'raw' / job['id'] / 'A.iso').read_bytes() == files['A.iso']
|
||||
assert (setup / 'raw' / job['id'] / 'B.iso').read_bytes() == files['B.iso']
|
||||
|
||||
|
||||
def test_duplicate_filenames_fail_without_writing(setup):
|
||||
j = multi_job(('A/Game.iso', 'B/Game.iso'))
|
||||
with pytest.raises(archive_download.ArchiveDownloadError, match='powtarzające'):
|
||||
archive_download._transfer_many(j)
|
||||
assert not (setup / 'raw' / j['id']).exists()
|
||||
|
||||
|
||||
def test_retry_partial_archive_not_mistaken_for_ready_conversion(setup, monkeypatch):
|
||||
j = multi_job()
|
||||
raw = setup / 'raw' / j['id']
|
||||
raw.mkdir(parents=True)
|
||||
(raw / 'A.iso').write_bytes(b'a')
|
||||
store.update(j['id'], state='error', progress=99.99)
|
||||
|
||||
async def idle_worker():
|
||||
await asyncio.sleep(1000)
|
||||
monkeypatch.setattr(main, 'worker', idle_worker)
|
||||
with TestClient(main.app) as client:
|
||||
response = client.post(f'/api/jobs/{j["id"]}/retry', auth=('admin', 'unit-test-password'))
|
||||
assert response.status_code == 200
|
||||
assert response.json()['state'] == 'queued'
|
||||
|
||||
|
||||
def test_legacy_aria2_file_is_not_accepted_as_finished(setup, monkeypatch):
|
||||
job = multi_job(('A.iso', 'B.iso'))
|
||||
raw = setup / 'raw' / job['id']
|
||||
raw.mkdir(parents=True)
|
||||
(raw / 'A.iso').write_bytes(b'old incomplete file')
|
||||
(raw / 'A.iso.aria2').write_bytes(b'aria2 control file')
|
||||
files = {'A.iso': b'A' * 128, 'B.iso': b'B' * 128}
|
||||
requests, _ = mock_archive(monkeypatch, files)
|
||||
archive_download._transfer_many(job)
|
||||
assert ('A.iso', 'bytes=0-0') in requests
|
||||
assert (raw / 'A.iso').read_bytes() == files['A.iso']
|
||||
assert (raw / 'B.iso').read_bytes() == files['B.iso']
|
||||
|
||||
|
||||
def test_aggregate_progress_is_weighted_by_bytes_not_file_count(setup):
|
||||
j = multi_job()
|
||||
tracker = archive_download._BatchProgress(j['id'], ['A.iso', 'B.iso', 'C.iso'], 50)
|
||||
tracker.report(0, 100, 100)
|
||||
tracker.report(1, 10, 1000)
|
||||
tracker.report(2, 20, 2000)
|
||||
tracker.publish()
|
||||
data = store.get(j['id'])
|
||||
assert data['total'] == 3100
|
||||
assert data['completed'] == 130
|
||||
assert data['progress'] == 4.19
|
||||
assert 'limit 50 połączeń' in data['note']
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Deterministic tests for multi-range authenticated Archive.org downloads."""
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app import archive_download, main, store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, 'DATA', tmp_path)
|
||||
monkeypatch.setattr(store, 'DB_PATH', tmp_path / 'queue.sqlite3')
|
||||
monkeypatch.setenv('ARCHIVE_CONNECTIONS', '4')
|
||||
monkeypatch.setattr(archive_download, 'MIN_SEGMENT_BYTES', 16)
|
||||
monkeypatch.setattr(archive_download.time, 'sleep', lambda _: None)
|
||||
store.init_db()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def job():
|
||||
return main.new_job('url', 'https://archive.org/download/demo/Game.iso', 'Demo', 'raw')
|
||||
|
||||
|
||||
class BytesStream(httpx.SyncByteStream):
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def __iter__(self):
|
||||
yield self.data
|
||||
|
||||
|
||||
def server(monkeypatch, data, custom=None, etag='"release-1"'):
|
||||
requests = []
|
||||
|
||||
def handler(req):
|
||||
requests.append(req)
|
||||
if custom:
|
||||
res = custom(req)
|
||||
if res is not None:
|
||||
return res
|
||||
header = req.headers.get('range')
|
||||
if not header:
|
||||
return httpx.Response(200, stream=BytesStream(data), headers={'Content-Length': str(len(data))})
|
||||
match = re.fullmatch(r'bytes=(\d+)-(\d*)', header)
|
||||
assert match, header
|
||||
start = int(match.group(1))
|
||||
end = int(match.group(2)) if match.group(2) else len(data) - 1
|
||||
assert end < len(data) and 0 <= start <= end
|
||||
return httpx.Response(206, stream=BytesStream(data[start:end + 1]), headers={
|
||||
'Content-Range': f'bytes {start}-{end}/{len(data)}',
|
||||
'Content-Length': str(end - start + 1), 'ETag': etag})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(archive_download, '_client', lambda: httpx.Client(
|
||||
transport=transport, follow_redirects=False))
|
||||
return requests
|
||||
|
||||
|
||||
def final_path(env, j):
|
||||
return env / 'raw' / j['id'] / 'Game.iso'
|
||||
|
||||
|
||||
def test_segments_reconstruct_in_order_and_show_ranges(env, monkeypatch):
|
||||
data = bytes(range(128))
|
||||
requests = server(monkeypatch, data)
|
||||
j = job()
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert store.get(j['id'])['state'] == 'preparing'
|
||||
assert not (env / 'raw' / j['id'] / 'Game.iso.part.segments').exists()
|
||||
spans = [req.headers['range'] for req in requests]
|
||||
assert spans[0] == 'bytes=0-0'
|
||||
assert sorted(spans[1:]) == ['bytes=0-31', 'bytes=32-63', 'bytes=64-95', 'bytes=96-127']
|
||||
assert all(req.headers.get('if-range') == '"release-1"' for req in requests[1:])
|
||||
assert '4 połączeń HTTP Range' in (env / 'logs' / (j['id'] + '.download.log')).read_text()
|
||||
|
||||
|
||||
def test_existing_serial_prefix_is_reused(env, monkeypatch):
|
||||
data = bytes(range(200))
|
||||
requests = server(monkeypatch, data)
|
||||
j = job()
|
||||
folder = env / 'raw' / j['id']
|
||||
folder.mkdir(parents=True)
|
||||
(folder / 'Game.iso.part').write_bytes(data[:40])
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert len(requests) == 5
|
||||
assert all(int(r.headers['range'].split('=')[1].split('-')[0]) >= 40 for r in requests[1:])
|
||||
|
||||
|
||||
def test_interrupted_segment_resumes_only_missing_bytes(env, monkeypatch):
|
||||
data = bytes(range(128))
|
||||
failed = {'once': False}
|
||||
monkeypatch.setattr(archive_download, 'CHUNK_BYTES', 4)
|
||||
|
||||
class BrokenStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield data[:5]
|
||||
raise httpx.ReadError('connection reset')
|
||||
|
||||
def break_once(req):
|
||||
if req.headers.get('range') == 'bytes=0-31' and not failed['once']:
|
||||
failed['once'] = True
|
||||
return httpx.Response(206, stream=BrokenStream(), headers={
|
||||
'Content-Range': 'bytes 0-31/128', 'ETag': '"release-1"'})
|
||||
return None
|
||||
|
||||
requests = server(monkeypatch, data, custom=break_once)
|
||||
monkeypatch.setattr(archive_download, 'MAX_ATTEMPTS', 1)
|
||||
j = job()
|
||||
with pytest.raises(archive_download.ArchiveDownloadError, match='Segment'):
|
||||
archive_download._transfer_one(j)
|
||||
assert (env / 'raw' / j['id'] / 'Game.iso.part.segments').exists()
|
||||
assert failed['once']
|
||||
requests.clear()
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert any(r.headers.get('range') == 'bytes=4-31' for r in requests)
|
||||
assert not (env / 'raw' / j['id'] / 'Game.iso.part.segments').exists()
|
||||
|
||||
|
||||
def test_server_ignores_segment_range_and_serial_fallback(env, monkeypatch):
|
||||
data = bytes(range(128))
|
||||
def ignore(req):
|
||||
if req.headers.get('range') not in (None, 'bytes=0-0'):
|
||||
return httpx.Response(200, stream=BytesStream(data))
|
||||
return None
|
||||
|
||||
requests = server(monkeypatch, data, custom=ignore)
|
||||
j = job()
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert any('range' not in r.headers for r in requests)
|
||||
assert not (env / 'raw' / j['id'] / 'Game.iso.part.segments').exists()
|
||||
|
||||
|
||||
def test_http_503_one_segment_retries_without_losing_other_parts(env, monkeypatch):
|
||||
data = bytes(range(128))
|
||||
failed = {'once': False}
|
||||
def temporary(req):
|
||||
if req.headers.get('range') == 'bytes=32-63' and not failed['once']:
|
||||
failed['once'] = True
|
||||
return httpx.Response(503, headers={'Retry-After': '1'})
|
||||
return None
|
||||
|
||||
requests = server(monkeypatch, data, custom=temporary)
|
||||
j = job()
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert sum(req.headers.get('range') == 'bytes=32-63' for req in requests) == 2
|
||||
assert 'HTTP 503' in (env / 'logs' / (j['id'] + '.download.log')).read_text()
|
||||
|
||||
|
||||
def test_interrupted_assembly_is_rebuilt_safely(env, monkeypatch):
|
||||
data = bytes(range(128))
|
||||
server(monkeypatch, data)
|
||||
original = archive_download._assemble_segments
|
||||
failed = {'once': False}
|
||||
|
||||
def broken_assembly(jid, partial, destination, folder, manifest):
|
||||
if not failed['once']:
|
||||
failed['once'] = True
|
||||
manifest['assembling'] = True
|
||||
archive_download._write_manifest(folder, manifest)
|
||||
partial.write_bytes(b'partial-assembly')
|
||||
raise OSError('simulated crash')
|
||||
return original(jid, partial, destination, folder, manifest)
|
||||
|
||||
monkeypatch.setattr(archive_download, '_assemble_segments', broken_assembly)
|
||||
j = job()
|
||||
with pytest.raises(OSError, match='simulated crash'):
|
||||
archive_download._transfer_one(j)
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
|
||||
|
||||
def test_small_file_uses_single_transfer(env, monkeypatch):
|
||||
data = b'hello world'
|
||||
requests = server(monkeypatch, data)
|
||||
j = job()
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert len(requests) == 2
|
||||
assert requests[0].headers['range'] == 'bytes=0-0'
|
||||
assert 'range' not in requests[1].headers
|
||||
|
||||
|
||||
def test_archive_connections_can_be_disabled(env, monkeypatch):
|
||||
data = bytes(range(128))
|
||||
monkeypatch.setenv('ARCHIVE_CONNECTIONS', '1')
|
||||
requests = server(monkeypatch, data)
|
||||
j = job()
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert len(requests) == 1
|
||||
assert 'range' not in requests[0].headers
|
||||
|
||||
|
||||
def test_changed_etag_forces_clean_serial_restart(env, monkeypatch):
|
||||
data = bytes(range(128))
|
||||
def changed(req):
|
||||
if req.headers.get('range') not in (None, 'bytes=0-0'):
|
||||
value = req.headers['range'].split('=', 1)[1]
|
||||
lo, hi = [int(x) for x in value.split('-')]
|
||||
return httpx.Response(206, stream=BytesStream(data[lo:hi + 1]), headers={
|
||||
'Content-Range': f'bytes {lo}-{hi}/128', 'ETag': '"changed"'})
|
||||
return None
|
||||
|
||||
requests = server(monkeypatch, data, custom=changed)
|
||||
j = job()
|
||||
# This prefix must be discarded when the origin presents conflicting validators.
|
||||
raw = env / 'raw' / j['id']
|
||||
raw.mkdir(parents=True)
|
||||
(raw / 'Game.iso.part').write_bytes(b'old-data')
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
assert any('range' not in req.headers for req in requests)
|
||||
|
||||
|
||||
def test_archive_rejects_unexpected_compression(env, monkeypatch):
|
||||
def compressed(_request):
|
||||
return httpx.Response(206, headers={
|
||||
'Content-Encoding': 'gzip', 'Content-Range': 'bytes 0-0/128'})
|
||||
server(monkeypatch, bytes(range(128)), custom=compressed)
|
||||
j = job()
|
||||
with pytest.raises(archive_download.ArchiveDownloadError, match='Accept-Encoding'):
|
||||
archive_download._transfer_one(j)
|
||||
assert not final_path(env, j).exists()
|
||||
|
||||
|
||||
def test_invalid_connection_settings_clamped(env, monkeypatch):
|
||||
monkeypatch.setenv('ARCHIVE_CONNECTIONS', '100')
|
||||
assert archive_download._connection_limit() == 50
|
||||
monkeypatch.setenv('ARCHIVE_CONNECTIONS', '-1')
|
||||
assert archive_download._connection_limit() == 1
|
||||
monkeypatch.setenv('ARCHIVE_CONNECTIONS', 'invalid')
|
||||
assert archive_download._connection_limit() == 50
|
||||
|
||||
|
||||
def test_default_fifty_parallel_ranges_complete(env, monkeypatch):
|
||||
"""The new default really runs 50 transfers, not only displaying that value."""
|
||||
monkeypatch.delenv('ARCHIVE_CONNECTIONS', raising=False)
|
||||
assert archive_download._connection_limit() == 50
|
||||
data = bytes(range(256)) * 4 # Enough to split into 50 ranges in this test
|
||||
requests = server(monkeypatch, data)
|
||||
j = job()
|
||||
archive_download._transfer_one(j)
|
||||
assert final_path(env, j).read_bytes() == data
|
||||
spans = [request.headers['range'] for request in requests]
|
||||
assert spans[0] == 'bytes=0-0'
|
||||
assert len(spans) == 51 # Range probe plus 50 segments
|
||||
assert len(set(spans[1:])) == 50
|
||||
assert store.get(j['id'])['state'] == 'preparing'
|
||||
assert '50 połączeń HTTP Range' in (env / 'logs' / (j['id'] + '.download.log')).read_text()
|
||||
@@ -0,0 +1,124 @@
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import common, store, pipeline, download
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, 'DATA', tmp_path)
|
||||
monkeypatch.setattr(store, 'DB_PATH', tmp_path / 'queue.sqlite3')
|
||||
monkeypatch.setattr(pipeline, 'RAW', tmp_path / 'raw')
|
||||
monkeypatch.setattr(pipeline, 'READY', tmp_path / 'ready')
|
||||
monkeypatch.setattr(pipeline, 'WORK', tmp_path / 'work')
|
||||
monkeypatch.setattr(pipeline, 'LOGS', tmp_path / 'logs')
|
||||
store.init_db()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_uris():
|
||||
assert common.validate_url('https://archive.org/download/demo/game.iso')[0] == 'url'
|
||||
assert common.validate_url('https://vimm.net/vault/12345')[0] == 'vimm'
|
||||
assert common.validate_url('magnet:?xt=urn:btih:c98fbaa2a36ee48e3d9423be02cdbefd9281bc7d')[0] == 'magnet'
|
||||
for url in ('file:///etc/passwd', 'http://127.0.0.1/admin',
|
||||
'https://user:password@example.org', 'https://localhost/file',
|
||||
'ftp://example.org/file'):
|
||||
with pytest.raises(ValueError):
|
||||
common.validate_url(url)
|
||||
|
||||
|
||||
def test_archive_id_and_escape():
|
||||
assert common.parse_archive_id('https://archive.org/details/some-item') == 'some-item'
|
||||
assert common.archive_download_url('some-item', 'Game (1).7z').endswith('Game%20%281%29.7z')
|
||||
for name in ('../target.iso', '/etc/password', 'foo/../../abc', 'a\\b', ''):
|
||||
with pytest.raises(ValueError):
|
||||
common.archive_download_url('item', name)
|
||||
for name in ('../../secret', '/absolute/file', 'C:\\Windows\\System32\\x', 'folder/../x'):
|
||||
with pytest.raises(pipeline.ProcessingError):
|
||||
pipeline.safe_archive_path(name)
|
||||
|
||||
|
||||
def test_database_queue(isolated):
|
||||
for t in ('Test1', 'Test2'):
|
||||
store.create({'id': t, 'title': t, 'source_type': 'url',
|
||||
'source': 'https://example.org/test.zip', 'format': 'xex'})
|
||||
assert len(store.list_jobs()) == 2
|
||||
assert store.next_job()['id'] == 'Test1'
|
||||
store.update('Test1', state='done', progress=100)
|
||||
assert store.next_job()['id'] == 'Test2'
|
||||
|
||||
|
||||
def test_process_xex_zip(isolated):
|
||||
jid = str(uuid.uuid4())
|
||||
store.create({'id': jid, 'title': 'Demo Game', 'source_type': 'import',
|
||||
'source': 'game.zip', 'format': 'xex'})
|
||||
raw = isolated / 'raw' / jid
|
||||
raw.mkdir(parents=True)
|
||||
with zipfile.ZipFile(raw / 'game.zip', 'w') as archive:
|
||||
archive.writestr('folder/default.xex', b'This is a fake XEX for queue testing')
|
||||
archive.writestr('folder/data/readme.txt', b'hello')
|
||||
asyncio.run(pipeline.process_job(store.get(jid)))
|
||||
job = store.get(jid)
|
||||
assert job['state'] == 'done'
|
||||
assert (Path(job['output']) / 'Gra' / 'default.xex').exists()
|
||||
assert (Path(job['output']) / 'Gra' / 'data' / 'readme.txt').read_bytes() == b'hello'
|
||||
|
||||
|
||||
def test_zip_traversal_rejected(isolated):
|
||||
jid = 'traversal'
|
||||
store.create({'id': jid, 'title': 'Bad', 'source_type': 'import',
|
||||
'source': 'bad.zip', 'format': 'xex'})
|
||||
raw = isolated / 'raw' / jid
|
||||
raw.mkdir(parents=True)
|
||||
with zipfile.ZipFile(raw / 'bad.zip', 'w') as archive:
|
||||
archive.writestr('../../pwned', b'nope')
|
||||
with pytest.raises(pipeline.ProcessingError):
|
||||
asyncio.run(pipeline.process_job(store.get(jid)))
|
||||
assert not (isolated / 'pwned').exists()
|
||||
|
||||
|
||||
def test_raw_copy(isolated):
|
||||
jid = 'simple'
|
||||
store.create({'id': jid, 'title': 'Keep File', 'source_type': 'import',
|
||||
'source': 'anything.bin', 'format': 'raw'})
|
||||
raw = isolated / 'raw' / jid
|
||||
raw.mkdir(parents=True)
|
||||
(raw / 'anything.bin').write_bytes(b'abc')
|
||||
asyncio.run(pipeline.process_job(store.get(jid)))
|
||||
assert (Path(store.get(jid)['output']) / 'files' / 'anything.bin').read_bytes() == b'abc'
|
||||
|
||||
|
||||
def test_multipart_urls():
|
||||
job={'source_type':'archive_many', 'source':'["https://host/A.part1.rar", "https://host/A.part2.rar"]'}
|
||||
assert len(download.source_urls(job)) == 2
|
||||
assert download.target_filename('https://example.com/Gra%20XYZ.iso') == 'Gra XYZ.iso'
|
||||
|
||||
|
||||
def test_api_auth_and_add(isolated, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import main
|
||||
monkeypatch.setenv('WEB_USER', 'admin')
|
||||
monkeypatch.setenv('WEB_PASSWORD', 'secretsecret1234')
|
||||
async def idle_worker():
|
||||
await asyncio.sleep(10000)
|
||||
monkeypatch.setattr(main, 'worker', idle_worker)
|
||||
with TestClient(main.app) as client:
|
||||
assert client.get('/').status_code == 401
|
||||
assert client.get('/api/jobs').status_code == 401
|
||||
auth = ('admin', 'secretsecret1234')
|
||||
assert client.get('/', auth=auth).status_code == 200
|
||||
r = client.post('/api/jobs', auth=auth,
|
||||
json={'url': 'https://example.com/DEMO.iso','format': 'xex'})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()['title'] == 'DEMO.iso'
|
||||
assert client.get('/api/jobs', auth=auth).json()[0]['id'] == r.json()['id']
|
||||
assert client.post('/api/jobs', auth=auth,
|
||||
json={'url':'file:///etc/passwd','format':'xex'}).status_code == 400
|
||||
assert client.post('/api/jobs', auth=auth,
|
||||
json={'url':'https://example.org/foo.iso','format':'oops'}).status_code == 400
|
||||
@@ -0,0 +1,255 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import archive_auth, archive_download, bt, common, download, main, store, torrent_meta
|
||||
|
||||
|
||||
def bencode(item):
|
||||
if isinstance(item, int):
|
||||
return b'i' + str(item).encode() + b'e'
|
||||
if isinstance(item, str):
|
||||
item = item.encode()
|
||||
if isinstance(item, bytes):
|
||||
return str(len(item)).encode() + b':' + item
|
||||
if isinstance(item, (list, tuple)):
|
||||
return b'l' + b''.join(bencode(x) for x in item) + b'e'
|
||||
if isinstance(item, dict):
|
||||
return b'd' + b''.join(bencode(k) + bencode(v) for k, v in sorted(item.items())) + b'e'
|
||||
raise TypeError(item)
|
||||
|
||||
|
||||
def sample_torrent():
|
||||
return bencode({b'announce': b'https://tracker.example/announce', b'info': {
|
||||
b'name': b'MyGame', b'files': [
|
||||
{b'length': 100, b'path': [b'Game.iso']},
|
||||
{b'length': 40, b'path': [b'readme.txt']},
|
||||
{b'length': 12, b'path': [b'..padding', b'pad'], b'attr': b'p'},
|
||||
]}})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, 'DATA', tmp_path)
|
||||
monkeypatch.setattr(store, 'DB_PATH', tmp_path / 'queue.sqlite3')
|
||||
monkeypatch.setenv('WEB_USER', 'admin')
|
||||
monkeypatch.setenv('WEB_PASSWORD', 'mysecretpassword')
|
||||
monkeypatch.setenv('ARIA2_SECRET', 'secret-rpc-key')
|
||||
store.init_db()
|
||||
async def idle_worker():
|
||||
await asyncio.sleep(10000)
|
||||
monkeypatch.setattr(main, 'worker', idle_worker)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_torrent_metadata_parser_and_magnet_selection(tmp_path):
|
||||
file = tmp_path / 'test.torrent'
|
||||
file.write_bytes(sample_torrent())
|
||||
meta = torrent_meta.read_torrent(file)
|
||||
assert [row['name'] for row in meta['files']] == [
|
||||
'MyGame/Game.iso', 'MyGame/readme.txt', 'MyGame/..padding/pad']
|
||||
assert meta['files'][0]['index'] == 1
|
||||
assert meta['files'][2]['padding'] is True
|
||||
assert torrent_meta.suggested_indices('magnet:?xt=urn:btih:123&so=15,0-1', 20) == [1, 2, 16]
|
||||
|
||||
|
||||
def test_torrent_path_traversal_rejected(tmp_path):
|
||||
file = tmp_path / 'bad.torrent'
|
||||
file.write_bytes(bencode({b'info': {b'name': b'Game', b'files': [
|
||||
{b'length': 5, b'path': [b'..', b'secret']}]}}))
|
||||
with pytest.raises(torrent_meta.MetadataError):
|
||||
torrent_meta.read_torrent(file)
|
||||
with pytest.raises(torrent_meta.MetadataError):
|
||||
torrent_meta.decode_bencode(b'd3:fooi1eeTRAILING')
|
||||
|
||||
|
||||
def test_archive_session_secrets_and_logout(isolated):
|
||||
archive_auth._save_session('test@archive.org', {
|
||||
'logged-in-user': 'member%40archive.org', 'logged-in-sig': 'signedsession'})
|
||||
path = archive_auth.session_path()
|
||||
assert oct(path.stat().st_mode & 0o777) == '0o600'
|
||||
assert archive_auth.session_status() == {'authenticated': True, 'email': 'test@archive.org'}
|
||||
assert archive_auth.session_cookies().get('logged-in-sig') == 'signedsession'
|
||||
archive_auth.logout()
|
||||
assert archive_auth.session_status()['authenticated'] is False
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_archive_xauthn_login_no_password_saved(isolated, monkeypatch):
|
||||
original = httpx.AsyncClient
|
||||
def handler(request):
|
||||
assert request.method == 'POST'
|
||||
assert request.url.host == 'archive.org'
|
||||
assert b'password=not-saved' in request.content
|
||||
return httpx.Response(200, json={'success': True, 'values': {'cookies': {
|
||||
'logged-in-user': 'user%40example.org; Path=/',
|
||||
'logged-in-sig': 'sig%3Dabc; Path=/'}}})
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(archive_auth.httpx, 'AsyncClient', lambda **kw: original(**kw, transport=transport))
|
||||
status = asyncio.run(archive_auth.login('user@example.org', 'not-saved'))
|
||||
assert status['authenticated'] is True
|
||||
content = archive_auth.session_path().read_text()
|
||||
assert 'not-saved' not in content
|
||||
assert 'sig%3Dabc' in content
|
||||
|
||||
|
||||
def test_archive_relay_redirect_cookie_scoping_and_range(isolated, monkeypatch):
|
||||
archive_auth._save_session('user@example.org', {
|
||||
'logged-in-user': 'member', 'logged-in-sig': 'secret-cookie'})
|
||||
originals = httpx.AsyncClient
|
||||
seen = []
|
||||
def handler(request):
|
||||
seen.append((request.url.host, request.headers.get('cookie'), request.headers.get('range')))
|
||||
if request.url.host == 'archive.org':
|
||||
return httpx.Response(302, headers={'Location': 'https://ia808.us.archive.org/download/asset.zip'})
|
||||
class FakeStream(httpx.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
yield b'bcd'
|
||||
return httpx.Response(206, headers={'Content-Length': '3', 'Content-Range': 'bytes 1-3/4',
|
||||
'Content-Type': 'application/octet-stream'}, stream=FakeStream())
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(archive_auth.httpx, 'AsyncClient', lambda **kw: originals(**kw, transport=transport))
|
||||
|
||||
async def call():
|
||||
response = await archive_auth.stream_archive('https://archive.org/download/item/asset.zip', 'GET',
|
||||
{'range': 'bytes=1-3'})
|
||||
data = b''
|
||||
async for part in response.body_iterator:
|
||||
data += part
|
||||
return response, data
|
||||
response, data = asyncio.run(call())
|
||||
assert response.status_code == 206
|
||||
assert data == b'bcd'
|
||||
assert len(seen) == 2
|
||||
assert all('secret-cookie' in (item[1] or '') for item in seen)
|
||||
assert all(item[2] == 'bytes=1-3' for item in seen)
|
||||
assert archive_auth.allowed_redirect('https://attacker.example/') is False
|
||||
assert archive_auth.allowed_redirect('http://archive.org/') is False
|
||||
|
||||
|
||||
def test_magnet_inspection_selection_and_signed_archive_proxy(isolated, monkeypatch):
|
||||
started = []
|
||||
stopped = []
|
||||
async def fake_start(preview_id, magnet):
|
||||
started.append((preview_id, magnet))
|
||||
async def fake_stop(preview_id):
|
||||
stopped.append(preview_id)
|
||||
monkeypatch.setattr(bt, 'start_inspection', fake_start)
|
||||
monkeypatch.setattr(bt, 'stop_inspection', fake_stop)
|
||||
monkeypatch.setattr(bt, 'inspect_state', lambda pid: {'connections': 2, 'known_peers': 5})
|
||||
with TestClient(main.app) as client:
|
||||
auth = ('admin', 'mysecretpassword')
|
||||
magnet = 'magnet:?xt=urn:btih:c98fbaa2a36ee48e3d9423be02cdbefd9281bc7d&dn=Test&so=1'
|
||||
r = client.post('/api/magnet/inspect', auth=auth, json={'magnet': magnet})
|
||||
assert r.status_code == 200, r.text
|
||||
pid = r.json()['preview_id']
|
||||
status = client.get('/api/magnet/inspect/' + pid, auth=auth).json()
|
||||
assert status['status'] == 'loading'
|
||||
assert status['connections'] == 2 and status['known_peers'] == 5
|
||||
assert started == [(pid, magnet)]
|
||||
folder = torrent_meta.inspect_dir(pid)
|
||||
(folder / 'hash.torrent').write_bytes(sample_torrent())
|
||||
info = client.get('/api/magnet/inspect/' + pid, auth=auth).json()
|
||||
assert info['status'] == 'ready'
|
||||
assert info['suggested'] == [2]
|
||||
assert info['files'][1]['name'] == 'MyGame/readme.txt'
|
||||
assert client.post('/api/magnet/jobs', auth=auth,
|
||||
json={'preview_id': pid, 'files': [4], 'format': 'raw'}).status_code == 400
|
||||
assert client.post('/api/magnet/jobs', auth=auth,
|
||||
json={'preview_id': pid, 'files': [3], 'format': 'raw'}).status_code == 400
|
||||
r = client.post('/api/magnet/jobs', auth=auth,
|
||||
json={'preview_id': pid, 'files': [2], 'format': 'raw'})
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
assert job['source_type'] == 'torrent_selected'
|
||||
assert (isolated / 'torrents' / (job['id'] + '.torrent')).read_bytes() == sample_torrent()
|
||||
assert json.loads(job['source'])['selection'] == '2'
|
||||
assert pid in stopped
|
||||
|
||||
archive_job = main.new_job('archive_many', json.dumps(['https://archive.org/download/test/game.iso']), 'Demo', 'raw')
|
||||
uri = '/internal/archive/' + archive_job['id'] + '/0'
|
||||
assert client.get(uri).status_code == 403
|
||||
assert client.get(uri, headers={'X-X360-Proxy-Token': 'invalid'}).status_code == 403
|
||||
async def fake_stream(url, method, headers):
|
||||
assert url == 'https://archive.org/download/test/game.iso'
|
||||
return PlainTextResponse('proxy-ok')
|
||||
monkeypatch.setattr(archive_auth, 'stream_archive', fake_stream)
|
||||
token = archive_auth.signed_header(archive_job['id'], 0).split(': ', 1)[1]
|
||||
assert client.get(uri, headers={'X-X360-Proxy-Token': token}).text == 'proxy-ok'
|
||||
assert client.post('/api/jobs', auth=auth,
|
||||
json={'url': magnet, 'format': 'raw'}).status_code == 400
|
||||
|
||||
|
||||
def test_wrapped_and_html_escaped_magnet():
|
||||
hash_value = 'c98fbaa2a36ee48e3d9423be02cdbefd9281bc7d'
|
||||
pasted = "'**magnet**:?xt=urn:btih:" + hash_value + "&dn=Minerva_Myrient\\\n&so=15&tr=udp%3A%2F%2Ftracker.ope\\\nnbittorrent.com%3A6969%2Fannounce'"
|
||||
kind, cleaned = common.validate_url(pasted)
|
||||
assert kind == 'magnet'
|
||||
assert cleaned.startswith('magnet:?xt=urn:btih:' + hash_value + '&dn=')
|
||||
assert '&so=15&tr=' in cleaned
|
||||
assert 'tracker.openbittorrent.com' in cleaned
|
||||
assert '\\' not in cleaned and '&' not in cleaned and '\n' not in cleaned
|
||||
assert torrent_meta.suggested_indices(cleaned, 20) == [16]
|
||||
with pytest.raises(ValueError):
|
||||
common.validate_url('magnet:?xt=urn:btih:abcdef')
|
||||
|
||||
|
||||
def test_upload_torrent_and_queue_selection(isolated, monkeypatch):
|
||||
async def fake_rpc(method, *args):
|
||||
if method == 'addTorrent':
|
||||
return '123456'
|
||||
if method == 'tellStatus':
|
||||
return {'status': 'active', 'connections': '0'}
|
||||
return 'ok'
|
||||
monkeypatch.setattr(download, 'rpc', fake_rpc)
|
||||
with TestClient(main.app) as client:
|
||||
auth = ('admin', 'mysecretpassword')
|
||||
uploaded = client.post('/api/magnet/upload', auth=auth,
|
||||
content=sample_torrent(), headers={'Content-Type': 'application/x-bittorrent'})
|
||||
assert uploaded.status_code == 200, uploaded.text
|
||||
pid = uploaded.json()['preview_id']
|
||||
info = client.get('/api/magnet/inspect/' + pid, auth=auth).json()
|
||||
assert info['status'] == 'ready'
|
||||
assert info['files'][0]['name'] == 'MyGame/Game.iso'
|
||||
assert info['suggested'] == []
|
||||
job = client.post('/api/magnet/jobs', auth=auth,
|
||||
json={'preview_id': pid, 'files': [1], 'format': 'xex'})
|
||||
assert job.status_code == 200, job.text
|
||||
assert (isolated / 'torrents' / (job.json()['id'] + '.torrent')).exists()
|
||||
assert client.post('/api/magnet/upload', auth=auth,
|
||||
content=b'garbage', headers={'Content-Type': 'application/x-bittorrent'}).status_code == 400
|
||||
assert client.post('/api/magnet/upload', auth=auth,
|
||||
content=sample_torrent(), headers={'Content-Type': 'application/json'}).status_code == 415
|
||||
|
||||
|
||||
def test_metadata_status_includes_connections_and_timeout(isolated, monkeypatch):
|
||||
stopped = []
|
||||
async def fake_start(preview_id, magnet):
|
||||
pass
|
||||
async def fake_stop(preview_id):
|
||||
stopped.append(preview_id)
|
||||
monkeypatch.setattr(bt, 'start_inspection', fake_start)
|
||||
monkeypatch.setattr(bt, 'stop_inspection', fake_stop)
|
||||
monkeypatch.setattr(bt, 'inspect_state', lambda pid: {'connections': 0, 'known_peers': 0})
|
||||
magnet='magnet:?xt=urn:btih:c98fbaa2a36ee48e3d9423be02cdbefd9281bc7d&so=15'
|
||||
with TestClient(main.app) as client:
|
||||
auth=('admin','mysecretpassword')
|
||||
resp=client.post('/api/magnet/inspect',auth=auth,json={'magnet':magnet})
|
||||
assert resp.status_code == 200
|
||||
pid=resp.json()['preview_id']
|
||||
status=client.get('/api/magnet/inspect/'+pid,auth=auth).json()
|
||||
assert status['status']=='loading' and status['connections']==0
|
||||
request_path=torrent_meta.inspect_dir(pid)/'request.json'
|
||||
state=json.loads(request_path.read_text())
|
||||
state['created'] -= 310
|
||||
request_path.write_text(json.dumps(state))
|
||||
expired=client.get('/api/magnet/inspect/'+pid,auth=auth).json()
|
||||
assert expired['status']=='error'
|
||||
assert pid in stopped
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Regression checks for host-side /xbox storage and safe upgrade migration."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import main
|
||||
|
||||
PROJECT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _copy_scripts(dst):
|
||||
for name in ('xbox-paths.sh', 'migrate-to-xbox.sh', 'setup.sh', '.env.example'):
|
||||
shutil.copy2(PROJECT / name, dst / name)
|
||||
|
||||
|
||||
def _run_script(project, name, root, **extra):
|
||||
env = os.environ.copy()
|
||||
env.update(XBOX_HOST_DIR=str(root), XBOX_MIGRATION_OFFLINE='1', XBOX_SKIP_DOCKER='1', **extra)
|
||||
return subprocess.run(['sh', name], cwd=project, env=env,
|
||||
capture_output=True, text=True, timeout=30)
|
||||
|
||||
|
||||
def test_config_uses_host_and_dedicated_downloads():
|
||||
compose = (PROJECT / 'compose.yaml').read_text()
|
||||
assert 'source: ${XBOX_HOST_DIR:-/xbox}' in compose
|
||||
assert 'source: ${XBOX_HOST_DIR:-/xbox}/downloads' in compose
|
||||
assert 'target: /data/raw' in compose
|
||||
assert 'target: /data' in compose
|
||||
assert 'create_host_path: false' in compose
|
||||
assert 'TMPDIR: /data/work/tmp' in compose
|
||||
assert './data:/data' not in compose
|
||||
assert 'XBOX_HOST_DIR=/xbox' in (PROJECT / '.env.example').read_text()
|
||||
assert '--dir=/data/raw' in (PROJECT / 'entrypoint.sh').read_text()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which('rsync'), reason='rsync not installed on test host')
|
||||
def test_migrate_preserves_original_data_and_queue(tmp_path):
|
||||
project = tmp_path / 'old project'
|
||||
project.mkdir()
|
||||
_copy_scripts(project)
|
||||
host = tmp_path / 'host volume'
|
||||
host.mkdir()
|
||||
data = project / 'data'
|
||||
(data / 'raw' / 'task-1').mkdir(parents=True)
|
||||
(data / 'raw' / 'task-1' / 'demo.iso.part').write_bytes(b'12345')
|
||||
(data / 'work' / 'task-1').mkdir(parents=True)
|
||||
(data / 'work' / 'task-1' / 'unpacked.iso').write_bytes(b'unpacked')
|
||||
(data / 'ready' / 'xex' / 'demo').mkdir(parents=True)
|
||||
(data / 'ready' / 'xex' / 'demo' / 'default.xex').write_bytes(b'xex')
|
||||
(data / 'private').mkdir()
|
||||
(data / 'private' / 'archive-session.json').write_text('{"session": "example"}')
|
||||
(data / 'queue.sqlite3').write_bytes(b'queue')
|
||||
(data / 'queue.sqlite3-wal').write_bytes(b'wal')
|
||||
(data / 'aria2').mkdir()
|
||||
(data / 'aria2' / 'session.txt').write_text('/data/raw/task-1/demo.iso')
|
||||
|
||||
result = _run_script(project, 'migrate-to-xbox.sh', host)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (host / 'downloads' / 'task-1' / 'demo.iso.part').read_bytes() == b'12345'
|
||||
assert (host / 'work' / 'task-1' / 'unpacked.iso').read_bytes() == b'unpacked'
|
||||
assert (host / 'ready' / 'xex' / 'demo' / 'default.xex').read_bytes() == b'xex'
|
||||
assert (host / 'queue.sqlite3').read_bytes() == b'queue'
|
||||
assert (host / 'queue.sqlite3-wal').read_bytes() == b'wal'
|
||||
assert (host / 'private' / 'archive-session.json').is_file()
|
||||
assert (host / 'aria2' / 'session.txt').read_text() == '/data/raw/task-1/demo.iso'
|
||||
assert not (host / 'raw').exists()
|
||||
assert not (host / '.x360-migration-in-progress').exists()
|
||||
assert (data / 'raw' / 'task-1' / 'demo.iso.part').read_bytes() == b'12345'
|
||||
assert (data / 'queue.sqlite3').read_bytes() == b'queue'
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which('rsync'), reason='rsync not installed on test host')
|
||||
def test_migration_does_not_replace_existing_queue(tmp_path):
|
||||
project = tmp_path / 'project'
|
||||
project.mkdir()
|
||||
_copy_scripts(project)
|
||||
host = tmp_path / 'xbox'
|
||||
host.mkdir()
|
||||
(project / 'data').mkdir()
|
||||
(project / 'data' / 'queue.sqlite3').write_bytes(b'old')
|
||||
(host / 'queue.sqlite3').write_bytes(b'new')
|
||||
result = _run_script(project, 'migrate-to-xbox.sh', host)
|
||||
assert result.returncode != 0
|
||||
assert (host / 'queue.sqlite3').read_bytes() == b'new'
|
||||
assert not (host / '.x360-migration-in-progress').exists()
|
||||
|
||||
|
||||
def test_setup_does_not_silently_drop_existing_database(tmp_path):
|
||||
project = tmp_path / 'project'
|
||||
project.mkdir()
|
||||
_copy_scripts(project)
|
||||
host = tmp_path / 'xbox'
|
||||
host.mkdir()
|
||||
(project / 'data').mkdir()
|
||||
(project / 'data' / 'queue.sqlite3').write_bytes(b'important')
|
||||
result = _run_script(project, 'setup.sh', host)
|
||||
assert result.returncode != 0
|
||||
assert 'migrate-to-xbox.sh' in result.stderr
|
||||
assert not (project / '.env').exists()
|
||||
|
||||
|
||||
def test_setup_persists_custom_host_path(tmp_path):
|
||||
project = tmp_path / 'project'
|
||||
project.mkdir()
|
||||
_copy_scripts(project)
|
||||
host = tmp_path / 'external-host-share'
|
||||
host.mkdir()
|
||||
result = _run_script(project, 'setup.sh', host)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert f'XBOX_HOST_DIR={host}' in (project / '.env').read_text()
|
||||
assert (host / 'downloads').is_dir()
|
||||
assert (host / 'work' / 'tmp').is_dir()
|
||||
assert (host / 'ready').is_dir()
|
||||
|
||||
|
||||
def test_missing_host_directory_fails_without_creating(tmp_path):
|
||||
project = tmp_path / 'project'
|
||||
project.mkdir()
|
||||
_copy_scripts(project)
|
||||
host = tmp_path / 'not-mounted'
|
||||
result = _run_script(project, 'setup.sh', host)
|
||||
assert result.returncode != 0
|
||||
assert not host.exists()
|
||||
assert not (project / '.env').exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_shows_actual_host_path(tmp_path, monkeypatch):
|
||||
from app import store
|
||||
monkeypatch.setattr(store, 'DATA', tmp_path)
|
||||
(tmp_path / 'raw').mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv('XBOX_HOST_DIR', '/xbox')
|
||||
result = await main.system()
|
||||
assert result['host_data_dir'] == '/xbox'
|
||||
assert result['total'] > 0
|
||||
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app import archive_auth, archive_download, bt, main, store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(store, 'DATA', tmp_path)
|
||||
monkeypatch.setattr(store, 'DB_PATH', tmp_path / 'queue.sqlite3')
|
||||
monkeypatch.setenv('ARIA2_SECRET', 'test')
|
||||
monkeypatch.setenv('ARCHIVE_CONNECTIONS', '1') # Old single-stream behavior is covered separately.
|
||||
store.init_db()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def create_archive(url='https://archive.org/download/item/Game.iso'):
|
||||
return main.new_job('url', url, 'Game', 'raw')
|
||||
|
||||
|
||||
def fake_http_client(monkeypatch, handler):
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(archive_download, '_client', lambda: httpx.Client(
|
||||
transport=transport, cookies=archive_auth.session_cookies(), follow_redirects=False))
|
||||
monkeypatch.setattr(archive_download.time, 'sleep', lambda delay: None)
|
||||
|
||||
|
||||
class BytesStream(httpx.SyncByteStream):
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def __iter__(self):
|
||||
yield self.data
|
||||
|
||||
|
||||
def test_archive_transient_http_500_is_retried_and_logged(isolated, monkeypatch):
|
||||
archive_auth._save_session('user@example.org', {
|
||||
'logged-in-user': 'user', 'logged-in-sig': 'cookie-secret'})
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
if len(requests) < 3:
|
||||
return httpx.Response(500, text='upstream temporarily broken')
|
||||
return httpx.Response(200, stream=BytesStream(b'ISO-CONTENT'), headers={'Content-Length': '11'})
|
||||
|
||||
fake_http_client(monkeypatch, handler)
|
||||
job = create_archive()
|
||||
archive_download._transfer_one(job)
|
||||
assert store.get(job['id'])['state'] == 'preparing'
|
||||
assert (isolated / 'raw' / job['id'] / 'Game.iso').read_bytes() == b'ISO-CONTENT'
|
||||
assert len(requests) == 3
|
||||
assert all('cookie-secret' in req.headers.get('cookie', '') for req in requests)
|
||||
text = (isolated / 'logs' / (job['id'] + '.download.log')).read_text()
|
||||
assert 'HTTP 500' in text and 'Ukończono' in text
|
||||
assert 'cookie-secret' not in text
|
||||
|
||||
|
||||
def test_archive_range_resume_with_redirect(isolated, monkeypatch):
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append((request.url.host, request.headers.get('range')))
|
||||
if request.url.host == 'archive.org':
|
||||
return httpx.Response(302, headers={'Location': 'https://ia444.us.archive.org/download/item/Game.iso'})
|
||||
return httpx.Response(206, stream=BytesStream(b'DEF'), headers={'Content-Range': 'bytes 3-5/6',
|
||||
'Content-Length': '3'})
|
||||
|
||||
fake_http_client(monkeypatch, handler)
|
||||
job = create_archive()
|
||||
raw = isolated / 'raw' / job['id']
|
||||
raw.mkdir(parents=True)
|
||||
(raw / 'Game.iso.part').write_bytes(b'ABC')
|
||||
archive_download._transfer_one(job)
|
||||
assert (raw / 'Game.iso').read_bytes() == b'ABCDEF'
|
||||
assert not (raw / 'Game.iso.part').exists()
|
||||
assert requests == [('archive.org', 'bytes=3-'), ('ia444.us.archive.org', 'bytes=3-')]
|
||||
|
||||
|
||||
def test_archive_403_is_explicit_and_not_retried(isolated, monkeypatch):
|
||||
seen = []
|
||||
def handler(request):
|
||||
seen.append(1)
|
||||
return httpx.Response(403, text='denied')
|
||||
fake_http_client(monkeypatch, handler)
|
||||
job = create_archive()
|
||||
with pytest.raises(archive_download.ArchiveDownloadError, match='HTTP 403'):
|
||||
archive_download._transfer_one(job)
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_archive_persistent_500_reports_origin_failure(isolated, monkeypatch):
|
||||
seen = []
|
||||
def handler(request):
|
||||
seen.append(1)
|
||||
return httpx.Response(500)
|
||||
fake_http_client(monkeypatch, handler)
|
||||
job = create_archive()
|
||||
with pytest.raises(archive_download.ArchiveDownloadError, match='HTTP 500 po 5 próbach'):
|
||||
archive_download._transfer_one(job)
|
||||
assert len(seen) == 5
|
||||
|
||||
|
||||
def test_archive_cancel_keeps_partial_files(isolated, monkeypatch):
|
||||
job = create_archive()
|
||||
store.update(job['id'], state='paused')
|
||||
fake_http_client(monkeypatch, lambda req: pytest.fail('Do not download paused job'))
|
||||
with pytest.raises(archive_download.ArchiveStopped):
|
||||
archive_download._transfer_one(job)
|
||||
|
||||
|
||||
def test_bt_helper_subprocess_inspection_and_fetch(isolated, monkeypatch):
|
||||
"""Exercise process supervisor and status semantics without real peers."""
|
||||
from tests.test_features import sample_torrent
|
||||
|
||||
helper = isolated / 'fake-helper'
|
||||
# Tests the Python supervisor lifecycle, not the compiled Go / network engine.
|
||||
helper.write_text('#!' + sys.executable + '\n' + '''
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
args = sys.argv[1:]
|
||||
mode = args[0]
|
||||
def param(x): return args[args.index(x)+1]
|
||||
state = Path(param('-status'))
|
||||
state.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mode == 'inspect':
|
||||
Path(param('-out')).write_bytes(bytes.fromhex(os.environ['FAKE_TORRENT_HEX']))
|
||||
state.write_text(json.dumps({'status':'ready','connections':2}))
|
||||
else:
|
||||
output=Path(param('-out'))
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output/'Game.iso').write_bytes(b'foo')
|
||||
state.write_text(json.dumps({'status':'done','downloaded':3,'total':3}))
|
||||
''')
|
||||
helper.chmod(0o755)
|
||||
monkeypatch.setenv('FAKE_TORRENT_HEX', sample_torrent().hex())
|
||||
monkeypatch.setenv('BT_HELPER', str(helper))
|
||||
monkeypatch.setattr(bt, '_DOWNLOADS', {})
|
||||
monkeypatch.setattr(bt, '_INSPECTIONS', {})
|
||||
|
||||
async def check():
|
||||
pid = 'a' * 32
|
||||
folder = isolated / 'metadata' / pid
|
||||
folder.mkdir(parents=True)
|
||||
proc = await bt.start_inspection(pid, 'magnet:?xt=urn:btih:' + 'a'*40)
|
||||
assert await proc.wait() == 0
|
||||
assert bt.inspect_state(pid)['status'] == 'ready'
|
||||
await bt.stop_inspection(pid)
|
||||
job = main.new_job('torrent_selected', json.dumps({'selection':'1'}), 'Game', 'raw')
|
||||
(isolated/'torrents').mkdir()
|
||||
(isolated/'torrents'/(job['id']+'.torrent')).write_bytes(sample_torrent())
|
||||
await bt.start_download(job)
|
||||
proc = bt._DOWNLOADS[job['id']]
|
||||
assert await proc.wait() == 0
|
||||
await bt.poll_download(store.get(job['id']))
|
||||
assert store.get(job['id'])['state'] == 'preparing'
|
||||
await bt.shutdown()
|
||||
asyncio.run(check())
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Browser test: pasted magnet cleanup, live diagnostics and .torrent upload."""
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
root = Path(__file__).resolve().parents[1] / 'app' / 'static'
|
||||
html = (root / 'index.html').read_text().replace('<link rel="stylesheet" href="/static/style.css">', '').replace('<script src="/static/app.js" defer></script>', '')
|
||||
css = (root / 'style.css').read_text()
|
||||
js = (root / 'app.js').read_text()
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path='/usr/bin/chromium', args=['--no-sandbox', '--disable-dev-shm-usage'])
|
||||
page = browser.new_page(viewport={'width': 1280, 'height': 900})
|
||||
errors = []
|
||||
page.on('pageerror', lambda err: errors.append(str(err)))
|
||||
page.set_content(html)
|
||||
page.add_style_tag(content=css)
|
||||
page.evaluate('''() => {
|
||||
window.__seenMagnet = null;
|
||||
window.fetch = async (url, options = {}) => {
|
||||
const u = String(url), m = options.method || 'GET';
|
||||
let reply = {};
|
||||
if (u === '/api/system') reply = {free:1000000000,total:2000000000};
|
||||
else if (u === '/api/jobs' && m === 'GET') reply = [];
|
||||
else if (u === '/api/archive/session') reply = {authenticated:false};
|
||||
else if (u === '/api/magnet/inspect' && m === 'POST') {
|
||||
window.__seenMagnet = JSON.parse(options.body).magnet;
|
||||
reply = {status:'loading',preview_id:'a'.repeat(32)};
|
||||
}
|
||||
else if (u === '/api/magnet/upload') reply = {status:'ready',preview_id:'b'.repeat(32)};
|
||||
else if (u.includes('/magnet/inspect/' + 'a'.repeat(32)) && m === 'GET')
|
||||
reply = {status:'loading',elapsed:35,connections:0,aria_status:'active'};
|
||||
else if (u.includes('/magnet/inspect/' + 'b'.repeat(32)) && m === 'GET')
|
||||
reply = {status:'ready',name:'Demo',suggested:[],files:[{index:1,name:'Demo/Game.iso',size:1024,padding:false}]};
|
||||
else if (u.startsWith('/api/magnet/inspect/') && m === 'POST') reply = {ok:true};
|
||||
return new Response(JSON.stringify(reply), {status:200,headers:{'Content-Type':'application/json'}});
|
||||
};
|
||||
}''')
|
||||
page.add_script_tag(content=js)
|
||||
raw = "'**magnet**:?xt=urn:btih:c98fbaa2a36ee48e3d9423be02cdbefd9281bc7d&so=15\\\n&tr=udp%3A%2F%2Fexample.com%2Fannounce'"
|
||||
page.locator('#urls').fill(raw)
|
||||
page.locator('#linksForm button[type=submit]').click()
|
||||
page.locator('#magnetDialog').wait_for(state='visible')
|
||||
page.locator('#magnetStatus').get_by_text('Brak połączeń', exact=False).wait_for()
|
||||
parsed = page.evaluate('window.__seenMagnet')
|
||||
assert parsed.startswith('magnet:?xt=urn:btih:c98fbaa2a36ee48e3d9423be02cdbefd9281bc7d&so=15&tr='), parsed
|
||||
assert '\n' not in parsed and '&' not in parsed
|
||||
page.screenshot(path='/mnt/data/x360-magnet-diagnostics-preview.png', full_page=True)
|
||||
page.locator('#torrentFileDialog').set_input_files({'name':'demo.torrent','mimeType':'application/x-bittorrent','buffer':b'd4:infod4:name4:Demoe e'})
|
||||
page.locator('#magnetResults').wait_for(state='visible')
|
||||
assert page.locator('#magnetFiles input[type=checkbox]').count() == 1
|
||||
page.screenshot(path='/mnt/data/x360-magnet-upload-preview.png', full_page=True)
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
print('UI magnet fallback PASS')
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Browser UI smoke test with mocked fetch; Chromium policies block navigation."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / 'app' / 'static'
|
||||
html = (ROOT / 'index.html').read_text()
|
||||
html = html.replace('<link rel="stylesheet" href="/static/style.css">', '')
|
||||
html = html.replace('<script src="/static/app.js" defer></script>', '')
|
||||
css = (ROOT / 'style.css').read_text()
|
||||
js = (ROOT / 'app.js').read_text()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(executable_path='/usr/bin/chromium',
|
||||
args=['--no-sandbox', '--disable-dev-shm-usage'])
|
||||
page = browser.new_page(viewport={'width': 1250, 'height': 950})
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.set_content(html)
|
||||
page.add_style_tag(content=css)
|
||||
page.evaluate(r'''() => {
|
||||
window.__submitted = [];
|
||||
window.fetch = async (url, opts = {}) => {
|
||||
const u = String(url);
|
||||
const m = opts.method || 'GET';
|
||||
let value = {ok:true};
|
||||
if (u === '/api/system') value = {free:100000000000, total:200000000000};
|
||||
else if (u === '/api/jobs' && m === 'GET') value = [];
|
||||
else if (u === '/api/archive/session' && m === 'GET') value = {authenticated:window.__loggedIn === true,email:window.__loggedIn ? 'demo@example.org' : ''};
|
||||
else if (u === '/api/archive/session' && m === 'POST') {window.__loggedIn=true;value={authenticated:true,email:'demo@example.org'};}
|
||||
else if (u === '/api/magnet/inspect' && m === 'POST') value = {preview_id:'a'.repeat(32),status:'loading'};
|
||||
else if (u.startsWith('/api/magnet/inspect/') && m === 'GET') value = {status:'ready',name:'Sample',suggested:[2],files:[
|
||||
{index:1,name:'Sample/Game.iso',size:7210000000,padding:false},
|
||||
{index:2,name:'Sample/Manual.pdf',size:15313,padding:false},
|
||||
{index:3,name:'Sample/.pad',size:700,padding:true}
|
||||
]};
|
||||
else if (u === '/api/magnet/jobs' && m === 'POST') {window.__submitted.push(JSON.parse(opts.body));value={ok:true};}
|
||||
return new Response(JSON.stringify(value), {status:200,headers:{'Content-Type':'application/json'}});
|
||||
};
|
||||
}''')
|
||||
page.add_script_tag(content=js)
|
||||
page.locator('button[data-tab="archive"]').click()
|
||||
assert page.locator('#archiveEmail').is_visible()
|
||||
page.locator('#archiveEmail').fill('demo@example.org')
|
||||
page.locator('#archivePassword').fill('dummy-password')
|
||||
page.locator('#archiveLoginForm button[type=submit]').click()
|
||||
page.locator('#archiveLogout').wait_for(state='visible')
|
||||
page.screenshot(path='/mnt/data/x360-archive-auth-preview.png', full_page=True)
|
||||
page.locator('button[data-tab="links"]').click()
|
||||
page.locator('#urls').fill('magnet:?xt=urn:btih:c98fbaa2a36ee48e3d9423be02cdbefd9281bc7d&so=1')
|
||||
page.locator('#linksForm [type=submit]').click()
|
||||
page.locator('#magnetResults').wait_for(state='visible')
|
||||
assert page.locator('#magnetFiles input[type="checkbox"]').count() == 3
|
||||
assert page.locator('#magnetFiles input[type="checkbox"]').nth(1).is_checked()
|
||||
page.screenshot(path='/mnt/data/x360-magnet-select-preview.png', full_page=True)
|
||||
page.locator('#magnetFiles input[type="checkbox"]').nth(0).check()
|
||||
page.locator('#magnetAdd').click()
|
||||
page.locator('#magnetDialog').wait_for(state='hidden')
|
||||
assert page.evaluate('window.__submitted') == [
|
||||
{'preview_id':'a'*32,'files':[2,1],'title':'','format':'xex'}]
|
||||
assert errors == [], errors
|
||||
browser.close()
|
||||
print('UI smoke PASS: Archive login form, torrent modal, file selection, submit')
|
||||
Reference in new issue
Block a user