From e028ad472d970e8565a0eb806eaf31c5ded43e05 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 2 Apr 2026 00:24:34 +0200 Subject: [PATCH] Add payload serializer guardrails --- README.md | 4 +- .../Api/ResponseCompressionApiTests.cs | 27 ++++ RpgRoller.Tests/PayloadBudgetTests.cs | 127 ++++++++++++++++++ RpgRoller/Components/Pages/Workspace.razor.cs | 2 +- RpgRoller/Components/RpgRollerApiClient.cs | 5 +- RpgRoller/Contracts/RpgRollerJson.cs | 19 +++ .../RpgRollerJsonSerializerContext.cs | 58 ++++++++ RpgRoller/Program.cs | 9 ++ RpgRoller/Services/GameService.cs | 11 +- 9 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 RpgRoller.Tests/Api/ResponseCompressionApiTests.cs create mode 100644 RpgRoller.Tests/PayloadBudgetTests.cs create mode 100644 RpgRoller/Contracts/RpgRollerJson.cs create mode 100644 RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs diff --git a/README.md b/README.md index 5ddb8af..42362c9 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ dotnet dotnet-ef migrations add --project RpgRoller/RpgRoller.cs - Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. - Workspace reads are resolved server-side through scoped query services; browser interop remains for browser-only concerns such as session storage, SSE wiring, and DOM helpers. - Live workspace refreshes now compare separate roster, per-character sheet, and log versions so unrelated state changes do not force a full roster + sheet + log reload. -- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 50-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`. +- Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`. - Campaign log rows now ship compact summary data first and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded. +- Hot API contracts share a source-generated `System.Text.Json` context, and HTTP JSON responses are gzip-compressed when the client advertises support. - OpenAPI contract source remains at `openapi/RpgRoller.json`. ## Test and Coverage @@ -111,6 +112,7 @@ dotnet dotnet-ef migrations add --project RpgRoller/RpgRoller.cs ```powershell dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings ``` +- Regression tests enforce payload budgets for the hottest contracts: character sheet reads, initial log page loads, incremental log updates, and roll mutation responses. - Coverage gate: ```powershell pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 diff --git a/RpgRoller.Tests/Api/ResponseCompressionApiTests.cs b/RpgRoller.Tests/Api/ResponseCompressionApiTests.cs new file mode 100644 index 0000000..83fe974 --- /dev/null +++ b/RpgRoller.Tests/Api/ResponseCompressionApiTests.cs @@ -0,0 +1,27 @@ +namespace RpgRoller.Tests; + +public sealed class ResponseCompressionApiTests : ApiTestBase +{ + public ResponseCompressionApiTests(WebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task AuthenticatedJsonResponses_EnableGzipCompression() + { + using var factory = CreateFactory(); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(client, "gm-compress", "Password123", "GM"); + await LoginAsync(client, "gm-compress", "Password123"); + _ = await PostAsync(client, "/api/campaigns", new("Compressed", "d6")); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/campaigns"); + request.Headers.Add("Accept-Encoding", "gzip"); + + var response = await client.SendAsync(request); + + response.EnsureSuccessStatusCode(); + Assert.Contains("gzip", response.Content.Headers.ContentEncoding); + } +} diff --git a/RpgRoller.Tests/PayloadBudgetTests.cs b/RpgRoller.Tests/PayloadBudgetTests.cs new file mode 100644 index 0000000..24642b3 --- /dev/null +++ b/RpgRoller.Tests/PayloadBudgetTests.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using RpgRoller.Contracts; + +namespace RpgRoller.Tests; + +public sealed class PayloadBudgetTests +{ + [Fact] + public void CharacterSheetPayload_StaysWithinBudget() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register("gm-sheet", "Password123", "GM"); + service.Register("owner-sheet", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-sheet", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-sheet", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Sheet", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Payload Hero", campaign.Id)); + + var groupIds = new List + { + ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Combat", "2D+1", 1, true)).Id, + ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Social", "2D+1", 1, true)).Id, + ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Knowledge", "2D+1", 1, true)).Id, + ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, character.Id, "Survival", "2D+1", 1, true)).Id + }; + + for (var i = 0; i < 18; i++) + { + Guid? skillGroupId = i < 16 ? groupIds[i % groupIds.Count] : null; + _ = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, $"Skill {i:D2}", "2D+1", 1, true, skillGroupId)); + } + + var sheet = ServiceTestSupport.GetValue(service.GetCharacterSheet(ownerSession, character.Id)); + AssertPayloadWithinBudget(sheet, 12 * 1024, "initial character sheet"); + } + + [Fact] + public void CampaignLogInitialPagePayload_StaysWithinBudget() + { + using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(220)); + var service = harness.Service; + + service.Register("gm-log-budget", "Password123", "GM"); + service.Register("owner-log-budget", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-log-budget", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-log-budget", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Log", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Log Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true)); + + for (var i = 0; i < 25; i++) + _ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); + + var page = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25)); + AssertPayloadWithinBudget(page, 8 * 1024, "initial log page"); + } + + [Fact] + public void CampaignLogIncrementalPayload_StaysWithinBudget() + { + using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(240)); + var service = harness.Service; + + service.Register("gm-log-delta", "Password123", "GM"); + service.Register("owner-log-delta", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-log-delta", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-log-delta", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Delta", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Delta Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Stealth", "2D+1", 1, true)); + + for (var i = 0; i < 25; i++) + _ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); + + var initialPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 25)); + _ = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); + + var incrementalPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, initialPage.Cursor, 25)); + AssertPayloadWithinBudget(incrementalPage, 2 * 1024, "incremental log update"); + } + + [Fact] + public void RollResultPayload_StaysWithinJsInteropBudget() + { + using var harness = ServiceTestSupport.CreateHarness(CreateScriptedRolls(40)); + var service = harness.Service; + + service.Register("gm-roll-budget", "Password123", "GM"); + service.Register("owner-roll-budget", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-roll-budget", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-roll-budget", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Payload Roll", "d6")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Roll Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Wild Roll", "6D+3", 3, true)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); + AssertPayloadWithinBudget(roll, 16 * 1024, "roll mutation response"); + } + + private static void AssertPayloadWithinBudget(T payload, int maxBytes, string label) + { + var byteCount = JsonSerializer.SerializeToUtf8Bytes(payload, SerializerOptions).Length; + Assert.True(byteCount <= maxBytes, $"{label} payload was {byteCount} bytes, above the {maxBytes}-byte budget."); + } + + private static int[] CreateScriptedRolls(int count) + { + var values = new[] { 6, 5, 4, 3, 2, 1 }; + var scriptedRolls = new int[count]; + for (var i = 0; i < count; i++) + scriptedRolls[i] = values[i % values.Length]; + + return scriptedRolls; + } + + private static readonly JsonSerializerOptions SerializerOptions = RpgRollerJson.CreateSerializerOptions(); +} diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index 2b2a606..037eabe 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -1236,7 +1236,7 @@ public partial class Workspace : IAsyncDisposable private const string CampaignSessionKey = "campaign"; private const string MobilePanelSessionKey = "play-panel"; private const string RollVisibilitySessionKey = "roll-visibility"; - private const int CampaignLogWindowSize = 50; + private const int CampaignLogWindowSize = 25; private const int ToastDurationMs = 3200; private sealed record WorkspaceToast(Guid Id, string Message, bool IsError); diff --git a/RpgRoller/Components/RpgRollerApiClient.cs b/RpgRoller/Components/RpgRollerApiClient.cs index 43f3ccf..ed6e6f7 100644 --- a/RpgRoller/Components/RpgRollerApiClient.cs +++ b/RpgRoller/Components/RpgRollerApiClient.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.JSInterop; +using RpgRoller.Contracts; namespace RpgRoller.Components; @@ -37,7 +38,7 @@ public sealed class RpgRollerApiClient throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); } - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private static readonly JsonSerializerOptions JsonOptions = RpgRollerJson.CreateSerializerOptions(); private readonly IJSRuntime m_Js; } @@ -49,4 +50,4 @@ public sealed class ApiRequestException : Exception } public int StatusCode { get; } -} \ No newline at end of file +} diff --git a/RpgRoller/Contracts/RpgRollerJson.cs b/RpgRoller/Contracts/RpgRollerJson.cs new file mode 100644 index 0000000..b15cb10 --- /dev/null +++ b/RpgRoller/Contracts/RpgRollerJson.cs @@ -0,0 +1,19 @@ +using System.Text.Json; + +namespace RpgRoller.Contracts; + +public static class RpgRollerJson +{ + public static JsonSerializerOptions CreateSerializerOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + Configure(options); + return options; + } + + public static void Configure(JsonSerializerOptions options) + { + if (!options.TypeInfoResolverChain.Contains(RpgRollerJsonSerializerContext.Default)) + options.TypeInfoResolverChain.Insert(0, RpgRollerJsonSerializerContext.Default); + } +} diff --git a/RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs b/RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs new file mode 100644 index 0000000..7ec6bfd --- /dev/null +++ b/RpgRoller/Contracts/RpgRollerJsonSerializerContext.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RpgRoller.Contracts; + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(ApiError))] +[JsonSerializable(typeof(AdminUserSummary))] +[JsonSerializable(typeof(AdminUserSummary[]))] +[JsonSerializable(typeof(CampaignGmSummary))] +[JsonSerializable(typeof(CampaignLogEntry))] +[JsonSerializable(typeof(CampaignLogEntry[]))] +[JsonSerializable(typeof(CampaignLogListEntry))] +[JsonSerializable(typeof(CampaignLogListEntry[]))] +[JsonSerializable(typeof(CampaignLogPage))] +[JsonSerializable(typeof(CampaignOption))] +[JsonSerializable(typeof(CampaignOption[]))] +[JsonSerializable(typeof(CampaignRollDetail))] +[JsonSerializable(typeof(CampaignRoster))] +[JsonSerializable(typeof(CampaignStateSnapshot))] +[JsonSerializable(typeof(CampaignSummary))] +[JsonSerializable(typeof(CampaignSummary[]))] +[JsonSerializable(typeof(CharacterSheet))] +[JsonSerializable(typeof(CharacterSheetSkill))] +[JsonSerializable(typeof(CharacterSheetSkillGroup))] +[JsonSerializable(typeof(CharacterStateVersion))] +[JsonSerializable(typeof(CharacterStateVersion[]))] +[JsonSerializable(typeof(CharacterSummary))] +[JsonSerializable(typeof(CharacterSummary[]))] +[JsonSerializable(typeof(CreateCampaignRequest))] +[JsonSerializable(typeof(CreateCharacterRequest))] +[JsonSerializable(typeof(CreateSkillGroupRequest))] +[JsonSerializable(typeof(CreateSkillRequest))] +[JsonSerializable(typeof(HealthResponse))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(LoginRequest))] +[JsonSerializable(typeof(MeResponse))] +[JsonSerializable(typeof(RegisterRequest))] +[JsonSerializable(typeof(RollDieResult))] +[JsonSerializable(typeof(RollDieResult[]))] +[JsonSerializable(typeof(RollResult))] +[JsonSerializable(typeof(RollSkillRequest))] +[JsonSerializable(typeof(RulesetDefinition))] +[JsonSerializable(typeof(RulesetDefinition[]))] +[JsonSerializable(typeof(SkillGroupSummary))] +[JsonSerializable(typeof(SkillSummary))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(UpdateCharacterRequest))] +[JsonSerializable(typeof(UpdateSkillGroupRequest))] +[JsonSerializable(typeof(UpdateSkillRequest))] +[JsonSerializable(typeof(UpdateUserRolesRequest))] +[JsonSerializable(typeof(UserSummary))] +public partial class RpgRollerJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 3c3d78c..3712bf6 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -1,10 +1,18 @@ +using Microsoft.AspNetCore.ResponseCompression; using RpgRoller.Api; using RpgRoller.Components; +using RpgRoller.Contracts; using RpgRoller.Hosting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddResponseCompression(options => +{ + options.EnableForHttps = true; + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]); +}); +builder.Services.ConfigureHttpJsonOptions(options => RpgRollerJson.Configure(options.SerializerOptions)); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -25,6 +33,7 @@ if (!string.IsNullOrWhiteSpace(configuredPathBase)) app.UsePathBase(normalizedPathBase); } +app.UseResponseCompression(); app.UseStaticFiles(); app.UseAntiforgery(); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index fa53c80..d302315 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -797,7 +797,7 @@ public sealed class GameService : IGameService var (user, campaign) = context.Value!; var entries = GetVisibleCampaignLogEntriesLocked(user, campaign) - .TakeLast(CampaignLogPageSize) + .TakeLast(CampaignLogHistoryWindowSize) .Select(ToLogEntry) .ToArray(); @@ -1596,9 +1596,9 @@ public sealed class GameService : IGameService private static int NormalizeCampaignLogPageSize(int? limit) { if (!limit.HasValue) - return CampaignLogPageSize; + return CampaignLogLivePageSize; - return Math.Clamp(limit.Value, 1, CampaignLogPageSize); + return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize); } private static UserAccount CloneUser(UserAccount user) @@ -1692,8 +1692,9 @@ public sealed class GameService : IGameService }; } - private const int CampaignLogPageSize = 100; - private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web); + private const int CampaignLogHistoryWindowSize = 100; + private const int CampaignLogLivePageSize = 25; + private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions(); private readonly Dictionary m_CampaignsById = []; private readonly Dictionary m_CampaignStateById = []; private readonly Dictionary m_CharactersById = [];