diff --git a/h/routes.py b/h/routes.py index c8933018e66..1e20ed93ea8 100644 --- a/h/routes.py +++ b/h/routes.py @@ -159,6 +159,14 @@ def includeme(config): # noqa: PLR0915 config.add_route( "api.bulk.lms.annotations", "/api/bulk/lms/annotations", request_method="POST" ) + config.add_route( + "api.bulk.checkpoint", "/api/bulk/checkpoint", request_method="POST" + ) + config.add_route( + "api.bulk.checkpoint.reveal", + "/api/bulk/checkpoint/reveal", + request_method="POST", + ) config.add_route("api.groups", "/api/groups", factory="h.traversal.GroupRoot") config.add_route( diff --git a/h/security/policy/_auth_client.py b/h/security/policy/_auth_client.py index 2cdb79ab630..ac98d7dd01e 100644 --- a/h/security/policy/_auth_client.py +++ b/h/security/policy/_auth_client.py @@ -39,6 +39,8 @@ class AuthClientPolicy: ("api.bulk.annotation", "POST"), ("api.bulk.group", "POST"), ("api.bulk.lms.annotations", "POST"), + ("api.bulk.checkpoint", "POST"), + ("api.bulk.checkpoint.reveal", "POST"), ] @classmethod diff --git a/h/services/checkpoint.py b/h/services/checkpoint.py index a917187a823..bde6f9a9b2c 100644 --- a/h/services/checkpoint.py +++ b/h/services/checkpoint.py @@ -1,17 +1,21 @@ from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime +from typing import ClassVar -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select +from sqlalchemy.dialects.postgresql import insert from h.models import ( Annotation, Checkpoint, Document, DocumentURI, + Group, GroupMembership, User, ) from h.models.group import LMSRole +from h.schemas import ValidationError @dataclass @@ -126,6 +130,153 @@ def hides_annotation(self, user: User | None, annotation: Annotation) -> bool: return False + _ROLE_MAP: ClassVar[dict[str, LMSRole]] = { + "instructor": LMSRole.LMS_INSTRUCTOR, + "student": LMSRole.LMS_STUDENT, + } + + def set_user_role( + self, + authority: str, + username: str, + role: str, + group_authority_provided_ids: list[str], + ) -> None: + """Set the lms_role for a user in the given groups.""" + lms_role = self._ROLE_MAP.get(role) + if not lms_role: + return + + user = User.get_by_username(self.db, username, authority) + if not user: + return + + memberships = self.db.scalars( + select(GroupMembership) + .join(Group, Group.id == GroupMembership.group_id) + .where(GroupMembership.user_id == user.id) + .where(Group.authority == authority) + .where(Group.authority_provided_id.in_(group_authority_provided_ids)) + ).all() + + for membership in memberships: + membership.lms_role = lms_role.value + + def upsert_checkpoint( + self, + authority: str, + group_authority_provided_id: str, + document_uri: str, + reveal_date: str | None = None, + ) -> Checkpoint | None: + """ + Upsert a checkpoint for a (group, document) pair. + + Resolves the group by authority + authority_provided_id and the + document by URI. Creates the document if it doesn't exist yet. + + Returns the upserted Checkpoint, or None if the group or document + could not be resolved. + """ + group = self.db.scalar( + select(Group).where( + Group.authority == authority, + Group.authority_provided_id == group_authority_provided_id, + ) + ) + if not group: + return None + + document = Document.find_or_create_by_uris( + self.db, claimant_uri=document_uri, uris=[] + ).first() + if not document: + return None + + parsed_reveal_date = None + if reveal_date: + try: + parsed_reveal_date = datetime.fromisoformat(reveal_date) + except ValueError as err: + msg = f"Invalid reveal_date: {reveal_date!r}" + raise ValidationError(msg) from err + # Store naive UTC to match the column and the utcnow() comparisons. + if parsed_reveal_date.tzinfo is not None: + parsed_reveal_date = parsed_reveal_date.astimezone(UTC).replace( + tzinfo=None + ) + + stmt = ( + insert(Checkpoint) + .values( + group_id=group.id, + document_id=document.id, + previous_checkpoint_id=None, + reveal_date=parsed_reveal_date, + ) + # If a checkpoint already exists for this (group, document), update the + # reveal_date. coalesce keeps the existing date if the new one is NULL. + .on_conflict_do_update( + constraint="uq__checkpoint__group_id__document_id__previous_checkpoint_id", + set_={ + "reveal_date": func.coalesce( + parsed_reveal_date, Checkpoint.reveal_date + ) + }, + ) + .returning(Checkpoint.id) + ) + result = self.db.execute(stmt) + self.db.flush() + + checkpoint_id = result.scalar() + return self.db.get(Checkpoint, checkpoint_id) + + def reveal_checkpoints( + self, + authority: str, + group_authority_provided_id: str, + document_uri: str, + ) -> Checkpoint | None: + """ + Reveal a checkpoint by setting its reveal_date to now. + + Returns the updated Checkpoint, or None if not found. + """ + group = self.db.scalar( + select(Group).where( + Group.authority == authority, + Group.authority_provided_id == group_authority_provided_id, + ) + ) + if not group: + return None + + document_ids = [ + doc.id for doc in Document.find_by_uris(self.db, [document_uri]) + ] + if not document_ids: + return None + + checkpoint = self.db.scalar( + select(Checkpoint) + .where(Checkpoint.group_id == group.id) + .where(Checkpoint.document_id.in_(document_ids)) + .where( + or_( + Checkpoint.reveal_date.is_(None), + Checkpoint.reveal_date > datetime.utcnow(), # noqa: DTZ003 + ) + ) + .limit(1) + ) + if not checkpoint: + return None + + checkpoint.reveal_date = datetime.utcnow() # noqa: DTZ003 + self.db.flush() + return checkpoint + def _hidden_scope(self, user: User, checkpoint: Checkpoint) -> HiddenScope: group_pubid = checkpoint.group.pubid diff --git a/h/views/api/bulk/checkpoint.py b/h/views/api/bulk/checkpoint.py new file mode 100644 index 00000000000..07134edd49f --- /dev/null +++ b/h/views/api/bulk/checkpoint.py @@ -0,0 +1,116 @@ +import json +from datetime import datetime + +from importlib_resources import files +from pyramid.response import Response + +from h.schemas.base import JSONSchema +from h.security import Permission +from h.services.checkpoint import CheckpointService +from h.views.api.config import api_config + + +def _is_revealed(checkpoint): + return ( + checkpoint is not None + and checkpoint.reveal_date is not None + and checkpoint.reveal_date <= datetime.utcnow() # noqa: DTZ003 + ) + + +def _reveal_date_isoformat(checkpoint): + if checkpoint and checkpoint.reveal_date: + return checkpoint.reveal_date.isoformat() + return None + + +class BulkCheckpointSchema(JSONSchema): + _SCHEMA_FILE = files("h.views.api.bulk") / "checkpoint_schema.json" + schema_version = 7 + schema = json.loads(_SCHEMA_FILE.read_text(encoding="utf-8")) + + +class BulkCheckpointRevealSchema(JSONSchema): + _SCHEMA_FILE = files("h.views.api.bulk") / "checkpoint_reveal_schema.json" + schema_version = 7 + schema = json.loads(_SCHEMA_FILE.read_text(encoding="utf-8")) + + +@api_config( + versions=["v1", "v2"], + route_name="api.bulk.checkpoint", + request_method="POST", + link_name="bulk.checkpoint", + description="Upsert checkpoints", + permission=Permission.API.BULK_ACTION, +) +def upsert_checkpoints(request): + data = BulkCheckpointSchema().validate(request.json) + authority = request.identity.auth_client.authority + + checkpoint_service = request.find_service(CheckpointService) + + group_authority_provided_ids = [ + item["group_authority_provided_id"] for item in data["checkpoints"] + ] + + if user_data := data.get("user"): + checkpoint_service.set_user_role( + authority=authority, + username=user_data["username"], + role=user_data["role"], + group_authority_provided_ids=group_authority_provided_ids, + ) + + results = [] + for item in data["checkpoints"]: + checkpoint = checkpoint_service.upsert_checkpoint( + authority=authority, + group_authority_provided_id=item["group_authority_provided_id"], + document_uri=item["document_uri"], + reveal_date=item.get("reveal_date"), + ) + results.append( + { + "group_authority_provided_id": item["group_authority_provided_id"], + "document_uri": item["document_uri"], + "created": checkpoint is not None, + "revealed": _is_revealed(checkpoint), + "reveal_date": _reveal_date_isoformat(checkpoint), + } + ) + + return Response(json=results, status=200) + + +@api_config( + versions=["v1", "v2"], + route_name="api.bulk.checkpoint.reveal", + request_method="POST", + link_name="bulk.checkpoint.reveal", + description="Reveal checkpoints, making annotations visible immediately", + permission=Permission.API.BULK_ACTION, +) +def reveal_checkpoints(request): + data = BulkCheckpointRevealSchema().validate(request.json) + authority = request.identity.auth_client.authority + + checkpoint_service = request.find_service(CheckpointService) + + results = [] + for item in data["checkpoints"]: + checkpoint = checkpoint_service.reveal_checkpoints( + authority=authority, + group_authority_provided_id=item["group_authority_provided_id"], + document_uri=item["document_uri"], + ) + results.append( + { + "group_authority_provided_id": item["group_authority_provided_id"], + "document_uri": item["document_uri"], + "revealed": checkpoint is not None, + "reveal_date": _reveal_date_isoformat(checkpoint), + } + ) + + return Response(json=results, status=200) diff --git a/h/views/api/bulk/checkpoint_reveal_schema.json b/h/views/api/bulk/checkpoint_reveal_schema.json new file mode 100644 index 00000000000..1f3d26d1675 --- /dev/null +++ b/h/views/api/bulk/checkpoint_reveal_schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "type": "object", + "title": "Bulk checkpoint reveal", + "properties": { + "authority": { + "type": "string" + }, + "checkpoints": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "group_authority_provided_id": { + "type": "string" + }, + "document_uri": { + "type": "string" + } + }, + "required": ["group_authority_provided_id", "document_uri"], + "additionalProperties": false + } + } + }, + "required": ["authority", "checkpoints"], + "additionalProperties": false +} diff --git a/h/views/api/bulk/checkpoint_schema.json b/h/views/api/bulk/checkpoint_schema.json new file mode 100644 index 00000000000..2d9e1e8e643 --- /dev/null +++ b/h/views/api/bulk/checkpoint_schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "type": "object", + "title": "Bulk checkpoint upsert", + "properties": { + "authority": { + "type": "string" + }, + "checkpoints": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "group_authority_provided_id": { + "type": "string" + }, + "document_uri": { + "type": "string" + }, + "reveal_date": { + "type": ["string", "null"] + } + }, + "required": ["group_authority_provided_id", "document_uri"], + "additionalProperties": false + } + }, + "user": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "role": { + "type": "string", + "enum": ["instructor", "student"] + } + }, + "required": ["username", "role"], + "additionalProperties": false + } + }, + "required": ["authority", "checkpoints"], + "additionalProperties": false +} diff --git a/tests/unit/h/routes_test.py b/tests/unit/h/routes_test.py index ef41b3ccea6..e76d0926211 100644 --- a/tests/unit/h/routes_test.py +++ b/tests/unit/h/routes_test.py @@ -153,6 +153,12 @@ def test_includeme(): "/api/bulk/lms/annotations", request_method="POST", ), + call("api.bulk.checkpoint", "/api/bulk/checkpoint", request_method="POST"), + call( + "api.bulk.checkpoint.reveal", + "/api/bulk/checkpoint/reveal", + request_method="POST", + ), call("api.groups", "/api/groups", factory="h.traversal.GroupRoot"), call( "api.group", diff --git a/tests/unit/h/services/checkpoint_test.py b/tests/unit/h/services/checkpoint_test.py index 580a78f6586..cee7bcb7e48 100644 --- a/tests/unit/h/services/checkpoint_test.py +++ b/tests/unit/h/services/checkpoint_test.py @@ -3,8 +3,9 @@ import pytest -from h.models import GroupMembership +from h.models import Document, GroupMembership from h.models.group import LMSRole +from h.schemas import ValidationError from h.services.checkpoint import CheckpointService, factory from h.util.uri import normalize as uri_normalize @@ -316,6 +317,243 @@ def peer_annotation(self, factories, group, document): return self.annotation(factories, group, document, userid="acct:peer@localhost") +class TestUpsert: + def test_it_creates_a_new_checkpoint(self, svc, factories): + group = factories.Group() + + checkpoint = svc.upsert_checkpoint( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + reveal_date="2026-07-01T10:00:00", + ) + + assert checkpoint.group_id == group.id + assert checkpoint.previous_checkpoint_id is None + assert checkpoint.reveal_date == datetime(2026, 7, 1, 10, 0, 0) # noqa: DTZ001 + + def test_it_creates_a_checkpoint_with_no_reveal_date(self, svc, factories): + group = factories.Group() + + checkpoint = svc.upsert_checkpoint( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + ) + + assert checkpoint.reveal_date is None + + def test_it_stores_a_timezone_aware_reveal_date_as_naive_utc(self, svc, factories): + group = factories.Group() + + checkpoint = svc.upsert_checkpoint( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + reveal_date="2026-07-01T10:00:00+02:00", + ) + + # 10:00 at +02:00 is 08:00 UTC, stored without tzinfo. + assert checkpoint.reveal_date == datetime(2026, 7, 1, 8, 0, 0) # noqa: DTZ001 + + def test_it_updates_an_existing_checkpoint(self, svc, factories, db_session): + group = factories.Group() + document = factories.Document() + factories.DocumentURI(document=document, uri="http://example.com/page") + existing = factories.Checkpoint( + group=group, document=document, reveal_date=None + ) + # The bulk endpoint loads the checkpoint fresh; expire the cached instance + # so db.get() reloads the row the conflict-update actually wrote. + db_session.expire(existing) + + checkpoint = svc.upsert_checkpoint( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + reveal_date="2026-07-01T10:00:00", + ) + + assert checkpoint.id == existing.id + assert checkpoint.reveal_date == datetime(2026, 7, 1, 10, 0, 0) # noqa: DTZ001 + + def test_it_raises_for_an_invalid_reveal_date(self, svc, factories): + group = factories.Group() + + with pytest.raises(ValidationError): + svc.upsert_checkpoint( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + reveal_date="not-a-date", + ) + + def test_it_returns_None_when_the_group_is_not_found(self, svc): + assert ( + svc.upsert_checkpoint( + authority="example.com", + group_authority_provided_id="missing", + document_uri="http://example.com/page", + ) + is None + ) + + def test_it_returns_None_when_the_document_cannot_be_resolved( + self, svc, factories, mocker + ): + group = factories.Group() + query = mocker.Mock() + query.first.return_value = None + mocker.patch.object(Document, "find_or_create_by_uris", return_value=query) + + assert ( + svc.upsert_checkpoint( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + ) + is None + ) + + +class TestSetUserRole: + @pytest.mark.parametrize( + "role,expected", + [ + ("instructor", LMSRole.LMS_INSTRUCTOR.value), + ("student", LMSRole.LMS_STUDENT.value), + ], + ) + def test_it_sets_the_role(self, svc, factories, db_session, role, expected): + user = factories.User() + group = factories.Group() + membership = GroupMembership(user=user, group=group) + db_session.add(membership) + db_session.flush() + + svc.set_user_role( + authority=user.authority, + username=user.username, + role=role, + group_authority_provided_ids=[group.authority_provided_id], + ) + + assert membership.lms_role == expected + + def test_it_only_sets_the_role_in_the_given_groups( + self, svc, factories, db_session + ): + user = factories.User() + group = factories.Group() + other_group = factories.Group() + membership = GroupMembership(user=user, group=group) + other_membership = GroupMembership(user=user, group=other_group) + db_session.add_all([membership, other_membership]) + db_session.flush() + + svc.set_user_role( + authority=user.authority, + username=user.username, + role="instructor", + group_authority_provided_ids=[group.authority_provided_id], + ) + + assert membership.lms_role == LMSRole.LMS_INSTRUCTOR.value + assert other_membership.lms_role is None + + def test_it_does_nothing_when_the_user_is_not_found(self, svc): + svc.set_user_role( + authority="example.com", + username="nonexistent", + role="instructor", + group_authority_provided_ids=["whatever"], + ) + + def test_it_does_nothing_when_no_groups_match(self, svc, factories): + user = factories.User() + + svc.set_user_role( + authority=user.authority, + username=user.username, + role="student", + group_authority_provided_ids=["nonexistent"], + ) + + def test_it_does_nothing_for_unknown_role(self, svc, factories, db_session): + user = factories.User() + group = factories.Group() + membership = GroupMembership(user=user, group=group) + db_session.add(membership) + db_session.flush() + + svc.set_user_role( + authority=user.authority, + username=user.username, + role="unknown", + group_authority_provided_ids=[group.authority_provided_id], + ) + + assert membership.lms_role is None + + +class TestRevealCheckpoints: + def test_it_reveals_an_active_checkpoint(self, svc, factories, db_session): + group = factories.Group() + document = factories.Document() + factories.DocumentURI(document=document, uri="http://example.com/page") + checkpoint = factories.Checkpoint( + group=group, document=document, reveal_date=None + ) + db_session.flush() + + result = svc.reveal_checkpoints( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + ) + + assert result.id == checkpoint.id + assert result.reveal_date is not None + + def test_it_returns_None_when_group_not_found(self, svc): + result = svc.reveal_checkpoints( + authority="example.com", + group_authority_provided_id="missing", + document_uri="http://example.com/page", + ) + + assert result is None + + def test_it_returns_None_when_document_not_found(self, svc, factories): + group = factories.Group() + + result = svc.reveal_checkpoints( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/nonexistent", + ) + + assert result is None + + def test_it_returns_None_when_no_active_checkpoint(self, svc, factories): + group = factories.Group() + document = factories.Document() + factories.DocumentURI(document=document, uri="http://example.com/page") + factories.Checkpoint( + group=group, + document=document, + reveal_date=datetime.utcnow() - timedelta(days=1), # noqa: DTZ003 + ) + + result = svc.reveal_checkpoints( + authority=group.authority, + group_authority_provided_id=group.authority_provided_id, + document_uri="http://example.com/page", + ) + + assert result is None + + class TestFactory: def test_it(self, pyramid_request): svc = factory(mock.sentinel.context, pyramid_request) diff --git a/tests/unit/h/views/api/bulk/checkpoint_test.py b/tests/unit/h/views/api/bulk/checkpoint_test.py new file mode 100644 index 00000000000..16364903933 --- /dev/null +++ b/tests/unit/h/views/api/bulk/checkpoint_test.py @@ -0,0 +1,316 @@ +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from h.schemas import ValidationError +from h.services.checkpoint import CheckpointService +from h.views.api.bulk.checkpoint import ( + BulkCheckpointRevealSchema, + BulkCheckpointSchema, + reveal_checkpoints, + upsert_checkpoints, +) + + +class TestBulkCheckpointSchema: + def test_it_is_a_valid_schema(self, schema): + # Basic self-check that this is a valid JSON schema. + assert not schema.validator.check_schema(schema.schema) + + @pytest.fixture + def schema(self): + return BulkCheckpointSchema() + + +@pytest.mark.usefixtures("checkpoint_service", "with_auth_client") +class TestUpsertCheckpoints: + def test_it(self, pyramid_request, checkpoint_service): + pyramid_request.json = { + "authority": "lms.hypothes.is", + "user": {"username": "teacher", "role": "instructor"}, + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + "reveal_date": "2026-07-01T10:00:00", + }, + { + "group_authority_provided_id": "group2", + "document_uri": "http://example.com/2", + }, + ], + } + + response = upsert_checkpoints(pyramid_request) + + checkpoint_service.set_user_role.assert_called_once_with( + authority=pyramid_request.identity.auth_client.authority, + username="teacher", + role="instructor", + group_authority_provided_ids=["group1", "group2"], + ) + checkpoint_service.upsert_checkpoint.assert_any_call( + authority=pyramid_request.identity.auth_client.authority, + group_authority_provided_id="group1", + document_uri="http://example.com/1", + reveal_date="2026-07-01T10:00:00", + ) + checkpoint_service.upsert_checkpoint.assert_any_call( + authority=pyramid_request.identity.auth_client.authority, + group_authority_provided_id="group2", + document_uri="http://example.com/2", + reveal_date=None, + ) + assert response.status_code == 200 + assert response.json == [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + "created": True, + "revealed": False, + "reveal_date": None, + }, + { + "group_authority_provided_id": "group2", + "document_uri": "http://example.com/2", + "created": True, + "revealed": False, + "reveal_date": None, + }, + ] + + def test_it_does_not_set_role_without_user( + self, pyramid_request, checkpoint_service + ): + pyramid_request.json = { + "authority": "lms.hypothes.is", + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + }, + ], + } + + upsert_checkpoints(pyramid_request) + + checkpoint_service.set_user_role.assert_not_called() + + def test_it_sets_student_role(self, pyramid_request, checkpoint_service): + pyramid_request.json = { + "authority": "lms.hypothes.is", + "user": {"username": "student1", "role": "student"}, + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + }, + ], + } + + upsert_checkpoints(pyramid_request) + + checkpoint_service.set_user_role.assert_called_once_with( + authority=pyramid_request.identity.auth_client.authority, + username="student1", + role="student", + group_authority_provided_ids=["group1"], + ) + + def test_it_reports_unresolved_items_as_not_created( + self, pyramid_request, checkpoint_service + ): + checkpoint_service.upsert_checkpoint.return_value = None + pyramid_request.json = { + "authority": "lms.hypothes.is", + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + }, + ], + } + + response = upsert_checkpoints(pyramid_request) + + assert response.json == [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + "created": False, + "revealed": False, + "reveal_date": None, + }, + ] + + def test_it_includes_reveal_date(self, pyramid_request, checkpoint_service): + # The reveal_date column is naive UTC, so a checkpoint read back from + # the DB has a naive reveal_date. A past one must report revealed=True + # and isoformat without an offset — not crash comparing naive vs aware. + reveal_date = datetime(2020, 1, 1, 10, 0, 0) # noqa: DTZ001 + checkpoint_service.upsert_checkpoint.return_value = Mock( + reveal_date=reveal_date + ) + pyramid_request.json = { + "authority": "lms.hypothes.is", + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + "reveal_date": "2020-01-01T10:00:00", + }, + ], + } + + response = upsert_checkpoints(pyramid_request) + + assert response.json == [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + "created": True, + "revealed": True, + "reveal_date": "2020-01-01T10:00:00", + }, + ] + + def test_it_raises_with_invalid_request(self, pyramid_request): + pyramid_request.json = {"nope": True} + + with pytest.raises(ValidationError): + upsert_checkpoints(pyramid_request) + + def test_it_ignores_body_authority_and_uses_authenticated_client( + self, pyramid_request, checkpoint_service + ): + # Security: a caller must not be able to act on another tenant's groups + # by supplying a different authority in the request body. The authority + # must always come from the authenticated auth_client. + client_authority = pyramid_request.identity.auth_client.authority + assert client_authority != "attacker.hypothes.is" + pyramid_request.json = { + "authority": "attacker.hypothes.is", + "user": {"username": "teacher", "role": "instructor"}, + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + }, + ], + } + + upsert_checkpoints(pyramid_request) + + checkpoint_service.set_user_role.assert_called_once_with( + authority=client_authority, + username="teacher", + role="instructor", + group_authority_provided_ids=["group1"], + ) + checkpoint_service.upsert_checkpoint.assert_called_once_with( + authority=client_authority, + group_authority_provided_id="group1", + document_uri="http://example.com/1", + reveal_date=None, + ) + + @pytest.fixture + def checkpoint_service(self, mock_service): + service = mock_service(CheckpointService) + service.upsert_checkpoint.return_value.reveal_date = None + return service + + +class TestBulkCheckpointRevealSchema: + def test_it_is_a_valid_schema(self, schema): + assert not schema.validator.check_schema(schema.schema) + + @pytest.fixture + def schema(self): + return BulkCheckpointRevealSchema() + + +@pytest.mark.usefixtures("checkpoint_service", "with_auth_client") +class TestRevealCheckpoints: + def test_it(self, pyramid_request, checkpoint_service): + pyramid_request.json = { + "authority": "lms.hypothes.is", + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + }, + ], + } + + response = reveal_checkpoints(pyramid_request) + + checkpoint_service.reveal_checkpoints.assert_called_once_with( + authority=pyramid_request.identity.auth_client.authority, + group_authority_provided_id="group1", + document_uri="http://example.com/1", + ) + assert response.status_code == 200 + assert response.json == [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + "revealed": True, + "reveal_date": None, + }, + ] + + def test_it_reports_unresolved_items(self, pyramid_request, checkpoint_service): + checkpoint_service.reveal_checkpoints.return_value = None + pyramid_request.json = { + "authority": "lms.hypothes.is", + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + }, + ], + } + + response = reveal_checkpoints(pyramid_request) + + assert response.json == [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + "revealed": False, + "reveal_date": None, + }, + ] + + def test_it_ignores_body_authority_and_uses_authenticated_client( + self, pyramid_request, checkpoint_service + ): + # Security: authority must come from the authenticated auth_client, + # never from the request body. + client_authority = pyramid_request.identity.auth_client.authority + assert client_authority != "attacker.hypothes.is" + pyramid_request.json = { + "authority": "attacker.hypothes.is", + "checkpoints": [ + { + "group_authority_provided_id": "group1", + "document_uri": "http://example.com/1", + }, + ], + } + + reveal_checkpoints(pyramid_request) + + checkpoint_service.reveal_checkpoints.assert_called_once_with( + authority=client_authority, + group_authority_provided_id="group1", + document_uri="http://example.com/1", + ) + + @pytest.fixture + def checkpoint_service(self, mock_service): + service = mock_service(CheckpointService) + service.reveal_checkpoints.return_value.reveal_date = None + return service