From 7e83d96b1df8a9e39d648648bddd148ee3f5073a Mon Sep 17 00:00:00 2001 From: Yutsuo Date: Sun, 30 Aug 2026 14:24:42 -0300 Subject: [PATCH] feat: add secure settings dialog --- src/voice_transcriptor/ui/__init__.py | 1 + src/voice_transcriptor/ui/settings_dialog.py | 144 ++++++++++++++++ tests/ui/test_settings_dialog.py | 169 +++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 src/voice_transcriptor/ui/__init__.py create mode 100644 src/voice_transcriptor/ui/settings_dialog.py create mode 100644 tests/ui/test_settings_dialog.py diff --git a/src/voice_transcriptor/ui/__init__.py b/src/voice_transcriptor/ui/__init__.py new file mode 100644 index 0000000..3f934e6 --- /dev/null +++ b/src/voice_transcriptor/ui/__init__.py @@ -0,0 +1 @@ +"""Native user-interface components for Voice Transcriptor.""" diff --git a/src/voice_transcriptor/ui/settings_dialog.py b/src/voice_transcriptor/ui/settings_dialog.py new file mode 100644 index 0000000..113e7f1 --- /dev/null +++ b/src/voice_transcriptor/ui/settings_dialog.py @@ -0,0 +1,144 @@ +"""Modal editor for non-secret settings and the stored API key.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, +) + +from voice_transcriptor.models import AppSettings +from voice_transcriptor.services.credentials import CredentialError, CredentialService +from voice_transcriptor.services.settings import SettingsError, SettingsRepository + + +class SettingsDialog(QDialog): + """Edit application settings while keeping the API key out of normal storage.""" + + settings_saved = Signal(AppSettings) + + def __init__( + self, + settings: AppSettings, + repository: SettingsRepository, + credentials: CredentialService, + parent=None, + ) -> None: + super().__init__(parent) + self._repository = repository + self._credentials = credentials + self.setWindowTitle("Settings") + + self.model_input = QLineEdit(settings.model, self) + self.model_input.setObjectName("modelInput") + self.language_input = QLineEdit(settings.language, self) + self.language_input.setObjectName("languageInput") + self.api_key_input = QLineEdit(self) + self.api_key_input.setObjectName("apiKeyInput") + self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password) + self.output_directory_input = QLineEdit(str(settings.output_directory), self) + self.output_directory_input.setObjectName("outputDirectoryInput") + self.choose_directory_button = QPushButton("Browse…", self) + self.choose_directory_button.setObjectName("chooseDirectoryButton") + self.choose_directory_button.clicked.connect(self.choose_output_directory) + + output_directory_layout = QHBoxLayout() + output_directory_layout.addWidget(self.output_directory_input) + output_directory_layout.addWidget(self.choose_directory_button) + + form = QFormLayout() + form.addRow("OpenAI API key", self.api_key_input) + form.addRow("Model", self.model_input) + form.addRow("Language", self.language_input) + form.addRow("Output directory", output_directory_layout) + + self.button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, + parent=self, + ) + self.save_button = self.button_box.button(QDialogButtonBox.StandardButton.Save) + self.save_button.setObjectName("saveButton") + self.cancel_button = self.button_box.button(QDialogButtonBox.StandardButton.Cancel) + self.cancel_button.setObjectName("cancelButton") + self.button_box.accepted.connect(self.save) + self.button_box.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(self.button_box) + + def choose_output_directory(self) -> None: + directory = QFileDialog.getExistingDirectory( + self, + "Choose output directory", + self.output_directory_input.text(), + ) + if directory: + self.output_directory_input.setText(directory) + + def save(self) -> None: + """Persist valid settings and, only when supplied, replace the API key.""" + api_key = self.api_key_input.text().strip() + self.api_key_input.clear() + + output_directory = self._output_directory() + if output_directory is None: + QMessageBox.warning( + self, + "Invalid output directory", + "Choose an accessible output directory.", + ) + return + + model = self.model_input.text().strip() + language = self.language_input.text().strip() + if not model or not language: + QMessageBox.warning(self, "Invalid settings", "Model and language are required.") + return + + updated_settings = AppSettings(model, language, output_directory) + try: + self._repository.save(updated_settings) + except SettingsError: + QMessageBox.warning(self, "Could not save settings", "Settings could not be saved.") + return + + if api_key: + try: + self._credentials.set_api_key(api_key) + except CredentialError: + QMessageBox.warning( + self, + "Could not save settings", + "The API key could not be saved.", + ) + return + + self.settings_saved.emit(updated_settings) + self.accept() + + def _output_directory(self) -> Path | None: + value = self.output_directory_input.text().strip() + if not value: + return None + + directory = Path(value) + try: + if not directory.is_dir(): + return None + if not os.access(directory, os.R_OK | os.W_OK): + return None + except OSError: + return None + return directory diff --git a/tests/ui/test_settings_dialog.py b/tests/ui/test_settings_dialog.py new file mode 100644 index 0000000..28abcd6 --- /dev/null +++ b/tests/ui/test_settings_dialog.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from PySide6.QtWidgets import QLineEdit, QMessageBox + +from voice_transcriptor.models import AppSettings +from voice_transcriptor.services.credentials import CredentialError +from voice_transcriptor.services.settings import SettingsError +from voice_transcriptor.ui.settings_dialog import SettingsDialog + + +class FakeRepository: + def __init__(self) -> None: + self.saved: list[AppSettings] = [] + + def save(self, settings: AppSettings) -> None: + self.saved.append(settings) + + +class FakeCredentials: + def __init__(self, api_key: str | None = None) -> None: + self.api_key = api_key + self.saved_values: list[str] = [] + + def get_api_key(self) -> str | None: + return self.api_key + + def has_api_key(self) -> bool: + return bool(self.api_key) + + def set_api_key(self, value: str) -> None: + self.saved_values.append(value) + self.api_key = value + + +@pytest.fixture +def settings(tmp_path: Path) -> AppSettings: + output_directory = tmp_path / "output" + output_directory.mkdir() + return AppSettings("gpt-4o-transcribe", "pt-BR", output_directory) + + +def test_populates_fields_from_settings(qtbot, settings: AppSettings) -> None: + dialog = SettingsDialog(settings, FakeRepository(), FakeCredentials()) + qtbot.addWidget(dialog) + + assert dialog.model_input.text() == settings.model + assert dialog.language_input.text() == settings.language + assert dialog.output_directory_input.text() == str(settings.output_directory) + assert dialog.api_key_input.text() == "" + + +def test_api_key_input_uses_password_echo_mode(qtbot, settings: AppSettings) -> None: + dialog = SettingsDialog(settings, FakeRepository(), FakeCredentials()) + qtbot.addWidget(dialog) + + assert dialog.api_key_input.echoMode() is QLineEdit.EchoMode.Password + + +def test_blank_api_key_preserves_existing_credential(qtbot, settings: AppSettings) -> None: + credentials = FakeCredentials(api_key="stored-token") + repository = FakeRepository() + dialog = SettingsDialog(settings, repository, credentials) + qtbot.addWidget(dialog) + + dialog.save() + + assert credentials.api_key == "stored-token" + assert credentials.saved_values == [] + assert repository.saved == [settings] + + +def test_save_persists_new_api_key_and_emits_saved_settings(qtbot, settings: AppSettings) -> None: + credentials = FakeCredentials() + repository = FakeRepository() + dialog = SettingsDialog(settings, repository, credentials) + qtbot.addWidget(dialog) + saved_with = qtbot.waitSignal(dialog.settings_saved) + supplied_key = "test-token-for-dialog" + + dialog.api_key_input.setText(supplied_key) + dialog.model_input.setText("gpt-4o-mini-transcribe") + dialog.save() + + assert saved_with.args == [ + AppSettings("gpt-4o-mini-transcribe", "pt-BR", settings.output_directory) + ] + assert credentials.api_key == supplied_key + assert repository.saved == saved_with.args + + +def test_save_rejects_nonexistent_output_directory(qtbot, settings: AppSettings, tmp_path: Path) -> None: + repository = FakeRepository() + dialog = SettingsDialog(settings, repository, FakeCredentials()) + qtbot.addWidget(dialog) + warnings: list[tuple[str, str]] = [] + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr( + QMessageBox, + "warning", + lambda parent, title, text: warnings.append((title, text)), + ) + try: + dialog.output_directory_input.setText(str(tmp_path / "missing")) + dialog.save() + finally: + monkeypatch.undo() + + assert repository.saved == [] + assert warnings == [("Invalid output directory", "Choose an accessible output directory.")] + assert dialog.isVisible() is False + + +def test_save_clears_key_field_and_never_writes_key_to_widgets(qtbot, settings: AppSettings) -> None: + dialog = SettingsDialog(settings, FakeRepository(), FakeCredentials()) + qtbot.addWidget(dialog) + supplied_key = "test-token-for-dialog" + + dialog.api_key_input.setText(supplied_key) + dialog.save() + + assert dialog.api_key_input.text() == "" + assert all(supplied_key not in widget.text() for widget in dialog.findChildren(QLineEdit)) + + +def test_service_failures_show_sanitized_message_and_keep_dialog_usable( + qtbot, settings: AppSettings, monkeypatch +) -> None: + class FailingCredentials(FakeCredentials): + def set_api_key(self, value: str) -> None: + raise CredentialError("backend detail must not be shown") + + dialog = SettingsDialog(settings, FakeRepository(), FailingCredentials()) + qtbot.addWidget(dialog) + warnings: list[tuple[str, str]] = [] + monkeypatch.setattr( + QMessageBox, + "warning", + lambda parent, title, text: warnings.append((title, text)), + ) + + dialog.api_key_input.setText("test-token-for-dialog") + dialog.save() + + assert warnings == [("Could not save settings", "The API key could not be saved.")] + assert dialog.result() == 0 + assert dialog.api_key_input.text() == "" + + +def test_settings_repository_failure_is_sanitized(qtbot, settings: AppSettings, monkeypatch) -> None: + class FailingRepository(FakeRepository): + def save(self, settings: AppSettings) -> None: + raise SettingsError("filesystem detail must not be shown") + + dialog = SettingsDialog(settings, FailingRepository(), FakeCredentials()) + qtbot.addWidget(dialog) + warnings: list[tuple[str, str]] = [] + monkeypatch.setattr( + QMessageBox, + "warning", + lambda parent, title, text: warnings.append((title, text)), + ) + + dialog.save() + + assert warnings == [("Could not save settings", "Settings could not be saved.")] + assert dialog.result() == 0