"""Encryption of the secrets that have to live in the database. Backup-destination credentials can't be files on disk — background jobs need them — so they are encrypted at rest with a key derived from ``SECRET_KEY``. The behaviour that matters beyond the round-trip: rows written before this existed are plaintext and must keep working, and a changed ``SECRET_KEY`` must produce a clear error rather than silent garbage. """ from __future__ import annotations import json import pytest @pytest.fixture def crypto(): from services import crypto_service return crypto_service def test_round_trip(crypto): payload = json.dumps({"host": "nas.lan", "password": "hunter2"}) encrypted = crypto.encrypt(payload) assert encrypted.startswith(crypto.PREFIX) assert "hunter2" not in encrypted assert crypto.decrypt(encrypted) == payload def test_encrypting_twice_is_a_no_op(crypto): """Guards the migration, which may run over a mix of both forms.""" once = crypto.encrypt("secret") assert crypto.encrypt(once) == once def test_plaintext_rows_are_passed_through(crypto): """Rows written before encryption existed must not break on read.""" legacy = '{"host": "old.example"}' assert crypto.is_encrypted(legacy) is False assert crypto.decrypt(legacy) == legacy def test_empty_values(crypto): assert crypto.decrypt(crypto.encrypt("")) == "" assert crypto.decrypt("") == "" def test_a_changed_secret_key_raises_a_useful_error(crypto, monkeypatch): from config import settings encrypted = crypto.encrypt("secret") monkeypatch.setattr(settings, "SECRET_KEY", "a-completely-different-key", raising=False) with pytest.raises(crypto.DecryptError) as excinfo: crypto.decrypt(encrypted) assert "SECRET_KEY" in str(excinfo.value) def test_destination_config_round_trips_through_the_service(monkeypatch): """The service-level pair the routers actually use.""" from models.backup_destination import BackupDestination from services import backup_destination_service as svc config = {"host": "nas.lan", "username": "backup", "password": "hunter2"} stored = svc.dump_config(config) assert "hunter2" not in stored dest = BackupDestination(name="nas", type="sftp", config=stored) assert svc.parse_config(dest) == config def test_undecryptable_destination_reads_as_unconfigured(monkeypatch): """A lost key must not take the destinations list down with a 500. The destination shows up empty (and the reason is logged) so the rest of the UI keeps working and the user can re-enter the credentials. """ from config import settings from models.backup_destination import BackupDestination from services import backup_destination_service as svc dest = BackupDestination(name="nas", type="sftp", config=svc.dump_config({"host": "x"})) monkeypatch.setattr(settings, "SECRET_KEY", "yet-another-key", raising=False) assert svc.parse_config(dest) == {} def test_migration_encrypts_plaintext_rows_and_is_idempotent(db): from sqlmodel import Session, select from database import engine from models.backup_destination import BackupDestination from services import backup_destination_service as svc from services import crypto_service with Session(engine) as session: session.add( BackupDestination( name="legacy-plaintext", type="sftp", config='{"host": "legacy.example", "password": "in-the-clear"}', ) ) session.commit() assert svc.migrate_plaintext_configs(session) >= 1 row = session.exec( select(BackupDestination).where(BackupDestination.name == "legacy-plaintext") ).one() assert crypto_service.is_encrypted(row.config) assert "in-the-clear" not in row.config assert svc.parse_config(row)["password"] == "in-the-clear" # Second pass finds nothing left to do. assert svc.migrate_plaintext_configs(session) == 0