first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-15 14:15:10 +02:00
commit 58cfd95600
35 files changed
+5357

No files matched your search

+228
View File
@@ -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']
+255
View File
@@ -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()
+124
View File
@@ -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
+255
View File
@@ -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 + "&amp;dn=Minerva_Myrient\\\n&amp;so=15&amp;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 '&amp;' 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
+138
View File
@@ -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
+164
View File
@@ -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())
+54
View File
@@ -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&amp;so=15\\\n&amp;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 '&amp;' 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')
+63
View File
@@ -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')