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 @@
|
||||
# Tests for AI-Orchestrator
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Unit tests for the PAT Manager alert sender module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from scripts.pat_manager.alerter import AlertSender
|
||||
from scripts.pat_manager.errors import AlertError
|
||||
from scripts.pat_manager.models import (
|
||||
AlertChannel,
|
||||
AlertResult,
|
||||
CheckResult,
|
||||
PatEntry,
|
||||
PatStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expiring_result() -> CheckResult:
|
||||
"""A CheckResult for a Jira token expiring in 7 days."""
|
||||
return CheckResult(
|
||||
entry=PatEntry(
|
||||
service="Jira",
|
||||
token_name="jira-api-access",
|
||||
expiry_date="2025-08-01",
|
||||
renewal_method="manual",
|
||||
),
|
||||
status=PatStatus.EXPIRING_SOON,
|
||||
days_remaining=7,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_result() -> CheckResult:
|
||||
"""A CheckResult for an expired Confluence token."""
|
||||
return CheckResult(
|
||||
entry=PatEntry(
|
||||
service="Confluence",
|
||||
token_name="confluence-bot",
|
||||
expiry_date="2025-06-01",
|
||||
renewal_method="manual",
|
||||
),
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orgmylife_config() -> dict:
|
||||
"""Config for OrgMyLife alert channel."""
|
||||
return {
|
||||
"orgmylife_url": "https://orgmylife.app",
|
||||
"orgmylife_api_key": "test-api-key-123",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_config() -> dict:
|
||||
"""Config for email alert channel."""
|
||||
return {
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": "587",
|
||||
"smtp_user": "user@example.com",
|
||||
"smtp_pass": "password123",
|
||||
"alert_email_to": "admin@example.com",
|
||||
}
|
||||
|
||||
|
||||
class TestAlertSenderInit:
|
||||
"""Tests for AlertSender initialization."""
|
||||
|
||||
def test_orgmylife_channel(self, orgmylife_config: dict) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
assert sender.channel == AlertChannel.ORGMYLIFE
|
||||
assert sender.config == orgmylife_config
|
||||
|
||||
def test_email_channel(self, email_config: dict) -> None:
|
||||
sender = AlertSender(AlertChannel.EMAIL, email_config)
|
||||
assert sender.channel == AlertChannel.EMAIL
|
||||
assert sender.config == email_config
|
||||
|
||||
|
||||
class TestBuildTaskTitle:
|
||||
"""Tests for task title formatting."""
|
||||
|
||||
def test_title_format(self, expiring_result: CheckResult, orgmylife_config: dict) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
title = sender._build_task_title(expiring_result)
|
||||
assert title == "[PAT Renewal] Jira - jira-api-access"
|
||||
|
||||
def test_title_contains_service_and_token(
|
||||
self, expired_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
title = sender._build_task_title(expired_result)
|
||||
assert "Confluence" in title
|
||||
assert "confluence-bot" in title
|
||||
assert title == "[PAT Renewal] Confluence - confluence-bot"
|
||||
|
||||
|
||||
class TestBuildTaskDescription:
|
||||
"""Tests for task description formatting."""
|
||||
|
||||
def test_expiring_description(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expiring_result)
|
||||
assert "7 days" in desc
|
||||
assert "2025-08-01" in desc
|
||||
assert "Manual renewal required" in desc
|
||||
assert "Jira" in desc
|
||||
assert "jira-api-access" in desc
|
||||
|
||||
def test_expired_description(
|
||||
self, expired_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expired_result)
|
||||
assert "0 days remaining" in desc
|
||||
assert "Token has expired" in desc
|
||||
assert "Manual renewal required" in desc
|
||||
|
||||
|
||||
class TestSendAlertOrgMyLife:
|
||||
"""Tests for OrgMyLife alert sending."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_successful_task_creation(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: no existing tasks
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
# Mock: successful task creation
|
||||
respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(201, json={"id": 1, "title": "[PAT Renewal] Jira - jira-api-access"})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
result = await sender.send_alert(expiring_result)
|
||||
|
||||
assert result.sent is True
|
||||
assert result.channel == AlertChannel.ORGMYLIFE
|
||||
assert result.error_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_skips_when_existing_task(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: existing task found
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json=[{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}],
|
||||
)
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
result = await sender.send_alert(expiring_result)
|
||||
|
||||
assert result.sent is False
|
||||
assert result.channel == AlertChannel.ORGMYLIFE
|
||||
assert result.error_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_raises_alert_error_on_failure(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: no existing tasks
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
# Mock: task creation fails
|
||||
respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(500, json={"error": "Internal Server Error"})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
with pytest.raises(AlertError) as exc_info:
|
||||
await sender.send_alert(expiring_result)
|
||||
|
||||
assert "orgmylife" in exc_info.value.channel
|
||||
assert "500" in exc_info.value.reason
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_api_key_header_sent(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: no existing tasks
|
||||
get_route = respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
# Mock: successful task creation
|
||||
post_route = respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(201, json={"id": 1})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
await sender.send_alert(expiring_result)
|
||||
|
||||
# Verify API key was sent
|
||||
assert get_route.calls[0].request.headers["X-API-Key"] == "test-api-key-123"
|
||||
assert post_route.calls[0].request.headers["X-API-Key"] == "test-api-key-123"
|
||||
|
||||
|
||||
class TestCheckExistingTask:
|
||||
"""Tests for idempotent alerting via check_existing_task."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_returns_true_when_task_exists(self, orgmylife_config: dict) -> None:
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json=[{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}],
|
||||
)
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_returns_false_when_no_task(self, orgmylife_config: dict) -> None:
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_returns_false_on_api_error(self, orgmylife_config: dict) -> None:
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(500, json={"error": "Server Error"})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_handles_wrapped_response(self, orgmylife_config: dict) -> None:
|
||||
"""Test handling of paginated/wrapped API response."""
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json={
|
||||
"tasks": [
|
||||
{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is True
|
||||
|
||||
|
||||
class TestSendAlertEmail:
|
||||
"""Tests for email alert sending."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_missing_smtp_config(
|
||||
self, expiring_result: CheckResult
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.EMAIL, {})
|
||||
with pytest.raises(AlertError) as exc_info:
|
||||
await sender.send_alert(expiring_result)
|
||||
assert "email" in exc_info.value.channel
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_missing_email_to(
|
||||
self, expiring_result: CheckResult
|
||||
) -> None:
|
||||
config = {"smtp_host": "smtp.example.com"}
|
||||
sender = AlertSender(AlertChannel.EMAIL, config)
|
||||
with pytest.raises(AlertError) as exc_info:
|
||||
await sender.send_alert(expiring_result)
|
||||
assert "email" in exc_info.value.channel
|
||||
|
||||
|
||||
class TestDaysRemainingInAlert:
|
||||
"""Tests for days remaining in alert messages."""
|
||||
|
||||
def test_expired_shows_zero_days(
|
||||
self, expired_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expired_result)
|
||||
assert "0 days remaining" in desc
|
||||
|
||||
def test_expiring_shows_correct_days(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expiring_result)
|
||||
assert "7 days" in desc
|
||||
|
||||
def test_none_days_remaining_treated_as_zero(self, orgmylife_config: dict) -> None:
|
||||
result = CheckResult(
|
||||
entry=PatEntry(
|
||||
service="OrgMyLife",
|
||||
token_name="test-token",
|
||||
expiry_date=None,
|
||||
renewal_method="manual",
|
||||
),
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=None,
|
||||
error_message=None,
|
||||
)
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(result)
|
||||
assert "0 days remaining" in desc
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Property-based tests for the PAT Manager alert sender module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.alerter import AlertSender
|
||||
from scripts.pat_manager.models import (
|
||||
AlertChannel,
|
||||
CheckResult,
|
||||
PatEntry,
|
||||
PatStatus,
|
||||
)
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
SERVICES = ["GitLab", "Jira", "Confluence", "OrgMyLife"]
|
||||
RENEWAL_METHODS = ["auto", "manual"]
|
||||
ALL_STATUSES = [PatStatus.HEALTHY, PatStatus.EXPIRING_SOON, PatStatus.EXPIRED, PatStatus.CHECK_FAILED]
|
||||
|
||||
# Services that require manual renewal (used for alert testing)
|
||||
manual_services = st.sampled_from(["Jira", "Confluence", "OrgMyLife"])
|
||||
|
||||
# Statuses that trigger alerts
|
||||
alert_statuses = st.sampled_from([PatStatus.EXPIRING_SOON, PatStatus.EXPIRED])
|
||||
|
||||
# Token names: non-empty strings up to 128 chars (printable, no control chars)
|
||||
token_names = st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "P", "S"), min_codepoint=32),
|
||||
min_size=1,
|
||||
max_size=128,
|
||||
).filter(lambda s: s.strip() != "")
|
||||
|
||||
# Days remaining: 0 for expired, 1-14 for expiring soon
|
||||
days_remaining_values = st.integers(min_value=0, max_value=14)
|
||||
|
||||
# Expiry dates in ISO 8601 format
|
||||
expiry_dates = st.dates(
|
||||
min_value=datetime.date(2020, 1, 1),
|
||||
max_value=datetime.date(2030, 12, 31),
|
||||
).map(lambda d: d.isoformat())
|
||||
|
||||
|
||||
def pat_entries() -> st.SearchStrategy[PatEntry]:
|
||||
"""Generate valid PatEntry instances with random services and token names."""
|
||||
return st.builds(
|
||||
PatEntry,
|
||||
service=st.sampled_from(SERVICES),
|
||||
token_name=st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "P")),
|
||||
min_size=1,
|
||||
max_size=64,
|
||||
),
|
||||
expiry_date=st.one_of(
|
||||
st.none(),
|
||||
st.dates().map(lambda d: d.isoformat()),
|
||||
),
|
||||
renewal_method=st.sampled_from(RENEWAL_METHODS),
|
||||
)
|
||||
|
||||
|
||||
# --- Additional imports for Property 9 ---
|
||||
from datetime import date
|
||||
|
||||
from scripts.pat_manager.checker import classify_by_date
|
||||
|
||||
|
||||
# --- Strategies for Property 9 ---
|
||||
|
||||
# Generate ISO 8601 date strings in range 2020-2099
|
||||
prop9_expiry_dates = st.dates(
|
||||
min_value=date(2020, 1, 1),
|
||||
max_value=date(2099, 12, 31),
|
||||
).map(lambda d: d.isoformat())
|
||||
|
||||
# Generate reference dates in range 2020-2099
|
||||
prop9_reference_dates = st.dates(
|
||||
min_value=date(2020, 1, 1),
|
||||
max_value=date(2099, 12, 31),
|
||||
)
|
||||
|
||||
|
||||
# --- Property 9 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 9: Days remaining calculation
|
||||
class TestDaysRemainingCalculation:
|
||||
"""
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
Property 9: For any expiry date and reference date, the computed
|
||||
days_remaining SHALL equal max(0, (expiry_date - reference_date).days)
|
||||
— yielding 0 for already-expired tokens and the correct positive integer
|
||||
for future dates.
|
||||
"""
|
||||
|
||||
@given(expiry_date=prop9_expiry_dates, reference_date=prop9_reference_dates)
|
||||
@settings(max_examples=100)
|
||||
def test_days_remaining_equals_max_zero_delta(
|
||||
self, expiry_date: str, reference_date: date
|
||||
) -> None:
|
||||
"""Days remaining is always max(0, (expiry - ref).days)."""
|
||||
_status, days_remaining = classify_by_date(
|
||||
expiry_date, reference_date, window=14
|
||||
)
|
||||
|
||||
parsed_expiry = date.fromisoformat(expiry_date)
|
||||
expected = max(0, (parsed_expiry - reference_date).days)
|
||||
|
||||
assert days_remaining == expected
|
||||
|
||||
|
||||
def healthy_check_results() -> st.SearchStrategy[CheckResult]:
|
||||
"""Generate CheckResult instances that are all HEALTHY."""
|
||||
return st.builds(
|
||||
CheckResult,
|
||||
entry=pat_entries(),
|
||||
status=st.just(PatStatus.HEALTHY),
|
||||
days_remaining=st.integers(min_value=15, max_value=365),
|
||||
error_message=st.none(),
|
||||
stale_warning=st.just(False),
|
||||
)
|
||||
|
||||
|
||||
def check_results_any_status() -> st.SearchStrategy[CheckResult]:
|
||||
"""Generate CheckResult instances with any valid status."""
|
||||
return st.builds(
|
||||
CheckResult,
|
||||
entry=pat_entries(),
|
||||
status=st.sampled_from(ALL_STATUSES),
|
||||
days_remaining=st.one_of(
|
||||
st.none(),
|
||||
st.integers(min_value=0, max_value=365),
|
||||
),
|
||||
error_message=st.one_of(st.none(), st.text(min_size=1, max_size=50)),
|
||||
stale_warning=st.booleans(),
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def check_results_requiring_alert(draw: st.DrawFn) -> CheckResult:
|
||||
"""Generate CheckResult objects that require alerts (manual renewal, expiring/expired)."""
|
||||
service = draw(manual_services)
|
||||
token_name = draw(token_names)
|
||||
status = draw(alert_statuses)
|
||||
days = draw(days_remaining_values)
|
||||
expiry_date = draw(expiry_dates)
|
||||
|
||||
# Align days_remaining with status
|
||||
if status == PatStatus.EXPIRED:
|
||||
days = 0
|
||||
else:
|
||||
days = max(1, days) # expiring_soon should have at least 1 day
|
||||
|
||||
entry = PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method="manual",
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
# --- Helper: Alert decision logic ---
|
||||
|
||||
|
||||
def needs_alert(result: CheckResult) -> bool:
|
||||
"""Determine if a CheckResult should trigger an alert.
|
||||
|
||||
A PAT needs an alert if:
|
||||
- Its status is EXPIRING_SOON or EXPIRED
|
||||
- AND its renewal_method is "manual"
|
||||
|
||||
GitLab tokens with auto renewal that failed rotation would also
|
||||
need alerts, but that is handled by the orchestrator flow (rotation
|
||||
failure falls back to alert). For this property test, we focus on
|
||||
the direct filtering logic based on status and renewal_method.
|
||||
"""
|
||||
if result.status not in (PatStatus.EXPIRING_SOON, PatStatus.EXPIRED):
|
||||
return False
|
||||
if result.entry.renewal_method == "manual":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Property 7 Tests ---
|
||||
|
||||
|
||||
# Feature: pat-renewal, Property 7: Alert triggering correctness
|
||||
class TestAlertTriggeringCorrectness:
|
||||
"""
|
||||
**Validates: Requirements 4.1, 5.4**
|
||||
|
||||
Property 7: Alert triggering correctness
|
||||
- If ALL PATs have status "healthy", THEN zero alerts SHALL be sent
|
||||
- For each manual-renewal PAT with status "expiring soon" or "expired",
|
||||
exactly one alert SHALL be triggered
|
||||
"""
|
||||
|
||||
@given(results=st.lists(healthy_check_results(), min_size=0, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_all_healthy_means_zero_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""If all PATs are healthy, zero alerts should be triggered.
|
||||
|
||||
This verifies Requirement 5.4: IF all PATs are classified as "healthy",
|
||||
THEN the PAT_Manager SHALL complete without creating OrgMyLife tasks or
|
||||
sending email alerts.
|
||||
"""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
assert len(alerts_needed) == 0, (
|
||||
f"Expected zero alerts for all-healthy set, got {len(alerts_needed)}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_one_alert_per_expiring_expired_manual_token(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""Each manual-renewal PAT with status expiring/expired triggers exactly one alert.
|
||||
|
||||
This verifies Requirement 4.1: WHEN a Manual_Renewal_Token has 14 or fewer
|
||||
days until expiration or is already expired, THE PAT_Manager SHALL send an
|
||||
alert via the configured Alert_Channel.
|
||||
|
||||
The count of alerts needed must equal the count of results that are
|
||||
manual-renewal AND have status EXPIRING_SOON or EXPIRED.
|
||||
"""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
# Count expected: manual tokens with expiring_soon or expired status
|
||||
expected_count = sum(
|
||||
1
|
||||
for r in results
|
||||
if r.entry.renewal_method == "manual"
|
||||
and r.status in (PatStatus.EXPIRING_SOON, PatStatus.EXPIRED)
|
||||
)
|
||||
|
||||
assert len(alerts_needed) == expected_count, (
|
||||
f"Expected {expected_count} alerts, got {len(alerts_needed)}. "
|
||||
f"Results: {[(r.entry.renewal_method, r.status.value) for r in results]}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_healthy_tokens_never_trigger_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""No token with HEALTHY status should ever trigger an alert,
|
||||
regardless of its renewal_method."""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
for alert in alerts_needed:
|
||||
assert alert.status != PatStatus.HEALTHY, (
|
||||
f"Healthy token should not trigger alert: "
|
||||
f"{alert.entry.service}/{alert.entry.token_name}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_auto_renewal_tokens_do_not_trigger_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""Tokens with renewal_method "auto" should not trigger alerts
|
||||
through the standard filtering logic (they go through rotation first)."""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
for alert in alerts_needed:
|
||||
assert alert.entry.renewal_method != "auto", (
|
||||
f"Auto-renewal token should not trigger alert via filtering: "
|
||||
f"{alert.entry.service}/{alert.entry.token_name}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_check_failed_tokens_do_not_trigger_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""Tokens with CHECK_FAILED status should not trigger alerts
|
||||
(they are reported differently in the summary)."""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
for alert in alerts_needed:
|
||||
assert alert.status != PatStatus.CHECK_FAILED, (
|
||||
f"Check-failed token should not trigger alert: "
|
||||
f"{alert.entry.service}/{alert.entry.token_name}"
|
||||
)
|
||||
|
||||
|
||||
# --- Property 8 Tests ---
|
||||
|
||||
|
||||
# Feature: pat-renewal, Property 8: Alert message completeness
|
||||
class TestAlertMessageCompleteness:
|
||||
"""
|
||||
**Validates: Requirements 4.2, 4.3**
|
||||
|
||||
Property 8: Alert message completeness
|
||||
- If the alert channel is OrgMyLife, the task title SHALL contain both the
|
||||
service name and token identifier.
|
||||
- The description SHALL contain service name, token identifier, and expiry
|
||||
information.
|
||||
"""
|
||||
|
||||
@given(result=check_results_requiring_alert())
|
||||
@settings(max_examples=100)
|
||||
def test_orgmylife_task_title_contains_service_and_token(
|
||||
self, result: CheckResult
|
||||
) -> None:
|
||||
"""OrgMyLife task title contains both service name and token identifier."""
|
||||
sender = AlertSender(
|
||||
AlertChannel.ORGMYLIFE,
|
||||
{"orgmylife_url": "https://orgmylife.app", "orgmylife_api_key": "test-key"},
|
||||
)
|
||||
|
||||
title = sender._build_task_title(result)
|
||||
|
||||
# Title SHALL contain both the service name and token identifier
|
||||
assert result.entry.service in title, (
|
||||
f"Title '{title}' does not contain service name '{result.entry.service}'"
|
||||
)
|
||||
assert result.entry.token_name in title, (
|
||||
f"Title '{title}' does not contain token name '{result.entry.token_name}'"
|
||||
)
|
||||
|
||||
@given(result=check_results_requiring_alert())
|
||||
@settings(max_examples=100)
|
||||
def test_orgmylife_task_description_contains_required_fields(
|
||||
self, result: CheckResult
|
||||
) -> None:
|
||||
"""OrgMyLife task description contains service name, token name, and days remaining info."""
|
||||
sender = AlertSender(
|
||||
AlertChannel.ORGMYLIFE,
|
||||
{"orgmylife_url": "https://orgmylife.app", "orgmylife_api_key": "test-key"},
|
||||
)
|
||||
|
||||
description = sender._build_task_description(result)
|
||||
|
||||
# Description SHALL contain service name
|
||||
assert result.entry.service in description, (
|
||||
f"Description does not contain service name '{result.entry.service}'"
|
||||
)
|
||||
# Description SHALL contain token identifier
|
||||
assert result.entry.token_name in description, (
|
||||
f"Description does not contain token name '{result.entry.token_name}'"
|
||||
)
|
||||
# Description SHALL contain days remaining information
|
||||
days = result.days_remaining if result.days_remaining is not None else 0
|
||||
if days == 0:
|
||||
assert "0 days remaining" in description, (
|
||||
f"Description does not contain '0 days remaining' for expired token"
|
||||
)
|
||||
else:
|
||||
assert str(days) in description, (
|
||||
f"Description does not contain days remaining '{days}'"
|
||||
)
|
||||
|
||||
|
||||
# --- Additional imports for Property 10 ---
|
||||
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
|
||||
# --- Property 10 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 10: Alert idempotence
|
||||
class TestAlertIdempotence:
|
||||
"""
|
||||
**Validates: Requirements 4.5**
|
||||
|
||||
Property 10: For any PAT that requires an alert, if an open OrgMyLife task
|
||||
already exists for the same service and token_name, THEN no new task SHALL
|
||||
be created. Running the alert flow twice for the same PAT state SHALL produce
|
||||
at most one task.
|
||||
"""
|
||||
|
||||
@given(
|
||||
service=manual_services,
|
||||
token_name=token_names,
|
||||
expiry_date=expiry_dates,
|
||||
status=alert_statuses,
|
||||
days_remaining=days_remaining_values,
|
||||
)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_no_duplicate_task_when_open_task_exists(
|
||||
self,
|
||||
service: str,
|
||||
token_name: str,
|
||||
expiry_date: str,
|
||||
status: PatStatus,
|
||||
days_remaining: int,
|
||||
) -> None:
|
||||
"""When an open task already exists for a PAT, no new task is created."""
|
||||
entry = PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method="manual",
|
||||
)
|
||||
check_result = CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
config = {
|
||||
"orgmylife_url": "https://orgmylife.app",
|
||||
"orgmylife_api_key": "test-api-key",
|
||||
}
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, config)
|
||||
|
||||
expected_title = f"[PAT Renewal] {service} - {token_name}"
|
||||
|
||||
with respx.mock:
|
||||
# Mock GET: return a list containing a task with matching title
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json=[{"title": expected_title, "status": "open"}],
|
||||
)
|
||||
)
|
||||
# Mock POST: should NOT be called
|
||||
post_route = respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(201, json={"id": 1})
|
||||
)
|
||||
|
||||
result = await sender.send_alert(check_result)
|
||||
|
||||
# Alert was not sent (no new task created)
|
||||
assert result.sent is False
|
||||
# POST endpoint was never called (no duplicate task creation)
|
||||
assert not post_route.called
|
||||
@@ -0,0 +1,734 @@
|
||||
"""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
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Property-based tests for the PAT Manager expiry checker module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.checker import ExpiryChecker
|
||||
from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
# HTTP status codes that are NOT in {200-299, 401, 403}
|
||||
# These should all yield "check failed" classification.
|
||||
non_auth_error_codes = st.one_of(
|
||||
st.just(400),
|
||||
st.just(402),
|
||||
st.integers(min_value=404, max_value=499),
|
||||
st.integers(min_value=500, max_value=599),
|
||||
)
|
||||
|
||||
|
||||
# --- Property 12 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 12: Non-auth HTTP errors yield "check failed"
|
||||
class TestNonAuthHttpErrorsYieldCheckFailed:
|
||||
"""
|
||||
**Validates: Requirements 6.6**
|
||||
|
||||
Property 12: For any HTTP response status code that is not 200-299, 401,
|
||||
or 403, the classification SHALL be "check failed". This includes 4xx errors
|
||||
(other than 401/403), 5xx errors, and timeout conditions.
|
||||
"""
|
||||
|
||||
@given(status_code=non_auth_error_codes)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_jira_non_auth_errors_yield_check_failed(
|
||||
self, status_code: int
|
||||
) -> None:
|
||||
"""Jira check with non-auth HTTP errors produces check_failed status."""
|
||||
entry = PatEntry(
|
||||
service="Jira",
|
||||
token_name="test-jira-token",
|
||||
expiry_date="2025-12-01",
|
||||
renewal_method="manual",
|
||||
)
|
||||
checker = ExpiryChecker(max_retries=0)
|
||||
|
||||
with patch.dict(os.environ, {"JIRA_URL": "https://jira.example.com"}):
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=Response(status_code)
|
||||
)
|
||||
result = await checker.check_jira(entry, "fake-token")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
assert len(result.error_message) > 0
|
||||
|
||||
@given(status_code=non_auth_error_codes)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_confluence_non_auth_errors_yield_check_failed(
|
||||
self, status_code: int
|
||||
) -> None:
|
||||
"""Confluence check with non-auth HTTP errors produces check_failed status."""
|
||||
entry = PatEntry(
|
||||
service="Confluence",
|
||||
token_name="test-confluence-token",
|
||||
expiry_date="2025-12-01",
|
||||
renewal_method="manual",
|
||||
)
|
||||
checker = ExpiryChecker(max_retries=0)
|
||||
|
||||
with patch.dict(os.environ, {"CONFLUENCE_URL": "https://confluence.example.com"}):
|
||||
with respx.mock:
|
||||
respx.get(
|
||||
"https://confluence.example.com/rest/api/user/current"
|
||||
).mock(return_value=Response(status_code))
|
||||
result = await checker.check_confluence(entry, "fake-token")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
assert len(result.error_message) > 0
|
||||
|
||||
@given(status_code=non_auth_error_codes)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_orgmylife_non_auth_errors_yield_check_failed(
|
||||
self, status_code: int
|
||||
) -> None:
|
||||
"""OrgMyLife check with non-auth HTTP errors produces check_failed status."""
|
||||
entry = PatEntry(
|
||||
service="OrgMyLife",
|
||||
token_name="test-orgmylife-token",
|
||||
expiry_date="2025-12-01",
|
||||
renewal_method="manual",
|
||||
)
|
||||
checker = ExpiryChecker(max_retries=0)
|
||||
|
||||
with patch.dict(os.environ, {"ORGMYLIFE_URL": "https://orgmylife.example.com"}):
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=Response(status_code)
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "fake-token")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
assert len(result.error_message) > 0
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for configuration layer."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.config import (
|
||||
ServiceConfig,
|
||||
build_config,
|
||||
expand_path,
|
||||
resolve_env_var,
|
||||
validate_dispatch_config,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveEnvVar:
|
||||
"""Tests for environment variable resolution."""
|
||||
|
||||
def test_resolves_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("MY_TOKEN", "secret123")
|
||||
assert resolve_env_var("$MY_TOKEN") == "secret123"
|
||||
|
||||
def test_missing_env_var_returns_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("NONEXISTENT_VAR", raising=False)
|
||||
assert resolve_env_var("$NONEXISTENT_VAR") == ""
|
||||
|
||||
def test_literal_value_unchanged(self):
|
||||
assert resolve_env_var("literal_value") == "literal_value"
|
||||
|
||||
def test_non_string_passthrough(self):
|
||||
assert resolve_env_var(123) == 123 # type: ignore
|
||||
|
||||
|
||||
class TestExpandPath:
|
||||
"""Tests for path expansion."""
|
||||
|
||||
def test_tilde_expansion(self, tmp_path):
|
||||
result = expand_path("~/workspaces", tmp_path)
|
||||
assert str(Path.home()) in result
|
||||
|
||||
def test_relative_path_resolved(self, tmp_path):
|
||||
result = expand_path("./workspaces", tmp_path)
|
||||
assert str(tmp_path) in result
|
||||
|
||||
def test_absolute_path_unchanged(self, tmp_path):
|
||||
if os.name == "nt":
|
||||
result = expand_path("C:\\workspaces", tmp_path)
|
||||
assert "C:\\" in result
|
||||
else:
|
||||
result = expand_path("/tmp/workspaces", tmp_path)
|
||||
assert result.startswith("/tmp")
|
||||
|
||||
def test_empty_uses_default(self, tmp_path):
|
||||
result = expand_path("", tmp_path)
|
||||
assert "symphony_workspaces" in result
|
||||
|
||||
|
||||
class TestBuildConfig:
|
||||
"""Tests for building typed config from raw dict."""
|
||||
|
||||
def test_defaults_applied(self, tmp_path):
|
||||
config = build_config({}, tmp_path)
|
||||
assert config.polling.interval_ms == 30000
|
||||
assert config.agent.max_concurrent_agents == 10
|
||||
assert config.agent.max_turns == 20
|
||||
assert config.codex.command == "codex app-server"
|
||||
assert config.codex.turn_timeout_ms == 3600000
|
||||
assert config.hooks.timeout_ms == 60000
|
||||
|
||||
def test_tracker_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "test-key")
|
||||
raw = {
|
||||
"tracker": {
|
||||
"kind": "linear",
|
||||
"project_slug": "my-proj",
|
||||
"active_states": ["Ready", "Working"],
|
||||
}
|
||||
}
|
||||
config = build_config(raw, tmp_path)
|
||||
assert config.tracker.kind == "linear"
|
||||
assert config.tracker.project_slug == "my-proj"
|
||||
assert config.tracker.api_key == "test-key"
|
||||
assert config.tracker.active_states == ["Ready", "Working"]
|
||||
assert config.tracker.endpoint == "https://api.linear.app/graphql"
|
||||
|
||||
def test_per_state_concurrency_normalization(self, tmp_path):
|
||||
raw = {
|
||||
"agent": {
|
||||
"max_concurrent_agents_by_state": {
|
||||
"In Progress": 3,
|
||||
"Todo": "invalid",
|
||||
"Review": -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
config = build_config(raw, tmp_path)
|
||||
# Only valid positive entries kept, normalized to lowercase
|
||||
assert config.agent.max_concurrent_agents_by_state == {"in progress": 3}
|
||||
|
||||
|
||||
class TestValidateDispatchConfig:
|
||||
"""Tests for dispatch config validation."""
|
||||
|
||||
def test_valid_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "test-key")
|
||||
raw = {
|
||||
"tracker": {"kind": "linear", "project_slug": "proj"},
|
||||
}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert errors == []
|
||||
|
||||
def test_missing_tracker_kind(self, tmp_path):
|
||||
config = build_config({}, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("tracker.kind" in e for e in errors)
|
||||
|
||||
def test_unsupported_tracker_kind(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "key")
|
||||
raw = {"tracker": {"kind": "jira", "project_slug": "x"}}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("Unsupported" in e for e in errors)
|
||||
|
||||
def test_missing_api_key(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("LINEAR_API_KEY", raising=False)
|
||||
raw = {"tracker": {"kind": "linear", "project_slug": "x"}}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("api_key" in e for e in errors)
|
||||
|
||||
def test_missing_project_slug(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "key")
|
||||
raw = {"tracker": {"kind": "linear"}}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("project_slug" in e for e in errors)
|
||||
@@ -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
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for orchestrator dispatch logic."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.models import BlockerRef, Issue, OrchestratorState, RunningEntry, LiveSession
|
||||
from ai_orchestrator.orchestrator import Orchestrator
|
||||
|
||||
|
||||
def make_issue(
|
||||
id: str = "id-1",
|
||||
identifier: str = "ABC-1",
|
||||
title: str = "Test",
|
||||
state: str = "Todo",
|
||||
priority: int | None = 1,
|
||||
created_at: datetime | None = None,
|
||||
blocked_by: list | None = None,
|
||||
) -> Issue:
|
||||
return Issue(
|
||||
id=id,
|
||||
identifier=identifier,
|
||||
title=title,
|
||||
state=state,
|
||||
priority=priority,
|
||||
created_at=created_at or datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
blocked_by=blocked_by or [],
|
||||
)
|
||||
|
||||
|
||||
class TestDispatchSorting:
|
||||
"""Tests for issue dispatch sorting."""
|
||||
|
||||
def test_priority_ascending(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
||||
orch = Orchestrator(workflow)
|
||||
|
||||
issues = [
|
||||
make_issue(id="3", identifier="C", priority=3),
|
||||
make_issue(id="1", identifier="A", priority=1),
|
||||
make_issue(id="2", identifier="B", priority=2),
|
||||
]
|
||||
sorted_issues = orch._sort_for_dispatch(issues)
|
||||
assert [i.identifier for i in sorted_issues] == ["A", "B", "C"]
|
||||
|
||||
def test_null_priority_sorts_last(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
||||
orch = Orchestrator(workflow)
|
||||
|
||||
issues = [
|
||||
make_issue(id="1", identifier="A", priority=None),
|
||||
make_issue(id="2", identifier="B", priority=2),
|
||||
]
|
||||
sorted_issues = orch._sort_for_dispatch(issues)
|
||||
assert sorted_issues[0].identifier == "B"
|
||||
assert sorted_issues[1].identifier == "A"
|
||||
|
||||
def test_oldest_first_tiebreaker(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
||||
orch = Orchestrator(workflow)
|
||||
|
||||
issues = [
|
||||
make_issue(
|
||||
id="2", identifier="B", priority=1,
|
||||
created_at=datetime(2024, 2, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
make_issue(
|
||||
id="1", identifier="A", priority=1,
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
sorted_issues = orch._sort_for_dispatch(issues)
|
||||
assert sorted_issues[0].identifier == "A"
|
||||
|
||||
|
||||
class TestDispatchEligibility:
|
||||
"""Tests for should_dispatch logic."""
|
||||
|
||||
def setup_method(self, method, tmp_path=None):
|
||||
"""Set up a basic orchestrator for testing."""
|
||||
pass
|
||||
|
||||
def _make_orchestrator(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: fake-key
|
||||
active_states: ["Todo", "In Progress"]
|
||||
terminal_states: ["Done", "Cancelled"]
|
||||
agent:
|
||||
max_concurrent_agents: 5
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
return orch
|
||||
|
||||
def test_active_issue_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(state="Todo")
|
||||
assert orch._should_dispatch(issue) is True
|
||||
|
||||
def test_terminal_issue_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(state="Done")
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_already_running_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(id="id-1", state="Todo")
|
||||
orch._state.running["id-1"] = RunningEntry(
|
||||
issue_id="id-1", identifier="ABC-1", issue=issue
|
||||
)
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_already_claimed_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(id="id-1", state="Todo")
|
||||
orch._state.claimed.add("id-1")
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_todo_with_non_terminal_blocker_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(
|
||||
state="Todo",
|
||||
blocked_by=[BlockerRef(id="b1", state="In Progress")],
|
||||
)
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_todo_with_terminal_blocker_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(
|
||||
state="Todo",
|
||||
blocked_by=[BlockerRef(id="b1", state="Done")],
|
||||
)
|
||||
assert orch._should_dispatch(issue) is True
|
||||
|
||||
def test_missing_required_fields_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(title="", state="Todo")
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
|
||||
class TestBackoffComputation:
|
||||
"""Tests for retry backoff calculation."""
|
||||
|
||||
def test_first_retry(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: key
|
||||
agent:
|
||||
max_retry_backoff_ms: 300000
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
# attempt 1: 10000 * 2^0 = 10000
|
||||
assert orch._compute_backoff(1) == 10000
|
||||
|
||||
def test_second_retry(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: key
|
||||
agent:
|
||||
max_retry_backoff_ms: 300000
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
# attempt 2: 10000 * 2^1 = 20000
|
||||
assert orch._compute_backoff(2) == 20000
|
||||
|
||||
def test_backoff_capped(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: key
|
||||
agent:
|
||||
max_retry_backoff_ms: 300000
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
# attempt 10: 10000 * 2^9 = 5120000, capped at 300000
|
||||
assert orch._compute_backoff(10) == 300000
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for prompt builder."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.models import Issue
|
||||
from ai_orchestrator.prompt import (
|
||||
DEFAULT_PROMPT,
|
||||
TemplateParseError,
|
||||
TemplateRenderError,
|
||||
render_prompt,
|
||||
)
|
||||
|
||||
|
||||
def make_issue(**kwargs) -> Issue:
|
||||
"""Create a test issue with defaults."""
|
||||
defaults = {
|
||||
"id": "issue-1",
|
||||
"identifier": "ABC-123",
|
||||
"title": "Fix the bug",
|
||||
"description": "Something is broken",
|
||||
"state": "In Progress",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return Issue(**defaults)
|
||||
|
||||
|
||||
class TestRenderPrompt:
|
||||
"""Tests for prompt rendering."""
|
||||
|
||||
def test_basic_rendering(self):
|
||||
template = "Work on {{ issue.identifier }}: {{ issue.title }}"
|
||||
issue = make_issue()
|
||||
result = render_prompt(template, issue)
|
||||
assert result == "Work on ABC-123: Fix the bug"
|
||||
|
||||
def test_attempt_variable(self):
|
||||
template = "{% if attempt %}Retry #{{ attempt }}{% endif %}"
|
||||
issue = make_issue()
|
||||
result = render_prompt(template, issue, attempt=3)
|
||||
assert result == "Retry #3"
|
||||
|
||||
def test_attempt_none_on_first_run(self):
|
||||
template = "{% if attempt %}retry{% else %}first{% endif %}"
|
||||
issue = make_issue()
|
||||
result = render_prompt(template, issue, attempt=None)
|
||||
assert result == "first"
|
||||
|
||||
def test_empty_template_returns_default(self):
|
||||
issue = make_issue()
|
||||
result = render_prompt("", issue)
|
||||
assert result == DEFAULT_PROMPT
|
||||
|
||||
def test_unknown_variable_fails(self):
|
||||
template = "{{ unknown_var }}"
|
||||
issue = make_issue()
|
||||
with pytest.raises(TemplateRenderError):
|
||||
render_prompt(template, issue)
|
||||
|
||||
def test_issue_labels_iterable(self):
|
||||
template = "{% for label in issue.labels %}{{ label }} {% endfor %}"
|
||||
issue = make_issue(labels=["bug", "urgent"])
|
||||
result = render_prompt(template, issue)
|
||||
assert "bug" in result
|
||||
assert "urgent" in result
|
||||
|
||||
def test_issue_description_none(self):
|
||||
template = "{{ issue.description or 'No description' }}"
|
||||
issue = make_issue(description=None)
|
||||
result = render_prompt(template, issue)
|
||||
assert result == "No description"
|
||||
|
||||
def test_syntax_error_raises(self):
|
||||
template = "{% if unclosed"
|
||||
issue = make_issue()
|
||||
with pytest.raises(TemplateParseError):
|
||||
render_prompt(template, issue)
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Unit tests for the PAT Registry module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.pat_manager.errors import RegistryValidationError
|
||||
from scripts.pat_manager.models import PatEntry
|
||||
from scripts.pat_manager.registry import PatRegistry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> PatRegistry:
|
||||
"""Create a fresh PatRegistry instance."""
|
||||
return PatRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_entry_dict() -> dict:
|
||||
"""A valid PAT entry dictionary."""
|
||||
return {
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-pipeline-token",
|
||||
"expiry_date": "2025-08-15",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_registry_data() -> dict:
|
||||
"""A valid registry JSON structure with multiple entries."""
|
||||
return {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-pipeline-token",
|
||||
"expiry_date": "2025-08-15",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "jira-api-access",
|
||||
"expiry_date": "2025-07-20",
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
{
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "orchestrator-api-key",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestValidateEntry:
|
||||
"""Tests for PatRegistry.validate_entry()."""
|
||||
|
||||
def test_valid_gitlab_entry(self, registry: PatRegistry, valid_entry_dict: dict) -> None:
|
||||
result = registry.validate_entry(valid_entry_dict)
|
||||
assert isinstance(result, PatEntry)
|
||||
assert result.service == "GitLab"
|
||||
assert result.token_name == "ci-pipeline-token"
|
||||
assert result.expiry_date == "2025-08-15"
|
||||
assert result.renewal_method == "auto"
|
||||
|
||||
def test_valid_jira_entry(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "jira-token",
|
||||
"expiry_date": "2025-12-31",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.service == "Jira"
|
||||
assert result.renewal_method == "manual"
|
||||
|
||||
def test_valid_confluence_entry(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "Confluence",
|
||||
"token_name": "conf-token",
|
||||
"expiry_date": "2025-06-01",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.service == "Confluence"
|
||||
|
||||
def test_valid_orgmylife_entry(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "oml-key",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.service == "OrgMyLife"
|
||||
assert result.expiry_date is None
|
||||
|
||||
def test_null_expiry_date_accepted(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "no-expiry",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.expiry_date is None
|
||||
|
||||
def test_missing_expiry_date_field_accepted(self, registry: PatRegistry) -> None:
|
||||
"""expiry_date is optional (defaults to None when missing)."""
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "no-expiry-field",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.expiry_date is None
|
||||
|
||||
def test_missing_service_field(self, registry: PatRegistry) -> None:
|
||||
entry = {"token_name": "test", "renewal_method": "manual"}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "service" in exc_info.value.failed_fields
|
||||
|
||||
def test_missing_token_name_field(self, registry: PatRegistry) -> None:
|
||||
entry = {"service": "GitLab", "renewal_method": "auto"}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_missing_renewal_method_field(self, registry: PatRegistry) -> None:
|
||||
entry = {"service": "GitLab", "token_name": "test"}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "renewal_method" in exc_info.value.failed_fields
|
||||
|
||||
def test_invalid_service_value(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitHub",
|
||||
"token_name": "test",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "service" in exc_info.value.failed_fields
|
||||
|
||||
def test_empty_token_name(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_token_name_too_long(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "x" * 129,
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_token_name_max_length_accepted(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "x" * 128,
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert len(result.token_name) == 128
|
||||
|
||||
def test_invalid_expiry_date_format(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025/08/15",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "expiry_date" in exc_info.value.failed_fields
|
||||
|
||||
def test_invalid_expiry_date_values(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-13-01",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "expiry_date" in exc_info.value.failed_fields
|
||||
|
||||
def test_invalid_expiry_date_feb_30(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-02-30",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "expiry_date" in exc_info.value.failed_fields
|
||||
|
||||
def test_renewal_method_mismatch_gitlab(self, registry: PatRegistry) -> None:
|
||||
"""GitLab must have renewal_method 'auto'."""
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "renewal_method" in exc_info.value.failed_fields
|
||||
|
||||
def test_renewal_method_mismatch_jira(self, registry: PatRegistry) -> None:
|
||||
"""Jira must have renewal_method 'manual'."""
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "renewal_method" in exc_info.value.failed_fields
|
||||
|
||||
def test_non_dict_entry_raises(self, registry: PatRegistry) -> None:
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.validate_entry("not a dict") # type: ignore
|
||||
|
||||
def test_multiple_failed_fields(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "InvalidService",
|
||||
"token_name": "",
|
||||
"renewal_method": "invalid",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert len(exc_info.value.failed_fields) >= 2
|
||||
|
||||
|
||||
class TestLoad:
|
||||
"""Tests for PatRegistry.load()."""
|
||||
|
||||
def test_load_valid_registry(
|
||||
self, registry: PatRegistry, tmp_path: Path, valid_registry_data: dict
|
||||
) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(valid_registry_data), encoding="utf-8")
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert len(entries) == 3
|
||||
assert entries[0].service == "GitLab"
|
||||
assert entries[1].service == "Jira"
|
||||
assert entries[2].service == "OrgMyLife"
|
||||
|
||||
def test_load_empty_registry(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text('{"version": "1.0", "tokens": []}', encoding="utf-8")
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert entries == []
|
||||
|
||||
def test_load_nonexistent_file(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "nonexistent.json"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
registry.load(file_path)
|
||||
|
||||
def test_load_invalid_json(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text("not valid json", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
registry.load(file_path)
|
||||
|
||||
def test_load_duplicate_entries_raises(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "same-name",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "same-name",
|
||||
"expiry_date": "2025-06-01",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
],
|
||||
}
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.load(file_path)
|
||||
assert "service" in exc_info.value.failed_fields
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_load_same_token_name_different_services(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
"""Same token_name in different services is allowed."""
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "api-token",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "api-token",
|
||||
"expiry_date": "2025-06-01",
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
],
|
||||
}
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert len(entries) == 2
|
||||
|
||||
def test_load_invalid_entry_raises(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "InvalidService",
|
||||
"token_name": "test",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
],
|
||||
}
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.load(file_path)
|
||||
|
||||
|
||||
class TestSave:
|
||||
"""Tests for PatRegistry.save()."""
|
||||
|
||||
def test_save_entries(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
entries = [
|
||||
PatEntry(
|
||||
service="GitLab",
|
||||
token_name="test-token",
|
||||
expiry_date="2025-08-15",
|
||||
renewal_method="auto",
|
||||
),
|
||||
PatEntry(
|
||||
service="Jira",
|
||||
token_name="jira-token",
|
||||
expiry_date=None,
|
||||
renewal_method="manual",
|
||||
),
|
||||
]
|
||||
file_path = tmp_path / "registry.json"
|
||||
registry.save(file_path, entries)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["version"] == "1.0"
|
||||
assert len(data["tokens"]) == 2
|
||||
assert data["tokens"][0]["service"] == "GitLab"
|
||||
assert data["tokens"][1]["expiry_date"] is None
|
||||
|
||||
def test_save_empty_list(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
registry.save(file_path, [])
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data == {"version": "1.0", "tokens": []}
|
||||
|
||||
def test_save_creates_parent_directories(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
file_path = tmp_path / "subdir" / "nested" / "registry.json"
|
||||
registry.save(file_path, [])
|
||||
assert file_path.exists()
|
||||
|
||||
|
||||
class TestCreateEmpty:
|
||||
"""Tests for PatRegistry.create_empty()."""
|
||||
|
||||
def test_create_empty_file(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "new-registry.json"
|
||||
registry.create_empty(file_path)
|
||||
|
||||
assert file_path.exists()
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data == {"version": "1.0", "tokens": []}
|
||||
|
||||
def test_create_empty_loadable(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
"""An empty registry created by create_empty should be loadable."""
|
||||
file_path = tmp_path / "new-registry.json"
|
||||
registry.create_empty(file_path)
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert entries == []
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""Tests for save -> load round-trip consistency."""
|
||||
|
||||
def test_round_trip_preserves_entries(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
original_entries = [
|
||||
PatEntry(
|
||||
service="GitLab",
|
||||
token_name="ci-token",
|
||||
expiry_date="2025-08-15",
|
||||
renewal_method="auto",
|
||||
),
|
||||
PatEntry(
|
||||
service="Jira",
|
||||
token_name="jira-access",
|
||||
expiry_date="2025-12-31",
|
||||
renewal_method="manual",
|
||||
),
|
||||
PatEntry(
|
||||
service="OrgMyLife",
|
||||
token_name="api-key",
|
||||
expiry_date=None,
|
||||
renewal_method="manual",
|
||||
),
|
||||
]
|
||||
|
||||
file_path = tmp_path / "registry.json"
|
||||
registry.save(file_path, original_entries)
|
||||
loaded_entries = registry.load(file_path)
|
||||
|
||||
assert len(loaded_entries) == len(original_entries)
|
||||
for original, loaded in zip(original_entries, loaded_entries):
|
||||
assert loaded.service == original.service
|
||||
assert loaded.token_name == original.token_name
|
||||
assert loaded.expiry_date == original.expiry_date
|
||||
assert loaded.renewal_method == original.renewal_method
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Property-based tests for the PAT Registry module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.errors import RegistryValidationError
|
||||
from scripts.pat_manager.models import PatEntry
|
||||
from scripts.pat_manager.registry import PatRegistry, VALID_SERVICES
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
# Valid service names and their required renewal methods
|
||||
_SERVICES_AND_METHODS = [
|
||||
("GitLab", "auto"),
|
||||
("Jira", "manual"),
|
||||
("Confluence", "manual"),
|
||||
("OrgMyLife", "manual"),
|
||||
]
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate valid ISO 8601 date strings (YYYY-MM-DD)."""
|
||||
year = draw(st.integers(min_value=2020, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
# Limit days based on month to avoid invalid dates
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
# Simplified leap year check
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_token_names(draw: st.DrawFn) -> str:
|
||||
"""Generate valid token names (1-128 chars, printable ASCII)."""
|
||||
name = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "S"),
|
||||
whitelist_characters="-_",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=128,
|
||||
)
|
||||
)
|
||||
# Ensure no control characters or empty after strip
|
||||
assume(len(name.strip()) > 0)
|
||||
return name
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_pat_entries(draw: st.DrawFn) -> PatEntry:
|
||||
"""Generate a valid PatEntry with consistent service/renewal_method."""
|
||||
service, renewal_method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
token_name = draw(valid_token_names())
|
||||
expiry_date = draw(st.one_of(st.none(), valid_iso_dates()))
|
||||
return PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method=renewal_method,
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def unique_pat_entry_lists(draw: st.DrawFn) -> list[PatEntry]:
|
||||
"""Generate a list of valid PatEntry objects with no duplicate (service, token_name) pairs."""
|
||||
entries = draw(st.lists(valid_pat_entries(), min_size=1, max_size=10))
|
||||
# Deduplicate by (service, token_name)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique_entries: list[PatEntry] = []
|
||||
for entry in entries:
|
||||
key = (entry.service, entry.token_name)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_entries.append(entry)
|
||||
assume(len(unique_entries) >= 1)
|
||||
return unique_entries
|
||||
|
||||
|
||||
@st.composite
|
||||
def invalid_entry_dicts(draw: st.DrawFn) -> dict:
|
||||
"""Generate entry dicts that are invalid (missing fields, bad service, bad token_name, etc.)."""
|
||||
strategy_choice = draw(st.integers(min_value=0, max_value=4))
|
||||
|
||||
if strategy_choice == 0:
|
||||
# Missing required field: service
|
||||
return {
|
||||
"token_name": draw(valid_token_names()),
|
||||
"renewal_method": draw(st.sampled_from(["auto", "manual"])),
|
||||
}
|
||||
elif strategy_choice == 1:
|
||||
# Missing required field: token_name
|
||||
service, method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
return {
|
||||
"service": service,
|
||||
"renewal_method": method,
|
||||
}
|
||||
elif strategy_choice == 2:
|
||||
# Missing required field: renewal_method
|
||||
service, _ = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
return {
|
||||
"service": service,
|
||||
"token_name": draw(valid_token_names()),
|
||||
}
|
||||
elif strategy_choice == 3:
|
||||
# Invalid service name
|
||||
bad_service = draw(
|
||||
st.text(min_size=1, max_size=20).filter(
|
||||
lambda s: s not in VALID_SERVICES
|
||||
)
|
||||
)
|
||||
return {
|
||||
"service": bad_service,
|
||||
"token_name": draw(valid_token_names()),
|
||||
"renewal_method": draw(st.sampled_from(["auto", "manual"])),
|
||||
}
|
||||
else:
|
||||
# Empty token_name or too long
|
||||
bad_name = draw(
|
||||
st.one_of(
|
||||
st.just(""),
|
||||
st.text(min_size=129, max_size=200),
|
||||
)
|
||||
)
|
||||
service, method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
return {
|
||||
"service": service,
|
||||
"token_name": bad_name,
|
||||
"renewal_method": method,
|
||||
}
|
||||
|
||||
|
||||
@st.composite
|
||||
def duplicate_entry_from_existing(draw: st.DrawFn, entries: list[PatEntry]) -> dict:
|
||||
"""Generate a duplicate entry dict that has the same (service, token_name) as an existing entry."""
|
||||
assume(len(entries) > 0)
|
||||
existing = draw(st.sampled_from(entries))
|
||||
# Create a dict with same service+token_name but possibly different expiry
|
||||
expiry = draw(st.one_of(st.none(), valid_iso_dates()))
|
||||
return {
|
||||
"service": existing.service,
|
||||
"token_name": existing.token_name,
|
||||
"expiry_date": expiry,
|
||||
"renewal_method": existing.renewal_method,
|
||||
}
|
||||
|
||||
|
||||
# --- Property 1 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 1: Registry serialization round-trip
|
||||
class TestRegistrySerializationRoundTrip:
|
||||
"""
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
Property 1: For any valid list of PAT entries, serializing the registry
|
||||
to JSON and deserializing it back SHALL produce an equivalent list of
|
||||
entries with identical field values.
|
||||
"""
|
||||
|
||||
@given(
|
||||
entries=st.lists(valid_pat_entries(), min_size=0, max_size=15).map(
|
||||
lambda entries: list({
|
||||
(e.service, e.token_name): e for e in entries
|
||||
}.values())
|
||||
)
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_save_load_round_trip(
|
||||
self, entries: list[PatEntry], tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""save(path, entries) followed by load(path) produces identical entries."""
|
||||
tmp_path = tmp_path_factory.mktemp("prop1_roundtrip")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Save entries to file
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Load entries back
|
||||
loaded = registry.load(file_path)
|
||||
|
||||
# Verify same count
|
||||
assert len(loaded) == len(entries)
|
||||
|
||||
# Verify each entry has identical field values
|
||||
for original, restored in zip(entries, loaded):
|
||||
assert restored.service == original.service
|
||||
assert restored.token_name == original.token_name
|
||||
assert restored.expiry_date == original.expiry_date
|
||||
assert restored.renewal_method == original.renewal_method
|
||||
|
||||
|
||||
# --- Property 3 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 3: Invalid entries and duplicates preserve registry state
|
||||
class TestInvalidEntriesAndDuplicatesPreserveState:
|
||||
"""
|
||||
**Validates: Requirements 1.3, 1.5**
|
||||
|
||||
Property 3: For any PAT registry and any entry that is either invalid
|
||||
(fails validation) or a duplicate (same service + token_name already exists),
|
||||
attempting to add that entry SHALL leave the registry unchanged — the entry
|
||||
count and all existing entries remain identical.
|
||||
"""
|
||||
|
||||
@given(
|
||||
entries=unique_pat_entry_lists(),
|
||||
bad_entry=invalid_entry_dicts(),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_invalid_entry_preserves_registry_state(
|
||||
self, entries: list[PatEntry], bad_entry: dict, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""Loading a registry file with an appended invalid entry raises
|
||||
RegistryValidationError and the original saved file remains unchanged.
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp("prop3_invalid")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Save the valid registry
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Read the original file content for comparison
|
||||
original_content = file_path.read_text(encoding="utf-8")
|
||||
|
||||
# Create a corrupted registry file with the bad entry appended
|
||||
corrupted_path = tmp_path / "corrupted.json"
|
||||
data = json.loads(original_content)
|
||||
data["tokens"].append(bad_entry)
|
||||
corrupted_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
# Attempting to load the corrupted file should raise RegistryValidationError
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.load(corrupted_path)
|
||||
|
||||
# The original file must remain unchanged
|
||||
assert file_path.read_text(encoding="utf-8") == original_content
|
||||
|
||||
# The original file is still loadable and produces the same entries
|
||||
reloaded = registry.load(file_path)
|
||||
assert len(reloaded) == len(entries)
|
||||
for original, loaded in zip(entries, reloaded):
|
||||
assert loaded.service == original.service
|
||||
assert loaded.token_name == original.token_name
|
||||
assert loaded.expiry_date == original.expiry_date
|
||||
assert loaded.renewal_method == original.renewal_method
|
||||
|
||||
@given(entries=unique_pat_entry_lists())
|
||||
@settings(max_examples=100)
|
||||
def test_duplicate_entry_preserves_registry_state(
|
||||
self, entries: list[PatEntry], tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""Loading a registry file with a duplicate (service, token_name) entry
|
||||
raises RegistryValidationError and the original saved file remains unchanged.
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp("prop3_dup")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Save the valid registry
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Read the original file content for comparison
|
||||
original_content = file_path.read_text(encoding="utf-8")
|
||||
|
||||
# Pick an existing entry to duplicate
|
||||
existing = entries[0]
|
||||
duplicate_dict = {
|
||||
"service": existing.service,
|
||||
"token_name": existing.token_name,
|
||||
"expiry_date": "2099-12-31", # Different expiry to prove it's a new entry
|
||||
"renewal_method": existing.renewal_method,
|
||||
}
|
||||
|
||||
# Create a corrupted registry file with the duplicate appended
|
||||
corrupted_path = tmp_path / "corrupted_dup.json"
|
||||
data = json.loads(original_content)
|
||||
data["tokens"].append(duplicate_dict)
|
||||
corrupted_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
# Attempting to load the corrupted file should raise RegistryValidationError
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.load(corrupted_path)
|
||||
|
||||
# The original file must remain unchanged
|
||||
assert file_path.read_text(encoding="utf-8") == original_content
|
||||
|
||||
# The original file is still loadable and produces the same entries
|
||||
reloaded = registry.load(file_path)
|
||||
assert len(reloaded) == len(entries)
|
||||
for original, loaded in zip(entries, reloaded):
|
||||
assert loaded.service == original.service
|
||||
assert loaded.token_name == original.token_name
|
||||
assert loaded.expiry_date == original.expiry_date
|
||||
assert loaded.renewal_method == original.renewal_method
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Unit tests for the Summary Reporter module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus, RotationResult
|
||||
from scripts.pat_manager.reporter import SummaryReporter
|
||||
|
||||
|
||||
def make_entry(service: str = "GitLab", token_name: str = "ci-token") -> PatEntry:
|
||||
"""Helper to create a PatEntry for testing."""
|
||||
return PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date="2025-08-15",
|
||||
renewal_method="auto" if service == "GitLab" else "manual",
|
||||
)
|
||||
|
||||
|
||||
def make_result(
|
||||
service: str = "GitLab",
|
||||
token_name: str = "ci-token",
|
||||
status: PatStatus = PatStatus.HEALTHY,
|
||||
days_remaining: int | None = 45,
|
||||
error_message: str | None = None,
|
||||
) -> CheckResult:
|
||||
"""Helper to create a CheckResult for testing."""
|
||||
entry = make_entry(service, token_name)
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
|
||||
class TestSummaryReporter:
|
||||
"""Tests for SummaryReporter.report()."""
|
||||
|
||||
def test_report_header(self) -> None:
|
||||
"""Report starts with the expected header."""
|
||||
reporter = SummaryReporter()
|
||||
report = reporter.report([], [])
|
||||
assert "=== PAT Lifecycle Check Summary ===" in report
|
||||
|
||||
def test_report_contains_table_headers(self) -> None:
|
||||
"""Report contains column headers."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result()]
|
||||
report = reporter.report(results, [])
|
||||
assert "Service" in report
|
||||
assert "Token Name" in report
|
||||
assert "Status" in report
|
||||
assert "Details" in report
|
||||
|
||||
def test_report_one_healthy_pat(self) -> None:
|
||||
"""Report shows healthy PAT with days remaining."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result(days_remaining=45)]
|
||||
report = reporter.report(results, [])
|
||||
assert "GitLab" in report
|
||||
assert "ci-token" in report
|
||||
assert "healthy" in report
|
||||
assert "45 days remaining" in report
|
||||
|
||||
def test_report_expiring_soon_pat(self) -> None:
|
||||
"""Report shows expiring soon PAT with days remaining."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(
|
||||
service="Jira",
|
||||
token_name="jira-api",
|
||||
status=PatStatus.EXPIRING_SOON,
|
||||
days_remaining=7,
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "Jira" in report
|
||||
assert "jira-api" in report
|
||||
assert "expiring soon" in report
|
||||
assert "7 days remaining" in report
|
||||
|
||||
def test_report_expired_pat(self) -> None:
|
||||
"""Report shows expired PAT with 0 days remaining."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(
|
||||
service="Confluence",
|
||||
token_name="confluence-bot",
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "Confluence" in report
|
||||
assert "confluence-bot" in report
|
||||
assert "expired" in report
|
||||
assert "0 days remaining" in report
|
||||
|
||||
def test_report_check_failed_pat(self) -> None:
|
||||
"""Report shows check failed PAT with error message."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(
|
||||
service="OrgMyLife",
|
||||
token_name="orchestrator-key",
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message="Connection timeout",
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "OrgMyLife" in report
|
||||
assert "orchestrator-key" in report
|
||||
assert "check failed" in report
|
||||
assert "Connection timeout" in report
|
||||
|
||||
def test_report_multiple_pats(self) -> None:
|
||||
"""Report contains one line per PAT."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(service="GitLab", token_name="token-a", days_remaining=45),
|
||||
make_result(
|
||||
service="Jira",
|
||||
token_name="token-b",
|
||||
status=PatStatus.EXPIRING_SOON,
|
||||
days_remaining=7,
|
||||
),
|
||||
make_result(
|
||||
service="Confluence",
|
||||
token_name="token-c",
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
),
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "token-a" in report
|
||||
assert "token-b" in report
|
||||
assert "token-c" in report
|
||||
|
||||
def test_report_no_rotation_section_when_empty(self) -> None:
|
||||
"""Report omits rotation section when no rotations occurred."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result()]
|
||||
report = reporter.report(results, [])
|
||||
assert "Rotation Results" not in report
|
||||
|
||||
def test_report_includes_rotation_section(self) -> None:
|
||||
"""Report includes rotation section with successful rotations."""
|
||||
reporter = SummaryReporter()
|
||||
entry = make_entry("GitLab", "ci-pipeline-token")
|
||||
results = [make_result()]
|
||||
rotations = [
|
||||
RotationResult(
|
||||
success=True,
|
||||
new_token="glpat-new-value",
|
||||
new_expiry_date="2025-08-01",
|
||||
error_message=None,
|
||||
entry=entry,
|
||||
old_expiry_date="2025-07-01",
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, rotations)
|
||||
assert "--- Rotation Results ---" in report
|
||||
assert "GitLab/ci-pipeline-token" in report
|
||||
assert "Rotated successfully" in report
|
||||
assert "old: 2025-07-01" in report
|
||||
assert "new: 2025-08-01" in report
|
||||
|
||||
def test_report_excludes_failed_rotations(self) -> None:
|
||||
"""Report rotation section only shows successful rotations."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result()]
|
||||
rotations = [
|
||||
RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message="Rotation failed",
|
||||
entry=make_entry("GitLab", "failed-token"),
|
||||
old_expiry_date="2025-07-01",
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, rotations)
|
||||
assert "Rotation Results" not in report
|
||||
|
||||
def test_report_empty_results(self) -> None:
|
||||
"""Report handles empty results list gracefully."""
|
||||
reporter = SummaryReporter()
|
||||
report = reporter.report([], [])
|
||||
assert "=== PAT Lifecycle Check Summary ===" in report
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Property-based tests for the Summary Reporter module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus
|
||||
from scripts.pat_manager.reporter import SummaryReporter
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
_SERVICES_AND_METHODS = [
|
||||
("GitLab", "auto"),
|
||||
("Jira", "manual"),
|
||||
("Confluence", "manual"),
|
||||
("OrgMyLife", "manual"),
|
||||
]
|
||||
|
||||
_ALL_STATUSES = [
|
||||
PatStatus.HEALTHY,
|
||||
PatStatus.EXPIRING_SOON,
|
||||
PatStatus.EXPIRED,
|
||||
PatStatus.CHECK_FAILED,
|
||||
]
|
||||
|
||||
|
||||
@st.composite
|
||||
def simple_token_names(draw: st.DrawFn) -> str:
|
||||
"""Generate simple token names using alphanumeric + hyphens for reliable report matching."""
|
||||
name = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("Ll", "Lu", "Nd"),
|
||||
whitelist_characters="-",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=64,
|
||||
)
|
||||
)
|
||||
# Ensure name starts with a letter (not hyphen) and is non-empty after strip
|
||||
assume(len(name.strip()) > 0)
|
||||
assume(name[0].isalpha())
|
||||
return name
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate valid ISO 8601 date strings (YYYY-MM-DD)."""
|
||||
year = draw(st.integers(min_value=2020, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def check_results(draw: st.DrawFn) -> CheckResult:
|
||||
"""Generate a CheckResult with a random status and appropriate fields."""
|
||||
service, renewal_method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
token_name = draw(simple_token_names())
|
||||
expiry_date = draw(st.one_of(st.none(), valid_iso_dates()))
|
||||
status = draw(st.sampled_from(_ALL_STATUSES))
|
||||
|
||||
# Generate appropriate days_remaining and error_message based on status
|
||||
if status == PatStatus.CHECK_FAILED:
|
||||
days_remaining = None
|
||||
error_message = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "Z")),
|
||||
min_size=3,
|
||||
max_size=50,
|
||||
)
|
||||
)
|
||||
elif status == PatStatus.EXPIRED:
|
||||
days_remaining = 0
|
||||
error_message = None
|
||||
elif status == PatStatus.EXPIRING_SOON:
|
||||
days_remaining = draw(st.integers(min_value=1, max_value=14))
|
||||
error_message = None
|
||||
else: # HEALTHY
|
||||
days_remaining = draw(st.integers(min_value=15, max_value=365))
|
||||
error_message = None
|
||||
|
||||
entry = PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method=renewal_method,
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
|
||||
# --- Property 11 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 11: Summary report completeness
|
||||
class TestSummaryReportCompleteness:
|
||||
"""
|
||||
**Validates: Requirements 5.3**
|
||||
|
||||
Property 11: For any list of check results, the generated summary report
|
||||
SHALL contain one line per result, and each line SHALL include the service
|
||||
name, token identifier, and classification status.
|
||||
"""
|
||||
|
||||
@given(results=st.lists(check_results(), min_size=1, max_size=10))
|
||||
@settings(max_examples=100)
|
||||
def test_report_contains_service_token_and_status_for_each_result(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""For each check result, the report contains the service name,
|
||||
token name, and status value."""
|
||||
reporter = SummaryReporter()
|
||||
report = reporter.report(results, [])
|
||||
|
||||
for result in results:
|
||||
# The report must contain the service name
|
||||
assert result.entry.service in report, (
|
||||
f"Service '{result.entry.service}' not found in report"
|
||||
)
|
||||
# The report must contain the token name
|
||||
assert result.entry.token_name in report, (
|
||||
f"Token name '{result.entry.token_name}' not found in report"
|
||||
)
|
||||
# The report must contain the status value
|
||||
assert result.status.value in report, (
|
||||
f"Status '{result.status.value}' not found in report"
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Property-based tests for the GitLab Rotator module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.models import PatEntry, RotationResult
|
||||
from scripts.pat_manager.registry import PatRegistry
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
_SERVICES_AND_METHODS = [
|
||||
("GitLab", "auto"),
|
||||
("Jira", "manual"),
|
||||
("Confluence", "manual"),
|
||||
("OrgMyLife", "manual"),
|
||||
]
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate valid ISO 8601 date strings (YYYY-MM-DD)."""
|
||||
year = draw(st.integers(min_value=2020, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def future_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate future ISO 8601 date strings (always in the future)."""
|
||||
year = draw(st.integers(min_value=2026, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_token_names(draw: st.DrawFn) -> str:
|
||||
"""Generate valid token names (1-128 chars, printable ASCII)."""
|
||||
name = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "S"),
|
||||
whitelist_characters="-_",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=128,
|
||||
)
|
||||
)
|
||||
assume(len(name.strip()) > 0)
|
||||
return name
|
||||
|
||||
|
||||
@st.composite
|
||||
def gitlab_pat_entries(draw: st.DrawFn) -> PatEntry:
|
||||
"""Generate a valid GitLab PatEntry with a valid expiry_date."""
|
||||
token_name = draw(valid_token_names())
|
||||
expiry_date = draw(valid_iso_dates())
|
||||
return PatEntry(
|
||||
service="GitLab",
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method="auto",
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def successful_rotation_results(draw: st.DrawFn, original_expiry: str) -> RotationResult:
|
||||
"""Generate a successful RotationResult with a new expiry date different from the original."""
|
||||
new_expiry = draw(future_iso_dates())
|
||||
assume(new_expiry != original_expiry)
|
||||
return RotationResult(
|
||||
success=True,
|
||||
new_token="glpat-" + draw(st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N")),
|
||||
min_size=10,
|
||||
max_size=30,
|
||||
)),
|
||||
new_expiry_date=new_expiry,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
# --- Property 6 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 6: Registry update after successful rotation
|
||||
class TestRegistryUpdateAfterSuccessfulRotation:
|
||||
"""
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
Property 6: For any GitLab PAT entry and any valid rotation response
|
||||
containing a new expiry date, after successful rotation the registry
|
||||
entry's expiry_date SHALL equal the new expiry date from the rotation
|
||||
response.
|
||||
"""
|
||||
|
||||
@given(
|
||||
entry=gitlab_pat_entries(),
|
||||
new_expiry=future_iso_dates(),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_registry_entry_updated_with_new_expiry_after_rotation(
|
||||
self, entry: PatEntry, new_expiry: str, tmp_path_factory
|
||||
) -> None:
|
||||
"""After successful rotation, the registry entry's expiry_date equals
|
||||
the new expiry date from the rotation response."""
|
||||
assume(new_expiry != entry.expiry_date)
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("prop6_rotation")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Step 1: Save a registry with the original entry
|
||||
registry.save(file_path, [entry])
|
||||
|
||||
# Step 2: Create a RotationResult with success=True and the new expiry_date
|
||||
rotation_result = RotationResult(
|
||||
success=True,
|
||||
new_token="glpat-new-rotated-token",
|
||||
new_expiry_date=new_expiry,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
# Step 3: Simulate the registry update after successful rotation
|
||||
# Load the registry, update the entry's expiry_date, save it back
|
||||
entries = registry.load(file_path)
|
||||
assert len(entries) == 1
|
||||
|
||||
# Update the entry's expiry_date to the new one from RotationResult
|
||||
entries[0].expiry_date = rotation_result.new_expiry_date
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Step 4: Reload and verify the entry's expiry_date equals the new_expiry_date
|
||||
reloaded = registry.load(file_path)
|
||||
assert len(reloaded) == 1
|
||||
assert reloaded[0].expiry_date == new_expiry
|
||||
assert reloaded[0].expiry_date == rotation_result.new_expiry_date
|
||||
|
||||
# Also verify other fields remain unchanged
|
||||
assert reloaded[0].service == entry.service
|
||||
assert reloaded[0].token_name == entry.token_name
|
||||
assert reloaded[0].renewal_method == entry.renewal_method
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Tests for the SecretUpdater module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from nacl.public import PrivateKey
|
||||
|
||||
from scripts.pat_manager.errors import SecretUpdateError
|
||||
from scripts.pat_manager.secret_updater import SecretUpdater
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keypair():
|
||||
"""Generate a NaCl keypair for testing 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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def updater():
|
||||
"""Create a SecretUpdater instance for testing."""
|
||||
return SecretUpdater(
|
||||
github_token="ghp_test_token_123",
|
||||
repo_owner="test-owner",
|
||||
repo_name="test-repo",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_success(updater, keypair):
|
||||
"""Test successful secret update flow."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
# Mock GET public key endpoint
|
||||
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-id-123"},
|
||||
)
|
||||
)
|
||||
|
||||
# Mock PUT secret endpoint
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
||||
).mock(return_value=httpx.Response(204))
|
||||
|
||||
result = await updater.update_secret("MY_SECRET", "super-secret-value")
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_created_201(updater, keypair):
|
||||
"""Test secret creation returns True on 201."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
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-id-456"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/NEW_SECRET"
|
||||
).mock(return_value=httpx.Response(201))
|
||||
|
||||
result = await updater.update_secret("NEW_SECRET", "new-value")
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_public_key_fetch_fails_http_error(updater):
|
||||
"""Test SecretUpdateError raised when public key fetch returns non-200."""
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(return_value=httpx.Response(403))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "value")
|
||||
|
||||
assert "public key" in str(exc_info.value).lower()
|
||||
assert "403" in str(exc_info.value)
|
||||
# Ensure token value is NOT in the error message
|
||||
assert "value" not in str(exc_info.value) or "secret value" not in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_public_key_network_error(updater):
|
||||
"""Test SecretUpdateError raised on network failure during key fetch."""
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "value")
|
||||
|
||||
assert "MY_SECRET" in str(exc_info.value)
|
||||
assert "ConnectError" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_put_fails_http_error(updater, keypair):
|
||||
"""Test SecretUpdateError raised when PUT returns unexpected status."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
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-id-789"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
||||
).mock(return_value=httpx.Response(422))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "secret-token-value")
|
||||
|
||||
assert "422" in str(exc_info.value)
|
||||
# Ensure the actual secret value is NOT exposed in the error
|
||||
assert "secret-token-value" not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_put_network_error(updater, keypair):
|
||||
"""Test SecretUpdateError raised on network failure during PUT."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
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-id-000"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
||||
).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "value")
|
||||
|
||||
assert "MY_SECRET" in str(exc_info.value)
|
||||
assert "TimeoutException" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_does_not_expose_token_value(updater, keypair):
|
||||
"""Verify that error messages never contain the secret value."""
|
||||
_, _, public_key_b64 = keypair
|
||||
sensitive_value = "ghp_SUPER_SECRET_TOKEN_12345"
|
||||
|
||||
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-id-sec"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/TOKEN_SECRET"
|
||||
).mock(return_value=httpx.Response(500))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("TOKEN_SECRET", sensitive_value)
|
||||
|
||||
error_str = str(exc_info.value)
|
||||
assert sensitive_value not in error_str
|
||||
assert "ghp_SUPER_SECRET" not in error_str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encrypt_secret_produces_valid_output(updater, keypair):
|
||||
"""Test that _encrypt_secret produces base64-encoded encrypted data."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
encrypted = updater._encrypt_secret(public_key_b64, "test-secret")
|
||||
|
||||
# Should be valid base64
|
||||
decoded = base64.b64decode(encrypted)
|
||||
# NaCl sealed box output is always 48 bytes longer than the plaintext
|
||||
# (32 bytes ephemeral public key + 16 bytes MAC)
|
||||
assert len(decoded) == len("test-secret".encode("utf-8")) + 48
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_sends_correct_headers(updater, keypair):
|
||||
"""Verify correct authorization headers are sent."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
key_route = 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-id-hdr"},
|
||||
)
|
||||
)
|
||||
|
||||
put_route = respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/HDR_SECRET"
|
||||
).mock(return_value=httpx.Response(204))
|
||||
|
||||
await updater.update_secret("HDR_SECRET", "value")
|
||||
|
||||
# Verify authorization header was sent
|
||||
assert key_route.called
|
||||
key_request = key_route.calls[0].request
|
||||
assert key_request.headers["Authorization"] == "Bearer ghp_test_token_123"
|
||||
|
||||
assert put_route.called
|
||||
put_request = put_route.calls[0].request
|
||||
assert put_request.headers["Authorization"] == "Bearer ghp_test_token_123"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for workflow loader."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.workflow import (
|
||||
MissingWorkflowFileError,
|
||||
WorkflowFrontMatterNotAMapError,
|
||||
WorkflowParseError,
|
||||
load_workflow,
|
||||
parse_workflow,
|
||||
)
|
||||
|
||||
|
||||
class TestParseWorkflow:
|
||||
"""Tests for parse_workflow function."""
|
||||
|
||||
def test_full_workflow_with_front_matter(self):
|
||||
content = """---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: my-project
|
||||
polling:
|
||||
interval_ms: 15000
|
||||
---
|
||||
You are working on {{ issue.identifier }}: {{ issue.title }}
|
||||
"""
|
||||
result = parse_workflow(content)
|
||||
assert result.config["tracker"]["kind"] == "linear"
|
||||
assert result.config["tracker"]["project_slug"] == "my-project"
|
||||
assert result.config["polling"]["interval_ms"] == 15000
|
||||
assert "{{ issue.identifier }}" in result.prompt_template
|
||||
|
||||
def test_no_front_matter(self):
|
||||
content = "Just a prompt template with {{ issue.title }}"
|
||||
result = parse_workflow(content)
|
||||
assert result.config == {}
|
||||
assert result.prompt_template == content
|
||||
|
||||
def test_empty_front_matter(self):
|
||||
content = """---
|
||||
---
|
||||
Hello {{ issue.identifier }}"""
|
||||
result = parse_workflow(content)
|
||||
assert result.config == {}
|
||||
assert "Hello" in result.prompt_template
|
||||
|
||||
def test_front_matter_not_a_map(self):
|
||||
content = """---
|
||||
- item1
|
||||
- item2
|
||||
---
|
||||
prompt body"""
|
||||
with pytest.raises(WorkflowFrontMatterNotAMapError):
|
||||
parse_workflow(content)
|
||||
|
||||
def test_invalid_yaml(self):
|
||||
content = """---
|
||||
invalid: yaml: content: [
|
||||
---
|
||||
prompt"""
|
||||
with pytest.raises(WorkflowParseError):
|
||||
parse_workflow(content)
|
||||
|
||||
def test_prompt_is_trimmed(self):
|
||||
content = """---
|
||||
key: value
|
||||
---
|
||||
|
||||
Some prompt with whitespace
|
||||
|
||||
"""
|
||||
result = parse_workflow(content)
|
||||
assert result.prompt_template == "Some prompt with whitespace"
|
||||
|
||||
|
||||
class TestLoadWorkflow:
|
||||
"""Tests for load_workflow function."""
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
with pytest.raises(MissingWorkflowFileError):
|
||||
load_workflow(tmp_path / "nonexistent.md")
|
||||
|
||||
def test_valid_file(self, tmp_path):
|
||||
workflow_file = tmp_path / "WORKFLOW.md"
|
||||
workflow_file.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
---
|
||||
Hello {{ issue.title }}""")
|
||||
|
||||
result = load_workflow(workflow_file)
|
||||
assert result.config["tracker"]["kind"] == "linear"
|
||||
assert "Hello" in result.prompt_template
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for workspace manager."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.workspace import (
|
||||
WorkspacePathError,
|
||||
get_workspace_path,
|
||||
sanitize_workspace_key,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeWorkspaceKey:
|
||||
"""Tests for workspace key sanitization."""
|
||||
|
||||
def test_simple_identifier(self):
|
||||
assert sanitize_workspace_key("ABC-123") == "ABC-123"
|
||||
|
||||
def test_special_characters_replaced(self):
|
||||
assert sanitize_workspace_key("ABC/123") == "ABC_123"
|
||||
assert sanitize_workspace_key("my project!") == "my_project_"
|
||||
|
||||
def test_dots_and_underscores_preserved(self):
|
||||
assert sanitize_workspace_key("v1.0_release") == "v1.0_release"
|
||||
|
||||
def test_unicode_replaced(self):
|
||||
assert sanitize_workspace_key("café-123") == "caf__123"
|
||||
|
||||
|
||||
class TestGetWorkspacePath:
|
||||
"""Tests for workspace path computation."""
|
||||
|
||||
def test_deterministic_path(self, tmp_path):
|
||||
root = str(tmp_path)
|
||||
path1 = get_workspace_path(root, "ABC-123")
|
||||
path2 = get_workspace_path(root, "ABC-123")
|
||||
assert path1 == path2
|
||||
|
||||
def test_path_inside_root(self, tmp_path):
|
||||
root = str(tmp_path)
|
||||
path = get_workspace_path(root, "ABC-123")
|
||||
assert str(path).startswith(str(tmp_path))
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
# The sanitizer replaces .. with __ so this is safe
|
||||
root = str(tmp_path)
|
||||
path = get_workspace_path(root, "../escape")
|
||||
# Should be sanitized to __escape and stay inside root
|
||||
assert str(path).startswith(str(tmp_path))
|
||||
Reference in New Issue
Block a user