Mbin 1.10.1 runs in the pasture (its image, a messenger worker, a RabbitMQ
of its own, its API limits raised), and peers/mbin_token.py gets mbuser's
token through the authorization-code flow. scenarios/mbin.sh: 24 checks and
one known gap, magazines both ways, titled threads, a Note to a magazine as
a microblog post, comments, favourites and upvotes both ways, a moderator's
lock, unlock and removal, the unfollow and statistics.
What it showed:
- Mbin sends a magazine's threads to its subscribers as the author's Create,
the magazine as its audience, never announced. A post whose group is
followed here and lives on the post's own server is now kept as if
announced; the same from another server is not.
- A moderator's lock is a bare Lock (and Undo{Lock}): LockHandler takes it
from the post's own server only.
- Mbin takes private messages only as ChatMessage and its actors say
nothing about it; PrivaPub never decides by a server's software, so this
stays open as G-0008 for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
224 lines
11 KiB
C#
224 lines
11 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels.Social;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Tests.Support;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Federation
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class CommunityEditsTests : 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 string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
|
|
|
|
// a post in a community the persona follows; the community on the poster's server unless another origin is given
|
|
async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced(string communityOrigin = default)
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var community = new RemoteActor(_harness.Peer, "cats", communityOrigin, type: "Group");
|
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
|
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 note = new JsonObject
|
|
{
|
|
["id"] = $"{Origin(poster)}/notes/{Guid.NewGuid():N}",
|
|
["type"] = "Page",
|
|
["name"] = "a title",
|
|
["attributedTo"] = poster.Id,
|
|
["content"] = "<p>first</p>",
|
|
["to"] = new JsonArray(community.Id, Addressing.Public),
|
|
["audience"] = community.Id,
|
|
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
|
|
};
|
|
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, note.ToJsonString());
|
|
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/create/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = poster.Id, ["object"] = note.DeepClone() });
|
|
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == note["id"]!.GetValue<string>()).ExecuteSingleAsync(token);
|
|
return (community, poster, note, post);
|
|
}
|
|
|
|
Task Announce(RemoteActor community, JsonObject inner) =>
|
|
_harness.Deliver(community, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = $"{Origin(community)}/announce/{Guid.NewGuid():N}",
|
|
["type"] = "Announce",
|
|
["actor"] = community.Id,
|
|
["to"] = new JsonArray(Addressing.Public),
|
|
["object"] = inner
|
|
});
|
|
|
|
[Fact]
|
|
public async Task A_community_edit_is_an_edit_only_when_newer_and_keeps_its_history()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (community, poster, note, post) = await Announced();
|
|
|
|
var refresh = (JsonObject)note.DeepClone();
|
|
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, refresh.ToJsonString());
|
|
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
|
|
var afterRefresh = await DB.Default.Find<Post>().OneAsync(post.ID, token);
|
|
|
|
var edit = (JsonObject)note.DeepClone();
|
|
edit["content"] = "<p>second</p>";
|
|
edit["updated"] = DateTime.UtcNow.ToString("O");
|
|
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, edit.ToJsonString());
|
|
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
|
|
var afterEdit = await DB.Default.Find<Post>().OneAsync(post.ID, token);
|
|
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
|
|
|
|
Assert.Empty(afterRefresh.Revisions);
|
|
Assert.Null(afterRefresh.EditedAt);
|
|
Assert.Equal("<p>second</p>", afterEdit.ContentHtml);
|
|
Assert.Equal("a title", afterEdit.Title);
|
|
Assert.Single(afterEdit.Revisions);
|
|
Assert.NotNull(afterEdit.EditedAt);
|
|
Assert.Equal(2, record.Revisions.Count);
|
|
Assert.Equal(new[] { "refresh", "edit" }, _harness.Ledger.Of("in").Where(e => e.Activity == "Announce" && e.Object == "Update").Select(e => e.Reason));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_community_delete_leaves_a_tombstone_and_no_trace()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (community, poster, note, post) = await Announced();
|
|
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
|
|
|
|
await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue<string>() });
|
|
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
|
|
Assert.False(await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
|
|
Assert.False(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAnyAsync(token));
|
|
Assert.True(await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
// PieFed keeps serving a thread its moderator removed: a community on the post's own server speaks for it
|
|
[Fact]
|
|
public async Task A_removal_a_community_on_the_posts_server_announces_is_taken_though_the_post_is_still_served()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (community, poster, note, post) = await Announced();
|
|
|
|
await Announce(community, new JsonObject
|
|
{
|
|
["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id,
|
|
["object"] = note["id"]!.GetValue<string>(), ["summary"] = "off topic"
|
|
});
|
|
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_removal_a_community_elsewhere_announces_waits_for_the_posts_server_to_say_it_is_gone()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (community, poster, note, post) = await Announced(_harness.Peer.B);
|
|
var delete = new JsonObject
|
|
{
|
|
["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = community.Id,
|
|
["object"] = note["id"]!.GetValue<string>()
|
|
};
|
|
|
|
await Announce(community, (JsonObject)delete.DeepClone());
|
|
var kept = await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token);
|
|
_harness.Peer.Serve(new Uri(note["id"]!.GetValue<string>()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue<string>(), ["type"] = "Tombstone" }.ToJsonString());
|
|
delete["id"] = $"{Origin(community)}/delete/{Guid.NewGuid():N}";
|
|
await Announce(community, delete);
|
|
|
|
Assert.True(kept);
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ID == post.ID).ExecuteAnyAsync(token));
|
|
}
|
|
|
|
// Mbin sends a magazine's thread to its subscribers as the author's Create, and a moderator's lock as it is, with no
|
|
// announce: the magazine's server speaks for them, another server does not
|
|
[Fact]
|
|
public async Task A_thread_and_its_lock_that_a_followed_groups_own_server_sends_without_announcing_them_are_taken()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (root, alice) = await _harness.Persona("alice");
|
|
var magazine = new RemoteActor(_harness.Peer, "books", type: "Group");
|
|
var poster = new RemoteActor(_harness.Peer, "poster");
|
|
var stranger = new RemoteActor(_harness.Peer, "stranger", _harness.Peer.B);
|
|
await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = magazine.Id }, token);
|
|
await DB.Default.Update<Following>().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token);
|
|
JsonObject Page(RemoteActor author) => new()
|
|
{
|
|
["id"] = $"{Origin(author)}/m/books/t/{Guid.NewGuid():N}", ["type"] = "Page", ["name"] = "a thread",
|
|
["attributedTo"] = author.Id, ["content"] = "<p>in the magazine</p>",
|
|
["to"] = new JsonArray(magazine.Id, Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"),
|
|
["audience"] = magazine.Id, ["published"] = DateTime.UtcNow.ToString("O")
|
|
};
|
|
JsonObject Create(RemoteActor author, JsonObject page) => new()
|
|
{
|
|
["id"] = $"{Origin(author)}/f/object/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id,
|
|
["to"] = page["to"]!.DeepClone(), ["cc"] = page["cc"]!.DeepClone(), ["object"] = page
|
|
};
|
|
JsonObject Lock(RemoteActor moderator, string uri) => new()
|
|
{
|
|
["id"] = $"{Origin(moderator)}/f/object/{Guid.NewGuid():N}", ["type"] = "Lock", ["actor"] = moderator.Id, ["object"] = uri
|
|
};
|
|
var page = Page(poster);
|
|
var elsewhere = Page(stranger);
|
|
var uri = page["id"]!.GetValue<string>();
|
|
|
|
await _harness.Deliver(poster, "/human-centipede", Create(poster, page));
|
|
await _harness.Deliver(stranger, "/human-centipede", Create(stranger, elsewhere));
|
|
var kept = await DB.Default.Find<Post>().Match(p => p.ObjectURI == uri).ExecuteSingleAsync(token);
|
|
await _harness.Deliver(stranger, "/human-centipede", Lock(stranger, uri));
|
|
var lockedByStranger = (await DB.Default.Find<Post>().OneAsync(kept.ID, token)).LockedAt;
|
|
var moderator = new RemoteActor(_harness.Peer, "moderator");
|
|
var locking = Lock(moderator, uri);
|
|
await _harness.Deliver(moderator, "/human-centipede", locking);
|
|
var locked = (await DB.Default.Find<Post>().OneAsync(kept.ID, token)).LockedAt;
|
|
await _harness.Deliver(moderator, "/human-centipede", new JsonObject
|
|
{
|
|
["id"] = $"{Origin(moderator)}/f/object/{Guid.NewGuid():N}", ["type"] = "Undo", ["actor"] = moderator.Id, ["object"] = locking.DeepClone()
|
|
});
|
|
|
|
Assert.Equal(magazine.Id, kept.AudienceURI);
|
|
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == kept.ID && e.AvatarId == alice.Id).ExecuteAnyAsync(token));
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == elsewhere["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
|
|
Assert.Null(lockedByStranger);
|
|
Assert.NotNull(locked);
|
|
Assert.Null((await DB.Default.Find<Post>().OneAsync(kept.ID, token)).LockedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_fetched_object_does_not_wear_the_signature_of_the_activity_that_caused_the_fetch()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var (_, _, _, post) = await Announced();
|
|
|
|
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token);
|
|
|
|
Assert.Equal(ObjectPath.Fetched, record.Path);
|
|
Assert.Null(record.KeyId);
|
|
Assert.Null(record.SignatureScheme);
|
|
Assert.Empty(record.SignedHeaders);
|
|
Assert.Null(record.ActivityContext);
|
|
Assert.Equal("Announce", record.ActivityType);
|
|
Assert.InRange(record.ReceivedAt, DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow.AddSeconds(1));
|
|
Assert.Contains("fep-1b12-audience", record.Extensions);
|
|
Assert.NotNull(record.ContextNamespaces);
|
|
}
|
|
}
|
|
}
|