diff --git a/src/vorta/views/dialogs/repo/repo_add.py b/src/vorta/views/dialogs/repo/repo_add.py index c0befcbde..b48e7ef8a 100644 --- a/src/vorta/views/dialogs/repo/repo_add.py +++ b/src/vorta/views/dialogs/repo/repo_add.py @@ -7,6 +7,7 @@ QDialogButtonBox, QFormLayout, QLabel, + QMessageBox, QSizePolicy, ) @@ -15,7 +16,7 @@ from vorta.keyring.abc import VortaKeyring from vorta.store.models import RepoModel from vorta.utils import borg_compat, choose_file_dialog, get_asset, get_private_keys -from vorta.views.partials.password_input import PasswordInput, PasswordLineEdit +from vorta.views.partials.password_input import PasswordInput from vorta.views.utils import get_colored_icon uifile = get_asset('UI/dialogs/repo/repo_add.ui') @@ -131,7 +132,8 @@ def values(self): class AddRepoWindow(RepoWindow): def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle("Add New Repository") + self.setWindowTitle("Add Repository") + self.title.setText(self.tr('Add Repository')) self.passwordInput = PasswordInput() self.passwordInput.add_form_to_layout(self.repoDataFormLayout) @@ -149,6 +151,9 @@ def __init__(self, parent=None): self.display_backend_warning() self.init_encryption() + # start in "connect" mode: hide init only widgets + self._set_init_widgets_visible(False) + def set_password(self, URL): '''Autofill password from keyring only if current entry is empty''' password = VortaKeyring.get_keyring().get_password('vorta-repo', URL) @@ -209,48 +214,74 @@ def display_backend_warning(self): if self.encryptionComboBox.currentData() != 'none': self.passwordInput.set_error_label(VortaKeyring.get_keyring().get_backend_warning()) - def validate(self): - return super().validate() and self.passwordInput.validate() - - def run(self): - if self.validate(): - params = BorgInitJob.prepare(self.values) - if params['ok']: - self.saveButton.setEnabled(False) - job = BorgInitJob(params['cmd'], params) - job.updated.connect(self._set_status) - job.result.connect(self.run_result) - QApplication.instance().jobs_manager.add_job(job) - else: - self._set_status(params['message']) - - -class ExistingRepoWindow(RepoWindow): - def __init__(self): - super().__init__() - self.title.setText(self.tr('Connect to existing Repository')) - self.setWindowTitle("Add Existing Repository") + def _set_init_widgets_visible(self, visible): + self.passwordInput.confirmLineEdit.setVisible(visible) + self.passwordInput._label_confirm.setVisible(visible) + self.encryptionLabel.setVisible(visible) + self.encryptionComboBox.setVisible(visible) + if not visible: + self.passwordInput.set_validation_enabled(False) - self.passwordLabel = QLabel(self.tr('Password:')) - self.passwordInput = PasswordLineEdit(placeholder_text=self.tr("Enter the encryption passphrase")) - self.repoDataFormLayout.addRow(self.passwordLabel, self.passwordInput) + def _validate_repo_fields(self): + return super().validate() - def set_password(self, URL): - '''Autofill password from keyring only if current entry is empty''' - password = VortaKeyring.get_keyring().get_password('vorta-repo', URL) - if password and self.passwordInput.get_password() == "": - self._set_status(self.tr("Autofilled password from password manager.")) - self.passwordInput.setText(password) + def validate(self): + return self._validate_repo_fields() and self.passwordInput.validate() def run(self): - if self.validate(): - params = BorgInfoRepoJob.prepare(self.values) - if params['ok']: - self.saveButton.setEnabled(False) - job = BorgInfoRepoJob(params['cmd'], params) - job.updated.connect(self._set_status) - job.result.connect(self.run_result) - self.thread = job # Keep reference for tests - QApplication.instance().jobs_manager.add_job(job) + if not self._validate_repo_fields(): + return + + self.saveButton.setEnabled(False) + self._set_status(self.tr('Checking repository…')) + params = BorgInfoRepoJob.prepare(self.values) + if params['ok']: + job = BorgInfoRepoJob(params['cmd'], params) + job.updated.connect(self._set_status) + job.result.connect(self._probe_result) + QApplication.instance().jobs_manager.add_job(job) + else: + self.saveButton.setEnabled(True) + self._set_status(params['message']) + + def _probe_result(self, result): + if result['returncode'] == 0: + self.saveButton.setEnabled(True) + self.added_repo.emit(result) + self.accept() + else: + error_msgs = ' '.join(msg for _, msg in result.get('errors', [])) + if 'does not exist' in error_msgs.lower() or 'is not a valid repository' in error_msgs.lower(): + reply = QMessageBox.question( + self, + self.tr('Repository not found'), + self.tr('No repository found at this location. Initialize a new one?'), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self._set_init_widgets_visible(True) + self.passwordInput.set_validation_enabled(True) + self._set_status(self.tr('Please confirm your password and choose encryption to initialize.')) + if not self.passwordInput.validate(): + self.saveButton.setEnabled(True) + return + self._init_repo() + else: + self.saveButton.setEnabled(True) + self._set_status(self.tr('Unable to add your repository.')) else: - self._set_status(params['message']) + self.saveButton.setEnabled(True) + self._set_status(error_msgs if error_msgs else self.tr('Unable to add your repository.')) + + def _init_repo(self): + self._set_status(self.tr('Initializing new repository…')) + params = BorgInitJob.prepare(self.values) + if params['ok']: + job = BorgInitJob(params['cmd'], params) + job.updated.connect(self._set_status) + job.result.connect(self.run_result) + QApplication.instance().jobs_manager.add_job(job) + else: + self.saveButton.setEnabled(True) + self._set_status(params['message']) diff --git a/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py index 0a661857e..070574f2b 100644 --- a/src/vorta/views/repo_tab.py +++ b/src/vorta/views/repo_tab.py @@ -9,7 +9,7 @@ from vorta.i18n.richtext import escape, format_richtext, link from vorta.store.models import ArchiveModel, RepoModel from vorta.utils import borg_compat, get_asset, get_private_keys, pretty_bytes -from vorta.views.dialogs.repo.repo_add import AddRepoWindow, ExistingRepoWindow +from vorta.views.dialogs.repo.repo_add import AddRepoWindow from vorta.views.dialogs.repo.repo_change_passphrase import ChangeBorgPassphraseWindow from vorta.views.dialogs.repo.ssh import SSHAddWindow @@ -35,13 +35,7 @@ def __init__(self, parent=None, profile_provider=None): # Populate dropdowns self.copyURLbutton.clicked.connect(self.copy_URL_action) - # init repo add button - self.menuAddRepo = QMenu(self.bAddRepo) - - self.menuAddRepo.addAction(self.tr("New Repository…"), self.new_repo) - self.menuAddRepo.addAction(self.tr("Existing Repository…"), self.add_existing_repo) - - self.bAddRepo.setMenu(self.menuAddRepo) + self.bAddRepo.clicked.connect(self.add_repo) # init repo util button self.menuRepoUtil = QMenu(self.bRepoUtil) @@ -267,22 +261,11 @@ def ssh_copy_to_clipboard_action(self): def compression_select_action(self, index): self.save_profile_attr('compression', self.repoCompression.currentData()) - def new_repo(self): - """Open a dialog to create a new repo and add it to vorta.""" + def add_repo(self): window = AddRepoWindow() self._window = window # For tests window.setParent(self, QtCore.Qt.WindowType.Sheet) window.added_repo.connect(self.process_new_repo) - # window.rejected.connect(lambda: self.repoSelector.setCurrentIndex(0)) - window.open() - - def add_existing_repo(self): - """Open a dialog to add a existing repo to vorta.""" - window = ExistingRepoWindow() - self._window = window # For tests - window.setParent(self, QtCore.Qt.WindowType.Sheet) - window.added_repo.connect(self.process_new_repo) - # window.rejected.connect(lambda: self.repoSelector.setCurrentIndex(0)) window.open() def repo_select_action(self): diff --git a/tests/integration/test_init.py b/tests/integration/test_init.py index 9e01c392b..dd9ff687a 100644 --- a/tests/integration/test_init.py +++ b/tests/integration/test_init.py @@ -21,7 +21,7 @@ def test_create_repo(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): """Test initializing a new repository""" main = qapp.main_window - main.repoTab.new_repo() + main.repoTab.add_repo() add_repo_window = main.repoTab._window main.show() @@ -44,6 +44,9 @@ def test_create_repo(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir): qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, LONG_PASSWORD) + # borg info fails for new/empty directories, triggering an init confirmation prompt + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes) + initial_count = main.repoTab.repoSelector.count() add_repo_window.run() @@ -88,7 +91,7 @@ def test_add_existing_repo(qapp, qtbot, monkeypatch, choose_file_dialog): ) # add existing repo again - main.repoTab.add_existing_repo() + main.repoTab.add_repo() add_repo_window = main.repoTab._window monkeypatch.setattr( diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py index ced9200ec..b758cee7e 100644 --- a/tests/unit/test_repo.py +++ b/tests/unit/test_repo.py @@ -1,3 +1,4 @@ +import logging import os import uuid from typing import Any, Dict @@ -10,6 +11,7 @@ import vorta.borg.borg_job from vorta.keyring.abc import VortaKeyring from vorta.store.models import ArchiveModel, BackupProfileModel, EventLogModel, RepoModel +from vorta.views.dialogs.repo.repo_add import AddRepoWindow LONG_PASSWORD = 'long-password-long' SHORT_PASSWORD = 'hunter2' @@ -25,31 +27,35 @@ ], ) def test_new_repo_password_validation(qapp, qtbot, borg_json_output, first_password, second_password, validation_error): - # Add new repo window + # add new repo window main = qapp.main_window tab = main.repoTab - tab.new_repo() + tab.add_repo() add_repo_window = tab._window qtbot.addWidget(add_repo_window) + # reveal init only widgets so password validation is active + add_repo_window._set_init_widgets_visible(True) + add_repo_window.passwordInput.set_validation_enabled(True) + qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, first_password) qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, second_password) - qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) + add_repo_window.passwordInput.validate() assert add_repo_window.passwordInput.validation_label.text() == validation_error @pytest.mark.parametrize( - "repo_name, error_text", + "repo_name, status_text", [ - ('test_repo_name', ''), # valid repo name - ('a' * 64, ''), # also valid (<=64 characters) + ('test_repo_name', 'Checking repository\u2026'), # valid repo name, probe starts + ('a' * 64, 'Checking repository\u2026'), # also valid (<=64 characters) ('a' * 65, 'Repository name must be less than 65 characters.'), # not valid (>64 characters) ], ) -def test_repo_add_name_validation(qapp, qtbot, borg_json_output, repo_name, error_text): +def test_repo_add_name_validation(qapp, qtbot, borg_json_output, repo_name, status_text): main = qapp.main_window tab = main.repoTab - tab.new_repo() + tab.add_repo() add_repo_window = tab._window test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain qtbot.addWidget(add_repo_window) @@ -57,7 +63,7 @@ def test_repo_add_name_validation(qapp, qtbot, borg_json_output, repo_name, erro qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) qtbot.keyClicks(add_repo_window.repoName, repo_name) qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) - assert add_repo_window.errorText.text() == error_text + assert add_repo_window.errorText.text() == status_text def test_repo_unlink(qapp, qtbot, monkeypatch): @@ -110,7 +116,7 @@ def test_repo_unlink_shared_repository_keeps_dropdown_entry(qapp, qtbot, mocker, def test_password_autofill(qapp, qtbot): main = qapp.main_window tab = main.repoTab - tab.new_repo() + tab.add_repo() add_repo_window = tab._window test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain @@ -126,11 +132,11 @@ def test_password_autofill(qapp, qtbot): def test_repo_add_failure(qapp, qtbot, borg_json_output): main = qapp.main_window tab = main.repoTab - tab.new_repo() + tab.add_repo() add_repo_window = tab._window qtbot.addWidget(add_repo_window) - # Add repo with invalid URL + # add repo with invalid URL qtbot.keyClicks(add_repo_window.repoURL, 'aaa') qtbot.mouseClick(add_repo_window.saveButton, QtCore.Qt.MouseButton.LeftButton) assert add_repo_window.errorText.text().startswith('Please enter a valid repo URL') @@ -139,12 +145,12 @@ def test_repo_add_failure(qapp, qtbot, borg_json_output): def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): main = qapp.main_window tab = main.repoTab - tab.new_repo() + tab.add_repo() add_repo_window = tab._window - test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain + test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # random repo URL to avoid macOS keychain test_repo_name = 'Test Repo' - # Enter valid repo URL, name, and password + # enter valid repo URL, name, and password qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) qtbot.keyClicks(add_repo_window.repoName, test_repo_name) qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD) @@ -180,16 +186,16 @@ def test_ssh_dialog_success(qapp, qtbot, mocker, tmpdir): ssh_dialog.outputFileTextBox.setText(key_tmpfile_full) ssh_dialog.generate_key() - # Ensure new key file was created + # Ensures new key file was created qtbot.waitUntil(lambda: ssh_dialog_closed.called, **pytest._wait_defaults) assert len(ssh_dir.listdir()) == 2 - # Ensure new key is populated in SSH combobox + # Ensures new key is populated in SSH combobox mocker.patch('os.path.expanduser', return_value=str(tmpdir)) tab.init_ssh() assert tab.sshComboBox.count() == 2 - # Ensure valid keys were created + # Ensures valid keys were created key_tmpfile_content = key_tmpfile.read() assert key_tmpfile_content.startswith('-----BEGIN OPENSSH PRIVATE KEY-----') pub_tmpfile_content = pub_tmpfile.read() @@ -213,10 +219,10 @@ def test_ssh_dialog_failure(qapp, qtbot, mocker, monkeypatch, tmpdir): qtbot.waitUntil(lambda: failure_message.called, **pytest._wait_defaults) failure_message.assert_called_once() - # Ensure no new ney file was created + # Ensures no new ney file was created assert len(ssh_dir.listdir()) == 0 - # Ensure no new key file in combo box + # Ensures no new key file in combo box mocker.patch('os.path.expanduser', return_value=str(tmpdir)) tab.init_ssh() assert tab.sshComboBox.count() == 1 @@ -315,7 +321,7 @@ def test_create(qapp, borg_json_output, mocker, qtbot): "icon": QMessageBox.Icon.Critical, "info": "The process running the check job got a kill signal. Try again.", }, - {"return_code": 130, "error": "", "icon": None, "info": None}, # keyboard interrupt + {"return_code": 130, "error": "", "icon": None, "info": None}, ], ) def test_repo_check_failed_response(qapp, qtbot, mocker, response): @@ -388,3 +394,112 @@ def test_handle_passphrase_change_result(qapp, qtbot, mocker, result, expected_t mock_instance.setWindowTitle.assert_called_once_with(tab.tr(expected_title)) mock_instance.setText.assert_called_once_with(tab.tr(expected_text)) mock_instance.show.assert_called_once() + + +@pytest.mark.parametrize( + "error_message", + [ + 'Repository /tmp/nonexistent-repo does not exist.', + '/tmp/nonexistent-repo is not a valid repository. Check repo config.', + ], +) +def test_add_repo_not_found_offers_init(qapp, qtbot, mocker, error_message): + main = qapp.main_window + tab = main.repoTab + tab.add_repo() + window = tab._window + + mocker.patch.object( + QMessageBox, + 'question', + return_value=QMessageBox.StandardButton.Yes, + ) + mock_init = mocker.patch.object(window, '_init_repo') + + qtbot.keyClicks(window.passwordInput.passwordLineEdit, 'long-password-long') + qtbot.keyClicks(window.passwordInput.confirmLineEdit, 'long-password-long') + window.repoURL.setText('/tmp/nonexistent-repo') + window.repoName.setText('Test Repo') + window.is_remote_repo = False + + result = { + 'returncode': 2, + 'cmd': ['borg', 'info', '/tmp/nonexistent-repo'], + 'errors': [(logging.ERROR, error_message)], + 'params': {'repo_url': '/tmp/nonexistent-repo', 'profile_name': 'Default'}, + 'data': '', + } + window._probe_result(result) + + QMessageBox.question.assert_called_once() + mock_init.assert_called_once() + + +def test_add_repo_not_found_user_declines(qapp, qtbot, mocker): + main = qapp.main_window + tab = main.repoTab + tab.add_repo() + window = tab._window + + mocker.patch.object( + QMessageBox, + 'question', + return_value=QMessageBox.StandardButton.No, + ) + + window.repoURL.setText('/tmp/nonexistent-repo') + window.is_remote_repo = False + + result = { + 'returncode': 2, + 'cmd': ['borg', 'info', '/tmp/nonexistent-repo'], + 'errors': [(logging.ERROR, 'Repository /tmp/nonexistent-repo does not exist.')], + 'params': {'repo_url': '/tmp/nonexistent-repo', 'profile_name': 'Default'}, + 'data': '', + } + window._probe_result(result) + + assert window.errorText.text() == 'Unable to add your repository.' + + +def test_add_repo_other_error_no_init_offer(qapp, qtbot, mocker): + main = qapp.main_window + tab = main.repoTab + tab.add_repo() + window = tab._window + + mock_question = mocker.patch.object(QMessageBox, 'question') + + window.repoURL.setText('host.example.com:repo') + window.is_remote_repo = True + + result = { + 'returncode': 2, + 'cmd': ['borg', 'info', 'host.example.com:repo'], + 'errors': [(logging.ERROR, 'Connection refused')], + 'params': {'repo_url': 'host.example.com:repo', 'profile_name': 'Default'}, + 'data': '', + } + window._probe_result(result) + + mock_question.assert_not_called() + assert window.errorText.text() == 'Connection refused' + + +def test_add_repo_probe_succeeds_connects(qapp, qtbot): + window = AddRepoWindow() + + signal_received = [] + window.added_repo.connect(lambda r: signal_received.append(r)) + + result = { + 'returncode': 0, + 'cmd': ['borg', 'info', '/tmp/existing-repo'], + 'errors': [], + 'params': {'repo_url': '/tmp/existing-repo', 'repo_name': 'Test'}, + 'data': {}, + } + window._probe_result(result) + + assert len(signal_received) == 1 + assert signal_received[0]['returncode'] == 0