from __future__ import annotations import importlib import uuid from datetime import datetime, timedelta, timezone import pytest from alembic.migration import MigrationContext from alembic.operations import Operations from sqlalchemy import inspect, select, text from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from api.models.decision import Decision from api.models.domain import Domain from api.models.managed_repo import ManagedRepo from api.models.progress_event import ProgressEvent from api.models.repository_rename import ( RepositoryForgeIdentity, RepositoryRenameOperation, RepositorySlug, ) from api.models.sbom_snapshot import SBOMSnapshot from api.models.task import Task from api.models.token_event import TokenEvent from api.models.workplan import Workplan from tests.conftest import create_test_domain, create_test_repo def _operation( *, repo_id: uuid.UUID, identity_id: uuid.UUID, old_slug: str, new_slug: str, forge_repository_id: int, phase: str = "draft", ) -> RepositoryRenameOperation: now = datetime.now(timezone.utc) return RepositoryRenameOperation( repo_id=repo_id, forge_identity_id=identity_id, forge_identity_state="verified", expected_provider="forgejo", expected_forge_instance="https://forgejo.coulomb.social", expected_forge_owner="coulomb", expected_forge_repository_id=forge_repository_id, expected_source_commit="a" * 40, expected_default_branch="main", old_slug=old_slug, new_slug=new_slug, old_coordinates={"slug": old_slug}, new_coordinates={"slug": new_slug}, phase=phase, actor="test", phase_changed_at=now, preflighted_at=now if phase != "draft" else None, preflight_expires_at=(now + timedelta(minutes=30)) if phase != "draft" else None, ) @pytest.mark.asyncio async def test_repo_registration_creates_unverified_identity_and_canonical_slug( client, test_engine ): await create_test_domain(client) repo = await create_test_repo(client, slug="identity-test") factory = async_sessionmaker( test_engine, class_=AsyncSession, expire_on_commit=False ) async with factory() as session: identity = await session.scalar( select(RepositoryForgeIdentity).where( RepositoryForgeIdentity.repo_id == uuid.UUID(repo["id"]) ) ) slug = await session.scalar( select(RepositorySlug).where( RepositorySlug.repo_id == uuid.UUID(repo["id"]) ) ) assert identity is not None assert identity.verification_state == "unverified" assert identity.provider is None assert identity.forge_instance is None assert identity.forge_owner is None assert identity.forge_repository_id is None assert slug is not None assert (slug.slug, slug.kind, slug.protected) == ( "identity-test", "canonical", True, ) @pytest.mark.asyncio async def test_protected_alias_cannot_be_claimed_by_another_repository( client, test_engine ): await create_test_domain(client) source = await create_test_repo(client, slug="access-engine") other = await create_test_repo(client, slug="another-repo") factory = async_sessionmaker( test_engine, class_=AsyncSession, expire_on_commit=False ) async with factory() as session: session.add( RepositorySlug( repo_id=uuid.UUID(source["id"]), slug="flex-auth", kind="alias", protected=True, ) ) await session.commit() response = await client.post( "/repos/", json={ "domain_slug": "infotech", "slug": "flex-auth", "name": "Conflicting repository", }, ) assert response.status_code == 409 async with factory() as session: session.add( RepositorySlug( repo_id=uuid.UUID(other["id"]), slug="flex-auth", kind="alias", protected=True, ) ) with pytest.raises(IntegrityError): await session.commit() async with factory() as session: session.add( RepositorySlug( repo_id=uuid.UUID(source["id"]), slug="second-canonical", kind="canonical", protected=True, ) ) with pytest.raises(IntegrityError): await session.commit() @pytest.mark.asyncio async def test_forge_identity_is_unique_and_unverified_repo_cannot_open_operation( client, test_engine ): await create_test_domain(client) first = await create_test_repo(client, slug="first-repo") second = await create_test_repo(client, slug="second-repo") factory = async_sessionmaker( test_engine, class_=AsyncSession, expire_on_commit=False ) now = datetime.now(timezone.utc) async with factory() as session: first_identity = await session.scalar( select(RepositoryForgeIdentity).where( RepositoryForgeIdentity.repo_id == uuid.UUID(first["id"]) ) ) assert first_identity is not None first_identity.provider = "forgejo" first_identity.forge_instance = "https://forgejo.coulomb.social" first_identity.forge_owner = "coulomb" first_identity.forge_repository_id = 42 first_identity.verification_state = "verified" first_identity.verified_at = now first_identity.verified_by = "test" first_identity.verification_evidence = {"source": "forge-api"} await session.commit() async with factory() as session: second_identity = await session.scalar( select(RepositoryForgeIdentity).where( RepositoryForgeIdentity.repo_id == uuid.UUID(second["id"]) ) ) assert second_identity is not None session.add( _operation( repo_id=uuid.UUID(second["id"]), identity_id=second_identity.id, old_slug="second-repo", new_slug="second-repo-renamed", forge_repository_id=43, ) ) with pytest.raises(IntegrityError): await session.commit() async with factory() as session: second_identity = await session.scalar( select(RepositoryForgeIdentity).where( RepositoryForgeIdentity.repo_id == uuid.UUID(second["id"]) ) ) assert second_identity is not None second_identity.provider = "forgejo" second_identity.forge_instance = "https://forgejo.coulomb.social" second_identity.forge_owner = "coulomb" second_identity.forge_repository_id = 42 second_identity.verification_state = "verified" second_identity.verified_at = now second_identity.verified_by = "test" with pytest.raises(IntegrityError): await session.commit() @pytest.mark.asyncio async def test_only_one_active_operation_can_hold_a_repo_or_target_slug( client, test_engine ): await create_test_domain(client) first = await create_test_repo(client, slug="first-active") second = await create_test_repo(client, slug="second-active") factory = async_sessionmaker( test_engine, class_=AsyncSession, expire_on_commit=False ) now = datetime.now(timezone.utc) identities: dict[str, RepositoryForgeIdentity] = {} async with factory() as session: for repo, forge_repository_id in ((first, 101), (second, 102)): identity = await session.scalar( select(RepositoryForgeIdentity).where( RepositoryForgeIdentity.repo_id == uuid.UUID(repo["id"]) ) ) assert identity is not None identity.provider = "forgejo" identity.forge_instance = "https://forgejo.coulomb.social" identity.forge_owner = "coulomb" identity.forge_repository_id = forge_repository_id identity.verification_state = "verified" identity.verified_at = now identity.verified_by = "test" identities[repo["slug"]] = identity await session.commit() async with factory() as session: session.add( _operation( repo_id=uuid.UUID(first["id"]), identity_id=identities["first-active"].id, old_slug="first-active", new_slug="shared-target", forge_repository_id=101, ) ) await session.commit() async with factory() as session: session.add( _operation( repo_id=uuid.UUID(first["id"]), identity_id=identities["first-active"].id, old_slug="first-active", new_slug="another-target", forge_repository_id=101, ) ) with pytest.raises(IntegrityError): await session.commit() async with factory() as session: session.add( _operation( repo_id=uuid.UUID(second["id"]), identity_id=identities["second-active"].id, old_slug="second-active", new_slug="shared-target", forge_repository_id=102, ) ) with pytest.raises(IntegrityError): await session.commit() @pytest.mark.asyncio async def test_flex_auth_preflight_representation_preserves_all_existing_ids(test_engine): factory = async_sessionmaker( test_engine, class_=AsyncSession, expire_on_commit=False ) repo_id = uuid.UUID("fda8ad85-a7d7-4055-8f21-902a533e59df") domain_id = uuid.uuid4() workplan_id = uuid.uuid4() task_id = uuid.uuid4() progress_id = uuid.uuid4() decision_id = uuid.uuid4() token_id = uuid.uuid4() sbom_id = uuid.uuid4() identity_id = uuid.uuid4() now = datetime.now(timezone.utc) async with factory() as session: session.add_all( [ Domain(id=domain_id, slug="net-kingdom", name="Net Kingdom"), ManagedRepo( id=repo_id, domain_id=domain_id, slug="flex-auth", name="Flex Auth", ), ] ) await session.flush() session.add_all( [ RepositoryForgeIdentity( id=identity_id, repo_id=repo_id, provider="forgejo", forge_instance="https://forgejo.coulomb.social", forge_owner="coulomb", forge_repository_id=42, verification_state="verified", verified_at=now, verified_by="test", verification_evidence={"source": "forge-api", "repository_id": 42}, ), RepositorySlug( repo_id=repo_id, slug="flex-auth", kind="canonical", protected=True, ), Workplan( id=workplan_id, repo_id=repo_id, slug="flex-wp-0001", title="Existing work", ), TokenEvent( id=token_id, repo_id=repo_id, tokens_in=10, tokens_out=5, ), SBOMSnapshot( id=sbom_id, repo_id=repo_id, snapshot_at=now, entry_count=0, created_at=now, ), ] ) await session.flush() session.add_all( [ Task( id=task_id, workplan_id=workplan_id, record_id="FLEX-WP-0001-T01", title="Existing task", ), Decision( id=decision_id, workplan_id=workplan_id, title="Existing decision", ), ] ) await session.flush() session.add( ProgressEvent( id=progress_id, workplan_id=workplan_id, task_id=task_id, decision_id=decision_id, event_type="note", summary="Existing evidence", ) ) session.add( _operation( repo_id=repo_id, identity_id=identity_id, old_slug="flex-auth", new_slug="access-engine", forge_repository_id=42, phase="preflighted", ) ) await session.flush() assert await session.scalar( select(ManagedRepo.slug).where(ManagedRepo.id == repo_id) ) == "flex-auth" assert await session.scalar( select(RepositoryRenameOperation.expected_forge_repository_id).where( RepositoryRenameOperation.repo_id == repo_id ) ) == 42 assert await session.scalar(select(Workplan.id)) == workplan_id assert await session.scalar(select(Task.id)) == task_id assert await session.scalar(select(ProgressEvent.id)) == progress_id assert await session.scalar(select(Decision.id)) == decision_id assert await session.scalar(select(TokenEvent.id)) == token_id assert await session.scalar(select(SBOMSnapshot.id)) == sbom_id # No commit: this proves representability without touching either live # State Hub or Forgejo and leaves the test database unchanged. await session.rollback() @pytest.mark.asyncio async def test_migration_upgrade_and_downgrade_are_additive(test_engine): migration = importlib.import_module( "migrations.versions.f3c4d5e6a7b8_repository_rename_identity" ) rollback_migration = importlib.import_module( "migrations.versions.a4d5e6f7b8c9_allow_guarded_slug_rollback" ) schema = f"rename_migration_{uuid.uuid4().hex}" repo_ids = [uuid.uuid4(), uuid.uuid4()] sentinel_ids = { "workplans": uuid.uuid4(), "tasks": uuid.uuid4(), "progress_events": uuid.uuid4(), "decisions": uuid.uuid4(), "token_events": uuid.uuid4(), "sbom_snapshots": uuid.uuid4(), } async with test_engine.begin() as connection: def run(sync_connection): quoted_schema = sync_connection.dialect.identifier_preparer.quote(schema) sync_connection.exec_driver_sql(f"CREATE SCHEMA {quoted_schema}") sync_connection.exec_driver_sql( f"SET LOCAL search_path TO {quoted_schema}, public" ) sync_connection.exec_driver_sql( "CREATE TABLE managed_repos " "(id uuid PRIMARY KEY, slug varchar(100) NOT NULL UNIQUE, name text)" ) for table_name in sentinel_ids: sync_connection.exec_driver_sql( f"CREATE TABLE {table_name} (id uuid PRIMARY KEY, marker text)" ) sync_connection.execute( text( "INSERT INTO managed_repos (id, slug, name) " "VALUES (:first, 'flex-auth', 'Flex Auth'), " "(:second, 'state-hub', 'State Hub')" ), {"first": repo_ids[0], "second": repo_ids[1]}, ) for table_name, sentinel_id in sentinel_ids.items(): sync_connection.execute( text(f"INSERT INTO {table_name} (id, marker) VALUES (:id, 'keep')"), {"id": sentinel_id}, ) original_op = migration.op original_rollback_op = rollback_migration.op migration.op = Operations(MigrationContext.configure(sync_connection)) rollback_migration.op = migration.op try: migration.upgrade() rollback_migration.upgrade() tables = set(inspect(sync_connection).get_table_names(schema=schema)) assert { "repository_forge_identities", "repository_rename_operations", "repository_slugs", }.issubset(tables) identity_rows = sync_connection.execute( text( "SELECT repo_id, verification_state, provider, forge_repository_id " "FROM repository_forge_identities ORDER BY repo_id" ) ).all() assert len(identity_rows) == 2 assert all( row.verification_state == "unverified" and row.provider is None and row.forge_repository_id is None for row in identity_rows ) slug_rows = sync_connection.execute( text( "SELECT repo_id, slug, kind, protected " "FROM repository_slugs ORDER BY slug" ) ).all() assert {(row.slug, row.kind, row.protected) for row in slug_rows} == { ("flex-auth", "canonical", True), ("state-hub", "canonical", True), } identity_id = sync_connection.execute( text( "UPDATE repository_forge_identities SET " "provider = 'forgejo', " "forge_instance = 'https://forgejo.coulomb.social', " "forge_owner = 'coulomb', forge_repository_id = 42, " "verification_state = 'verified', verified_at = now(), " "verified_by = 'migration-test' " "WHERE repo_id = :repo_id RETURNING id" ), {"repo_id": repo_ids[0]}, ).scalar_one() with pytest.raises(DBAPIError): with sync_connection.begin_nested(): sync_connection.execute( text( "UPDATE repository_forge_identities " "SET forge_repository_id = 43 WHERE id = :id" ), {"id": identity_id}, ) with pytest.raises(DBAPIError): with sync_connection.begin_nested(): sync_connection.execute( text( "DELETE FROM repository_slugs " "WHERE repo_id = :repo_id" ), {"repo_id": repo_ids[0]}, ) operation_id = uuid.uuid4() sync_connection.execute( text( "INSERT INTO repository_rename_operations (" "id, repo_id, forge_identity_id, forge_identity_state, " "expected_provider, expected_forge_instance, " "expected_forge_owner, expected_forge_repository_id, " "expected_source_commit, expected_default_branch, " "old_slug, new_slug, old_coordinates, new_coordinates, " "phase, actor, phase_changed_at, evidence, created_at, updated_at" ") VALUES (" ":id, :repo_id, :identity_id, 'verified', 'forgejo', " "'https://forgejo.coulomb.social', 'coulomb', 42, " ":commit, 'main', 'flex-auth', 'access-engine', " "'{\"slug\": \"flex-auth\"}'::jsonb, " "'{\"slug\": \"access-engine\"}'::jsonb, " "'draft', 'migration-test', now(), '{}'::jsonb, now(), now()" ")" ), { "id": operation_id, "repo_id": repo_ids[0], "identity_id": identity_id, "commit": "a" * 40, }, ) sync_connection.execute( text( "UPDATE repository_rename_operations SET " "phase = 'preflighted', preflighted_at = now(), " "phase_changed_at = now() WHERE id = :id" ), {"id": operation_id}, ) with pytest.raises(DBAPIError): with sync_connection.begin_nested(): sync_connection.execute( text( "UPDATE repository_rename_operations " "SET old_slug = 'rewritten' WHERE id = :id" ), {"id": operation_id}, ) # A forward cutover may make the old canonical an alias. T03 # permits restoring it only while its own operation is in the # durable rollback-preflight phase. sync_connection.execute( text( "UPDATE repository_slugs SET kind = 'alias', " "source_operation_id = :operation_id " "WHERE repo_id = :repo_id AND slug = 'flex-auth'" ), {"operation_id": operation_id, "repo_id": repo_ids[0]}, ) sync_connection.execute( text( "INSERT INTO repository_slugs " "(id, repo_id, slug, kind, protected, source_operation_id, created_at, updated_at) " "VALUES (gen_random_uuid(), :repo_id, 'access-engine', 'canonical', true, " ":operation_id, now(), now())" ), {"operation_id": operation_id, "repo_id": repo_ids[0]}, ) sync_connection.execute( text( "UPDATE repository_slugs SET kind = 'alias' " "WHERE repo_id = :repo_id AND slug = 'access-engine'" ), {"repo_id": repo_ids[0]}, ) with pytest.raises(DBAPIError): with sync_connection.begin_nested(): sync_connection.execute( text( "UPDATE repository_slugs SET kind = 'canonical' " "WHERE repo_id = :repo_id AND slug = 'flex-auth'" ), {"repo_id": repo_ids[0]}, ) sync_connection.execute( text( "UPDATE repository_rename_operations SET " "phase = 'rollback-preflight', phase_changed_at = now() " "WHERE id = :id" ), {"id": operation_id}, ) sync_connection.execute( text( "UPDATE repository_slugs SET kind = 'canonical' " "WHERE repo_id = :repo_id AND slug = 'flex-auth'" ), {"repo_id": repo_ids[0]}, ) with pytest.raises(DBAPIError): with sync_connection.begin_nested(): sync_connection.execute( text( "DELETE FROM repository_rename_operations " "WHERE id = :id" ), {"id": operation_id}, ) assert sync_connection.execute( text("SELECT id, slug FROM managed_repos ORDER BY slug") ).all() == sorted( [(repo_ids[0], "flex-auth"), (repo_ids[1], "state-hub")], key=lambda item: item[1], ) for table_name, sentinel_id in sentinel_ids.items(): assert sync_connection.execute( text(f"SELECT id, marker FROM {table_name}") ).one() == (sentinel_id, "keep") rollback_migration.downgrade() migration.downgrade() tables = set(inspect(sync_connection).get_table_names(schema=schema)) assert "repository_forge_identities" not in tables assert "repository_rename_operations" not in tables assert "repository_slugs" not in tables for table_name, sentinel_id in sentinel_ids.items(): assert sync_connection.execute( text(f"SELECT id, marker FROM {table_name}") ).one() == (sentinel_id, "keep") finally: migration.op = original_op rollback_migration.op = original_rollback_op sync_connection.exec_driver_sql("SET LOCAL search_path TO public") sync_connection.exec_driver_sql(f"DROP SCHEMA {quoted_schema} CASCADE") await connection.run_sync(run)