Add payload serializer guardrails

This commit is contained in:
2026-04-02 00:24:34 +02:00
parent ddb57cde8f
commit e028ad472d
9 changed files with 253 additions and 9 deletions

View File

@@ -101,8 +101,9 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. - 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. - 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. - 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. - 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`. - OpenAPI contract source remains at `openapi/RpgRoller.json`.
## Test and Coverage ## Test and Coverage
@@ -111,6 +112,7 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
```powershell ```powershell
dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings 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: - Coverage gate:
```powershell ```powershell
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70

View File

@@ -0,0 +1,27 @@
namespace RpgRoller.Tests;
public sealed class ResponseCompressionApiTests : ApiTestBase
{
public ResponseCompressionApiTests(WebApplicationFactory<Program> 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<CreateCampaignRequest, CampaignSummary>(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);
}
}

View File

@@ -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<Guid>
{
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>(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();
}

View File

@@ -1236,7 +1236,7 @@ public partial class Workspace : IAsyncDisposable
private const string CampaignSessionKey = "campaign"; private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility"; private const string RollVisibilitySessionKey = "roll-visibility";
private const int CampaignLogWindowSize = 50; private const int CampaignLogWindowSize = 25;
private const int ToastDurationMs = 3200; private const int ToastDurationMs = 3200;
private sealed record WorkspaceToast(Guid Id, string Message, bool IsError); private sealed record WorkspaceToast(Guid Id, string Message, bool IsError);

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using RpgRoller.Contracts;
namespace RpgRoller.Components; namespace RpgRoller.Components;
@@ -37,7 +38,7 @@ public sealed class RpgRollerApiClient
throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); 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; private readonly IJSRuntime m_Js;
} }
@@ -49,4 +50,4 @@ public sealed class ApiRequestException : Exception
} }
public int StatusCode { get; } public int StatusCode { get; }
} }

View File

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

View File

@@ -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<AdminUserSummary>))]
[JsonSerializable(typeof(IReadOnlyList<CharacterStateVersion>))]
[JsonSerializable(typeof(IReadOnlyList<RollDieResult>))]
[JsonSerializable(typeof(IReadOnlyList<string>))]
[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
{
}

View File

@@ -1,10 +1,18 @@
using Microsoft.AspNetCore.ResponseCompression;
using RpgRoller.Api; using RpgRoller.Api;
using RpgRoller.Components; using RpgRoller.Components;
using RpgRoller.Contracts;
using RpgRoller.Hosting; using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment); builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
builder.Services.AddRazorComponents().AddInteractiveServerComponents(); 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.AddHttpContextAccessor();
builder.Services.AddScoped<RpgRollerApiClient>(); builder.Services.AddScoped<RpgRollerApiClient>();
builder.Services.AddScoped<WorkspaceSessionTokenAccessor>(); builder.Services.AddScoped<WorkspaceSessionTokenAccessor>();
@@ -25,6 +33,7 @@ if (!string.IsNullOrWhiteSpace(configuredPathBase))
app.UsePathBase(normalizedPathBase); app.UsePathBase(normalizedPathBase);
} }
app.UseResponseCompression();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAntiforgery(); app.UseAntiforgery();

View File

@@ -797,7 +797,7 @@ public sealed class GameService : IGameService
var (user, campaign) = context.Value!; var (user, campaign) = context.Value!;
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign) var entries = GetVisibleCampaignLogEntriesLocked(user, campaign)
.TakeLast(CampaignLogPageSize) .TakeLast(CampaignLogHistoryWindowSize)
.Select(ToLogEntry) .Select(ToLogEntry)
.ToArray(); .ToArray();
@@ -1596,9 +1596,9 @@ public sealed class GameService : IGameService
private static int NormalizeCampaignLogPageSize(int? limit) private static int NormalizeCampaignLogPageSize(int? limit)
{ {
if (!limit.HasValue) 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) private static UserAccount CloneUser(UserAccount user)
@@ -1692,8 +1692,9 @@ public sealed class GameService : IGameService
}; };
} }
private const int CampaignLogPageSize = 100; private const int CampaignLogHistoryWindowSize = 100;
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web); private const int CampaignLogLivePageSize = 25;
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly Dictionary<Guid, Campaign> m_CampaignsById = []; private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, CampaignStateTracker> m_CampaignStateById = []; private readonly Dictionary<Guid, CampaignStateTracker> m_CampaignStateById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = []; private readonly Dictionary<Guid, Character> m_CharactersById = [];