f640889f22
- 仪表盘视图:超管=平台总览四指标卡(/api/platform/stats 合并视图)+
租户卡片墙(逐租户 ?tenant= 下钻,含停用租户);租户管理员=自己
指标卡(/tenants/{slug}/stats,slug 取登录态);口径文案照
stats.py docstring(线索=留资三选一/成功率分母0显—等)
- 时间范围快捷切换(今天/近7天/近30天)→ from/to 本地日期串;
utils/statsRange.ts 纯函数 + epoch 防快速切换过期响应竞写
- 空数据态:无租户 ElEmpty / 单租户零数据自然显 0 与 —
- api/stats.ts 补 fetchPlatformStats/fetchTenantStats;tenants.ts 补
fetchTenantList(卡片墙枚举,其余仍留 Task 6)
- vitest +11(statsRange 6 + stats API 5,总 36)
- 顺手1:test_auth must_change 409 测试补 detail 断言,锁死前后端
契约文案(client.ts MUST_CHANGE_DETAIL)
- 顺手2:删除 beforeEach.ts 的 isUnauthorizedError 死分支与 ApiStatus
import(模板 isHttpError 判自建 ApiError 恒 false,分支不可达)
Co-Authored-By: Claude Code <noreply@anthropic.com>
199 lines
7.8 KiB
Python
199 lines
7.8 KiB
Python
"""平台认证(Phase 2 Task 2):login/logout/change-password + 认证依赖。
|
|
|
|
经 app_client(jiancai Host)打真实路由——Task 3 前 /api/platform/* 仍过
|
|
租户 Host 解析,jiancai 是已知域名故直达(计划注:本任务即挂路由,TDD
|
|
循环才能闭环)。conftest 的 lifespan bootstrap 超管 admin/admin123
|
|
(must_change=True)每测试全新临时平台库(Task 1 就绪)。
|
|
|
|
改密后 token 语义(本实现选定,见 auth_routes.change_password docstring):
|
|
当前 token 不失效、must_change 就地清零——改完密无需重登。
|
|
|
|
跨测试隔离:conftest autouse _reset_platform_auth 复位内存 token 表与
|
|
登录限流计数(照抄 rate_limit 纪律)。
|
|
"""
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.platform.auth import (
|
|
UserContext,
|
|
login_limiter,
|
|
require_super,
|
|
require_tenant,
|
|
)
|
|
|
|
LOGIN = "/api/platform/auth/login"
|
|
LOGOUT = "/api/platform/auth/logout"
|
|
CHANGE_PW = "/api/platform/auth/change-password"
|
|
|
|
|
|
def _login(client, username="admin", password="admin123"):
|
|
return client.post(LOGIN, json={"username": username, "password": password})
|
|
|
|
|
|
def _auth(token):
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
# ---- 登录 ---------------------------------------------------------------------
|
|
|
|
|
|
def test_login_ok_returns_token(app_client):
|
|
r = _login(app_client)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert set(data) == {"token", "role", "tenant_slug", "must_change_password"}
|
|
assert data["role"] == "super"
|
|
assert data["tenant_slug"] is None
|
|
assert data["must_change_password"] is True # 引导密码须首登改掉
|
|
assert data["token"]
|
|
|
|
|
|
def test_login_bad_password_401(app_client):
|
|
r = _login(app_client, password="wrong")
|
|
assert r.status_code == 401
|
|
# 防枚举: 用户不存在与密码错同一响应(状态+文案都不区分)
|
|
r2 = _login(app_client, username="nosuchuser")
|
|
assert r2.status_code == 401
|
|
assert r2.json() == r.json()
|
|
|
|
|
|
def test_login_lockout_after_5_fails(app_client):
|
|
for _ in range(5):
|
|
assert _login(app_client, password="wrong").status_code == 401
|
|
# 第 6 次 429——即使密码正确(锁定检查先于验证)
|
|
assert _login(app_client).status_code == 429
|
|
# 窗口恢复: 已记录失败拨到窗外(等效 15 分钟过后,免真实等待)→ 可再登录
|
|
key = ("admin", "testclient")
|
|
login_limiter._fails[key] = [
|
|
t - login_limiter.window_seconds - 1
|
|
for t in login_limiter._fails[key]]
|
|
assert _login(app_client).status_code == 200
|
|
|
|
|
|
# ---- token 生命周期 ------------------------------------------------------------
|
|
|
|
|
|
def test_token_expiry(app_client, monkeypatch):
|
|
"""TTL 可注入: 拨成负值 → 签发即过期 → 受保护端点 401。"""
|
|
from app.platform import auth
|
|
monkeypatch.setattr(auth.token_store, "ttl_seconds", -1)
|
|
r = _login(app_client)
|
|
assert r.status_code == 200
|
|
token = r.json()["token"]
|
|
r2 = app_client.post(
|
|
CHANGE_PW, headers=_auth(token),
|
|
json={"old_password": "admin123", "new_password": "n3w-pass!"})
|
|
assert r2.status_code == 401
|
|
|
|
|
|
def test_protected_requires_auth(app_client):
|
|
"""无 Authorization → 401(依赖链 get_current_user 先于 enforce)。"""
|
|
assert app_client.post(LOGOUT).status_code == 401
|
|
|
|
|
|
def test_logout_invalidates_token(app_client):
|
|
token = _login(app_client).json()["token"]
|
|
# must_change=True → logout 409,先改密(enforce 语义连锁,见下)
|
|
app_client.post(
|
|
CHANGE_PW, headers=_auth(token),
|
|
json={"old_password": "admin123", "new_password": "n3w-pass-2"})
|
|
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 200
|
|
# 同 token 再用 → 401(已撤销,立即失效)
|
|
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 401
|
|
|
|
|
|
# ---- 改密 + must_change 409 语义 -------------------------------------------------
|
|
|
|
|
|
def test_change_password_flow(app_client):
|
|
token = _login(app_client).json()["token"]
|
|
|
|
# 旧密改新密成功
|
|
r = app_client.post(
|
|
CHANGE_PW, headers=_auth(token),
|
|
json={"old_password": "admin123", "new_password": "n3w-pass-1"})
|
|
assert r.status_code == 200
|
|
|
|
# 选定语义: 改密后当前 token 不失效、must_change 就地清零
|
|
# (logout 受 enforce 保护,改密后可 200 即证 token 存活且标志已清)
|
|
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 200
|
|
|
|
# 旧密失效
|
|
assert _login(app_client).status_code == 401
|
|
# 新密可登录,引导标志已清
|
|
data = _login(app_client, password="n3w-pass-1").json()
|
|
assert data["must_change_password"] is False
|
|
|
|
|
|
def test_must_change_password_409_semantics(app_client):
|
|
"""引导密码强制语义: must_change=True 时受保护端点(logout)409
|
|
(detail 为固定文案——前端 client.ts 的 MUST_CHANGE_DETAIL 依赖此
|
|
契约,改动文案即红);change-password 本身豁免(200);改完 409 消除。"""
|
|
token = _login(app_client).json()["token"]
|
|
r = app_client.post(LOGOUT, headers=_auth(token))
|
|
assert r.status_code == 409
|
|
assert r.json()["detail"] == "必须先修改初始密码"
|
|
r = app_client.post(
|
|
CHANGE_PW, headers=_auth(token),
|
|
json={"old_password": "admin123", "new_password": "n3w-pass-3"})
|
|
assert r.status_code == 200
|
|
assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 200
|
|
|
|
|
|
# ---- 角色依赖(工厂直测;端点级覆盖在 Task 5)---------------------------------------
|
|
|
|
|
|
def test_tenant_admin_role_scoped():
|
|
tenant_admin = UserContext(
|
|
user_id=2, role="tenant", tenant_slug="jiancai",
|
|
must_change_password=False)
|
|
with pytest.raises(HTTPException) as ei:
|
|
require_super(tenant_admin)
|
|
assert ei.value.status_code == 403
|
|
|
|
dep = require_tenant("jiancai")
|
|
assert dep(tenant_admin) is tenant_admin # 本租户放行
|
|
super_user = UserContext(
|
|
user_id=1, role="super", tenant_slug=None, must_change_password=False)
|
|
assert dep(super_user) is super_user # super 全通
|
|
other = UserContext(
|
|
user_id=3, role="tenant", tenant_slug="auto",
|
|
must_change_password=False)
|
|
with pytest.raises(HTTPException) as ei2:
|
|
dep(other)
|
|
assert ei2.value.status_code == 403 # 它租户 403
|
|
|
|
|
|
def test_role_dependencies_chain_must_change_409(app_client):
|
|
"""审查修复 Issue 1: require_super/require_tenant 参数走 platform_user
|
|
(get_current_user + enforce 连锁)——must_change=True 的请求先撞 409 门,
|
|
Task 5 路由挂角色依赖时不可能静默漏挂。
|
|
|
|
连锁只在 FastAPI 依赖解析时生效(直调函数体只跑角色判断),故用
|
|
一次性探针 app 挂真实 DI 验证;token 经主 app 的真实 login/change-password
|
|
路由取放(同一进程级 TokenStore)。端点级覆盖仍在 Task 5。"""
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
probe = FastAPI()
|
|
|
|
@probe.post("/probe-super")
|
|
async def _super(user=Depends(require_super)):
|
|
return {"user_id": user.user_id}
|
|
|
|
@probe.post("/probe-tenant")
|
|
async def _tenant(user=Depends(require_tenant("jiancai"))):
|
|
return {"user_id": user.user_id}
|
|
|
|
token = _login(app_client).json()["token"] # 超管, must_change=True
|
|
with TestClient(probe) as c:
|
|
# 409 门先于角色判断
|
|
assert c.post("/probe-super", headers=_auth(token)).status_code == 409
|
|
assert c.post("/probe-tenant", headers=_auth(token)).status_code == 409
|
|
# 改密(标志就地清零)后放行到角色层: super 两个依赖都过
|
|
app_client.post(
|
|
CHANGE_PW, headers=_auth(token),
|
|
json={"old_password": "admin123", "new_password": "n3w-pass-9"})
|
|
assert c.post("/probe-super", headers=_auth(token)).status_code == 200
|
|
assert c.post("/probe-tenant", headers=_auth(token)).status_code == 200
|