- Custom emoji (Emoji tags) on posts, display names, bios and profile fields, at most 64 per object, proxied, in Status.emojis and Account.emojis. - Remote profiles keep their header, profile fields, locked flag, published date, movedTo, indexable, memorial and image descriptions (a locked GoToSocial account no longer shows as open). - Polls: incoming Questions (Mastodon, Misskey, Pleroma, GoToSocial shapes) with counts, voters, end and closed; our own polls from the Mastodon API go out as Questions; votes in are counted once per voter and never become replies; personas vote on other servers' polls with one Note per choice; counts refresh with an Update at most every three minutes; a poll closes on time and tells its voters and its author. GET /api/v1/polls/:id and POST /api/v1/polls/:id/votes. - An Update without a newer `updated` only refreshes poll, video, audio and event details and leaves no revision. Checked live against GoToSocial: each side's poll reaches the other as a poll and each side's vote is counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
353 lines
14 KiB
C#
353 lines
14 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Timelines;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Inbox.Handlers;
|
|
using PrivaPub.Federation.Inbox;
|
|
using PrivaPub.Federation.Moderation;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.StaticServices;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Federation
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class InboxScenarioTests : IAsyncLifetime
|
|
{
|
|
const string Host = "privapub.test";
|
|
const string Base = "https://" + Host;
|
|
|
|
Peer _peer;
|
|
LocalActorService _local;
|
|
InboxReceiver _receiver;
|
|
InboxProcessor _processor;
|
|
DomainBlocks _blocks;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_peer = await Peer.Start();
|
|
var cache = new MemoryCache(new MemoryCacheOptions());
|
|
_local = new LocalActorService(new DbEntities(), new StaticOptions<AppConfiguration>(new AppConfiguration { BackendBaseAddress = Base }));
|
|
var remote = new RemoteActorService(Peer.Http(cache), _local, cache, new DbEntities());
|
|
var queue = new JobQueue();
|
|
var delivery = new DeliveryService(new DbEntities(), queue);
|
|
var db = new DbEntities();
|
|
_blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
|
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance);
|
|
_processor = new InboxProcessor(remote, new IActivityHandler[]
|
|
{
|
|
new FollowHandler(db, _local, remote, delivery),
|
|
new UndoHandler(db, _local),
|
|
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue, new ObjectRecords(queue)), new GroupDistributor(delivery), new ObjectRecords(queue), new PollService(db, _local, delivery, queue)),
|
|
new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
|
|
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery), new ObjectRecords(queue))
|
|
}, NullLogger<InboxProcessor>.Instance);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_peer != default)
|
|
await _peer.DisposeAsync();
|
|
}
|
|
|
|
async Task<LocalActor> LocalAvatar(string name)
|
|
{
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
var avatar = new Avatar { UserName = $"{name}{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(avatar, TestContext.Current.CancellationToken);
|
|
return _local.FromAvatar(avatar);
|
|
}
|
|
|
|
static JsonObject DirectCreate(RemoteActor author, string to, string context = default, string objectOrigin = default, string attributedTo = default)
|
|
{
|
|
var id = $"{objectOrigin ?? Origin(author.Id)}/notes/{Guid.NewGuid():N}";
|
|
var note = new JsonObject
|
|
{
|
|
["id"] = id,
|
|
["type"] = "Note",
|
|
["attributedTo"] = attributedTo ?? author.Id,
|
|
["content"] = "<p>psst</p>",
|
|
["to"] = new JsonArray(to),
|
|
["cc"] = new JsonArray()
|
|
};
|
|
if (context != default)
|
|
note["context"] = context;
|
|
return new JsonObject
|
|
{
|
|
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
|
|
["type"] = "Create",
|
|
["actor"] = author.Id,
|
|
["to"] = new JsonArray(to),
|
|
["object"] = note
|
|
};
|
|
}
|
|
|
|
async Task<InboxResult> Deliver(RemoteActor sender, string path, JsonNode activity)
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var result = await _receiver.Receive(sender.Post(Host, path, activity), default, token);
|
|
var dedupe = "inbox|" + (activity is JsonObject ? activity["id"]?.GetValue<string>() : default);
|
|
var job = await DB.Default.Find<Job>().Match(j => j.DedupeKey == dedupe).ExecuteFirstAsync(token);
|
|
if (job != default)
|
|
Assert.Equal(JobResult.Done, (await _processor.Handle(job, token)).Result);
|
|
return result;
|
|
}
|
|
|
|
static string Origin(string uri) => new Uri(uri).GetLeftPart(UriPartial.Authority);
|
|
|
|
[Fact]
|
|
public async Task A_context_cannot_pull_a_stranger_into_an_existing_conversation()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var context = $"{_peer.A}/contexts/{Guid.NewGuid():N}";
|
|
|
|
var first = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context));
|
|
var injected = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context));
|
|
|
|
Assert.Equal(202, first.StatusCode);
|
|
Assert.Equal(202, injected.StatusCode);
|
|
var bobDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token);
|
|
var malloryDm = await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
|
|
Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId);
|
|
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.ConversationId, token);
|
|
Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Replies_between_the_same_people_land_in_the_same_conversation()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
|
|
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
|
await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
|
|
|
var dms = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteAsync(token);
|
|
Assert.Equal(2, dms.Count);
|
|
Assert.Single(dms.Select(d => d.ConversationId).Distinct());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_activity_id_on_another_origin_is_refused()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var create = DirectCreate(mallory, alice.Uri);
|
|
create["id"] = $"{_peer.B}/activities/{Guid.NewGuid():N}";
|
|
|
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", create);
|
|
|
|
Assert.Equal(400, result.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_note_put_in_someone_elses_mouth_is_refused()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var victim = new RemoteActor(_peer, "victim");
|
|
|
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
|
|
|
|
Assert.Equal(400, result.StatusCode);
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_cross_origin_object_is_fetched_from_its_origin_before_it_is_believed()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
|
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B));
|
|
|
|
Assert.Equal(202, result.StatusCode);
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
static JsonObject PublicCreate(RemoteActor author, string inReplyTo = default, params string[] cc)
|
|
{
|
|
var id = $"{Origin(author.Id)}/notes/{Guid.NewGuid():N}";
|
|
var note = new JsonObject
|
|
{
|
|
["id"] = id,
|
|
["type"] = "Note",
|
|
["attributedTo"] = author.Id,
|
|
["content"] = "<p>hello</p>",
|
|
["to"] = new JsonArray(Addressing.Public),
|
|
["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray())
|
|
};
|
|
if (inReplyTo != default)
|
|
note["inReplyTo"] = inReplyTo;
|
|
return new JsonObject
|
|
{
|
|
["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}",
|
|
["type"] = "Create",
|
|
["actor"] = author.Id,
|
|
["object"] = note
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_public_reply_to_a_local_post_is_kept_and_counted()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var parent = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, ActorURI = alice.Uri, Text = "hi" };
|
|
parent.ID = (string)parent.GenerateNewID();
|
|
parent.ObjectURI = alice.PostUri(parent.ID);
|
|
await DB.Default.SaveAsync(parent, token);
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
|
|
await Deliver(bob, "/human-centipede", PublicCreate(bob, parent.ObjectURI));
|
|
|
|
var reply = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
|
Assert.Equal(PostVisibility.Public, reply.Visibility);
|
|
Assert.Equal(parent.ID, reply.AnsweringToPostId);
|
|
Assert.Equal(alice.Id, reply.InReplyToAccountId);
|
|
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(parent.ID, token)).RepliesCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_public_post_nobody_here_asked_for_is_dropped()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
|
|
await Deliver(bob, "/human-centipede", PublicCreate(bob));
|
|
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_mention_is_kept_and_linked_to_the_local_persona()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var create = PublicCreate(bob, default, alice.Uri);
|
|
create["object"]!["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.Handle });
|
|
|
|
await Deliver(bob, "/human-centipede", create);
|
|
|
|
var post = await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
|
var mention = Assert.Single(post.Mentions);
|
|
Assert.True(mention.IsLocal);
|
|
Assert.Equal(alice.Id, mention.AccountId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var bob = new RemoteActor(_peer, "bob", _peer.B);
|
|
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token);
|
|
try
|
|
{
|
|
await _blocks.Reload(token);
|
|
var before = _peer.Requests.Count;
|
|
|
|
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
|
|
|
|
Assert.Equal(202, result.StatusCode);
|
|
Assert.Equal(before, _peer.Requests.Count);
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
|
|
}
|
|
finally
|
|
{
|
|
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
|
|
await _blocks.Reload(token);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void A_block_covers_subdomains_but_not_lookalikes()
|
|
{
|
|
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
|
|
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
|
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
|
|
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
|
.SetValue(blocks, DateTime.UtcNow);
|
|
|
|
Assert.NotNull(blocks.Find("evil.example"));
|
|
Assert.NotNull(blocks.Find("A.Evil.Example."));
|
|
Assert.Null(blocks.Find("notevil.example"));
|
|
Assert.False(blocks.IsSuspended("evil.example"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_bad_signature_is_a_401()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
var request = mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri));
|
|
request.Headers["Signature"] = request.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA");
|
|
|
|
Assert.Equal(401, (await _receiver.Receive(request, alice, token)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Junk_is_a_400_never_a_500()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await LocalAvatar("alice");
|
|
var mallory = new RemoteActor(_peer, "mallory");
|
|
|
|
foreach (var junk in new JsonNode[] { new JsonArray(1, 2), JsonValue.Create("x"), new JsonObject { ["type"] = "Create" } })
|
|
Assert.Equal(400, (await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", junk)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_circle_only_takes_follow_requests()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
var circle = new GroupEntity { UserName = $"circle{Guid.NewGuid():N}"[..20], PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(circle, token);
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var actor = _local.FromGroup(circle);
|
|
var follow = new JsonObject
|
|
{
|
|
["id"] = $"{bob.Id}/follows/{Guid.NewGuid():N}",
|
|
["type"] = "Follow",
|
|
["actor"] = bob.Id,
|
|
["object"] = actor.Uri
|
|
};
|
|
|
|
Assert.True(actor.IsCircle);
|
|
Assert.True(actor.ManuallyApprovesFollowers);
|
|
Assert.False(actor.Discoverable);
|
|
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
|
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
|
|
Assert.False(request.IsAccepted);
|
|
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
|
|
}
|
|
}
|
|
}
|