fix(validation): close Theme 3 — 11 input-validation / unbounded-input findings

Each finding is a focused validation guard or upper bound at a trust boundary.
Highlights:
- Commons-015: EncryptionMetadata ctor now validates Algorithm (AES-256-GCM
  only), Kdf (PBKDF2-SHA256 only), Iterations ([100k, 10M]), non-null Salt/IV.
- Transport-004: new BundleUnlockRateLimiter (sliding-window, per-key,
  singleton) wired into BundleImporter.LoadAsync; over-budget callers see
  BundleUnlockRateLimitedException. Per-bundle 3-strike + per-window cap.
- ESG-022: ExternalSystemClient.InvokeHttpAsync allow-lists the documented
  GET/POST/PUT/PATCH/DELETE set (case-insensitive); unknown verbs throw.
- SEL-015: SiteEventLogger queue now bounded (10k cap, DropOldest); dropped
  events fault their Task and increment FailedWriteCount so the drop is
  observable instead of an unbounded memory growth.
- SEL-017: EventLogQueryService clamps caller-supplied PageSize to a new
  MaxQueryPageSize cap (default 500) so int.MaxValue can't OOM the host.
- SEL-020: LogEventAsync rejects severities outside {Info, Warning, Error}
  (matches SQLite BINARY-collation query filter).
- InboundAPI-020: ContentType "json" check now case-insensitive
  (application/JSON no longer slips through as not-json).
- InboundAPI-024: _knownBadMethods capped at 1000 entries (drops new entries
  once full); per-request DB lookup remains the correctness path.
- SR-025: HandleSetStaticAttribute validates the attribute name against the
  deployed config; unknown names now return Success=false instead of
  leaking orphan override rows into the SQLite store.
- TE-021: MoveTemplateAsync runs the sibling-name-collision check at the
  destination, mirroring TemplateFolderService.MoveFolderAsync.
- TE-022: LockEnforcer's once-locked-stays-locked rule now also covers
  LockedInDerived (was previously only IsLocked).

New regression tests across 8 test projects (EncryptionMetadata, rate
limiter, ESG client allow-list, SEL bounded channel / PageSize clamp /
severity validation, InboundAPI ContentType + bad-methods cap, SiteRT
unknown-attribute, TemplateEngine MoveTemplate + LockedInDerived).
Build clean; affected suites all green. README regenerated: 93 open (was 104).

