Python: Add Purview Middleware (#1142)

* [Py Purview] Purview Python Initial Commit

* [Py Purview] Purview Python Minor Fixes

* [Py Purview] Purview Python Comment Fixesish

* [Py Purview] Purview Python Agent Middleware Done

* [Py Purview] Purview Python Agent Middleware Done

* [Py Purview] Purview Python Lint Errors

* [Py Purview] Purview Python Final Hopefully

* [Py Purview] Purview Python Final Hopefully

* [Py Purview] Purview Python Fix ReadMe

* [Py Purview] Purview Python Fix MyPy

* [Py Purview] Purview Python Minor Updates on comments

* [Py Purview] Purview Python Fix Build Error

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
Rishabh Chawla
2025-10-16 14:46:04 -07:00
committed by GitHub
Unverified
parent 76ae0a62ac
commit 59da578902
24 changed files with 3707 additions and 5 deletions
+68
View File
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared pytest fixtures for Purview tests."""
import pytest
from agent_framework_purview._models import (
Activity,
ActivityMetadata,
ContentToProcess,
DeviceMetadata,
IntegratedAppMetadata,
OperatingSystemSpecifications,
PolicyLocation,
ProcessContentRequest,
ProcessConversationMetadata,
ProtectedAppMetadata,
PurviewTextContent,
)
@pytest.fixture
def content_to_process_factory():
"""Factory fixture to create ContentToProcess objects with test data."""
def _create_content(text: str = "Test") -> ContentToProcess:
text_content = PurviewTextContent(data=text)
metadata = ProcessConversationMetadata(
identifier="msg-1",
content=text_content,
name="Test",
is_truncated=False,
)
activity_meta = ActivityMetadata(activity=Activity.UPLOAD_TEXT)
device_meta = DeviceMetadata(
operating_system_specifications=OperatingSystemSpecifications(
operating_system_platform="Windows", operating_system_version="10"
)
)
integrated_app = IntegratedAppMetadata(name="App", version="1.0")
location = PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")
protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location)
return ContentToProcess(
content_entries=[metadata],
activity_metadata=activity_meta,
device_metadata=device_meta,
integrated_app_metadata=integrated_app,
protected_app_metadata=protected_app,
)
return _create_content
@pytest.fixture
def process_content_request_factory(content_to_process_factory):
"""Factory fixture to create ProcessContentRequest objects with test data."""
def _create_request(
text: str = "Test", user_id: str = "user-123", tenant_id: str = "tenant-456"
) -> ProcessContentRequest:
content = content_to_process_factory(text)
return ProcessContentRequest(
content_to_process=content,
user_id=user_id,
tenant_id=tenant_id,
)
return _create_request
@@ -0,0 +1,149 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview chat middleware."""
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import ChatContext, ChatMessage, Role
from azure.core.credentials import AccessToken
from agent_framework_purview import PurviewChatPolicyMiddleware, PurviewSettings
@dataclass
class DummyChatClient:
name: str = "dummy"
class TestPurviewChatPolicyMiddleware:
@pytest.fixture
def mock_credential(self) -> AsyncMock:
credential = AsyncMock()
credential.get_token = AsyncMock(return_value=AccessToken("fake-token", 9999999999))
return credential
@pytest.fixture
def settings(self) -> PurviewSettings:
return PurviewSettings(app_name="Test App", tenant_id="test-tenant")
@pytest.fixture
def middleware(self, mock_credential: AsyncMock, settings: PurviewSettings) -> PurviewChatPolicyMiddleware:
return PurviewChatPolicyMiddleware(mock_credential, settings)
@pytest.fixture
def chat_context(self) -> ChatContext:
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
return ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
)
async def test_initialization(self, middleware: PurviewChatPolicyMiddleware) -> None:
assert middleware._client is not None
assert middleware._processor is not None
async def test_allows_clean_prompt(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
next_called = False
async def mock_next(ctx: ChatContext) -> None:
nonlocal next_called
next_called = True
class Result:
def __init__(self):
self.messages = [ChatMessage(role=Role.ASSISTANT, text="Hi there")]
ctx.result = Result()
await middleware.process(chat_context, mock_next)
assert next_called
assert mock_proc.call_count == 2
assert chat_context.result.messages[0].role == Role.ASSISTANT
async def test_blocks_prompt(self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext) -> None:
with patch.object(middleware._processor, "process_messages", return_value=(True, "user-123")):
async def mock_next(ctx: ChatContext) -> None: # should not run
raise AssertionError("next should not be called when prompt blocked")
await middleware.process(chat_context, mock_next)
assert chat_context.terminate
assert chat_context.result
msg = chat_context.result[0] # type: ignore[index]
assert msg.role in ("system", Role.SYSTEM)
assert "blocked" in msg.text.lower()
async def test_blocks_response(self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext) -> None:
call_state = {"count": 0}
async def side_effect(messages, activity, user_id=None):
call_state["count"] += 1
should_block = call_state["count"] == 2
return (should_block, "user-123")
with patch.object(middleware._processor, "process_messages", side_effect=side_effect):
async def mock_next(ctx: ChatContext) -> None:
class Result:
def __init__(self):
self.messages = [ChatMessage(role=Role.ASSISTANT, text="Sensitive output")] # pragma: no cover
ctx.result = Result()
await middleware.process(chat_context, mock_next)
assert call_state["count"] == 2
msgs = getattr(chat_context.result, "messages", None) or chat_context.result
first_msg = msgs[0]
assert first_msg.role in ("system", Role.SYSTEM)
assert "blocked" in first_msg.text.lower()
async def test_streaming_skips_post_check(self, middleware: PurviewChatPolicyMiddleware) -> None:
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
streaming_context = ChatContext(
chat_client=chat_client,
messages=[ChatMessage(role=Role.USER, text="Hello")],
chat_options=chat_options,
is_streaming=True,
)
with patch.object(middleware._processor, "process_messages", return_value=False) as mock_proc:
async def mock_next(ctx: ChatContext) -> None:
ctx.result = MagicMock()
await middleware.process(streaming_context, mock_next)
assert mock_proc.call_count == 1
async def test_chat_middleware_handles_post_check_exception(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
"""Test that exceptions in post-check are logged but don't affect result."""
call_count = 0
async def mock_process_messages(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return (False, "user-123") # Pre-check succeeds
raise Exception("Post-check error") # Post-check fails
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
# Create a mock result with messages attribute
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
ctx.result = result
await middleware.process(chat_context, mock_next)
# Should have been called twice (pre and post)
assert call_count == 2
# Result should still be set
assert chat_context.result is not None
@@ -0,0 +1,238 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview client."""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from azure.core.credentials import AccessToken
from agent_framework_purview import PurviewSettings
from agent_framework_purview._client import PurviewClient
from agent_framework_purview._exceptions import (
PurviewAuthenticationError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
)
from agent_framework_purview._models import (
PolicyLocation,
ProcessContentRequest,
ProtectionScopesRequest,
)
class TestPurviewClient:
"""Test PurviewClient functionality."""
@pytest.fixture
def mock_credential(self) -> MagicMock:
"""Create a mock async credential."""
from azure.core.credentials_async import AsyncTokenCredential
credential = MagicMock(spec=AsyncTokenCredential)
mock_token = AccessToken("fake-token", 9999999999)
async def mock_get_token(*args, **kwargs):
return mock_token
credential.get_token = mock_get_token
return credential
@pytest.fixture
def settings(self) -> PurviewSettings:
"""Create test settings."""
return PurviewSettings(app_name="Test App", tenant_id="test-tenant", default_user_id="test-user")
@pytest.fixture
async def client(self, mock_credential: MagicMock, settings: PurviewSettings) -> PurviewClient:
"""Create a PurviewClient with mock credential."""
client = PurviewClient(mock_credential, settings, timeout=10.0)
yield client
await client.close()
async def test_client_initialization(self, mock_credential: MagicMock, settings: PurviewSettings) -> None:
"""Test PurviewClient initialization."""
client = PurviewClient(mock_credential, settings)
assert client._credential == mock_credential
assert client._settings == settings
assert client._graph_uri == "https://graph.microsoft.com/v1.0"
assert client._timeout == 10.0
await client.close()
async def test_get_token_async_credential(self, client: PurviewClient, mock_credential: MagicMock) -> None:
"""Test _get_token with async credential."""
token = await client._get_token(tenant_id="test-tenant")
assert token == "fake-token"
async def test_get_token_sync_credential(self, settings: PurviewSettings) -> None:
"""Test _get_token with sync credential."""
sync_credential = MagicMock()
sync_credential.get_token = MagicMock(return_value=AccessToken("sync-token", 9999999999))
client = PurviewClient(sync_credential, settings)
with patch("asyncio.get_running_loop") as mock_loop:
mock_executor = AsyncMock()
mock_executor.return_value = AccessToken("sync-token", 9999999999)
mock_loop.return_value.run_in_executor = mock_executor
token = await client._get_token(tenant_id="test-tenant")
assert token == "sync-token"
await client.close()
async def test_get_user_info_from_token(self, client: PurviewClient) -> None:
"""Test get_user_info_from_token extracts user info."""
import base64
import json
payload = {"tid": "test-tenant", "oid": "test-user", "idtyp": "user"}
payload_str = json.dumps(payload)
payload_bytes = payload_str.encode("utf-8")
payload_b64 = base64.urlsafe_b64encode(payload_bytes).decode("utf-8").rstrip("=")
fake_token = f"header.{payload_b64}.signature"
with patch.object(client, "_get_token", return_value=fake_token):
user_info = await client.get_user_info_from_token(tenant_id="test-tenant")
assert user_info["tenant_id"] == "test-tenant"
assert user_info["user_id"] == "test-user"
@pytest.mark.parametrize(
"status_code,exception_type",
[
(401, PurviewAuthenticationError),
(403, PurviewAuthenticationError),
(429, PurviewRateLimitError),
(400, PurviewRequestError),
(404, PurviewRequestError),
(500, PurviewServiceError),
(502, PurviewServiceError),
],
)
async def test_post_error_handling(
self, client: PurviewClient, content_to_process_factory, status_code: int, exception_type: type[Exception]
) -> None:
"""Test _post method handles different HTTP errors correctly."""
from agent_framework_purview._models import ProcessContentResponse
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = status_code
mock_response.text = "Error message"
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Error", request=MagicMock(), response=mock_response
)
with patch.object(client._client, "post", return_value=mock_response), pytest.raises(exception_type):
await client._post(
"https://graph.microsoft.com/v1.0/test",
request,
ProcessContentResponse,
"fake-token",
)
async def test_process_content_success(
self, client: PurviewClient, content_to_process_factory, mock_credential: MagicMock
) -> None:
"""Test process_content method success path."""
content = content_to_process_factory("Test message")
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"id": "response-123", "protectionScopeState": "notModified"}
with patch.object(client._client, "post", return_value=mock_response):
response = await client.process_content(request)
assert response.id == "response-123"
assert response.protection_scope_state == "notModified"
async def test_get_protection_scopes_success(self, client: PurviewClient) -> None:
"""Test get_protection_scopes method success path."""
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request = ProtectionScopesRequest(
user_id="user-123", tenant_id="tenant-456", locations=[location], correlation_id="corr-789"
)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"scopeIdentifier": "scope-123", "value": []}
with patch.object(client._client, "post", return_value=mock_response):
response = await client.get_protection_scopes(request)
assert response.scope_identifier == "scope-123"
assert response.scopes == []
async def test_client_close(self, mock_credential: AsyncMock, settings: PurviewSettings) -> None:
"""Test client properly closes HTTP client."""
client = PurviewClient(mock_credential, settings)
with patch.object(client._client, "aclose", new_callable=AsyncMock) as mock_close:
await client.close()
mock_close.assert_called_once()
async def test_invalid_jwt_token_format(self, client: PurviewClient) -> None:
"""Test that invalid JWT token format raises ValueError."""
with pytest.raises(ValueError, match="Invalid JWT token format"):
client._extract_token_info("invalid-token-without-dots")
async def test_rate_limit_error(self, client: PurviewClient) -> None:
"""Test that 429 status code raises PurviewRateLimitError."""
request = ProcessContentRequest(
user_id="test-user",
tenant_id="test-tenant",
content_to_process=[],
correlation_id="test-correlation-id",
)
with (
patch.object(client, "_get_token", return_value="fake-token"),
patch.object(
client._client,
"post",
return_value=httpx.Response(429, text="Rate limited", request=httpx.Request("POST", "http://test")),
),
pytest.raises(PurviewRateLimitError, match="Rate limited"),
):
await client.process_content(request)
async def test_generic_request_error(self, client: PurviewClient) -> None:
"""Test that non-200/201/202 status codes raise PurviewRequestError."""
request = ProcessContentRequest(
user_id="test-user",
tenant_id="test-tenant",
content_to_process=[],
correlation_id="test-correlation-id",
)
with (
patch.object(client, "_get_token", return_value="fake-token"),
patch.object(
client._client,
"post",
return_value=httpx.Response(
500, text="Internal server error", request=httpx.Request("POST", "http://test")
),
),
pytest.raises(PurviewRequestError, match="Purview request failed"),
):
await client.process_content(request)
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview exceptions."""
from agent_framework_purview import (
PurviewAuthenticationError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
)
class TestPurviewExceptions:
"""Test custom Purview exception classes."""
def test_purview_service_error(self) -> None:
"""Test PurviewServiceError base exception."""
error = PurviewServiceError("Service error occurred")
assert str(error) == "Service error occurred"
assert isinstance(error, Exception)
def test_purview_authentication_error(self) -> None:
"""Test PurviewAuthenticationError exception."""
error = PurviewAuthenticationError("Authentication failed")
assert str(error) == "Authentication failed"
assert isinstance(error, PurviewServiceError)
def test_purview_rate_limit_error(self) -> None:
"""Test PurviewRateLimitError exception."""
error = PurviewRateLimitError("Rate limit exceeded")
assert str(error) == "Rate limit exceeded"
assert isinstance(error, PurviewServiceError)
def test_purview_request_error(self) -> None:
"""Test PurviewRequestError exception."""
error = PurviewRequestError("Request failed")
assert str(error) == "Request failed"
assert isinstance(error, PurviewServiceError)
@@ -0,0 +1,201 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview middleware."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentRunContext, AgentRunResponse, ChatMessage, Role
from azure.core.credentials import AccessToken
from agent_framework_purview import PurviewPolicyMiddleware, PurviewSettings
class TestPurviewPolicyMiddleware:
"""Test PurviewPolicyMiddleware functionality."""
@pytest.fixture
def mock_credential(self) -> AsyncMock:
"""Create a mock async credential."""
credential = AsyncMock()
credential.get_token = AsyncMock(return_value=AccessToken("fake-token", 9999999999))
return credential
@pytest.fixture
def settings(self) -> PurviewSettings:
"""Create test settings."""
return PurviewSettings(app_name="Test App", tenant_id="test-tenant")
@pytest.fixture
def middleware(self, mock_credential: AsyncMock, settings: PurviewSettings) -> PurviewPolicyMiddleware:
"""Create PurviewPolicyMiddleware instance."""
return PurviewPolicyMiddleware(mock_credential, settings)
@pytest.fixture
def mock_agent(self) -> MagicMock:
"""Create a mock agent."""
agent = MagicMock()
agent.name = "test-agent"
return agent
def test_middleware_initialization(self, mock_credential: AsyncMock, settings: PurviewSettings) -> None:
"""Test PurviewPolicyMiddleware initialization."""
middleware = PurviewPolicyMiddleware(mock_credential, settings)
assert middleware._client is not None
assert middleware._processor is not None
async def test_middleware_allows_clean_prompt(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware allows prompt that passes policy check."""
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello, how are you?")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
next_called = False
async def mock_next(ctx: AgentRunContext) -> None:
nonlocal next_called
next_called = True
ctx.result = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="I'm good, thanks!")])
await middleware.process(context, mock_next)
assert next_called
assert context.result is not None
assert not context.terminate
async def test_middleware_blocks_prompt_on_policy_violation(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware blocks prompt that violates policy."""
context = AgentRunContext(
agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Sensitive information")]
)
with patch.object(middleware._processor, "process_messages", return_value=(True, "user-123")):
next_called = False
async def mock_next(ctx: AgentRunContext) -> None:
nonlocal next_called
next_called = True
await middleware.process(context, mock_next)
assert not next_called
assert context.result is not None
assert context.terminate
assert len(context.result.messages) == 1
assert context.result.messages[0].role == Role.SYSTEM
assert "blocked by policy" in context.result.messages[0].text.lower()
async def test_middleware_checks_response(self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock) -> None:
"""Test middleware checks agent response for policy violations."""
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
call_count = 0
async def mock_process_messages(messages, activity, user_id=None):
nonlocal call_count
call_count += 1
should_block = call_count != 1
return (should_block, "user-123")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = AgentRunResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Here's some sensitive information")]
)
await middleware.process(context, mock_next)
assert call_count == 2
assert context.result is not None
assert len(context.result.messages) == 1
assert context.result.messages[0].role == Role.SYSTEM
assert "blocked by policy" in context.result.messages[0].text.lower()
async def test_middleware_handles_result_without_messages(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware handles result that doesn't have messages attribute."""
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = "Some non-standard result"
await middleware.process(context, mock_next)
assert context.result == "Some non-standard result"
async def test_middleware_processor_receives_correct_activity(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware passes correct activity type to processor."""
from agent_framework_purview._models import Activity
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_process:
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Response")])
await middleware.process(context, mock_next)
assert mock_process.call_count == 2
for call in mock_process.call_args_list:
assert call[0][1] == Activity.UPLOAD_TEXT
async def test_middleware_handles_pre_check_exception(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that exceptions in pre-check are logged but don't stop processing."""
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
with patch.object(
middleware._processor, "process_messages", side_effect=Exception("Pre-check error")
) as mock_process:
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Response")])
await middleware.process(context, mock_next)
# Should have been called twice (pre-check raises, then post-check also raises)
assert mock_process.call_count == 2
# Context should not be terminated
assert not context.terminate
# Result should be set by mock_next
assert context.result is not None
async def test_middleware_handles_post_check_exception(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that exceptions in post-check are logged but don't affect result."""
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
call_count = 0
async def mock_process_messages(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return (False, "user-123") # Pre-check succeeds
raise Exception("Post-check error") # Post-check fails
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: AgentRunContext) -> None:
ctx.result = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Response")])
await middleware.process(context, mock_next)
# Should have been called twice (pre and post)
assert call_count == 2
# Result should still be set
assert context.result is not None
assert hasattr(context.result, "messages")
@@ -0,0 +1,246 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview models and serialization."""
from agent_framework_purview._models import (
Activity,
ActivityMetadata,
ContentToProcess,
DeviceMetadata,
IntegratedAppMetadata,
OperatingSystemSpecifications,
PolicyLocation,
ProcessContentRequest,
ProcessContentResponse,
ProcessConversationMetadata,
ProtectedAppMetadata,
ProtectionScopeActivities,
ProtectionScopesRequest,
ProtectionScopesResponse,
PurviewTextContent,
deserialize_flag,
serialize_flag,
)
class TestFlagOperations:
"""Test flag serialization and deserialization operations."""
def test_protection_scope_activities_flag_combination(self) -> None:
"""Test combining flags."""
combined = ProtectionScopeActivities.UPLOAD_TEXT | ProtectionScopeActivities.UPLOAD_FILE
assert combined.value == 3
assert ProtectionScopeActivities.UPLOAD_TEXT in combined
assert ProtectionScopeActivities.UPLOAD_FILE in combined
def test_deserialize_flag_with_string(self) -> None:
"""Test deserializing flag from comma-separated string."""
mapping = {
"uploadText": ProtectionScopeActivities.UPLOAD_TEXT,
"uploadFile": ProtectionScopeActivities.UPLOAD_FILE,
}
result = deserialize_flag("uploadText,uploadFile", mapping, ProtectionScopeActivities)
assert result is not None
assert ProtectionScopeActivities.UPLOAD_TEXT in result
assert ProtectionScopeActivities.UPLOAD_FILE in result
def test_deserialize_flag_with_none(self) -> None:
"""Test deserializing None returns None."""
mapping = {"uploadText": ProtectionScopeActivities.UPLOAD_TEXT}
result = deserialize_flag(None, mapping, ProtectionScopeActivities)
assert result is None
def test_serialize_flag_with_none(self) -> None:
"""Test serializing None returns None."""
result = serialize_flag(None, [])
assert result is None
def test_serialize_flag_with_values(self) -> None:
"""Test serializing flag with values."""
flag = ProtectionScopeActivities.UPLOAD_TEXT | ProtectionScopeActivities.UPLOAD_FILE
ordered = [
("uploadText", ProtectionScopeActivities.UPLOAD_TEXT),
("uploadFile", ProtectionScopeActivities.UPLOAD_FILE),
]
result = serialize_flag(flag, ordered)
assert result == "uploadText,uploadFile"
class TestComplexModels:
"""Test complex models with nested structures."""
def test_content_to_process_with_nested_structures(self) -> None:
"""Test ContentToProcess with all nested structures."""
text_content = PurviewTextContent(data="Test")
metadata = ProcessConversationMetadata(
identifier="msg-1",
content=text_content,
name="Test",
is_truncated=False,
)
activity_meta = ActivityMetadata(activity=Activity.UPLOAD_TEXT)
device_meta = DeviceMetadata(
operating_system_specifications=OperatingSystemSpecifications(
operating_system_platform="Windows", operating_system_version="10"
)
)
integrated_app = IntegratedAppMetadata(name="App", version="1.0")
location = PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")
protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location)
content = ContentToProcess(
content_entries=[metadata],
activity_metadata=activity_meta,
device_metadata=device_meta,
integrated_app_metadata=integrated_app,
protected_app_metadata=protected_app,
)
assert len(content.content_entries) == 1
assert content.activity_metadata.activity == Activity.UPLOAD_TEXT
assert content.device_metadata.operating_system_specifications.operating_system_platform == "Windows"
assert content.integrated_app_metadata.name == "App"
assert content.protected_app_metadata.name == "Protected"
class TestRequestResponseSerialization:
"""Test request/response serialization with aliases."""
def test_protection_scopes_request_serialization(self) -> None:
"""Test ProtectionScopesRequest serializes activities correctly."""
location = PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")
request = ProtectionScopesRequest(
user_id="user-123",
tenant_id="tenant-456",
activities=ProtectionScopeActivities.UPLOAD_TEXT | ProtectionScopeActivities.UPLOAD_FILE,
locations=[location],
)
dumped = request.model_dump(by_alias=True, exclude_none=True, mode="json")
assert "activities" in dumped
assert isinstance(dumped["activities"], str)
assert "uploadText" in dumped["activities"]
class TestModelDeserialization:
"""Test model deserialization from API responses."""
def test_protection_scopes_response_deserialization(self) -> None:
"""Test ProtectionScopesResponse deserializes 'value' to 'scopes'."""
api_data = {
"scopeIdentifier": "scope-123",
"value": [
{
"activities": "uploadText,downloadText",
"locations": [{"@odata.type": "location.type", "value": "/path"}],
"policyActions": [{"action": "warn", "restrictionAction": "blockAccess"}],
"executionMode": "evaluateInline",
}
],
}
response = ProtectionScopesResponse.model_validate(api_data)
assert response.scope_identifier == "scope-123"
assert response.scopes is not None
assert len(response.scopes) == 1
assert response.scopes[0].execution_mode == "evaluateInline"
def test_process_content_response_deserialization(self) -> None:
"""Test ProcessContentResponse deserializes aliased fields correctly."""
api_data = {
"id": "response-123",
"protectionScopeState": "blocked",
"policyActions": [{"action": "block", "restrictionAction": "blockAccess"}],
}
response = ProcessContentResponse.model_validate(api_data)
assert response.id == "response-123"
assert response.protection_scope_state == "blocked"
assert len(response.policy_actions) == 1
def test_content_serialization_uses_aliases(self) -> None:
"""Test ContentToProcess serializes with camelCase aliases."""
text_content = PurviewTextContent(data="Test")
metadata = ProcessConversationMetadata(
identifier="msg-1",
content=text_content,
name="Test",
is_truncated=False,
)
activity_meta = ActivityMetadata(activity=Activity.UPLOAD_TEXT)
device_meta = DeviceMetadata(
operating_system_specifications=OperatingSystemSpecifications(
operating_system_platform="Windows", operating_system_version="10"
)
)
integrated_app = IntegratedAppMetadata(name="App", version="1.0")
location = PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")
protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location)
content = ContentToProcess(
content_entries=[metadata],
activity_metadata=activity_meta,
device_metadata=device_meta,
integrated_app_metadata=integrated_app,
protected_app_metadata=protected_app,
)
dumped = content.model_dump(by_alias=True, exclude_none=True, mode="json")
assert "contentEntries" in dumped
assert "activityMetadata" in dumped
assert "deviceMetadata" in dumped
assert "integratedAppMetadata" in dumped
assert "protectedAppMetadata" in dumped
def test_process_content_request_excludes_private_fields(self) -> None:
"""Test ProcessContentRequest excludes private fields when serializing."""
text_content = PurviewTextContent(data="Test")
metadata = ProcessConversationMetadata(
identifier="msg-1",
content=text_content,
name="Test",
is_truncated=False,
)
activity_meta = ActivityMetadata(activity=Activity.UPLOAD_TEXT)
device_meta = DeviceMetadata(
operating_system_specifications=OperatingSystemSpecifications(
operating_system_platform="Windows", operating_system_version="10"
)
)
integrated_app = IntegratedAppMetadata(name="App", version="1.0")
location = PolicyLocation(data_type="microsoft.graph.policyLocationApplication", value="app-id")
protected_app = ProtectedAppMetadata(name="Protected", version="1.0", application_location=location)
content = ContentToProcess(
content_entries=[metadata],
activity_metadata=activity_meta,
device_metadata=device_meta,
integrated_app_metadata=integrated_app,
protected_app_metadata=protected_app,
)
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
correlation_id="corr-789",
)
dumped = request.model_dump(by_alias=True, exclude_none=True, mode="json")
# Check that excluded fields are not present
assert "user_id" not in dumped
assert "tenant_id" not in dumped
assert "correlation_id" not in dumped
# Check that content is present
assert "contentToProcess" in dumped
@@ -0,0 +1,369 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview processor."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import ChatMessage, Role
from agent_framework_purview import PurviewAppLocation, PurviewLocationType, PurviewSettings
from agent_framework_purview._models import (
Activity,
DlpAction,
DlpActionInfo,
ProcessContentResponse,
RestrictionAction,
)
from agent_framework_purview._processor import ScopedContentProcessor, _is_valid_guid
class TestGuidValidation:
"""Test GUID validation helper."""
def test_valid_guid(self) -> None:
"""Test _is_valid_guid with valid GUIDs."""
assert _is_valid_guid("12345678-1234-1234-1234-123456789012")
assert _is_valid_guid("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d")
def test_invalid_guid(self) -> None:
"""Test _is_valid_guid with invalid GUIDs."""
assert not _is_valid_guid("not-a-guid")
assert not _is_valid_guid("")
assert not _is_valid_guid(None)
class TestScopedContentProcessor:
"""Test ScopedContentProcessor functionality."""
@pytest.fixture
def mock_client(self) -> AsyncMock:
"""Create a mock Purview client."""
client = AsyncMock()
client.get_user_info_from_token = AsyncMock(
return_value={
"tenant_id": "12345678-1234-1234-1234-123456789012",
"user_id": "12345678-1234-1234-1234-123456789012",
"client_id": "12345678-1234-1234-1234-123456789012",
}
)
return client
@pytest.fixture
def settings_with_defaults(self) -> PurviewSettings:
"""Create settings with default values."""
app_location = PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="12345678-1234-1234-1234-123456789012"
)
return PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=app_location,
)
@pytest.fixture
def settings_without_defaults(self) -> PurviewSettings:
"""Create settings without default values (requiring token info)."""
return PurviewSettings(app_name="Test App")
@pytest.fixture
def processor(self, mock_client: AsyncMock, settings_with_defaults: PurviewSettings) -> ScopedContentProcessor:
"""Create a ScopedContentProcessor with mock client."""
return ScopedContentProcessor(mock_client, settings_with_defaults)
async def test_processor_initialization(
self, mock_client: AsyncMock, settings_with_defaults: PurviewSettings
) -> None:
"""Test ScopedContentProcessor initialization."""
processor = ScopedContentProcessor(mock_client, settings_with_defaults)
assert processor._client == mock_client
assert processor._settings == settings_with_defaults
async def test_process_messages_with_defaults(self, processor: ScopedContentProcessor) -> None:
"""Test process_messages with settings that have defaults."""
messages = [
ChatMessage(role=Role.USER, text="Hello"),
ChatMessage(role=Role.ASSISTANT, text="Hi there"),
]
with patch.object(processor, "_map_messages", return_value=([], None)) as mock_map:
should_block, user_id = await processor.process_messages(messages, Activity.UPLOAD_TEXT)
assert should_block is False
assert user_id is None
mock_map.assert_called_once_with(messages, Activity.UPLOAD_TEXT, None)
async def test_process_messages_blocks_content(
self, processor: ScopedContentProcessor, process_content_request_factory
) -> None:
"""Test process_messages returns True when content should be blocked."""
messages = [ChatMessage(role=Role.USER, text="Sensitive content")]
mock_request = process_content_request_factory("Sensitive content")
mock_response = ProcessContentResponse(**{
"policyActions": [DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restrictionAction=RestrictionAction.BLOCK)]
})
with (
patch.object(processor, "_map_messages", return_value=([mock_request], "user-123")),
patch.object(processor, "_process_with_scopes", return_value=mock_response),
):
should_block, user_id = await processor.process_messages(messages, Activity.UPLOAD_TEXT)
assert should_block is True
assert user_id == "user-123"
async def test_map_messages_creates_requests(
self, processor: ScopedContentProcessor, mock_client: AsyncMock
) -> None:
"""Test _map_messages creates ProcessContentRequest objects."""
messages = [
ChatMessage(
role=Role.USER,
text="Test message",
message_id="msg-123",
author_name="12345678-1234-1234-1234-123456789012",
),
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
assert len(requests) == 1
assert requests[0].user_id == "12345678-1234-1234-1234-123456789012"
assert requests[0].tenant_id == "12345678-1234-1234-1234-123456789012"
assert user_id == "12345678-1234-1234-1234-123456789012"
async def test_map_messages_without_defaults_gets_token_info(self, mock_client: AsyncMock) -> None:
"""Test _map_messages gets token info when settings lack some defaults."""
settings = PurviewSettings(app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012")
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test", message_id="msg-123")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
mock_client.get_user_info_from_token.assert_called_once()
assert len(requests) == 1
assert user_id is not None
async def test_map_messages_raises_on_missing_tenant_id(self, mock_client: AsyncMock) -> None:
"""Test _map_messages raises ValueError when tenant_id cannot be determined."""
settings = PurviewSettings(app_name="Test App") # No tenant_id
processor = ScopedContentProcessor(mock_client, settings)
mock_client.get_user_info_from_token = AsyncMock(
return_value={"user_id": "test-user", "client_id": "test-client"}
)
messages = [ChatMessage(role=Role.USER, text="Test", message_id="msg-123")]
with pytest.raises(ValueError, match="Tenant id required"):
await processor._map_messages(messages, Activity.UPLOAD_TEXT)
async def test_check_applicable_scopes_no_scopes(
self, processor: ScopedContentProcessor, process_content_request_factory
) -> None:
"""Test _check_applicable_scopes when no scopes are returned."""
from agent_framework_purview._models import ProtectionScopesResponse
request = process_content_request_factory()
response = ProtectionScopesResponse(**{"value": None})
should_process, actions = processor._check_applicable_scopes(request, response)
assert should_process is False
assert actions == []
async def test_check_applicable_scopes_with_block_action(
self, processor: ScopedContentProcessor, process_content_request_factory
) -> None:
"""Test _check_applicable_scopes identifies block actions."""
from agent_framework_purview._models import (
PolicyLocation,
PolicyScope,
ProtectionScopeActivities,
ProtectionScopesResponse,
)
request = process_content_request_factory()
block_action = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restrictionAction=RestrictionAction.BLOCK)
scope_location = PolicyLocation(**{
"@odata.type": "microsoft.graph.policyLocationApplication",
"value": "app-id",
})
scope = PolicyScope(**{
"policyActions": [block_action],
"activities": ProtectionScopeActivities.UPLOAD_TEXT,
"locations": [scope_location],
})
response = ProtectionScopesResponse(**{"value": [scope]})
should_process, actions = processor._check_applicable_scopes(request, response)
assert should_process is True
assert len(actions) == 1
assert actions[0].action == DlpAction.BLOCK_ACCESS
async def test_combine_policy_actions(self, processor: ScopedContentProcessor) -> None:
"""Test _combine_policy_actions merges action lists."""
action1 = DlpActionInfo(action=DlpAction.BLOCK_ACCESS, restrictionAction=RestrictionAction.BLOCK)
action2 = DlpActionInfo(action=DlpAction.OTHER, restrictionAction=RestrictionAction.OTHER)
combined = processor._combine_policy_actions([action1], [action2])
assert len(combined) == 2
assert action1 in combined
assert action2 in combined
async def test_process_with_scopes_calls_client_methods(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test _process_with_scopes calls get_protection_scopes and process_content."""
from agent_framework_purview._models import (
ContentActivitiesResponse,
ProtectionScopesResponse,
)
request = process_content_request_factory()
mock_client.get_protection_scopes = AsyncMock(return_value=ProtectionScopesResponse(**{"value": []}))
mock_client.process_content = AsyncMock(
return_value=ProcessContentResponse(**{"id": "response-123", "protectionScopeState": "notModified"})
)
mock_client.send_content_activities = AsyncMock(return_value=ContentActivitiesResponse(**{"error": None}))
response = await processor._process_with_scopes(request)
mock_client.get_protection_scopes.assert_called_once()
mock_client.process_content.assert_not_called()
mock_client.send_content_activities.assert_called_once()
assert response.id is None
async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None:
"""Test user_id extraction from message additional_properties."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER,
text="Test message",
additional_properties={"user_id": "22345678-1234-1234-1234-123456789012"},
),
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
assert len(requests) == 1
assert user_id == "22345678-1234-1234-1234-123456789012"
assert requests[0].user_id == "22345678-1234-1234-1234-123456789012"
async def test_map_messages_with_provided_user_id_fallback(self, mock_client: AsyncMock) -> None:
"""Test using provided_user_id when no other source is available."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test message")]
requests, user_id = await processor._map_messages(
messages, Activity.UPLOAD_TEXT, provided_user_id="32345678-1234-1234-1234-123456789012"
)
assert len(requests) == 1
assert user_id == "32345678-1234-1234-1234-123456789012"
assert requests[0].user_id == "32345678-1234-1234-1234-123456789012"
async def test_map_messages_returns_empty_when_no_user_id(self, mock_client: AsyncMock) -> None:
"""Test that empty results are returned when user_id cannot be resolved."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test message")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
assert len(requests) == 0
assert user_id is None
async def test_process_content_sends_activities_when_not_applicable(
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test that content activities are sent when scopes don't apply."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
processor = ScopedContentProcessor(mock_client, settings)
pc_request = process_content_request_factory()
# Mock get_protection_scopes to return no applicable scopes
mock_ps_response = MagicMock()
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
# Mock send_content_activities to return success
mock_ca_response = MagicMock()
mock_ca_response.error = None
mock_client.send_content_activities.return_value = mock_ca_response
response = await processor._process_with_scopes(pc_request)
mock_client.get_protection_scopes.assert_called_once()
mock_client.process_content.assert_not_called()
mock_client.send_content_activities.assert_called_once()
# When content activities succeed, response has no errors (processing_errors can be None or empty)
assert response.processing_errors is None or response.processing_errors == []
async def test_process_content_handles_activities_error(
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test error handling when content activities fail."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
processor = ScopedContentProcessor(mock_client, settings)
pc_request = process_content_request_factory()
# Mock get_protection_scopes to return no applicable scopes
mock_ps_response = MagicMock()
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
# Mock send_content_activities to return error
mock_ca_response = MagicMock()
mock_ca_response.error = "Test error message"
mock_client.send_content_activities.return_value = mock_ca_response
response = await processor._process_with_scopes(pc_request)
assert len(response.processing_errors) == 1
assert response.processing_errors[0].message == "Test error message"
@@ -0,0 +1,85 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview settings."""
import pytest
from agent_framework_purview import PurviewAppLocation, PurviewLocationType, PurviewSettings
class TestPurviewSettings:
"""Test PurviewSettings configuration."""
def test_settings_defaults(self) -> None:
"""Test PurviewSettings with default values."""
settings = PurviewSettings(app_name="Test App")
assert settings.app_name == "Test App"
assert settings.graph_base_uri == "https://graph.microsoft.com/v1.0/"
assert settings.tenant_id is None
assert settings.purview_app_location is None
assert settings.process_inline is False
def test_settings_with_custom_values(self) -> None:
"""Test PurviewSettings with custom values."""
app_location = PurviewAppLocation(location_type=PurviewLocationType.APPLICATION, location_value="app-123")
settings = PurviewSettings(
app_name="Test App",
graph_base_uri="https://graph.microsoft-ppe.com",
tenant_id="test-tenant-id",
process_inline=True,
purview_app_location=app_location,
)
assert settings.graph_base_uri == "https://graph.microsoft-ppe.com"
assert settings.tenant_id == "test-tenant-id"
assert settings.process_inline is True
assert settings.purview_app_location.location_value == "app-123"
@pytest.mark.parametrize(
"graph_uri,expected_scope",
[
("https://graph.microsoft.com/v1.0/", "https://graph.microsoft.com/.default"),
("https://graph.microsoft-ppe.com/v1.0/", "https://graph.microsoft-ppe.com/.default"),
],
)
def test_get_scopes(self, graph_uri: str, expected_scope: str) -> None:
"""Test get_scopes returns correct scope for different URIs."""
settings = PurviewSettings(app_name="Test App", graph_base_uri=graph_uri)
scopes = settings.get_scopes()
assert len(scopes) == 1
assert expected_scope in scopes
class TestPurviewAppLocation:
"""Test PurviewAppLocation configuration."""
@pytest.mark.parametrize(
"location_type,location_value,expected_odata_type",
[
(PurviewLocationType.APPLICATION, "app-123", "microsoft.graph.policyLocationApplication"),
(PurviewLocationType.URI, "https://example.com", "microsoft.graph.policyLocationUrl"),
(PurviewLocationType.DOMAIN, "example.com", "microsoft.graph.policyLocationDomain"),
],
)
def test_get_policy_location(
self, location_type: PurviewLocationType, location_value: str, expected_odata_type: str
) -> None:
"""Test get_policy_location returns correct structure for all location types."""
location = PurviewAppLocation(location_type=location_type, location_value=location_value)
policy_location = location.get_policy_location()
assert policy_location["@odata.type"] == expected_odata_type
assert policy_location["value"] == location_value
class TestPurviewLocationType:
"""Test PurviewLocationType enum."""
def test_location_type_values(self) -> None:
"""Test PurviewLocationType enum has expected values."""
assert PurviewLocationType.APPLICATION == "application"
assert PurviewLocationType.URI == "uri"
assert PurviewLocationType.DOMAIN == "domain"