Extract environment-backed settings into a dedicated module and switch the aiohttp app to a factory-based bootstrap. Move runtime dependencies into typed app state, tighten CORS defaults, reject sensitive inline yt-dlp options, and harden the container entrypoint validation and ownership flow. Update tests to cover settings validation, app factory behavior, CORS policy, and entrypoint safety checks.
286 lines
8.4 KiB
Python
286 lines
8.4 KiB
Python
"""HTTP handler and app factory tests for ``main``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
from yarl import URL
|
|
|
|
import main
|
|
|
|
|
|
def _make_settings(tmp_path: Path, **overrides):
|
|
app_root = tmp_path / "app_root"
|
|
browser = app_root / "ui" / "dist" / "metube" / "browser"
|
|
browser.mkdir(parents=True)
|
|
(browser / "index.html").write_text("<html><body></body></html>", encoding="utf-8")
|
|
|
|
download_dir = tmp_path / "downloads"
|
|
state_dir = tmp_path / "state"
|
|
temp_dir = tmp_path / "temp"
|
|
download_dir.mkdir()
|
|
state_dir.mkdir()
|
|
temp_dir.mkdir()
|
|
|
|
env = {k: str(v) for k, v in main.Config._DEFAULTS.items()}
|
|
env.update(
|
|
{
|
|
"DOWNLOAD_DIR": str(download_dir),
|
|
"AUDIO_DOWNLOAD_DIR": str(download_dir),
|
|
"STATE_DIR": str(state_dir),
|
|
"TEMP_DIR": str(temp_dir),
|
|
"YTDL_OPTIONS": "{}",
|
|
"YTDL_OPTIONS_FILE": "",
|
|
"LOGLEVEL": "INFO",
|
|
}
|
|
)
|
|
env.update({key: str(value) for key, value in overrides.items()})
|
|
return main.Config.from_env(env, app_root=app_root)
|
|
|
|
|
|
@pytest.fixture
|
|
def app(tmp_path):
|
|
return main.create_app(_make_settings(tmp_path))
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_dqueue(app):
|
|
dqueue = MagicMock()
|
|
dqueue.initialize = AsyncMock(return_value=None)
|
|
dqueue.add = AsyncMock(return_value={"status": "ok"})
|
|
dqueue.cancel = AsyncMock(return_value={"status": "ok"})
|
|
dqueue.clear = AsyncMock(return_value={"status": "ok"})
|
|
dqueue.start_pending = AsyncMock(return_value={"status": "ok"})
|
|
dqueue.cancel_add = MagicMock()
|
|
dqueue.queue = MagicMock()
|
|
dqueue.done = MagicMock()
|
|
dqueue.pending = MagicMock()
|
|
dqueue.queue.saved_items = MagicMock(return_value=[])
|
|
dqueue.done.saved_items = MagicMock(return_value=[])
|
|
dqueue.pending.saved_items = MagicMock(return_value=[])
|
|
dqueue.get = MagicMock(return_value=([], []))
|
|
app[main.DQUEUE_KEY] = dqueue
|
|
return dqueue
|
|
|
|
|
|
def _valid_video_add_body(**kwargs):
|
|
base = {
|
|
"url": "https://example.com/watch?v=1",
|
|
"download_type": "video",
|
|
"codec": "auto",
|
|
"format": "any",
|
|
"quality": "best",
|
|
}
|
|
base.update(kwargs)
|
|
return base
|
|
|
|
|
|
def _request(app, body: dict | None = None):
|
|
req = MagicMock(spec=web.Request)
|
|
req.app = app
|
|
req.headers = {}
|
|
req.cookies = {}
|
|
req.scheme = "http"
|
|
req.host = "localhost:8081"
|
|
req.url = URL("http://localhost:8081/")
|
|
if body is not None:
|
|
req.json = AsyncMock(return_value=body)
|
|
return req
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_ok(app, mock_dqueue):
|
|
req = _request(app, _valid_video_add_body())
|
|
resp = await main.add(req)
|
|
assert resp.status == 200
|
|
data = json.loads(resp.text)
|
|
assert data["status"] == "ok"
|
|
mock_dqueue.add.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_missing_url_returns_400(app, mock_dqueue):
|
|
req = _request(app, {"download_type": "video", "quality": "best", "format": "any"})
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.add(req)
|
|
mock_dqueue.add.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_invalid_download_type(app, mock_dqueue):
|
|
req = _request(app, _valid_video_add_body(download_type="invalid"))
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.add(req)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_invalid_video_quality(app, mock_dqueue):
|
|
req = _request(app, _valid_video_add_body(quality="9999"))
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.add(req)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_invalid_subtitle_language(app, mock_dqueue):
|
|
req = _request(
|
|
app,
|
|
{
|
|
"url": "https://example.com/v",
|
|
"download_type": "captions",
|
|
"codec": "auto",
|
|
"format": "srt",
|
|
"quality": "best",
|
|
"subtitle_language": "bad language!",
|
|
},
|
|
)
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.add(req)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_custom_name_prefix_path_traversal(app, mock_dqueue):
|
|
req = _request(app, _valid_video_add_body(custom_name_prefix="../evil"))
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.add(req)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_chapter_template_path_traversal(app, mock_dqueue):
|
|
req = _request(
|
|
app,
|
|
_valid_video_add_body(
|
|
split_by_chapters=True,
|
|
chapter_template="/etc/passwd%(title)s",
|
|
),
|
|
)
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.add(req)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_invalid_json_body(app, mock_dqueue):
|
|
req = _request(app)
|
|
req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0))
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.add(req)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_missing_ids(app, mock_dqueue):
|
|
req = _request(app, {"where": "queue"})
|
|
with pytest.raises(web.HTTPBadRequest):
|
|
await main.delete(req)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_queue_calls_cancel(app, mock_dqueue):
|
|
req = _request(app, {"where": "queue", "ids": ["http://x"]})
|
|
resp = await main.delete(req)
|
|
assert resp.status == 200
|
|
mock_dqueue.cancel.assert_awaited_once_with(["http://x"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_pending(app, mock_dqueue):
|
|
req = _request(app, {"ids": ["a"]})
|
|
resp = await main.start(req)
|
|
assert resp.status == 200
|
|
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_shape(app, mock_dqueue):
|
|
req = _request(app)
|
|
resp = await main.history(req)
|
|
assert resp.status == 200
|
|
data = json.loads(resp.text)
|
|
assert set(data.keys()) == {"done", "queue", "pending"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_version_json(app, mock_dqueue):
|
|
req = _request(app)
|
|
resp = await main.version(req)
|
|
assert resp.status == 200
|
|
body = json.loads(resp.text)
|
|
assert "yt-dlp" in body and "version" in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cookie_status(app, mock_dqueue):
|
|
req = _request(app)
|
|
resp = await main.cookie_status(req)
|
|
assert resp.status == 200
|
|
data = json.loads(resp.text)
|
|
assert data.get("status") == "ok"
|
|
assert "has_cookies" in data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_options_add_cors(app, mock_dqueue):
|
|
req = _request(app)
|
|
resp = await main.add_cors(req)
|
|
assert resp.status == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_cookies_missing_field(app, mock_dqueue):
|
|
req = _request(app)
|
|
reader = MagicMock()
|
|
field = MagicMock()
|
|
field.name = "wrongname"
|
|
reader.next = AsyncMock(side_effect=[field, None])
|
|
req.multipart = AsyncMock(return_value=reader)
|
|
resp = await main.upload_cookies(req)
|
|
assert resp.status == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_legacy_format_migrated(app, mock_dqueue):
|
|
req = _request(app, {"url": "https://example.com/v", "format": "m4a", "quality": "best"})
|
|
resp = await main.add(req)
|
|
assert resp.status == 200
|
|
call = mock_dqueue.add.await_args
|
|
assert call is not None
|
|
assert call.args[1] == "audio"
|
|
|
|
|
|
def test_create_app_registers_state(tmp_path):
|
|
app = main.create_app(_make_settings(tmp_path))
|
|
assert app[main.SETTINGS_KEY].URL_PREFIX == "/"
|
|
assert main.DQUEUE_KEY in app
|
|
assert main.SOCKETIO_KEY in app
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_prepare_allows_same_origin(app, mock_dqueue):
|
|
req = _request(app)
|
|
req.headers = {"Origin": "http://localhost:8081"}
|
|
response = web.Response()
|
|
await main.on_prepare(req, response)
|
|
assert response.headers["Access-Control-Allow-Origin"] == "http://localhost:8081"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_prepare_rejects_untrusted_origin(app, mock_dqueue):
|
|
req = _request(app)
|
|
req.headers = {"Origin": "https://evil.example"}
|
|
response = web.Response()
|
|
await main.on_prepare(req, response)
|
|
assert "Access-Control-Allow-Origin" not in response.headers
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_prepare_allows_trusted_origin(tmp_path):
|
|
app = main.create_app(
|
|
_make_settings(tmp_path, TRUSTED_ORIGINS="https://trusted.example")
|
|
)
|
|
req = _request(app)
|
|
req.headers = {"Origin": "https://trusted.example"}
|
|
response = web.Response()
|
|
await main.on_prepare(req, response)
|
|
assert response.headers["Access-Control-Allow-Origin"] == "https://trusted.example"
|