From e33591776e12ceb39f2f55e1a36bf1097bc1b904 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Wed, 16 Sep 2026 14:42:11 -0500 Subject: [PATCH] Apply the shared note-visibility rule to the v3 notes list The v3 notes sub-resource read the parent relation directly. Its comment said it mirrored v2, but v2 has routed note reads through visible_notes() since 3.2.100, so the two paths disagreed on what private means. The existing privacy test asserted the older behaviour and is inverted here, and the rule is now checked on all three parents plus the envelope count. --- dojo/api_v3/subresources.py | 6 +-- unittests/api_v3/test_apiv3_subresources.py | 43 +++++++++++++++++---- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/dojo/api_v3/subresources.py b/dojo/api_v3/subresources.py index 1978b852781..02288264cbf 100644 --- a/dojo/api_v3/subresources.py +++ b/dojo/api_v3/subresources.py @@ -58,6 +58,7 @@ from dojo.api_v3.refs import Ref, to_ref from dojo.authorization.authorization import user_has_permission from dojo.file_uploads.models import FileUpload +from dojo.notes.helper import visible_notes from dojo.notes.models import NoteHistory, Notes from dojo.utils import generate_file_response @@ -208,10 +209,7 @@ def build_notes_router( def list_notes(request: HttpRequest, parent_id: int): parent = _resolve_parent(request, get_parent_queryset, parent_label, parent_id) _require(request, parent, view_permission) - # Mirror v2 exactly: return every note incl. private ones. In v2 the notes @action returns - # `parent.notes.all()`; `private` only excludes a note from generated reports, it is not a - # per-user read filter (§12). `select_related("author")` keeps the list query count flat. - notes = parent.notes + notes = visible_notes(parent.notes, request.user) page_qs = notes.select_related("author").order_by("-date", "-id") envelope = paginate(request, count_qs=notes.all(), page_qs=page_qs, serialize=_serialize_note) return json_response(envelope) diff --git a/unittests/api_v3/test_apiv3_subresources.py b/unittests/api_v3/test_apiv3_subresources.py index cd2288880b2..0cf72e77367 100644 --- a/unittests/api_v3/test_apiv3_subresources.py +++ b/unittests/api_v3/test_apiv3_subresources.py @@ -2,7 +2,7 @@ Generic notes / tags / files sub-resource tests for API v3 (§4.12, OS5). Covers the storage support matrix (notes/files: finding/engagement/test; tags: those + asset), -note privacy (v2 parity: private notes are returned, not per-user filtered), parent-authorization +note privacy (v2 parity: `private` is a per-user read filter), parent-authorization inheritance (404 unknown-or-unauthorized parent, 403 write), multipart upload + streamed download roundtrip, tag replace/append/delete semantics + normalization, the pagination envelope on the list endpoints, and the MANDATORY constant-query guarantee for the finding notes/tags/files lists. @@ -105,17 +105,44 @@ def test_private_note_returned_and_flagged(self): match = next(n for n in listing["results"] if n["id"] == created["id"]) self.assertTrue(match["private"]) - def test_private_note_visible_to_other_authorized_user_v2_parity(self): - """v2 parity: the notes endpoint returns *all* notes; `private` is not a per-user read filter.""" + def test_private_note_hidden_from_other_authorized_user(self): + """v2 parity: `private` is a per-user read filter, so only the author sees their own.""" member = Dojo_User.objects.create_user(username="v3_note_member", password="x") # noqa: S106 self.finding.test.engagement.product.authorized_users.add(member) - created = self.client.post( + member_client = self.token_client(user=member) + + for resource, parent in self.note_file_parents: + with self.subTest(resource=resource): + private = self.client.post( + self.v3_url(f"{resource}/{parent.pk}/notes"), + {"entry": f"private on {resource}", "private": True}, format="json", + ).json() + public = self.client.post( + self.v3_url(f"{resource}/{parent.pk}/notes"), + {"entry": f"public on {resource}", "private": False}, format="json", + ).json() + + member_view = self.get_json(f"{resource}/{parent.pk}/notes", client=member_client) + member_ids = [n["id"] for n in member_view["results"]] + self.assertNotIn(private["id"], member_ids) + self.assertIn(public["id"], member_ids) + self.assertNotIn(f"private on {resource}", str(member_view)) + + author_ids = [n["id"] for n in self.get_json(f"{resource}/{parent.pk}/notes")["results"]] + self.assertIn(private["id"], author_ids) + + def test_private_note_hidden_from_member_count_and_pagination(self): + member = Dojo_User.objects.create_user(username="v3_note_counter", password="x") # noqa: S106 + self.finding.test.engagement.product.authorized_users.add(member) + before = self.get_json(f"findings/{self.finding.pk}/notes", client=self.token_client(user=member))["count"] + + self.client.post( self.v3_url(f"findings/{self.finding.pk}/notes"), - {"entry": "private-but-visible", "private": True}, format="json", - ).json() + {"entry": "counted?", "private": True}, format="json", + ) - member_view = self.get_json(f"findings/{self.finding.pk}/notes", client=self.token_client(user=member)) - self.assertIn(created["id"], [n["id"] for n in member_view["results"]]) + after = self.get_json(f"findings/{self.finding.pk}/notes", client=self.token_client(user=member)) + self.assertEqual(before, after["count"]) class TestApiV3SubresourcesFiles(_SubResourceBase):