File: ExternalServiceTests.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.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
 
namespace Aspire.Hosting.Tests;
 
public class ExternalServiceTests
{
    [Fact]
    public void AddExternalServiceWithString()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/");
 
        Assert.Equal("nuget", externalService.Resource.Name);
        Assert.Equal("https://nuget.org/", externalService.Resource.Uri?.ToString());
        Assert.Null(externalService.Resource.UrlParameter);
    }
 
    [Fact]
    public void AddExternalServiceWithUri()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var uri = new Uri("https://nuget.org/");
        var externalService = builder.AddExternalService("nuget", uri);
 
        Assert.Equal("nuget", externalService.Resource.Name);
        Assert.Equal("https://nuget.org/", externalService.Resource.Uri?.ToString());
        Assert.Null(externalService.Resource.UrlParameter);
    }
 
    [Fact]
    public void AddExternalServiceWithParameter()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var urlParam = builder.AddParameter("nuget-url");
        var externalService = builder.AddExternalService("nuget", urlParam);
 
        Assert.Equal("nuget", externalService.Resource.Name);
        Assert.Null(externalService.Resource.Uri);
        Assert.NotNull(externalService.Resource.UrlParameter);
        Assert.Equal("nuget-url", externalService.Resource.UrlParameter.Name);
    }
 
    [Theory]
    [InlineData("not-a-url")]
    [InlineData("")]
    [InlineData("https://example.com/path")]
    [InlineData("https://example.com/path?query=value")]
    [InlineData("https://example.com#fragment")]
    public void AddExternalServiceThrowsWithInvalidUrl(string invalidUrl)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var ex = Assert.Throws<ArgumentException>(() => builder.AddExternalService("nuget", invalidUrl));
        Assert.Contains("invalid", ex.Message, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public void AddExternalServiceThrowsWithRelativeUri()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var relativeUri = new Uri("/relative", UriKind.Relative);
        var ex = Assert.Throws<ArgumentException>(() => builder.AddExternalService("nuget", relativeUri));
        Assert.Contains("absolute", ex.Message, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    public void AddExternalServiceThrowsWithUriWithPath()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var uriWithPath = new Uri("https://api.example.com/api/v1");
        var ex = Assert.Throws<ArgumentException>(() => builder.AddExternalService("nuget", uriWithPath));
        Assert.Contains("absolute path must be \"/\"", ex.Message);
    }
 
    [Theory]
    [InlineData("https://nuget.org/")]
    [InlineData("http://localhost/")]
    [InlineData("https://example.com:8080/")]
    public void AddExternalServiceAcceptsValidUrls(string validUrl)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", validUrl);
 
        Assert.Equal("nuget", externalService.Resource.Name);
        Assert.Equal(validUrl, externalService.Resource.Uri?.ToString());
    }
 
    [Fact]
    public async Task ExternalServiceWithHttpsCanBeReferenced()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/");
        var project = builder.AddProject<TestProject>("project")
                             .WithReference(externalService);
 
        // Call environment variable callbacks.
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
 
        // Check that service discovery information was injected with https scheme
        Assert.Contains(config, kvp => kvp.Key == "services__nuget__https__0" && kvp.Value == "https://nuget.org/");
    }
 
    [Fact]
    public async Task ExternalServiceWithHttpCanBeReferenced()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "http://nuget.org/");
        var project = builder.AddProject<TestProject>("project")
                             .WithReference(externalService);
 
        // Call environment variable callbacks.
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
 
        // Check that service discovery information was injected with http scheme
        Assert.Contains(config, kvp => kvp.Key == "services__nuget__http__0" && kvp.Value == "http://nuget.org/");
    }
 
    [Fact]
    public async Task ExternalServiceWithParameterCanBeReferencedInRunMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:nuget-url"] = "https://nuget.org/";
 
        var urlParam = builder.AddParameter("nuget-url");
        var externalService = builder.AddExternalService("nuget", urlParam);
        var project = builder.AddProject<TestProject>("project")
                             .WithReference(externalService);
 
        // Call environment variable callbacks.
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
 
        // Check that service discovery information was injected with the correct scheme from parameter value
        Assert.Contains(config, kvp => kvp.Key == "services__nuget__https__0");
        // The value should be the URL value from the parameter
        var urlValue = config["services__nuget__https__0"];
        Assert.Equal("https://nuget.org/", urlValue);
    }
 
    [Fact]
    public async Task ExternalServiceWithParameterCanBeReferencedInPublishMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var urlParam = builder.AddParameter("nuget-url");
        var externalService = builder.AddExternalService("nuget", urlParam);
        var project = builder.AddProject<TestProject>("project")
                             .WithReference(externalService);
 
        var app = builder.Build();
 
        // Call environment variable callbacks.
        var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Publish, app.Services).DefaultTimeout();
 
        // In publish mode, scheme defaults to "default" since we can't validate the parameter value
        Assert.Contains(config, kvp => kvp.Key == "services__nuget__default__0");
        var urlValue = config["services__nuget__default__0"];
        Assert.Equal(urlParam.Resource.ValueExpression, urlValue);
    }
 
    [Fact]
    public async Task ExternalServiceWithInvalidParameterThrowsInRunMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:nuget-url"] = "invalid-url";
 
        var urlParam = builder.AddParameter("nuget-url");
        var externalService = builder.AddExternalService("nuget", urlParam);
        var project = builder.AddProject<TestProject>("project")
                             .WithReference(externalService);
 
        // Should throw when trying to evaluate environment variables with invalid parameter value
        await Assert.ThrowsAsync<DistributedApplicationException>(async () =>
        {
            await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
        });
    }
 
    [Fact]
    public void ExternalServiceWithHttpHealthCheck()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/")
                                     .WithHttpHealthCheck();
 
        // Build the app to register health checks
        using var app = builder.Build();
 
        // Verify that health check was registered
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        Assert.NotNull(healthCheckAnnotations.FirstOrDefault(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external")));
    }
 
    [Fact]
    public void ExternalServiceWithHttpHealthCheckCustomPath()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/")
                                     .WithHttpHealthCheck("/health", 200);
 
        // Build the app to register health checks
        using var app = builder.Build();
 
        // Verify that health check was registered
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        Assert.NotNull(healthCheckAnnotations.FirstOrDefault(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external")));
    }
 
    [Fact]
    public void ExternalServiceWithHttpHealthCheckInvalidPath()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/");
 
        // Should throw with invalid relative path
        Assert.Throws<ArgumentException>(() => externalService.WithHttpHealthCheck(path: "https://invalid.com/path"));
    }
 
    [Fact]
    public void ExternalServiceResourceHasExpectedInitialState()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/");
 
        // Verify the resource has the expected annotations
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<ResourceSnapshotAnnotation>(out var snapshotAnnotations));
        var snapshot = Assert.Single(snapshotAnnotations);
        Assert.Equal("ExternalService", snapshot.InitialSnapshot.ResourceType);
    }
 
    [Fact]
    public void ExternalServiceResourceImplementsExpectedInterfaces()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/");
 
        // Verify the resource implements the expected interfaces
        Assert.IsAssignableFrom<IResourceWithoutLifetime>(externalService.Resource);
    }
 
    [Fact]
    public void ExternalServiceResourceIsExcludedFromPublishingManifest()
    {
        //ManifestPublishingCallbackAnnotation
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("nuget", "https://nuget.org/");
 
        // Verify the resource has the expected annotations
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<ManifestPublishingCallbackAnnotation>(out var manifestAnnotations));
        var annotation = Assert.Single(manifestAnnotations);
        Assert.Equal(ManifestPublishingCallbackAnnotation.Ignore, annotation);
    }
 
    [Fact]
    public void ExternalServiceUrlValidationHelper()
    {
        // Test the static validation helper method
        Assert.True(ExternalServiceResource.UrlIsValidForExternalService("https://nuget.org/", out var uri, out var message));
        Assert.Equal("https://nuget.org/", uri!.ToString());
        Assert.Null(message);
 
        Assert.False(ExternalServiceResource.UrlIsValidForExternalService("invalid-url", out var invalidUri, out var invalidMessage));
        Assert.Null(invalidUri);
        Assert.NotNull(invalidMessage);
        Assert.Contains("absolute URI", invalidMessage);
 
        Assert.False(ExternalServiceResource.UrlIsValidForExternalService("https://nuget.org/path", out var pathUri, out var pathMessage));
        Assert.Null(pathUri);
        Assert.NotNull(pathMessage);
        Assert.Contains("absolute path must be \"/\"", pathMessage);
    }
 
    [Fact]
    public async Task ExternalServiceWithParameterGetValueAsyncErrorMarksAsFailedToStart()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        // Create a parameter with a broken value callback
        var urlParam = builder.AddParameter("failing-url", () => throw new InvalidOperationException("Parameter resolution failed"));
        var externalService = builder.AddExternalService("external", urlParam);
 
        using var app = builder.Build();
 
        // Start the app to trigger InitializeResourceEvent
        var appStartTask = app.StartAsync();
 
        // Wait for the resource to be marked as FailedToStart
        var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync(
            externalService.Resource.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.FailedToStart
        ).DefaultTimeout();
 
        // Verify the resource is in the correct state
        Assert.Equal(KnownResourceStates.FailedToStart, resourceEvent.Snapshot.State?.Text);
 
        await app.StopAsync();
        await appStartTask; // Ensure start completes
    }
 
    [Fact]
    public async Task ExternalServiceWithParameterInvalidUrlMarksAsFailedToStart()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        // Create a parameter that returns an invalid URL
        var urlParam = builder.AddParameter("invalid-url", () => "invalid-url-not-absolute");
        var externalService = builder.AddExternalService("external", urlParam);
 
        using var app = builder.Build();
 
        // Start the app to trigger InitializeResourceEvent
        var appStartTask = app.StartAsync();
 
        // Wait for the resource to be marked as FailedToStart
        var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync(
            externalService.Resource.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.FailedToStart
        ).DefaultTimeout();
 
        // Verify the resource is in the correct state
        Assert.Equal(KnownResourceStates.FailedToStart, resourceEvent.Snapshot.State?.Text);
 
        await app.StopAsync();
        await appStartTask; // Ensure start completes
    }
 
    [Fact]
    public async Task ExternalServiceWithValidParameterMarksAsRunning()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        // Create a parameter that returns a valid URL
        var urlParam = builder.AddParameter("valid-url", () => "https://example.com/");
        var externalService = builder.AddExternalService("external", urlParam);
 
        using var app = builder.Build();
 
        // Start the app to trigger InitializeResourceEvent
        var appStartTask = app.StartAsync();
 
        // Wait for the resource to be marked as Running
        var resourceEvent = await app.ResourceNotifications.WaitForResourceAsync(
            externalService.Resource.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.Running
        ).DefaultTimeout();
 
        // Verify the resource is in the correct state
        Assert.Equal(KnownResourceStates.Running, resourceEvent.Snapshot.State?.Text);
 
        await app.StopAsync();
        await appStartTask; // Ensure start completes
    }
 
    [Fact]
    public void ExternalServiceWithParameterHttpHealthCheckRegistersCustomHealthCheck()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var urlParam = builder.AddParameter("external-url");
        var externalService = builder.AddExternalService("external", urlParam)
                                     .WithHttpHealthCheck();
 
        // Build the app to register health checks
        using var app = builder.Build();
 
        // Verify that health check was registered
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        var healthCheckAnnotation = healthCheckAnnotations.FirstOrDefault(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external"));
        Assert.NotNull(healthCheckAnnotation);
 
        // Verify that the custom health check is registered in DI
        var healthCheckService = app.Services.GetService<HealthCheckService>();
        Assert.NotNull(healthCheckService);
    }
 
    [Fact]
    public void ExternalServiceWithStaticUrlHttpHealthCheckUsesUrlGroup()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var externalService = builder.AddExternalService("external", "https://example.com/")
                                     .WithHttpHealthCheck();
 
        // Build the app to register health checks
        using var app = builder.Build();
 
        // Verify that health check was registered
        Assert.True(externalService.Resource.TryGetAnnotationsOfType<HealthCheckAnnotation>(out var healthCheckAnnotations));
        var healthCheckAnnotation = healthCheckAnnotations.FirstOrDefault(hc => hc.Key.StartsWith($"{externalService.Resource.Name}_external"));
        Assert.NotNull(healthCheckAnnotation);
 
        // Verify that health check service is available
        var healthCheckService = app.Services.GetService<HealthCheckService>();
        Assert.NotNull(healthCheckService);
    }
 
    [Fact]
    public async Task ExternalServiceWithParameterHttpHealthCheckResolvesUrlAsync()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration["Parameters:external-url"] = "https://example.com/";
 
        var urlParam = builder.AddParameter("external-url");
        var externalService = builder.AddExternalService("external", urlParam)
                                     .WithHttpHealthCheck("/status/200");
 
        using var app = builder.Build();
 
        // Get the health check service and run health checks
        var healthCheckService = app.Services.GetRequiredService<HealthCheckService>();
 
        // Find our specific 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();
 
        // The result should be healthy since we're using httpbin.org which should be accessible
        // However, in a test environment this might fail due to network issues, so we just check that it ran
        Assert.Contains(healthCheckKey, result.Entries.Keys);
    }
 
    [Fact]
    public async Task ExternalServiceWithParameterPublishManifest()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var urlParam = builder.AddParameter("external-url");
        var externalService = builder.AddExternalService("external", urlParam);
 
        var project = builder.AddProject<TestProject>("project")
                     .WithReference(externalService)
                     .WithEnvironment("EXTERNAL_SERVICE", externalService);
 
        var manifest = await ManifestUtils.GetManifest(project.Resource);
 
        await Verify(manifest.ToString(), extension: "json");
    }
 
    private sealed class TestProject : IProjectMetadata
    {
        public string ProjectPath => "testproject";
        public LaunchSettings LaunchSettings { get; } = new();
    }
}