using MongoDB.Entities; using PrivaPub.Models.Jobs; using PrivaPub.Models.Post; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Http { [Trait("Category", "Integration")] public sealed class ProvenanceAndReactionsTests : IAsyncLifetime { PrivaPubHost _host; Peer _peer; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { if (_peer != default) await _peer.DisposeAsync(); } static CancellationToken Token => TestContext.Current.CancellationToken; static string Provenance(string id) => $"/api/privapub/v1/statuses/{id}/provenance"; [Fact] public async Task A_local_post_has_a_local_provenance() { var alice = await _host.Mastodon("alice"); var status = await alice.Status("made here"); var provenance = (await _host.Client().Get(Provenance(status.Text("id")))).Ok(); Assert.Equal("local", provenance.Body.Text("path")); Assert.Equal(status.Text("uri"), provenance.Body.Text("object_uri")); Assert.Equal(status.Text("url"), provenance.Body.Text("url")); Assert.Null(provenance.Body["signature"]); Assert.Null(provenance.Body["raw"]); } [Fact] public async Task A_delivered_post_shows_its_signature_and_raw_form_and_reading_it_asks_nobody() { var alice = await _host.Mastodon("alice"); var bob = new RemoteActor(_peer, "bob"); var create = bob.Create("

signed and sealed

", new[] { MastodonHelpers.Public }, new[] { alice.Uri }, note => note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName })); await _host.Deliver(bob, alice.Mouth, create); var noteId = create["object"].Text("id"); var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token); var asked = _peer.Requests.Count; var provenance = (await alice.Client.Get(Provenance(post.ID))).Ok().Body; var anonymous = (await _host.Client().Get(Provenance(post.ID))).Ok().Body; Assert.Equal("delivered", provenance.Text("path")); Assert.Equal(noteId, provenance.Text("object_uri")); Assert.Equal("Note", provenance.Text("object_type")); Assert.Null(provenance["fetched_by"]); Assert.Equal((create.Text("id"), "Create", bob.Id), (provenance["activity"].Text("id"), provenance["activity"].Text("type"), provenance["activity"].Text("actor"))); Assert.Equal(bob.KeyId, provenance["signature"].Text("key_id")); Assert.Equal("rsa-sha256", provenance["signature"].Text("algorithm")); Assert.Contains("digest", provenance["signature"]!["headers"]!.AsArray().Select(h => h!.GetValue())); Assert.Equal(noteId, provenance["raw"].Text("id")); Assert.False(string.IsNullOrEmpty(provenance.Text("raw_hash"))); Assert.True(provenance.Number("raw_bytes") > 0); Assert.NotNull(provenance.Text("received_at")); Assert.Equal(provenance.ToJsonString(), anonymous.ToJsonString()); Assert.Equal(asked, _peer.Requests.Count); } [Fact] public async Task A_fetched_parent_shows_no_signature_and_was_fetched_by_the_instance_actor() { var alice = await _host.Mastodon("alice"); var bob = new RemoteActor(_peer, "bob"); var parentPath = $"/notes/{Guid.NewGuid():N}"; var parentId = _peer.A + parentPath; _peer.Serve(parentPath, new JsonObject { ["id"] = parentId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = new JsonArray(MastodonHelpers.Public), ["content"] = "

the start of it

", ["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O") }.ToJsonString()); var reply = bob.Create("

and a reply

", new[] { MastodonHelpers.Public }, new[] { alice.Uri }, note => { note["inReplyTo"] = parentId; note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName }); }); await _host.Deliver(bob, alice.Mouth, reply); var parent = await DB.Default.Find().Match(p => p.ObjectURI == parentId).ExecuteFirstAsync(Token); Assert.NotNull(parent); var asked = _peer.Requests.Count; var provenance = (await alice.Client.Get(Provenance(parent.ID))).Ok().Body; Assert.Equal("fetched", provenance.Text("path")); Assert.Equal("instance-actor", provenance.Text("fetched_by")); Assert.Null(provenance["signature"]); Assert.Equal((reply.Text("id"), "Create"), (provenance["activity"].Text("id"), provenance["activity"].Text("type"))); Assert.Null(provenance["inbox"]); Assert.Equal(parentId, provenance["raw"].Text("id")); Assert.Contains(_peer.Requests, r => r.Path == parentPath && r.Signature.Contains("/peasants/privapub#main-key")); Assert.Equal(asked, _peer.Requests.Count); } [Fact] public async Task Provenance_follows_who_may_see_the_post() { var alice = await _host.Mastodon("alice"); var other = await _host.Mastodon("other"); var bob = new RemoteActor(_peer, "bob"); var direct = bob.Create("

between us

", new[] { alice.Uri }, shape: note => note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.UserName })); await _host.Deliver(bob, alice.Mouth, direct); var noteId = direct["object"].Text("id"); var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token); Assert.Equal("delivered", (await alice.Client.Get(Provenance(post.ID))).Ok().Body.Text("path")); Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get(Provenance(post.ID))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance(post.ID))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get(Provenance("000000000000000000000000"))).Status); } [Fact] public async Task An_instance_is_described_from_what_was_stored_and_reading_it_asks_nobody() { var host = $"described{Guid.NewGuid():N}.example"; await DB.Default.SaveAsync(new RemoteInstance { Host = host, Software = "gotosocial", SoftwareVersion = "0.20.0", NodeName = "a test pasture", Protocols = new List { "activitypub" }, OpenRegistrations = false, DescribedAt = DateTime.UtcNow.AddDays(-1), NodeInfo = "{\"software\":{\"name\":\"gotosocial\",\"version\":\"0.20.0\"}}", ConsecutiveFailures = 2, LastFailureAt = DateTime.UtcNow.AddHours(-1) }, Token); var alice = await _host.Mastodon("alice"); var described = (await alice.Client.Get($"/api/privapub/v1/instances/{host.ToUpperInvariant()}")).Ok().Body; Assert.Equal(host, described.Text("host")); Assert.Equal(("gotosocial", "0.20.0", "a test pasture"), (described.Text("software"), described.Text("version"), described.Text("node_name"))); Assert.Equal("activitypub", Assert.Single(described["protocols"]!.AsArray())!.GetValue()); Assert.False(described.Flag("open_registrations")); Assert.Equal("gotosocial", described["node_info"]!["software"].Text("name")); Assert.Equal(2, described["delivery"].Number("consecutive_failures")); Assert.NotNull(described["delivery"].Text("last_failure_at")); Assert.Null(described["delivery"]["last_success_at"]); Assert.Equal(described.ToJsonString(), (await _host.Client().Get($"/api/privapub/v1/instances/{host}")).Ok().Body.ToJsonString()); Assert.Equal(HttpStatusCode.NotFound, (await _host.Client().Get($"/api/privapub/v1/instances/unknown{Guid.NewGuid():N}.example")).Status); Assert.Empty(_peer.Requests); } [Fact] public async Task Reacting_to_a_remote_post_federates_an_emoji_react_and_its_undo() { var since = DateTime.UtcNow.AddSeconds(-1); var alice = await _host.Mastodon("alice"); var carol = await _host.Mastodon("carol"); var bob = new RemoteActor(_peer, "bob"); var post = await _host.PublicPostFrom(bob, alice); var path = $"/api/v1/pleroma/statuses/{post.ID}/reactions"; var reacted = (await alice.Client.Put(path + "/%F0%9F%91%8D")).Ok(); var reaction = Assert.Single(reacted.Body["emoji_reactions"]!.AsArray()); Assert.Equal(("👍", 1, true), (reaction.Text("name"), reaction.Number("count"), reaction.Flag("me"))); Assert.Equal("👍", Assert.Single(reacted.Body["pleroma"]!["emoji_reactions"]!.AsArray()).Text("name")); (await carol.Client.Put(path + "/%F0%9F%91%8D")).Ok(); var listed = Assert.Single((await _host.Client().Get(path)).Ok().Array); Assert.Equal(("👍", 2, false), (listed.Text("name"), listed.Number("count"), listed.Flag("me"))); Assert.Equal(new[] { alice.Id, carol.Id }.Order(), listed!["accounts"]!.AsArray().Select(a => a.Text("id")).Order()); Assert.True(Assert.Single((await alice.Client.Get(path)).Ok().Array).Flag("me")); var react = Assert.Single(await bob.Delivered(since), d => d.Type() == "EmojiReact" && d.Text("actor") == alice.Uri); Assert.Equal(("👍", post.ObjectURI), (react.Text("content"), react.Text("object"))); var withdrawn = (await alice.Client.Delete(path + "/%F0%9F%91%8D")).Ok(); Assert.Equal(1, Assert.Single(withdrawn.Body["emoji_reactions"]!.AsArray()).Number("count")); var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo" && d.Text("actor") == alice.Uri); Assert.Equal(react.Text("id"), undo["object"].Text("id")); Assert.Equal("EmojiReact", undo["object"].Text("type")); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Put(path + "/abc")).Status); Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Put("/api/v1/pleroma/statuses/000000000000000000000000/reactions/%F0%9F%91%8D")).Status); } [Fact] public async Task Reacting_to_a_local_post_notifies_its_author_and_federates_nothing() { var since = DateTime.UtcNow.AddSeconds(-1); var alice = await _host.Mastodon("alice"); var carol = await _host.Mastodon("carol"); var bob = new RemoteActor(_peer, "bob"); await _host.FollowedBy(carol, bob); var post = await carol.Status("react to me"); (await alice.Client.Put($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok(); var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=pleroma:emoji_reaction")).Ok().Array); Assert.Equal(("🎉", alice.Id, post.Text("id")), (notification.Text("emoji"), notification["account"].Text("id"), notification["status"].Text("id"))); Assert.DoesNotContain(await bob.Delivered(since), d => d.Type() is "EmojiReact" or "Like"); (await alice.Client.Delete($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions/%F0%9F%8E%89")).Ok(); Assert.Empty((await carol.Client.Get($"/api/v1/pleroma/statuses/{post.Text("id")}/reactions")).Ok().Array); } } }