From 254a9b80f9f2432f7e0ab6d05b1f8b0ca3ef45d7 Mon Sep 17 00:00:00 2001 From: xkello Date: Mon, 26 Jan 2026 16:05:16 +0100 Subject: [PATCH 01/16] Update batch API endpoint, openapi documentation and add batch test --- server/mergin/sync/permissions.py | 54 ++++++++++++ server/mergin/sync/public_api_v2.yaml | 84 +++++++++++++++++++ .../mergin/sync/public_api_v2_controller.py | 29 ++++++- server/mergin/sync/schemas_v2.py | 4 + server/mergin/tests/test_public_api_v2.py | 64 ++++++++++++++ 5 files changed, 233 insertions(+), 2 deletions(-) diff --git a/server/mergin/sync/permissions.py b/server/mergin/sync/permissions.py index 7dd042d5..317f9e96 100644 --- a/server/mergin/sync/permissions.py +++ b/server/mergin/sync/permissions.py @@ -247,6 +247,60 @@ def require_project_by_uuid( return project +def require_project_by_many_uuids( + uuids: list[str], permission: ProjectPermissions, scheduled=False, expose=True +) -> list[dict]: + """ + Retrieves multiple projects by their UUIDs after validating existence, workspace status, and permissions. + + Args: + uuids (list[str]): The unique identifiers of the projects. + permission (ProjectPermissions): The permission level required to access the projects. + scheduled (bool, optional): If ``True``, bypasses the check for projects marked for deletion. + expose (bool, optional): Controls security disclosure behavior on permission failure. + - If `True`: Returns 403 Forbidden (reveals project exists but access is denied). + - If `False`: Returns 404 Not Found (hides project existence for security). + Standard is that reading results in 404, while writing results in 403 + """ + valid_uuids = [uuid for uuid in uuids if is_valid_uuid(uuid)] + if not valid_uuids: + abort(404) + + projects = Project.query.filter(Project.id.in_(valid_uuids)).filter( + Project.storage_params.isnot(None) + ) + if not scheduled: + projects = projects.filter(Project.removed_at.is_(None)) + projects = projects.all() + if not projects: + abort(404) + + filtered_projects = [] + for project in projects: + + workspace = project.workspace + if not workspace: + continue + if not is_active_workspace(workspace): + continue + + if not permission.check(project, current_user) and not expose: + # logged in - NO, have acccess - NONE, public project - NO + if current_user.is_anonymous and not project.public: + # we don't want to tell anonymous user if a private project exists + filtered_projects.append({"id": project.id, "error": 404}) + continue + # logged in - YES, have access - NO, public project - NO + elif not current_user.is_anonymous and not project.public: + filtered_projects.append({"id": project.id, "error": 403}) + continue + filtered_projects.append(project) + + if not filtered_projects: + abort(404) + + return filtered_projects + def get_upload(transaction_id): upload = Upload.query.get_or_404(transaction_id) diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index c81be8af..87660086 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -367,6 +367,77 @@ paths: $ref: "#/components/schemas/ProjectLocked" x-openapi-router-controller: mergin.sync.public_api_v2_controller + + /projects/batch: + post: + tags: + - project + summary: Get multiple projects by UUIDs + operationId: list_batch_projects + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ids] + properties: + ids: + type: array + minItems: 1 + maxItems: 100 + description: List of project UUIDs to fetch + items: + type: string + format: uuid + examples: + example: + value: + ids: + - "9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f" + - "0f1e2d3c-4b5a-6978-9a0b-1c2d3e4f5a6b" + responses: + "200": + description: Projects returned as a list of simple project objects and/or error objects. + content: + application/json: + schema: + type: object + required: [projects] + properties: + projects: + type: array + items: + oneOf: + - $ref: "#/components/schemas/Project" + - $ref: "#/components/schemas/BatchError" + examples: + mixed: + value: + projects: + - id: "9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f" + name: survey + version: v2 + public: false + size: 17092380 + created_at: "2025-10-24T08:27:56Z" + updated_at: "2025-10-24T08:28:00.279699Z" + workspace: + id: 123 + name: mergin + role: reader + - id: 9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f + error: 404 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + x-openapi-router-controller: mergin.sync.public_api_v2_controller + /workspaces/{workspace_id}/projects: get: tags: @@ -547,6 +618,19 @@ components: example: code: UploadError detail: "Project version could not be created (UploadError)" + BatchError: + type: object + properties: + id: + type: string + format: uuid + example: "9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f" + error: + type: integer + example: 404 + required: + - id + - error # Data ProjectRole: type: string diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 3e28aa40..44b4bb6a 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -16,7 +16,7 @@ from mergin.sync.tasks import remove_transaction_chunks -from .schemas_v2 import ProjectSchema as ProjectSchemaV2 +from .schemas_v2 import BatchErrorSchema, ProjectSchema as ProjectSchemaV2 from ..app import db from ..auth import auth_required from ..auth.models import User @@ -40,7 +40,11 @@ project_version_created, push_finished, ) -from .permissions import ProjectPermissions, require_project_by_uuid, projects_query +from .permissions import ( + ProjectPermissions, + require_project_by_uuid, + projects_query, + require_project_by_many_uuids) from .public_api_controller import catch_sync_failure from .schemas import ( ProjectMemberSchema, @@ -445,3 +449,24 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N data = ProjectSchemaV2(many=True).dump(result) return jsonify(projects=data, count=total, page=page, per_page=per_page), 200 + +@auth_required +def list_batch_projects(): + """List projects by given list of UUIDs. Limit to 100 projects per request. + + :rtype: Dict[str: List[Project]] + """ + ids = request.json.get("ids", []) + if not ids: + abort(400, "No project UUIDs provided") + if len(ids) > 100: + abort(400, "BatchLimitExceeded") + + items = require_project_by_many_uuids(ids, ProjectPermissions.Read, expose=False) + project_objects = [i for i in items if not isinstance(i, dict)] + error_items = [i for i in items if isinstance(i, dict)] + + project_data = ProjectSchemaV2(many=True).dump(project_objects) + error_data = BatchErrorSchema(many=True).dump(error_items) + return jsonify(projects=project_data + error_data), 200 + \ No newline at end of file diff --git a/server/mergin/sync/schemas_v2.py b/server/mergin/sync/schemas_v2.py index d6b781ee..6311d503 100644 --- a/server/mergin/sync/schemas_v2.py +++ b/server/mergin/sync/schemas_v2.py @@ -46,3 +46,7 @@ class Meta: "workspace", "role", ) + +class BatchErrorSchema(ma.Schema): + id = fields.UUID(required=True) + error = fields.Integer(required=True) \ No newline at end of file diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index e058c589..b79e8e9b 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -693,3 +693,67 @@ def test_list_workspace_projects(client): # logout logout(client) assert client.get(url + "?page=1&per_page=10").status_code == 401 + + + + +def test_list_projects_in_batch_access_table(client): + admin = User.query.filter_by(username=DEFAULT_USER[0]).first() + test_workspace = create_workspace() + + # create two projects: one private, one public + private_proj = create_project("batch_private", test_workspace, admin) + public_proj = create_project("batch_public", test_workspace, admin) + + # mark public project as public + p = Project.query.get(public_proj.id) + p.public = True + db.session.commit() + + url = "/v2/projects/batch" + priv_id = str(private_proj.id) + pub_id = str(public_proj.id) + + # must be logged in (matches suite behavior) + login(client, DEFAULT_USER[0], DEFAULT_USER[1]) + + # missing ids -> 400 (connexion validation) + resp = client.post(url, json={}) + assert resp.status_code == 400 + + # F1=Y, F2=Y, F3=N -> Project detail (private, owner) + resp = client.post(url, json={"ids": [priv_id]}) + assert resp.status_code == 200 + assert len(resp.json["projects"]) == 1 + assert resp.json["projects"][0]["id"] == priv_id + assert "error" not in resp.json["projects"][0] + + # F1=Y, F2=Y, F3=Y -> Project detail (public, owner) + resp = client.post(url, json={"ids": [pub_id]}) + assert resp.status_code == 200 + assert len(resp.json["projects"]) == 1 + assert resp.json["projects"][0]["id"] == pub_id + assert "error" not in resp.json["projects"][0] + + # Create a second user with NO access to workspace/projects + user2 = add_user("user_batch", "password") + login(client, user2.username, "password") + + # F1=Y, F2=N, F3=Y -> Project detail (public, reader-only) + resp = client.post(url, json={"ids": [pub_id]}) + assert resp.status_code == 200 + assert len(resp.json["projects"]) == 1 + assert resp.json["projects"][0]["id"] == pub_id + assert "error" not in resp.json["projects"][0] + + # F1=Y, F2=N, F3=N -> Error 403 (private, logged in, no access) + resp = client.post(url, json={"ids": [priv_id]}) + assert resp.status_code == 200 + assert len(resp.json["projects"]) == 1 + assert resp.json["projects"][0]["id"] == priv_id + assert resp.json["projects"][0]["error"] == 403 + + # logout -> endpoint protected => 401 + logout(client) + assert client.post(url, json={"ids": [pub_id]}).status_code == 401 + From 235f1b142bd261b5fed41ba81abaa6c1b3794fec Mon Sep 17 00:00:00 2001 From: xkello Date: Fri, 30 Jan 2026 10:50:31 +0100 Subject: [PATCH 02/16] Make changes to code to better represent specs, split test logic for reuseability in future --- server/mergin/sync/permissions.py | 56 ++++++------- server/mergin/sync/public_api_v2.yaml | 46 +++------- .../mergin/sync/public_api_v2_controller.py | 24 ++++-- server/mergin/tests/test_permissions.py | 83 ++++++++++++++++++- server/mergin/tests/test_public_api_v2.py | 51 +++--------- 5 files changed, 141 insertions(+), 119 deletions(-) diff --git a/server/mergin/sync/permissions.py b/server/mergin/sync/permissions.py index 317f9e96..92d73ea5 100644 --- a/server/mergin/sync/permissions.py +++ b/server/mergin/sync/permissions.py @@ -248,7 +248,7 @@ def require_project_by_uuid( return project def require_project_by_many_uuids( - uuids: list[str], permission: ProjectPermissions, scheduled=False, expose=True + uuids: list[str], permission: ProjectPermissions ) -> list[dict]: """ Retrieves multiple projects by their UUIDs after validating existence, workspace status, and permissions. @@ -256,49 +256,45 @@ def require_project_by_many_uuids( Args: uuids (list[str]): The unique identifiers of the projects. permission (ProjectPermissions): The permission level required to access the projects. - scheduled (bool, optional): If ``True``, bypasses the check for projects marked for deletion. - expose (bool, optional): Controls security disclosure behavior on permission failure. - - If `True`: Returns 403 Forbidden (reveals project exists but access is denied). - - If `False`: Returns 404 Not Found (hides project existence for security). - Standard is that reading results in 404, while writing results in 403 """ - valid_uuids = [uuid for uuid in uuids if is_valid_uuid(uuid)] - if not valid_uuids: - abort(404) + valid_uuids = [uuid + if is_valid_uuid(uuid) + else abort(400, {"code" : "InvalidUUID", "detail" : "Invalid UUID format"}) + for uuid in uuids + ] - projects = Project.query.filter(Project.id.in_(valid_uuids)).filter( - Project.storage_params.isnot(None) + projects = ( + Project.query + .filter(Project.id.in_(valid_uuids)) + .filter(Project.storage_params.isnot(None)) + .all() ) - if not scheduled: - projects = projects.filter(Project.removed_at.is_(None)) - projects = projects.all() - if not projects: - abort(404) + by_id = {str(project.id): project for project in projects} filtered_projects = [] - for project in projects: - - workspace = project.workspace - if not workspace: + for uuid in valid_uuids: + + project = by_id.get(uuid) + if not project: + filtered_projects.append({"id": uuid, "error": 404}) continue - if not is_active_workspace(workspace): + + workspace = project.workspace + if not workspace or not is_active_workspace(workspace): + filtered_projects.append({"id": uuid, "error": 404}) continue - if not permission.check(project, current_user) and not expose: + if not permission.check(project, current_user): # logged in - NO, have acccess - NONE, public project - NO if current_user.is_anonymous and not project.public: # we don't want to tell anonymous user if a private project exists - filtered_projects.append({"id": project.id, "error": 404}) - continue + filtered_projects.append({"id": uuid, "error": 404}) # logged in - YES, have access - NO, public project - NO - elif not current_user.is_anonymous and not project.public: - filtered_projects.append({"id": project.id, "error": 403}) - continue + else: + filtered_projects.append({"id": uuid, "error": 403}) + continue filtered_projects.append(project) - if not filtered_projects: - abort(404) - return filtered_projects diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index 87660086..9adc4e64 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -384,18 +384,9 @@ paths: properties: ids: type: array - minItems: 1 - maxItems: 100 description: List of project UUIDs to fetch items: - type: string - format: uuid - examples: - example: - value: - ids: - - "9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f" - - "0f1e2d3c-4b5a-6978-9a0b-1c2d3e4f5a6b" + $ref: "#/components/parameters/ProjectId" responses: "200": description: Projects returned as a list of simple project objects and/or error objects. @@ -410,30 +401,15 @@ paths: items: oneOf: - $ref: "#/components/schemas/Project" - - $ref: "#/components/schemas/BatchError" - examples: - mixed: - value: - projects: - - id: "9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f" - name: survey - version: v2 - public: false - size: 17092380 - created_at: "2025-10-24T08:27:56Z" - updated_at: "2025-10-24T08:28:00.279699Z" - workspace: - id: 123 - name: mergin - role: reader - - id: 9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f - error: 404 - "400": - $ref: "#/components/responses/BadRequest" + - $ref: "#/components/schemas/BatchItemError" + "400": + description: Batch limit exceeded or one or more UUIDs were invalid + content: + application/problem+json: + schema: + $ref: "#/components/schemas/CustomError" "401": $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" x-openapi-router-controller: mergin.sync.public_api_v2_controller @@ -618,13 +594,11 @@ components: example: code: UploadError detail: "Project version could not be created (UploadError)" - BatchError: + BatchItemError: type: object properties: id: - type: string - format: uuid - example: "9b2b1a2b-3e73-4f1c-9f7a-1a2b3c4d5e6f" + $ref: "#/components/parameters/ProjectId" error: type: integer example: 404 diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 44b4bb6a..25d16af9 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -457,16 +457,22 @@ def list_batch_projects(): :rtype: Dict[str: List[Project]] """ ids = request.json.get("ids", []) - if not ids: - abort(400, "No project UUIDs provided") if len(ids) > 100: - abort(400, "BatchLimitExceeded") + abort(400, + { + "code" : "BatchLimitExceeded", + "detail" : "A maximum of 100 project UUIDS can be requested at once" + } + ) + + items = require_project_by_many_uuids(ids, ProjectPermissions.Read) - items = require_project_by_many_uuids(ids, ProjectPermissions.Read, expose=False) - project_objects = [i for i in items if not isinstance(i, dict)] - error_items = [i for i in items if isinstance(i, dict)] + projects = [] + for item in items: + if isinstance(item, dict): + projects.append(BatchErrorSchema().dump(item)) + else: + projects.append(ProjectSchemaV2().dump(item)) - project_data = ProjectSchemaV2(many=True).dump(project_objects) - error_data = BatchErrorSchema(many=True).dump(error_items) - return jsonify(projects=project_data + error_data), 200 + return jsonify(projects=projects), 200 \ No newline at end of file diff --git a/server/mergin/tests/test_permissions.py b/server/mergin/tests/test_permissions.py index 230961f0..87b49580 100644 --- a/server/mergin/tests/test_permissions.py +++ b/server/mergin/tests/test_permissions.py @@ -2,15 +2,28 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial +import pytest import datetime from flask_login import AnonymousUserMixin -from ..sync.permissions import require_project, ProjectPermissions -from ..sync.models import ProjectRole +from mergin.tests import DEFAULT_USER + +from ..sync.permissions import ( + require_project, + require_project_by_many_uuids, + ProjectPermissions, +) +from ..sync.models import Project, ProjectRole from ..auth.models import User from ..app import db from ..config import Configuration -from .utils import add_user, create_project, create_workspace +from .utils import ( + add_user, + create_project, + create_workspace, + login, + logout, +) def test_project_permissions(client): @@ -116,3 +129,67 @@ def test_project_permissions(client): assert ProjectPermissions.All.check(project, user) assert ProjectPermissions.Edit.check(project, user) assert ProjectPermissions.get_user_project_role(project, user) == ProjectRole.OWNER + +def test_permissions_require_project_by_many_uuids(client): + """Test require_project_by_many_uuids with various permission scenarios.""" + admin = User.query.filter_by(username=DEFAULT_USER[0]).first() + test_workspace = create_workspace() + + private_proj = create_project("batch_private", test_workspace, admin) + public_proj = create_project("batch_public", test_workspace, admin) + + p = Project.query.get(public_proj.id) + p.public = True + db.session.commit() + + priv_id = str(private_proj.id) + pub_id = str(public_proj.id) + + # First user with access to both projects + login(client, DEFAULT_USER[0], DEFAULT_USER[1]) + + with client: + client.get("/") + items = require_project_by_many_uuids([priv_id, pub_id], ProjectPermissions.Read) + assert len(items) == 2 + assert all(not isinstance(i, dict) for i in items) + + # Second user with no access to private project + user2 = add_user("user_batch", "password") + login(client, user2.username, "password") + + with client: + client.get("/") + items = require_project_by_many_uuids([pub_id, priv_id], ProjectPermissions.Read) + assert len(items) == 2 + + # public -> Project object (no dict error) + assert not isinstance(items[0], dict) + assert str(items[0].id) == pub_id + + # private -> dict error 403 + assert isinstance(items[1], dict) + assert items[1]["id"] == priv_id + assert items[1]["error"] == 403 + + # Logged-out (anonymous) user + logout(client) + + with client: + client.get("/") + items = require_project_by_many_uuids([priv_id, pub_id], ProjectPermissions.Read) + + assert len(items) == 2 + + # private project -> hidden + assert isinstance(items[0], dict) + assert items[0]["id"] == priv_id + assert items[0]["error"] == 404 + + # public project -> accessible + assert not isinstance(items[1], dict) + assert str(items[1].id) == pub_id + + # InvalidUUID + with pytest.raises(Exception): + require_project_by_many_uuids(["not-a-uuid"], ProjectPermissions.Read) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index b79e8e9b..1a790c1f 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -42,7 +42,7 @@ SyncFailuresHistory, Upload, ) -from mergin.sync.utils import get_chunk_location +from mergin.sync.utils import get_chunk_location from . import TMP_DIR, test_project, test_workspace_id, test_project_dir from .test_project_controller import ( CHUNK_SIZE, @@ -694,18 +694,14 @@ def test_list_workspace_projects(client): logout(client) assert client.get(url + "?page=1&per_page=10").status_code == 401 - - - -def test_list_projects_in_batch_access_table(client): +def test_list_projects_in_batch(client): + """Test batch project listing endpoint.""" admin = User.query.filter_by(username=DEFAULT_USER[0]).first() test_workspace = create_workspace() - # create two projects: one private, one public private_proj = create_project("batch_private", test_workspace, admin) public_proj = create_project("batch_public", test_workspace, admin) - # mark public project as public p = Project.query.get(public_proj.id) p.public = True db.session.commit() @@ -714,46 +710,19 @@ def test_list_projects_in_batch_access_table(client): priv_id = str(private_proj.id) pub_id = str(public_proj.id) - # must be logged in (matches suite behavior) - login(client, DEFAULT_USER[0], DEFAULT_USER[1]) + #login(client, DEFAULT_USER[0], DEFAULT_USER[1]) TODO: why needed? # missing ids -> 400 (connexion validation) resp = client.post(url, json={}) assert resp.status_code == 400 - # F1=Y, F2=Y, F3=N -> Project detail (private, owner) - resp = client.post(url, json={"ids": [priv_id]}) - assert resp.status_code == 200 - assert len(resp.json["projects"]) == 1 - assert resp.json["projects"][0]["id"] == priv_id - assert "error" not in resp.json["projects"][0] - - # F1=Y, F2=Y, F3=Y -> Project detail (public, owner) - resp = client.post(url, json={"ids": [pub_id]}) - assert resp.status_code == 200 - assert len(resp.json["projects"]) == 1 - assert resp.json["projects"][0]["id"] == pub_id - assert "error" not in resp.json["projects"][0] - - # Create a second user with NO access to workspace/projects - user2 = add_user("user_batch", "password") - login(client, user2.username, "password") - - # F1=Y, F2=N, F3=Y -> Project detail (public, reader-only) - resp = client.post(url, json={"ids": [pub_id]}) + # returns envelope with projects list + resp = client.post(url, json={"ids": [priv_id, pub_id]}) assert resp.status_code == 200 - assert len(resp.json["projects"]) == 1 - assert resp.json["projects"][0]["id"] == pub_id - assert "error" not in resp.json["projects"][0] + assert "projects" in resp.json + assert isinstance(resp.json["projects"], list) + assert len(resp.json["projects"]) == 2 - # F1=Y, F2=N, F3=N -> Error 403 (private, logged in, no access) - resp = client.post(url, json={"ids": [priv_id]}) - assert resp.status_code == 200 - assert len(resp.json["projects"]) == 1 - assert resp.json["projects"][0]["id"] == priv_id - assert resp.json["projects"][0]["error"] == 403 - - # logout -> endpoint protected => 401 + # endpoint protected => 401 logout(client) assert client.post(url, json={"ids": [pub_id]}).status_code == 401 - From aa1eb6de1a5db945aa82b98bde583a878131a1ec Mon Sep 17 00:00:00 2001 From: xkello Date: Fri, 30 Jan 2026 10:57:12 +0100 Subject: [PATCH 03/16] add name to CLA-signed-list --- LICENSES/CLA-signed-list.md | 1 + 1 file changed, 1 insertion(+) diff --git a/LICENSES/CLA-signed-list.md b/LICENSES/CLA-signed-list.md index bd7ccdfe..431143ad 100644 --- a/LICENSES/CLA-signed-list.md +++ b/LICENSES/CLA-signed-list.md @@ -21,3 +21,4 @@ C/ My company has custom contribution contract with Lutra Consulting Ltd. or I a * luxusko, 25th August 2023 * jozef-budac, 30th January 2024 * fernandinand, 13th March 2025 +* xkello, 26th January 2026 \ No newline at end of file From d44dfee057bee05696fbc77900937ed7f6c4c636 Mon Sep 17 00:00:00 2001 From: xkello Date: Fri, 30 Jan 2026 13:11:44 +0100 Subject: [PATCH 04/16] Add test fix where user was getting Project object instead of an error --- server/mergin/tests/test_permissions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/mergin/tests/test_permissions.py b/server/mergin/tests/test_permissions.py index 87b49580..c34315cd 100644 --- a/server/mergin/tests/test_permissions.py +++ b/server/mergin/tests/test_permissions.py @@ -154,6 +154,7 @@ def test_permissions_require_project_by_many_uuids(client): assert len(items) == 2 assert all(not isinstance(i, dict) for i in items) + Configuration.GLOBAL_READ = False # Second user with no access to private project user2 = add_user("user_batch", "password") login(client, user2.username, "password") From 1914dc33f3aed8a58b5468cc80eaab2a50cbda08 Mon Sep 17 00:00:00 2001 From: xkello Date: Fri, 30 Jan 2026 15:48:11 +0100 Subject: [PATCH 05/16] Fix 2 on test for permissions function --- server/mergin/tests/test_permissions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/mergin/tests/test_permissions.py b/server/mergin/tests/test_permissions.py index c34315cd..f191598f 100644 --- a/server/mergin/tests/test_permissions.py +++ b/server/mergin/tests/test_permissions.py @@ -154,7 +154,11 @@ def test_permissions_require_project_by_many_uuids(client): assert len(items) == 2 assert all(not isinstance(i, dict) for i in items) + # Reset global permissions for subsequent tests Configuration.GLOBAL_READ = False + Configuration.GLOBAL_WRITE = False + Configuration.GLOBAL_ADMIN = False + # Second user with no access to private project user2 = add_user("user_batch", "password") login(client, user2.username, "password") From a4ad4a3df81a1dcab5e63e08885eedf5da683572 Mon Sep 17 00:00:00 2001 From: xkello Date: Tue, 3 Feb 2026 09:46:49 +0100 Subject: [PATCH 06/16] Fix controller and permissions functions for better reusability --- server/mergin/sync/permissions.py | 66 ++++++------------- server/mergin/sync/public_api_v2.yaml | 12 ++-- .../mergin/sync/public_api_v2_controller.py | 49 ++++++++++---- server/mergin/tests/test_permissions.py | 46 ++++--------- server/mergin/tests/test_public_api_v2.py | 31 ++++++++- 5 files changed, 105 insertions(+), 99 deletions(-) diff --git a/server/mergin/sync/permissions.py b/server/mergin/sync/permissions.py index 92d73ea5..bf546004 100644 --- a/server/mergin/sync/permissions.py +++ b/server/mergin/sync/permissions.py @@ -247,55 +247,31 @@ def require_project_by_uuid( return project -def require_project_by_many_uuids( - uuids: list[str], permission: ProjectPermissions -) -> list[dict]: - """ - Retrieves multiple projects by their UUIDs after validating existence, workspace status, and permissions. - Args: - uuids (list[str]): The unique identifiers of the projects. - permission (ProjectPermissions): The permission level required to access the projects. +def check_project_permissions(project: Project, permission: ProjectPermissions) -> int | None: + """Check project permissions and return appropriate HTTP error code if check fails. + :param project: project + :type project: Project + :param permission: permission to check + :type permission: ProjectPermissions + :return: HTTP error code if permission check fails, None otherwise + :rtype: int | None """ - valid_uuids = [uuid - if is_valid_uuid(uuid) - else abort(400, {"code" : "InvalidUUID", "detail" : "Invalid UUID format"}) - for uuid in uuids - ] + + workspace = project.workspace + if not workspace or not is_active_workspace(workspace): + return 404 - projects = ( - Project.query - .filter(Project.id.in_(valid_uuids)) - .filter(Project.storage_params.isnot(None)) - .all() - ) - by_id = {str(project.id): project for project in projects} - - filtered_projects = [] - for uuid in valid_uuids: - - project = by_id.get(uuid) - if not project: - filtered_projects.append({"id": uuid, "error": 404}) - continue - - workspace = project.workspace - if not workspace or not is_active_workspace(workspace): - filtered_projects.append({"id": uuid, "error": 404}) - continue - - if not permission.check(project, current_user): - # logged in - NO, have acccess - NONE, public project - NO - if current_user.is_anonymous and not project.public: - # we don't want to tell anonymous user if a private project exists - filtered_projects.append({"id": uuid, "error": 404}) - # logged in - YES, have access - NO, public project - NO - else: - filtered_projects.append({"id": uuid, "error": 403}) - continue - filtered_projects.append(project) + if not permission.check(project, current_user): + # logged in - NO, have acccess - NONE, public project - NO + if current_user.is_anonymous: + # we don't want to tell anonymous user if a private project exists + return 404 + # logged in - YES, have access - NO, public project - NO + return 403 + + return None - return filtered_projects def get_upload(transaction_id): diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index 9adc4e64..c631a175 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -386,7 +386,7 @@ paths: type: array description: List of project UUIDs to fetch items: - $ref: "#/components/parameters/ProjectId" + $ref: "#/components/schemas/ProjectId" responses: "200": description: Projects returned as a list of simple project objects and/or error objects. @@ -504,9 +504,7 @@ components: description: UUID of the project required: true schema: - type: string - format: uuid - pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b + $ref: "#/components/schemas/ProjectId" WorkspaceId: name: workspace_id in: path @@ -515,6 +513,10 @@ components: schema: type: integer schemas: + ProjectId: + type: string + format: uuid + pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b # Errors CustomError: type: object @@ -598,7 +600,7 @@ components: type: object properties: id: - $ref: "#/components/parameters/ProjectId" + $ref: "#/components/schemas/ProjectId" error: type: integer example: 404 diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 25d16af9..2774d449 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -41,17 +41,23 @@ push_finished, ) from .permissions import ( - ProjectPermissions, + ProjectPermissions, + check_project_permissions, require_project_by_uuid, - projects_query, - require_project_by_many_uuids) + projects_query) from .public_api_controller import catch_sync_failure from .schemas import ( ProjectMemberSchema, UploadChunkSchema, ) from .storages.disk import move_to_tmp, save_to_file -from .utils import get_device_id, get_ip, get_user_agent, get_chunk_location +from .utils import ( + get_device_id, + get_ip, + get_user_agent, + get_chunk_location, + is_valid_uuid +) from .workspace import WorkspaceRole from ..utils import parse_order_params @@ -465,14 +471,35 @@ def list_batch_projects(): } ) - items = require_project_by_many_uuids(ids, ProjectPermissions.Read) + valid_uuids = [uuid + if is_valid_uuid(uuid) + else abort(400, {"code" : "InvalidUUID", "detail" : "Invalid UUID format"}) + for uuid in ids + ] + + projects = ( + Project.query + .filter(Project.id.in_(valid_uuids)) + .filter(Project.storage_params.isnot(None)) + .filter(Project.removed_at.is_(None)) + .all() + ) + by_id = {str(project.id): project for project in projects} + + filtered_projects = [] + for uuid in valid_uuids: + + project = by_id.get(uuid) + + if not project: + filtered_projects.append(BatchErrorSchema().dump({"id": uuid, "error": 404})) + continue - projects = [] - for item in items: - if isinstance(item, dict): - projects.append(BatchErrorSchema().dump(item)) + err = check_project_permissions(project, ProjectPermissions.Read) + if err is not None: + filtered_projects.append(BatchErrorSchema().dump({"id": uuid, "error": err})) else: - projects.append(ProjectSchemaV2().dump(item)) + filtered_projects.append(ProjectSchemaV2().dump(project)) - return jsonify(projects=projects), 200 + return jsonify(projects=filtered_projects), 200 \ No newline at end of file diff --git a/server/mergin/tests/test_permissions.py b/server/mergin/tests/test_permissions.py index f191598f..3fdb3552 100644 --- a/server/mergin/tests/test_permissions.py +++ b/server/mergin/tests/test_permissions.py @@ -10,7 +10,7 @@ from ..sync.permissions import ( require_project, - require_project_by_many_uuids, + check_project_permissions, ProjectPermissions, ) from ..sync.models import Project, ProjectRole @@ -130,8 +130,8 @@ def test_project_permissions(client): assert ProjectPermissions.Edit.check(project, user) assert ProjectPermissions.get_user_project_role(project, user) == ProjectRole.OWNER -def test_permissions_require_project_by_many_uuids(client): - """Test require_project_by_many_uuids with various permission scenarios.""" +def test_check_project_permissions(client): + """Test check_project_permissions with various permission scenarios.""" admin = User.query.filter_by(username=DEFAULT_USER[0]).first() test_workspace = create_workspace() @@ -142,17 +142,16 @@ def test_permissions_require_project_by_many_uuids(client): p.public = True db.session.commit() - priv_id = str(private_proj.id) - pub_id = str(public_proj.id) + priv_proj = Project.query.get(private_proj.id) + pub_proj = Project.query.get(public_proj.id) # First user with access to both projects login(client, DEFAULT_USER[0], DEFAULT_USER[1]) with client: client.get("/") - items = require_project_by_many_uuids([priv_id, pub_id], ProjectPermissions.Read) - assert len(items) == 2 - assert all(not isinstance(i, dict) for i in items) + assert check_project_permissions(priv_proj, ProjectPermissions.Read) is None + assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None # Reset global permissions for subsequent tests Configuration.GLOBAL_READ = False @@ -165,36 +164,13 @@ def test_permissions_require_project_by_many_uuids(client): with client: client.get("/") - items = require_project_by_many_uuids([pub_id, priv_id], ProjectPermissions.Read) - assert len(items) == 2 - - # public -> Project object (no dict error) - assert not isinstance(items[0], dict) - assert str(items[0].id) == pub_id - - # private -> dict error 403 - assert isinstance(items[1], dict) - assert items[1]["id"] == priv_id - assert items[1]["error"] == 403 + assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None + assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 403 # Logged-out (anonymous) user logout(client) with client: client.get("/") - items = require_project_by_many_uuids([priv_id, pub_id], ProjectPermissions.Read) - - assert len(items) == 2 - - # private project -> hidden - assert isinstance(items[0], dict) - assert items[0]["id"] == priv_id - assert items[0]["error"] == 404 - - # public project -> accessible - assert not isinstance(items[1], dict) - assert str(items[1].id) == pub_id - - # InvalidUUID - with pytest.raises(Exception): - require_project_by_many_uuids(["not-a-uuid"], ProjectPermissions.Read) + assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 404 + assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 1a790c1f..2a18b212 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -710,8 +710,6 @@ def test_list_projects_in_batch(client): priv_id = str(private_proj.id) pub_id = str(public_proj.id) - #login(client, DEFAULT_USER[0], DEFAULT_USER[1]) TODO: why needed? - # missing ids -> 400 (connexion validation) resp = client.post(url, json={}) assert resp.status_code == 400 @@ -722,7 +720,34 @@ def test_list_projects_in_batch(client): assert "projects" in resp.json assert isinstance(resp.json["projects"], list) assert len(resp.json["projects"]) == 2 + # Both projects returned as full objects for admin + for proj in resp.json["projects"]: + assert "id" in proj + assert "name" in proj # full project object + + # Reset global permissions + from ..config import Configuration + Configuration.GLOBAL_READ = False + Configuration.GLOBAL_WRITE = False + Configuration.GLOBAL_ADMIN = False + + # Second user with no access to private project + user2 = add_user("user_batch", "password") + login(client, user2.username, "password") + + resp = client.post(url, json={"ids": [pub_id, priv_id]}) + assert resp.status_code == 200 + projects = resp.json["projects"] + assert len(projects) == 2 + + # public -> full object + pub_result = next(p for p in projects if p.get("id") == pub_id) + assert "name" in pub_result + + # private -> error 403 + priv_result = next(p for p in projects if p.get("id") == priv_id) + assert priv_result["error"] == 403 - # endpoint protected => 401 + # Logged-out (anonymous) user - endpoint requires auth logout(client) assert client.post(url, json={"ids": [pub_id]}).status_code == 401 From 541c7245cab5ce4abbe23f34d8c8e5f3a948e273 Mon Sep 17 00:00:00 2001 From: xkello Date: Tue, 3 Feb 2026 11:23:11 +0100 Subject: [PATCH 07/16] Remove auth for public api endpoint, add for invalid UUID --- server/mergin/sync/public_api_v2_controller.py | 1 - server/mergin/tests/test_public_api_v2.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 2774d449..369e58ad 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -456,7 +456,6 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N data = ProjectSchemaV2(many=True).dump(result) return jsonify(projects=data, count=total, page=page, per_page=per_page), 200 -@auth_required def list_batch_projects(): """List projects by given list of UUIDs. Limit to 100 projects per request. diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 2a18b212..8034f6dc 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -714,6 +714,10 @@ def test_list_projects_in_batch(client): resp = client.post(url, json={}) assert resp.status_code == 400 + # invalid UUID -> 400 + resp = client.post(url, json={"ids": ["invalid-uuid", pub_id]}) + assert resp.status_code == 400 + # returns envelope with projects list resp = client.post(url, json={"ids": [priv_id, pub_id]}) assert resp.status_code == 200 @@ -726,7 +730,6 @@ def test_list_projects_in_batch(client): assert "name" in proj # full project object # Reset global permissions - from ..config import Configuration Configuration.GLOBAL_READ = False Configuration.GLOBAL_WRITE = False Configuration.GLOBAL_ADMIN = False From ddbabe5c407d56de4d77d2a25afdc68350d239f9 Mon Sep 17 00:00:00 2001 From: xkello Date: Tue, 3 Feb 2026 12:46:34 +0100 Subject: [PATCH 08/16] Fix test where it expected auth required and added some casses for anonymous user --- server/mergin/tests/test_public_api_v2.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 8034f6dc..24140385 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -751,6 +751,17 @@ def test_list_projects_in_batch(client): priv_result = next(p for p in projects if p.get("id") == priv_id) assert priv_result["error"] == 403 - # Logged-out (anonymous) user - endpoint requires auth + # Logged-out (anonymous) user - endpoint allows access to public projects, denies private logout(client) - assert client.post(url, json={"ids": [pub_id]}).status_code == 401 + resp = client.post(url, json={"ids": [pub_id, priv_id]}) + assert resp.status_code == 200 + projects = resp.json["projects"] + assert len(projects) == 2 + + # public -> full object + pub_result = next(p for p in projects if p.get("id") == pub_id) + assert "name" in pub_result + + # private -> error 404 (anonymous cannot access private) + priv_result = next(p for p in projects if p.get("id") == priv_id) + assert priv_result["error"] == 404 From 1c71d1208ff3660f66482115a824cab18b132baf Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 5 Feb 2026 13:26:08 +0100 Subject: [PATCH 09/16] Mock config --- server/mergin/tests/test_public_api_v2.py | 39 ++++++++++++----------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 24140385..010234f2 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -42,7 +42,7 @@ SyncFailuresHistory, Upload, ) -from mergin.sync.utils import get_chunk_location +from mergin.sync.utils import get_chunk_location from . import TMP_DIR, test_project, test_workspace_id, test_project_dir from .test_project_controller import ( CHUNK_SIZE, @@ -694,6 +694,7 @@ def test_list_workspace_projects(client): logout(client) assert client.get(url + "?page=1&per_page=10").status_code == 401 + def test_list_projects_in_batch(client): """Test batch project listing endpoint.""" admin = User.query.filter_by(username=DEFAULT_USER[0]).first() @@ -729,27 +730,29 @@ def test_list_projects_in_batch(client): assert "id" in proj assert "name" in proj # full project object - # Reset global permissions - Configuration.GLOBAL_READ = False - Configuration.GLOBAL_WRITE = False - Configuration.GLOBAL_ADMIN = False - # Second user with no access to private project user2 = add_user("user_batch", "password") login(client, user2.username, "password") - resp = client.post(url, json={"ids": [pub_id, priv_id]}) - assert resp.status_code == 200 - projects = resp.json["projects"] - assert len(projects) == 2 - - # public -> full object - pub_result = next(p for p in projects if p.get("id") == pub_id) - assert "name" in pub_result - - # private -> error 403 - priv_result = next(p for p in projects if p.get("id") == priv_id) - assert priv_result["error"] == 403 + with patch.object(Configuration, "GLOBAL_READ", False): + resp = client.post(url, json={"ids": [pub_id, priv_id]}) + assert resp.status_code == 200 + projects = resp.json["projects"] + assert len(projects) == 2 + + # public -> full object + pub_result = next(p for p in projects if p.get("id") == pub_id) + assert "name" in pub_result + + # private -> error 403 + priv_result = next(p for p in projects if p.get("id") == priv_id) + assert priv_result["error"] == 403 + + # global permission allows any user to list the project + with patch.object(Configuration, "GLOBAL_READ", True): + resp = client.post(url, json={"ids": [pub_id, priv_id]}) + priv_result = next(p for p in resp.json["projects"] if p.get("id") == priv_id) + assert "name" in priv_result # Logged-out (anonymous) user - endpoint allows access to public projects, denies private logout(client) From 8d073b8061260366450b19282f81851390c35cea Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 5 Feb 2026 13:26:53 +0100 Subject: [PATCH 10/16] Create project handler method to allow EE to check workspaces state --- server/mergin/sync/permissions.py | 9 +-- server/mergin/sync/project_handler.py | 10 ++++ .../mergin/sync/public_api_v2_controller.py | 59 +++++++++---------- server/mergin/tests/test_project_handler.py | 26 ++++++++ 4 files changed, 66 insertions(+), 38 deletions(-) diff --git a/server/mergin/sync/permissions.py b/server/mergin/sync/permissions.py index bf546004..e155020a 100644 --- a/server/mergin/sync/permissions.py +++ b/server/mergin/sync/permissions.py @@ -248,7 +248,9 @@ def require_project_by_uuid( return project -def check_project_permissions(project: Project, permission: ProjectPermissions) -> int | None: +def check_project_permissions( + project: Project, permission: ProjectPermissions +) -> int | None: """Check project permissions and return appropriate HTTP error code if check fails. :param project: project :type project: Project @@ -257,10 +259,6 @@ def check_project_permissions(project: Project, permission: ProjectPermissions) :return: HTTP error code if permission check fails, None otherwise :rtype: int | None """ - - workspace = project.workspace - if not workspace or not is_active_workspace(workspace): - return 404 if not permission.check(project, current_user): # logged in - NO, have acccess - NONE, public project - NO @@ -273,7 +271,6 @@ def check_project_permissions(project: Project, permission: ProjectPermissions) return None - def get_upload(transaction_id): upload = Upload.query.get_or_404(transaction_id) # upload to 'removed' projects is forbidden diff --git a/server/mergin/sync/project_handler.py b/server/mergin/sync/project_handler.py index 8299935a..7949dc20 100644 --- a/server/mergin/sync/project_handler.py +++ b/server/mergin/sync/project_handler.py @@ -28,3 +28,13 @@ def get_email_receivers(self, project: Project) -> List[User]: ) .all() ) + + @staticmethod + def get_projects_by_uuids(uuids: List[str]) -> [Project]: + """Gets non-deleted projects""" + return ( + Project.query.filter(Project.id.in_(uuids)) + .filter(Project.storage_params.isnot(None)) + .filter(Project.removed_at.is_(None)) + .all() + ) diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 369e58ad..b0b53756 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -42,9 +42,10 @@ ) from .permissions import ( ProjectPermissions, - check_project_permissions, - require_project_by_uuid, - projects_query) + check_project_permissions, + require_project_by_uuid, + projects_query, +) from .public_api_controller import catch_sync_failure from .schemas import ( ProjectMemberSchema, @@ -52,12 +53,12 @@ ) from .storages.disk import move_to_tmp, save_to_file from .utils import ( - get_device_id, - get_ip, - get_user_agent, - get_chunk_location, - is_valid_uuid -) + get_device_id, + get_ip, + get_user_agent, + get_chunk_location, + is_valid_uuid, +) from .workspace import WorkspaceRole from ..utils import parse_order_params @@ -456,49 +457,43 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N data = ProjectSchemaV2(many=True).dump(result) return jsonify(projects=data, count=total, page=page, per_page=per_page), 200 + def list_batch_projects(): """List projects by given list of UUIDs. Limit to 100 projects per request. :rtype: Dict[str: List[Project]] """ - ids = request.json.get("ids", []) + ids = list( + dict.fromkeys(request.json.get("ids", [])) + ) # remove duplicates while preserving the order if len(ids) > 100: - abort(400, + abort( + 400, { - "code" : "BatchLimitExceeded", - "detail" : "A maximum of 100 project UUIDS can be requested at once" - } + "code": "BatchLimitExceeded", + "detail": "A maximum of 100 project UUIDS can be requested at once", + }, ) - valid_uuids = [uuid - if is_valid_uuid(uuid) - else abort(400, {"code" : "InvalidUUID", "detail" : "Invalid UUID format"}) - for uuid in ids - ] - - projects = ( - Project.query - .filter(Project.id.in_(valid_uuids)) - .filter(Project.storage_params.isnot(None)) - .filter(Project.removed_at.is_(None)) - .all() - ) + projects = current_app.project_handler.get_projects_by_uuids(ids) by_id = {str(project.id): project for project in projects} filtered_projects = [] - for uuid in valid_uuids: - + for uuid in ids: project = by_id.get(uuid) if not project: - filtered_projects.append(BatchErrorSchema().dump({"id": uuid, "error": 404})) + filtered_projects.append( + BatchErrorSchema().dump({"id": uuid, "error": 404}) + ) continue err = check_project_permissions(project, ProjectPermissions.Read) if err is not None: - filtered_projects.append(BatchErrorSchema().dump({"id": uuid, "error": err})) + filtered_projects.append( + BatchErrorSchema().dump({"id": uuid, "error": err}) + ) else: filtered_projects.append(ProjectSchemaV2().dump(project)) return jsonify(projects=filtered_projects), 200 - \ No newline at end of file diff --git a/server/mergin/tests/test_project_handler.py b/server/mergin/tests/test_project_handler.py index 76040ca8..f453e0fb 100644 --- a/server/mergin/tests/test_project_handler.py +++ b/server/mergin/tests/test_project_handler.py @@ -1,3 +1,6 @@ +from datetime import datetime + +from . import DEFAULT_USER from ..sync.models import Project, ProjectRole from .utils import add_user, create_project, create_workspace from ..sync.project_handler import ProjectHandler @@ -51,3 +54,26 @@ def test_email_receivers(client): db.session.commit() receivers = project_handler.get_email_receivers(project) assert len(receivers) == 0 + + +def test_get_projects_by_uuids(client): + """Test getting projects with their UUIDs""" + project_handler = ProjectHandler() + test_workspace = create_workspace() + user = User.query.filter_by(username=DEFAULT_USER[0]).first() + p_found = create_project("p_found", test_workspace, user) + p_removed = create_project("p_removed", test_workspace, user) + p_removed.removed_at = datetime.now() + db.session.commit() + p_other = create_project("p_other", test_workspace, user) + ids = [ + str(p_found.id), + str(p_removed.id), + ] + + projects = project_handler.get_projects_by_uuids(ids) + returned_ids = [str(p.id) for p in projects] + assert str(p_found.id) in returned_ids + assert str(p_removed.id) not in returned_ids + assert str(p_other.id) not in returned_ids + assert len(projects) == 1 From 65d95c302a7943598f543ddc95be47268e60f691 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 5 Feb 2026 13:35:33 +0100 Subject: [PATCH 11/16] black . --- server/mergin/sync/schemas_v2.py | 3 ++- server/mergin/tests/test_permissions.py | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/server/mergin/sync/schemas_v2.py b/server/mergin/sync/schemas_v2.py index 6311d503..55b5be52 100644 --- a/server/mergin/sync/schemas_v2.py +++ b/server/mergin/sync/schemas_v2.py @@ -47,6 +47,7 @@ class Meta: "role", ) + class BatchErrorSchema(ma.Schema): id = fields.UUID(required=True) - error = fields.Integer(required=True) \ No newline at end of file + error = fields.Integer(required=True) diff --git a/server/mergin/tests/test_permissions.py b/server/mergin/tests/test_permissions.py index 3fdb3552..ff8e5df8 100644 --- a/server/mergin/tests/test_permissions.py +++ b/server/mergin/tests/test_permissions.py @@ -18,10 +18,10 @@ from ..app import db from ..config import Configuration from .utils import ( - add_user, - create_project, - create_workspace, - login, + add_user, + create_project, + create_workspace, + login, logout, ) @@ -130,6 +130,7 @@ def test_project_permissions(client): assert ProjectPermissions.Edit.check(project, user) assert ProjectPermissions.get_user_project_role(project, user) == ProjectRole.OWNER + def test_check_project_permissions(client): """Test check_project_permissions with various permission scenarios.""" admin = User.query.filter_by(username=DEFAULT_USER[0]).first() From 69641d6b94c6ca0e7b62c8b42fa72fe0513c80f8 Mon Sep 17 00:00:00 2001 From: xkello Date: Thu, 5 Feb 2026 16:13:18 +0100 Subject: [PATCH 12/16] Add test to check batch size, add custom error class --- server/mergin/sync/config.py | 2 ++ server/mergin/sync/errors.py | 5 +++++ server/mergin/sync/public_api_v2_controller.py | 18 +++++++----------- server/mergin/tests/test_public_api_v2.py | 8 ++++++++ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index e616a0ca..b6ec79f7 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -78,3 +78,5 @@ class Configuration(object): EXCLUDED_CLONE_FILENAMES = config( "EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv() ) + # max batch size for fetch projects in batch endpoint + MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int) diff --git a/server/mergin/sync/errors.py b/server/mergin/sync/errors.py index 35985ab9..18a9a202 100644 --- a/server/mergin/sync/errors.py +++ b/server/mergin/sync/errors.py @@ -95,3 +95,8 @@ def to_dict(self) -> Dict: class BigChunkError(ResponseError): code = "BigChunkError" detail = f"Chunk size exceeds maximum allowed size {MAX_CHUNK_SIZE} MB" + + +class BatchLimitError(ResponseError): + code = "BatchLimitExceeded" + detail = f"Batch size exceeds maximum allowed size {Configuration.MAX_BATCH_SIZE}" \ No newline at end of file diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index b0b53756..33828961 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -22,6 +22,7 @@ from ..auth.models import User from .errors import ( AnotherUploadRunning, + BatchLimitError, BigChunkError, DataSyncError, ProjectLocked, @@ -463,17 +464,12 @@ def list_batch_projects(): :rtype: Dict[str: List[Project]] """ - ids = list( - dict.fromkeys(request.json.get("ids", [])) - ) # remove duplicates while preserving the order - if len(ids) > 100: - abort( - 400, - { - "code": "BatchLimitExceeded", - "detail": "A maximum of 100 project UUIDS can be requested at once", - }, - ) + + # remove duplicates while preserving the order + ids = list(dict.fromkeys(request.json.get("ids", []))) + max_batch = current_app.config.get("MAX_BATCH_SIZE", 100) + if len(ids) > max_batch: + return BatchLimitError().response(400) projects = current_app.project_handler.get_projects_by_uuids(ids) by_id = {str(project.id): project for project in projects} diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 010234f2..b6916349 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -23,6 +23,7 @@ import pytest from datetime import datetime, timedelta, timezone import json +import uuid from mergin.app import db from mergin.config import Configuration @@ -768,3 +769,10 @@ def test_list_projects_in_batch(client): # private -> error 404 (anonymous cannot access private) priv_result = next(p for p in projects if p.get("id") == priv_id) assert priv_result["error"] == 404 + + # batch size limit: generate more than allowed uuids and expect error + max_batch = client.application.config.get("MAX_BATCH_SIZE", 100) + ids = [str(uuid.uuid4()) for _ in range(max_batch + 1)] + resp = client.post(url, json={"ids": ids}) + assert resp.status_code == 400 + assert resp.json["code"] == "BatchLimitExceeded" From 3e0345237b89a913a4c213f057a36e0d0bad377e Mon Sep 17 00:00:00 2001 From: xkello Date: Thu, 5 Feb 2026 16:13:41 +0100 Subject: [PATCH 13/16] Add test to check batch size, add custom error class --- server/mergin/sync/errors.py | 2 +- server/mergin/sync/public_api_v2_controller.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/mergin/sync/errors.py b/server/mergin/sync/errors.py index 18a9a202..a5f1fce6 100644 --- a/server/mergin/sync/errors.py +++ b/server/mergin/sync/errors.py @@ -99,4 +99,4 @@ class BigChunkError(ResponseError): class BatchLimitError(ResponseError): code = "BatchLimitExceeded" - detail = f"Batch size exceeds maximum allowed size {Configuration.MAX_BATCH_SIZE}" \ No newline at end of file + detail = f"Batch size exceeds maximum allowed size {Configuration.MAX_BATCH_SIZE}" diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 33828961..bdd8ef7d 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -464,9 +464,9 @@ def list_batch_projects(): :rtype: Dict[str: List[Project]] """ - + # remove duplicates while preserving the order - ids = list(dict.fromkeys(request.json.get("ids", []))) + ids = list(dict.fromkeys(request.json.get("ids", []))) max_batch = current_app.config.get("MAX_BATCH_SIZE", 100) if len(ids) > max_batch: return BatchLimitError().response(400) From ef37a44ceec1b8ae22199f04321b1d74816ecb51 Mon Sep 17 00:00:00 2001 From: xkello Date: Thu, 5 Feb 2026 16:18:46 +0100 Subject: [PATCH 14/16] Fix setting global configs for permissions test --- server/mergin/tests/test_permissions.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/server/mergin/tests/test_permissions.py b/server/mergin/tests/test_permissions.py index ff8e5df8..73bf5ab4 100644 --- a/server/mergin/tests/test_permissions.py +++ b/server/mergin/tests/test_permissions.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial import pytest +from unittest.mock import patch import datetime from flask_login import AnonymousUserMixin @@ -154,19 +155,17 @@ def test_check_project_permissions(client): assert check_project_permissions(priv_proj, ProjectPermissions.Read) is None assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None - # Reset global permissions for subsequent tests - Configuration.GLOBAL_READ = False - Configuration.GLOBAL_WRITE = False - Configuration.GLOBAL_ADMIN = False - - # Second user with no access to private project - user2 = add_user("user_batch", "password") - login(client, user2.username, "password") - - with client: - client.get("/") - assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None - assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 403 + # Second user with no access to private project (ensure global perms disabled) + with patch.object(Configuration, "GLOBAL_READ", False), patch.object( + Configuration, "GLOBAL_WRITE", False + ), patch.object(Configuration, "GLOBAL_ADMIN", False): + user2 = add_user("user_batch", "password") + login(client, user2.username, "password") + + with client: + client.get("/") + assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None + assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 403 # Logged-out (anonymous) user logout(client) From 595a55440f374facb79f7524f6207f1158c3667e Mon Sep 17 00:00:00 2001 From: xkello Date: Fri, 6 Feb 2026 11:02:46 +0100 Subject: [PATCH 15/16] Move passing IDs to function args --- server/mergin/sync/public_api_v2_controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index bdd8ef7d..449ce445 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -459,14 +459,16 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N return jsonify(projects=data, count=total, page=page, per_page=per_page), 200 -def list_batch_projects(): +def list_batch_projects(ids): """List projects by given list of UUIDs. Limit to 100 projects per request. + :param ids: List of project UUIDs + :type ids: List[str] :rtype: Dict[str: List[Project]] """ # remove duplicates while preserving the order - ids = list(dict.fromkeys(request.json.get("ids", []))) + ids = list(dict.fromkeys(ids)) max_batch = current_app.config.get("MAX_BATCH_SIZE", 100) if len(ids) > max_batch: return BatchLimitError().response(400) From 69645211f42057b51b6d70a3cee2371350c3f067 Mon Sep 17 00:00:00 2001 From: xkello Date: Fri, 6 Feb 2026 11:41:28 +0100 Subject: [PATCH 16/16] Fix bad connexion binding --- server/mergin/sync/public_api_v2_controller.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 449ce445..a5820bb7 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -459,16 +459,15 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N return jsonify(projects=data, count=total, page=page, per_page=per_page), 200 -def list_batch_projects(ids): +def list_batch_projects(body): """List projects by given list of UUIDs. Limit to 100 projects per request. :param ids: List of project UUIDs :type ids: List[str] :rtype: Dict[str: List[Project]] """ - + ids = list(dict.fromkeys(body.get("ids", []))) # remove duplicates while preserving the order - ids = list(dict.fromkeys(ids)) max_batch = current_app.config.get("MAX_BATCH_SIZE", 100) if len(ids) > max_batch: return BatchLimitError().response(400)