File: Backchannel\AuxiliaryBackchannelRpcTargetTests.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.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Backchannel;
 
public class AuxiliaryBackchannelRpcTargetTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task GetResourceSnapshotsAsync_ReturnsEmptyList_WhenAppModelIsNull()
    {
        var services = new ServiceCollection();
        services.AddSingleton(ResourceNotificationServiceTestHelpers.Create());
        var serviceProvider = services.BuildServiceProvider();
 
        var target = new AuxiliaryBackchannelRpcTarget(
            NullLogger<AuxiliaryBackchannelRpcTarget>.Instance,
            serviceProvider);
 
        var result = await target.GetResourceSnapshotsAsync();
 
        Assert.Empty(result);
    }
 
    [Fact]
    public async Task GetResourceSnapshotsAsync_EnumeratesResources()
    {
        using var builder = TestDistributedApplicationBuilder.Create(outputHelper);
 
        builder.AddParameter("myparam");
        builder.AddResource(new CustomResource(KnownResourceNames.AspireDashboard));
 
        var resourceWithReplicas = builder.AddResource(new CustomResource("myresource"));
        resourceWithReplicas.WithAnnotation(new DcpInstancesAnnotation([
            new DcpInstance("myresource-abc123", "abc123", 0),
            new DcpInstance("myresource-def456", "def456", 1)
        ]));
 
        using var app = builder.Build();
        await app.StartAsync();
 
        var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();
        await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-abc123", s => s with
        {
            State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success)
        });
        await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-def456", s => s with
        {
            State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success)
        });
 
        var target = new AuxiliaryBackchannelRpcTarget(
            NullLogger<AuxiliaryBackchannelRpcTarget>.Instance,
            app.Services);
 
        var result = await target.GetResourceSnapshotsAsync();
 
        // Dashboard resource should be skipped
        Assert.DoesNotContain(result, r => r.Name == KnownResourceNames.AspireDashboard);
 
        // Parameter resource (no replicas) should be returned with matching Name/DisplayName
        var paramSnapshot = Assert.Single(result, r => r.Name == "myparam");
        Assert.Equal("myparam", paramSnapshot.DisplayName);
        Assert.Equal("Parameter", paramSnapshot.ResourceType);
 
        // Resource with DcpInstancesAnnotation should return multiple instances
        Assert.Contains(result, r => r.Name == "myresource-abc123");
        Assert.Contains(result, r => r.Name == "myresource-def456");
        Assert.All(result.Where(r => r.Name.StartsWith("myresource-")), r => Assert.Equal("myresource", r.DisplayName));
 
        await app.StopAsync();
    }
 
    [Fact]
    public async Task GetResourceSnapshotsAsync_MapsSnapshotData()
    {
        using var builder = TestDistributedApplicationBuilder.Create(outputHelper);
 
        var custom = builder.AddResource(new CustomResource("myresource"));
 
        using var app = builder.Build();
        await app.StartAsync();
 
        var createdAt = DateTime.UtcNow.AddMinutes(-5);
        var startedAt = DateTime.UtcNow.AddMinutes(-4);
 
        var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();
        await notificationService.PublishUpdateAsync(custom.Resource, s => s with
        {
            State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success),
            CreationTimeStamp = createdAt,
            StartTimeStamp = startedAt,
            Urls = [
                new UrlSnapshot("http", "http://localhost:5000", false) { DisplayProperties = new UrlDisplayPropertiesSnapshot("HTTP Endpoint", 1) },
                new UrlSnapshot("https", "https://localhost:5001", true) { DisplayProperties = new UrlDisplayPropertiesSnapshot("HTTPS Endpoint", 2) },
                new UrlSnapshot("inactive", "http://localhost:5002", false) { IsInactive = true }
            ],
            Relationships = [
                new RelationshipSnapshot("dependency1", "Reference"),
                new RelationshipSnapshot("dependency2", "WaitFor")
            ],
            HealthReports = [
                new HealthReportSnapshot("check1", Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy, "All good", null),
                new HealthReportSnapshot("check2", Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy, "Failed", "Exception occurred")
            ],
            Volumes = [
                new VolumeSnapshot("/host/path", "/container/path", "bind", false),
                new VolumeSnapshot("myvolume", "/data", "volume", true)
            ],
            EnvironmentVariables = [
                new EnvironmentVariableSnapshot("MY_VAR", "my-value", false),
                new EnvironmentVariableSnapshot("ANOTHER_VAR", "another-value", true)
            ],
            Commands = [
                new ResourceCommandSnapshot("resource-start", ResourceCommandState.Enabled, "Start", "Start the resource", null, null, null, null, false),
                new ResourceCommandSnapshot("resource-stop", ResourceCommandState.Disabled, "Stop", "Stop the resource", null, null, null, null, false),
                new ResourceCommandSnapshot("resource-restart", ResourceCommandState.Hidden, "Restart", null, null, null, null, null, true)
            ],
            Properties = [
                new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, "normal-value"),
                new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true }
            ]
        });
 
        var target = new AuxiliaryBackchannelRpcTarget(
            NullLogger<AuxiliaryBackchannelRpcTarget>.Instance,
            app.Services);
 
        var result = await target.GetResourceSnapshotsAsync();
 
        var snapshot = Assert.Single(result);
 
        // State
        Assert.Equal("Running", snapshot.State);
        Assert.Equal(KnownResourceStateStyles.Success, snapshot.StateStyle);
 
        // Timestamps
        Assert.Equal(createdAt, snapshot.CreatedAt);
        Assert.Equal(startedAt, snapshot.StartedAt);
 
        // URLs (inactive URLs should be excluded)
        Assert.Equal(2, snapshot.Urls.Length);
        Assert.Contains(snapshot.Urls, u => u.Name == "http" && u.Url == "http://localhost:5000" && !u.IsInternal);
        Assert.Contains(snapshot.Urls, u => u.Name == "https" && u.Url == "https://localhost:5001" && u.IsInternal);
        Assert.DoesNotContain(snapshot.Urls, u => u.Name == "inactive");
 
        // URL display properties
        var httpUrl = snapshot.Urls.Single(u => u.Name == "http");
        Assert.NotNull(httpUrl.DisplayProperties);
        Assert.Equal("HTTP Endpoint", httpUrl.DisplayProperties.DisplayName);
        Assert.Equal(1, httpUrl.DisplayProperties.SortOrder);
 
        var httpsUrl = snapshot.Urls.Single(u => u.Name == "https");
        Assert.NotNull(httpsUrl.DisplayProperties);
        Assert.Equal("HTTPS Endpoint", httpsUrl.DisplayProperties.DisplayName);
        Assert.Equal(2, httpsUrl.DisplayProperties.SortOrder);
 
        // Relationships
        Assert.Equal(2, snapshot.Relationships.Length);
        Assert.Contains(snapshot.Relationships, r => r.ResourceName == "dependency1" && r.Type == "Reference");
        Assert.Contains(snapshot.Relationships, r => r.ResourceName == "dependency2" && r.Type == "WaitFor");
 
        // Health reports
        Assert.Equal(2, snapshot.HealthReports.Length);
        Assert.Contains(snapshot.HealthReports, h => h.Name == "check1" && h.Status == "Healthy");
        Assert.Contains(snapshot.HealthReports, h => h.Name == "check2" && h.Status == "Unhealthy" && h.ExceptionText == "Exception occurred");
 
        // Volumes
        Assert.Equal(2, snapshot.Volumes.Length);
        Assert.Contains(snapshot.Volumes, v => v.Source == "/host/path" && v.Target == "/container/path" && !v.IsReadOnly);
        Assert.Contains(snapshot.Volumes, v => v.Source == "myvolume" && v.Target == "/data" && v.IsReadOnly);
 
        // Environment variables
        Assert.Equal(2, snapshot.EnvironmentVariables.Length);
        Assert.Contains(snapshot.EnvironmentVariables, e => e.Name == "MY_VAR" && e.Value == "my-value" && !e.IsFromSpec);
        Assert.Contains(snapshot.EnvironmentVariables, e => e.Name == "ANOTHER_VAR" && e.Value == "another-value" && e.IsFromSpec);
 
        // Commands
        Assert.Equal(3, snapshot.Commands.Length);
        Assert.Contains(snapshot.Commands, c => c.Name == "resource-start" && c.DisplayName == "Start" && c.Description == "Start the resource" && c.State == "Enabled");
        Assert.Contains(snapshot.Commands, c => c.Name == "resource-stop" && c.DisplayName == "Stop" && c.Description == "Stop the resource" && c.State == "Disabled");
        Assert.Contains(snapshot.Commands, c => c.Name == "resource-restart" && c.DisplayName == "Restart" && c.Description == null && c.State == "Hidden");
 
        // Properties (sensitive values should be redacted)
        Assert.True(snapshot.Properties.TryGetValue(CustomResourceKnownProperties.Source, out var normalValue));
        Assert.Equal("normal-value", normalValue);
        Assert.True(snapshot.Properties.TryGetValue("ConnectionString", out var sensitiveValue));
        Assert.Null(sensitiveValue);
 
        await app.StopAsync();
    }
 
    private sealed class CustomResource(string name) : Resource(name)
    {
    }
}