test: 补齐 asset_service CRUD 与 API 装配层 fail-closed 测试

- tests/test_asset_service.py:以模块名直接调用 asset_service,覆盖 create/get/
  update/delete 正常路径与失败分支(缺详情 400、账号悬空 400、不存在 404)、
  删除的详情+子表联动清理与 metrics 清理失败容错、VPS 凭证加密入库、list 筛选/
  搜索/排序白名单兜底、get_expiring 到期口径
- tests/test_api_assembly.py:首次用 TestClient 挂载 app.main:app,以 RFC5737 公网
  来源验证 API_KEY/AGENT_KEY 未配置或错误时 401(fail-closed)、正确时放行
  (/api/assets 201、/api/agent/report 进入业务校验 404)
- 复用 conftest sys.path 约定与 tmp_path+create_engine 临时库;依赖覆盖到临时库、
  不触发 lifespan,免凭证、免网络

334 passed(新增 25)
This commit is contained in:
gouki
2026-10-12 21:31:22 +00:00
parent 46faadbcae
commit ca2b49952a
2 changed files with 451 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
"""装配层测试:TestClient 挂载 app.main:app,验证路由装配与 API_KEY/AGENT_KEY fail-closed
此前测试从不构造 TestClient(0 引用),路由装配、依赖注入与「密钥未配置时对外
一律拒绝」的 fail-closed 语义全靠人工验证。本文件用 fastapi.testclient.TestClient
挂载真实 app,覆盖:
- 基础装配:/health、读路由 /api/assets 正常挂载且走临时库
- API_KEY fail-closed:外部来源在密钥未配置 / 错误时 401,正确时放行(201)
- AGENT_KEY fail-closed:外部来源在密钥未配置 / 错误时 401,正确时通过鉴权进入业务校验
安全前提(app/core/security.py):TestClient 显式使用 RFC 5737 文档段公网 IP
(203.0.113.7) 作为来源,不属于可信内网(100.64/10、127/8、::1),因此走「外部来源」
分支——密钥未配置即拒绝,正是 fail-closed 的核心属性。
全程免凭证、免网络:不触发 lifespan(不 init_db、不启清理协程),get_session /
get_metrics_session 依赖被覆盖到 tmp_path 临时库,agent 路由的 assets_engine 一并指过去。
运行:.venv/bin/pytest tests/test_api_assembly.py -v
"""
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
import app.models.asset # noqa: F401 确保所有表注册进 SQLModel.metadata
import app.models.credential # noqa: F401
import app.models.monitor # noqa: F401
import app.models.provider # noqa: F401
import app.models.ssl # noqa: F401
import app.routers.agent as agent_router
from app.core.config import settings
from app.database import get_metrics_session, get_session
from app.main import app
# 外部来源(RFC 5737 TEST-NET-3 文档段,绝不属于可信内网)
EXTERNAL_CLIENT = ("203.0.113.7", 50000)
VPS_BODY = {
"name": "ext-vps",
"asset_type": "vps",
"provider": "vultr",
"vps_detail": {"ip_address": "1.2.3.4"},
}
@pytest.fixture()
def client(tmp_path, monkeypatch):
"""挂载真实 app 的 TestClient:依赖覆盖到临时库,密钥默认留空(fail-closed 起点)"""
assets_eng = create_engine(
f"sqlite:///{tmp_path / 'assets.db'}", connect_args={"check_same_thread": False}
)
metrics_eng = create_engine(
f"sqlite:///{tmp_path / 'metrics.db'}", connect_args={"check_same_thread": False}
)
SQLModel.metadata.create_all(assets_eng)
SQLModel.metadata.create_all(metrics_eng)
def _assets_session():
with Session(assets_eng, expire_on_commit=False) as s:
yield s
def _metrics_session():
with Session(metrics_eng, expire_on_commit=False) as s:
yield s
app.dependency_overrides[get_session] = _assets_session
app.dependency_overrides[get_metrics_session] = _metrics_session
# agent.report 的 _validate_asset_id 直接用模块级 assets_engine,指到临时库避免碰本地 data/
monkeypatch.setattr(agent_router, "assets_engine", assets_eng)
# 每次从「无密钥」的干净状态开始,fail-closed 才是被验证的默认行为
monkeypatch.setattr(settings, "API_KEY", "")
monkeypatch.setattr(settings, "AGENT_KEY", "")
yield TestClient(app, client=EXTERNAL_CLIENT)
app.dependency_overrides.clear()
assets_eng.dispose()
metrics_eng.dispose()
# ----------------------------- 基础装配 ----------------------------- #
def test_health_route_assembles(client):
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_list_assets_route_is_open_and_empty(client):
"""读路由不受 API Key 保护,装配正确时返回空列表(临时库无数据)"""
resp = client.get("/api/assets")
assert resp.status_code == 200
assert resp.json() == []
# ----------------------------- API_KEY fail-closed ----------------------------- #
def test_post_assets_fail_closed_when_api_key_unset(client):
"""外部来源 + 未配置 API_KEY:写操作必须 401,绝不裸奔"""
resp = client.post("/api/assets", json=VPS_BODY)
assert resp.status_code == 401
def test_post_assets_rejects_wrong_api_key(client, monkeypatch):
monkeypatch.setattr(settings, "API_KEY", "s3cret")
resp = client.post("/api/assets", json=VPS_BODY, headers={"X-API-Key": "wrong"})
assert resp.status_code == 401
def test_post_assets_accepts_correct_api_key(client, monkeypatch):
"""正确密钥放行:完整走 router -> service -> 临时库,并可在列表读回"""
monkeypatch.setattr(settings, "API_KEY", "s3cret")
resp = client.post("/api/assets", json=VPS_BODY, headers={"X-API-Key": "s3cret"})
assert resp.status_code == 201
assert resp.json()["vps_detail"]["ip_address"] == "1.2.3.4"
listing = client.get("/api/assets").json()
assert len(listing) == 1
assert listing[0]["name"] == "ext-vps"
# ----------------------------- AGENT_KEY fail-closed ----------------------------- #
def test_agent_report_fail_closed_when_agent_key_unset(client):
"""外部来源 + 未配置 AGENT_KEY:上报必须 401(防止公网伪造监控数据)"""
resp = client.post("/api/agent/report", json={"asset_id": 1})
assert resp.status_code == 401
def test_agent_report_rejects_wrong_agent_key(client, monkeypatch):
monkeypatch.setattr(settings, "AGENT_KEY", "akey")
resp = client.post(
"/api/agent/report", json={"asset_id": 1}, headers={"X-Agent-Key": "bad"}
)
assert resp.status_code == 401
def test_agent_report_passes_auth_with_correct_key(client, monkeypatch):
"""正确密钥通过鉴权后进入业务校验:asset_id 不存在 -> 404(而非 401),证明闸门已放行"""
monkeypatch.setattr(settings, "AGENT_KEY", "akey")
resp = client.post(
"/api/agent/report", json={"asset_id": 999}, headers={"X-Agent-Key": "akey"}
)
assert resp.status_code == 404