File: Integration\Playwright\BrowserTokenAuthenticationTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.TestUtilities;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.Hosting;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Playwright;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Integration.Playwright;
 
[RequiresFeature(TestFeature.Playwright)]
public class BrowserTokenAuthenticationTests : PlaywrightTestsBase<BrowserTokenAuthenticationTests.BrowserTokenDashboardServerFixture>
{
    public class BrowserTokenDashboardServerFixture : DashboardServerFixture
    {
        public BrowserTokenDashboardServerFixture()
        {
            Configuration[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.BrowserToken);
            Configuration[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = "VALID_TOKEN";
        }
    }
 
    public sealed class BrowserTokenDashboardServerWithHttpAndHttpsFixture : DashboardServerFixture
    {
        public BrowserTokenDashboardServerWithHttpAndHttpsFixture()
        {
            // Bind to 127.0.0.1 rather than the "localhost" hostname: Kestrel rejects dynamic-port
            // binding (":0") on "localhost" with "Dynamic port binding is not supported when binding
            // to localhost. You must either bind to 127.0.0.1:0 or [::1]:0, or both." The WebKit test
            // navigates to the resolved address with IgnoreHTTPSErrors, so the loopback IP is fine.
            Configuration[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://127.0.0.1:0;http://127.0.0.1:0";
            Configuration[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.BrowserToken);
            Configuration[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = "VALID_TOKEN";
        }
    }
 
    public BrowserTokenAuthenticationTests(BrowserTokenDashboardServerFixture dashboardServerFixture)
        : base(dashboardServerFixture)
    {
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task BrowserToken_LoginPage_Success_RedirectToResources()
    {
        // Arrange
        await RunTestAsync(async page =>
        {
            // Act
            var response = await page.GotoAsync("/").DefaultTimeout();
            var uri = new Uri(response!.Url);
 
            Assert.Equal("/login?returnUrl=%2F", uri.PathAndQuery);
 
            var tokenTextBox = page.GetByRole(AriaRole.Textbox);
            await tokenTextBox.FillAsync("VALID_TOKEN").DefaultTimeout();
 
            var submitButton = SubmitButton(page);
            await submitButton.ClickAsync().DefaultTimeout();
 
            // Wait for navigation to complete after successful login.
            // The page redirects from /login to / (resources page).
            await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/").DefaultTimeout();
 
            // Assert
            await Assertions
                .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName))
                .ToBeVisibleAsync()
                .DefaultTimeout();
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task BrowserToken_LoginPage_SuccessWithWhitespaceToken_RedirectToResources()
    {
        // Arrange
        await RunTestAsync(async page =>
        {
            // Act
            var response = await page.GotoAsync("/").DefaultTimeout();
            var uri = new Uri(response!.Url);
 
            Assert.Equal("/login?returnUrl=%2F", uri.PathAndQuery);
 
            var tokenTextBox = page.GetByRole(AriaRole.Textbox);
            await tokenTextBox.FillAsync(" VALID_TOKEN ").DefaultTimeout();
 
            var submitButton = SubmitButton(page);
            await submitButton.ClickAsync().DefaultTimeout();
 
            await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/").DefaultTimeout();
 
            // Assert
            await Assertions
                .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName))
                .ToBeVisibleAsync()
                .DefaultTimeout();
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task BrowserToken_LoginPage_Failure_DisplayFailureMessage()
    {
        // Arrange
        await RunTestAsync(async page =>
        {
            // Act
            var response = await page.GotoAsync("/").DefaultTimeout();
            var uri = new Uri(response!.Url);
 
            Assert.Equal("/login?returnUrl=%2F", uri.PathAndQuery);
 
            var tokenTextBox = page.GetByRole(AriaRole.Textbox);
            await tokenTextBox.FillAsync("INVALID_TOKEN").DefaultTimeout();
 
            var submitButton = SubmitButton(page);
            await submitButton.ClickAsync().DefaultTimeout();
 
            // Assert
            await Assertions
                .Expect(page.GetByText(Login.InvalidTokenErrorMessage))
                .ToBeVisibleAsync()
                .DefaultTimeout();
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task BrowserToken_QueryStringToken_Success_RestrictToResources()
    {
        // Arrange
        await RunTestAsync(async page =>
        {
            // Act
            await page.GotoAsync("/login?t=VALID_TOKEN").DefaultTimeout();
 
            // Assert
            await Assertions
                .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName))
                .ToBeVisibleAsync()
                .DefaultTimeout();
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task BrowserToken_QueryStringToken_Failure_DisplayLoginPage()
    {
        // Arrange
        await RunTestAsync(async page =>
        {
            // Act
            await page.GotoAsync("/login?t=INVALID_TOKEN").DefaultTimeout();
 
            var submitButton = SubmitButton(page);
            var name = await submitButton.GetAttributeAsync("name").DefaultTimeout();
 
            // Assert
            Assert.Equal("submit-token", name);
        });
    }
 
    private static ILocator SubmitButton(IPage page) => page.Locator("fluent-button[name='submit-token']");
}
 
[RequiresFeature(TestFeature.Playwright)]
public sealed class BrowserTokenAuthenticationApplicationNameTests(PlaywrightFixture playwrightFixture) : IClassFixture<PlaywrightFixture>
{
    private const string BrowserToken = "VALID_TOKEN";
 
    [Theory]
    [InlineData("Same application", "Same application", true)]
    [InlineData("First application", "Second application", false)]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task BrowserToken_ApplicationName_ScopesAuthentication(string firstApplicationName, string secondApplicationName, bool canAccessBoth)
    {
        await using var firstDashboard = CreateDashboard(firstApplicationName);
        await using var secondDashboard = CreateDashboard(secondApplicationName);
        await firstDashboard.StartAsync();
        await secondDashboard.StartAsync();
 
        var firstBaseUrl = firstDashboard.FrontendSingleEndPointAccessor().GetResolvedAddress();
        var secondBaseUrl = secondDashboard.FrontendSingleEndPointAccessor().GetResolvedAddress();
 
        // Browser cookies are scoped to the loopback host rather than the port, so both dashboards
        // must be accessed from the same browser context to reproduce localhost cookie sharing.
        await using var context = await playwrightFixture.Browser.NewContextAsync();
        var page = await context.NewPageAsync();
 
        var firstResponse = await page.GotoAsync($"{firstBaseUrl}/login?t={BrowserToken}").DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        Assert.Equal("/", new Uri(firstResponse!.Url).AbsolutePath);
 
        var secondResponse = await page.GotoAsync(secondBaseUrl).DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        Assert.Equal(canAccessBoth ? "/" : "/login", new Uri(secondResponse!.Url).AbsolutePath);
    }
 
    private static DashboardWebApplication CreateDashboard(string applicationName)
    {
        var configuration = new Dictionary<string, string?>
        {
            [DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "http://127.0.0.1:0",
            [DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = "http://127.0.0.1:0",
            [DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = nameof(OtlpAuthMode.Unsecured),
            [DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.BrowserToken),
            [DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = BrowserToken,
            [DashboardConfigNames.DashboardApplicationName.ConfigKey] = applicationName
        };
 
        return DashboardServerFixture.CreateDashboardApp(configuration);
    }
}
 
[RequiresFeature(TestFeature.Playwright)]
public class BrowserTokenAuthenticationHttpAndHttpsTests : PlaywrightTestsBase<BrowserTokenAuthenticationTests.BrowserTokenDashboardServerWithHttpAndHttpsFixture>
{
    public BrowserTokenAuthenticationHttpAndHttpsTests(BrowserTokenAuthenticationTests.BrowserTokenDashboardServerWithHttpAndHttpsFixture dashboardServerFixture)
        : base(dashboardServerFixture)
    {
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task BrowserToken_QueryStringToken_HttpsThenHttp_WebKit_Success()
    {
        using var playwright = await Microsoft.Playwright.Playwright.CreateAsync();
        await using var browser = await LaunchWebKitAsync(playwright);
 
        await RunHttpsThenHttpAsync(browser);
    }
 
    private static async Task<IBrowser> LaunchWebKitAsync(IPlaywright playwright)
    {
        try
        {
            return await playwright.Webkit.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
        }
        catch (PlaywrightException ex) when (IsWebKitBrowserUnavailable(ex))
        {
            Assert.Skip("Playwright WebKit is not available in this environment.");
            throw;
        }
    }
 
    private static bool IsWebKitBrowserUnavailable(PlaywrightException ex)
    {
        return ex.Message.Contains("Executable doesn't exist", StringComparison.Ordinal) ||
            ex.Message.Contains("Host system is missing dependencies", StringComparison.Ordinal);
    }
 
    private async Task RunHttpsThenHttpAsync(IBrowser browser)
    {
        var endpoints = DashboardServerFixture.DashboardApp.FrontendEndPointsAccessor
            .Select(accessor => accessor())
            .ToList();
        var httpsEndpoint = endpoints.Single(e => e.IsHttps);
        var httpEndpoint = endpoints.Single(e => !e.IsHttps);
 
        var httpsBaseUrl = httpsEndpoint.GetResolvedAddress(replaceIPAnyWithLocalhost: true);
        var httpBaseUrl = httpEndpoint.GetResolvedAddress(replaceIPAnyWithLocalhost: true);
 
        var context = await browser.NewContextAsync(new BrowserNewContextOptions
        {
            IgnoreHTTPSErrors = true
        });
        PlaywrightFixture.ConfigureTimeouts(context);
        try
        {
            var page = await context.NewPageAsync();
            try
            {
                await page.GotoAsync($"{httpsBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout();
                await Assertions
                    .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName))
                    .ToBeVisibleAsync()
                    .DefaultTimeout();
 
                await page.GotoAsync($"{httpBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout();
                await Assertions
                    .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName))
                    .ToBeVisibleAsync()
                    .DefaultTimeout();
 
                await page.GotoAsync($"{httpBaseUrl}/structuredlogs").DefaultTimeout();
                Assert.Equal("/structuredlogs", new Uri(page.Url).AbsolutePath);
                await Assertions
                    .Expect(page.GetByRole(AriaRole.Button, new() { Name = "submit-token" }))
                    .Not
                    .ToBeVisibleAsync()
                    .DefaultTimeout();
            }
            finally
            {
                await page.CloseAsync();
            }
        }
        finally
        {
            await context.DisposeAsync();
        }
    }
}