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:
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user