using System.Net; using System.Text; using ZB.MOM.WW.ScadaBridge.CLI; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; /// /// Bearer-token exchange in (review 2026-10-21 05/U1, 05/P1): /// credentials are exchanged at /auth/token once per process and the token reused across /// calls and client instances; a refused token is re-exchanged once, then Basic; a server /// without bearer support falls back to Basic; bad credentials are not retried with Basic. /// public class ManagementHttpClientTokenTests { private const string BaseUrl = "http://localhost:9001"; private sealed class FakeServer : HttpMessageHandler { private int _tokenCounter; public List<(string Path, string? Auth)> Requests { get; } = []; public Func? TokenResponse { get; set; } public Func? ManagementResponse { get; set; } public Func? BeforeManagementResponse { get; set; } public HttpResponseMessage IssueToken(int expiresIn = 900) { var token = $"tok-{++_tokenCounter}"; return Json(HttpStatusCode.OK, $"{{\"access_token\":\"{token}\",\"token_type\":\"Bearer\",\"expires_in\":{expiresIn},\"management_bearer\":true}}"); } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri!.AbsolutePath; var auth = request.Headers.Authorization?.ToString(); Requests.Add((path, auth)); if (path == "/auth/token") { var form = await request.Content!.ReadAsStringAsync(cancellationToken); Assert.Contains("username=user", form); Assert.Null(auth); // credentials travel in the form body, never a header return TokenResponse?.Invoke() ?? IssueToken(); } if (BeforeManagementResponse is not null) await BeforeManagementResponse(auth); return ManagementResponse?.Invoke(auth) ?? Json(HttpStatusCode.OK, "{\"ok\":true}"); } } private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; private static ManagementHttpClient Client(FakeServer server, CliTokenStore store) => new(new HttpClient(server), BaseUrl, "user", "pass", store); private static readonly string BasicHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass")); /// One exchange serves every call, across client instances in the same process. /// A task that represents the asynchronous operation. [Fact] public async Task ExchangesOnce_AndReusesTokenAcrossCallsAndClients() { var server = new FakeServer(); var store = new CliTokenStore(); using (var first = Client(server, store)) { Assert.Equal(200, (await first.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode); Assert.Equal(200, (await first.SendGetAsync("api/audit/query", TimeSpan.FromSeconds(5))).StatusCode); } using (var second = Client(server, store)) { Assert.Equal(200, (await second.SendPostAsync("api/audit/x", null, TimeSpan.FromSeconds(5))).StatusCode); } Assert.Single(server.Requests, r => r.Path == "/auth/token"); Assert.All(server.Requests.Where(r => r.Path != "/auth/token"), r => Assert.Equal("Bearer tok-1", r.Auth)); } /// A cached token the server now refuses is re-exchanged once and the call retried. /// A task that represents the asynchronous operation. [Fact] public async Task StaleCachedToken_IsRefreshedOnce() { var server = new FakeServer(); var store = new CliTokenStore(); using var client = Client(server, store); await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); // mints tok-1 server.ManagementResponse = auth => auth == "Bearer tok-1" ? Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid or expired bearer token.\",\"code\":\"AUTH_FAILED\"}") : Json(HttpStatusCode.OK, "{\"ok\":true}"); var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(200, response.StatusCode); Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token")); Assert.Equal("Bearer tok-2", server.Requests[^1].Auth); } /// A token minted for this call and still refused falls back to Basic, then stays on Basic. /// A task that represents the asynchronous operation. [Fact] public async Task FreshTokenRefused_FallsBackToBasic_AndStaysBasic() { var server = new FakeServer { ManagementResponse = auth => auth!.StartsWith("Bearer", StringComparison.Ordinal) ? Json(HttpStatusCode.Unauthorized, "{\"error\":\"nope\"}") : Json(HttpStatusCode.OK, "{\"ok\":true}"), }; using var client = Client(server, new CliTokenStore()); Assert.Equal(200, (await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode); Assert.Equal(200, (await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode); Assert.Single(server.Requests, r => r.Path == "/auth/token"); Assert.Equal(BasicHeader, server.Requests[^1].Auth); Assert.Equal(BasicHeader, server.Requests[^2].Auth); } /// A server without /auth/token (404) is Basic for the rest of the process. /// A task that represents the asynchronous operation. [Fact] public async Task NoTokenEndpoint_FallsBackToBasic_WithoutReprobing() { var server = new FakeServer { TokenResponse = () => Json(HttpStatusCode.NotFound, "") }; using var client = Client(server, new CliTokenStore()); await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Single(server.Requests, r => r.Path == "/auth/token"); Assert.All(server.Requests.Where(r => r.Path == "/management"), r => Assert.Equal(BasicHeader, r.Auth)); } /// An older server whose token response lacks management_bearer is treated as Basic-only. /// A task that represents the asynchronous operation. [Fact] public async Task OlderServerToken_WithoutManagementBearer_UsesBasic() { var server = new FakeServer { TokenResponse = () => Json(HttpStatusCode.OK, "{\"access_token\":\"legacy\",\"token_type\":\"Bearer\"}"), }; using var client = Client(server, new CliTokenStore()); await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(BasicHeader, server.Requests.Single(r => r.Path == "/management").Auth); } /// Bad credentials at the exchange are reported as-is, with no second (Basic) bind. /// A task that represents the asynchronous operation. [Fact] public async Task BadCredentials_AreNotRetriedWithBasic() { var server = new FakeServer { TokenResponse = () => Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid username or password.\",\"code\":\"AUTH_FAILED\"}"), }; using var client = Client(server, new CliTokenStore()); var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(401, response.StatusCode); Assert.Equal("AUTH_FAILED", response.ErrorCode); Assert.DoesNotContain(server.Requests, r => r.Path == "/management"); } /// A transient exchange failure uses Basic for that call only and retries the exchange next time. /// A task that represents the asynchronous operation. [Fact] public async Task TransientExchangeFailure_UsesBasicForThatCallOnly() { var server = new FakeServer(); server.TokenResponse = () => Json(HttpStatusCode.ServiceUnavailable, ""); using var client = Client(server, new CliTokenStore()); await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(BasicHeader, server.Requests[^1].Auth); server.TokenResponse = null; // exchange recovers await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token")); Assert.Equal("Bearer tok-1", server.Requests[^1].Auth); } /// The debug-stream helper returns the cached token, or a failure for bad credentials. /// A task that represents the asynchronous operation. [Fact] public async Task TryGetBearerToken_ReturnsTokenOrFailure() { var ok = new FakeServer(); using (var client = Client(ok, new CliTokenStore())) { var result = await client.TryGetBearerTokenAsync(CancellationToken.None); Assert.Equal("tok-1", result.Token); Assert.Null(result.Failure); } var bad = new FakeServer { TokenResponse = () => Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid username or password.\",\"code\":\"AUTH_FAILED\"}"), }; using (var client = Client(bad, new CliTokenStore())) { var result = await client.TryGetBearerTokenAsync(CancellationToken.None); Assert.Null(result.Token); Assert.Equal(401, result.Failure!.StatusCode); } } /// The internal test constructor without a store never exchanges (Basic only). /// A task that represents the asynchronous operation. [Fact] public async Task NoStore_SendsBasicOnly() { var server = new FakeServer(); using var client = new ManagementHttpClient(new HttpClient(server), BaseUrl, "user", "pass"); await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(new[] { ("/management", (string?)BasicHeader) }, server.Requests); } /// /// Two callers refused the same token: the second must reuse the token the first already /// re-exchanged instead of binding again (review L3). /// /// A task that represents the asynchronous operation. [Fact] public async Task ConcurrentRefusal_ReusesPeerRefresh_InsteadOfSecondExchange() { var server = new FakeServer(); var store = new CliTokenStore(); using var a = Client(server, store); using var b = Client(server, store); await a.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); // tok-1 cached server.ManagementResponse = auth => auth == "Bearer tok-1" ? Json(HttpStatusCode.Unauthorized, "{\"error\":\"expired\"}") : Json(HttpStatusCode.OK, "{\"ok\":true}"); var nested = false; server.BeforeManagementResponse = async auth => { // While B's tok-1 request is in flight, A hits the same refusal and refreshes. if (auth == "Bearer tok-1" && !nested) { nested = true; Assert.Equal(200, (await a.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode); } }; var response = await b.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); Assert.Equal(200, response.StatusCode); Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token")); Assert.Equal("Bearer tok-2", server.Requests[^1].Auth); } /// The production HttpClient never follows redirects, so credentials cannot be replayed (review L2). /// A task that represents the asynchronous operation. [Fact] public async Task TokenExchange_DoesNotFollowRedirects() { var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; var requestLines = new List(); var serve = Task.Run(async () => { for (var i = 0; i < 2; i++) { using var tcp = await listener.AcceptTcpClientAsync(); await using var stream = tcp.GetStream(); var buffer = new byte[8192]; var read = await stream.ReadAsync(buffer); requestLines.Add(Encoding.ASCII.GetString(buffer, 0, read).Split("\r\n")[0]); var reply = i == 0 ? $"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:{port}/stolen\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" : "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"; await stream.WriteAsync(Encoding.ASCII.GetBytes(reply)); } }); try { using var client = new ManagementHttpClient( ManagementHttpClient.CreateDefaultHttpClient(), $"http://127.0.0.1:{port}", "user", "pass", new CliTokenStore()); await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); } finally { await serve.WaitAsync(TimeSpan.FromSeconds(5)); listener.Stop(); } Assert.StartsWith("POST /auth/token", requestLines[0]); Assert.DoesNotContain(requestLines, l => l.Contains("/stolen", StringComparison.Ordinal)); Assert.StartsWith("POST /management", requestLines[1]); // 307 is a non-success → Basic for that call } }