imap-migration-tools/test/auth/test_oauth2_microsoft.py
Javier Callico ac3307bf1d
Align names for scripts and commands (#40)
* Add comprehensive tests for IMAP email restoration functionality

- Implement tests for loading labels manifest, including both old and new formats.
- Add tests for extracting flags from the manifest.
- Create tests for parsing .eml files, including edge cases.
- Develop tests for retrieving .eml files from a directory.
- Validate configuration settings and handle missing credentials.
- Integrate tests for restoring emails from a backup, including Gmail mode and deletion of orphaned emails.
- Test progress caching and backup folder discovery.
- Ensure robust handling of email labels and flags during restoration.
- Verify that the restore process correctly interacts with a mock IMAP server.

* Add comprehensive tests for IMAP utilities and compression support

- Introduced tests for `imap_common.py` covering environment variable verification, IMAP connection handling, folder name normalization, MIME header decoding, message details extraction, duplicate detection, filename sanitization, and trash folder detection.
- Added tests for `imap_compress.py` to validate the functionality of the `_CompressedSocket` wrapper, ensuring data compression and decompression works as expected.
- Implemented integration tests to verify that compression is enabled during IMAP connection setup.
2026-02-15 08:39:37 -05:00

291 lines
12 KiB
Python

"""
Tests for oauth2_microsoft.py
Tests cover:
- Tenant discovery from email domain
- Token acquisition using MSAL device code flow
- MSAL app caching and silent token refresh
"""
import json
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")))
from auth import oauth2_microsoft
from conftest import temp_env
from mock_oauth_server import MOCK_TENANT_ID
@pytest.fixture(autouse=True)
def clear_caches():
"""Clear module-level caches between tests."""
oauth2_microsoft._msal_app_cache.clear()
oauth2_microsoft._tenant_cache.clear()
yield
oauth2_microsoft._msal_app_cache.clear()
oauth2_microsoft._tenant_cache.clear()
class TestDiscoverTenant:
"""Tests for discover_tenant function."""
def test_successful_discovery(self):
"""Test successful tenant ID extraction from OpenID config."""
tenant_id = "12345678-abcd-ef01-2345-67890abcdef0"
data = {
"issuer": f"https://sts.windows.net/{tenant_id}/",
"authorization_endpoint": f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize",
}
with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data):
result = oauth2_microsoft.discover_tenant("user@contoso.com")
assert result == tenant_id
def test_domain_extraction(self):
"""Test that domain is correctly extracted from email."""
data = {"issuer": "https://sts.windows.net/abcdef01-2345-6789-abcd-ef0123456789/"}
with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data) as mock_fetch:
oauth2_microsoft.discover_tenant("user@example.org")
host, path = mock_fetch.call_args[0][0], mock_fetch.call_args[0][1]
assert host == "login.microsoftonline.com"
assert "example.org" in path
def test_network_error(self, capsys):
"""Test returns None on network error."""
with patch.object(oauth2_microsoft, "_fetch_json_https", side_effect=OSError("Connection refused")):
result = oauth2_microsoft.discover_tenant("user@invalid.example")
assert result is None
captured = capsys.readouterr()
assert "Could not discover" in captured.out
def test_invalid_json(self, capsys):
"""Test returns None on invalid JSON response."""
with patch.object(
oauth2_microsoft,
"_fetch_json_https",
side_effect=json.JSONDecodeError("Expecting value", "not json", 0),
):
result = oauth2_microsoft.discover_tenant("user@test.com")
assert result is None
def test_no_tenant_in_issuer(self, capsys):
"""Test returns None when issuer has no tenant GUID."""
data = {"issuer": "https://sts.windows.net/not-a-guid/"}
with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data):
result = oauth2_microsoft.discover_tenant("user@test.com")
assert result is None
captured = capsys.readouterr()
assert "Could not extract tenant ID" in captured.out
def test_tenant_caching(self):
"""Test that discovered tenant IDs are cached."""
tenant_id = "12345678-abcd-ef01-2345-67890abcdef0"
data = {"issuer": f"https://sts.windows.net/{tenant_id}/"}
with patch.object(oauth2_microsoft, "_fetch_json_https", return_value=data) as mock_fetch:
# First call
result1 = oauth2_microsoft.discover_tenant("user@example.com")
# Second call with same domain
result2 = oauth2_microsoft.discover_tenant("another@example.com")
assert result1 == tenant_id
assert result2 == tenant_id
# Should only fetch once due to caching
assert mock_fetch.call_count == 1
def test_discovery_env_override(self, mock_oauth_server):
"""Test discovery uses OAUTH2_MICROSOFT_DISCOVERY_URL when set."""
with temp_env({"OAUTH2_MICROSOFT_DISCOVERY_URL": mock_oauth_server}):
result = oauth2_microsoft.discover_tenant("user@example.org")
assert result == MOCK_TENANT_ID
class TestAcquireToken:
"""Tests for acquire_token function."""
def test_successful_token(self):
"""Test successful token acquisition with auto-discovery."""
with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
mock_msal = MagicMock()
mock_app = MagicMock()
mock_app.get_accounts.return_value = []
mock_app.initiate_device_flow.return_value = {"user_code": "ABC123", "message": "Go to..."}
mock_app.acquire_token_by_device_flow.return_value = {"access_token": "test_token"}
mock_msal.PublicClientApplication.return_value = mock_app
with patch.dict("sys.modules", {"msal": mock_msal}):
result = oauth2_microsoft.acquire_token("client-id", "user@test.com")
assert result == "test_token"
def test_custom_authority_url(self):
"""Test custom authority URL from environment variable."""
custom_base = "https://custom.login.example.com"
tenant_id = "tenant-123"
expected_authority = f"{custom_base}/{tenant_id}"
with patch.object(oauth2_microsoft, "discover_tenant", return_value=tenant_id):
mock_msal = MagicMock()
mock_app = MagicMock()
mock_app.get_accounts.return_value = []
mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "msg"}
mock_app.acquire_token_by_device_flow.return_value = {"access_token": "token"}
mock_msal.PublicClientApplication.return_value = mock_app
with patch.dict("sys.modules", {"msal": mock_msal}):
with temp_env({"OAUTH2_MICROSOFT_AUTHORITY_BASE_URL": custom_base}):
oauth2_microsoft.acquire_token("client-id", "user@test.com")
mock_msal.PublicClientApplication.assert_called_with("client-id", authority=expected_authority)
def test_tenant_discovery_failure(self, capsys):
"""Test returns None when tenant discovery fails."""
with patch.object(oauth2_microsoft, "discover_tenant", return_value=None):
result = oauth2_microsoft.acquire_token("client-id", "user@test.com")
assert result is None
def test_cached_token(self):
"""Test returns cached token when available."""
with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
mock_msal = MagicMock()
mock_app = MagicMock()
mock_account = {"username": "user@test.com"}
mock_app.get_accounts.return_value = [mock_account]
mock_app.acquire_token_silent.return_value = {"access_token": "cached_token"}
mock_msal.PublicClientApplication.return_value = mock_app
with patch.dict("sys.modules", {"msal": mock_msal}):
result = oauth2_microsoft.acquire_token("client-id", "user@test.com")
assert result == "cached_token"
def test_msal_app_cached_on_first_call(self):
"""Test MSAL app is cached after first call."""
with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
mock_msal = MagicMock()
mock_app = MagicMock()
mock_app.get_accounts.return_value = []
mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "Go to..."}
mock_app.acquire_token_by_device_flow.return_value = {"access_token": "token1"}
mock_msal.PublicClientApplication.return_value = mock_app
with patch.dict("sys.modules", {"msal": mock_msal}):
oauth2_microsoft.acquire_token("client-id", "user@test.com")
assert ("client-id", "tenant-123") in oauth2_microsoft._msal_app_cache
def test_cached_app_reused_on_second_call(self):
"""Test second call reuses cached MSAL app instead of creating new one."""
with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
mock_msal = MagicMock()
mock_app = MagicMock()
mock_app.get_accounts.return_value = []
mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "Go to..."}
mock_app.acquire_token_by_device_flow.return_value = {"access_token": "token1"}
mock_msal.PublicClientApplication.return_value = mock_app
with patch.dict("sys.modules", {"msal": mock_msal}):
oauth2_microsoft.acquire_token("client-id", "user@test.com")
# Second call — simulate cached token available (refresh token worked)
mock_account = {"username": "user@test.com"}
mock_app.get_accounts.return_value = [mock_account]
mock_app.acquire_token_silent.return_value = {"access_token": "refreshed_token"}
result = oauth2_microsoft.acquire_token("client-id", "user@test.com")
assert result == "refreshed_token"
# PublicClientApplication should only have been called once (first call)
assert mock_msal.PublicClientApplication.call_count == 1
def test_missing_msal_library(self):
"""Test exits when msal package is not installed."""
with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
with patch.dict("sys.modules", {"msal": None}):
with pytest.raises(SystemExit):
oauth2_microsoft.acquire_token("client-id", "user@test.com")
def test_no_token_in_response(self):
"""Test returns None when MSAL response has no access_token."""
with patch.object(oauth2_microsoft, "discover_tenant", return_value="tenant-123"):
mock_msal = MagicMock()
mock_app = MagicMock()
mock_app.get_accounts.return_value = []
mock_app.initiate_device_flow.return_value = {"user_code": "ABC", "message": "Go to..."}
mock_app.acquire_token_by_device_flow.return_value = {} # No access_token
mock_msal.PublicClientApplication.return_value = mock_app
with patch.dict("sys.modules", {"msal": mock_msal}):
result = oauth2_microsoft.acquire_token("client-id", "user@test.com")
assert result is None
class TestFetchJsonHttps:
"""Tests for internal _fetch_json_https function."""
def test_invalid_host_raises_value_error(self):
"""Test invalid host raises ValueError."""
# Test empty host
with pytest.raises(ValueError, match="Invalid host"):
oauth2_microsoft._fetch_json_https("", "/path")
# Test None host
with pytest.raises(ValueError, match="Invalid host"):
oauth2_microsoft._fetch_json_https(None, "/path")
# Test host with newline
with pytest.raises(ValueError, match="Invalid host"):
oauth2_microsoft._fetch_json_https("api.example.com\n", "/path")
def test_path_normalization(self):
"""Test path missing leading slash is corrected."""
mock_response = MagicMock()
mock_response.status = 200
mock_response.read.return_value = b'{"key": "value"}'
mock_conn = MagicMock()
mock_conn.getresponse.return_value = mock_response
with patch("http.client.HTTPSConnection", return_value=mock_conn):
# Pass path without '/'
result = oauth2_microsoft._fetch_json_https("api.example.com", "my/resource")
# Check request called with normalized path
mock_conn.request.assert_called_with("GET", "/my/resource", headers={"Accept": "application/json"})
assert result == {"key": "value"}
def test_https_ssl_context(self):
"""Test SSL context is created and passed to HTTPSConnection."""
mock_response = MagicMock()
mock_response.status = 200
mock_response.read.return_value = b"{}"
mock_conn = MagicMock()
mock_conn.getresponse.return_value = mock_response
mock_context = MagicMock()
with patch("ssl.create_default_context", return_value=mock_context) as mock_create_context:
with patch("http.client.HTTPSConnection", return_value=mock_conn) as mock_https:
oauth2_microsoft._fetch_json_https("example.com", "/")
# Verify SSL context creation
mock_create_context.assert_called_once()
# Verify context passed to connection
mock_https.assert_called_with("example.com", timeout=10, context=mock_context)