feat(proxy): T-P1 鉴权+账本(原子预扣/热路径缓存/令牌桶/管理鉴权原语)
- ledger.py:students/keys CRUD + 日限额 check_and_count(跨日重置,注入时钟)+ 流水分页;计费两阶段拆分至 billing.py(BillingMixin,评审聚焦): try_hold 锁内读现状->日上限判定->余额原子防线->holding 流水(request_id 幂等)、 settle 按真实值回补(预扣-实际)差额、void 全额退款(上游失败) - auth.py:issue_key(sk-campus- 前缀,明文只返回一次,库存 sha256+前缀)、 authenticate 校验链(形态/注销/停用/日额/rpm,热路径 LRU TTL30s D-P10)、 RateLimiter(令牌桶 rpm + per-key 并发信号量,D-P9 单进程)、 verify_admin(hmac.compare_digest;未配置仅 loopback) - 测试 +17:签发/错key/注销/停用/403/rpm 429/日额 429/并发槽/admin 策略 + 预扣-结算-回补一致/双向差额/余额不足/日上限/幂等/跨日重置/void/并发10路不超扣 - 全量 339 passed
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""代理鉴权/限流测试(T-P1)。"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.proxy.auth import (
|
||||
KEY_PREFIX,
|
||||
RateLimiter,
|
||||
authenticate,
|
||||
issue_key,
|
||||
reset_auth_state,
|
||||
verify_admin,
|
||||
)
|
||||
from gateway.proxy.errors import (
|
||||
ProxyAuthError,
|
||||
QuotaError,
|
||||
SuspendedError,
|
||||
)
|
||||
from gateway.proxy.ledger import Ledger
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path):
|
||||
reset_auth_state()
|
||||
led = Ledger.init_db(tmp_path / "proxy.sqlite3")
|
||||
sid = led.upsert_student("张三", "py24", balance_yuan=10.0, daily_cap_yuan=5.0)
|
||||
yield {"ledger": led, "sid": sid}
|
||||
reset_auth_state()
|
||||
|
||||
|
||||
def test_issue_and_authenticate_roundtrip(env):
|
||||
"""签发 -> 鉴权通过 -> 上下文含学生信息。"""
|
||||
led = env["ledger"]
|
||||
k = issue_key(led, env["sid"], rpm_cap=10, day_cap_req=200)
|
||||
assert k["key"].startswith(KEY_PREFIX)
|
||||
ctx = authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS())
|
||||
assert ctx["key_id"] == k["key_id"]
|
||||
assert ctx["student_id"] == env["sid"]
|
||||
assert ctx["student_status"] == "active"
|
||||
|
||||
|
||||
def _AUTH_LIMITS():
|
||||
from gateway.proxy.auth import _AUTH_SINGLETON_LIMITS
|
||||
return _AUTH_SINGLETON_LIMITS
|
||||
|
||||
|
||||
def test_auth_bad_key_401(env):
|
||||
led = env["ledger"]
|
||||
with pytest.raises(ProxyAuthError):
|
||||
authenticate("Bearer sk-campus-wrong", led, _AUTH_LIMITS())
|
||||
with pytest.raises(ProxyAuthError):
|
||||
authenticate("wrong-scheme", led, _AUTH_LIMITS())
|
||||
with pytest.raises(ProxyAuthError):
|
||||
authenticate("", led, _AUTH_LIMITS())
|
||||
|
||||
|
||||
def test_auth_revoked_401(env):
|
||||
"""注销后鉴权 401(热缓存主动失效路径)。"""
|
||||
led = env["ledger"]
|
||||
k = issue_key(led, env["sid"])
|
||||
authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS()) # 热缓存
|
||||
assert led.revoke_key(k["key_id"]) is True
|
||||
# 注销后缓存内 ctx 仍会查库 revoked 字段?——当前实现缓存整行:
|
||||
# revoke 后 find_key 不再命中;这里强制走一次缓存过期模拟最坏 30s 窗口
|
||||
reset_auth_state()
|
||||
with pytest.raises(ProxyAuthError):
|
||||
authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS())
|
||||
|
||||
|
||||
def test_auth_suspended_403(env):
|
||||
led = env["ledger"]
|
||||
k = issue_key(led, env["sid"])
|
||||
led.set_status(env["sid"], "suspended")
|
||||
reset_auth_state()
|
||||
with pytest.raises(SuspendedError):
|
||||
authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS())
|
||||
|
||||
|
||||
def test_rpm_token_bucket_429(env):
|
||||
"""rpm 令牌桶:突发超过容量被拒(进程内)。"""
|
||||
led = env["ledger"]
|
||||
k = issue_key(led, env["sid"], rpm_cap=3, day_cap_req=1000)
|
||||
results = [authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS())
|
||||
for _ in range(3)]
|
||||
assert len(results) == 3
|
||||
with pytest.raises(QuotaError):
|
||||
authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS())
|
||||
|
||||
|
||||
def test_day_req_cap_429(env):
|
||||
"""日请求上限(持久计数,跨进程语义):第 cap+1 次 429。"""
|
||||
led = env["ledger"]
|
||||
k = issue_key(led, env["sid"], rpm_cap=1000, day_cap_req=5)
|
||||
for _ in range(5):
|
||||
authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS())
|
||||
with pytest.raises(QuotaError):
|
||||
authenticate(f"Bearer {k['key']}", led, _AUTH_LIMITS())
|
||||
|
||||
|
||||
def test_rate_limiter_concurrent_slots():
|
||||
"""并发槽信号量:acquire/release 配平。"""
|
||||
rl = RateLimiter(concurrent_per_key=2)
|
||||
assert rl.acquire_slot(1) is True
|
||||
assert rl.acquire_slot(1) is True
|
||||
assert rl.acquire_slot(1) is False
|
||||
rl.release_slot(1)
|
||||
assert rl.acquire_slot(1) is True
|
||||
rl.release_slot(1)
|
||||
rl.release_slot(1)
|
||||
|
||||
|
||||
def test_verify_admin_policy():
|
||||
"""配置 admin_key -> compare_digest;未配置 -> 仅 loopback。"""
|
||||
assert verify_admin("secret", "secret", "1.2.3.4") is True
|
||||
assert verify_admin("wrong", "secret", "127.0.0.1") is False
|
||||
assert verify_admin("", "", "127.0.0.1") is True
|
||||
assert verify_admin("", "", "8.8.8.8") is False
|
||||
@@ -0,0 +1,133 @@
|
||||
"""账本两阶段计费测试(T-P1):预扣-结算-回补一致 / 幂等 / 跨日重置 / 并发不超扣。"""
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.proxy.ledger import Ledger
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def led(tmp_path):
|
||||
return Ledger.init_db(tmp_path / "proxy.sqlite3")
|
||||
|
||||
|
||||
def _mk_student_key(led, balance_yuan=1.0, cap_yuan=5.0):
|
||||
sid = led.upsert_student("测试生", balance_yuan=balance_yuan, daily_cap_yuan=cap_yuan)
|
||||
kid = led.create_key(sid, f"hash-{sid}", f"prefix-{sid}")
|
||||
return sid, kid
|
||||
|
||||
|
||||
def test_hold_settle_refund_consistency(led):
|
||||
"""预扣-结算-回补全链条:余额/日消费/流水三处一致。"""
|
||||
sid, kid = _mk_student_key(led, balance_yuan=1.0) # 1000 毫元
|
||||
ts = 1788300000.0
|
||||
assert led.try_hold("r1", kid, sid, "m", "b", 400, ts) is True
|
||||
assert led.get_student(sid)["balance_milli"] == 600
|
||||
assert led.get_student(sid)["spent_today_milli"] == 400
|
||||
# 结算实际 300 -> 回补 100
|
||||
assert led.settle("r1", 300, in_miss_tok=100, out_tok=50,
|
||||
upstream_cost_milli=200) is True
|
||||
stu = led.get_student(sid)
|
||||
assert stu["balance_milli"] == 700
|
||||
assert stu["spent_today_milli"] == 300
|
||||
u = led.get_usage("r1")
|
||||
assert u["charged_milli"] == 300
|
||||
assert u["margin_milli"] == 100 # charged - upstream_cost
|
||||
assert u["status"] == "ok"
|
||||
|
||||
|
||||
def test_settle_underpay_and_overpay(led):
|
||||
"""结算高于预扣(多扣余额)/低于预扣(回补)两个方向。"""
|
||||
sid, kid = _mk_student_key(led, balance_yuan=1.0)
|
||||
ts = 1788300000.0
|
||||
led.try_hold("r1", kid, sid, "m", "b", 100, ts)
|
||||
led.settle("r1", 250) # 实际更贵 -> 余额再多扣 150
|
||||
assert led.get_student(sid)["balance_milli"] == 1000 - 250
|
||||
led.try_hold("r2", kid, sid, "m", "b", 100, ts)
|
||||
led.settle("r2", 40) # 实际更便宜 -> 回补 60
|
||||
assert led.get_student(sid)["balance_milli"] == 1000 - 250 - 40
|
||||
|
||||
|
||||
def test_hold_insufficient_balance_402(led):
|
||||
sid, kid = _mk_student_key(led, balance_yuan=0.01) # 10 毫元
|
||||
assert led.try_hold("r1", kid, sid, "m", "b", 11, ts=1788300000.0) is False
|
||||
u = led.get_usage("r1")
|
||||
assert u["status"] == "insufficient" and u["charged_milli"] == 0
|
||||
assert led.get_student(sid)["balance_milli"] == 10 # 未扣
|
||||
|
||||
|
||||
def test_daily_cap_blocks(led):
|
||||
"""日消费上限:累计超限拒(402 语义)。"""
|
||||
sid, kid = _mk_student_key(led, balance_yuan=10.0, cap_yuan=0.5) # 日上限 500
|
||||
ts = 1788300000.0
|
||||
assert led.try_hold("r1", kid, sid, "m", "b", 300, ts) is True
|
||||
assert led.try_hold("r2", kid, sid, "m", "b", 300, ts) is False # 600 > 500
|
||||
assert led.try_hold("r3", kid, sid, "m", "b", 200, ts) is True # 500 == 500 可
|
||||
# 结算回补后额度释放:spent = 500 - 200(回补) = 300
|
||||
led.settle("r1", 100)
|
||||
assert led.try_hold("r4", kid, sid, "m", "b", 300, ts) is False # 300+300=600 超限
|
||||
assert led.try_hold("r5", kid, sid, "m", "b", 200, ts) is True # 300+200=500 恰好
|
||||
|
||||
|
||||
def test_hold_idempotent_on_request_id(led):
|
||||
"""request_id 幂等:重复 hold 不重复扣。"""
|
||||
sid, kid = _mk_student_key(led, balance_yuan=1.0)
|
||||
ts = 1788300000.0
|
||||
assert led.try_hold("dup", kid, sid, "m", "b", 100, ts) is True
|
||||
assert led.try_hold("dup", kid, sid, "m", "b", 100, ts) is True
|
||||
assert led.get_student(sid)["balance_milli"] == 900
|
||||
|
||||
|
||||
def test_daily_reset_across_days(led):
|
||||
"""跨日重置:注入更晚时间戳,req_today/spent_today 归零。"""
|
||||
sid, kid = _mk_student_key(led, balance_yuan=1.0, cap_yuan=0.1) # 日上限 100
|
||||
t1 = 1788300000.0 # 某日
|
||||
led.check_and_count(kid, sid, t1)
|
||||
led.try_hold("r1", kid, sid, "m", "b", 100, t1)
|
||||
assert led.get_student(sid)["spent_today_milli"] == 100
|
||||
t2 = t1 + 86400 + 60 # 次日
|
||||
assert led.check_and_count(kid, sid, t2) is True # 新一天计数重置
|
||||
assert led.try_hold("r2", kid, sid, "m", "b", 100, t2) is True
|
||||
assert led.get_student(sid)["spent_today_milli"] == 100 # 新一天重新累计
|
||||
|
||||
|
||||
def test_void_full_refund(led):
|
||||
"""上游失败全额退款;重复 void 不二次退。"""
|
||||
sid, kid = _mk_student_key(led, balance_yuan=1.0)
|
||||
ts = 1788300000.0
|
||||
led.try_hold("r1", kid, sid, "m", "b", 400, ts)
|
||||
assert led.void("r1", status="aborted") is True
|
||||
assert led.get_student(sid)["balance_milli"] == 1000
|
||||
assert led.get_usage("r1")["charged_milli"] == 0
|
||||
assert led.get_usage("r1")["status"] == "aborted"
|
||||
assert led.void("r1") is False # 已不是 holding
|
||||
|
||||
|
||||
def test_concurrent_hold_never_overdraft(led):
|
||||
"""并发 N 路同时预扣:余额不足者精确拒绝、不超扣(D-P11 核心验收)。"""
|
||||
sid, kid = _mk_student_key(led, balance_yuan=0.01) # 10 毫元
|
||||
results = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker(i):
|
||||
ok = led.try_hold(f"c{i}", kid, sid, "m", "b", 5, ts=1788300000.0)
|
||||
with lock:
|
||||
results.append(ok)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert sorted(results) == [False] * 8 + [True] * 2 # 恰好 2 路成功
|
||||
assert led.get_student(sid)["balance_milli"] == 0 # 精确扣完,不为负
|
||||
|
||||
|
||||
def test_record_idempotent_via_primary_key(led):
|
||||
"""流水主键幂等:同 request_id 只保留一条。"""
|
||||
sid, kid = _mk_student_key(led)
|
||||
led.try_hold("px", kid, sid, "m", "b", 10, ts=1788300000.0)
|
||||
led.try_hold("px", kid, sid, "m", "b", 10, ts=1788300000.0)
|
||||
led.settle("px", 5)
|
||||
assert led.get_usage("px")["charged_milli"] == 5
|
||||
assert len(led.list_usage()) == 1
|
||||
Reference in New Issue
Block a user