Skip to content

Commit 35bd561

Browse files
committed
Add tests for auth_init.py
1 parent c228bfb commit 35bd561

File tree

4 files changed

+254
-7
lines changed

4 files changed

+254
-7
lines changed

AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,22 @@ When adding a new feature, add tests for it in the appropriate file.
7878
If the feature is a UI element, add an e2e test for it.
7979
If it is an API endpoint, add an app integration test for it.
8080
If it is a function or method, add a unit test for it.
81-
Use mocks from conftest.py to mock external services.
81+
Use mocks from tests/conftest.py to mock external services. Prefer mocking at the HTTP/requests level when possible.
8282

8383
When you're running tests, make sure you activate the .venv virtual environment first:
8484

85-
```bash
85+
```shell
8686
source .venv/bin/activate
8787
```
8888

8989
## Sending pull requests
9090

9191
When sending pull requests, make sure to follow the PULL_REQUEST_TEMPLATE.md format.
92+
93+
## Upgrading dependencies
94+
95+
To upgrade a particular package in the backend, use the following command, replacing `<package-name>` with the name of the package you want to upgrade:
96+
97+
```shell
98+
cd app/backend && uv pip compile requirements.in -o requirements.txt --python-version 3.9 --upgrade-package package-name
99+
```

app/backend/requirements.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ msal-extensions==1.3.1
199199
# via azure-identity
200200
msgraph-core==1.3.3
201201
# via msgraph-sdk
202-
msgraph-sdk==1.26.0
202+
msgraph-sdk==1.45.0
203203
# via -r requirements.in
204204
msrest==0.7.1
205205
# via azure-monitor-opentelemetry-exporter
@@ -431,7 +431,6 @@ typing-extensions==4.13.2
431431
# pypdf
432432
# quart
433433
# quart-cors
434-
# rich
435434
# taskgroup
436435
# uvicorn
437436
urllib3==2.5.0

tests/mocks.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,17 @@
3434

3535
class MockAzureCredential(AsyncTokenCredential):
3636

37-
async def get_token(self, uri):
38-
return MockToken("", 9999999999, "")
37+
async def get_token(self, *scopes, **kwargs): # accept claims, enable_cae, etc.
38+
# Return a simple mock token structure with required attributes
39+
return MockToken("mock-token", 9999999999, "mock-token")
3940

4041

4142
class MockAzureCredentialExpired(AsyncTokenCredential):
4243

4344
def __init__(self):
4445
self.access_number = 0
4546

46-
async def get_token(self, uri):
47+
async def get_token(self, *scopes, **kwargs):
4748
self.access_number += 1
4849
if self.access_number == 1:
4950
return MockToken("", 0, "")

tests/test_auth_init.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import os
2+
from unittest import mock
3+
4+
import pytest
5+
from msgraph import GraphServiceClient
6+
from msgraph.generated.models.application import Application
7+
from msgraph.generated.models.password_credential import PasswordCredential
8+
from msgraph.generated.models.service_principal import ServicePrincipal
9+
10+
from .mocks import MockAzureCredential
11+
from scripts import auth_init
12+
from scripts.auth_init import (
13+
add_client_secret,
14+
client_app,
15+
create_application,
16+
create_or_update_application_with_secret,
17+
server_app_initial,
18+
server_app_permission_setup,
19+
)
20+
21+
22+
@pytest.fixture
23+
def graph_client(monkeypatch):
24+
"""GraphServiceClient whose network layer is intercepted to avoid real HTTP calls.
25+
26+
We exercise real request builders while intercepting the adapter's send_async.
27+
"""
28+
29+
client = GraphServiceClient(credentials=MockAzureCredential(), scopes=["https://graph.microsoft.com/.default"])
30+
31+
calls = {
32+
"applications.post": [],
33+
"applications.patch": [],
34+
"applications.add_password.post": [],
35+
"service_principals.post": [],
36+
}
37+
created_ids = {"object_id": "OBJ123", "client_id": "APP123"}
38+
secret_text_value = {"value": "SECRET_VALUE"}
39+
40+
async def fake_send_async(request_info, return_type, error_mapping=None):
41+
url = request_info.url or ""
42+
method = (
43+
request_info.http_method.value
44+
if hasattr(request_info.http_method, "value")
45+
else str(request_info.http_method)
46+
)
47+
if method == "POST" and url.endswith("/applications"):
48+
body = request_info.content
49+
calls["applications.post"].append(body)
50+
return Application(
51+
id=created_ids["object_id"],
52+
app_id=created_ids["client_id"],
53+
display_name=getattr(body, "display_name", None),
54+
)
55+
if method == "POST" and url.endswith("/servicePrincipals"):
56+
calls["service_principals.post"].append(request_info.content)
57+
return ServicePrincipal()
58+
if method == "PATCH" and "/applications/" in url:
59+
calls["applications.patch"].append(request_info.content)
60+
return Application()
61+
if method == "POST" and url.endswith("/addPassword"):
62+
calls["applications.add_password.post"].append(request_info.content)
63+
return PasswordCredential(secret_text=secret_text_value["value"])
64+
raise AssertionError(f"Unexpected request: {method} {url}")
65+
66+
# Patch the adapter
67+
monkeypatch.setattr(client.request_adapter, "send_async", fake_send_async)
68+
69+
client._test_calls = calls # type: ignore[attr-defined]
70+
client._test_secret_text_value = secret_text_value # type: ignore[attr-defined]
71+
client._test_ids = created_ids # type: ignore[attr-defined]
72+
return client
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_create_application_success(graph_client):
77+
graph = graph_client
78+
request = server_app_initial(42)
79+
object_id, client_id = await create_application(graph, request)
80+
assert object_id == "OBJ123"
81+
assert client_id == "APP123"
82+
assert len(graph._test_calls["service_principals.post"]) == 1
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_create_application_missing_ids(graph_client, monkeypatch):
87+
graph = graph_client
88+
89+
original_send_async = graph.request_adapter.send_async
90+
91+
async def bad_send_async(request_info, return_type, error_mapping=None): # type: ignore[unused-argument]
92+
url = request_info.url or ""
93+
method = (
94+
request_info.http_method.value
95+
if hasattr(request_info.http_method, "value")
96+
else str(request_info.http_method)
97+
)
98+
if method == "POST" and url.endswith("/applications"):
99+
return Application(id=None, app_id=None)
100+
return await original_send_async(request_info, return_type, error_mapping)
101+
102+
monkeypatch.setattr(graph.request_adapter, "send_async", bad_send_async)
103+
with pytest.raises(ValueError):
104+
await create_application(graph, server_app_initial(1))
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_add_client_secret_success(graph_client):
109+
graph = graph_client
110+
secret = await add_client_secret(graph, "OBJ123")
111+
assert secret == "SECRET_VALUE"
112+
assert len(graph._test_calls["applications.add_password.post"]) == 1
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_add_client_secret_missing_secret(graph_client):
117+
graph = graph_client
118+
graph._test_secret_text_value["value"] = None # type: ignore
119+
with pytest.raises(ValueError):
120+
await add_client_secret(graph, "OBJ123")
121+
122+
123+
@pytest.mark.asyncio
124+
async def test_create_or_update_application_creates_and_adds_secret(graph_client, monkeypatch):
125+
graph = graph_client
126+
updates: list[tuple[str, str]] = []
127+
128+
def fake_update_env(name, val):
129+
updates.append((name, val))
130+
131+
# Ensure env vars not set
132+
with mock.patch.dict(os.environ, {}, clear=True):
133+
monkeypatch.setattr(auth_init, "update_azd_env", fake_update_env)
134+
135+
# Force get_application to return None (not found)
136+
async def fake_get_application(graph_client, client_id):
137+
return None
138+
139+
monkeypatch.setattr("scripts.auth_init.get_application", fake_get_application)
140+
object_id, app_id, created = await create_or_update_application_with_secret(
141+
graph,
142+
app_id_env_var="AZURE_SERVER_APP_ID",
143+
app_secret_env_var="AZURE_SERVER_APP_SECRET",
144+
request_app=server_app_initial(55),
145+
)
146+
assert created is True
147+
assert object_id == "OBJ123"
148+
assert app_id == "APP123"
149+
# Two updates: app id and secret
150+
assert {u[0] for u in updates} == {"AZURE_SERVER_APP_ID", "AZURE_SERVER_APP_SECRET"}
151+
assert len(graph._test_calls["applications.add_password.post"]) == 1
152+
153+
154+
@pytest.mark.asyncio
155+
async def test_create_or_update_application_existing_adds_secret(graph_client, monkeypatch):
156+
graph = graph_client
157+
updates: list[tuple[str, str]] = []
158+
159+
def fake_update_env(name, val):
160+
updates.append((name, val))
161+
162+
with mock.patch.dict(os.environ, {"AZURE_SERVER_APP_ID": "APP123"}, clear=True):
163+
monkeypatch.setattr(auth_init, "update_azd_env", fake_update_env)
164+
165+
async def fake_get_application(graph_client, client_id):
166+
# Return existing object id for provided app id
167+
return "OBJ999"
168+
169+
monkeypatch.setattr("scripts.auth_init.get_application", fake_get_application)
170+
object_id, app_id, created = await create_or_update_application_with_secret(
171+
graph,
172+
app_id_env_var="AZURE_SERVER_APP_ID",
173+
app_secret_env_var="AZURE_SERVER_APP_SECRET",
174+
request_app=server_app_initial(77),
175+
)
176+
assert created is False
177+
assert object_id == "OBJ999"
178+
assert app_id == "APP123"
179+
# Secret should be added since not in env
180+
assert any(name == "AZURE_SERVER_APP_SECRET" for name, _ in updates)
181+
# Application patch should have been called
182+
# Patch captured
183+
assert len(graph._test_calls["applications.patch"]) == 1
184+
185+
186+
@pytest.mark.asyncio
187+
async def test_create_or_update_application_existing_with_secret(graph_client, monkeypatch):
188+
graph = graph_client
189+
with mock.patch.dict(
190+
os.environ, {"AZURE_SERVER_APP_ID": "APP123", "AZURE_SERVER_APP_SECRET": "EXISTING"}, clear=True
191+
):
192+
193+
async def fake_get_application(graph_client, client_id):
194+
return "OBJ999"
195+
196+
monkeypatch.setattr("scripts.auth_init.get_application", fake_get_application)
197+
object_id, app_id, created = await create_or_update_application_with_secret(
198+
graph,
199+
app_id_env_var="AZURE_SERVER_APP_ID",
200+
app_secret_env_var="AZURE_SERVER_APP_SECRET",
201+
request_app=server_app_initial(88),
202+
)
203+
assert created is False
204+
assert object_id == "OBJ999"
205+
assert app_id == "APP123"
206+
# No secret added
207+
assert len(graph._test_calls["applications.add_password.post"]) == 0
208+
209+
210+
def test_client_app_validation_errors():
211+
# Server app without api
212+
server_app = server_app_initial(1)
213+
server_app.api = None # type: ignore
214+
with pytest.raises(ValueError):
215+
client_app("server_app_id", server_app, 2)
216+
217+
# Server app with empty scopes
218+
# attach empty api
219+
server_app_permission = server_app_permission_setup("server_app")
220+
server_app_permission.api.oauth2_permission_scopes = [] # type: ignore
221+
with pytest.raises(ValueError):
222+
client_app("server_app_id", server_app_permission, 2)
223+
224+
225+
def test_client_app_success():
226+
server_app_permission = server_app_permission_setup("server_app")
227+
c_app = client_app("server_app", server_app_permission, 123)
228+
assert c_app.web is not None
229+
assert c_app.spa is not None
230+
assert c_app.required_resource_access is not None
231+
assert len(c_app.required_resource_access) >= 1
232+
233+
234+
def test_server_app_permission_setup():
235+
# simulate after creation we know app id
236+
app_with_permissions = server_app_permission_setup("server_app_id")
237+
assert app_with_permissions.identifier_uris == ["api://server_app_id"]
238+
assert app_with_permissions.required_resource_access is not None
239+
assert len(app_with_permissions.required_resource_access) == 1

0 commit comments

Comments
 (0)