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:
thepraandClaude Opus 5.5 committed 2026-10-03 11:53:23 +02:00
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));
}
}
}
+257
View File
@@ -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));
}
}
}
+346
View File
@@ -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();
}
}