e968566e9d
- CreateDefaultHttpClient sets AllowAutoRedirect=false so a 307/308 cannot re-POST the /auth/token credential form elsewhere. - A forced re-exchange after a 401 carries the refused token; inside the gate it reuses a token another caller already re-exchanged instead of binding again. The BasicOnly switch is written under the same gate. - SCADABRIDGE_VERBOSE=1 prints a stderr note when a fresh token is refused and the CLI falls back to Basic. - debug stream's AccessTokenProvider retries the exchange once and documents the null-token degradation (the hub refuses, automatic reconnect retries). - Tests: concurrent refusal reuses the peer refresh (2 exchanges, not 3); real-socket redirect is not followed.
309 lines
14 KiB
C#
309 lines
14 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using ZB.MOM.WW.ScadaBridge.CLI;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
|
|
|
/// <summary>
|
|
/// Bearer-token exchange in <see cref="ManagementHttpClient"/> (review 2026-10-21 05/U1, 05/P1):
|
|
/// credentials are exchanged at <c>/auth/token</c> 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.
|
|
/// </summary>
|
|
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<HttpResponseMessage>? TokenResponse { get; set; }
|
|
public Func<string?, HttpResponseMessage>? ManagementResponse { get; set; }
|
|
public Func<string?, Task>? 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<HttpResponseMessage> 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"));
|
|
|
|
/// <summary>One exchange serves every call, across client instances in the same process.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>A cached token the server now refuses is re-exchanged once and the call retried.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>A token minted for this call and still refused falls back to Basic, then stays on Basic.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>A server without /auth/token (404) is Basic for the rest of the process.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>An older server whose token response lacks management_bearer is treated as Basic-only.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Bad credentials at the exchange are reported as-is, with no second (Basic) bind.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>A transient exchange failure uses Basic for that call only and retries the exchange next time.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>The debug-stream helper returns the cached token, or a failure for bad credentials.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>The internal test constructor without a store never exchanges (Basic only).</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Two callers refused the same token: the second must reuse the token the first already
|
|
/// re-exchanged instead of binding again (review L3).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>The production HttpClient never follows redirects, so credentials cannot be replayed (review L2).</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<string>();
|
|
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
|
|
}
|
|
}
|