819f1b4665
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).
133 lines
5.7 KiB
C#
133 lines
5.7 KiB
C#
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();
|
|
}
|
|
}
|