File: Dashboard\DashboardServiceDataTerminalTests.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.Dashboard;
using Aspire.Hosting.Tests.Helpers;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Resource = Aspire.Hosting.ApplicationModel.Resource;
 
namespace Aspire.Hosting.Tests.Dashboard;
 
#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
 
/// <summary>
/// Guards the resource-snapshot stamping path in <see cref="DashboardServiceData"/>
/// that flows terminal availability and per-replica UDS path from the AppHost model into
/// the dashboard's wire snapshot. This is the only path that tells the dashboard a
/// resource has an attachable terminal and which local socket to dial — a regression
/// here can either hide the terminal UI entirely or, worse, leak the UDS path into the
/// non-sensitive properties surface.
/// </summary>
[Trait("Partition", "3")]
public class DashboardServiceDataTerminalTests
{
    [Fact]
    public async Task Snapshot_WithoutTerminalAnnotation_HasNoTerminalProperties()
    {
        var (data, notifications, resource) = CreateHarness(addTerminal: false);
        using var _ = data;
 
        await notifications.PublishUpdateAsync(resource, s => s with { State = new ResourceStateSnapshot("Running", null) }).DefaultTimeout();
 
        var snapshot = await data.WaitForResourceAsync(resource.Name, _ => true).DefaultTimeout();
 
        Assert.DoesNotContain(snapshot.Properties, p => p.Name.StartsWith("terminal.", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task Snapshot_WithTerminal_SingleReplica_StampsAllFourPropertiesAndMarksUdsPathSensitive()
    {
        var (data, notifications, resource) = CreateHarness(
            addTerminal: true,
            replicaCount: 1);
        using var _ = data;
 
        await notifications.PublishUpdateAsync(resource, s => s with { State = new ResourceStateSnapshot("Running", null) }).DefaultTimeout();
 
        var snapshot = await data.WaitForResourceAsync(resource.Name, r => HasTerminalProperty(r, "terminal.enabled")).DefaultTimeout();
 
        var enabled = GetTerminalProperty(snapshot, "terminal.enabled");
        var index = GetTerminalProperty(snapshot, "terminal.replicaIndex");
        var count = GetTerminalProperty(snapshot, "terminal.replicaCount");
        var path = GetTerminalProperty(snapshot, "terminal.consumerUdsPath");
 
        Assert.Equal("true", enabled.Value.StringValue);
        Assert.False(enabled.IsSensitive);
        Assert.Equal("0", index.Value.StringValue);
        Assert.False(index.IsSensitive);
        Assert.Equal("1", count.Value.StringValue);
        Assert.False(count.IsSensitive);
        // The local socket is what attach actually dials; it must be masked in the
        // dashboard's resource details panel. Regressing this would leak a host-local
        // file path through the non-sensitive properties surface.
        Assert.Equal(GetTerminalHosts(resource)[0].Layout.ConsumerUdsPath, path.Value.StringValue);
        Assert.True(path.IsSensitive);
    }
 
    [Fact]
    public async Task Snapshot_WithTerminal_MultiReplica_PicksMatchingInstanceIndex()
    {
        // Three-replica resource; the snapshot is published for instance index 2.
        // The stamped properties must point at host 2's UDS, not host 0's.
        var (data, notifications, resource) = CreateHarness(
            addTerminal: true,
            replicaCount: 3,
            dcpInstances: [
                new DcpInstance("myapp-aaa", "aaa", 0),
                new DcpInstance("myapp-bbb", "bbb", 1),
                new DcpInstance("myapp-ccc", "ccc", 2),
            ]);
        using var _ = data;
 
        await notifications.PublishUpdateAsync(resource, "myapp-ccc", s => s with { State = new ResourceStateSnapshot("Running", null) }).DefaultTimeout();
 
        var snapshot = await data.WaitForResourceAsync("myapp-ccc", r => HasTerminalProperty(r, "terminal.enabled")).DefaultTimeout();
 
        Assert.Equal("2", GetTerminalProperty(snapshot, "terminal.replicaIndex").Value.StringValue);
        Assert.Equal("3", GetTerminalProperty(snapshot, "terminal.replicaCount").Value.StringValue);
        Assert.Equal(
            GetTerminalHosts(resource)[2].Layout.ConsumerUdsPath,
            GetTerminalProperty(snapshot, "terminal.consumerUdsPath").Value.StringValue);
    }
 
    [Fact]
    public async Task Snapshot_WithTerminal_NoDcpInstancesAnnotation_FallsBackToIndexZero()
    {
        // Non-DCP resources (or pre-DCP snapshots) won't have a DcpInstancesAnnotation.
        // ResolveReplicaIndex must fall back to 0 so the snapshot still gets a usable
        // consumer UDS path rather than dropping the path entirely.
        var (data, notifications, resource) = CreateHarness(
            addTerminal: true,
            replicaCount: 2,
            dcpInstances: null);
        using var _ = data;
 
        await notifications.PublishUpdateAsync(resource, s => s with { State = new ResourceStateSnapshot("Running", null) }).DefaultTimeout();
 
        var snapshot = await data.WaitForResourceAsync(resource.Name, r => HasTerminalProperty(r, "terminal.enabled")).DefaultTimeout();
 
        Assert.Equal("0", GetTerminalProperty(snapshot, "terminal.replicaIndex").Value.StringValue);
        Assert.Equal(
            GetTerminalHosts(resource)[0].Layout.ConsumerUdsPath,
            GetTerminalProperty(snapshot, "terminal.consumerUdsPath").Value.StringValue);
    }
 
    [Fact]
    public async Task Snapshot_WithTerminal_ResourceIdNotInInstances_FallsBackToIndexZero()
    {
        // DcpInstancesAnnotation is present but the resourceId published doesn't match
        // any instance.Name — guard against this returning -1 or throwing, which would
        // either crash snapshot integration or out-of-range the host array.
        var (data, notifications, resource) = CreateHarness(
            addTerminal: true,
            replicaCount: 2,
            dcpInstances: [
                new DcpInstance("myapp-aaa", "aaa", 0),
                new DcpInstance("myapp-bbb", "bbb", 1),
            ]);
        using var _ = data;
 
        await notifications.PublishUpdateAsync(resource, "myapp-unknown", s => s with { State = new ResourceStateSnapshot("Running", null) }).DefaultTimeout();
 
        var snapshot = await data.WaitForResourceAsync("myapp-unknown", r => HasTerminalProperty(r, "terminal.enabled")).DefaultTimeout();
 
        Assert.Equal("0", GetTerminalProperty(snapshot, "terminal.replicaIndex").Value.StringValue);
        Assert.Equal(
            GetTerminalHosts(resource)[0].Layout.ConsumerUdsPath,
            GetTerminalProperty(snapshot, "terminal.consumerUdsPath").Value.StringValue);
    }
 
    private static bool HasTerminalProperty(ResourceSnapshot snapshot, string name)
        => snapshot.Properties.Any(p => string.Equals(p.Name, name, StringComparison.Ordinal));
 
    private static (string Name, Google.Protobuf.WellKnownTypes.Value Value, bool IsSensitive, string? DisplayName, bool IsHighlighted, int? SortOrder) GetTerminalProperty(ResourceSnapshot snapshot, string name)
        => snapshot.Properties.Single(p => string.Equals(p.Name, name, StringComparison.Ordinal));
 
    private static IReadOnlyList<TerminalHostResource> GetTerminalHosts(Resource resource)
        => resource.Annotations.OfType<TerminalAnnotation>().Single().TerminalHosts;
 
    private static (DashboardServiceData Data, ResourceNotificationService Notifications, TestResource Resource) CreateHarness(
        bool addTerminal,
        int replicaCount = 1,
        IReadOnlyList<DcpInstance>? dcpInstances = null)
    {
        var resource = new TestResource("myapp");
 
        if (addTerminal)
        {
            // Synthesise per-replica layouts directly rather than going through the
            // public WithTerminal() path so the test stays focused on snapshot stamping
            // and doesn't depend on the full DistributedApplication lifecycle.
            var hosts = new TerminalHostResource[replicaCount];
            var baseDir = Directory.CreateTempSubdirectory("dsdt-").FullName;
            for (var i = 0; i < replicaCount; i++)
            {
                var pseudoId = $"test{i.ToString(System.Globalization.CultureInfo.InvariantCulture).PadLeft(7, '0')}";
                var layout = new TerminalHostLayout(
                    replicaId: pseudoId,
                    parentReplicaIndex: i,
                    producerUdsPath: Path.Combine(baseDir, $"{pseudoId}.dcp.sock"),
                    consumerUdsPath: Path.Combine(baseDir, $"{pseudoId}.host.sock"),
                    controlUdsPath: Path.Combine(baseDir, $"{pseudoId}.ctrl.sock"),
                    metadataPath: Path.Combine(baseDir, $"{pseudoId}.metadata.json"));
                hosts[i] = new TerminalHostResource($"myapp-terminalhost-{i}", resource, layout);
            }
            var annotation = new TerminalAnnotation(new TerminalOptions { Columns = 132, Rows = 40 });
            annotation.Initialize(hosts);
            resource.Annotations.Add(annotation);
        }
 
        if (dcpInstances is not null)
        {
            resource.Annotations.Add(new DcpInstancesAnnotation([.. dcpInstances]));
        }
 
        var loggerService = new ResourceLoggerService();
        var notifications = new ResourceNotificationService(
            NullLogger<ResourceNotificationService>.Instance,
            new TestHostApplicationLifetime(),
            new ServiceCollection().BuildServiceProvider(),
            loggerService);
        var interactions = new InteractionService(
            NullLogger<InteractionService>.Instance,
            new DistributedApplicationOptions(),
            new ServiceCollection().BuildServiceProvider(),
            new ConfigurationBuilder().Build(),
            new TestInteractionFileUploadStore());
        var data = new DashboardServiceData(
            notifications,
            loggerService,
            NullLogger<DashboardServiceData>.Instance,
            new ResourceCommandService(notifications, loggerService, new ServiceCollection().BuildServiceProvider()),
            interactions,
            new TestInteractionFileUploadStore());
        return (data, notifications, resource);
    }
 
    private sealed class TestResource(string name) : Resource(name);
 
    private sealed class TestHostApplicationLifetime : IHostApplicationLifetime
    {
        public CancellationToken ApplicationStarted { get; } = new CancellationTokenSource().Token;
        public CancellationToken ApplicationStopped { get; } = new CancellationTokenSource().Token;
        public CancellationToken ApplicationStopping { get; } = new CancellationTokenSource().Token;
        public void StopApplication() { }
    }
}