96 integration tests through PrivaPubHost, the real pipeline end to end:
- OAuth: the token's subject is the persona, and neither the token response,
verify_credentials nor the stored token entries name the root. A wrong password shows
an error and sets no login cookie; a login without the antiforgery token is a 400; the
return address never leaves the site; deny answers access_denied with no code; another
root's persona re-renders the choice with no code; a banned root is sent back to the
login and a code issued before the ban buys no token; force_login asks again; a code
works once and its reuse revokes the token it bought; password and refresh_token
grants are refused; a client_credentials token gets 401 on user routes; a read-only
token gets 403 with a Mastodon error on POST /api/v1/statuses; follow covers
read:follows; revoke works; the login and authorize pages send their CSP and no-store;
the 11th /oauth/login from one address in a minute is a 429.
- Accounts: sign-up, duplicates in any case, invalid models answer 400 with a message,
login and logout, recovery email, settings, password change, invitation sign-up and
login (refusing a persona named after the login), recovery without an email, through
an unreachable mail server, with a wrong and with a valid code, token refresh, the 11th
sign-up from one address, expired, garbage and foreign-key JWTs.
- Personas: a rootId in the body is ignored, the username regex and reserved names hold,
personas and groups share ReservedName, an update delivers Update{Person} to followers,
PublishedOn and the id's day fall within two weeks before creation, the list holds only
one's own personas, another root's persona cannot be updated.
- Groups: communities and circles are created, joining takes the code and the password,
members leave and owners cannot, a remote follow request becomes a member only on
approval, a circle never shows in lookup, account by id, v2 search or /@name, and its
/flock and /wardens answer 404 unsigned and to non-members, 200 to a member's signed GET.
- Moderation (Exclusive, it suspends localhost): ban, unban and remove; non-admins get
403; reports are listed without the reporter and resolved; domain blocks are inserted,
listed and deleted, bad domains refused, and a suspended server's delivery is answered
202 and kept nowhere; the data endpoints.
- AdminCommands: exit codes 0, 1 and 2 and the resulting policies.
Fixed:
- A banned or removed root kept using /clientapi with its JWT until it expired: only /api
re-checked the root. JwtEvents.TokenValidated now loads the root and fails the request
when it is banned or deleted, and takes the policy claims from the database, so a
demoted admin loses admin at once (and a promoted one gains it).
- The 401 and 403 bodies JwtEvents writes were PascalCase while every other /clientapi
answer is camelCase; they now use the web defaults.
- /clientapi/user/sniff/again (token refresh) answered an empty 200; it now answers a
fresh JwtUser, like login.
- Password recovery answered SMTP reply codes as HTTP statuses (421, 454, 554, and 550 for
an invalid address); a mail server failure is now 503 and an invalid address 400.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
309 lines
15 KiB
C#
309 lines
15 KiB
C#
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<HttpStatusCode> Settings(string jwt)
|
|
{
|
|
using var client = _host.As(jwt);
|
|
return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode;
|
|
}
|
|
|
|
async Task<HttpStatusCode> Credentials(string token)
|
|
{
|
|
using var client = _host.As(token);
|
|
return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode;
|
|
}
|
|
|
|
async Task<HttpResponseMessage> LogIn(Root root)
|
|
{
|
|
using var client = _host.Client();
|
|
return await client.PostJson("/clientapi/user/login", new { userName = root.UserName, password = root.Password });
|
|
}
|
|
|
|
static Task<HttpResponseMessage> Remove(HttpClient client, params string[] ids) =>
|
|
client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = ids }) });
|
|
|
|
static Task<RootUser> Stored(string id) => DB.Default.Find<RootUser>().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"] = "<p>hello</p>" }
|
|
};
|
|
}
|
|
|
|
[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<string>());
|
|
|
|
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<HttpResponseMessage>
|
|
{
|
|
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<int>());
|
|
Assert.Equal("Forbidden.", body["errorMessage"]!.GetValue<string>());
|
|
}
|
|
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<DomainBlock>().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<Models.Social.Report>().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<string>() == reportId)!;
|
|
Assert.Equal("local", listed["reporter"]!.GetValue<string>());
|
|
Assert.Equal(target.Id, listed["targetAccountId"]!.GetValue<string>());
|
|
Assert.Equal("spam", listed["category"]!.GetValue<string>());
|
|
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<Models.Social.Report>().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<string>());
|
|
Assert.Equal("Silence", view["severity"]!.GetValue<string>());
|
|
Assert.True(view["rejectMedia"]!.GetValue<bool>());
|
|
Assert.Contains(listed, b => b!["domain"]!.GetValue<string>() == domain);
|
|
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
|
Assert.Equal("Suspend", (await suspended.JsonBody())["severity"]!.GetValue<string>());
|
|
Assert.Equal(view["id"]!.GetValue<string>(), (await suspended.JsonBody())["id"]!.GetValue<string>());
|
|
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
|
|
Assert.DoesNotContain(after, b => b!["domain"]!.GetValue<string>() == domain);
|
|
Assert.Null(_host.Get<IDomainBlocks>().Find(domain));
|
|
}
|
|
finally
|
|
{
|
|
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
|
|
await _host.Get<IDomainBlocks>().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<DomainBlock>().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<Job>().Match(j => j.DedupeKey == "inbox|" + blocked["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
|
|
Assert.Equal(0, await _host.RunInbox(blocked["id"]!.GetValue<string>(), token));
|
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == blocked["object"]!["id"]!.GetValue<string>()).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<string>(), token));
|
|
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == welcome["object"]!["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
|
|
}
|
|
finally
|
|
{
|
|
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
|
|
await _host.Get<IDomainBlocks>().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<string>()).Order().ToList();
|
|
Assert.Equal(new[] { "en", "it" }, codes);
|
|
}
|
|
}
|
|
}
|