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
258 lines
13 KiB
C#
258 lines
13 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Tests.Support;
|
|
using PrivaPub.Tests.Support.Host;
|
|
|
|
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Tests.Http
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class ClientApiGroupsTests : IAsyncLifetime
|
|
{
|
|
const string DoorPassword = "door-Pass-1";
|
|
|
|
PrivaPubHost _host;
|
|
Peer _peer;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_host = await PrivaPubHost.Shared();
|
|
_peer = await Peer.Start();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_peer != default)
|
|
await _peer.DisposeAsync();
|
|
}
|
|
|
|
async Task<Persona> NewPersona(string name) => await _host.Persona(await _host.SignUp(name), name);
|
|
|
|
static Task<GroupEntity> Stored(JsonObject group) =>
|
|
DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
|
|
|
async Task<List<string>> Groups(Persona persona)
|
|
{
|
|
using var client = _host.As(persona.Root.Jwt);
|
|
var response = await client.GetAsync("/clientapi/group/list?avatarId=" + persona.Id, TestContext.Current.CancellationToken);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
return (await response.JsonItems()).Select(g => g!["id"]!.GetValue<string>()).ToList();
|
|
}
|
|
|
|
async Task<HttpResponseMessage> Approve(Persona manager, JsonObject group, string actorUri)
|
|
{
|
|
using var client = _host.As(manager.Root.Jwt);
|
|
return await client.PostJson("/clientapi/group/approve", new { avatarId = manager.Id, groupId = group["id"]!.GetValue<string>(), memberActorURI = actorUri });
|
|
}
|
|
|
|
async Task<(Persona Owner, JsonObject Circle, RemoteActor Member)> CircleWithRemoteMember()
|
|
{
|
|
var owner = await NewPersona("circleowner");
|
|
var circle = await _host.Group(owner, community: false, name: "circle");
|
|
var member = new RemoteActor(_peer, "member");
|
|
await _host.Follow(member, _peer.A, circle["userName"]!.GetValue<string>());
|
|
Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode);
|
|
return (owner, circle, member);
|
|
}
|
|
|
|
async Task<HttpStatusCode> Get(string path, RemoteActor signer = default)
|
|
{
|
|
using var client = _host.Client();
|
|
using var request = signer == default ? new HttpRequestMessage(HttpMethod.Get, path) : signer.SignedGet(path);
|
|
request.Headers.Accept.ParseAdd("application/activity+json");
|
|
return (await client.SendAsync(request, TestContext.Current.CancellationToken)).StatusCode;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_community_and_a_circle_are_created()
|
|
{
|
|
var owner = await NewPersona("founder");
|
|
|
|
var community = await _host.Group(owner, community: true, name: "community");
|
|
var circle = await _host.Group(owner, community: false, password: DoorPassword, name: "circle");
|
|
|
|
Assert.True(community["isCommunity"]!.GetValue<bool>());
|
|
Assert.False(circle["isCommunity"]!.GetValue<bool>());
|
|
foreach (var group in new[] { community, circle })
|
|
{
|
|
Assert.True(group["isOwner"]!.GetValue<bool>());
|
|
Assert.Equal(64, group["invitationCode"]!.GetValue<string>().Length);
|
|
Assert.Equal($"{PrivaPubHost.Base}/peasants/{group["userName"]!.GetValue<string>()}", group["url"]!.GetValue<string>());
|
|
Assert.Equal(1, group["membersCount"]!.GetValue<int>());
|
|
}
|
|
Assert.True(circle["isPasswordRequired"]!.GetValue<bool>());
|
|
Assert.False(community["isPasswordRequired"]!.GetValue<bool>());
|
|
Assert.Equal(GroupKind.Community, (await Stored(community)).Kind);
|
|
var storedCircle = await Stored(circle);
|
|
Assert.Equal(GroupKind.Circle, storedCircle.Kind);
|
|
Assert.NotEqual(DoorPassword, storedCircle.HashedInvitationPassword);
|
|
Assert.Contains(storedCircle.Members, m => m.AvatarId == owner.Id && m.Role == GroupRole.Owner);
|
|
Assert.Equal(new[] { community["id"]!.GetValue<string>(), circle["id"]!.GetValue<string>() }.Order(), (await Groups(owner)).Order());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_group_needs_a_persona_of_ones_own()
|
|
{
|
|
var owner = await NewPersona("founder");
|
|
var stranger = await _host.SignUp("stranger");
|
|
using var client = _host.As(stranger.Jwt);
|
|
|
|
var insert = await client.PostJson("/clientapi/group/insert", new { avatarId = owner.Id, userName = $"stolen{Guid.NewGuid():N}"[..20], name = "stolen", isCommunity = true });
|
|
var list = await client.GetAsync("/clientapi/group/list?avatarId=" + owner.Id, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, insert.StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, list.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Joining_takes_the_code_and_the_password()
|
|
{
|
|
var owner = await NewPersona("host");
|
|
var circle = await _host.Group(owner, community: false, password: DoorPassword);
|
|
var joiner = await NewPersona("guest");
|
|
var invitationCode = circle["invitationCode"]!.GetValue<string>();
|
|
using var client = _host.As(joiner.Root.Jwt);
|
|
|
|
var noPassword = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode });
|
|
var wrongPassword = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = "wrong" });
|
|
var wrongCode = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = $"{Guid.NewGuid():N}{Guid.NewGuid():N}", invitationPassword = DoorPassword });
|
|
var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = DoorPassword });
|
|
var twice = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = DoorPassword });
|
|
|
|
Assert.Equal(HttpStatusCode.NotAcceptable, noPassword.StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotAcceptable, wrongPassword.StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, wrongCode.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
|
|
var view = await joined.JsonBody();
|
|
Assert.Equal(2, view["membersCount"]!.GetValue<int>());
|
|
Assert.False(view["isOwner"]!.GetValue<bool>());
|
|
Assert.Null(view["invitationCode"]);
|
|
Assert.Equal(HttpStatusCode.OK, twice.StatusCode);
|
|
Assert.Equal(2, (await Stored(circle)).Members.Count);
|
|
Assert.Equal(new[] { circle["id"]!.GetValue<string>() }, await Groups(joiner));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Joining_needs_a_persona_of_ones_own()
|
|
{
|
|
var owner = await NewPersona("host");
|
|
var community = await _host.Group(owner, community: true);
|
|
var victim = await NewPersona("victim");
|
|
var stranger = await _host.SignUp("stranger");
|
|
using var client = _host.As(stranger.Jwt);
|
|
|
|
var response = await client.PostJson("/clientapi/group/join", new { avatarId = victim.Id, invitationCode = community["invitationCode"]!.GetValue<string>() });
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
Assert.DoesNotContain((await Stored(community)).Members, m => m.AvatarId == victim.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Members_leave_and_owners_cannot()
|
|
{
|
|
var owner = await NewPersona("host");
|
|
var community = await _host.Group(owner, community: true);
|
|
var member = await NewPersona("leaver");
|
|
var groupId = community["id"]!.GetValue<string>();
|
|
using (var joining = _host.As(member.Root.Jwt))
|
|
Assert.Equal(HttpStatusCode.OK, (await joining.PostJson("/clientapi/group/join", new { avatarId = member.Id, invitationCode = community["invitationCode"]!.GetValue<string>() })).StatusCode);
|
|
using var memberClient = _host.As(member.Root.Jwt);
|
|
using var ownerClient = _host.As(owner.Root.Jwt);
|
|
|
|
var left = await memberClient.PostJson("/clientapi/group/leave", new { avatarId = member.Id, groupId });
|
|
var ownerLeft = await ownerClient.PostJson("/clientapi/group/leave", new { avatarId = owner.Id, groupId });
|
|
|
|
Assert.True(left.IsSuccessStatusCode);
|
|
Assert.Empty(await Groups(member));
|
|
Assert.Equal(HttpStatusCode.BadRequest, ownerLeft.StatusCode);
|
|
Assert.Equal(new[] { owner.Id }, (await Stored(community)).Members.Select(m => m.AvatarId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_circle_takes_a_remote_member_only_once_the_owner_approves()
|
|
{
|
|
var owner = await NewPersona("approver");
|
|
var circle = await _host.Group(owner, community: false);
|
|
var member = new RemoteActor(_peer, "asker");
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
|
|
await _host.Follow(member, _peer.A, circle["userName"]!.GetValue<string>());
|
|
|
|
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle["id"]!.GetValue<string>() && f.ActorURI == member.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
|
|
Assert.False(request.IsAccepted);
|
|
Assert.DoesNotContain((await Stored(circle)).Members, m => m.AvatarId == member.Id);
|
|
Assert.Empty(await Jobs.Deliveries(member.Id + "/inbox", since, TestContext.Current.CancellationToken));
|
|
|
|
var outsider = await NewPersona("outsider");
|
|
Assert.Equal(HttpStatusCode.NotFound, (await Approve(outsider, circle, member.Id)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await Approve(owner, circle, $"{_peer.A}/users/nobody")).StatusCode);
|
|
Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode);
|
|
|
|
Assert.Contains((await Stored(circle)).Members, m => m.IsForeign && m.AvatarId == member.Id);
|
|
Assert.True((await DB.Default.Find<Follower>().MatchID(request.ID).ExecuteFirstAsync(TestContext.Current.CancellationToken)).IsAccepted);
|
|
var accept = Assert.Single(await Jobs.Deliveries(member.Id + "/inbox", since, TestContext.Current.CancellationToken));
|
|
Assert.Equal("Accept", accept["type"]!.GetValue<string>());
|
|
Assert.Equal(circle["url"]!.GetValue<string>(), accept["actor"]!.GetValue<string>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_circle_is_never_found()
|
|
{
|
|
var owner = await NewPersona("hidden");
|
|
var circle = await _host.Group(owner, community: false, name: "circle");
|
|
var community = await _host.Group(owner, community: true, name: "community");
|
|
var circleName = circle["userName"]!.GetValue<string>();
|
|
var communityName = community["userName"]!.GetValue<string>();
|
|
using var anonymous = _host.Client();
|
|
using var searcher = _host.As(await _host.MastodonToken(await NewPersona("searcher")));
|
|
|
|
foreach (var acct in new[] { circleName, $"{circleName}@{PrivaPubHost.Host}", $"@{circleName}@{PrivaPubHost.Host}" })
|
|
{
|
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/v1/accounts/lookup?acct=" + Uri.EscapeDataString(acct), TestContext.Current.CancellationToken)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await searcher.GetAsync("/api/v1/accounts/lookup?acct=" + Uri.EscapeDataString(acct), TestContext.Current.CancellationToken)).StatusCode);
|
|
}
|
|
Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync("/api/v1/accounts/lookup?acct=" + communityName, TestContext.Current.CancellationToken)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await searcher.GetAsync("/api/v1/accounts/" + circle["id"]!.GetValue<string>(), TestContext.Current.CancellationToken)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, (await searcher.GetAsync("/api/v1/accounts/" + community["id"]!.GetValue<string>(), TestContext.Current.CancellationToken)).StatusCode);
|
|
|
|
foreach (var q in new[] { circleName, $"@{circleName}@{PrivaPubHost.Host}", circle["url"]!.GetValue<string>() })
|
|
{
|
|
var found = await searcher.GetStringAsync($"/api/v2/search?resolve=true&q={Uri.EscapeDataString(q)}", TestContext.Current.CancellationToken);
|
|
Assert.Empty(JsonNode.Parse(found)!["accounts"]!.AsArray());
|
|
Assert.Empty(JsonNode.Parse(found)!["statuses"]!.AsArray());
|
|
}
|
|
var communityFound = await searcher.GetStringAsync($"/api/v2/search?resolve=true&q={Uri.EscapeDataString(community["url"]!.GetValue<string>())}", TestContext.Current.CancellationToken);
|
|
Assert.Single(JsonNode.Parse(communityFound)!["accounts"]!.AsArray());
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"/@{circleName}", TestContext.Current.CancellationToken)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync($"/@{communityName}", TestContext.Current.CancellationToken)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_circles_members_and_wardens_are_shown_only_to_members()
|
|
{
|
|
var (_, circle, member) = await CircleWithRemoteMember();
|
|
var outsider = new RemoteActor(_peer, "outsider");
|
|
var community = await _host.Group(await NewPersona("open"), community: true);
|
|
var circleName = circle["userName"]!.GetValue<string>();
|
|
var communityName = community["userName"]!.GetValue<string>();
|
|
|
|
foreach (var collection in new[] { "flock", "wardens" })
|
|
{
|
|
var path = $"/peasants/{circleName}/{collection}";
|
|
Assert.Equal(HttpStatusCode.NotFound, await Get(path));
|
|
Assert.Equal(HttpStatusCode.NotFound, await Get(path, outsider));
|
|
Assert.Equal(HttpStatusCode.OK, await Get(path, member));
|
|
Assert.Equal(HttpStatusCode.OK, await Get($"/peasants/{communityName}/{collection}"));
|
|
}
|
|
}
|
|
}
|
|
}
|