mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
643 lines
25 KiB
C#
643 lines
25 KiB
C#
using System.Reflection;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using RadicadeServer.Admin;
|
|
using RadicadeServer.Auth;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.RecNet.Accounts;
|
|
using RadicadeServer.RecNet.Accounts.Local;
|
|
using RadicadeServer.RecNet.Auth;
|
|
using RadicadeServer.RecNet.Auth.Cached;
|
|
using RadicadeServer.RecNet.Auth.Connection;
|
|
using RadicadeServer.RecNet.Enums.Moderation;
|
|
using RadicadeServer.RecNet.Enums.Players;
|
|
using RadicadeServer.RecNet.Moderation;
|
|
using RadicadeServer.RecNet.Responses;
|
|
using RadicadeServer.Utils;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
public class AccountLockTests
|
|
{
|
|
private const string Secret = "test-bot-secret";
|
|
private const string JwtKey = "account-lock-test-signing-key-with-sufficient-length";
|
|
|
|
private const string ExactMessage =
|
|
"@GreatEagle: This account has been locked for security reasons. Contact us at https://support.radicade.app.";
|
|
|
|
private const string ExactReason =
|
|
"Moderator (Account lock. Please submit a ticket with us to discuss. https://support.radicade.app/new/account-support/)";
|
|
|
|
private static RecDBContext NewDb(DbContextOptions<RecDBContext>? options = null) =>
|
|
new(options ?? TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options);
|
|
|
|
private static async Task SeedAccountAsync(RecDBContext db, long id, string username, string password, string deviceId = "device-1")
|
|
{
|
|
db.Profiles.Add(new Profile { accountId = id, username = username, displayName = username, profileImage = "default" });
|
|
var player = new PlayerData { AccountId = id, DeviceId = deviceId, DeviceClass = DeviceClass.Screen, AppVersion = "20230831", Email = "" };
|
|
player.Hashword = new PasswordHasher<PlayerData>().HashPassword(player, password);
|
|
db.Players.Add(player);
|
|
db.Presences.Add(new RealPresence { playerId = id, deviceClass = DeviceClass.Screen });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static AuthAPI CreateApi(RecDBContext db)
|
|
{
|
|
var context = new DefaultHttpContext { User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity()) };
|
|
context.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
return new AuthAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } };
|
|
}
|
|
|
|
private static TokenConnectData PasswordRequest(string username, string password) => new()
|
|
{
|
|
grant_type = "password",
|
|
platform = Platforms.Standalone,
|
|
device_id = "device-1",
|
|
client_id = "recroom",
|
|
ver = "20230831",
|
|
device_class = DeviceClass.Screen,
|
|
username = username,
|
|
password = password
|
|
};
|
|
|
|
private static TokenConnectData LinkedRefreshRequest() => new()
|
|
{
|
|
grant_type = "refresh_token",
|
|
platform = Platforms.Steam,
|
|
platform_id = "76561198000000000",
|
|
device_id = "device-1",
|
|
client_id = "recroom",
|
|
ver = "20230831",
|
|
device_class = DeviceClass.Screen,
|
|
refresh_token = GuidFromAccount(17)
|
|
};
|
|
|
|
private static string GuidFromAccount(long accountId)
|
|
{
|
|
var bytes = new byte[16];
|
|
Array.Copy(BitConverter.GetBytes(accountId), 0, bytes, 0, 8);
|
|
return new Guid(bytes).ToString();
|
|
}
|
|
|
|
private static async Task<IActionResult> PanelAction(RecDBContext db, long playerId, string kind)
|
|
{
|
|
var context = new DefaultHttpContext();
|
|
var api = new AdminPanelAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } };
|
|
var original = DBConfig.AdminApiKey;
|
|
DBConfig.AdminApiKey = Secret;
|
|
try { return await api.PlayerPanelAction(playerId, kind, Secret); }
|
|
finally { DBConfig.AdminApiKey = original; }
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public void TheLoginErrorIsExactlyTheSpecifiedMessageIncludingTheAtSign()
|
|
{
|
|
Assert.Equal(ExactMessage, AccountLock.LoginErrorMessage("GreatEagle"));
|
|
Assert.Equal(ExactMessage, AccountLock.LoginErrorMessage("@GreatEagle"));
|
|
Assert.Equal(AccountLock.ErrorCode, "ACCOUNT_PERMALOCKED");
|
|
Assert.Equal("https://support.radicade.app", AccountLock.SupportUrl);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheLoginErrorHasNoTitleOrPrefixAndNeverShowsTheInternalBanReason()
|
|
{
|
|
var message = AccountLock.LoginErrorMessage("GreatEagle");
|
|
|
|
Assert.DoesNotContain("Login", message);
|
|
Assert.DoesNotContain("Error:", message);
|
|
Assert.DoesNotContain("Login:", message);
|
|
Assert.DoesNotContain("Account lock.", message);
|
|
Assert.DoesNotContain("Please submit a ticket", message);
|
|
Assert.StartsWith("@GreatEagle: This account has been locked", message);
|
|
Assert.EndsWith("Contact us at https://support.radicade.app.", message);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheLoginErrorFallsBackSecurelyWhenTheUsernameCannotBeResolved()
|
|
{
|
|
foreach (var blank in new[] { null, "", " ", "@" })
|
|
{
|
|
var message = AccountLock.LoginErrorMessage(blank);
|
|
Assert.Equal(
|
|
"@Account: This account has been locked for security reasons. Contact us at https://support.radicade.app.",
|
|
message);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TheBanReasonIsTheExactModeratorWrappedStringAndIsNotTheLoginError()
|
|
{
|
|
Assert.StartsWith("Moderator (", AccountLock.Reason);
|
|
Assert.EndsWith("https://support.radicade.app/new/account-support/)", AccountLock.Reason);
|
|
Assert.Equal(ExactReason, AccountLock.Reason);
|
|
|
|
var login = AccountLock.LoginErrorMessage("GreatEagle");
|
|
Assert.DoesNotContain(AccountLock.Reason, login);
|
|
Assert.DoesNotContain("Moderator", login);
|
|
Assert.False(login.Contains(AccountLock.Reason, StringComparison.Ordinal));
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public async Task ApplyingALockPersistsThePermanentModeratorBan()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
|
|
var result = await AccountLock.ApplyAsync(db, 17, "StaffOne", "RAD-42");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.True(result.Changed);
|
|
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == 17);
|
|
Assert.True(ban.IsBan);
|
|
Assert.Equal(ReportCategory.Moderator, ban.Category);
|
|
Assert.Equal(ExactReason, ban.Message);
|
|
Assert.Equal(AccountLock.BanMethod, ban.BanMethod);
|
|
Assert.Equal(DateTime.MaxValue, ban.ExpireDuration);
|
|
Assert.Equal("RAD-42", ban.ModeratorNote);
|
|
Assert.True(await AccountLock.IsLockedAsync(db, 17));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyingALockTwiceIsIdempotent()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
var second = await AccountLock.ApplyAsync(db, 17, "StaffTwo", null);
|
|
|
|
Assert.True(second.Success);
|
|
Assert.False(second.Changed);
|
|
Assert.Single(await db.Moderation.Where(x => x.PlayerId == 17).ToListAsync());
|
|
Assert.True(await AccountLock.IsLockedAsync(db, 17));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnlockingRemovesOnlyTheAccountLock()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
var result = await AccountLock.RemoveAsync(db, 17, "StaffOne", "RAD-42");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Empty(await db.Moderation.Where(x => x.PlayerId == 17).ToListAsync());
|
|
Assert.False(await AccountLock.IsLockedAsync(db, 17));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnlockingRefusesToRemoveAnUnrelatedBan()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
db.Moderation.Add(new ModerationDB
|
|
{
|
|
PlayerId = 17,
|
|
IsBan = true,
|
|
Category = ReportCategory.CoC_Trolling,
|
|
Message = "trolling",
|
|
BanMethod = "Admin Panel",
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpireDuration = DateTime.UtcNow.AddDays(3)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await AccountLock.RemoveAsync(db, 17, "StaffOne", null);
|
|
|
|
Assert.False(result.Success);
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == 17);
|
|
Assert.Equal("Admin Panel", ban.BanMethod);
|
|
Assert.Equal("trolling", ban.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LockStateFailsClosedWhenItCannotBeRead()
|
|
{
|
|
var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
await db.DisposeAsync();
|
|
|
|
Assert.True(await AccountLock.IsLockedAsync(db, 17));
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public async Task ANormalAccountCanStillLogIn()
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = JwtKey;
|
|
try
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
|
|
(await CreateApi(db).ConnectWithToken(PasswordRequest("GreatEagle", "pw123456"))).Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error));
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ALockedAccountCannotLogInWithCorrectCredentialsAndNoSessionIsIssued()
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = JwtKey;
|
|
try
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
|
|
(await CreateApi(db).ConnectWithToken(PasswordRequest("GreatEagle", "pw123456"))).Result).Value);
|
|
|
|
Assert.Equal("ACCOUNT_PERMALOCKED", response.error);
|
|
Assert.Equal(ExactMessage, response.error_description);
|
|
Assert.True(string.IsNullOrEmpty(response.access_token));
|
|
Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), x => x.accountId == 17);
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ALockedAccountCanStillSignInThroughAnIdentityThatIsAlreadyLinked()
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = JwtKey;
|
|
try
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
db.CachedLogins.Add(new CachedLogin
|
|
{
|
|
accountId = 17,
|
|
platform = Platforms.Steam,
|
|
platformId = "76561198000000000",
|
|
lastLoginTime = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
|
|
(await CreateApi(db).ConnectWithToken(LinkedRefreshRequest())).Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ALockedAccountIsRefusedAgainOnceTheLinkedIdentityIsDeleted()
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = JwtKey;
|
|
try
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
db.CachedLogins.Add(new CachedLogin
|
|
{
|
|
accountId = 17,
|
|
platform = Platforms.Steam,
|
|
platformId = "76561198000000000",
|
|
lastLoginTime = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
db.CachedLogins.RemoveRange(db.CachedLogins.Where(x => x.accountId == 17));
|
|
await db.SaveChangesAsync();
|
|
|
|
for (var attempt = 0; attempt < 2; attempt++)
|
|
{
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
|
|
(await CreateApi(db).ConnectWithToken(LinkedRefreshRequest())).Result).Value);
|
|
|
|
Assert.Equal("ACCOUNT_PERMALOCKED", response.error);
|
|
Assert.Equal(ExactMessage, response.error_description);
|
|
Assert.True(string.IsNullOrEmpty(response.access_token));
|
|
}
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ALockedAccountCanStillUseItsPasswordOnALinkedIdentityButNotAFreshOne()
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = JwtKey;
|
|
try
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
db.CachedLogins.Add(new CachedLogin
|
|
{
|
|
accountId = 17,
|
|
platform = Platforms.Standalone,
|
|
platformId = "radicade-quest:device-1",
|
|
lastLoginTime = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
var linked = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
|
|
(await CreateApi(db).ConnectWithToken(PasswordRequest("GreatEagle", "pw123456"))).Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(linked.error), linked.error);
|
|
Assert.False(string.IsNullOrWhiteSpace(linked.access_token));
|
|
|
|
var fresh = PasswordRequest("GreatEagle", "pw123456");
|
|
fresh.device_id = "device-2";
|
|
var refused = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
|
|
(await CreateApi(db).ConnectWithToken(fresh)).Result).Value);
|
|
Assert.Equal("ACCOUNT_PERMALOCKED", refused.error);
|
|
Assert.Equal(ExactMessage, refused.error_description);
|
|
Assert.True(string.IsNullOrEmpty(refused.access_token));
|
|
Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), x => x.platformId == "radicade-quest:device-2");
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheLockSurvivesAServerRestart()
|
|
{
|
|
var options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
await using (var first = NewDb(options))
|
|
{
|
|
await SeedAccountAsync(first, 17, "GreatEagle", "pw123456");
|
|
await AccountLock.ApplyAsync(first, 17, "StaffOne", null);
|
|
}
|
|
|
|
await using var restarted = NewDb(options);
|
|
Assert.True(await AccountLock.IsLockedAsync(restarted, 17));
|
|
|
|
var lockBan = await AccountLock.FindActiveLockAsync(restarted, 17);
|
|
Assert.NotNull(lockBan);
|
|
Assert.Equal(ReportCategory.Moderator, lockBan!.Category);
|
|
Assert.Equal(ExactReason, lockBan.Message);
|
|
Assert.Equal(DateTime.MaxValue, lockBan.ExpireDuration);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateTokenNeverMintsATokenForALockedAccount()
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = JwtKey;
|
|
try
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
var generate = typeof(AuthAPI).GetMethod("GenerateToken",
|
|
BindingFlags.NonPublic | BindingFlags.Static)!;
|
|
var result = await (Task<Success>)generate.Invoke(null, new object[]
|
|
{
|
|
db, 17L, "20230831", DeviceClass.Screen, Platforms.Steam, "studio:17"
|
|
})!;
|
|
|
|
Assert.False(result.success);
|
|
Assert.Equal("ACCOUNT_PERMALOCKED", result.error);
|
|
Assert.Null(result.value);
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public async Task TheLockIsARealBanForTheInGameGatesAndCarriesTheExactReason()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
var lockBan = await db.Moderation.SingleAsync(x => x.PlayerId == 17);
|
|
Assert.True(lockBan.IsBan);
|
|
Assert.Equal(ReportCategory.Moderator, lockBan.Category);
|
|
Assert.Equal(ExactReason, lockBan.Message);
|
|
Assert.Equal(DateTime.MaxValue, lockBan.ExpireDuration);
|
|
Assert.NotNull(await AccountLock.FindActiveLockAsync(db, 17));
|
|
|
|
Assert.True(await db.IsBanned(17));
|
|
var ban = await db.ResolveBanAsync(17);
|
|
Assert.NotNull(ban);
|
|
Assert.Equal(ReportCategory.Moderator, ban!.Category);
|
|
Assert.Equal(ExactReason, ban.Reason);
|
|
Assert.True(ban.IsPermanent);
|
|
|
|
var details = PlayerUtils.BuildBlockDetails(true, ban.Category, ban.Reason, ban.ExpiryUtc, startedAtUtc: ban.StartedAtUtc);
|
|
Assert.Equal(ReportCategory.Moderator, details.ReportCategory);
|
|
Assert.Equal(ExactReason, details.Message);
|
|
Assert.Equal("Moderation", details.BanType);
|
|
Assert.DoesNotContain("locked for security reasons", details.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LockingAnOnlinePlayerLandsAsABanEvenIfTheDormMoveCannotRun()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
|
|
var presence = await db.Presences.SingleAsync(x => x.playerId == 17);
|
|
presence.isOnline = true;
|
|
presence.roomInstance = 999999;
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.True(await AccountLock.IsLockedAsync(db, 17));
|
|
Assert.Single(await db.Moderation.Where(x => x.PlayerId == 17).ToListAsync());
|
|
Assert.True(await db.IsBanned(17));
|
|
|
|
var ban = await db.ResolveBanAsync(17);
|
|
Assert.NotNull(ban);
|
|
Assert.Equal(ReportCategory.Moderator, ban!.Category);
|
|
Assert.Equal(ExactReason, ban.Reason);
|
|
Assert.True(ban.IsPermanent);
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public async Task ALockCannotBeAppealedThroughTheAppealEndpoints()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
await AccountLock.ApplyAsync(db, 17, "StaffOne", null);
|
|
|
|
var context = new DefaultHttpContext
|
|
{
|
|
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(
|
|
new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "17") },
|
|
"test"))
|
|
};
|
|
var appeal = new RadicadeServer.API.Moderation.BanAppealAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = context }
|
|
};
|
|
var submitApi = new RadicadeServer.API.Moderation.AppealAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = context }
|
|
};
|
|
|
|
var generated = await appeal.GenerateCode();
|
|
var payload = Assert.IsAssignableFrom<ObjectResult>(generated.Result).Value!;
|
|
var payloadType = payload.GetType();
|
|
Assert.False((bool)payloadType.GetProperty("success")!.GetValue(payload)!);
|
|
Assert.Equal(AccountLock.NotAppealableMessage, payloadType.GetProperty("error")!.GetValue(payload) as string);
|
|
Assert.Null((await db.Moderation.SingleAsync(x => x.PlayerId == 17)).AppealCode);
|
|
|
|
var lockBan = await db.Moderation.SingleAsync(x => x.PlayerId == 17);
|
|
lockBan.AppealCode = "LOCKCODE";
|
|
await db.SaveChangesAsync();
|
|
|
|
var submitted = await submitApi.Submit(new RadicadeServer.API.Moderation.SubmitAppealRequest
|
|
{
|
|
Code = "LOCKCODE",
|
|
Text = "please let me back in, this is a genuine appeal"
|
|
});
|
|
|
|
var refused = Assert.IsType<BadRequestObjectResult>(submitted.Result);
|
|
Assert.Equal(AccountLock.NotAppealableMessage, Assert.IsType<OkResponse>(refused.Value).Message);
|
|
Assert.Null((await db.Moderation.SingleAsync(x => x.PlayerId == 17)).AppealText);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheAppealCooldownTextIsTheAgreedWording()
|
|
{
|
|
const string expectedPrefix = "You cannot file another ban appeal for: ";
|
|
|
|
Assert.Equal(expectedPrefix, PlayerUtils.AppealCooldownPrefix);
|
|
Assert.Equal(expectedPrefix + "29 days, 3 hours",
|
|
PlayerUtils.AppealCooldownPrefix + PlayerUtils.FormatAppealCooldown(TimeSpan.FromDays(29) + TimeSpan.FromHours(3)));
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public void RepeatedFailuresAlertAndSlowButNeverImposeMoreThanTheCap()
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
var count = 0;
|
|
for (var i = 0; i < AuthSecurityMonitor.AccountFailureAlertThreshold; i++)
|
|
count = AuthSecurityMonitor.RecordFailedLogin(7, "203.0.113.9");
|
|
|
|
Assert.Equal(AuthSecurityMonitor.AccountFailureAlertThreshold, count);
|
|
Assert.Equal(0, AuthSecurityMonitor.ProgressiveDelayMs(3));
|
|
Assert.True(AuthSecurityMonitor.ProgressiveDelayMs(count) > 0);
|
|
Assert.True(AuthSecurityMonitor.ProgressiveDelayMs(10_000) <= AuthSecurityMonitor.MaxProgressiveDelayMs);
|
|
}
|
|
|
|
[Fact]
|
|
public void SprayingAcrossManyAccountsFromOneSourceIsDetected()
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
for (long account = 100; account < 100 + AuthSecurityMonitor.SourceSprayAccountThreshold; account++)
|
|
AuthSecurityMonitor.RecordFailedLogin(account, "198.51.100.4");
|
|
|
|
Assert.True(AuthSecurityMonitor.IsSourceThrottled("198.51.100.4"));
|
|
}
|
|
|
|
[Fact]
|
|
public void RateLimitingCannotBeAbusedToLockOutAnotherPlayer()
|
|
{
|
|
AuthSecurityMonitor.Reset();
|
|
|
|
for (var i = 0; i < 60; i++) AuthSecurityMonitor.RecordFailedLogin(42, "198.51.100.7");
|
|
Assert.False(AuthSecurityMonitor.IsSourceThrottled("198.51.100.7"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SecuritySignalsAloneNeverCreateALock()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 17, "GreatEagle", "pw123456");
|
|
|
|
AuthSecurityMonitor.Reset();
|
|
for (var i = 0; i < 50; i++) AuthSecurityMonitor.RecordFailedLogin(17, "198.51.100.7");
|
|
AuthSecurityMonitor.RecordSuccessfulLogin(17, "203.0.113.55", newDevice: true, newIp: true);
|
|
AuthSecurityMonitor.NoteCredentialChange(17, "password changed");
|
|
|
|
Assert.Empty(await db.Moderation.ToListAsync());
|
|
Assert.False(await AccountLock.IsLockedAsync(db, 17));
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public async Task StaffCanLockAndUnlockThroughThePanel()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 2, "GreatEagle", "pw123456");
|
|
|
|
Assert.IsType<OkObjectResult>(await PanelAction(db, 2, "lock"));
|
|
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == 2);
|
|
Assert.Equal(ReportCategory.Moderator, ban.Category);
|
|
Assert.Equal(AccountLock.BanMethod, ban.BanMethod);
|
|
Assert.Equal(ExactReason, ban.Message);
|
|
Assert.Equal(DateTime.MaxValue, ban.ExpireDuration);
|
|
|
|
Assert.IsType<OkObjectResult>(await PanelAction(db, 2, "unlock"));
|
|
Assert.Empty(await db.Moderation.Where(x => x.PlayerId == 2).ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnlockingThroughThePanelRefusesAndPreservesAnUnrelatedBan()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 2, "GreatEagle", "pw123456");
|
|
db.Moderation.Add(new ModerationDB
|
|
{
|
|
PlayerId = 2,
|
|
IsBan = true,
|
|
Category = ReportCategory.CoC_Trolling,
|
|
Message = "trolling",
|
|
BanMethod = "Admin Panel",
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpireDuration = DateTime.UtcNow.AddDays(3)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
Assert.IsType<ConflictObjectResult>(await PanelAction(db, 2, "unlock"));
|
|
Assert.Equal("Admin Panel", (await db.Moderation.SingleAsync(x => x.PlayerId == 2)).BanMethod);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnauthorizedStaffCannotLock()
|
|
{
|
|
await using var db = NewDb();
|
|
await SeedAccountAsync(db, 2, "GreatEagle", "pw123456");
|
|
|
|
var context = new DefaultHttpContext();
|
|
var api = new AdminPanelAPI(db) { ControllerContext = new ControllerContext { HttpContext = context } };
|
|
var original = DBConfig.AdminApiKey;
|
|
DBConfig.AdminApiKey = Secret;
|
|
try
|
|
{
|
|
var result = await api.PlayerPanelAction(2, "lock", "not-the-key");
|
|
Assert.IsNotType<OkObjectResult>(result);
|
|
Assert.Empty(await db.Moderation.ToListAsync());
|
|
}
|
|
finally { DBConfig.AdminApiKey = original; }
|
|
}
|
|
}
|