feat(options): eager startup validation for central-component options (Transport, SiteCallAudit, DeploymentManager) (arch-review 08 §1.5)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review 08 §1.5: <see cref="DeploymentManagerOptions"/> timeouts are
|
||||
/// applied as linked-CTS deadlines around lifecycle commands, per-site artifact
|
||||
/// deployment, and operation-lock acquisition. A zero/negative duration makes a
|
||||
/// deadline fire immediately (or a <c>CancellationTokenSource</c> constructor
|
||||
/// throw), so a misconfigured section must be rejected at startup with a clear,
|
||||
/// key-naming message rather than silently breaking deployments.
|
||||
/// </summary>
|
||||
public class DeploymentManagerOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(DeploymentManagerOptions options) =>
|
||||
new DeploymentManagerOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
var result = Validate(new DeploymentManagerOptions());
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroLifecycleCommandTimeout_IsRejected()
|
||||
{
|
||||
var result = Validate(new DeploymentManagerOptions { LifecycleCommandTimeout = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("LifecycleCommandTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeLifecycleCommandTimeout_IsRejected()
|
||||
{
|
||||
var result = Validate(new DeploymentManagerOptions { LifecycleCommandTimeout = TimeSpan.FromSeconds(-1) });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("LifecycleCommandTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroArtifactDeploymentTimeoutPerSite_IsRejected()
|
||||
{
|
||||
var result = Validate(new DeploymentManagerOptions { ArtifactDeploymentTimeoutPerSite = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ArtifactDeploymentTimeoutPerSite", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroOperationLockTimeout_IsRejected()
|
||||
{
|
||||
var result = Validate(new DeploymentManagerOptions { OperationLockTimeout = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("OperationLockTimeout", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review 08 §1.5: <see cref="SiteCallAuditOptions"/> drives stuck-call
|
||||
/// detection, KPI windowing, the central→site relay Ask timeout, and the two
|
||||
/// central-singleton scheduler cadences plus their retention window. A
|
||||
/// zero/negative duration or non-positive count must be rejected at startup with
|
||||
/// a clear, key-naming message rather than making an Akka scheduler spin or a KPI
|
||||
/// query silently degrade. The <c>Resolved*</c> computed properties clamp away
|
||||
/// bad cadence values, so the BASE properties are validated here.
|
||||
/// </summary>
|
||||
public class SiteCallAuditOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(SiteCallAuditOptions options) =>
|
||||
new SiteCallAuditOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions());
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroStuckAgeThreshold_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions { StuckAgeThreshold = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("StuckAgeThreshold", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroKpiInterval_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions { KpiInterval = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("KpiInterval", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRelayTimeout_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions { RelayTimeout = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("RelayTimeout", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroReconciliationInterval_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions { ReconciliationInterval = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ReconciliationInterval", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroReconciliationBatchSize_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions { ReconciliationBatchSize = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ReconciliationBatchSize", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroPurgeInterval_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions { PurgeInterval = TimeSpan.Zero });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("PurgeInterval", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRetentionDays_IsRejected()
|
||||
{
|
||||
var result = Validate(new SiteCallAuditOptions { RetentionDays = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Arch-review 08 §1.5: <see cref="TransportOptions"/> feeds bundle size caps,
|
||||
/// zip-bomb defences, unlock rate limits and — security-critically — the PBKDF2
|
||||
/// iteration count used to derive the bundle encryption key. A zero/negative or
|
||||
/// otherwise degenerate value must be rejected at startup with a clear,
|
||||
/// key-naming message rather than silently weakening key derivation or crashing
|
||||
/// deep inside the import pipeline.
|
||||
/// </summary>
|
||||
public class TransportOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(TransportOptions options) =>
|
||||
new TransportOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
[Fact]
|
||||
public void DefaultOptions_AreValid()
|
||||
{
|
||||
var result = Validate(new TransportOptions());
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroBundleSessionTtlMinutes_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { BundleSessionTtlMinutes = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("BundleSessionTtlMinutes", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaxBundleSizeMb_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxBundleSizeMb = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxBundleSizeMb", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroPbkdf2Iterations_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { Pbkdf2Iterations = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("Pbkdf2Iterations", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pbkdf2Iterations_BelowSecurityFloor_IsRejected()
|
||||
{
|
||||
// A config typo (e.g. 6_000 instead of 600_000) must not silently weaken
|
||||
// bundle key derivation: anything under 100_000 is rejected.
|
||||
var result = Validate(new TransportOptions { Pbkdf2Iterations = 99_999 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("Pbkdf2Iterations", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pbkdf2Iterations_AtSecurityFloor_IsValid()
|
||||
{
|
||||
var result = Validate(new TransportOptions { Pbkdf2Iterations = 100_000 });
|
||||
Assert.True(result.Succeeded, result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaxUnlockAttemptsPerSession_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxUnlockAttemptsPerSession = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxUnlockAttemptsPerSession", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaxBundleEntryDecompressedMb_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxBundleEntryDecompressedMb = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxBundleEntryDecompressedMb", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaxBundleEntryCount_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxBundleEntryCount = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxBundleEntryCount", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaxBundleEntryCompressionRatio_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxBundleEntryCompressionRatio = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxBundleEntryCompressionRatio", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaxUnlockAttemptsPerIpPerHour_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { MaxUnlockAttemptsPerIpPerHour = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("MaxUnlockAttemptsPerIpPerHour", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroSchemaVersionMajor_IsRejected()
|
||||
{
|
||||
var result = Validate(new TransportOptions { SchemaVersionMajor = 0 });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("SchemaVersionMajor", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void BlankSourceEnvironment_IsRejected(string value)
|
||||
{
|
||||
var result = Validate(new TransportOptions { SourceEnvironment = value });
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("SourceEnvironment", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user