import os import stat from pathlib import Path import pytest from backend.app.services.credential_crypto import ( CredentialCrypto, InvalidCredentialBlob, ) pytestmark = pytest.mark.integration def test_load_or_create_writes_key_with_mode_0600(tmp_path): key_path = tmp_path / "secrets" / "credential_key.b64" CredentialCrypto(key_path, bootstrap_ok=True) assert key_path.exists() mode = stat.S_IMODE(os.stat(key_path).st_mode) assert mode == 0o600 # parent dir is 0o700 parent_mode = stat.S_IMODE(os.stat(key_path.parent).st_mode) assert parent_mode == 0o700 def test_load_existing_key_is_idempotent(tmp_path): key_path = tmp_path / "credential_key.b64" crypto1 = CredentialCrypto(key_path, bootstrap_ok=True) contents_after_first = key_path.read_bytes() crypto2 = CredentialCrypto(key_path, bootstrap_ok=True) contents_after_second = key_path.read_bytes() assert contents_after_first == contents_after_second # And both crypto instances decrypt each other's ciphertext ct = crypto1.encrypt("hello") assert crypto2.decrypt(ct) == "hello" def test_encrypt_decrypt_round_trip(tmp_path): crypto = CredentialCrypto(tmp_path / "k", bootstrap_ok=True) plaintext = "domain.com\tTRUE\t/\tTRUE\t1700000000\tname\tvalue" ct = crypto.encrypt(plaintext) assert isinstance(ct, bytes) assert ct != plaintext.encode() assert crypto.decrypt(ct) == plaintext def test_decrypt_with_wrong_key_raises(tmp_path): crypto_a = CredentialCrypto(tmp_path / "a", bootstrap_ok=True) crypto_b = CredentialCrypto(tmp_path / "b", bootstrap_ok=True) ct = crypto_a.encrypt("secret") with pytest.raises(InvalidCredentialBlob): crypto_b.decrypt(ct) def test_missing_key_without_bootstrap_raises(tmp_path, monkeypatch): """Audit 2026-06-02: without explicit opt-in, a missing key file is a fatal startup error — silent regeneration on partial restore would make every existing Credential row undecryptable.""" from backend.app.services.credential_crypto import MissingCredentialKey monkeypatch.delenv("CURATOR_BOOTSTRAP_NEW_KEY", raising=False) with pytest.raises(MissingCredentialKey): CredentialCrypto(tmp_path / "absent.b64") def test_missing_key_with_env_var_bootstraps(tmp_path, monkeypatch): """The env var CURATOR_BOOTSTRAP_NEW_KEY=1 is the operator's first-time-setup opt-in for auto-creating the key file.""" monkeypatch.setenv("CURATOR_BOOTSTRAP_NEW_KEY", "1") key_path = tmp_path / "bootstrap.b64" CredentialCrypto(key_path) # no bootstrap_ok kwarg — relies on env assert key_path.exists()