新增并完善多模块接口测试用例,覆盖: 1. 权限校验:认证缺失、普通用户越权访问场景 2. 参数合法性校验:空值、格式错误、边界值、枚举约束 3. 响应字段增强:补充业务字段断言与分页回传校验 4. 异常场景:不存在资源、超限参数、无效格式等400/404/422场景
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 — 已声明 key 返回值,未声明 key 记入 missing,整体 200
|
||
response = await test_client.get(
|
||
f"{CONFIG_URL}/values", params={"keys": [NON_EXISTENT_CONFIG_KEY]}, 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
|