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:
@@ -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)"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ScadaLink.ExternalSystemGateway.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user