using MongoDB.Entities; using PrivaPub.Federation.Moderation; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; using PrivaPub.Models.Post; 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")] [Xunit.Collection(nameof(Exclusive))] public sealed class ClientApiModerationTests : IAsyncLifetime { PrivaPubHost _host; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; async Task Settings(string jwt) { using var client = _host.As(jwt); return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode; } async Task Credentials(string token) { using var client = _host.As(token); return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode; } async Task LogIn(Root root) { using var client = _host.Client(); return await client.PostJson("/clientapi/user/login", new { userName = root.UserName, password = root.Password }); } static Task Remove(HttpClient client, params string[] ids) => client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = ids }) }); static Task Stored(string id) => DB.Default.Find().MatchID(id).ExecuteFirstAsync(TestContext.Current.CancellationToken); static JsonObject DirectNote(RemoteActor sender, string origin, Persona persona) { var noteId = $"{origin}/notes/{Guid.NewGuid():N}"; var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"); return new JsonObject { ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = sender.Id, ["to"] = to.DeepClone(), ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = to.DeepClone(), ["content"] = "

hello

" } }; } [Fact] public async Task An_admin_bans_and_unbans_a_root() { var admin = await _host.Admin(); var victim = await _host.Persona(await _host.SignUp("victim"), "victim"); var token = await _host.MastodonToken(victim); using var client = _host.As(admin.Jwt); var banned = await client.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { victim.Root.Id } }); Assert.Equal(HttpStatusCode.OK, banned.StatusCode); Assert.True((await Stored(victim.Root.Id)).IsBanned); Assert.Equal(HttpStatusCode.Unauthorized, await Settings(victim.Root.Jwt)); Assert.Equal(HttpStatusCode.Unauthorized, await Credentials(token)); var login = await LogIn(victim.Root); Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode); Assert.Contains("banned", (await login.JsonBody())["errorMessage"]!.GetValue()); var unbanned = await client.PostJson("/clientapi/admin/unban/users", new { userIdList = new[] { victim.Root.Id } }); Assert.Equal(HttpStatusCode.OK, unbanned.StatusCode); Assert.Equal(HttpStatusCode.OK, await Settings(victim.Root.Jwt)); Assert.Equal(HttpStatusCode.OK, await Credentials(token)); Assert.Equal(HttpStatusCode.OK, (await LogIn(victim.Root)).StatusCode); } [Fact] public async Task An_admin_cannot_ban_or_remove_itself() { var admin = await _host.Admin(); var victim = await _host.SignUp("victim"); using var client = _host.As(admin.Jwt); Assert.Equal(HttpStatusCode.OK, (await client.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { admin.Id, victim.Id } })).StatusCode); var removed = await Remove(client, admin.Id); Assert.False((await Stored(admin.Id)).IsBanned); Assert.True((await Stored(victim.Id)).IsBanned); Assert.Equal(HttpStatusCode.BadRequest, removed.StatusCode); Assert.Null((await Stored(admin.Id)).DeletedAt); Assert.Equal(HttpStatusCode.OK, await Settings(admin.Jwt)); } [Fact] public async Task An_admin_removes_a_root() { var admin = await _host.Admin(); var victim = await _host.Persona(await _host.SignUp("removed"), "removed"); var token = await _host.MastodonToken(victim); using var client = _host.As(admin.Jwt); var removed = await Remove(client, victim.Root.Id); var again = await Remove(client, victim.Root.Id); Assert.Equal(HttpStatusCode.OK, removed.StatusCode); var stored = await Stored(victim.Root.Id); Assert.NotNull(stored.DeletedAt); Assert.Null(stored.HashedPassword); Assert.NotEqual(victim.Root.UserName, stored.UserName); Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, await Settings(victim.Root.Jwt)); Assert.Equal(HttpStatusCode.Unauthorized, await Credentials(token)); Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(victim.Root)).StatusCode); } [Fact] public async Task Only_admins_moderate() { var root = await _host.SignUp("plain"); var target = await _host.SignUp("target"); using var client = _host.As(root.Jwt); using var anonymous = _host.Client(); var forbidden = new List { await client.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { target.Id } }), await client.PostJson("/clientapi/admin/unban/users", new { userIdList = new[] { target.Id } }), await Remove(client, target.Id), await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken), await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain = "forbidden.example" }), await client.PostAsync("/clientapi/admin/domainblocks/delete?domain=forbidden.example", default, TestContext.Current.CancellationToken), await client.GetAsync("/clientapi/moderator/reports", TestContext.Current.CancellationToken), await client.PostAsync($"/clientapi/moderator/reports/{target.Id}/resolve", default, TestContext.Current.CancellationToken) }; var unauthorized = await anonymous.PostJson("/clientapi/admin/ban/users", new { userIdList = new[] { target.Id } }); foreach (var response in forbidden) { Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); var body = await response.JsonBody(); Assert.Equal(403, body["statusCode"]!.GetValue()); Assert.Equal("Forbidden.", body["errorMessage"]!.GetValue()); } Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode); var stored = await Stored(target.Id); Assert.False(stored.IsBanned); Assert.Null(stored.DeletedAt); Assert.False(await DB.Default.Find().Match(b => b.Domain == "forbidden.example").ExecuteAnyAsync(TestContext.Current.CancellationToken)); } [Fact] public async Task A_moderator_lists_and_resolves_reports() { var admin = await _host.Admin(); var reporter = await _host.Persona(await _host.SignUp("reporter"), "reporter"); var target = await _host.Persona(await _host.SignUp("reported"), "reported"); var comment = $"spam {Guid.NewGuid():N}"; using (var api = _host.As(await _host.MastodonToken(reporter))) Assert.Equal(HttpStatusCode.OK, (await api.Form("/api/v1/reports", ("account_id", target.Id), ("comment", comment), ("category", "spam"))).StatusCode); var reportId = (await DB.Default.Find().Match(r => r.Comment == comment).ExecuteSingleAsync(TestContext.Current.CancellationToken)).ID; using var client = _host.As(admin.Jwt); var open = await client.GetStringAsync("/clientapi/moderator/reports?resolved=false", TestContext.Current.CancellationToken); var resolved = await client.PostAsync($"/clientapi/moderator/reports/{reportId}/resolve", default, TestContext.Current.CancellationToken); var stillOpen = await client.GetStringAsync("/clientapi/moderator/reports?resolved=false", TestContext.Current.CancellationToken); var closed = await client.GetStringAsync("/clientapi/moderator/reports?resolved=true", TestContext.Current.CancellationToken); var unknown = await client.PostAsync($"/clientapi/moderator/reports/{MongoDB.Bson.ObjectId.GenerateNewId()}/resolve", default, TestContext.Current.CancellationToken); var junk = await client.PostAsync("/clientapi/moderator/reports/x/resolve", default, TestContext.Current.CancellationToken); var listed = JsonNode.Parse(open)!.AsArray().Single(r => r!["id"]!.GetValue() == reportId)!; Assert.Equal("local", listed["reporter"]!.GetValue()); Assert.Equal(target.Id, listed["targetAccountId"]!.GetValue()); Assert.Equal("spam", listed["category"]!.GetValue()); Assert.DoesNotContain(reporter.Id, open); Assert.DoesNotContain(reporter.UserName, open); Assert.DoesNotContain(reporter.Root.Id, open); Assert.Equal(HttpStatusCode.OK, resolved.StatusCode); Assert.DoesNotContain(reportId, stillOpen); Assert.Contains(reportId, closed); var stored = await DB.Default.Find().MatchID(reportId).ExecuteFirstAsync(TestContext.Current.CancellationToken); Assert.Equal(admin.Id, stored.ResolvedBy); Assert.NotNull(stored.ResolvedAt); Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); Assert.Equal(HttpStatusCode.NotFound, junk.StatusCode); } [Fact] public async Task Domain_blocks_are_inserted_listed_and_deleted() { var admin = await _host.Admin(); var domain = $"blocked-{Guid.NewGuid():N}.example"; using var client = _host.As(admin.Jwt); try { var inserted = await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain = $" {domain.ToUpperInvariant()}. ", suspend = false, rejectMedia = true, publicComment = "noisy" }); var listed = await (await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken)).JsonItems(); var suspended = await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain, suspend = true }); var deleted = await client.PostAsync($"/clientapi/admin/domainblocks/delete?domain={domain}", default, TestContext.Current.CancellationToken); var after = await (await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken)).JsonItems(); Assert.Equal(HttpStatusCode.OK, inserted.StatusCode); var view = await inserted.JsonBody(); Assert.Equal(domain, view["domain"]!.GetValue()); Assert.Equal("Silence", view["severity"]!.GetValue()); Assert.True(view["rejectMedia"]!.GetValue()); Assert.Contains(listed, b => b!["domain"]!.GetValue() == domain); Assert.Equal(HttpStatusCode.OK, suspended.StatusCode); Assert.Equal("Suspend", (await suspended.JsonBody())["severity"]!.GetValue()); Assert.Equal(view["id"]!.GetValue(), (await suspended.JsonBody())["id"]!.GetValue()); Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); Assert.DoesNotContain(after, b => b!["domain"]!.GetValue() == domain); Assert.Null(_host.Get().Find(domain)); } finally { await DB.Default.DeleteAsync(b => b.Domain == domain); await _host.Get().Reload(CancellationToken.None); } } [Theory] [InlineData("")] [InlineData("ab")] [InlineData("not a domain")] [InlineData("127.0.0.1")] [InlineData("https://bad.example/")] [InlineData("bad..example")] public async Task A_bad_domain_is_refused(string domain) { var admin = await _host.Admin(); using var client = _host.As(admin.Jwt); var response = await client.PostJson("/clientapi/admin/domainblocks/insert", new { domain }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var normalised = DomainBlocks.Normalise(domain); Assert.False(await DB.Default.Find().Match(b => b.Domain == normalised).ExecuteAnyAsync(TestContext.Current.CancellationToken)); } [Fact] public async Task A_suspended_server_is_answered_202_and_nothing_is_kept() { var token = TestContext.Current.CancellationToken; var admin = await _host.Admin(); var persona = await _host.Persona(await _host.SignUp("suspend"), "suspend"); await using var peer = await Peer.Start(); var sender = new RemoteActor(peer, "suspended", origin: peer.B); var blocked = DirectNote(sender, peer.B, persona); var welcome = DirectNote(sender, peer.B, persona); using var adminClient = _host.As(admin.Jwt); using var client = _host.Client(); try { Assert.Equal(HttpStatusCode.OK, (await adminClient.PostJson("/clientapi/admin/domainblocks/insert", new { domain = "localhost", suspend = true })).StatusCode); var answer = await client.SendAsync(sender.SignedPost($"/peasants/{persona.UserName}/mouth", blocked), token); Assert.Equal(HttpStatusCode.Accepted, answer.StatusCode); Assert.False(await DB.Default.Find().Match(j => j.DedupeKey == "inbox|" + blocked["id"]!.GetValue()).ExecuteAnyAsync(token)); Assert.Equal(0, await _host.RunInbox(blocked["id"]!.GetValue(), token)); Assert.False(await DB.Default.Find().Match(p => p.ObjectURI == blocked["object"]!["id"]!.GetValue()).ExecuteAnyAsync(token)); Assert.Empty(peer.Requests); Assert.Equal(HttpStatusCode.OK, (await adminClient.PostAsync("/clientapi/admin/domainblocks/delete?domain=localhost", default, token)).StatusCode); Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(sender.SignedPost($"/peasants/{persona.UserName}/mouth", welcome), token)).StatusCode); Assert.Equal(1, await _host.RunInbox(welcome["id"]!.GetValue(), token)); Assert.True(await DB.Default.Find().Match(p => p.ObjectURI == welcome["object"]!["id"]!.GetValue()).ExecuteAnyAsync(token)); } finally { await DB.Default.DeleteAsync(b => b.Domain == "localhost"); await _host.Get().Reload(CancellationToken.None); } } [Fact] public async Task The_data_endpoints_answer_anyone() { using var client = _host.Client(); var ping = await client.GetAsync("/clientapi/data/ping", TestContext.Current.CancellationToken); var version = await client.GetAsync("/clientapi/data/current-version", TestContext.Current.CancellationToken); var languages = await client.GetAsync("/clientapi/data/languages", TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NoContent, ping.StatusCode); Assert.Equal(HttpStatusCode.OK, version.StatusCode); Assert.False(string.IsNullOrWhiteSpace(await version.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))); Assert.Equal(HttpStatusCode.OK, languages.StatusCode); var codes = (await languages.JsonItems()).Select(l => l!["international2Code"]!.GetValue()).Order().ToList(); Assert.Equal(new[] { "en", "it" }, codes); } } }