"""Tests for application/parser/connectors/confluence/auth.py""" import datetime from unittest.mock import MagicMock, patch import pytest import requests # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def mock_settings(): s = MagicMock() s.CONFLUENCE_CLIENT_ID = "test-client-id" s.CONFLUENCE_CLIENT_SECRET = "test-client-secret" s.CONNECTOR_REDIRECT_BASE_URI = "https://redirect.example.com/callback" s.MONGO_DB_NAME = "test_db" return s @pytest.fixture def auth(mock_settings): with patch("application.parser.connectors.confluence.auth.settings", mock_settings): from application.parser.connectors.confluence.auth import ConfluenceAuth return ConfluenceAuth() # --------------------------------------------------------------------------- # Initialisation # --------------------------------------------------------------------------- class TestConfluenceAuthInit: pass @pytest.mark.unit def test_init_sets_credentials(self, auth, mock_settings): assert auth.client_id == "test-client-id" assert auth.client_secret == "test-client-secret" assert auth.redirect_uri == "https://redirect.example.com/callback" @pytest.mark.unit def test_init_missing_client_id_raises(self, mock_settings): mock_settings.CONFLUENCE_CLIENT_ID = None with patch("application.parser.connectors.confluence.auth.settings", mock_settings): from application.parser.connectors.confluence.auth import ConfluenceAuth with pytest.raises(ValueError, match="CONFLUENCE_CLIENT_ID"): ConfluenceAuth() @pytest.mark.unit def test_init_missing_client_secret_raises(self, mock_settings): mock_settings.CONFLUENCE_CLIENT_SECRET = None with patch("application.parser.connectors.confluence.auth.settings", mock_settings): from application.parser.connectors.confluence.auth import ConfluenceAuth with pytest.raises(ValueError, match="CONFLUENCE_CLIENT_SECRET"): ConfluenceAuth() @pytest.mark.unit def test_init_both_missing_raises(self, mock_settings): mock_settings.CONFLUENCE_CLIENT_ID = None mock_settings.CONFLUENCE_CLIENT_SECRET = None with patch("application.parser.connectors.confluence.auth.settings", mock_settings): from application.parser.connectors.confluence.auth import ConfluenceAuth with pytest.raises(ValueError): ConfluenceAuth() # --------------------------------------------------------------------------- # get_authorization_url # --------------------------------------------------------------------------- class TestGetAuthorizationUrl: pass @pytest.mark.unit def test_returns_url_with_required_params(self, auth): url = auth.get_authorization_url(state="test_state") assert "auth.atlassian.com/authorize" in url assert "client_id=test-client-id" in url assert "state=test_state" in url assert "response_type=code" in url assert "prompt=consent" in url @pytest.mark.unit def test_scopes_included_in_url(self, auth): url = auth.get_authorization_url() # All required scopes should be present assert "read%3Apage%3Aconfluence" in url or "read:page:confluence" in url @pytest.mark.unit def test_no_state_still_builds_url(self, auth): url = auth.get_authorization_url() assert "auth.atlassian.com/authorize" in url @pytest.mark.unit def test_redirect_uri_included(self, auth): url = auth.get_authorization_url(state="s") assert "redirect.example.com" in url # --------------------------------------------------------------------------- # exchange_code_for_tokens # --------------------------------------------------------------------------- class TestExchangeCodeForTokens: pass def _make_mock_response(self, json_data, status_code=200): resp = MagicMock() resp.status_code = status_code resp.json.return_value = json_data resp.raise_for_status = MagicMock() if status_code >= 400: resp.raise_for_status.side_effect = requests.exceptions.HTTPError( response=resp ) return resp @pytest.mark.unit def test_successful_exchange(self, auth): token_resp = self._make_mock_response({ "access_token": "at", "refresh_token": "rt", "expires_in": 3600, }) resources_resp = self._make_mock_response([{"id": "cloud-123"}]) me_resp = self._make_mock_response({"display_name": "Test User", "email": "test@example.com"}) with patch("requests.post", return_value=token_resp), \ patch("requests.get", side_effect=[resources_resp, me_resp]): result = auth.exchange_code_for_tokens("auth_code") assert result["access_token"] == "at" assert result["refresh_token"] == "rt" assert result["cloud_id"] == "cloud-123" assert result["user_info"]["email"] == "test@example.com" assert result["user_info"]["name"] == "Test User" assert "expiry" in result assert result["token_uri"] == auth.TOKEN_URL @pytest.mark.unit def test_empty_code_raises(self, auth): with pytest.raises(ValueError, match="Authorization code is required"): auth.exchange_code_for_tokens("") @pytest.mark.unit def test_missing_access_token_raises(self, auth): token_resp = self._make_mock_response({"refresh_token": "rt"}) with patch("requests.post", return_value=token_resp): with pytest.raises(ValueError, match="access token"): auth.exchange_code_for_tokens("code") @pytest.mark.unit def test_missing_refresh_token_raises(self, auth): token_resp = self._make_mock_response({"access_token": "at"}) resources_resp = self._make_mock_response([{"id": "cloud-123"}]) with patch("requests.post", return_value=token_resp), \ patch("requests.get", return_value=resources_resp): with pytest.raises(ValueError, match="refresh token"): auth.exchange_code_for_tokens("code") @pytest.mark.unit def test_http_error_from_token_endpoint_raises(self, auth): token_resp = self._make_mock_response({}, status_code=400) with patch("requests.post", return_value=token_resp): with pytest.raises(requests.exceptions.HTTPError): auth.exchange_code_for_tokens("code") @pytest.mark.unit def test_expiry_is_iso_string(self, auth): token_resp = self._make_mock_response({ "access_token": "at", "refresh_token": "rt", "expires_in": 7200, }) resources_resp = self._make_mock_response([{"id": "cloud-abc"}]) me_resp = self._make_mock_response({}) with patch("requests.post", return_value=token_resp), \ patch("requests.get", side_effect=[resources_resp, me_resp]): result = auth.exchange_code_for_tokens("code") # Verify expiry is a valid ISO datetime string expiry_dt = datetime.datetime.fromisoformat(result["expiry"]) now = datetime.datetime.now(datetime.timezone.utc) # Should be ~2 hours in the future assert expiry_dt > now # --------------------------------------------------------------------------- # refresh_access_token # --------------------------------------------------------------------------- class TestRefreshAccessToken: pass def _make_mock_response(self, json_data, status_code=200): resp = MagicMock() resp.status_code = status_code resp.json.return_value = json_data resp.raise_for_status = MagicMock() if status_code >= 400: resp.raise_for_status.side_effect = requests.exceptions.HTTPError( response=resp ) return resp @pytest.mark.unit def test_successful_refresh(self, auth): token_resp = self._make_mock_response({ "access_token": "new_at", "refresh_token": "new_rt", "expires_in": 3600, }) resources_resp = self._make_mock_response([{"id": "cloud-123"}]) with patch("requests.post", return_value=token_resp), \ patch("requests.get", return_value=resources_resp): result = auth.refresh_access_token("old_refresh") assert result["access_token"] == "new_at" assert result["refresh_token"] == "new_rt" assert result["cloud_id"] == "cloud-123" assert "expiry" in result @pytest.mark.unit def test_refresh_falls_back_to_old_refresh_token(self, auth): token_resp = self._make_mock_response({ "access_token": "new_at", "expires_in": 3600, # no refresh_token in response }) resources_resp = self._make_mock_response([{"id": "cloud-123"}]) with patch("requests.post", return_value=token_resp), \ patch("requests.get", return_value=resources_resp): result = auth.refresh_access_token("original_rt") assert result["refresh_token"] == "original_rt" @pytest.mark.unit def test_empty_refresh_token_raises(self, auth): with pytest.raises(ValueError, match="Refresh token is required"): auth.refresh_access_token("") @pytest.mark.unit def test_http_error_raises(self, auth): token_resp = self._make_mock_response({}, status_code=401) with patch("requests.post", return_value=token_resp): with pytest.raises(requests.exceptions.HTTPError): auth.refresh_access_token("rt") # --------------------------------------------------------------------------- # is_token_expired # --------------------------------------------------------------------------- class TestIsTokenExpired: pass @pytest.mark.unit def test_empty_token_info_returns_true(self, auth): assert auth.is_token_expired({}) is True @pytest.mark.unit def test_none_token_info_returns_true(self, auth): assert auth.is_token_expired(None) is True @pytest.mark.unit def test_expired_token_returns_true(self, auth): past = ( datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1) ).isoformat() assert auth.is_token_expired({"expiry": past}) is True @pytest.mark.unit def test_valid_token_returns_false(self, auth): future = ( datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1) ).isoformat() assert auth.is_token_expired({"expiry": future}) is False @pytest.mark.unit def test_within_60s_buffer_returns_true(self, auth): almost_expired = ( datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=30) ).isoformat() assert auth.is_token_expired({"expiry": almost_expired}) is True @pytest.mark.unit def test_no_expiry_with_access_token_returns_false(self, auth): # No expiry field but has access_token -> treat as not expired (edge case) result = auth.is_token_expired({"access_token": "at"}) # The implementation: if not expiry -> return bool(token_info.get("access_token")) # bool("at") is True, but that means it's treating it as "not missing", # However the actual logic returns bool(access_token) which is True meaning "not expired" # Actually the implementation: return bool(token_info.get("access_token")) # bool("at") == True but function returns it as "is expired" vs "is not expired" # Reading the code: if not expiry: return bool(token_info.get("access_token")) # This returns True (has token) meaning NOT expired? That seems inverted. # The actual return is used as is_expired, so True => expired. # With access_token="at", bool("at")=True => expired=True. # But empty access_token {} => bool(None)=False => expired=False. # We just test the actual behavior: assert isinstance(result, bool) @pytest.mark.unit def test_invalid_expiry_format_returns_true(self, auth): assert auth.is_token_expired({"expiry": "not-a-date"}) is True @pytest.mark.unit def test_none_expiry_returns_false_when_has_access_token(self, auth): # expiry=None -> if not expiry: return bool(access_token) # bool("at") = True, meaning this is "expired=True" result = auth.is_token_expired({"expiry": None, "access_token": "at"}) assert isinstance(result, bool) # --------------------------------------------------------------------------- # get_token_info_from_session # --------------------------------------------------------------------------- class TestGetTokenInfoFromSession: pass def _mock_mongo_client(self, mock_settings, session_doc): mock_collection = MagicMock() mock_collection.find_one.return_value = session_doc mock_db = MagicMock() mock_db.__getitem__ = MagicMock(return_value=mock_collection) return {mock_settings.MONGO_DB_NAME: mock_db} # --------------------------------------------------------------------------- # sanitize_token_info # --------------------------------------------------------------------------- class TestSanitizeTokenInfo: pass @pytest.mark.unit def test_includes_cloud_id(self, auth): token_info = { "access_token": "at", "refresh_token": "rt", "token_uri": "uri", "expiry": "2025-01-01", "cloud_id": "cid", } result = auth.sanitize_token_info(token_info) assert result["cloud_id"] == "cid" assert result["access_token"] == "at" @pytest.mark.unit def test_excludes_non_standard_fields(self, auth): token_info = { "access_token": "at", "refresh_token": "rt", "token_uri": "uri", "expiry": "2025-01-01", "cloud_id": "cid", "random_field": "should_not_appear", } result = auth.sanitize_token_info(token_info) assert "random_field" not in result # --------------------------------------------------------------------------- # _fetch_cloud_id # --------------------------------------------------------------------------- class TestFetchCloudId: pass @pytest.mark.unit def test_returns_first_resource_id(self, auth): resp = MagicMock() resp.json.return_value = [{"id": "cloud-abc"}, {"id": "cloud-def"}] resp.raise_for_status = MagicMock() with patch("requests.get", return_value=resp): cloud_id = auth._fetch_cloud_id("access_tok") assert cloud_id == "cloud-abc" @pytest.mark.unit def test_empty_resources_raises(self, auth): resp = MagicMock() resp.json.return_value = [] resp.raise_for_status = MagicMock() with patch("requests.get", return_value=resp): with pytest.raises(ValueError, match="No accessible Confluence sites"): auth._fetch_cloud_id("access_tok") @pytest.mark.unit def test_http_error_raises(self, auth): resp = MagicMock() resp.raise_for_status.side_effect = requests.exceptions.HTTPError( response=MagicMock(status_code=403) ) with patch("requests.get", return_value=resp): with pytest.raises(requests.exceptions.HTTPError): auth._fetch_cloud_id("access_tok") # --------------------------------------------------------------------------- # _fetch_user_info # --------------------------------------------------------------------------- class TestFetchUserInfo: pass @pytest.mark.unit def test_returns_user_info(self, auth): resp = MagicMock() resp.json.return_value = {"display_name": "Alice", "email": "alice@example.com"} resp.raise_for_status = MagicMock() with patch("requests.get", return_value=resp): info = auth._fetch_user_info("access_tok") assert info["email"] == "alice@example.com" assert info["display_name"] == "Alice" @pytest.mark.unit def test_http_error_returns_empty_dict(self, auth): """_fetch_user_info catches exceptions and returns empty dict.""" with patch("requests.get", side_effect=requests.exceptions.ConnectionError("conn")): info = auth._fetch_user_info("access_tok") assert info == {} @pytest.mark.unit def test_http_error_status_returns_empty_dict(self, auth): resp = MagicMock() resp.raise_for_status.side_effect = requests.exceptions.HTTPError( response=MagicMock(status_code=500) ) with patch("requests.get", return_value=resp): info = auth._fetch_user_info("access_tok") assert info == {}