12 KiB
ActivityPub Testing Helper API
This document describes helper endpoints for testing ActivityPub federation.
Purpose
These endpoints allow you to manually trigger ActivityPub activities for testing purposes without implementing the full UI federation integration yet.
Helper Endpoints
1. Send Follow Activity
Endpoint: POST /api/activitypub/test/follow
Description: Sends a Follow activity to a remote actor
Request Body:
{
"targetActorUri": "http://mastodon.local:3001/users/testuser"
}
Response:
{
"success": true,
"activityId": "http://solar.local:5000/activitypub/activities/...",
"targetActor": "http://mastodon.local:3001/users/testuser"
}
2. Send Like Activity
Endpoint: POST /api/activitypub/test/like
Description: Sends a Like activity for a post (can be local or remote)
Request Body:
{
"postId": "POST_ID",
"targetActorUri": "http://mastodon.local:3001/users/testuser"
}
Response:
{
"success": true,
"activityId": "http://solar.local:5000/activitypub/activities/..."
}
3. Send Announce (Boost) Activity
Endpoint: POST /api/activitypub/test/announce
Description: Boosts a post to followers
Request Body:
{
"postId": "POST_ID"
}
4. Send Undo Activity
Endpoint: POST /api/activitypub/test/undo
Description: Undoes a previous activity
Request Body:
{
"activityType": "Like", // or "Follow", "Announce"
"objectUri": "http://solar.local:5000/activitypub/objects/POST_ID"
}
5. Get Federation Status
Endpoint: GET /api/activitypub/test/status
Description: Returns current federation statistics
Response:
{
"actors": {
"total": 5,
"local": 1,
"remote": 4
},
"contents": {
"total": 25,
"byType": {
"Note": 20,
"Article": 5
}
},
"relationships": {
"total": 8,
"accepted": 6,
"pending": 1,
"rejected": 1
},
"activities": {
"total": 45,
"byStatus": {
"Completed": 40,
"Pending": 3,
"Failed": 2
},
"byType": {
"Create": 20,
"Follow": 8,
"Accept": 6,
"Like": 5,
"Announce": 3,
"Undo": 2,
"Delete": 1
}
}
}
6. Get Recent Activities
Endpoint: GET /api/activitypub/test/activities
Query Parameters:
limit: Number of activities to return (default: 20)type: Filter by activity type (optional)
Response:
{
"activities": [
{
"id": "ACTIVITY_ID",
"type": "Follow",
"status": "Completed",
"actorUri": "http://mastodon.local:3001/users/testuser",
"objectUri": "http://solar.local:5000/activitypub/actors/solaruser",
"createdAt": "2024-01-15T10:30:00Z",
"errorMessage": null
}
]
}
7. Get Actor Keys
Endpoint: GET /api/activitypub/test/actors/{username}/keys
Description: Returns the public/private key pair for a publisher
Response:
{
"username": "solaruser",
"hasKeys": true,
"actorUri": "http://solar.local:5000/activitypub/actors/solaruser",
"publicKeyId": "http://solar.local:5000/activitypub/actors/solaruser#main-key",
"publicKey": "-----BEGIN PUBLIC KEY-----\n...",
"privateKeyStored": true
}
8. Test HTTP Signature
Endpoint: POST /api/activitypub/test/sign
Description: Test if a signature string is valid for a given public key
Request Body:
{
"publicKey": "-----BEGIN PUBLIC KEY-----\n...",
"signingString": "(request-target): post /inbox\nhost: example.com\ndate: ...",
"signature": "..."
}
Response:
{
"valid": true,
"message": "Signature is valid"
}
Controller Implementation
Create DysonNetwork.Sphere/ActivityPub/ActivityPubTestController.cs:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.ActivityPub;
[ApiController]
[Route("api/activitypub/test")]
[Authorize] // Require auth for testing
public class ActivityPubTestController(
AppDatabase db,
ActivityPubDeliveryService deliveryService,
ActivityPubKeyService keyService,
ActivityPubSignatureService signatureService,
IConfiguration configuration,
ILogger<ActivityPubTestController> logger
) : ControllerBase
{
[HttpPost("follow")]
public async Task<ActionResult> TestFollow([FromBody] TestFollowRequest request)
{
var currentUser = GetCurrentUser();
var publisher = await GetPublisherForUser(currentUser.Id);
if (publisher == null)
return BadRequest("Publisher not found");
var success = await deliveryService.SendFollowActivityAsync(
publisher.Id,
request.TargetActorUri
);
return Ok(new
{
success,
targetActorUri = request.TargetActorUri,
publisherId = publisher.Id
});
}
[HttpPost("like")]
public async Task<ActionResult> TestLike([FromBody] TestLikeRequest request)
{
var currentUser = GetCurrentUser();
var publisher = await GetPublisherForUser(currentUser.Id);
var success = await deliveryService.SendLikeActivityAsync(
request.PostId,
currentUser.Id,
request.TargetActorUri
);
return Ok(new { success, postId = request.PostId });
}
[HttpPost("announce")]
public async Task<ActionResult> TestAnnounce([FromBody] TestAnnounceRequest request)
{
var post = await db.Posts.FindAsync(request.PostId);
if (post == null)
return NotFound();
var success = await deliveryService.SendCreateActivityAsync(post);
return Ok(new { success, postId = request.PostId });
}
[HttpPost("undo")]
public async Task<ActionResult> TestUndo([FromBody] TestUndoRequest request)
{
var currentUser = GetCurrentUser();
var publisher = await GetPublisherForUser(currentUser.Id);
if (publisher == null)
return BadRequest("Publisher not found");
var success = await deliveryService.SendUndoActivityAsync(
request.ActivityType,
request.ObjectUri,
publisher.Id
);
return Ok(new { success, activityType = request.ActivityType });
}
[HttpGet("status")]
public async Task<ActionResult> GetStatus()
{
var totalActors = await db.FediverseActors.CountAsync();
var localActors = await db.FediverseActors
.CountAsync(a => a.Uri.Contains("solar.local"));
var totalContents = await db.FediverseContents.CountAsync();
var relationships = await db.FediverseRelationships
.GroupBy(r => r.State)
.Select(g => new { State = g.Key, Count = g.Count() })
.ToListAsync();
var activitiesByStatus = await db.FediverseActivities
.GroupBy(a => a.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync();
var activitiesByType = await db.FediverseActivities
.GroupBy(a => a.Type)
.Select(g => new { Type = g.Key, Count = g.Count() })
.ToListAsync();
return Ok(new
{
actors = new
{
total = totalActors,
local = localActors,
remote = totalActors - localActors
},
contents = new
{
total = totalContents
},
relationships = relationships.ToDictionary(r => r.State.ToString(), r => r.Count),
activities = new
{
byStatus = activitiesByStatus.ToDictionary(a => a.Status.ToString(), a => a.Count),
byType = activitiesByType.ToDictionary(a => a.Type.ToString(), a => a.Count)
}
});
}
[HttpGet("activities")]
public async Task<ActionResult> GetActivities([FromQuery] int limit = 20, [FromQuery] string? type = null)
{
var query = db.FediverseActivities
.OrderByDescending(a => a.CreatedAt);
if (!string.IsNullOrEmpty(type))
{
query = query.Where(a => a.Type.ToString() == type);
}
var activities = await query
.Take(limit)
.Select(a => new
{
a.Id,
a.Type,
a.Status,
ActorUri = a.Actor.Uri,
ObjectUri = a.ObjectUri,
a.CreatedAt,
a.ErrorMessage
})
.ToListAsync();
return Ok(new { activities });
}
[HttpGet("actors/{username}/keys")]
public async Task<ActionResult> GetActorKeys(string username)
{
var publisher = await db.Publishers
.FirstOrDefaultAsync(p => p.Name == username);
if (publisher == null)
return NotFound();
var actorUrl = $"http://solar.local:5000/activitypub/actors/{username}";
var (privateKey, publicKey) = keyService.GenerateKeyPair();
return Ok(new
{
username,
hasKeys = publisher.Meta != null,
actorUri,
publicKeyId = $"{actorUrl}#main-key",
publicKey = publicKey,
privateKeyStored = publisher.Meta != null
});
}
[HttpPost("sign")]
public ActionResult TestSignature([FromBody] TestSignatureRequest request)
{
var isValid = keyService.Verify(
request.PublicKey,
request.SigningString,
request.Signature
);
return Ok(new
{
valid = isValid,
message = isValid ? "Signature is valid" : "Signature is invalid"
});
}
private async Task<SnPublisher?> GetPublisherForUser(Guid accountId)
{
return await db.Publishers
.Include(p => p.Members)
.Where(p => p.Members.Any(m => m.AccountId == accountId))
.FirstOrDefaultAsync();
}
private Guid GetCurrentUser()
{
// Implement based on your auth system
return Guid.Empty;
}
}
public class TestFollowRequest
{
public string TargetActorUri { get; set; } = string.Empty;
}
public class TestLikeRequest
{
public Guid PostId { get; set; }
public string TargetActorUri { get; set; } = string.Empty;
}
public class TestAnnounceRequest
{
public Guid PostId { get; set; }
}
public class TestUndoRequest
{
public string ActivityType { get; set; } = string.Empty;
public string ObjectUri { get; set; } = string.Empty;
}
public class TestSignatureRequest
{
public string PublicKey { get; set; } = string.Empty;
public string SigningString { get; set; } = string.Empty;
public string Signature { get; set; } = string.Empty;
}
Testing with Helper Endpoints
1. Test Follow
curl -X POST http://solar.local:5000/api/activitypub/test/follow \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"targetActorUri": "http://mastodon.local:3001/users/testuser"
}'
2. Test Like
curl -X POST http://solar.local:5000/api/activitypub/test/like \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"postId": "YOUR_POST_ID",
"targetActorUri": "http://mastodon.local:5000/activitypub/actors/mastodonuser"
}'
3. Check Status
curl http://solar.local:5000/api/activitypub/test/status \
-H "Authorization: Bearer YOUR_TOKEN"
4. Get Recent Activities
curl "http://solar.local:5000/api/activitypub/test/activities?limit=10" \
-H "Authorization: Bearer YOUR_TOKEN"
Integration with Main Flow
These helper endpoints can be used to:
- Quickly test federation without full UI integration
- Debug specific activity types in isolation
- Verify HTTP signatures are correct
- Test error handling for various scenarios
- Monitor federation status during development
Security Notes
- All test endpoints require authentication
- Use only in development/staging environments
- Remove or disable in production
- Rate limiting recommended if exposing to public
Cleanup
After testing, you can:
- Remove the test controller (optional)
- Disable test endpoints
- Clear test activities from database
- Reset test relationships
-- Clear test data
DELETE FROM fediverse_activities WHERE created_at < NOW() - INTERVAL '1 day';