T6: the Mastodon API over HTTP
88 integration tests drive the Mastodon client API through the whole server
(PrivaPubHost), with remote actors on an in-process Peer and deliveries read
from the job queue. Helpers live in Support/Host/MastodonHelpers.cs.
Coverage:
- Accounts: verify_credentials (no root id or login name); update_credentials
with indexed and array fields_attributes (form and JSON), source[*],
quote_policy, locked/bot, avatar and header uploads resized and stripped of
EXIF and XMP; lookup (local, @domain, remote; a circle, the instance actor
and a circle's id answer 404); search with and without resolve (only a
signed-in persona resolves, the Peer is untouched otherwise), by post and
actor address, hashtags, undiscoverable personas; account statuses with
pinned, exclude_replies, exclude_reblogs, only_media, tagged and Link paging
both ways; followers-only posts for followers (local and remote authors);
community accounts; followers/following only to their owner; follow (open,
locked, remote Follow delivery), unfollow and Undo; follow requests from
local and remote followers answered with the original Follow;
remove_from_followers; blocks with Reject and Block/Undo deliveries; mutes
with duration and the notifications choice, never federated; domain blocks;
relationships with junk ids; a banned login's tokens; reports forwarded as a
Flag from the instance actor only; account stub routes.
- Statuses: each visibility's to/cc as delivered; CW as summary; replies to
local and remote posts (mention, inReplyTo, the author's inbox); polls and
votes (local, and remote votes only to the author without published); media
attached only by its owner; quotes, the quotes list and revocation; edit
history, source and the Update delivery; delete for redraft, the 410
Tombstone and the Delete delivery; favourite/reblog counts with Like,
Announce and their Undos; favourited_by, reblogged_by; bookmarks; pins;
interaction_policy matching canQuote in the note and in the Update;
strangers get 404 for followers-only and direct posts; a located post is
unreachable by id for anyone else on every route; statuses?id[];
Idempotency-Key; scopes; deleting a reblog.
- Timelines: home paging with max_id, since_id and min_id; public local and
remote; tag (anonymous); list stub; favourites; conversations and read;
markers; notifications with types[], exclude_types[], account_id, paging,
get, dismiss, clear and unread_count.
- Instance: v1 and v2 (4.2.0 (compatible; PrivaPub)), peers, activity, rules,
extended_description, apps and every stub route.
- Media: v1 and v2 uploads, owner-only GET and PUT, 422 for unsupported or
unreadable files, video and audio made with ffmpeg lavfi sources and checked
with ffprobe; every remote media address goes through the proxy; the proxy
refuses unsigned URLs, streams ranges as 206 without caching, caches whole
downloads and serves them with ranges, and streams anything over
Media:MaxProxiedBytes (a SmallProxyHost) without caching.
- Provenance of local, delivered (signature) and fetched (instance actor,
no signature, the trigger as activity) posts, visibility of provenance,
instance descriptions; reading any of them makes no outbound request.
Pleroma reactions with EmojiReact and Undo deliveries, and local reaction
notifications.
Bugs fixed:
- remove_from_followers deleted the Follower row but never told a remote
follower. It now sends Reject{Follow} with the stored Follow id, through
RelationshipService.RemoveFollower, which Block now shares.
- VisibilityPolicy.CanSee refused followers-only posts to accepted followers,
so a post in their home timeline answered 404 to GET, context, favourite and
reply. Followers of the author (local or remote) may now see them.
- Account statuses of a remote account hid followers-only posts from
personas that follow it.
- exclude_replies dropped the author's own threads; like Mastodon it now
drops only replies to other accounts.
- A community account's statuses were always empty: they are now the posts
addressed to the community.
- Pinning someone else's visible post answered 404; it answers 422 like
Mastodon.
- GET /api/v1/notifications/:id answered 200 with null when the notification's
post was gone; it answers 404.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
31d614efd4
commit
fc5bb9511f
12 files changed
+2415
-18
No files matched your search
@@ -0,0 +1,628 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
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.Net.Http.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class MastodonAccountsTests : 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;
|
||||||
|
|
||||||
|
async Task<(RemoteActor Actor, string Id)> Remote(string name = "bob")
|
||||||
|
{
|
||||||
|
var actor = new RemoteActor(_peer, name);
|
||||||
|
var known = await _host.Known(actor);
|
||||||
|
return (actor, known.ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Verify_credentials_shows_the_persona_and_its_source_and_nothing_of_the_login()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
var me = (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(alice.Id, me.Body.Text("id"));
|
||||||
|
Assert.Equal(alice.UserName, me.Body.Text("username"));
|
||||||
|
Assert.Equal(alice.UserName, me.Body.Text("acct"));
|
||||||
|
Assert.Equal(alice.Uri, me.Body.Text("uri"));
|
||||||
|
Assert.Equal("public", me.Body["source"].Text("privacy"));
|
||||||
|
Assert.Equal("public", me.Body["source"].Text("quote_policy"));
|
||||||
|
Assert.Equal(0, me.Body["source"].Number("follow_requests_count"));
|
||||||
|
Assert.DoesNotContain(alice.Persona.Root.Id, me.Text);
|
||||||
|
Assert.DoesNotContain(alice.Persona.Root.UserName, me.Text);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/accounts/verify_credentials")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_credentials_takes_fields_in_both_shapes_the_source_and_the_quote_policy()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
var updated = (await alice.Client.Patch("/api/v1/accounts/update_credentials",
|
||||||
|
("display_name", " Alice Liddell "), ("note", "down the hole"), ("locked", "true"), ("bot", "true"),
|
||||||
|
("fields_attributes[0][name]", "Site"), ("fields_attributes[0][value]", "https://example.com"),
|
||||||
|
("fields_attributes[1][name]", "Pronouns"), ("fields_attributes[1][value]", "she/her"),
|
||||||
|
("source[privacy]", "private"), ("source[sensitive]", "true"), ("source[language]", "it"), ("source[quote_policy]", "followers"))).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("Alice Liddell", updated.Body.Text("display_name"));
|
||||||
|
Assert.True(updated.Body.Flag("locked"));
|
||||||
|
Assert.True(updated.Body.Flag("bot"));
|
||||||
|
Assert.Equal(new[] { "Site", "Pronouns" }, updated.Body["fields"]!.AsArray().Select(f => f.Text("name")));
|
||||||
|
Assert.Equal("down the hole", updated.Body["source"].Text("note"));
|
||||||
|
Assert.Equal("private", updated.Body["source"].Text("privacy"));
|
||||||
|
Assert.True(updated.Body["source"].Flag("sensitive"));
|
||||||
|
Assert.Equal("it", updated.Body["source"].Text("language"));
|
||||||
|
Assert.Equal("followers", updated.Body["source"].Text("quote_policy"));
|
||||||
|
|
||||||
|
var arrayShape = (await alice.Client.Json(HttpMethod.Patch, "/api/v1/accounts/update_credentials", new JsonObject
|
||||||
|
{
|
||||||
|
["fields_attributes"] = new JsonArray(new JsonObject { ["name"] = "Garden", ["value"] = "roses" }, new JsonObject { ["name"] = "Cat", ["value"] = "Dinah" }),
|
||||||
|
["source"] = new JsonObject { ["quote_policy"] = "everyone" }
|
||||||
|
})).Ok();
|
||||||
|
Assert.Equal(new[] { ("Garden", "roses"), ("Cat", "Dinah") },
|
||||||
|
arrayShape.Body["source"]!["fields"]!.AsArray().Select(f => (f.Text("name"), f.Text("value"))));
|
||||||
|
Assert.Equal("followers", arrayShape.Body["source"].Text("quote_policy"));
|
||||||
|
|
||||||
|
var formArray = (await alice.Client.Patch("/api/v1/accounts/update_credentials",
|
||||||
|
("fields_attributes[][name]", "One"), ("fields_attributes[][value]", "1"), ("fields_attributes[][name]", "Two"), ("fields_attributes[][value]", "2"))).Ok();
|
||||||
|
Assert.Equal(new[] { ("One", "1"), ("Two", "2") }, formArray.Body["source"]!["fields"]!.AsArray().Select(f => (f.Text("name"), f.Text("value"))));
|
||||||
|
|
||||||
|
var again = (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Ok();
|
||||||
|
Assert.Equal(2, again.Body["fields"]!.AsArray().Count);
|
||||||
|
Assert.Equal("private", again.Body["source"].Text("privacy"));
|
||||||
|
var status = await alice.Status("with the default visibility");
|
||||||
|
Assert.Equal("private", status.Text("visibility"));
|
||||||
|
Assert.True(status.Flag("sensitive"));
|
||||||
|
Assert.Equal("it", status.Text("language"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Avatar_and_header_uploads_are_resized_and_lose_their_metadata()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var picture = MastodonHelpers.JpegWithMetadata(900, 700);
|
||||||
|
using (var original = NetVips.Image.NewFromBuffer(picture))
|
||||||
|
Assert.Contains("exif-data", original.GetFields());
|
||||||
|
var form = MastodonHelpers.Multipart(("avatar", picture, "image/jpeg", "me.jpg"), ("header", MastodonHelpers.JpegWithMetadata(2000, 900), "image/jpeg", "header.jpg"));
|
||||||
|
form.Add(new StringContent("Alice"), "display_name");
|
||||||
|
|
||||||
|
var updated = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/update_credentials") { Content = form })).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("Alice", updated.Body.Text("display_name"));
|
||||||
|
foreach (var (field, maxWidth, maxHeight) in new[] { ("avatar", 400, 400), ("header", 1500, 500) })
|
||||||
|
{
|
||||||
|
var url = updated.Body.Text(field);
|
||||||
|
Assert.StartsWith($"{PrivaPubHost.Base}/media/files/", url);
|
||||||
|
var bytes = await _host.Client().GetByteArrayAsync(url, Token);
|
||||||
|
MastodonHelpers.AssertNoMetadata(bytes);
|
||||||
|
using var stored = NetVips.Image.NewFromBuffer(bytes);
|
||||||
|
Assert.True(stored.Width <= maxWidth && stored.Height <= maxHeight, $"{field} is {stored.Width}x{stored.Height}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Lookup_finds_local_and_remote_accounts_but_never_a_circle_or_the_instance_actor()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var (bob, bobId) = await Remote();
|
||||||
|
var circleName = $"circle{Guid.NewGuid():N}"[..20];
|
||||||
|
string circleId;
|
||||||
|
using (var clientApi = _host.As(alice.Persona.Root.Jwt))
|
||||||
|
{
|
||||||
|
var circle = await clientApi.PostAsJsonAsync("/clientapi/group/insert", new { avatarId = alice.Id, userName = circleName, name = "inner", isCommunity = false }, Token);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, circle.StatusCode);
|
||||||
|
circleId = (await circle.Content.ReadFromJsonAsync<JsonObject>(Token))!["id"]!.GetValue<string>();
|
||||||
|
}
|
||||||
|
var anonymous = _host.Client();
|
||||||
|
|
||||||
|
Assert.Equal(alice.Id, (await anonymous.Get($"/api/v1/accounts/lookup?acct={alice.UserName}")).Ok().Body.Text("id"));
|
||||||
|
Assert.Equal(alice.Id, (await anonymous.Get($"/api/v1/accounts/lookup?acct=@{alice.UserName}@{PrivaPubHost.Host}")).Ok().Body.Text("id"));
|
||||||
|
var remote = (await anonymous.Get($"/api/v1/accounts/lookup?acct={Uri.EscapeDataString(bob.Handle())}")).Ok();
|
||||||
|
Assert.Equal(bobId, remote.Body.Text("id"));
|
||||||
|
Assert.Equal(bob.Handle(), remote.Body.Text("acct"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/lookup?acct={circleName}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=privapub")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/accounts/lookup?acct=nobody{Guid.NewGuid():N}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/lookup?acct=a@b@c")).Status);
|
||||||
|
|
||||||
|
Assert.Equal(alice.UserName, (await anonymous.Get($"/api/v1/accounts/{alice.Id}")).Ok().Body.Text("username"));
|
||||||
|
Assert.Equal(bob.Handle(), (await anonymous.Get($"/api/v1/accounts/{bobId}")).Ok().Body.Text("acct"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get("/api/v1/accounts/000000000000000000000000")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/{circleId}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/accounts/{circleId}/statuses")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/accounts/{circleId}/follow")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Search_resolves_a_remote_handle_only_when_asked_by_a_signed_in_persona()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
_peer.WebFinger(bob);
|
||||||
|
var handle = Uri.EscapeDataString(bob.Handle());
|
||||||
|
|
||||||
|
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/search?q={handle}")).Ok().Array);
|
||||||
|
Assert.Empty((await alice.Client.Get($"/api/v2/search?q={handle}")).Ok().Body["accounts"]!.AsArray());
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get($"/api/v1/accounts/search?q={handle}&resolve=true")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get($"/api/v2/search?q={handle}&resolve=true")).Status);
|
||||||
|
Assert.Empty(_peer.Requests);
|
||||||
|
|
||||||
|
var resolved = (await alice.Client.Get($"/api/v1/accounts/search?q={handle}&resolve=true")).Ok();
|
||||||
|
|
||||||
|
var found = Assert.Single(resolved.Array);
|
||||||
|
Assert.Equal(bob.Handle(), found.Text("acct"));
|
||||||
|
Assert.Contains(_peer.Requests, r => r.Path == "/.well-known/webfinger");
|
||||||
|
Assert.Equal(found.Text("id"), Assert.Single((await alice.Client.Get($"/api/v2/search?q={handle}&type=accounts")).Ok().Body["accounts"]!.AsArray()).Text("id"));
|
||||||
|
Assert.Contains(alice.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var tag = $"tag{Guid.NewGuid():N}"[..16];
|
||||||
|
var tagged = await alice.Status($"first #{tag}");
|
||||||
|
var thread = await alice.Status("and a thread", ("in_reply_to_id", tagged.Text("id")));
|
||||||
|
var upload = (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media")
|
||||||
|
{
|
||||||
|
Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(64, 48), "image/jpeg", "a.jpg"))
|
||||||
|
})).Ok();
|
||||||
|
var withMedia = await alice.Status("a picture", ("media_ids[]", upload.Body.Text("id")));
|
||||||
|
var carolPost = await carol.Status("boost me");
|
||||||
|
var answer = await alice.Status("an answer", ("in_reply_to_id", carolPost.Text("id")));
|
||||||
|
var reblog = (await alice.Client.Post($"/api/v1/statuses/{carolPost.Text("id")}/reblog")).Ok().Body;
|
||||||
|
var pinned = await alice.Status("pinned");
|
||||||
|
(await alice.Client.Post($"/api/v1/statuses/{pinned.Text("id")}/pin")).Ok();
|
||||||
|
var path = $"/api/v1/accounts/{alice.Id}/statuses";
|
||||||
|
string[] Ids(params JsonObject[] statuses) => statuses.Select(s => s.Text("id")).ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(Ids(pinned), (await carol.Client.Get(path + "?pinned=true")).Ok().Ids);
|
||||||
|
Assert.True((await carol.Client.Get(path + "?pinned=true")).Ok().Array[0].Flag("pinned"));
|
||||||
|
Assert.Equal(Ids(pinned, reblog.AsObject(), answer, withMedia, thread, tagged), (await carol.Client.Get(path)).Ok().Ids);
|
||||||
|
Assert.Equal(Ids(pinned, reblog.AsObject(), withMedia, thread, tagged), (await carol.Client.Get(path + "?exclude_replies=true")).Ok().Ids);
|
||||||
|
Assert.DoesNotContain(reblog.Text("id"), (await carol.Client.Get(path + "?exclude_reblogs=true")).Ok().Ids);
|
||||||
|
Assert.Equal(Ids(withMedia), (await carol.Client.Get(path + "?only_media=true")).Ok().Ids);
|
||||||
|
Assert.Equal(Ids(tagged), (await _host.Client().Get(path + $"?tagged={tag.ToUpperInvariant()}")).Ok().Ids);
|
||||||
|
|
||||||
|
var first = (await carol.Client.Get(path + "?limit=2")).Ok();
|
||||||
|
Assert.Equal(Ids(pinned, reblog.AsObject()), first.Ids);
|
||||||
|
var next = (await carol.Client.Get(first.Link("next"))).Ok();
|
||||||
|
Assert.Equal(Ids(answer, withMedia), next.Ids);
|
||||||
|
Assert.Contains("limit=2", next.Link("prev"));
|
||||||
|
Assert.Equal(first.Ids, (await carol.Client.Get(next.Link("prev"))).Ok().Ids);
|
||||||
|
var last = (await carol.Client.Get(next.Link("next"))).Ok();
|
||||||
|
Assert.Equal(Ids(thread, tagged), last.Ids);
|
||||||
|
Assert.Empty((await carol.Client.Get(last.Link("next"))).Ok().Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_community_account_lists_the_posts_addressed_to_it()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var reader = await _host.Mastodon("reader");
|
||||||
|
var name = $"community{Guid.NewGuid():N}"[..20];
|
||||||
|
string communityId;
|
||||||
|
using (var clientApi = _host.As(alice.Persona.Root.Jwt))
|
||||||
|
{
|
||||||
|
var created = await clientApi.PostAsJsonAsync("/clientapi/group/insert", new { avatarId = alice.Id, userName = name, name = "Gardeners", isCommunity = true }, Token);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, created.StatusCode);
|
||||||
|
communityId = (await created.Content.ReadFromJsonAsync<JsonObject>(Token))!["id"]!.GetValue<string>();
|
||||||
|
}
|
||||||
|
var addressed = await alice.Status($"@{name} first meeting on Sunday");
|
||||||
|
await alice.Status("not for the garden");
|
||||||
|
|
||||||
|
var community = (await reader.Client.Get($"/api/v1/accounts/lookup?acct={name}")).Ok();
|
||||||
|
Assert.Equal(communityId, community.Body.Text("id"));
|
||||||
|
Assert.True(community.Body.Flag("group"));
|
||||||
|
Assert.Equal(new[] { addressed.Text("id") }, (await reader.Client.Get($"/api/v1/accounts/{communityId}/statuses")).Ok().Ids);
|
||||||
|
Assert.Equal(1, (await _host.Client().Get($"/api/v1/accounts/{communityId}")).Ok().Body.Number("statuses_count"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_follower_sees_followers_only_posts_and_a_stranger_does_not()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var follower = await _host.Mastodon("follower");
|
||||||
|
var stranger = await _host.Mastodon("stranger");
|
||||||
|
(await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
var open = await alice.Status("for everyone");
|
||||||
|
var quiet = await alice.Status("for followers", ("visibility", "private"));
|
||||||
|
var direct = await alice.Status($"@{follower.UserName} for you", ("visibility", "direct"));
|
||||||
|
var path = $"/api/v1/accounts/{alice.Id}/statuses";
|
||||||
|
|
||||||
|
Assert.Equal(new[] { quiet.Text("id"), open.Text("id") }, (await follower.Client.Get(path)).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { open.Text("id") }, (await stranger.Client.Get(path)).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { open.Text("id") }, (await _host.Client().Get(path)).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { direct.Text("id"), quiet.Text("id"), open.Text("id") }, (await alice.Client.Get(path)).Ok().Ids);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{quiet.Text("id")}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await follower.Client.Get($"/api/v1/statuses/{quiet.Text("id")}")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Followers_and_following_are_listed_only_to_their_owner()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = await _host.Mastodon("bob");
|
||||||
|
(await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { bob.Id }, (await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Ids);
|
||||||
|
Assert.Empty((await bob.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array);
|
||||||
|
Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array);
|
||||||
|
Assert.Equal(new[] { alice.Id }, (await bob.Client.Get($"/api/v1/accounts/{bob.Id}/following")).Ok().Ids);
|
||||||
|
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{bob.Id}/following")).Ok().Array);
|
||||||
|
var account = (await bob.Client.Get($"/api/v1/accounts/{alice.Id}")).Ok();
|
||||||
|
Assert.Equal(1, account.Body.Number("followers_count"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Following_is_immediate_for_an_open_persona_requested_for_a_locked_one_and_queued_for_a_remote_one()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var locked = await _host.Mastodon("locked");
|
||||||
|
var follower = await _host.Mastodon("follower");
|
||||||
|
(await locked.Client.Patch("/api/v1/accounts/update_credentials", ("locked", "true"))).Ok();
|
||||||
|
var (bob, bobId) = await Remote();
|
||||||
|
|
||||||
|
var open = (await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
Assert.True(open.Body.Flag("following"));
|
||||||
|
Assert.False(open.Body.Flag("requested"));
|
||||||
|
Assert.True(open.Body.Flag("showing_reblogs"));
|
||||||
|
var notified = (await alice.Client.Get("/api/v1/notifications?types[]=follow")).Ok();
|
||||||
|
Assert.Equal(follower.Id, Assert.Single(notified.Array)!["account"].Text("id"));
|
||||||
|
|
||||||
|
var asked = (await follower.Client.Post($"/api/v1/accounts/{locked.Id}/follow", ("reblogs", "false"))).Ok();
|
||||||
|
Assert.False(asked.Body.Flag("following"));
|
||||||
|
Assert.True(asked.Body.Flag("requested"));
|
||||||
|
Assert.Equal(new[] { follower.Id }, (await locked.Client.Get("/api/v1/follow_requests")).Ok().Ids);
|
||||||
|
Assert.Equal(1, (await locked.Client.Get("/api/v1/accounts/verify_credentials")).Ok().Body["source"].Number("follow_requests_count"));
|
||||||
|
Assert.True((await locked.Client.Post($"/api/v1/follow_requests/{follower.Id}/authorize")).Ok().Body.Flag("followed_by"));
|
||||||
|
var accepted = Assert.Single((await follower.Client.Get($"/api/v1/accounts/relationships?id[]={locked.Id}")).Ok().Array);
|
||||||
|
Assert.True(accepted.Flag("following"));
|
||||||
|
Assert.False(accepted.Flag("showing_reblogs"));
|
||||||
|
Assert.Empty((await locked.Client.Get("/api/v1/follow_requests")).Ok().Array);
|
||||||
|
|
||||||
|
var remote = (await follower.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok();
|
||||||
|
Assert.True(remote.Body.Flag("requested"));
|
||||||
|
var follow = Assert.Single(await bob.Delivered(since), d => d.Type() == "Follow");
|
||||||
|
Assert.Equal(follower.Uri, follow.Text("actor"));
|
||||||
|
Assert.Equal(bob.Id, follow.Text("object"));
|
||||||
|
|
||||||
|
Assert.False((await follower.Client.Post($"/api/v1/accounts/{alice.Id}/unfollow")).Ok().Body.Flag("following"));
|
||||||
|
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array);
|
||||||
|
Assert.False((await follower.Client.Post($"/api/v1/accounts/{bobId}/unfollow")).Ok().Body.Flag("requested"));
|
||||||
|
var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo");
|
||||||
|
Assert.Equal(follow.Text("id"), undo["object"].Text("id"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await follower.Client.Post("/api/v1/accounts/000000000000000000000000/follow")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await follower.Client.Post($"/api/v1/accounts/{follower.Id}/follow")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Remote_follow_requests_are_answered_with_the_original_follow()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var locked = await _host.Mastodon("locked");
|
||||||
|
(await locked.Client.Patch("/api/v1/accounts/update_credentials", ("locked", "true"))).Ok();
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var carol = new RemoteActor(_peer, "carol");
|
||||||
|
var bobFollow = await _host.FollowedBy(locked, bob);
|
||||||
|
var carolFollow = await _host.FollowedBy(locked, carol);
|
||||||
|
var bobId = (await _host.Known(bob)).ID;
|
||||||
|
var carolId = (await _host.Known(carol)).ID;
|
||||||
|
|
||||||
|
var requests = (await locked.Client.Get("/api/v1/follow_requests")).Ok();
|
||||||
|
Assert.Equal(new[] { carolId, bobId }, requests.Ids);
|
||||||
|
Assert.True(Assert.Single((await locked.Client.Get($"/api/v1/accounts/relationships?id[]={bobId}")).Ok().Array).Flag("requested_by"));
|
||||||
|
Assert.Empty(await bob.Delivered(since));
|
||||||
|
|
||||||
|
Assert.True((await locked.Client.Post($"/api/v1/follow_requests/{bobId}/authorize")).Ok().Body.Flag("followed_by"));
|
||||||
|
Assert.False((await locked.Client.Post($"/api/v1/follow_requests/{carolId}/reject")).Ok().Body.Flag("requested_by"));
|
||||||
|
|
||||||
|
var accept = Assert.Single(await bob.Delivered(since));
|
||||||
|
Assert.Equal("Accept", accept.Type());
|
||||||
|
Assert.Equal(locked.Uri, accept.Text("actor"));
|
||||||
|
Assert.Equal(bobFollow, accept["object"].Text("id"));
|
||||||
|
var reject = Assert.Single(await carol.Delivered(since));
|
||||||
|
Assert.Equal("Reject", reject.Type());
|
||||||
|
Assert.Equal(carolFollow, reject["object"].Text("id"));
|
||||||
|
Assert.Equal(carol.Id, reject["object"].Text("actor"));
|
||||||
|
Assert.Empty((await locked.Client.Get("/api/v1/follow_requests")).Ok().Array);
|
||||||
|
Assert.Equal(new[] { bobId }, (await locked.Client.Get($"/api/v1/accounts/{locked.Id}/followers")).Ok().Ids);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await locked.Client.Post($"/api/v1/follow_requests/{carolId}/authorize")).Status);
|
||||||
|
|
||||||
|
var local = await _host.Mastodon("local");
|
||||||
|
(await local.Client.Post($"/api/v1/accounts/{locked.Id}/follow")).Ok();
|
||||||
|
(await locked.Client.Post($"/api/v1/follow_requests/{local.Id}/reject")).Ok();
|
||||||
|
var refused = Assert.Single((await local.Client.Get($"/api/v1/accounts/relationships?id[]={locked.Id}")).Ok().Array);
|
||||||
|
Assert.False(refused.Flag("requested"));
|
||||||
|
Assert.False(refused.Flag("following"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Removing_a_follower_ends_the_follow_on_both_sides_and_tells_a_remote_follower()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var local = await _host.Mastodon("local");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var follow = await _host.FollowedBy(alice, bob);
|
||||||
|
var bobId = (await _host.Known(bob)).ID;
|
||||||
|
(await local.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
Assert.True(Assert.Single((await alice.Client.Get($"/api/v1/accounts/relationships?id[]={bobId}")).Ok().Array).Flag("followed_by"));
|
||||||
|
|
||||||
|
var removed = (await alice.Client.Post($"/api/v1/accounts/{bobId}/remove_from_followers")).Ok();
|
||||||
|
|
||||||
|
Assert.False(removed.Body.Flag("followed_by"));
|
||||||
|
var reject = Assert.Single(await bob.Delivered(since), d => d.Type() == "Reject");
|
||||||
|
Assert.Equal(alice.Uri, reject.Text("actor"));
|
||||||
|
Assert.Equal(follow, reject["object"].Text("id"));
|
||||||
|
Assert.Equal("Follow", reject["object"].Text("type"));
|
||||||
|
Assert.Equal(bob.Id, reject["object"].Text("actor"));
|
||||||
|
Assert.Equal(alice.Uri, reject["object"].Text("object"));
|
||||||
|
|
||||||
|
Assert.False((await alice.Client.Post($"/api/v1/accounts/{local.Id}/remove_from_followers")).Ok().Body.Flag("followed_by"));
|
||||||
|
Assert.False(Assert.Single((await local.Client.Get($"/api/v1/accounts/relationships?id[]={alice.Id}")).Ok().Array).Flag("following"));
|
||||||
|
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array);
|
||||||
|
Assert.Empty((await local.Client.Get($"/api/v1/accounts/{local.Id}/following")).Ok().Array);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/accounts/000000000000000000000000/remove_from_followers")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Blocks_federate_and_reject_a_remote_follower()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var local = await _host.Mastodon("local");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var follow = await _host.FollowedBy(alice, bob);
|
||||||
|
var bobId = (await _host.Known(bob)).ID;
|
||||||
|
(await local.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
|
||||||
|
var blocked = (await alice.Client.Post($"/api/v1/accounts/{bobId}/block")).Ok();
|
||||||
|
|
||||||
|
Assert.True(blocked.Body.Flag("blocking"));
|
||||||
|
Assert.False(blocked.Body.Flag("followed_by"));
|
||||||
|
var sent = await bob.Delivered(since);
|
||||||
|
var reject = Assert.Single(sent, d => d.Type() == "Reject");
|
||||||
|
Assert.Equal((follow, "Follow", bob.Id), (reject["object"].Text("id"), reject["object"].Text("type"), reject["object"].Text("actor")));
|
||||||
|
var block = Assert.Single(sent, d => d.Type() == "Block");
|
||||||
|
Assert.Equal((alice.Uri, bob.Id), (block.Text("actor"), block.Text("object")));
|
||||||
|
Assert.True((await alice.Client.Post($"/api/v1/accounts/{local.Id}/block")).Ok().Body.Flag("blocking"));
|
||||||
|
Assert.Equal(new[] { local.Id, bobId }, (await alice.Client.Get("/api/v1/blocks")).Ok().Ids);
|
||||||
|
var blockedBy = Assert.Single((await local.Client.Get($"/api/v1/accounts/relationships?id[]={alice.Id}")).Ok().Array);
|
||||||
|
Assert.True(blockedBy.Flag("blocked_by"));
|
||||||
|
Assert.False(blockedBy.Flag("following"));
|
||||||
|
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/followers")).Ok().Array);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/accounts/{alice.Id}/block")).Status);
|
||||||
|
|
||||||
|
Assert.False((await alice.Client.Post($"/api/v1/accounts/{bobId}/unblock")).Ok().Body.Flag("blocking"));
|
||||||
|
var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo");
|
||||||
|
Assert.Equal(block.Text("id"), undo["object"].Text("id"));
|
||||||
|
(await alice.Client.Post($"/api/v1/accounts/{local.Id}/unblock")).Ok();
|
||||||
|
Assert.Empty((await alice.Client.Get("/api/v1/blocks")).Ok().Array);
|
||||||
|
Assert.Equal(new[] { "Accept", "Block", "Reject", "Undo" }, (await bob.Delivered(since)).Select(d => d.Type()).Order());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mutes_keep_their_duration_and_notification_choice_and_never_federate()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var chatty = await _host.Mastodon("chatty");
|
||||||
|
var muffled = await _host.Mastodon("muffled");
|
||||||
|
var (bob, bobId) = await Remote();
|
||||||
|
(await alice.Client.Post($"/api/v1/accounts/{chatty.Id}/follow")).Ok();
|
||||||
|
var post = (await alice.Status("like me")).Text("id");
|
||||||
|
|
||||||
|
var muted = (await alice.Client.Post($"/api/v1/accounts/{chatty.Id}/mute", ("duration", "3600"), ("notifications", "false"))).Ok();
|
||||||
|
Assert.True(muted.Body.Flag("muting"));
|
||||||
|
Assert.False(muted.Body.Flag("muting_notifications"));
|
||||||
|
var mute = await DB.Default.Find<Mute>().Match(m => m.AvatarId == alice.Id && m.TargetAccountId == chatty.Id).ExecuteFirstAsync(Token);
|
||||||
|
Assert.InRange(mute.ExpiresAt!.Value, DateTime.UtcNow.AddMinutes(59), DateTime.UtcNow.AddMinutes(61));
|
||||||
|
var forever = (await alice.Client.Post($"/api/v1/accounts/{muffled.Id}/mute")).Ok();
|
||||||
|
Assert.True(forever.Body.Flag("muting_notifications"));
|
||||||
|
Assert.Null((await DB.Default.Find<Mute>().Match(m => m.AvatarId == alice.Id && m.TargetAccountId == muffled.Id).ExecuteFirstAsync(Token)).ExpiresAt);
|
||||||
|
(await alice.Client.Post($"/api/v1/accounts/{bobId}/mute")).Ok();
|
||||||
|
Assert.Equal(new[] { bobId, muffled.Id, chatty.Id }, (await alice.Client.Get("/api/v1/mutes")).Ok().Ids);
|
||||||
|
|
||||||
|
var hidden = (await chatty.Status("you will not see this")).Text("id");
|
||||||
|
(await chatty.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
|
||||||
|
(await muffled.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
|
||||||
|
Assert.DoesNotContain(hidden, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { chatty.Id }, (await alice.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array.Select(n => n!["account"].Text("id")));
|
||||||
|
Assert.Empty(await bob.Delivered(since));
|
||||||
|
|
||||||
|
Assert.False((await alice.Client.Post($"/api/v1/accounts/{chatty.Id}/unmute")).Ok().Body.Flag("muting"));
|
||||||
|
(await alice.Client.Post($"/api/v1/accounts/{muffled.Id}/unmute")).Ok();
|
||||||
|
(await alice.Client.Post($"/api/v1/accounts/{bobId}/unmute")).Ok();
|
||||||
|
Assert.Empty((await alice.Client.Get("/api/v1/mutes")).Ok().Array);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/accounts/{alice.Id}/mute")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Domain_blocks_are_listed_added_and_removed()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var domain = $"blocked{Guid.NewGuid():N}.example";
|
||||||
|
|
||||||
|
Assert.Equal("{}", (await alice.Client.Post("/api/v1/domain_blocks", ("domain", domain.ToUpperInvariant()))).Ok().Text);
|
||||||
|
(await alice.Client.Post("/api/v1/domain_blocks", ("domain", "not a domain"))).Ok();
|
||||||
|
Assert.Equal(new[] { domain }, (await alice.Client.Get("/api/v1/domain_blocks")).Ok().Array.Select(d => d!.GetValue<string>()));
|
||||||
|
|
||||||
|
(await alice.Client.Delete("/api/v1/domain_blocks", ("domain", domain))).Ok();
|
||||||
|
Assert.Empty((await alice.Client.Get("/api/v1/domain_blocks")).Ok().Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Relationships_answer_every_asked_id_even_a_junk_one()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = await _host.Mastodon("bob");
|
||||||
|
|
||||||
|
var answer = (await alice.Client.Get($"/api/v1/accounts/relationships?id[]=junk&id[]={bob.Id}&id[]=junk")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "junk", bob.Id }, answer.Ids);
|
||||||
|
Assert.All(answer.Array, r => Assert.False(r.Flag("following") || r.Flag("blocking") || r.Flag("muting") || r.Flag("followed_by")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_remote_authors_followers_only_posts_show_only_to_the_personas_that_follow_them()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var (bob, bobId) = await Remote();
|
||||||
|
(await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok();
|
||||||
|
var follow = Assert.Single(await bob.Delivered(since), d => d.Type() == "Follow");
|
||||||
|
await _host.Deliver(bob, alice.Mouth, new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = $"{bob.Origin()}/accepts/{Guid.NewGuid():N}",
|
||||||
|
["type"] = "Accept",
|
||||||
|
["actor"] = bob.Id,
|
||||||
|
["object"] = follow.DeepClone()
|
||||||
|
});
|
||||||
|
Assert.True(Assert.Single((await alice.Client.Get($"/api/v1/accounts/relationships?id[]={bobId}")).Ok().Array).Flag("following"));
|
||||||
|
var create = bob.Create("<p>for my followers</p>", new[] { bob.Id + "/followers" });
|
||||||
|
await _host.Deliver(bob, alice.Mouth, create);
|
||||||
|
var noteId = create["object"].Text("id");
|
||||||
|
var stored = await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(Token);
|
||||||
|
Assert.Equal(PostVisibility.FollowersOnly, stored.Visibility);
|
||||||
|
var path = $"/api/v1/accounts/{bobId}/statuses";
|
||||||
|
|
||||||
|
Assert.Equal(new[] { stored.ID }, (await alice.Client.Get(path)).Ok().Ids);
|
||||||
|
Assert.Empty((await carol.Client.Get(path)).Ok().Array);
|
||||||
|
Assert.Empty((await _host.Client().Get(path)).Ok().Array);
|
||||||
|
Assert.Equal("private", (await alice.Client.Get($"/api/v1/statuses/{stored.ID}")).Ok().Body.Text("visibility"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Get($"/api/v1/statuses/{stored.ID}")).Status);
|
||||||
|
Assert.Contains(stored.ID, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids);
|
||||||
|
Assert.True((await alice.Client.Post($"/api/v1/statuses/{stored.ID}/favourite")).Ok().Body.Flag("favourited"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Search_by_address_finds_local_posts_and_personas_but_never_a_circle_or_a_hidden_post()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var stranger = await _host.Mastodon("stranger");
|
||||||
|
var open = await alice.Status("findable");
|
||||||
|
var quiet = await alice.Status("not for strangers", ("visibility", "private"));
|
||||||
|
var circleName = $"circle{Guid.NewGuid():N}"[..20];
|
||||||
|
using (var clientApi = _host.As(alice.Persona.Root.Jwt))
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await clientApi.PostAsJsonAsync("/clientapi/group/insert",
|
||||||
|
new { avatarId = alice.Id, userName = circleName, name = "inner", isCommunity = false }, Token)).StatusCode);
|
||||||
|
|
||||||
|
var byPost = (await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(open.Text("uri"))}")).Ok();
|
||||||
|
Assert.Equal(open.Text("id"), Assert.Single(byPost.Body["statuses"]!.AsArray()).Text("id"));
|
||||||
|
Assert.Equal(open.Text("id"), Assert.Single((await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(open.Text("url"))}")).Ok().Body["statuses"]!.AsArray()).Text("id"));
|
||||||
|
var hidden = (await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(quiet.Text("uri"))}&resolve=true")).Ok();
|
||||||
|
Assert.Empty(hidden.Body["statuses"]!.AsArray());
|
||||||
|
Assert.Equal(alice.Id, Assert.Single((await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(alice.Uri)}")).Ok().Body["accounts"]!.AsArray()).Text("id"));
|
||||||
|
Assert.Empty((await stranger.Client.Get($"/api/v2/search?q={Uri.EscapeDataString($"{PrivaPubHost.Base}/peasants/{circleName}")}&resolve=true")).Ok().Body["accounts"]!.AsArray());
|
||||||
|
Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={circleName}")).Ok().Array);
|
||||||
|
var tag = (await stranger.Client.Get("/api/v2/search?q=%23Cats&type=hashtags")).Ok();
|
||||||
|
Assert.Equal("cats", Assert.Single(tag.Body["hashtags"]!.AsArray()).Text("name"));
|
||||||
|
Assert.Empty(tag.Body["accounts"]!.AsArray());
|
||||||
|
|
||||||
|
(await alice.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok();
|
||||||
|
Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Array);
|
||||||
|
Assert.Equal(alice.Id, (await stranger.Client.Get($"/api/v1/accounts/lookup?acct={alice.UserName}")).Ok().Body.Text("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_banned_login_loses_every_persona_token()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Status);
|
||||||
|
|
||||||
|
await DB.Default.Update<RootUser>().MatchID(alice.Persona.Root.Id).Modify(r => r.IsBanned, true).ExecuteAsync(Token);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await alice.Client.Get("/api/v1/accounts/verify_credentials")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await alice.Client.Post("/api/v1/statuses", ("status", "still here?"))).Status);
|
||||||
|
Assert.Equal(alice.Id, (await _host.Client().Get($"/api/v1/accounts/{alice.Id}")).Ok().Body.Text("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_forwarded_report_leaves_as_a_flag_from_the_instance_actor_never_from_the_reporter()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var local = await _host.Mastodon("local");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var post = await _host.PublicPostFrom(bob, alice, "<p>something nasty</p>");
|
||||||
|
var bobId = (await _host.Known(bob)).ID;
|
||||||
|
var unrelated = await alice.Status("mine");
|
||||||
|
|
||||||
|
var report = (await alice.Client.Post("/api/v1/reports", ("account_id", bobId), ("status_ids[]", post.ID), ("status_ids[]", unrelated.Text("id")),
|
||||||
|
("comment", "please look"), ("category", "spam"), ("forward", "true"))).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(bobId, report.Body["target_account"].Text("id"));
|
||||||
|
Assert.Equal(new[] { post.ID }, report.Body["status_ids"]!.AsArray().Select(i => i!.GetValue<string>()));
|
||||||
|
Assert.True(report.Body.Flag("forwarded"));
|
||||||
|
Assert.Equal("spam", report.Body.Text("category"));
|
||||||
|
var flag = Assert.Single(await bob.Delivered(since));
|
||||||
|
Assert.Equal("Flag", flag.Type());
|
||||||
|
Assert.Equal($"{PrivaPubHost.Base}/peasants/privapub", flag.Text("actor"));
|
||||||
|
Assert.Equal(new[] { bob.Id, post.ObjectURI }, flag["object"]!.AsArray().Select(o => o!.GetValue<string>()));
|
||||||
|
Assert.DoesNotContain(alice.UserName, flag.ToJsonString());
|
||||||
|
Assert.DoesNotContain(alice.Id, flag.ToJsonString());
|
||||||
|
|
||||||
|
var quiet = (await alice.Client.Post("/api/v1/reports", ("account_id", bobId), ("comment", "just noting"))).Ok();
|
||||||
|
Assert.False(quiet.Body.Flag("forwarded"));
|
||||||
|
Assert.False((await alice.Client.Post("/api/v1/reports", ("account_id", local.Id), ("forward", "true"))).Ok().Body.Flag("forwarded"));
|
||||||
|
Assert.Single(await bob.Delivered(since));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/reports", ("account_id", "000000000000000000000000"))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Statuses_count_still_counts_located_posts_pending_an_owner_decision()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
await alice.Status("visible");
|
||||||
|
await _host.Located(alice, "by the river");
|
||||||
|
|
||||||
|
var account = (await _host.Client().Get($"/api/v1/accounts/{alice.Id}")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(2, account.Body.Number("statuses_count"));
|
||||||
|
Assert.Single((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Account_stub_routes_answer_empty_lists()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
Assert.Empty((await _host.Client().Get($"/api/v1/accounts/{alice.Id}/featured_tags")).Ok().Array);
|
||||||
|
Assert.Empty((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/lists")).Ok().Array);
|
||||||
|
var familiar = (await alice.Client.Get($"/api/v1/accounts/familiar_followers?id[]={alice.Id}")).Ok();
|
||||||
|
Assert.Equal(alice.Id, Assert.Single(familiar.Array).Text("id"));
|
||||||
|
Assert.Empty(familiar.Array[0]!["accounts"]!.AsArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using PrivaPub.Api.Mastodon.Controllers;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class MastodonInstanceTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
PrivaPubHost _host;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||||
|
_host = await PrivaPubHost.Shared();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Both_instance_documents_advertise_mastodon_4_2_and_the_domain()
|
||||||
|
{
|
||||||
|
var anonymous = _host.Client();
|
||||||
|
|
||||||
|
var v1 = (await anonymous.Get("/api/v1/instance")).Ok();
|
||||||
|
Assert.Equal("4.2.0 (compatible; PrivaPub)", v1.Body.Text("version"));
|
||||||
|
Assert.Equal(InstanceController.Version, v1.Body.Text("version"));
|
||||||
|
Assert.Equal(PrivaPubHost.Host, v1.Body.Text("uri"));
|
||||||
|
Assert.False(v1.Body.Flag("registrations"));
|
||||||
|
Assert.True(v1.Body["stats"].Number("user_count") >= 0);
|
||||||
|
Assert.Equal(5000, v1.Body["configuration"]!["statuses"].Number("max_characters"));
|
||||||
|
Assert.Equal(4, v1.Body["configuration"]!["polls"].Number("max_options"));
|
||||||
|
Assert.Contains("image/jpeg", v1.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue<string>()));
|
||||||
|
Assert.Empty(v1.Body["rules"]!.AsArray());
|
||||||
|
Assert.Null(v1.Body["contact_account"]);
|
||||||
|
Assert.True(v1.Body.AsObject().ContainsKey("contact_account"));
|
||||||
|
|
||||||
|
var v2 = (await anonymous.Get("/api/v2/instance")).Ok();
|
||||||
|
Assert.Equal(InstanceController.Version, v2.Body.Text("version"));
|
||||||
|
Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain"));
|
||||||
|
Assert.Equal($"wss://{PrivaPubHost.Host}", v2.Body["configuration"]!["urls"].Text("streaming"));
|
||||||
|
Assert.False(v2.Body["registrations"].Flag("enabled"));
|
||||||
|
Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled"));
|
||||||
|
Assert.Equal(7, v2.Body["api_versions"].Number("mastodon"));
|
||||||
|
|
||||||
|
var signedIn = await _host.Mastodon("reader");
|
||||||
|
Assert.Equal(InstanceController.Version, (await signedIn.Client.Get("/api/v1/instance")).Ok().Body.Text("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_instance_side_routes_answer_without_a_login()
|
||||||
|
{
|
||||||
|
var anonymous = _host.Client();
|
||||||
|
|
||||||
|
Assert.Empty((await anonymous.Get("/api/v1/instance/peers")).Ok().Array);
|
||||||
|
Assert.Empty((await anonymous.Get("/api/v1/instance/activity")).Ok().Array);
|
||||||
|
Assert.Empty((await anonymous.Get("/api/v1/instance/rules")).Ok().Array);
|
||||||
|
var extended = (await anonymous.Get("/api/v1/instance/extended_description")).Ok();
|
||||||
|
Assert.StartsWith("<p>", extended.Body.Text("content"));
|
||||||
|
Assert.EndsWith("T00:00:00.000Z", extended.Body.Text("updated_at"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/v1/custom_emojis")]
|
||||||
|
[InlineData("/api/v1/announcements")]
|
||||||
|
[InlineData("/api/v1/trends")]
|
||||||
|
[InlineData("/api/v1/trends/tags")]
|
||||||
|
[InlineData("/api/v1/trends/statuses")]
|
||||||
|
[InlineData("/api/v1/trends/links")]
|
||||||
|
public async Task Public_stub_routes_answer_empty_lists_to_anyone(string path)
|
||||||
|
{
|
||||||
|
Assert.Empty((await _host.Client().Get(path)).Ok().Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/v1/filters")]
|
||||||
|
[InlineData("/api/v2/filters")]
|
||||||
|
[InlineData("/api/v1/lists")]
|
||||||
|
[InlineData("/api/v1/suggestions")]
|
||||||
|
[InlineData("/api/v2/suggestions")]
|
||||||
|
[InlineData("/api/v1/followed_tags")]
|
||||||
|
[InlineData("/api/v1/endorsements")]
|
||||||
|
[InlineData("/api/v1/featured_tags")]
|
||||||
|
[InlineData("/api/v1/scheduled_statuses")]
|
||||||
|
public async Task Signed_in_stub_routes_answer_empty_lists(string path)
|
||||||
|
{
|
||||||
|
var reader = await _host.Mastodon("reader");
|
||||||
|
|
||||||
|
Assert.Empty((await reader.Client.Get(path)).Ok().Array);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get(path)).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Apps_are_registered_validated_and_verified()
|
||||||
|
{
|
||||||
|
var anonymous = _host.Client();
|
||||||
|
|
||||||
|
var app = (await anonymous.Post("/api/v1/apps", ("client_name", "tusky-ish"), ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"), ("scopes", "read write"),
|
||||||
|
("website", "https://example.com"))).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("tusky-ish", app.Body.Text("name"));
|
||||||
|
Assert.Equal(new[] { "read", "write" }, app.Body["scopes"]!.AsArray().Select(s => s!.GetValue<string>()));
|
||||||
|
Assert.False(string.IsNullOrEmpty(app.Body.Text("client_id")));
|
||||||
|
Assert.False(string.IsNullOrEmpty(app.Body.Text("client_secret")));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("redirect_uris", "urn:ietf:wg:oauth:2.0:oob"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("client_name", "x"), ("redirect_uris", "javascript:alert(1)"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await anonymous.Post("/api/v1/apps", ("client_name", "x"))).Status);
|
||||||
|
|
||||||
|
var reader = await _host.Mastodon("reader");
|
||||||
|
var verified = (await reader.Client.Get("/api/v1/apps/verify_credentials")).Ok();
|
||||||
|
Assert.Equal("privapub-tests", verified.Body.Text("name"));
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.Get("/api/v1/apps/verify_credentials")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preferences_follow_the_persona_and_push_has_no_subscription()
|
||||||
|
{
|
||||||
|
var reader = await _host.Mastodon("reader", scopes: "read write follow push");
|
||||||
|
(await reader.Client.Patch("/api/v1/accounts/update_credentials", ("source[privacy]", "unlisted"), ("source[sensitive]", "true"))).Ok();
|
||||||
|
|
||||||
|
var preferences = (await reader.Client.Get("/api/v1/preferences")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("unlisted", preferences.Body.Text("posting:default:visibility"));
|
||||||
|
Assert.True(preferences.Body.Flag("posting:default:sensitive"));
|
||||||
|
Assert.False(preferences.Body.Flag("reading:expand:spoilers"));
|
||||||
|
var push = await reader.Client.Get("/api/v1/push/subscription");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, push.Status);
|
||||||
|
Assert.Equal("Record not found", push.Body.Text("error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
using PrivaPub.Domain.Media;
|
||||||
|
using PrivaPub.Tests.Support;
|
||||||
|
using PrivaPub.Tests.Support.Host;
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Http
|
||||||
|
{
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class MastodonMediaTests : 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 Task<ApiAnswer> Upload(Mastodon account, string path, byte[] bytes, string contentType, string fileName, params (string Key, string Value)[] fields)
|
||||||
|
{
|
||||||
|
var form = MastodonHelpers.Multipart(("file", bytes, contentType, fileName));
|
||||||
|
foreach (var (key, value) in fields)
|
||||||
|
form.Add(new StringContent(value), key);
|
||||||
|
return account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, path) { Content = form });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<(int ExitCode, string Output)> Run(string program, params string[] arguments)
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo(program) { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||||
|
foreach (var argument in arguments)
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(start)!;
|
||||||
|
var output = process.StandardOutput.ReadToEndAsync(Token);
|
||||||
|
var errors = process.StandardError.ReadToEndAsync(Token);
|
||||||
|
await process.WaitForExitAsync(Token);
|
||||||
|
return (process.ExitCode, await output + await errors);
|
||||||
|
}
|
||||||
|
catch (System.ComponentModel.Win32Exception)
|
||||||
|
{
|
||||||
|
return (-1, $"{program} is not installed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<byte[]> Made(string extension, params string[] arguments)
|
||||||
|
{
|
||||||
|
if ((await Run("ffmpeg", "-version")).ExitCode != 0 || (await Run("ffprobe", "-version")).ExitCode != 0)
|
||||||
|
Assert.Skip("ffmpeg and ffprobe are needed to make and inspect audio and video");
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"privapub-av-{Guid.NewGuid():N}.{extension}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (exitCode, output) = await Run("ffmpeg", arguments.Append(path).Prepend("-nostdin").Prepend("-y").ToArray());
|
||||||
|
Assert.True(exitCode == 0, output);
|
||||||
|
return await File.ReadAllBytesAsync(path, Token);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string> Probe(string url)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"privapub-probe-{Guid.NewGuid():N}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllBytesAsync(path, await _host.Client().GetByteArrayAsync(url, Token), Token);
|
||||||
|
var (exitCode, output) = await Run("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path);
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/v1/media")]
|
||||||
|
[InlineData("/api/v2/media")]
|
||||||
|
public async Task Images_upload_with_their_description_and_focus_and_without_their_metadata(string route)
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
var uploaded = (await Upload(alice, route, MastodonHelpers.JpegWithMetadata(640, 480), "image/jpeg", "holiday.jpg",
|
||||||
|
("description", "a blue square"), ("focus", "0.5,-0.25"))).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("image", uploaded.Body.Text("type"));
|
||||||
|
Assert.Equal("a blue square", uploaded.Body.Text("description"));
|
||||||
|
Assert.Equal(640, uploaded.Body["meta"]!["original"].Number("width"));
|
||||||
|
Assert.Equal(480, uploaded.Body["meta"]!["original"].Number("height"));
|
||||||
|
Assert.Equal(0.5, uploaded.Body["meta"]!["focus"]!["x"]!.GetValue<double>());
|
||||||
|
Assert.Equal(-0.25, uploaded.Body["meta"]!["focus"]!["y"]!.GetValue<double>());
|
||||||
|
Assert.False(string.IsNullOrEmpty(uploaded.Body.Text("blurhash")));
|
||||||
|
Assert.Null(uploaded.Body.Text("remote_url"));
|
||||||
|
foreach (var field in new[] { "url", "preview_url" })
|
||||||
|
{
|
||||||
|
Assert.StartsWith($"{PrivaPubHost.Base}/media/files/", uploaded.Body.Text(field));
|
||||||
|
MastodonHelpers.AssertNoMetadata(await _host.Client().GetByteArrayAsync(uploaded.Body.Text(field), Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Media_is_read_and_described_only_by_its_owner()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var mallory = await _host.Mastodon("mallory");
|
||||||
|
var id = (await Upload(alice, "/api/v2/media", MastodonHelpers.JpegWithMetadata(32, 32), "image/jpeg", "a.jpg")).Ok().Body.Text("id");
|
||||||
|
|
||||||
|
Assert.Equal(id, (await alice.Client.Get($"/api/v1/media/{id}")).Ok().Body.Text("id"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v1/media/{id}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Put($"/api/v1/media/{id}", ("description", "mine now"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get($"/api/v1/media/{id}")).Status);
|
||||||
|
|
||||||
|
var described = (await alice.Client.Put($"/api/v1/media/{id}", ("description", " a tiny square "), ("focus", "2,-3"))).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("a tiny square", described.Body.Text("description"));
|
||||||
|
Assert.Equal((1.0, -1.0), (described.Body["meta"]!["focus"]!["x"]!.GetValue<double>(), described.Body["meta"]!["focus"]!["y"]!.GetValue<double>()));
|
||||||
|
Assert.Equal("a tiny square", (await alice.Client.Get($"/api/v1/media/{id}")).Ok().Body.Text("description"));
|
||||||
|
Assert.Null((await alice.Client.Put($"/api/v1/media/{id}", ("description", ""))).Ok().Body.Text("description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unsupported_unreadable_and_missing_files_are_refused_with_422()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
var text = await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("hello"), "text/plain", "a.txt");
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, text.Status);
|
||||||
|
Assert.Contains("not supported", text.Body.Text("error"));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("not a jpeg"), "image/jpeg", "a.jpg")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Upload(alice, "/api/v2/media", Encoding.UTF8.GetBytes("not a video"), "video/mp4", "a.mp4")).Status);
|
||||||
|
var empty = new MultipartFormDataContent { { new StringContent("no file"), "description" } };
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v2/media") { Content = empty })).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/media")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Video_is_remuxed_without_its_metadata()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var video = await Made("mp4", "-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=10", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
||||||
|
"-metadata", "title=secret title", "-metadata", "comment=filmed at home", "-metadata:s:v:0", "handler_name=hidden handler",
|
||||||
|
"-c:v", "mpeg4", "-c:a", "aac", "-shortest");
|
||||||
|
Assert.Contains("secret title", Encoding.Latin1.GetString(video));
|
||||||
|
|
||||||
|
var uploaded = (await Upload(alice, "/api/v2/media", video, "video/mp4", "clip.mp4")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("video", uploaded.Body.Text("type"));
|
||||||
|
Assert.Equal(320, uploaded.Body["meta"]!["original"].Number("width"));
|
||||||
|
Assert.EndsWith(".mp4", uploaded.Body.Text("url"));
|
||||||
|
Assert.EndsWith(".jpg", uploaded.Body.Text("preview_url"));
|
||||||
|
var probe = await Probe(uploaded.Body.Text("url"));
|
||||||
|
Assert.Contains("\"codec_type\": \"video\"", probe);
|
||||||
|
Assert.DoesNotContain("secret title", probe);
|
||||||
|
Assert.DoesNotContain("filmed at home", probe);
|
||||||
|
Assert.DoesNotContain("hidden handler", probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Audio_is_remuxed_without_its_metadata()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var audio = await Made("m4a", "-f", "lavfi", "-i", "sine=frequency=330:duration=1", "-metadata", "title=secret song", "-metadata", "artist=Alice Smith",
|
||||||
|
"-c:a", "aac");
|
||||||
|
|
||||||
|
var uploaded = (await Upload(alice, "/api/v2/media", audio, "audio/mp4", "song.m4a")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal("audio", uploaded.Body.Text("type"));
|
||||||
|
var probe = await Probe(uploaded.Body.Text("url"));
|
||||||
|
Assert.Contains("\"codec_type\": \"audio\"", probe);
|
||||||
|
Assert.DoesNotContain("secret song", probe);
|
||||||
|
Assert.DoesNotContain("Alice Smith", probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] Bytes(int length)
|
||||||
|
{
|
||||||
|
var bytes = new byte[length];
|
||||||
|
Random.Shared.NextBytes(bytes);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
string Served(byte[] bytes, string contentType = "video/mp4")
|
||||||
|
{
|
||||||
|
var path = $"/media/{Guid.NewGuid():N}.bin";
|
||||||
|
_peer.ServeFile(path, bytes, contentType);
|
||||||
|
return _peer.A + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
static HttpRequestMessage Ranged(string url, long from, long to)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Range = new RangeHeaderValue(from, to);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Every_remote_media_address_the_api_returns_goes_through_the_proxy()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var document = bob.Document();
|
||||||
|
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = _peer.A + "/avatar.png" };
|
||||||
|
document["image"] = new JsonObject { ["type"] = "Image", ["url"] = _peer.A + "/header.png" };
|
||||||
|
_peer.Serve($"/users/{bob.Name}", document.ToJsonString());
|
||||||
|
var bobId = (await _host.Known(bob)).ID;
|
||||||
|
var post = await _host.PublicPostFrom(bob, alice, "<p>look :blob:</p>", note =>
|
||||||
|
{
|
||||||
|
note["attachment"] = new JsonArray(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "Document", ["mediaType"] = "image/png", ["url"] = _peer.A + "/picture.png", ["name"] = "a picture"
|
||||||
|
});
|
||||||
|
note["tag"]!.AsArray().Add(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "Emoji", ["name"] = ":blob:", ["icon"] = new JsonObject { ["type"] = "Image", ["url"] = _peer.A + "/blob.png" }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var proxied = $"{PrivaPubHost.Base}/media/proxy/";
|
||||||
|
|
||||||
|
var account = (await alice.Client.Get($"/api/v1/accounts/{bobId}")).Ok().Body;
|
||||||
|
var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body;
|
||||||
|
|
||||||
|
foreach (var field in new[] { "avatar", "avatar_static", "header", "header_static" })
|
||||||
|
Assert.StartsWith(proxied, account.Text(field));
|
||||||
|
Assert.StartsWith(proxied, status["account"].Text("avatar"));
|
||||||
|
var attachment = Assert.Single(status["media_attachments"]!.AsArray());
|
||||||
|
Assert.StartsWith(proxied, attachment.Text("url"));
|
||||||
|
Assert.StartsWith(proxied, attachment.Text("preview_url"));
|
||||||
|
Assert.Equal(_peer.A + "/picture.png", attachment.Text("remote_url"));
|
||||||
|
Assert.Equal("a picture", attachment.Text("description"));
|
||||||
|
Assert.StartsWith(proxied, Assert.Single(status["emojis"]!.AsArray()).Text("url"));
|
||||||
|
var everything = account.ToJsonString() + status.ToJsonString();
|
||||||
|
foreach (var file in new[] { "/avatar.png", "/header.png", "/blob.png" })
|
||||||
|
Assert.DoesNotContain(_peer.A + file, everything);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_proxy_refuses_a_url_it_did_not_sign()
|
||||||
|
{
|
||||||
|
var proxy = _host.Get<IMediaProxy>();
|
||||||
|
var remote = Served(Bytes(100));
|
||||||
|
var wrapped = proxy.Wrap(remote);
|
||||||
|
var parts = new Uri(wrapped).AbsolutePath.Split('/');
|
||||||
|
var other = new Uri(proxy.Wrap(Served(Bytes(100)))).AbsolutePath.Split('/');
|
||||||
|
using var client = _host.Client();
|
||||||
|
|
||||||
|
Assert.StartsWith($"{PrivaPubHost.Base}/media/proxy/", wrapped);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/media/proxy/AAAAAAAAAAAAAAAAAAAAAA/{parts[^1]}", Token)).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/media/proxy/{parts[^2]}/{other[^1]}", Token)).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/media/proxy/{parts[^2]}/!!!", Token)).StatusCode);
|
||||||
|
Assert.Empty(_peer.Requests);
|
||||||
|
Assert.Equal($"{PrivaPubHost.Base}/media/files/a.jpg", proxy.Wrap($"{PrivaPubHost.Base}/media/files/a.jpg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_ranged_request_is_streamed_as_206_and_never_cached()
|
||||||
|
{
|
||||||
|
var proxy = _host.Get<IMediaProxy>();
|
||||||
|
var bytes = Bytes(10_000);
|
||||||
|
var remote = Served(bytes);
|
||||||
|
using var client = _host.Client();
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(Ranged(proxy.Wrap(remote), 100, 199), Token);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode);
|
||||||
|
Assert.Equal("bytes 100-199/10000", response.Content.Headers.ContentRange!.ToString());
|
||||||
|
Assert.Equal(bytes[100..200], await response.Content.ReadAsByteArrayAsync(Token));
|
||||||
|
Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
|
||||||
|
Assert.Equal(default, proxy.Cached(remote));
|
||||||
|
using var again = await client.SendAsync(Ranged(proxy.Wrap(remote), 0, 9), Token);
|
||||||
|
Assert.Equal(bytes[..10], await again.Content.ReadAsByteArrayAsync(Token));
|
||||||
|
Assert.Equal(2, _peer.Requests.Count);
|
||||||
|
Assert.All(_peer.Requests, r => Assert.StartsWith("bytes=", r.Headers["Range"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_whole_download_is_cached_and_then_served_with_ranges()
|
||||||
|
{
|
||||||
|
var proxy = _host.Get<IMediaProxy>();
|
||||||
|
var bytes = Bytes(5_000);
|
||||||
|
var remote = Served(bytes, "image/png");
|
||||||
|
using var client = _host.Client();
|
||||||
|
|
||||||
|
using var whole = await client.GetAsync(proxy.Wrap(remote), Token);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, whole.StatusCode);
|
||||||
|
Assert.Equal("image/png", whole.Content.Headers.ContentType!.MediaType);
|
||||||
|
Assert.Equal(bytes, await whole.Content.ReadAsByteArrayAsync(Token));
|
||||||
|
Assert.NotNull(proxy.Cached(remote).Path);
|
||||||
|
using var part = await client.SendAsync(Ranged(proxy.Wrap(remote), 10, 19), Token);
|
||||||
|
Assert.Equal(HttpStatusCode.PartialContent, part.StatusCode);
|
||||||
|
Assert.Equal(bytes[10..20], await part.Content.ReadAsByteArrayAsync(Token));
|
||||||
|
using var cached = await client.GetAsync(proxy.Wrap(remote), Token);
|
||||||
|
Assert.Equal(bytes, await cached.Content.ReadAsByteArrayAsync(Token));
|
||||||
|
Assert.Single(_peer.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Anything_over_the_proxy_limit_is_streamed_and_never_cached()
|
||||||
|
{
|
||||||
|
var host = await SmallProxyHost.Shared();
|
||||||
|
var proxy = host.Get<IMediaProxy>();
|
||||||
|
var bytes = Bytes(SmallProxyHost.Limit * 4);
|
||||||
|
var remote = Served(bytes);
|
||||||
|
using var client = host.Client();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync(proxy.Wrap(remote), Token);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(bytes.Length, response.Content.Headers.ContentLength);
|
||||||
|
Assert.Equal(bytes, await response.Content.ReadAsByteArrayAsync(Token));
|
||||||
|
Assert.Equal(default, proxy.Cached(remote));
|
||||||
|
using var again = await client.GetAsync(proxy.Wrap(remote), Token);
|
||||||
|
Assert.Equal(bytes, await again.Content.ReadAsByteArrayAsync(Token));
|
||||||
|
Assert.Equal(4, _peer.Requests.Count);
|
||||||
|
var small = Served(Bytes(SmallProxyHost.Limit / 2));
|
||||||
|
using (var fits = await client.GetAsync(proxy.Wrap(small), Token))
|
||||||
|
Assert.Equal(HttpStatusCode.OK, fits.StatusCode);
|
||||||
|
Assert.NotNull(proxy.Cached(small).Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SmallProxyHost : PrivaPubHost
|
||||||
|
{
|
||||||
|
public const int Limit = 16 * 1024;
|
||||||
|
|
||||||
|
static readonly SemaphoreSlim Boot = new(1, 1);
|
||||||
|
static SmallProxyHost _shared;
|
||||||
|
|
||||||
|
public static new async Task<SmallProxyHost> Shared()
|
||||||
|
{
|
||||||
|
await PrivaPubHost.Shared();
|
||||||
|
await Boot.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_shared == default)
|
||||||
|
{
|
||||||
|
var host = new SmallProxyHost();
|
||||||
|
_ = host.Services;
|
||||||
|
_shared = host;
|
||||||
|
}
|
||||||
|
return _shared;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Boot.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<KeyValuePair<string, string>> Settings() =>
|
||||||
|
base.Settings().Append(new KeyValuePair<string, string>("Media:MaxProxiedBytes", Limit.ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
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 MastodonStatusesTests : 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 IEnumerable<string> Strings(JsonNode node) => node!.AsArray().Select(n => n!.GetValue<string>());
|
||||||
|
|
||||||
|
static async Task<JsonObject> Image(Mastodon account) =>
|
||||||
|
(await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v1/media")
|
||||||
|
{
|
||||||
|
Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(80, 60), "image/jpeg", "a.jpg"))
|
||||||
|
})).Ok().Object;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Each_visibility_is_addressed_the_way_mastodon_addresses_it()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var carol = new RemoteActor(_peer, "carol");
|
||||||
|
await _host.FollowedBy(alice, bob);
|
||||||
|
_peer.WebFinger(carol);
|
||||||
|
var followers = alice.Uri + "/groupies";
|
||||||
|
|
||||||
|
var open = await alice.Status("for everyone");
|
||||||
|
var unlisted = await alice.Status("quietly public", ("visibility", "unlisted"));
|
||||||
|
var quiet = await alice.Status("for followers", ("visibility", "private"));
|
||||||
|
var direct = await alice.Status($"@{carol.Handle()} just you", ("visibility", "direct"));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "public", "unlisted", "private", "direct" }, new[] { open, unlisted, quiet, direct }.Select(s => s.Text("visibility")));
|
||||||
|
var creates = (await bob.Delivered(since)).Where(d => d.Type() == "Create").ToDictionary(d => d["object"].Text("id"));
|
||||||
|
Assert.Equal(3, creates.Count);
|
||||||
|
var publicNote = creates[open.Text("uri")]["object"];
|
||||||
|
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(publicNote["to"]));
|
||||||
|
Assert.Equal(new[] { followers }, Strings(publicNote["cc"]));
|
||||||
|
var unlistedNote = creates[unlisted.Text("uri")]["object"];
|
||||||
|
Assert.Equal(new[] { followers }, Strings(unlistedNote["to"]));
|
||||||
|
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"]));
|
||||||
|
var quietNote = creates[quiet.Text("uri")]["object"];
|
||||||
|
Assert.Equal(new[] { followers }, Strings(quietNote["to"]));
|
||||||
|
Assert.Empty(quietNote["cc"]!.AsArray());
|
||||||
|
var whisper = Assert.Single(await carol.Delivered(since));
|
||||||
|
Assert.Equal(direct.Text("uri"), whisper["object"].Text("id"));
|
||||||
|
Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"]));
|
||||||
|
Assert.Empty(whisper["object"]!["cc"]!.AsArray());
|
||||||
|
Assert.Equal(carol.Handle(), Assert.Single(direct["mentions"]!.AsArray()).Text("acct"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_content_warning_is_kept_as_spoiler_text_and_federates_as_a_summary()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
var warned = await alice.Status("the butler did it", ("spoiler_text", "spoilers"));
|
||||||
|
|
||||||
|
Assert.True(warned.Flag("sensitive"));
|
||||||
|
Assert.Equal("spoilers", warned.Text("spoiler_text"));
|
||||||
|
var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{warned.Text("id")}")).Ok().Body;
|
||||||
|
Assert.Equal("spoilers", note.Text("summary"));
|
||||||
|
Assert.True(note.Flag("sensitive"));
|
||||||
|
Assert.Contains("the butler did it", note.Text("content"));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", " "))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reply_to_a_local_post_threads_counts_and_shows_in_the_context()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = await _host.Mastodon("bob");
|
||||||
|
var root = await alice.Status("what do you think?");
|
||||||
|
|
||||||
|
var reply = await bob.Status("I agree", ("in_reply_to_id", root.Text("id")));
|
||||||
|
var nested = await alice.Status("thanks", ("in_reply_to_id", reply.Text("id")));
|
||||||
|
|
||||||
|
Assert.Equal(root.Text("id"), reply.Text("in_reply_to_id"));
|
||||||
|
Assert.Equal(alice.Id, reply.Text("in_reply_to_account_id"));
|
||||||
|
Assert.Equal(1, (await bob.Client.Get($"/api/v1/statuses/{root.Text("id")}")).Ok().Body.Number("replies_count"));
|
||||||
|
var context = (await _host.Client().Get($"/api/v1/statuses/{reply.Text("id")}/context")).Ok();
|
||||||
|
Assert.Equal(new[] { root.Text("id") }, context.Body["ancestors"]!.AsArray().Select(s => s.Text("id")));
|
||||||
|
Assert.Equal(new[] { nested.Text("id") }, context.Body["descendants"]!.AsArray().Select(s => s.Text("id")));
|
||||||
|
var whole = (await alice.Client.Get($"/api/v1/statuses/{root.Text("id")}/context")).Ok();
|
||||||
|
Assert.Equal(new[] { reply.Text("id"), nested.Text("id") }, whole.Body["descendants"]!.AsArray().Select(s => s.Text("id")));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await bob.Client.Post("/api/v1/statuses", ("status", "lost"), ("in_reply_to_id", "000000000000000000000000"))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_reply_to_a_remote_post_mentions_its_author_and_reaches_its_inbox()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
_peer.WebFinger(bob);
|
||||||
|
var parent = await _host.PublicPostFrom(bob, alice);
|
||||||
|
var bobId = (await _host.Known(bob)).ID;
|
||||||
|
|
||||||
|
var reply = await alice.Status($"@{bob.Handle()} well said", ("in_reply_to_id", parent.ID));
|
||||||
|
|
||||||
|
Assert.Equal(parent.ID, reply.Text("in_reply_to_id"));
|
||||||
|
Assert.Equal(bobId, reply.Text("in_reply_to_account_id"));
|
||||||
|
var mention = Assert.Single(reply["mentions"]!.AsArray());
|
||||||
|
Assert.Equal((bobId, bob.Handle()), (mention.Text("id"), mention.Text("acct")));
|
||||||
|
var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
|
||||||
|
var note = create["object"]!;
|
||||||
|
Assert.Equal(parent.ObjectURI, note.Text("inReplyTo"));
|
||||||
|
Assert.Contains(bob.Id, Strings(note["cc"]));
|
||||||
|
Assert.Contains(note["tag"]!.AsArray(), t => t.Text("type") == "Mention" && t.Text("href") == bob.Id);
|
||||||
|
Assert.Equal(1, (await alice.Client.Get($"/api/v1/statuses/{parent.ID}")).Ok().Body.Number("replies_count"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_poll_is_created_validated_and_federated_as_a_question()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
var status = await alice.Status("tea or coffee?", ("poll[options][]", "tea"), ("poll[options][]", "coffee"), ("poll[expires_in]", "600"),
|
||||||
|
("poll[multiple]", "true"));
|
||||||
|
|
||||||
|
var poll = status["poll"]!;
|
||||||
|
Assert.Equal(new[] { "tea", "coffee" }, poll["options"]!.AsArray().Select(o => o.Text("title")));
|
||||||
|
Assert.True(poll.Flag("multiple"));
|
||||||
|
Assert.False(poll.Flag("expired"));
|
||||||
|
Assert.NotNull(poll.Text("expires_at"));
|
||||||
|
Assert.Equal(status.Text("id"), (await _host.Client().Get($"/api/v1/polls/{status.Text("id")}")).Ok().Body.Text("id"));
|
||||||
|
var question = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body;
|
||||||
|
Assert.Equal("Question", question.Text("type"));
|
||||||
|
Assert.Equal(2, question["anyOf"]!.AsArray().Count);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "one?"), ("poll[options][]", "only"),
|
||||||
|
("poll[expires_in]", "600"))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Media_is_attached_only_by_its_owner()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var mallory = await _host.Mastodon("mallory");
|
||||||
|
var upload = await Image(alice);
|
||||||
|
|
||||||
|
var status = await alice.Status("look", ("media_ids[]", upload.Text("id")));
|
||||||
|
|
||||||
|
var attachment = Assert.Single(status["media_attachments"]!.AsArray());
|
||||||
|
Assert.Equal(upload.Text("id"), attachment.Text("id"));
|
||||||
|
Assert.Equal("image", attachment.Text("type"));
|
||||||
|
var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body;
|
||||||
|
Assert.Equal(upload.Text("url"), Assert.Single(note["attachment"]!.AsArray()).Text("url"));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await mallory.Client.Post("/api/v1/statuses", ("status", "mine"), ("media_ids[]", (await Image(alice)).Text("id")))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "again"), ("media_ids[]", upload.Text("id")))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_quote_of_a_local_post_is_accepted_listed_and_revocable()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var original = await carol.Status("quote me");
|
||||||
|
var closed = await carol.Status("do not quote me", ("quote_approval_policy", "nobody"));
|
||||||
|
|
||||||
|
var quote = await alice.Status("so true", ("quoted_status_id", original.Text("id")));
|
||||||
|
|
||||||
|
Assert.Equal("accepted", quote["quote"].Text("state"));
|
||||||
|
Assert.Equal(original.Text("id"), quote["quote"]!["quoted_status"].Text("id"));
|
||||||
|
Assert.Equal(1, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count"));
|
||||||
|
Assert.Equal(new[] { quote.Text("id") }, (await _host.Client().Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Ids);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "anyway"), ("quoted_status_id", closed.Text("id")))).Status);
|
||||||
|
Assert.Empty(closed["quote_approval"]!["automatic"]!.AsArray());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Status);
|
||||||
|
var revoked = (await carol.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(quote.Text("id"), revoked.Body.Text("id"));
|
||||||
|
Assert.Equal("revoked", revoked.Body["quote"].Text("state"));
|
||||||
|
Assert.Empty((await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Array);
|
||||||
|
Assert.Equal(0, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_edit_keeps_its_history_shows_its_source_and_federates_an_update()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var other = await _host.Mastodon("other");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
await _host.FollowedBy(alice, bob);
|
||||||
|
var status = await alice.Status("first version", ("spoiler_text", "cw"));
|
||||||
|
var id = status.Text("id");
|
||||||
|
|
||||||
|
var edited = (await alice.Client.Put($"/api/v1/statuses/{id}", ("status", "second version"))).Ok();
|
||||||
|
|
||||||
|
Assert.NotNull(edited.Body.Text("edited_at"));
|
||||||
|
Assert.Contains("second version", edited.Body.Text("content"));
|
||||||
|
Assert.Contains("second version", (await other.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Text("content"));
|
||||||
|
var history = (await _host.Client().Get($"/api/v1/statuses/{id}/history")).Ok().Array;
|
||||||
|
Assert.Equal(2, history.Count);
|
||||||
|
Assert.Contains("first version", history[0].Text("content"));
|
||||||
|
Assert.Equal("cw", history[0].Text("spoiler_text"));
|
||||||
|
Assert.Contains("second version", history[1].Text("content"));
|
||||||
|
Assert.Equal(alice.Id, history[1]!["account"].Text("id"));
|
||||||
|
var source = (await alice.Client.Get($"/api/v1/statuses/{id}/source")).Ok();
|
||||||
|
Assert.Equal(("second version", ""), (source.Body.Text("text"), source.Body.Text("spoiler_text")));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get($"/api/v1/statuses/{id}/source")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/statuses/{id}", ("status", "hijacked"))).Status);
|
||||||
|
var update = Assert.Single(await bob.Delivered(since), d => d.Type() == "Update");
|
||||||
|
Assert.Equal(status.Text("uri"), update["object"].Text("id"));
|
||||||
|
Assert.Contains("second version", update["object"].Text("content"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_returns_the_text_for_a_redraft_and_leaves_a_tombstone()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var other = await _host.Mastodon("other");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
await _host.FollowedBy(alice, bob);
|
||||||
|
var status = await alice.Status("regrettable", ("spoiler_text", "hot take"));
|
||||||
|
var id = status.Text("id");
|
||||||
|
var scribble = $"/peasants/{alice.UserName}/scribbles/{id}";
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await _host.ActivityPub(scribble)).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Delete($"/api/v1/statuses/{id}")).Status);
|
||||||
|
|
||||||
|
var deleted = (await alice.Client.Delete($"/api/v1/statuses/{id}")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(id, deleted.Body.Text("id"));
|
||||||
|
Assert.Equal("regrettable", deleted.Body.Text("text"));
|
||||||
|
Assert.Equal("hot take", deleted.Body.Text("spoiler_text"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{id}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Delete($"/api/v1/statuses/{id}")).Status);
|
||||||
|
var tombstone = await _host.ActivityPub(scribble);
|
||||||
|
Assert.Equal(HttpStatusCode.Gone, tombstone.Status);
|
||||||
|
Assert.Equal("Tombstone", tombstone.Body.Text("type"));
|
||||||
|
Assert.Equal(status.Text("uri"), tombstone.Body.Text("id"));
|
||||||
|
var delete = Assert.Single(await bob.Delivered(since), d => d.Type() == "Delete");
|
||||||
|
Assert.Equal(status.Text("uri"), delete["object"].Text("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Favourites_and_reblogs_count_and_federate_with_their_undos()
|
||||||
|
{
|
||||||
|
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 remote = await _host.PublicPostFrom(bob, alice);
|
||||||
|
var path = $"/api/v1/statuses/{remote.ID}";
|
||||||
|
|
||||||
|
var liked = (await alice.Client.Post(path + "/favourite")).Ok();
|
||||||
|
Assert.True(liked.Body.Flag("favourited"));
|
||||||
|
Assert.Equal(1, liked.Body.Number("favourites_count"));
|
||||||
|
Assert.Equal(new[] { alice.Id }, (await _host.Client().Get(path + "/favourited_by")).Ok().Ids);
|
||||||
|
Assert.Equal(1, (await alice.Client.Post(path + "/favourite")).Ok().Body.Number("favourites_count"));
|
||||||
|
var unliked = (await alice.Client.Post(path + "/unfavourite")).Ok();
|
||||||
|
Assert.False(unliked.Body.Flag("favourited"));
|
||||||
|
Assert.Equal(0, unliked.Body.Number("favourites_count"));
|
||||||
|
|
||||||
|
var reblog = (await alice.Client.Post(path + "/reblog")).Ok();
|
||||||
|
Assert.Equal(remote.ID, reblog.Body["reblog"].Text("id"));
|
||||||
|
Assert.True(reblog.Body["reblog"].Flag("reblogged"));
|
||||||
|
Assert.Equal(1, reblog.Body["reblog"].Number("reblogs_count"));
|
||||||
|
Assert.Equal(new[] { alice.Id }, (await carol.Client.Get(path + "/reblogged_by")).Ok().Ids);
|
||||||
|
var unreblogged = (await alice.Client.Post(path + "/unreblog")).Ok();
|
||||||
|
Assert.False(unreblogged.Body.Flag("reblogged"));
|
||||||
|
Assert.Equal(0, unreblogged.Body.Number("reblogs_count"));
|
||||||
|
Assert.Empty((await carol.Client.Get(path + "/reblogged_by")).Ok().Array);
|
||||||
|
|
||||||
|
var sent = await bob.Delivered(since);
|
||||||
|
var like = Assert.Single(sent, d => d.Type() == "Like");
|
||||||
|
Assert.Equal((alice.Uri, remote.ObjectURI), (like.Text("actor"), like.Text("object")));
|
||||||
|
var announce = Assert.Single(sent, d => d.Type() == "Announce");
|
||||||
|
Assert.Equal(remote.ObjectURI, announce.Text("object"));
|
||||||
|
var undos = sent.Where(d => d.Type() == "Undo").ToList();
|
||||||
|
Assert.Equal(new[] { "Announce", "Like" }, undos.Select(u => u["object"].Text("type")).Order());
|
||||||
|
Assert.Equal(like.Text("id"), undos.Single(u => u["object"].Text("type") == "Like")["object"].Text("id"));
|
||||||
|
|
||||||
|
var local = await carol.Status("a local one");
|
||||||
|
(await alice.Client.Post($"/api/v1/statuses/{local.Text("id")}/favourite")).Ok();
|
||||||
|
var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array);
|
||||||
|
Assert.Equal((alice.Id, local.Text("id")), (notification["account"].Text("id"), notification["status"].Text("id")));
|
||||||
|
var quiet = await carol.Status("followers only", ("visibility", "private"));
|
||||||
|
(await alice.Client.Post($"/api/v1/accounts/{carol.Id}/follow")).Ok();
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/reblog")).Status);
|
||||||
|
Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bookmarks_are_kept_listed_and_removed()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var first = await carol.Status("worth keeping");
|
||||||
|
var second = await carol.Status("this too");
|
||||||
|
|
||||||
|
Assert.True((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/bookmark")).Ok().Body.Flag("bookmarked"));
|
||||||
|
(await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok();
|
||||||
|
(await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { second.Text("id"), first.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids);
|
||||||
|
Assert.Empty((await carol.Client.Get("/api/v1/bookmarks")).Ok().Array);
|
||||||
|
var page = (await alice.Client.Get("/api/v1/bookmarks?limit=1")).Ok();
|
||||||
|
Assert.Equal(new[] { second.Text("id") }, page.Ids);
|
||||||
|
Assert.Equal(new[] { first.Text("id") }, (await alice.Client.Get(page.Link("next"))).Ok().Ids);
|
||||||
|
Assert.False((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/unbookmark")).Ok().Body.Flag("bookmarked"));
|
||||||
|
Assert.Equal(new[] { second.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/statuses/000000000000000000000000/bookmark")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pins_take_only_the_owners_public_posts()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var mine = await alice.Status("pin me");
|
||||||
|
var quiet = await alice.Status("not this", ("visibility", "private"));
|
||||||
|
var theirs = await carol.Status("not yours");
|
||||||
|
|
||||||
|
Assert.True((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/pin")).Ok().Body.Flag("pinned"));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{theirs.Text("id")}/pin")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status);
|
||||||
|
var featured = (await _host.ActivityPub($"/peasants/{alice.UserName}/trophies")).Ok().Body;
|
||||||
|
Assert.Equal(mine.Text("uri"), Assert.Single(featured["orderedItems"]!.AsArray()).Text("id"));
|
||||||
|
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
(await alice.Client.Post($"/api/v1/statuses/{(await alice.Status($"more {i}")).Text("id")}/pin")).Ok();
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{(await alice.Status("one too many")).Text("id")}/pin")).Status);
|
||||||
|
|
||||||
|
Assert.False((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/unpin")).Ok().Body.Flag("pinned"));
|
||||||
|
Assert.Equal(4, (await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses?pinned=true")).Ok().Array.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_interaction_policy_sets_who_may_quote_and_matches_the_activitypub_note()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
await _host.FollowedBy(alice, bob);
|
||||||
|
var open = await alice.Status("quotable");
|
||||||
|
var quiet = await alice.Status("followers only", ("visibility", "private"));
|
||||||
|
var scribble = $"/peasants/{alice.UserName}/scribbles/{open.Text("id")}";
|
||||||
|
string CanQuote(JsonNode note) => Assert.Single(note["interactionPolicy"]!["canQuote"]!["automaticApproval"]!.AsArray())!.GetValue<string>();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "public" }, Strings(open["quote_approval"]!["automatic"]));
|
||||||
|
Assert.Equal(MastodonHelpers.Public, CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
|
||||||
|
|
||||||
|
var followers = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "followers"))).Ok();
|
||||||
|
Assert.Equal(new[] { "followers" }, Strings(followers.Body["quote_approval"]!["automatic"]));
|
||||||
|
Assert.Equal(alice.Uri + "/groupies", CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
|
||||||
|
|
||||||
|
var nobody = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "nobody"))).Ok();
|
||||||
|
Assert.Empty(nobody.Body["quote_approval"]!["automatic"]!.AsArray());
|
||||||
|
Assert.Equal(alice.Uri, CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
|
||||||
|
|
||||||
|
var forced = (await alice.Client.Put($"/api/v1/statuses/{quiet.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Ok();
|
||||||
|
Assert.Empty(forced.Body["quote_approval"]!["automatic"]!.AsArray());
|
||||||
|
var updates = (await bob.Delivered(since)).Where(d => d.Type() == "Update").ToList();
|
||||||
|
Assert.Equal(3, updates.Count);
|
||||||
|
Assert.Equal(alice.Uri, CanQuote(updates.Single(u => u["object"].Text("id") == quiet.Text("uri"))["object"]));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity,
|
||||||
|
(await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "everyone"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound,
|
||||||
|
(await (await _host.Mastodon("other")).Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_stranger_gets_404_for_followers_only_and_direct_posts()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var follower = await _host.Mastodon("follower");
|
||||||
|
var friend = await _host.Mastodon("friend");
|
||||||
|
var stranger = await _host.Mastodon("stranger");
|
||||||
|
(await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
var quiet = await alice.Status("followers only", ("visibility", "private"));
|
||||||
|
var direct = await alice.Status($"@{friend.UserName} only you", ("visibility", "direct"));
|
||||||
|
var anonymous = _host.Client();
|
||||||
|
|
||||||
|
foreach (var id in new[] { quiet.Text("id"), direct.Text("id") })
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}/context")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/favourite")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/bookmark")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}/favourited_by")).Status);
|
||||||
|
}
|
||||||
|
Assert.Empty((await stranger.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Array);
|
||||||
|
Assert.Equal(new[] { quiet.Text("id") }, (await follower.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { direct.Text("id") }, (await friend.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await follower.Client.Get($"/api/v1/statuses/{quiet.Text("id")}/context")).Status);
|
||||||
|
Assert.Equal(quiet.Text("id"), (await follower.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", quiet.Text("id")))).Ok().Body.Text("in_reply_to_id"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{quiet.Text("id")}")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_located_post_is_never_reachable_by_id_by_anyone_else()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var other = await _host.Mastodon("other");
|
||||||
|
var id = await _host.Located(alice, "the bench by the river");
|
||||||
|
var anonymous = _host.Client();
|
||||||
|
|
||||||
|
foreach (var client in new[] { other.Client, anonymous })
|
||||||
|
{
|
||||||
|
foreach (var path in new[] { "", "/context", "/history", "/favourited_by", "/reblogged_by", "/quotes" })
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/statuses/{id}{path}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/privapub/v1/statuses/{id}/provenance")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/pleroma/statuses/{id}/reactions")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/polls/{id}")).Status);
|
||||||
|
Assert.Empty((await client.Get($"/api/v1/statuses?id[]={id}")).Ok().Array);
|
||||||
|
Assert.DoesNotContain(id, (await client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Ids);
|
||||||
|
}
|
||||||
|
foreach (var action in new[] { "favourite", "reblog", "bookmark", "pin" })
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post($"/api/v1/statuses/{id}/{action}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/pleroma/statuses/{id}/reactions/%F0%9F%91%8D")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "qt"), ("quoted_status_id", id))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{id}")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Creating_honours_the_idempotency_key_the_scopes_and_needs_a_recipient_for_a_direct_message()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var reader = await _host.Mastodon("reader", scopes: "read");
|
||||||
|
var key = Guid.NewGuid().ToString("N");
|
||||||
|
Task<ApiAnswer> Keyed(string text)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses") { Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["status"] = text }) };
|
||||||
|
request.Headers.Add("Idempotency-Key", key);
|
||||||
|
return alice.Client.Exchange(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = (await Keyed("once")).Ok();
|
||||||
|
var second = (await Keyed("once again")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(first.Body.Text("id"), second.Body.Text("id"));
|
||||||
|
Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
|
||||||
|
var noRecipient = await alice.Client.Post("/api/v1/statuses", ("status", "to nobody"), ("visibility", "direct"));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, noRecipient.Status);
|
||||||
|
Assert.Contains("recipient", noRecipient.Body.Text("error"));
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post("/api/v1/statuses", ("status", "read only"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post($"/api/v1/statuses/{first.Body.Text("id")}/favourite")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get($"/api/v1/statuses/{first.Body.Text("id")}")).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post("/api/v1/statuses", ("status", "anonymous"))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Local_polls_take_one_vote_per_persona_and_never_from_their_author()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var poll = await alice.Status("pick one", ("poll[options][]", "red"), ("poll[options][]", "blue"), ("poll[expires_in]", "3600"));
|
||||||
|
var path = $"/api/v1/polls/{poll.Text("id")}";
|
||||||
|
|
||||||
|
var voted = (await carol.Client.Post(path + "/votes", ("choices[]", "1"))).Ok();
|
||||||
|
|
||||||
|
Assert.True(voted.Body.Flag("voted"));
|
||||||
|
Assert.Equal(new[] { 1 }, voted.Body["own_votes"]!.AsArray().Select(v => v!.GetValue<int>()));
|
||||||
|
Assert.Equal(1, voted.Body.Number("votes_count"));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await carol.Client.Post(path + "/votes", ("choices[]", "0"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post(path + "/votes", ("choices[]", "0"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("dave")).Client.Post(path + "/votes", ("choices[]", "0"), ("choices[]", "1"))).Status);
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("erin")).Client.Post(path + "/votes", ("choices[]", "7"))).Status);
|
||||||
|
var anonymous = (await _host.Client().Get(path)).Ok();
|
||||||
|
Assert.Null(anonymous.Body["voted"]);
|
||||||
|
Assert.Equal(new[] { 0, 1 }, anonymous.Body["options"]!.AsArray().Select(o => o.Number("votes_count")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_vote_on_a_remote_poll_goes_only_to_its_author_without_a_published_date()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var carol = new RemoteActor(_peer, "carol");
|
||||||
|
await _host.FollowedBy(alice, carol);
|
||||||
|
var question = await _host.PublicPostFrom(bob, alice, "<p>which?</p>", note =>
|
||||||
|
{
|
||||||
|
note["type"] = "Question";
|
||||||
|
note["oneOf"] = new JsonArray(
|
||||||
|
new JsonObject { ["type"] = "Note", ["name"] = "this", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } },
|
||||||
|
new JsonObject { ["type"] = "Note", ["name"] = "that", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } });
|
||||||
|
note["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O");
|
||||||
|
});
|
||||||
|
|
||||||
|
var voted = (await alice.Client.Post($"/api/v1/polls/{question.ID}/votes", ("choices[]", "1"))).Ok();
|
||||||
|
|
||||||
|
Assert.True(voted.Body.Flag("voted"));
|
||||||
|
var vote = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
|
||||||
|
Assert.Equal("that", vote["object"].Text("name"));
|
||||||
|
Assert.Equal(question.ObjectURI, vote["object"].Text("inReplyTo"));
|
||||||
|
Assert.Null(vote["object"]!["published"]);
|
||||||
|
Assert.Equal(new[] { bob.Id }, Strings(vote["object"]!["to"]));
|
||||||
|
Assert.DoesNotContain(await carol.Delivered(since), d => d.Type() == "Create");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deleting_a_reblog_undoes_it()
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var remote = await _host.PublicPostFrom(bob, alice);
|
||||||
|
var reblog = (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok().Body;
|
||||||
|
|
||||||
|
(await alice.Client.Delete($"/api/v1/statuses/{reblog.Text("id")}")).Ok();
|
||||||
|
|
||||||
|
Assert.False((await alice.Client.Get($"/api/v1/statuses/{remote.ID}")).Ok().Body.Flag("reblogged"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{reblog.Text("id")}")).Status);
|
||||||
|
var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo");
|
||||||
|
Assert.Equal("Announce", undo["object"].Text("type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Many_statuses_are_fetched_by_id_in_one_call()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var first = await alice.Status("one");
|
||||||
|
var second = await alice.Status("two");
|
||||||
|
|
||||||
|
var answer = (await _host.Client().Get($"/api/v1/statuses?id[]={first.Text("id")}&id[]={second.Text("id")}&id[]=junk")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { first.Text("id"), second.Text("id") }, answer.Ids.Order());
|
||||||
|
Assert.Empty((await _host.Client().Get("/api/v1/statuses")).Ok().Array);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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 MastodonTimelinesTests : 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_home_timeline_pages_with_max_since_and_min_id()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = await _host.Mastodon("bob");
|
||||||
|
var posts = new List<string>();
|
||||||
|
for (var i = 1; i <= 4; i++)
|
||||||
|
posts.Add((await alice.Status($"post {i}")).Text("id"));
|
||||||
|
(await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
var afterFollow = (await alice.Status("post 5")).Text("id");
|
||||||
|
var (p1, p2, p3, p4) = (posts[0], posts[1], posts[2], posts[3]);
|
||||||
|
|
||||||
|
var first = (await alice.Client.Get("/api/v1/timelines/home?limit=2")).Ok();
|
||||||
|
Assert.Equal(new[] { afterFollow, p4 }, first.Ids);
|
||||||
|
Assert.Equal(new[] { p3, p2 }, (await alice.Client.Get(first.Link("next"))).Ok().Ids);
|
||||||
|
Assert.Empty((await alice.Client.Get(first.Link("prev"))).Ok().Array);
|
||||||
|
Assert.Equal(new[] { p2, p1 }, (await alice.Client.Get($"/api/v1/timelines/home?max_id={p3}&limit=2")).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { afterFollow, p4 }, (await alice.Client.Get($"/api/v1/timelines/home?since_id={p1}&limit=2")).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { p3, p2 }, (await alice.Client.Get($"/api/v1/timelines/home?min_id={p1}&limit=2")).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { p3 }, (await alice.Client.Get($"/api/v1/timelines/home?min_id={p2}&max_id={p4}")).Ok().Ids);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { afterFollow }, (await bob.Client.Get("/api/v1/timelines/home")).Ok().Ids);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v1/timelines/home")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<List<string>> Window(HttpClient client, string filter, string since, string until)
|
||||||
|
{
|
||||||
|
var ids = new List<string>();
|
||||||
|
for (var page = 0; page < 50; page++)
|
||||||
|
{
|
||||||
|
var batch = (await client.Get($"/api/v1/timelines/public?{filter}&since_id={since}&max_id={until}&limit=40")).Ok().Ids.ToList();
|
||||||
|
if (batch.Count == 0)
|
||||||
|
break;
|
||||||
|
ids.AddRange(batch);
|
||||||
|
until = batch[^1];
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_public_timeline_splits_local_from_remote_and_leaves_out_unlisted_posts()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = new RemoteActor(_peer, "bob");
|
||||||
|
var since = MastodonHelpers.IdBefore(DateTime.UtcNow);
|
||||||
|
var remote = await _host.PublicPostFrom(bob, alice);
|
||||||
|
var open = (await alice.Status("hello world")).Text("id");
|
||||||
|
var unlisted = (await alice.Status("hello quietly", ("visibility", "unlisted"))).Text("id");
|
||||||
|
var until = MastodonHelpers.IdBefore(DateTime.UtcNow.AddSeconds(1));
|
||||||
|
var anonymous = _host.Client();
|
||||||
|
|
||||||
|
var everything = await Window(anonymous, "local=false", since, until);
|
||||||
|
Assert.Contains(open, everything);
|
||||||
|
Assert.Contains(remote.ID, everything);
|
||||||
|
Assert.DoesNotContain(unlisted, everything);
|
||||||
|
var local = await Window(anonymous, "local=true", since, until);
|
||||||
|
Assert.Contains(open, local);
|
||||||
|
Assert.DoesNotContain(remote.ID, local);
|
||||||
|
var federated = await Window(alice.Client, "remote=true", since, until);
|
||||||
|
Assert.Contains(remote.ID, federated);
|
||||||
|
Assert.DoesNotContain(open, federated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_tag_timeline_is_open_to_anonymous_readers_and_shows_only_public_posts()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var tag = $"t{Guid.NewGuid():N}"[..16];
|
||||||
|
var open = (await alice.Status($"about #{tag}")).Text("id");
|
||||||
|
await alice.Status($"quietly about #{tag}", ("visibility", "unlisted"));
|
||||||
|
await alice.Status($"privately about #{tag}", ("visibility", "private"));
|
||||||
|
var later = (await alice.Status($"more about #{tag.ToUpperInvariant()}")).Text("id");
|
||||||
|
|
||||||
|
var tagged = (await _host.Client().Get($"/api/v1/timelines/tag/{tag}")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { later, open }, tagged.Ids);
|
||||||
|
Assert.Equal(new[] { later }, (await _host.Client().Get($"/api/v1/timelines/tag/{tag}?local=true&limit=1")).Ok().Ids);
|
||||||
|
Assert.Equal(new[] { later, open }, (await alice.Client.Get($"/api/v1/timelines/tag/%23{tag}")).Ok().Ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_list_timeline_is_an_empty_stub()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
Assert.Empty((await alice.Client.Get("/api/v1/timelines/list/anything")).Ok().Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Favourites_list_what_the_persona_liked_newest_first()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var first = (await carol.Status("one")).Text("id");
|
||||||
|
var second = (await carol.Status("two")).Text("id");
|
||||||
|
(await alice.Client.Post($"/api/v1/statuses/{first}/favourite")).Ok();
|
||||||
|
(await alice.Client.Post($"/api/v1/statuses/{second}/favourite")).Ok();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { second, first }, (await alice.Client.Get("/api/v1/favourites")).Ok().Ids);
|
||||||
|
var page = (await alice.Client.Get("/api/v1/favourites?limit=1")).Ok();
|
||||||
|
Assert.Equal(new[] { second }, page.Ids);
|
||||||
|
Assert.Equal(new[] { first }, (await alice.Client.Get(page.Link("next"))).Ok().Ids);
|
||||||
|
Assert.Empty((await carol.Client.Get("/api/v1/favourites")).Ok().Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Conversations_list_direct_messages_to_their_participants_only()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var bob = await _host.Mastodon("bob");
|
||||||
|
var stranger = await _host.Mastodon("stranger");
|
||||||
|
(await alice.Status($"@{bob.UserName} psst", ("visibility", "direct"))).Text("id");
|
||||||
|
var answer = (await bob.Status($"@{alice.UserName} what?", ("visibility", "direct"))).Text("id");
|
||||||
|
|
||||||
|
var mine = Assert.Single((await alice.Client.Get("/api/v1/conversations")).Ok().Array);
|
||||||
|
Assert.Equal(new[] { bob.Id }, mine!["accounts"]!.AsArray().Select(a => a.Text("id")));
|
||||||
|
Assert.Equal(answer, mine["last_status"].Text("id"));
|
||||||
|
var theirs = Assert.Single((await bob.Client.Get("/api/v1/conversations")).Ok().Array);
|
||||||
|
Assert.Equal(mine.Text("id"), theirs.Text("id"));
|
||||||
|
Assert.Equal(new[] { alice.Id }, theirs!["accounts"]!.AsArray().Select(a => a.Text("id")));
|
||||||
|
Assert.Empty((await stranger.Client.Get("/api/v1/conversations")).Ok().Array);
|
||||||
|
|
||||||
|
var read = (await bob.Client.Post($"/api/v1/conversations/{mine.Text("id")}/read")).Ok();
|
||||||
|
Assert.Equal(mine.Text("id"), read.Body.Text("id"));
|
||||||
|
Assert.Equal(answer, read.Body["last_status"].Text("id"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/conversations/{mine.Text("id")}/read")).Status);
|
||||||
|
Assert.DoesNotContain(answer, (await stranger.Client.Get($"/api/v1/accounts/{bob.Id}/statuses")).Ok().Ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Markers_are_saved_versioned_and_read_back_per_timeline()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
|
||||||
|
var saved = (await alice.Client.Post("/api/v1/markers", ("home[last_read_id]", "100"))).Ok();
|
||||||
|
Assert.Equal("100", saved.Body["home"].Text("last_read_id"));
|
||||||
|
Assert.Equal(1, saved.Body["home"].Number("version"));
|
||||||
|
Assert.Null(saved.Body["notifications"]);
|
||||||
|
|
||||||
|
var again = (await alice.Client.Json(HttpMethod.Post, "/api/v1/markers", new JsonObject
|
||||||
|
{
|
||||||
|
["home"] = new JsonObject { ["last_read_id"] = "200" },
|
||||||
|
["notifications"] = new JsonObject { ["last_read_id"] = "7" }
|
||||||
|
})).Ok();
|
||||||
|
Assert.Equal(("200", 2), (again.Body["home"].Text("last_read_id"), again.Body["home"].Number("version")));
|
||||||
|
Assert.Equal(("7", 1), (again.Body["notifications"].Text("last_read_id"), again.Body["notifications"].Number("version")));
|
||||||
|
|
||||||
|
var home = (await alice.Client.Get("/api/v1/markers?timeline[]=home")).Ok().Object;
|
||||||
|
Assert.Equal(new[] { "home" }, home.Select(p => p.Key));
|
||||||
|
Assert.Equal("200", home["home"].Text("last_read_id"));
|
||||||
|
Assert.Equal(new[] { "home", "notifications" }, (await alice.Client.Get("/api/v1/markers")).Ok().Object.Select(p => p.Key).Order());
|
||||||
|
Assert.Empty((await (await _host.Mastodon("other")).Client.Get("/api/v1/markers")).Ok().Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_notification_about_a_deleted_post_is_gone_not_null()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var post = (await alice.Status("short lived")).Text("id");
|
||||||
|
(await carol.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
|
||||||
|
var id = Assert.Single((await alice.Client.Get("/api/v1/notifications")).Ok().Array).Text("id");
|
||||||
|
|
||||||
|
(await alice.Client.Delete($"/api/v1/statuses/{post}")).Ok();
|
||||||
|
|
||||||
|
Assert.Empty((await alice.Client.Get("/api/v1/notifications")).Ok().Array);
|
||||||
|
var gone = await alice.Client.Get($"/api/v1/notifications/{id}");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, gone.Status);
|
||||||
|
Assert.Equal("Record not found", gone.Body.Text("error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Notifications_filter_page_dismiss_and_clear()
|
||||||
|
{
|
||||||
|
var alice = await _host.Mastodon("alice");
|
||||||
|
var carol = await _host.Mastodon("carol");
|
||||||
|
var post = (await alice.Status("notice me")).Text("id");
|
||||||
|
(await carol.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||||
|
(await carol.Client.Post($"/api/v1/statuses/{post}/favourite")).Ok();
|
||||||
|
var mention = (await carol.Status($"hey @{alice.UserName}")).Text("id");
|
||||||
|
(await carol.Client.Post($"/api/v1/statuses/{post}/reblog")).Ok();
|
||||||
|
|
||||||
|
var all = (await alice.Client.Get("/api/v1/notifications")).Ok();
|
||||||
|
Assert.Equal(new[] { "reblog", "mention", "favourite", "follow" }, all.Array.Select(n => n.Text("type")));
|
||||||
|
Assert.All(all.Array, n => Assert.Equal(carol.Id, n!["account"].Text("id")));
|
||||||
|
Assert.Equal(mention, all.Array[1]!["status"].Text("id"));
|
||||||
|
Assert.Equal(new[] { "mention" }, (await alice.Client.Get("/api/v1/notifications?types[]=mention")).Ok().Array.Select(n => n.Text("type")));
|
||||||
|
Assert.Equal(new[] { "mention", "favourite" },
|
||||||
|
(await alice.Client.Get("/api/v1/notifications?exclude_types[]=follow&exclude_types[]=reblog")).Ok().Array.Select(n => n.Text("type")));
|
||||||
|
Assert.Equal(4, (await alice.Client.Get($"/api/v1/notifications?account_id={carol.Id}")).Ok().Array.Count);
|
||||||
|
var page = (await alice.Client.Get("/api/v1/notifications?limit=3")).Ok();
|
||||||
|
Assert.Equal(new[] { "follow" }, (await alice.Client.Get(page.Link("next"))).Ok().Array.Select(n => n.Text("type")));
|
||||||
|
Assert.Equal(4, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count"));
|
||||||
|
|
||||||
|
var followId = all.Array[3].Text("id");
|
||||||
|
Assert.Equal("follow", (await alice.Client.Get($"/api/v1/notifications/{followId}")).Ok().Body.Text("type"));
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Get($"/api/v1/notifications/{followId}")).Status);
|
||||||
|
(await carol.Client.Post($"/api/v1/notifications/{followId}/dismiss")).Ok();
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await alice.Client.Get($"/api/v1/notifications/{followId}")).Status);
|
||||||
|
(await alice.Client.Post($"/api/v1/notifications/{followId}/dismiss")).Ok();
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/notifications/{followId}")).Status);
|
||||||
|
Assert.Equal(3, (await alice.Client.Get("/api/v1/notifications")).Ok().Array.Count);
|
||||||
|
|
||||||
|
(await alice.Client.Post("/api/v1/notifications/clear")).Ok();
|
||||||
|
Assert.Empty((await alice.Client.Get("/api/v1/notifications")).Ok().Array);
|
||||||
|
Assert.Equal(0, (await alice.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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("<p>signed and sealed</p>", 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<Post>().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<string>()));
|
||||||
|
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"] = "<p>the start of it</p>",
|
||||||
|
["published"] = DateTime.UtcNow.AddMinutes(-5).ToString("O")
|
||||||
|
}.ToJsonString());
|
||||||
|
var reply = bob.Create("<p>and a reply</p>", 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<Post>().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("<p>between us</p>", 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<Post>().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<string> { "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<string>());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using NetVips;
|
||||||
|
|
||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace PrivaPub.Tests.Support.Host
|
||||||
|
{
|
||||||
|
public sealed record Mastodon(Persona Persona, string Token, HttpClient Client)
|
||||||
|
{
|
||||||
|
public string Id => Persona.Id;
|
||||||
|
public string UserName => Persona.UserName;
|
||||||
|
public string Uri => $"{PrivaPubHost.Base}/peasants/{Persona.UserName}";
|
||||||
|
public string Mouth => $"/peasants/{Persona.UserName}/mouth";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ApiAnswer(HttpStatusCode Status, JsonNode Body, string Text, HttpResponseHeaders Headers)
|
||||||
|
{
|
||||||
|
public JsonObject Object => Body.AsObject();
|
||||||
|
public JsonArray Array => Body.AsArray();
|
||||||
|
public IEnumerable<string> Ids => Array.Select(item => item!["id"]!.GetValue<string>());
|
||||||
|
|
||||||
|
public ApiAnswer Ok()
|
||||||
|
{
|
||||||
|
Assert.True(Status == HttpStatusCode.OK, $"expected 200, got {(int)Status}: {Text}");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Link(string rel) =>
|
||||||
|
Headers.TryGetValues("Link", out var values)
|
||||||
|
? Regex.Matches(string.Join(", ", values), "<([^>]+)>; rel=\"([a-z]+)\"").FirstOrDefault(m => m.Groups[2].Value == rel)?.Groups[1].Value
|
||||||
|
: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MastodonHelpers
|
||||||
|
{
|
||||||
|
public const string Public = "https://www.w3.org/ns/activitystreams#Public";
|
||||||
|
|
||||||
|
public static async Task<Mastodon> Mastodon(this PrivaPubHost host, string name = "persona", Root root = default, string scopes = "read write follow")
|
||||||
|
{
|
||||||
|
root ??= await host.SignUp();
|
||||||
|
var persona = await host.Persona(root, name);
|
||||||
|
var token = await host.MastodonToken(persona, scopes);
|
||||||
|
return new Mastodon(persona, token, host.As(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<ApiAnswer> Get(this HttpClient client, string path) => client.Call(HttpMethod.Get, path);
|
||||||
|
|
||||||
|
public static Task<ApiAnswer> Post(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Post, path, form);
|
||||||
|
|
||||||
|
public static Task<ApiAnswer> Put(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Put, path, form);
|
||||||
|
|
||||||
|
public static Task<ApiAnswer> Patch(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Patch, path, form);
|
||||||
|
|
||||||
|
public static Task<ApiAnswer> Delete(this HttpClient client, string path, params (string Key, string Value)[] form) => client.Call(HttpMethod.Delete, path, form);
|
||||||
|
|
||||||
|
public static Task<ApiAnswer> Call(this HttpClient client, HttpMethod method, string path, params (string Key, string Value)[] form)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, path);
|
||||||
|
if (form.Length > 0)
|
||||||
|
request.Content = new FormUrlEncodedContent(form.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||||
|
return client.Exchange(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<ApiAnswer> Json(this HttpClient client, HttpMethod method, string path, JsonNode body) =>
|
||||||
|
client.Exchange(new HttpRequestMessage(method, path) { Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json") });
|
||||||
|
|
||||||
|
public static async Task<ApiAnswer> Exchange(this HttpClient client, HttpRequestMessage request)
|
||||||
|
{
|
||||||
|
using (request)
|
||||||
|
{
|
||||||
|
using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
JsonNode body = default;
|
||||||
|
if (!string.IsNullOrWhiteSpace(text) && text.TrimStart()[0] is '{' or '[')
|
||||||
|
body = JsonNode.Parse(text);
|
||||||
|
return new ApiAnswer(response.StatusCode, body, text, response.Headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<JsonObject> Status(this Mastodon account, string text, params (string Key, string Value)[] more) =>
|
||||||
|
(await account.Client.Post("/api/v1/statuses", more.Prepend(("status", text)).ToArray())).Ok().Object;
|
||||||
|
|
||||||
|
public static string Text(this JsonNode node, string property) => node?[property]?.GetValue<string>();
|
||||||
|
|
||||||
|
public static int Number(this JsonNode node, string property) => node![property]!.GetValue<int>();
|
||||||
|
|
||||||
|
public static bool Flag(this JsonNode node, string property) => node![property]!.GetValue<bool>();
|
||||||
|
|
||||||
|
public static async Task<ForeignAvatar> Known(this PrivaPubHost host, RemoteActor actor) =>
|
||||||
|
await host.Get<IRemoteActorService>().GetActor(actor.Id, refresh: true, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
public static string Handle(this RemoteActor actor) => $"{actor.Name}@{new Uri(actor.Id).Authority}";
|
||||||
|
|
||||||
|
public static string Inbox(this RemoteActor actor) => actor.Id + "/inbox";
|
||||||
|
|
||||||
|
public static string Origin(this RemoteActor actor) => actor.Id.Split("/users/")[0];
|
||||||
|
|
||||||
|
public static void WebFinger(this Peer peer, RemoteActor actor) =>
|
||||||
|
peer.ServeText("/.well-known/webfinger", new JsonObject
|
||||||
|
{
|
||||||
|
["subject"] = $"acct:{actor.Handle()}",
|
||||||
|
["links"] = new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Id })
|
||||||
|
}.ToJsonString(), "application/jrd+json");
|
||||||
|
|
||||||
|
public static async Task Deliver(this PrivaPubHost host, RemoteActor actor, string path, JsonObject activity)
|
||||||
|
{
|
||||||
|
using var client = host.Client();
|
||||||
|
using var response = await client.SendAsync(actor.SignedPost(path, activity), TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||||
|
await host.RunInbox(activity["id"]!.GetValue<string>(), TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> FollowedBy(this PrivaPubHost host, Mastodon account, RemoteActor actor)
|
||||||
|
{
|
||||||
|
var followId = $"{actor.Origin()}/follows/{Guid.NewGuid():N}";
|
||||||
|
await host.Deliver(actor, account.Mouth, new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = followId,
|
||||||
|
["type"] = "Follow",
|
||||||
|
["actor"] = actor.Id,
|
||||||
|
["object"] = account.Uri
|
||||||
|
});
|
||||||
|
return followId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject Create(this RemoteActor actor, string content, IEnumerable<string> to, IEnumerable<string> cc = default,
|
||||||
|
Action<JsonObject> shape = default)
|
||||||
|
{
|
||||||
|
var noteId = $"{actor.Origin()}/notes/{Guid.NewGuid():N}";
|
||||||
|
var note = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = noteId,
|
||||||
|
["type"] = "Note",
|
||||||
|
["attributedTo"] = actor.Id,
|
||||||
|
["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()),
|
||||||
|
["cc"] = new JsonArray((cc ?? Enumerable.Empty<string>()).Select(c => (JsonNode)c).ToArray()),
|
||||||
|
["content"] = content,
|
||||||
|
["published"] = DateTime.UtcNow.ToString("O")
|
||||||
|
};
|
||||||
|
shape?.Invoke(note);
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = noteId + "/activity",
|
||||||
|
["type"] = "Create",
|
||||||
|
["actor"] = actor.Id,
|
||||||
|
["to"] = note["to"]!.DeepClone(),
|
||||||
|
["cc"] = note["cc"]!.DeepClone(),
|
||||||
|
["object"] = note
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Post> PublicPostFrom(this PrivaPubHost host, RemoteActor actor, Mastodon mentioned, string content = "<p>a remote thought</p>",
|
||||||
|
Action<JsonObject> shape = default)
|
||||||
|
{
|
||||||
|
var create = actor.Create(content, new[] { Public }, new[] { mentioned.Uri }, note =>
|
||||||
|
{
|
||||||
|
note["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = mentioned.Uri, ["name"] = "@" + mentioned.UserName });
|
||||||
|
shape?.Invoke(note);
|
||||||
|
});
|
||||||
|
await host.Deliver(actor, mentioned.Mouth, create);
|
||||||
|
var noteId = create["object"]!["id"]!.GetValue<string>();
|
||||||
|
return await DB.Default.Find<Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<List<JsonObject>> Delivered(this RemoteActor actor, DateTime since) =>
|
||||||
|
await Jobs.Deliveries(actor.Inbox(), since, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
public static string Type(this JsonObject activity) => activity["type"]!.GetValue<string>();
|
||||||
|
|
||||||
|
public static byte[] JpegWithMetadata(int width, int height)
|
||||||
|
{
|
||||||
|
using var image = (NetVips.Image.Black(width, height, bands: 3) + new double[] { 30, 140, 200 }).Cast(Enums.BandFormat.Uchar);
|
||||||
|
using var tagged = image.Mutate(m =>
|
||||||
|
{
|
||||||
|
m.Set(GValue.GStrType, "exif-ifd0-ImageDescription", "where I live");
|
||||||
|
m.Set(GValue.GStrType, "exif-ifd0-Artist", "Alice Smith");
|
||||||
|
m.Set(GValue.BlobType, "xmp-data", Encoding.UTF8.GetBytes("<x:xmpmeta xmlns:x='adobe:ns:meta/'><secret/></x:xmpmeta>"));
|
||||||
|
});
|
||||||
|
return tagged.WriteToBuffer(".jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AssertNoMetadata(byte[] image)
|
||||||
|
{
|
||||||
|
using var loaded = NetVips.Image.NewFromBuffer(image);
|
||||||
|
var fields = loaded.GetFields();
|
||||||
|
Assert.DoesNotContain("exif-data", fields);
|
||||||
|
Assert.DoesNotContain("xmp-data", fields);
|
||||||
|
Assert.DoesNotContain(fields, f => f.StartsWith("exif-ifd", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MultipartFormDataContent Multipart(params (string Name, byte[] Bytes, string ContentType, string FileName)[] files)
|
||||||
|
{
|
||||||
|
var content = new MultipartFormDataContent();
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var part = new ByteArrayContent(file.Bytes);
|
||||||
|
part.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType);
|
||||||
|
content.Add(part, file.Name, file.FileName);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<ApiAnswer> ActivityPub(this PrivaPubHost host, string path)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||||
|
request.Headers.Accept.ParseAdd("application/activity+json");
|
||||||
|
return await host.Client().Exchange(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> Located(this PrivaPubHost host, Mastodon account, string text)
|
||||||
|
{
|
||||||
|
using var client = host.As(account.Persona.Root.Jwt);
|
||||||
|
var response = await client.PostAsJsonAsync("/clientapi/post/insert",
|
||||||
|
new { avatarId = account.Id, text, latitude = 45.07, longitude = 7.68, rangeKm = 5 }, TestContext.Current.CancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(response.IsSuccessStatusCode, body);
|
||||||
|
return JsonNode.Parse(body)!["id"]!.GetValue<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string IdBefore(DateTime when) => ((int)new DateTimeOffset(when).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,11 +159,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = local != default
|
var query = local is { Kind: LocalActorKind.Group }
|
||||||
|
? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsShown)
|
||||||
|
: local != default
|
||||||
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsShown)
|
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsShown)
|
||||||
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy).Match(VisibilityPolicy.IsShown);
|
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy).Match(VisibilityPolicy.IsShown);
|
||||||
var viewerFollows = MyId != default && local != default
|
var viewerFollows = MyId != default && (local != default
|
||||||
&& await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
|
? await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
|
||||||
|
: await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == remote.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
|
||||||
if (MyId != id)
|
if (MyId != id)
|
||||||
query.Match(viewerFollows
|
query.Match(viewerFollows
|
||||||
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
|
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
|
||||||
@@ -171,7 +174,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
else
|
else
|
||||||
query.Match(p => p.Visibility != PostVisibility.LocalGeo);
|
query.Match(p => p.Visibility != PostVisibility.LocalGeo);
|
||||||
if (Params.Bool("exclude_replies") == true)
|
if (Params.Bool("exclude_replies") == true)
|
||||||
query.Match(p => p.InReplyToURI == null);
|
{
|
||||||
|
var self = local?.Id ?? remote.ID;
|
||||||
|
query.Match(p => p.InReplyToURI == null || p.InReplyToAccountId == self);
|
||||||
|
}
|
||||||
if (Params.Bool("exclude_reblogs") == true)
|
if (Params.Bool("exclude_reblogs") == true)
|
||||||
query.Match(p => p.ReblogOfPostId == null);
|
query.Match(p => p.ReblogOfPostId == null);
|
||||||
if (Params.Bool("only_media") == true)
|
if (Params.Bool("only_media") == true)
|
||||||
@@ -235,9 +241,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
var uri = local?.Uri ?? remote?.ActorURI;
|
var uri = local?.Uri ?? remote?.ActorURI;
|
||||||
if (uri == default)
|
if (uri == default)
|
||||||
return NotFoundError();
|
return NotFoundError();
|
||||||
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == Me.Id && f.ActorURI == uri);
|
await _relationships.RemoveFollower(Me, uri, id, token);
|
||||||
if (local != default)
|
|
||||||
await DB.Default.DeleteAsync<Following>(f => f.AvatarId == local.Id && f.TargetActorURI == Me.Uri);
|
|
||||||
return Json(await Relationship(id, token));
|
return Json(await Relationship(id, token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
||||||
.ExecuteFirstAsync(token);
|
.ExecuteFirstAsync(token);
|
||||||
if (post == default)
|
if (post == default)
|
||||||
return NotFoundError();
|
return await Visible(id, token) is { } other
|
||||||
|
? Error(StatusCodes.Status422UnprocessableEntity, other.ReblogOfPostId == null
|
||||||
|
? "Validation failed: Someone else's post cannot be pinned"
|
||||||
|
: "Validation failed: A boost cannot be pinned")
|
||||||
|
: NotFoundError();
|
||||||
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
|
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
|
||||||
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
|
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
|
||||||
|
|||||||
@@ -198,7 +198,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||||
{
|
{
|
||||||
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||||
return notification == default ? NotFoundError() : Json((await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault());
|
var mapped = notification == default ? default : (await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault();
|
||||||
|
return mapped == default ? NotFoundError() : Json(mapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
|
[HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using MongoDB.Entities;
|
|||||||
|
|
||||||
using PrivaPub.Models.Group;
|
using PrivaPub.Models.Group;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
|
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
@@ -41,8 +42,20 @@ namespace PrivaPub.Domain.Privacy
|
|||||||
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
|
PostVisibility.Circle => !string.IsNullOrEmpty(post.GroupId) && await DB.Default.Find<GroupEntity>()
|
||||||
.Match(g => g.ID == post.GroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId))
|
.Match(g => g.ID == post.GroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == viewerAvatarId))
|
||||||
.ExecuteAnyAsync(token),
|
.ExecuteAnyAsync(token),
|
||||||
|
PostVisibility.FollowersOnly => await FollowsAuthor(viewerAvatarId, post, token),
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async Task<bool> FollowsAuthor(string viewerAvatarId, Post post, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(post.ActorURI))
|
||||||
|
return await DB.Default.Find<Following>()
|
||||||
|
.Match(f => f.AvatarId == viewerAvatarId && f.TargetActorURI == post.ActorURI && f.State == FollowState.Accepted)
|
||||||
|
.ExecuteAnyAsync(token);
|
||||||
|
return !post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && await DB.Default.Find<Following>()
|
||||||
|
.Match(f => f.AvatarId == viewerAvatarId && f.TargetIsLocal && f.TargetAccountId == post.GroupUserId && f.State == FollowState.Accepted)
|
||||||
|
.ExecuteAnyAsync(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ namespace PrivaPub.Domain.Relationships
|
|||||||
{
|
{
|
||||||
Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token);
|
Task Block(LocalActor me, string targetActorUri, string targetAccountId, CancellationToken token);
|
||||||
Task Unblock(LocalActor me, string targetActorUri, CancellationToken token);
|
Task Unblock(LocalActor me, string targetActorUri, CancellationToken token);
|
||||||
|
Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token);
|
||||||
Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token);
|
Task Mute(LocalActor me, string targetActorUri, string targetAccountId, bool hideNotifications, TimeSpan? duration, CancellationToken token);
|
||||||
Task Unmute(LocalActor me, string targetActorUri, CancellationToken token);
|
Task Unmute(LocalActor me, string targetActorUri, CancellationToken token);
|
||||||
Task BlockDomain(LocalActor me, string domain, CancellationToken token);
|
Task BlockDomain(LocalActor me, string domain, CancellationToken token);
|
||||||
@@ -49,11 +50,19 @@ namespace PrivaPub.Domain.Relationships
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _follows.UnfollowAs(me, targetActorUri, token);
|
await _follows.UnfollowAs(me, targetActorUri, token);
|
||||||
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == targetActorUri).ExecuteFirstAsync(token);
|
await RemoveFollower(me, targetActorUri, targetAccountId, token);
|
||||||
if (follower != default)
|
await Forget(me, targetAccountId, token);
|
||||||
|
if (block != default)
|
||||||
|
await Tell(me, targetActorUri, BlockActivity(me, block), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveFollower(LocalActor me, string followerActorUri, string followerAccountId, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
var follower = await _dbEntities.Followers.Match(f => f.LocalActorId == me.Id && f.ActorURI == followerActorUri).ExecuteFirstAsync(token);
|
||||||
|
if (follower == default)
|
||||||
|
return;
|
||||||
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
await DB.Default.DeleteAsync<Follower>(follower.ID);
|
||||||
if (!targetActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
|
if (!followerActorUri.StartsWith(me.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(follower.FollowActivityURI))
|
||||||
{
|
{
|
||||||
var reject = new JsonObject
|
var reject = new JsonObject
|
||||||
{
|
{
|
||||||
@@ -61,16 +70,12 @@ namespace PrivaPub.Domain.Relationships
|
|||||||
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
|
["id"] = me.ActivityUri($"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"),
|
||||||
["type"] = "Reject",
|
["type"] = "Reject",
|
||||||
["actor"] = me.Uri,
|
["actor"] = me.Uri,
|
||||||
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = targetActorUri, ["object"] = me.Uri }
|
["object"] = new JsonObject { ["id"] = follower.FollowActivityURI, ["type"] = "Follow", ["actor"] = followerActorUri, ["object"] = me.Uri }
|
||||||
};
|
};
|
||||||
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
|
await _delivery.Enqueue(me, new[] { follower.InboxURL }, reject, token);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == targetAccountId);
|
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == me.Uri && f.AvatarId == followerAccountId);
|
||||||
}
|
|
||||||
await Forget(me, targetAccountId, token);
|
|
||||||
if (block != default)
|
|
||||||
await Tell(me, targetActorUri, BlockActivity(me, block), token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
|
public async Task Unblock(LocalActor me, string targetActorUri, CancellationToken token)
|
||||||
|
|||||||
Reference in new issue
Block a user