Skip to content

Commit 59e9b0f

Browse files
authored
Merge pull request #231 from dtinit/feature/overview-actor-account-portability-oauth-field
feat: Update Actor Account Portability OAuth field plus codebase and tests refactor
2 parents eeec4e2 + d0c67e2 commit 59e9b0f

12 files changed

Lines changed: 288 additions & 183 deletions

File tree

testbed/core/json_ld_builders.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
77
from .utils.oauth_utils import build_oauth_endpoint_url
88
from .models import CreateActivity, LikeActivity, FollowActivity
99

10+
# Build JSON-LD Actor with LOLA compliance.
1011
def build_actor_json_ld(actor, auth_context=None):
1112
"""
12-
Build JSON-LD Actor with optional LOLA enhancements.
13+
The accountPortabilityOauth field MUST always be present
14+
for OAuth endpoint discovery (public visibility).
15+
16+
The migration.* properties are conditionally included only
17+
when the request includes a valid portability token (scoped access).
1318
1419
Args:
1520
actor: The Actor model instance
@@ -21,37 +26,42 @@ def build_actor_json_ld(actor, auth_context=None):
2126
Returns:
2227
Dict containing ActivityPub Actor with conditional LOLA fields
2328
"""
29+
2430
# Extract request for dynamic URL generation
2531
request = auth_context.get('request') if auth_context else None
2632

27-
# Base ActivityPub Actor (always included) - now with dynamic URLs
33+
# Build actor URL
2834
actor_id = build_actor_id(actor.id, request)
35+
36+
# Base ActivityPub Actor (always included)
2937
actor_data = {
3038
"@context": build_actor_context(),
3139
"type": "Person",
3240
"id": actor_id,
3341
"preferredUsername": actor.username,
3442
"name": actor.username,
35-
# Standard ActivityPub collections
3643
"inbox": f"{actor_id}/inbox",
37-
"outbox": f"{actor_id}/outbox",
38-
"previously": actor.previously or [], # Ensure it's always a list
44+
"previously": actor.previously or [],
45+
"accountPortabilityOauth": build_oauth_endpoint_url(request)
3946
}
4047

41-
# Add LOLA fields ONLY when authenticated with portability scope
48+
# Privacy-sensitive fields ONLY with portability scope
4249
if auth_context and auth_context.get('has_portability_scope'):
43-
# Required LOLA discovery field
44-
actor_data["accountPortabilityOauth"] = build_oauth_endpoint_url(request)
45-
46-
# LOLA social collections (privacy-sensitive relationship data)
47-
actor_data["followers"] = f"{actor_id}/followers"
50+
# Standard ActivityPub collections (privacy-sensitive)
51+
actor_data["outbox"] = f"{actor_id}/outbox"
4852
actor_data["following"] = f"{actor_id}/following"
49-
50-
# Additional LOLA discovery fields
51-
actor_data["content"] = f"{actor_id}/content"
53+
actor_data["followers"] = f"{actor_id}/followers"
5254
actor_data["liked"] = f"{actor_id}/liked"
5355
actor_data["blocked"] = f"{actor_id}/blocked"
54-
actor_data["migration"] = f"{actor_id}/outbox"
56+
57+
# LOLA migration endpoints (same URLs, scope-filtered responses)
58+
actor_data["migration"] = {
59+
"outbox": f"{actor_id}/outbox",
60+
"content": f"{actor_id}/content",
61+
"following": f"{actor_id}/following",
62+
"blocked": f"{actor_id}/blocked",
63+
"liked": f"{actor_id}/liked"
64+
}
5565

5666
return actor_data
5767

testbed/core/json_ld_utils.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
class JsonLDContext:
2-
ACTIVITY_STREAM = "https://www.w3.org/ns/activitystreams"
3-
LOLA = "https://swicg.github.io/activitypub-data-portability/lola.jsonld"
1+
ACTIVITY_STREAM_CONTEXT = "https://www.w3.org/ns/activitystreams"
2+
LOLA_CONTEXT = "https://swicg.github.io/activitypub-data-portability/lola"
3+
BLOCKED_CONTEXT = "https://purl.archive.org/socialweb/blocked"
44

