chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
"""Tests for the Microsoft Graph webhook adapter."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides
|
||||
from gateway.platforms.msgraph_webhook import AIOHTTP_AVAILABLE, MSGraphWebhookAdapter
|
||||
|
||||
|
||||
def _make_adapter(**extra_overrides) -> MSGraphWebhookAdapter:
|
||||
extra = {
|
||||
"host": "127.0.0.1",
|
||||
"client_state": "expected-client-state",
|
||||
"accepted_resources": ["communications/onlineMeetings"],
|
||||
}
|
||||
extra.update(extra_overrides)
|
||||
return MSGraphWebhookAdapter(PlatformConfig(enabled=True, extra=extra))
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
query=None,
|
||||
json_payload=None,
|
||||
raw_body: bytes | None = None,
|
||||
content_length: int | None = None,
|
||||
remote="127.0.0.1",
|
||||
):
|
||||
self.query = query or {}
|
||||
self._json_payload = json_payload
|
||||
self._raw_body = raw_body
|
||||
self.content_length = content_length
|
||||
self.remote = remote
|
||||
|
||||
async def json(self):
|
||||
if isinstance(self._json_payload, Exception):
|
||||
raise self._json_payload
|
||||
return self._json_payload
|
||||
|
||||
async def read(self):
|
||||
if self._raw_body is not None:
|
||||
return self._raw_body
|
||||
return json.dumps(self._json_payload or {}).encode("utf-8")
|
||||
|
||||
|
||||
class TestMSGraphWebhookConfig:
|
||||
def test_gateway_config_accepts_msgraph_webhook_platform(self):
|
||||
config = GatewayConfig.from_dict(
|
||||
{
|
||||
"platforms": {
|
||||
"msgraph_webhook": {
|
||||
"enabled": True,
|
||||
"extra": {"client_state": "expected"},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert Platform.MSGRAPH_WEBHOOK in config.platforms
|
||||
assert Platform.MSGRAPH_WEBHOOK in config.get_connected_platforms()
|
||||
|
||||
def test_env_overrides_apply_to_existing_msgraph_webhook_platform(self, monkeypatch):
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.MSGRAPH_WEBHOOK: PlatformConfig(enabled=True, extra={})}
|
||||
)
|
||||
|
||||
monkeypatch.setenv("MSGRAPH_WEBHOOK_PORT", "8650")
|
||||
monkeypatch.setenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "env-state")
|
||||
monkeypatch.setenv(
|
||||
"MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES",
|
||||
"communications/onlineMeetings, chats/getAllMessages",
|
||||
)
|
||||
|
||||
_apply_env_overrides(config)
|
||||
|
||||
extra = config.platforms[Platform.MSGRAPH_WEBHOOK].extra
|
||||
assert extra["port"] == 8650
|
||||
assert extra["client_state"] == "env-state"
|
||||
assert extra["accepted_resources"] == [
|
||||
"communications/onlineMeetings",
|
||||
"chats/getAllMessages",
|
||||
]
|
||||
|
||||
|
||||
class TestMSGraphValidationHandshake:
|
||||
@pytest.mark.anyio
|
||||
async def test_connect_requires_client_state(self):
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
pytest.skip("aiohttp not installed")
|
||||
adapter = MSGraphWebhookAdapter(PlatformConfig(enabled=True, extra={}))
|
||||
connected = await adapter.connect()
|
||||
assert connected is False
|
||||
# is_connected is a @property on the base adapter, not a method.
|
||||
assert adapter.is_connected is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connect_requires_source_allowlist_on_public_bind(self):
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
pytest.skip("aiohttp not installed")
|
||||
adapter = _make_adapter(host="0.0.0.0", port=0, allowed_source_cidrs=[])
|
||||
connected = await adapter.connect()
|
||||
assert connected is False
|
||||
assert adapter.is_connected is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connect_allows_loopback_without_source_allowlist(self):
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
pytest.skip("aiohttp not installed")
|
||||
adapter = _make_adapter(host="127.0.0.1", port=0, allowed_source_cidrs=[])
|
||||
try:
|
||||
connected = await adapter.connect()
|
||||
assert connected is True
|
||||
assert adapter.is_connected is True
|
||||
finally:
|
||||
await adapter.disconnect()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validation_token_echo_on_get(self):
|
||||
adapter = _make_adapter()
|
||||
resp = await adapter._handle_validation(
|
||||
_FakeRequest(query={"validationToken": "abc123"})
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert resp.text == "abc123"
|
||||
assert resp.content_type == "text/plain"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bare_get_without_validation_token_rejected(self):
|
||||
"""GET without validationToken is 400 so the endpoint can't be enumerated."""
|
||||
adapter = _make_adapter()
|
||||
resp = await adapter._handle_validation(_FakeRequest())
|
||||
assert resp.status == 400
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_post_with_validation_token_still_echoes(self):
|
||||
"""Tolerate defensive clients that send validationToken on POST."""
|
||||
adapter = _make_adapter()
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(query={"validationToken": "abc123"})
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert resp.text == "abc123"
|
||||
|
||||
|
||||
class TestMSGraphNotifications:
|
||||
@pytest.mark.anyio
|
||||
async def test_missing_client_state_is_auth_rejected(self):
|
||||
adapter = _make_adapter(client_state=None)
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-no-client-state",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-1",
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
assert resp.status == 403
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_valid_notification_accepted_and_scheduled(self):
|
||||
adapter = _make_adapter()
|
||||
scheduled: list[tuple[dict, object]] = []
|
||||
|
||||
async def _capture(notification, event):
|
||||
scheduled.append((notification, event))
|
||||
|
||||
adapter.set_notification_scheduler(_capture)
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-1",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-1",
|
||||
"clientState": "expected-client-state",
|
||||
"resourceData": {"id": "meeting-1"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
resp = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
# Success is 202 with empty body: internal counters must not leak to
|
||||
# the wire. Counters are still observable via /health.
|
||||
assert resp.status == 202
|
||||
assert resp.body is None or not resp.body
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert len(scheduled) == 1
|
||||
notification, event = scheduled[0]
|
||||
assert notification["id"] == "notif-1"
|
||||
assert event.source.platform == Platform.MSGRAPH_WEBHOOK
|
||||
assert event.source.chat_type == "webhook"
|
||||
assert event.message_id == "id:notif-1"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oversized_notification_rejected_by_content_length(self):
|
||||
adapter = _make_adapter(max_body_bytes=100)
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-oversized",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-1",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload=payload, content_length=101)
|
||||
)
|
||||
|
||||
assert resp.status == 413
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_chunked_oversized_notification_rejected_after_read(self):
|
||||
adapter = _make_adapter(max_body_bytes=100)
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-chunked-oversized",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-1",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(
|
||||
json_payload=payload,
|
||||
raw_body=b"x" * 101,
|
||||
content_length=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert resp.status == 413
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_non_object_notification_body_rejected(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload=[], raw_body=b"[]")
|
||||
)
|
||||
|
||||
assert resp.status == 400
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bad_client_state_rejected_as_auth_failure(self):
|
||||
"""Every-item-bad-clientState batches return 403 so forged POSTs stop retrying."""
|
||||
adapter = _make_adapter()
|
||||
scheduled: list[tuple[dict, object]] = []
|
||||
|
||||
async def _capture(notification, event):
|
||||
scheduled.append((notification, event))
|
||||
|
||||
adapter.set_notification_scheduler(_capture)
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-2",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-2",
|
||||
"clientState": "wrong-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
resp = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
assert resp.status == 403
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert scheduled == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_client_state_compare_is_timing_safe(self, monkeypatch):
|
||||
"""Ensure hmac.compare_digest is used for clientState comparison."""
|
||||
import hmac
|
||||
|
||||
calls: list[tuple[str, str]] = []
|
||||
real_compare = hmac.compare_digest
|
||||
|
||||
def _spy(a, b):
|
||||
calls.append((a, b))
|
||||
return real_compare(a, b)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.msgraph_webhook.hmac.compare_digest", _spy
|
||||
)
|
||||
|
||||
adapter = _make_adapter()
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-timing",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-x",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
|
||||
assert calls, "hmac.compare_digest was never called; clientState check is not timing-safe"
|
||||
provided, expected = calls[0]
|
||||
assert provided == "expected-client-state"
|
||||
assert expected == "expected-client-state"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_duplicate_notification_deduped(self):
|
||||
adapter = _make_adapter()
|
||||
scheduled: list[tuple[dict, object]] = []
|
||||
|
||||
async def _capture(notification, event):
|
||||
scheduled.append((notification, event))
|
||||
|
||||
adapter.set_notification_scheduler(_capture)
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-dup",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-3",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
first = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
assert first.status == 202
|
||||
second = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
# Duplicate-only batch still returns 202 so Graph stops retrying.
|
||||
assert second.status == 202
|
||||
assert adapter._duplicate_count == 1
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert len(scheduled) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notifications_without_id_are_not_deduped(self):
|
||||
adapter = _make_adapter()
|
||||
scheduled: list[tuple[dict, object]] = []
|
||||
|
||||
async def _capture(notification, event):
|
||||
scheduled.append((notification, event))
|
||||
|
||||
adapter.set_notification_scheduler(_capture)
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-3",
|
||||
"clientState": "expected-client-state",
|
||||
"resourceData": {"id": "meeting-3"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
first = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
second = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
|
||||
assert first.status == 202
|
||||
assert second.status == 202
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert len(scheduled) == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resource_patterns_accept_leading_slash(self):
|
||||
adapter = _make_adapter(accepted_resources=["/communications/onlineMeetings"])
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-slash",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-4",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
resp = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
assert resp.status == 202
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resource_not_in_allowlist_returns_400(self):
|
||||
"""Every-item-rejected-for-non-auth returns 400 (configuration issue)."""
|
||||
adapter = _make_adapter(accepted_resources=["communications/onlineMeetings"])
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-bad-resource",
|
||||
"resource": "users/u1/messages",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
assert resp.status == 400
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_malformed_body_returns_400(self):
|
||||
adapter = _make_adapter()
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload=ValueError("bad json"))
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_missing_value_array_returns_400(self):
|
||||
adapter = _make_adapter()
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload={"not_value": []})
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_seen_receipts_are_bounded(self):
|
||||
adapter = _make_adapter(max_seen_receipts=2)
|
||||
|
||||
async def _capture(notification, event):
|
||||
return None
|
||||
|
||||
adapter.set_notification_scheduler(_capture)
|
||||
|
||||
async def _post(notification_id: str):
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": notification_id,
|
||||
"subscriptionId": "sub-1",
|
||||
"changeType": "updated",
|
||||
"resource": "communications/onlineMeetings/meeting-3",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
return await adapter._handle_notification(_FakeRequest(json_payload=payload))
|
||||
|
||||
first = await _post("notif-a")
|
||||
second = await _post("notif-b")
|
||||
third = await _post("notif-c")
|
||||
|
||||
assert first.status == 202
|
||||
assert second.status == 202
|
||||
assert third.status == 202
|
||||
assert len(adapter._seen_receipts) == 2
|
||||
assert list(adapter._seen_receipt_order) == ["id:notif-b", "id:notif-c"]
|
||||
|
||||
replay = await _post("notif-a")
|
||||
# notif-a evicted from the bounded cache, so it's accepted again (202)
|
||||
# rather than treated as a duplicate.
|
||||
assert replay.status == 202
|
||||
assert adapter._accepted_count == 4
|
||||
|
||||
|
||||
class TestMSGraphSourceIPAllowlist:
|
||||
@pytest.mark.anyio
|
||||
async def test_public_bind_without_allowlist_fails_closed(self):
|
||||
"""Public binds must not accept requests until a source allowlist is configured."""
|
||||
adapter = _make_adapter(host="0.0.0.0", allowed_source_cidrs=[])
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-ip",
|
||||
"resource": "communications/onlineMeetings/m",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload=payload, remote="203.0.113.99")
|
||||
)
|
||||
assert resp.status == 403
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_loopback_bind_without_allowlist_still_accepts_local_requests(self):
|
||||
"""Loopback-only listeners may rely on local proxying/tunnels instead of CIDRs."""
|
||||
adapter = _make_adapter(host="127.0.0.1", allowed_source_cidrs=[])
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-ip-local",
|
||||
"resource": "communications/onlineMeetings/m",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload=payload, remote="127.0.0.1")
|
||||
)
|
||||
assert resp.status == 202
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_post_from_disallowed_ip_rejected(self):
|
||||
adapter = _make_adapter(allowed_source_cidrs=["10.0.0.0/8"])
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-ip-bad",
|
||||
"resource": "communications/onlineMeetings/m",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload=payload, remote="203.0.113.99")
|
||||
)
|
||||
assert resp.status == 403
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_post_from_allowed_ip_accepted(self):
|
||||
adapter = _make_adapter(allowed_source_cidrs=["10.0.0.0/8", "203.0.113.0/24"])
|
||||
payload = {
|
||||
"value": [
|
||||
{
|
||||
"id": "notif-ip-ok",
|
||||
"resource": "communications/onlineMeetings/m",
|
||||
"clientState": "expected-client-state",
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = await adapter._handle_notification(
|
||||
_FakeRequest(json_payload=payload, remote="203.0.113.5")
|
||||
)
|
||||
assert resp.status == 202
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validation_handshake_also_respects_allowlist(self):
|
||||
"""A disallowed IP shouldn't be able to probe the handshake endpoint."""
|
||||
adapter = _make_adapter(allowed_source_cidrs=["10.0.0.0/8"])
|
||||
resp = await adapter._handle_validation(
|
||||
_FakeRequest(query={"validationToken": "probe"}, remote="203.0.113.99")
|
||||
)
|
||||
assert resp.status == 403
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_endpoint_also_respects_allowlist(self):
|
||||
"""The readiness endpoint should not leak counters to arbitrary sources."""
|
||||
adapter = _make_adapter(allowed_source_cidrs=["10.0.0.0/8"])
|
||||
resp = await adapter._handle_health(_FakeRequest(remote="203.0.113.99"))
|
||||
assert resp.status == 403
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invalid_cidr_entries_are_ignored_at_init(self):
|
||||
"""Malformed CIDR strings should log a warning and be ignored, not crash."""
|
||||
adapter = _make_adapter(
|
||||
allowed_source_cidrs=["10.0.0.0/8", "not-a-cidr", "", "203.0.113.0/24"]
|
||||
)
|
||||
assert len(adapter._allowed_source_networks) == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cidr_list_accepts_comma_string(self):
|
||||
"""Env-var-style 'cidr1, cidr2' strings parse as a list."""
|
||||
adapter = _make_adapter(allowed_source_cidrs="10.0.0.0/8, 203.0.113.0/24")
|
||||
assert len(adapter._allowed_source_networks) == 2
|
||||
Reference in New Issue
Block a user