using MongoDB.Entities; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Jobs; using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using System.Text.Json.Nodes; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Tests.Federation { // FEP-171b: a thread's owner adds a reply someone else wrote to the thread's context (Hubzilla's Add{Create}), and the // reply is taken as passed on by the owner, on its own proof; an Add aimed at a collection elsewhere is not a thread's [Trait("Category", "Integration")] public sealed class ConversationContainerTests : IAsyncLifetime { const string Public = "https://www.w3.org/ns/activitystreams#Public"; 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 CancellationToken Token => TestContext.Current.CancellationToken; static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); static JsonObject Create(RemoteActor author, string text, string context, string inReplyTo = default) { var noteId = $"{Origin(author)}/item/{Guid.NewGuid():N}"; var note = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", ["context"] = context, ["to"] = new JsonArray(Public), ["published"] = DateTime.UtcNow.ToString("O") }; if (inReplyTo != default) note["inReplyTo"] = inReplyTo; return new JsonObject { ["id"] = $"{Origin(author)}/activity/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["to"] = new JsonArray(Public), ["object"] = note }; } async Task Held(string objectUri) => await DB.Default.Find().Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(Token); async Task RunQueued(string innerId) { var job = await DB.Default.Find().Match(j => j.DedupeKey == "inbox|forwarded|" + innerId && j.State == JobState.Pending).ExecuteFirstAsync(Token); Assert.NotNull(job); Assert.Equal(JobResult.Done, (await _harness.Processor.Handle(job, Token)).Result); } [Fact] public async Task A_reply_the_threads_owner_adds_to_its_context_is_taken_on_its_proof() { var (_, alice) = await _harness.Persona("alice"); var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true); var commenter = new RemoteActor(_harness.Peer, "commenter", ed25519: true); await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); var context = $"{Origin(owner)}/conversation/{Guid.NewGuid():N}"; var root = Create(owner, "a thread", context); await _harness.Deliver(owner, "/human-centipede", root); var rootId = root["object"]!["id"]!.GetValue(); Assert.True(await Held(rootId)); var reply = commenter.Prove(Create(commenter, "a reply in it", context, rootId)); var replyId = reply["object"]!["id"]!.GetValue(); await _harness.Deliver(owner, "/human-centipede", owner.Prove(new JsonObject { ["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = reply, ["target"] = new JsonObject { ["id"] = context, ["type"] = "Collection", ["attributedTo"] = owner.Id }, ["to"] = new JsonArray(Public) })); await RunQueued(reply["id"]!.GetValue()); var stored = await DB.Default.Find().Match(p => p.ObjectURI == replyId).ExecuteFirstAsync(Token); Assert.NotNull(stored); Assert.Equal(commenter.Id, stored.ActorURI); Assert.Equal((await DB.Default.Find().Match(p => p.ObjectURI == rootId).ExecuteFirstAsync(Token)).ID, stored.AnsweringToPostId); } // Forte gives a post's Update the id of its Create, and sends it only added to the thread's context [Fact] public async Task An_update_added_under_its_creates_id_is_taken_as_the_edit() { var (_, alice) = await _harness.Persona("alice"); var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true); await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = owner.Id, TargetInboxURL = owner.Id + "/inbox", State = FollowState.Accepted }, Token); var context = $"{Origin(owner)}/conversation/{Guid.NewGuid():N}"; var create = owner.Prove(Create(owner, "as first written", context)); JsonObject Added(JsonObject inner) => owner.Prove(new JsonObject { ["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = inner, ["target"] = new JsonObject { ["id"] = context, ["type"] = "Collection", ["attributedTo"] = owner.Id } }); await _harness.Deliver(owner, "/human-centipede", Added(create)); await RunQueued(create["id"]!.GetValue()); var update = create.DeepClone().AsObject(); update.Remove("proof"); update["type"] = "Update"; update["object"]!["content"] = "

as edited

"; update["object"]!["updated"] = DateTime.UtcNow.AddSeconds(1).ToString("O"); owner.Prove(update); await _harness.Deliver(owner, "/human-centipede", Added(update)); var job = await DB.Default.Find().Match(j => j.DedupeKey.StartsWith("inbox|forwarded|" + update["id"]!.GetValue() + "|") && j.State == JobState.Pending) .ExecuteFirstAsync(Token); Assert.NotNull(job); Assert.Equal(JobResult.Done, (await _harness.Processor.Handle(job, Token)).Result); var noteId = create["object"]!["id"]!.GetValue(); Assert.Contains("as edited", (await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token)).ContentHtml); } [Fact] public async Task An_add_to_a_collection_on_another_server_is_no_threads() { var owner = new RemoteActor(_harness.Peer, "owner", ed25519: true); var commenter = new RemoteActor(_harness.Peer, "commenter", _harness.Peer.B, ed25519: true); var reply = commenter.Prove(Create(commenter, "elsewhere", $"{Origin(commenter)}/conversation/1")); await _harness.Deliver(owner, "/human-centipede", new JsonObject { ["id"] = $"{Origin(owner)}/activity/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = owner.Id, ["object"] = reply, ["target"] = $"{Origin(commenter)}/conversation/1" }); Assert.False(await DB.Default.Find().Match(j => j.DedupeKey == "inbox|forwarded|" + reply["id"]!.GetValue()).ExecuteAnyAsync(Token)); } } }