fix(external-system-gateway): resolve ExternalSystemGateway-012,013,014 — failure logging, connection-limit wiring, test coverage; ExternalSystemGateway-011 flagged

This commit is contained in:
Joseph Doherty
2026-05-16 22:14:23 -04:00
parent e9ee4e3ea5
commit e57ccd78b7
7 changed files with 509 additions and 15 deletions

View File

@@ -1,5 +1,7 @@
using System.Net;
using System.Net.Http.Headers;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
@@ -603,6 +605,237 @@ public class ExternalSystemClientTests
"A caller-cancelled CachedCall must not be buffered for retry");
}
// ── ExternalSystemGateway-014: BuildUrl query-string, ApplyAuth, connection errors ──
[Fact]
public async Task Call_GetWithParameters_AppendsEscapedQueryString()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { 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);
await client.CallAsync("TestAPI", "search", new Dictionary<string, object?>
{
["q"] = "a b&c",
["page"] = 2,
});
// AbsoluteUri preserves percent-encoding; the '&' inside a value must be
// escaped so it is not mistaken for a parameter separator.
var uri = handler.LastUri!.AbsoluteUri;
Assert.StartsWith("https://api.example.com/search?", uri);
Assert.Contains("q=a%20b%26c", uri);
Assert.Contains("page=2", uri);
}
[Fact]
public async Task Call_PostWithParameters_SendsJsonBody()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("create", "POST", "/create") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { 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);
await client.CallAsync("TestAPI", "create", new Dictionary<string, object?> { ["name"] = "widget" });
Assert.Equal("https://api.example.com/create", handler.LastUri!.ToString());
Assert.Contains("\"name\":\"widget\"", handler.LastBody);
}
[Fact]
public async Task Call_ApiKeyAuthWithDefaultHeader_SendsXApiKeyHeader()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
{
Id = 1,
AuthConfiguration = "secret-key-123",
};
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { 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);
await client.CallAsync("TestAPI", "getData");
Assert.True(handler.LastHeaders!.TryGetValues("X-API-Key", out var values));
Assert.Equal("secret-key-123", values!.Single());
}
[Fact]
public async Task Call_ApiKeyAuthWithCustomHeader_SendsNamedHeader()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
{
Id = 1,
AuthConfiguration = "Authorization-Token:abc",
};
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { 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);
await client.CallAsync("TestAPI", "getData");
Assert.True(handler.LastHeaders!.TryGetValues("Authorization-Token", out var values));
Assert.Equal("abc", values!.Single());
}
[Fact]
public async Task Call_BasicAuth_SendsBase64AuthorizationHeader()
{
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "basic")
{
Id = 1,
AuthConfiguration = "alice:s3cret",
};
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
_repository.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { 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);
await client.CallAsync("TestAPI", "getData");
var auth = handler.LastHeaders!.Authorization;
Assert.NotNull(auth);
Assert.Equal("Basic", auth!.Scheme);
var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(auth.Parameter!));
Assert.Equal("alice:s3cret", decoded);
}
[Fact]
public async Task Call_ConnectionError_IsClassifiedAsTransient()
{
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(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
// A connection-level failure (e.g. host unreachable) surfaces as HttpRequestException.
var handler = new ThrowingHttpMessageHandler(new HttpRequestException("connection refused"));
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
var result = await client.CallAsync("TestAPI", "getData");
Assert.False(result.Success);
Assert.Contains("Transient error", result.ErrorMessage);
}
// ── ExternalSystemGateway-012: permanent failures must be logged ──
[Fact]
public async Task Call_PermanentFailure_LogsAWarning()
{
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(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.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 logger = new CapturingLogger<ExternalSystemClient>();
var client = new ExternalSystemClient(_httpClientFactory, _repository, logger);
await client.CallAsync("TestAPI", "badMethod");
// The design doc requires permanent failures to be surfaced to Site Event
// Logging — the gateway must emit at least a warning, not stay silent.
Assert.Contains(logger.Entries, e =>
e.Level >= LogLevel.Warning && e.Message.Contains("TestAPI"));
}
[Fact]
public async Task Call_TransientFailure_DoesNotLogAtWarningOrAbove()
{
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(Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemDefinition> { system });
_repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new List<ExternalSystemMethod> { method });
var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom");
var httpClient = new HttpClient(handler);
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
var logger = new CapturingLogger<ExternalSystemClient>();
var client = new ExternalSystemClient(_httpClientFactory, _repository, logger);
await client.CallAsync("TestAPI", "failMethod");
// A transient failure is normal operation handled by retry/S&F — it must not
// be logged at warning level (only permanent failures are).
Assert.DoesNotContain(logger.Entries, e => e.Level >= LogLevel.Warning);
}
/// <summary>Test helper: an ILogger that records every entry for assertions.</summary>
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
/// <summary>
/// Test helper: mock HTTP message handler.
/// </summary>
@@ -667,7 +900,7 @@ public class ExternalSystemClientTests
}
/// <summary>
/// Test helper: captures the request URI of the last request.
/// Test helper: captures the URI, headers and body of the last request.
/// </summary>
private class RequestCapturingHandler : HttpMessageHandler
{
@@ -681,17 +914,31 @@ public class ExternalSystemClientTests
}
public Uri? LastUri { get; private set; }
public HttpRequestHeaders? LastHeaders { get; private set; }
public string? LastBody { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastUri = request.RequestUri;
return Task.FromResult(new HttpResponseMessage(_statusCode)
LastHeaders = request.Headers;
LastBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_body)
});
};
}
}
/// <summary>Test helper: an HTTP handler that throws a connection-level exception.</summary>
private class ThrowingHttpMessageHandler : HttpMessageHandler
{
private readonly Exception _exception;
public ThrowingHttpMessageHandler(Exception exception) => _exception = exception;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> throw _exception;
}
/// <summary>
/// Test helper: an HTTP handler that hangs until cancelled (simulates a slow/hung system).
/// </summary>