fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""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 == {}
|