5-
# Return the basic context used in most responses
5+
# Basic context used in most responses
66
def build_basic_context():
7-
return JsonLDContext.ACTIVITY_STREAM
7+
return ACTIVITY_STREAM_CONTEXT
88

9-
# Return the extended context used specifcally for Actor responses
9+
# Return the extended context used specifically for Actor responses
10+
# Includes blocked collection support (FEP-c648)
1011
def build_actor_context():
1112
return [
12-
JsonLDContext.ACTIVITY_STREAM,
13-
JsonLDContext.LOLA
13+
ACTIVITY_STREAM_CONTEXT,
14+
BLOCKED_CONTEXT,
15+
LOLA_CONTEXT
1416
]
1517

1618
def build_id_url(type_name, obj_id, request):

testbed/core/tests/test_api.py

Lines changed: 14 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -74,102 +74,11 @@ def test_outbox_not_found():
7474
response = APIClient().get(reverse("actor-outbox", kwargs={"pk": 99999}))
7575
assert response.status_code == status.HTTP_404_NOT_FOUND
7676

77-
78-
# LOLA Authentication Tests
79-
8077
"""
81-
Tests the complete request-response cycle with different authentication states
82-
to verify that endpoints properly serve enhanced data for LOLA-authenticated requests.
78+
Edge case tests for LOLA authentication.
79+
These tests focus on specific edge cases: URL params, outbox filtering, error handling, headers.
8380
"""
8481
class TestLOLAAuthenticationAPI:
85-
# Constants for OAuth scopes
86-
LOLA_SCOPE = 'activitypub_account_portability read write'
87-
BASIC_SCOPE = 'read write'
88-
89-
# Helper methods for repeated assertions
90-
91-
# Helper to verify standard ActivityPub fields
92-
def assert_basic_activitypub_structure(self, data, actor, mock_request):
93-
assert data["@context"] == build_actor_context()
94-
assert data["type"] == "Person"
95-
96-
assert data["id"] == build_actor_id(actor.id, mock_request)
97-
assert data["preferredUsername"] == actor.username
98-
99-
# Helper to verify LOLA fields are absent
100-
def assert_no_lola_fields(self, data):
101-
lola_fields = ["accountPortabilityOauth", "content", "blocked", "migration"]
102-
for field in lola_fields:
103-
assert field not in data
104-
105-
# Helper to verify LOLA fields are present and properly formatted
106-
def assert_has_lola_fields(self, data, actor):
107-
assert "accountPortabilityOauth" in data
108-
assert "content" in data
109-
assert "blocked" in data
110-
assert "migration" in data
111-
112-
# Verify LOLA URLs are properly formatted
113-
assert data["accountPortabilityOauth"].endswith("/oauth/authorize/")
114-
assert data["content"].endswith(f"/actors/{actor.id}/content")
115-
assert data["blocked"].endswith(f"/actors/{actor.id}/blocked")
116-
assert data["migration"].endswith(f"/actors/{actor.id}/outbox")
117-
118-
# Helper to create authenticated client
119-
def get_authenticated_client(self, token):
120-
client = APIClient()
121-
client.credentials(HTTP_AUTHORIZATION=f'Bearer {token.token}')
122-
return client
123-
124-
# Test that unauthenticated requests return basic ActivityPub data only
125-
@pytest.mark.django_db
126-
def test_actor_detail_unauthenticated_returns_basic_activitypub(self, mock_request):
127-
actor = create_isolated_actor("unauthenticated_test")
128-
client = APIClient()
129-
130-
response = client.get(reverse("actor-detail", kwargs={"pk": actor.id}))
131-
132-
# Should succeed with basic ActivityPub response
133-
assert response.status_code == status.HTTP_200_OK
134-
135-
data = response.data
136-
# Use helper methods for assertions
137-
self.assert_basic_activitypub_structure(data, actor, mock_request)
138-
self.assert_no_lola_fields(data)
139-
140-
# Test that LOLA-authenticated requests return enhanced data with collection URLs
141-
@pytest.mark.django_db
142-
def test_actor_detail_with_lola_scope_returns_enhanced_data(self, mock_request):
143-
actor = create_isolated_actor("lola_enhanced_test")
144-
lola_token = AccessTokenFactory(lola_scope=True)
145-
client = self.get_authenticated_client(lola_token)
146-
147-
response = client.get(reverse("actor-detail", kwargs={"pk": actor.id}))
148-
149-
# Should succeed with enhanced response
150-
assert response.status_code == status.HTTP_200_OK
151-
152-
data = response.data
153-
# Use helper methods for assertions
154-
self.assert_basic_activitypub_structure(data, actor, mock_request)
155-
self.assert_has_lola_fields(data, actor)
156-
157-
# Test that authenticated requests without LOLA scope return basic data
158-
@pytest.mark.django_db
159-
def test_actor_detail_with_basic_token_returns_basic_data(self, mock_request):
160-
actor = create_isolated_actor("basic_token_test")
161-
basic_token = AccessTokenFactory(scope=self.BASIC_SCOPE)
162-
client = self.get_authenticated_client(basic_token)
163-
164-
response = client.get(reverse("actor-detail", kwargs={"pk": actor.id}))
165-
166-
# Should succeed but return basic data (no LOLA scope)
167-
assert response.status_code == status.HTTP_200_OK
168-
169-
data = response.data
170-
# Use helper methods for assertions
171-
self.assert_basic_activitypub_structure(data, actor, mock_request)
172-
self.assert_no_lola_fields(data)
17382

