513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
265 lines
11 KiB
C#
265 lines
11 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using System.Net;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="HttpDeploymentConfigFetcher"/> using a stub
|
|
/// <see cref="HttpMessageHandler"/>. No live HTTP connections are made.
|
|
/// </summary>
|
|
public class HttpDeploymentConfigFetcherTests
|
|
{
|
|
private static HttpDeploymentConfigFetcher CreateFetcher(HttpMessageHandler handler)
|
|
{
|
|
var client = new HttpClient(handler);
|
|
return new HttpDeploymentConfigFetcher(client, NullLogger<HttpDeploymentConfigFetcher>.Instance);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Happy path
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Verifies that a 200 response returns the body.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_200_ReturnsBody()
|
|
{
|
|
const string expectedBody = """{"instanceUniqueName":"test"}""";
|
|
var handler = new StubHandler(HttpStatusCode.OK, expectedBody);
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
var result = await fetcher.FetchAsync("http://central", "dep-1", "tok-abc", CancellationToken.None);
|
|
|
|
Assert.Equal(expectedBody, result);
|
|
}
|
|
|
|
/// <summary>Verifies that the request URL is built correctly.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_200_RequestUrlIsCorrect()
|
|
{
|
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
await fetcher.FetchAsync("http://central", "dep-abc", "tok", CancellationToken.None);
|
|
|
|
Assert.NotNull(handler.LastRequest);
|
|
Assert.Equal(
|
|
"http://central/api/internal/deployments/dep-abc/config",
|
|
handler.LastRequest!.RequestUri!.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that the deployment token header is set on the request.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_200_SetsDeploymentTokenHeader()
|
|
{
|
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
await fetcher.FetchAsync("http://central", "dep-1", "my-secret-token", CancellationToken.None);
|
|
|
|
Assert.NotNull(handler.LastRequest);
|
|
Assert.True(handler.LastRequest!.Headers.Contains("X-Deployment-Token"));
|
|
Assert.Equal("my-secret-token", handler.LastRequest.Headers.GetValues("X-Deployment-Token").Single());
|
|
}
|
|
|
|
/// <summary>Verifies that a base URL with a trailing slash does not produce a double slash.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_BaseUrlWithTrailingSlash_NoDoubleSlashInUrl()
|
|
{
|
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
await fetcher.FetchAsync("http://central/", "dep-1", "tok", CancellationToken.None);
|
|
|
|
Assert.NotNull(handler.LastRequest);
|
|
var url = handler.LastRequest!.RequestUri!.ToString();
|
|
Assert.DoesNotContain("//api", url);
|
|
Assert.Equal("http://central/api/internal/deployments/dep-1/config", url);
|
|
}
|
|
|
|
/// <summary>Verifies that a deployment id needing escaping is escaped exactly once in the URL.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_DeploymentIdNeedingEscaping_EscapesOnceInUrl()
|
|
{
|
|
var handler = new StubHandler(HttpStatusCode.OK, "{}");
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
// A deploymentId containing a space and a slash — both require escaping.
|
|
await fetcher.FetchAsync("http://central", "dep a/b", "tok", CancellationToken.None);
|
|
|
|
Assert.NotNull(handler.LastRequest);
|
|
// Uri.OriginalString preserves the escaped form as built (no normalization/decoding).
|
|
var url = handler.LastRequest!.RequestUri!.OriginalString;
|
|
Assert.Equal("http://central/api/internal/deployments/dep%20a%2Fb/config", url);
|
|
// Not double-encoded: the percent-sign itself must not be re-escaped to %25.
|
|
Assert.DoesNotContain("%25", url);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 404 → IsSuperseded = true
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Verifies that a 404 response throws with the superseded flag set.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_404_ThrowsWithIsSupersededTrue()
|
|
{
|
|
var handler = new StubHandler(HttpStatusCode.NotFound, "Not found");
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
|
() => fetcher.FetchAsync("http://central", "dep-gone", "tok", CancellationToken.None));
|
|
|
|
Assert.True(ex.IsSuperseded);
|
|
Assert.Contains("dep-gone", ex.Message);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Other HTTP errors → IsSuperseded = false
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Verifies that a 401 response throws with the superseded flag clear.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_401_ThrowsWithIsSupersededFalse()
|
|
{
|
|
var handler = new StubHandler(HttpStatusCode.Unauthorized, "Unauthorized");
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
|
() => fetcher.FetchAsync("http://central", "dep-1", "bad-tok", CancellationToken.None));
|
|
|
|
Assert.False(ex.IsSuperseded);
|
|
Assert.Contains("401", ex.Message);
|
|
}
|
|
|
|
/// <summary>Verifies that a 500 response throws with the superseded flag clear.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_500_ThrowsWithIsSupersededFalse()
|
|
{
|
|
var handler = new StubHandler(HttpStatusCode.InternalServerError, "Server error");
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
|
|
|
|
Assert.False(ex.IsSuperseded);
|
|
Assert.Contains("500", ex.Message);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Transport failure → DeploymentConfigFetchException (not raw HttpRequestException)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Verifies that a transport exception is wrapped in a deployment config fetch exception.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_TransportException_WrapsAsDeploymentConfigFetchException()
|
|
{
|
|
var handler = new ThrowingHandler(new HttpRequestException("Connection refused"));
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
|
|
|
|
Assert.False(ex.IsSuperseded);
|
|
Assert.IsType<HttpRequestException>(ex.InnerException);
|
|
Assert.Contains("Connection refused", ex.Message);
|
|
}
|
|
|
|
/// <summary>Verifies that an HTTP client timeout is wrapped as a transport error rather than a cancellation.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_HttpClientTimeout_WrapsAsTransportError_NotCancellation()
|
|
{
|
|
// HttpClient.Timeout surfaces as TaskCanceledException while the caller's ct is NOT cancelled.
|
|
var handler = new ThrowingHandler(new TaskCanceledException("simulated HttpClient timeout"));
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", CancellationToken.None));
|
|
|
|
Assert.False(ex.IsSuperseded);
|
|
Assert.IsType<TaskCanceledException>(ex.InnerException);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Cancellation propagates as-is
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Verifies that a cancelled token propagates an operation-canceled exception.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task FetchAsync_CancelledToken_PropagatesOperationCanceledException()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var handler = new DelayHandler(); // never returns
|
|
var fetcher = CreateFetcher(handler);
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => fetcher.FetchAsync("http://central", "dep-1", "tok", cts.Token));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Stub handlers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private sealed class StubHandler : HttpMessageHandler
|
|
{
|
|
private readonly HttpStatusCode _status;
|
|
private readonly string _body;
|
|
/// <summary>The last request the handler received.</summary>
|
|
public HttpRequestMessage? LastRequest { get; private set; }
|
|
|
|
/// <summary>Creates a handler that always replies with the given status and body.</summary>
|
|
/// <param name="status">The HTTP status code to return.</param>
|
|
/// <param name="body">The response body to return.</param>
|
|
public StubHandler(HttpStatusCode status, string body)
|
|
{
|
|
_status = status;
|
|
_body = body;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
LastRequest = request;
|
|
var resp = new HttpResponseMessage(_status)
|
|
{
|
|
Content = new StringContent(_body)
|
|
};
|
|
return Task.FromResult(resp);
|
|
}
|
|
}
|
|
|
|
private sealed class ThrowingHandler : HttpMessageHandler
|
|
{
|
|
private readonly Exception _ex;
|
|
/// <summary>Creates a handler that throws the given exception.</summary>
|
|
/// <param name="ex">The exception to throw.</param>
|
|
public ThrowingHandler(Exception ex) => _ex = ex;
|
|
|
|
/// <inheritdoc />
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
=> throw _ex;
|
|
}
|
|
|
|
private sealed class DelayHandler : HttpMessageHandler
|
|
{
|
|
/// <inheritdoc />
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, ct);
|
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
|
}
|
|
}
|
|
}
|