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:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
+1
View File
@@ -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
+138
View File
@@ -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))