1. 清理测试文件中未使用的导入与冗余代码 2. 修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道 3. 调整批量消息撤回的响应语义,对齐其他端点的部分成功契约 4. 修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑 5. 替换anyio为asyncio并行调用,修正时间UTC导入路径 6. 优化前端外部系统概览页的刷新状态提示与缓存逻辑 7. 修复测试用例中的断言与请求方式问题,适配httpx删除请求特性 8. 重构后端路由的依赖注入,移除冗余的数据库会话依赖 9. 调整测试用例的权限校验逻辑,修正强制登出的权限判断 10. 修复语义分块测试的numpy依赖问题,清理冗余导入
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""Integration tests for channels config_router endpoints.
|
||
|
||
覆盖配置管理域(CFG-02 / CFG-READ-01 / CFG-READ-MANY / CFG-UPDATE-01 /
|
||
CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE)的
|
||
鉴权矩阵、请求体校验与关键错误路径。所有动态 ``/{key}`` 路径用
|
||
``NON_EXISTENT_CONFIG_KEY`` 触发 400(VALIDATION_ERROR),静态路径
|
||
(schema / values / export / import / batch)通过提前声明规避动态捕获。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from .conftest import BASE_URL, NON_EXISTENT_CONFIG_KEY
|
||
|
||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||
|
||
CONFIG_URL = f"{BASE_URL}/config"
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for GET /config/schema ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_schema_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/schema")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_schema_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/schema", headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_admin_can_get_config_schema(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/schema", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], (dict, list))
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/export — superadmin only ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_export_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/export")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_export_config_requires_superadmin_for_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/export", headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_superadmin_can_export_config(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — admin_headers 实际为 superadmin(见 conftest.py + test_auth_router
|
||
# 中 _require_superadmin 校验),通过 get_superadmin_user 守门后导出成功。
|
||
# standard_user 被拒场景由 test_export_config_requires_superadmin_for_standard_user 覆盖。
|
||
response = await test_client.get(f"{CONFIG_URL}/export", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], dict)
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /config/import — superadmin only ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_import_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"config_data": {"key": "value"}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_import_config_rejects_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"config_data": {"key": "value"}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body, headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_superadmin_can_import_config_dry_run(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — admin_headers 为 superadmin,通过 get_superadmin_user 守门;
|
||
# dry_run=True 仅校验统计不写入,非法 key 记入 failed_count,整体返回 200。
|
||
body = {"config_data": {"key": "value"}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], dict)
|
||
|
||
|
||
async def test_import_config_rejects_empty_config_data(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — config_data min_length=1,空字典违反约束
|
||
body = {"config_data": {}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_import_config_rejects_missing_config_data(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — config_data 必填字段缺失
|
||
body = {"dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === PUT /config/batch — validation ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_batch_update_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"updates": [{"key": "k", "value": "v"}]}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_batch_update_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"updates": [{"key": "k", "value": "v"}]}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json=body, headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_batch_update_config_rejects_empty_updates(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — updates 为空违反 min_length=1
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json={"updates": []}, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_batch_update_config_rejects_too_many_updates(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — 51 条超过 max_length=50
|
||
body = {"updates": [{"key": f"k{i}", "value": "v"} for i in range(51)]}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/values — batch read (CFG-READ-MANY) ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_values_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params={"keys": ["k"]})
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_values_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{CONFIG_URL}/values", params={"keys": ["k"]}, headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_admin_can_get_config_values(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — 使用 CONFIG_SCHEMA 中已声明 key,整体返回 200
|
||
response = await test_client.get(
|
||
f"{CONFIG_URL}/values", params={"keys": ["dm_policy"]}, headers=admin_headers
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], (dict, list))
|
||
|
||
|
||
async def test_get_config_values_rejects_empty_keys(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — keys min_length=1,空列表违反约束
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params={"keys": []}, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_get_config_values_rejects_too_many_keys(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — 51 个 key 超过 max_length=50(BATCH_UPDATE_LIMIT)
|
||
params = [("keys", f"k{i}") for i in range(51)]
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params=params, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_get_config_values_rejects_missing_keys_param(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — keys 为必填 Query 参数,缺失触发 422
|
||
response = await test_client.get(f"{CONFIG_URL}/values", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/{key} — non-existent key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_get_config_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
# =============================================================================
|
||
# === PUT /config/{key} — non-existent key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_update_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"value": "new_value"}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_update_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"value": "new_value"}
|
||
# Act
|
||
response = await test_client.put(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_update_config_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"value": "new_value"}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
async def test_update_config_rejects_missing_value(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — value 必填字段缺失
|
||
body = {"scope": "global"}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_update_config_rejects_zero_expected_version(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — expected_version ge=1,0 违反约束
|
||
body = {"value": "new_value", "expected_version": 0}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /config/{key}/rollback — non-existent key + validation ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_rollback_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"target_version": 1}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_rollback_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"target_version": 1}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback", json=body, headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_rollback_config_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"target_version": 1}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback", json=body, headers=admin_headers
|
||
)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
async def test_rollback_config_rejects_zero_target_version(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — target_version=0 违反 ge=1
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback",
|
||
json={"target_version": 0},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_rollback_config_rejects_missing_target_version(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — target_version 必填字段缺失
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback",
|
||
json={},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/{key}/history — non-existent key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_history_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/history")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_history_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/history", headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_get_config_history_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/history", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
# =============================================================================
|
||
# === Static path vs dynamic — /config/schema not captured as key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_schema_static_path_not_captured_as_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — 静态路径 /config/schema 应被 get_config_schema 捕获,而非 get_config
|
||
response = await test_client.get(f"{CONFIG_URL}/schema", headers=admin_headers)
|
||
# Assert — schema 端点返回 200,而非 404(若被当作 key="schema" 则 404)
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|