using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Text; using ZB.MOM.WW.ScadaBridge.CLI; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// /// Regression tests for CLI-013 — /// (success, error-body parsing, connection-failure, and timeout paths) was untested. /// Uses a stub so no live server is required. /// public class ManagementHttpClientTests { private sealed class StubHandler : HttpMessageHandler { private readonly Func> _responder; public StubHandler(HttpStatusCode status, string body) : this((_, _) => Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json"), })) { } public StubHandler(Func> responder) { _responder = responder; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => _responder(request, cancellationToken); } private static ManagementHttpClient ClientWith(StubHandler handler) => new(new HttpClient(handler), "http://localhost:9001", "user", "pass"); [Fact] public async Task SendCommandAsync_Success_ReturnsJsonData() { using var client = ClientWith(new StubHandler(HttpStatusCode.OK, "{\"id\":1}")); var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(200, response.StatusCode); Assert.Equal("{\"id\":1}", response.JsonData); Assert.Null(response.Error); Assert.Null(response.ErrorCode); } [Fact] public async Task SendCommandAsync_ErrorBody_ParsesErrorAndCode() { using var client = ClientWith(new StubHandler( HttpStatusCode.BadRequest, "{\"error\":\"Bad input\",\"code\":\"INVALID_ARGUMENT\"}")); var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(400, response.StatusCode); Assert.Null(response.JsonData); Assert.Equal("Bad input", response.Error); Assert.Equal("INVALID_ARGUMENT", response.ErrorCode); } [Fact] public async Task SendCommandAsync_NonJsonErrorBody_FallsBackToRawBody() { using var client = ClientWith(new StubHandler( HttpStatusCode.BadGateway, "Bad Gateway")); var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(502, response.StatusCode); Assert.Equal("Bad Gateway", response.Error); Assert.Null(response.ErrorCode); } [Fact] public async Task SendCommandAsync_ConnectionFailure_ReturnsStatusZero() { using var client = ClientWith(new StubHandler((_, _) => throw new HttpRequestException("connection refused"))); var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(0, response.StatusCode); Assert.Equal("CONNECTION_FAILED", response.ErrorCode); Assert.Contains("connection refused", response.Error); } [Fact] public async Task SendCommandAsync_Timeout_Returns504() { using var client = ClientWith(new StubHandler(async (_, ct) => { await Task.Delay(Timeout.Infinite, ct); return new HttpResponseMessage(HttpStatusCode.OK); })); var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromMilliseconds(50)); Assert.Equal(504, response.StatusCode); Assert.Equal("TIMEOUT", response.ErrorCode); } } /// /// The public constructor must leave /// INFINITE so the per-call /// is the single overall deadline — a fixed /// client timeout silently truncated every caller with a longer per-call timeout /// (deploy site's 5-minute bulk deploy, the 5-minute bundle calls), /// which then printed a fake 504 while the server kept working. The connect phase /// is bounded separately on , honoring /// the SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS override — consistent with how /// every other CLI setting is environment-overridable (). Runs /// in the shared "Environment" collection (see ) so it /// never races another test mutating process-wide environment variables. /// [Collection("Environment")] public class ManagementHttpClientTimeoutTests { private const string EnvVar = "SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS"; [Fact] public void DefaultConstructor_LeavesClientTimeoutInfinite() { using var client = new ManagementHttpClient("http://localhost:9001", "user", "pass"); Assert.Equal(Timeout.InfiniteTimeSpan, client.EffectiveTimeout); } [Fact] public void ConnectTimeout_DefaultsToThirtySeconds_WhenEnvVarUnset() { var original = Environment.GetEnvironmentVariable(EnvVar); try { Environment.SetEnvironmentVariable(EnvVar, null); Assert.Equal(TimeSpan.FromSeconds(30), ManagementHttpClient.DefaultConnectTimeout); Assert.Equal(TimeSpan.FromSeconds(30), ManagementHttpClient.ResolveConnectTimeout()); } finally { Environment.SetEnvironmentVariable(EnvVar, original); } } [Theory] [InlineData("0")] [InlineData("-5")] [InlineData("not-a-number")] [InlineData("")] public void InvalidOrNonPositiveEnvValue_FallsBackToDefaultConnectTimeout(string value) { var original = Environment.GetEnvironmentVariable(EnvVar); try { Environment.SetEnvironmentVariable(EnvVar, value); Assert.Equal(TimeSpan.FromSeconds(30), ManagementHttpClient.ResolveConnectTimeout()); } finally { Environment.SetEnvironmentVariable(EnvVar, original); } } [Fact] public void PositiveEnvValue_OverridesDefaultConnectTimeout() { var original = Environment.GetEnvironmentVariable(EnvVar); try { Environment.SetEnvironmentVariable(EnvVar, "5"); Assert.Equal(TimeSpan.FromSeconds(5), ManagementHttpClient.ResolveConnectTimeout()); } finally { Environment.SetEnvironmentVariable(EnvVar, original); } } /// /// The regression that matters: a per-call timeout LONGER than the old 30 s /// client cap must actually be honored. Two calls against the same hanging /// local listener — one with a short deadline, one with a longer one — must /// time out in that order and at their own deadlines, which is only possible /// if is not silently capping both. Uses a /// real socket (not the stub handler) so the connect + send path is exercised /// end to end, and sub-second deadlines so the test stays fast. /// [Fact] public async Task PerCallTimeoutLongerThanTheOldClientCap_IsHonored() { // A listener that accepts connections and then never answers: every // request hangs until the caller's own deadline fires. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; var accepted = new List(); var acceptLoop = Task.Run(async () => { try { while (true) accepted.Add(await listener.AcceptTcpClientAsync()); } catch (ObjectDisposedException) { /* listener stopped — expected */ } catch (SocketException) { /* listener stopped — expected */ } }); try { using var client = new ManagementHttpClient($"http://127.0.0.1:{port}", "user", "pass"); var shortSw = Stopwatch.StartNew(); var shortResponse = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromMilliseconds(300)); shortSw.Stop(); var longSw = Stopwatch.StartNew(); var longResponse = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromMilliseconds(1500)); longSw.Stop(); Assert.Equal("TIMEOUT", shortResponse.ErrorCode); Assert.Equal("TIMEOUT", longResponse.ErrorCode); // The longer deadline must genuinely outlast the shorter one rather // than both being clipped to a single client-wide cap. Assert.True( longSw.Elapsed > TimeSpan.FromMilliseconds(1000), $"1.5 s per-call timeout returned after only {longSw.ElapsedMilliseconds} ms — the client cap truncated it."); Assert.True( shortSw.Elapsed < TimeSpan.FromMilliseconds(1000), $"300 ms per-call timeout took {shortSw.ElapsedMilliseconds} ms."); } finally { listener.Stop(); foreach (var c in accepted) c.Dispose(); await acceptLoop; } } }