2026-10-04 03:48:32 +02:00
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.Models.Federation;
|
|
|
|
|
using PrivaPub.Models.Post;
|
|
|
|
|
using PrivaPub.Models.Social;
|
|
|
|
|
using PrivaPub.Tests.Support;
|
|
|
|
|
using PrivaPub.Tests.Support.Host;
|
|
|
|
|
|
|
|
|
|
using System.Net;
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
|
|
|
|
|
using static PrivaPub.Tests.Support.Host.FederationHelpers;
|
|
|
|
|
|
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
|
|
|
|
|
|
namespace PrivaPub.Tests.Http
|
|
|
|
|
{
|
|
|
|
|
// Two views of the same fact give one answer (owner decision 2026-10-04: fix the mismatches).
|
|
|
|
|
[Trait("Category", "Integration")]
|
|
|
|
|
public sealed class OneAnswerTests : IAsyncLifetime
|
|
|
|
|
{
|
|
|
|
|
PrivaPubHost _host;
|
|
|
|
|
HttpClient _client;
|
|
|
|
|
Peer _peer;
|
|
|
|
|
|
|
|
|
|
public async ValueTask InitializeAsync()
|
|
|
|
|
{
|
|
|
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
|
|
|
_host = await PrivaPubHost.Shared();
|
|
|
|
|
_client = _host.Client();
|
|
|
|
|
_peer = await Peer.Start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async ValueTask DisposeAsync()
|
|
|
|
|
{
|
|
|
|
|
_client?.Dispose();
|
|
|
|
|
if (_peer != default)
|
|
|
|
|
await _peer.DisposeAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Anyone_may_search_and_only_a_signed_in_reader_resolves_or_pages()
|
|
|
|
|
{
|
|
|
|
|
var reader = await _host.Mastodon("searcher");
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await _client.Get("/api/v2/search?q=somebody")).Status);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&resolve=true")).Status);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&offset=5")).Status);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v2/search?q=somebody&offset=5")).Status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_remote_account_that_deletes_itself_leaves_nothing_in_the_counts()
|
|
|
|
|
{
|
|
|
|
|
var token = TestContext.Current.CancellationToken;
|
|
|
|
|
var author = await _host.Mastodon("liked");
|
|
|
|
|
var post = await author.Status("like me");
|
|
|
|
|
var fan = new RemoteActor(_peer, "fickle");
|
|
|
|
|
var like = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["id"] = $"{fan.Id}#likes/{Guid.NewGuid():N}",
|
|
|
|
|
["type"] = "Like",
|
|
|
|
|
["actor"] = fan.Id,
|
|
|
|
|
["object"] = post.Text("uri")
|
|
|
|
|
};
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", like), token)).StatusCode);
|
|
|
|
|
await _host.RunInbox(like["id"]!.GetValue<string>(), token);
|
|
|
|
|
Assert.Equal(1, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count"));
|
|
|
|
|
|
|
|
|
|
var delete = new JsonObject
|
|
|
|
|
{
|
|
|
|
|
["id"] = $"{fan.Id}#delete",
|
|
|
|
|
["type"] = "Delete",
|
|
|
|
|
["actor"] = fan.Id,
|
|
|
|
|
["object"] = fan.Id,
|
|
|
|
|
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public")
|
|
|
|
|
};
|
|
|
|
|
Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", delete), token)).StatusCode);
|
|
|
|
|
await _host.RunInbox(delete["id"]!.GetValue<string>(), token);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(0, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count"));
|
|
|
|
|
Assert.Empty((await author.Client.Get($"/api/v1/statuses/{post.Text("id")}/favourited_by")).Ok().Array);
|
|
|
|
|
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.ActorURI == fan.Id).ExecuteAnyAsync(token));
|
|
|
|
|
Assert.False(await DB.Default.Find<Notification>().Match(n => n.FromActorURI == fan.Id).ExecuteAnyAsync(token));
|
|
|
|
|
Assert.Equal(0, (await author.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Only_public_and_unlisted_replies_are_counted()
|
|
|
|
|
{
|
|
|
|
|
var author = await _host.Mastodon("threadstart");
|
|
|
|
|
var replier = await _host.Mastodon("replier");
|
|
|
|
|
var root = await author.Status("say something");
|
|
|
|
|
var id = root.Text("id");
|
|
|
|
|
|
|
|
|
|
await replier.Status("in public", ("in_reply_to_id", id));
|
|
|
|
|
await replier.Status("quietly", ("in_reply_to_id", id), ("visibility", "unlisted"));
|
|
|
|
|
await replier.Status("for my followers", ("in_reply_to_id", id), ("visibility", "private"));
|
|
|
|
|
await replier.Status($"@{author.UserName} just you", ("in_reply_to_id", id), ("visibility", "direct"));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(2, (await author.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Number("replies_count"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_status_longer_than_advertised_is_refused()
|
|
|
|
|
{
|
|
|
|
|
var author = await _host.Mastodon("wordy");
|
|
|
|
|
var limit = (await _client.Get("/api/v2/instance")).Ok().Body["configuration"]!["statuses"].Number("max_characters");
|
|
|
|
|
|
|
|
|
|
var refused = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit + 1)));
|
|
|
|
|
var accepted = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit)));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, refused.Status);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, accepted.Status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Joining_a_community_by_invitation_is_following_it()
|
|
|
|
|
{
|
|
|
|
|
var token = TestContext.Current.CancellationToken;
|
|
|
|
|
var owner = await _host.Persona(await _host.SignUp(), "commowner");
|
|
|
|
|
var joinerRoot = await _host.SignUp("joiner");
|
|
|
|
|
var joiner = await _host.Persona(joinerRoot, "joiner");
|
|
|
|
|
var group = await _host.Group(owner, community: true);
|
|
|
|
|
var groupName = group["userName"]!.GetValue<string>();
|
|
|
|
|
using var client = _host.As(joinerRoot.Jwt);
|
|
|
|
|
|
|
|
|
|
var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = group["invitationCode"]!.GetValue<string>() });
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
|
|
|
|
|
Assert.Equal(1, (await _client.Fetch($"/peasants/{groupName}/groupies")).Json["totalItems"]!.GetValue<int>());
|
|
|
|
|
Assert.True(await DB.Default.Find<Following>().Match(f => f.AvatarId == joiner.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static TheoryData<string> AnsweredRoutes() => new()
|
|
|
|
|
{
|
|
|
|
|
"/api/v1/directory",
|
|
|
|
|
"/api/v1/tags/cats",
|
|
|
|
|
"/api/v1/instance/languages",
|
|
|
|
|
"/api/v1/instance/translation_languages",
|
|
|
|
|
"/api/v1/instance/domain_blocks",
|
|
|
|
|
"/api/v1/instance/privacy_policy",
|
|
|
|
|
"/api/v1/timelines/link?url=https%3A%2F%2Fexample.org%2F",
|
|
|
|
|
"/api/v1/accounts/000000000000000000000000/identity_proofs"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
[Theory]
|
|
|
|
|
[MemberData(nameof(AnsweredRoutes))]
|
|
|
|
|
public async Task Mastodon_routes_we_have_nothing_behind_answer_instead_of_404(string path)
|
|
|
|
|
{
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await _client.Get(path)).Status);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:56:18 +02:00
|
|
|
[Fact]
|
|
|
|
|
public async Task Likes_of_one_post_come_as_one_group_of_notifications()
|
|
|
|
|
{
|
|
|
|
|
var author = await _host.Mastodon("grouped");
|
|
|
|
|
var first = await _host.Mastodon("fanone");
|
|
|
|
|
var second = await _host.Mastodon("fantwo");
|
|
|
|
|
var post = await author.Status("like me twice");
|
|
|
|
|
var id = post.Text("id");
|
|
|
|
|
await first.Client.Post($"/api/v1/statuses/{id}/favourite");
|
|
|
|
|
await second.Client.Post($"/api/v1/statuses/{id}/favourite");
|
|
|
|
|
|
|
|
|
|
var list = (await author.Client.Get("/api/v2/notifications")).Ok().Body;
|
|
|
|
|
var group = Assert.Single(list["notification_groups"]!.AsArray(), g => g!.Text("type") == "favourite")!;
|
|
|
|
|
var key = group.Text("group_key");
|
|
|
|
|
|
|
|
|
|
Assert.Equal($"grouped-favourite-{id}", key);
|
|
|
|
|
Assert.Equal(2, group.Number("notifications_count"));
|
|
|
|
|
Assert.Equal(2, group["sample_account_ids"]!.AsArray().Count);
|
|
|
|
|
Assert.Equal(id, group.Text("status_id"));
|
|
|
|
|
Assert.Contains(list["statuses"]!.AsArray(), s => s!.Text("id") == id);
|
|
|
|
|
Assert.True((await author.Client.Get("/api/v2/notifications/unread_count")).Ok().Body.Number("count") >= 1);
|
|
|
|
|
Assert.Equal(key, Assert.Single((await author.Client.Get($"/api/v2/notifications/{key}")).Ok().Body["notification_groups"]!.AsArray())!.Text("group_key"));
|
|
|
|
|
Assert.Equal(2, (await author.Client.Get($"/api/v2/notifications/{key}/accounts")).Ok().Array.Count);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await author.Client.Post($"/api/v2/notifications/{key}/dismiss")).Status);
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, (await author.Client.Get($"/api/v2/notifications/{key}")).Status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_hashtag_link_opens_a_page_of_public_posts_with_that_tag()
|
|
|
|
|
{
|
|
|
|
|
var author = await _host.Mastodon("tagger");
|
|
|
|
|
var tag = $"tag{Guid.NewGuid():N}"[..16];
|
|
|
|
|
await author.Status($"about #{tag}");
|
|
|
|
|
await author.Status($"quietly about #{tag}", ("visibility", "private"));
|
|
|
|
|
|
|
|
|
|
var page = await _client.Fetch($"/tags/{tag}", Browser);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, page.Status);
|
|
|
|
|
Assert.Contains("about", page.Text);
|
|
|
|
|
Assert.DoesNotContain("quietly", page.Text);
|
|
|
|
|
Assert.Contains("default-src 'none'", page.Response.Headers.GetValues("Content-Security-Policy").Single());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_communitys_announces_resolve_while_the_post_is_shown()
|
|
|
|
|
{
|
|
|
|
|
var token = TestContext.Current.CancellationToken;
|
|
|
|
|
var owner = await _host.Persona(await _host.SignUp(), "announcer");
|
|
|
|
|
var community = await _host.FederatedGroup(owner, community: true);
|
|
|
|
|
var post = await _host.Publish(owner, new PrivaPub.Domain.Statuses.StatusDraft { Text = "into the community", PlainText = true, GroupId = community.Id });
|
|
|
|
|
var kept = await DB.Default.Find<GroupAnnouncement>().Match(a => a.GroupId == community.Id && a.ObjectURI == post.ObjectURI).ExecuteAsync(token);
|
|
|
|
|
Assert.Equal(2, kept.Count);//the activity, and the object for Mastodon
|
|
|
|
|
|
|
|
|
|
foreach (var announcement in kept)
|
|
|
|
|
{
|
|
|
|
|
var fetched = await _client.Fetch(PathOf(announcement.ActivityURI));
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, fetched.Status);
|
|
|
|
|
Assert.Equal("Announce", fetched.Json["type"]!.GetValue<string>());
|
|
|
|
|
Assert.Equal(announcement.ActivityURI, fetched.Json["id"]!.GetValue<string>());
|
|
|
|
|
}
|
|
|
|
|
var listed = await _client.Fetch($"/peasants/{community.UserName}/grunts/announce-{post.ID}");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, listed.Status);
|
|
|
|
|
|
|
|
|
|
await _host.Remove(owner, post);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(PathOf(kept[0].ActivityURI))).Status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_boost_points_at_the_boosted_posts_page()
|
|
|
|
|
{
|
|
|
|
|
var author = await _host.Mastodon("boosted");
|
|
|
|
|
var booster = await _host.Mastodon("booster");
|
|
|
|
|
var post = await author.Status("boost me");
|
|
|
|
|
|
|
|
|
|
var boost = (await booster.Client.Post($"/api/v1/statuses/{post.Text("id")}/reblog")).Ok().Body;
|
|
|
|
|
|
|
|
|
|
Assert.Equal(post.Text("url"), boost.Text("url"));
|
|
|
|
|
var browser = await _client.Fetch(PathOf(boost.Text("uri")), Browser);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_remote_accounts_counts_are_what_its_server_publishes()
|
|
|
|
|
{
|
|
|
|
|
var token = TestContext.Current.CancellationToken;
|
|
|
|
|
var reader = await _host.Mastodon("counter");
|
|
|
|
|
var popular = new RemoteActor(_peer, "popular");
|
|
|
|
|
_peer.Serve(new Uri(popular.Id).AbsolutePath + "/followers",
|
|
|
|
|
$$"""{"@context":"https://www.w3.org/ns/activitystreams","id":"{{popular.Id}}/followers","type":"OrderedCollection","totalItems":42}""");
|
|
|
|
|
var remote = _host.Get<PrivaPub.Federation.Actors.IRemoteActorService>();
|
|
|
|
|
var account = await remote.GetActor(popular.Id, refresh: true, token);
|
|
|
|
|
|
|
|
|
|
await _host.Run(j => j.Kind == PrivaPub.Models.Jobs.JobKind.CountAccount && j.Payload == popular.Id, token);
|
|
|
|
|
|
|
|
|
|
var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body;
|
|
|
|
|
Assert.Equal(42, shown.Number("followers_count"));
|
|
|
|
|
Assert.Equal(0, shown.Number("following_count"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:48:32 +02:00
|
|
|
[Fact]
|
|
|
|
|
public async Task Notification_policy_and_requests_answer_a_signed_in_reader()
|
|
|
|
|
{
|
|
|
|
|
var reader = await _host.Mastodon("policy");
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v1/notifications/policy")).Status);
|
|
|
|
|
Assert.Equal("accept", (await reader.Client.Get("/api/v2/notifications/policy")).Ok().Body.Text("for_not_following"));
|
|
|
|
|
Assert.Empty((await reader.Client.Get("/api/v1/notifications/requests")).Ok().Array);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|