from __future__ import annotations import shutil from datetime import timedelta from pathlib import Path import pytest from app.accounts.repository import AccountRepository from app.accounts.service import AccountService from app.core.errors import AppError from app.core.security import SecretCipher from app.core.time import to_iso, utc_now from app.database.database import Database from app.fleet.repository import FleetRepository from app.integrations.repository import IntegrationRepository from app.rotation.repository import RotationRepository MIGRATIONS_PATH = Path(__file__).resolve().parents[1] / "migrations" @pytest.fixture def database(tmp_path: Path) -> Database: result = Database(tmp_path / "accounts.db", MIGRATIONS_PATH) result.migrate() return result @pytest.fixture def repository(database: Database) -> AccountRepository: return AccountRepository(database, SecretCipher(b"a" * 32)) def create_aws_account( repository: AccountRepository, *, name: str = "AWS Production", access_key: str = "AKIA_REPOSITORY_ACCESS", secret_key: str = "repository-secret-key", session_token: str | None = "repository-session-token", ): secrets = { "aws_access_key_id": access_key, "aws_secret_access_key": secret_key, } if session_token is not None: secrets["aws_session_token"] = session_token return repository.create_account( provider="aws", name=name, use_default_aws_credentials=False, secret_values=secrets, ) def test_repository_crud_resolution_flags_and_encrypted_storage( database: Database, repository: AccountRepository, ) -> None: aws = create_aws_account(repository) default_aws = repository.create_account( provider="aws", name="AWS Instance Role", use_default_aws_credentials=True, secret_values={}, ) cloudflare = repository.create_account( provider="cloudflare", name="Cloudflare Production", use_default_aws_credentials=None, secret_values={"cloudflare_api_token": "cf-repository-token"}, ) assert repository.get_account(aws.id) == aws assert {item.id for item in repository.list_accounts()} == { aws.id, default_aws.id, cloudflare.id, } assert [item.id for item in repository.list_accounts("cloudflare")] == [cloudflare.id] assert repository.secret_flags(aws.id) == { "aws_access_key_id": True, "aws_secret_access_key": True, "aws_session_token": True, "cloudflare_api_token": False, } assert repository.secret_flags(default_aws.id) == { "aws_access_key_id": False, "aws_secret_access_key": False, "aws_session_token": False, "cloudflare_api_token": False, } assert repository.secret_flags(cloudflare.id)["cloudflare_api_token"] is True use_default, aws_secrets = repository.resolve_aws(aws.id) assert use_default is False assert aws_secrets == { "aws_access_key_id": "AKIA_REPOSITORY_ACCESS", "aws_secret_access_key": "repository-secret-key", "aws_session_token": "repository-session-token", } assert repository.resolve_aws(default_aws.id) == ( True, { "aws_access_key_id": None, "aws_secret_access_key": None, "aws_session_token": None, }, ) assert repository.resolve_cloudflare(cloudflare.id) == "cf-repository-token" plaintexts = ( "AKIA_REPOSITORY_ACCESS", "repository-secret-key", "repository-session-token", "cf-repository-token", ) with database.connect() as connection: rows = connection.execute( "SELECT account_id, name, nonce, ciphertext FROM account_secrets" ).fetchall() stored = " ".join(str(value) for row in rows for value in tuple(row)) assert all(secret not in stored for secret in plaintexts) def test_account_secret_ciphertext_is_bound_to_account_aad( database: Database, repository: AccountRepository, ) -> None: first = create_aws_account(repository, name="AWS First", session_token=None) second = create_aws_account(repository, name="AWS Second", session_token=None) with database.connect() as connection, connection: encrypted = connection.execute( """ SELECT key_version, nonce, ciphertext FROM account_secrets WHERE account_id = ? AND name = 'aws_access_key_id' """, (first.id,), ).fetchone() assert encrypted is not None connection.execute( """ UPDATE account_secrets SET key_version = ?, nonce = ?, ciphertext = ? WHERE account_id = ? AND name = 'aws_access_key_id' """, ( encrypted["key_version"], encrypted["nonce"], encrypted["ciphertext"], second.id, ), ) with pytest.raises(AppError) as exc_info: repository.resolve_aws(second.id) assert exc_info.value.code == "SECRET_DECRYPTION_FAILED" def test_repository_update_cas_replaces_pair_clears_session_and_soft_deletes( repository: AccountRepository, ) -> None: account = create_aws_account(repository) updated = repository.update_account( account.id, expected_version=account.config_version, values={"name": "AWS Renamed"}, secret_values={ "aws_access_key_id": "AKIA_REPLACED_ACCESS", "aws_secret_access_key": "replaced-secret-key", }, delete_secret_names=("aws_session_token",), ) assert updated.name == "AWS Renamed" assert updated.config_version == account.config_version + 1 assert repository.resolve_aws(account.id) == ( False, { "aws_access_key_id": "AKIA_REPLACED_ACCESS", "aws_secret_access_key": "replaced-secret-key", "aws_session_token": None, }, ) with pytest.raises(RuntimeError, match="ACCOUNT_CONFIG_VERSION_CONFLICT"): repository.update_account( account.id, expected_version=account.config_version, values={"name": "Stale rename"}, secret_values={}, ) archived = repository.archive_account(account.id) assert archived.archived_at is not None assert archived.config_version == updated.config_version + 1 assert repository.get_account(account.id) is None assert repository.list_accounts() == [] assert repository.get_account(account.id, include_archived=True) == archived assert not any(repository.secret_flags(account.id).values()) def test_provider_validation_and_active_instance_prevent_archive( repository: AccountRepository, database: Database, ) -> None: aws = create_aws_account(repository) cloudflare = repository.create_account( provider="cloudflare", name="Cloudflare Bound", use_default_aws_credentials=None, secret_values={"cloudflare_api_token": "cf-bound-token"}, ) with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"): repository.resolve_cloudflare(aws.id) with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"): repository.resolve_aws(cloudflare.id) with pytest.raises(RuntimeError, match="ACCOUNT_PROVIDER_MISMATCH"): repository.create_account( provider="aws", name="Wrong secret type", use_default_aws_credentials=False, secret_values={"cloudflare_api_token": "wrong-provider-token"}, ) instance = FleetRepository(database).create_instance( { "id": "account-bound-instance", "aws_account_id": aws.id, "cloudflare_account_id": cloudflare.id, "display_name": "Account bound proxy", "aws_region": "ap-southeast-1", "lightsail_instance_name": "account-bound-node", "cloudflare_zone_name": "example.com", "cloudflare_zone_id": "zone-account-bound", "cloudflare_record_name": "account-bound.example.com", "socks_port": 1080, "proxy_health_check": True, "health_timeout_seconds": 120, "release_grace_seconds": 75, "enabled": True, } ) with pytest.raises(RuntimeError, match="ACCOUNT_IN_USE"): repository.archive_account(aws.id) with pytest.raises(RuntimeError, match="ACCOUNT_IN_USE"): repository.archive_account(cloudflare.id) FleetRepository(database).archive_instance(instance.id) assert repository.archive_account(aws.id).archived_at is not None assert repository.archive_account(cloudflare.id).archived_at is not None def test_active_fleet_run_and_dns_lock_block_account_writes( database: Database, repository: AccountRepository, ) -> None: account = create_aws_account(repository) now = to_iso() with database.connect() as connection, connection: connection.execute( """ INSERT INTO fleet_runs( id, target_type, target_id, target_name, trigger, status, active_slot, total_items, succeeded_items, started_at, updated_at ) VALUES ( 'account-lock-run', 'instance', 'target', 'Target', 'manual', 'running', 1, 0, 0, ?, ? ) """, (now, now), ) blocked_by_run = ( lambda: create_aws_account(repository, name="Blocked create"), lambda: repository.update_account( account.id, expected_version=account.config_version, values={"name": "Blocked update"}, secret_values={}, ), lambda: repository.archive_account(account.id), ) for write in blocked_by_run: with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): write() with database.connect() as connection, connection: connection.execute("DELETE FROM fleet_runs WHERE id = 'account-lock-run'") connection.execute( """ INSERT INTO fleet_operation_locks( slot, kind, owner_id, lease_until, created_at ) VALUES (1, 'dns_sync', 'account-dns-lock', ?, ?) """, (to_iso(utc_now() + timedelta(minutes=5)), now), ) with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): repository.update_account( account.id, expected_version=account.config_version, values={"name": "Still blocked"}, secret_values={}, ) with database.connect() as connection, connection: connection.execute( "UPDATE fleet_operation_locks SET lease_until = ? WHERE owner_id = ?", (to_iso(utc_now() - timedelta(seconds=1)), "account-dns-lock"), ) created = create_aws_account(repository, name="Created after lock expiry") assert created.name == "Created after lock expiry" with database.connect() as connection: assert ( connection.execute( "SELECT 1 FROM fleet_operation_locks WHERE owner_id = 'account-dns-lock'" ).fetchone() is None ) def test_legacy_global_credentials_are_imported_and_bound_idempotently( tmp_path: Path, ) -> None: staged_migrations = tmp_path / "migrations" staged_migrations.mkdir() for name in ( "001_initial.sql", "002_operation_locks.sql", "003_multi_instance_static_ip.sql", "004_rotation_rollback.sql", ): shutil.copy2(MIGRATIONS_PATH / name, staged_migrations) database = Database(tmp_path / "legacy-accounts.db", staged_migrations) database.migrate() cipher = SecretCipher(b"l" * 32) integrations = IntegrationRepository(database, cipher) integrations.save( False, { "aws_access_key_id": "AKIA_LEGACY_IMPORT", "aws_secret_access_key": "legacy-import-secret", "aws_session_token": "legacy-import-session", "cloudflare_api_token": "legacy-import-cloudflare", }, ) now = to_iso() with database.connect() as connection, connection: connection.execute( """ INSERT INTO managed_instances( id, display_name, aws_region, lightsail_instance_name, cloudflare_zone_name, cloudflare_zone_id, cloudflare_record_name, socks_port, proxy_health_check, health_timeout_seconds, release_grace_seconds, enabled, config_version, created_at, updated_at ) VALUES ( 'legacy-account-instance', 'Legacy account instance', 'us-east-1', 'legacy-account-node', 'example.com', 'legacy-zone', 'legacy-account.example.com', 1080, 1, 120, 75, 1, 1, ?, ? ) """, (now, now), ) shutil.copy2(MIGRATIONS_PATH / "005_credential_accounts.sql", staged_migrations) database.migrate() accounts = AccountRepository(database, cipher) service = AccountService(accounts) first = service.import_legacy_accounts(integrations) second = service.import_legacy_accounts(integrations) assert ( first == second == { "aws": "legacy-aws", "cloudflare": "legacy-cloudflare", } ) assert accounts.resolve_aws("legacy-aws") == ( False, { "aws_access_key_id": "AKIA_LEGACY_IMPORT", "aws_secret_access_key": "legacy-import-secret", "aws_session_token": "legacy-import-session", }, ) assert accounts.resolve_cloudflare("legacy-cloudflare") == "legacy-import-cloudflare" with database.connect() as connection: instance = connection.execute( """ SELECT aws_account_id, cloudflare_account_id FROM managed_instances WHERE id = 'legacy-account-instance' """ ).fetchone() counts = connection.execute( """ SELECT (SELECT COUNT(*) FROM credential_accounts) AS accounts, (SELECT COUNT(*) FROM account_secrets) AS secrets """ ).fetchone() assert dict(instance) == { "aws_account_id": "legacy-aws", "cloudflare_account_id": "legacy-cloudflare", } assert dict(counts) == {"accounts": 2, "secrets": 4} def test_attention_run_allows_only_current_account_secret_recovery( database: Database, repository: AccountRepository, ) -> None: aws = create_aws_account(repository, name="AWS Recovery") other_aws = create_aws_account(repository, name="AWS Other") cloudflare = repository.create_account( provider="cloudflare", name="Cloudflare Recovery", use_default_aws_credentials=None, secret_values={"cloudflare_api_token": "cf-recovery-token"}, ) instance = FleetRepository(database).create_instance( { "id": "recovery-account-instance", "aws_account_id": aws.id, "cloudflare_account_id": cloudflare.id, "display_name": "Recovery account instance", "aws_region": "us-east-1", "lightsail_instance_name": "recovery-account-node", "cloudflare_zone_name": "example.com", "cloudflare_zone_id": "recovery-zone", "cloudflare_record_name": "recovery-account.example.com", "socks_port": 1080, "proxy_health_check": True, "health_timeout_seconds": 120, "release_grace_seconds": 75, "enabled": True, } ) run = RotationRepository(database).create_for_instance(instance.id) with database.connect() as connection, connection: connection.execute( "UPDATE fleet_runs SET status = 'needs_attention' WHERE id = ?", (run.id,), ) connection.execute( """ UPDATE fleet_run_items SET status = 'needs_attention' WHERE run_id = ? """, (run.id,), ) recovered = repository.update_account( aws.id, expected_version=aws.config_version, values={ "name": aws.name, "use_default_aws_credentials": False, }, secret_values={ "aws_access_key_id": "AKIA_RECOVERED_ACCESS", "aws_secret_access_key": "recovered-secret-key", }, delete_secret_names=("aws_session_token",), ) assert recovered.config_version == aws.config_version + 1 assert repository.resolve_aws(aws.id)[1] == { "aws_access_key_id": "AKIA_RECOVERED_ACCESS", "aws_secret_access_key": "recovered-secret-key", "aws_session_token": None, } with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): repository.update_account( other_aws.id, expected_version=other_aws.config_version, values={}, secret_values={ "aws_access_key_id": "AKIA_OTHER_REPLACED", "aws_secret_access_key": "other-replaced-secret", }, ) with pytest.raises(RuntimeError, match="FLEET_RUN_ACTIVE"): repository.update_account( aws.id, expected_version=recovered.config_version, values={"name": "Renamed during recovery"}, secret_values={"aws_session_token": "new-session-token"}, ) with database.connect() as connection: event = connection.execute( """ SELECT message, details_json FROM fleet_events WHERE run_id = ? AND stage = 'credentials' """, (run.id,), ).fetchone() assert event["message"] == "管理员已更新当前账号的恢复凭据" assert "recovered-secret-key" not in event["details_json"] assert "aws_secret_access_key" in event["details_json"]