"""Integration tests for channels audit_router endpoints.""" from __future__ import annotations import httpx import pytest from .conftest import BASE_URL, ISO_TIME_END, ISO_TIME_START, NON_EXISTENT_LOG_ID pytestmark = [pytest.mark.asyncio, pytest.mark.integration] AUDIT_URL = f"{BASE_URL}/audit" # ============================================================================= # === Auth three-tier for GET /audit/logs === # ============================================================================= async def test_query_audit_logs_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{AUDIT_URL}/logs") # Assert assert response.status_code == 401, response.text async def test_query_audit_logs_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get(f"{AUDIT_URL}/logs", headers=standard_user["headers"]) # Assert assert response.status_code == 403, response.text async def test_admin_can_query_audit_logs(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{AUDIT_URL}/logs", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert "data" in payload # ============================================================================= # === Pagination validation for GET /audit/logs === # ============================================================================= async def test_query_audit_logs_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers): # Act — limit=0 violates ge=1 response = await test_client.get(f"{AUDIT_URL}/logs", params={"limit": 0}, headers=admin_headers) # Assert assert response.status_code == 422, response.text async def test_query_audit_logs_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers): # Act — limit=1001 violates le=1000 response = await test_client.get(f"{AUDIT_URL}/logs", params={"limit": 1001}, headers=admin_headers) # Assert assert response.status_code == 422, response.text async def test_query_audit_logs_rejects_negative_offset(test_client: httpx.AsyncClient, admin_headers): # Act — offset=-1 violates ge=0 response = await test_client.get(f"{AUDIT_URL}/logs", params={"offset": -1}, headers=admin_headers) # Assert assert response.status_code == 422, response.text async def test_query_audit_logs_rejects_invalid_operation_type(test_client: httpx.AsyncClient, admin_headers): # Act — operation_type is an enum (AuditOperationType), invalid value returns 422 response = await test_client.get( f"{AUDIT_URL}/logs", params={"operation_type": "invalid_op"}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_admin_can_query_audit_logs_with_time_range(test_client: httpx.AsyncClient, admin_headers): # Act — filter by start_time / end_time response = await test_client.get( f"{AUDIT_URL}/logs", params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END}, headers=admin_headers, ) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert "data" in payload async def test_query_audit_logs_rejects_invalid_time_format(test_client: httpx.AsyncClient, admin_headers): # Act — "not-a-date" is not ISO 8601, parse_datetime raises ValidationError (400) response = await test_client.get( f"{AUDIT_URL}/logs", params={"start_time": "not-a-date"}, headers=admin_headers, ) # Assert assert response.status_code == 400, response.text # ============================================================================= # === GET /audit/logs/stats and /audit/operations === # ============================================================================= async def test_admin_can_get_audit_log_stats(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{AUDIT_URL}/logs/stats", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert "data" in payload async def test_admin_can_list_audit_operations(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{AUDIT_URL}/operations", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert "data" in payload # ============================================================================= # === GET /audit/export (StreamingResponse, JSON array) === # ============================================================================= async def test_admin_can_export_audit_logs(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{AUDIT_URL}/export", headers=admin_headers) # Assert assert response.status_code == 200, response.text # /audit/export 返回 StreamingResponse(JSON 数组 [entry1, ...]), # 不遵循 success/data 标准响应包装;无匹配数据时为空数组 []。 payload = response.json() assert isinstance(payload, list) # ============================================================================= # === GET /audit/retention-policy === # ============================================================================= async def test_admin_can_get_retention_policy(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{AUDIT_URL}/retention-policy", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert "data" in payload # ============================================================================= # === PUT /audit/retention-policy (superadmin only) === # ============================================================================= async def test_update_retention_policy_requires_auth(test_client: httpx.AsyncClient): # Arrange body = {"default_retention_days": 90} # Act response = await test_client.put(f"{AUDIT_URL}/retention-policy", json=body) # Assert assert response.status_code == 401, response.text async def test_update_retention_policy_rejects_standard_user(test_client: httpx.AsyncClient, standard_user): # Arrange body = {"default_retention_days": 90} # Act response = await test_client.put( f"{AUDIT_URL}/retention-policy", json=body, headers=standard_user["headers"], ) # Assert assert response.status_code == 403, response.text async def test_superadmin_can_update_retention_policy(test_client: httpx.AsyncClient, admin_headers): # Arrange — admin_headers 实际为 superadmin(见 conftest.py + test_auth_router # 中 _require_superadmin 校验),通过 get_superadmin_user 守门;body 合法 # 应返回 200。standard_user 被拒场景由 test_update_retention_policy_rejects_standard_user 覆盖。 body = {"default_retention_days": 90} # Act response = await test_client.put(f"{AUDIT_URL}/retention-policy", json=body, headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert "data" in payload async def test_update_retention_policy_rejects_archive_before_ge_retention( test_client: httpx.AsyncClient, admin_headers ): # Arrange — auto_archive_before_days >= default_retention_days 违反 # RetentionPolicyUpdateCmd.__post_init__ 业务规则,构造时抛出 # ValidationError(VALIDATION_ERROR -> HTTP 400)。admin_headers 为 # superadmin,通过 get_superadmin_user 守门后抵达 DTO 构造阶段。 # 注意:此非 Pydantic Field 校验(ge=1)—— 10/10 均通过 Field 约束, # 故不是 422;亦非 dispatch-stage RuleViolationError(422)。 body = { "default_retention_days": 10, "auto_archive_before_days": 10, } # Act response = await test_client.put(f"{AUDIT_URL}/retention-policy", json=body, headers=admin_headers) # Assert — DTO __post_init__ 抛 ValidationError -> 400 assert response.status_code == 400, response.text async def test_update_retention_policy_rejects_zero_retention_days( test_client: httpx.AsyncClient, admin_headers ): # Arrange — default_retention_days=0 violates Field(ge=1) body = {"default_retention_days": 0} # Act response = await test_client.put(f"{AUDIT_URL}/retention-policy", json=body, headers=admin_headers) # Assert assert response.status_code == 422, response.text # ============================================================================= # === 404 paths for non-existent resources === # ============================================================================= async def test_get_audit_log_returns_404_for_nonexistent(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{AUDIT_URL}/logs/{NON_EXISTENT_LOG_ID}", headers=admin_headers) # Assert assert response.status_code == 404, response.text async def test_get_audit_log_rejects_oversized_log_id(test_client: httpx.AsyncClient, admin_headers): # Arrange — log_id longer than max_length=64 violates Path constraint (422) oversized_id = "x" * 65 # Act response = await test_client.get(f"{AUDIT_URL}/logs/{oversized_id}", headers=admin_headers) # Assert assert response.status_code == 422, response.text