diff --git a/PrivaPub.Tests/Http/ClientApiAccountsTests.cs b/PrivaPub.Tests/Http/ClientApiAccountsTests.cs new file mode 100644 index 0000000..794be5b --- /dev/null +++ b/PrivaPub.Tests/Http/ClientApiAccountsTests.cs @@ -0,0 +1,445 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +using MongoDB.Entities; + +using PrivaPub.ClientModels; +using PrivaPub.Models.Group; +using PrivaPub.Models.User; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Text; +using System.Text.Json.Nodes; + +using GroupEntity = PrivaPub.Models.Group.Group; + +namespace PrivaPub.Tests.Http +{ + [Trait("Category", "Integration")] + public sealed class ClientApiAccountsTests : IAsyncLifetime + { + const string NewPassword = "Other-Pass-2!"; + + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + static string Name(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..20]; + + async Task LogIn(string userName, string password) + { + using var client = _host.Client(); + return await client.PostJson("/clientapi/user/login", new { userName, password }); + } + + async Task Settings(string jwt) + { + using var client = _host.As(jwt); + return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode; + } + + static async Task Message(HttpResponseMessage response) + { + var body = await response.JsonBody(); + return body["errorMessage"]?.GetValue() ?? body["title"]?.GetValue(); + } + + string Jwt(string rootId, string userName, DateTime expires, string key = default) + { + var configuration = _host.Get(); + var signing = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key ?? configuration["AppConfiguration:Jwt:Key"]!)); + var token = new JwtSecurityToken(configuration["AppConfiguration:Jwt:Issuer"], configuration["AppConfiguration:Jwt:Audience"], + new[] { new Claim(ClaimTypes.UserData, rootId), new Claim(ClaimTypes.Name, userName), new Claim(Policies.IsUser, "true") }, + notBefore: expires.AddHours(-1), expires: expires, signingCredentials: new SigningCredentials(signing, SecurityAlgorithms.HmacSha512)); + return new JwtSecurityTokenHandler().WriteToken(token); + } + + [Fact] + public async Task Signing_up_answers_a_token_for_the_new_root() + { + var userName = Name("Signup"); + using var client = _host.Client(); + + var response = await client.PostJson("/clientapi/user/signup", new { userName, password = Accounts.Password }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var jwt = await response.JsonBody(); + Assert.Equal(userName.ToLowerInvariant(), jwt["username"]!.GetValue()); + Assert.Equal(new[] { Policies.IsUser }, jwt["policies"]!.AsArray().Select(p => p!.GetValue())); + Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue())); + var stored = await DB.Default.Find().MatchID(jwt["userId"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); + Assert.NotEqual(Accounts.Password, stored.HashedPassword); + } + + [Fact] + public async Task A_taken_username_is_refused_whatever_its_case() + { + var root = await _host.SignUp("taken"); + using var client = _host.Client(); + + var again = await client.PostJson("/clientapi/user/signup", new { userName = root.UserName, password = Accounts.Password }); + var shouted = await client.PostJson("/clientapi/user/signup", new { userName = root.UserName.ToUpperInvariant(), password = Accounts.Password }); + + Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode); + Assert.Contains("already taken", await Message(again)); + Assert.Equal(HttpStatusCode.BadRequest, shouted.StatusCode); + Assert.Equal(1, await DB.Default.CountAsync(u => u.UserName == root.UserName, TestContext.Current.CancellationToken)); + } + + public static TheoryData InvalidSignUps() => new() + { + "{}", + "{\"userName\":\"ab\",\"password\":\"Test-Pass-1!\"}", + "{\"userName\":\"has space\",\"password\":\"Test-Pass-1!\"}", + "{\"userName\":\"validname\",\"password\":\"short\"}", + "{\"userName\":\"validname\",\"password\":\"nouppercase1\"}", + "{\"userName\":\"validname\",\"password\":\"Has Space1\"}", + "{\"userName\":\"validname\",\"password\":\"Test-Pass-1!\",\"lightThemeIndexColour\":400}", + "{\"userName\":\"" + new string('a', 40) + "\",\"password\":\"Test-Pass-1!\"}", + "not json" + }; + + [Theory] + [MemberData(nameof(InvalidSignUps))] + public async Task An_invalid_sign_up_answers_400_with_a_message(string body) + { + using var client = _host.Client(); + + var response = await client.PostAsync("/clientapi/user/signup", new StringContent(body, Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(string.IsNullOrEmpty(await Message(response))); + } + + [Fact] + public async Task Logging_in_and_out() + { + var root = await _host.SignUp("login"); + + var ok = await LogIn(root.UserName, root.Password); + var wrong = await LogIn(root.UserName, "Wrong-Pass-1!"); + var unknown = await LogIn(Name("nobody"), root.Password); + using var signedIn = _host.As((await ok.JsonBody())["token"]!.GetValue()); + var logout = await signedIn.GetAsync("/clientapi/user/logout", TestContext.Current.CancellationToken); + using var anonymous = _host.Client(); + var anonymousLogout = await anonymous.GetAsync("/clientapi/user/logout", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + Assert.Equal(root.Id, (await ok.JsonBody())["userId"]!.GetValue()); + Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode); + Assert.Equal("Wrong password.", await Message(wrong)); + Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, anonymousLogout.StatusCode); + } + + [Fact] + public async Task Update_sets_the_recovery_email_and_refuses_one_in_use() + { + var root = await _host.SignUp("email"); + var other = await _host.SignUp("email"); + var email = $"{Guid.NewGuid():N}@example.test"; + using var client = _host.As(root.Jwt); + using var otherClient = _host.As(other.Jwt); + + var set = await client.PostJson("/clientapi/user/update", new { email }); + var taken = await otherClient.PostJson("/clientapi/user/update", new { email }); + var invalid = await otherClient.PostJson("/clientapi/user/update", new { email = "not an email" }); + + Assert.Equal(HttpStatusCode.OK, set.StatusCode); + Assert.Equal(email, (await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Email); + Assert.Equal(HttpStatusCode.BadRequest, taken.StatusCode); + Assert.Contains("already taken", await Message(taken)); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + Assert.Null((await DB.Default.Find().MatchID(other.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Email); + } + + [Fact] + public async Task Settings_round_trip() + { + var root = await _host.SignUp("settings"); + using var client = _host.As(root.Jwt); + + var updated = await client.PostJson("/clientapi/user/update/settings", new + { + languageCode = "it", + lightThemeIndexColour = 100, + darkThemeIndexColour = 200, + iconsThemeIndexColour = 50, + themeIsDarkMode = true, + themeIsDarkGray = true + }); + var settings = await (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).JsonBody(); + var unsupported = await client.PostJson("/clientapi/user/update/settings", new { languageCode = "xx" }); + var outOfRange = await client.PostJson("/clientapi/user/update/settings", new { languageCode = "en", lightThemeIndexColour = 360 }); + + Assert.Equal(HttpStatusCode.OK, updated.StatusCode); + Assert.Equal("it", settings["languageCode"]!.GetValue()); + Assert.Equal(100, settings["lightThemeIndexColour"]!.GetValue()); + Assert.Equal(200, settings["darkThemeIndexColour"]!.GetValue()); + Assert.Equal(50, settings["iconsThemeIndexColour"]!.GetValue()); + Assert.True(settings["themeIsDarkMode"]!.GetValue()); + Assert.True(settings["themeIsDarkGray"]!.GetValue()); + Assert.Equal(HttpStatusCode.BadRequest, unsupported.StatusCode); + Assert.Contains("unsupported", await Message(unsupported)); + Assert.Equal(HttpStatusCode.BadRequest, outOfRange.StatusCode); + Assert.Equal("it", (await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Settings.LanguageCode); + } + + [Fact] + public async Task Changing_the_password_needs_the_old_one() + { + var root = await _host.SignUp("password"); + using var client = _host.As(root.Jwt); + + var wrongOld = await client.PostJson("/clientapi/user/update/password", new { oldPassword = "Wrong-Pass-1!", newPassword = NewPassword, repeatedPassword = NewPassword }); + var mismatch = await client.PostJson("/clientapi/user/update/password", new { oldPassword = root.Password, newPassword = NewPassword, repeatedPassword = "Something-3!" }); + var weak = await client.PostJson("/clientapi/user/update/password", new { oldPassword = root.Password, newPassword = "weak", repeatedPassword = "weak" }); + var changed = await client.PostJson("/clientapi/user/update/password", new { oldPassword = root.Password, newPassword = NewPassword, repeatedPassword = NewPassword }); + + Assert.Equal(HttpStatusCode.BadRequest, wrongOld.StatusCode); + Assert.Equal("Wrong password.", await Message(wrongOld)); + Assert.Equal(HttpStatusCode.BadRequest, mismatch.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode); + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode); + } + + [Fact] + public async Task An_invitation_signs_up_a_root_with_a_persona_in_the_group() + { + var owner = await _host.Persona(await _host.SignUp("owner"), "owner"); + var group = await _host.Group(owner, community: false, password: "door-Pass-1"); + var invitationCode = group["invitationCode"]!.GetValue(); + var userName = Name("invited"); + var avatarUserName = Name("guest"); + using var client = _host.Client(); + + var wrongPassword = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode, invitationPassword = "nope", avatarUserName }); + var wrongCode = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode = $"{Guid.NewGuid():N}{Guid.NewGuid():N}", invitationPassword = "door-Pass-1", avatarUserName }); + var response = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode, invitationPassword = "door-Pass-1", avatarUserName, avatarName = "Guest" }); + + Assert.Equal(HttpStatusCode.NotAcceptable, wrongPassword.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, wrongCode.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var jwt = await response.JsonBody(); + using var invited = _host.As(jwt["token"]!.GetValue()); + var avatars = await (await invited.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems(); + var avatar = Assert.Single(avatars)!; + Assert.Equal(avatarUserName, avatar["userName"]!.GetValue()); + Assert.Equal("Guest", avatar["name"]!.GetValue()); + var stored = await DB.Default.Find().MatchID(group["id"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); + Assert.Contains(stored.Members, m => !m.IsForeign && m.AvatarId == avatar["id"]!.GetValue() && m.Role == GroupRole.Member); + } + + [Fact] + public async Task An_invitation_never_names_the_persona_after_the_login() + { + var owner = await _host.Persona(await _host.SignUp("owner"), "owner"); + var invitationCode = (await _host.Group(owner, community: true))["invitationCode"]!.GetValue(); + var userName = Name("same"); + using var client = _host.Client(); + + var same = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode, avatarUserName = userName }); + var shouted = await client.PostJson("/clientapi/user/invitation/signup", new { userName = userName.ToUpperInvariant(), password = Accounts.Password, invitationCode, avatarUserName = userName }); + var missing = await client.PostJson("/clientapi/user/invitation/signup", new { userName, password = Accounts.Password, invitationCode }); + + Assert.Equal(HttpStatusCode.BadRequest, same.StatusCode); + Assert.Equal("Your persona's username must differ from your login.", await Message(same)); + Assert.Equal(HttpStatusCode.BadRequest, shouted.StatusCode); + Assert.Equal("Your persona's username must differ from your login.", await Message(shouted)); + Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); + Assert.False(await DB.Default.Find().Match(u => u.UserName == userName).ExecuteAnyAsync(TestContext.Current.CancellationToken)); + Assert.False(await DB.Default.Find().Match(a => a.UserName == userName).ExecuteAnyAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task An_existing_root_joins_through_an_invitation_with_a_new_persona() + { + var owner = await _host.Persona(await _host.SignUp("owner"), "owner"); + var group = await _host.Group(owner, community: true); + var root = await _host.SignUp("joiner"); + var avatarUserName = Name("joined"); + using var client = _host.Client(); + + var response = await client.PostJson("/clientapi/user/invitation/login", new + { + userName = root.UserName, + password = root.Password, + invitationCode = group["invitationCode"]!.GetValue(), + avatarUserName + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var joined = _host.As(root.Jwt); + var avatar = Assert.Single(await (await joined.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems())!; + Assert.Equal(avatarUserName, avatar["userName"]!.GetValue()); + var groups = await (await joined.GetAsync("/clientapi/group/list?avatarId=" + avatar["id"]!.GetValue(), TestContext.Current.CancellationToken)).JsonItems(); + Assert.Equal(group["id"]!.GetValue(), Assert.Single(groups)!["id"]!.GetValue()); + } + + [Fact] + public async Task Recovery_without_an_email_is_refused() + { + var root = await _host.SignUp("noemail"); + using var client = _host.Client(); + + var response = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName }); + var unknown = await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }); + var neither = await client.PostJson("/clientapi/user/recover/password", new { }); + + Assert.Equal(HttpStatusCode.Locked, response.StatusCode); + Assert.Contains("doesn't have an email", await Message(response)); + Assert.False(await DB.Default.Find().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken)); + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, neither.StatusCode); + } + + [Fact] + public async Task Recovery_through_an_unreachable_mail_server_says_so_and_nothing_more() + { + var root = await _host.SignUp("smtp"); + var email = $"{Guid.NewGuid():N}@example.test"; + using (var signedIn = _host.As(root.Jwt)) + Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode); + using var client = _host.Client(); + + var byName = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName }); + var byEmail = await client.PostJson("/clientapi/user/recover/password", new { email }); + + foreach (var response in new[] { byName, byEmail }) + { + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal("Failed to send email.", JsonNode.Parse(body)!["errorMessage"]!.GetValue()); + foreach (var leak in new[] { "xception", "efused", "ocket", "127.0.0.1", "smtp", "SMTP", " at " }) + Assert.DoesNotContain(leak, body); + } + } + + [Fact] + public async Task A_wrong_recovery_code_changes_nothing() + { + using var client = _host.Client(); + var code = Guid.NewGuid().ToString("N"); + + var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code }); + + Assert.Equal(HttpStatusCode.OK, valid.StatusCode); + Assert.Equal("false", await valid.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(HttpStatusCode.NotFound, change.StatusCode); + Assert.Equal("Invalid recovery code.", await Message(change)); + } + + [Fact] + public async Task A_valid_recovery_code_resets_the_password_once() + { + var root = await _host.SignUp("recover"); + var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); + await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, RecoveryCode = code }, TestContext.Current.CancellationToken); + using var client = _host.Client(); + + var valid = await client.PostAsync("/clientapi/user/recover/valid", new StringContent($"\"{code}\"", Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code }); + var again = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = "Third-Pass-3!", repeatedPassword = "Third-Pass-3!", recoveryCode = code }); + + Assert.Equal("true", await valid.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.True(change.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NotFound, again.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await LogIn(root.UserName, root.Password)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, NewPassword)).StatusCode); + Assert.False(await DB.Default.Find().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Sniffing_again_answers_a_fresh_token() + { + var root = await _host.SignUp("sniff"); + using var client = _host.As(root.Jwt); + + var response = await client.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken); + using var anonymous = _host.Client(); + var refused = await anonymous.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var jwt = await response.JsonBody(); + Assert.Equal(root.Id, jwt["userId"]!.GetValue()); + Assert.Equal(root.UserName, jwt["username"]!.GetValue()); + Assert.True(jwt["expiration"]!.GetValue() > DateTime.UtcNow.Ticks); + Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue())); + Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode); + } + + [Fact] + public async Task The_eleventh_sign_up_from_one_address_in_a_minute_is_refused() + { + using var client = _host.ClientAt("198.51.100.21"); + for (var attempt = 1; attempt <= 10; attempt++) + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/signup", new { })).StatusCode); + + var eleventh = await client.PostJson("/clientapi/user/signup", new { userName = Name("limited"), password = Accounts.Password }); + + Assert.Equal(HttpStatusCode.TooManyRequests, eleventh.StatusCode); + } + + [Fact] + public async Task A_bad_token_answers_401_with_a_json_body() + { + var root = await _host.SignUp("badjwt"); + var tokens = new[] + { + "not-a-jwt", + Jwt(root.Id, root.UserName, DateTime.UtcNow.AddHours(-1)), + Jwt(root.Id, root.UserName, DateTime.UtcNow.AddHours(1), key: "another-key-entirely-0123456789abcdef0123456789abcdef0123456789abcdef") + }; + + foreach (var token in tokens) + { + using var client = _host.As(token); + var response = await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType); + var body = await response.JsonBody(); + Assert.Equal(401, body["statusCode"]!.GetValue()); + Assert.False(body["isValid"]!.GetValue()); + Assert.Contains("invalid_token", response.Headers.WwwAuthenticate.ToString()); + } + Assert.Equal(HttpStatusCode.OK, await Settings(Jwt(root.Id, root.UserName, DateTime.UtcNow.AddHours(1)))); + } + + [Fact] + public async Task A_banned_or_removed_root_loses_the_client_api_at_once() + { + var banned = await _host.SignUp("banned"); + var removed = await _host.SignUp("removed"); + Assert.Equal(HttpStatusCode.OK, await Settings(banned.Jwt)); + Assert.Equal(HttpStatusCode.OK, await Settings(removed.Jwt)); + + await ClientApi.Ban(banned.Id); + await DB.Default.Update().MatchID(removed.Id).Modify(u => u.DeletedAt, DateTime.UtcNow).ExecuteAsync(TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, await Settings(banned.Jwt)); + Assert.Equal(HttpStatusCode.Unauthorized, await Settings(removed.Jwt)); + using (var client = _host.As(banned.Jwt)) + { + Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostJson("/clientapi/avatar/private/insert", new { userName = Name("late"), name = "late", biography = "testing" })).StatusCode); + } + await ClientApi.Ban(banned.Id, banned: false); + Assert.Equal(HttpStatusCode.OK, await Settings(banned.Jwt)); + } + } +} diff --git a/PrivaPub.Tests/Http/ClientApiGroupsTests.cs b/PrivaPub.Tests/Http/ClientApiGroupsTests.cs new file mode 100644 index 0000000..c46f6a3 --- /dev/null +++ b/PrivaPub.Tests/Http/ClientApiGroupsTests.cs @@ -0,0 +1,257 @@ +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}")); + } + } + } +} diff --git a/PrivaPub.Tests/Http/ClientApiModerationTests.cs b/PrivaPub.Tests/Http/ClientApiModerationTests.cs new file mode 100644 index 0000000..cf0acd4 --- /dev/null +++ b/PrivaPub.Tests/Http/ClientApiModerationTests.cs @@ -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 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); + } + } +} diff --git a/PrivaPub.Tests/Http/ClientApiPersonasTests.cs b/PrivaPub.Tests/Http/ClientApiPersonasTests.cs new file mode 100644 index 0000000..92f413a --- /dev/null +++ b/PrivaPub.Tests/Http/ClientApiPersonasTests.cs @@ -0,0 +1,204 @@ +using MongoDB.Bson; +using MongoDB.Entities; + +using PrivaPub.Models.Federation; +using PrivaPub.Models.User; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Globalization; +using System.Net; +using System.Text.Json.Nodes; + +using GroupEntity = PrivaPub.Models.Group.Group; + +namespace PrivaPub.Tests.Http +{ + [Trait("Category", "Integration")] + public sealed class ClientApiPersonasTests : IAsyncLifetime + { + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + static string Name(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..20]; + + async Task Insert(Root root, object body) + { + using var client = _host.As(root.Jwt); + return await client.PostJson("/clientapi/avatar/private/insert", body); + } + + async Task> Listed(Root root) + { + using var client = _host.As(root.Jwt); + return (await (await client.GetAsync("/clientapi/avatar/private/list", TestContext.Current.CancellationToken)).JsonItems()) + .Select(a => a!["id"]!.GetValue()).ToList(); + } + + static DateTime IdDay(string id) => ObjectId.Parse(id).CreationTime; + + static void WithinTwoWeeksBefore(DateTime created, DateTime published, string id) + { + Assert.Equal(published.Date, published); + Assert.InRange(published, created.Date.AddDays(-13), DateTime.UtcNow.Date); + Assert.Equal(published, IdDay(id)); + } + + [Fact] + public async Task A_root_id_in_the_body_is_ignored() + { + var root = await _host.SignUp("owner"); + var other = await _host.SignUp("victim"); + var userName = Name("planted"); + + var response = await Insert(root, new { userName, name = "planted", biography = "testing", rootId = other.Id }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var id = (await response.JsonBody())["id"]!.GetValue(); + var link = await DB.Default.Find().Match(r => r.AvatarId == id).ExecuteSingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(root.Id, link.RootId); + Assert.Contains(id, await Listed(root)); + Assert.DoesNotContain(id, await Listed(other)); + Assert.DoesNotContain(root.Id, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("Upper")] + [InlineData("has-dash")] + [InlineData("dot.ted")] + [InlineData("spa ce")] + [InlineData("ünïcode")] + [InlineData("at@sign")] + [InlineData("")] + public async Task Usernames_outside_the_regex_are_refused(string userName) + { + var root = await _host.SignUp("regex"); + + var response = await Insert(root, new { userName, name = "regex", biography = "testing" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains((await response.JsonBody())["errors"]!.AsObject(), e => e.Key.Equals("userName", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(await Listed(root)); + if (userName.Length > 0) + Assert.False(await DB.Default.Find().Match(r => r.Name == userName || r.Name == userName.ToLowerInvariant()).ExecuteAnyAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("admin")] + [InlineData("privapub")] + [InlineData("root")] + [InlineData("moderator")] + [InlineData("abuse")] + public async Task Reserved_names_are_refused(string userName) + { + var root = await _host.SignUp("reserved"); + + var response = await Insert(root, new { userName, name = userName, biography = "testing" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("already take", (await response.JsonBody())["errorMessage"]!.GetValue()); + Assert.Empty(await Listed(root)); + } + + [Fact] + public async Task Personas_and_groups_share_one_name_space() + { + var root = await _host.SignUp("names"); + var persona = await _host.Persona(root, "names"); + var group = await _host.Group(persona, community: true); + var groupName = group["userName"]!.GetValue(); + using var client = _host.As(root.Jwt); + + var personaOverGroup = await Insert(root, new { userName = groupName, name = "copy", biography = "testing" }); + var groupOverPersona = await client.PostJson("/clientapi/group/insert", new { avatarId = persona.Id, userName = persona.UserName, name = "copy", isCommunity = true }); + var personaOverPersona = await Insert(await _host.SignUp("names"), new { userName = persona.UserName, name = "copy", biography = "testing" }); + + Assert.Equal(HttpStatusCode.BadRequest, personaOverGroup.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, groupOverPersona.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, personaOverPersona.StatusCode); + var reserved = await DB.Default.Find().Match(r => r.Name == groupName || r.Name == persona.UserName).ExecuteAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, reserved.Count); + Assert.Contains(reserved, r => r.Name == persona.UserName && r.OwnerKind == LocalActorKind.Person && r.OwnerId == persona.Id); + Assert.Contains(reserved, r => r.Name == groupName && r.OwnerKind == LocalActorKind.Group && r.OwnerId == group["id"]!.GetValue()); + Assert.Equal(1, await DB.Default.CountAsync(a => a.UserName == persona.UserName, TestContext.Current.CancellationToken)); + Assert.Equal(1, await DB.Default.CountAsync(g => g.UserName == groupName, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task An_update_is_delivered_to_followers() + { + await using var peer = await Peer.Start(); + var persona = await _host.Persona(await _host.SignUp("update"), "update"); + var follower = new RemoteActor(peer, "fan"); + await _host.Follow(follower, peer.A, persona.UserName); + var since = DateTime.UtcNow.AddSeconds(-1); + using var client = _host.As(persona.Root.Jwt); + + var response = await client.PostJson("/clientapi/avatar/private/update", new { avatarId = persona.Id, name = "Renamed", biography = "new biography" }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var update = Assert.Single(await Jobs.Deliveries(follower.Id + "/inbox", since, TestContext.Current.CancellationToken), d => d["type"]!.GetValue() == "Update"); + Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", update["actor"]!.GetValue()); + var person = update["object"]!.AsObject(); + Assert.Equal("Person", person["type"]!.GetValue()); + Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", person["id"]!.GetValue()); + Assert.Equal("Renamed", person["name"]!.GetValue()); + Assert.DoesNotContain(persona.Root.Id, update.ToJsonString()); + Assert.DoesNotContain(persona.Root.UserName, update.ToJsonString()); + } + + [Fact] + public async Task Updating_another_roots_persona_is_refused() + { + var persona = await _host.Persona(await _host.SignUp("mine"), "mine"); + var intruder = await _host.SignUp("intruder"); + using var client = _host.As(intruder.Jwt); + + var response = await client.PostJson("/clientapi/avatar/private/update", new { avatarId = persona.Id, name = "Hijacked", biography = "testing" }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.NotEqual("Hijacked", (await DB.Default.Find().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Name); + } + + [Fact] + public async Task Published_days_and_ids_fall_within_two_weeks_before_creation() + { + var created = DateTime.UtcNow; + var persona = await _host.Persona(await _host.SignUp("published"), "published"); + var group = await _host.Group(persona, community: true); + using var client = _host.Client(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/peasants/{persona.UserName}"); + request.Headers.Accept.ParseAdd("application/activity+json"); + + var avatar = await DB.Default.Find().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken); + var stored = await DB.Default.Find().MatchID(group["id"]!.GetValue()).ExecuteFirstAsync(TestContext.Current.CancellationToken); + var actor = JsonNode.Parse(await (await client.SendAsync(request, TestContext.Current.CancellationToken)).Content.ReadAsStringAsync(TestContext.Current.CancellationToken))!; + + WithinTwoWeeksBefore(created, avatar.PublishedOn, avatar.ID); + WithinTwoWeeksBefore(created, stored.PublishedOn, stored.ID); + var published = DateTime.Parse(actor["published"]!.GetValue(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal); + Assert.Equal(avatar.PublishedOn, published); + Assert.DoesNotContain(avatar.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture), actor.ToJsonString()); + } + + [Fact] + public async Task The_list_holds_only_the_roots_own_personas() + { + var root = await _host.SignUp("list"); + var other = await _host.SignUp("list"); + var first = await _host.Persona(root, "first"); + var second = await _host.Persona(root, "second"); + var theirs = await _host.Persona(other, "theirs"); + + Assert.Equal(new[] { first.Id, second.Id }.Order(), (await Listed(root)).Order()); + Assert.Equal(new[] { theirs.Id }, await Listed(other)); + } + } +} diff --git a/PrivaPub.Tests/Http/OAuthTests.cs b/PrivaPub.Tests/Http/OAuthTests.cs new file mode 100644 index 0000000..9a3e1e7 --- /dev/null +++ b/PrivaPub.Tests/Http/OAuthTests.cs @@ -0,0 +1,346 @@ +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Http +{ + [Trait("Category", "Integration")] + public sealed class OAuthTests : IAsyncLifetime + { + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + async Task NewPersona(string name = "oauth") => await _host.Persona(await _host.SignUp(), name); + + static async Task SignedIn(HttpClient client, OAuthApp app) + { + var response = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); + return response.StatusCode == HttpStatusCode.OK; + } + + static async Task AssertMastodonError(HttpResponseMessage response, HttpStatusCode status) + { + Assert.Equal(status, response.StatusCode); + Assert.False(string.IsNullOrEmpty((await response.JsonBody())["error"]?.GetValue())); + } + + [Fact] + public async Task The_token_names_the_persona_and_never_the_root() + { + var persona = await NewPersona(); + var root = persona.Root; + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + + var exchanged = await client.Exchange(app, await client.Code(app, persona)); + var body = await exchanged.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, exchanged.StatusCode); + Assert.DoesNotContain(root.Id, body); + Assert.DoesNotContain(root.UserName, body); + var token = JsonNode.Parse(body)!["access_token"]!.GetValue(); + using var api = _host.As(token); + var account = await api.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken); + Assert.Contains($"\"id\":\"{persona.Id}\"", account); + Assert.DoesNotContain(root.Id, account); + Assert.DoesNotContain(root.UserName, account); + + var stored = await ClientApi.StoredTokens(app.ClientId); + Assert.NotEmpty(stored); + foreach (var entry in stored) + { + Assert.Equal(persona.Id, entry["subject"].AsString); + var raw = entry.ToString(); + var payload = entry.Contains("payload") && entry["payload"].IsString ? ClientApi.JwtPayload(entry["payload"].AsString) : string.Empty; + Assert.DoesNotContain(root.Id, raw + payload); + Assert.DoesNotContain(root.UserName, raw + payload); + } + var access = stored.Single(t => ClientApi.IsAccessToken(t)); + Assert.Equal(persona.Id, JsonNode.Parse(ClientApi.JwtPayload(access["payload"].AsString))!["sub"]!.GetValue()); + } + + [Fact] + public async Task A_wrong_password_shows_an_error_and_signs_nobody_in() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + + var response = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("That username and password do not match.", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.DoesNotContain(response.Headers.TryGetValues("Set-Cookie", out var cookies) ? cookies : Enumerable.Empty(), + c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal)); + var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode); + Assert.StartsWith("/oauth/login?", authorize.Headers.Location!.OriginalString); + } + + [Fact] + public async Task A_login_without_the_antiforgery_token_is_refused() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + await client.GetStringAsync("/oauth/login", TestContext.Current.CancellationToken); + + var response = await client.Form("/oauth/login", ("returnUrl", "/oauth/authorize?x=1"), ("userName", persona.Root.UserName), ("password", persona.Root.Password)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.StartsWith("privapub.oauth=", StringComparison.Ordinal))); + } + + [Theory] + [InlineData("https://evil.example/oauth/authorize?client_id=x")] + [InlineData("//evil.example/oauth/authorize?client_id=x")] + [InlineData("/\\evil.example/oauth/authorize?client_id=x")] + [InlineData("/somewhere/else")] + public async Task The_return_address_never_leaves_the_site(string returnUrl) + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + + var response = await client.SignIn(persona.Root, returnUrl); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/oauth/authorize?signed_in=1", response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task Denying_answers_access_denied_and_no_code() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); + + var response = await client.Choose(app, persona.Id, decision: "deny"); + var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Null(ClientApi.CodeIn(page)); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var location = response.Headers.Location!.OriginalString; + Assert.StartsWith(ClientApi.OutOfBand + "?error=access_denied", location); + Assert.DoesNotContain("code=", location); + Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken); + } + + [Fact] + public async Task Choosing_another_roots_persona_shows_the_choice_again_without_a_code() + { + var mine = await NewPersona(); + var theirs = await NewPersona("theirs"); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(mine.Root, app.ReturnUrl())).StatusCode); + + var response = await client.Choose(app, theirs.Id); + var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(ClientApi.CodeIn(page)); + Assert.Contains(mine.Id, page); + Assert.DoesNotContain(theirs.Id, page); + Assert.DoesNotContain(theirs.UserName, page); + Assert.DoesNotContain(await ClientApi.StoredTokens(app.ClientId), ClientApi.IsAccessToken); + } + + [Fact] + public async Task A_banned_root_is_sent_back_to_the_login() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); + var choice = await client.Choice(app); + + await ClientApi.Ban(persona.Root.Id); + var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); + var chosen = await client.Decide(choice, persona.Id); + var again = await client.SignIn(persona.Root, app.ReturnUrl()); + + Assert.Equal(HttpStatusCode.Redirect, authorize.StatusCode); + Assert.StartsWith("/oauth/login?returnUrl=", authorize.Headers.Location!.OriginalString); + Assert.Equal(HttpStatusCode.Redirect, chosen.StatusCode); + Assert.StartsWith("/oauth/login?returnUrl=", chosen.Headers.Location!.OriginalString); + Assert.Equal(HttpStatusCode.OK, again.StatusCode); + Assert.Contains("That username and password do not match.", await again.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task A_code_issued_before_a_ban_buys_no_token() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + var code = await client.Code(app, persona); + + await ClientApi.Ban(persona.Root.Id); + var response = await client.Exchange(app, code); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("invalid_grant", (await response.JsonBody())["error"]!.GetValue()); + } + + [Fact] + public async Task Force_login_asks_for_the_password_again() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); + Assert.True(await SignedIn(client, app)); + + var forced = await client.GetAsync(app.ReturnUrl("force_login=true"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Redirect, forced.StatusCode); + var location = forced.Headers.Location!.OriginalString; + Assert.StartsWith("/oauth/login?returnUrl=", location); + var returnUrl = Uri.UnescapeDataString(location["/oauth/login?returnUrl=".Length..]); + Assert.Contains("force_login=true", returnUrl); + Assert.DoesNotContain("signed_in", returnUrl); + var signedIn = await client.SignIn(persona.Root, returnUrl); + Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); + Assert.EndsWith("&signed_in=1", signedIn.Headers.Location!.OriginalString); + var page = await client.GetAsync(signedIn.Headers.Location!.OriginalString, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Contains(persona.Id, await page.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task An_authorization_code_works_once() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + var code = await client.Code(app, persona); + + var first = await client.Exchange(app, code); + var second = await client.Exchange(app, code); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + Assert.Equal("invalid_grant", (await second.JsonBody())["error"]!.GetValue()); + using var api = _host.As((await first.JsonBody())["access_token"]!.GetValue()); + await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); + } + + [Theory] + [InlineData("password")] + [InlineData("refresh_token")] + public async Task Grants_other_than_code_and_client_credentials_are_refused(string grant) + { + var persona = await NewPersona(); + using var client = _host.Client(); + var app = await client.RegisterApp(); + + var response = await client.Form("/oauth/token", ("grant_type", grant), ("client_id", app.ClientId), ("client_secret", app.ClientSecret), + ("username", persona.Root.UserName), ("password", persona.Root.Password), ("refresh_token", "anything"), ("scope", "read")); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.DoesNotContain("access_token", body); + Assert.Equal("unsupported_grant_type", JsonNode.Parse(body)!["error"]!.GetValue()); + } + + [Fact] + public async Task A_client_credentials_token_is_no_persona() + { + using var client = _host.Client(); + var app = await client.RegisterApp("read write"); + var response = await client.Form("/oauth/token", ("grant_type", "client_credentials"), ("client_id", app.ClientId), ("client_secret", app.ClientSecret), ("scope", "read")); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = (await response.JsonBody())["access_token"]!.GetValue(); + using var api = _host.As(token); + + Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/apps/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); + await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); + await AssertMastodonError(await api.GetAsync("/api/v1/notifications", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task A_read_only_token_cannot_post() + { + var token = await _host.MastodonToken(await NewPersona(), "read"); + using var api = _host.As(token); + + var response = await api.Form("/api/v1/statuses", ("status", "nope")); + + await AssertMastodonError(response, HttpStatusCode.Forbidden); + Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); + } + + [Fact] + public async Task The_follow_scope_covers_reading_follows() + { + var token = await _host.MastodonToken(await NewPersona(), "follow"); + using var api = _host.As(token); + + Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/follow_requests", TestContext.Current.CancellationToken)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/blocks", TestContext.Current.CancellationToken)).StatusCode); + await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Forbidden); + } + + [Fact] + public async Task A_revoked_token_stops_working() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + var token = await client.Token(app, persona); + using var api = _host.As(token); + Assert.Equal(HttpStatusCode.OK, (await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode); + + var revoked = await client.Form("/oauth/revoke", ("token", token), ("client_id", app.ClientId), ("client_secret", app.ClientSecret)); + + Assert.Equal(HttpStatusCode.OK, revoked.StatusCode); + await AssertMastodonError(await api.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken), HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task The_login_and_authorize_pages_are_hardened() + { + var persona = await NewPersona(); + using var client = _host.Client(cookies: true); + var app = await client.RegisterApp(); + + var login = await client.GetAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(app.ReturnUrl()), TestContext.Current.CancellationToken); + var failed = await client.SignIn(persona.Root, app.ReturnUrl(), password: "Wrong-Pass-1!"); + Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); + var authorize = await client.GetAsync(app.ReturnUrl(), TestContext.Current.CancellationToken); + + foreach (var response in new[] { login, failed, authorize }) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var csp = string.Join(";", response.Headers.GetValues("Content-Security-Policy")); + Assert.Contains("default-src 'none'", csp); + Assert.Contains("frame-ancestors 'none'", csp); + Assert.Contains("no-store", response.Headers.CacheControl!.ToString()); + } + } + + [Fact] + public async Task The_eleventh_login_from_one_address_in_a_minute_is_refused() + { + using var client = _host.ClientAt("198.51.100.12"); + for (var attempt = 1; attempt <= 10; attempt++) + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken)).StatusCode); + + var eleventh = await client.GetAsync("/oauth/login", TestContext.Current.CancellationToken); + using var elsewhere = _host.ClientAt("198.51.100.13"); + var other = await elsewhere.GetAsync("/oauth/login", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.TooManyRequests, eleventh.StatusCode); + Assert.Equal(HttpStatusCode.OK, other.StatusCode); + } + } +} diff --git a/PrivaPub.Tests/Infrastructure/AdminCommandsTests.cs b/PrivaPub.Tests/Infrastructure/AdminCommandsTests.cs new file mode 100644 index 0000000..b54d6e7 --- /dev/null +++ b/PrivaPub.Tests/Infrastructure/AdminCommandsTests.cs @@ -0,0 +1,99 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels; +using PrivaPub.Infrastructure.Cli; +using PrivaPub.Models.User; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; + +namespace PrivaPub.Tests.Infrastructure +{ + [Trait("Category", "Integration")] + public sealed class AdminCommandsTests : IAsyncLifetime + { + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + static async Task> StoredPolicies(Root root) => + (await DB.Default.Find().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Policies.Order().ToList(); + + async Task AdminRoute(Root root) + { + using var client = _host.As(root.Jwt); + return (await client.GetAsync("/clientapi/admin/domainblocks/list", TestContext.Current.CancellationToken)).StatusCode; + } + + [Fact] + public async Task Promote_makes_an_admin_and_a_moderator() + { + var root = await _host.SignUp("promote"); + + Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName.ToUpperInvariant() })); + Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName })); + + Assert.Equal(new[] { Policies.IsAdmin, Policies.IsModerator, Policies.IsUser }.Order(), await StoredPolicies(root)); + } + + [Fact] + public async Task Demote_leaves_a_plain_user() + { + var root = await _host.SignUp("demote"); + Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName })); + + Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName })); + Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName })); + + Assert.Equal(new[] { Policies.IsUser }, await StoredPolicies(root)); + } + + [Fact] + public async Task An_unknown_root_exits_1() + { + Assert.Equal(1, await AdminCommands.Run(new[] { "promote", $"nobody{Guid.NewGuid():N}" })); + Assert.Equal(1, await AdminCommands.Run(new[] { "demote", $"nobody{Guid.NewGuid():N}" })); + } + + public static TheoryData Misuses() => new() + { + Array.Empty(), + new[] { "promote" }, + new[] { "promote", "a", "b" }, + new[] { "delete", "a" }, + new[] { "PROMOTE", "a" } + }; + + [Theory] + [MemberData(nameof(Misuses))] + public async Task Misuse_exits_2(string[] args) + { + Assert.Equal(2, await AdminCommands.Run(args)); + } + + [Fact] + public async Task A_token_follows_its_roots_policies_at_once() + { + var root = await _host.SignUp("policies"); + Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root)); + + Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName })); + Assert.Equal(HttpStatusCode.OK, await AdminRoute(root)); + using (var client = _host.As(root.Jwt)) + { + var refreshed = await (await client.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).JsonBody(); + Assert.Contains(Policies.IsAdmin, refreshed["policies"]!.AsArray().Select(p => p!.GetValue())); + } + + Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName })); + Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root)); + } + } +} diff --git a/PrivaPub.Tests/Support/Host/ClientApiHelpers.cs b/PrivaPub.Tests/Support/Host/ClientApiHelpers.cs new file mode 100644 index 0000000..467f7e3 --- /dev/null +++ b/PrivaPub.Tests/Support/Host/ClientApiHelpers.cs @@ -0,0 +1,166 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Models.User; + +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace PrivaPub.Tests.Support.Host +{ + public sealed record OAuthApp(string ClientId, string ClientSecret, string Scopes) + { + public string Query(string extra = default) => + $"client_id={Uri.EscapeDataString(ClientId)}&redirect_uri={Uri.EscapeDataString(ClientApi.OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(Scopes)}" + + (extra == default ? string.Empty : "&" + extra); + + public string ReturnUrl(string extra = default) => "/oauth/authorize?" + Query(extra); + } + + public static partial class ClientApi + { + public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob"; + + public static HttpClient ClientAt(this PrivaPubHost host, string address, bool cookies = false) + { + var client = host.Client(cookies); + client.DefaultRequestHeaders.Remove(PrivaPubHost.ClientHeader); + client.DefaultRequestHeaders.Add(PrivaPubHost.ClientHeader, address); + return client; + } + + public static async Task JsonBody(this HttpResponseMessage response) => + JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject(); + + public static async Task JsonItems(this HttpResponseMessage response) => + JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray(); + + public static Task PostJson(this HttpClient client, string path, object body) => + client.PostAsJsonAsync(path, body); + + public static Task Form(this HttpClient client, string path, params (string Key, string Value)[] fields) => + client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value)))); + + public static async Task RegisterApp(this HttpClient client, string scopes = "read write follow") + { + var response = await client.Form("/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var app = await response.JsonBody(); + return new OAuthApp(app["client_id"]!.GetValue(), app["client_secret"]!.GetValue(), scopes); + } + + public static async Task SignIn(this HttpClient client, Root root, string returnUrl, string password = default) + { + var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl)); + return await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary + { + ["returnUrl"] = returnUrl, + ["__RequestVerificationToken"] = Antiforgery(login), + ["userName"] = root.UserName, + ["password"] = password ?? root.Password + })); + } + + public static string Antiforgery(string page) => AntiforgeryToken().Match(page).Groups[1].Value; + + public static async Task>> Choice(this HttpClient client, OAuthApp app) + { + var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1"); + return HiddenInput().Matches(page).Select(m => new KeyValuePair(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList(); + } + + public static Task Decide(this HttpClient client, IEnumerable> choice, string avatarId, string decision = "allow") => + client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(choice.Append(new("avatarId", avatarId)).Append(new("decision", decision)))); + + public static async Task Choose(this HttpClient client, OAuthApp app, string avatarId, string decision = "allow") => + await client.Decide(await client.Choice(app), avatarId, decision); + + public static string CodeIn(string page) => CodeElement().Match(page) is { Success: true } match ? match.Groups[1].Value : default; + + public static async Task Code(this HttpClient client, OAuthApp app, Persona persona) + { + Assert.Equal(HttpStatusCode.Redirect, (await client.SignIn(persona.Root, app.ReturnUrl())).StatusCode); + var code = CodeIn(await (await client.Choose(app, persona.Id)).Content.ReadAsStringAsync()); + Assert.False(string.IsNullOrEmpty(code)); + return code; + } + + public static Task Exchange(this HttpClient client, OAuthApp app, string code) => + client.Form("/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", app.ClientId), + ("client_secret", app.ClientSecret), ("redirect_uri", OutOfBand)); + + public static async Task Token(this HttpClient client, OAuthApp app, Persona persona) + { + var response = await client.Exchange(app, await client.Code(app, persona)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.JsonBody())["access_token"]!.GetValue(); + } + + public static async Task Group(this PrivaPubHost host, Persona owner, bool community, string password = default, string name = "group") + { + using var client = host.As(owner.Root.Jwt); + var response = await client.PostJson("/clientapi/group/insert", new + { + avatarId = owner.Id, + userName = $"{name}{Guid.NewGuid():N}"[..20], + name, + isCommunity = community, + invitationPassword = password + }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.JsonBody(); + } + + public static async Task Ban(string rootId, bool banned = true) => + await DB.Default.Update().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync(); + + public static async Task Follow(this PrivaPubHost host, RemoteActor follower, string origin, string userName) + { + var follow = new JsonObject + { + ["id"] = $"{origin}/follows/{Guid.NewGuid():N}", + ["type"] = "Follow", + ["actor"] = follower.Id, + ["object"] = $"{PrivaPubHost.Base}/peasants/{userName}" + }; + using var client = host.Client(); + Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(follower.SignedPost($"/peasants/{userName}/mouth", follow))).StatusCode); + Assert.Equal(1, await host.RunInbox(follow["id"]!.GetValue())); + return follow; + } + + public static async Task> StoredTokens(string clientId) + { + var database = DB.Default.Database(); + var application = await database.GetCollection("openiddict.applications") + .Find(Builders.Filter.Eq("client_id", clientId)).FirstAsync(); + return await database.GetCollection("openiddict.tokens") + .Find(Builders.Filter.Eq("application_id", application["_id"])).ToListAsync(); + } + + public static bool IsAccessToken(BsonDocument token) => + token.GetValue("type", BsonNull.Value) is { IsString: true } type && type.AsString.EndsWith("access_token", StringComparison.Ordinal); + + public static string JwtPayload(string jwt) + { + var parts = jwt.Split('.'); + if (parts.Length != 3) + return string.Empty; + var segment = parts[1].Replace('-', '+').Replace('_', '/'); + return Encoding.UTF8.GetString(Convert.FromBase64String(segment.PadRight(segment.Length + (4 - segment.Length % 4) % 4, '='))); + } + + [GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")] + private static partial Regex AntiforgeryToken(); + + [GeneratedRegex("([^<]*)")] + private static partial Regex CodeElement(); + } +} diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs index f990f97..11f9621 100644 --- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs +++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs @@ -435,10 +435,25 @@ namespace PrivaPub.Controllers.ClientToServer #region Auth refresh [HttpGet, Route("/clientapi/user/sniff/again"), Authorize(Policy = Policies.IsUser)] - public async Task SniffAgain() + public async Task SniffAgain([FromServices] DbEntities dbEntities, CancellationToken token) { + var result = new WebResult(); + try + { + var user = await dbEntities.RootUsers.MatchID(User.GetUserId()).ExecuteFirstAsync(token); + if (user is not { IsBanned: false, DeletedAt: null }) + return Unauthorized(result.Invalidate(Localizer["Unauthorized."], StatusCodes.Status401Unauthorized)); + result = await UsersService.GetUserSettingsAsync(user.ID); + if (!result.IsValid) + return StatusCode(result.StatusCode, result); - return Ok(); + return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data)); + } + catch (Exception ex) + { + Logger.LogError(ex, $"{nameof(User)}.{nameof(SniffAgain)}()"); + return BadRequest(result.Invalidate(Localizer["Something went wrong."])); + } } #endregion diff --git a/PrivaPub/Services/JwtEvents.cs b/PrivaPub/Services/JwtEvents.cs index fa79f80..2a2fe72 100644 --- a/PrivaPub/Services/JwtEvents.cs +++ b/PrivaPub/Services/JwtEvents.cs @@ -2,8 +2,11 @@ using Microsoft.Extensions.Localization; using PrivaPub.ClientModels; +using PrivaPub.Extensions; using PrivaPub.Resources; +using PrivaPub.StaticServices; +using System.Security.Claims; using System.Text; using System.Text.Json; @@ -11,9 +14,35 @@ namespace PrivaPub.Services { public class JwtEvents : JwtBearerEvents { + static readonly string[] PolicyClaims = { Policies.IsUser, Policies.IsModerator, Policies.IsAdmin }; + static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); + ILogger _logger { get; set; } const string contentType = "application/json"; + //a token outlives a ban, a removal or a demotion, so every request asks the database who the root is now + public override async Task TokenValidated(TokenValidatedContext context) + { + var rootId = context.Principal.GetUserId(); + var root = string.IsNullOrEmpty(rootId) + ? default + : await context.HttpContext.RequestServices.GetRequiredService().RootUsers.MatchID(rootId) + .ExecuteFirstAsync(context.HttpContext.RequestAborted); + if (root is not { IsBanned: false, DeletedAt: null }) + { + context.Fail("The account can no longer be used."); + return; + } + if (context.Principal.Identity is not ClaimsIdentity identity) + return; + foreach (var policy in PolicyClaims) + { + foreach (var claim in identity.FindAll(policy).ToList()) + identity.TryRemoveClaim(claim); + identity.AddClaim(new Claim(policy, root.Policies.Contains(policy) ? "true" : "false")); + } + } + public override async Task Challenge(JwtBearerChallengeContext context) { context.HandleResponse(); @@ -24,7 +53,7 @@ namespace PrivaPub.Services var localizer = context.HttpContext.RequestServices.GetRequiredService>(); var webResult = new WebResult().Invalidate(localizer["Unauthorized."], StatusCodes.Status401Unauthorized); context.Response.ContentType = contentType; - await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult))); + await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult, Web))); } catch (Exception ex) { @@ -40,7 +69,7 @@ namespace PrivaPub.Services var localizer = context.HttpContext.RequestServices.GetRequiredService>(); var webResult = new WebResult().Invalidate(localizer["Forbidden."], StatusCodes.Status403Forbidden); context.Response.ContentType = contentType; - await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult))); + await context.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(webResult, Web))); } catch (Exception ex) { diff --git a/PrivaPub/Services/RootUsersService.cs b/PrivaPub/Services/RootUsersService.cs index 898f08e..46d5550 100644 --- a/PrivaPub/Services/RootUsersService.cs +++ b/PrivaPub/Services/RootUsersService.cs @@ -411,13 +411,13 @@ namespace PrivaPub.Services if (!smtpClient.IsConnected) { Logger.LogError($"Failed to connect to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer})."); - return result.Invalidate(Localizer["Failed to send email."], (int)SmtpStatusCode.ServiceNotAvailable); + return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable); } } catch (Exception ex) { Logger.LogError(ex, $"Error at connection to the SMTP server({AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpServer})."); - return result.Invalidate(Localizer["Failed to send email."], (int)SmtpStatusCode.ServiceNotAvailable, exception: ex); + return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex); } try @@ -427,21 +427,21 @@ namespace PrivaPub.Services { Logger.LogError($"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}."); return result.Invalidate(Localizer["Failed to send email."], - (int)SmtpStatusCode.TemporaryAuthenticationFailure); + StatusCodes.Status503ServiceUnavailable); } } catch (Exception ex) { Logger.LogError(ex, $"Failed SMTP authentication of {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}."); return result.Invalidate(Localizer["Failed to send email."], - (int)SmtpStatusCode.TemporaryAuthenticationFailure, exception: ex); + StatusCodes.Status503ServiceUnavailable, exception: ex); } try { var toParsed = await smtpClient.VerifyAsync(user.Email); if (toParsed == null) - return result.Invalidate(Localizer["Invalid email."], (int)SmtpStatusCode.MailboxUnavailable); + return result.Invalidate(Localizer["Invalid email."], StatusCodes.Status400BadRequest); } catch (OperationCanceledException ex) { @@ -483,7 +483,7 @@ Eugene from collAnon, following is the password recovery link: catch (Exception ex) { Logger.LogError(ex, $"Error at email sending to {user.Email} from {AppConfigurationService.AppConfiguration.EmailConfiguration.SmtpUsername}."); - return result.Invalidate(Localizer["Failed to send email."], (int)SmtpStatusCode.TransactionFailed, exception: ex); + return result.Invalidate(Localizer["Failed to send email."], StatusCodes.Status503ServiceUnavailable, exception: ex); } await smtpClient.DisconnectAsync(quit: true);