diff --git a/mealie/core/settings/settings.py b/mealie/core/settings/settings.py index 37ab732aa..36335e014 100644 --- a/mealie/core/settings/settings.py +++ b/mealie/core/settings/settings.py @@ -50,13 +50,19 @@ def determine_secrets(data_dir: Path, secret: str, production: bool) -> str: secrets_file = data_dir.joinpath(secret) if secrets_file.is_file(): with open(secrets_file) as f: - return f.read() - else: - data_dir.mkdir(parents=True, exist_ok=True) - with open(secrets_file, "w") as f: - new_secret = secrets.token_hex(32) - f.write(new_secret) - return new_secret + existing_secret = f.read().strip() + if existing_secret: + return existing_secret + + data_dir.mkdir(parents=True, exist_ok=True) + new_secret = secrets.token_hex(32) + tmp_file = secrets_file.with_suffix(".tmp") + with open(tmp_file, "w") as f: + f.write(new_secret) + f.flush() + os.fsync(f.fileno()) + tmp_file.replace(secrets_file) + return new_secret def get_secrets_dir() -> str | None: diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 2a5072f9b..8d0814d05 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -1,12 +1,13 @@ import json import re from dataclasses import dataclass +from pathlib import Path from typing import Any import pytest from mealie.core.config import get_app_settings -from mealie.core.settings.settings import AppSettings +from mealie.core.settings.settings import AppSettings, determine_secrets def test_non_default_settings(monkeypatch): @@ -367,3 +368,42 @@ def test_sensitive_settings_mask(monkeypatch: pytest.MonkeyPatch): for setting in sensitive_settings: assert settings[setting] == "*****" assert settings_json[setting] == "*****" + + +class DetermineSecretsTests: + def test_non_production_returns_fixed_key(self, tmp_path: Path): + result = determine_secrets(tmp_path, ".secret", production=False) + assert result == "shh-secret-test-key" + + def test_generates_secret_when_file_missing(self, tmp_path: Path): + result = determine_secrets(tmp_path, ".secret", production=True) + assert result + assert (tmp_path / ".secret").read_text() == result + + def test_reuses_existing_secret(self, tmp_path: Path): + (tmp_path / ".secret").write_text("existing-secret") + result = determine_secrets(tmp_path, ".secret", production=True) + assert result == "existing-secret" + + def test_regenerates_when_file_is_empty(self, tmp_path: Path): + (tmp_path / ".secret").write_text("") + result = determine_secrets(tmp_path, ".secret", production=True) + assert result + assert (tmp_path / ".secret").read_text() == result + + def test_regenerates_when_file_is_whitespace_only(self, tmp_path: Path): + (tmp_path / ".secret").write_text(" \n ") + result = determine_secrets(tmp_path, ".secret", production=True) + assert result + assert (tmp_path / ".secret").read_text() == result + + def test_generates_unique_secrets(self, tmp_path: Path): + dir_a = tmp_path / "a" + dir_b = tmp_path / "b" + result_a = determine_secrets(dir_a, ".secret", production=True) + result_b = determine_secrets(dir_b, ".secret", production=True) + assert result_a != result_b + + def test_no_tmp_file_left_after_write(self, tmp_path: Path): + determine_secrets(tmp_path, ".secret", production=True) + assert not (tmp_path / ".tmp").exists()