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() # --- #3422: the install docs quote this module, so they must keep agreeing -- # # README.md and .env.example both print the literal failure a new installer # hits, and the literal path and env var they must act on. That is the right # call — a stranger greps for the string their terminal showed them — but it # means those two files now DEPEND on this module's wording, with nothing # connecting them. The characteristic defect of the install surface is exactly # this: the documented behaviour and the code drift apart, and the code is the # one that is right. # # Presence checks on both sides, deliberately: an absence check against prose # would pass for the wrong reason the moment a sentence were reworded # (snippet #3352). _REPO_ROOT = Path(__file__).resolve().parents[1] _INSTALL_DOCS = ("README.md", ".env.example") def test_the_bootstrap_refusal_still_reads_the_way_the_docs_quote_it(tmp_path, monkeypatch): """The three things a reader is told to look for, in the raised message.""" from backend.app.services.credential_crypto import ( _BOOTSTRAP_ENV_VAR, MissingCredentialKey, ) monkeypatch.delenv(_BOOTSTRAP_ENV_VAR, raising=False) with pytest.raises(MissingCredentialKey) as exc: CredentialCrypto(tmp_path / "absent.b64") message = str(exc.value) # The sentence README.md reproduces verbatim. assert "Fernet key file not found at" in message # The variable both docs tell the operator to set. assert _BOOTSTRAP_ENV_VAR in message # The alternative the docs lean on — that a restored instance restores the # key rather than minting one. Losing this line loses the whole point. assert "restore the key file" in message def test_the_install_docs_name_the_real_key_path_and_variable(): """Pins the two literals, read from the code rather than retyped here. Changing `_CREDENTIAL_KEY_PATH` or the env var name without updating the docs fails this — which is the only thing standing between a rename and a README that sends strangers to a path that does not exist. """ from backend.app import _CREDENTIAL_KEY_PATH from backend.app.services.credential_crypto import _BOOTSTRAP_ENV_VAR for name in _INSTALL_DOCS: text = (_REPO_ROOT / name).read_text() assert str(_CREDENTIAL_KEY_PATH) in text, f"{name} does not name the key path" assert _BOOTSTRAP_ENV_VAR in text, f"{name} does not name the bootstrap variable" def test_compose_passes_the_bootstrap_variable_through(): """The docs say "set it in .env"; that only works if compose forwards it. Without this line the instruction is silently inert — the operator sets the variable, the container never sees it, and the failure is identical to not having set it at all. """ from backend.app.services.credential_crypto import _BOOTSTRAP_ENV_VAR compose = (_REPO_ROOT / "docker-compose.yml").read_text() assert f"{_BOOTSTRAP_ENV_VAR}: ${{{_BOOTSTRAP_ENV_VAR}" in compose