from __future__ import annotations import httpx from tests.conftest import User, register async def test_register_creates_tenant_plan_and_quota(user: User) -> None: me = (await user.get("/me")).json() assert me["tenant"]["name"] == "测试集成公司" assert me["role"] == "owner" and "project.create" in me["permissions"] assert me["plan"]["code"] == "free" assert me["user"]["phone_hint"] == f"{user.phone[:3]}****{user.phone[-4:]}" assert [(a["unit"], a["balance"]) for a in (await user.get("/quota")).json()["accounts"]] == [("parse", 3)] async def test_phone_is_not_stored_in_plaintext(user: User) -> None: from sqlalchemy import text from bid_doctor.core.db import anonymous_tx async with anonymous_tx() as s: rows = (await s.execute(text("SELECT external_user_id, contact_hint FROM login_identity"))).all() assert all(user.phone not in (ext, hint) for ext, hint in rows) async def test_login_unregistered_keeps_code_for_register(app_client: httpx.AsyncClient) -> None: phone = "13700000001" await app_client.post("/auth/sms-codes", json={"phone": phone, "purpose": "login"}) r = await app_client.post("/auth/login", json={"phone": phone, "sms_code": "000000", "client_kind": "web"}) assert r.status_code == 404 and r.json()["code"] == "phone_not_registered" r = await app_client.post( "/auth/register", json={ "phone": phone, "sms_code": "000000", "tenant_name": "乙公司", "display_name": "李工", "terms_version": "v1", "client_kind": "web", }, ) assert r.status_code == 201, r.text async def test_wrong_code_is_rejected(app_client: httpx.AsyncClient) -> None: phone = "13700000002" await app_client.post("/auth/sms-codes", json={"phone": phone, "purpose": "register"}) r = await app_client.post( "/auth/register", json={ "phone": phone, "sms_code": "123456", "tenant_name": "丙公司", "display_name": "王工", "terms_version": "v1", "client_kind": "web", }, ) assert r.status_code == 422 and r.json()["code"] == "sms_code_invalid" async def test_concurrent_login_needs_confirmation_then_kicks_old_device(app_client: httpx.AsyncClient) -> None: u = await register(app_client) from bid_doctor.core.valkey import valkey await valkey().flushdb() # 跳过 60 秒重发间隔 await app_client.post("/auth/sms-codes", json={"phone": u.phone, "purpose": "login"}) body = {"phone": u.phone, "sms_code": "000000", "client_kind": "desktop"} r = await app_client.post("/auth/login", json=body) assert r.status_code == 409 and r.json()["code"] == "concurrent_session" assert len(r.json()["sessions"]) == 1 r = await app_client.post("/auth/login", json={**body, "force": True}) assert r.status_code == 200 old = await u.get("/me") # 旧设备被挤下线(API3),而且知道是为什么 assert old.status_code == 401 assert old.json()["code"] == "session_revoked" assert old.json()["reason"] == "concurrent_login" new = {"Authorization": f"Bearer {r.json()['token']}"} assert (await app_client.get("/me", headers=new)).status_code == 200 async def test_web_cookie_requires_csrf_header_on_writes(app_client: httpx.AsyncClient) -> None: phone = "13700000003" await app_client.post("/auth/sms-codes", json={"phone": phone, "purpose": "register"}) r = await app_client.post( "/auth/register", json={ "phone": phone, "sms_code": "000000", "tenant_name": "丁公司", "display_name": "赵工", "terms_version": "v1", "client_kind": "web", }, ) assert "token" not in r.json() cookies = {"bd_session": r.cookies["bd_session"], "bd_csrf": r.cookies["bd_csrf"]} app_client.cookies.clear() # 共用的测试客户端不留 cookie,后面的用例要看到「未登录」 jar = httpx.Cookies(cookies) assert (await app_client.get("/me", cookies=jar)).status_code == 200 r = await app_client.post("/projects", json={}, cookies=jar) assert r.status_code == 403 and r.json()["code"] == "csrf_failed" r = await app_client.post("/projects", json={}, cookies=jar, headers={"X-CSRF-Token": cookies["bd_csrf"]}) assert r.status_code == 201 async def test_unauthenticated_and_problem_format(app_client: httpx.AsyncClient) -> None: r = await app_client.get("/me") assert r.status_code == 401 assert r.headers["content-type"].startswith("application/problem+json") body = r.json() assert body["code"] == "unauthenticated" and body["request_id"] == r.headers["x-request-id"]