From 40b2dacce0b536bbce74af7ffd432a29cccf89cb Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 30 Sep 2026 09:22:09 +0200 Subject: [PATCH] Fix 403 scenario --- Mergin/projects_manager.py | 8 ++++++-- Mergin/utils.py | 11 ++++++++--- tests/test_utils.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Mergin/projects_manager.py b/Mergin/projects_manager.py index 15e5aadd..a59534de 100644 --- a/Mergin/projects_manager.py +++ b/Mergin/projects_manager.py @@ -28,6 +28,7 @@ LoginError, find_qgis_files, login_error_message, + lost_project_access, mergin_project_local_path, same_dir, send_logs, @@ -234,14 +235,15 @@ def create_project(self, project_name, project_dir, is_public, namespace): return True - def refresh_project_role_variable(self, project_dir): + def refresh_project_role_variable(self, project_dir, lost_access=False): """Re-read the role the client just refreshed, unless this is not the project open in QGIS. Both sync and the status dialog can be started from the Browser panel for any downloaded project, while the variable only ever describes the open one. """ if same_dir(project_dir, mergin_project_local_path()): - write_project_role_variable(MerginProject(project_dir).project_role()) + role = None if lost_access else MerginProject(project_dir).project_role() + write_project_role_variable(role) def project_status(self, project_dir): if project_dir is None: @@ -293,6 +295,8 @@ def project_status(self, project_dir): self.reset_local_changes(project_dir, dlg.file_to_reset) except (URLError, ClientError, InvalidProject) as e: + if lost_project_access(e): + self.refresh_project_role_variable(project_dir, lost_access=True) msg = f"Failed to get status for project {project_name}:\n\n{str(e)}" QMessageBox.critical(None, "Project status", msg, QMessageBox.StandardButton.Close) except AuthTokenExpiredError: diff --git a/Mergin/utils.py b/Mergin/utils.py index c1ac47cb..839c57c5 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1024,6 +1024,11 @@ def get_local_mergin_projects_info(workspace=None): return local_projects_info +def lost_project_access(error): + """Whether the server refused to read the project because the user's access to it was removed.""" + return isinstance(error, ClientError) and error.http_error == 403 + + def refresh_project_role(mc, project_dir): """Ask the server for the user's current role on the project.""" try: @@ -1034,9 +1039,9 @@ def refresh_project_role(mc, project_dir): # servers below 2025.8.2 have no v2 project info, and old projects have no id in their metadata role = mc.project_info(mp.project_full_name())["role"] write_project_role_variable(role) - except (InvalidProject, ClientError, URLError): - # keep the role already read from the metadata, the variable has to stay usable offline - pass + except (InvalidProject, ClientError, URLError, LoginError, AuthTokenExpiredError) as e: + if lost_project_access(e): + write_project_role_variable(None) def set_qgis_project_mergin_variables(project_dir): diff --git a/tests/test_utils.py b/tests/test_utils.py index db0cf9d6..22567e9c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -9,6 +9,10 @@ import tempfile from pathlib import Path from typing import Dict +from unittest.mock import MagicMock +from urllib.error import URLError + +import pytest from qgis.core import ( QgsCoordinateReferenceSystem, @@ -23,10 +27,14 @@ from qgis.PyQt.QtCore import QVariant from Mergin.utils import ( + AuthTokenExpiredError, + ClientError, create_map_sketches_layer, create_tracking_layer, get_datum_shift_grids, is_valid_name, + LoginError, + refresh_project_role, remove_project_role_variable, same_schema, set_qgis_project_mergin_variables, @@ -259,3 +267,26 @@ def test_project_role_variable_missing_in_metadata(mergin_project_dir: Path): set_qgis_project_mergin_variables(str(mergin_project_dir)) assert QgsExpressionContextUtils.globalScope().variable("mm_project_role") == "" remove_project_role_variable() + + +@pytest.mark.parametrize( + "error, expected_role", + [ + (ClientError("Forbidden", http_error=403), ""), + (ClientError("Internal server error", http_error=500), "editor"), + (URLError("offline"), "editor"), + (AuthTokenExpiredError("Token has expired - please re-login"), "editor"), + (LoginError("Invalid username or password"), "editor"), + ], +) +def test_refresh_project_role_on_server_error(mergin_project_dir: Path, error: Exception, expected_role: str): + """Only removed access clears the role, any other failure keeps the one from the last sync.""" + set_qgis_project_mergin_variables(str(mergin_project_dir)) + mc = MagicMock() + mc.project_info_v2.side_effect = error + mc.project_info.side_effect = error + + refresh_project_role(mc, str(mergin_project_dir)) + + assert QgsExpressionContextUtils.globalScope().variable("mm_project_role") == expected_role + remove_project_role_variable()