"""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