using MongoDB.Entities; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Security.Cryptography; using System.Text.Json.Nodes; using static PrivaPub.Tests.Support.Host.FederationHelpers; namespace PrivaPub.Tests.Http { [Trait("Category", "Integration")] public sealed class FederationGetTests : IAsyncLifetime { const string Base = PrivaPubHost.Base; PrivaPubHost _host; HttpClient _client; Peer _peer; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); _client = _host.Client(); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { _client?.Dispose(); if (_peer != default) await _peer.DisposeAsync(); } [Fact] public async Task The_actor_document_is_activity_json_with_an_spki_key_on_the_actor() { var persona = await _host.Persona(await _host.SignUp(), "actor"); var avatar = await DB.Default.Find().MatchID(persona.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken); var fetched = await _client.Fetch($"/peasants/{persona.UserName}"); Assert.Equal(HttpStatusCode.OK, fetched.Status); Assert.Equal(ActivityJson, fetched.MediaType); Assert.Contains("Accept", fetched.Response.Headers.Vary); var actor = fetched.Json; var id = persona.ActorUri(); Assert.Equal(id, actor["id"]!.GetValue()); Assert.Equal("Person", actor["type"]!.GetValue()); Assert.Equal(persona.UserName, actor["preferredUsername"]!.GetValue()); Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue()); Assert.Equal(id + "/anus", actor["outbox"]!.GetValue()); Assert.Equal(id + "/groupies", actor["followers"]!.GetValue()); Assert.Equal(id + "/stalking", actor["following"]!.GetValue()); Assert.Equal(id + "/trophies", actor["featured"]!.GetValue()); Assert.Equal(id + "/tattoos", actor["featuredTags"]!.GetValue()); Assert.Equal($"{Base}/@{persona.UserName}", actor["url"]!.GetValue()); Assert.Equal(Base + "/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue()); var key = actor["publicKey"]!; Assert.Equal(id + "#main-key", key["id"]!.GetValue()); Assert.Equal(id, key["owner"]!.GetValue()); var pem = key["publicKeyPem"]!.GetValue(); Assert.StartsWith("-----BEGIN PUBLIC KEY-----", pem); using var rsa = RSA.Create(); rsa.ImportFromPem(pem); Assert.True(rsa.KeySize >= 2048); Assert.Equal(ActivityPubRenderer.Day(avatar.PublishedOn), actor["published"]!.GetValue()); Assert.EndsWith("T00:00:00Z", actor["published"]!.GetValue()); Assert.DoesNotContain(Keys(actor), k => k.Contains("created", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(ActivityPubRenderer.Timestamp(avatar.CreatedAt)[..16], fetched.Text); Assert.DoesNotContain(persona.Root.Id, fetched.Text); Assert.DoesNotContain(persona.Root.UserName, fetched.Text); } [Fact] public async Task The_actor_answers_ld_json_and_sends_browsers_to_the_profile_page() { var persona = await _host.Persona(await _host.SignUp(), "browser"); var ld = await _client.Fetch($"/peasants/{persona.UserName}", LdJson); var browser = await _client.Fetch($"/peasants/{persona.UserName}", Browser); var instance = await _client.Fetch("/peasants/privapub", Browser); Assert.Equal(HttpStatusCode.OK, ld.Status); Assert.Equal(persona.ActorUri(), ld.Json["id"]!.GetValue()); Assert.Equal(HttpStatusCode.Redirect, browser.Status); Assert.Equal($"{Base}/@{persona.UserName}", browser.Location); Assert.Contains("Accept", browser.Response.Headers.Vary); Assert.Equal(HttpStatusCode.OK, instance.Status); Assert.Equal("Application", instance.Json["type"]!.GetValue()); } [Fact] public async Task Users_moves_permanently_to_peasants_and_unknown_actors_are_404() { var persona = await _host.Persona(await _host.SignUp(), "users"); var moved = await _client.Fetch($"/users/{persona.UserName}"); var unknown = await _client.Fetch($"/peasants/nobody{Guid.NewGuid():N}"[..28]); Assert.Equal(HttpStatusCode.MovedPermanently, moved.Status); Assert.Equal($"/peasants/{persona.UserName}", moved.Location); Assert.Equal(HttpStatusCode.NotFound, unknown.Status); } [Fact] public async Task The_outbox_pages_public_posts_and_boosts_and_nothing_else() { var persona = await _host.Persona(await _host.SignUp(), "outbox"); var friend = await _host.Persona(await _host.SignUp(), "friend"); var boosted = await _host.Publish(friend, "worth a boost"); var shown = new List(); for (var i = 0; i < 20; i++) shown.Add(await _host.Publish(persona, $"public number {i}")); shown.Add(await _host.Publish(persona, "quietly public", PostVisibility.Unlisted)); var hidden = new List { await _host.Publish(persona, "for followers", PostVisibility.FollowersOnly), await _host.Publish(persona, $"@{friend.UserName} just you", PostVisibility.Direct), await _host.PublishLocated(persona, "only around here"), await _host.FederatedCopy(persona) }; var deleted = await _host.Publish(persona, "regretted"); await _host.Remove(persona, deleted); hidden.Add(deleted); var reblog = await _host.Reblog(persona, boosted); Assert.Equal(PostVisibility.LocalGeo, hidden[2].Visibility); var outbox = await _client.Fetch($"/peasants/{persona.UserName}/anus"); Assert.Equal(HttpStatusCode.OK, outbox.Status); Assert.Equal(ActivityJson, outbox.MediaType); Assert.Equal("OrderedCollection", outbox.Json["type"]!.GetValue()); Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue()); Assert.Equal(22, outbox.Json["totalItems"]!.GetValue()); Assert.Null(outbox.Json["orderedItems"]); var first = outbox.Json["first"]!.GetValue(); Assert.Equal(persona.ActorUri() + "/anus?page=true", first); var page1 = await _client.Fetch(PathOf(first)); Assert.Equal(HttpStatusCode.OK, page1.Status); Assert.Equal("OrderedCollectionPage", page1.Json["type"]!.GetValue()); Assert.Equal(persona.ActorUri() + "/anus", page1.Json["partOf"]!.GetValue()); var items1 = page1.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList(); Assert.Equal(20, items1.Count); var next = page1.Json["next"]!.GetValue(); Assert.StartsWith(persona.ActorUri() + "/anus?page=true&max_id=", next); var page2 = await _client.Fetch(PathOf(next)); Assert.Equal(HttpStatusCode.OK, page2.Status); Assert.Equal(next, page2.Json["id"]!.GetValue()); var items2 = page2.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList(); Assert.Equal(2, items2.Count); Assert.Null(page2.Json["next"]); var items = items1.Concat(items2).ToList(); Assert.Equal(22, items.Select(i => i["id"]!.GetValue()).Distinct().Count()); var announce = items[0]; Assert.Equal("Announce", announce["type"]!.GetValue()); Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue()); Assert.Equal($"{persona.ActorUri()}/grunts/announce-{boosted.ID}", announce["id"]!.GetValue()); Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue()); Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue()); var creates = items.Skip(1).ToList(); Assert.All(creates, c => { Assert.Equal("Create", c["type"]!.GetValue()); Assert.Equal(persona.ActorUri(), c["actor"]!.GetValue()); Assert.Equal(persona.ActorUri(), c["object"]!["attributedTo"]!.GetValue()); }); Assert.Equal(shown.Select(p => p.ObjectURI).Reverse(), creates.Select(c => c["object"]!["id"]!.GetValue())); var everything = page1.Text + page2.Text; foreach (var post in hidden) Assert.DoesNotContain(post.ID, everything); var junk = await _client.Fetch($"/peasants/{persona.UserName}/anus?page=true&max_id=not-an-id"); Assert.True((int)junk.Status < 500, $"a junk max_id answered {(int)junk.Status}"); } [Fact] public async Task Followers_and_following_give_no_names_away() { var token = TestContext.Current.CancellationToken; var persona = await _host.Persona(await _host.SignUp(), "groupies"); var friend = await _host.Persona(await _host.SignUp(), "stalked"); var accepted = $"https://remote.example/users/fan{Guid.NewGuid():N}"; var pending = $"https://remote.example/users/hopeful{Guid.NewGuid():N}"; await DB.Default.SaveAsync(new[] { new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = accepted, InboxURL = accepted + "/inbox" }, new Follower { LocalActorId = persona.Id, LocalActorKind = LocalActorKind.Person, ActorURI = pending, InboxURL = pending + "/inbox", IsAccepted = false } }, token); await DB.Default.SaveAsync(new Following { AvatarId = persona.Id, TargetAccountId = friend.Id, TargetActorURI = friend.ActorUri(), TargetIsLocal = true, State = FollowState.Accepted }, token); var followers = await _client.Fetch($"/peasants/{persona.UserName}/groupies"); var following = await _client.Fetch($"/peasants/{persona.UserName}/stalking"); Assert.Equal(HttpStatusCode.OK, followers.Status); Assert.Equal("OrderedCollection", followers.Json["type"]!.GetValue()); Assert.Equal(persona.ActorUri() + "/groupies", followers.Json["id"]!.GetValue()); Assert.Equal(1, followers.Json["totalItems"]!.GetValue()); Assert.DoesNotContain(accepted, followers.Text); Assert.DoesNotContain(pending, followers.Text); Assert.Equal(HttpStatusCode.OK, following.Status); Assert.Equal(persona.ActorUri() + "/stalking", following.Json["id"]!.GetValue()); Assert.Equal(0, following.Json["totalItems"]!.GetValue()); Assert.DoesNotContain(friend.UserName, following.Text); } [Fact] public async Task Trophies_are_the_public_pins_newest_first_and_tattoos_is_empty() { var token = TestContext.Current.CancellationToken; var persona = await _host.Persona(await _host.SignUp(), "trophies"); var older = await _host.Publish(persona, "pinned first"); var newer = await _host.Publish(persona, "pinned second"); var followersOnly = await _host.Publish(persona, "pinned but private", PostVisibility.FollowersOnly); var removed = await _host.Publish(persona, "pinned then deleted"); foreach (var post in new[] { older, followersOnly, removed, newer }) await DB.Default.SaveAsync(new Pin { AvatarId = persona.Id, PostId = post.ID }, token); await _host.Remove(persona, removed); var featured = await _client.Fetch($"/peasants/{persona.UserName}/trophies"); var tags = await _client.Fetch($"/peasants/{persona.UserName}/tattoos"); Assert.Equal(HttpStatusCode.OK, featured.Status); Assert.Equal(persona.ActorUri() + "/trophies", featured.Json["id"]!.GetValue()); Assert.Equal(2, featured.Json["totalItems"]!.GetValue()); var notes = featured.Json["orderedItems"]!.AsArray(); Assert.Equal(new[] { newer.ObjectURI, older.ObjectURI }, notes.Select(n => n!["id"]!.GetValue())); Assert.All(notes, n => Assert.Equal("Note", n!["type"]!.GetValue())); Assert.DoesNotContain(followersOnly.ID, featured.Text); Assert.DoesNotContain(removed.ID, featured.Text); Assert.Equal(HttpStatusCode.OK, tags.Status); Assert.Equal(persona.ActorUri() + "/tattoos", tags.Json["id"]!.GetValue()); Assert.Equal(0, tags.Json["totalItems"]!.GetValue()); Assert.Empty(tags.Json["orderedItems"]!.AsArray()); } [Fact] public async Task A_scribble_is_served_while_public_and_a_tombstone_once_deleted() { var persona = await _host.Persona(await _host.SignUp(), "scribble"); var other = await _host.Persona(await _host.SignUp(), "other"); var shown = await _host.Publish(persona, "hello fediverse"); var unlisted = await _host.Publish(persona, "hello quietly", PostVisibility.Unlisted); var followersOnly = await _host.Publish(persona, "hello followers", PostVisibility.FollowersOnly); var direct = await _host.Publish(persona, $"@{other.UserName} hello you", PostVisibility.Direct); var located = await _host.PublishLocated(persona, "hello neighbours"); var deleted = await _host.Publish(persona, "goodbye"); await _host.Remove(persona, deleted); string Path(Post post) => $"/peasants/{persona.UserName}/scribbles/{post.ID}"; var note = await _client.Fetch(Path(shown)); Assert.Equal(HttpStatusCode.OK, note.Status); Assert.Equal(ActivityJson, note.MediaType); Assert.Equal(shown.ObjectURI, note.Json["id"]!.GetValue()); Assert.Equal("Note", note.Json["type"]!.GetValue()); Assert.Equal(persona.ActorUri(), note.Json["attributedTo"]!.GetValue()); Assert.NotNull(note.Json["@context"]); Assert.Contains(ActivityPubRenderer.Public, Strings(note.Json["to"])); Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(Path(unlisted))).Status); var browser = await _client.Fetch(Path(shown), Browser); Assert.Equal(HttpStatusCode.Redirect, browser.Status); Assert.Equal($"{Base}/@{persona.UserName}/{shown.ID}", browser.Location); foreach (var post in new[] { followersOnly, direct, located }) Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(post))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{other.UserName}/scribbles/{shown.ID}")).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{persona.UserName}/scribbles/{Guid.NewGuid():N}"[..48])).Status); var tombstone = await _client.Fetch(Path(deleted)); Assert.Equal(HttpStatusCode.Gone, tombstone.Status); Assert.Equal(ActivityJson, tombstone.MediaType); Assert.Equal("Tombstone", tombstone.Json["type"]!.GetValue()); Assert.Equal(deleted.ObjectURI, tombstone.Json["id"]!.GetValue()); Assert.Equal("Note", tombstone.Json["formerType"]!.GetValue()); Assert.NotNull(tombstone.Json["deleted"]); Assert.DoesNotContain("goodbye", tombstone.Text); } [Fact] public async Task A_circle_post_is_served_only_to_a_member_or_its_instance_actor() { var owner = await _host.Persona(await _host.SignUp(), "circleowner"); var circle = await _host.FederatedGroup(owner, community: false); var member = new RemoteActor(_peer, "member"); var memberServer = new RemoteActor(_peer, "instance", type: "Application"); var neighbour = new RemoteActor(_peer, "neighbour"); var stranger = new RemoteActor(_peer, "stranger", _peer.B); var strangerServer = new RemoteActor(_peer, "instance", _peer.B, "Application"); await circle.AddForeignMember(member.Id); var post = await _host.Publish(owner, new StatusDraft { Text = "just us", PlainText = true, GroupId = circle.Id }); Assert.Equal(PostVisibility.Circle, post.Visibility); var path = $"/peasants/{owner.UserName}/scribbles/{post.ID}"; var asMember = await _client.Fetch(member.SignedGet(path)); var asMemberServer = await _client.Fetch(memberServer.SignedGet(path)); Assert.Equal(HttpStatusCode.OK, asMember.Status); Assert.Equal(post.ObjectURI, asMember.Json["id"]!.GetValue()); Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"])); Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text); Assert.Equal(HttpStatusCode.OK, asMemberServer.Status); // a refetch names the member, as the member's copy did; the instance actor sees the members on its server only Assert.Equal(new[] { member.Id }, Strings(asMember.Json["cc"])); Assert.Equal(new[] { member.Id }, Strings(asMemberServer.Json["cc"])); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path)).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path, Browser)).Status); foreach (var outsider in new[] { neighbour, stranger, strangerServer }) Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(outsider.SignedGet(path))).Status); var create = $"/peasants/{owner.UserName}/grunts/create-{post.ID}"; Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(create)).Status); var createAsMember = await _client.Fetch(member.SignedGet(create)); Assert.Equal(HttpStatusCode.OK, createAsMember.Status); Assert.Equal(post.ObjectURI, createAsMember.Json["object"]!["id"]!.GetValue()); Assert.Equal(new[] { member.Id }, Strings(createAsMember.Json["object"]!["cc"])); } [Fact] public async Task Followers_only_and_direct_posts_are_served_to_signed_fetches_from_those_they_were_for() { var token = TestContext.Current.CancellationToken; var owner = await _host.Persona(await _host.SignUp(), "private"); var follower = new RemoteActor(_peer, "follower"); var followerServer = new RemoteActor(_peer, "instance", type: "Application"); var recipient = new RemoteActor(_peer, "recipient"); var stranger = new RemoteActor(_peer, "stranger", _peer.B); _peer.WebFinger(recipient); await DB.Default.SaveAsync(new Follower { LocalActorId = owner.Id, LocalActorKind = LocalActorKind.Person, ActorURI = follower.Id, InboxURL = follower.Id + "/inbox" }, token); var quiet = await _host.Publish(owner, "for followers", PostVisibility.FollowersOnly); var direct = await _host.Publish(owner, new StatusDraft { Text = $"@{recipient.Handle()} just you", PlainText = true, Visibility = PostVisibility.Direct }); var quietPath = $"/peasants/{owner.UserName}/scribbles/{quiet.ID}"; var directPath = $"/peasants/{owner.UserName}/scribbles/{direct.ID}"; Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(follower.SignedGet(quietPath))).Status); Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(followerServer.SignedGet(quietPath))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status); var asRecipient = await _client.Fetch(recipient.SignedGet(directPath)); Assert.Equal(HttpStatusCode.OK, asRecipient.Status); Assert.Contains(recipient.Id, Strings(asRecipient.Json["to"])); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(follower.SignedGet(directPath))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(directPath)).Status); // the DM's context is the conversation, for its participants only var context = PathOf(asRecipient.Json["context"]!.GetValue()); var conversation = await _client.Fetch(recipient.SignedGet(context)); Assert.Equal(HttpStatusCode.OK, conversation.Status); Assert.Equal(new[] { direct.ObjectURI }, Strings(conversation.Json["orderedItems"])); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(context))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(context)).Status); // once deleted, those it was for learn it is gone; everyone else still learns nothing await _host.Remove(owner, quiet); Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(follower.SignedGet(quietPath))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status); } [Fact] public async Task A_circles_followers_members_and_wardens_are_shown_only_to_its_members() { var token = TestContext.Current.CancellationToken; var owner = await _host.Persona(await _host.SignUp(), "circlelists"); var circle = await _host.FederatedGroup(owner, community: false); var community = await _host.FederatedGroup(owner, community: true); var member = new RemoteActor(_peer, "member"); var stranger = new RemoteActor(_peer, "stranger", _peer.B); await circle.AddForeignMember(member.Id); foreach (var group in new[] { circle, community }) await DB.Default.SaveAsync(new Follower { LocalActorId = group.Id, LocalActorKind = LocalActorKind.Group, ActorURI = member.Id, InboxURL = member.Id + "/inbox" }, token); foreach (var collection in new[] { "groupies", "flock", "wardens" }) { var path = $"/peasants/{circle.UserName}/{collection}"; Assert.True((await _client.Fetch(path)).Status == HttpStatusCode.NotFound, $"the circle's {collection} answered an unsigned GET"); Assert.True((await _client.Fetch(stranger.SignedGet(path))).Status == HttpStatusCode.NotFound, $"the circle's {collection} answered a stranger"); var asMember = await _client.Fetch(member.SignedGet(path)); Assert.True(asMember.Status == HttpStatusCode.OK, $"the circle's {collection} refused a member"); Assert.Equal(circle.Uri + "/" + collection, asMember.Json["id"]!.GetValue()); var open = await _client.Fetch($"/peasants/{community.UserName}/{collection}"); Assert.True(open.Status == HttpStatusCode.OK, $"the community's {collection} answered {(int)open.Status}"); } Assert.Equal(1, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/groupies"))).Json["totalItems"]!.GetValue()); Assert.Equal(2, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/flock"))).Json["totalItems"]!.GetValue()); var wardens = await _client.Fetch($"/peasants/{community.UserName}/wardens"); Assert.Equal(new[] { owner.ActorUri() }, Strings(wardens.Json["orderedItems"])); Assert.Equal(community.Uri + "/wardens", (await _client.Fetch($"/peasants/{community.UserName}")).Json["attributedTo"]!.GetValue()); Assert.Null((await _client.Fetch($"/peasants/{circle.UserName}")).Json["attributedTo"]); } [Fact] public async Task Grunts_resolve_creates_and_boosts() { var persona = await _host.Persona(await _host.SignUp(), "grunts"); var friend = await _host.Persona(await _host.SignUp(), "boosted"); var post = await _host.Publish(persona, "a grunt"); var followersOnly = await _host.Publish(persona, "a private grunt", PostVisibility.FollowersOnly); var original = await _host.Publish(friend, "boost me"); var reblog = await _host.Reblog(persona, original); string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}"; var create = await _client.Fetch(Path($"create-{post.ID}")); var announce = await _client.Fetch(Path($"announce-{original.ID}")); Assert.Equal(HttpStatusCode.OK, create.Status); Assert.Equal(ActivityJson, create.MediaType); Assert.Equal("Create", create.Json["type"]!.GetValue()); Assert.Equal(Base + Path($"create-{post.ID}"), create.Json["id"]!.GetValue()); Assert.Equal(post.ObjectURI, create.Json["object"]!["id"]!.GetValue()); Assert.Equal(post.ActivityURI, create.Json["id"]!.GetValue()); Assert.Equal(HttpStatusCode.OK, announce.Status); Assert.Equal("Announce", announce.Json["type"]!.GetValue()); Assert.Equal(reblog.ObjectURI, announce.Json["id"]!.GetValue()); Assert.Equal(original.ObjectURI, announce.Json["object"]!.GetValue()); foreach (var unknown in new[] { $"create-{followersOnly.ID}", $"create-{Guid.NewGuid():N}"[..31], $"announce-{post.ID}", $"like-{post.ID}", "x" }) Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(unknown))).Status); Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{original.ID}")).Status); } [Fact] public async Task A_parrot_licence_is_served_by_its_author_until_revoked() { var token = TestContext.Current.CancellationToken; var quoted = await _host.Persona(await _host.SignUp(), "quoted"); var quoter = await _host.Persona(await _host.SignUp(), "quoter"); var original = await _host.Publish(quoted, "quote me"); var quoting = await _host.Publish(quoter, new StatusDraft { Text = "well said", PlainText = true, QuotedStatusId = original.ID }); Assert.Equal(QuoteState.Accepted, quoting.QuoteState); var licenceUri = quoting.QuoteAuthorizationURI; Assert.StartsWith(quoted.ActorUri() + "/parrot-licences/", licenceUri); var licenceId = licenceUri.Split('/')[^1]; var licence = await _client.Fetch(PathOf(licenceUri)); var wrongAuthor = await _client.Fetch($"/peasants/{quoter.UserName}/parrot-licences/{licenceId}"); var unknown = await _client.Fetch($"/peasants/{quoted.UserName}/parrot-licences/{Guid.NewGuid():N}"[..48]); Assert.Equal(HttpStatusCode.OK, licence.Status); Assert.Equal(ActivityJson, licence.MediaType); Assert.Equal("QuoteAuthorization", licence.Json["type"]!.GetValue()); Assert.Equal(licenceUri, licence.Json["id"]!.GetValue()); Assert.Equal(quoted.ActorUri(), licence.Json["attributedTo"]!.GetValue()); Assert.Equal(quoting.ObjectURI, licence.Json["interactingObject"]!.GetValue()); Assert.Equal(original.ObjectURI, licence.Json["interactionTarget"]!.GetValue()); Assert.Equal(HttpStatusCode.NotFound, wrongAuthor.Status); Assert.Equal(HttpStatusCode.NotFound, unknown.Status); Assert.True(await _host.Get().RevokeLicence(original, quoting, token)); var revoked = await _client.Fetch(PathOf(licenceUri)); Assert.Equal(HttpStatusCode.Gone, revoked.Status); Assert.Equal(ActivityJson, revoked.MediaType); Assert.Equal("Tombstone", revoked.Json["type"]!.GetValue()); Assert.Equal("QuoteAuthorization", revoked.Json["formerType"]!.GetValue()); Assert.Equal(licenceUri, revoked.Json["id"]!.GetValue()); } [Fact] public async Task Secure_mode_refuses_unsigned_gets_except_for_the_instance_actor() { var secure = await SecureModeHost.Shared(); using var client = secure.Client(); var persona = await _host.Persona(await _host.SignUp(), "secure"); var post = await _host.Publish(persona, "only for the signed"); var reader = new RemoteActor(_peer, "reader"); var paths = new[] { $"/peasants/{persona.UserName}", $"/peasants/{persona.UserName}/anus", $"/peasants/{persona.UserName}/anus?page=true", $"/peasants/{persona.UserName}/groupies", $"/peasants/{persona.UserName}/stalking", $"/peasants/{persona.UserName}/trophies", $"/peasants/{persona.UserName}/tattoos", $"/peasants/{persona.UserName}/scribbles/{post.ID}", $"/peasants/{persona.UserName}/grunts/create-{post.ID}", $"/peasants/nobody{Guid.NewGuid():N}"[..28] }; foreach (var path in paths) Assert.True((await client.Fetch(path)).Status == HttpStatusCode.Unauthorized, $"{path} answered an unsigned GET"); var instance = await client.Fetch("/peasants/privapub"); Assert.Equal(HttpStatusCode.OK, instance.Status); Assert.Equal("Application", instance.Json["type"]!.GetValue()); foreach (var path in paths[..^1]) Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET"); // a browser is not asked for a signature: it is only sent to the public pages var browser = await client.Fetch($"/peasants/{persona.UserName}", Browser); Assert.Equal(HttpStatusCode.Redirect, browser.Status); Assert.Equal(HttpStatusCode.Redirect, (await client.Fetch($"/peasants/{persona.UserName}/scribbles/{post.ID}", Browser)).Status); } } }