mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
663 lines
28 KiB
C#
663 lines
28 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Text.Json;
|
|
using RadicadeServer.API.Moderation;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.RecNet.Accounts;
|
|
using RadicadeServer.RecNet.Auth;
|
|
using RadicadeServer.RecNet.Enums.Moderation;
|
|
using RadicadeServer.RecNet.Moderation;
|
|
using RadicadeServer.Utils;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// The three things this server can do to an account, and the rules that keep them apart:
|
|
///
|
|
/// * a timeout is never appealable - no code is issued, so the client draws neither the appeal
|
|
/// button nor the support text under it;
|
|
/// * a voice moderation ban keeps the appeal button and refuses the appeal behind it;
|
|
/// * a voice restriction is not a ban at all: it cuts the microphone and leaves the account
|
|
/// status screen, the ban list and the reputation alone.
|
|
/// </summary>
|
|
public class ModerationTimeoutVoiceTests
|
|
{
|
|
private const long Player = 42;
|
|
|
|
private static RecDBContext NewDb() => new(
|
|
TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options);
|
|
|
|
private static async Task<RecDBContext> WithProfile(long accountId = Player, string? role = null)
|
|
{
|
|
var db = NewDb();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = accountId,
|
|
username = $"player{accountId}",
|
|
displayName = $"Player {accountId}",
|
|
profileImage = "",
|
|
role = role ?? ""
|
|
});
|
|
await db.SaveChangesAsync();
|
|
return db;
|
|
}
|
|
|
|
private static DefaultHttpContext SignedInAs(long accountId) => new()
|
|
{
|
|
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(
|
|
new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, accountId.ToString()) },
|
|
"test"))
|
|
};
|
|
|
|
private static ModerationDB TimeoutBan(long playerId, string method, DateTime? expiry = null) => new()
|
|
{
|
|
PlayerId = playerId,
|
|
IsBan = true,
|
|
Message = "You have received a timeout due to a high number of reports for Trolling.",
|
|
Category = ReportCategory.CoC_Trolling,
|
|
BanMethod = method,
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpireDuration = expiry ?? DateTime.UtcNow.AddHours(1)
|
|
};
|
|
|
|
// ---------------------------------------------------------------- policy
|
|
|
|
[Fact]
|
|
public void ATimeoutIsBetweenAnHourAndADay()
|
|
{
|
|
Assert.Equal(TimeSpan.FromHours(1), ModerationPolicy.ClampTimeout(TimeSpan.FromMinutes(1)));
|
|
Assert.Equal(TimeSpan.FromHours(24), ModerationPolicy.ClampTimeout(TimeSpan.FromDays(7)));
|
|
Assert.Equal(TimeSpan.FromHours(6), ModerationPolicy.ClampTimeout(TimeSpan.FromHours(6)));
|
|
}
|
|
|
|
[Fact]
|
|
public void TimeoutsAreNotAppealableAndThatIsWhatSeparatesThemFromABan()
|
|
{
|
|
Assert.False(ModerationPolicy.IssuesAppealCode("Automatic Timeout"));
|
|
Assert.False(ModerationPolicy.IssuesAppealCode("Moderator Report"));
|
|
Assert.False(ModerationPolicy.IssuesAppealCode("Timeout"));
|
|
Assert.True(ModerationPolicy.IssuesAppealCode("Admin Panel"));
|
|
Assert.True(ModerationPolicy.IssuesAppealCode("Voice Moderation"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AVoiceBanDrawsTheButtonAndThenRefusesTheAppeal()
|
|
{
|
|
Assert.True(ModerationPolicy.IssuesAppealCode("Voice Moderation"));
|
|
Assert.True(ModerationPolicy.RefusesAppeal("Voice Moderation"));
|
|
Assert.False(ModerationPolicy.RefusesAppeal("Admin Panel"));
|
|
Assert.False(ModerationPolicy.IssuesAppealCode("Voice Moderation") && !ModerationPolicy.RefusesAppeal("Voice Moderation"),
|
|
"a voice ban must never be a ban that can actually be appealed");
|
|
}
|
|
|
|
[Fact]
|
|
public void ATimeoutStartsAtAnHourUnlessGivenADuration()
|
|
{
|
|
var now = new DateTime(2026, 10, 12, 12, 0, 0, DateTimeKind.Utc);
|
|
Assert.Equal(now.AddHours(1), ModerationPolicy.TimeoutExpiry(now));
|
|
Assert.Equal(now.AddHours(8), ModerationPolicy.TimeoutExpiry(now, TimeSpan.FromHours(8)));
|
|
Assert.Equal(now.AddHours(24), ModerationPolicy.TimeoutExpiry(now, TimeSpan.FromDays(30)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtendingATimeoutMeasuresFromWhenItStartedAndStopsAtADay()
|
|
{
|
|
var started = new DateTime(2026, 10, 12, 12, 0, 0, DateTimeKind.Utc);
|
|
var current = started.AddHours(1);
|
|
|
|
// More reports raise the duration but do not restart the clock.
|
|
Assert.Equal(started.AddHours(6), ModerationPolicy.ExtendedTimeoutExpiry(started, current, TimeSpan.FromHours(6)));
|
|
// A shorter ask never shortens a timeout that is already running.
|
|
Assert.Equal(current, ModerationPolicy.ExtendedTimeoutExpiry(started, current, TimeSpan.FromMinutes(5)));
|
|
// And the whole thing stops at a day from when it started.
|
|
Assert.Equal(started.AddHours(24), ModerationPolicy.ExtendedTimeoutExpiry(started, current, TimeSpan.FromDays(2)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- timeouts
|
|
|
|
[Fact]
|
|
public async Task ATimeoutCannotBeAppealedAndCarriesNoCode()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Moderation.Add(TimeoutBan(Player, ModerationPolicy.AutomaticTimeoutBanMethod));
|
|
await db.SaveChangesAsync();
|
|
|
|
var appeal = new BanAppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } };
|
|
var generated = await appeal.GenerateCode();
|
|
|
|
var payload = Assert.IsAssignableFrom<ObjectResult>(generated.Result).Value!;
|
|
var type = payload.GetType();
|
|
Assert.False((bool)type.GetProperty("success")!.GetValue(payload)!);
|
|
Assert.Equal(AccountLock.NotAppealableMessage, type.GetProperty("error")!.GetValue(payload) as string);
|
|
|
|
// The poll the account status screen makes must not mint a code for one either: an empty
|
|
// code is what keeps the appeal button off the screen.
|
|
var reporting = new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } };
|
|
var details = await reporting.GetBlockDetails();
|
|
var detailsObject = Assert.IsType<BlockDetails>(Assert.IsAssignableFrom<ObjectResult>(details).Value);
|
|
Assert.Equal("Timeout", detailsObject.BanType);
|
|
Assert.Equal(string.Empty, detailsObject.BanAppealCode);
|
|
Assert.Equal(string.Empty, detailsObject.BanAppealText);
|
|
Assert.Null((await db.Moderation.SingleAsync(x => x.PlayerId == Player)).AppealCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAlreadyIssuedCodeCannotBeTurnedIntoATimeoutAppeal()
|
|
{
|
|
await using var db = await WithProfile();
|
|
var ban = TimeoutBan(Player, ModerationPolicy.ModeratorReportBanMethod);
|
|
ban.AppealCode = "OLDTIMEOUT";
|
|
db.Moderation.Add(ban);
|
|
await db.SaveChangesAsync();
|
|
|
|
var submitApi = new AppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } };
|
|
var submitted = await submitApi.Submit(new SubmitAppealRequest
|
|
{
|
|
Code = "OLDTIMEOUT",
|
|
Text = "please lift this timeout, it was not me"
|
|
});
|
|
|
|
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 == Player)).AppealText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AVoiceModerationBanKeepsTheButtonAndRefusesTheAppeal()
|
|
{
|
|
await using var db = await WithProfile();
|
|
var ban = TimeoutBan(Player, ModerationPolicy.VoiceModerationBanMethod);
|
|
ban.AppealCode = "VOICECODE";
|
|
db.Moderation.Add(ban);
|
|
await db.SaveChangesAsync();
|
|
|
|
var appeal = new BanAppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } };
|
|
var generated = await appeal.GenerateCode();
|
|
var payload = Assert.IsAssignableFrom<ObjectResult>(generated.Result).Value!;
|
|
var type = payload.GetType();
|
|
Assert.False((bool)type.GetProperty("success")!.GetValue(payload)!);
|
|
Assert.Equal(AccountLock.NotAppealableMessage, type.GetProperty("error")!.GetValue(payload) as string);
|
|
|
|
// The ban is still a ban, so the row keeps the code the screen draws its button from.
|
|
Assert.Equal("VOICECODE", (await db.Moderation.SingleAsync(x => x.PlayerId == Player)).AppealCode);
|
|
|
|
var submitApi = new AppealAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) } };
|
|
var submitted = await submitApi.Submit(new SubmitAppealRequest
|
|
{
|
|
Code = "VOICECODE",
|
|
Text = "this was a joke with friends, please reconsider"
|
|
});
|
|
var refused = Assert.IsType<BadRequestObjectResult>(submitted.Result);
|
|
Assert.Equal(AccountLock.NotAppealableMessage, Assert.IsType<OkResponse>(refused.Value).Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AModeratorReportIsAnHourLongTimeoutWithNoAppeal()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 7,
|
|
username = "moderator",
|
|
displayName = "Moderator",
|
|
profileImage = "",
|
|
role = "moderator"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var before = DateTime.UtcNow;
|
|
var api = new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(7) } };
|
|
var result = await api.CreateReport(new CreateReportRequest
|
|
{
|
|
PlayerIdReported = Player,
|
|
ReportCategory = ReportCategory.CoC_Trolling,
|
|
Details = "griefing the lobby",
|
|
RoomId = 1
|
|
});
|
|
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player);
|
|
Assert.True(ban.IsBan);
|
|
Assert.Equal(ModerationPolicy.ModeratorReportBanMethod, ban.BanMethod);
|
|
Assert.Null(ban.AppealCode);
|
|
Assert.True(ban.ExpireDuration >= before.AddMinutes(59) && ban.ExpireDuration <= before.AddMinutes(61),
|
|
$"a moderator report should be about an hour, was {ban.ExpireDuration - before}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AFlipOfReportsTimesOutTheReportedPlayerWithoutAnAppeal()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 9,
|
|
username = "reporter",
|
|
displayName = "Reporter",
|
|
profileImage = "",
|
|
role = ""
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new PlayerReportingAPI(db) { ControllerContext = new ControllerContext { HttpContext = SignedInAs(9) } };
|
|
var before = DateTime.UtcNow;
|
|
|
|
// The threshold is crossed somewhere in here; reporting the same player repeatedly is what
|
|
// the automatic timeout exists to catch, so the last report must leave a timeout behind.
|
|
for (var i = 0; i < 8; i++)
|
|
{
|
|
await api.CreateReport(new CreateReportRequest
|
|
{
|
|
PlayerIdReported = Player,
|
|
ReportCategory = ReportCategory.CoC_Trolling,
|
|
Details = $"report {i}",
|
|
RoomId = 1
|
|
});
|
|
}
|
|
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player);
|
|
Assert.True(ban.IsBan);
|
|
Assert.Equal(ModerationPolicy.AutomaticTimeoutBanMethod, ban.BanMethod);
|
|
Assert.Null(ban.AppealCode);
|
|
Assert.Contains("timeout", ban.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.True(ban.ExpireDuration <= before.AddHours(24).AddMinutes(1),
|
|
"an automatic timeout must not exceed a day");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the admin panel's IsTimeout
|
|
|
|
private const string AdminSecret = "test-bot-secret";
|
|
|
|
private static async Task<IActionResult> AdminPatch(RecDBContext db, string json)
|
|
{
|
|
var payload = System.Text.Json.JsonDocument.Parse(json).RootElement.Clone();
|
|
// The panel reads its own session cookie for the action log, so the request has to exist.
|
|
var api = new RadicadeServer.Admin.AdminPanelAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
var original = DBConfig.AdminApiKey;
|
|
DBConfig.AdminApiKey = AdminSecret;
|
|
try { return await api.Update("players", Player, AdminSecret, payload); }
|
|
finally { DBConfig.AdminApiKey = original; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheAdminIsTimeoutCheckboxMakesTheBanATimeoutWithNoAppeal()
|
|
{
|
|
await using var db = await WithProfile();
|
|
var before = DateTime.UtcNow;
|
|
|
|
var result = await AdminPatch(db, """
|
|
{
|
|
"banned": true,
|
|
"ban_reason": "Trolling",
|
|
"ban_type": "Temporary",
|
|
"ban_category": 7,
|
|
"ban_method": "Admin Panel",
|
|
"ban_is_timeout": true
|
|
}
|
|
""");
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player);
|
|
Assert.True(ban.IsBan);
|
|
Assert.Equal(ModerationPolicy.AutomaticTimeoutBanMethod, ban.BanMethod);
|
|
Assert.Null(ban.AppealCode);
|
|
Assert.Contains("timeout", ban.Message, StringComparison.OrdinalIgnoreCase);
|
|
// IsTimeout keeps the duration inside the timeout window.
|
|
Assert.True(ban.ExpireDuration >= before.AddMinutes(59) && ban.ExpireDuration <= before.AddHours(24).AddMinutes(1),
|
|
$"a timeout must land between an hour and a day from now, was {ban.ExpireDuration - before}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAdminTimeoutLongerThanADayIsClampedAndAnExistingAppealCodeIsDropped()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Moderation.Add(new ModerationDB
|
|
{
|
|
PlayerId = Player,
|
|
IsBan = true,
|
|
Message = "old ban",
|
|
Category = ReportCategory.Misc,
|
|
BanMethod = "Admin Panel",
|
|
AppealCode = "OLDCODE",
|
|
ExpireDuration = DateTime.UtcNow.AddDays(30)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var before = DateTime.UtcNow;
|
|
var until = DateTime.UtcNow.AddDays(7).ToString("O");
|
|
var result = await AdminPatch(db, $$"""
|
|
{
|
|
"banned": true,
|
|
"ban_reason": "Voice spam",
|
|
"ban_type": "Temporary",
|
|
"ban_category": 7,
|
|
"ban_method": "Admin Panel",
|
|
"ban_until": "{{until}}",
|
|
"ban_is_timeout": true
|
|
}
|
|
""");
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player);
|
|
Assert.Equal(ModerationPolicy.AutomaticTimeoutBanMethod, ban.BanMethod);
|
|
Assert.Null(ban.AppealCode);
|
|
Assert.True(ban.ExpireDuration <= before.AddHours(24).AddMinutes(1),
|
|
$"a seven day timeout ask must be clamped to a day, was {ban.ExpireDuration - before}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAdminVoiceModerationBanKeepsACodeSoTheButtonIsDrawn()
|
|
{
|
|
await using var db = await WithProfile();
|
|
|
|
var result = await AdminPatch(db, """
|
|
{
|
|
"banned": true,
|
|
"ban_reason": "Sexually explicit behavior or photos",
|
|
"ban_type": "Temporary",
|
|
"ban_category": 2,
|
|
"ban_method": "Voice Moderation",
|
|
"ban_until": "2027-01-01T00:00:00.000Z"
|
|
}
|
|
""");
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
|
|
var ban = await db.Moderation.SingleAsync(x => x.PlayerId == Player);
|
|
Assert.Equal(ModerationPolicy.VoiceModerationBanMethod, ban.BanMethod);
|
|
// The code is issued so the screen still draws the appeal button, and the appeal it opens
|
|
// is then refused - the player is told the ban cannot be appealed instead of being left
|
|
// staring at a screen with no way to ask.
|
|
Assert.True(ModerationPolicy.IssuesAppealCode(ban.BanMethod));
|
|
Assert.True(ModerationPolicy.RefusesAppeal(ban.BanMethod));
|
|
// The code is what puts the button on the screen; the refusal happens when it is pressed.
|
|
Assert.False(string.IsNullOrWhiteSpace(ban.AppealCode));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- what the client is told
|
|
|
|
private static async Task<BlockDetails> Poll(RecDBContext db)
|
|
{
|
|
var reporting = new PlayerReportingAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) }
|
|
};
|
|
var result = await reporting.GetBlockDetails();
|
|
return Assert.IsType<BlockDetails>(Assert.IsAssignableFrom<ObjectResult>(result).Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AVoiceBanTellsTheClientItIsOneSoItPrintsItsOwnSentence()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Moderation.Add(new ModerationDB
|
|
{
|
|
PlayerId = Player,
|
|
IsBan = true,
|
|
Message = "Sexually explicit behavior or photos",
|
|
Category = ReportCategory.CoC_Sexual,
|
|
BanMethod = ModerationPolicy.VoiceModerationBanMethod,
|
|
AppealCode = "VOICECODE",
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpireDuration = DateTime.UtcNow.AddDays(7)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var details = await Poll(db);
|
|
|
|
// The sentence the player reads for one of these is the client's own, chosen off this flag:
|
|
// a voice ban that does not set it is drawn with the ordinary "After a review of reports,
|
|
// you have received a ban for" headline instead, which is the bug this pins down.
|
|
Assert.True(details.IsVoiceModAutoban);
|
|
Assert.Equal("VoiceModeration", details.BanType);
|
|
|
|
// The reason the moderator gave shows underneath that sentence, and the sentence itself is
|
|
// not repeated into it.
|
|
Assert.Equal("Sexually explicit behavior or photos", details.Message);
|
|
Assert.DoesNotContain("voice moderation system", details.Message, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// The button comes from the code, and the text beside it is the refusal.
|
|
Assert.Equal("VOICECODE", details.BanAppealCode);
|
|
Assert.Equal(AccountLock.NotAppealableMessage, details.BanAppealText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnOrdinaryBanClaimsNeitherAVoiceBanNorATimeout()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Moderation.Add(new ModerationDB
|
|
{
|
|
PlayerId = Player,
|
|
IsBan = true,
|
|
Message = "Harassment",
|
|
Category = ReportCategory.Harassment,
|
|
BanMethod = "Admin Panel",
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpireDuration = DateTime.UtcNow.AddDays(3)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var details = await Poll(db);
|
|
|
|
Assert.False(details.IsVoiceModAutoban);
|
|
Assert.Equal("Moderation", details.BanType);
|
|
// An ordinary ban is shown with the reason as it was written.
|
|
Assert.Equal("Harassment", details.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ATimeoutIsNotAVoiceBan()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Moderation.Add(TimeoutBan(Player, ModerationPolicy.AutomaticTimeoutBanMethod));
|
|
await db.SaveChangesAsync();
|
|
|
|
var details = await Poll(db);
|
|
|
|
Assert.False(details.IsVoiceModAutoban);
|
|
Assert.Equal("Timeout", details.BanType);
|
|
Assert.Equal("You have received a timeout due to a high number of reports for Trolling.", details.Message);
|
|
// No code and no text: the appeal button and the support line under it are not drawn.
|
|
Assert.Equal(string.Empty, details.BanAppealCode);
|
|
Assert.Equal(string.Empty, details.BanAppealText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheBodyPushedToABannedClientCarriesTheVoiceFlag()
|
|
{
|
|
await using var db = await WithProfile();
|
|
var ban = new PlayerUtils.PlayerBanInfo("Moderation", "Sexually explicit behavior or photos",
|
|
DateTime.UtcNow.AddDays(7), "mod:9", ReportCategory.CoC_Sexual,
|
|
AppealCode: "VOICECODE", StartedAtUtc: DateTime.UtcNow,
|
|
BanMethod: ModerationPolicy.VoiceModerationBanMethod);
|
|
|
|
var details = await PlayerUtils.BuildBanBlockDetailsAsync(db, ban, accountId: Player);
|
|
|
|
Assert.True(details.IsVoiceModAutoban);
|
|
Assert.Equal("VoiceModeration", details.BanType);
|
|
Assert.Equal(AccountLock.NotAppealableMessage, details.BanAppealText);
|
|
Assert.Equal("Sexually explicit behavior or photos", details.DisplayReason);
|
|
}
|
|
|
|
/* The client finds these by name, so the name is part of the contract. The API serializes with
|
|
no naming policy - Program.cs sets PropertyNamingPolicy = null - which is why the field the
|
|
client looks for is spelled the same in C# and on the wire. */
|
|
[Fact]
|
|
public async Task TheVoiceFlagTravelsUnderTheNameTheClientLooksFor()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.Moderation.Add(new ModerationDB
|
|
{
|
|
PlayerId = Player,
|
|
IsBan = true,
|
|
Message = "Sexually explicit behavior or photos",
|
|
Category = ReportCategory.CoC_Sexual,
|
|
BanMethod = ModerationPolicy.VoiceModerationBanMethod,
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpireDuration = DateTime.UtcNow.AddDays(7)
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var details = await Poll(db);
|
|
var json = JsonSerializer.Serialize(details, new JsonSerializerOptions { PropertyNamingPolicy = null });
|
|
|
|
Assert.Contains("\"IsVoiceModAutoban\":true", json);
|
|
// The fields the client already reads out of this body are spelled the same way.
|
|
Assert.Contains("\"IsBan\":true", json);
|
|
Assert.Contains("\"BanAppealText\":", json);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAdminVoiceModerationBanReachesTheClientAsAVoiceBan()
|
|
{
|
|
await using var db = await WithProfile();
|
|
|
|
var result = await AdminPatch(db, """
|
|
{
|
|
"banned": true,
|
|
"ban_reason": "Sexually explicit behavior or photos",
|
|
"ban_type": "Temporary",
|
|
"ban_category": 2,
|
|
"ban_method": "Voice Moderation",
|
|
"ban_until": "2027-01-01T00:00:00.000Z"
|
|
}
|
|
""");
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
|
|
var details = await Poll(db);
|
|
Assert.True(details.IsVoiceModAutoban);
|
|
Assert.Equal("VoiceModeration", details.BanType);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- voice restrictions
|
|
|
|
[Fact]
|
|
public async Task AVoiceRestrictionCutsVoiceChatAndNothingElse()
|
|
{
|
|
await using var db = await WithProfile();
|
|
|
|
Assert.Null(await VoiceChatRestrictions.ActiveAsync(db, Player));
|
|
|
|
var restriction = await VoiceChatRestrictions.ApplyAsync(db, Player, "slurs in voice chat",
|
|
ReportCategory.CoC_Trolling, TimeSpan.FromHours(6), VoiceChatRestrictions.Method, "StaffOne", "warning given first");
|
|
|
|
Assert.Equal(Player, restriction.PlayerId);
|
|
Assert.Equal(VoiceChatRestrictions.Method, restriction.IssuedBy);
|
|
Assert.Equal("StaffOne", restriction.IssuedByUsername);
|
|
|
|
var active = await VoiceChatRestrictions.ActiveAsync(db, Player);
|
|
Assert.NotNull(active);
|
|
|
|
// A restriction is not a ban: nothing may resolve as one, or the player is ejected and the
|
|
// account status screen appears.
|
|
Assert.Empty(await db.Moderation.Where(x => x.PlayerId == Player).ToListAsync());
|
|
Assert.Null(await db.ResolveBanAsync(Player));
|
|
|
|
var payload = VoiceChatRestrictions.Payload(active);
|
|
var payloadType = payload.GetType();
|
|
Assert.True((bool)payloadType.GetProperty("restricted")!.GetValue(payload)!);
|
|
Assert.Equal(restriction.ExpiresAt, (DateTime)payloadType.GetProperty("expiresAtUtc")!.GetValue(payload)!);
|
|
|
|
Assert.True(await VoiceChatRestrictions.ClearAsync(db, Player, "StaffOne"));
|
|
Assert.Null(await VoiceChatRestrictions.ActiveAsync(db, Player));
|
|
Assert.False(await VoiceChatRestrictions.ClearAsync(db, Player, "StaffOne"));
|
|
|
|
// Lifted, not deleted: the history stays.
|
|
var row = await db.VoiceChatRestrictions.SingleAsync(x => x.PlayerId == Player);
|
|
Assert.NotNull(row.ClearedAt);
|
|
Assert.Equal("StaffOne", row.ClearedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARestrictionTellsThePlayerWhyInACoachMessage()
|
|
{
|
|
await using var db = await WithProfile();
|
|
|
|
var restriction = await VoiceChatRestrictions.ApplyAsync(db, Player, "slurs in voice chat",
|
|
ReportCategory.CoC_Trolling, TimeSpan.FromHours(6), VoiceChatRestrictions.Method);
|
|
|
|
Assert.NotNull(restriction);
|
|
|
|
// What the player is told when the restriction lands, and who it comes from. The send
|
|
// itself is best effort - it goes out through the notify hub, which a unit test has no
|
|
// factory for - so the sentence and the sender are what can be pinned here.
|
|
Assert.Equal(
|
|
"You have had your voice privileges temporarily restricted due to speech which violates the Code of Conduct.",
|
|
VoiceChatRestrictions.CoachMessage);
|
|
Assert.Equal(1, VoiceChatRestrictions.CoachAccountId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ASecondRestrictionExtendsTheFirstAndNeverShortensIt()
|
|
{
|
|
await using var db = await WithProfile();
|
|
|
|
var first = await VoiceChatRestrictions.ApplyAsync(db, Player, "first", ReportCategory.Misc,
|
|
TimeSpan.FromHours(12), VoiceChatRestrictions.Method);
|
|
var longExpiry = first.ExpiresAt;
|
|
|
|
var shortened = await VoiceChatRestrictions.ApplyAsync(db, Player, "second", ReportCategory.Misc,
|
|
TimeSpan.FromHours(1), VoiceChatRestrictions.Method);
|
|
Assert.Equal(longExpiry, shortened.ExpiresAt);
|
|
Assert.Equal("second", shortened.Reason);
|
|
|
|
var extended = await VoiceChatRestrictions.ApplyAsync(db, Player, "third", ReportCategory.Misc,
|
|
TimeSpan.FromDays(30), VoiceChatRestrictions.Method);
|
|
Assert.True(extended.ExpiresAt > longExpiry);
|
|
Assert.True(extended.ExpiresAt <= DateTime.UtcNow.AddDays(30).AddMinutes(1));
|
|
Assert.Single(await db.VoiceChatRestrictions.Where(x => x.PlayerId == Player).ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheClientEndpointAnswersWithTheStateTheClientBuildReads()
|
|
{
|
|
await using var db = await WithProfile();
|
|
var api = new RadicadeServer.API.Config.VoiceChatRestrictionAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = SignedInAs(Player) }
|
|
};
|
|
|
|
var clear = Assert.IsAssignableFrom<OkObjectResult>(await api.GetRestriction()).Value!;
|
|
Assert.False((bool)clear.GetType().GetProperty("restricted")!.GetValue(clear)!);
|
|
|
|
await VoiceChatRestrictions.ApplyAsync(db, Player, "voice spam", ReportCategory.CoC_Trolling,
|
|
TimeSpan.FromHours(6), VoiceChatRestrictions.Method);
|
|
|
|
var restricted = Assert.IsAssignableFrom<OkObjectResult>(await api.GetRestriction()).Value!;
|
|
var type = restricted.GetType();
|
|
Assert.True((bool)type.GetProperty("restricted")!.GetValue(restricted)!);
|
|
Assert.Equal("voice spam", type.GetProperty("reason")!.GetValue(restricted) as string);
|
|
Assert.True((int)type.GetProperty("secondsRemaining")!.GetValue(restricted)! > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnExpiredRestrictionIsNoRestriction()
|
|
{
|
|
await using var db = await WithProfile();
|
|
db.VoiceChatRestrictions.Add(new VoiceChatRestriction
|
|
{
|
|
PlayerId = Player,
|
|
Reason = "expired",
|
|
Category = ReportCategory.Misc,
|
|
StartedAt = DateTime.UtcNow.AddHours(-5),
|
|
ExpiresAt = DateTime.UtcNow.AddHours(-1),
|
|
IssuedBy = VoiceChatRestrictions.Method
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
Assert.Null(await VoiceChatRestrictions.ActiveAsync(db, Player));
|
|
var payload = VoiceChatRestrictions.Payload(null);
|
|
Assert.False((bool)payload.GetType().GetProperty("restricted")!.GetValue(payload)!);
|
|
}
|
|
}
|