mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-24 08:03:53 +08:00
feat: add FastAPI manager API compatibility baseline
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.crypto import bcrypt_hash, sm2_encrypt_c1c3c2
|
||||
from app.core.errors import AppError
|
||||
from app.core.redis import JavaRedisCodec
|
||||
from app.core.security import AuthUser
|
||||
from app.repositories.security import SecurityRepository
|
||||
from app.routers.security import security_router
|
||||
from app.schemas.security import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
RetrievePasswordRequest,
|
||||
SmsVerificationRequest,
|
||||
)
|
||||
from app.services.security import AliyunSmsSender, CaptchaService, SecurityService
|
||||
from tests.domain_support import FakeRedis, sqlite_session
|
||||
|
||||
SECURITY_SCHEMA = [
|
||||
"CREATE TABLE sys_params (param_code TEXT PRIMARY KEY, param_value TEXT)",
|
||||
"CREATE TABLE sys_user ("
|
||||
"id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT, super_admin INTEGER, status INTEGER, "
|
||||
"creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
|
||||
"CREATE TABLE sys_user_token ("
|
||||
"id INTEGER PRIMARY KEY, user_id INTEGER UNIQUE, token TEXT, expire_date DATETIME, "
|
||||
"update_date DATETIME, create_date DATETIME)",
|
||||
"CREATE TABLE sys_dict_type (id INTEGER PRIMARY KEY, dict_type TEXT)",
|
||||
"CREATE TABLE sys_dict_data ("
|
||||
"id INTEGER PRIMARY KEY, dict_type_id INTEGER, dict_label TEXT, dict_value TEXT, sort INTEGER)",
|
||||
]
|
||||
SM2_VECTOR = json.loads((Path(__file__).parent / "fixtures" / "sm2-c1c3c2-golden.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _request() -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/xiaozhi/user/login",
|
||||
"headers": [(b"user-agent", b"Manager-Web"), (b"x-forwarded-for", b"192.0.2.10")],
|
||||
"client": ("127.0.0.1", 52341),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sm2_login_reuses_token_and_password_change_expires_it() -> None:
|
||||
public_key, private_key = SM2_VECTOR["publicKey"], SM2_VECTOR["privateKey"]
|
||||
redis = FakeRedis()
|
||||
async with sqlite_session(SECURITY_SCHEMA) as session:
|
||||
await session.execute(
|
||||
text("INSERT INTO sys_params VALUES ('server.private_key', :private_key)"),
|
||||
{"private_key": private_key},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO sys_user "
|
||||
"(id, username, password, super_admin, status) VALUES (1, 'alice', :password, 1, 1)"
|
||||
),
|
||||
{"password": bcrypt_hash("StrongPass1", rounds=4)},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
captcha = CaptchaService(redis) # type: ignore[arg-type]
|
||||
service = SecurityService(
|
||||
SecurityRepository(session),
|
||||
redis=redis, # type: ignore[arg-type]
|
||||
captcha=captcha,
|
||||
)
|
||||
await redis.set("sys:captcha:first", JavaRedisCodec.encode("Ab12C"), ex=300)
|
||||
first = await service.login(
|
||||
LoginRequest(
|
||||
username="alice",
|
||||
password=sm2_encrypt_c1c3c2(public_key, "Ab12CStrongPass1"),
|
||||
captcha_id="first",
|
||||
),
|
||||
_request(),
|
||||
)
|
||||
assert len(first["token"]) == 32
|
||||
assert len(first["clientHash"]) == 32
|
||||
assert await redis.get("sys:captcha:first") is None
|
||||
|
||||
await redis.set("sys:captcha:second", JavaRedisCodec.encode("Z9x8Y"), ex=300)
|
||||
second = await service.login(
|
||||
LoginRequest(
|
||||
username="alice",
|
||||
password=sm2_encrypt_c1c3c2(public_key, "Z9x8YStrongPass1"),
|
||||
captcha_id="second",
|
||||
),
|
||||
_request(),
|
||||
)
|
||||
assert second["token"] == first["token"]
|
||||
|
||||
auth = AuthUser(1, "alice", 1, 1, first["token"], {})
|
||||
await service.change_password(
|
||||
auth,
|
||||
PasswordChangeRequest(password="StrongPass1", new_password="NewStrong2"), # noqa: S106
|
||||
)
|
||||
expiry = await session.scalar(text("SELECT expire_date FROM sys_user_token WHERE user_id = 1"))
|
||||
assert expiry is not None
|
||||
parsed_expiry = datetime.fromisoformat(expiry) if isinstance(expiry, str) else expiry
|
||||
assert parsed_expiry < datetime.now()
|
||||
|
||||
await redis.set("sys:captcha:bad", JavaRedisCodec.encode("right"), ex=300)
|
||||
with pytest.raises(AppError) as captured:
|
||||
await service.login(
|
||||
LoginRequest(
|
||||
username="alice",
|
||||
password=sm2_encrypt_c1c3c2(public_key, "wrongNewStrong2"),
|
||||
captcha_id="bad",
|
||||
),
|
||||
_request(),
|
||||
)
|
||||
assert captured.value.code == 10067
|
||||
assert await redis.get("sys:captcha:bad") is None
|
||||
|
||||
with pytest.raises(AppError) as missing_password:
|
||||
await service.login(LoginRequest(username="alice"), _request())
|
||||
assert missing_password.value.code == 10130
|
||||
|
||||
with pytest.raises(AppError) as manual_validation:
|
||||
await service.change_password(auth, PasswordChangeRequest(), "en-US")
|
||||
assert manual_validation.value.code == 500
|
||||
assert manual_validation.value.message == "The password cannot be empty"
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO sys_params VALUES "
|
||||
"('server.enable_mobile_register', 'true'), ('server.public_key', 'unused')"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
with pytest.raises(AppError) as retrieve_validation:
|
||||
await service.retrieve_password(RetrievePasswordRequest(), "de-DE")
|
||||
assert retrieve_validation.value.code == 500
|
||||
assert retrieve_validation.value.message == "Das Passwort darf nicht leer sein"
|
||||
|
||||
|
||||
class _RecordingSmsSender:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
async def send_verification_code(self, phone: str | None, code: str) -> None:
|
||||
assert phone is not None
|
||||
self.calls.append((phone, code))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sms_rate_limit_cache_ttl_and_aliyun_rpc_shape() -> None:
|
||||
redis = FakeRedis()
|
||||
sender = _RecordingSmsSender()
|
||||
async with sqlite_session(SECURITY_SCHEMA) as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO sys_params VALUES "
|
||||
"('server.enable_mobile_register', 'true'),"
|
||||
"('server.sms_max_send_count', '2'),"
|
||||
"('aliyun.sms.access_key_id', 'test-id'),"
|
||||
"('aliyun.sms.access_key_secret', 'test-secret'),"
|
||||
"('aliyun.sms.sign_name', 'test-sign'),"
|
||||
"('aliyun.sms.sms_code_template_code', 'SMS_123')"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await redis.set("sys:captcha:sms-flow", JavaRedisCodec.encode("A1b2C"), ex=300)
|
||||
service = SecurityService(
|
||||
SecurityRepository(session),
|
||||
redis=redis, # type: ignore[arg-type]
|
||||
captcha=CaptchaService(redis), # type: ignore[arg-type]
|
||||
sms_sender=sender,
|
||||
)
|
||||
with pytest.raises(AppError) as missing_sms_fields:
|
||||
await service.send_sms_verification(SmsVerificationRequest())
|
||||
assert missing_sms_fields.value.code == 10067
|
||||
|
||||
dto = SmsVerificationRequest(phone="+8613800138000", captcha="a1B2c", captcha_id="sms-flow")
|
||||
await service.send_sms_verification(dto)
|
||||
|
||||
assert len(sender.calls) == 1
|
||||
assert sender.calls[0][0] == "+8613800138000"
|
||||
assert sender.calls[0][1].isdigit() and len(sender.calls[0][1]) == 6
|
||||
cached_code = JavaRedisCodec.decode(await redis.get("sys:captcha:sms:Validate:Code:+8613800138000"))
|
||||
assert cached_code == sender.calls[0][1]
|
||||
assert 0 < await redis.ttl("sms:Validate:Code:+8613800138000:today_count") <= 86400
|
||||
|
||||
with pytest.raises(AppError) as captured:
|
||||
await service.send_sms_verification(dto)
|
||||
assert captured.value.code == 10060
|
||||
|
||||
recorded: dict[str, Any] = {}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
recorded["params"] = dict(urllib.parse.parse_qsl((await request.aread()).decode()))
|
||||
return httpx.Response(200, json={"Code": "OK"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
aliyun = AliyunSmsSender(
|
||||
SecurityRepository(session),
|
||||
redis=redis, # type: ignore[arg-type]
|
||||
client=client,
|
||||
)
|
||||
await aliyun.send_verification_code("+8613800138000", "123456")
|
||||
params = recorded["params"]
|
||||
assert params["Action"] == "SendSms"
|
||||
assert params["TemplateParam"] == '{"code":"123456"}'
|
||||
assert params["Signature"]
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE sys_params SET param_value = '' "
|
||||
"WHERE param_code IN ('aliyun.sms.access_key_id', 'aliyun.sms.access_key_secret')"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
blank_credentials = AliyunSmsSender(
|
||||
SecurityRepository(session),
|
||||
redis=FakeRedis(), # type: ignore[arg-type]
|
||||
)
|
||||
with pytest.raises(AppError) as connection_error:
|
||||
await blank_credentials.send_verification_code("+8613800138000", "123456")
|
||||
assert connection_error.value.code == 10056
|
||||
|
||||
|
||||
def test_security_router_exposes_all_login_controller_routes_and_ping() -> None:
|
||||
routes = {(next(iter(route.methods)), route.path) for route in security_router.routes}
|
||||
assert len(routes) == 9
|
||||
assert ("POST", "/user/login") in routes
|
||||
assert ("GET", "/user/captcha") in routes
|
||||
assert ("GET", "/api/ping") in routes
|
||||
Reference in New Issue
Block a user