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)); } } }