Lemmy, PieFed and Mbin relay their members' votes, and the undoing of them, inside the community's Announce; PrivaPub dropped them all as unsupported (G-0003, the Lemmy scenario's expected failures). A relayed Like, Dislike or Undo from a community a persona follows is now handled as if its actor had sent it. The community vouches for what accounts on its own server do and for what is done to its own posts, as Lemmy trusts it (refetching every vote would not scale); a vote from elsewhere on anything else is believed only once fetched from its own origin. Moderation relayed the same way is still open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
277 lines
15 KiB
C#
277 lines
15 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.Social;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using static PrivaPub.Tests.Support.FederatedSeeds;
|
|
|
|
namespace PrivaPub.Tests.Federation
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class InboxGapTests : 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 JsonObject Follow(RemoteActor follower, string target) =>
|
|
new() { ["id"] = NewId(follower, "follows"), ["type"] = "Follow", ["actor"] = follower.Id, ["object"] = target };
|
|
|
|
Task<ForeignAvatar> Stored(RemoteActor actor) =>
|
|
DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == actor.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
|
|
|
|
PrivaPub.Models.Statistics.InteractionEvent Processed(string activity) => _harness.Ledger.Of("in").Last(e => e.Activity == activity);
|
|
|
|
[Fact]
|
|
public async Task An_actor_update_refreshes_the_account_from_its_origin_even_when_its_updated_is_older()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri));
|
|
var before = await Stored(bob);
|
|
using var rotated = RSA.Create(2048);
|
|
var document = bob.Document();
|
|
document["name"] = "Bob Renamed";
|
|
document["summary"] = "<p>a new bio<script>alert(1)</script></p>";
|
|
document["publicKey"]!["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem();
|
|
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString());
|
|
var embedded = (JsonObject)document.DeepClone();
|
|
embedded["name"] = "What the activity claims";
|
|
embedded["updated"] = "2001-01-01T00:00:00Z";
|
|
|
|
var result = await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", embedded));
|
|
|
|
var after = await Stored(bob);
|
|
Assert.Equal(202, result.StatusCode);
|
|
Assert.Null(before.Name);
|
|
Assert.Equal(before.ID, after.ID);
|
|
Assert.Equal("Bob Renamed", after.Name);
|
|
Assert.Equal("<p>a new bio</p>", after.Biography);
|
|
Assert.Equal(bob.KeyId, after.PublicKeyId);
|
|
Assert.NotEqual(before.PublicKey, after.PublicKey);
|
|
Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey);
|
|
Assert.Equal(("accepted", "actor-refresh"), (Processed("Update").Outcome, Processed("Update").Reason));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_key_moved_to_a_new_id_replaces_the_old_one()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
await _harness.Deliver(bob, "/human-centipede", Follow(bob, alice.Uri));
|
|
using var rotated = RSA.Create(2048);
|
|
var document = bob.Document();
|
|
document["publicKey"] = new JsonObject { ["id"] = bob.Id + "#key-2", ["owner"] = bob.Id, ["publicKeyPem"] = rotated.ExportSubjectPublicKeyInfoPem() };
|
|
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath, document.ToJsonString());
|
|
|
|
await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Update", JsonValue.Create(bob.Id)!));
|
|
|
|
var after = await Stored(bob);
|
|
Assert.Equal(bob.Id + "#key-2", after.PublicKeyId);
|
|
Assert.Equal(rotated.ExportSubjectPublicKeyInfoPem(), after.PublicKey);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Undoing_a_follow_removes_the_follower_by_the_activity_id_or_by_its_object()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
var bobFollows = Follow(bob, alice.Uri);
|
|
await _harness.Deliver(bob, "/human-centipede", bobFollows);
|
|
await _harness.Deliver(carol, "/human-centipede", Follow(carol, alice.Uri));
|
|
Assert.Equal(2, await DB.Default.CountAsync<Follower>(f => f.LocalActorId == alice.Id, token));
|
|
|
|
await _harness.Deliver(bob, "/human-centipede", Activity(bob, "Undo", JsonValue.Create(IdOf(bobFollows))!));
|
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
|
Assert.Equal(("accepted", "undone"), (Processed("Undo").Outcome, Processed("Undo").Reason));
|
|
|
|
var forgotten = Follow(carol, alice.Uri);
|
|
forgotten["id"] = NewId(carol, "follows");
|
|
await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten));
|
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAnyAsync(token));
|
|
|
|
await _harness.Deliver(carol, "/human-centipede", Activity(carol, "Undo", forgotten));
|
|
Assert.Equal(("dropped", "unknown-object"), (Processed("Undo").Outcome, Processed("Undo").Reason));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_rejected_quote_request_marks_the_quote_rejected_and_only_the_quoted_author_can_reject_it()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var ann = new RemoteActor(_harness.Peer, "ann");
|
|
var mallory = new RemoteActor(_harness.Peer, "mallory");
|
|
var note = PublicNote(ann, "<p>ask me first</p>", alice.Uri);
|
|
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@alice" });
|
|
note["interactionPolicy"] = new JsonObject { ["canQuote"] = new JsonObject { ["manualApproval"] = new JsonArray(Addressing.Public) } };
|
|
await _harness.Deliver(ann, "/human-centipede", Create(ann, note));
|
|
var original = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token);
|
|
var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "may I?", QuotedStatusId = original.ID }, token);
|
|
Assert.Equal(QuoteState.Pending, outcome.Post.QuoteState);
|
|
var request = Assert.Single(await _harness.Outgoing(ann.Id + "/inbox"), a => a["type"]!.GetValue<string>() == "QuoteRequest");
|
|
|
|
await _harness.Deliver(mallory, "/human-centipede", Activity(mallory, "Reject", JsonValue.Create(IdOf(request))!));
|
|
Assert.Equal(QuoteState.Pending, (await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token)).QuoteState);
|
|
|
|
await _harness.Deliver(ann, "/human-centipede", Activity(ann, "Reject", request));
|
|
|
|
var quoting = await DB.Default.Find<Post>().OneAsync(outcome.Post.ID, token);
|
|
Assert.Equal(QuoteState.Rejected, quoting.QuoteState);
|
|
Assert.Null(quoting.QuoteAuthorizationURI);
|
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(original.ID, token)).QuotesCount);
|
|
Assert.Equal(("accepted", "quote-answer"), (Processed("Reject").Outcome, Processed("Reject").Reason));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_vote_a_followed_community_relays_counts_and_its_undo_takes_it_back()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
|
|
var stranger = new RemoteActor(_harness.Peer, "dogs", type: "Group");
|
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
|
var voter = new RemoteActor(_harness.Peer, "voter");
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
|
|
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
|
var page = PublicNote(poster, "<p>a post</p>", community.Id);
|
|
page["type"] = "Page";
|
|
page["name"] = "a title";
|
|
page["audience"] = community.Id;
|
|
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
|
|
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
|
|
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI, ["audience"] = community.Id };
|
|
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
|
|
var liked = Processed("Announce");
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
|
|
var unliked = Processed("Announce");
|
|
await _harness.Deliver(stranger, "/human-centipede", Activity(stranger, "Announce", like));
|
|
var unfollowed = Processed("Announce");
|
|
|
|
//Lemmy relays its members' votes inside the community's Announce (G-0003): a voter on the community's own server
|
|
//is vouched for by it, and the vote is handled as if the voter had sent it; a community nobody follows is ignored
|
|
Assert.Equal("accepted", liked.Outcome);
|
|
Assert.Equal("accepted", unliked.Outcome);
|
|
Assert.Equal(("dropped", "not-followed"), (unfollowed.Outcome, unfollowed.Reason));
|
|
var after = await DB.Default.Find<Post>().OneAsync(post.ID, token);
|
|
Assert.Equal(0, after.FavouritesCount);
|
|
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
// a voter on another server than the community is believed for the community's own posts, as Lemmy trusts it; for
|
|
// anything else only once its vote is fetched from its own origin
|
|
[Fact]
|
|
public async Task A_relayed_vote_from_another_server_counts_on_the_communitys_posts_and_elsewhere_only_once_fetched()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
|
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
|
var voter = new RemoteActor(_harness.Peer, "voter", origin: _harness.Peer.B);
|
|
var forger = new RemoteActor(_harness.Peer, "forger", origin: _harness.Peer.B);
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token);
|
|
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
|
var page = PublicNote(poster, "<p>a post</p>", community.Id);
|
|
page["type"] = "Page";
|
|
page["name"] = "a title";
|
|
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
|
|
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
|
|
var other = await OwnPost(alice, token);
|
|
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = post.ObjectURI };
|
|
var forged = new JsonObject { ["id"] = NewId(forger, "likes"), ["type"] = "Like", ["actor"] = forger.Id, ["object"] = other.ObjectURI };
|
|
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", forged));
|
|
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
|
Assert.True(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token));
|
|
Assert.Equal(("dropped", "fetch-failed"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(other.ID, token)).FavouritesCount);
|
|
}
|
|
|
|
// a public post of a persona's own, outside any community
|
|
static async Task<Post> OwnPost(PrivaPub.Federation.Actors.LocalActor author, CancellationToken token)
|
|
{
|
|
var post = new Post { GroupUserId = author.Id, AuthorAccountId = author.Id, Text = "not the community's" };
|
|
post.ID = (string)post.GenerateNewID();
|
|
post.ObjectURI = author.PostUri(post.ID);
|
|
await DB.Default.SaveAsync(post, token);
|
|
return post;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_locked_persona_holds_a_follow_until_it_decides_and_answers_with_the_original_follow()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, open) = await _harness.Persona("alice");
|
|
await DB.Default.Update<Avatar>().MatchID(open.Id).Modify(a => a.Settings.IsLocked, true).ExecuteAsync(token);
|
|
var alice = await _harness.Local.FindById(LocalActorKind.Person, open.Id, token);
|
|
var bob = new RemoteActor(_harness.Peer, "bob");
|
|
var carol = new RemoteActor(_harness.Peer, "carol");
|
|
var bobFollows = Follow(bob, alice.Uri);
|
|
var carolFollows = Follow(carol, alice.Uri);
|
|
Assert.True(alice.ManuallyApprovesFollowers);
|
|
|
|
await _harness.Deliver(bob, "/human-centipede", bobFollows);
|
|
await _harness.Deliver(carol, "/human-centipede", carolFollows);
|
|
|
|
var pending = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id).ExecuteAsync(token);
|
|
Assert.Equal(2, pending.Count);
|
|
Assert.All(pending, f => Assert.False(f.IsAccepted));
|
|
Assert.Equal(("accepted", "pending"), (Processed("Follow").Outcome, Processed("Follow").Reason));
|
|
var requests = await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id).ExecuteAsync(token);
|
|
Assert.Equal(2, requests.Count);
|
|
Assert.All(requests, n => Assert.Equal(NotificationType.FollowRequest, n.Type));
|
|
Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
|
|
Assert.Empty(await _harness.Delivery.FollowerInboxes(alice, token));
|
|
|
|
var bobAccount = await Stored(bob);
|
|
var carolAccount = await Stored(carol);
|
|
Assert.True(await _harness.Follows.Decide(alice, bobAccount.ID, accept: true, token));
|
|
Assert.True(await _harness.Follows.Decide(alice, carolAccount.ID, accept: false, token));
|
|
Assert.False(await _harness.Follows.Decide(alice, carolAccount.ID, accept: true, token));
|
|
|
|
var accept = Assert.Single(await _harness.Outgoing(bob.Id + "/inbox"));
|
|
Assert.Equal("Accept", accept["type"]!.GetValue<string>());
|
|
Assert.Equal(alice.Uri, accept["actor"]!.GetValue<string>());
|
|
Assert.Equal(IdOf(bobFollows), IdOf(accept["object"]!));
|
|
Assert.Equal(bob.Id, accept["object"]!["actor"]!.GetValue<string>());
|
|
var reject = Assert.Single(await _harness.Outgoing(carol.Id + "/inbox"));
|
|
Assert.Equal("Reject", reject["type"]!.GetValue<string>());
|
|
Assert.Equal(IdOf(carolFollows), IdOf(reject["object"]!));
|
|
Assert.True((await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == bob.Id).ExecuteSingleAsync(token)).IsAccepted);
|
|
Assert.False(await DB.Default.Find<Follower>().Match(f => f.LocalActorId == alice.Id && f.ActorURI == carol.Id).ExecuteAnyAsync(token));
|
|
Assert.Contains(await DB.Default.Find<Notification>().Match(n => n.AvatarId == alice.Id && n.Type == NotificationType.Follow).ExecuteAsync(token),
|
|
n => n.FromAccountId == bobAccount.ID);
|
|
Assert.Equal(new[] { bob.Id + "/inbox" }, await _harness.Delivery.FollowerInboxes(alice, token));
|
|
}
|
|
}
|
|
}
|