Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object) 400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from `privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06, `ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left. The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69 checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's post too late once, and Ghost passes alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
580 lines
30 KiB
C#
580 lines
30 KiB
C#
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<Avatar>().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<string>());
|
|
Assert.Equal("Person", actor["type"]!.GetValue<string>());
|
|
Assert.Equal(persona.UserName, actor["preferredUsername"]!.GetValue<string>());
|
|
Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue<string>());
|
|
Assert.Equal(id + "/anus", actor["outbox"]!.GetValue<string>());
|
|
Assert.Equal(id + "/groupies", actor["followers"]!.GetValue<string>());
|
|
Assert.Equal(id + "/stalking", actor["following"]!.GetValue<string>());
|
|
Assert.Equal(id + "/trophies", actor["featured"]!.GetValue<string>());
|
|
Assert.Equal(id + "/tattoos", actor["featuredTags"]!.GetValue<string>());
|
|
Assert.Equal($"{Base}/@{persona.UserName}", actor["url"]!.GetValue<string>());
|
|
Assert.Equal(Base + "/human-centipede", actor["endpoints"]!["sharedInbox"]!.GetValue<string>());
|
|
|
|
var key = actor["publicKey"]!;
|
|
Assert.Equal(id + "#main-key", key["id"]!.GetValue<string>());
|
|
Assert.Equal(id, key["owner"]!.GetValue<string>());
|
|
var pem = key["publicKeyPem"]!.GetValue<string>();
|
|
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<string>());
|
|
Assert.EndsWith("T00:00:00Z", actor["published"]!.GetValue<string>());
|
|
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<string>());
|
|
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<string>());
|
|
}
|
|
|
|
// PieFed reads the inbox it sends a community's announces to from the Application at a peer's root
|
|
[Fact]
|
|
public async Task The_instance_actor_answers_at_the_root_for_activitypub_only()
|
|
{
|
|
var root = await _client.Fetch("/");
|
|
var browser = await _client.Fetch("/", Browser);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, root.Status);
|
|
Assert.Equal("Application", root.Json["type"]!.GetValue<string>());
|
|
Assert.Equal($"{Base}/peasants/privapub", root.Json["id"]!.GetValue<string>());
|
|
Assert.Equal($"{Base}/peasants/privapub/mouth", root.Json["inbox"]!.GetValue<string>());
|
|
Assert.Equal(HttpStatusCode.NotFound, browser.Status);
|
|
}
|
|
|
|
// Lemmy takes a report only from a Person, a Service or an Organization, whose document names its preferredUsername,
|
|
// inbox, outbox and key; a browser gets the document too, as from the instance actor: the reporter has no page
|
|
[Fact]
|
|
public async Task The_reporter_is_a_service_with_an_inbox_an_outbox_and_its_own_key()
|
|
{
|
|
var fetched = await _client.Fetch("/peasants/privapub_reports");
|
|
var browser = await _client.Fetch("/peasants/privapub_reports", Browser);
|
|
var outbox = await _client.Fetch("/peasants/privapub_reports/anus");
|
|
var instance = await _client.Fetch("/peasants/privapub");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, fetched.Status);
|
|
var actor = fetched.Json;
|
|
var id = $"{Base}/peasants/privapub_reports";
|
|
Assert.Equal(id, actor["id"]!.GetValue<string>());
|
|
Assert.Equal("Service", actor["type"]!.GetValue<string>());
|
|
Assert.Equal("privapub_reports", actor["preferredUsername"]!.GetValue<string>());
|
|
Assert.Equal(id + "/mouth", actor["inbox"]!.GetValue<string>());
|
|
Assert.Equal(id + "/anus", actor["outbox"]!.GetValue<string>());
|
|
Assert.Equal(id, actor["url"]!.GetValue<string>());
|
|
Assert.Equal(id + "#main-key", actor["publicKey"]!["id"]!.GetValue<string>());
|
|
Assert.Equal(id, actor["publicKey"]!["owner"]!.GetValue<string>());
|
|
Assert.NotEqual(instance.Json["publicKey"]!["publicKeyPem"]!.GetValue<string>(), actor["publicKey"]!["publicKeyPem"]!.GetValue<string>());
|
|
Assert.False(actor["discoverable"]!.GetValue<bool>());
|
|
Assert.Null(actor["wall"]);
|
|
Assert.Null(actor["implements"]);
|
|
Assert.Equal(HttpStatusCode.OK, browser.Status);
|
|
Assert.Equal("Service", browser.Json["type"]!.GetValue<string>());
|
|
Assert.Equal(HttpStatusCode.OK, outbox.Status);
|
|
Assert.Equal(0, outbox.Json["totalItems"]!.GetValue<int>());
|
|
}
|
|
|
|
[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<Post>();
|
|
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<Post>
|
|
{
|
|
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<string>());
|
|
Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue<string>());
|
|
// statuses_count, as Mastodon counts it: everything but the DM, the deleted post and the copy of a remote post,
|
|
// the followers-only and located posts and the boost included; only the public posts are listed
|
|
Assert.Equal(24, outbox.Json["totalItems"]!.GetValue<int>());
|
|
Assert.Null(outbox.Json["orderedItems"]);
|
|
var first = outbox.Json["first"]!.GetValue<string>();
|
|
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<string>());
|
|
Assert.Equal(persona.ActorUri() + "/anus", page1.Json["partOf"]!.GetValue<string>());
|
|
var items1 = page1.Json["orderedItems"]!.AsArray().Select(i => i!.AsObject()).ToList();
|
|
Assert.Equal(20, items1.Count);
|
|
var next = page1.Json["next"]!.GetValue<string>();
|
|
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<string>());
|
|
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<string>()).Distinct().Count());
|
|
var announce = items[0];
|
|
Assert.Equal("Announce", announce["type"]!.GetValue<string>());
|
|
Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue<string>());
|
|
Assert.Equal($"{persona.ActorUri()}/grunts/announce-{reblog.ID}", announce["id"]!.GetValue<string>());
|
|
Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue<string>());
|
|
Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue<string>());
|
|
|
|
var creates = items.Skip(1).ToList();
|
|
Assert.All(creates, c =>
|
|
{
|
|
Assert.Equal("Create", c["type"]!.GetValue<string>());
|
|
Assert.Equal(persona.ActorUri(), c["actor"]!.GetValue<string>());
|
|
Assert.Equal(persona.ActorUri(), c["object"]!["attributedTo"]!.GetValue<string>());
|
|
});
|
|
Assert.Equal(shown.Select(p => p.ObjectURI).Reverse(), creates.Select(c => c["object"]!["id"]!.GetValue<string>()));
|
|
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<string>());
|
|
Assert.Equal(persona.ActorUri() + "/groupies", followers.Json["id"]!.GetValue<string>());
|
|
Assert.Equal(1, followers.Json["totalItems"]!.GetValue<int>());
|
|
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<string>());
|
|
Assert.Equal(1, following.Json["totalItems"]!.GetValue<int>());//counted, as following_count says, never listed
|
|
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<string>());
|
|
Assert.Equal(2, featured.Json["totalItems"]!.GetValue<int>());
|
|
var notes = featured.Json["orderedItems"]!.AsArray();
|
|
Assert.Equal(new[] { newer.ObjectURI, older.ObjectURI }, notes.Select(n => n!["id"]!.GetValue<string>()));
|
|
Assert.All(notes, n => Assert.Equal("Note", n!["type"]!.GetValue<string>()));
|
|
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<string>());
|
|
Assert.Equal(0, tags.Json["totalItems"]!.GetValue<int>());
|
|
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<string>());
|
|
Assert.Equal("Note", note.Json["type"]!.GetValue<string>());
|
|
Assert.Equal(persona.ActorUri(), note.Json["attributedTo"]!.GetValue<string>());
|
|
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<string>());
|
|
Assert.Equal(deleted.ObjectURI, tombstone.Json["id"]!.GetValue<string>());
|
|
Assert.Equal("Note", tombstone.Json["formerType"]!.GetValue<string>());
|
|
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<string>());
|
|
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<string>());
|
|
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<string>());
|
|
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<string>());
|
|
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<int>());
|
|
Assert.Equal(2, (await _client.Fetch(member.SignedGet($"/peasants/{circle.UserName}/flock"))).Json["totalItems"]!.GetValue<int>());
|
|
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<string>());
|
|
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-{reblog.ID}"));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, create.Status);
|
|
Assert.Equal(ActivityJson, create.MediaType);
|
|
Assert.Equal("Create", create.Json["type"]!.GetValue<string>());
|
|
Assert.Equal(Base + Path($"create-{post.ID}"), create.Json["id"]!.GetValue<string>());
|
|
Assert.Equal(post.ObjectURI, create.Json["object"]!["id"]!.GetValue<string>());
|
|
Assert.Equal(post.ActivityURI, create.Json["id"]!.GetValue<string>());
|
|
Assert.Equal(HttpStatusCode.OK, announce.Status);
|
|
Assert.Equal("Announce", announce.Json["type"]!.GetValue<string>());
|
|
Assert.Equal(reblog.ObjectURI, announce.Json["id"]!.GetValue<string>());
|
|
Assert.Equal(original.ObjectURI, announce.Json["object"]!.GetValue<string>());
|
|
|
|
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(Path($"announce-{original.ID}"))).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{reblog.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<string>());
|
|
Assert.Equal(licenceUri, licence.Json["id"]!.GetValue<string>());
|
|
Assert.Equal(quoted.ActorUri(), licence.Json["attributedTo"]!.GetValue<string>());
|
|
Assert.Equal(quoting.ObjectURI, licence.Json["interactingObject"]!.GetValue<string>());
|
|
Assert.Equal(original.ObjectURI, licence.Json["interactionTarget"]!.GetValue<string>());
|
|
Assert.Equal(HttpStatusCode.NotFound, wrongAuthor.Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, unknown.Status);
|
|
|
|
Assert.True(await _host.Get<IQuoteService>().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<string>());
|
|
Assert.Equal("QuoteAuthorization", revoked.Json["formerType"]!.GetValue<string>());
|
|
Assert.Equal(licenceUri, revoked.Json["id"]!.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Secure_mode_refuses_unsigned_gets_except_for_the_servers_own_actors()
|
|
{
|
|
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<string>());
|
|
var reporter = await client.Fetch("/peasants/privapub_reports");
|
|
Assert.Equal(HttpStatusCode.OK, reporter.Status);
|
|
Assert.Equal("Service", reporter.Json["type"]!.GetValue<string>());
|
|
Assert.Equal(HttpStatusCode.OK, (await client.Fetch("/")).Status);
|
|
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);
|
|
}
|
|
}
|
|
}
|