Mobilizon's organiser sends the Create, Update and Delete of an event attributed to the group, which announces the Event itself. PrivaPub refused the organiser's activities as misattributed (400) and kept the event through the group's Announce, so an edit was lost and a deletion left the event in place. An object attributed to another account of the actor's own server is now that server's to vouch for: created or edited as the server has it, under the account it is attributed to, and deleted once the server answers 404 or 410. Attributed to an account elsewhere, it is still refused. Checked against Mobilizon 5.2.4 in the pasture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
513 lines
30 KiB
C#
513 lines
30 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);
|
|
|
|
// a follow request nobody answers is sent again after 15 minutes, an hour, 6 hours... (Lemmy 1.0 lost the Accept of a
|
|
// follow it took before it started sending to us, and the community's posts were refused as not followed)
|
|
[Fact]
|
|
public async Task A_follow_request_nobody_answers_is_sent_again_with_a_growing_pause_until_it_is()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var quiet = new RemoteActor(_harness.Peer, "quiet");
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = quiet.Id }, token);
|
|
var following = await DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == quiet.Id).ExecuteSingleAsync(token);
|
|
async Task<int> Follows() => (await _harness.Outgoing(quiet.Id + "/inbox")).Count(a => a["type"]!.GetValue<string>() == "Follow");
|
|
var made = following.CreatedAt;
|
|
Assert.Equal(1, await Follows());
|
|
|
|
// (the store is shared with other tests' requests: what this one got is counted on its own inbox)
|
|
async Task<int> After(TimeSpan wait)
|
|
{
|
|
var before = await Follows();
|
|
await _harness.Follows.ResendPending(made + wait, token);
|
|
return await Follows() - before;
|
|
}
|
|
Assert.Equal(0, await After(TimeSpan.FromMinutes(10)));
|
|
Assert.Equal(1, await After(TimeSpan.FromMinutes(16)));
|
|
Assert.Equal(0, await After(TimeSpan.FromMinutes(30)));
|
|
Assert.Equal(1, await After(TimeSpan.FromMinutes(77)));
|
|
Assert.Equal(3, await Follows());
|
|
// each one again under its own id (Lemmy never answers an id it has seen), and an answer naming it counts
|
|
var sentIds = (await _harness.Outgoing(quiet.Id + "/inbox")).Select(a => a["id"]!.GetValue<string>()).ToList();
|
|
Assert.Equal(new[] { following.FollowActivityURI, following.FollowActivityURI + "-again-1", following.FollowActivityURI + "-again-2" }, sentIds);
|
|
|
|
// a request older than the count is sent again too; an answered one never
|
|
await DB.Default.Update<Following>().MatchID(following.ID).Modify(b => b.Unset(f => f.Resent)).Modify(b => b.Unset(f => f.ResentAt)).ExecuteAsync(token);
|
|
Assert.Equal(1, await After(TimeSpan.FromDays(1)));
|
|
await _harness.Deliver(quiet, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = NewId(quiet, "accepts"), ["type"] = "Accept", ["actor"] = quiet.Id, ["object"] = following.FollowActivityURI + "-again-2"
|
|
});
|
|
Assert.Equal(FollowState.Accepted, (await DB.Default.Find<Following>().OneAsync(following.ID, token)).State);
|
|
Assert.Equal(0, await After(TimeSpan.FromDays(30)));
|
|
}
|
|
|
|
// Pixelfed names our post by its page (/@name/<id>, the post's url) in its Like, Announce and their Undo: the post is
|
|
// found the same as by its id
|
|
// Friendica's activity ids are uniqid(), a prefix and the microsecond: two of its processes answering two follows at
|
|
// once gave both Accepts one id, and the second was dropped as a copy of the first, leaving that follow pending
|
|
[Fact]
|
|
public async Task Two_activities_a_server_gave_one_id_are_both_taken_and_a_true_copy_once()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var dario = new RemoteActor(_harness.Peer, "dario");
|
|
var jun = new RemoteActor(_harness.Peer, "jun");
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = dario.Id }, token);
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = jun.Id }, token);
|
|
Task<Following> Of(RemoteActor target) =>
|
|
DB.Default.Find<Following>().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == target.Id).ExecuteSingleAsync(token);
|
|
async Task<JsonObject> Accept(RemoteActor by, string id) => new()
|
|
{
|
|
["id"] = id,
|
|
["type"] = "Accept",
|
|
["actor"] = by.Id,
|
|
["object"] = new JsonObject { ["id"] = (await Of(by)).FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = by.Id }
|
|
};
|
|
var shared = NewId(dario, "activity");
|
|
|
|
var first = await _harness.Deliver(dario, "/human-centipede", await Accept(dario, shared));
|
|
var second = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared));
|
|
var copy = await _harness.Deliver(jun, "/human-centipede", await Accept(jun, shared));
|
|
|
|
Assert.Equal(("queued", "queued", "duplicate"), (first.Reason, second.Reason, copy.Reason));
|
|
Assert.Equal(FollowState.Accepted, (await Of(dario)).State);
|
|
Assert.Equal(FollowState.Accepted, (await Of(jun)).State);
|
|
}
|
|
|
|
// Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used
|
|
// to be refused as misattributed (400), so the edit was lost and the deletion left the event in place
|
|
[Fact]
|
|
public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var group = new RemoteActor(_harness.Peer, "group", type: "Group");
|
|
var organiser = new RemoteActor(_harness.Peer, "organiser");
|
|
await Follows(alice.Id, group);
|
|
var path = $"/events/{Guid.NewGuid():N}";
|
|
JsonObject Event(string name) => new()
|
|
{
|
|
["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "<p>bring bread</p>",
|
|
["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z",
|
|
["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers")
|
|
};
|
|
_harness.Peer.Serve(path, Event("A picnic").ToJsonString());
|
|
Task<Post> Stored() => DB.Default.Find<Post>().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token);
|
|
|
|
var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic")));
|
|
Assert.Equal(202, created.StatusCode);
|
|
Assert.Equal(group.Id, (await Stored()).ActorURI);
|
|
|
|
var moved = Event("A picnic, moved indoors");
|
|
moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
|
|
_harness.Peer.Serve(path, moved.ToJsonString());
|
|
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims")));
|
|
Assert.Equal("A picnic, moved indoors", (await Stored()).Title);
|
|
|
|
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
|
|
Assert.NotNull(await Stored());
|
|
_harness.Peer.Answer(path, 410);
|
|
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
|
|
Assert.Null(await Stored());
|
|
|
|
// attributed to someone of another server, it is still refused
|
|
var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B);
|
|
var claimed = Event("Someone else's");
|
|
claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}";
|
|
claimed["attributedTo"] = elsewhere.Id;
|
|
Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var fan = new RemoteActor(_harness.Peer, "fan");
|
|
var post = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "a picture" }, token)).Post;
|
|
var page = alice.PostHtmlUrl(post.ID);
|
|
Assert.NotEqual(post.ObjectURI, page);
|
|
var like = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = page };
|
|
|
|
await _harness.Deliver(fan, "/human-centipede", like);
|
|
Assert.Equal("accepted", Processed("Like").Outcome);
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
|
await _harness.Deliver(fan, "/human-centipede", Activity(fan, "Undo", like));
|
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
|
|
|
|
// a page that is no post of ours stays as it is
|
|
var elsewhere = new JsonObject { ["id"] = NewId(fan, "likes"), ["type"] = "Like", ["actor"] = fan.Id, ["object"] = alice.PostHtmlUrl("000000000000000000000000") };
|
|
await _harness.Deliver(fan, "/human-centipede", elsewhere);
|
|
Assert.Equal(("dropped", "unknown-object"), (Processed("Like").Outcome, Processed("Like").Reason));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
// Lemmy sends a vote to the community alone: a vote on our reply in a thread of a community nobody here follows still
|
|
// reaches us through it, and only through that community (found by the town's village)
|
|
[Fact]
|
|
public async Task A_vote_on_our_reply_in_an_unfollowed_communitys_thread_counts_through_that_community_only()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, alice) = await _harness.Persona("alice");
|
|
var community = new RemoteActor(_harness.Peer, "cats", type: "Group");
|
|
var other = new RemoteActor(_harness.Peer, "dogs", type: "Group");
|
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
|
var voter = new RemoteActor(_harness.Peer, "voter");
|
|
var page = PublicNote(poster, "<p>a thread</p>", community.Id);
|
|
page["type"] = "Page";
|
|
page["name"] = "a thread";
|
|
page["audience"] = community.Id;
|
|
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
|
|
var root = await _harness.RemotePosts.StoreContext(IdOf(page), 0, token);
|
|
Assert.Equal(community.Id, root.AudienceURI);
|
|
var reply = (await _harness.Statuses.Publish(alice, new StatusDraft { Text = "my answer", InReplyTo = root.ID }, token)).Post;
|
|
var like = new JsonObject { ["id"] = NewId(voter, "likes"), ["type"] = "Like", ["actor"] = voter.Id, ["object"] = reply.ObjectURI };
|
|
|
|
await _harness.Deliver(other, "/human-centipede", Activity(other, "Announce", like));
|
|
Assert.Equal(("dropped", "not-followed"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", like));
|
|
Assert.Equal("accepted", Processed("Announce").Outcome);
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(voter, "Undo", like)));
|
|
Assert.Equal(0, (await DB.Default.Find<Post>().OneAsync(reply.ID, token)).FavouritesCount);
|
|
}
|
|
|
|
// Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce
|
|
// (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo
|
|
[Fact]
|
|
public async Task A_community_locks_a_post_and_bans_a_persona_until_it_undoes_either()
|
|
{
|
|
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 moderator = new RemoteActor(_harness.Peer, "moderator");
|
|
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);
|
|
JsonObject Page(string text, bool commentsEnabled = true)
|
|
{
|
|
var page = PublicNote(poster, $"<p>{text}</p>", community.Id);
|
|
page["type"] = "Page";
|
|
page["name"] = text;
|
|
page["audience"] = community.Id;
|
|
page["commentsEnabled"] = commentsEnabled;
|
|
_harness.Peer.Serve(new Uri(IdOf(page)).AbsolutePath, page.ToJsonString());
|
|
return page;
|
|
}
|
|
async Task<Post> Announced(JsonObject page)
|
|
{
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, page)));
|
|
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == IdOf(page)).ExecuteSingleAsync(token);
|
|
}
|
|
Task<StatusOutcome> Reply(Post post) => _harness.Statuses.Publish(alice, new StatusDraft { Text = "a reply", InReplyTo = post.ID }, token);
|
|
var post = await Announced(Page("a thread"));
|
|
var lockIt = new JsonObject { ["id"] = NewId(moderator, "locks"), ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = post.ObjectURI };
|
|
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", lockIt));
|
|
Assert.Equal(("accepted", "locked"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
|
var refused = await Reply(post);
|
|
Assert.Equal((422, "Validation failed: This thread is locked"), (refused.Status, refused.Error));
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", lockIt)));
|
|
Assert.Null((await DB.Default.Find<Post>().OneAsync(post.ID, token)).LockedAt);
|
|
Assert.True((await Reply(post)).Ok);
|
|
// a post its community serves locked arrives locked
|
|
Assert.NotNull((await Announced(Page("born locked", commentsEnabled: false))).LockedAt);
|
|
|
|
var ban = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id };
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", ban));
|
|
Assert.Equal(("accepted", "banned"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
|
var banned = await Reply(post);
|
|
Assert.Equal((422, "Validation failed: This community banned you"), (banned.Status, banned.Error));
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Activity(moderator, "Undo", ban)));
|
|
Assert.Equal(("accepted", "unbanned"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
|
Assert.True((await Reply(post)).Ok);
|
|
|
|
// Lemmy also sends the ban straight to the persona's server, as its moderator's Block: still the community's ban,
|
|
// never the moderator's own block of the persona
|
|
var direct = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = alice.Uri, ["target"] = community.Id };
|
|
await _harness.Deliver(moderator, "/human-centipede", direct);
|
|
Assert.Equal(("accepted", "banned"), (Processed("Block").Outcome, Processed("Block").Reason));
|
|
Assert.False(await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == alice.Id && b.ActorURI == moderator.Id).ExecuteAnyAsync(token));
|
|
Assert.Equal(422, (await Reply(post)).Status);
|
|
await _harness.Deliver(moderator, "/human-centipede", Activity(moderator, "Undo", direct));
|
|
Assert.True((await Reply(post)).Ok);
|
|
|
|
var elsewhere = new JsonObject { ["id"] = NewId(moderator, "bans"), ["type"] = "Block", ["actor"] = moderator.Id, ["object"] = poster.Id, ["target"] = community.Id };
|
|
await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", elsewhere));
|
|
Assert.Equal(("dropped", "not-ours"), (Processed("Announce").Outcome, Processed("Announce").Reason));
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
}
|
|
}
|