- 专辑搜索静态路由优先注册,避免被 /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 与文档
258 lines
9.8 KiB
Python
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"
|