"""Integration tests for the PAT Manager orchestrator flow. Tests the full end-to-end orchestrator with mocked external APIs using respx. Validates Requirements: 2.5, 2.6, 3.2, 3.4, 3.5, 4.6, 5.4, 5.5, 6.8 """ from __future__ import annotations import base64 import json from datetime import date, timedelta from pathlib import Path from unittest.mock import AsyncMock, patch import httpx import pytest import respx from nacl.public import PrivateKey from scripts.pat_manager.__main__ import main, REGISTRY_PATH def _future_date(days: int = 60) -> str: """Return an ISO date string `days` in the future.""" return (date.today() + timedelta(days=days)).isoformat() def _expiring_date(days: int = 7) -> str: """Return an ISO date string `days` in the future (within 14-day window).""" return (date.today() + timedelta(days=days)).isoformat() def _write_registry(tmp_path: Path, tokens: list[dict]) -> Path: """Write a registry JSON file to tmp_path and return its path.""" registry_path = tmp_path / "pat-registry.json" data = {"version": "1.0", "tokens": tokens} registry_path.write_text(json.dumps(data, indent=2), encoding="utf-8") return registry_path def _generate_keypair(): """Generate a NaCl keypair for mocking GitHub secret encryption.""" private_key = PrivateKey.generate() public_key = private_key.public_key public_key_b64 = base64.b64encode(bytes(public_key)).decode("utf-8") return private_key, public_key, public_key_b64 def _set_base_env(monkeypatch, tmp_path: Path, registry_path: Path): """Set common environment variables for all integration tests.""" monkeypatch.setattr( "scripts.pat_manager.__main__.REGISTRY_PATH", registry_path ) monkeypatch.setenv("GITLAB_PAT", "glpat-test-token-123") monkeypatch.setenv("JIRA_PAT", "jira-test-token-456") monkeypatch.setenv("CONFLUENCE_PAT", "confluence-test-token-789") monkeypatch.setenv("ORGMYLIFE_API_KEY", "orgmylife-api-key-abc") monkeypatch.setenv("GH_TOKEN", "ghp_github_token_xyz") monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com") monkeypatch.setenv("JIRA_URL", "https://jira.example.com") monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com") monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com") monkeypatch.setenv("ALERT_CHANNEL", "orgmylife") monkeypatch.setenv("GITLAB_TOKEN_ID", "12345") monkeypatch.setenv("GITHUB_REPOSITORY", "test-owner/test-repo") # --------------------------------------------------------------------------- # Test 1: Happy path - all healthy → no alerts, summary only # Validates: Requirements 5.4, 5.5 # --------------------------------------------------------------------------- @pytest.mark.asyncio @respx.mock async def test_happy_path_all_healthy(tmp_path, monkeypatch, capsys): """All PATs are healthy (far-future expiry) → exit 0, no alerts sent.""" far_future = _future_date(60) tokens = [ { "service": "GitLab", "token_name": "ci-token", "expiry_date": far_future, "renewal_method": "auto", }, { "service": "Jira", "token_name": "jira-access", "expiry_date": far_future, "renewal_method": "manual", }, { "service": "OrgMyLife", "token_name": "oml-key", "expiry_date": far_future, "renewal_method": "manual", }, ] registry_path = _write_registry(tmp_path, tokens) _set_base_env(monkeypatch, tmp_path, registry_path) # Mock GitLab check → healthy (expires_at far in future) respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": far_future}) ) # Mock Jira check → 200 (token works) respx.get("https://jira.example.com/rest/api/2/myself").mock( return_value=httpx.Response(200, json={"name": "user"}) ) # Mock OrgMyLife check → 200 (token works) respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(200, json=[]) ) # No alert routes should be called alert_route = respx.post("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(201, json={"id": 1}) ) # Mock asyncio.sleep to avoid delays monkeypatch.setattr("asyncio.sleep", AsyncMock()) exit_code = await main() assert exit_code == 0 # No alerts should have been sent assert not alert_route.called # Summary report should be printed captured = capsys.readouterr() assert "PAT Lifecycle Check Summary" in captured.out assert "ci-token" in captured.out assert "jira-access" in captured.out assert "oml-key" in captured.out # --------------------------------------------------------------------------- # Test 2: GitLab rotation flow with mocked APIs # Validates: Requirements 3.2, 3.4 # --------------------------------------------------------------------------- @pytest.mark.asyncio @respx.mock async def test_gitlab_rotation_flow(tmp_path, monkeypatch, capsys): """GitLab token expiring soon → rotation + secret update → registry updated.""" expiring = _expiring_date(7) new_expiry = _future_date(90) _, _, public_key_b64 = _generate_keypair() tokens = [ { "service": "GitLab", "token_name": "ci-token", "expiry_date": expiring, "renewal_method": "auto", }, ] registry_path = _write_registry(tmp_path, tokens) _set_base_env(monkeypatch, tmp_path, registry_path) # Mock GitLab check → expiring soon respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": expiring}) ) # Mock GitLab rotation → success respx.post( "https://gitlab.example.com/api/v4/personal_access_tokens/12345/rotate" ).mock( return_value=httpx.Response( 200, json={"token": "glpat-new-rotated-token", "expires_at": new_expiry}, ) ) # Mock GitHub public key fetch respx.get( "https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key" ).mock( return_value=httpx.Response( 200, json={"key": public_key_b64, "key_id": "key-123"} ) ) # Mock GitHub secret update → success respx.put( "https://api.github.com/repos/test-owner/test-repo/actions/secrets/GITLAB_PAT" ).mock(return_value=httpx.Response(204)) # Mock asyncio.sleep to avoid delays monkeypatch.setattr("asyncio.sleep", AsyncMock()) exit_code = await main() assert exit_code == 0 # Verify registry was updated with new expiry date updated_registry = json.loads(registry_path.read_text(encoding="utf-8")) assert updated_registry["tokens"][0]["expiry_date"] == new_expiry # Verify rotation was logged captured = capsys.readouterr() assert "Rotated GitLab token" in captured.out assert new_expiry in captured.out # --------------------------------------------------------------------------- # Test 3: Rotation success + secret update failure → alert triggered # Validates: Requirements 3.5 # --------------------------------------------------------------------------- @pytest.mark.asyncio @respx.mock async def test_rotation_success_secret_update_failure_triggers_alert( tmp_path, monkeypatch, capsys ): """Rotation succeeds but GitHub secret update fails → alert is triggered.""" expiring = _expiring_date(7) new_expiry = _future_date(90) _, _, public_key_b64 = _generate_keypair() tokens = [ { "service": "GitLab", "token_name": "ci-token", "expiry_date": expiring, "renewal_method": "auto", }, ] registry_path = _write_registry(tmp_path, tokens) _set_base_env(monkeypatch, tmp_path, registry_path) # Mock GitLab check → expiring soon respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock( return_value=httpx.Response(200, json={"expires_at": expiring}) ) # Mock GitLab rotation → success respx.post( "https://gitlab.example.com/api/v4/personal_access_tokens/12345/rotate" ).mock( return_value=httpx.Response( 200, json={"token": "glpat-new-rotated-token", "expires_at": new_expiry}, ) ) # Mock GitHub public key fetch → success respx.get( "https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key" ).mock( return_value=httpx.Response( 200, json={"key": public_key_b64, "key_id": "key-123"} ) ) # Mock GitHub secret update → FAILURE (500) respx.put( "https://api.github.com/repos/test-owner/test-repo/actions/secrets/GITLAB_PAT" ).mock(return_value=httpx.Response(500)) # Mock OrgMyLife GET tasks (check existing) → no existing tasks orgmylife_get_route = respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(200, json=[]) ) # Mock OrgMyLife POST tasks (create alert) → success orgmylife_post_route = respx.post("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(201, json={"id": 42}) ) # Mock asyncio.sleep to avoid delays monkeypatch.setattr("asyncio.sleep", AsyncMock()) exit_code = await main() # Alert was delivered successfully, so exit code is 0 assert exit_code == 0 # Verify alert was triggered (POST to OrgMyLife) assert orgmylife_post_route.called # Verify registry was NOT updated (secret update failed) updated_registry = json.loads(registry_path.read_text(encoding="utf-8")) assert updated_registry["tokens"][0]["expiry_date"] == expiring # --------------------------------------------------------------------------- # Test 4: Alert channel unreachable → non-zero exit code # Validates: Requirements 4.6 # --------------------------------------------------------------------------- @pytest.mark.asyncio @respx.mock async def test_alert_channel_unreachable_nonzero_exit(tmp_path, monkeypatch, capsys): """Alert channel (OrgMyLife) unreachable → exit code 1.""" expiring = _expiring_date(7) tokens = [ { "service": "Jira", "token_name": "jira-access", "expiry_date": expiring, "renewal_method": "manual", }, ] registry_path = _write_registry(tmp_path, tokens) _set_base_env(monkeypatch, tmp_path, registry_path) # Mock Jira check → 200 (token works, but expiry within window) respx.get("https://jira.example.com/rest/api/2/myself").mock( return_value=httpx.Response(200, json={"name": "user"}) ) # Mock OrgMyLife GET tasks → connection error respx.get("https://orgmylife.example.com/api/tasks").mock( side_effect=httpx.ConnectError("Connection refused") ) # Mock OrgMyLife POST tasks → connection error respx.post("https://orgmylife.example.com/api/tasks").mock( side_effect=httpx.ConnectError("Connection refused") ) # Mock asyncio.sleep to avoid delays monkeypatch.setattr("asyncio.sleep", AsyncMock()) exit_code = await main() # Alert delivery failed → exit code 1 assert exit_code == 1 # --------------------------------------------------------------------------- # Test 5: Service timeout after retries → "check failed" classification # Validates: Requirements 2.5 # --------------------------------------------------------------------------- @pytest.mark.asyncio @respx.mock async def test_service_timeout_after_retries_check_failed( tmp_path, monkeypatch, capsys ): """Service times out on all retry attempts → classified as 'check failed'.""" tokens = [ { "service": "Jira", "token_name": "jira-access", "expiry_date": _future_date(30), "renewal_method": "manual", }, ] registry_path = _write_registry(tmp_path, tokens) _set_base_env(monkeypatch, tmp_path, registry_path) # Mock Jira API → timeout on all attempts respx.get("https://jira.example.com/rest/api/2/myself").mock( side_effect=httpx.TimeoutException("Request timed out") ) # Mock asyncio.sleep to avoid delays in retry logic monkeypatch.setattr("asyncio.sleep", AsyncMock()) exit_code = await main() # "check failed" doesn't trigger alerts, so exit code is 0 assert exit_code == 0 # Verify summary shows "check failed" captured = capsys.readouterr() assert "check failed" in captured.out assert "jira-access" in captured.out # --------------------------------------------------------------------------- # Test 6: Null expiry_date classification scenarios # Validates: Requirements 2.6, 6.8 # --------------------------------------------------------------------------- @pytest.mark.asyncio @respx.mock async def test_null_expiry_date_api_success_classified_healthy( tmp_path, monkeypatch, capsys ): """Null expiry_date + API success → classified as 'healthy'.""" tokens = [ { "service": "OrgMyLife", "token_name": "oml-key", "expiry_date": None, "renewal_method": "manual", }, ] registry_path = _write_registry(tmp_path, tokens) _set_base_env(monkeypatch, tmp_path, registry_path) # Mock OrgMyLife check → 200 (token works) respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(200, json=[]) ) # No alert routes should be called alert_route = respx.post("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(201, json={"id": 1}) ) # Mock asyncio.sleep to avoid delays monkeypatch.setattr("asyncio.sleep", AsyncMock()) exit_code = await main() assert exit_code == 0 assert not alert_route.called # Verify summary shows "healthy" captured = capsys.readouterr() assert "healthy" in captured.out assert "oml-key" in captured.out @pytest.mark.asyncio @respx.mock async def test_null_expiry_date_api_401_classified_expired( tmp_path, monkeypatch, capsys ): """Null expiry_date + API returns 401 → classified as 'expired'.""" tokens = [ { "service": "OrgMyLife", "token_name": "oml-key", "expiry_date": None, "renewal_method": "manual", }, ] registry_path = _write_registry(tmp_path, tokens) _set_base_env(monkeypatch, tmp_path, registry_path) # Mock OrgMyLife check → 401 (token expired/revoked) respx.get("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(401) ) # Mock OrgMyLife alert task creation → success respx.post("https://orgmylife.example.com/api/tasks").mock( return_value=httpx.Response(201, json={"id": 99}) ) # Mock asyncio.sleep to avoid delays monkeypatch.setattr("asyncio.sleep", AsyncMock()) exit_code = await main() assert exit_code == 0 # Verify summary shows "expired" captured = capsys.readouterr() assert "expired" in captured.out assert "oml-key" in captured.out