Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceMcpServerTests.cs
T
Oliver Kieselbach ef67dec263 fix(backend): system-health URLs post EU-migration + GA endpoint visibility
- Agent Binaries check + ops watcher now probe the canonical download alias
  (download.autopilotmonitor.com) AND the legacy blob keepalive; alias failure
  is unhealthy, legacy failure a warning
- McpServerBaseUrl fallback moved from the stale kindwave WEU ACA FQDN to the
  stable custom domain mcp.autopilotmonitor.com (CNAME to the new GWC instance)
- health/detailed and health/mcp include the probed endpoint URLs in check
  details for Global Admins only (storage table endpoint, WEBSITE_HOSTNAME,
  alias/legacy download URLs, MCP server URL)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:42:40 +02:00

140 lines
5.4 KiB
C#

using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Tests.GraphResolution;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Service-level tests for the MCP-server HealthCheck card. Drives
/// <see cref="HealthCheckService.CheckMcpServerAsync"/> through a stubbed HTTP handler
/// and asserts the card's status/message/details shape. Reuses the
/// <see cref="StubHttpMessageHandler"/> from the Graph-resolver suite.
/// </summary>
public class HealthCheckServiceMcpServerTests
{
[Fact]
public async Task Check_HealthyResponseWithVersion_ReportsHealthyAndSurfacesVersion()
{
var svc = BuildService(HttpStatusCode.OK, "{\"status\":\"healthy\",\"version\":\"1.4.0\"}");
var check = await svc.CheckMcpServerAsync();
Assert.Equal("MCP Server", check.Name);
Assert.Equal("healthy", check.Status);
Assert.Contains("reachable", check.Message);
Assert.Equal("1.4.0", check.Details!["Version"]);
}
[Fact]
public async Task Check_HealthyResponseWithoutVersion_ReportsHealthyWithNoDetails()
{
var svc = BuildService(HttpStatusCode.OK, "{\"status\":\"healthy\"}");
var check = await svc.CheckMcpServerAsync();
Assert.Equal("healthy", check.Status);
Assert.Null(check.Details);
}
[Fact]
public async Task Check_NonSuccessStatus_ReportsUnhealthyWithoutLeakingUrl()
{
var svc = BuildService(HttpStatusCode.ServiceUnavailable, "");
var check = await svc.CheckMcpServerAsync();
Assert.Equal("unhealthy", check.Status);
Assert.Contains("503", check.Message);
Assert.DoesNotContain("http", check.Message, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Check_WithEndpointUrl_SurfacesServerUrlAlongsideVersion()
{
var svc = BuildService(HttpStatusCode.OK, "{\"status\":\"healthy\",\"version\":\"1.4.0\"}");
var check = await svc.CheckMcpServerAsync(includeEndpointUrl: true);
// The Version detail must merge into — not replace — the Server URL detail.
Assert.Equal("https://mcp.example.test", check.Details!["Server URL"]);
Assert.Equal("1.4.0", check.Details["Version"]);
}
[Fact]
public async Task Check_WithEndpointUrl_SurfacesServerUrlEvenWhenUnhealthy()
{
var svc = BuildService(HttpStatusCode.ServiceUnavailable, "");
var check = await svc.CheckMcpServerAsync(includeEndpointUrl: true);
Assert.Equal("unhealthy", check.Status);
Assert.Equal("https://mcp.example.test", check.Details!["Server URL"]);
}
[Fact]
public async Task Check_TimeoutOrConnectionFailure_ReportsWarningForColdStart()
{
var svc = BuildServiceWithFactory(
new ThrowingHttpClientFactory(new TaskCanceledException("timeout")));
var check = await svc.CheckMcpServerAsync();
// Failure to wake the scaled-to-zero container within the budget is a warning,
// not a hard outage (a re-check may still succeed once it finishes warming).
Assert.Equal("warning", check.Status);
Assert.Contains("could not be reached", check.Message);
}
/// <summary>
/// Builds a HealthCheckService wired only with the dependencies that
/// <see cref="HealthCheckService.CheckMcpServerAsync"/> touches. The MCP base URL is
/// pinned via the <c>McpServerUrl</c> override so the stub handler matches.
/// </summary>
private static HealthCheckService BuildService(HttpStatusCode status, string body)
{
var handler = new StubHttpMessageHandler().When("/health", status, body);
return BuildServiceWithFactory(new StubHttpClientFactory(handler));
}
private static HealthCheckService BuildServiceWithFactory(System.Net.Http.IHttpClientFactory factory)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["McpServerUrl"] = "https://mcp.example.test",
})
.Build();
return new HealthCheckService(
NullLogger<HealthCheckService>.Instance,
adminConfigService: null!,
httpClientFactory: factory,
metricsReader: null!,
poisonQueueProbe: null!,
configuration: config);
}
/// <summary>HTTP handler that always throws — simulates a cold-start timeout / connection failure.</summary>
private sealed class ThrowingHttpMessageHandler : System.Net.Http.HttpMessageHandler
{
private readonly System.Exception _ex;
public ThrowingHttpMessageHandler(System.Exception ex) => _ex = ex;
protected override Task<System.Net.Http.HttpResponseMessage> SendAsync(
System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
=> throw _ex;
}
private sealed class ThrowingHttpClientFactory : System.Net.Http.IHttpClientFactory
{
private readonly System.Exception _ex;
public ThrowingHttpClientFactory(System.Exception ex) => _ex = ex;
public System.Net.Http.HttpClient CreateClient(string name)
=> new(new ThrowingHttpMessageHandler(_ex), disposeHandler: true);
}
}