Files
SocialPub/PrivaPub.Tests/Http/ClientApiAccountsTests.cs
T
thepraandClaude Opus 5.5 8c2eba6cbb Everything on, phase 3: sign-in and recovery tell nothing, recovered passwords end sessions, deleted roots are gone everywhere
Owner decision 2026-10-04: fix the account privacy findings.

- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
  is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
  password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
  - Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
    email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
  - Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
  - A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
    checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
  the password).
  - Its sessions end.
  - Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
  - The personas' posts are emptied.
  - /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
    LocalActorService.Gone. The names stay reserved.
  - The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
    "Deleted user".

Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.

657 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-04 03:16:59 +02:00

493 lines
25 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.Models.Group;
using PrivaPub.Models.User;
using PrivaPub.Services;
using PrivaPub.Models.Jobs;
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>());
// a wrong password and an unknown login get the same answer, so sign-in tells nobody which logins exist
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.Equal(RootUsersService.NoMatch, await Message(wrong));
Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode);
Assert.Equal(await Message(wrong), await Message(unknown));
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_answers_the_same_whoever_asks_and_queues_the_work()
{
var token = TestContext.Current.CancellationToken;
var since = DateTime.UtcNow.AddSeconds(-1);
var withEmail = await _host.SignUp("withemail");
var withoutEmail = await _host.SignUp("noemail");
var email = $"{Guid.NewGuid():N}@example.test";
using (var signedIn = _host.As(withEmail.Jwt))
Assert.Equal(HttpStatusCode.OK, (await signedIn.PostJson("/clientapi/user/update", new { email })).StatusCode);
using var client = _host.Client();
var answers = new[]
{
await client.PostJson("/clientapi/user/recover/password", new { userName = withEmail.UserName }),
await client.PostJson("/clientapi/user/recover/password", new { email }),
await client.PostJson("/clientapi/user/recover/password", new { userName = withoutEmail.UserName }),
await client.PostJson("/clientapi/user/recover/password", new { userName = Name("nobody") }),
await client.PostJson("/clientapi/user/recover/password", new { email = $"{Guid.NewGuid():N}@example.test" })
};
var bodies = new List<string>();
foreach (var answer in answers)
{
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
bodies.Add(await answer.Content.ReadAsStringAsync(token));
}
Assert.Single(bodies.Distinct());
Assert.Contains("recovery link is on its way", bodies[0]);
var queued = await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.SendRecovery && j.CreatedAt >= since).ExecuteAsync(token);
Assert.True(queued.Count >= answers.Length);
Assert.Contains(queued, j => j.Payload.Contains(withEmail.Id));
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostJson("/clientapi/user/recover/password", new { })).StatusCode);
}
[Fact]
public async Task The_recovery_job_keeps_only_a_hash_and_an_unreachable_mail_server_says_nothing_to_anyone()
{
var token = TestContext.Current.CancellationToken;
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 answer = await client.PostJson("/clientapi/user/recover/password", new { userName = root.UserName });
await _host.Run(j => j.Kind == JobKind.SendRecovery && j.Payload.Contains(root.Id), token);
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
var recovery = await DB.Default.Find<EmailRecovery>().Match(r => r.RootUserId == root.Id).ExecuteFirstAsync(token);
Assert.NotNull(recovery);
Assert.Null(recovery.RecoveryCode);
Assert.Equal(64, recovery.CodeHash.Length);
Assert.InRange(recovery.ExpiresAt, DateTime.UtcNow.AddMinutes(50), DateTime.UtcNow.AddMinutes(61));
}
[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, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddHours(1) },
TestContext.Current.CancellationToken);
var persona = await _host.Persona(root, "recovered");
var apiToken = await _host.MastodonToken(persona);
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));
// whoever held the old sessions may be why the password was recovered: they end
using (var oldSession = _host.As(root.Jwt))
Assert.Equal(HttpStatusCode.Unauthorized, (await oldSession.GetAsync("/clientapi/user/sniff/again", TestContext.Current.CancellationToken)).StatusCode);
using (var oldApp = _host.Client())
{
oldApp.DefaultRequestHeaders.Authorization = new("Bearer", apiToken);
Assert.Equal(HttpStatusCode.Unauthorized, (await oldApp.GetAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken)).StatusCode);
}
}
[Fact]
public async Task An_expired_recovery_code_changes_nothing()
{
var root = await _host.SignUp("expired");
var code = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
await DB.Default.SaveAsync(new EmailRecovery { RootUserId = root.Id, CodeHash = RecoveryJob.Hash(code), ExpiresAt = DateTime.UtcNow.AddMinutes(-1) },
TestContext.Current.CancellationToken);
using var client = _host.Client();
var change = await client.PostJson("/clientapi/user/recover/update/password", new { newPassword = NewPassword, repeatedPassword = NewPassword, recoveryCode = code });
Assert.Equal(HttpStatusCode.NotFound, change.StatusCode);
Assert.Equal(HttpStatusCode.OK, (await LogIn(root.UserName, root.Password)).StatusCode);
}
[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));
}
}
}