Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions testbed/core/json_ld_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def build_note_json_ld(note, auth_context=None):
return {
"@context": build_basic_context(breadcrumbs=bool(note.previously)),
"type": "Note",
"id": build_note_id(note.id, request),
"id": build_note_id(note, request),
"attributedTo": build_actor_id(note.actor.id, request),
"content": note.content,
"published": note.published.isoformat(),
Expand All @@ -115,7 +115,7 @@ def build_create_activity_json_ld(activity, auth_context=None):
json_ld = {
"@context": build_basic_context(breadcrumbs=bool(activity.previously)),
"type": "Create",
"id": build_activity_id("create", activity.id, request),
"id": build_activity_id("create", activity, request),
"actor": build_actor_id(activity.actor.id, request),
"published": activity.timestamp.isoformat(),
"visibility": activity.visibility,
Expand Down Expand Up @@ -148,7 +148,7 @@ def build_like_object_json_ld(like, auth_context=None):
}
if like.note.visibility == "public":
return build_note_json_ld(like.note, auth_context)
return build_note_id(like.note.id, request)
return build_note_id(like.note, request)


def build_like_activity_json_ld(activity, auth_context=None):
Expand All @@ -158,7 +158,7 @@ def build_like_activity_json_ld(activity, auth_context=None):
base = {
"@context": build_basic_context(breadcrumbs=bool(activity.previously)),
"type": "Like",
"id": build_activity_id("like", activity.id, request),
"id": build_activity_id("like", activity, request),
"actor": build_actor_id(activity.actor.id, request),
"published": activity.timestamp.isoformat(),
"visibility": activity.visibility,
Expand All @@ -176,7 +176,7 @@ def build_follow_activity_json_ld(activity, auth_context=None):
base = {
"@context": build_basic_context(breadcrumbs=bool(activity.previously)),
"type": "Follow",
"id": build_activity_id("follow", activity.id, request),
"id": build_activity_id("follow", activity, request),
"actor": build_actor_id(activity.actor.id, request),
"published": activity.timestamp.isoformat(),
"visibility": activity.visibility,
Expand Down
16 changes: 4 additions & 12 deletions testbed/core/json_ld_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,14 @@ def build_actor_context():
def build_url(request, name, **kwargs):
return request.build_absolute_uri(reverse(name, kwargs=kwargs))

def build_id_url(type_name, obj_id, request):
"""
Build dynamic URLs based on the current request.
This ensures URLs work in development, production, and any deployment environment.
"""
base_url = f"{request.scheme}://{request.get_host()}"
return f"{base_url}/api/{type_name}/{obj_id}/"

def build_actor_id(actor_id, request):
return build_url(request, "actor-detail", pk=actor_id)

def build_activity_id(activity_kind, activity_id, request):
return build_id_url(f"activities/{activity_kind}", activity_id, request)
def build_activity_id(activity_kind, activity, request):
return build_url(request, f"{activity_kind}-activity-detail", pk=activity.actor_id, object_pk=activity.pk)

def build_note_id(note_id, request):
return build_id_url("notes", note_id, request)
def build_note_id(note, request):
return build_url(request, "note-detail", pk=note.actor_id, object_pk=note.pk)

def build_outbox_id(actor_id, request):
return build_url(request, "actor-outbox", pk=actor_id)
2 changes: 1 addition & 1 deletion testbed/core/tests/test_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def test_outbox_newest_first_ties_by_pk(actor, other_actor, note, lola_auth_cont
("Create", oldest),
]
assert [item["id"] for item in items] == [
build_activity_id(kind.lower(), activity.pk, mock_request) for kind, activity in expected
build_activity_id(kind.lower(), activity, mock_request) for kind, activity in expected
]

# Test that a supplied timestamp survives to the database, so a copied activity can keep its original date (LOLA §7.1.7)
Expand Down
4 changes: 2 additions & 2 deletions testbed/core/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ def test_like_of_another_accounts_private_note_is_served_as_id_only(mock_request
response = APIClient().get(reverse("actor-outbox", kwargs={"pk": liker.id}))

likes = [item for item in response.data["orderedItems"] if item["type"] == "Like"]
assert [like["object"] for like in likes] == [build_note_id(note.id, mock_request)]
assert [like["object"] for like in likes] == [build_note_id(note, mock_request)]
assert b"AUTHOR PRIVATE CONTENT" not in response.content
assert b"lemongrove.example/followers" not in response.content

Expand All @@ -564,7 +564,7 @@ def test_liked_items_are_the_like_objects(basic_auth_context, mock_request):
response = lola_client(liker).get(reverse("liked-collection", kwargs={"pk": liker.id}))

assert response.data["orderedItems"] == [
build_note_id(private_note.id, mock_request),
build_note_id(private_note, mock_request),
build_note_json_ld(public_note, basic_auth_context),
{"@context": [ACTIVITY_STREAM_CONTEXT, PREVIOUSLY_TERM], **remote_data, "id": remote.object_url},
]
Expand Down
18 changes: 9 additions & 9 deletions testbed/core/tests/test_json_ld_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_build_note_json_ld(note, basic_auth_context, mock_request):

assert json_ld["@context"] == build_basic_context()
assert json_ld["type"] == "Note"
assert json_ld["id"] == build_note_id(note.id, mock_request)
assert json_ld["id"] == build_note_id(note, mock_request)
assert json_ld["attributedTo"] == build_actor_id(note.actor.id, mock_request)
assert json_ld["content"] == note.content
assert json_ld["visibility"] == note.visibility
Expand Down Expand Up @@ -87,10 +87,10 @@ def test_build_create_activity_json_ld_note(actor, basic_auth_context, mock_requ

assert json_ld["@context"] == build_basic_context()
assert json_ld["type"] == "Create"
assert json_ld["id"] == build_activity_id("create", activity.id, mock_request)
assert json_ld["id"] == build_activity_id("create", activity, mock_request)
assert json_ld["actor"] == build_actor_id(actor.id, mock_request)
assert json_ld["object"]["type"] == "Note"
assert json_ld["object"]["id"] == build_note_id(note.id, mock_request)
assert json_ld["object"]["id"] == build_note_id(note, mock_request)

# Test building JSON-LD for actor creation activity
@pytest.mark.django_db
Expand All @@ -104,7 +104,7 @@ def test_build_create_activity_json_ld_actor_creation(actor, basic_auth_context,

assert json_ld["@context"] == build_basic_context()
assert json_ld["type"] == "Create"
assert json_ld["id"] == build_activity_id("create", activity.id, mock_request)
assert json_ld["id"] == build_activity_id("create", activity, mock_request)
assert json_ld["actor"] == build_actor_id(actor.id, mock_request)
assert json_ld["object"]["type"] == "Person"
assert json_ld["object"]["id"] == build_actor_id(actor.id, mock_request)
Expand All @@ -119,10 +119,10 @@ def test_build_like_activity_json_ld_local(actor, basic_auth_context, mock_reque

assert json_ld["@context"] == build_basic_context()
assert json_ld["type"] == "Like"
assert json_ld["id"] == build_activity_id("like", activity.id, mock_request)
assert json_ld["id"] == build_activity_id("like", activity, mock_request)
assert json_ld["actor"] == build_actor_id(actor.id, mock_request)
assert json_ld["object"]["type"] == "Note"
assert json_ld["object"]["id"] == build_note_id(note.id, mock_request)
assert json_ld["object"]["id"] == build_note_id(note, mock_request)

# Test building JSON-LD for remote like activity
@pytest.mark.django_db
Expand All @@ -137,7 +137,7 @@ def test_build_like_activity_json_ld_remote(actor, basic_auth_context, mock_requ
json_ld = build_like_activity_json_ld(activity, basic_auth_context)
assert json_ld["@context"] == build_basic_context()
assert json_ld["type"] == "Like"
assert json_ld["id"] == build_activity_id("like", activity.id, mock_request)
assert json_ld["id"] == build_activity_id("like", activity, mock_request)
assert json_ld["actor"] == build_actor_id(actor.id, mock_request)
assert json_ld["object"]["id"] == "https://remote.example/notes/123"
assert json_ld["object"]["content"] == "Remote content"
Expand All @@ -153,7 +153,7 @@ def test_build_follow_activity_json_ld_local(actor, other_actor, basic_auth_cont
json_ld = build_follow_activity_json_ld(activity, basic_auth_context)
assert json_ld["@context"] == build_basic_context()
assert json_ld["type"] == "Follow"
assert json_ld["id"] == build_activity_id("follow", activity.id, mock_request)
assert json_ld["id"] == build_activity_id("follow", activity, mock_request)
assert json_ld["actor"] == build_actor_id(actor.id, mock_request)
assert json_ld["object"]["type"] == "Person"
assert json_ld["object"]["id"] == build_actor_id(other_actor.id, mock_request)
Expand All @@ -171,7 +171,7 @@ def test_build_follow_activity_json_ld_remote(actor, basic_auth_context, mock_re
json_ld = build_follow_activity_json_ld(activity, basic_auth_context)
assert json_ld["@context"] == build_basic_context()
assert json_ld["type"] == "Follow"
assert json_ld["id"] == build_activity_id("follow", activity.id, mock_request)
assert json_ld["id"] == build_activity_id("follow", activity, mock_request)
assert json_ld["actor"] == build_actor_id(actor.id, mock_request)
assert json_ld["object"]["id"] == "https://remote.example/users/remote_user"
assert json_ld["object"]["preferredUsername"] == "remote_user"
Expand Down
22 changes: 9 additions & 13 deletions testbed/core/tests/test_json_ld_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
PREVIOUSLY_TERM,
build_basic_context,
build_actor_context,
build_id_url,
build_actor_id,
build_activity_id,
build_note_id,
build_outbox_id,
)
from testbed.core.models import CreateActivity, FollowActivity, LikeActivity, Note

# Test that context URLs are correct
def test_json_ld_context_constants():
Expand All @@ -26,29 +26,25 @@ def test_build_basic_context():
def test_build_actor_context():
assert build_actor_context() == [ACTIVITY_STREAM_CONTEXT, BLOCKED_CONTEXT, PREVIOUSLY_TERM]

# Test base URL builder function
def test_build_id_url(mock_request):
url = build_id_url("test", 123, mock_request)
assert url == "http://testserver/api/test/123/"

def test_build_actor_id(mock_request):
actor_id = build_actor_id(123, mock_request)
assert actor_id == "http://testserver/api/actors/123/"

# Unsaved instances: reverse() needs no database. Owner 7 and object 123 differ so a swap would show
def test_activity_ids_are_unique_across_kinds(mock_request):
ids = [
build_activity_id(activity_kind, 123, mock_request)
for activity_kind in ("create", "like", "follow")
build_activity_id(activity_kind, model(pk=123, actor_id=7), mock_request)
for activity_kind, model in (("create", CreateActivity), ("like", LikeActivity), ("follow", FollowActivity))
]
assert ids == [
"http://testserver/api/activities/create/123/",
"http://testserver/api/activities/like/123/",
"http://testserver/api/activities/follow/123/",
"http://testserver/api/actors/7/activities/create/123/",
"http://testserver/api/actors/7/activities/like/123/",
"http://testserver/api/actors/7/activities/follow/123/",
]

def test_build_note_id(mock_request):
note_id = build_note_id(123, mock_request)
assert note_id == "http://testserver/api/notes/123/"
note_id = build_note_id(Note(pk=123, actor_id=7), mock_request)
assert note_id == "http://testserver/api/actors/7/notes/123/"

def test_build_outbox_id(mock_request):
outbox_id = build_outbox_id(123, mock_request)
Expand Down
89 changes: 89 additions & 0 deletions testbed/core/tests/test_object_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient

from testbed.core.factories import (
CreateActivityFactory,
FollowActivityFactory,
IsolatedActorFactory,
LikeActivityFactory,
NoteFactory,
)
from testbed.core.json_ld_builders import (
build_create_activity_json_ld,
build_follow_activity_json_ld,
build_like_activity_json_ld,
build_note_json_ld,
)
from testbed.core.tests.helpers import lola_client


def detail_url(route, obj, actor=None):
return reverse(route, kwargs={"pk": (actor or obj.actor).pk, "object_pk": obj.pk})


# ActivityPub §3.1: an object dereferences, and a public one is served to anyone exactly as the collections serve it.
@pytest.mark.django_db
@pytest.mark.parametrize("route, object_factory, related, build", [
("note-detail", NoteFactory, {}, build_note_json_ld),
("create-activity-detail", CreateActivityFactory, {"note": None}, build_create_activity_json_ld),
("like-activity-detail", LikeActivityFactory, {"remote": True}, build_like_activity_json_ld),
("follow-activity-detail", FollowActivityFactory, {"remote": True}, build_follow_activity_json_ld),
])
def test_public_object_is_served_to_anyone(basic_auth_context, route, object_factory, related, build):
obj = object_factory(actor=IsolatedActorFactory(prefix="object_owner"), visibility="public", **related)

response = APIClient().get(detail_url(route, obj), HTTP_ACCEPT="application/activity+json")

assert response.status_code == status.HTTP_200_OK
assert response["Content-Type"] == "application/activity+json"
assert response.data == build(obj, basic_auth_context)
# The id is where the object lives, it names the URL just fetched
assert response.data["id"] == response.wsgi_request.build_absolute_uri()


# Anything other than "public" is strict. Only a token bound to the owner gets through
@pytest.mark.django_db
@pytest.mark.parametrize("visibility", ["private", "followers-only"])
def test_non_public_object_is_seen_only_by_its_owner(visibility):
owner = IsolatedActorFactory(prefix="hidden_owner")
other = IsolatedActorFactory(prefix="hidden_other")
url = detail_url("note-detail", NoteFactory(actor=owner, visibility=visibility))

assert APIClient().get(url).status_code == status.HTTP_404_NOT_FOUND
assert lola_client(other).get(url).status_code == status.HTTP_404_NOT_FOUND
assert lola_client(owner).get(url).status_code == status.HTTP_200_OK


# A hidden object, a missing one and one under another actor all give the same 404
@pytest.mark.django_db
def test_hidden_missing_and_misplaced_objects_answer_alike():
owner = IsolatedActorFactory(prefix="alike_owner")
other = IsolatedActorFactory(prefix="alike_other")
hidden = NoteFactory(actor=owner, visibility="private")
public = NoteFactory(actor=owner, visibility="public")

responses = [
APIClient().get(detail_url("note-detail", hidden)),
lola_client(other).get(detail_url("note-detail", hidden)),
APIClient().get(reverse("note-detail", kwargs={"pk": owner.pk, "object_pk": 999999})),
APIClient().get(detail_url("note-detail", public, actor=other)),
]

answers = {(r.status_code, r.data["error_code"], r.data["detail"], r.data["hint"]) for r in responses}
assert len(answers) == 1
assert answers.pop()[:2] == (status.HTTP_404_NOT_FOUND, "object_not_found")


# LOLA §5: a token bound to another actor is refused on a public object, as on every dual-mode endpoint
@pytest.mark.django_db
def test_public_object_refuses_another_actors_token():
owner = IsolatedActorFactory(prefix="refuse_owner")
other = IsolatedActorFactory(prefix="refuse_other")
url = detail_url("note-detail", NoteFactory(actor=owner, visibility="public"))

response = lola_client(other).get(url)

assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data["error_code"] == "actor_mismatch"
28 changes: 28 additions & 0 deletions testbed/core/urls/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
content_collection,
liked_collection,
blocked_collection,
note_detail,
create_activity_detail,
like_activity_detail,
follow_activity_detail,
)

urlpatterns = [
Expand Down Expand Up @@ -48,6 +52,30 @@
blocked_collection,
name="blocked-collection",
),
# Note: the URL its `id` names, nested under the owning actor so a moved actor can still be found from it
path(
"actors/<int:pk>/notes/<int:object_pk>/",
note_detail,
name="note-detail",
),
# Create activity
path(
"actors/<int:pk>/activities/create/<int:object_pk>/",
create_activity_detail,
name="create-activity-detail",
),
# Like activity
path(
"actors/<int:pk>/activities/like/<int:object_pk>/",
like_activity_detail,
name="like-activity-detail",
),
# Follow activity
path(
"actors/<int:pk>/activities/follow/<int:object_pk>/",
follow_activity_detail,
name="follow-activity-detail",
),
# Dedicated LOLA migration collection routes. These are the URLs advertised under the Actor `migration` object.
# Each route delegates to the existing collection view so the advertised URL is real and resolves.
# The behavioral update of the migration surface (migration-outbox activity filtering, pagination, and
Expand Down
22 changes: 22 additions & 0 deletions testbed/core/utils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class ErrorCodes:
# Authentication & Authorization Errors (4xx)
INSUFFICIENT_SCOPE = "insufficient_scope"
ACTOR_NOT_FOUND = "actor_not_found"
OBJECT_NOT_FOUND = "object_not_found"
FORBIDDEN_ACCESS = "forbidden_access"
UNAUTHORIZED = "unauthorized"
ACTOR_MISMATCH = "actor_mismatch"
Expand Down Expand Up @@ -120,6 +121,27 @@ def build_actor_not_found_error(actor_id, request=None):
)


def build_object_not_found_error(request=None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This nested "build_x" approach looks very much like a functional approach for what could be simpler as an object-oriented approach - the method is a very thin wrapper. Error response is already an object concept in python requests, and already an inheritance-based one, so a specific "ObjectNotFound" error could be built on the generic "ResourceNotFound" error

Even cleaner (and requiring less utils) is raising custom exceptions and the custom exceptions get handled in post-view common handling that adds the error code.

Looking at this whole util file as more gets added to it and its purpose gets more clear, I am seeing what it does and guessing that it could be replaced with 10 lines even taking this basic functional approach.

  • it adds more detailed codes on top of 400, 404, 500 with constants
  • it adds full-sentence descriptions
  • adds timestamps (redundant)
  • adds new ID (generated once and does not correlate with anything)

Some of these jobs could be done with simple lookups (if the INSUFFICIENT_SCOPE constant is passed or the InsufficientScopeException is thrown, lookup the correct HTTP status code and explanation to return with the Response. ). Other jobs could be done in the Response object or exception handling. The new ID and timestamp jobs don't seem to be adding value.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're absolutely right with it. I'll iterate over it.

"""
Build standardized 404 error for a Note or activity that is missing, belongs to another actor,
or is not visible to the caller. All three get this same response, so it never reveals that a
private object exists (ActivityPub §3.2).

Args:
request (HttpRequest, optional): Django request object for context

Returns:
Response: 404 error response with object_not_found error code
"""
return build_error_response(
error_code=ErrorCodes.OBJECT_NOT_FOUND,
detail="No such object",
status_code=404,
request=request,
hint="The object does not exist under this actor, or is not visible to this caller",
)


def build_insufficient_scope_error(required_scope, endpoint_path, request=None):
"""
Build standardized 403 error for insufficient OAuth scope.
Expand Down
Loading
Loading