Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions tests/tools/test_microsoft_graph_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Tests for tools/microsoft_graph_auth.py."""

from __future__ import annotations

import asyncio

import httpx
import pytest

from tools.microsoft_graph_auth import (
CachedAccessToken,
DEFAULT_GRAPH_SCOPE,
GraphCredentials,
MicrosoftGraphConfigError,
MicrosoftGraphTokenError,
MicrosoftGraphTokenProvider,
)


class TestGraphCredentials:
def test_from_env_raises_for_missing_required_values(self):
with pytest.raises(MicrosoftGraphConfigError) as exc:
GraphCredentials.from_env({})
assert "MSGRAPH_TENANT_ID" in str(exc.value)
assert "MSGRAPH_CLIENT_ID" in str(exc.value)
assert "MSGRAPH_CLIENT_SECRET" in str(exc.value)

def test_from_env_optional_returns_none_when_not_configured(self):
assert GraphCredentials.from_env({}, required=False) is None

def test_from_env_builds_normalized_credentials(self):
creds = GraphCredentials.from_env(
{
"MSGRAPH_TENANT_ID": "tenant-123",
"MSGRAPH_CLIENT_ID": "client-456",
"MSGRAPH_CLIENT_SECRET": "secret-789",
}
)
assert creds is not None
assert creds.scope == DEFAULT_GRAPH_SCOPE
assert creds.token_url.endswith("/tenant-123/oauth2/v2.0/token")


@pytest.mark.anyio
class TestMicrosoftGraphTokenProvider:
async def test_reuses_cached_token_until_expiry(self):
calls: list[int] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(1)
return httpx.Response(
200,
json={
"access_token": f"token-{len(calls)}",
"expires_in": 3600,
"token_type": "Bearer",
},
)

provider = MicrosoftGraphTokenProvider(
GraphCredentials("tenant", "client", "secret"),
transport=httpx.MockTransport(handler),
)

first = await provider.get_access_token()
second = await provider.get_access_token()

assert first == "token-1"
assert second == "token-1"
assert len(calls) == 1

async def test_concurrent_calls_share_one_token_fetch(self):
calls: list[int] = []

provider = MicrosoftGraphTokenProvider(
GraphCredentials("tenant", "client", "secret"),
)

async def _fake_fetch():
calls.append(1)
await asyncio.sleep(0)
return CachedAccessToken(
access_token="token-1",
token_type="Bearer",
expires_at=9_999_999_999,
)

provider._fetch_access_token = _fake_fetch # type: ignore[method-assign]

first, second = await asyncio.gather(
provider.get_access_token(),
provider.get_access_token(),
)

assert first == "token-1"
assert second == "token-1"
assert len(calls) == 1

async def test_refreshes_when_cached_token_is_expired(self):
calls: list[int] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(1)
expires_in = 0 if len(calls) == 1 else 3600
return httpx.Response(
200,
json={
"access_token": f"token-{len(calls)}",
"expires_in": expires_in,
"token_type": "Bearer",
},
)

provider = MicrosoftGraphTokenProvider(
GraphCredentials("tenant", "client", "secret"),
transport=httpx.MockTransport(handler),
skew_seconds=0,
)

first = await provider.get_access_token()
second = await provider.get_access_token()

assert first == "token-1"
assert second == "token-2"
assert len(calls) == 2

async def test_force_refresh_bypasses_cache(self):
calls: list[int] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(1)
return httpx.Response(
200,
json={
"access_token": f"token-{len(calls)}",
"expires_in": 3600,
},
)

provider = MicrosoftGraphTokenProvider(
GraphCredentials("tenant", "client", "secret"),
transport=httpx.MockTransport(handler),
)

first = await provider.get_access_token()
second = await provider.get_access_token(force_refresh=True)

assert first == "token-1"
assert second == "token-2"
assert len(calls) == 2

async def test_invalid_token_response_raises(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"expires_in": 3600})

provider = MicrosoftGraphTokenProvider(
GraphCredentials("tenant", "client", "secret"),
transport=httpx.MockTransport(handler),
)

with pytest.raises(MicrosoftGraphTokenError) as exc:
await provider.get_access_token()
assert "access_token" in str(exc.value)

async def test_http_error_includes_server_message(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
401,
json={"error": "invalid_client", "error_description": "bad secret"},
)

provider = MicrosoftGraphTokenProvider(
GraphCredentials("tenant", "client", "secret"),
transport=httpx.MockTransport(handler),
)

with pytest.raises(MicrosoftGraphTokenError) as exc:
await provider.get_access_token()
assert "bad secret" in str(exc.value)
152 changes: 152 additions & 0 deletions tests/tools/test_microsoft_graph_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Tests for tools/microsoft_graph_client.py."""