Note: a separate manual re-run was needed for the SiteEventLogging hunk
because its initial subagent's source edits never landed on disk despite
reporting success (file-collision-style failure mode).
This commit is contained in:
Joseph Doherty
2026-05-28 06:58:25 -04:00
parent 344379a40a
commit 819f1b4665
35 changed files with 1457 additions and 73 deletions
@@ -0,0 +1,143 @@
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Commons.Tests.Types.Transport;
/// <summary>
/// Commons-015: <see cref="EncryptionMetadata"/> must reject malformed envelopes at
/// the type boundary (unknown algorithm, unsupported KDF, sub-minimum or over-cap
/// iteration counts, null salt/IV). Valid construction must round-trip the fields.
/// </summary>
public sealed class EncryptionMetadataTests
{
[Fact]
public void Constructor_WithDocumentedValues_Succeeds()
{
// 600_000 is the design-doc production value; "abc"/"def" are placeholder
// Base64 strings, kept short for test legibility.
var meta = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def");
Assert.Equal("AES-256-GCM", meta.Algorithm);
Assert.Equal("PBKDF2-SHA256", meta.Kdf);
Assert.Equal(600_000, meta.Iterations);
Assert.Equal("abc", meta.SaltB64);
Assert.Equal("def", meta.IvB64);
}
[Theory]
[InlineData("AES-128-CBC")] // weaker algorithm
[InlineData("AES-256-CBC")] // unauthenticated mode
[InlineData("aes-256-gcm")] // case must match exactly
[InlineData("")]
[InlineData("FOO")]
public void Constructor_UnknownAlgorithm_Throws(string algorithm)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: algorithm,
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Algorithm", ex.ParamName);
Assert.Contains("AES-256-GCM", ex.Message);
}
[Theory]
[InlineData("PBKDF2-SHA1")] // weaker hash
[InlineData("argon2id")] // unsupported KDF
[InlineData("pbkdf2-sha256")] // case must match
[InlineData("")]
public void Constructor_UnknownKdf_Throws(string kdf)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: kdf,
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Kdf", ex.ParamName);
Assert.Contains("PBKDF2-SHA256", ex.Message);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(1)]
[InlineData(99_999)] // one below the floor
[InlineData(10_000_001)] // one above the ceiling
[InlineData(int.MaxValue)]
public void Constructor_IterationsOutOfRange_Throws(int iterations)
{
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: iterations,
SaltB64: "abc",
IvB64: "def"));
Assert.Equal("Iterations", ex.ParamName);
}
[Theory]
[InlineData(100_000)] // OWASP minimum (exact)
[InlineData(600_000)] // design-doc production value
[InlineData(10_000_000)] // ceiling (exact)
public void Constructor_IterationsAtBoundary_Succeeds(int iterations)
{
// Exercises the inclusive boundary check on both ends.
var meta = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: iterations,
SaltB64: "abc",
IvB64: "def");
Assert.Equal(iterations, meta.Iterations);
}
[Fact]
public void Constructor_NullSalt_Throws()
{
// null is rejected; empty is permitted (the seed pattern used by BundleSerializer.Pack).
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: null!,
IvB64: "def"));
}
[Fact]
public void Constructor_NullIv_Throws()
{
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: "abc",
IvB64: null!));
}
[Fact]
public void Constructor_EmptySaltAndIv_Succeeds_ForSeedPattern()
{
// BundleSerializer.Pack re-stamps salt/iv from the ciphertext it actually
// writes, so callers (BundleExporter) construct a seed instance with empty
// placeholders. Validation must therefore accept empty here.
var seed = new EncryptionMetadata(
Algorithm: "AES-256-GCM",
Kdf: "PBKDF2-SHA256",
Iterations: 600_000,
SaltB64: string.Empty,
IvB64: string.Empty);
Assert.Equal(string.Empty, seed.SaltB64);
Assert.Equal(string.Empty, seed.IvB64);
}
}
@@ -990,4 +990,58 @@ public class ExternalSystemClientTests
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
}
}
/// <summary>
/// ExternalSystemGateway-022: an HTTP method outside the documented allowlist
/// (GET/POST/PUT/PATCH/DELETE) must be rejected at the gateway entry with a
/// clear <see cref="ArgumentException"/> — not silently dispatched as a
/// non-standard verb whose parameters land in neither body nor query string.
/// </summary>
[Theory]
[InlineData("FOO")]
[InlineData("DLETE")] // common typo for DELETE
[InlineData("GIT")]
[InlineData("OPTIONS")] // valid HTTP verb but outside the gateway's documented set
[InlineData("HEAD")]
public async Task Call_UnsupportedHttpMethod_ThrowsArgumentException(string httpMethod)
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("badVerb", httpMethod, "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
// No handler is needed — validation rejects the verb before any HTTP traffic.
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(new RequestCapturingHandler(HttpStatusCode.OK, "{}")));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => client.CallAsync("TestAPI", "badVerb"));
Assert.Contains(httpMethod, ex.Message);
Assert.Contains("GET", ex.Message);
}
[Theory]
[InlineData("GET")]
[InlineData("get")] // case-insensitive — operator-authored strings
[InlineData("Post")]
[InlineData("PATCH")]
[InlineData("delete")]
public async Task Call_DocumentedHttpMethod_IsAccepted(string httpMethod)
{
// The allowlist is case-insensitive (the entity column is free-form).
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("doIt", httpMethod, "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("TestAPI", "doIt");
Assert.True(result.Success);
}
}
@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.InboundApi;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace ScadaLink.InboundAPI.Tests;
/// <summary>
/// InboundAPI-020: the inbound API handler must accept JSON content types
/// case-insensitively. A request with <c>application/JSON</c>,
/// <c>Application/Json</c>, or <c>application/json</c> must all enter the
/// JSON-deserialization path — the previous <c>Contains("json")</c> check
/// was case-sensitive so a capitalised value silently skipped body parsing
/// and any required parameters surfaced as a 400 even though the caller
/// sent a valid JSON body.
/// </summary>
public class EndpointContentTypeTests
{
/// <summary>
/// Stub hasher that returns its input unchanged. Lets the test pre-seed the
/// repository with a known "hash" value without depending on the real
/// HMAC-with-pepper hasher.
/// </summary>
private sealed class IdentityHasher : IApiKeyHasher
{
public string Hash(string keyValue) => keyValue;
}
[Theory]
[InlineData("application/json")]
[InlineData("application/JSON")]
[InlineData("Application/Json")]
[InlineData("APPLICATION/JSON")]
public async Task ContentTypeCheck_IsCaseInsensitive_ParsesBodyForAnyCasing(string contentType)
{
const string apiKeyValue = "test-key";
const string methodName = "echoParam";
var key = ApiKey.FromHash("test", apiKeyValue);
key.IsEnabled = true;
key.Id = 1;
var method = new ApiMethod(methodName, "return Parameters[\"value\"];")
{
Id = 1,
TimeoutSeconds = 10,
// One Integer parameter, required — proves the body was actually
// parsed: if the case-sensitive bug returns, body parsing is
// skipped and the validator reports the missing field as a 400.
ParameterDefinitions = """[{"name":"value","type":"Integer","required":true}]""",
};
var repo = Substitute.For<IInboundApiRepository>();
repo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
repo.GetMethodByNameAsync(methodName, Arg.Any<CancellationToken>())
.Returns(method);
repo.GetApprovedKeysForMethodAsync(method.Id, Arg.Any<CancellationToken>())
.Returns(new List<ApiKey> { key });
using var host = await BuildHostAsync(repo);
var client = host.GetTestClient();
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
{
// Bypass HttpClient's MediaTypeHeaderValue auto-normalization by
// setting the header through TryAddWithoutValidation — we need the
// exact casing reach the server intact.
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("{\"value\":42}"))
};
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
request.Headers.Add("X-API-Key", apiKeyValue);
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
Assert.True(
response.StatusCode == HttpStatusCode.OK,
$"Expected 200 for content-type '{contentType}' but got {(int)response.StatusCode}: {body}");
Assert.Contains("42", body);
}
private static async Task<IHost> BuildHostAsync(IInboundApiRepository repo)
{
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
// RouteHelper depends on IInstanceLocator + IInstanceRouter
// (InboundAPI-017). Tests for content-type handling never
// route, so both can be no-op stubs — the production
// CommunicationServiceInstanceRouter would need a real
// CommunicationService which isn't wired here.
services.AddSingleton(Substitute.For<IInstanceLocator>());
services.Configure<InboundApiOptions>(_ => { });
services.AddInboundAPI();
services.RemoveAll<IInstanceRouter>();
services.AddSingleton(Substitute.For<IInstanceRouter>());
// The production AddInboundAPI registration of IApiKeyHasher
// requires a configured pepper. Replace it with the identity
// stub so the seeded ApiKey.KeyHash matches "test-key"
// deterministically without depending on configuration.
services.RemoveAll<IApiKeyHasher>();
services.AddSingleton<IApiKeyHasher>(new IdentityHasher());
services.AddLogging();
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapInboundAPI());
});
});
return await hostBuilder.StartAsync();
}
}
@@ -362,6 +362,52 @@ public class InboundScriptExecutorTests
Assert.True(_executor.CompileAndRegister(good));
}
// --- InboundAPI-024: _knownBadMethods must be bounded so a spam attack of
// unique method names cannot grow the cache without bound. ---
[Fact]
public void KnownBadMethodsCache_SizeNeverExceedsCap_UnderUniqueNameFlood()
{
// Flood the executor with bad-method names well past the cache cap. The
// cache must stabilise at or below the cap — any further unique bad name
// is dropped rather than added (the per-request DB lookup remains the
// correctness path; this cache is only a fast-fail optimisation).
const int cap = 1000;
const int floodCount = cap + 500;
for (var i = 0; i < floodCount; i++)
{
var bad = new ApiMethod($"bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
Assert.False(_executor.CompileAndRegister(bad));
}
Assert.True(
_executor.KnownBadMethodCount <= cap,
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
}
[Fact]
public async Task KnownBadMethodsCache_LazyCompilePath_AlsoCappedUnderUniqueNameFlood()
{
// The lazy-compile path (ExecuteAsync on an unregistered method) records
// failures via the same capped helper as CompileAndRegister, so flooding
// it with unique URLs must not grow the cache without bound.
const int cap = 1000;
const int floodCount = cap + 250;
for (var i = 0; i < floodCount; i++)
{
var method = new ApiMethod($"lazy-bad-{i}", "%%% invalid %%%") { Id = i + 1, TimeoutSeconds = 10 };
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), _route, TimeSpan.FromSeconds(10));
Assert.False(result.Success);
}
Assert.True(
_executor.KnownBadMethodCount <= cap,
$"known-bad cache size {_executor.KnownBadMethodCount} must not exceed cap {cap}");
}
// --- InboundAPI-014: the script return value is validated against ReturnDefinition ---
[Fact]
@@ -316,4 +316,45 @@ public class EventLogQueryServiceTests : IDisposable
cmd.ExecuteNonQuery();
});
}
// --- SiteEventLogging-017: PageSize hard upper bound ---
[Fact]
public async Task Query_PageSize_IsClampedToMaxQueryPageSize()
{
// Tighten the cap to make the assertion deterministic without seeding 100k rows.
var dbPath = Path.Combine(Path.GetTempPath(), $"test_pagesize_{Guid.NewGuid()}.db");
var options = Options.Create(new SiteEventLogOptions
{
DatabasePath = dbPath,
QueryPageSize = 500,
MaxQueryPageSize = 3,
});
using var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
var query = new EventLogQueryService(logger, options, NullLogger<EventLogQueryService>.Instance);
try
{
// Seed 5 rows but request PageSize = 100_000 — must be clamped to 3.
for (var i = 0; i < 5; i++)
await logger.LogEventAsync("script", "Info", null, $"src-{i}", $"msg-{i}");
var response = query.ExecuteQuery(new EventLogQueryRequest(
CorrelationId: Guid.NewGuid().ToString(),
SiteId: "site-1",
From: null, To: null,
EventType: null, Severity: null, InstanceId: null, KeywordFilter: null,
ContinuationToken: null,
PageSize: 100_000,
Timestamp: DateTimeOffset.UtcNow));
Assert.Equal(3, response.Entries.Count);
Assert.True(response.HasMore);
}
finally
{
logger.Dispose();
if (File.Exists(dbPath)) File.Delete(dbPath);
}
}
}
@@ -140,4 +140,34 @@ public class SiteEventLoggerTests : IDisposable
var count = (long)cmd.ExecuteScalar()!;
Assert.Equal(6, count);
}
// --- SiteEventLogging-020: severity validation against the closed set ---
[Theory]
[InlineData("info")] // wrong casing
[InlineData("warn")] // abbreviation
[InlineData("ERROR")] // wrong casing
[InlineData("Debug")] // not in set
[InlineData("Critical")] // not in set
public async Task LogEventAsync_ThrowsOnUnknownSeverity(string badSeverity)
{
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => _logger.LogEventAsync("script", badSeverity, null, "Source", "Message"));
Assert.Contains(badSeverity, ex.Message);
Assert.Contains("Info, Warning, Error", ex.Message);
}
[Theory]
[InlineData("Info")]
[InlineData("Warning")]
[InlineData("Error")]
public async Task LogEventAsync_AcceptsAllDocumentedSeverities(string severity)
{
await _logger.LogEventAsync("script", severity, null, "Source", "Message");
using var cmd = _verifyConnection.CreateCommand();
cmd.CommandText = "SELECT severity FROM site_events";
var stored = (string)cmd.ExecuteScalar()!;
Assert.Equal(severity, stored);
}
}
@@ -176,4 +176,41 @@ public class InstanceActorSetAttributeTests : TestKit, IDisposable
Assert.Single(overrides);
Assert.Equal("Backup", overrides["Label"]);
}
// SiteRuntime-025: SetAttribute on an unknown attribute name must NOT
// pollute the in-memory dictionary, NOT publish a synthetic
// AttributeValueChanged, and NOT persist a durable override row.
[Fact]
public async Task SetAttribute_UnknownAttribute_ReturnsFailureAndDoesNotPersistOverride()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpUnknown",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Label", Value = "Main", DataType = "String" }
]
};
var dclProbe = CreateTestProbe();
var actor = CreateInstanceActor("PumpUnknown", config, dclProbe.Ref);
actor.Tell(new SetStaticAttributeCommand(
"corr-unknown", "PumpUnknown", "notARealAttr", "x", DateTimeOffset.UtcNow));
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.False(response.Success);
Assert.Contains("Unknown attribute", response.ErrorMessage);
Assert.Contains("notARealAttr", response.ErrorMessage);
// The DCL must NOT receive any write — the attribute does not exist.
dclProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// No durable override row should be persisted for an unknown attribute —
// otherwise the polluting key resurrects on every restart via
// HandleOverridesLoaded.
await Task.Delay(300);
var overrides = await _storage.GetStaticOverridesAsync("PumpUnknown");
Assert.DoesNotContain("notARealAttr", overrides.Keys);
}
}
@@ -211,4 +211,46 @@ public class LockEnforcerTests
Assert.Null(result);
}
// ========================================================================
// TemplateEngine-022: LockedInDerived one-way ratchet
// ========================================================================
[Fact]
public void ValidateLockedInDerivedChange_ClearLocked_ReturnsError()
{
// Once a base template marks a member LockedInDerived, the flag may
// not be cleared — derived overrides previously blocked would
// otherwise become retroactively legal.
var result = LockEnforcer.ValidateLockedInDerivedChange(true, false, "Speed");
Assert.NotNull(result);
Assert.Contains("locked-in-derived", result);
Assert.Contains("cannot be cleared", result);
}
[Fact]
public void ValidateLockedInDerivedChange_LockUnlocked_ReturnsNull()
{
// Setting the flag from false→true is the normal direction.
var result = LockEnforcer.ValidateLockedInDerivedChange(false, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockedInDerivedChange_KeepLocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockedInDerivedChange(true, true, "Speed");
Assert.Null(result);
}
[Fact]
public void ValidateLockedInDerivedChange_KeepUnlocked_ReturnsNull()
{
var result = LockEnforcer.ValidateLockedInDerivedChange(false, false, "Speed");
Assert.Null(result);
}
}
@@ -1086,6 +1086,8 @@ public class TemplateServiceTests
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1098,6 +1100,8 @@ public class TemplateServiceTests
{
var t = new Template("X") { Id = 1, FolderId = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, null, "admin");
@@ -1117,4 +1121,78 @@ public class TemplateServiceTests
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
[Fact]
public async Task MoveTemplate_NameCollisionAtDestination_Fails()
{
// TemplateEngine-021: moving "Pump" into a folder that already contains a
// template named "Pump" (case-insensitive) must be rejected before the
// FolderId is persisted. Mirrors TemplateFolderService.MoveFolderAsync
// sibling-name uniqueness, and pins the design rule that naming
// collisions are design-time errors.
var moving = new Template("Pump") { Id = 1, FolderId = null };
var existing = new Template("pump") { Id = 2, FolderId = 7 }; // case-insensitive clash
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, existing });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
// The destination FolderId must NOT have been written when the move is rejected.
Assert.Null(moving.FolderId);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task MoveTemplate_NoCollisionAtDestination_Succeeds()
{
// TemplateEngine-021 companion: a sibling with the same name in a
// *different* folder is not a collision. The move must succeed.
var moving = new Template("Pump") { Id = 1, FolderId = null };
var unrelated = new Template("Pump") { Id = 2, FolderId = 8 }; // different folder
var folder = new TemplateFolder("Dev") { Id = 7 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, unrelated });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(7, result.Value.FolderId);
}
[Fact]
public async Task UpdateAttribute_LockedInDerivedDowngrade_OnBase_Rejected()
{
// TemplateEngine-022: LockedInDerived is a one-way ratchet on base
// templates. Once true, it cannot be cleared — otherwise existing
// derived overrides that were previously blocked would become
// retroactively legal.
var baseTemplate = new Template("Sensor") { Id = 2 }; // base — not derived
var existing = new TemplateAttribute("SetPoint")
{
Id = 100,
TemplateId = 2,
DataType = DataType.Float,
LockedInDerived = true
};
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
var proposed = new TemplateAttribute("SetPoint")
{
DataType = DataType.Float,
LockedInDerived = false // attempt to clear the lock
};
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
Assert.True(result.IsFailure);
Assert.Contains("locked-in-derived", result.Error);
Assert.True(existing.LockedInDerived); // unchanged
}
}
@@ -1,12 +1,16 @@
using System.Security.Cryptography;
using System.Text;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Transport.Encryption;
namespace ScadaLink.Transport.Tests.Encryption;
public sealed class BundleSecretEncryptorTests
{
private const int TestIterations = 10_000; // Lower than production for test speed.
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum);
// the production value is 600_000. Using the floor keeps the test fast while
// remaining a valid EncryptionMetadata.Iterations.
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
[Fact]
public void Encrypt_then_Decrypt_roundtrips_arbitrary_bytes()
@@ -102,6 +102,10 @@ public sealed class BundleImporterLoadTests
encryptor: encryptor,
entitySerializer: entitySerializer,
sessionStore: store,
// T-004: the unlock rate limiter shares the test clock so its trailing-hour
// window pruning is deterministic. The window itself is the production
// default (1 hour).
unlockRateLimiter: new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow),
options: iOpts,
timeProvider: clock,
templateRepo: Substitute.For<ITemplateEngineRepository>(),
@@ -0,0 +1,160 @@
using ScadaLink.Transport.Import;
namespace ScadaLink.Transport.Tests.Import;
/// <summary>
/// Transport-004: <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
/// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The
/// limiter accepts any opaque caller key (typically a remote IP); these tests use
/// IP-style strings to mirror the documented intent.
/// </summary>
public sealed class BundleUnlockRateLimiterTests
{
private sealed class TestClock : TimeProvider
{
private DateTimeOffset _now;
public TestClock(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) { _now += delta; }
}
[Fact]
public void TryRegisterAttempt_UnderLimit_ReturnsTrue()
{
// The first N attempts at the same key are permitted; the trailing-hour
// count tracks them.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 1; i <= 10; i++)
{
Assert.True(
limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10),
$"Attempt {i} should be allowed (under the cap).");
}
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Fact]
public void TryRegisterAttempt_AtLimit_RejectsNextAttempt()
{
// N attempts allowed, attempt N+1 rejected — the headline contract.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 11th attempt within the hour exceeds the cap.
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Subsequent attempts also rejected — the limiter does NOT silently let a
// 12th, 13th, ... attempt through (no leak past the cap).
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// And the recorded count never exceeds the cap (rejected attempts are not
// appended to the trailing-hour queue).
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Fact]
public void TryRegisterAttempt_EntriesExpireAfterWindow()
{
// Once the trailing-hour window rolls past every recorded attempt the key
// is fully reset — a legitimate operator returning later is not penalised.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the window's end. Every recorded timestamp is now
// strictly older than (now - window) and must be pruned.
clock.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));
Assert.Equal(0, limiter.GetAttemptCount("10.0.0.1"));
// A fresh full budget is available.
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
[Fact]
public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly()
{
// Sliding window — when only some of the recorded entries have aged out,
// exactly that many slots are released.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
// Five attempts at t=0.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 30 minutes later, five more — saturates the budget.
clock.Advance(TimeSpan.FromMinutes(30));
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the first batch's window. Only those five entries expire;
// the second batch (recorded at t=30) is still within window from t=61.
clock.Advance(TimeSpan.FromMinutes(31));
Assert.Equal(5, limiter.GetAttemptCount("10.0.0.1"));
// Five fresh slots are available.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
[Fact]
public void TryRegisterAttempt_PerKeyIsolation()
{
// The cap is per key — saturating one IP does not affect another.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// A different IP has its own untouched budget.
Assert.True(limiter.TryRegisterAttempt("10.0.0.2", maxAttemptsPerWindow: 10));
Assert.Equal(1, limiter.GetAttemptCount("10.0.0.2"));
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryRegisterAttempt_BlankKey_Throws(string? key)
{
var limiter = new BundleUnlockRateLimiter();
Assert.ThrowsAny<ArgumentException>(
() => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void TryRegisterAttempt_NonPositiveLimit_Throws(int limit)
{
var limiter = new BundleUnlockRateLimiter();
Assert.Throws<ArgumentOutOfRangeException>(
() => limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: limit));
}
}
@@ -8,7 +8,10 @@ namespace ScadaLink.Transport.Tests.Serialization;
public sealed class BundleSerializerTests
{
private const int TestIterations = 10_000;
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum).
// Using the floor keeps the suite fast while passing EncryptionMetadata
// validation.
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
private static BundleContentDto SampleContent() => new(
TemplateFolders: new[] { new TemplateFolderDto("Root", ParentName: null, SortOrder: 0) },