"""Integration tests for channels config_router endpoints. 覆盖配置管理域(CFG-02 / CFG-READ-01 / CFG-UPDATE-01 / CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE)的鉴权矩阵、 请求体校验与关键错误路径。所有动态 ``/{key}`` 路径用 ``NON_EXISTENT_CONFIG_KEY`` 触发 404,静态路径(schema / 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) # ============================================================================= # === 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/{key} — non-existent key === # ============================================================================= async def test_get_config_returns_404_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 == 404, response.text # ============================================================================= # === PUT /config/{key} — non-existent key === # ============================================================================= async def test_update_config_returns_404_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 == 404, response.text # ============================================================================= # === POST /config/{key}/rollback — non-existent key + validation === # ============================================================================= async def test_rollback_config_returns_404_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 == 404, response.text 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 # ============================================================================= # === GET /config/{key}/history — non-existent key === # ============================================================================= async def test_get_config_history_returns_404_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 == 404, response.text # ============================================================================= # === 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