17483
# Test that URL parameter authentication works for LOLA testing
17584
@pytest.mark.django_db
@@ -183,10 +92,8 @@ def test_actor_detail_url_parameter_authentication(self):
18392
response = client.get(f"{url}?auth_token={lola_token.token}")
18493

18594
assert response.status_code == status.HTTP_200_OK
186-
187-
data = response.data
188-
# Should have LOLA fields (proves URL parameter auth worked)
189-
self.assert_has_lola_fields(data, actor)
95+
# Should have migration field (proves URL parameter auth worked)
96+
assert "migration" in response.data
19097

19198
# Test that outbox shows different content based on authentication
19299
@pytest.mark.django_db
@@ -224,38 +131,6 @@ def test_outbox_content_filtering_by_authentication(self, mock_request):
224131
assert public_data["id"] == expected_outbox_id
225132
assert lola_data["id"] == expected_outbox_id
226133

227-
# Test that demonstrates clear differences between public and LOLA responses
228-
@pytest.mark.django_db
229-
def test_side_by_side_authentication_comparison(self):
230-
actor = create_isolated_actor("comparison_test")
231-
lola_token = AccessTokenFactory(lola_scope=True)
232-
233-
# Public request
234-
public_client = APIClient()
235-
public_response = public_client.get(reverse("actor-detail", kwargs={"pk": actor.id}))
236-
public_data = public_response.data
237-
238-
# LOLA request
239-
lola_client = self.get_authenticated_client(lola_token)
240-
lola_response = lola_client.get(reverse("actor-detail", kwargs={"pk": actor.id}))
241-
lola_data = lola_response.data
242-
243-
# Both should succeed
244-
assert public_response.status_code == status.HTTP_200_OK
245-
assert lola_response.status_code == status.HTTP_200_OK
246-
247-
# Both should have identical basic fields
248-
basic_fields = ["@context", "type", "id", "preferredUsername", "name", "previously"]
249-
for field in basic_fields:
250-
assert public_data[field] == lola_data[field]
251-
252-
# Only LOLA should have enhanced fields
253-
lola_fields = ["accountPortabilityOauth", "content", "blocked", "migration"]
254-
for field in lola_fields:
255-
assert field not in public_data
256-
assert field in lola_data
257-
assert isinstance(lola_data[field], str) # Should be URL strings
258-
259134
# Test that invalid tokens gracefully degrade to unauthenticated behavior
260135
@pytest.mark.django_db
261136
def test_invalid_token_graceful_degradation(self):
@@ -268,13 +143,8 @@ def test_invalid_token_graceful_degradation(self):
268143

269144
# Should succeed with public data (graceful degradation)
270145
assert response.status_code == status.HTTP_200_OK
271-
272-
data = response.data
273-
assert data["type"] == "Person"
274-
# Should NOT have LOLA fields (invalid token treated as unauthenticated)
275-
assert "accountPortabilityOauth" not in data
276-
assert "content" not in data
277-
assert "blocked" not in data
146+
# Should NOT have migration field (invalid token = unauthenticated)
147+
assert "migration" not in response.data
278148

