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 # --- the first-boot race ----------------------------------------------------- def test_every_process_bootstrapping_at_once_ends_up_with_the_same_key(tmp_path): """Run 7368's smoke, and the reason the key write is a link rather than a write. hypercorn starts several worker processes and each one builds the app, so on a first boot they all reach the bootstrap together. `write_bytes` creates the file at size zero and fills it a moment later, which gave a second process an `exists()` of True and a `read_bytes()` of b"": ValueError: Fernet key must be 32 url-safe base64-encoded bytes. Threads rather than processes because the failure is about the ORDER of two filesystem operations, which threads reproduce and which a subprocess fixture would make this suite pay for on every run. What is asserted is AGREEMENT, not merely that nobody crashed: a race that left each worker holding its own key would pass a "did it raise" check and produce a system where a credential written by one worker cannot be read by the next. """ import threading path = tmp_path / "secrets" / "credential_key.b64" keys: list[bytes] = [] errors: list[Exception] = [] start = threading.Barrier(8) def bootstrap(): try: start.wait(timeout=5) CredentialCrypto(path, bootstrap_ok=True) keys.append(path.read_bytes()) except Exception as exc: # noqa: BLE001 — recorded, asserted below errors.append(exc) threads = [threading.Thread(target=bootstrap) for _ in range(8)] for t in threads: t.start() for t in threads: t.join(timeout=10) assert errors == [] assert len(keys) == 8 assert len(set(keys)) == 1, "the workers disagree about the credential key" def test_the_bootstrap_leaves_no_temp_file_behind(tmp_path): """The temp file is an implementation detail of the atomic write and must not survive it — a stray `.credential_key.b64..tmp` in the secrets directory is a copy of the key with nothing guarding it.""" path = tmp_path / "secrets" / "credential_key.b64" CredentialCrypto(path, bootstrap_ok=True) assert sorted(p.name for p in path.parent.iterdir()) == ["credential_key.b64"]