Browse Source

feat: add secure settings dialog

master
Yutsuo 4 days ago
parent
commit
7e83d96b1d
  1. 1
      src/voice_transcriptor/ui/__init__.py
  2. 144
      src/voice_transcriptor/ui/settings_dialog.py
  3. 169
      tests/ui/test_settings_dialog.py

1
src/voice_transcriptor/ui/__init__.py

@ -0,0 +1 @@
"""Native user-interface components for Voice Transcriptor."""

144
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

169
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
Loading…
Cancel
Save