"""P1 操作级权限测试: InstanceGroup.permissions 字段生效 测试矩阵: - admin 不受 permissions 限制 - 组没配置 permissions → 允许所有(向后兼容) - 组配置了 ["query:select"] → 只有 select 允许,ticket:create 被拒绝 - 用户级覆盖权限优先于组默认 - 公开实例不受 permissions 限制 - 集成测试: query/ticket 端点权限检查 """ import json import sys import pytest from contextlib import asynccontextmanager from fastapi import HTTPException from unittest.mock import patch, AsyncMock, MagicMock from app.models.instance import Instance from app.models.instance_group import InstanceGroup, user_groups from app.core.security import create_access_token # ── Fixtures ────────────────────────────────────────────── @pytest.fixture async def group_with_permissions(db): """创建带 permissions 配置的权限组""" group = InstanceGroup( name="只读组", db_type="mysql", permissions=json.dumps(["query:select", "instance:detail"]), ) db.add(group) await db.flush() instance = Instance( name="生产MySQL", db_type="mysql", host="10.0.0.1", port=3306, username="root", password="pw", database="prod_db", status="disconnected", group_id=group.id, ) db.add(instance) await db.commit() await db.refresh(group) await db.refresh(instance) return group, instance @pytest.fixture async def group_no_permissions(db): """创建没有 permissions 配置的权限组(向后兼容)""" group = InstanceGroup(name="默认组", db_type="mysql", permissions=None) db.add(group) await db.flush() instance = Instance( name="测试MySQL", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="pw", database="test_db", status="disconnected", group_id=group.id, ) db.add(instance) await db.commit() await db.refresh(group) await db.refresh(instance) return group, instance # ── 单元测试: check_instance_permission ────────────────── class TestCheckInstancePermission: """check_instance_permission 核心逻辑测试""" @pytest.mark.asyncio async def test_admin_bypasses_permission_check( self, db, admin_user, group_with_permissions ): """admin 不受 permissions 限制""" from app.core.permissions import check_instance_permission group, instance = group_with_permissions # ticket:create 不在组 permissions 中,但 admin 应该通过 await check_instance_permission(admin_user, instance, "ticket:create", db) @pytest.mark.asyncio async def test_public_instance_allows_all(self, db, developer_user): """公开实例不受 permissions 限制""" from app.core.permissions import check_instance_permission instance = Instance( name="公开", db_type="mysql", host="127.0.0.1", port=3306, username="root", password="pw", database="pub_db", status="disconnected", group_id=None, ) db.add(instance) await db.commit() await check_instance_permission(developer_user, instance, "ticket:create", db) @pytest.mark.asyncio async def test_allowed_action_passes( self, db, developer_user, group_with_permissions ): """用户在组内,操作在 permissions 列表中 → 通过""" from app.core.permissions import check_instance_permission group, instance = group_with_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() # query:select 在组 permissions 中 await check_instance_permission(developer_user, instance, "query:select", db) @pytest.mark.asyncio async def test_denied_action_raises_403( self, db, developer_user, group_with_permissions ): """用户在组内,操作不在 permissions 列表中 → 403""" from app.core.permissions import check_instance_permission group, instance = group_with_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() # ticket:create 不在组 permissions ["query:select", "instance:detail"] 中 with pytest.raises(HTTPException) as exc_info: await check_instance_permission( developer_user, instance, "ticket:create", db ) assert exc_info.value.status_code == 403 @pytest.mark.asyncio async def test_no_permissions_config_allows_all( self, db, developer_user, group_no_permissions ): """组没有配置 permissions → 允许所有操作(向后兼容)""" from app.core.permissions import check_instance_permission group, instance = group_no_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() # ticket:create 应该被允许,因为组没有配置限制 await check_instance_permission(developer_user, instance, "ticket:create", db) @pytest.mark.asyncio async def test_user_override_permissions( self, db, developer_user, group_with_permissions ): """用户级覆盖权限优先于组默认""" from app.core.permissions import check_instance_permission group, instance = group_with_permissions # 组 permissions = ["query:select", "instance:detail"] # 用户覆盖为 ["query:select"] — 只给查询权限 await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id, permissions=json.dumps(["query:select"]), ) ) await db.commit() # query:select 通过(用户级覆盖包含) await check_instance_permission(developer_user, instance, "query:select", db) # instance:detail 被拒绝(组默认有,但用户覆盖只给了 query:select) with pytest.raises(HTTPException) as exc_info: await check_instance_permission( developer_user, instance, "instance:detail", db ) assert exc_info.value.status_code == 403 # ── 单元测试: _get_effective_permissions ────────────────── class TestGetEffectivePermissions: """_get_effective_permissions 权限解析逻辑""" @pytest.mark.asyncio async def test_returns_all_when_no_config( self, db, developer_user, group_no_permissions ): """无权限配置时返回全部权限""" from app.core.permissions import _get_effective_permissions, PERMISSION_ACTIONS group, instance = group_no_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() result = await _get_effective_permissions(developer_user, instance, db) assert result == PERMISSION_ACTIONS @pytest.mark.asyncio async def test_returns_group_permissions( self, db, developer_user, group_with_permissions ): """有组权限配置时返回组权限""" from app.core.permissions import _get_effective_permissions group, instance = group_with_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() result = await _get_effective_permissions(developer_user, instance, db) assert result == {"query:select", "instance:detail"} @pytest.mark.asyncio async def test_user_override_takes_precedence( self, db, developer_user, group_with_permissions ): """用户级覆盖优先于组默认""" from app.core.permissions import _get_effective_permissions group, instance = group_with_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id, permissions=json.dumps(["query:select"]), ) ) await db.commit() result = await _get_effective_permissions(developer_user, instance, db) assert result == {"query:select"} @pytest.mark.asyncio async def test_admin_not_in_group( self, db, admin_user, group_with_permissions ): """admin 不在任何组,但 check_instance_permission 不调用此函数(admin bypass)""" # admin 在 check_instance_permission 中直接 return,不会调用 _get_effective_permissions pass # ── 集成测试: API 端点权限检查 ──────────────────────────── class TestPermissionEndpointIntegration: """权限检查在实际 API 端点中的集成""" @pytest.mark.asyncio async def test_query_with_permission( self, client, db, developer_user, group_with_permissions ): """有 query:select 权限 → 查询不被 403(mock DB连接避免挂起)""" group, instance = group_with_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() token = create_access_token( data={"sub": str(developer_user.id), "role": developer_user.role} ) # Mock aiomysql 防止真实连接挂起(见 skill #20, #37) mock_cursor = AsyncMock() mock_cursor.description = [("1",)] mock_cursor.fetchall.return_value = [{"1": 1}] mock_cursor.close = AsyncMock() @asynccontextmanager async def mock_cursor_ctx(*a, **kw): yield mock_cursor mock_conn = AsyncMock() mock_conn.cursor = mock_cursor_ctx mock_conn.commit = AsyncMock() mock_conn.rollback = AsyncMock() mock_conn.close = AsyncMock() mock_aiomysql = MagicMock() mock_aiomysql.connect = AsyncMock(return_value=mock_conn) mock_aiomysql.DictCursor = MagicMock() with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): resp = await client.post( "/api/v1/query", json={ "instance_id": instance.id, "database": "test", "sql": "SELECT 1", }, headers={"Authorization": f"Bearer {token}"}, ) # 不应是 403(权限通过,mock 返回成功) assert resp.status_code != 403 @pytest.mark.asyncio async def test_ticket_create_without_permission( self, client, db, developer_user, group_with_permissions ): """没有 ticket:create 权限 → 创建工单 403""" group, instance = group_with_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() token = create_access_token( data={"sub": str(developer_user.id), "role": developer_user.role} ) resp = await client.post( "/api/v1/tickets", json={ "title": "测试工单标题", "db_type": "mysql", "instance_id": instance.id, "target_database": "test", "sql_content": "ALTER TABLE t ADD COLUMN c INT", "reason": "测试创建工单权限", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 assert "ticket:create" in resp.json()["detail"] @pytest.mark.asyncio async def test_ticket_create_with_permission( self, client, db, developer_user, group_no_permissions ): """组没配置 permissions → 创建工单不被 403(向后兼容)""" group, instance = group_no_permissions await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() token = create_access_token( data={"sub": str(developer_user.id), "role": developer_user.role} ) resp = await client.post( "/api/v1/tickets", json={ "title": "测试工单标题", "db_type": "mysql", "instance_id": instance.id, "target_database": "test", "sql_content": "ALTER TABLE t ADD COLUMN c INT", "reason": "测试创建工单权限", }, headers={"Authorization": f"Bearer {token}"}, ) # 不应是 403(可能 400 因为数据校验,但权限通过) assert resp.status_code != 403 @pytest.mark.asyncio async def test_query_denied_for_group_without_select( self, client, db, developer_user ): """组 permissions 只有 ticket:create → query 被拒绝""" group = InstanceGroup( name="工单组", db_type="mysql", permissions=json.dumps(["ticket:create"]), ) db.add(group) await db.flush() instance = Instance( name="工单MySQL", db_type="mysql", host="10.0.0.1", port=3306, username="root", password="pw", database="prod_db", status="disconnected", group_id=group.id, ) db.add(instance) await db.execute( user_groups.insert().values( user_id=developer_user.id, group_id=group.id ) ) await db.commit() token = create_access_token( data={"sub": str(developer_user.id), "role": developer_user.role} ) resp = await client.post( "/api/v1/query", json={"instance_id": instance.id, "database": "test", "sql": "SELECT 1"}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 assert "query:select" in resp.json()["detail"]