Castopod announces an episode with a Note that only links to the episode's page, which serves a PodcastEpisode. PrivaPub reads PodcastEpisode (its words in description, its sound in audio, its cover in image), and a Note that is only a link to its author's own episode takes the episode's sound, title, cover and words, so the post plays. A server that publishes no NodeInfo is described from NodeInfo2 (/.well-known/x-nodeinfo2), as Castopod publishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
272 lines
12 KiB
C#
272 lines
12 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.Post;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Federation
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class InboundRoutingTests : IAsyncLifetime
|
|
{
|
|
Harness _harness;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_harness = await Harness.Start();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_harness != default)
|
|
await _harness.DisposeAsync();
|
|
}
|
|
|
|
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
|
|
|
static string NewId(RemoteActor actor, string kind) => $"{Origin(actor)}/{kind}/{Guid.NewGuid():N}";
|
|
|
|
async Task<Post> LocalPost(string text)
|
|
{
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
await _harness.Posts.InsertPost(root, new InsertPostForm { AvatarId = alice.Id, Text = text }, TestContext.Current.CancellationToken);
|
|
return await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_downvote_counts_and_its_undo_takes_it_back()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var post = await LocalPost("vote on me");
|
|
var lemmy = new RemoteActor(_harness.Peer, "voter");
|
|
var dislike = new JsonObject { ["id"] = NewId(lemmy, "activities/dislike"), ["type"] = "Dislike", ["actor"] = lemmy.Id, ["object"] = post.ObjectURI };
|
|
|
|
await _harness.Deliver(lemmy, "/human-centipede", dislike);
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
|
|
|
|
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(lemmy, "activities/undo"), ["type"] = "Undo", ["actor"] = lemmy.Id, ["object"] = dislike.DeepClone()
|
|
});
|
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).DownvotesCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_delete_that_arrives_first_keeps_the_post_from_coming_back()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
|
var noteId = NewId(mallory, "notes");
|
|
|
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(mallory, "activities/delete"), ["type"] = "Delete", ["actor"] = mallory.Id,
|
|
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Tombstone" }
|
|
});
|
|
await _harness.Deliver(mallory, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(mallory, "activities/create"), ["type"] = "Create", ["actor"] = mallory.Id,
|
|
["object"] = new JsonObject
|
|
{
|
|
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = mallory.Id, ["content"] = "<p>late</p>",
|
|
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(alice.Uri),
|
|
["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" })
|
|
}
|
|
});
|
|
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_lemmy_private_message_arrives_as_a_direct_message()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var lemmy = new RemoteActor(_harness.Peer, "pm");
|
|
var messageId = NewId(lemmy, "private_message");
|
|
|
|
await _harness.Deliver(lemmy, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(lemmy, "activities/create"), ["type"] = "Create", ["actor"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
|
|
["object"] = new JsonObject
|
|
{
|
|
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = lemmy.Id, ["to"] = new JsonArray(alice.Uri),
|
|
["content"] = "<p>psst</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
|
|
}
|
|
});
|
|
|
|
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
|
|
Assert.NotNull(message);
|
|
Assert.Equal(PostVisibility.Direct, message.Visibility);
|
|
Assert.NotNull(message.ConversationId);
|
|
}
|
|
|
|
// Mbin addresses its private messages to the recipient's profile page (its `url`), not to the actor
|
|
[Fact]
|
|
public async Task A_chat_message_addressed_to_a_personas_profile_page_reaches_the_persona()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var mbin = new RemoteActor(_harness.Peer, "mbuser");
|
|
var messageId = NewId(mbin, "f/object");
|
|
|
|
await _harness.Deliver(mbin, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(mbin, "f/object"), ["type"] = "Create", ["actor"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
|
|
["object"] = new JsonObject
|
|
{
|
|
["id"] = messageId, ["type"] = "ChatMessage", ["attributedTo"] = mbin.Id, ["to"] = new JsonArray(alice.HtmlUrl), ["cc"] = new JsonArray(),
|
|
["content"] = "<p>an answer</p>", ["mediaType"] = "text/html", ["published"] = DateTime.UtcNow.ToString("O")
|
|
}
|
|
});
|
|
|
|
var message = await DB.Default.Find<Post>().Match(p => p.ObjectURI == messageId).ExecuteFirstAsync(token);
|
|
Assert.NotNull(message);
|
|
Assert.Equal(PostVisibility.Direct, message.Visibility);
|
|
var conversation = await DB.Default.Find<DmGroup>().OneAsync(message.ConversationId, token);
|
|
Assert.Equal(new[] { alice.Id, mbin.Id }.Order(StringComparer.Ordinal), conversation.Members.Select(m => m.AvatarId).Order(StringComparer.Ordinal));
|
|
}
|
|
|
|
// Lemmy 0.19 and Mbin take a private message only as a ChatMessage: someone who writes to us that way is answered in
|
|
// kind, anyone else with a Note
|
|
[Fact]
|
|
public async Task A_direct_message_answers_in_the_form_its_recipient_writes_in()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var lemmy = new RemoteActor(_harness.Peer, "pm");
|
|
var mastodon = new RemoteActor(_harness.Peer, "dm");
|
|
async Task<Post> Received(RemoteActor sender, string type)
|
|
{
|
|
var id = NewId(sender, "messages");
|
|
await _harness.Deliver(sender, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(sender, "activities/create"), ["type"] = "Create", ["actor"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
|
|
["object"] = new JsonObject
|
|
{
|
|
["id"] = id, ["type"] = type, ["attributedTo"] = sender.Id, ["to"] = new JsonArray(alice.Uri),
|
|
["content"] = "<p>psst</p>", ["published"] = DateTime.UtcNow.ToString("O")
|
|
}
|
|
});
|
|
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == id).ExecuteSingleAsync(token);
|
|
}
|
|
async Task<JsonObject> Answer(RemoteActor to, Post message)
|
|
{
|
|
var answer = await _harness.Statuses.Publish(alice, new PrivaPub.Domain.Statuses.StatusDraft
|
|
{
|
|
Text = "psst back", Visibility = PostVisibility.Direct, ConversationId = message.ConversationId
|
|
}, token);
|
|
Assert.True(answer.Ok, answer.Error);
|
|
return (JsonObject)(await _harness.Outgoing(to.Id + "/inbox")).Last(a => a["type"]!.GetValue<string>() == "Create")["object"]!;
|
|
}
|
|
|
|
var chat = await Answer(lemmy, await Received(lemmy, "ChatMessage"));
|
|
var note = await Answer(mastodon, await Received(mastodon, "Note"));
|
|
|
|
Assert.Equal("ChatMessage", chat["type"]!.GetValue<string>());
|
|
Assert.Equal(lemmy.Id, Assert.Single(chat["to"]!.AsArray())!.GetValue<string>());
|
|
Assert.Null(chat["cc"]);
|
|
Assert.DoesNotContain("h-card", chat["content"]!.GetValue<string>());
|
|
Assert.Equal("Note", note["type"]!.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
|
|
{
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9");
|
|
|
|
var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri
|
|
});
|
|
|
|
Assert.Equal(503, result.StatusCode);
|
|
Assert.Equal(300, result.RetryAfterSeconds);
|
|
}
|
|
|
|
// Forte follows a persona by its profile page, as WebFinger names it, rather than by its actor's id; so does its Undo
|
|
[Fact]
|
|
public async Task A_follow_of_a_personas_profile_page_follows_the_persona()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var forte = new RemoteActor(_harness.Peer, "forte");
|
|
var follow = new JsonObject { ["id"] = NewId(forte, "follow"), ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl };
|
|
|
|
Assert.Equal(202, (await _harness.Deliver(forte, "/human-centipede", follow)).StatusCode);
|
|
Assert.True(await DB.Default.Find<PrivaPub.Models.Federation.Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id && f.IsAccepted).ExecuteAnyAsync(token));
|
|
Assert.Contains(await _harness.Outgoing(forte.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "Accept");
|
|
|
|
await _harness.Deliver(forte, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(forte, "undo"), ["type"] = "Undo", ["actor"] = forte.Id,
|
|
["object"] = new JsonObject { ["type"] = "Follow", ["actor"] = forte.Id, ["object"] = alice.HtmlUrl }
|
|
});
|
|
Assert.False(await DB.Default.Find<PrivaPub.Models.Federation.Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == forte.Id).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
// Castopod announces an episode with a note that only links to it: the post takes the episode's sound, title and cover
|
|
[Fact]
|
|
public async Task A_note_that_only_links_its_authors_episode_carries_the_episodes_sound()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var podcast = new RemoteActor(_harness.Peer, "pod");
|
|
await DB.Default.SaveAsync(new PrivaPub.Models.Social.Following
|
|
{
|
|
AvatarId = alice.Id, TargetActorURI = podcast.Id, TargetInboxURL = podcast.Id + "/inbox", State = PrivaPub.Models.Social.FollowState.Accepted
|
|
}, token);
|
|
var origin = new Uri(podcast.Id).GetLeftPart(UriPartial.Authority);
|
|
var episodePath = $"/episodes/{Guid.NewGuid():N}";
|
|
_harness.Peer.Serve(episodePath, new JsonObject
|
|
{
|
|
["id"] = origin + episodePath, ["type"] = "PodcastEpisode", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
|
|
["description"] = new JsonObject { ["type"] = "Note", ["content"] = "the meadow", ["mediaType"] = "text/markdown" },
|
|
["image"] = new JsonObject { ["type"] = "Image", ["url"] = origin + "/cover.png" },
|
|
["audio"] = new JsonObject
|
|
{
|
|
["id"] = origin + "/one.mp3", ["type"] = "Audio", ["name"] = "Episode one", ["duration"] = 4.0,
|
|
["url"] = new JsonObject { ["href"] = origin + "/one.mp3", ["type"] = "Link", ["mediaType"] = "audio/mpeg" }
|
|
}
|
|
}.ToJsonString());
|
|
var noteId = NewId(podcast, "posts");
|
|
|
|
await _harness.Deliver(podcast, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
|
|
["object"] = new JsonObject
|
|
{
|
|
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = podcast.Id, ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
|
|
["content"] = $"<a href=\"{origin + episodePath}\">Episode one</a><br/>", ["published"] = DateTime.UtcNow.ToString("O")
|
|
}
|
|
});
|
|
|
|
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
|
|
Assert.Equal(origin + "/one.mp3", Assert.Single(post.Media).RemoteURL);
|
|
Assert.Equal(("Episode one", origin + "/cover.png", 4.0), (post.Title, post.CoverURL, post.Audio.DurationSeconds));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_join_of_a_local_post_is_answered_with_ignore()
|
|
{
|
|
var post = await LocalPost("not an event");
|
|
var mobilizon = new RemoteActor(_harness.Peer, "joiner");
|
|
|
|
await _harness.Deliver(mobilizon, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(mobilizon, "join"), ["type"] = "Join", ["actor"] = mobilizon.Id, ["object"] = post.ObjectURI
|
|
});
|
|
|
|
var answer = Assert.Single(await _harness.Outgoing(mobilizon.Id + "/inbox"));
|
|
Assert.Equal("Ignore", answer["type"]!.GetValue<string>());
|
|
}
|
|
}
|
|
}
|