"""Unit tests for the PAT Manager expiry checker module.""" from __future__ import annotations from datetime import date, timedelta import pytest import httpx from scripts.pat_manager.checker import ExpiryChecker, classify_by_date from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus class TestClassifyByDate: """Tests for the classify_by_date pure function.""" def test_expired_token(self) -> None: """A token with expiry_date before reference_date is expired.""" status, days = classify_by_date("2025-01-01", date(2025, 1, 15), window=14) assert status == PatStatus.EXPIRED assert days == 0 def test_expiring_soon_on_boundary(self) -> None: """A token expiring exactly on reference_date is expiring soon.""" status, days = classify_by_date("2025-01-15", date(2025, 1, 15), window=14) assert status == PatStatus.EXPIRING_SOON assert days == 0 def test_expiring_soon_within_window(self) -> None: """A token expiring within the window is expiring soon.""" status, days = classify_by_date("2025-01-20", date(2025, 1, 15), window=14) assert status == PatStatus.EXPIRING_SOON assert days == 5 def test_expiring_soon_at_window_end(self) -> None: """A token expiring exactly at window end is expiring soon.""" status, days = classify_by_date("2025-01-29", date(2025, 1, 15), window=14) assert status == PatStatus.EXPIRING_SOON assert days == 14 def test_healthy_beyond_window(self) -> None: """A token expiring beyond the window is healthy.""" status, days = classify_by_date("2025-01-30", date(2025, 1, 15), window=14) assert status == PatStatus.HEALTHY assert days == 15 def test_healthy_far_future(self) -> None: """A token expiring far in the future is healthy.""" status, days = classify_by_date("2026-06-01", date(2025, 1, 15), window=14) assert status == PatStatus.HEALTHY assert days == 502 def test_custom_window(self) -> None: """Classification respects custom window size.""" # With window=7, 10 days out is healthy status, days = classify_by_date("2025-01-25", date(2025, 1, 15), window=7) assert status == PatStatus.HEALTHY assert days == 10 # With window=30, 10 days out is expiring soon status, days = classify_by_date("2025-01-25", date(2025, 1, 15), window=30) assert status == PatStatus.EXPIRING_SOON assert days == 10 class TestExpiryCheckerInit: """Tests for ExpiryChecker initialization.""" def test_default_values(self) -> None: """ExpiryChecker uses correct defaults.""" checker = ExpiryChecker() assert checker.expiry_window_days == 14 assert checker.timeout == 30 assert checker.max_retries == 2 def test_custom_values(self) -> None: """ExpiryChecker accepts custom configuration.""" checker = ExpiryChecker(expiry_window_days=7, timeout=60, max_retries=3) assert checker.expiry_window_days == 7 assert checker.timeout == 60 assert checker.max_retries == 3 class TestCheckAll: """Tests for ExpiryChecker.check_all method.""" @pytest.mark.asyncio async def test_missing_secret_returns_check_failed(self) -> None: """Entries with no matching secret get check_failed status.""" checker = ExpiryChecker() entry = PatEntry( service="GitLab", token_name="test-token", expiry_date="2025-06-01", renewal_method="auto", ) results = await checker.check_all([entry], secrets={}) assert len(results) == 1 assert results[0].status == PatStatus.CHECK_FAILED assert results[0].days_remaining is None assert "No secret found" in results[0].error_message @pytest.mark.asyncio async def test_unsupported_service_returns_check_failed(self) -> None: """Entries with unsupported service get check_failed status.""" checker = ExpiryChecker() entry = PatEntry( service="UnknownService", token_name="test-token", expiry_date="2025-06-01", renewal_method="manual", ) # Provide a secret that would match via the fallback empty key results = await checker.check_all([entry], secrets={"": "some-token"}) assert len(results) == 1 assert results[0].status == PatStatus.CHECK_FAILED @pytest.mark.asyncio async def test_gitlab_check_with_mock_api(self) -> None: """GitLab check with a mocked API response classifies correctly.""" import respx checker = ExpiryChecker() entry = PatEntry( service="GitLab", token_name="test-token", expiry_date="2025-06-01", renewal_method="auto", ) # Mock the GitLab API to return a 401 (expired token) with respx.mock: respx.get("https://gitlab.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(401) ) results = await checker.check_all( [entry], secrets={"GITLAB_PAT": "glpat-test123"} ) assert len(results) == 1 assert results[0].status == PatStatus.EXPIRED assert results[0].days_remaining == 0 @pytest.mark.asyncio async def test_empty_entries_returns_empty_list(self) -> None: """No entries produces no results.""" checker = ExpiryChecker() results = await checker.check_all([], secrets={"GITLAB_PAT": "token"}) assert results == [] @pytest.mark.asyncio async def test_multiple_entries_returns_one_result_per_entry(self) -> None: """Each entry produces exactly one result.""" checker = ExpiryChecker() entries = [ PatEntry("GitLab", "token-1", "2025-06-01", "auto"), PatEntry("Jira", "token-2", "2025-07-01", "manual"), PatEntry("Confluence", "token-3", "2025-08-01", "manual"), ] results = await checker.check_all(entries, secrets={}) assert len(results) == 3 # All should be check_failed since no secrets provided for result in results: assert result.status == PatStatus.CHECK_FAILED class TestCheckGitLab: """Tests for ExpiryChecker.check_gitlab method.""" @pytest.mark.asyncio async def test_gitlab_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab token with future expiry is classified as healthy.""" import respx from datetime import date, timedelta future_date = (date.today() + timedelta(days=30)).isoformat() monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker() entry = PatEntry("GitLab", "ci-token", future_date, "auto") with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": future_date}) ) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.HEALTHY assert result.days_remaining == 30 @pytest.mark.asyncio async def test_gitlab_expiring_soon_token(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab token expiring within window is classified as expiring soon.""" import respx from datetime import date, timedelta soon_date = (date.today() + timedelta(days=7)).isoformat() monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker() entry = PatEntry("GitLab", "ci-token", soon_date, "auto") with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": soon_date}) ) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.EXPIRING_SOON assert result.days_remaining == 7 @pytest.mark.asyncio async def test_gitlab_expired_token_via_api(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab token with past expiry from API is classified as expired.""" import respx from datetime import date, timedelta past_date = (date.today() - timedelta(days=5)).isoformat() monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker() entry = PatEntry("GitLab", "ci-token", past_date, "auto") with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": past_date}) ) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.EXPIRED assert result.days_remaining == 0 @pytest.mark.asyncio async def test_gitlab_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab API returning 401 means token is expired.""" import respx monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker() entry = PatEntry("GitLab", "ci-token", "2025-12-01", "auto") with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(401) ) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.EXPIRED assert result.days_remaining == 0 @pytest.mark.asyncio async def test_gitlab_500_means_check_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab API returning 500 means check failed.""" import respx monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker() entry = PatEntry("GitLab", "ci-token", "2025-12-01", "auto") with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(500) ) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.CHECK_FAILED assert "HTTP 500" in result.error_message @pytest.mark.asyncio async def test_gitlab_null_expires_at_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab token with null expires_at and no stored expiry is healthy.""" import respx monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker() entry = PatEntry("GitLab", "ci-token", None, "auto") with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": None}) ) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.HEALTHY assert result.days_remaining is None @pytest.mark.asyncio async def test_gitlab_stale_expiry_warning(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab API succeeds but stored expiry_date has passed → expired with stale warning.""" import respx from datetime import date, timedelta future_api_date = (date.today() + timedelta(days=30)).isoformat() past_stored_date = (date.today() - timedelta(days=5)).isoformat() monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker() entry = PatEntry("GitLab", "ci-token", past_stored_date, "auto") with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": future_api_date}) ) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.EXPIRED assert result.stale_warning is True @pytest.mark.asyncio async def test_gitlab_timeout_retries_then_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitLab API timing out after retries results in check_failed.""" import asyncio import respx monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") checker = ExpiryChecker(max_retries=1, timeout=1) entry = PatEntry("GitLab", "ci-token", "2025-12-01", "auto") async def noop_sleep(_: float) -> None: pass with respx.mock: respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( side_effect=httpx.ConnectError("Connection refused") ) monkeypatch.setattr("asyncio.sleep", noop_sleep) result = await checker.check_gitlab(entry, "glpat-test") assert result.status == PatStatus.CHECK_FAILED assert result.error_message is not None class TestCheckJira: """Tests for ExpiryChecker.check_jira method.""" @pytest.mark.asyncio async def test_jira_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira token with successful API call and future expiry is healthy.""" import respx from datetime import date, timedelta future_date = (date.today() + timedelta(days=30)).isoformat() monkeypatch.setenv("JIRA_URL", "https://jira.example.com") checker = ExpiryChecker() entry = PatEntry("Jira", "jira-token", future_date, "manual") with respx.mock: respx.get("https://jira.example.com/rest/api/2/myself").mock( return_value=httpx.Response(200, json={"displayName": "Test User"}) ) result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.HEALTHY assert result.days_remaining == 30 @pytest.mark.asyncio async def test_jira_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira API returning 401 means token is expired.""" import respx monkeypatch.setenv("JIRA_URL", "https://jira.example.com") checker = ExpiryChecker() entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual") with respx.mock: respx.get("https://jira.example.com/rest/api/2/myself").mock( return_value=httpx.Response(401) ) result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.EXPIRED assert result.days_remaining == 0 @pytest.mark.asyncio async def test_jira_no_url_env_var(self) -> None: """Jira check fails when JIRA_URL is not set.""" import os # Ensure JIRA_URL is not set os.environ.pop("JIRA_URL", None) checker = ExpiryChecker() entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual") result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.CHECK_FAILED assert "JIRA_URL" in result.error_message @pytest.mark.asyncio async def test_jira_null_expiry_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira token with no expiry_date and successful API call is healthy.""" import respx monkeypatch.setenv("JIRA_URL", "https://jira.example.com") checker = ExpiryChecker() entry = PatEntry("Jira", "jira-token", None, "manual") with respx.mock: respx.get("https://jira.example.com/rest/api/2/myself").mock( return_value=httpx.Response(200, json={"displayName": "Test User"}) ) result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.HEALTHY assert result.days_remaining is None @pytest.mark.asyncio async def test_jira_stale_expiry_warning(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira API succeeds but stored expiry_date has passed → expired with stale warning.""" import respx from datetime import date, timedelta past_date = (date.today() - timedelta(days=5)).isoformat() monkeypatch.setenv("JIRA_URL", "https://jira.example.com") checker = ExpiryChecker() entry = PatEntry("Jira", "jira-token", past_date, "manual") with respx.mock: respx.get("https://jira.example.com/rest/api/2/myself").mock( return_value=httpx.Response(200, json={"displayName": "Test User"}) ) result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.EXPIRED assert result.stale_warning is True @pytest.mark.asyncio async def test_jira_500_means_check_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: """Jira API returning 500 means check failed.""" import respx monkeypatch.setenv("JIRA_URL", "https://jira.example.com") checker = ExpiryChecker() entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual") with respx.mock: respx.get("https://jira.example.com/rest/api/2/myself").mock( return_value=httpx.Response(500) ) result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.CHECK_FAILED assert "HTTP 500" in result.error_message class TestCheckConfluence: """Tests for ExpiryChecker.check_confluence method.""" @pytest.mark.asyncio async def test_confluence_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None: """Confluence token with successful API call and future expiry is healthy.""" import respx from datetime import date, timedelta future_date = (date.today() + timedelta(days=30)).isoformat() monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com") checker = ExpiryChecker() entry = PatEntry("Confluence", "conf-token", future_date, "manual") with respx.mock: respx.get("https://confluence.example.com/rest/api/user/current").mock( return_value=httpx.Response(200, json={"displayName": "Test User"}) ) result = await checker.check_confluence(entry, "conf-pat-123") assert result.status == PatStatus.HEALTHY assert result.days_remaining == 30 @pytest.mark.asyncio async def test_confluence_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: """Confluence API returning 401 means token is expired.""" import respx monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com") checker = ExpiryChecker() entry = PatEntry("Confluence", "conf-token", "2025-12-01", "manual") with respx.mock: respx.get("https://confluence.example.com/rest/api/user/current").mock( return_value=httpx.Response(401) ) result = await checker.check_confluence(entry, "conf-pat-123") assert result.status == PatStatus.EXPIRED assert result.days_remaining == 0 @pytest.mark.asyncio async def test_confluence_no_url_env_var(self) -> None: """Confluence check fails when CONFLUENCE_URL is not set.""" import os os.environ.pop("CONFLUENCE_URL", None) checker = ExpiryChecker() entry = PatEntry("Confluence", "conf-token", "2025-12-01", "manual") result = await checker.check_confluence(entry, "conf-pat-123") assert result.status == PatStatus.CHECK_FAILED assert "CONFLUENCE_URL" in result.error_message @pytest.mark.asyncio async def test_confluence_null_expiry_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None: """Confluence token with no expiry_date and successful API call is healthy.""" import respx monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com") checker = ExpiryChecker() entry = PatEntry("Confluence", "conf-token", None, "manual") with respx.mock: respx.get("https://confluence.example.com/rest/api/user/current").mock( return_value=httpx.Response(200, json={"displayName": "Test User"}) ) result = await checker.check_confluence(entry, "conf-pat-123") assert result.status == PatStatus.HEALTHY assert result.days_remaining is None class TestCheckOrgMyLife: """Tests for ExpiryChecker.check_orgmylife method.""" @pytest.mark.asyncio async def test_orgmylife_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None: """OrgMyLife token with successful API call and future expiry is healthy.""" import respx from datetime import date, timedelta future_date = (date.today() + timedelta(days=30)).isoformat() monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com") checker = ExpiryChecker() entry = PatEntry("OrgMyLife", "oml-token", future_date, "manual") with respx.mock: respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(200, json=[]) ) result = await checker.check_orgmylife(entry, "oml-key-123") assert result.status == PatStatus.HEALTHY assert result.days_remaining == 30 @pytest.mark.asyncio async def test_orgmylife_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: """OrgMyLife API returning 401 means token is expired.""" import respx monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com") checker = ExpiryChecker() entry = PatEntry("OrgMyLife", "oml-token", "2025-12-01", "manual") with respx.mock: respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(401) ) result = await checker.check_orgmylife(entry, "oml-key-123") assert result.status == PatStatus.EXPIRED assert result.days_remaining == 0 @pytest.mark.asyncio async def test_orgmylife_403_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: """OrgMyLife API returning 403 means token is expired.""" import respx monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com") checker = ExpiryChecker() entry = PatEntry("OrgMyLife", "oml-token", "2025-12-01", "manual") with respx.mock: respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(403) ) result = await checker.check_orgmylife(entry, "oml-key-123") assert result.status == PatStatus.EXPIRED assert result.days_remaining == 0 @pytest.mark.asyncio async def test_orgmylife_default_url(self) -> None: """OrgMyLife uses default URL when env var not set.""" import respx import os from datetime import date, timedelta os.environ.pop("ORGMYLIFE_URL", None) future_date = (date.today() + timedelta(days=30)).isoformat() checker = ExpiryChecker() entry = PatEntry("OrgMyLife", "oml-token", future_date, "manual") with respx.mock: respx.get("https://orgmylife.app/api/tasks").mock( return_value=httpx.Response(200, json=[]) ) result = await checker.check_orgmylife(entry, "oml-key-123") assert result.status == PatStatus.HEALTHY @pytest.mark.asyncio async def test_orgmylife_null_expiry_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None: """OrgMyLife token with no expiry_date and successful API call is healthy.""" import respx monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com") checker = ExpiryChecker() entry = PatEntry("OrgMyLife", "oml-token", None, "manual") with respx.mock: respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(200, json=[]) ) result = await checker.check_orgmylife(entry, "oml-key-123") assert result.status == PatStatus.HEALTHY assert result.days_remaining is None @pytest.mark.asyncio async def test_orgmylife_null_expiry_401_expired(self, monkeypatch: pytest.MonkeyPatch) -> None: """OrgMyLife token with no expiry_date and 401 response is expired.""" import respx monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com") checker = ExpiryChecker() entry = PatEntry("OrgMyLife", "oml-token", None, "manual") with respx.mock: respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(401) ) result = await checker.check_orgmylife(entry, "oml-key-123") assert result.status == PatStatus.EXPIRED assert result.days_remaining == 0 @pytest.mark.asyncio async def test_orgmylife_500_means_check_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: """OrgMyLife API returning 500 means check failed.""" import respx monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com") checker = ExpiryChecker() entry = PatEntry("OrgMyLife", "oml-token", "2025-12-01", "manual") with respx.mock: respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(500) ) result = await checker.check_orgmylife(entry, "oml-key-123") assert result.status == PatStatus.CHECK_FAILED assert "HTTP 500" in result.error_message class TestRetryLogic: """Tests for the retry mechanism in service checks.""" @pytest.mark.asyncio async def test_retry_succeeds_on_second_attempt(self, monkeypatch: pytest.MonkeyPatch) -> None: """Request succeeds after first timeout, second attempt works.""" import asyncio import respx from datetime import date, timedelta future_date = (date.today() + timedelta(days=30)).isoformat() monkeypatch.setenv("JIRA_URL", "https://jira.example.com") async def noop_sleep(_: float) -> None: pass monkeypatch.setattr("asyncio.sleep", noop_sleep) checker = ExpiryChecker(max_retries=2) entry = PatEntry("Jira", "jira-token", future_date, "manual") call_count = 0 def side_effect(request): nonlocal call_count call_count += 1 if call_count == 1: raise httpx.ConnectError("Connection refused") return httpx.Response(200, json={"displayName": "Test User"}) with respx.mock: respx.get("https://jira.example.com/rest/api/2/myself").mock( side_effect=side_effect ) result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.HEALTHY assert call_count == 2 @pytest.mark.asyncio async def test_all_retries_exhausted(self, monkeypatch: pytest.MonkeyPatch) -> None: """All retries exhausted results in check_failed.""" import asyncio import respx monkeypatch.setenv("JIRA_URL", "https://jira.example.com") async def noop_sleep(_: float) -> None: pass monkeypatch.setattr("asyncio.sleep", noop_sleep) checker = ExpiryChecker(max_retries=2) entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual") with respx.mock: respx.get("https://jira.example.com/rest/api/2/myself").mock( side_effect=httpx.TimeoutException("Request timed out") ) result = await checker.check_jira(entry, "jira-pat-123") assert result.status == PatStatus.CHECK_FAILED assert result.error_message is not None