Files
2026-10-16 23:33:13 -07:00

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; }
}
}