279149
# Test graceful handling of malformed authorization headers
280150
@pytest.mark.parametrize("malformed_header", [
@@ -284,25 +154,25 @@ def test_invalid_token_graceful_degradation(self):
284154
"InvalidFormat token", # Malformed header
285155
])
286156
@pytest.mark.django_db
287-
def test_malformed_authorization_header_handling(self, malformed_header, mock_request):
157+
def test_malformed_authorization_header_handling(self, malformed_header):
288158
actor = create_isolated_actor("malformed_header_test")
289159
client = APIClient()
290160
client.credentials(HTTP_AUTHORIZATION=malformed_header)
291161

292162
response = client.get(reverse("actor-detail", kwargs={"pk": actor.id}))
293163

294-
# Should succeed with public data for all malformed cases
164+
# Should succeed with public data (malformed auth = unauthenticated)
295165
assert response.status_code == status.HTTP_200_OK
296-
data = response.data
297-
self.assert_basic_activitypub_structure(data, actor, mock_request)
298-
self.assert_no_lola_fields(data)
166+
# Should NOT have migration field
167+
assert "migration" not in response.data
299168

300169
# Test that content-type headers are set correctly for API responses
301170
@pytest.mark.django_db
302171
def test_content_type_headers_set_correctly(self):
303172
actor = create_isolated_actor("content_type_test")
304173
lola_token = AccessTokenFactory(lola_scope=True)
305-
client = self.get_authenticated_client(lola_token)
174+
client = APIClient()
175+
client.credentials(HTTP_AUTHORIZATION=f'Bearer {lola_token.token}')
306176

307177
# Request with format=json
308178
response = client.get(reverse("actor-detail", kwargs={"pk": actor.id}), {"format": "json"})
@@ -312,10 +182,8 @@ def test_content_type_headers_set_correctly(self):
312182
assert response["Content-Type"] == "application/json"
313183
# Should have CORS header for federation
314184
assert response["Access-Control-Allow-Origin"] == "*"
315-
316-
# Should still have LOLA fields
317-
data = response.data
318-
assert "accountPortabilityOauth" in data
185+
# Should have migration field (authenticated)
186+
assert "migration" in response.data
319187

320188

321189
# LOLA Following Collection Tests

testbed/core/tests/test_json_ld_utils.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import pytest
22
from testbed.core.json_ld_utils import (
3-
JsonLDContext,
3+
ACTIVITY_STREAM_CONTEXT,
4+
LOLA_CONTEXT,
5+
BLOCKED_CONTEXT,
46
build_basic_context,
57
build_actor_context,
68
build_id_url,
@@ -12,22 +14,24 @@
1214

1315
# Test that context URLs are correct
1416
def test_json_ld_context_constants():
15-
assert JsonLDContext.ACTIVITY_STREAM == "https://www.w3.org/ns/activitystreams"
16-
assert JsonLDContext.LOLA == "https://swicg.github.io/activitypub-data-portability/lola.jsonld"
17+
assert ACTIVITY_STREAM_CONTEXT == "https://www.w3.org/ns/activitystreams"
18+
assert LOLA_CONTEXT == "https://swicg.github.io/activitypub-data-portability/lola"
19+
assert BLOCKED_CONTEXT == "https://purl.archive.org/socialweb/blocked"
1720

1821
# Test basic context builder returns single URL
1922
def test_build_basic_context():
2023
context = build_basic_context()
21-
assert context == JsonLDContext.ACTIVITY_STREAM
24+
assert context == ACTIVITY_STREAM_CONTEXT
2225
assert isinstance(context, str)
2326

24-
# Test actor context builder returns list with both URLs
27+
# Test actor context builder returns list with all three URLs
2528
def test_build_actor_context():
2629
context = build_actor_context()
2730
assert isinstance(context, list)
28-
assert len(context) == 2
29-
assert JsonLDContext.ACTIVITY_STREAM in context
30-
assert JsonLDContext.LOLA in context
31+
assert len(context) == 3
32+
assert ACTIVITY_STREAM_CONTEXT in context
33+
assert BLOCKED_CONTEXT in context
34+
assert LOLA_CONTEXT in context
3135

3236
# Test base URL builder function
3337
def test_build_id_url(mock_request):

0 commit comments

Comments
 (0)