"""账号功能:昵称、密码设置/修改、密码登录。 这些都是"账号被占"的高风险入口,所以断言不只看 200,还要看: - 密码一字节都不回给客户端(只回 has_password) - 没设过密码的人不会被密码登录误放进去 - 改完密码,旧密码立刻失效 - 连错太多次要先锁一会儿,别让登录接口变成离线爆破的放大镜 """ from __future__ import annotations from httpx import AsyncClient from .conftest import auth, login, unwrap async def _me(client: AsyncClient, user: dict) -> dict: return unwrap(await client.get("/api/v1/auth/me", headers=user["headers"])) async def test_me_reports_profile_without_leaking_password(client: AsyncClient) -> None: user = await login(client) data = await _me(client, user) assert data["user"]["has_password"] is False assert data["user"]["phone"].endswith(user["phone"][-4:]) assert "****" in data["user"]["phone"] # 只回「设没设」,不回密码本身:字段白名单比黑名单可靠 assert set(data["user"]) == { "id", "phone", "email", "email_verified", "nickname", "avatar_url", "has_password", "platform_admin", "created_at", } async def test_patch_nickname(client: AsyncClient) -> None: user = await login(client) data = unwrap( await client.patch( "/api/v1/auth/me", json={"nickname": " 老王 "}, headers=user["headers"] ) ) assert data["user"]["nickname"] == "老王" # 空昵称要被拦住,不能变成"无名氏" resp = await client.patch("/api/v1/auth/me", json={"nickname": " "}, headers=user["headers"]) assert resp.json()["code"] == 1002 async def test_set_password_then_login_with_it(client: AsyncClient) -> None: user = await login(client) unwrap( await client.post( "/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"], ) ) assert (await _me(client, user))["user"]["has_password"] is True # 验证码登录照旧可用,两条路并存 data = unwrap( await client.post( "/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}, ) ) assert data["tokens"]["access_token"] assert data["user"]["id"] == user["user_id"] async def test_password_login_rejects_wrong_password(client: AsyncClient) -> None: user = await login(client) unwrap( await client.post( "/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"] ) ) resp = await client.post( "/api/v1/auth/login/password", json={"phone": user["phone"], "password": "wrong-pass1"} ) assert resp.status_code == 400 assert resp.json()["code"] == 2013 async def test_password_login_on_account_without_password(client: AsyncClient) -> None: user = await login(client) resp = await client.post( "/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"} ) # 没设过密码 = 密码不对,不能因为「没设置」就报 2014 把信息漏出去 assert resp.json()["code"] == 2013 async def test_unknown_phone_looks_the_same_as_wrong_password(client: AsyncClient) -> None: user = await login(client) unwrap( await client.post( "/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"] ) ) resp = await client.post( "/api/v1/auth/login/password", json={"phone": "13511112222", "password": "dadao2026"} ) assert resp.json()["code"] == 2013 async def test_change_password_requires_old_one_and_invalidates_it(client: AsyncClient) -> None: user = await login(client) unwrap( await client.post( "/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"] ) ) # 不给旧密码 → 拒绝 resp = await client.post( "/api/v1/auth/password", json={"password": "dadao2027"}, headers=user["headers"] ) assert resp.json()["code"] == 2013 # 旧密码给错 → 拒绝 resp = await client.post( "/api/v1/auth/password", json={"password": "dadao2027", "old_password": "nope-1234"}, headers=user["headers"], ) assert resp.json()["code"] == 2013 unwrap( await client.post( "/api/v1/auth/password", json={"password": "dadao2027", "old_password": "dadao2026"}, headers=user["headers"], ) ) # 新密码能登,旧密码不能 unwrap( await client.post( "/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2027"} ) ) resp = await client.post( "/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"} ) assert resp.json()["code"] == 2013 async def test_weak_password_rejected(client: AsyncClient) -> None: user = await login(client) for weak in ["short1", "12345678", "abcdefgh"]: resp = await client.post( "/api/v1/auth/password", json={"password": weak}, headers=user["headers"] ) assert resp.json()["code"] == 2015, weak async def test_password_endpoints_require_login(client: AsyncClient) -> None: resp = await client.post("/api/v1/auth/password", json={"password": "dadao2026"}) assert resp.status_code == 401 resp = await client.patch("/api/v1/auth/me", json={"nickname": "x"}, headers=auth("nope")) assert resp.status_code == 401 async def test_repeated_failures_are_throttled(client: AsyncClient) -> None: user = await login(client) unwrap( await client.post( "/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"] ) ) for _ in range(10): await client.post( "/api/v1/auth/login/password", json={"phone": user["phone"], "password": "bad-pass1"} ) resp = await client.post( "/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"} ) # 次数用光之后,连正确密码也要先等一会儿——锁的是这段时间,不是这一次请求 assert resp.json()["code"] == 2016