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 NewPersona(string name) => await _host.Persona(await _host.SignUp(name), name); static Task Stored(JsonObject group) => DB.Default.Find().MatchID(group["id"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); async Task> 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()).ToList(); } async Task 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(), 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()); Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode); return (owner, circle, member); } async Task 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()); Assert.False(circle["isCommunity"]!.GetValue()); foreach (var group in new[] { community, circle }) { Assert.True(group["isOwner"]!.GetValue()); Assert.Equal(64, group["invitationCode"]!.GetValue().Length); Assert.Equal($"{PrivaPubHost.Base}/peasants/{group["userName"]!.GetValue()}", group["url"]!.GetValue()); Assert.Equal(1, group["membersCount"]!.GetValue()); } Assert.True(circle["isPasswordRequired"]!.GetValue()); Assert.False(community["isPasswordRequired"]!.GetValue()); 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(), circle["id"]!.GetValue() }.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(); 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()); Assert.False(view["isOwner"]!.GetValue()); Assert.Null(view["invitationCode"]); Assert.Equal(HttpStatusCode.OK, twice.StatusCode); Assert.Equal(2, (await Stored(circle)).Members.Count); Assert.Equal(new[] { circle["id"]!.GetValue() }, 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() }); 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(); 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() })).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()); var request = await DB.Default.Find().Match(f => f.LocalActorId == circle["id"]!.GetValue() && 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().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()); Assert.Equal(circle["url"]!.GetValue(), accept["actor"]!.GetValue()); } [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(); var communityName = community["userName"]!.GetValue(); 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(), TestContext.Current.CancellationToken)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await searcher.GetAsync("/api/v1/accounts/" + community["id"]!.GetValue(), TestContext.Current.CancellationToken)).StatusCode); foreach (var q in new[] { circleName, $"@{circleName}@{PrivaPubHost.Host}", circle["url"]!.GetValue() }) { 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())}", 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(); var communityName = community["userName"]!.GetValue(); 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}")); } } } }