Files
bid-doctor/server/tests/test_ops_console.py
YANG JIANKUAN dd8c04001d
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:06:06 +08:00

182 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""运营后台(FR-M6-09,m6-plan §2,决策 M6-2 / M6-4 / M6-7):独立进程、用户名 + 密码 + TOTP、IP 白名单;
跨企业只读走 bd_ops,对某个企业的操作走它自己的租户事务并写审计;不显示业务内容。"""
from __future__ import annotations
import base64
import itertools
from collections.abc import AsyncIterator
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from bid_doctor.core.crypto import totp
from bid_doctor.core.db import TenantContext, ops_tx, tenant_tx
from tests.conftest import User, project_with_tender, register, run_parse
from tests.test_billing_flow import INVOICE, entries
_names = itertools.count(1)
async def ops_client(ip: str = "127.0.0.1") -> httpx.AsyncClient:
from bid_doctor.ops.app import create_ops_app
transport = httpx.ASGITransport(app=create_ops_app(), client=(ip, 51000))
return httpx.AsyncClient(transport=transport, base_url="http://ops/ops/v1")
async def new_admin() -> tuple[str, bytes]:
from bid_doctor.ops.auth import create_admin
username = f"ops{next(_names)}"
uri = await create_admin(username, "运营小王", "correct horse battery")
b32 = parse_qs(urlsplit(uri).query)["secret"][0]
return username, base64.b32decode(b32 + "=" * (-len(b32) % 8))
@pytest.fixture
async def ops() -> AsyncIterator[httpx.AsyncClient]:
username, secret = await new_admin()
async with await ops_client() as c:
r = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
assert r.status_code == 200, r.text
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
yield c
async def test_login_needs_password_totp_and_allowed_ip() -> None:
username, secret = await new_admin()
async with await ops_client() as c:
bad = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": "000000"}
)
assert bad.status_code == 401 and bad.json()["code"] == "ops_login_failed"
assert (await c.get("/me")).status_code == 401
async with await ops_client("10.1.2.3") as far:
denied = await far.post("/auth/login", json={"username": username, "password": "x", "totp": totp(secret)})
assert denied.status_code == 403 and denied.json()["code"] == "ops_ip_denied"
# 用户端的 bd_app 读不到运营账号表
async with tenant_tx(
TenantContext(tenant_id=__import__("uuid").uuid4(), user_id=__import__("uuid").UUID(int=0))
) as s:
with pytest.raises(ProgrammingError):
await s.execute(text("SELECT 1 FROM ops_admin"))
async def test_tenants_activation_and_adjust(ops: httpx.AsyncClient, app_client: httpx.AsyncClient) -> None:
u: User = await register(app_client, "运营测试甲公司")
project_id, doc_id = await project_with_tender(u)
assert (await run_parse(u, doc_id))["status"] == "succeeded"
found = (await ops.get("/tenants", params={"q": "运营测试甲"})).json()["items"]
assert [t["name"] for t in found] == ["运营测试甲公司"] and found[0]["balance"] == 2
assert (await ops.get("/tenants", params={"q": u.phone})).json()["items"][0]["id"] == u.tenant_id
detail = (await ops.get(f"/tenants/{u.tenant_id}")).json()
assert detail["projects"] == 1 and detail["jobs"]["parse_job"] == {"succeeded": 1}
assert "未命名项目" not in str(detail) and project_id not in str(detail) # 不显示项目名称和编号
# 开通:用户提交 → 运营标记到账 → 开通 → 登记发票;全程写审计(操作人是运营)
req = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
listed = (await ops.get("/activation-requests", params={"status": "submitted"})).json()["items"]
assert req["id"] in [x["id"] for x in listed]
assert (await ops.get("/tenants", params={"q": req["request_no"]})).json()["items"][0]["id"] == u.tenant_id
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 409
assert (await ops.post(f"/activation-requests/{req['id']}/paid")).status_code == 200
r = await ops.post(f"/activation-requests/{req['id']}/activate", json={})
assert r.status_code == 200 and r.json()["subscription"]["status"] == "active", r.text
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 200
assert (await u.get("/me")).json()["plan"]["code"] == "pro"
# 再申请一份,运营退回(要写原因),用户看得到原因
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
assert (await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": ""})).status_code == 422
assert (
await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": "抬头和转账单位不一致"})
).status_code == 200
mine = (await u.get("/activation-requests")).json()["items"]
assert mine[0]["status"] == "rejected" and mine[0]["ops_note"] == "抬头和转账单位不一致"
# 调额度:原因必填;不能扣成负数;流水里看得到
assert (
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": ""})
).status_code == 422
too_much = await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": -9999, "reason": "测试扣减"})
assert too_much.status_code == 422
assert (
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": "试用补偿"})
).status_code == 201
adj = (await entries(u))[0]
assert (adj["kind"], adj["amount"], adj["reason"]) == ("adjust", 20, "试用补偿")
events = (await ops.get("/audit-events", params={"tenant_id": u.tenant_id, "action": "ops."})).json()["items"]
actions = {e["action"] for e in events}
assert {
"ops.tenant.view",
"ops.activation.paid",
"ops.activation.activate",
"ops.activation.reject",
"ops.activation.invoice",
"ops.quota.adjust",
} <= actions
assert all(e["actor_kind"] == "ops" and e["actor_name"] == "运营小王" for e in events)
async def test_feedback_and_failed_jobs_without_content(ops: httpx.AsyncClient, user: User) -> None:
from tests.test_check_flow import checked_project
project_id, _, run = await checked_project(user)
finding = (await user.get(f"/check-runs/{run['id']}/findings")).json()["items"][0]
r = await user.post(
"/feedback",
json={
"project_id": project_id,
"object_type": "finding",
"object_id": finding["id"],
"kind": "false_positive",
"note": "这不是问题(误报)",
},
)
assert r.status_code == 201
items = (await ops.get("/feedback", params={"kind": "false_positive"})).json()["items"]
mine = next(x for x in items if x["note"] == "这不是问题(误报)")
assert mine["title"] == finding["title"] and mine["rule_set_version"] and mine["pages"]
assert "quote" not in str(mine) and "text" not in mine # 不带原文
async with ops_tx() as s: # bd_ops 只读:改不了业务表
with pytest.raises(ProgrammingError):
await s.execute(text("UPDATE project SET name = 'x'"))
assert isinstance((await ops.get("/failed-jobs")).json()["items"], list)
async def test_every_endpoint_checks_permission() -> None:
"""角色只留结构:现在只有管理员(全部权限);没定义的角色什么权限都没有,接口一律 403,/me 照常给出权限点。"""
username, secret = await new_admin()
async with ops_tx() as s:
await s.execute(text("UPDATE ops_admin SET role = 'sales' WHERE username = :u"), {"u": username})
async with await ops_client() as c:
r = await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
assert r.status_code == 200 and r.json()["permissions"] == [] and r.json()["role"] == "sales"
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
for path in ("/tenants", "/activation-requests", "/feedback", "/failed-jobs", "/audit-events"):
assert (await c.get(path)).status_code == 403, path
assert (await c.get("/summary")).json() == {"open_requests": 0, "failed_jobs_7d": 0, "feedback_7d": 0}
async with await ops_client() as c: # 管理员
username, secret = await new_admin()
me = (
await c.post(
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
)
).json()
assert (
me["role"] == "admin"
and me["role_label"] == "管理员"
and "audit.view" in me["permissions"]
and me["env"] == "test"
)