Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.
- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
Link.
- The instance API advertises what is enforced:
- max_characters, now enforced with a 422;
- max_pinned_statuses = MaxPins;
- the media types and limits MediaService and MediaOptions accept;
- PollService's limits;
- the configured languages;
- no streaming URL until streaming exists.
domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
- directory, tags/{name}, timelines/link and identity_proofs;
- instance/languages, translation_languages, domain_blocks and privacy_policy;
- the v1 and v2 notification policy, and notification requests.
Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.
671 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
165 lines
6.5 KiB
C#
165 lines
6.5 KiB
C#
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|