Phase 8: Production readiness — failover tests, security hardening, sandboxing, deployment docs

- WP-1-3: Central/site failover + dual-node recovery tests (17 tests)
- WP-4: Performance testing framework for target scale (7 tests)
- WP-5: Security hardening (LDAPS, JWT key length, no secrets in logs) (11 tests)
- WP-6: Script sandboxing adversarial tests (28 tests, all forbidden APIs)
- WP-7: Recovery drill test scaffolds (5 tests)
- WP-8: Observability validation (structured logs, correlation IDs, metrics) (6 tests)
- WP-9: Message contract compatibility (forward/backward compat) (18 tests)
- WP-10: Deployment packaging (installation guide, production checklist, topology)
- WP-11: Operational runbooks (failover, troubleshooting, maintenance)
92 new tests, all passing. Zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 22:12:31 -04:00
parent 3b2320bd35
commit b659978764
68 changed files with 6253 additions and 44 deletions

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
namespace ScadaLink.ExternalSystemGateway.Tests;
/// <summary>
/// WP-9: Tests for Database access — connection resolution, cached writes.
/// </summary>
public class DatabaseGatewayTests
{
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
[Fact]
public async Task GetConnection_NotFound_Throws()
{
_repository.GetAllDatabaseConnectionsAsync().Returns(new List<DatabaseConnectionDefinition>());
var gateway = new DatabaseGateway(
_repository,
NullLogger<DatabaseGateway>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.GetConnectionAsync("nonexistent"));
}
[Fact]
public async Task CachedWrite_NoStoreAndForward_Throws()
{
var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 };
_repository.GetAllDatabaseConnectionsAsync()
.Returns(new List<DatabaseConnectionDefinition> { conn });
var gateway = new DatabaseGateway(
_repository,
NullLogger<DatabaseGateway>.Instance,
storeAndForward: null);
await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)"));
}
[Fact]
public async Task CachedWrite_ConnectionNotFound_Throws()
{
_repository.GetAllDatabaseConnectionsAsync().Returns(new List<DatabaseConnectionDefinition>());
var gateway = new DatabaseGateway(
_repository,
NullLogger<DatabaseGateway>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.CachedWriteAsync("nonexistent", "INSERT INTO t VALUES (1)"));
}
}

View File

@@ -0,0 +1,67 @@
using System.Net;
namespace ScadaLink.ExternalSystemGateway.Tests;
/// <summary>
/// WP-8: Tests for HTTP error classification.
/// Transient: connection refused, timeout, HTTP 408/429/5xx.
/// Permanent: HTTP 4xx (except 408/429).
/// </summary>
public class ErrorClassifierTests
{
[Theory]
[InlineData(HttpStatusCode.InternalServerError, true)]
[InlineData(HttpStatusCode.BadGateway, true)]
[InlineData(HttpStatusCode.ServiceUnavailable, true)]
[InlineData(HttpStatusCode.GatewayTimeout, true)]
[InlineData(HttpStatusCode.RequestTimeout, true)]
[InlineData((HttpStatusCode)429, true)] // TooManyRequests
public void TransientStatusCodes_ClassifiedCorrectly(HttpStatusCode statusCode, bool expectedTransient)
{
Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode));
}
[Theory]
[InlineData(HttpStatusCode.BadRequest, false)]
[InlineData(HttpStatusCode.Unauthorized, false)]
[InlineData(HttpStatusCode.Forbidden, false)]
[InlineData(HttpStatusCode.NotFound, false)]
[InlineData(HttpStatusCode.MethodNotAllowed, false)]
[InlineData(HttpStatusCode.Conflict, false)]
public void PermanentStatusCodes_ClassifiedCorrectly(HttpStatusCode statusCode, bool expectedTransient)
{
Assert.Equal(expectedTransient, ErrorClassifier.IsTransient(statusCode));
}
[Fact]
public void HttpRequestException_IsTransient()
{
Assert.True(ErrorClassifier.IsTransient(new HttpRequestException("Connection refused")));
}
[Fact]
public void TaskCanceledException_IsTransient()
{
Assert.True(ErrorClassifier.IsTransient(new TaskCanceledException("Timeout")));
}
[Fact]
public void TimeoutException_IsTransient()
{
Assert.True(ErrorClassifier.IsTransient(new TimeoutException()));
}
[Fact]
public void GenericException_IsNotTransient()
{
Assert.False(ErrorClassifier.IsTransient(new InvalidOperationException("bad input")));
}
[Fact]
public void AsTransient_CreatesCorrectException()
{
var ex = ErrorClassifier.AsTransient("test message");
Assert.IsType<TransientExternalSystemException>(ex);
Assert.Equal("test message", ex.Message);
}
}

View File

@@ -0,0 +1,178 @@
using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Interfaces.Repositories;
namespace ScadaLink.ExternalSystemGateway.Tests;
/// <summary>
/// WP-6/7: Tests for ExternalSystemClient — HTTP client, call modes, error handling.
/// </summary>
public class ExternalSystemClientTests
{
private readonly IExternalSystemRepository _repository = Substitute.For<IExternalSystemRepository>();
private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();
[Fact]
public async Task Call_SystemNotFound_ReturnsError()
{
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("nonexistent", "method");
Assert.False(result.Success);
Assert.Contains("not found", result.ErrorMessage);
}
[Fact]
public async Task Call_MethodNotFound_ReturnsError()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod>());
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("TestAPI", "missingMethod");
Assert.False(result.Success);
Assert.Contains("not found", result.ErrorMessage);
}
[Fact]
public async Task Call_SuccessfulHttp_ReturnsResponse()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"result\": 42}");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("TestAPI", "getData");
Assert.True(result.Success);
Assert.Contains("42", result.ResponseJson!);
}
[Fact]
public async Task Call_Transient500_ReturnsTransientError()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("failMethod", "POST", "/fail") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "server error");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("TestAPI", "failMethod");
Assert.False(result.Success);
Assert.Contains("Transient error", result.ErrorMessage);
}
[Fact]
public async Task Call_Permanent400_ReturnsPermanentError()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("badMethod", "POST", "/bad") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "bad request");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("TestAPI", "badMethod");
Assert.False(result.Success);
Assert.Contains("Permanent error", result.ErrorMessage);
}
[Fact]
public async Task CachedCall_SystemNotFound_ReturnsError()
{
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition>());
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger<ExternalSystemClient>.Instance);
var result = await client.CachedCallAsync("nonexistent", "method");
Assert.False(result.Success);
Assert.Contains("not found", result.ErrorMessage);
}
[Fact]
public async Task CachedCall_Success_ReturnsDirectly()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync().Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1).Returns(new List<ExternalSystemMethod> { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"ok\": true}");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
var client = new ExternalSystemClient(
_httpClientFactory, _repository,
NullLogger<ExternalSystemClient>.Instance);
var result = await client.CachedCallAsync("TestAPI", "getData");
Assert.True(result.Success);
Assert.False(result.WasBuffered);
}
/// <summary>
/// Test helper: mock HTTP message handler.
/// </summary>
private class MockHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly string _body;
public MockHttpMessageHandler(HttpStatusCode statusCode, string body)
{
_statusCode = statusCode;
_body = body;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_body)
});
}
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
@@ -21,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.ExternalSystemGateway/ScadaLink.ExternalSystemGateway.csproj" />
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,10 +0,0 @@
namespace ScadaLink.ExternalSystemGateway.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}