- core:配置(prod 拒绝默认密钥 / dev 短信 / 假模型)、租户事务 + 行级安全策略、 problem+json 错误码、信封加密与签名下载、对象存储、事务内入队、Valkey stream 事件流、权限点 - identity:手机号 HMAC 存储、注册、登录(挤下线需确认,API3)、服务端会话 - billing:额度流水只追加,预扣 / 结算 / 退回,一笔预扣只有一个结局 - document:分片上传、按文件头识别格式、Gotenberg 转换、文字层与稳定句子编号 - checklist:标注规则识别、模型抽取、三道校验、标注类条款规则兜底、重跑对齐 - ai:提示词注册表(extract.substantive v1 来自 S4 v2)、假模型、OpenAI 兼容供应商、ai_call 流水 - 迁移:data-model 基线、套餐、Procrastinate、会话状态函数 - 测试 30 个:租户隔离、额度、失败退回、对象加密、契约一致性 - Compose(含 migrate)、镜像、CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
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"]
|