T5: OAuth and the client API over HTTP
96 integration tests through PrivaPubHost, the real pipeline end to end:
- OAuth: the token's subject is the persona, and neither the token response,
verify_credentials nor the stored token entries name the root. A wrong password shows
an error and sets no login cookie; a login without the antiforgery token is a 400; the
return address never leaves the site; deny answers access_denied with no code; another
root's persona re-renders the choice with no code; a banned root is sent back to the
login and a code issued before the ban buys no token; force_login asks again; a code
works once and its reuse revokes the token it bought; password and refresh_token
grants are refused; a client_credentials token gets 401 on user routes; a read-only
token gets 403 with a Mastodon error on POST /api/v1/statuses; follow covers
read:follows; revoke works; the login and authorize pages send their CSP and no-store;
the 11th /oauth/login from one address in a minute is a 429.
- Accounts: sign-up, duplicates in any case, invalid models answer 400 with a message,
login and logout, recovery email, settings, password change, invitation sign-up and
login (refusing a persona named after the login), recovery without an email, through
an unreachable mail server, with a wrong and with a valid code, token refresh, the 11th
sign-up from one address, expired, garbage and foreign-key JWTs.
- Personas: a rootId in the body is ignored, the username regex and reserved names hold,
personas and groups share ReservedName, an update delivers Update{Person} to followers,
PublishedOn and the id's day fall within two weeks before creation, the list holds only
one's own personas, another root's persona cannot be updated.
- Groups: communities and circles are created, joining takes the code and the password,
members leave and owners cannot, a remote follow request becomes a member only on
approval, a circle never shows in lookup, account by id, v2 search or /@name, and its
/flock and /wardens answer 404 unsigned and to non-members, 200 to a member's signed GET.
- Moderation (Exclusive, it suspends localhost): ban, unban and remove; non-admins get
403; reports are listed without the reporter and resolved; domain blocks are inserted,
listed and deleted, bad domains refused, and a suspended server's delivery is answered
202 and kept nowhere; the data endpoints.
- AdminCommands: exit codes 0, 1 and 2 and the resulting policies.
Fixed:
- A banned or removed root kept using /clientapi with its JWT until it expired: only /api
re-checked the root. JwtEvents.TokenValidated now loads the root and fails the request
when it is banned or deleted, and takes the policy claims from the database, so a
demoted admin loses admin at once (and a promoted one gains it).
- The 401 and 403 bodies JwtEvents writes were PascalCase while every other /clientapi
answer is camelCase; they now use the web defaults.
- /clientapi/user/sniff/again (token refresh) answered an empty 200; it now answers a
fresh JwtUser, like login.
- Password recovery answered SMTP reply codes as HTTP statuses (421, 454, 554, and 550 for
an invalid address); a mail server failure is now 503 and an invalid address 400.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
1 parent
c301f0c498
commit
c5e4934ba6
10 files changed
+1879
-10
No files matched your search
@@ -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<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>());
|
||||
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<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]
|
||||
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<EmailRecovery>().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<string>());
|
||||
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<EmailRecovery>().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<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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Persona> NewPersona(string name) => await _host.Persona(await _host.SignUp(name), name);
|
||||
|
||||
static Task<GroupEntity> Stored(JsonObject group) =>
|
||||
DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
async Task<List<string>> Groups(Persona persona)
|
||||
{
|
||||
using var client = _host.As(persona.Root.Jwt);
|
||||
var response = await client.GetAsync("/clientapi/group/list?avatarId=" + persona.Id, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return (await response.JsonItems()).Select(g => g!["id"]!.GetValue<string>()).ToList();
|
||||
}
|
||||
|
||||
async Task<HttpResponseMessage> Approve(Persona manager, JsonObject group, string actorUri)
|
||||
{
|
||||
using var client = _host.As(manager.Root.Jwt);
|
||||
return await client.PostJson("/clientapi/group/approve", new { avatarId = manager.Id, groupId = group["id"]!.GetValue<string>(), memberActorURI = actorUri });
|
||||
}
|
||||
|
||||
async Task<(Persona Owner, JsonObject Circle, RemoteActor Member)> CircleWithRemoteMember()
|
||||
{
|
||||
var owner = await NewPersona("circleowner");
|
||||
var circle = await _host.Group(owner, community: false, name: "circle");
|
||||
var member = new RemoteActor(_peer, "member");
|
||||
await _host.Follow(member, _peer.A, circle["userName"]!.GetValue<string>());
|
||||
Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode);
|
||||
return (owner, circle, member);
|
||||
}
|
||||
|
||||
async Task<HttpStatusCode> Get(string path, RemoteActor signer = default)
|
||||
{
|
||||
using var client = _host.Client();
|
||||
using var request = signer == default ? new HttpRequestMessage(HttpMethod.Get, path) : signer.SignedGet(path);
|
||||
request.Headers.Accept.ParseAdd("application/activity+json");
|
||||
return (await client.SendAsync(request, TestContext.Current.CancellationToken)).StatusCode;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_community_and_a_circle_are_created()
|
||||
{
|
||||
var owner = await NewPersona("founder");
|
||||
|
||||
var community = await _host.Group(owner, community: true, name: "community");
|
||||
var circle = await _host.Group(owner, community: false, password: DoorPassword, name: "circle");
|
||||
|
||||
Assert.True(community["isCommunity"]!.GetValue<bool>());
|
||||
Assert.False(circle["isCommunity"]!.GetValue<bool>());
|
||||
foreach (var group in new[] { community, circle })
|
||||
{
|
||||
Assert.True(group["isOwner"]!.GetValue<bool>());
|
||||
Assert.Equal(64, group["invitationCode"]!.GetValue<string>().Length);
|
||||
Assert.Equal($"{PrivaPubHost.Base}/peasants/{group["userName"]!.GetValue<string>()}", group["url"]!.GetValue<string>());
|
||||
Assert.Equal(1, group["membersCount"]!.GetValue<int>());
|
||||
}
|
||||
Assert.True(circle["isPasswordRequired"]!.GetValue<bool>());
|
||||
Assert.False(community["isPasswordRequired"]!.GetValue<bool>());
|
||||
Assert.Equal(GroupKind.Community, (await Stored(community)).Kind);
|
||||
var storedCircle = await Stored(circle);
|
||||
Assert.Equal(GroupKind.Circle, storedCircle.Kind);
|
||||
Assert.NotEqual(DoorPassword, storedCircle.HashedInvitationPassword);
|
||||
Assert.Contains(storedCircle.Members, m => m.AvatarId == owner.Id && m.Role == GroupRole.Owner);
|
||||
Assert.Equal(new[] { community["id"]!.GetValue<string>(), circle["id"]!.GetValue<string>() }.Order(), (await Groups(owner)).Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_group_needs_a_persona_of_ones_own()
|
||||
{
|
||||
var owner = await NewPersona("founder");
|
||||
var stranger = await _host.SignUp("stranger");
|
||||
using var client = _host.As(stranger.Jwt);
|
||||
|
||||
var insert = await client.PostJson("/clientapi/group/insert", new { avatarId = owner.Id, userName = $"stolen{Guid.NewGuid():N}"[..20], name = "stolen", isCommunity = true });
|
||||
var list = await client.GetAsync("/clientapi/group/list?avatarId=" + owner.Id, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, insert.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, list.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Joining_takes_the_code_and_the_password()
|
||||
{
|
||||
var owner = await NewPersona("host");
|
||||
var circle = await _host.Group(owner, community: false, password: DoorPassword);
|
||||
var joiner = await NewPersona("guest");
|
||||
var invitationCode = circle["invitationCode"]!.GetValue<string>();
|
||||
using var client = _host.As(joiner.Root.Jwt);
|
||||
|
||||
var noPassword = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode });
|
||||
var wrongPassword = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = "wrong" });
|
||||
var wrongCode = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = $"{Guid.NewGuid():N}{Guid.NewGuid():N}", invitationPassword = DoorPassword });
|
||||
var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = DoorPassword });
|
||||
var twice = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode, invitationPassword = DoorPassword });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotAcceptable, noPassword.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotAcceptable, wrongPassword.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, wrongCode.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
|
||||
var view = await joined.JsonBody();
|
||||
Assert.Equal(2, view["membersCount"]!.GetValue<int>());
|
||||
Assert.False(view["isOwner"]!.GetValue<bool>());
|
||||
Assert.Null(view["invitationCode"]);
|
||||
Assert.Equal(HttpStatusCode.OK, twice.StatusCode);
|
||||
Assert.Equal(2, (await Stored(circle)).Members.Count);
|
||||
Assert.Equal(new[] { circle["id"]!.GetValue<string>() }, await Groups(joiner));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Joining_needs_a_persona_of_ones_own()
|
||||
{
|
||||
var owner = await NewPersona("host");
|
||||
var community = await _host.Group(owner, community: true);
|
||||
var victim = await NewPersona("victim");
|
||||
var stranger = await _host.SignUp("stranger");
|
||||
using var client = _host.As(stranger.Jwt);
|
||||
|
||||
var response = await client.PostJson("/clientapi/group/join", new { avatarId = victim.Id, invitationCode = community["invitationCode"]!.GetValue<string>() });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.DoesNotContain((await Stored(community)).Members, m => m.AvatarId == victim.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Members_leave_and_owners_cannot()
|
||||
{
|
||||
var owner = await NewPersona("host");
|
||||
var community = await _host.Group(owner, community: true);
|
||||
var member = await NewPersona("leaver");
|
||||
var groupId = community["id"]!.GetValue<string>();
|
||||
using (var joining = _host.As(member.Root.Jwt))
|
||||
Assert.Equal(HttpStatusCode.OK, (await joining.PostJson("/clientapi/group/join", new { avatarId = member.Id, invitationCode = community["invitationCode"]!.GetValue<string>() })).StatusCode);
|
||||
using var memberClient = _host.As(member.Root.Jwt);
|
||||
using var ownerClient = _host.As(owner.Root.Jwt);
|
||||
|
||||
var left = await memberClient.PostJson("/clientapi/group/leave", new { avatarId = member.Id, groupId });
|
||||
var ownerLeft = await ownerClient.PostJson("/clientapi/group/leave", new { avatarId = owner.Id, groupId });
|
||||
|
||||
Assert.True(left.IsSuccessStatusCode);
|
||||
Assert.Empty(await Groups(member));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, ownerLeft.StatusCode);
|
||||
Assert.Equal(new[] { owner.Id }, (await Stored(community)).Members.Select(m => m.AvatarId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circle_takes_a_remote_member_only_once_the_owner_approves()
|
||||
{
|
||||
var owner = await NewPersona("approver");
|
||||
var circle = await _host.Group(owner, community: false);
|
||||
var member = new RemoteActor(_peer, "asker");
|
||||
var since = DateTime.UtcNow.AddSeconds(-1);
|
||||
|
||||
await _host.Follow(member, _peer.A, circle["userName"]!.GetValue<string>());
|
||||
|
||||
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle["id"]!.GetValue<string>() && f.ActorURI == member.Id).ExecuteSingleAsync(TestContext.Current.CancellationToken);
|
||||
Assert.False(request.IsAccepted);
|
||||
Assert.DoesNotContain((await Stored(circle)).Members, m => m.AvatarId == member.Id);
|
||||
Assert.Empty(await Jobs.Deliveries(member.Id + "/inbox", since, TestContext.Current.CancellationToken));
|
||||
|
||||
var outsider = await NewPersona("outsider");
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await Approve(outsider, circle, member.Id)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await Approve(owner, circle, $"{_peer.A}/users/nobody")).StatusCode);
|
||||
Assert.True((await Approve(owner, circle, member.Id)).IsSuccessStatusCode);
|
||||
|
||||
Assert.Contains((await Stored(circle)).Members, m => m.IsForeign && m.AvatarId == member.Id);
|
||||
Assert.True((await DB.Default.Find<Follower>().MatchID(request.ID).ExecuteFirstAsync(TestContext.Current.CancellationToken)).IsAccepted);
|
||||
var accept = Assert.Single(await Jobs.Deliveries(member.Id + "/inbox", since, TestContext.Current.CancellationToken));
|
||||
Assert.Equal("Accept", accept["type"]!.GetValue<string>());
|
||||
Assert.Equal(circle["url"]!.GetValue<string>(), accept["actor"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circle_is_never_found()
|
||||
{
|
||||
var owner = await NewPersona("hidden");
|
||||
var circle = await _host.Group(owner, community: false, name: "circle");
|
||||
var community = await _host.Group(owner, community: true, name: "community");
|
||||
var circleName = circle["userName"]!.GetValue<string>();
|
||||
var communityName = community["userName"]!.GetValue<string>();
|
||||
using var anonymous = _host.Client();
|
||||
using var searcher = _host.As(await _host.MastodonToken(await NewPersona("searcher")));
|
||||
|
||||
foreach (var acct in new[] { circleName, $"{circleName}@{PrivaPubHost.Host}", $"@{circleName}@{PrivaPubHost.Host}" })
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync("/api/v1/accounts/lookup?acct=" + Uri.EscapeDataString(acct), TestContext.Current.CancellationToken)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await searcher.GetAsync("/api/v1/accounts/lookup?acct=" + Uri.EscapeDataString(acct), TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync("/api/v1/accounts/lookup?acct=" + communityName, TestContext.Current.CancellationToken)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await searcher.GetAsync("/api/v1/accounts/" + circle["id"]!.GetValue<string>(), TestContext.Current.CancellationToken)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await searcher.GetAsync("/api/v1/accounts/" + community["id"]!.GetValue<string>(), TestContext.Current.CancellationToken)).StatusCode);
|
||||
|
||||
foreach (var q in new[] { circleName, $"@{circleName}@{PrivaPubHost.Host}", circle["url"]!.GetValue<string>() })
|
||||
{
|
||||
var found = await searcher.GetStringAsync($"/api/v2/search?resolve=true&q={Uri.EscapeDataString(q)}", TestContext.Current.CancellationToken);
|
||||
Assert.Empty(JsonNode.Parse(found)!["accounts"]!.AsArray());
|
||||
Assert.Empty(JsonNode.Parse(found)!["statuses"]!.AsArray());
|
||||
}
|
||||
var communityFound = await searcher.GetStringAsync($"/api/v2/search?resolve=true&q={Uri.EscapeDataString(community["url"]!.GetValue<string>())}", TestContext.Current.CancellationToken);
|
||||
Assert.Single(JsonNode.Parse(communityFound)!["accounts"]!.AsArray());
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"/@{circleName}", TestContext.Current.CancellationToken)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync($"/@{communityName}", TestContext.Current.CancellationToken)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_circles_members_and_wardens_are_shown_only_to_members()
|
||||
{
|
||||
var (_, circle, member) = await CircleWithRemoteMember();
|
||||
var outsider = new RemoteActor(_peer, "outsider");
|
||||
var community = await _host.Group(await NewPersona("open"), community: true);
|
||||
var circleName = circle["userName"]!.GetValue<string>();
|
||||
var communityName = community["userName"]!.GetValue<string>();
|
||||
|
||||
foreach (var collection in new[] { "flock", "wardens" })
|
||||
{
|
||||
var path = $"/peasants/{circleName}/{collection}";
|
||||
Assert.Equal(HttpStatusCode.NotFound, await Get(path));
|
||||
Assert.Equal(HttpStatusCode.NotFound, await Get(path, outsider));
|
||||
Assert.Equal(HttpStatusCode.OK, await Get(path, member));
|
||||
Assert.Equal(HttpStatusCode.OK, await Get($"/peasants/{communityName}/{collection}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HttpStatusCode> Settings(string jwt)
|
||||
{
|
||||
using var client = _host.As(jwt);
|
||||
return (await client.GetAsync("/clientapi/user/settings", TestContext.Current.CancellationToken)).StatusCode;
|
||||
}
|
||||
|
||||
async Task<HttpStatusCode> Credentials(string token)
|
||||
{
|
||||
using var client = _host.As(token);
|
||||
return (await client.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode;
|
||||
}
|
||||
|
||||
async Task<HttpResponseMessage> LogIn(Root root)
|
||||
{
|
||||
using var client = _host.Client();
|
||||
return await client.PostJson("/clientapi/user/login", new { userName = root.UserName, password = root.Password });
|
||||
}
|
||||
|
||||
static Task<HttpResponseMessage> Remove(HttpClient client, params string[] ids) =>
|
||||
client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/clientapi/admin/remove/users") { Content = JsonContent.Create(new { userIdList = ids }) });
|
||||
|
||||
static Task<RootUser> Stored(string id) => DB.Default.Find<RootUser>().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"] = "<p>hello</p>" }
|
||||
};
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
|
||||
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<HttpResponseMessage>
|
||||
{
|
||||
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<int>());
|
||||
Assert.Equal("Forbidden.", body["errorMessage"]!.GetValue<string>());
|
||||
}
|
||||
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<DomainBlock>().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<Models.Social.Report>().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<string>() == reportId)!;
|
||||
Assert.Equal("local", listed["reporter"]!.GetValue<string>());
|
||||
Assert.Equal(target.Id, listed["targetAccountId"]!.GetValue<string>());
|
||||
Assert.Equal("spam", listed["category"]!.GetValue<string>());
|
||||
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<Models.Social.Report>().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<string>());
|
||||
Assert.Equal("Silence", view["severity"]!.GetValue<string>());
|
||||
Assert.True(view["rejectMedia"]!.GetValue<bool>());
|
||||
Assert.Contains(listed, b => b!["domain"]!.GetValue<string>() == domain);
|
||||
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
||||
Assert.Equal("Suspend", (await suspended.JsonBody())["severity"]!.GetValue<string>());
|
||||
Assert.Equal(view["id"]!.GetValue<string>(), (await suspended.JsonBody())["id"]!.GetValue<string>());
|
||||
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
|
||||
Assert.DoesNotContain(after, b => b!["domain"]!.GetValue<string>() == domain);
|
||||
Assert.Null(_host.Get<IDomainBlocks>().Find(domain));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
|
||||
await _host.Get<IDomainBlocks>().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<DomainBlock>().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<Job>().Match(j => j.DedupeKey == "inbox|" + blocked["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
|
||||
Assert.Equal(0, await _host.RunInbox(blocked["id"]!.GetValue<string>(), token));
|
||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ObjectURI == blocked["object"]!["id"]!.GetValue<string>()).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<string>(), token));
|
||||
Assert.True(await DB.Default.Find<Post>().Match(p => p.ObjectURI == welcome["object"]!["id"]!.GetValue<string>()).ExecuteAnyAsync(token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
|
||||
await _host.Get<IDomainBlocks>().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<string>()).Order().ToList();
|
||||
Assert.Equal(new[] { "en", "it" }, codes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HttpResponseMessage> Insert(Root root, object body)
|
||||
{
|
||||
using var client = _host.As(root.Jwt);
|
||||
return await client.PostJson("/clientapi/avatar/private/insert", body);
|
||||
}
|
||||
|
||||
async Task<List<string>> 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<string>()).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<string>();
|
||||
var link = await DB.Default.Find<RootToAvatar>().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<ReservedName>().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<string>());
|
||||
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<string>();
|
||||
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<ReservedName>().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<string>());
|
||||
Assert.Equal(1, await DB.Default.CountAsync<Avatar>(a => a.UserName == persona.UserName, TestContext.Current.CancellationToken));
|
||||
Assert.Equal(1, await DB.Default.CountAsync<GroupEntity>(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<string>() == "Update");
|
||||
Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", update["actor"]!.GetValue<string>());
|
||||
var person = update["object"]!.AsObject();
|
||||
Assert.Equal("Person", person["type"]!.GetValue<string>());
|
||||
Assert.Equal($"{PrivaPubHost.Base}/peasants/{persona.UserName}", person["id"]!.GetValue<string>());
|
||||
Assert.Equal("Renamed", person["name"]!.GetValue<string>());
|
||||
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<Avatar>().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<Avatar>().MatchID(persona.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken);
|
||||
var stored = await DB.Default.Find<GroupEntity>().MatchID(group["id"]!.GetValue<string>()).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<string>(), 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Persona> NewPersona(string name = "oauth") => await _host.Persona(await _host.SignUp(), name);
|
||||
|
||||
static async Task<bool> 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<string>()));
|
||||
}
|
||||
|
||||
[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<string>();
|
||||
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<string>());
|
||||
}
|
||||
|
||||
[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<string>(),
|
||||
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<string>());
|
||||
}
|
||||
|
||||
[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<string>());
|
||||
using var api = _host.As((await first.JsonBody())["access_token"]!.GetValue<string>());
|
||||
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<string>());
|
||||
}
|
||||
|
||||
[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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<string>> StoredPolicies(Root root) =>
|
||||
(await DB.Default.Find<RootUser>().MatchID(root.Id).ExecuteFirstAsync(TestContext.Current.CancellationToken)).Policies.Order().ToList();
|
||||
|
||||
async Task<HttpStatusCode> 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<string[]> Misuses() => new()
|
||||
{
|
||||
Array.Empty<string>(),
|
||||
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<string>()));
|
||||
}
|
||||
|
||||
Assert.Equal(0, await AdminCommands.Run(new[] { "demote", root.UserName }));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, await AdminRoute(root));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JsonObject> JsonBody(this HttpResponseMessage response) =>
|
||||
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
|
||||
|
||||
public static async Task<JsonArray> JsonItems(this HttpResponseMessage response) =>
|
||||
JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsArray();
|
||||
|
||||
public static Task<HttpResponseMessage> PostJson(this HttpClient client, string path, object body) =>
|
||||
client.PostAsJsonAsync(path, body);
|
||||
|
||||
public static Task<HttpResponseMessage> Form(this HttpClient client, string path, params (string Key, string Value)[] fields) =>
|
||||
client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value))));
|
||||
|
||||
public static async Task<OAuthApp> 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<string>(), app["client_secret"]!.GetValue<string>(), scopes);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> 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<string, string>
|
||||
{
|
||||
["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<List<KeyValuePair<string, string>>> Choice(this HttpClient client, OAuthApp app)
|
||||
{
|
||||
var page = await client.GetStringAsync(app.ReturnUrl() + "&signed_in=1");
|
||||
return HiddenInput().Matches(page).Select(m => new KeyValuePair<string, string>(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList();
|
||||
}
|
||||
|
||||
public static Task<HttpResponseMessage> Decide(this HttpClient client, IEnumerable<KeyValuePair<string, string>> 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<HttpResponseMessage> 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<string> 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<HttpResponseMessage> 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<string> 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<string>();
|
||||
}
|
||||
|
||||
public static async Task<JsonObject> 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<RootUser>().MatchID(rootId).Modify(u => u.IsBanned, banned).ExecuteAsync();
|
||||
|
||||
public static async Task<JsonObject> 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<string>()));
|
||||
return follow;
|
||||
}
|
||||
|
||||
public static async Task<List<BsonDocument>> StoredTokens(string clientId)
|
||||
{
|
||||
var database = DB.Default.Database();
|
||||
var application = await database.GetCollection<BsonDocument>("openiddict.applications")
|
||||
.Find(Builders<BsonDocument>.Filter.Eq("client_id", clientId)).FirstAsync();
|
||||
return await database.GetCollection<BsonDocument>("openiddict.tokens")
|
||||
.Find(Builders<BsonDocument>.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("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
|
||||
private static partial Regex HiddenInput();
|
||||
|
||||
[GeneratedRegex("<code>([^<]*)</code>")]
|
||||
private static partial Regex CodeElement();
|
||||
}
|
||||
}
|
||||
@@ -435,10 +435,25 @@ namespace PrivaPub.Controllers.ClientToServer
|
||||
#region Auth refresh
|
||||
|
||||
[HttpGet, Route("/clientapi/user/sniff/again"), Authorize(Policy = Policies.IsUser)]
|
||||
public async Task<IActionResult> SniffAgain()
|
||||
public async Task<IActionResult> 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
|
||||
|
||||
@@ -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<JwtEvents> _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<DbEntities>().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<IStringLocalizer<GenericRes>>();
|
||||
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<IStringLocalizer<GenericRes>>();
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user