File: Health\FriendlyHealthCheckErrorMessagesTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
 
namespace Aspire.Hosting.Tests.Health;
 
[Trait("Partition", "6")]
public class FriendlyHealthCheckErrorMessagesTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public async Task StaticUriHealthCheck_ReturnsTimeoutMessage()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        // Add an external service with a health check to a URL that will timeout
        // Using a non-routable IP (TEST-NET-1) to simulate timeout
        var externalService = builder.AddExternalService("test", "http://192.0.2.1/")
            .WithHttpHealthCheck();
 
        using var app = builder.Build();
 
        var healthCheckService = app.Services.GetRequiredService<HealthCheckService>();
 
        // Get the health check key
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        var healthCheckKey = healthCheckAnnotations.First(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external")).Key;
 
        // Run the health check with a short timeout
        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        var result = await healthCheckService.CheckHealthAsync(
            registration => registration.Name == healthCheckKey,
            cts.Token).DefaultTimeout();
 
        // Verify we got the unhealthy result for our health check
        Assert.Contains(healthCheckKey, result.Entries.Keys);
        var entry = result.Entries[healthCheckKey];
 
        // The health check should be unhealthy
        Assert.Equal(HealthStatus.Unhealthy, entry.Status);
 
        // The description should contain a friendly message about timeout or connection failure
        Assert.NotNull(entry.Description);
        Assert.True(
            entry.Description.Contains("timed out", StringComparison.OrdinalIgnoreCase) ||
            entry.Description.Contains("Failed to connect", StringComparison.OrdinalIgnoreCase) ||
            entry.Description.Contains("was canceled", StringComparison.OrdinalIgnoreCase),
            $"Expected friendly error message, but got: {entry.Description}");
    }
 
    [Fact]
    public async Task ParameterUriHealthCheck_ReturnsTimeoutMessage()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.Configuration["Parameters:test-url"] = "http://192.0.2.1/";
 
        var urlParam = builder.AddParameter("test-url");
        var externalService = builder.AddExternalService("test", urlParam)
            .WithHttpHealthCheck();
 
        using var app = builder.Build();
 
        var healthCheckService = app.Services.GetRequiredService<HealthCheckService>();
 
        // Get the health check key
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        var healthCheckKey = healthCheckAnnotations.First(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external")).Key;
 
        // Run the health check with a short timeout
        var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        var result = await healthCheckService.CheckHealthAsync(
            registration => registration.Name == healthCheckKey,
            cts.Token).DefaultTimeout();
 
        // Verify we got the unhealthy result for our health check
        Assert.Contains(healthCheckKey, result.Entries.Keys);
        var entry = result.Entries[healthCheckKey];
 
        // The health check should be unhealthy
        Assert.Equal(HealthStatus.Unhealthy, entry.Status);
 
        // The description should contain a friendly message
        Assert.NotNull(entry.Description);
        Assert.True(
            entry.Description.Contains("timed out", StringComparison.OrdinalIgnoreCase) ||
            entry.Description.Contains("Failed to connect", StringComparison.OrdinalIgnoreCase) ||
            entry.Description.Contains("was canceled", StringComparison.OrdinalIgnoreCase),
            $"Expected friendly error message, but got: {entry.Description}");
    }
 
    [Fact]
    public async Task ParameterUriHealthCheck_InvalidUrlReturnsMessage()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.Configuration["Parameters:test-url"] = "invalid-url";
 
        var urlParam = builder.AddParameter("test-url");
        var externalService = builder.AddExternalService("test", urlParam)
            .WithHttpHealthCheck();
 
        using var app = builder.Build();
 
        var healthCheckService = app.Services.GetRequiredService<HealthCheckService>();
 
        // Get the health check key
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        var healthCheckKey = healthCheckAnnotations.First(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external")).Key;
 
        // Run the health check
        var result = await healthCheckService.CheckHealthAsync(
            registration => registration.Name == healthCheckKey,
            CancellationToken.None).DefaultTimeout();
 
        // Verify we got the unhealthy result for our health check
        Assert.Contains(healthCheckKey, result.Entries.Keys);
        var entry = result.Entries[healthCheckKey];
 
        // The health check should be unhealthy
        Assert.Equal(HealthStatus.Unhealthy, entry.Status);
 
        // The description should contain a friendly message about invalid URL
        Assert.NotNull(entry.Description);
        Assert.Contains("invalid", entry.Description, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task StaticUriHealthCheck_ReturnsStatusCodeMessage()
    {
        var webAppBuilder = WebApplication.CreateSlimBuilder();
        webAppBuilder.WebHost.UseUrls("http://127.0.0.1:0");
        await using var webApp = webAppBuilder.Build();
        webApp.MapGet("/", () => Results.NotFound());
        await webApp.StartAsync();
        var endpoint = new Uri(webApp.Urls.First() + "/");
 
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var externalService = builder.AddExternalService("test", endpoint.ToString())
            .WithHttpHealthCheck(statusCode: 200);
 
        using var app = builder.Build();
        var healthCheckService = app.Services.GetRequiredService<HealthCheckService>();
 
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        var healthCheckKey = healthCheckAnnotations.First(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external")).Key;
 
        var result = await healthCheckService.CheckHealthAsync(
            registration => registration.Name == healthCheckKey,
            CancellationToken.None).DefaultTimeout();
        Assert.Contains(healthCheckKey, result.Entries.Keys);
        var entry = result.Entries[healthCheckKey];
 
        Assert.Equal(HealthStatus.Unhealthy, entry.Status);
        Assert.Equal($"Request to {endpoint} returned 404 NotFound", entry.Description);
    }
 
    [Fact]
    public void GetFriendlyErrorMessage_StripsCredentialsFromUri()
    {
        var uri = new Uri("http://user:secret@example.com:8080/");
        var exception = new HttpRequestException();
 
        var message = HttpHealthCheckHelpers.GetFriendlyErrorMessage(uri, exception, CancellationToken.None);
 
        Assert.DoesNotContain("user", message);
        Assert.DoesNotContain("secret", message);
        Assert.Contains("example.com", message);
    }
 
    [Fact]
    public void GetFriendlyErrorMessage_PreservesUriWithoutCredentials()
    {
        var uri = new Uri("http://example.com:8080/");
        var exception = new HttpRequestException();
 
        var message = HttpHealthCheckHelpers.GetFriendlyErrorMessage(uri, exception, CancellationToken.None);
 
        Assert.Contains("http://example.com:8080/", message);
    }
 
    [Fact]
    public void GetFriendlyErrorMessage_ReturnsTimeoutForTaskCanceledException()
    {
        var uri = new Uri("http://example.com/");
        var exception = new TaskCanceledException();
 
        var message = HttpHealthCheckHelpers.GetFriendlyErrorMessage(uri, exception, CancellationToken.None);
 
        Assert.Contains("timed out", message);
    }
 
    [Fact]
    public void GetFriendlyErrorMessage_ReturnsCanceledWhenTokenIsCanceled()
    {
        var uri = new Uri("http://example.com/");
        var exception = new OperationCanceledException();
        var cts = new CancellationTokenSource();
        cts.Cancel();
 
        var message = HttpHealthCheckHelpers.GetFriendlyErrorMessage(uri, exception, cts.Token);
 
        Assert.Contains("canceled", message);
    }
 
    [Fact]
    public void GetFriendlyErrorMessage_ReturnsStatusCodeForHttpRequestException()
    {
        var uri = new Uri("http://example.com/");
        var exception = new HttpRequestException(null, null, System.Net.HttpStatusCode.NotFound);
 
        var message = HttpHealthCheckHelpers.GetFriendlyErrorMessage(uri, exception, CancellationToken.None);
 
        Assert.Contains("404", message);
        Assert.Contains("NotFound", message);
    }
}