Files
MusicServer/tests/test_http.py
T
snowy-yang 0df18f048b fix(server): 修复路由遮蔽、流式背压与鉴权限流顺序
- 专辑搜索静态路由优先注册,避免被 /album/{source}/{id}/{page} 捕获
- mg 流改为异步背压队列,慢客户端不再 QueueFull 悬挂,错误中断响应
- 限流移到鉴权外层,401 爆破计入限流
- 酷我 token 重试移出锁,修复 asyncio.Lock 递归死锁
- 补 CORS、5sing 音质、parse _kind、wy 首次登录 cookie、stream 签名溢出
- mg 流直连、lifespan 关闭 aiohttp 会话、cookie 登录路由与 auth_store 健壮性
- 测试改用 aiohttp + 进程内 uvicorn,移除 httpx/TestClient
- 更新 start.bat 与文档
2026-10-23 23:40:55 +08:00

258 lines
9.8 KiB
Python

"""HTTP 层回归测试:CORS、限流顺序、路由遮蔽、流式管道与登录存储。
测试通过 tests/conftest.py 的临时 DATA_DIR 隔离,不会写真实 data/auth.json。
HTTP 请求用 aiohttp 打到同一个进程内的 uvicorn,避免依赖 Starlette TestClient/httpx。
"""
from __future__ import annotations
import asyncio
import importlib
import socket
import threading
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import quote
import aiohttp
import app.routes.music as music
import app.sdk.apis.kw as kw
import app.sdk.auth.wy as wy
import app.sdk.auth_store as auth_store
import pytest
import uvicorn
from app.main import create_app
from app.sdk import SOURCE_MODULES
def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
class _LiveServer:
"""在后台线程运行真实 uvicorn,测试用 aiohttp 访问。"""
def __init__(self, app: Any) -> None:
self.port = _free_port()
config = uvicorn.Config(
app,
host="127.0.0.1",
port=self.port,
log_level="error",
access_log=False,
lifespan="on",
)
self.server = uvicorn.Server(config)
self.thread = threading.Thread(target=self.server.run, daemon=True)
def start(self) -> None:
self.thread.start()
deadline = time.time() + 10
while not self.server.started:
if not self.thread.is_alive() or time.time() > deadline:
raise RuntimeError("测试服务器启动失败")
time.sleep(0.01)
def stop(self) -> None:
self.server.should_exit = True
self.thread.join(timeout=5)
@asynccontextmanager
async def live_client(app: Any) -> AsyncIterator[tuple[aiohttp.ClientSession, str]]:
server = _LiveServer(app)
await asyncio.to_thread(server.start)
try:
async with aiohttp.ClientSession() as session:
yield session, f"http://127.0.0.1:{server.port}"
finally:
await asyncio.to_thread(server.stop)
async def test_cors_headers_and_preflight() -> None:
async with live_client(create_app()) as (session, base):
async with session.get(f"{base}/health", headers={"Origin": "http://example.com"}) as response:
assert response.status == 200
assert response.headers["access-control-allow-origin"] == "*"
async with session.options(
f"{base}/health",
headers={"Origin": "http://example.com", "Access-Control-Request-Method": "GET"},
) as response:
assert response.status == 200
assert response.headers["access-control-allow-origin"] == "*"
async def test_rate_limit_counts_unauthorized_requests(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AUTH_KEY", "secret")
monkeypatch.setenv("RATE_LIMIT", "2")
statuses: list[int] = []
async with live_client(create_app()) as (session, base):
for _ in range(3):
async with session.get(f"{base}/health", headers={"X-Server-Key": "wrong"}) as response:
statuses.append(response.status)
# 前两次进入鉴权返回 401,第三次在鉴权之前被限流拦截。
assert statuses == [401, 401, 429]
async def test_album_search_is_not_shadowed(monkeypatch: pytest.MonkeyPatch) -> None:
kg = SOURCE_MODULES["kg"]
async def fake_search(keyword: str, page: int = 1, limit: int = 20) -> dict:
return {"list": [{"name": keyword}], "page": page, "limit": limit, "total": 1}
monkeypatch.setattr(kg.album, "search_album", fake_search)
async with live_client(create_app()) as (session, base):
async with session.get(f"{base}/album/kg/search/{quote('魔杰座')}") as response:
assert response.status == 200
payload = await response.json()
assert payload["data"]["list"][0]["name"] == "魔杰座"
async def test_parse_kind_is_not_a_query_parameter(monkeypatch: pytest.MonkeyPatch) -> None:
seen: dict[str, str] = {}
async def fake_parse(kind: str, source: str, link: str) -> dict:
seen.update(kind=kind, source=source, link=link)
return {"kind": kind}
async with live_client(create_app()) as (session, base):
async with session.get(f"{base}/openapi.json") as response:
spec = await response.json()
params = spec["paths"]["/parse/song/{source}"]["post"].get("parameters", [])
assert "_kind" not in {item["name"] for item in params}
monkeypatch.setattr(music, "_parse_handler", fake_parse)
async with session.post(f"{base}/parse/song/kw?_kind=playlist", json={"link": "x"}) as response:
assert response.status == 200
assert seen == {"kind": "song", "source": "kw", "link": "x"}
async def test_5sing_quality_from_sources_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
fivesing = SOURCE_MODULES["5sing"]
async def fake_url(song_info: dict, quality: str, retry_num: int = 0) -> dict:
return {"type": "sq", "url": "https://example.com/a.mp3"}
monkeypatch.setattr(fivesing, "get_music_url", fake_url)
async with live_client(create_app()) as (session, base):
async with session.get(f"{base}/url/5sing/932557%7Cyc", params={"quality": "sq"}) as response:
assert response.status == 200
payload = await response.json()
assert payload["data"] == {"type": "sq", "url": "https://example.com/a.mp3"}
async def test_find_rejects_unknown_source() -> None:
async with live_client(create_app()) as (session, base):
async with session.get(f"{base}/find/nope/{quote('稻香')}") as response:
assert response.status == 404
async def test_stream_sign_rejects_infinite_exp(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("AUTH_KEY", "secret")
async with live_client(create_app()) as (session, base):
async with session.get(f"{base}/stream/mg/x", params={"exp": "inf", "sig": "x"}) as response:
assert response.status == 401
async def test_stream_slow_consumer_does_not_hang(monkeypatch: pytest.MonkeyPatch) -> None:
class DummyDecoder:
@staticmethod
def feed(chunk: bytes) -> bytes:
return chunk
async def fake_stream(url: str, feed, on_chunk) -> None:
for _ in range(100):
await on_chunk(b"x" * 1024)
async def consume(response, delay: float) -> int:
count = 0
async for _ in response.body_iterator:
count += 1
await asyncio.sleep(delay)
return count
monkeypatch.setattr(music, "stream_decrypted", fake_stream)
response = music._stream_response("http://example.com/a.mp3", DummyDecoder()) # type: ignore[arg-type]
assert await asyncio.wait_for(consume(response, 0.001), timeout=5) == 100
async def test_stream_error_is_propagated(monkeypatch: pytest.MonkeyPatch) -> None:
class DummyDecoder:
@staticmethod
def feed(chunk: bytes) -> bytes:
return chunk
async def failing_stream(url: str, feed, on_chunk) -> None:
await on_chunk(b"x")
raise RuntimeError("upstream boom")
async def consume(response) -> None:
async for _ in response.body_iterator:
pass
monkeypatch.setattr(music, "stream_decrypted", failing_stream)
response = music._stream_response("http://example.com/a.mp3", DummyDecoder()) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="upstream boom"):
await asyncio.wait_for(consume(response), timeout=5)
async def test_kw_token_retry_does_not_deadlock(monkeypatch: pytest.MonkeyPatch) -> None:
async def boom() -> list[str]:
raise RuntimeError("network down")
monkeypatch.setattr(kw, "fetch_home_set_cookie", boom)
monkeypatch.setitem(kw._state, "token", None)
monkeypatch.setitem(kw._state, "lock", None)
with pytest.raises(RuntimeError, match="获取酷我token失败"):
await asyncio.wait_for(kw.get_token(), timeout=2)
def test_auth_store_rejects_non_dict_json(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
monkeypatch.setenv("DATA_DIR", str(tmp_path))
importlib.reload(auth_store)
(tmp_path / "auth.json").write_text("[]", encoding="utf-8")
assert auth_store.get_credentials("wy") is None
assert any(path.name.startswith("auth.json.corrupt-") for path in tmp_path.iterdir())
async def test_cookie_login_bind_and_logout() -> None:
async with live_client(create_app()) as (session, base):
async with session.post(f"{base}/login/bd", json={"cookie": "BDUSS=test"}) as response:
assert response.status == 200
async with session.get(f"{base}/login/status") as response:
payload = await response.json()
assert payload["data"]["bd"]["logged"] is True
async with session.delete(f"{base}/login/bd") as response:
assert response.status == 200
async with session.get(f"{base}/login/status") as response:
payload = await response.json()
assert payload["data"]["bd"]["logged"] is False
async with session.post(f"{base}/login/nope", json={"cookie": "x"}) as response:
assert response.status == 404
async def test_wy_login_profile_uses_new_cookie(monkeypatch: pytest.MonkeyPatch) -> None:
seen: dict[str, str | None] = {}
async def fake_fetch(cookie: str | None = None) -> dict:
seen["cookie"] = cookie
return {"userid": 1, "nickname": "n", "avatar": None, "vipType": None}
monkeypatch.setattr(wy, "_fetch_profile", fake_fetch)
monkeypatch.setattr(wy, "set_credentials", lambda *args, **kwargs: None)
profile = await wy._apply_login(["MUSIC_U=abc; Path=/"])
assert seen["cookie"] is not None and seen["cookie"].startswith("MUSIC_U=abc")
assert profile["nickname"] == "n"