Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user