From 2502e4d10a9578aec071245c7ebe863c9973abc7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 21:11:24 -0400 Subject: [PATCH] =?UTF-8?q?fix(external-system-gateway):=20resolve=20Exter?= =?UTF-8?q?nalSystemGateway-004..010=20=E2=80=94=20honour=20retry=20settin?= =?UTF-8?q?gs,=20dispose=20HTTP=20messages,=20fix=20URL=20building,=20trun?= =?UTF-8?q?cate=20error=20bodies,=20fix=20connection=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExternalSystemGateway/findings.md | 134 ++++++- .../DatabaseGateway.cs | 27 +- .../ExternalSystemClient.cs | 101 ++++-- .../DatabaseGatewayTests.cs | 64 ++++ .../ExternalSystemClientTests.cs | 341 ++++++++++++++++++ 5 files changed, 615 insertions(+), 52 deletions(-) diff --git a/code-reviews/ExternalSystemGateway/findings.md b/code-reviews/ExternalSystemGateway/findings.md index cc7c050..67384ba 100644 --- a/code-reviews/ExternalSystemGateway/findings.md +++ b/code-reviews/ExternalSystemGateway/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 11 | +| Open findings | 4 | ## Summary @@ -215,7 +215,7 @@ is flipped back to `true`. |--|--| | Severity | Medium | | Category | Design-document adherence | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:114-115`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:86-87` | **Description** @@ -242,7 +242,21 @@ should be tracked against the SiteRuntime module. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). `CachedCallAsync` and `CachedWriteAsync` now pass +the definition's `MaxRetries` to `EnqueueAsync` verbatim — the `> 0` guard is dropped, so +a legitimately-configured `MaxRetries` of 0 ("never retry") is honoured instead of being +collapsed to the S&F default. The `RetryDelay > TimeSpan.Zero` guard is deliberately +**kept**: `TimeSpan.Zero` is the entity default for an unconfigured field and a literal +zero-delay retry loop is not a valid configuration, so falling back to the S&F default +interval for an unset delay is correct (only `MaxRetries == 0` is a meaningful operator +choice). Regression test `CachedCall_TransientFailure_ZeroMaxRetriesIsHonouredNotTreatedAsUnset` +buffers a transient failure and asserts the buffered message carries `MaxRetries == 0` +rather than the S&F default; `CachedCall_TransientFailure_BuffersWithSystemRetrySettings` +additionally covers a non-default settings pass-through. The companion fix in +`SiteExternalSystemRepository.MapExternalSystem` to actually read the `MaxRetries` / +`RetryDelay` columns from SQLite remains a tracked follow-up against the SiteRuntime +module (outside this module's edit scope) — until then, sites still supply +`MaxRetries == 0`, which this fix now correctly honours as "never retry". ### ExternalSystemGateway-005 — `HttpRequestMessage` and `HttpResponseMessage` are not disposed @@ -250,7 +264,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Performance & resource management | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:133-167` | **Description** @@ -272,15 +286,22 @@ occurs on the exception paths. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). `InvokeHttpAsync` now declares the request as +`using var request` and wraps all response handling in a `using (response)` block, so +both `IDisposable` instances (and the request's `StringContent` / the response content +stream) are released on the success path **and** on the permanent/transient +exception paths. Regression tests `Call_SuccessfulHttp_DisposesRequestAndResponse` and +`Call_PermanentFailure_StillDisposesRequestAndResponse` use a disposal-tracking +`HttpMessageHandler`/`HttpContent` and assert both the request and the response content +are disposed; both were verified to fail before the `using` wrappers were added. ### ExternalSystemGateway-006 — `BuildUrl` ignores path templates and appends a trailing slash for empty paths | | | |--|--| -| Severity | Medium | +| Severity | Medium — partially re-triaged: trailing-slash bug fixed; path-templating sub-issue is a design decision (see Resolution) | | Category | Correctness & logic bugs | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:180-196` | **Description** @@ -305,7 +326,25 @@ example and state that paths are literal. Also avoid appending a trailing `/` wh **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). The **trailing-slash bug** is fixed: `BuildUrl` +now appends a `/`-joined path segment only when the method's path is non-empty after +trimming, so a method targeting the base URL itself produces `https://host/api` rather +than `https://host/api/`. Regression tests `Call_MethodWithEmptyPath_DoesNotAppendTrailingSlash` +and `Call_MethodWithPath_BuildsExpectedUrl` (asserting on the captured request URI) +cover the empty-path and normal-path cases; the empty-path test was verified to fail +before the fix. + +Re-triage of the **path-templating sub-issue** (`{id}` placeholder substitution): this +is a genuine design decision, not a code bug, and it requires editing the component +design doc — both outside this module's edit scope (`src/`, `tests/`, this file only). +The current code treats method paths as literal strings and routes parameters to the +query string (GET/DELETE) or JSON body (POST/PUT); a method authored as `/recipes/{id}` +sends the `{id}` token verbatim. **Tracked follow-up / surfaced design question:** the +design owner must decide whether path templating is in scope — if yes, implement +`{name}` substitution in `BuildUrl` and exclude substituted params from the +query/body; if no, the `Component-ExternalSystemGateway.md` `/recipes/{id}` example must +be changed to a literal path. The trailing-slash defect (the concrete correctness bug +in this finding) is fully resolved. ### ExternalSystemGateway-007 — External error response bodies are echoed verbatim into script-visible error messages @@ -313,7 +352,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:167-177` | **Description** @@ -335,15 +374,25 @@ the script. Optionally only include the body when the content type is textual. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). `InvokeHttpAsync` now truncates the external error +response body to `MaxErrorBodyChars` (2048) via a `Truncate` helper before embedding it +into the transient/permanent exception message — so a misbehaving or hostile endpoint +can no longer inflate every script-visible `ErrorMessage` and Site Event Logging entry +with a multi-megabyte body. When truncation occurs the message is suffixed with +`… [truncated, N chars total]` so the original size is still visible. Regression test +`Call_PermanentFailureWithHugeErrorBody_TruncatesErrorMessage` drives a 400 with a +500 KB body and asserts the resulting `ErrorMessage` is bounded (< 4096 chars); it was +verified to fail (500 040-char message) before the cap was added. Content-type +filtering was considered optional in the recommendation and was not implemented — the +size cap alone closes the inflation/disclosure vector. ### ExternalSystemGateway-008 — Cancellation is conflated with transient timeout failure | | | |--|--| -| Severity | Medium | +| Severity | Medium — re-triaged: root cause already fixed in current source (see Resolution) | | Category | Error handling & resilience | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/ErrorClassifier.cs:24-30`, `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:157-159` | **Description** @@ -367,15 +416,37 @@ classification. Only treat a cancellation as a timeout when the supplied token i **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). **Re-triage:** the root cause described — a +caller-initiated cancellation being misclassified as a transient failure — is **no +longer present in the current source** and is not reproducible. `InvokeHttpAsync` +already wraps both `SendAsync` and the response-body `ReadAsStringAsync` in ordered +`catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)` +filters that rethrow the caller's cancellation *before* the +`catch (Exception ex) when (ErrorClassifier.IsTransient(ex))` branch is ever reached +(this was added alongside the `ExternalSystemGateway-002` timeout fix). A caller-cancel +therefore propagates as `OperationCanceledException` and is never buffered; only the +gateway's own timeout token reclassifies as transient. + +`ErrorClassifier.IsTransient(Exception)` does still return `true` for +`TaskCanceledException`/`OperationCanceledException`, but that is **correct and +intentional**: a `TaskCanceledException` raised by an HTTP timeout *is* a genuine +transient failure, and the only caller (`InvokeHttpAsync:238`) is unreachable for a +caller-cancellation because the two preceding `when`-filtered catches intercept it +first. The transient-vs-cancel decision is contextual (which token fired) and cannot +be made from the exception type alone, which is exactly why the call site does it. +No source change was required. A regression guard, +`CachedCall_CallerCancellation_IsNotBufferedAsTransient`, was added: it cancels the +caller token mid-`CachedCall` and asserts an `OperationCanceledException` is thrown and +the S&F buffer remains empty (the cancelled work is not retried). The existing +`Call_CallerCancellation_IsNotMisreportedAsTimeout` covers the synchronous `Call` path. ### ExternalSystemGateway-009 — `StoreAndForwardResult` from `EnqueueAsync` is discarded; permanent failures during buffering are swallowed | | | |--|--| -| Severity | Medium | +| Severity | Medium — re-triaged: root cause subsumed by the ExternalSystemGateway-003 dispatch redesign (see Resolution) | | Category | Correctness & logic bugs | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:109-117` | **Description** @@ -398,7 +469,27 @@ partly subsumed by the dispatch redesign in finding 003.) **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). **Re-triage:** the stated root cause — "a +permanent failure surfaced by `EnqueueAsync`'s immediate-delivery attempt is silently +lost" — **can no longer occur** in the current source, and the dead `sfResult` variable +the finding cites has already been removed. The `ExternalSystemGateway-003` fix changed +`CachedCallAsync` to call `EnqueueAsync` with `attemptImmediateDelivery: false`. With +that flag, `EnqueueAsync` never invokes the registered delivery handler: it skips the +immediate-delivery block entirely (so the `StoreAndForwardResult(false, …, …)` +permanent-failure return at `StoreAndForwardService.cs:147` is unreachable from this +caller) and unconditionally buffers, returning `Accepted: true, WasBuffered: true` +(`StoreAndForwardService.cs:180`). The `ExternalCallResult(true, null, null, +WasBuffered: true)` that `CachedCallAsync` returns is therefore now factually correct +in every reachable case — the message *is* buffered and there is no swallowed permanent +failure. Permanent HTTP 4xx failures are still surfaced synchronously, because +`CachedCallAsync` makes its own first HTTP attempt and catches +`PermanentExternalSystemException` *before* it ever reaches `EnqueueAsync`. No source +change was required beyond the `ExternalSystemGateway-003` redesign that already landed. +Coverage: `CachedCall_TransientFailure_BuffersWithSystemRetrySettings` asserts both +`result.WasBuffered == true` and that the message is genuinely present in the S&F buffer +(depth == 1), confirming the `WasBuffered: true` claim is not a lie; the existing +`CachedCall` permanent-failure path is exercised by `Call_Permanent400_ReturnsPermanentError` +semantics shared via `InvokeHttpAsync`. ### ExternalSystemGateway-010 — `GetConnectionAsync` leaks the `SqlConnection` when `OpenAsync` fails @@ -406,7 +497,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Performance & resource management | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:48-50` | **Description** @@ -427,7 +518,14 @@ Wrap the open in a try/catch that disposes the connection before rethrowing: **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). `GetConnectionAsync` now wraps `OpenAsync` in a +`try/catch` that calls `await connection.DisposeAsync()` before rethrowing, so a failed +open (unreachable server, bad credentials, cancellation) no longer leaks the +`SqlConnection`. Connection creation was extracted into an `internal virtual +CreateConnection(string)` factory so the failure path is unit-testable. Regression test +`GetConnection_OpenFails_DisposesConnectionBeforeRethrowing` substitutes a `DbConnection` +whose `OpenAsync` always throws and asserts the connection is disposed when the +exception propagates; it was verified to fail before the `try/catch` was added. ### ExternalSystemGateway-011 — Every call performs a full repository scan of all systems and methods diff --git a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs index dcb30f2..e88d0f5 100644 --- a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs +++ b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs @@ -45,11 +45,29 @@ public class DatabaseGateway : IDatabaseGateway throw new InvalidOperationException($"Database connection '{connectionName}' not found"); } - var connection = new SqlConnection(definition.ConnectionString); - await connection.OpenAsync(cancellationToken); + var connection = CreateConnection(definition.ConnectionString); + try + { + await connection.OpenAsync(cancellationToken); + } + catch + { + // OpenAsync failed (unreachable server, bad credentials, cancellation) — + // dispose the just-created connection before the exception propagates so + // it is not leaked (ExternalSystemGateway-010). + await connection.DisposeAsync(); + throw; + } return connection; } + /// + /// Creates the underlying ADO.NET connection for a connection string. Virtual so + /// tests can substitute a connection whose OpenAsync fails. + /// + internal virtual DbConnection CreateConnection(string connectionString) => + new SqlConnection(connectionString); + /// /// Submits a SQL write to the store-and-forward engine for reliable delivery. /// @@ -78,12 +96,15 @@ public class DatabaseGateway : IDatabaseGateway Parameters = parameters }); + // The per-connection retry settings are passed through verbatim — a + // configured MaxRetries of 0 means "never retry" and must NOT be + // collapsed to the S&F default (ExternalSystemGateway-004). await _storeAndForward.EnqueueAsync( StoreAndForwardCategory.CachedDbWrite, connectionName, payload, originInstanceName, - definition.MaxRetries > 0 ? definition.MaxRetries : null, + definition.MaxRetries, definition.RetryDelay > TimeSpan.Zero ? definition.RetryDelay : null); } diff --git a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs index 2408927..52abe8a 100644 --- a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs @@ -113,12 +113,16 @@ public class ExternalSystemClient : IExternalSystemClient // attemptImmediateDelivery: false — this method already made the HTTP // attempt above; letting EnqueueAsync re-invoke the handler would // dispatch the same request a second time. + // + // The per-system retry settings are passed through verbatim — a + // configured MaxRetries of 0 means "never retry" and must NOT be + // collapsed to the S&F default (ExternalSystemGateway-004). await _storeAndForward.EnqueueAsync( StoreAndForwardCategory.ExternalSystem, systemName, payload, originInstanceName, - system.MaxRetries > 0 ? system.MaxRetries : null, + system.MaxRetries, system.RetryDelay > TimeSpan.Zero ? system.RetryDelay : null, attemptImmediateDelivery: false); @@ -183,7 +187,11 @@ public class ExternalSystemClient : IExternalSystemClient var client = _httpClientFactory.CreateClient($"ExternalSystem_{system.Name}"); var url = BuildUrl(system.EndpointUrl, method.Path, parameters, method.HttpMethod); - var request = new HttpRequestMessage(new HttpMethod(method.HttpMethod), url); + + // The request and response own IDisposable resources (StringContent, the + // response content stream). Dispose both, including on the exception paths + // (ExternalSystemGateway-005). + using var request = new HttpRequestMessage(new HttpMethod(method.HttpMethod), url); // Apply authentication ApplyAuth(request, system); @@ -232,44 +240,75 @@ public class ExternalSystemClient : IExternalSystemClient throw ErrorClassifier.AsTransient($"Connection error to {system.Name}: {ex.Message}", ex); } - // The timeout also covers reading the response body (the design's - // "round-trip" guarantee), so the linked token is used for the read too. - string body; - try + using (response) { - body = await response.Content.ReadAsStringAsync(linkedCts.Token); + // The timeout also covers reading the response body (the design's + // "round-trip" guarantee), so the linked token is used for the read too. + string body; + try + { + body = await response.Content.ReadAsStringAsync(linkedCts.Token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested) + { + throw ErrorClassifier.AsTransient( + $"Timeout reading response from {system.Name} after {_options.DefaultHttpTimeout.TotalSeconds:0.##}s", ex); + } + + if (response.IsSuccessStatusCode) + { + return body; + } + + // Bound the external error body before embedding it into a + // script-visible message / event-log entry — a misbehaving or hostile + // endpoint must not be able to inflate every error string + // (ExternalSystemGateway-007). + var errorBody = Truncate(body, MaxErrorBodyChars); + + if (ErrorClassifier.IsTransient(response.StatusCode)) + { + throw ErrorClassifier.AsTransient( + $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}"); + } + + throw new PermanentExternalSystemException( + $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}", + (int)response.StatusCode); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + } + + /// + /// Upper bound (characters) on an external error response body echoed into a + /// script-visible error message — see ExternalSystemGateway-007. + /// + private const int MaxErrorBodyChars = 2048; + + private static string Truncate(string value, int maxChars) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxChars) { - throw; - } - catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested) - { - throw ErrorClassifier.AsTransient( - $"Timeout reading response from {system.Name} after {_options.DefaultHttpTimeout.TotalSeconds:0.##}s", ex); + return value; } - if (response.IsSuccessStatusCode) - { - return body; - } - - var errorBody = body; - - if (ErrorClassifier.IsTransient(response.StatusCode)) - { - throw ErrorClassifier.AsTransient( - $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}"); - } - - throw new PermanentExternalSystemException( - $"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}", - (int)response.StatusCode); + return value.Substring(0, maxChars) + $"… [truncated, {value.Length} chars total]"; } private static string BuildUrl(string baseUrl, string path, IReadOnlyDictionary? parameters, string httpMethod) { - var url = baseUrl.TrimEnd('/') + "/" + path.TrimStart('/'); + // A method that targets the base URL itself has an empty (or "/") path. + // Appending a trailing "/" in that case yields ".../api/" which some + // servers treat as a distinct resource — only append a segment when the + // method actually defines a non-empty relative path (ExternalSystemGateway-006). + var trimmedBase = baseUrl.TrimEnd('/'); + var trimmedPath = path.Trim().TrimStart('/'); + var url = string.IsNullOrEmpty(trimmedPath) + ? trimmedBase + : trimmedBase + "/" + trimmedPath; // For GET/DELETE, append parameters as query string if ((httpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) || diff --git a/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs b/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs index 0d9cd27..7f1fc94 100644 --- a/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs +++ b/tests/ScadaLink.ExternalSystemGateway.Tests/DatabaseGatewayTests.cs @@ -1,3 +1,5 @@ +using System.Data; +using System.Data.Common; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.Commons.Entities.ExternalSystems; @@ -75,4 +77,66 @@ public class DatabaseGatewayTests Assert.False(delivered); // permanent — the S&F engine parks the message } + + // ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ── + + [Fact] + public async Task GetConnection_OpenFails_DisposesConnectionBeforeRethrowing() + { + var conn = new DatabaseConnectionDefinition("testDb", "Server=localhost;Database=test") { Id = 1 }; + _repository.GetAllDatabaseConnectionsAsync().Returns(new List { conn }); + + var fake = new ThrowingDbConnection(); + var gateway = new ConnectionFactoryStubGateway(_repository, fake); + + await Assert.ThrowsAsync( + () => gateway.GetConnectionAsync("testDb")); + + Assert.True(fake.WasDisposed, "The SqlConnection was leaked — it must be disposed when OpenAsync fails"); + } + + /// Test gateway that substitutes the connection factory with a stub. + private sealed class ConnectionFactoryStubGateway : DatabaseGateway + { + private readonly DbConnection _connection; + + public ConnectionFactoryStubGateway(IExternalSystemRepository repository, DbConnection connection) + : base(repository, NullLogger.Instance) => _connection = connection; + + internal override DbConnection CreateConnection(string connectionString) => _connection; + } + + /// A DbConnection whose OpenAsync always fails, tracking whether it was disposed. + private sealed class ThrowingDbConnection : DbConnection + { + public bool WasDisposed { get; private set; } + + public override Task OpenAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("simulated open failure"); + public override void Open() => throw new InvalidOperationException("simulated open failure"); + + protected override void Dispose(bool disposing) + { + if (disposing) WasDisposed = true; + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() + { + WasDisposed = true; + return base.DisposeAsync(); + } + + // Unused abstract members. + [System.Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString { get; set; } = string.Empty; + public override string Database => string.Empty; + public override string DataSource => string.Empty; + public override string ServerVersion => string.Empty; + public override ConnectionState State => ConnectionState.Closed; + public override void ChangeDatabase(string databaseName) => throw new NotSupportedException(); + public override void Close() { } + protected override DbTransaction BeginDbTransaction(IsolationLevel il) => throw new NotSupportedException(); + protected override DbCommand CreateDbCommand() => throw new NotSupportedException(); + } } diff --git a/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index 7d8f23b..83b25c8 100644 --- a/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ScadaLink.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -328,6 +328,281 @@ public class ExternalSystemClientTests () => client.CallAsync("TestAPI", "getData", cancellationToken: cts.Token)); } + // ── ExternalSystemGateway-004: per-system retry settings honoured for cached calls ── + + [Fact] + public async Task CachedCall_TransientFailure_BuffersWithSystemRetrySettings() + { + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") + { + Id = 1, + MaxRetries = 7, + RetryDelay = TimeSpan.FromSeconds(42), + }; + var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; + _repository.GetAllExternalSystemsAsync(Arg.Any()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var dbName = $"EsgRetry_{Guid.NewGuid():N}"; + var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; + using var keepAlive = new SqliteConnection(connStr); + keepAlive.Open(); + var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + await storage.InitializeAsync(); + // S&F defaults deliberately different from the system's settings. + var sfOptions = new StoreAndForwardOptions + { + DefaultMaxRetries = 3, + DefaultRetryInterval = TimeSpan.FromMinutes(10), + RetryTimerInterval = TimeSpan.FromMinutes(10), + }; + var sf = new StoreAndForwardService(storage, sfOptions, NullLogger.Instance); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance, + storeAndForward: sf); + + var result = await client.CachedCallAsync("TestAPI", "postData"); + Assert.True(result.WasBuffered); + + var depth = await storage.GetBufferDepthByCategoryAsync(); + Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem]); + + var (maxRetries, retryIntervalMs) = ReadBufferedRetrySettings(connStr); + Assert.Equal(7, maxRetries); + Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, retryIntervalMs); + } + + private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr) + { + using var conn = new SqliteConnection(connStr); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT max_retries, retry_interval_ms FROM sf_messages"; + using var reader = cmd.ExecuteReader(); + Assert.True(reader.Read(), "expected exactly one buffered message"); + var result = (reader.GetInt32(0), reader.GetInt64(1)); + Assert.False(reader.Read(), "expected exactly one buffered message"); + return result; + } + + [Fact] + public async Task CachedCall_TransientFailure_ZeroMaxRetriesIsHonouredNotTreatedAsUnset() + { + // MaxRetries == 0 must mean "never retry", not "fall back to the S&F default". + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") + { + Id = 1, + MaxRetries = 0, + RetryDelay = TimeSpan.FromSeconds(5), + }; + var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; + _repository.GetAllExternalSystemsAsync(Arg.Any()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var httpClient = new HttpClient(new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "boom")); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var dbName = $"EsgRetryZero_{Guid.NewGuid():N}"; + var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; + using var keepAlive = new SqliteConnection(connStr); + keepAlive.Open(); + var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + await storage.InitializeAsync(); + var sfOptions = new StoreAndForwardOptions + { + DefaultMaxRetries = 99, + DefaultRetryInterval = TimeSpan.FromMinutes(10), + RetryTimerInterval = TimeSpan.FromMinutes(10), + }; + var sf = new StoreAndForwardService(storage, sfOptions, NullLogger.Instance); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance, + storeAndForward: sf); + + await client.CachedCallAsync("TestAPI", "postData"); + + var (maxRetries, _) = ReadBufferedRetrySettings(connStr); + Assert.Equal(0, maxRetries); // honoured — not the default of 99 + } + + // ── ExternalSystemGateway-005: HttpRequestMessage / HttpResponseMessage disposal ── + + [Fact] + public async Task Call_SuccessfulHttp_DisposesRequestAndResponse() + { + 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()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var handler = new DisposalTrackingHandler(HttpStatusCode.OK, "{\"ok\":true}"); + var httpClient = new HttpClient(handler); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + await client.CallAsync("TestAPI", "getData"); + + Assert.True(handler.RequestDisposed, "HttpRequestMessage was not disposed"); + Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed"); + } + + [Fact] + public async Task Call_PermanentFailure_StillDisposesRequestAndResponse() + { + 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()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var handler = new DisposalTrackingHandler(HttpStatusCode.BadRequest, "bad request"); + var httpClient = new HttpClient(handler); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + await client.CallAsync("TestAPI", "badMethod"); + + Assert.True(handler.RequestDisposed, "HttpRequestMessage was not disposed on the error path"); + Assert.True(handler.ResponseContentDisposed, "HttpResponseMessage content was not disposed on the error path"); + } + + // ── ExternalSystemGateway-006: BuildUrl — empty path must not append a trailing slash ── + + [Fact] + public async Task Call_MethodWithEmptyPath_DoesNotAppendTrailingSlash() + { + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 }; + var method = new ExternalSystemMethod("root", "GET", "") { Id = 1, ExternalSystemDefinitionId = 1 }; + _repository.GetAllExternalSystemsAsync(Arg.Any()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); + var httpClient = new HttpClient(handler); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + await client.CallAsync("TestAPI", "root"); + + Assert.Equal("https://api.example.com/api", handler.LastUri!.ToString()); + } + + [Fact] + public async Task Call_MethodWithPath_BuildsExpectedUrl() + { + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com/api", "none") { Id = 1 }; + var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 }; + _repository.GetAllExternalSystemsAsync(Arg.Any()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); + var httpClient = new HttpClient(handler); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + await client.CallAsync("TestAPI", "getData"); + + Assert.Equal("https://api.example.com/api/data", handler.LastUri!.ToString()); + } + + // ── ExternalSystemGateway-007: external error body must be truncated, not echoed verbatim ── + + [Fact] + public async Task Call_PermanentFailureWithHugeErrorBody_TruncatesErrorMessage() + { + 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()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var hugeBody = new string('X', 500_000); + var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, hugeBody); + var httpClient = new HttpClient(handler); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + var result = await client.CallAsync("TestAPI", "badMethod"); + + Assert.False(result.Success); + // The error message must be bounded — a misbehaving endpoint cannot inflate + // every script-visible error string / event-log entry. + Assert.True(result.ErrorMessage!.Length < 4096, + $"Error message was {result.ErrorMessage.Length} chars — expected it to be truncated"); + } + + // ── ExternalSystemGateway-008: cancellation of a CachedCall must not be buffered ── + + [Fact] + public async Task CachedCall_CallerCancellation_IsNotBufferedAsTransient() + { + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; + var method = new ExternalSystemMethod("postData", "POST", "/post") { Id = 1, ExternalSystemDefinitionId = 1 }; + _repository.GetAllExternalSystemsAsync(Arg.Any()) + .Returns(new List { system }); + _repository.GetMethodsByExternalSystemIdAsync(1, Arg.Any()) + .Returns(new List { method }); + + var httpClient = new HttpClient(new HangingHttpMessageHandler(TimeSpan.FromMinutes(10))); + _httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + var dbName = $"EsgCancel_{Guid.NewGuid():N}"; + var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared"; + using var keepAlive = new SqliteConnection(connStr); + keepAlive.Open(); + var storage = new StoreAndForwardStorage(connStr, NullLogger.Instance); + await storage.InitializeAsync(); + var sfOptions = new StoreAndForwardOptions + { + DefaultRetryInterval = TimeSpan.FromMinutes(10), + RetryTimerInterval = TimeSpan.FromMinutes(10), + }; + var sf = new StoreAndForwardService(storage, sfOptions, NullLogger.Instance); + + var options = new ExternalSystemGatewayOptions { DefaultHttpTimeout = TimeSpan.FromMinutes(5) }; + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance, + storeAndForward: sf, + options: Microsoft.Extensions.Options.Options.Create(options)); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + // Caller asked to abandon the work — it must NOT be buffered for retry. + await Assert.ThrowsAnyAsync( + () => client.CachedCallAsync("TestAPI", "postData", cancellationToken: cts.Token)); + + var depth = await storage.GetBufferDepthByCategoryAsync(); + Assert.False( + depth.TryGetValue(ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem, out var n) && n > 0, + "A caller-cancelled CachedCall must not be buffered for retry"); + } + /// /// Test helper: mock HTTP message handler. /// @@ -351,6 +626,72 @@ public class ExternalSystemClientTests } } + /// + /// Test helper: tracks disposal of the request and the response content. + /// + private class DisposalTrackingHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private readonly string _body; + + public DisposalTrackingHandler(HttpStatusCode statusCode, string body) + { + _statusCode = statusCode; + _body = body; + } + + public bool RequestDisposed { get; private set; } + public bool ResponseContentDisposed { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Content = new TrackingContent(string.Empty, () => RequestDisposed = true); + var response = new HttpResponseMessage(_statusCode) + { + Content = new TrackingContent(_body, () => ResponseContentDisposed = true) + }; + return Task.FromResult(response); + } + + private sealed class TrackingContent : StringContent + { + private readonly Action _onDispose; + public TrackingContent(string content, Action onDispose) : base(content) => _onDispose = onDispose; + + protected override void Dispose(bool disposing) + { + if (disposing) _onDispose(); + base.Dispose(disposing); + } + } + } + + /// + /// Test helper: captures the request URI of the last request. + /// + private class RequestCapturingHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private readonly string _body; + + public RequestCapturingHandler(HttpStatusCode statusCode, string body) + { + _statusCode = statusCode; + _body = body; + } + + public Uri? LastUri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastUri = request.RequestUri; + return Task.FromResult(new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_body) + }); + } + } + /// /// Test helper: an HTTP handler that hangs until cancelled (simulates a slow/hung system). ///