Files
SocialPub/PrivaPub.Tests/Federation/InboxScenarioTests.cs
T
thepraandClaude Opus 5.5 dcd024100a
Build / Build (push) Successful in 36s
Every remote object keeps its raw form and how it reached us, for the client's details view
- ObjectRecord, one per stored remote object: the raw JSON (up to 256 KB, always hashed), delivered or fetched,
  refetched from origin or not, the activity that brought it (or caused the fetch), shared or personal inbox, the
  signature's key, algorithm and signed headers, received time, published and updated, the delivering activity's
  @context, and up to ten later revisions from Update.
- Delivery details travel from InboxReceiver through the inbox job to the handlers as Arrival.Current.
- A host is described from its NodeInfo when we first hear from it, at most weekly (DescribeInstance job), never when
  someone opens the details view.
- GET /api/privapub/v1/statuses/:id/provenance and /api/privapub/v1/instances/:host, with the extensions an object
  used detected from its raw form (044f quotes, interaction policies, contexts, proofs, Misskey fields, MFM, FEP-8967
  links, emoji, polls, language maps, url variants, Markdown content).

Checked live: a GoToSocial reply shows as delivered to the shared inbox, signed hs2019 with GoToSocial's fragment-less
key id, with its interaction policy detected, and gts.test is described as gotosocial 0.22.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:49:22 +02:00

352 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.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 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);
}
}
}