T5: OAuth and the client API over HTTP

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:53:23 +02:00
1 parent c301f0c498
commit c5e4934ba6
10 files changed
+1879 -10

No files matched your search

@@ -0,0 +1,308 @@
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);
}
}
}