diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 421fa1b1a8b..1bee7bc83a5 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -529,7 +529,10 @@ public DashboardWebApplication( private void PrintSummary(ResolvedEndpointInfo? frontendEndpointInfo) { var options = _app.Services.GetRequiredService>().CurrentValue; - var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + var suppressBrowserToken = _app.Configuration.GetBool(KnownConfigNames.DashboardSuppressBrowserTokenInOutput) ?? false; + var token = !suppressBrowserToken && options.Frontend.AuthMode == FrontendAuthMode.BrowserToken + ? options.Frontend.BrowserToken + : null; var frontendAddress = frontendEndpointInfo?.GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); diff --git a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj index 132cfa53dbc..49a983d2f81 100644 --- a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj +++ b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 739f7af180b..ec794134ac1 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -12,6 +13,129 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationHostingTestingExtensions { + private const string DashboardResourceName = "aspire-dashboard"; + + /// + /// Gets the URL for the running Aspire dashboard. + /// + /// The distributed application. + /// A token used to cancel the operation. + /// + /// An absolute that can be opened in a browser. The URL authenticates the browser when + /// authentication is enabled and otherwise points directly to the dashboard. + /// + /// + /// + /// Enable dashboard support with when creating + /// the testing builder. + /// + /// + /// This method does not start the distributed application. Call + /// before requesting the dashboard URL. + /// + /// + /// This method waits for the dashboard resource to become healthy. Pass a cancellation token when the wait must + /// be bounded. When authentication is enabled, the returned URI contains an authentication credential and should + /// be treated as sensitive. + /// + /// + /// Thrown when is . + /// + /// Thrown when the application is in publish mode, the dashboard is disabled, the application has not started, + /// or a dashboard URL is unavailable. + /// + /// Thrown when the dashboard reaches a terminal failure state. + /// Thrown when is canceled. + /// Thrown when the distributed application has been disposed. + /// + /// + /// var options = new DistributedApplicationTestingBuilderOptions + /// { + /// EnableDashboard = true + /// }; + /// + /// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []); + /// await using var app = await builder.BuildAsync(); + /// await app.StartAsync(); + /// + /// var dashboardUrl = await app.GetDashboardUrlAsync(); + /// + /// + [AspireExportIgnore(Reason = "Use the exported getDashboardUrl overload without a cancellation token.")] + public static async Task GetDashboardUrlAsync( + this DistributedApplication app, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(app); + + var executionContext = app.Services.GetRequiredService(); + if (executionContext.IsPublishMode) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlPublishModeExceptionMessage); + } + + var applicationOptions = app.Services.GetRequiredService(); + if (applicationOptions.DisableDashboard) + { + throw new InvalidOperationException(Properties.Resources.DashboardDisabledExceptionMessage); + } + + ThrowIfNotStarted(app, Properties.Resources.DashboardUrlApplicationNotStartedExceptionMessage); + cancellationToken.ThrowIfCancellationRequested(); + + await app.ResourceNotifications.WaitForResourceHealthyAsync( + DashboardResourceName, + WaitBehavior.StopOnResourceUnavailable, + cancellationToken).ConfigureAwait(false); + + var applicationModel = app.Services.GetRequiredService(); + if (!applicationModel.Resources.TryGetByName(DashboardResourceName, out var resource) || + resource is not IResourceWithEndpoints dashboardResource) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + var httpsEndpoint = dashboardResource.GetEndpoint("https"); + var httpEndpoint = dashboardResource.GetEndpoint("http"); + var dashboardEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; + if (!dashboardEndpoint.Exists) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + var dashboardUrl = await EndpointHostHelpers.GetUrlWithTargetHostAsync(dashboardEndpoint, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(dashboardUrl)) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + var browserToken = app.Services.GetRequiredService()["AppHost:BrowserToken"]; + if (!string.IsNullOrEmpty(browserToken)) + { + dashboardUrl = $"{dashboardUrl.TrimEnd('/')}/login?t={Uri.EscapeDataString(browserToken)}"; + } + + if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + + return dashboardUri; + } + + /// + /// Gets the URL for the running Aspire dashboard. + /// + /// + /// An absolute that can be opened in a browser. When authentication is enabled, the URI + /// contains an authentication credential and should be treated as sensitive. + /// + [AspireExport("getDashboardUrl")] + internal static Task GetDashboardUrlAsyncExport(this DistributedApplication app) + { + return app.GetDashboardUrlAsync(default); + } + /// /// Creates an configured to communicate with the specified resource. /// @@ -127,7 +251,7 @@ internal static Uri GetEndpointForNetworkExport(this DistributedApplication app, static IResource GetResource(DistributedApplication app, string resourceName) { - ThrowIfNotStarted(app); + ThrowIfNotStarted(app, Properties.Resources.ApplicationNotStartedExceptionMessage); var applicationModel = app.Services.GetRequiredService(); if (!applicationModel.Resources.TryGetByName(resourceName, out var resource)) @@ -166,12 +290,12 @@ static string GetEndpointUriStringCore(DistributedApplication app, string resour return endpoint.Url; } - static void ThrowIfNotStarted(DistributedApplication app) + static void ThrowIfNotStarted(DistributedApplication app, string exceptionMessage) { var lifetime = app.Services.GetRequiredService(); if (!lifetime.ApplicationStarted.IsCancellationRequested) { - throw new InvalidOperationException(Properties.Resources.ApplicationNotStartedExceptionMessage); + throw new InvalidOperationException(exceptionMessage); } } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 21ac4d57fab..d98508f1ac0 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -22,6 +22,7 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationTestingBuilder { + /// /// Creates a new instance of . /// @@ -37,6 +38,42 @@ public static Task CreateAsync CreateAsync(typeof(TEntryPoint), cancellationToken); + /// + /// Creates a new instance of using the specified testing options. + /// + /// + /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. + /// + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to pass to the entry point. + /// The . + /// + /// A new instance of . + /// + /// + /// Thrown when or is , or when + /// contains a value. + /// + /// Thrown when contains an empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as CreateAsync<TEntryPoint>(default) + /// continue to bind to the existing cancellation-token overload. + /// + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] + public static Task CreateAsync( + DistributedApplicationTestingBuilderOptions options, + string[] args, + CancellationToken cancellationToken = default) + where TEntryPoint : class + { + ArgumentNullException.ThrowIfNull(options); + + return CreateAsync(typeof(TEntryPoint), options, args, cancellationToken); + } + /// /// Creates a new instance of . /// @@ -49,6 +86,40 @@ public static Task CreateAsync CreateAsync(Type entryPoint, CancellationToken cancellationToken = default) => CreateAsync(entryPoint, [], cancellationToken); + /// + /// Creates a new instance of using the specified testing options. + /// + /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to pass to the entry point. + /// The . + /// + /// A new instance of . + /// + /// + /// Thrown when , , or is + /// , or when contains a value. + /// + /// Thrown when contains an empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as CreateAsync(entryPoint, default) + /// continue to bind to the existing cancellation-token overload. + /// + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] + public static Task CreateAsync( + Type entryPoint, + DistributedApplicationTestingBuilderOptions options, + string[] args, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + return CreateAsyncCore(entryPoint, args, options, (_, __) => { }, cancellationToken); + } + /// /// Creates a new instance of . /// @@ -106,13 +177,29 @@ public static Task CreateAsync [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] public static async Task CreateAsync(Type entryPoint, string[] args, Action configureBuilder, CancellationToken cancellationToken = default) + => await CreateAsyncCore(entryPoint, args, testingOptions: null, configureBuilder, cancellationToken).ConfigureAwait(false); + + private static async Task CreateAsyncCore( + Type entryPoint, + string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, + Action configureBuilder, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entryPoint); ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder, nameof(configureBuilder)); - var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, configureBuilder); - return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, testingOptions, configureBuilder); + try + { + return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await factory.DisposeAsync().ConfigureAwait(false); + throw; + } } /// @@ -125,6 +212,35 @@ public static async Task CreateAsync(Type public static IDistributedApplicationTestingBuilder Create(params string[] args) => Create(args, (_, __) => { }); + /// + /// Creates a new instance of using the specified testing options. + /// + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to use when building the distributed application. + /// + /// A new instance of . + /// + /// + /// Thrown when or is , or when + /// contains a value. + /// + /// Thrown when contains an empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as Create(default) continue to bind to + /// the existing command-line-arguments overload. + /// + public static IDistributedApplicationTestingBuilder Create( + DistributedApplicationTestingBuilderOptions options, + string[] args) + { + ArgumentNullException.ThrowIfNull(options); + + return CreateCore(args, options, (_, __) => { }); + } + /// /// Creates a new instance of . /// @@ -134,11 +250,18 @@ public static IDistributedApplicationTestingBuilder Create(params string[] args) /// A new instance of . /// public static IDistributedApplicationTestingBuilder Create(string[] args, Action configureBuilder) + => CreateCore(args, testingOptions: null, configureBuilder); + + private static IDistributedApplicationTestingBuilder CreateCore( + string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, + Action configureBuilder, + Assembly? appHostAssembly = null) { ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder); - return new TestingBuilder(args, configureBuilder); + return new TestingBuilder(args, testingOptions, configureBuilder, appHostAssembly); } /// @@ -154,11 +277,162 @@ internal static IDistributedApplicationTestingBuilder Create( string[] args, Action configureBuilder, Assembly appHostAssembly) + => CreateCore(args, testingOptions: null, configureBuilder, appHostAssembly); + + private static bool IsDashboardTestingEnabled(DistributedApplicationTestingBuilderOptions? testingOptions) { - ThrowIfNullOrContainsIsNullOrEmpty(args); - ArgumentNullException.ThrowIfNull(configureBuilder); + // Only the explicit option turns on the hardened testing defaults below. Setting + // DistributedApplicationOptions.DisableDashboard = false through the configureBuilder callback is the older, + // already-shipped spelling of "run a dashboard", and it has to keep the behavior it shipped with: callers use + // it to exercise dashboard behavior against configuration they chose themselves (fixed URLs, anonymous + // access, an ambient browser token), and in publish mode it is simply ignored because no dashboard resource + // is ever added. Treating it as equivalent to the option silently rewrote that configuration and turned + // publish-mode callers into an InvalidOperationException. + return testingOptions?.EnableDashboard == true; + } + + private static void ConfigureDashboardTesting( + DistributedApplicationOptions applicationOptions, + HostApplicationBuilderSettings hostBuilderOptions, + DistributedApplicationTestingBuilderOptions? testingOptions, + out DashboardTestingState dashboardTestingState) + { + if (!IsDashboardTestingEnabled(testingOptions)) + { + dashboardTestingState = default; + return; + } + + var browserToken = TokenGenerator.GenerateToken(); + var resourceServiceApiKey = TokenGenerator.GenerateToken(); + + dashboardTestingState = new DashboardTestingState( + Enabled: true, + BrowserToken: browserToken, + ResourceServiceApiKey: resourceServiceApiKey); + + applicationOptions.DisableDashboard = false; + + // Command-line configuration has higher precedence than the configuration sources supplied through + // HostApplicationBuilderSettings. Append this after the AppHost callback so creation-time arguments and + // configuration cannot select anonymous dashboard authentication before testing defaults are reapplied. + // + // The browser token rides along on the same mechanism, and deliberately not on the in-memory collection + // below, for two reasons. DistributedApplicationBuilder resolves the token during construction and freezes + // it into AppHost:BrowserToken, so it has to be visible before the builder is created and there has to be + // exactly one value. And turning off anonymous access only closes the door if a credential actually exists: + // without this, an ambient ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN on a CI agent would share a single known + // token across every test application running there. + // + // The resource service API key is seeded the same way and for the same second reason. The builder generates + // one when configuration does not supply it, but an ambient ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY would + // otherwise be adopted, giving every test application on that agent the same resource-service credential. + // + // DistributedApplicationFactory assigns the same array instance to both properties before the AppHost + // callback runs, and that callback is free to replace either one. Read whichever instance it left in place, + // and both when they diverged, so caller arguments are appended to rather than silently dropped. + string[] existingArgs = ReferenceEquals(hostBuilderOptions.Args, applicationOptions.Args) + ? [.. hostBuilderOptions.Args ?? []] + : [.. hostBuilderOptions.Args ?? [], .. applicationOptions.Args ?? []]; + + hostBuilderOptions.Args = + [ + .. existingArgs, + $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false", + $"{KnownConfigNames.DashboardFrontendBrowserToken}={browserToken}", + $"{KnownConfigNames.DashboardResourceServiceClientApiKey}={resourceServiceApiKey}" + ]; + applicationOptions.Args = hostBuilderOptions.Args; + + hostBuilderOptions.Configuration ??= new(); + AddDashboardTestingConfiguration(hostBuilderOptions.Configuration); + } + + private static void ConfigureDashboardTesting(IDistributedApplicationBuilder builder, DashboardTestingState dashboardTestingState) + { + if (!dashboardTestingState.Enabled) + { + return; + } + + if (builder.ExecutionContext.IsPublishMode) + { + throw new InvalidOperationException(Properties.Resources.DashboardTestingPublishModeExceptionMessage); + } + + // Apply these after the builder has loaded environment variables and command-line arguments so test + // automation cannot accidentally opt back into fixed ports, anonymous access, or interactivity. + // Callers can still override runtime settings through the returned builder; constructor-time service + // selection, including dashboard authentication, has already completed. + AddDashboardTestingConfiguration(builder.Configuration); + + // Restore the generated browser token if something cleared it. DistributedApplicationBuilder freezes the + // token into AppHost:BrowserToken during construction, but DashboardOptions does not read that key until the + // application starts, so AppHost code running between those two points can blank it. DashboardEventHandlers + // treats a null or empty token as a request for Unsecured frontend authentication, which would silently + // downgrade the authenticated default this opt-in promises, so the guard mirrors that same emptiness check + // and leaves any non-empty token, including a deliberately chosen one, alone. This runs after the AppHost + // entry point has finished configuring, because the builder is not handed back until it reaches Build(), and + // before the caller sees the builder, so a test that wants the anonymous dashboard can still choose it + // through the returned builder. + if (string.IsNullOrEmpty(builder.Configuration["AppHost:BrowserToken"])) + { + builder.Configuration["AppHost:BrowserToken"] = dashboardTestingState.BrowserToken; + } - return new TestingBuilder(args, configureBuilder, appHostAssembly); + // Restore resource-service authentication for the same reason and through the same window. The builder + // freezes AppHost:ResourceService:AuthMode and :ApiKey during construction, but DashboardServiceHost does + // not bind that section into ResourceServiceOptions until the application starts, so AppHost code running in + // between can downgrade either one. ResourceServiceApiKeyAuthenticationHandler only checks the API key + // header when AuthMode is ApiKey and otherwise authenticates every request, and ValidateResourceServiceOptions + // likewise stops requiring a key once the mode is Unsecured, so clearing either value alone is enough to + // expose the resource model on the loopback resource-service endpoint. Both halves are restored together + // because either one on its own leaves the endpoint unauthenticated. Unlike the browser token there is no + // "deliberately chosen" value to preserve: a caller who supplied their own key still ends up in ApiKey mode + // with that key, so this only rewrites configuration that would have been unauthenticated. + if (!string.Equals(builder.Configuration["AppHost:ResourceService:AuthMode"], "ApiKey", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrEmpty(builder.Configuration["AppHost:ResourceService:ApiKey"])) + { + builder.Configuration["AppHost:ResourceService:AuthMode"] = "ApiKey"; + builder.Configuration["AppHost:ResourceService:ApiKey"] = dashboardTestingState.ResourceServiceApiKey; + } + + // Enabling the dashboard makes the hosting default wait indefinitely when a dependency becomes unavailable, + // which would hang a test run instead of failing it. A later user registration can still override this. + builder.Services.Configure( + options => options.DefaultWaitBehavior = WaitBehavior.StopOnResourceUnavailable); + } + + private static void AddDashboardTestingConfiguration(IConfigurationBuilder configuration) + { + configuration.AddInMemoryCollection(new Dictionary + { + ["DcpPublisher:RandomizePorts"] = "true", + + // Empty means "not configured", which is how the product asks for a dynamically assigned port: + // ConfigureDefaultDashboardOptions normalizes blank to null, and DashboardEventHandlers then creates + // the endpoint with port: null. Writing an explicit "http://127.0.0.1:0" instead would parse to the + // fixed port 0 and only behave dynamically while DcpPublisher:RandomizePorts stays true, which a test + // is free to turn off. These endpoints still bind to loopback because EndpointAnnotation.TargetHost + // defaults to localhost. + [KnownAspNetCoreConfigNames.Urls] = string.Empty, + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = string.Empty, + [KnownConfigNames.DashboardOtlpHttpEndpointUrl] = string.Empty, + + // The resource service reads its port directly and has first-class handling for port 0. + [KnownConfigNames.ResourceServiceEndpointUrl] = "http://127.0.0.1:0", + + [KnownConfigNames.AllowUnsecuredTransport] = "true", + [KnownConfigNames.DashboardUnsecuredAllowAnonymous] = "false", + [KnownConfigNames.InteractivityEnabled] = "false", + + // The AppHost and child dashboard each write a startup summary. Under the testing host, both flow into + // test and CI output, so withhold the live browser credential from each while keeping their endpoint + // lines. The ASPIRE_DASHBOARD-prefixed value is copied to the child process by DashboardEventHandlers. + // Tests get the credential through GetDashboardUrlAsync instead. + ["AppHost:SuppressDashboardLoginUrlInStartupSummary"] = "true", + [KnownConfigNames.DashboardSuppressBrowserTokenInOutput] = "true" + }); } private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) @@ -178,14 +452,29 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) } } - private sealed class SuspendingDistributedApplicationFactory(Type entryPoint, string[] args, Action configureBuilder) + /// + /// The dashboard testing configuration resolved during builder construction. Carried as a single value so the + /// pre-construction and post-construction halves of the configuration cannot drift apart. + /// + private readonly record struct DashboardTestingState(bool Enabled, string? BrowserToken, string? ResourceServiceApiKey); + + private sealed class SuspendingDistributedApplicationFactory( + Type entryPoint, + string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, + Action configureBuilder) : DistributedApplicationFactory(entryPoint, args) { private readonly SemaphoreSlim _continueBuilding = new(0); + // Resolved while the builder is being constructed, because dashboard services and dashboard authentication + // are selected during construction and the post-construction half of the configuration has to agree with it. + private DashboardTestingState _dashboardTestingState; + public async Task CreateBuilderAsync(CancellationToken cancellationToken) { var innerBuilder = await ResolveBuilderAsync(cancellationToken).ConfigureAwait(false); + ConfigureDashboardTesting(innerBuilder, _dashboardTestingState); return new Builder(this, innerBuilder); } @@ -193,11 +482,7 @@ protected override void OnBuilderCreating(DistributedApplicationOptions applicat { base.OnBuilderCreating(applicationOptions, hostOptions); configureBuilder(applicationOptions, hostOptions); - } - - protected override void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder) - { - base.OnBuilderCreated(applicationBuilder); + ConfigureDashboardTesting(applicationOptions, hostOptions, testingOptions, out _dashboardTestingState); } protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder) @@ -323,18 +608,21 @@ public async Task StopAsync(CancellationToken cancellationToken) private sealed class TestingBuilder( string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, Assembly? appHostAssembly = null) : IDistributedApplicationTestingBuilder { - private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, configureBuilder, appHostAssembly); + private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, testingOptions, configureBuilder, appHostAssembly); private DistributedApplication? _app; private static DistributedApplicationBuilder CreateInnerBuilder( string[] args, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, Assembly? appHostAssembly = null) { + var dashboardTestingState = default(DashboardTestingState); var builder = TestingBuilderFactory.CreateBuilder(args, onConstructing: (applicationOptions, hostBuilderOptions) => { Assembly appAssembly; @@ -347,9 +635,15 @@ private static DistributedApplicationBuilder CreateInnerBuilder( appAssembly = FindApplicationAssembly(); } - DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, configureBuilder); + DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, (options, settings) => + { + configureBuilder(options, settings); + ConfigureDashboardTesting(options, settings, testingOptions, out dashboardTestingState); + }); }); + ConfigureDashboardTesting(builder, dashboardTestingState); + if (!builder.Configuration.GetValue(KnownConfigNames.TestingDisableHttpClient, false)) { builder.Services.AddHttpClient(); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs new file mode 100644 index 00000000000..49cc6cc7999 --- /dev/null +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -0,0 +1,63 @@ +// 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.ApplicationModel; + +namespace Aspire.Hosting.Testing; + +/// +/// Options that control how an is created. +/// +/// +/// These options are applied while the underlying distributed application builder is being constructed, because the +/// dashboard services themselves are selected during construction and cannot be added afterwards. The settings those +/// services read - port allocation, interactivity, and the generated browser token - remain +/// adjustable through the returned builder until the application is built. +/// +/// +/// The following example creates a testing builder that runs the dashboard: +/// +/// var options = new DistributedApplicationTestingBuilderOptions +/// { +/// EnableDashboard = true +/// }; +/// +/// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []); +/// await using var app = await builder.BuildAsync(); +/// await app.StartAsync(); +/// +/// // Open this in a browser to inspect the running application. +/// var dashboardUrl = await app.GetDashboardUrlAsync(); +/// +/// +public sealed class DistributedApplicationTestingBuilderOptions +{ + /// + /// Gets or sets a value indicating whether the Aspire dashboard runs alongside the application under test. + /// + /// + /// to run the dashboard; otherwise, . + /// The default is . + /// + /// + /// + /// When the dashboard runs, it listens on loopback endpoints using dynamically assigned ports, so concurrent + /// test applications do not compete for a fixed port. Each application generates its own browser token instead + /// of reusing an ambient dashboard credential. Dashboard instances in the same browser still share the dashboard + /// authentication cookie, so use separate browser contexts when tests require isolation between dashboards. Use + /// to obtain a browser-ready + /// URL for the running dashboard. + /// + /// + /// Enabling the dashboard sets to + /// after AppHost configuration. To use a different default + /// for WaitFor, configure through the returned builder + /// before building the application. + /// + /// + /// The dashboard process remains reachable when a debugger pauses the test process, but resource state changes, + /// logs, and commands flow through the paused process and do not update until execution resumes. + /// + /// + public bool EnableDashboard { get; set; } +} diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs index 43c44729bc4..77556d413fb 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs +++ b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs @@ -69,6 +69,51 @@ internal static string ApplicationNotStartedExceptionMessage { } } + /// + /// Looks up a localized string similar to The dashboard is not enabled for this application.. + /// + internal static string DashboardDisabledExceptionMessage { + get { + return ResourceManager.GetString("DashboardDisabledExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The application must be started before retrieving the dashboard URL.. + /// + internal static string DashboardUrlApplicationNotStartedExceptionMessage { + get { + return ResourceManager.GetString("DashboardUrlApplicationNotStartedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The dashboard URL is not available in publish mode.. + /// + internal static string DashboardUrlPublishModeExceptionMessage { + get { + return ResourceManager.GetString("DashboardUrlPublishModeExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The dashboard URL is not available.. + /// + internal static string DashboardUrlUnavailableExceptionMessage { + get { + return ResourceManager.GetString("DashboardUrlUnavailableExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard testing is not supported in publish mode.. + /// + internal static string DashboardTestingPublishModeExceptionMessage { + get { + return ResourceManager.GetString("DashboardTestingPublishModeExceptionMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Endpoint '{0}' for resource '{1}' not found.. /// diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.resx b/src/Aspire.Hosting.Testing/Properties/Resources.resx index ec59974c7d9..cc5dcc3d91e 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.resx +++ b/src/Aspire.Hosting.Testing/Properties/Resources.resx @@ -120,6 +120,21 @@ The application must be started before resolving endpoints or connection strings. + + The dashboard is not enabled for this application. + + + The application must be started before retrieving the dashboard URL. + + + The dashboard URL is not available in publish mode. + + + The dashboard URL is not available. + + + Dashboard testing is not supported in publish mode. + Endpoint '{0}' for resource '{1}' not found. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf index 61efe72e431..7bc577a4364 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf @@ -7,6 +7,31 @@ Aplikace musí být spuštěna před překladem koncových bodů nebo připojovacích řetězců. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Koncový bod {0} pro prostředek {1} se nenašel. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf index e2c744e2440..86d7f8093d8 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf @@ -7,6 +7,31 @@ Die Anwendung muss gestartet werden, bevor Endpunkte oder Verbindungszeichenfolgen aufgelöst werden können. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Der Endpunkt "{0}" für die Ressource "{1}" wurde nicht gefunden. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf index 1804c647edf..063c8087209 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf @@ -7,6 +7,31 @@ La aplicación debe iniciarse antes de resolver los puntos de conexión o las cadenas de conexión. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. No se encuentra el de punto de conexión '{0}' para el recurso '{1}'. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf index 313b33dc41c..888554c6c5c 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf @@ -7,6 +7,31 @@ L’application doit être démarrée avant de résoudre les points de terminaison ou les chaînes de connexion. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Le point de terminaison « {0} » de la ressource « {1} » est introuvable. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf index e71c7e91baa..9a04bc110fe 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf @@ -7,6 +7,31 @@ È necessario avviare l'applicazione prima di risolvere endpoint o stringhe di connessione. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. L'endpoint '{0}' per la risorsa '{1}' non è stato trovato. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf index 6b22c3d35d7..9823cb7f2ba 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf @@ -7,6 +7,31 @@ エンドポイントまたは接続文字列を解決する前に、アプリケーションを起動する必要があります。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. リソース '{1}' のエンドポイント '{0}' が見つかりません。 diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf index d4b32f6bf43..fa3e4495858 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf @@ -7,6 +7,31 @@ 엔드포인트 또는 연결 문자열을 확인하려면 먼저 애플리케이션을 시작해야 합니다. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. '{1}' 리소스에 대한 '{0}' 엔드포인트를 찾을 수 없습니다. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf index 4af037877ef..5580e892519 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf @@ -7,6 +7,31 @@ Aplikacja musi zostać uruchomiona przed rozpoznaniem punktów końcowych lub parametrów połączenia. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Nie znaleziono punktu końcowego „{0}” dla zasobu „{1}”. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf index 4bae1d1e624..d51ea9f9546 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf @@ -7,6 +7,31 @@ O aplicativo deve ser iniciado antes de resolver pontos de extremidade ou cadeias de conexão. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. O ponto de extremidade “{0}” para o recurso “{1}” não foi encontrado. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf index 9df64bff376..0dda672f576 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf @@ -7,6 +7,31 @@ Перед разрешением конечных точек или строк подключения необходимо запустить приложение. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. Конечная точка "{0}" для ресурса "{1}" не найдена. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf index 3e26aac0c56..88c297690f7 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf @@ -7,6 +7,31 @@ Uç noktalarını veya bağlantı dizelerini çözümlemeden önce uygulama başlatmalıdır. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. '{1}' kaynağı için '{0}' uç noktası bulunamadı. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf index 162d75096bb..2929dbe5bcd 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf @@ -7,6 +7,31 @@ 必须先启动应用程序,然后才能解析终结点或连接字符串。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. 找不到资源“{1}”的终结点“{0}”。 diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf index bac7f8875b3..646e0225a6d 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf @@ -7,6 +7,31 @@ 必須先啟動應用程式,才能解析端點或連接字串。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. + + + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. + + + + The dashboard URL is not available. + The dashboard URL is not available. + + Endpoint '{0}' for resource '{1}' not found. 找不到資源 '{1}' 的端點 '{0}'。 diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs index 9a955056b39..1c6c50316d9 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs @@ -6,7 +6,8 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Provides helper methods for validating localhost addresses. +/// Provides helper methods for validating localhost addresses and for resolving endpoint URLs +/// against a configured target host. /// public static class EndpointHostHelpers { @@ -120,8 +121,11 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) /// The endpoint reference. /// A cancellation token. /// The URL with the appropriate hostname. - internal static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) + /// Thrown when is null. + public static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(endpoint); + var allocatedUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(allocatedUrl)) @@ -132,8 +136,13 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) // If the configured TargetHost is a localhost TLD (e.g., aspire-dashboard.dev.localhost), // we need to use that instead of the allocated address (localhost) since the TLD hostname // is what the user expects to see and use in the browser. + // + // Only do this when the resolved address is itself loopback. The same endpoint resolves to a + // routable address (e.g. container1.dev.internal) when the reference carries a non-local + // ContextNetworkID, and a *.localhost name always resolves to the caller's own loopback, so + // substituting there would point a container at itself. var targetHost = endpoint.EndpointAnnotation.TargetHost; - if (IsLocalhostTld(targetHost) && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var uri)) + if (IsLocalhostTld(targetHost) && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var uri) && uri.IsLoopback) { return $"{uri.Scheme}://{targetHost}:{uri.Port}"; } diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 27c7395f501..57549d0ddae 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -542,7 +542,13 @@ private async Task DashboardStarted(ResourceReadyEvent @event, CancellationToken () => GetEndpointUrlAsync(dashboardResource, KnownEndpointNames.OtlpHttpEndpointName, cancellationToken), options.OtlpHttpEndpointUrl).ConfigureAwait(false); - LoggingHelpers.WriteDashboardSummary(distributedApplicationLogger, dashboardUrl, otlpGrpcUrl, otlpHttpUrl, browserToken, isContainer: false); + // Withholding the token drops the login URL from the summary and the separate "Login to the dashboard at" + // line, leaving the dashboard and OTLP endpoints. Testing sets this because its token is a live + // credential for a dashboard the test already has a supported accessor for, and the AppHost logger in + // that mode is test and CI output. + var summaryToken = options.SuppressLoginUrlInStartupSummary ? null : browserToken; + + LoggingHelpers.WriteDashboardSummary(distributedApplicationLogger, dashboardUrl, otlpGrpcUrl, otlpHttpUrl, summaryToken, isContainer: false); } private async ValueTask ResolveUrlAsync(Func> resolveCallback, string? configuredUrl) diff --git a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs index 5972cd59936..0473d0fdd19 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs @@ -18,6 +18,17 @@ internal class DashboardOptions public string? ApiKey { get; set; } public string AspNetCoreEnvironment { get; set; } = "Production"; public bool? TelemetryOptOut { get; set; } + + /// + /// Suppresses the login URL, and only the login URL, from the AppHost's dashboard startup summary. + /// + /// + /// Set by Aspire.Hosting.Testing when it runs a dashboard for a test application. That dashboard's + /// browser token is generated per application and handed to the test through + /// GetDashboardUrlAsync, so writing it to the AppHost logger only publishes a live credential + /// into test and CI output. The dashboard and OTLP endpoint lines are still written. + /// + public bool SuppressLoginUrlInStartupSummary { get; set; } } internal class ConfigureDefaultDashboardOptions(IConfiguration configuration, IOptions dcpOptions) : IConfigureOptions @@ -36,6 +47,8 @@ public void Configure(DashboardOptions options) options.AspNetCoreEnvironment = configuration[KnownAspNetCoreConfigNames.Environment] ?? "Production"; + options.SuppressLoginUrlInStartupSummary = bool.TryParse(configuration["AppHost:SuppressDashboardLoginUrlInStartupSummary"], out var suppressLoginUrl) && suppressLoginUrl; + options.TelemetryOptOut = bool.TryParse(configuration["ASPIRE_DASHBOARD_TELEMETRY_OPTOUT"], out var telemetryOptOut) ? telemetryOptOut : null; diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 4838d69ded2..a853aa915e9 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -10,6 +10,7 @@ internal static class KnownConfigNames public const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; public const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; public const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; + public const string DashboardSuppressBrowserTokenInOutput = "ASPIRE_DASHBOARD_SUPPRESS_BROWSER_TOKEN_IN_OUTPUT"; public const string DashboardResourceServiceClientApiKey = "ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY"; public const string DashboardUnsecuredAllowAnonymous = "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; public const string DashboardCorsAllowedOrigins = "ASPIRE_DASHBOARD_CORS_ALLOWED_ORIGINS"; diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj index 3a71b518c12..f63cad516b5 100644 --- a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -6,6 +6,10 @@ enable false + true + true + true + false false @@ -36,10 +40,17 @@ + + + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs new file mode 100644 index 00000000000..a1a4a8284df --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREINTERACTION001 + +using Aspire.Hosting.Dashboard; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; +using TestingResources = Aspire.Hosting.Testing.Properties.Resources; + +namespace Aspire.Hosting.Testing.Tests; + +public class DashboardTestingBuilderTests +{ + private const string AspNetCoreUrls = "ASPNETCORE_URLS"; + private const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; + private const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; + private const string DashboardUnsecuredAllowAnonymous = "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; + private const string InteractivityEnabled = "ASPIRE_INTERACTIVITY_ENABLED"; + private const string ResourceServiceEndpointUrl = "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"; + private const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; + private const string AppHostBrowserToken = "AppHost:BrowserToken"; + private const string DashboardResourceServiceApiKey = "ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY"; + private const string AppHostResourceServiceAuthMode = "AppHost:ResourceService:AuthMode"; + private const string AppHostResourceServiceApiKey = "AppHost:ResourceService:ApiKey"; + + [Fact] + public void DashboardIsDisabledByDefault() + { + var options = new DistributedApplicationTestingBuilderOptions(); + + Assert.False(options.EnableDashboard); + + using var builder = DistributedApplicationTestingBuilder.Create(); + Assert.Null(builder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardCanBeEnabledAtBuilderCreation(CreationSurface creationSurface) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, []); + + Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + AssertDashboardTestingDefaults(builder); + + await using var app = await builder.BuildAsync(); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingDefaultsOverrideCreationConfiguration(CreationSurface creationSurface) + { + string[] args = + [ + "--DcpPublisher:RandomizePorts=false", + $"--{AspNetCoreUrls}=http://127.0.0.1:12345", + $"--{DashboardOtlpGrpcEndpointUrl}=http://127.0.0.1:12346", + $"--{DashboardOtlpHttpEndpointUrl}=http://127.0.0.1:12347", + $"--{ResourceServiceEndpointUrl}=http://127.0.0.1:12348", + $"--{DashboardUnsecuredAllowAnonymous}=true", + $"--{InteractivityEnabled}=true" + ]; + + await using var builder = await CreateDashboardBuilderAsync(creationSurface, args); + + AssertDashboardTestingDefaults(builder); + Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration[AppHostResourceServiceAuthMode]); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingGeneratesAFreshBrowserTokenPerApplication(CreationSurface creationSurface) + { + const string SharedToken = "shared-browser-token"; + string[] args = [$"--{DashboardFrontendBrowserToken}={SharedToken}"]; + + await using var first = await CreateDashboardBuilderAsync(creationSurface, args); + await using var second = await CreateDashboardBuilderAsync(creationSurface, args); + + var firstToken = first.Configuration[AppHostBrowserToken]; + var secondToken = second.Configuration[AppHostBrowserToken]; + + Assert.NotEmpty(firstToken!); + Assert.NotEmpty(secondToken!); + Assert.NotEqual(SharedToken, firstToken); + Assert.NotEqual(SharedToken, secondToken); + Assert.NotEqual(firstToken, secondToken); + } + + [Fact] + public async Task DashboardTestingDefaultsAreNonInteractiveAndFailFast() + { + var builder = DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); + await using var app = await builder.BuildAsync(); + + Assert.False(app.Services.GetRequiredService().IsAvailable); + Assert.Equal( + WaitBehavior.StopOnResourceUnavailable, + app.Services.GetRequiredService>().Value.DefaultWaitBehavior); + } + + [Fact] + public async Task DashboardTestingOptionsCannotBeNull() + { + await Assert.ThrowsAsync( + () => DistributedApplicationTestingBuilder.CreateAsync(null!, [])); + await Assert.ThrowsAsync( + () => DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), null!, [])); + Assert.Throws(() => DistributedApplicationTestingBuilder.Create(null!, [])); + } + + [Fact] + public async Task ExistingDefaultCallsRemainUnambiguous() + { + Assert.Throws(() => DistributedApplicationTestingBuilder.Create(default!)); + + await using var genericBuilder = + await DistributedApplicationTestingBuilder.CreateAsync(default); + await using var typeBuilder = + await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), default); + + Assert.Null(genericBuilder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + Assert.Null(typeBuilder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + + await using var genericOptionsBuilder = + await DistributedApplicationTestingBuilder.CreateAsync( + CreateDashboardOptions(), + [], + default); + await using var typeOptionsBuilder = + await DistributedApplicationTestingBuilder.CreateAsync( + typeof(Projects.TestingAppHost1_AppHost), + CreateDashboardOptions(), + [], + default); + + Assert.Single(genericOptionsBuilder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + Assert.Single(typeOptionsBuilder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingIsRejectedInPublishMode(CreationSurface creationSurface) + { + var exception = await Assert.ThrowsAsync( + () => CreatePublishBuilderAsync(creationSurface)); + + Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingPublishModeFailureStopsAppHostEntryPoint(CreationSurface creationSurface) + { + using var probe = TestingAppHostEntryPointProbe.Create(); + + var exception = await Assert.ThrowsAsync( + () => CreatePublishBuilderAsync(creationSurface, $"--entry-point-exit-probe={probe.Id}")); + + Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); + await probe.Exited.WaitAsync(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task DashboardEnabledThroughConfigureBuilderKeepsCallerConfiguration() + { + await using var builder = await DistributedApplicationTestingBuilder.CreateAsync( + [$"--{DashboardUnsecuredAllowAnonymous}=true", $"--{AspNetCoreUrls}=http://127.0.0.1:12345"], + (options, _) => options.DisableDashboard = false); + + Assert.Equal("true", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("http://127.0.0.1:12345", builder.Configuration[AspNetCoreUrls]); + } + + [Theory] + [InlineData(CreationSurface.Generic, "--clear-apphost-browser-token")] + [InlineData(CreationSurface.Generic, "--null-apphost-browser-token")] + [InlineData(CreationSurface.Type, "--clear-apphost-browser-token")] + [InlineData(CreationSurface.Type, "--null-apphost-browser-token")] + public async Task DashboardTestingRestoresTheBrowserTokenAnAppHostCleared( + CreationSurface creationSurface, + string appHostFlag) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); + await using var app = await builder.BuildAsync(); + + var dashboardOptions = app.Services.GetRequiredService>().Value; + Assert.False(string.IsNullOrEmpty(dashboardOptions.DashboardToken)); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingLeavesTheBrowserTokenToTheCallersBuilder(CreationSurface creationSurface) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, []); + builder.Configuration[AppHostBrowserToken] = ""; + await using var app = await builder.BuildAsync(); + + var dashboardOptions = app.Services.GetRequiredService>().Value; + Assert.True(string.IsNullOrEmpty(dashboardOptions.DashboardToken)); + } + + [Theory] + [InlineData(CreationSurface.Generic, "--unsecure-apphost-resource-service")] + [InlineData(CreationSurface.Generic, "--clear-apphost-resource-service-key")] + [InlineData(CreationSurface.Type, "--unsecure-apphost-resource-service")] + [InlineData(CreationSurface.Type, "--clear-apphost-resource-service-key")] + public async Task DashboardTestingRestoresResourceServiceAuthenticationAnAppHostDowngraded( + CreationSurface creationSurface, + string appHostFlag) + { + await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); + + Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration[AppHostResourceServiceAuthMode]); + Assert.False(string.IsNullOrEmpty(builder.Configuration[AppHostResourceServiceApiKey])); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingGeneratesAFreshResourceServiceApiKeyPerApplication(CreationSurface creationSurface) + { + const string SharedKey = "shared-resource-service-key"; + string[] args = [$"--{DashboardResourceServiceApiKey}={SharedKey}"]; + + await using var first = await CreateDashboardBuilderAsync(creationSurface, args); + await using var second = await CreateDashboardBuilderAsync(creationSurface, args); + + var firstKey = first.Configuration[AppHostResourceServiceApiKey]; + var secondKey = second.Configuration[AppHostResourceServiceApiKey]; + + Assert.False(string.IsNullOrEmpty(firstKey)); + Assert.False(string.IsNullOrEmpty(secondKey)); + Assert.NotEqual(SharedKey, firstKey); + Assert.NotEqual(SharedKey, secondKey); + Assert.NotEqual(firstKey, secondKey); + } + + private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() + { + return new() + { + EnableDashboard = true + }; + } + + private static void AssertDashboardTestingDefaults(IDistributedApplicationTestingBuilder builder) + { + Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); + Assert.Equal(string.Empty, builder.Configuration[AspNetCoreUrls]); + Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpGrpcEndpointUrl]); + Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpHttpEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); + Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + + var browserToken = builder.Configuration[DashboardFrontendBrowserToken]; + Assert.NotEmpty(browserToken!); + Assert.Equal(browserToken, builder.Configuration[AppHostBrowserToken]); + } + + private static async Task CreateDashboardBuilderAsync( + CreationSurface creationSurface, + string[] args) + { + var options = CreateDashboardOptions(); + + return creationSurface switch + { + CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync(options, args), + CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), options, args), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), + _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) + }; + } + + private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface, params string[] additionalArgs) + { + var options = CreateDashboardOptions(); + string[] args = [.. additionalArgs, "--publisher", "manifest"]; + + var builder = creationSurface switch + { + CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync( + options, + args), + CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync( + typeof(Projects.TestingAppHost1_AppHost), + options, + args), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), + _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) + }; + + await builder.DisposeAsync(); + } + + public enum CreationSurface + { + Generic, + Type, + AdHoc + } +} diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs new file mode 100644 index 00000000000..4793a87831b --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs @@ -0,0 +1,375 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Aspire.TestUtilities; +using Aspire.Templates.Tests; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Playwright; +using Xunit; +using TestingResources = Aspire.Hosting.Testing.Properties.Resources; + +namespace Aspire.Hosting.Testing.Tests; + +public class DashboardUrlTests +{ + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncAuthenticatesDashboardBrowser() + { + await using var builder = await CreateDashboardBuilderAsync(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.True(dashboardUri.IsAbsoluteUri); + Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); + Assert.True(dashboardUri.IsLoopback); + Assert.InRange(dashboardUri.Port, 1, 65535); + Assert.Equal("/login", dashboardUri.AbsolutePath); + Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); + Assert.True(dashboardUri.Query.Length > 3); + + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var loginResponse = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode); + Assert.Equal("/", loginResponse.Headers.Location?.OriginalString); + Assert.Single( + loginResponse.Headers.GetValues("Set-Cookie"), + cookie => cookie.StartsWith(".Aspire.Dashboard.Auth", StringComparison.Ordinal)); + + using var protectedResponse = await httpClient.GetAsync( + new Uri(dashboardUri.GetLeftPart(UriPartial.Authority) + "/structuredlogs"), + cancellationTokenSource.Token); + Assert.Equal(HttpStatusCode.OK, protectedResponse.StatusCode); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncUsesConfiguredLocalhostTld() + { + const string DashboardHost = "dashboard.testing.localhost"; + + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["ASPNETCORE_URLS"] = $"http://{DashboardHost}:0"; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.Equal(DashboardHost, dashboardUri.Host); + Assert.Equal("/login", dashboardUri.AbsolutePath); + Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + [RequiresFeature(TestFeature.ContainerRuntime | TestFeature.Playwright)] + public async Task DashboardDisplaysResourceUpdatesWhileAppHostIsPausedAtBreakpoint() + { + const string resourceName = "dashboard-test-resource"; + const string initialState = "Dashboard test initial"; + const string updatedState = "Dashboard test updated"; + + using var breakpoint = TestingAppHostEntryPointProbe.CreateBreakpoint(); + await using var builder = await CreateDashboardBuilderAsync($"--entry-point-breakpoint-probe={breakpoint.Id}"); + builder.AddExternalService(resourceName, "https://example.com/"); + await using var app = await builder.BuildAsync(); + + try + { + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var cancellationToken = cancellationTokenSource.Token; + + // The AppHost signals this immediately after app.StartAsync() and then waits for Continue(). This models a + // debugger stopped on the next AppHost statement while hosted services, child resources, and the dashboard run. + await breakpoint.Reached.WaitAsync(cancellationToken); + + var runningResourceEvent = await app.ResourceNotifications.WaitForResourceAsync( + resourceName, + resourceEvent => resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running, + cancellationToken); + + await app.ResourceNotifications.PublishUpdateAsync( + runningResourceEvent.Resource, + snapshot => snapshot with { State = initialState }); + + // GetDashboardUrlAsync is a dashboard-health barrier, not a resource-propagation barrier. The resource service + // caches notifications for dashboard clients, so a browser that connects now receives this state as initial data. + var dashboardUri = await app.GetDashboardUrlAsync(cancellationToken); + + PlaywrightProvider.DetectAndSetInstalledPlaywrightDependenciesPath(); + Assertions.SetDefaultExpectTimeout(TestConstants.LongTimeoutDuration); + var browser = await PlaywrightProvider.CreateBrowserAsync(); + try + { + await using var context = await browser.NewContextAsync(); + var page = await context.NewPageAsync(); + await page.GotoAsync(dashboardUri.AbsoluteUri); + + var resourceRow = page.Locator("tr.resource-row").Filter(new() { HasText = resourceName }); + var stateCell = resourceRow.Locator(".state-column-cell"); + + await Assertions.Expect(resourceRow).ToBeVisibleAsync(); + await Assertions.Expect(stateCell).ToContainTextAsync(initialState); + + var updatedResourceTask = app.ResourceNotifications.WaitForResourceAsync( + resourceName, + resourceEvent => resourceEvent.Snapshot.State?.Text == updatedState, + cancellationToken); + + await app.ResourceNotifications.PublishUpdateAsync( + runningResourceEvent.Resource, + snapshot => snapshot with { State = updatedState }); + await updatedResourceTask; + + // PublishUpdateAsync updates the application notification stream while the AppHost breakpoint remains held. + // Dashboard consumption and browser rendering are asynchronous, so wait separately for the rendered change. + await Assertions.Expect(stateCell).ToContainTextAsync(updatedState); + } + finally + { + await browser.CloseAsync(); + } + } + finally + { + // Release AppHost execution before the application is disposed; otherwise its entry point cannot finish cleanup. + breakpoint.Continue(); + } + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncEncodesCustomBrowserToken() + { + const string BrowserToken = "browser&token#with+reserved"; + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["AppHost:BrowserToken"] = BrowserToken; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.Equal($"?t={Uri.EscapeDataString(BrowserToken)}", dashboardUri.Query); + + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var loginResponse = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode); + Assert.Equal("/", loginResponse.Headers.Location?.OriginalString); + Assert.Single( + loginResponse.Headers.GetValues("Set-Cookie"), + cookie => cookie.StartsWith(".Aspire.Dashboard.Auth", StringComparison.Ordinal)); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() + { + await using var firstBuilder = await CreateDashboardBuilderAsync(); + await using var secondBuilder = await CreateDashboardBuilderAsync(); + await using var firstApp = await firstBuilder.BuildAsync(); + await using var secondApp = await secondBuilder.BuildAsync(); + + await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var firstUrl = await firstApp.GetDashboardUrlAsync(cancellationTokenSource.Token); + var secondUrl = await secondApp.GetDashboardUrlAsync(cancellationTokenSource.Token); + var firstBaseUrl = firstUrl.GetLeftPart(UriPartial.Authority); + Uri[] invalidLoginUrls = + [ + new($"{firstBaseUrl}/login?t=wrong-token"), + new(firstBaseUrl + secondUrl.PathAndQuery), + new($"{firstBaseUrl}/login?t=%25") + ]; + + Assert.NotEqual(firstUrl.Port, secondUrl.Port); + Assert.NotEqual(firstUrl.Query, secondUrl.Query); + + foreach (var invalidLoginUrl in invalidLoginUrls) + { + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var response = await httpClient.GetAsync(invalidLoginUrl, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/login", response.Headers.Location?.OriginalString); + Assert.False(response.Headers.TryGetValues("Set-Cookie", out _)); + } + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncThrowsWhenDashboardIsDisabled() + { + var builder = DistributedApplicationTestingBuilder.Create(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal(TestingResources.DashboardDisabledExceptionMessage, exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncReturnsBaseUrlWhenDashboardAllowsAnonymousAccess() + { + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["AppHost:BrowserToken"] = string.Empty; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.True(dashboardUri.IsAbsoluteUri); + Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); + Assert.True(dashboardUri.IsLoopback); + Assert.InRange(dashboardUri.Port, 1, 65535); + Assert.Equal("/", dashboardUri.AbsolutePath); + Assert.Equal(string.Empty, dashboardUri.Query); + + using var httpClient = new HttpClient { Timeout = TestConstants.LongTimeoutTimeSpan }; + using var response = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task GetDashboardUrlAsyncThrowsBeforeApplicationStarts() + { + await using var builder = await CreateDashboardBuilderAsync(); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal(TestingResources.DashboardUrlApplicationNotStartedExceptionMessage, exception.Message); + } + + [Fact] + public async Task GetDashboardUrlAsyncThrowsInPublishMode() + { + var builder = DistributedApplicationTestingBuilder.Create(["--publisher", "manifest"]); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal(TestingResources.DashboardUrlPublishModeExceptionMessage, exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncPreservesTerminalDashboardFailure() + { + var missingDashboardPath = Path.Combine( + AppContext.BaseDirectory, + "missing-dashboard", + Guid.NewGuid().ToString("N")); + var builder = DistributedApplicationTestingBuilder.Create( + CreateDashboardOptions(), + [$"DcpPublisher:DashboardPath={missingDashboardPath}"]); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(cancellationTokenSource.Token)); + + Assert.Contains(KnownResourceNames.AspireDashboard, exception.Message, StringComparison.Ordinal); + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLogs() + { + await using var builder = await CreateDashboardBuilderAsync(); + var logCollector = new FakeLogCollector(); + builder.Services.AddLogging(logging => logging.AddProvider(new FakeLoggerProvider(logCollector))); + + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + var token = dashboardUri.Query["?t=".Length..]; + Assert.NotEmpty(token); + + // Resource log forwarding is asynchronous. Wait for the child dashboard's summary, which is written after + // its separate login URL line, so the assertion covers both the AppHost and dashboard-process output. + while (!logCollector.GetSnapshot().Any(record => + record.Category?.EndsWith(".Resources.aspire-dashboard", StringComparison.Ordinal) == true && + GetLogText(record).Contains("Aspire Dashboard", StringComparison.Ordinal))) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationTokenSource.Token); + } + + var written = string.Join( + Environment.NewLine, + logCollector.GetSnapshot().Select(GetLogText)); + + Assert.False( + written.Contains(token, StringComparison.Ordinal), + "The dashboard browser token was written to AppHost logs."); + Assert.Contains("Aspire Dashboard", written, StringComparison.Ordinal); + } + + private static string GetLogText(FakeLogRecord record) + { + return record.Message + " " + record.StructuredState?.Aggregate( + string.Empty, + (accumulated, pair) => accumulated + " " + pair.Value); + } + + private static Task CreateDashboardBuilderAsync(params string[] args) + { + return DistributedApplicationTestingBuilder.CreateAsync( + CreateDashboardOptions(), + args); + } + + private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() + { + return new() + { + EnableDashboard = true + }; + } +} diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs index 44f16084281..6c1683160c9 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs @@ -1,6 +1,9 @@ // 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.InternalTesting; + namespace Aspire.Hosting.Tests.ApplicationModel; [Trait("Partition", "4")] @@ -202,4 +205,69 @@ public void IsLocalhostOrLocalhostTld_VariousUriInputs_ReturnsExpectedResult(str // Assert Assert.Equal(expected, result); } + + [Theory] + [InlineData("dashboard.testing.localhost", "http://dashboard.testing.localhost:17454")] + [InlineData("app.dev.localhost", "http://app.dev.localhost:17454")] + [InlineData("localhost", "http://localhost:17454")] + [InlineData("example.com", "http://localhost:17454")] + public async Task GetUrlWithTargetHostAsync_UsesTargetHostOnlyForLocalhostTld(string targetHost, string expectedUrl) + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container1", "image") + .WithHttpEndpoint(name: "primary", targetPort: 10005) + .WithEndpoint("primary", ep => + { + ep.TargetHost = targetHost; + + // DCP allocates "localhost" even when a localhost TLD is configured, because + // "localhost" is the address the service actually binds to. The TLD hostname + // only ever exists in the URL presented back to the user. + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); + }); + + var url = await EndpointHostHelpers.GetUrlWithTargetHostAsync(container.GetEndpoint("primary")) + .AsTask() + .DefaultTimeout(); + + Assert.Equal(expectedUrl, url); + } + + [Fact] + public async Task GetUrlWithTargetHostAsync_KeepsAddressResolvedForANonLocalNetworkContext() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container1", "image") + .WithHttpEndpoint(name: "primary", targetPort: 10005) + .WithEndpoint("primary", ep => + { + ep.TargetHost = "app.dev.localhost"; + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); + ep.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint( + KnownNetworkIdentifiers.DefaultAspireContainerNetwork, + new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + }); + + var containerNetworkEndpoint = new EndpointReference( + container.Resource, + container.GetEndpoint("primary").EndpointAnnotation, + KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + + var url = await EndpointHostHelpers.GetUrlWithTargetHostAsync(containerNetworkEndpoint) + .AsTask() + .DefaultTimeout(); + + // A localhost TLD only ever resolves on the host loopback, so it must not be substituted into an + // address that was resolved for the container network - the container would be sent to its own loopback. + Assert.Equal("http://container1.dev.internal:10005", url); + } + + [Fact] + public async Task GetUrlWithTargetHostAsync_ThrowsForNullEndpoint() + { + await Assert.ThrowsAsync( + async () => await EndpointHostHelpers.GetUrlWithTargetHostAsync(null!)); + } } diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index de5634460ce..f76aefae555 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -7,8 +7,41 @@ #pragma warning disable ASPIRECERTIFICATES001 +using var entryPointProbe = TestingAppHostEntryPointProbe.Track(args); + var builder = DistributedApplication.CreateBuilder(args); +if (args.Contains("--clear-apphost-browser-token")) +{ + // The frozen token is the one dashboard setting an AppHost could still change after the testing builder had + // hardened things, because DashboardOptions does not read this key until the application starts. It is kept + // separate from --override-dashboard-testing-defaults so that flag keeps asserting on a token. + builder.Configuration["AppHost:BrowserToken"] = ""; +} + +if (args.Contains("--null-apphost-browser-token")) +{ + // Null and empty are distinct configuration values, but DashboardEventHandlers collapses both to Unsecured + // frontend authentication through string.IsNullOrEmpty, so both need coverage. + builder.Configuration["AppHost:BrowserToken"] = null; +} + +if (args.Contains("--unsecure-apphost-resource-service")) +{ + // The resource service has the same post-construction window as the browser token: DashboardServiceHost does + // not bind this section until the application starts. Downgrading the mode alone is enough, because + // ResourceServiceApiKeyAuthenticationHandler only inspects the API key header while the mode is ApiKey. + builder.Configuration["AppHost:ResourceService:AuthMode"] = "Unsecured"; + builder.Configuration["AppHost:ResourceService:ApiKey"] = ""; +} + +if (args.Contains("--clear-apphost-resource-service-key")) +{ + // Clearing only the key is the other half. ValidateResourceServiceOptions would fail the start in this state, + // so without the restore the application does not come up at all rather than coming up unauthenticated. + builder.Configuration["AppHost:ResourceService:ApiKey"] = ""; +} + builder.Configuration["ConnectionStrings:cs"] = "testconnection"; builder.AddConnectionString("cs"); @@ -58,6 +91,10 @@ await app.StartAsync(); +// Tests use this wait to model a debugger stopped on the next AppHost statement. The application has already +// started, so its hosted services, resources, and dashboard continue running while top-level execution is paused. +await TestingAppHostEntryPointProbe.WaitAtBreakpointAsync(args); + if (args.Contains("--wait-for-healthy")) { // Wait indefinitely until redis becomes healthy. diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs new file mode 100644 index 00000000000..d78baaefc7e --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; + +public static class TestingAppHostEntryPointProbe +{ + private const string ArgumentPrefix = "--entry-point-exit-probe="; + private const string BreakpointArgumentPrefix = "--entry-point-breakpoint-probe="; + private static readonly ConcurrentDictionary s_probes = new(); + private static readonly ConcurrentDictionary s_breakpoints = new(); + + public static Probe Create() + { + var id = Guid.NewGuid().ToString("N"); + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!s_probes.TryAdd(id, exited)) + { + throw new InvalidOperationException($"Could not create entry-point probe '{id}'."); + } + + return new Probe(id, exited.Task); + } + + public static IDisposable Track(string[] args) + { + // The test passes the probe as: + // --entry-point-exit-probe=<32-character GUID> + var argument = args.FirstOrDefault(arg => arg.StartsWith(ArgumentPrefix, StringComparison.Ordinal)); + if (argument is null) + { + return EmptyDisposable.Instance; + } + + var id = argument[ArgumentPrefix.Length..]; + if (!s_probes.TryGetValue(id, out var exited)) + { + throw new InvalidOperationException($"Entry-point probe '{id}' was not registered."); + } + + return new ExitSignal(exited); + } + + public static BreakpointProbe CreateBreakpoint() + { + var id = Guid.NewGuid().ToString("N"); + var signal = new BreakpointSignal( + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + if (!s_breakpoints.TryAdd(id, signal)) + { + throw new InvalidOperationException($"Could not create entry-point breakpoint probe '{id}'."); + } + + return new BreakpointProbe(id, signal.Reached, signal.Continue); + } + + public static async Task WaitAtBreakpointAsync(string[] args) + { + // The test passes the probe as: + // --entry-point-breakpoint-probe=<32-character GUID> + var argument = args.FirstOrDefault(arg => arg.StartsWith(BreakpointArgumentPrefix, StringComparison.Ordinal)); + if (argument is null) + { + return; + } + + var id = argument[BreakpointArgumentPrefix.Length..]; + if (!s_breakpoints.TryGetValue(id, out var signal)) + { + throw new InvalidOperationException($"Entry-point breakpoint probe '{id}' was not registered."); + } + + signal.Reached.TrySetResult(); + try + { + await signal.Continue.Task.ConfigureAwait(false); + } + finally + { + s_breakpoints.TryRemove(id, out _); + } + } + + public sealed class Probe(string id, Task exited) : IDisposable + { + public string Id { get; } = id; + + public Task Exited { get; } = exited; + + public void Dispose() + { + s_probes.TryRemove(Id, out _); + } + } + + public sealed class BreakpointProbe : IDisposable + { + private readonly TaskCompletionSource _continue; + + internal BreakpointProbe(string id, TaskCompletionSource reached, TaskCompletionSource @continue) + { + Id = id; + _continue = @continue; + Reached = reached.Task; + } + + public string Id { get; } + + public Task Reached { get; } + + public void Continue() + { + _continue.TrySetResult(); + } + + public void Dispose() + { + Continue(); + if (Reached.IsCompleted) + { + s_breakpoints.TryRemove(Id, out _); + } + } + } + + private sealed class ExitSignal(TaskCompletionSource exited) : IDisposable + { + public void Dispose() + { + exited.TrySetResult(); + } + } + + private sealed record BreakpointSignal(TaskCompletionSource Reached, TaskCompletionSource Continue); + + private sealed class EmptyDisposable : IDisposable + { + public static EmptyDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } +}