Files
xiaozhi-esp32-server/main/manager-api-fastapi/tests/test_security_domain.py
T

240 lines
9.6 KiB
Python

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