Conversations are keyed by who is in them, and circles stay home

S8: a direct message joins a conversation only when its participants are
exactly that conversation's members, found through a new
DmGroup.ParticipantsKey (a hash of the sorted members). A remote context
no longer decides anything: it let anyone who knew a conversation's
context post into it, and joining by context while dropping a participant
would have shown a reply to someone it was not addressed to. A context is
kept only when it is on the author's origin. Sending a DM to the same
people again reuses their conversation instead of opening a new one.

S9: Group.Kind is Circle or Community. A circle is not a federated actor:
its actor, collections, WebFinger and inbox answer 404, a remote Follow is
refused, and posts in it are IsLocalOnly - never delivered, never in an
outbox, never served. Communities keep today's behaviour until P4.
Migration _003 makes every existing group a circle, marks their posts
local-only and backfills the conversation keys.

End-to-end inbox tests sign real deliveries from a fake peer: a context
injection, a forged activity id, a note attributed to someone else, a
cross-origin object, a bad signature, junk bodies and a Follow of a circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 10:57:01 +02:00
1 parent bf7c88ce71
commit 2eb2a63f1e
14 files changed
+367 -34

No files matched your search

@@ -0,0 +1,205 @@
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.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()), 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);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using PrivaPub.Federation.Signing;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Support
{
public sealed class RemoteActor
{
readonly RSA _key = RSA.Create(2048);
public RemoteActor(Peer peer, string name, string origin = default)
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
public string Name { get; }
public string Id { get; }
public string KeyId => Id + "#main-key";
public JsonObject Document() => new()
{
["id"] = Id,
["type"] = "Person",
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["publicKey"] = new JsonObject
{
["id"] = KeyId,
["owner"] = Id,
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
}
};
public HttpRequest Post(string host, string path, JsonNode activity)
{
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture);
var digest = HttpSignatures.Digest(body);
var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}";
var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
var context = new DefaultHttpContext();
context.Request.Method = "POST";
context.Request.Host = new HostString(host);
context.Request.Path = path;
context.Features.Get<IHttpRequestFeature>().RawTarget = path;
context.Request.Headers["Date"] = date;
context.Request.Headers["Digest"] = digest;
context.Request.Headers["Content-Type"] = "application/activity+json";
context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\"";
context.Request.Body = new MemoryStream(body);
context.Request.ContentLength = body.Length;
return context.Request;
}
}
}