From 7c78ad465ef10fb371dd354bc2818251b3298cbf Mon Sep 17 00:00:00 2001 From: Yutsuo Date: Sun, 30 Aug 2026 14:02:04 -0300 Subject: [PATCH] feat: persist settings and credentials securely --- README.md | 6 ++ .../services/credentials.py | 37 ++++++++ src/voice_transcriptor/services/settings.py | 87 ++++++++++++++++++ tests/test_credentials.py | 72 +++++++++++++++ tests/test_settings.py | 88 +++++++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 src/voice_transcriptor/services/credentials.py create mode 100644 src/voice_transcriptor/services/settings.py create mode 100644 tests/test_credentials.py create mode 100644 tests/test_settings.py diff --git a/README.md b/README.md index e69de29..725c604 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,6 @@ +# Voice Transcriptor + +The editable default transcription model is `gpt-4o-transcribe`. New installations use +Brazilian Portuguese (`pt-BR`) and save output to the user's Documents folder when it is +available, otherwise to the home folder. API keys are stored only in the operating-system +keyring and are never written to the settings file. diff --git a/src/voice_transcriptor/services/credentials.py b/src/voice_transcriptor/services/credentials.py new file mode 100644 index 0000000..e770568 --- /dev/null +++ b/src/voice_transcriptor/services/credentials.py @@ -0,0 +1,37 @@ +"""Secure API-key storage backed by the operating system keyring.""" + +from __future__ import annotations + +import keyring + + +SERVICE_NAME = "voice-transcriptor" +ACCOUNT_NAME = "openai-api-key" + + +class CredentialError(Exception): + """A credential operation failed without exposing backend details.""" + + +class CredentialService: + """Read and write the application's API key through a keyring backend.""" + + def __init__(self, backend=keyring) -> None: + self._backend = backend + + def get_api_key(self) -> str | None: + try: + return self._backend.get_password(SERVICE_NAME, ACCOUNT_NAME) + except Exception: + raise CredentialError("Unable to access the saved API key.") from None + + def has_api_key(self) -> bool: + return bool(self.get_api_key()) + + def set_api_key(self, value: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise CredentialError("An API key is required.") + try: + self._backend.set_password(SERVICE_NAME, ACCOUNT_NAME, value) + except Exception: + raise CredentialError("Unable to save the API key.") from None diff --git a/src/voice_transcriptor/services/settings.py b/src/voice_transcriptor/services/settings.py new file mode 100644 index 0000000..c850df9 --- /dev/null +++ b/src/voice_transcriptor/services/settings.py @@ -0,0 +1,87 @@ +"""Per-user persistence for non-secret application settings.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +from voice_transcriptor.models import AppSettings + + +DEFAULT_MODEL = "gpt-4o-transcribe" +DEFAULT_LANGUAGE = "pt-BR" + + +class SettingsError(Exception): + """Settings could not be written.""" + + +class SettingsRepository: + """Load and atomically save settings without storing credentials.""" + + def __init__(self, path: Path | None = None) -> None: + self.path = path if path is not None else self.default_path() + + @staticmethod + def default_path() -> Path: + app_data = os.environ.get("APPDATA") + base_directory = Path(app_data) if app_data else Path.home() / "AppData" / "Roaming" + return base_directory / "VoiceTranscriptor" / "settings.json" + + @staticmethod + def default_settings() -> AppSettings: + home = Path.home() + documents = home / "Documents" + return AppSettings(DEFAULT_MODEL, DEFAULT_LANGUAGE, documents if documents.is_dir() else home) + + def load(self) -> tuple[AppSettings, str | None]: + if not self.path.exists(): + return self.default_settings(), None + + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + return self._settings_from_payload(payload), None + except (OSError, TypeError, ValueError, json.JSONDecodeError): + return self.default_settings(), "Could not load settings; defaults are being used." + + def save(self, settings: AppSettings) -> None: + payload = { + "model": settings.model, + "language": settings.language, + "output_directory": str(settings.output_directory), + } + temporary_path: Path | None = None + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp" + ) + temporary_path = Path(temporary_name) + with os.fdopen(descriptor, "w", encoding="utf-8") as temporary_file: + json.dump(payload, temporary_file, ensure_ascii=False) + temporary_path.replace(self.path) + except OSError as exc: + raise SettingsError("Unable to save settings.") from exc + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink(missing_ok=True) + + @staticmethod + def _settings_from_payload(payload: object) -> AppSettings: + if not isinstance(payload, dict): + raise ValueError("Settings payload must be an object.") + model = payload.get("model") + language = payload.get("language") + output_directory = payload.get("output_directory") + if ( + not isinstance(model, str) + or not model.strip() + or not isinstance(language, str) + or not language.strip() + or not isinstance(output_directory, str) + or not output_directory.strip() + ): + raise ValueError("Settings payload is invalid.") + return AppSettings(model, language, Path(output_directory)) diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..94b9fb3 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,72 @@ +import pytest + +from voice_transcriptor.services.credentials import CredentialError, CredentialService + + +class FakeKeyring: + def __init__(self) -> None: + self.value: str | None = None + self.calls: list[tuple[str, str, str | None]] = [] + + def get_password(self, service: str, account: str) -> str | None: + self.calls.append(("get", service, account)) + return self.value + + def set_password(self, service: str, account: str, value: str) -> None: + self.calls.append(("set", service, account, value)) + self.value = value + + +def test_get_api_key_uses_stable_keyring_service_and_account() -> None: + backend = FakeKeyring() + backend.value = "secret" + + assert CredentialService(backend).get_api_key() == "secret" + assert backend.calls == [("get", "voice-transcriptor", "openai-api-key")] + + +def test_has_api_key_reflects_keyring_value() -> None: + backend = FakeKeyring() + service = CredentialService(backend) + + assert service.has_api_key() is False + backend.value = "secret" + assert service.has_api_key() is True + + +def test_set_api_key_rejects_blank_values() -> None: + backend = FakeKeyring() + + with pytest.raises(CredentialError, match="API key"): + CredentialService(backend).set_api_key(" \t") + + assert backend.calls == [] + + +def test_set_api_key_persists_nonblank_value() -> None: + backend = FakeKeyring() + + CredentialService(backend).set_api_key("secret") + + assert backend.calls == [("set", "voice-transcriptor", "openai-api-key", "secret")] + + +@pytest.mark.parametrize("method", ["get_password", "set_password"]) +def test_backend_failures_are_sanitized(method: str) -> None: + class FailingKeyring: + def get_password(self, service: str, account: str) -> str | None: + raise RuntimeError("backend leaked secret") + + def set_password(self, service: str, account: str, value: str) -> None: + raise RuntimeError("backend leaked secret") + + service = CredentialService(FailingKeyring()) + + with pytest.raises(CredentialError) as caught: + if method == "get_password": + service.get_api_key() + else: + service.set_api_key("secret") + + assert "secret" not in str(caught.value).lower() + assert caught.value.__cause__ is None diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..d393b62 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,88 @@ +import json +from pathlib import Path + +import pytest + +from voice_transcriptor.models import AppSettings +from voice_transcriptor.services.settings import SettingsRepository + + +def test_load_missing_file_returns_documented_defaults(monkeypatch, tmp_path: Path) -> None: + documents = tmp_path / "Documents" + documents.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + settings, warning = SettingsRepository(tmp_path / "settings.json").load() + + assert settings == AppSettings("gpt-4o-transcribe", "pt-BR", documents) + assert warning is None + + +def test_load_missing_file_uses_home_when_documents_is_unavailable( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + settings, warning = SettingsRepository(tmp_path / "settings.json").load() + + assert settings.output_directory == tmp_path + assert warning is None + + +def test_save_and_load_round_trip_utf8_settings(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + expected = AppSettings("gpt-4o-transcribe", "pt-BR", tmp_path / "Transcrições") + repository = SettingsRepository(path) + + repository.save(expected) + actual, warning = repository.load() + + assert actual == expected + assert warning is None + assert json.loads(path.read_text(encoding="utf-8")) == { + "model": "gpt-4o-transcribe", + "language": "pt-BR", + "output_directory": str(tmp_path / "Transcrições"), + } + + +def test_load_malformed_file_recovers_defaults_with_nonfatal_warning(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text("not json", encoding="utf-8") + + settings, warning = SettingsRepository(path).load() + + assert settings.model == "gpt-4o-transcribe" + assert warning is not None + assert "settings" in warning.lower() + + +def test_save_replaces_existing_file_atomically(monkeypatch, tmp_path: Path) -> None: + path = tmp_path / "settings.json" + path.write_text("old", encoding="utf-8") + calls: list[tuple[Path, Path]] = [] + original_replace = Path.replace + + def recording_replace(source: Path, destination: Path) -> Path: + calls.append((source, destination)) + return original_replace(source, destination) + + monkeypatch.setattr(Path, "replace", recording_replace) + + SettingsRepository(path).save(AppSettings("model", "pt-BR", tmp_path / "out")) + + assert calls + source, destination = calls[0] + assert source.parent == path.parent + assert destination == path + assert json.loads(path.read_text(encoding="utf-8"))["model"] == "model" + + +def test_saved_settings_never_include_api_key_fields(tmp_path: Path) -> None: + path = tmp_path / "settings.json" + + SettingsRepository(path).save(AppSettings("model", "pt-BR", tmp_path / "out")) + + serialized = path.read_text(encoding="utf-8").lower() + assert "api" not in serialized + assert "key" not in serialized