2026-10-03 11:49:55 +02:00
|
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
|
|
|
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.ClientModels;
|
|
|
|
|
using PrivaPub.Models.Group;
|
|
|
|
|
using PrivaPub.Models.User;
|
2026-10-04 03:16:59 +02:00
|
|
|
using PrivaPub.Services;
|
|
|
|
|
using PrivaPub.Models.Jobs;
|
2026-10-03 11:49:55 +02:00
|
|
|
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<HttpResponseMessage> LogIn(string userName, string password)
|
|
|
|
|
{
|
|
|
|
|
using var client = _host.Client();
|
|
|
|
|
return await client.PostJson("/clientapi/user/login", new { userName, password });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task<HttpStatusCode> Settings(string jwt)
|
|
|
|
|
{
|
|
|
|
|
using var client = _host.As(jwt);
|
|
|
|
|
return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async Task<string> Message(HttpResponseMessage response)
|
|
|
|
|
{
|
|
|
|
|
var body = await response.JsonBody();
|
|
|
|
|
return body["errorMessage"]?.GetValue<string>() ?? body["title"]?.GetValue<string>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
string Jwt(string rootId, string userName, DateTime expires, string key = default)
|
|
|
|
|
{
|
|
|
|
|
var configuration = _host.Get<IConfiguration>();
|
|
|
|
|
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<string>());
|
|
|
|
|
Assert.Equal(new[] { Policies.IsUser }, jwt["policies"]!.AsArray().Select(p => p!.GetValue<string>()));
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue<string>()));
|
|
|
|
|
var stored = await DB.Default.Find<RootUser>().MatchID(jwt["userId"]!.GetValue<string>()).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<RootUser>(u => u.UserName == root.UserName, TestContext.Current.CancellationToken));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static TheoryData<string> 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<string>());
|
|
|
|
|
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<string>());
|
2026-10-04 03:16:59 +02:00
|
|
|
// a wrong password and an unknown login get the same answer, so sign-in tells nobody which logins exist
|
2026-10-03 11:49:55 +02:00
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
|
2026-10-04 03:16:59 +02:00
|
|
|
Assert.Equal(RootUsersService.NoMatch, await Message(wrong));
|
2026-10-03 11:49:55 +02:00
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
|
2026-10-04 03:16:59 +02:00
|
|
|
Assert.Equal(await Message(wrong), await Message(unknown));
|
2026-10-03 11:49:55 +02:00
|
|
|
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<RootUser>().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<RootUser>().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<string>());
|
|
|
|
|
Assert.Equal(100, settings["lightThemeIndexColour"]!.GetValue<int>());
|
|
|
|
|
Assert.Equal(200, settings["darkThemeIndexColour"]!.GetValue<int>());
|
|
|
|
|
Assert.Equal(50, settings["iconsThemeIndexColour"]!.GetValue<int>());
|
|
|
|
|
Assert.True(settings["themeIsDarkMode"]!.GetValue<bool>());
|
|
|
|
|
Assert.True(settings["themeIsDarkGray"]!.GetValue<bool>());
|
|
|
|
|
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<RootUser>().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<string>();
|
|
|
|
|
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<string>());
|
|
|
|
|
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<string>());
|
|
|
|
|
Assert.Equal("Guest", avatar["name"]!.GetValue<string>());
|
|
|
|
|
var stored = await DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
|
|
|
|
Assert.Contains(stored.Members, m => !m.IsForeign && m.AvatarId == avatar["id"]!.GetValue<string>() && 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<string>();
|
|
|
|
|
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<RootUser>().Match(u => u.UserName == userName).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
|
|
|
|
Assert.False(await DB.Default.Find<Avatar>().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<string>(),
|
|
|
|
|
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<string>());
|
|
|
|
|
var groups = await (await joined.GetAsync("/clientapi/group/list?avatarId=" + avatar["id"]!.GetValue<string>(), TestContext.Current.CancellationToken)).JsonItems();
|
|
|
|
|
Assert.Equal(group["id"]!.GetValue<string>(), Assert.Single(groups)!["id"]!.GetValue<string>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
2026-10-04 03:16:59 +02:00
|
|
|
public async Task Recovery_answers_the_same_whoever_asks_and_queues_the_work()
|
2026-10-03 11:49:55 +02:00
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
var token = TestContext.Current.CancellationToken;
|
|
|
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
|
|
|
var withEmail = await _host.SignUp("withemail");
|
|
|
|
|
var withoutEmail = await _host.SignUp("noemail");
|
|
|
|
|
var email = $"{Guid.NewGuid():N}@example.test";
|
|
|
|
|
using (var signedIn = _host.As(withEmail.Jwt))
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
|
2026-10-03 11:49:55 +02:00
|
|
|
using var client = _host.Client();
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
var answers = new[]
|
|
|
|
|
{
|
|
|
|
|
await client.PostJson("/clientapi/user/recover/password", new { userName = withEmail.UserName }),
|
|
|
|
|
await client.PostJson("/clientapi/user/recover/password", new { email }),
|
|
|
|
|
await client.PostJson("/clientapi/user/recover/password", new { userName = withoutEmail.UserName }),
|
|
|
|
|
await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }),
|
|
|
|
|
await client.PostJson("/clientapi/user/recover/password", new { email = $"{Guid.NewGuid():N}@example.test" })
|
|
|
|
|
};
|
|
|
|
|
var bodies = new List<string>();
|
|
|
|
|
foreach (var answer in answers)
|
|
|
|
|
{
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
|
|
|
|
bodies.Add(await answer.Content.ReadAsStringAsync(token));
|
|
|
|
|
}
|
2026-10-03 11:49:55 +02:00
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
Assert.Single(bodies.Distinct());
|
|
|
|
|
Assert.Contains("recovery link is on its way", bodies[0]);
|
|
|
|
|
var queued = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SendRecovery && j.CreatedAt >= since).ExecuteAsync(token);
|
|
|
|
|
Assert.True(queued.Count >= answers.Length);
|
|
|
|
|
Assert.Contains(queued, j => j.Payload.Contains(withEmail.Id));
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/recover/password", new { })).StatusCode);
|
2026-10-03 11:49:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
2026-10-04 03:16:59 +02:00
|
|
|
public async Task The_recovery_job_keeps_only_a_hash_and_an_unreachable_mail_server_says_nothing_to_anyone()
|
2026-10-03 11:49:55 +02:00
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
var token = TestContext.Current.CancellationToken;
|
2026-10-03 11:49:55 +02:00
|
|
|
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();
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
var answer = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
|
|
|
|
|
await _host.Run(j => j.Kind == JobKind.SendRecovery && j.Payload.Contains(root.Id), token);
|
2026-10-03 11:49:55 +02:00
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
|
|
|
|
|
var recovery = await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteFirstAsync(token);
|
|
|
|
|
Assert.NotNull(recovery);
|
|
|
|
|
Assert.Null(recovery.RecoveryCode);
|
|
|
|
|
Assert.Equal(64, recovery.CodeHash.Length);
|
|
|
|
|
Assert.InRange(recovery.ExpiresAt, DateTime.UtcNow.AddMinutes(50), DateTime.UtcNow.AddMinutes(61));
|
2026-10-03 11:49:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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");
|
2026-10-04 03:16:59 +02:00
|
|
|
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddHours(1) },
|
|
|
|
|
TestContext.Current.CancellationToken);
|
|
|
|
|
var persona = await _host.Persona(root, "recovered");
|
|
|
|
|
var apiToken = await _host.MastodonToken(persona);
|
2026-10-03 11:49:55 +02:00
|
|
|
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<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteAnyAsync(TestContext.Current.CancellationToken));
|
2026-10-04 03:16:59 +02:00
|
|
|
// whoever held the old sessions may be why the password was recovered: they end
|
|
|
|
|
using (var oldSession = _host.As(root.Jwt))
|
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await oldSession.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode);
|
|
|
|
|
using (var oldApp = _host.Client())
|
|
|
|
|
{
|
|
|
|
|
oldApp.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await oldApp.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task An_expired_recovery_code_changes_nothing()
|
|
|
|
|
{
|
|
|
|
|
var root = await _host.SignUp("expired");
|
|
|
|
|
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
|
|
|
|
|
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddMinutes(-1) },
|
|
|
|
|
TestContext.Current.CancellationToken);
|
|
|
|
|
using var client = _host.Client();
|
|
|
|
|
|
|
|
|
|
var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code });
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, change.StatusCode);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, root.Password)).StatusCode);
|
2026-10-03 11:49:55 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<string>());
|
|
|
|
|
Assert.Equal(root.UserName, jwt["username"]!.GetValue<string>());
|
|
|
|
|
Assert.True(jwt["expiration"]!.GetValue<long>() > DateTime.UtcNow.Ticks);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, await Settings(jwt["token"]!.GetValue<string>()));
|
|
|
|
|
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<int>());
|
|
|
|
|
Assert.False(body["isValid"]!.GetValue<bool>());
|
|
|
|
|
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<RootUser>().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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|