The single serial DeliveryWorker is replaced by Infrastructure/Jobs: - Job rows are leased with one FindOneAndUpdate (oldest RunAt first, a two-minute lease) and a reaper returns expired leases every 30 s; - enqueueing wakes the workers, which otherwise poll every five seconds; - delivery runs eight at a time with at most two per host, so a slow or dead server holds two slots, not the queue; - a failure waits n^4 + 15 + jitter seconds (Mastodon's curve) for up to 16 attempts; a 4xx other than 408/429 is final, a 429 honours Retry-After; - RemoteInstance is a per-host circuit breaker: ten consecutive failures quarantine a host for an hour, doubling to a week, and its jobs wait without spending attempts; - a delivery is queued once per activity and inbox (unique DedupeKey), and finished jobs expire after seven days (TTL on FinishedAt). Migration _004 moves pending Delivery rows into jobs and marks them abandoned, so a rollback to the old worker cannot send them twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
207 lines
7.8 KiB
C#
207 lines
7.8 KiB
C#
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Inbox;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
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;
|
|
InboxService _inbox;
|
|
|
|
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());
|
|
_inbox = new InboxService(new DbEntities(), _local, remote, new DeliveryService(new DbEntities(), new JobQueue()), NullLogger<InboxService>.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
|
|
};
|
|
}
|
|
|
|
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 _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri, context)), alice, token);
|
|
var injected = await _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, context)), alice, token);
|
|
|
|
Assert.Equal(202, first.StatusCode);
|
|
Assert.Equal(202, injected.StatusCode);
|
|
var bobDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token);
|
|
var malloryDm = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token);
|
|
Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId);
|
|
var bobConversation = await DB.Default.Find<DmGroup>().OneAsync(bobDm.GroupId, 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 _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token);
|
|
await _inbox.Receive(bob.Post(Host, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)), alice, token);
|
|
|
|
var dms = await DB.Default.Find<DmPost>().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token);
|
|
Assert.Equal(2, dms.Count);
|
|
Assert.Single(dms.Select(d => d.GroupId).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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", create), alice, token);
|
|
|
|
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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth",
|
|
DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)), alice, token);
|
|
|
|
Assert.Equal(400, result.StatusCode);
|
|
Assert.False(await DB.Default.Find<DmPost>().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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth",
|
|
DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)), alice, token);
|
|
|
|
Assert.Equal(202, result.StatusCode);
|
|
Assert.False(await DB.Default.Find<DmPost>().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[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 _inbox.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 _inbox.Receive(mallory.Post(Host, $"/peasants/{alice.UserName}/mouth", junk), alice, token)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_circle_is_not_a_federated_actor()
|
|
{
|
|
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.False(actor.IsFederated);
|
|
Assert.Equal(404, (await _inbox.Receive(bob.Post(Host, "/human-centipede", follow), default, token)).StatusCode);
|
|
}
|
|
}
|
|
}
|