", methods=["DELETE"])
+@bp.response(200, TagSchema)
+@bp.alt_response(401, description="Unauthorized")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="Not found")
+@jwt_required()
+def delete_tag(uri, tag):
+ """Remove a single tag from the dataset."""
+ username = get_jwt_identity()
+ if not dservercore.utils_auth.user_exists(username):
+ abort(401)
+
+ uri = url_suffix_to_uri(uri)
+
+ try:
+ # Get existing tags
+ existing_tags = get_tags_from_uri_by_user(username, uri)
+ # Remove tag if present
+ if tag in existing_tags:
+ existing_tags.remove(tag)
+ # Set updated tags
+ tags = set_tags_for_uri_by_user(username, uri, existing_tags)
+ except AuthorizationError:
+ abort(403)
+ except UnknownURIError:
+ current_app.logger.info("UnknownURIError")
+ abort(404)
+
+ return {"tags": tags}
+
+
diff --git a/dservercore/templates/index.html b/dservercore/templates/index.html
deleted file mode 100644
index 7be1599..0000000
--- a/dservercore/templates/index.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
- dserver
-
-
- dservercore
- {{ num_datasets }} registered datasets.
-
-
diff --git a/dservercore/user_routes.py b/dservercore/user_routes.py
index 0ff1ddd..f2d94be 100644
--- a/dservercore/user_routes.py
+++ b/dservercore/user_routes.py
@@ -9,14 +9,21 @@
)
from flask_smorest.pagination import PaginationParameters
+import dservercore
import dservercore.utils
import dservercore.utils_auth
from dservercore.blueprint import Blueprint
from dservercore.sort import SortParameters, ASCENDING, DESCENDING
from dservercore.schemas import SummarySchema
-from dservercore.sql_models import User, UserSchema, UserWithPermissionsSchema
-from dservercore.utils import register_user, delete_user, summary_of_datasets_by_user
+from dservercore.sql_models import User, UserSchema, UserUpdateSchema, UserWithPermissionsSchema
+from dservercore.utils import (
+ register_user,
+ delete_user,
+ summary_of_datasets_by_user,
+ get_permission_info,
+ register_permissions,
+)
bp = Blueprint("users", __name__, url_prefix="/users")
@@ -118,6 +125,50 @@ def user_put(data: UserSchema, username):
return "", success_code
+@bp.route("/", methods=["PATCH"])
+@bp.arguments(UserUpdateSchema)
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="Not found")
+@jwt_required()
+def user_patch(data: UserUpdateSchema, username):
+ """Partially update a user in dserver.
+
+ Only provided fields will be updated. Supports updating:
+ - is_admin: boolean
+ - display_name: string (can be null to clear)
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ # Get the user object and update only provided fields
+ user = User.query.filter_by(username=username).first()
+
+ if data.get("is_admin") is not None:
+ user.is_admin = data["is_admin"]
+
+ if "display_name" in data and data.get("display_name") is not None:
+ user.display_name = data["display_name"]
+ elif data.get("display_name") is None and "display_name" in data:
+ # Explicitly setting to None/null clears the display_name
+ user.display_name = None
+
+ dservercore.sql_db.session.commit()
+
+ return dservercore.utils.get_user_info(username)
+
+
@bp.route("/", methods=["DELETE"])
@bp.response(200)
@bp.alt_response(401, description="Not registered")
@@ -166,4 +217,146 @@ def user_summary_get(username):
abort(404)
summary = summary_of_datasets_by_user(username)
- return summary
\ No newline at end of file
+ return summary
+
+
+@bp.route("//search/", methods=["POST"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@bp.alt_response(409, description="Permission already exists")
+@jwt_required()
+def user_search_permission_post(username, base_uri):
+ """Grant search permission to a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_search_permissions"]:
+ abort(409)
+
+ permissions["users_with_search_permissions"].append(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
+
+
+@bp.route("//search/", methods=["DELETE"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@jwt_required()
+def user_search_permission_delete(username, base_uri):
+ """Revoke search permission from a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_search_permissions"]:
+ permissions["users_with_search_permissions"].remove(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
+
+
+@bp.route("//register/", methods=["POST"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@bp.alt_response(409, description="Permission already exists")
+@jwt_required()
+def user_register_permission_post(username, base_uri):
+ """Grant register permission to a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_register_permissions"]:
+ abort(409)
+
+ permissions["users_with_register_permissions"].append(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
+
+
+@bp.route("//register/", methods=["DELETE"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@jwt_required()
+def user_register_permission_delete(username, base_uri):
+ """Revoke register permission from a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_register_permissions"]:
+ permissions["users_with_register_permissions"].remove(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
\ No newline at end of file
diff --git a/dservercore/utils.py b/dservercore/utils.py
index 6d296f2..bf35c1d 100644
--- a/dservercore/utils.py
+++ b/dservercore/utils.py
@@ -1,6 +1,6 @@
"""Utility functions."""
-from datetime import datetime, date
+from datetime import datetime, date, timezone
import importlib
import json
import logging
@@ -12,6 +12,7 @@
from flask_smorest.pagination import PaginationParameters
from sqlalchemy.sql import exists
+import dtoolcore
import dtoolcore.utils
from dservercore import (
@@ -222,12 +223,13 @@ def register_user(username, data):
Example input structure::
- {"is_admin": True},
+ {"is_admin": True, "display_name": "John Doe"},
If a user is missing in the system it is skipped. The ``is_admin`` status
- defaults to False.
+ defaults to False. The ``display_name`` is optional.
"""
is_admin = data.get("is_admin", False)
+ display_name = data.get("display_name")
# User already exists, update
if sql_db.session.query(exists().where(User.username == username)).scalar():
@@ -235,8 +237,10 @@ def register_user(username, data):
sql_db.session.query(User).filter_by(username=username).all()
):
sqlalch_user_obj.is_admin = is_admin
+ if "display_name" in data:
+ sqlalch_user_obj.display_name = display_name
else: # user does not exist yet, create
- user = User(username=username, is_admin=is_admin)
+ user = User(username=username, is_admin=is_admin, display_name=display_name)
sql_db.session.add(user)
sql_db.session.commit()
@@ -325,14 +329,14 @@ def update_users(users):
Example input structure::
[
- {"username": "magic.mirror", "is_admin": True},
+ {"username": "magic.mirror", "is_admin": True, "display_name": "Magic Mirror"},
{"username": "snow.white", "is_admin": False},
{"username": "dopey"},
{"username": "sleepy"},
]
If a user is missing in the system it is skipped. The ``is_admin`` status
- defaults to False.
+ defaults to False. The ``display_name`` is only updated if provided.
"""
for user in users:
username = user["username"]
@@ -342,6 +346,8 @@ def update_users(users):
sql_db.session.query(User).filter_by(username=username).all()
): # NOQA
sqlalch_user_obj.is_admin = is_admin
+ if "display_name" in user:
+ sqlalch_user_obj.display_name = user.get("display_name")
sql_db.session.commit()
@@ -437,21 +443,25 @@ def summary_of_datasets_by_user(username):
datasets_per_creator = {}
datasets_per_base_uri = {}
datasets_per_tag = {}
+ datasets_per_uploader = {}
size_in_bytes_per_creator = {}
size_in_bytes_per_base_uri = {}
size_in_bytes_per_tag = {}
+ size_in_bytes_per_uploader = {}
total_size_in_bytes = 0
for ds in datasets:
user = ds["creator_username"]
uri = ds["base_uri"]
+ # size_in_bytes is optional on registration; treat missing as 0.
+ size_in_bytes = ds.get("size_in_bytes") or 0
datasets_per_creator[user] = datasets_per_creator.get(user, 0) + 1
datasets_per_base_uri[uri] = datasets_per_base_uri.get(uri, 0) + 1
- size_in_bytes_per_creator[user] = size_in_bytes_per_creator.get(user, 0) + ds["size_in_bytes"]
- size_in_bytes_per_base_uri[uri] = size_in_bytes_per_base_uri.get(uri, 0) + ds["size_in_bytes"]
+ size_in_bytes_per_creator[user] = size_in_bytes_per_creator.get(user, 0) + size_in_bytes
+ size_in_bytes_per_base_uri[uri] = size_in_bytes_per_base_uri.get(uri, 0) + size_in_bytes
# All datasets should have the "tags" key. However, it could be the
# case that a dataset in the database prior to version 0.14.0 fails
@@ -461,9 +471,18 @@ def summary_of_datasets_by_user(username):
if "tags" in ds:
for tag in ds["tags"]:
datasets_per_tag[tag] = datasets_per_tag.get(tag, 0) + 1
- size_in_bytes_per_tag[tag] = size_in_bytes_per_tag.get(tag, 0) + ds["size_in_bytes"]
+ size_in_bytes_per_tag[tag] = size_in_bytes_per_tag.get(tag, 0) + size_in_bytes
+
+ # Server-asserted registration provenance; datasets registered
+ # outside an authenticated request have no uploader.
+ uploader = ds.get("uploaded_by")
+ if uploader is not None:
+ datasets_per_uploader[uploader] = \
+ datasets_per_uploader.get(uploader, 0) + 1
+ size_in_bytes_per_uploader[uploader] = \
+ size_in_bytes_per_uploader.get(uploader, 0) + size_in_bytes
- total_size_in_bytes += ds["size_in_bytes"]
+ total_size_in_bytes += size_in_bytes
summary = {
"number_of_datasets": len(datasets),
@@ -476,7 +495,10 @@ def summary_of_datasets_by_user(username):
"size_in_bytes_per_base_uri": size_in_bytes_per_base_uri,
"tags": sorted(datasets_per_tag.keys()),
"datasets_per_tag": datasets_per_tag,
- "size_in_bytes_per_tag": size_in_bytes_per_tag
+ "size_in_bytes_per_tag": size_in_bytes_per_tag,
+ "uploaders": sorted(datasets_per_uploader.keys()),
+ "datasets_per_uploader": datasets_per_uploader,
+ "size_in_bytes_per_uploader": size_in_bytes_per_uploader
}
return summary
@@ -800,6 +822,8 @@ def create_dataset_obj_from_admin_metadata(admin_metadata):
created_at=created_at,
number_of_items=number_of_items,
size_in_bytes=size_in_bytes,
+ uploaded_by=admin_metadata.get("uploaded_by"),
+ uploaded_at=admin_metadata.get("uploaded_at"),
)
return dataset
@@ -838,6 +862,19 @@ def delete_dataset_admin_metadata(uri):
return uri
+def _get_authenticated_identity():
+ """Return the JWT identity of the current request, or None.
+
+ Registrations can also happen outside an authenticated request
+ context (Flask CLI, indexer, webhooks); those yield None.
+ """
+ try:
+ from flask_jwt_extended import get_jwt_identity
+ return get_jwt_identity()
+ except Exception:
+ return None
+
+
def register_dataset(dataset_info):
"""Put-update a dataset in the lookup server. Put is idempotent."""
@@ -848,6 +885,14 @@ def register_dataset(dataset_info):
if not base_uri_exists(base_uri):
raise (ValidationError("Base URI is not registered: {}".format(base_uri))) # NOQA
+ # Server-asserted registration provenance. Unlike the client-claimed
+ # creator_username, uploaded_by reflects the identity that actually
+ # authenticated this registration; it is never accepted from clients.
+ dataset_info = dict(dataset_info)
+ dataset_info["uploaded_by"] = _get_authenticated_identity()
+ dataset_info["uploaded_at"] = datetime.now(
+ timezone.utc).replace(tzinfo=None)
+
# Take a copy as register_dataset_descriptive_metadata makes
# changes to the dictionary, in particular it changes the
# types of the dates to datetime objects.
@@ -1068,3 +1113,262 @@ def get_annotations_from_uri_by_user(username, uri):
raise (AuthorizationError())
return current_app.retrieve.get_annotations(uri)
+
+
+def _update_tags_in_storage(uri, tags):
+ """Update tags in the actual storage backend using dtoolcore.
+
+ :param uri: dataset URI
+ :param tags: list of tags to set
+ """
+ try:
+ # Load the dataset
+ dataset = dtoolcore.DataSet.from_uri(uri)
+
+ # Get existing tags from storage
+ existing_tags = set(dataset.list_tags())
+ new_tags = set(tags)
+
+ # Delete tags that are no longer present
+ for tag in existing_tags - new_tags:
+ logger.debug(f"Deleting tag '{tag}' from storage for {uri}")
+ try:
+ dataset.delete_tag(tag)
+ except Exception as e:
+ logger.warning(f"Failed to delete tag '{tag}': {e}")
+
+ # Add new tags (put_tag includes name validation)
+ for tag in new_tags - existing_tags:
+ logger.debug(f"Adding tag '{tag}' to storage for {uri}")
+ dataset.put_tag(tag)
+
+ logger.info(f"Updated tags in storage for {uri}")
+
+ except Exception as e:
+ # Log but don't fail - database update succeeded
+ logger.warning(f"Failed to update tags in storage for {uri}: {e}")
+
+
+def _update_annotations_in_storage(uri, annotations):
+ """Update annotations in the actual storage backend using dtoolcore.
+
+ :param uri: dataset URI
+ :param annotations: dictionary of annotations to set
+ """
+ try:
+ # Load the dataset
+ dataset = dtoolcore.DataSet.from_uri(uri)
+
+ # Get existing annotation names from storage
+ existing_annotations = set(dataset.list_annotation_names())
+ new_annotations = set(annotations.keys())
+
+ # Delete annotations that are no longer present
+ for annotation_name in existing_annotations - new_annotations:
+ logger.debug(f"Deleting annotation '{annotation_name}' from storage for {uri}")
+ try:
+ dataset.delete_annotation(annotation_name)
+ except Exception as e:
+ logger.warning(f"Failed to delete annotation '{annotation_name}': {e}")
+
+ # Add/update annotations (put_annotation includes name validation)
+ for annotation_name, value in annotations.items():
+ logger.debug(f"Setting annotation '{annotation_name}' in storage for {uri}")
+ dataset.put_annotation(annotation_name, value)
+
+ logger.info(f"Updated annotations in storage for {uri}")
+
+ except Exception as e:
+ logger.warning(f"Failed to update annotations in storage for {uri}: {e}")
+
+
+def set_tags_for_uri_by_user(username, uri, tags):
+ """Set tags for a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param tags: list of tags to set
+ :returns: updated list of tags
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ user = get_user_obj(username)
+
+ base_uri_str = uri.rsplit("/", 1)[0]
+ base_uri = _get_base_uri_obj(base_uri_str)
+ if base_uri is None:
+ raise (UnknownBaseURIError())
+
+ # Check if user has register permissions (write access) for this base URI
+ if base_uri not in user.register_base_uris:
+ raise (AuthorizationError())
+
+ # Update tags in both search and retrieve plugins (database)
+ if hasattr(current_app.search, "set_tags"):
+ current_app.search.set_tags(uri, tags)
+ else:
+ logger.warning("Search plugin has no method 'set_tags'")
+
+ if hasattr(current_app.retrieve, "set_tags"):
+ current_app.retrieve.set_tags(uri, tags)
+ else:
+ logger.warning("Retrieve plugin has no method 'set_tags'")
+
+ # Update tags in actual storage backend
+ _update_tags_in_storage(uri, tags)
+
+ return tags
+
+
+def set_annotations_for_uri_by_user(username, uri, annotations):
+ """Set all annotations for a dataset (replaces existing annotations).
+
+ :param username: username
+ :param uri: dataset URI
+ :param annotations: dictionary of annotations to set
+ :returns: updated annotations dictionary
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ user = get_user_obj(username)
+
+ base_uri_str = uri.rsplit("/", 1)[0]
+ base_uri = _get_base_uri_obj(base_uri_str)
+ if base_uri is None:
+ raise (UnknownBaseURIError())
+
+ # Check if user has register permissions (write access) for this base URI
+ if base_uri not in user.register_base_uris:
+ raise (AuthorizationError())
+
+ # Update annotations in both search and retrieve plugins (database)
+ if hasattr(current_app.search, "set_annotations"):
+ current_app.search.set_annotations(uri, annotations)
+ else:
+ logger.warning("Search plugin has no method 'set_annotations'")
+
+ if hasattr(current_app.retrieve, "set_annotations"):
+ current_app.retrieve.set_annotations(uri, annotations)
+ else:
+ logger.warning("Retrieve plugin has no method 'set_annotations'")
+
+ # Update annotations in actual storage backend
+ _update_annotations_in_storage(uri, annotations)
+
+ return annotations
+
+
+def set_annotation_for_uri_by_user(username, uri, annotation_name, value):
+ """Set a single annotation for a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param annotation_name: name of the annotation
+ :param value: value to set for the annotation
+ :returns: updated annotations dictionary
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ # Get existing annotations
+ existing_annotations = get_annotations_from_uri_by_user(username, uri)
+
+ # Update the specific annotation
+ existing_annotations[annotation_name] = value
+
+ # Set all annotations
+ return set_annotations_for_uri_by_user(username, uri, existing_annotations)
+
+
+def delete_annotation_for_uri_by_user(username, uri, annotation_name):
+ """Delete a single annotation from a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param annotation_name: name of the annotation to delete
+ :returns: updated annotations dictionary
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ # Get existing annotations
+ existing_annotations = get_annotations_from_uri_by_user(username, uri)
+
+ # Remove the annotation if it exists
+ if annotation_name in existing_annotations:
+ del existing_annotations[annotation_name]
+
+ # Set all annotations
+ return set_annotations_for_uri_by_user(username, uri, existing_annotations)
+
+
+def _update_readme_in_storage(uri, content):
+ """Update README in the actual storage backend using dtoolcore.
+
+ :param uri: dataset URI
+ :param content: README content string
+ """
+ try:
+ # Load the dataset
+ dataset = dtoolcore.DataSet.from_uri(uri)
+
+ # Update the README using put_readme
+ logger.debug(f"Updating README in storage for {uri}")
+ dataset.put_readme(content)
+
+ logger.info(f"Updated README in storage for {uri}")
+
+ except Exception as e:
+ # Log but don't fail - database update succeeded
+ logger.warning(f"Failed to update README in storage for {uri}: {e}")
+
+
+def set_readme_for_uri_by_user(username, uri, content):
+ """Set README content for a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param content: README content string
+ :returns: updated README content
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ user = get_user_obj(username)
+
+ base_uri_str = uri.rsplit("/", 1)[0]
+ base_uri = _get_base_uri_obj(base_uri_str)
+ if base_uri is None:
+ raise (UnknownBaseURIError())
+
+ # Check if user has register permissions (write access) for this base URI
+ if base_uri not in user.register_base_uris:
+ raise (AuthorizationError())
+
+ # Update README in both search and retrieve plugins (database)
+ if hasattr(current_app.search, "set_readme"):
+ current_app.search.set_readme(uri, content)
+ else:
+ logger.warning("Search plugin has no method 'set_readme'")
+
+ if hasattr(current_app.retrieve, "set_readme"):
+ current_app.retrieve.set_readme(uri, content)
+ else:
+ logger.warning("Retrieve plugin has no method 'set_readme'")
+
+ # Update README in actual storage backend
+ _update_readme_in_storage(uri, content)
+
+ return content
diff --git a/dservercore/utils_auth.py b/dservercore/utils_auth.py
index 88e9f10..81dbf58 100644
--- a/dservercore/utils_auth.py
+++ b/dservercore/utils_auth.py
@@ -11,6 +11,7 @@
from flask_jwt_extended import jwt_required as flask_jwt_required
from flask_jwt_extended import get_jwt_identity as flask_get_jwt_identity
+from flask_jwt_extended import get_jwt as flask_get_jwt
def jwt_required(*jwt_required_args, **jwt_required_kwargs):
@@ -34,6 +35,14 @@ def get_jwt_identity():
return flask_get_jwt_identity()
+def get_jwt():
+ """Return JWT payload or empty dict if JWT authorisation disabled."""
+ if current_app.config.get("DISABLE_JWT_AUTHORISATION"):
+ return {}
+ else:
+ return flask_get_jwt()
+
+
def _get_user_obj(username):
return User.query.filter_by(username=username).first()
diff --git a/pyproject.toml b/pyproject.toml
index d437f7e..015b50a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,33 +1,33 @@
[build-system]
-requires = ["setuptools>=42", "setuptools_scm[toml]>=6.3"]
-build-backend = "setuptools.build_meta"
+requires = ["flit_scm"]
+build-backend = "flit_scm:buildapi"
[project]
name = "dservercore"
description = "Web API to register/lookup/search for dtool dataset metadata"
readme = "README.rst"
-license = {file = "LICENSE"}
+license = {text = "MIT"}
authors = [
{name = "Tjelvar Olsson", email = "tjelvar.olsson@gmail.com"}
]
dynamic = ["version"]
-requires-python = ">=3.8"
+requires-python = ">=3.9"
dependencies = [
- "setuptools",
- "flask<3",
- "pymongo",
- "alembic",
- "flask-sqlalchemy",
- "flask-migrate",
- "flask-pymongo",
- "flask-marshmallow",
- "flask-smorest",
- "marshmallow-sqlalchemy",
- "flask-cors",
- "dtoolcore>=3.18.0",
- "flask-jwt-extended[asymmetric_crypto]>=4.6.0",
- "pyyaml"
- ]
+ "flask<3",
+ "pymongo",
+ "alembic",
+ "flask-sqlalchemy",
+ "flask-migrate",
+ "flask-pymongo",
+ "flask-marshmallow",
+ "flask-smorest",
+ "marshmallow-sqlalchemy",
+ "flask-cors",
+ "dtoolcore>=3.18.0",
+ "flask-jwt-extended[asymmetric_crypto]>=4.6.0",
+ "pyyaml",
+ "marshmallow<4.0.0"
+]
[project.optional-dependencies]
test = [
@@ -48,16 +48,23 @@ Documentation = "https://dservercore.readthedocs.io"
Repository = "https://github.com/jic-dtool/dservercore"
Changelog = "https://github.com/jic-dtool/dservercore/blob/main/CHANGELOG.rst"
+[project.entry-points."flask.commands"]
+base_uri = "dservercore.cli:base_uri_cli"
+user = "dservercore.cli:user_cli"
+config = "dservercore.cli:config_cli"
+dataset = "dservercore.cli:dataset_cli"
+
+[tool.flit.module]
+name = "dservercore"
+
[tool.setuptools_scm]
version_scheme = "guess-next-dev"
local_scheme = "no-local-version"
write_to = "dservercore/version.py"
-[tool.setuptools]
-packages = ["dservercore"]
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "--cov=dservercore --cov-report=term-missing"
-[project.entry-points."flask.commands"]
-"base_uri" = "dservercore.cli:base_uri_cli"
-"user" = "dservercore.cli:user_cli"
-"config" = "dservercore.cli:config_cli"
-"dataset" = "dservercore.cli:dataset_cli"
+[tool.flake8]
+exclude = ["venv*", "env*", ".tox", ".git", "*.egg", "build", "docs", "migrations", "jwt-spike"]
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 52056ef..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,10 +0,0 @@
-[flake8]
-exclude=venv*,env*,.tox,.git,*.egg,build,docs,migrations,jwt-spike
-
-[tool:pytest]
-testpaths = tests
-#addopts = --cov=dservercore --cov-report=term-missing --disable-warnings
-addopts = --cov=dservercore --cov-report=term-missing
-
-[cov:run]
-source = dserver
diff --git a/tests/conftest.py b/tests/conftest.py
index 1468e5c..b649446 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -93,10 +93,10 @@ def tmp_app(request):
"SECRET_KEY": "secret",
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -105,6 +105,9 @@ def tmp_app(request):
"JWT_TOKEN_LOCATION": "headers",
"JWT_HEADER_NAME": "Authorization",
"JWT_HEADER_TYPE": "Bearer",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
@@ -151,10 +154,10 @@ def tmp_app_with_users(request):
"SECRET_KEY": "secret",
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -163,6 +166,9 @@ def tmp_app_with_users(request):
"JWT_TOKEN_LOCATION": "headers",
"JWT_HEADER_NAME": "Authorization",
"JWT_HEADER_TYPE": "Bearer",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
@@ -224,10 +230,10 @@ def tmp_app_with_data(request):
"OPENAPI_VERSION": '3.0.2',
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -236,6 +242,9 @@ def tmp_app_with_data(request):
"JWT_TOKEN_LOCATION": "headers",
"JWT_HEADER_NAME": "Authorization",
"JWT_HEADER_TYPE": "Bearer",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
@@ -348,14 +357,17 @@ def tmp_cli_runner(request):
"OPENAPI_VERSION": '3.0.2',
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
- "SECRET_KEY": "dev"
+ "SECRET_KEY": "dev",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 71c4d61..25e08f6 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -19,6 +19,7 @@ def test_cli_register_user(tmp_cli_runner): # NOQA
new_user = get_user_obj("admin")
expected_content = {
"username": "admin",
+ "display_name": None,
"is_admin": True,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -29,6 +30,7 @@ def test_cli_register_user(tmp_cli_runner): # NOQA
new_user = get_user_obj("dopey")
expected_content = {
"username": "dopey",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -55,12 +57,14 @@ def test_cli_list_users(tmp_cli_runner): # NOQA
expected_content = [
{
"username": "admin",
+ "display_name": None,
"is_admin": True,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": "grumpy",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
diff --git a/tests/test_me_routes.py b/tests/test_me_routes.py
index 8a5f146..7f9cc85 100644
--- a/tests/test_me_routes.py
+++ b/tests/test_me_routes.py
@@ -22,7 +22,8 @@ def test_me_route(
'is_admin': True,
'register_permissions_on_base_uris': [],
'search_permissions_on_base_uris': [],
- 'username': 'snow-white'
+ 'username': 'snow-white',
+ 'display_name': None
})
r = tmp_app_with_users_client.get(
@@ -45,7 +46,8 @@ def test_me_route(
'is_admin': False,
'register_permissions_on_base_uris': ['s3://snow-white'],
'search_permissions_on_base_uris': ['s3://snow-white'],
- 'username': 'grumpy'
+ 'username': 'grumpy',
+ 'display_name': None
})
headers = dict(Authorization="Bearer " + grumpy_token)
@@ -102,7 +104,10 @@ def test_me_summary_route(
"size_in_bytes_per_base_uri": {},
"tags": [],
"datasets_per_tag": {},
- "size_in_bytes_per_tag": {}
+ "size_in_bytes_per_tag": {},
+ "uploaders": [],
+ "datasets_per_uploader": {},
+ "size_in_bytes_per_uploader": {},
}
assert expected_content == json.loads(r.data.decode("utf-8"))
@@ -127,6 +132,9 @@ def test_me_summary_route(
"tags": ["evil", "fruit", "good"],
"datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3},
"size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0},
+ "uploaders": [],
+ "datasets_per_uploader": {},
+ "size_in_bytes_per_uploader": {},
}
assert expected_content == json.loads(r.data.decode("utf-8"))
@@ -147,7 +155,10 @@ def test_me_summary_route(
"size_in_bytes_per_base_uri": {},
"tags": [],
"datasets_per_tag": {},
- "size_in_bytes_per_tag": {}
+ "size_in_bytes_per_tag": {},
+ "uploaders": [],
+ "datasets_per_uploader": {},
+ "size_in_bytes_per_uploader": {},
}
assert expected_content == json.loads(r.data.decode("utf-8"))
diff --git a/tests/test_readme_write_authorization.py b/tests/test_readme_write_authorization.py
new file mode 100644
index 0000000..9e7c502
--- /dev/null
+++ b/tests/test_readme_write_authorization.py
@@ -0,0 +1,27 @@
+"""Audit regression test: PUT /readmes must map missing register
+permission to 403, not crash with an uncaught AuthorizationError (500)."""
+
+from urllib.parse import quote
+
+
+def test_set_readme_without_register_permission_403(
+ tmp_app_with_users_client, sleepy_token): # NOQA
+ """sleepy has search but not register permission on s3://snow-white."""
+ uri = "s3://snow-white/af6727bf-29c7-43dd-b42f-a5d7ede28337"
+ response = tmp_app_with_users_client.put(
+ f"/readmes/{quote(uri, safe='')}",
+ json={"readme": "---\nproject: hijack"},
+ headers={"Authorization": f"Bearer {sleepy_token}"},
+ )
+ assert response.status_code == 403
+
+
+def test_set_readme_unregistered_user_401(
+ tmp_app_with_users_client, noone_token): # NOQA
+ uri = "s3://snow-white/af6727bf-29c7-43dd-b42f-a5d7ede28337"
+ response = tmp_app_with_users_client.put(
+ f"/readmes/{quote(uri, safe='')}",
+ json={"readme": "---\nproject: x"},
+ headers={"Authorization": f"Bearer {noone_token}"},
+ )
+ assert response.status_code == 401
diff --git a/tests/test_sorting.py b/tests/test_sorting.py
index 22c0b29..dcf3da2 100644
--- a/tests/test_sorting.py
+++ b/tests/test_sorting.py
@@ -212,9 +212,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client):
assert len(data) == 3
- assert data[0] == {'is_admin': False, 'username': 'grumpy'}
- assert data[1] == {'is_admin': False, 'username': 'sleepy'}
- assert data[2] == {'is_admin': True, 'username': 'snow-white'}
+ assert data[0] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'}
+ assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'}
+ assert data[2] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'}
# order 2
response = tmp_app_with_dummy_sort_blueprint_client.get(
@@ -226,9 +226,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client):
assert len(data) == 3
- assert data[0] == {'is_admin': True, 'username': 'snow-white'}
- assert data[1] == {'is_admin': False, 'username': 'grumpy'}
- assert data[2] == {'is_admin': False, 'username': 'sleepy'}
+ assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'}
+ assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'}
+ assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'}
# order 3
response = tmp_app_with_dummy_sort_blueprint_client.get(
@@ -240,9 +240,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client):
assert len(data) == 3
- assert data[0] == {'is_admin': True, 'username': 'snow-white'}
- assert data[1] == {'is_admin': False, 'username': 'sleepy'}
- assert data[2] == {'is_admin': False, 'username': 'grumpy'}
+ assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'}
+ assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'}
+ assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'}
# order 4
response = tmp_app_with_dummy_sort_blueprint_client.get(
@@ -254,9 +254,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client):
assert len(data) == 3
- assert data[0] == {'is_admin': False, 'username': 'sleepy'}
- assert data[1] == {'is_admin': False, 'username': 'grumpy'}
- assert data[2] == {'is_admin': True, 'username': 'snow-white'}
+ assert data[0] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'}
+ assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'}
+ assert data[2] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'}
# order 6 - default
response = tmp_app_with_dummy_sort_blueprint_client.get(
@@ -268,9 +268,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client):
assert len(data) == 3
- assert data[0] == {'is_admin': True, 'username': 'snow-white'}
- assert data[1] == {'is_admin': False, 'username': 'grumpy'}
- assert data[2] == {'is_admin': False, 'username': 'sleepy'}
+ assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'}
+ assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'}
+ assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'}
@pytest.mark.parametrize("header_name", ("X-Dummy-Name", None))
diff --git a/tests/test_sql_dataset_utils.py b/tests/test_sql_dataset_utils.py
index 83e367e..59363f7 100644
--- a/tests/test_sql_dataset_utils.py
+++ b/tests/test_sql_dataset_utils.py
@@ -26,6 +26,8 @@ def test_sql_dataset_helper_functions(tmp_app_client): # NOQA
"created_at": 1536236399.19497,
"number_of_items": 47,
"size_in_bytes": 5741810,
+ "uploaded_by": None,
+ "uploaded_at": None,
}
# BaseURI not registered yet.
diff --git a/tests/test_sql_list_datasets_by_user.py b/tests/test_sql_list_datasets_by_user.py
index 5bd3369..84f1fb4 100644
--- a/tests/test_sql_list_datasets_by_user.py
+++ b/tests/test_sql_list_datasets_by_user.py
@@ -35,6 +35,8 @@ def test_list_datasets_by_user(tmp_app_client): # NOQA
"created_at": 1536236399.19497,
"number_of_items": 7283,
"size_in_bytes": 5741810,
+ "uploaded_by": None,
+ "uploaded_at": None,
}
admin_metadata_2 = {
"base_uri": base_uri_2,
@@ -46,6 +48,8 @@ def test_list_datasets_by_user(tmp_app_client): # NOQA
"created_at": 1536236399.19497,
"number_of_items": 392,
"size_in_bytes": 574181,
+ "uploaded_by": None,
+ "uploaded_at": None,
}
register_dataset_admin_metadata(admin_metadata_1)
register_dataset_admin_metadata(admin_metadata_2)
diff --git a/tests/test_summary_of_datasets_by_user.py b/tests/test_summary_of_datasets_by_user.py
index d9ef2a0..eb5c630 100644
--- a/tests/test_summary_of_datasets_by_user.py
+++ b/tests/test_summary_of_datasets_by_user.py
@@ -19,5 +19,8 @@ def test_summary_of_datasets_by_user(tmp_app_with_data_client): # NOQA
"tags": ["evil", "fruit", "good"],
"datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3},
"size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0},
+ "uploaders": [],
+ "datasets_per_uploader": {},
+ "size_in_bytes_per_uploader": {},
}
assert summary == exected_output
diff --git a/tests/test_uploaded_by.py b/tests/test_uploaded_by.py
new file mode 100644
index 0000000..dbe9f30
--- /dev/null
+++ b/tests/test_uploaded_by.py
@@ -0,0 +1,148 @@
+"""Registration provenance: the server records which authenticated user
+registered a dataset (uploaded_by/uploaded_at), as a server-asserted fact
+separate from the client-claimed creator_username."""
+
+import json
+from urllib.parse import quote
+
+UUID = "11111111-2222-4333-8444-555555555555"
+BASE_URI = "s3://snow-white"
+URI = f"{BASE_URI}/{UUID}"
+
+
+def dataset_payload(**overrides):
+ payload = {
+ "uuid": UUID,
+ "base_uri": BASE_URI,
+ "uri": URI,
+ "name": "provenance-test",
+ "type": "dataset",
+ "readme": "---\ndescription: provenance test",
+ "manifest": {
+ "dtoolcore_version": "3.18.0",
+ "hash_function": "md5sum_hexdigest",
+ "items": {},
+ },
+ "creator_username": "fr_lp1029", # noisy machine-local claim
+ "frozen_at": "1536238185.881941",
+ "annotations": {},
+ "tags": [],
+ }
+ payload.update(overrides)
+ return payload
+
+
+def register(client, token, payload):
+ return client.put(
+ f"/uris/{quote(URI, safe='')}",
+ data=json.dumps(payload),
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ },
+ )
+
+
+def test_register_via_api_records_authenticated_uploader(
+ tmp_app_with_users_client, grumpy_token): # NOQA
+ from dservercore.sql_models import Dataset
+
+ r = register(tmp_app_with_users_client, grumpy_token, dataset_payload())
+ assert r.status_code == 201
+
+ dataset = Dataset.query.filter_by(uri=URI).first()
+ assert dataset is not None
+ # creator_username keeps the client-side claim ...
+ assert dataset.creator_username == "fr_lp1029"
+ # ... while the server records who actually registered it.
+ assert dataset.uploaded_by == "grumpy"
+ assert dataset.uploaded_at is not None
+
+ # Exposed in API responses (list datasets via empty search).
+ r = tmp_app_with_users_client.post(
+ "/uris",
+ data=json.dumps({}),
+ headers={
+ "Authorization": f"Bearer {grumpy_token}",
+ "Content-Type": "application/json",
+ },
+ )
+ hits = [d for d in r.get_json() if d["uri"] == URI]
+ assert len(hits) == 1
+ assert hits[0]["uploaded_by"] == "grumpy"
+ assert hits[0]["uploaded_at"] is not None
+
+
+def test_register_without_request_context_leaves_uploader_empty(
+ tmp_app_with_users): # NOQA
+ """CLI/indexer registrations run outside a JWT request context."""
+ from dservercore.sql_models import Dataset
+ from dservercore.utils import register_dataset
+
+ register_dataset(dataset_payload(name="cli-registered"))
+
+ dataset = Dataset.query.filter_by(uri=URI).first()
+ assert dataset is not None
+ assert dataset.uploaded_by is None
+
+
+def test_client_cannot_forge_uploaded_by(
+ tmp_app_with_users_client, grumpy_token): # NOQA
+ """uploaded_by is server-asserted; the register schema must reject it."""
+ payload = dataset_payload(uploaded_by="mallory")
+ r = register(tmp_app_with_users_client, grumpy_token, payload)
+ assert r.status_code == 422
+
+
+def test_search_filter_by_uploaded_by(
+ tmp_app_with_users_client, grumpy_token): # NOQA
+ # Check if the search plugin supports filtering by uploaded_by.
+ search_plugin_supports_uploaded_by = True
+ try:
+ from dserver_search_plugin_mongo.utils_search import VALID_MONGO_QUERY_KEYS
+ if "uploaded_by" not in VALID_MONGO_QUERY_KEYS:
+ search_plugin_supports_uploaded_by = False
+ except ImportError:
+ pass
+
+ if not search_plugin_supports_uploaded_by:
+ import pytest
+ pytest.skip("Search plugin does not support uploaded_by filter")
+
+ r = register(tmp_app_with_users_client, grumpy_token, dataset_payload())
+ assert r.status_code == 201
+
+ def search(query):
+ r = tmp_app_with_users_client.post(
+ "/uris",
+ data=json.dumps(query),
+ headers={
+ "Authorization": f"Bearer {grumpy_token}",
+ "Content-Type": "application/json",
+ },
+ )
+ assert r.status_code == 200
+ return r.get_json()
+
+ hits = search({"uploaded_by": ["grumpy"]})
+ assert len(hits) == 1
+ assert hits[0]["uri"] == URI
+ assert hits[0]["uploaded_by"] == "grumpy"
+
+ assert search({"uploaded_by": ["someone-else"]}) == []
+
+
+def test_summary_includes_uploader_facets(
+ tmp_app_with_users_client, grumpy_token): # NOQA
+ r = register(tmp_app_with_users_client, grumpy_token, dataset_payload())
+ assert r.status_code == 201
+
+ r = tmp_app_with_users_client.get(
+ "/me/summary",
+ headers={"Authorization": f"Bearer {grumpy_token}"},
+ )
+ assert r.status_code == 200
+ summary = r.get_json()
+ assert "grumpy" in summary["uploaders"]
+ assert summary["datasets_per_uploader"]["grumpy"] == 1
+ assert summary["size_in_bytes_per_uploader"]["grumpy"] == 0
diff --git a/tests/test_uri_routes.py b/tests/test_uri_routes.py
index ddf391e..faa428f 100644
--- a/tests/test_uri_routes.py
+++ b/tests/test_uri_routes.py
@@ -5,6 +5,18 @@
from dservercore.utils import uri_to_url_suffix
+
+def _strip_registration_provenance(obj):
+ """Remove the server-stamped uploaded_by/uploaded_at fields (dynamic
+ timestamp) from API responses before comparing against static
+ expectations."""
+ entries = obj if isinstance(obj, list) else [obj]
+ for entry in entries:
+ entry.pop("uploaded_by", None)
+ entry.pop("uploaded_at", None)
+ return obj
+
+
def test_list_uri_route(
tmp_app_with_data_client,
grumpy_token,
@@ -64,7 +76,7 @@ def test_list_uri_route(
expected_order = [
bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by name and base uri
r = tmp_app_with_data_client.get(
@@ -78,7 +90,7 @@ def test_list_uri_route(
expected_order = [
bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white,
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by uuid and uri
r = tmp_app_with_data_client.get(
@@ -92,7 +104,7 @@ def test_list_uri_route(
expected_order = [
oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by uuid and uri with pagination
r = tmp_app_with_data_client.get(
@@ -106,7 +118,7 @@ def test_list_uri_route(
expected_order = [
bad_apples_on_mr_men, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
r = tmp_app_with_data_client.get(
"/uris",
@@ -119,7 +131,7 @@ def test_list_uri_route(
expected_order = [
oranges_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# check response for others
r = tmp_app_with_data_client.get(
@@ -164,7 +176,8 @@ def test_get_dataset_by_uri_route(
headers=headers
)
assert r.status_code == 200
- assert json.loads(r.data.decode("utf-8")) == bad_apples_on_mr_men
+ assert _strip_registration_provenance(
+ json.loads(r.data.decode("utf-8"))) == bad_apples_on_mr_men
# user has no search permission on base uri
r = tmp_app_with_data_client.get(
@@ -283,7 +296,11 @@ def test_put_dataset_by_uri_route(
"number_of_items": 1,
"size_in_bytes": 5741810,
}
- assert get_admin_metadata_from_uri(uri) == expected_content
+ admin_metadata = get_admin_metadata_from_uri(uri)
+ # Server-stamped provenance: registered via the API as grumpy.
+ assert admin_metadata.pop("uploaded_by") == "grumpy"
+ assert isinstance(admin_metadata.pop("uploaded_at"), float)
+ assert admin_metadata == expected_content
assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1
@@ -338,7 +355,11 @@ def test_put_dataset_by_uri_route(
# in retrieve plugin, and the implementation of a register_dataset method
# is not enforced on a plugin
# assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"]
- assert get_admin_metadata_from_uri(uri) == expected_content
+ admin_metadata = get_admin_metadata_from_uri(uri)
+ # Server-stamped provenance: registered via the API as grumpy.
+ assert admin_metadata.pop("uploaded_by") == "grumpy"
+ assert isinstance(admin_metadata.pop("uploaded_at"), float)
+ assert admin_metadata == expected_content
assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1
# URI suffix and dataset uri attribute disagree
@@ -450,7 +471,11 @@ def test_put_dataset_route_when_created_at_is_string(
"number_of_items": 1,
"size_in_bytes": 5741810,
}
- assert get_admin_metadata_from_uri(uri) == expected_content
+ admin_metadata = get_admin_metadata_from_uri(uri)
+ # Server-stamped provenance: registered via the API as grumpy.
+ assert admin_metadata.pop("uploaded_by") == "grumpy"
+ assert isinstance(admin_metadata.pop("uploaded_at"), float)
+ assert admin_metadata == expected_content
assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1
@@ -578,7 +603,7 @@ def test_uris_get_route_with_query(
expected_order = [
bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# Make sure that timestamps are returned as float.
first_entry = hits[0]
@@ -657,7 +682,7 @@ def test_uris_get_route_with_query(
expected_order = [
bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by name and base uri
r = tmp_app_with_data_client.get(
@@ -671,7 +696,7 @@ def test_uris_get_route_with_query(
expected_order = [
bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white,
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by uuid and uri
r = tmp_app_with_data_client.get(
@@ -685,7 +710,7 @@ def test_uris_get_route_with_query(
expected_order = [
oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by uuid and uri with pagination
r = tmp_app_with_data_client.get(
@@ -699,7 +724,7 @@ def test_uris_get_route_with_query(
expected_order = [
bad_apples_on_mr_men, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
r = tmp_app_with_data_client.get(
"/uris",
@@ -712,7 +737,7 @@ def test_uris_get_route_with_query(
expected_order = [
oranges_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# Search for apples (in README).
headers = dict(Authorization="Bearer " + grumpy_token)
@@ -729,7 +754,7 @@ def test_uris_get_route_with_query(
expected_order = [
bad_apples_on_mr_men, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# Search for U00096 (in manifest).
headers = dict(Authorization="Bearer " + grumpy_token)
@@ -746,7 +771,7 @@ def test_uris_get_route_with_query(
expected_order = [
bad_apples_on_mr_men, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# Search for crazystuff (in annotaitons).
headers = dict(Authorization="Bearer " + grumpy_token)
@@ -762,7 +787,7 @@ def test_uris_get_route_with_query(
expected_order = [
oranges_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
def test_uris_post_route_with_query(
@@ -867,7 +892,7 @@ def test_uris_post_route_with_query(
expected_order = [
bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by name and base uri
r = tmp_app_with_data_client.post(
@@ -883,7 +908,7 @@ def test_uris_post_route_with_query(
expected_order = [
bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white,
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by uuid and uri
r = tmp_app_with_data_client.post(
@@ -899,7 +924,7 @@ def test_uris_post_route_with_query(
expected_order = [
oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# sorting by uuid and uri with pagination
r = tmp_app_with_data_client.post(
@@ -915,7 +940,7 @@ def test_uris_post_route_with_query(
expected_order = [
bad_apples_on_mr_men, bad_apples_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
r = tmp_app_with_data_client.post(
"/uris",
@@ -930,7 +955,7 @@ def test_uris_post_route_with_query(
expected_order = [
oranges_on_snow_white
]
- assert hits == expected_order
+ assert _strip_registration_provenance(hits) == expected_order
# Search for apples (in README).
headers = dict(Authorization="Bearer " + grumpy_token)
diff --git a/tests/test_uri_search_by_uuid.py b/tests/test_uri_search_by_uuid.py
new file mode 100644
index 0000000..cab6163
--- /dev/null
+++ b/tests/test_uri_search_by_uuid.py
@@ -0,0 +1,56 @@
+"""Audit regression test: searching by UUID through the API must match
+datasets stored in the search index.
+
+The marshmallow UUID field used to deserialize query UUIDs into
+uuid.UUID objects, which pymongo encodes as BSON Binary - these never
+matched the uuid *strings* stored in the search index, so a uuids
+filter silently returned no results.
+"""
+
+import json
+
+
+def test_search_uris_by_uuid(tmp_app_with_data_client, grumpy_token): # NOQA
+ """tmp_app_with_data registers datasets with this UUID for grumpy."""
+ uuid = "af6727bf-29c7-43dd-b42f-a5d7ede28337"
+ response = tmp_app_with_data_client.post(
+ "/uris",
+ data=json.dumps({"uuids": [uuid]}),
+ headers={
+ "Authorization": "Bearer {}".format(grumpy_token),
+ "Content-Type": "application/json",
+ },
+ )
+ assert response.status_code == 200
+ hits = response.get_json()
+ assert len(hits) > 0
+ assert all(hit["uuid"] == uuid for hit in hits)
+
+
+def test_search_uris_by_uuid_uppercase_normalized(
+ tmp_app_with_data_client, grumpy_token): # NOQA
+ """UUIDs are case-insensitive; queries must normalize."""
+ uuid = "AF6727BF-29C7-43DD-B42F-A5D7EDE28337"
+ response = tmp_app_with_data_client.post(
+ "/uris",
+ data=json.dumps({"uuids": [uuid]}),
+ headers={
+ "Authorization": "Bearer {}".format(grumpy_token),
+ "Content-Type": "application/json",
+ },
+ )
+ assert response.status_code == 200
+ assert len(response.get_json()) > 0
+
+
+def test_search_uris_by_invalid_uuid_rejected(
+ tmp_app_with_data_client, grumpy_token): # NOQA
+ response = tmp_app_with_data_client.post(
+ "/uris",
+ data=json.dumps({"uuids": ["not-a-uuid"]}),
+ headers={
+ "Authorization": "Bearer {}".format(grumpy_token),
+ "Content-Type": "application/json",
+ },
+ )
+ assert response.status_code == 422
diff --git a/tests/test_user_routes.py b/tests/test_user_routes.py
index 326be66..32f44b7 100644
--- a/tests/test_user_routes.py
+++ b/tests/test_user_routes.py
@@ -19,7 +19,8 @@ def test_put_user_route(
'is_admin': False,
'register_permissions_on_base_uris': ['s3://snow-white'],
'search_permissions_on_base_uris': ['s3://snow-white'],
- 'username': 'grumpy'
+ 'username': 'grumpy',
+ 'display_name': None
})
headers = dict(Authorization="Bearer " + snowwhite_token)
@@ -52,7 +53,8 @@ def test_put_user_route(
'is_admin': True,
'register_permissions_on_base_uris': ['s3://snow-white'],
'search_permissions_on_base_uris': ['s3://snow-white'],
- 'username': 'grumpy'
+ 'username': 'grumpy',
+ 'display_name': None
})
r = tmp_app_with_users_client.get(
@@ -71,7 +73,8 @@ def test_put_user_route(
'is_admin': False,
'register_permissions_on_base_uris': ['s3://snow-white'],
'search_permissions_on_base_uris': ['s3://snow-white'],
- 'username': 'grumpy'
+ 'username': 'grumpy',
+ 'display_name': None
})
r = tmp_app_with_users_client.put(
@@ -117,7 +120,8 @@ def test_put_user_route(
'is_admin': True,
'register_permissions_on_base_uris': [],
'search_permissions_on_base_uris': [],
- 'username': 'dopey'
+ 'username': 'dopey',
+ 'display_name': None
})
assert user_response == expected_response
@@ -162,7 +166,8 @@ def test_get_user_route(
'is_admin': True,
'register_permissions_on_base_uris': [],
'search_permissions_on_base_uris': [],
- 'username': 'snow-white'
+ 'username': 'snow-white',
+ 'display_name': None
})
r = tmp_app_with_users_client.get(
@@ -185,7 +190,8 @@ def test_get_user_route(
'is_admin': False,
'register_permissions_on_base_uris': ['s3://snow-white'],
'search_permissions_on_base_uris': ['s3://snow-white'],
- 'username': 'grumpy'
+ 'username': 'grumpy',
+ 'display_name': None
})
r = tmp_app_with_users_client.get(
@@ -321,9 +327,15 @@ def test_list_user_route(
data = r.json
assert data == [
- {'is_admin': False, 'username': 'grumpy'},
- {'is_admin': False, 'username': 'sleepy'},
- {'is_admin': True, 'username': 'snow-white'}
+ {'display_name': None, 'is_admin': False, 'username': 'grumpy',
+ 'register_permissions_on_base_uris': ['s3://snow-white'],
+ 'search_permissions_on_base_uris': ['s3://snow-white']},
+ {'display_name': None, 'is_admin': False, 'username': 'sleepy',
+ 'register_permissions_on_base_uris': [],
+ 'search_permissions_on_base_uris': ['s3://snow-white']},
+ {'display_name': None, 'is_admin': True, 'username': 'snow-white',
+ 'register_permissions_on_base_uris': [],
+ 'search_permissions_on_base_uris': []}
]
headers = dict(Authorization="Bearer " + snowwhite_token)
@@ -336,9 +348,15 @@ def test_list_user_route(
data = r.json
assert data == [
- {'is_admin': True, 'username': 'snow-white'},
- {'is_admin': False, 'username': 'sleepy'},
- {'is_admin': False, 'username': 'grumpy'}
+ {'display_name': None, 'is_admin': True, 'username': 'snow-white',
+ 'register_permissions_on_base_uris': [],
+ 'search_permissions_on_base_uris': []},
+ {'display_name': None, 'is_admin': False, 'username': 'sleepy',
+ 'register_permissions_on_base_uris': [],
+ 'search_permissions_on_base_uris': ['s3://snow-white']},
+ {'display_name': None, 'is_admin': False, 'username': 'grumpy',
+ 'register_permissions_on_base_uris': ['s3://snow-white'],
+ 'search_permissions_on_base_uris': ['s3://snow-white']}
]
# Only admins allowed.
@@ -385,7 +403,10 @@ def test_dataset_summary_route(
"size_in_bytes_per_base_uri": {},
"tags": [],
"datasets_per_tag": {},
- "size_in_bytes_per_tag": {}
+ "size_in_bytes_per_tag": {},
+ "uploaders": [],
+ "datasets_per_uploader": {},
+ "size_in_bytes_per_uploader": {},
}
assert expected_content == json.loads(r.data.decode("utf-8"))
@@ -410,6 +431,9 @@ def test_dataset_summary_route(
"tags": ["evil", "fruit", "good"],
"datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3},
"size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0},
+ "uploaders": [],
+ "datasets_per_uploader": {},
+ "size_in_bytes_per_uploader": {},
}
assert expected_content == json.loads(r.data.decode("utf-8"))
@@ -439,7 +463,10 @@ def test_dataset_summary_route(
"size_in_bytes_per_base_uri": {},
"tags": [],
"datasets_per_tag": {},
- "size_in_bytes_per_tag": {}
+ "size_in_bytes_per_tag": {},
+ "uploaders": [],
+ "datasets_per_uploader": {},
+ "size_in_bytes_per_uploader": {},
}
assert expected_content == json.loads(r.data.decode("utf-8"))
@@ -489,3 +516,273 @@ def test_dataset_summary_route(
headers=headers
)
assert r.status_code == 404
+
+
+def test_grant_search_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test granting search permission via POST."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ # 1 - Admin grants search permission to sleepy on s3://snow-white
+ # sleepy already has search permission, so we need to first check
+ # the initial state then test with a new base_uri
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # First, register a new base URI for testing
+ from dservercore.utils import register_base_uri
+ register_base_uri("s3://test-bucket")
+
+ # Grant search permission - should succeed with 200
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://test-bucket" in user_response["search_permissions_on_base_uris"]
+
+ # 2 - Try to grant the same permission again - should return 409 Conflict
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 409
+
+ # 3 - Non-admin (grumpy) tries to grant permission - should return 403
+ headers = dict(Authorization="Bearer " + grumpy_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to grant permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to grant permission to non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.post(
+ "/users/nonexistent/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to grant permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://nonexistent-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+
+def test_revoke_search_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test revoking search permission via DELETE."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ # Initial state: grumpy and sleepy have search permissions on s3://snow-white
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # 1 - Admin revokes search permission from sleepy
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://snow-white" not in user_response["search_permissions_on_base_uris"]
+
+ # 2 - Revoking permission that doesn't exist should still succeed (idempotent)
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ # 3 - Non-admin (grumpy) tries to revoke permission - should return 403
+ headers = dict(Authorization="Bearer " + grumpy_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to revoke permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to revoke permission from non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/nonexistent/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to revoke permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://nonexistent-bucket",
+ headers=headers
+ )
+ assert r.status_code == 404
+
+
+def test_grant_register_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test granting register permission via POST."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # 1 - Admin grants register permission to sleepy on s3://snow-white
+ # sleepy does not have register permission initially
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://snow-white" in user_response["register_permissions_on_base_uris"]
+
+ # 2 - Try to grant the same permission again - should return 409 Conflict
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 409
+
+ # 3 - Non-admin (grumpy) tries to grant permission - should return 403
+ headers = dict(Authorization="Bearer " + grumpy_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to grant permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to grant permission to non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.post(
+ "/users/nonexistent/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to grant permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://nonexistent-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+
+def test_revoke_register_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test revoking register permission via DELETE."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ # Initial state: grumpy has register permission on s3://snow-white
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # 1 - Admin revokes register permission from grumpy
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://snow-white" not in user_response["register_permissions_on_base_uris"]
+
+ # 2 - Revoking permission that doesn't exist should still succeed (idempotent)
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ # 3 - Non-admin (sleepy) tries to revoke permission - should return 403
+ headers = dict(Authorization="Bearer " + sleepy_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to revoke permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to revoke permission from non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/nonexistent/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to revoke permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://nonexistent-bucket",
+ headers=headers
+ )
+ assert r.status_code == 404
diff --git a/tests/test_utils_permission_management.py b/tests/test_utils_permission_management.py
index 57a8c01..2bdcc62 100644
--- a/tests/test_utils_permission_management.py
+++ b/tests/test_utils_permission_management.py
@@ -32,18 +32,21 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
expected_content = [
{
"username": "snow-white",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": ["s3://snow-white"],
"register_permissions_on_base_uris": ["s3://snow-white"]
},
{
"username": "dopey",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": ["s3://snow-white"],
"register_permissions_on_base_uris": []
},
{
"username": "sleepy",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": ["s3://snow-white"],
"register_permissions_on_base_uris": []
@@ -70,18 +73,21 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
expected_content = [
{
"username": "snow-white",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": "dopey",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": "sleepy",
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
diff --git a/tests/test_utils_register_dataset.py b/tests/test_utils_register_dataset.py
index 1fe9197..fffffb1 100644
--- a/tests/test_utils_register_dataset.py
+++ b/tests/test_utils_register_dataset.py
@@ -72,7 +72,12 @@ def test_register_dataset(tmp_app_client): # NOQA
"number_of_items": 9876,
"size_in_bytes": 5741810,
}
- assert get_admin_metadata_from_uri(uri) == expected_content
+ admin_metadata = get_admin_metadata_from_uri(uri)
+ # Server-stamped registration provenance: dynamic timestamp, no
+ # authenticated identity outside a request context.
+ assert admin_metadata.pop("uploaded_by") is None
+ assert isinstance(admin_metadata.pop("uploaded_at"), float)
+ assert admin_metadata == expected_content
assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"]
with pytest.raises(ValidationError):
@@ -148,7 +153,12 @@ def test_register_dataset_without_created_at(tmp_app_client): # NOQA
"number_of_items": 1232,
"size_in_bytes": 5741810,
}
- assert get_admin_metadata_from_uri(uri) == expected_content
+ admin_metadata = get_admin_metadata_from_uri(uri)
+ # Server-stamped registration provenance: dynamic timestamp, no
+ # authenticated identity outside a request context.
+ assert admin_metadata.pop("uploaded_by") is None
+ assert isinstance(admin_metadata.pop("uploaded_at"), float)
+ assert admin_metadata == expected_content
assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"]
with pytest.raises(ValidationError):
@@ -222,7 +232,12 @@ def test_register_dataset_without_created_at_and_size_in_bytes(tmp_app_client):
"number_of_items": None,
"size_in_bytes": None,
}
- assert get_admin_metadata_from_uri(uri) == expected_content
+ admin_metadata = get_admin_metadata_from_uri(uri)
+ # Server-stamped registration provenance: dynamic timestamp, no
+ # authenticated identity outside a request context.
+ assert admin_metadata.pop("uploaded_by") is None
+ assert isinstance(admin_metadata.pop("uploaded_at"), float)
+ assert admin_metadata == expected_content
assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"]
with pytest.raises(ValidationError):
diff --git a/tests/test_utils_user_management.py b/tests/test_utils_user_management.py
index 1428a49..88f783c 100644
--- a/tests/test_utils_user_management.py
+++ b/tests/test_utils_user_management.py
@@ -25,6 +25,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
user_info = get_user_info(admin_username)
expected_content = {
"username": admin_username,
+ "display_name": None,
"is_admin": True,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -34,6 +35,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
user_info = get_user_info(data_champion_username)
expected_content = {
"username": data_champion_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -43,6 +45,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
user_info = get_user_info(standard_user_username)
expected_content = {
"username": standard_user_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -59,6 +62,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
user_info = get_user_info(new_username)
expected_content = {
"username": new_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -69,24 +73,28 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
expected_content = [
{
"username": admin_username,
+ "display_name": None,
"is_admin": True,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": data_champion_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": standard_user_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": new_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -100,12 +108,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
users_to_delete = [
{
"username": standard_user_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": new_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -116,12 +126,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
expected_content = [
{
"username": admin_username,
+ "display_name": None,
"is_admin": True,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": data_champion_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
@@ -142,12 +154,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA
expected_content = [
{
"username": admin_username,
+ "display_name": None,
"is_admin": False,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []
},
{
"username": data_champion_username,
+ "display_name": None,
"is_admin": True,
"search_permissions_on_base_uris": [],
"register_permissions_on_base_uris": []