From c5b27361c080e119653d958567360176b2cf296d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 05:39:50 -0400 Subject: [PATCH] feat(auditlog): add AuditLog:InboundMaxBytes option (default 1 MiB, [8 KiB, 16 MiB]) --- .../Configuration/AuditLogOptions.cs | 13 +++++ .../Configuration/AuditLogOptionsValidator.cs | 13 +++++ .../AuditLogOptionsBindingTests.cs | 4 +- .../AuditLogOptionsValidatorTests.cs | 53 +++++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs index 89cfe9b..4860b1e 100644 --- a/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs +++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs @@ -1,3 +1,6 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Enums; + namespace ScadaLink.AuditLog.Configuration; /// @@ -33,4 +36,14 @@ public sealed class AuditLogOptions /// Central retention window in days (default 365, range [30, 3650]). public int RetentionDays { get; set; } = 365; + + /// + /// Per-body byte ceiling applied to and + /// for rows + /// (default 1 MiB). The 8 KiB / 64 KiB default/error caps that apply to other channels + /// do not apply here — inbound traffic captures verbatim up to this ceiling and only + /// then sets . See + /// docs/plans/2026-05-23-inbound-api-full-response-audit-design.md. + /// + public int InboundMaxBytes { get; set; } = 1_048_576; } diff --git a/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs index 59785c3..b67bd49 100644 --- a/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs +++ b/src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs @@ -21,6 +21,12 @@ public sealed class AuditLogOptionsValidator : IValidateOptions /// Inclusive upper bound for . public const int MaxRetentionDays = 3650; + /// Inclusive lower bound for (8 KiB). + public const int MinInboundMaxBytes = 8_192; + + /// Inclusive upper bound for (16 MiB). + public const int MaxInboundMaxBytes = 16_777_216; + /// public ValidateOptionsResult Validate(string? name, AuditLogOptions options) { @@ -50,6 +56,13 @@ public sealed class AuditLogOptionsValidator : IValidateOptions $"must be in [{MinRetentionDays}, {MaxRetentionDays}] days."); } + if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes) + { + failures.Add( + $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " + + $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes."); + } + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); diff --git a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs index f9829cd..8cc07a7 100644 --- a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs @@ -45,7 +45,8 @@ public class AuditLogOptionsBindingTests "RedactSqlParamsMatching": "@token|@secret" } }, - "RetentionDays": 180 + "RetentionDays": 180, + "InboundMaxBytes": 524288 } } """; @@ -64,6 +65,7 @@ public class AuditLogOptionsBindingTests Assert.Equal(4096, opts.DefaultCapBytes); Assert.Equal(32768, opts.ErrorCapBytes); Assert.Equal(180, opts.RetentionDays); + Assert.Equal(524_288, opts.InboundMaxBytes); // HeaderRedactList: the Microsoft.Extensions.Configuration list binder // APPENDS to the default list, so we assert containment rather than diff --git a/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs new file mode 100644 index 0000000..6253746 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs @@ -0,0 +1,53 @@ +using ScadaLink.AuditLog.Configuration; + +namespace ScadaLink.AuditLog.Tests.Configuration; + +/// +/// Task 1 of docs/plans/2026-05-23-inbound-api-full-response-audit.md: +/// pins the default to 1 MiB and +/// the validator bounds to [8 KiB, 16 MiB]. The inbound channel needs a +/// much larger ceiling than the 8 KiB / 64 KiB default/error caps that other +/// channels use, but unbounded would let any caller flood the central +/// AuditLog table with arbitrarily large bodies — hence the upper bound. +/// Companion to which covers the existing +/// cap-bytes + retention invariants. +/// +public class AuditLogOptionsValidatorTests +{ + [Fact] + public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte() + { + // The doc'd default per docs/plans/2026-05-23-inbound-api-full-response-audit-design.md + // is 1 048 576 bytes (1 MiB). Pin it so a config drift is a test failure, + // not a silent operational surprise. + var opts = new AuditLogOptions(); + Assert.Equal(1_048_576, opts.InboundMaxBytes); + } + + [Theory] + [InlineData(8_192)] // documented min + [InlineData(1_048_576)] // default + [InlineData(16_777_216)] // documented max + public void Validate_InboundMaxBytes_InRange_Passes(int value) + { + var validator = new AuditLogOptionsValidator(); + var opts = new AuditLogOptions { InboundMaxBytes = value }; + Assert.True(validator.Validate(null, opts).Succeeded); + } + + [Theory] + [InlineData(0)] + [InlineData(8_191)] + [InlineData(16_777_217)] + [InlineData(int.MaxValue)] + public void Validate_InboundMaxBytes_OutOfRange_Fails(int value) + { + var validator = new AuditLogOptionsValidator(); + var opts = new AuditLogOptions { InboundMaxBytes = value }; + var result = validator.Validate(null, opts); + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal)); + } +}