Python: Surface oauth_consent_request events from Responses API in Foundry clients (#5070)

* Fix Foundry clients not surfacing oauth_consent_request events (#5054)

Override _parse_chunk_from_openai in both RawFoundryChatClient and
RawFoundryAgentChatClient to intercept response.output_item.added
events with item.type == 'oauth_consent_request'. The consent link
is validated (HTTPS required) and converted to
Content.from_oauth_consent_request, which the AG-UI layer already
knows how to emit as a CUSTOM event.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback for #5054 OAuth consent parsing

- Extract shared helper (try_parse_oauth_consent_event) to avoid
  duplicated logic between RawFoundryChatClient and
  RawFoundryAgentChatClient
- Use urllib.parse.urlparse() for HTTPS validation instead of
  case-sensitive startswith check
- Sanitize log messages to avoid leaking consent_link tokens;
  log only item id
- Add model=self.model to ChatResponseUpdate to match parent behavior
- Add assertions on role, raw_representation, and model in happy-path
  tests
- Add test for empty-string consent_link
- Add test verifying non-oauth events delegate to super()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Handle response.oauth_consent_requested top-level event (#5054)

Add support for the top-level response.oauth_consent_requested stream
event in addition to the response.output_item.added variant. The
service may emit either form; handle both so the consent link is
reliably surfaced.

Extract _validate_consent_link helper within _oauth_helpers.py to
reduce nesting.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event

* Address review feedback: defensive getattr and dedicated helper tests (#5054)

- Use getattr(event, 'type', None) in try_parse_oauth_consent_event
  for defensive access against malformed events without a type attribute
- Add test_oauth_helpers.py with unit tests for _validate_consent_link
  and try_parse_oauth_consent_event covering edge cases:
  - HTTPS URL with empty netloc (https:///path)
  - Warning log messages for rejected consent links
  - Event objects missing 'type' attribute

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event

* Fix mypy: match _parse_chunk_from_openai signature with superclass

Add seen_reasoning_delta_item_ids parameter to _parse_chunk_from_openai
overrides in both RawFoundryChatClient and RawFoundryAgentChatClient to
match the updated superclass signature on main. Update super() calls and
test assertions accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
This commit is contained in:
Giles Odigwe
2026-04-24 02:59:14 -07:00
committed by GitHub
Unverified
parent da32e8cf80
commit 7b70f80036
6 changed files with 625 additions and 10 deletions
@@ -19,6 +19,7 @@ from agent_framework import (
AgentSession,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
ChatResponseUpdate,
ContextProvider,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -35,6 +36,8 @@ from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
if sys.version_info >= (3, 13):
@@ -373,16 +376,19 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
) -> Any:
parsed_chunk = super()._parse_chunk_from_openai(
event,
options,
function_call_ids,
seen_reasoning_delta_item_ids,
)
) -> ChatResponseUpdate:
"""Parse streaming events while preserving hosted-agent session state."""
update = try_parse_oauth_consent_event(event, self.model)
if update is None:
update = super()._parse_chunk_from_openai(
event,
options,
function_call_ids,
seen_reasoning_delta_item_ids,
)
if _uses_foundry_agent_session(options.get("conversation_id")):
parsed_chunk.conversation_id = None
return parsed_chunk
update.conversation_id = None
return update
@override
def _check_model_presence(self, options: dict[str, Any]) -> None:
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
from agent_framework import (
ChatMiddlewareLayer,
ChatResponseUpdate,
Content,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -33,6 +34,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage]
if sys.version_info >= (3, 13):
@@ -241,6 +244,20 @@ class RawFoundryChatClient( # type: ignore[misc]
response_tools = super()._prepare_tools_for_openai(tools)
return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
@override
def _parse_chunk_from_openai(
self,
event: Any,
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
seen_reasoning_delta_item_ids: set[str] | None = None,
) -> ChatResponseUpdate:
"""Parse streaming event, intercepting oauth_consent_request items."""
update = try_parse_oauth_consent_event(event, self.model)
if update is not None:
return update
return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids)
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from typing import Any
from urllib.parse import urlparse
from agent_framework import ChatResponseUpdate, Content
logger = logging.getLogger(__name__)
def _validate_consent_link(consent_link: str, item_id: str) -> str:
"""Validate a consent link is HTTPS with a valid netloc.
Returns the link unchanged if valid, or an empty string if not.
"""
parsed = urlparse(consent_link)
if parsed.scheme.lower() != "https" or not parsed.netloc:
logger.warning(
"Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)",
item_id,
)
return ""
return consent_link
def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None:
"""Parse an oauth_consent_request from a streaming event, if present.
Returns a ``ChatResponseUpdate`` when *event* is a
``response.output_item.added`` carrying an ``oauth_consent_request`` item
or a top-level ``response.oauth_consent_requested`` event,
or ``None`` so the caller can fall through to the base implementation.
"""
consent_link: str = ""
raw_item: Any = None
event_type = getattr(event, "type", None)
if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request":
raw_item = event.item
consent_link = getattr(raw_item, "consent_link", None) or ""
elif event_type == "response.oauth_consent_requested":
raw_item = event
consent_link = getattr(event, "consent_link", None) or ""
else:
return None
item_id = getattr(raw_item, "id", "<unknown>")
if consent_link:
consent_link = _validate_consent_link(consent_link, item_id)
contents: list[Content] = []
if consent_link:
contents.append(
Content.from_oauth_consent_request(
consent_link=consent_link,
raw_representation=raw_item,
)
)
else:
logger.warning(
"Received oauth_consent_request output without valid consent_link (item id=%s)",
item_id,
)
return ChatResponseUpdate(
contents=contents,
role="assistant",
model=model,
raw_representation=event,
)
@@ -10,7 +10,17 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AgentResponse, AgentSession, ChatContext, ChatMiddleware, ChatResponse, Message, tool
from agent_framework import (
AgentResponse,
AgentSession,
ChatContext,
ChatMiddleware,
ChatResponse,
ChatResponseUpdate,
Message,
tool,
)
from agent_framework_openai._chat_client import RawOpenAIChatClient
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
@@ -287,6 +297,31 @@ def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id
assert result.conversation_id is None
def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_for_agent_sessions() -> None:
"""Test that agent-session stream updates do not overwrite session.service_session_id."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
parsed = ChatResponseUpdate(conversation_id="resp_123")
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._parse_chunk_from_openai",
return_value=parsed,
):
result = client._parse_chunk_from_openai(
event=MagicMock(type="response.output_text.delta"),
options={"conversation_id": "agent-session-123"},
function_call_ids={},
)
assert result.conversation_id is None
def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None:
"""Test that _check_model_presence does nothing (model is on service)."""
@@ -622,3 +657,158 @@ async def test_foundry_agent_custom_client_run() -> None:
assert isinstance(response, AgentResponse)
assert response.text is not None
assert "response test" in response.text.lower()
def test_parse_chunk_surfaces_oauth_consent_request() -> None:
"""An oauth_consent_request output item surfaces as Content with consent_link."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = "https://consent-host.example.com/login?data=abc123"
mock_item.id = "oauth-item-1"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 1
assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123"
assert update.role == "assistant"
assert update.raw_representation is mock_event
def test_parse_chunk_skips_non_https_oauth_consent() -> None:
"""An oauth_consent_request with a non-HTTPS link is rejected."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = "http://insecure.example.com/login"
mock_item.id = "oauth-item-2"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 0
def test_parse_chunk_handles_missing_consent_link() -> None:
"""An oauth_consent_request without a consent_link produces no content."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = None
mock_item.id = "oauth-item-3"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 0
def test_parse_chunk_handles_empty_string_consent_link() -> None:
"""An oauth_consent_request with empty-string consent_link produces no content."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = ""
mock_item.id = "oauth-item-4"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 0
def test_parse_chunk_delegates_non_oauth_events_to_super() -> None:
"""Non-oauth events are delegated to super()._parse_chunk_from_openai()."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
mock_event = MagicMock()
mock_event.type = "response.output_text.delta"
with patch.object(
RawOpenAIChatClient,
"_parse_chunk_from_openai",
return_value=MagicMock(),
) as mock_super:
client._parse_chunk_from_openai(mock_event, {}, {})
mock_super.assert_called_once_with(mock_event, {}, {}, None)
def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
"""A top-level response.oauth_consent_requested event surfaces as Content."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
)
mock_event = MagicMock()
mock_event.type = "response.oauth_consent_requested"
mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz"
mock_event.id = "consent-event-1"
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 1
assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz"
assert update.role == "assistant"
assert update.raw_representation is mock_event
@@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo
from agent_framework._telemetry import get_user_agent
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
from agent_framework_openai import OpenAIContentFilterException
from agent_framework_openai._chat_client import RawOpenAIChatClient
from azure.ai.projects.models import MCPTool as FoundryMCPTool
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
@@ -993,3 +994,165 @@ def test_get_mcp_tool_with_connection_id() -> None:
description="GitHub MCP via Foundry",
)
assert tool_obj is not None
def test_parse_chunk_surfaces_oauth_consent_request() -> None:
"""An oauth_consent_request output item surfaces as Content with consent_link."""
mock_project = MagicMock()
mock_openai = _make_mock_openai_client()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryChatClient(
project_client=mock_project,
model="test-model",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = "https://consent-host.example.com/login?data=abc123"
mock_item.id = "oauth-item-1"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 1
assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123"
assert update.role == "assistant"
assert update.raw_representation is mock_event
assert update.model == "test-model"
def test_parse_chunk_skips_non_https_oauth_consent() -> None:
"""An oauth_consent_request with a non-HTTPS link is rejected."""
mock_project = MagicMock()
mock_openai = _make_mock_openai_client()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryChatClient(
project_client=mock_project,
model="test-model",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = "http://insecure.example.com/login"
mock_item.id = "oauth-item-2"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 0
def test_parse_chunk_handles_missing_consent_link() -> None:
"""An oauth_consent_request without a consent_link produces no content."""
mock_project = MagicMock()
mock_openai = _make_mock_openai_client()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryChatClient(
project_client=mock_project,
model="test-model",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = None
mock_item.id = "oauth-item-3"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 0
def test_parse_chunk_handles_empty_string_consent_link() -> None:
"""An oauth_consent_request with empty-string consent_link produces no content."""
mock_project = MagicMock()
mock_openai = _make_mock_openai_client()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryChatClient(
project_client=mock_project,
model="test-model",
)
mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = ""
mock_item.id = "oauth-item-4"
mock_event.item = mock_item
mock_event.output_index = 0
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 0
def test_parse_chunk_delegates_non_oauth_events_to_super() -> None:
"""Non-oauth events are delegated to super()._parse_chunk_from_openai()."""
mock_project = MagicMock()
mock_openai = _make_mock_openai_client()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryChatClient(
project_client=mock_project,
model="test-model",
)
mock_event = MagicMock()
mock_event.type = "response.output_text.delta"
with patch.object(
RawOpenAIChatClient,
"_parse_chunk_from_openai",
return_value=MagicMock(),
) as mock_super:
client._parse_chunk_from_openai(mock_event, {}, {})
mock_super.assert_called_once_with(mock_event, {}, {}, None)
def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
"""A top-level response.oauth_consent_requested event surfaces as Content."""
mock_project = MagicMock()
mock_openai = _make_mock_openai_client()
mock_project.get_openai_client.return_value = mock_openai
client = RawFoundryChatClient(
project_client=mock_project,
model="test-model",
)
mock_event = MagicMock()
mock_event.type = "response.oauth_consent_requested"
mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz"
mock_event.id = "consent-event-1"
update = client._parse_chunk_from_openai(mock_event, {}, {})
consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 1
assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz"
assert update.role == "assistant"
assert update.raw_representation is mock_event
@@ -0,0 +1,164 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import logging
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
# region _validate_consent_link tests
def test_validate_consent_link_accepts_valid_https() -> None:
"""A valid HTTPS URL with a netloc passes validation."""
link = "https://consent.example.com/auth?code=123"
assert _validate_consent_link(link, "item-1") == link
def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None:
"""An HTTP link is rejected and a warning is logged."""
with caplog.at_level(logging.WARNING):
result = _validate_consent_link("http://insecure.example.com/login", "item-2")
assert result == ""
assert "non-HTTPS" in caplog.text
assert "item-2" in caplog.text
def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
"""An HTTPS URL with an empty netloc (e.g. https:///path) is rejected."""
with caplog.at_level(logging.WARNING):
result = _validate_consent_link("https:///path", "item-3")
assert result == ""
assert "non-HTTPS" in caplog.text
assert "item-3" in caplog.text
def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None:
"""A non-URL string is rejected."""
with caplog.at_level(logging.WARNING):
result = _validate_consent_link("not-a-url", "item-4")
assert result == ""
# endregion
# region try_parse_oauth_consent_event tests
def _make_output_item_event(
*,
item_type: str = "oauth_consent_request",
consent_link: Any = "https://consent.example.com/auth",
item_id: str = "oauth-item-1",
) -> MagicMock:
"""Create a mock ``response.output_item.added`` event."""
event = MagicMock()
event.type = "response.output_item.added"
item = MagicMock()
item.type = item_type
item.consent_link = consent_link
item.id = item_id
event.item = item
return event
def _make_top_level_event(
*,
consent_link: Any = "https://consent.example.com/authorize",
event_id: str = "consent-event-1",
) -> MagicMock:
"""Create a mock ``response.oauth_consent_requested`` event."""
event = MagicMock()
event.type = "response.oauth_consent_requested"
event.consent_link = consent_link
event.id = event_id
return event
def test_returns_none_for_unrelated_event() -> None:
"""An event with a non-oauth type returns None."""
event = MagicMock()
event.type = "response.output_text.delta"
assert try_parse_oauth_consent_event(event, "model-x") is None
def test_returns_none_for_event_without_type() -> None:
"""An event object missing a 'type' attribute returns None."""
event = object() # no type attribute
assert try_parse_oauth_consent_event(event, "model-x") is None
def test_parses_output_item_added_with_valid_link() -> None:
"""A response.output_item.added event with a valid HTTPS link produces Content."""
event = _make_output_item_event()
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert update.role == "assistant"
assert update.model == "test-model"
assert update.raw_representation is event
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent) == 1
assert consent[0].consent_link == "https://consent.example.com/auth"
def test_parses_top_level_consent_requested_event() -> None:
"""A response.oauth_consent_requested event produces Content."""
event = _make_top_level_event()
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
assert len(consent) == 1
assert consent[0].consent_link == "https://consent.example.com/authorize"
def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None:
"""A non-HTTPS consent_link produces an update with empty contents and logs a warning."""
event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "non-HTTPS" in caplog.text
def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None:
"""A None consent_link produces an update with empty contents and logs a warning."""
event = _make_output_item_event(consent_link=None, item_id="item-none")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "without valid consent_link" in caplog.text
def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None:
"""An empty-string consent_link produces an update with empty contents and logs a warning."""
event = _make_output_item_event(consent_link="", item_id="item-empty")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "without valid consent_link" in caplog.text
def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
"""An HTTPS URL with empty netloc (https:///path) is rejected."""
event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc")
with caplog.at_level(logging.WARNING):
update = try_parse_oauth_consent_event(event, "test-model")
assert update is not None
assert len(update.contents) == 0
assert "non-HTTPS" in caplog.text
# endregion