from __future__ import annotations import uuid import pytest import pytest_asyncio from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from api.config import settings from api.main import app from api.models.managed_repo import ManagedRepo from api.models.repository_rename import RepositoryRenameOperation, RepositorySlug from api.services.forge_repository import ( ForgeRepositorySnapshot, get_forge_repository_gateway, ) from tests.conftest import ( create_test_domain, create_test_repo, create_test_workplan, ) class FakeForge: def __init__(self, *, name: str = "flex-auth", repository_id: int = 417, head: str = "a" * 40): self.name = name self.repository_id = repository_id self.head = head self.rename_calls = 0 self.extra: dict[str, int] = {} self.unreadable = False def snapshot(self, name: str, repository_id: int | None = None) -> ForgeRepositorySnapshot: return ForgeRepositorySnapshot( repository_id=repository_id or self.repository_id, owner="coulomb", name=name, full_name=f"coulomb/{name}", default_branch="main", head_commit=self.head, clone_url=f"https://forge.example/coulomb/{name}.git", html_url=f"https://forge.example/coulomb/{name}", projection_readable=True, projection_source_present=True, projection_entry_count=1, ) async def inspect(self, *, instance: str, owner: str, name: str): if self.unreadable: from api.services.forge_repository import ForgeRepositoryUnreadable raise ForgeRepositoryUnreadable("simulated unreadable forge") if name == self.name: return self.snapshot(name) if name in self.extra: return self.snapshot(name, self.extra[name]) return None async def rename(self, *, instance: str, owner: str, old_name: str, new_name: str): assert old_name == self.name assert new_name not in self.extra self.rename_calls += 1 self.name = new_name return self.snapshot(new_name) @pytest_asyncio.fixture async def rename_setup(client, monkeypatch): monkeypatch.setattr(settings, "repository_rename_preflight_secret", "test-only-preflight-secret") domain = await create_test_domain(client) repo = await create_test_repo( client, domain_slug=domain["slug"], slug="flex-auth", remote_url="https://forge.example/coulomb/flex-auth.git", local_path="/srv/flex-auth", host_paths={"workstation": "/home/operator/flex-auth"}, ) forge = FakeForge() app.dependency_overrides[get_forge_repository_gateway] = lambda: forge verified = await client.post( f"/repos/{repo['id']}/forge-identity/verify", json={ "provider": "forgejo", "forge_instance": "https://forge.example", "forge_owner": "coulomb", "forge_repository_id": forge.repository_id, "verified_by": "pytest", }, ) assert verified.status_code == 200, verified.text return repo, forge async def _preflight(client, repo_id: str, new_slug: str = "access-engine"): response = await client.post( f"/repos/{repo_id}/rename/preflight", json={"new_slug": new_slug} ) assert response.status_code == 200, response.text return response.json() async def _operation(client, repo_id: str, preflight: dict): confirmation = f"rename:{repo_id}:flex-auth:access-engine" response = await client.post( f"/repos/{repo_id}/rename/operations", json={ "new_slug": "access-engine", "preflight_token": preflight["preflight_token"], "confirmation": confirmation, "actor": "pytest", }, ) assert response.status_code == 201, response.text return response.json(), confirmation async def _phase(client, repo_id: str, operation_id: str, phase: str, expected: str, confirmation: str, **extra): response = await client.post( f"/repos/{repo_id}/rename/operations/{operation_id}/phases/{phase}", json={ "expected_phase": expected, "confirmation": confirmation, **extra, }, ) assert response.status_code == 200, response.text return response.json() @pytest.mark.asyncio async def test_dry_run_has_no_persistent_changes(client, test_engine, rename_setup): repo, _forge = rename_setup factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: before_operations = await session.scalar(select(func.count()).select_from(RepositoryRenameOperation)) before_slugs = await session.scalar(select(func.count()).select_from(RepositorySlug)) before_repo = await session.get(ManagedRepo, uuid.UUID(repo["id"])) before = (before_operations, before_slugs, before_repo.slug, before_repo.updated_at) report = await _preflight(client, repo["id"]) assert report["safe_to_apply"] is True assert report["preflight_token"] assert report["current"]["statehub"]["repo_id"] == repo["id"] assert report["current"]["forge"]["repository_id"] == 417 assert report["retained_history"] async with factory() as session: after_operations = await session.scalar(select(func.count()).select_from(RepositoryRenameOperation)) after_slugs = await session.scalar(select(func.count()).select_from(RepositorySlug)) after_repo = await session.get(ManagedRepo, uuid.UUID(repo["id"])) after = (after_operations, after_slugs, after_repo.slug, after_repo.updated_at) assert after == before @pytest.mark.asyncio async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup): repo, forge = rename_setup workplan = await create_test_workplan( client, repo_id=repo["id"], slug="FLEX-WP-0001", status="active" ) task = await client.post( "/tasks/", json={ "workplan_id": workplan["id"], "title": "Security migration", "status": "todo", "priority": "high", }, ) assert task.status_code == 201, task.text progress = await client.post( "/progress/", json={"workplan_id": workplan["id"], "task_id": task.json()["id"], "summary": "baseline", "event_type": "note"}, ) assert progress.status_code == 201, progress.text preflight = await _preflight(client, repo["id"]) operation, confirmation = await _operation(client, repo["id"], preflight) operation_id = operation["id"] discover = await client.get( f"/repos/{repo['id']}/rename/operations", params={"active_only": True} ) assert discover.status_code == 200 assert [item["id"] for item in discover.json()] == [operation_id] # Simulate the hardest interruption: Forge committed the rename but the # State Hub request died before recording forge-renamed. await forge.rename( instance="https://forge.example", owner="coulomb", old_name="flex-auth", new_name="access-engine", ) phase = await _phase( client, repo["id"], operation_id, "forge-renamed", "preflighted", confirmation ) assert phase["phase"] == "forge-renamed" assert phase["evidence"]["phases"]["forge-renamed"]["resumed"] is True assert forge.rename_calls == 1 sequence = [ ("statehub-rebound", "forge-renamed", {}), ("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}), ("consumers-verified", "source-synced", {"checks": {"routes": True, "security-stack": True}}), ("completed", "consumers-verified", {}), ] for requested, expected, extra in sequence: read = await client.get(f"/repos/{repo['id']}/rename/operations/{operation_id}") assert read.status_code == 200 assert read.json()["phase"] == expected phase = await _phase( client, repo["id"], operation_id, requested, expected, confirmation, **extra ) assert phase["phase"] == requested if requested == "source-synced": appended = await client.post( "/progress/", json={ "workplan_id": workplan["id"], "task_id": task.json()["id"], "summary": "rename phase completed", "event_type": "note", }, ) assert appended.status_code == 201, appended.text replay = await _phase( client, repo["id"], operation_id, requested, expected, confirmation, **extra ) assert replay["id"] == operation_id assert replay["no_op"] is True verification = await client.get( f"/repos/{repo['id']}/rename/operations/{operation_id}/verify" ) assert verification.status_code == 200, verification.text assert verification.json()["ok"] is True discover = await client.get( f"/repos/{repo['id']}/rename/operations", params={"active_only": True} ) assert discover.json() == [] current = await client.get(f"/repos/by-id/{repo['id']}") assert current.status_code == 200, current.text assert current.json()["id"] == repo["id"] assert current.json()["slug"] == "access-engine" @pytest.mark.asyncio async def test_stale_head_wrong_id_target_conflict_and_queued_writes_fail_closed(client, rename_setup): repo, forge = rename_setup queued = await client.post( f"/repos/{repo['id']}/rename/preflight", json={ "new_slug": "access-engine", "queued_edge_writes": [{"id": "edge-1", "source_host": "laptop"}], }, ) assert queued.status_code == 200 assert queued.json()["safe_to_apply"] is False assert queued.json()["preflight_token"] is None forge.extra["access-engine"] = 999 conflict = await _preflight(client, repo["id"]) assert conflict["safe_to_apply"] is False assert {item["code"] for item in conflict["blockers"]} >= {"forge_target_claimed"} del forge.extra["access-engine"] forge.repository_id = 999 wrong_id = await _preflight(client, repo["id"]) assert wrong_id["safe_to_apply"] is False assert {item["code"] for item in wrong_id["blockers"]} >= {"wrong_forge_repository_id"} forge.repository_id = 417 preflight = await _preflight(client, repo["id"]) forge.head = "b" * 40 create = await client.post( f"/repos/{repo['id']}/rename/operations", json={ "new_slug": "access-engine", "preflight_token": preflight["preflight_token"], "confirmation": f"rename:{repo['id']}:flex-auth:access-engine", "actor": "pytest", }, ) assert create.status_code == 412, create.text assert "stale" in create.json()["detail"]["message"].lower() @pytest.mark.asyncio async def test_expired_token_bad_confirmation_and_unreadable_forge_fail_closed( client, rename_setup, monkeypatch ): repo, forge = rename_setup monkeypatch.setattr(settings, "repository_rename_preflight_ttl_seconds", -1) expired = await _preflight(client, repo["id"]) response = await client.post( f"/repos/{repo['id']}/rename/operations", json={ "new_slug": "access-engine", "preflight_token": expired["preflight_token"], "confirmation": f"rename:{repo['id']}:flex-auth:access-engine", "actor": "pytest", }, ) assert response.status_code == 412 assert "expired" in response.json()["detail"]["message"].lower() monkeypatch.setattr(settings, "repository_rename_preflight_ttl_seconds", 900) preflight = await _preflight(client, repo["id"]) response = await client.post( f"/repos/{repo['id']}/rename/operations", json={ "new_slug": "access-engine", "preflight_token": preflight["preflight_token"], "confirmation": "yes", "actor": "pytest", }, ) assert response.status_code == 412 forge.unreadable = True unreadable = await _preflight(client, repo["id"]) assert unreadable["safe_to_apply"] is False assert {item["code"] for item in unreadable["blockers"]} >= {"forge_unreadable"} @pytest.mark.asyncio async def test_rollback_restores_old_canonical_and_remains_auditable(client, rename_setup): repo, forge = rename_setup preflight = await _preflight(client, repo["id"]) operation, confirmation = await _operation(client, repo["id"], preflight) operation_id = operation["id"] for requested, expected, extra in [ ("forge-renamed", "preflighted", {}), ("statehub-rebound", "forge-renamed", {}), ("source-synced", "statehub-rebound", {"evidence": {"clone": "fresh"}}), ]: await _phase(client, repo["id"], operation_id, requested, expected, confirmation, **extra) rollback_confirmation = f"rollback:{operation_id}" report = await client.post( f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback-preflight", json={"expected_phase": "source-synced", "confirmation": rollback_confirmation}, ) assert report.status_code == 200, report.text assert report.json()["safe_to_rollback"] is True assert report.json()["irreversible"] rollback = await client.post( f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback", json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation}, ) assert rollback.status_code == 200, rollback.text assert rollback.json()["phase"] == "rolled-back" assert forge.name == "flex-auth" current = await client.get(f"/repos/by-id/{repo['id']}") assert current.json()["id"] == repo["id"] assert current.json()["slug"] == "flex-auth" history = rollback.json()["evidence"]["phases"] assert set(history) >= {"preflighted", "forge-renamed", "statehub-rebound", "source-synced", "rollback-preflight", "rolled-back"} replay = await client.post( f"/repos/{repo['id']}/rename/operations/{operation_id}/rollback", json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation}, ) assert replay.status_code == 200 assert replay.json()["no_op"] is True @pytest.mark.asyncio async def test_rollback_recovers_unrecorded_forge_rename(client, rename_setup): repo, forge = rename_setup preflight = await _preflight(client, repo["id"]) operation, _confirmation = await _operation(client, repo["id"], preflight) await forge.rename( instance="https://forge.example", owner="coulomb", old_name="flex-auth", new_name="access-engine", ) rollback_confirmation = f"rollback:{operation['id']}" report = await client.post( f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback-preflight", json={"expected_phase": "preflighted", "confirmation": rollback_confirmation}, ) assert report.status_code == 200, report.text assert report.json()["rollback_from_phase"] == "forge-renamed-unrecorded" rollback = await client.post( f"/repos/{repo['id']}/rename/operations/{operation['id']}/rollback", json={"expected_phase": "rollback-preflight", "confirmation": rollback_confirmation}, ) assert rollback.status_code == 200, rollback.text assert rollback.json()["phase"] == "rolled-back" assert forge.name == "flex-auth"