Skip to content

Commit a152c70

Browse files
dlkakbsteknium1
authored andcommitted
feat(msgraph): add auth and client foundation
1 parent ea8e608 commit a152c70

4 files changed

Lines changed: 873 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Tests for tools/microsoft_graph_auth.py."""
2+
3+
from __future__ import annotations
4+
5+
import httpx
6+
import pytest
7+
8+
from tools.microsoft_graph_auth import (
9+
DEFAULT_GRAPH_SCOPE,
10+
GraphCredentials,
11+
MicrosoftGraphConfigError,
12+
MicrosoftGraphTokenError,
13+
MicrosoftGraphTokenProvider,
14+
)
15+
16+
17+
class TestGraphCredentials:
18+
def test_from_env_raises_for_missing_required_values(self):
19+
with pytest.raises(MicrosoftGraphConfigError) as exc:
20+
GraphCredentials.from_env({})
21+
assert "MSGRAPH_TENANT_ID" in str(exc.value)
22+
assert "MSGRAPH_CLIENT_ID" in str(exc.value)
23+
assert "MSGRAPH_CLIENT_SECRET" in str(exc.value)
24+
25+
def test_from_env_optional_returns_none_when_not_configured(self):
26+
assert GraphCredentials.from_env({}, required=False) is None
27+
28+
def test_from_env_builds_normalized_credentials(self):
29+
creds = GraphCredentials.from_env(
30+
{
31+
"MSGRAPH_TENANT_ID": "tenant-123",
32+
"MSGRAPH_CLIENT_ID": "client-456",
33+
"MSGRAPH_CLIENT_SECRET": "secret-789",
34+
}
35+
)
36+
assert creds is not None
37+
assert creds.scope == DEFAULT_GRAPH_SCOPE
38+
assert creds.token_url.endswith("/tenant-123/oauth2/v2.0/token")
39+
40+
41+
@pytest.mark.anyio
42+
class TestMicrosoftGraphTokenProvider:
43+
async def test_reuses_cached_token_until_expiry(self):
44+
calls: list[int] = []
45+
46+
def handler(request: httpx.Request) -> httpx.Response:
47+
calls.append(1)
48+
return httpx.Response(
49+
200,
50+
json={
51+
"access_token": f"token-{len(calls)}",
52+
"expires_in": 3600,
53+
"token_type": "Bearer",
54+
},
55+
)
56+
57+
provider = MicrosoftGraphTokenProvider(
58+
GraphCredentials("tenant", "client", "secret"),
59+
transport=httpx.MockTransport(handler),
60+
)
61+
62+
first = await provider.get_access_token()
63+
second = await provider.get_access_token()
64+
65+
assert first == "token-1"
66+
assert second == "token-1"
67+
assert len(calls) == 1
68+
69+
async def test_refreshes_when_cached_token_is_expired(self):
70+
calls: list[int] = []
71+
72+
def handler(request: httpx.Request) -> httpx.Response:
73+
calls.append(1)
74+
expires_in = 0 if len(calls) == 1 else 3600
75+
return httpx.Response(
76+
200,
77+
json={
78+
"access_token": f"token-{len(calls)}",
79+
"expires_in": expires_in,
80+
"token_type": "Bearer",
81+
},
82+
)
83+
84+
provider = MicrosoftGraphTokenProvider(
85+
GraphCredentials("tenant", "client", "secret"),
86+
transport=httpx.MockTransport(handler),
87+
skew_seconds=0,
88+
)
89+
90+
first = await provider.get_access_token()
91+
second = await provider.get_access_token()
92+
93+
assert first == "token-1"
94+
assert second == "token-2"
95+
assert len(calls) == 2
96+
97+
async def test_force_refresh_bypasses_cache(self):
98+
calls: list[int] = []
99+
100+
def handler(request: httpx.Request) -> httpx.Response:
101+
calls.append(1)
102+
return httpx.Response(
103+
200,
104+
json={
105+
"access_token": f"token-{len(calls)}",
106+
"expires_in": 3600,
107+
},
108+
)
109+
110+
provider = MicrosoftGraphTokenProvider(
111+
GraphCredentials("tenant", "client", "secret"),
112+
transport=httpx.MockTransport(handler),
113+
)
114+
115+
first = await provider.get_access_token()
116+
second = await provider.get_access_token(force_refresh=True)
117+
118+
assert first == "token-1"
119+
assert second == "token-2"
120+
assert len(calls) == 2
121+
122+
async def test_invalid_token_response_raises(self):
123+
def handler(request: httpx.Request) -> httpx.Response:
124+
return httpx.Response(200, json={"expires_in": 3600})
125+
126+
provider = MicrosoftGraphTokenProvider(
127+
GraphCredentials("tenant", "client", "secret"),
128+
transport=httpx.MockTransport(handler),
129+
)
130+
131+
with pytest.raises(MicrosoftGraphTokenError) as exc:
132+
await provider.get_access_token()
133+
assert "access_token" in str(exc.value)
134+
135+
async def test_http_error_includes_server_message(self):
136+
def handler(request: httpx.Request) -> httpx.Response:
137+
return httpx.Response(
138+
401,
139+
json={"error": "invalid_client", "error_description": "bad secret"},
140+
)
141+
142+
provider = MicrosoftGraphTokenProvider(
143+
GraphCredentials("tenant", "client", "secret"),
144+
transport=httpx.MockTransport(handler),
145+
)
146+
147+
with pytest.raises(MicrosoftGraphTokenError) as exc:
148+
await provider.get_access_token()
149+
assert "bad secret" in str(exc.value)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Tests for tools/microsoft_graph_client.py."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import httpx
8+
import pytest
9+
10+
from tools.microsoft_graph_auth import GraphCredentials, MicrosoftGraphTokenProvider
11+
from tools.microsoft_graph_client import (
12+
MicrosoftGraphAPIError,
13+
MicrosoftGraphClient,
14+
MicrosoftGraphClientError,
15+
)
16+
17+
18+
def _make_provider() -> MicrosoftGraphTokenProvider:
19+
provider = MicrosoftGraphTokenProvider(GraphCredentials("tenant", "client", "secret"))
20+
provider._cached_token = type( # type: ignore[attr-defined]
21+
"Token",
22+
(),
23+
{
24+
"access_token": "cached-token",
25+
"is_expired": lambda self, skew_seconds=0: False,
26+
"expires_in_seconds": 3600,
27+
},
28+
)()
29+
return provider
30+
31+
32+
@pytest.mark.anyio
33+
class TestMicrosoftGraphClient:
34+
async def test_attaches_bearer_token_header(self):
35+
captured_auth: list[str] = []
36+
37+
def handler(request: httpx.Request) -> httpx.Response:
38+
captured_auth.append(request.headers["Authorization"])
39+
return httpx.Response(200, json={"ok": True})
40+
41+
client = MicrosoftGraphClient(
42+
_make_provider(),
43+
transport=httpx.MockTransport(handler),
44+
)
45+
payload = await client.get_json("/me")
46+
assert payload == {"ok": True}
47+
assert captured_auth == ["Bearer cached-token"]
48+
49+
async def test_retries_on_rate_limit_and_uses_retry_after(self):
50+
calls: list[int] = []
51+
sleeps: list[float] = []
52+
53+
def handler(request: httpx.Request) -> httpx.Response:
54+
calls.append(1)
55+
if len(calls) == 1:
56+
return httpx.Response(
57+
429,
58+
json={"error": {"code": "TooManyRequests", "message": "slow down"}},
59+
headers={"Retry-After": "3"},
60+
)
61+
return httpx.Response(200, json={"ok": True})
62+
63+
async def fake_sleep(delay: float) -> None:
64+
sleeps.append(delay)
65+
66+
client = MicrosoftGraphClient(
67+
_make_provider(),
68+
transport=httpx.MockTransport(handler),
69+
sleep=fake_sleep,
70+
max_retries=2,
71+
)
72+
73+
payload = await client.get_json("/me")
74+
75+
assert payload == {"ok": True}
76+
assert len(calls) == 2
77+
assert sleeps == [3.0]
78+
79+
async def test_raises_api_error_after_retry_budget_exhausted(self):
80+
sleeps: list[float] = []
81+
82+
def handler(request: httpx.Request) -> httpx.Response:
83+
return httpx.Response(503, json={"error": {"message": "unavailable"}})
84+
85+
async def fake_sleep(delay: float) -> None:
86+
sleeps.append(delay)
87+
88+
client = MicrosoftGraphClient(
89+
_make_provider(),
90+
transport=httpx.MockTransport(handler),
91+
sleep=fake_sleep,
92+
max_retries=1,
93+
)
94+
95+
with pytest.raises(MicrosoftGraphAPIError) as exc:
96+
await client.get_json("/me")
97+
assert exc.value.status_code == 503
98+
assert sleeps == [0.5]
99+
100+
async def test_collect_paginated_flattens_value_arrays(self):
101+
def handler(request: httpx.Request) -> httpx.Response:
102+
if str(request.url).endswith("/items"):
103+
return httpx.Response(
104+
200,
105+
json={
106+
"value": [{"id": "1"}],
107+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/items?page=2",
108+
},
109+
)
110+
return httpx.Response(200, json={"value": [{"id": "2"}]})
111+
112+
client = MicrosoftGraphClient(
113+
_make_provider(),
114+
transport=httpx.MockTransport(handler),
115+
)
116+
items = await client.collect_paginated("/items")
117+
assert items == [{"id": "1"}, {"id": "2"}]
118+
119+
async def test_download_to_file_writes_binary_content(self, tmp_path: Path):
120+
def handler(request: httpx.Request) -> httpx.Response:
121+
return httpx.Response(
122+
200,
123+
content=b"meeting-recording",
124+
headers={"content-type": "video/mp4"},
125+
)
126+
127+
client = MicrosoftGraphClient(
128+
_make_provider(),
129+
transport=httpx.MockTransport(handler),
130+
)
131+
destination = tmp_path / "recording.mp4"
132+
result = await client.download_to_file("/drive/item/content", destination)
133+
134+
assert destination.read_bytes() == b"meeting-recording"
135+
assert result["content_type"] == "video/mp4"
136+
assert result["size_bytes"] == len(b"meeting-recording")
137+
138+
async def test_invalid_json_response_raises_client_error(self):
139+
def handler(request: httpx.Request) -> httpx.Response:
140+
return httpx.Response(
141+
200,
142+
content=b"not-json",
143+
headers={"content-type": "application/json"},
144+
)
145+
146+
client = MicrosoftGraphClient(
147+
_make_provider(),
148+
transport=httpx.MockTransport(handler),
149+
)
150+
151+
with pytest.raises(MicrosoftGraphClientError):
152+
await client.get_json("/me")

0 commit comments

Comments
 (0)