from __future__ import annotations

from pathlib import Path

import httpx
import pytest

from tools.microsoft_graph_auth import GraphCredentials, MicrosoftGraphTokenProvider
from tools.microsoft_graph_client import (
MicrosoftGraphAPIError,
MicrosoftGraphClient,
MicrosoftGraphClientError,
)


def _make_provider() -> MicrosoftGraphTokenProvider:
provider = MicrosoftGraphTokenProvider(GraphCredentials("tenant", "client", "secret"))
provider._cached_token = type( # type: ignore[attr-defined]
"Token",
(),
{
"access_token": "cached-token",
"is_expired": lambda self, skew_seconds=0: False,
"expires_in_seconds": 3600,
},
)()
return provider


@pytest.mark.anyio
class TestMicrosoftGraphClient:
async def test_attaches_bearer_token_header(self):
captured_auth: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
captured_auth.append(request.headers["Authorization"])
return httpx.Response(200, json={"ok": True})

client = MicrosoftGraphClient(
_make_provider(),
transport=httpx.MockTransport(handler),
)
payload = await client.get_json("/me")
assert payload == {"ok": True}
assert captured_auth == ["Bearer cached-token"]

async def test_retries_on_rate_limit_and_uses_retry_after(self):
calls: list[int] = []
sleeps: list[float] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(1)
if len(calls) == 1:
return httpx.Response(
429,
json={"error": {"code": "TooManyRequests", "message": "slow down"}},
headers={"Retry-After": "3"},
)
return httpx.Response(200, json={"ok": True})

async def fake_sleep(delay: float) -> None:
sleeps.append(delay)

client = MicrosoftGraphClient(
_make_provider(),
transport=httpx.MockTransport(handler),
sleep=fake_sleep,
max_retries=2,
)

payload = await client.get_json("/me")

assert payload == {"ok": True}
assert len(calls) == 2
assert sleeps == [3.0]

async def test_raises_api_error_after_retry_budget_exhausted(self):
sleeps: list[float] = []

def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, json={"error": {"message": "unavailable"}})

async def fake_sleep(delay: float) -> None:
sleeps.append(delay)

client = MicrosoftGraphClient(
_make_provider(),
transport=httpx.MockTransport(handler),
sleep=fake_sleep,
max_retries=1,
)

with pytest.raises(MicrosoftGraphAPIError) as exc:
await client.get_json("/me")
assert exc.value.status_code == 503
assert sleeps == [0.5]

async def test_collect_paginated_flattens_value_arrays(self):
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url).endswith("/items"):
return httpx.Response(
200,
json={
"value": [{"id": "1"}],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/items?page=2",
},
)
return httpx.Response(200, json={"value": [{"id": "2"}]})

client = MicrosoftGraphClient(
_make_provider(),
transport=httpx.MockTransport(handler),
)
items = await client.collect_paginated("/items")
assert items == [{"id": "1"}, {"id": "2"}]

async def test_download_to_file_writes_binary_content(self, tmp_path: Path):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
content=b"meeting-recording",
headers={"content-type": "video/mp4"},
)

client = MicrosoftGraphClient(
_make_provider(),
transport=httpx.MockTransport(handler),
)
destination = tmp_path / "recording.mp4"
result = await client.download_to_file("/drive/item/content", destination)

assert destination.read_bytes() == b"meeting-recording"
assert result["content_type"] == "video/mp4"
assert result["size_bytes"] == len(b"meeting-recording")

async def test_invalid_json_response_raises_client_error(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
content=b"not-json",
headers={"content-type": "application/json"},
)

client = MicrosoftGraphClient(
_make_provider(),
transport=httpx.MockTransport(handler),
)

with pytest.raises(MicrosoftGraphClientError):
await client.get_json("/me")
Loading
Loading