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 与文档
This commit is contained in:
1 parent
39565a3913
commit
0df18f048b
19 files changed
+534
-241
No files matched your search
@@ -0,0 +1,19 @@
|
||||
"""测试隔离:所有用例使用独立 DATA_DIR,绝不读写真实的 data/auth.json。
|
||||
|
||||
必须在任何 app 模块导入前设置;config.data_dir() 是运行时读取环境变量的。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="music-server-tests-")
|
||||
os.environ.pop("AUTH_KEY", None)
|
||||
os.environ.pop("RATE_LIMIT", None)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""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"
|
||||
Reference in new issue
Block a user