mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [Purview] Add Caching and background processing in Python Purview Middleware (#1844)
* [PythonPurview] Add Caching and background processing * [PythonPurview] Updates based on comments
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for Purview cache provider."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework_purview._cache import (
|
||||
InMemoryCacheProvider,
|
||||
create_protection_scopes_cache_key,
|
||||
)
|
||||
from agent_framework_purview._models import PolicyLocation, ProtectionScopesRequest
|
||||
|
||||
|
||||
class TestInMemoryCacheProvider:
|
||||
"""Test InMemoryCacheProvider functionality."""
|
||||
|
||||
async def test_cache_set_and_get(self) -> None:
|
||||
"""Test basic set and get operations."""
|
||||
cache = InMemoryCacheProvider()
|
||||
|
||||
await cache.set("key1", "value1")
|
||||
result = await cache.get("key1")
|
||||
|
||||
assert result == "value1"
|
||||
|
||||
async def test_cache_get_nonexistent_key(self) -> None:
|
||||
"""Test get returns None for non-existent key."""
|
||||
cache = InMemoryCacheProvider()
|
||||
|
||||
result = await cache.get("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_cache_expiration(self) -> None:
|
||||
"""Test that cached values expire after TTL."""
|
||||
cache = InMemoryCacheProvider(default_ttl_seconds=1)
|
||||
|
||||
await cache.set("key1", "value1")
|
||||
result = await cache.get("key1")
|
||||
assert result == "value1"
|
||||
|
||||
await asyncio.sleep(1.1)
|
||||
result = await cache.get("key1")
|
||||
assert result is None
|
||||
|
||||
async def test_cache_custom_ttl(self) -> None:
|
||||
"""Test that custom TTL overrides default."""
|
||||
cache = InMemoryCacheProvider(default_ttl_seconds=10)
|
||||
|
||||
await cache.set("key1", "value1", ttl_seconds=1)
|
||||
result = await cache.get("key1")
|
||||
assert result == "value1"
|
||||
|
||||
await asyncio.sleep(1.1)
|
||||
result = await cache.get("key1")
|
||||
assert result is None
|
||||
|
||||
async def test_cache_update_existing_key(self) -> None:
|
||||
"""Test updating an existing cache entry."""
|
||||
cache = InMemoryCacheProvider()
|
||||
|
||||
await cache.set("key1", "value1")
|
||||
await cache.set("key1", "value2")
|
||||
result = await cache.get("key1")
|
||||
|
||||
assert result == "value2"
|
||||
|
||||
async def test_cache_remove(self) -> None:
|
||||
"""Test removing a cache entry."""
|
||||
cache = InMemoryCacheProvider()
|
||||
|
||||
await cache.set("key1", "value1")
|
||||
await cache.remove("key1")
|
||||
result = await cache.get("key1")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_cache_remove_nonexistent_key(self) -> None:
|
||||
"""Test removing non-existent key does not raise error."""
|
||||
cache = InMemoryCacheProvider()
|
||||
|
||||
await cache.remove("nonexistent")
|
||||
|
||||
async def test_cache_size_limit_eviction(self) -> None:
|
||||
"""Test that cache evicts old entries when size limit is reached."""
|
||||
cache = InMemoryCacheProvider(max_size_bytes=200)
|
||||
|
||||
await cache.set("key1", "a" * 50)
|
||||
await cache.set("key2", "b" * 50)
|
||||
await cache.set("key3", "c" * 50)
|
||||
|
||||
await cache.set("key4", "d" * 100)
|
||||
|
||||
result1 = await cache.get("key1")
|
||||
assert result1 is None
|
||||
|
||||
async def test_estimate_size_with_pydantic_model(self) -> None:
|
||||
"""Test size estimation with Pydantic models."""
|
||||
cache = InMemoryCacheProvider()
|
||||
|
||||
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
|
||||
request = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
|
||||
|
||||
await cache.set("key1", request)
|
||||
result = await cache.get("key1")
|
||||
|
||||
assert result == request
|
||||
|
||||
async def test_estimate_size_fallback(self) -> None:
|
||||
"""Test size estimation fallback for non-serializable objects."""
|
||||
cache = InMemoryCacheProvider()
|
||||
|
||||
class CustomObject:
|
||||
pass
|
||||
|
||||
obj = CustomObject()
|
||||
await cache.set("key1", obj)
|
||||
result = await cache.get("key1")
|
||||
|
||||
assert result == obj
|
||||
|
||||
async def test_cache_multiple_updates(self) -> None:
|
||||
"""Test that updating a key multiple times maintains correct size tracking."""
|
||||
cache = InMemoryCacheProvider(max_size_bytes=1000)
|
||||
|
||||
await cache.set("key1", "a" * 100)
|
||||
initial_size = cache._current_size_bytes
|
||||
|
||||
await cache.set("key1", "b" * 200)
|
||||
|
||||
assert cache._current_size_bytes != initial_size
|
||||
|
||||
async def test_eviction_with_stale_heap_entries(self) -> None:
|
||||
"""Test that eviction correctly handles stale heap entries."""
|
||||
cache = InMemoryCacheProvider(max_size_bytes=500)
|
||||
|
||||
await cache.set("key1", "a" * 100, ttl_seconds=10)
|
||||
await cache.set("key2", "b" * 100, ttl_seconds=10)
|
||||
await cache.set("key1", "c" * 100, ttl_seconds=20)
|
||||
|
||||
await cache.set("key3", "d" * 300)
|
||||
|
||||
result = await cache.get("key1")
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestCreateProtectionScopesCacheKey:
|
||||
"""Test cache key generation for ProtectionScopesRequest."""
|
||||
|
||||
def test_cache_key_deterministic(self) -> None:
|
||||
"""Test that same request generates same cache key."""
|
||||
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
|
||||
request1 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
|
||||
request2 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
|
||||
|
||||
key1 = create_protection_scopes_cache_key(request1)
|
||||
key2 = create_protection_scopes_cache_key(request2)
|
||||
|
||||
assert key1 == key2
|
||||
|
||||
def test_cache_key_different_for_different_requests(self) -> None:
|
||||
"""Test that different requests generate different cache keys."""
|
||||
location1 = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id1"})
|
||||
location2 = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id2"})
|
||||
request1 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location1])
|
||||
request2 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location2])
|
||||
|
||||
key1 = create_protection_scopes_cache_key(request1)
|
||||
key2 = create_protection_scopes_cache_key(request2)
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
def test_cache_key_excludes_correlation_id(self) -> None:
|
||||
"""Test that correlation_id is excluded from cache key."""
|
||||
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
|
||||
request1 = ProtectionScopesRequest(
|
||||
user_id="user1", tenant_id="tenant1", locations=[location], correlation_id="corr1"
|
||||
)
|
||||
request2 = ProtectionScopesRequest(
|
||||
user_id="user1", tenant_id="tenant1", locations=[location], correlation_id="corr2"
|
||||
)
|
||||
|
||||
key1 = create_protection_scopes_cache_key(request1)
|
||||
key2 = create_protection_scopes_cache_key(request2)
|
||||
|
||||
assert key1 == key2
|
||||
|
||||
def test_cache_key_format(self) -> None:
|
||||
"""Test that cache key has expected format."""
|
||||
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
|
||||
request = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
|
||||
|
||||
key = create_protection_scopes_cache_key(request)
|
||||
|
||||
assert key.startswith("purview:protection_scopes:")
|
||||
assert len(key) > len("purview:protection_scopes:")
|
||||
@@ -74,7 +74,8 @@ class TestPurviewChatPolicyMiddleware:
|
||||
await middleware.process(chat_context, mock_next)
|
||||
assert chat_context.terminate
|
||||
assert chat_context.result
|
||||
msg = chat_context.result[0] # type: ignore[index]
|
||||
assert hasattr(chat_context.result, "messages")
|
||||
msg = chat_context.result.messages[0]
|
||||
assert msg.role in ("system", Role.SYSTEM)
|
||||
assert "blocked" in msg.text.lower()
|
||||
|
||||
@@ -112,7 +113,7 @@ class TestPurviewChatPolicyMiddleware:
|
||||
chat_options=chat_options,
|
||||
is_streaming=True,
|
||||
)
|
||||
with patch.object(middleware._processor, "process_messages", return_value=False) as mock_proc:
|
||||
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
|
||||
|
||||
async def mock_next(ctx: ChatContext) -> None:
|
||||
ctx.result = MagicMock()
|
||||
@@ -123,7 +124,10 @@ class TestPurviewChatPolicyMiddleware:
|
||||
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."""
|
||||
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
|
||||
# Set ignore_exceptions to True to test exception suppression
|
||||
middleware._settings.ignore_exceptions = True
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_process_messages(*args, **kwargs):
|
||||
@@ -136,7 +140,6 @@ class TestPurviewChatPolicyMiddleware:
|
||||
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
|
||||
@@ -147,3 +150,127 @@ class TestPurviewChatPolicyMiddleware:
|
||||
assert call_count == 2
|
||||
# Result should still be set
|
||||
assert chat_context.result is not None
|
||||
|
||||
async def test_chat_middleware_uses_consistent_user_id(
|
||||
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
|
||||
) -> None:
|
||||
"""Test that the same user_id from pre-check is used in post-check."""
|
||||
captured_user_ids = []
|
||||
|
||||
async def mock_process_messages(messages, activity, user_id=None):
|
||||
captured_user_ids.append(user_id)
|
||||
return (False, "resolved-user-123")
|
||||
|
||||
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
|
||||
|
||||
async def mock_next(ctx: ChatContext) -> None:
|
||||
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
|
||||
assert len(captured_user_ids) == 2
|
||||
# First call should have None (no user_id provided yet)
|
||||
assert captured_user_ids[0] is None
|
||||
# Second call should have the resolved user_id from first call
|
||||
assert captured_user_ids[1] == "resolved-user-123"
|
||||
|
||||
async def test_chat_middleware_handles_payment_required_pre_check(self, mock_credential: AsyncMock) -> None:
|
||||
"""Test that 402 in pre-check is handled based on settings."""
|
||||
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
|
||||
|
||||
# Test with ignore_payment_required=False
|
||||
settings = PurviewSettings(app_name="Test App", ignore_payment_required=False)
|
||||
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
|
||||
|
||||
chat_client = DummyChatClient()
|
||||
chat_options = MagicMock()
|
||||
chat_options.model = "test-model"
|
||||
context = ChatContext(
|
||||
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
|
||||
)
|
||||
|
||||
async def mock_process_messages(*args, **kwargs):
|
||||
raise PurviewPaymentRequiredError("Payment required")
|
||||
|
||||
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
|
||||
|
||||
async def mock_next(ctx: ChatContext) -> None:
|
||||
raise AssertionError("next should not be called")
|
||||
|
||||
# Should raise the exception
|
||||
with pytest.raises(PurviewPaymentRequiredError):
|
||||
await middleware.process(context, mock_next)
|
||||
|
||||
async def test_chat_middleware_ignores_payment_required_when_configured(self, mock_credential: AsyncMock) -> None:
|
||||
"""Test that 402 is ignored when ignore_payment_required=True."""
|
||||
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
|
||||
|
||||
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
|
||||
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
|
||||
|
||||
chat_client = DummyChatClient()
|
||||
chat_options = MagicMock()
|
||||
chat_options.model = "test-model"
|
||||
context = ChatContext(
|
||||
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
|
||||
)
|
||||
|
||||
async def mock_process_messages(*args, **kwargs):
|
||||
raise PurviewPaymentRequiredError("Payment required")
|
||||
|
||||
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
|
||||
|
||||
async def mock_next(ctx: ChatContext) -> None:
|
||||
result = MagicMock()
|
||||
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
|
||||
context.result = result
|
||||
|
||||
# Should not raise, just log
|
||||
await middleware.process(context, mock_next)
|
||||
# Next should have been called
|
||||
assert context.result is not None
|
||||
|
||||
async def test_chat_middleware_handles_result_without_messages_attribute(
|
||||
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
|
||||
) -> None:
|
||||
"""Test middleware handles result that doesn't have messages attribute."""
|
||||
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
|
||||
|
||||
async def mock_next(ctx: ChatContext) -> None:
|
||||
# Set result to something without messages attribute
|
||||
ctx.result = "Some string result"
|
||||
|
||||
await middleware.process(chat_context, mock_next)
|
||||
|
||||
# Should not crash, result should be unchanged
|
||||
assert chat_context.result == "Some string result"
|
||||
|
||||
async def test_chat_middleware_with_ignore_exceptions(self, mock_credential: AsyncMock) -> None:
|
||||
"""Test that middleware respects ignore_exceptions setting."""
|
||||
settings = PurviewSettings(app_name="Test App", ignore_exceptions=True)
|
||||
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
|
||||
|
||||
chat_client = DummyChatClient()
|
||||
chat_options = MagicMock()
|
||||
chat_options.model = "test-model"
|
||||
context = ChatContext(
|
||||
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
|
||||
)
|
||||
|
||||
async def mock_process_messages(*args, **kwargs):
|
||||
raise ValueError("Some error")
|
||||
|
||||
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
|
||||
|
||||
async def mock_next(ctx: ChatContext) -> None:
|
||||
result = MagicMock()
|
||||
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
|
||||
context.result = result
|
||||
|
||||
# Should not raise, just log
|
||||
await middleware.process(context, mock_next)
|
||||
# Next should have been called
|
||||
assert context.result is not None
|
||||
|
||||
@@ -12,6 +12,7 @@ from agent_framework_purview import PurviewSettings
|
||||
from agent_framework_purview._client import PurviewClient
|
||||
from agent_framework_purview._exceptions import (
|
||||
PurviewAuthenticationError,
|
||||
PurviewPaymentRequiredError,
|
||||
PurviewRateLimitError,
|
||||
PurviewRequestError,
|
||||
PurviewServiceError,
|
||||
@@ -157,6 +158,7 @@ class TestPurviewClient:
|
||||
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {}
|
||||
mock_response.json.return_value = {"id": "response-123", "protectionScopeState": "notModified"}
|
||||
|
||||
with patch.object(client._client, "post", return_value=mock_response):
|
||||
@@ -174,6 +176,7 @@ class TestPurviewClient:
|
||||
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {} # Add headers attribute
|
||||
mock_response.json.return_value = {"scopeIdentifier": "scope-123", "value": []}
|
||||
|
||||
with patch.object(client._client, "post", return_value=mock_response):
|
||||
@@ -236,3 +239,157 @@ class TestPurviewClient:
|
||||
pytest.raises(PurviewRequestError, match="Purview request failed"),
|
||||
):
|
||||
await client.process_content(request)
|
||||
|
||||
async def test_prefer_header_sent_when_process_inline_true(
|
||||
self, client: PurviewClient, content_to_process_factory
|
||||
) -> None:
|
||||
"""Test that Prefer: evaluateInline header is sent when process_inline is True."""
|
||||
content = content_to_process_factory()
|
||||
request = ProcessContentRequest(
|
||||
content_to_process=content,
|
||||
user_id="user-123",
|
||||
tenant_id="tenant-456",
|
||||
process_inline=True,
|
||||
)
|
||||
|
||||
posted_headers = {}
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {}
|
||||
mock_response.json.return_value = {}
|
||||
|
||||
async def capture_post(url, json, headers):
|
||||
posted_headers.update(headers)
|
||||
return mock_response
|
||||
|
||||
with patch.object(client._client, "post", side_effect=capture_post):
|
||||
await client.process_content(request)
|
||||
|
||||
assert "Prefer" in posted_headers
|
||||
assert posted_headers["Prefer"] == "evaluateInline"
|
||||
|
||||
async def test_prefer_header_not_sent_when_process_inline_false(
|
||||
self, client: PurviewClient, content_to_process_factory
|
||||
) -> None:
|
||||
"""Test that Prefer header is not sent when process_inline is False."""
|
||||
content = content_to_process_factory()
|
||||
request = ProcessContentRequest(
|
||||
content_to_process=content,
|
||||
user_id="user-123",
|
||||
tenant_id="tenant-456",
|
||||
process_inline=False,
|
||||
)
|
||||
|
||||
posted_headers = {}
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {}
|
||||
mock_response.json.return_value = {}
|
||||
|
||||
async def capture_post(url, json, headers):
|
||||
posted_headers.update(headers)
|
||||
return mock_response
|
||||
|
||||
with patch.object(client._client, "post", side_effect=capture_post):
|
||||
await client.process_content(request)
|
||||
|
||||
assert "Prefer" not in posted_headers
|
||||
|
||||
async def test_prefer_header_not_sent_when_process_inline_none(
|
||||
self, client: PurviewClient, content_to_process_factory
|
||||
) -> None:
|
||||
"""Test that Prefer header is not sent when process_inline is None."""
|
||||
content = content_to_process_factory()
|
||||
request = ProcessContentRequest(
|
||||
content_to_process=content,
|
||||
user_id="user-123",
|
||||
tenant_id="tenant-456",
|
||||
process_inline=None,
|
||||
)
|
||||
|
||||
posted_headers = {}
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {}
|
||||
mock_response.json.return_value = {}
|
||||
|
||||
async def capture_post(url, json, headers):
|
||||
posted_headers.update(headers)
|
||||
return mock_response
|
||||
|
||||
with patch.object(client._client, "post", side_effect=capture_post):
|
||||
await client.process_content(request)
|
||||
|
||||
assert "Prefer" not in posted_headers
|
||||
|
||||
async def test_scope_identifier_extraction_from_etag(self, client: PurviewClient) -> None:
|
||||
"""Test that scope_identifier is extracted from ETag header."""
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"etag": '"test-scope-id"'}
|
||||
mock_response.json.return_value = {"value": []}
|
||||
|
||||
with patch.object(client._client, "post", return_value=mock_response):
|
||||
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
|
||||
response = await client.get_protection_scopes(req)
|
||||
|
||||
assert response.scope_identifier == "test-scope-id"
|
||||
|
||||
async def test_scope_identifier_sent_as_if_none_match_header(
|
||||
self, client: PurviewClient, content_to_process_factory
|
||||
) -> None:
|
||||
"""Test that scope_identifier is sent as If-None-Match header."""
|
||||
content = content_to_process_factory()
|
||||
request = ProcessContentRequest(
|
||||
content_to_process=content,
|
||||
user_id="user-123",
|
||||
tenant_id="tenant-456",
|
||||
scope_identifier="test-scope-id",
|
||||
)
|
||||
|
||||
posted_headers = {}
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {}
|
||||
mock_response.json.return_value = {}
|
||||
|
||||
async def capture_post(url, json, headers):
|
||||
posted_headers.update(headers)
|
||||
return mock_response
|
||||
|
||||
with patch.object(client._client, "post", side_effect=capture_post):
|
||||
await client.process_content(request)
|
||||
|
||||
assert "If-None-Match" in posted_headers
|
||||
assert posted_headers["If-None-Match"] == "test-scope-id"
|
||||
|
||||
async def test_402_payment_required_raises_exception_by_default(self, client: PurviewClient) -> None:
|
||||
"""Test that 402 raises exception when ignore_payment_required is False."""
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 402
|
||||
mock_response.text = "Payment required"
|
||||
|
||||
with patch.object(client._client, "post", return_value=mock_response):
|
||||
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
|
||||
|
||||
with pytest.raises(PurviewPaymentRequiredError):
|
||||
await client.get_protection_scopes(req)
|
||||
|
||||
async def test_402_payment_required_returns_empty_when_ignored(self, mock_credential: MagicMock) -> None:
|
||||
"""Test that 402 returns empty response when ignore_payment_required is True."""
|
||||
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
|
||||
client = PurviewClient(mock_credential, settings)
|
||||
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.status_code = 402
|
||||
mock_response.text = "Payment required"
|
||||
|
||||
with patch.object(client._client, "post", return_value=mock_response):
|
||||
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
|
||||
response = await client.get_protection_scopes(req)
|
||||
|
||||
# Should return empty response without raising
|
||||
assert response is not None
|
||||
assert response.scopes is None or response.scopes == []
|
||||
|
||||
await client.close()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
from agent_framework_purview import (
|
||||
PurviewAuthenticationError,
|
||||
PurviewPaymentRequiredError,
|
||||
PurviewRateLimitError,
|
||||
PurviewRequestError,
|
||||
PurviewServiceError,
|
||||
@@ -25,6 +26,12 @@ class TestPurviewExceptions:
|
||||
assert str(error) == "Authentication failed"
|
||||
assert isinstance(error, PurviewServiceError)
|
||||
|
||||
def test_purview_payment_required_error(self) -> None:
|
||||
"""Test PurviewPaymentRequiredError exception."""
|
||||
error = PurviewPaymentRequiredError("Payment required")
|
||||
assert str(error) == "Payment required"
|
||||
assert isinstance(error, PurviewServiceError)
|
||||
|
||||
def test_purview_rate_limit_error(self) -> None:
|
||||
"""Test PurviewRateLimitError exception."""
|
||||
error = PurviewRateLimitError("Rate limit exceeded")
|
||||
|
||||
@@ -120,6 +120,9 @@ class TestPurviewPolicyMiddleware:
|
||||
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
|
||||
) -> None:
|
||||
"""Test middleware handles result that doesn't have messages attribute."""
|
||||
# Set ignore_exceptions to True so AttributeError is caught and logged
|
||||
middleware._settings.ignore_exceptions = True
|
||||
|
||||
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
|
||||
|
||||
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
|
||||
@@ -153,7 +156,10 @@ class TestPurviewPolicyMiddleware:
|
||||
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."""
|
||||
"""Test that exceptions in pre-check are logged but don't stop processing when ignore_exceptions=True."""
|
||||
# Set ignore_exceptions to True
|
||||
middleware._settings.ignore_exceptions = True
|
||||
|
||||
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
|
||||
|
||||
with patch.object(
|
||||
@@ -175,7 +181,10 @@ class TestPurviewPolicyMiddleware:
|
||||
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."""
|
||||
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
|
||||
# Set ignore_exceptions to True
|
||||
middleware._settings.ignore_exceptions = True
|
||||
|
||||
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
|
||||
|
||||
call_count = 0
|
||||
@@ -199,3 +208,49 @@ class TestPurviewPolicyMiddleware:
|
||||
# Result should still be set
|
||||
assert context.result is not None
|
||||
assert hasattr(context.result, "messages")
|
||||
|
||||
async def test_middleware_with_ignore_exceptions_true(self, mock_credential: AsyncMock) -> None:
|
||||
"""Test that middleware logs but doesn't throw when ignore_exceptions is True."""
|
||||
settings = PurviewSettings(app_name="Test App", ignore_exceptions=True)
|
||||
middleware = PurviewPolicyMiddleware(mock_credential, settings)
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "test-agent"
|
||||
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
|
||||
|
||||
# Mock processor to raise an exception
|
||||
async def mock_process_messages(*args, **kwargs):
|
||||
raise ValueError("Test error")
|
||||
|
||||
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
|
||||
|
||||
async def mock_next(ctx):
|
||||
ctx.result = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Response")])
|
||||
|
||||
# Should not raise, just log
|
||||
await middleware.process(context, mock_next)
|
||||
|
||||
# Result should be set because next was called despite the error
|
||||
assert context.result is not None
|
||||
|
||||
async def test_middleware_with_ignore_exceptions_false(self, mock_credential: AsyncMock) -> None:
|
||||
"""Test that middleware throws exceptions when ignore_exceptions is False."""
|
||||
settings = PurviewSettings(app_name="Test App", ignore_exceptions=False)
|
||||
middleware = PurviewPolicyMiddleware(mock_credential, settings)
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.name = "test-agent"
|
||||
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
|
||||
|
||||
# Mock processor to raise an exception
|
||||
async def mock_process_messages(*args, **kwargs):
|
||||
raise ValueError("Test error")
|
||||
|
||||
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
|
||||
|
||||
async def mock_next(ctx):
|
||||
pass
|
||||
|
||||
# Should raise the exception
|
||||
with pytest.raises(ValueError, match="Test error"):
|
||||
await middleware.process(context, mock_next)
|
||||
|
||||
@@ -237,10 +237,7 @@ class TestModelDeserialization:
|
||||
|
||||
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 "user_id" in dumped
|
||||
assert "tenant_id" in dumped
|
||||
assert "correlation_id" not in dumped
|
||||
|
||||
# Check that content is present
|
||||
assert "contentToProcess" in dumped
|
||||
|
||||
@@ -170,7 +170,7 @@ class TestScopedContentProcessor:
|
||||
request = process_content_request_factory()
|
||||
response = ProtectionScopesResponse(**{"value": None})
|
||||
|
||||
should_process, actions = processor._check_applicable_scopes(request, response)
|
||||
should_process, actions, execution_mode = processor._check_applicable_scopes(request, response)
|
||||
|
||||
assert should_process is False
|
||||
assert actions == []
|
||||
@@ -200,7 +200,7 @@ class TestScopedContentProcessor:
|
||||
})
|
||||
response = ProtectionScopesResponse(**{"value": [scope]})
|
||||
|
||||
should_process, actions = processor._check_applicable_scopes(request, response)
|
||||
should_process, actions, execution_mode = processor._check_applicable_scopes(request, response)
|
||||
|
||||
assert should_process is True
|
||||
assert len(actions) == 1
|
||||
@@ -220,7 +220,7 @@ class TestScopedContentProcessor:
|
||||
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."""
|
||||
"""Test _process_with_scopes calls get_protection_scopes when scopes response is empty."""
|
||||
from agent_framework_purview._models import (
|
||||
ContentActivitiesResponse,
|
||||
ProtectionScopesResponse,
|
||||
@@ -237,9 +237,10 @@ class TestScopedContentProcessor:
|
||||
response = await processor._process_with_scopes(request)
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
# When no scopes apply, process_content is not called (activities are sent in background)
|
||||
mock_client.process_content.assert_not_called()
|
||||
mock_client.send_content_activities.assert_called_once()
|
||||
assert response.id is None
|
||||
# The response should have id=204 (No Content) when no scopes apply
|
||||
assert response.id == "204"
|
||||
|
||||
async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None:
|
||||
"""Test user_id extraction from message additional_properties."""
|
||||
@@ -308,7 +309,7 @@ class TestScopedContentProcessor:
|
||||
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."""
|
||||
"""Test that response is returned when scopes don't apply (activities sent in background)."""
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
@@ -325,7 +326,7 @@ class TestScopedContentProcessor:
|
||||
mock_ps_response.scopes = []
|
||||
mock_client.get_protection_scopes.return_value = mock_ps_response
|
||||
|
||||
# Mock send_content_activities to return success
|
||||
# Mock send_content_activities to return success (called in background)
|
||||
mock_ca_response = MagicMock()
|
||||
mock_ca_response.error = None
|
||||
mock_client.send_content_activities.return_value = mock_ca_response
|
||||
@@ -334,14 +335,13 @@ class TestScopedContentProcessor:
|
||||
|
||||
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 == []
|
||||
# Response should have id=204 when no scopes apply
|
||||
assert response.id == "204"
|
||||
|
||||
async def test_process_content_handles_activities_error(
|
||||
self, mock_client: AsyncMock, process_content_request_factory
|
||||
) -> None:
|
||||
"""Test error handling when content activities fail."""
|
||||
"""Test that errors in background activities don't affect the response."""
|
||||
settings = PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
@@ -358,12 +358,269 @@ class TestScopedContentProcessor:
|
||||
mock_ps_response.scopes = []
|
||||
mock_client.get_protection_scopes.return_value = mock_ps_response
|
||||
|
||||
# Mock send_content_activities to return error
|
||||
# Mock send_content_activities to return error (called in background task)
|
||||
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"
|
||||
# Since activities are sent in background, errors don't affect the response
|
||||
# Response should have id=204 when no scopes apply
|
||||
assert response.id == "204"
|
||||
|
||||
|
||||
class TestUserIdResolution:
|
||||
"""Test user ID resolution from various sources."""
|
||||
|
||||
@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": "11111111-1111-1111-1111-111111111111",
|
||||
"client_id": "12345678-1234-1234-1234-123456789012",
|
||||
}
|
||||
)
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def settings(self) -> PurviewSettings:
|
||||
"""Create settings."""
|
||||
return PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
purview_app_location=PurviewAppLocation(
|
||||
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
|
||||
),
|
||||
)
|
||||
|
||||
async def test_user_id_from_token_when_no_other_source(self, mock_client: AsyncMock) -> None:
|
||||
"""Test user_id is extracted from token when no other source available."""
|
||||
settings = PurviewSettings(app_name="Test App") # No tenant_id or app_location
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
|
||||
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
|
||||
|
||||
mock_client.get_user_info_from_token.assert_called_once()
|
||||
assert user_id == "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
async def test_user_id_from_additional_properties_takes_priority(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test user_id from additional_properties takes priority over token."""
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role=Role.USER,
|
||||
text="Test",
|
||||
additional_properties={"user_id": "22222222-2222-2222-2222-222222222222"},
|
||||
)
|
||||
]
|
||||
|
||||
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
|
||||
|
||||
# Token info should not be called since we have user_id in message
|
||||
mock_client.get_user_info_from_token.assert_not_called()
|
||||
assert user_id == "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
async def test_user_id_from_author_name_as_fallback(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test user_id is extracted from author_name when it's a valid GUID."""
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role=Role.USER,
|
||||
text="Test",
|
||||
author_name="33333333-3333-3333-3333-333333333333",
|
||||
)
|
||||
]
|
||||
|
||||
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
|
||||
|
||||
assert user_id == "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
async def test_author_name_ignored_if_not_valid_guid(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test author_name is ignored if it's not a valid GUID."""
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role=Role.USER,
|
||||
text="Test",
|
||||
author_name="John Doe", # Not a GUID
|
||||
)
|
||||
]
|
||||
|
||||
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
|
||||
|
||||
# Should return empty since author_name is not a valid GUID
|
||||
assert user_id is None
|
||||
assert len(requests) == 0
|
||||
|
||||
async def test_provided_user_id_used_as_last_resort(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test provided_user_id parameter is used as last resort."""
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
|
||||
requests, user_id = await processor._map_messages(
|
||||
messages, Activity.UPLOAD_TEXT, provided_user_id="44444444-4444-4444-4444-444444444444"
|
||||
)
|
||||
|
||||
assert user_id == "44444444-4444-4444-4444-444444444444"
|
||||
|
||||
async def test_invalid_provided_user_id_ignored(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
|
||||
"""Test invalid provided_user_id is ignored."""
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
|
||||
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid")
|
||||
|
||||
assert user_id is None
|
||||
assert len(requests) == 0
|
||||
|
||||
async def test_multiple_messages_same_user_id(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
|
||||
"""Test that all messages use the same resolved user_id."""
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role=Role.USER, text="First", additional_properties={"user_id": "55555555-5555-5555-5555-555555555555"}
|
||||
),
|
||||
ChatMessage(role=Role.ASSISTANT, text="Response"),
|
||||
ChatMessage(role=Role.USER, text="Second"),
|
||||
]
|
||||
|
||||
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
|
||||
|
||||
assert user_id == "55555555-5555-5555-5555-555555555555"
|
||||
# All requests should have the same user_id
|
||||
assert all(req.user_id == "55555555-5555-5555-5555-555555555555" for req in requests)
|
||||
|
||||
async def test_first_valid_user_id_in_messages_is_used(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test that the first valid user_id found in messages is used for all."""
|
||||
processor = ScopedContentProcessor(mock_client, settings)
|
||||
|
||||
messages = [
|
||||
ChatMessage(role=Role.USER, text="First", author_name="Not a GUID"),
|
||||
ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
text="Response",
|
||||
additional_properties={"user_id": "66666666-6666-6666-6666-666666666666"},
|
||||
),
|
||||
ChatMessage(
|
||||
role=Role.USER, text="Third", additional_properties={"user_id": "77777777-7777-7777-7777-777777777777"}
|
||||
),
|
||||
]
|
||||
|
||||
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
|
||||
|
||||
# First valid user_id (from second message) should be used
|
||||
assert user_id == "66666666-6666-6666-6666-666666666666"
|
||||
assert all(req.user_id == "66666666-6666-6666-6666-666666666666" for req in requests)
|
||||
|
||||
|
||||
class TestScopedContentProcessorCaching:
|
||||
"""Test caching functionality in ScopedContentProcessor."""
|
||||
|
||||
@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",
|
||||
}
|
||||
)
|
||||
client.get_protection_scopes = AsyncMock()
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def settings(self) -> PurviewSettings:
|
||||
"""Create test settings."""
|
||||
location = PurviewAppLocation(location_type=PurviewLocationType.APPLICATION, location_value="app-id")
|
||||
return PurviewSettings(
|
||||
app_name="Test App",
|
||||
tenant_id="12345678-1234-1234-1234-123456789012",
|
||||
default_user_id="12345678-1234-1234-1234-123456789012",
|
||||
purview_app_location=location,
|
||||
)
|
||||
|
||||
async def test_protection_scopes_cached_on_first_call(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test that protection scopes response is cached after first call."""
|
||||
from agent_framework_purview._cache import InMemoryCacheProvider
|
||||
from agent_framework_purview._models import ProtectionScopesResponse
|
||||
|
||||
cache_provider = InMemoryCacheProvider()
|
||||
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
|
||||
|
||||
mock_client.get_protection_scopes.return_value = ProtectionScopesResponse(
|
||||
scope_identifier="scope-123", scopes=[]
|
||||
)
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
|
||||
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
async def test_payment_required_exception_cached_at_tenant_level(
|
||||
self, mock_client: AsyncMock, settings: PurviewSettings
|
||||
) -> None:
|
||||
"""Test that 402 payment required exceptions are cached at tenant level."""
|
||||
from agent_framework_purview._cache import InMemoryCacheProvider
|
||||
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
|
||||
|
||||
cache_provider = InMemoryCacheProvider()
|
||||
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
|
||||
|
||||
mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required")
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
|
||||
with pytest.raises(PurviewPaymentRequiredError):
|
||||
await processor.process_messages(
|
||||
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
|
||||
)
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
with pytest.raises(PurviewPaymentRequiredError):
|
||||
await processor.process_messages(
|
||||
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
|
||||
)
|
||||
|
||||
mock_client.get_protection_scopes.assert_called_once()
|
||||
|
||||
async def test_custom_cache_provider_used(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
|
||||
"""Test that custom cache provider is used when provided."""
|
||||
from agent_framework_purview._cache import InMemoryCacheProvider
|
||||
|
||||
custom_cache = InMemoryCacheProvider(default_ttl_seconds=60)
|
||||
processor = ScopedContentProcessor(mock_client, settings, cache_provider=custom_cache)
|
||||
|
||||
assert processor._cache is custom_cache
|
||||
assert processor._cache._default_ttl == 60
|
||||
|
||||
@@ -18,7 +18,6 @@ class TestPurviewSettings:
|
||||
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."""
|
||||
@@ -28,13 +27,11 @@ class TestPurviewSettings:
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user