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); async Task<(RemoteActor Community, RemoteActor Poster, JsonObject Note, Post Post)> Announced() { var token = TestContext.Current.CancellationToken; var (root, alice) = await _harness.Persona("alice"); var community = new RemoteActor(_harness.Peer, "cats", 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().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"] = "

first

", ["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()).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().Match(p => p.ObjectURI == note["id"]!.GetValue()).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()).AbsolutePath, refresh.ToJsonString()); await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue() }); var afterRefresh = await DB.Default.Find().OneAsync(post.ID, token); var edit = (JsonObject)note.DeepClone(); edit["content"] = "

second

"; edit["updated"] = DateTime.UtcNow.ToString("O"); _harness.Peer.Serve(new Uri(note["id"]!.GetValue()).AbsolutePath, edit.ToJsonString()); await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/update/{Guid.NewGuid():N}", ["type"] = "Update", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue() }); var afterEdit = await DB.Default.Find().OneAsync(post.ID, token); var record = await DB.Default.Find().Match(r => r.ObjectURI == post.ObjectURI).ExecuteSingleAsync(token); Assert.Empty(afterRefresh.Revisions); Assert.Null(afterRefresh.EditedAt); Assert.Equal("

second

", 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()).AbsolutePath, new JsonObject { ["id"] = note["id"]!.GetValue(), ["type"] = "Tombstone" }.ToJsonString()); await Announce(community, new JsonObject { ["id"] = $"{Origin(poster)}/delete/{Guid.NewGuid():N}", ["type"] = "Delete", ["actor"] = poster.Id, ["object"] = note["id"]!.GetValue() }); Assert.False(await DB.Default.Find().Match(p => p.ID == post.ID).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find().Match(r => r.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token)); Assert.False(await DB.Default.Find().Match(e => e.PostId == post.ID).ExecuteAnyAsync(token)); Assert.True(await DB.Default.Find().Match(d => d.ObjectURI == post.ObjectURI).ExecuteAnyAsync(token)); } [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().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); } } }