using System.CommandLine; using System.Net; using System.Net.Sockets; using System.Text; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; [Collection("Console")] public class DeploymentDeadlineParityTests { /// Verifies that every single-deployment CLI alias waits for a server reply beyond the ordinary 30-second deadline. /// A task that represents the asynchronous operation. [Fact] public async Task EverySingleDeploymentAlias_WaitsForServerBeyondOrdinaryDeadline() { // Run the four independent loopback requests together: one 31-second window, // exercising the real commands and socket transport past the old 30-second cap. await Task.WhenAll( InvokeDelayed(["deploy", "instance", "--id", "1"], "MgmtDeployInstance"), InvokeDelayed(["instance", "deploy", "--id", "1"], "MgmtDeployInstance"), InvokeDelayed(["deploy", "artifacts", "--site-id", "1"], "MgmtDeployArtifacts"), InvokeDelayed(["site", "deploy-artifacts", "--site-id", "1"], "MgmtDeployArtifacts")); } private static async Task InvokeDelayed(string[] args, string expectedCommand) { using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45)); var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); try { var port = ((IPEndPoint)listener.LocalEndpoint).Port; var server = Task.Run(async () => { using var client = await listener.AcceptTcpClientAsync(deadline.Token); await using var stream = client.GetStream(); using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); var length = 0; while (await reader.ReadLineAsync(deadline.Token) is { Length: > 0 } line) if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) length = int.Parse(line[15..].Trim()); var request = new char[length]; var read = 0; while (read < length) { var count = await reader.ReadAsync(request.AsMemory(read), deadline.Token); if (count == 0) throw new EndOfStreamException(); read += count; } using var payload = JsonDocument.Parse(new string(request)); Assert.Equal(expectedCommand, payload.RootElement.GetProperty("command").GetString()); await Task.Delay(TimeSpan.FromSeconds(31), deadline.Token); // A zero-failure summary: the artifact-deploy aliases derive their exit code from // failureCount, so a bare "{}" would read as an invalid summary (exit 1). const string body = "{\"successCount\":1,\"failureCount\":0}"; var reply = Encoding.ASCII.GetBytes( $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}"); try { await stream.WriteAsync(reply, deadline.Token); } catch (IOException) { /* Old client closes at 30s; exit assertion diagnoses it. */ } }, deadline.Token); var root = new RootCommand(); var url = new Option("--url") { Recursive = true }; var format = new Option("--format") { Recursive = true }; var user = new Option("--username") { Recursive = true }; var password = new Option("--password") { Recursive = true }; root.Add(url); root.Add(format); root.Add(user); root.Add(password); root.Add(DeployCommands.Build(url, format, user, password)); root.Add(InstanceCommands.Build(url, format, user, password)); root.Add(SiteCommands.Build(url, format, user, password)); var result = root.Parse([.. args, "--url", $"http://127.0.0.1:{port}", "--username", "test", "--password", "test", "--format", "json"]); Assert.Empty(result.Errors); var exit = await result.InvokeAsync().WaitAsync(deadline.Token); await server; Assert.Equal(0, exit); } finally { listener.Stop(); } } }