From 8313e75fe7f78cb8304fbe2cf19dd8464b2f4c59 Mon Sep 17 00:00:00 2001 From: Scribe Date: Thu, 12 Mar 2026 00:04:17 -0700 Subject: [PATCH 1/3] feat: Add Playwright E2E tests for role-based navigation - Add PlaywrightFixture that initializes Aspire AppHost and Playwright browser - Add PlaywrightCollection for shared test fixture across E2E tests - Add Auth0LoginHelper for browser-based OIDC authentication flow - Add AdminNavigationTests (6 tests): login, menu visibility, navigation to all admin pages - Add AuthorNavigationTests (5 tests): login, menu visibility, navigation permissions - Add UserNavigationTests (5 tests): login, menu visibility, protected content access - Add UnauthenticatedNavigationTests (7 tests): login button, protected route redirect - Add LogoutTests (7 tests): logout functionality, menu hidden after logout Test credentials are read from environment variables for security: - E2E_TEST_ADMIN_EMAIL/PASSWORD - E2E_TEST_AUTHOR_EMAIL/PASSWORD - E2E_TEST_USER_EMAIL/PASSWORD Closes #109 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Fixtures/PlaywrightCollection.cs | 18 ++ .../Fixtures/PlaywrightFixture.cs | 151 +++++++++ tests/AppHost.Tests.E2E/GlobalUsings.cs | 2 + .../Helpers/Auth0LoginHelper.cs | 134 ++++++++ .../Navigation/AdminNavigationTests.cs | 250 +++++++++++++++ .../Navigation/AuthorNavigationTests.cs | 222 +++++++++++++ .../Navigation/LogoutTests.cs | 293 ++++++++++++++++++ .../UnauthenticatedNavigationTests.cs | 230 ++++++++++++++ .../Navigation/UserNavigationTests.cs | 220 +++++++++++++ 9 files changed, 1520 insertions(+) create mode 100644 tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs create mode 100644 tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs create mode 100644 tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs create mode 100644 tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs create mode 100644 tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs create mode 100644 tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs create mode 100644 tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs create mode 100644 tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs diff --git a/tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs b/tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs new file mode 100644 index 0000000..f926144 --- /dev/null +++ b/tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs @@ -0,0 +1,18 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : PlaywrightCollection.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +namespace AppHost.Tests.E2E.Fixtures; + +/// +/// Defines the PlaywrightE2E collection, backed by a shared PlaywrightFixture. +/// Tests in this collection share one Aspire host + Playwright browser instance. +/// +[ExcludeFromCodeCoverage] +[CollectionDefinition("PlaywrightE2E")] +public sealed class PlaywrightCollection : ICollectionFixture; diff --git a/tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs b/tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs new file mode 100644 index 0000000..55f5222 --- /dev/null +++ b/tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs @@ -0,0 +1,151 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : PlaywrightFixture.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +namespace AppHost.Tests.E2E.Fixtures; + +/// +/// Playwright fixture that extends DistributedApplicationFixture. +/// Initializes Playwright and provides browser/page instances for E2E tests. +/// Starts the Aspire AppHost and captures the Web app URL for browser navigation. +/// +[ExcludeFromCodeCoverage] +public sealed class PlaywrightFixture : IAsyncLifetime +{ + private IPlaywright? _playwright; + private IBrowser? _browser; + private IDistributedApplicationTestingBuilder? _builder; + private DistributedApplication? _app; + private string? _webUrl; + private ResourceNotificationService? _notificationService; + + /// + /// Gets the Playwright instance. + /// + public IPlaywright Playwright => + _playwright ?? throw new InvalidOperationException("Playwright not initialized. Check IsAvailable."); + + /// + /// Gets the browser instance. + /// + public IBrowser Browser => + _browser ?? throw new InvalidOperationException("Browser not initialized. Check IsAvailable."); + + /// + /// Gets the base URL of the web application. + /// + public string WebUrl => + _webUrl ?? throw new InvalidOperationException("Web URL not available. Check IsAvailable."); + + /// + /// Gets the Aspire app instance. + /// + public DistributedApplication App => + _app ?? throw new InvalidOperationException("Aspire app not initialized. Check IsAvailable."); + + /// + /// True if the fixture was successfully initialized. + /// + public bool IsAvailable { get; private set; } + + /// + /// The reason initialization was skipped or failed, if IsAvailable is false. + /// + public string? UnavailableReason { get; private set; } + + /// + /// Creates a new browser context with isolated state for test isolation. + /// + public async Task NewContextAsync(BrowserNewContextOptions? options = null) + { + return await Browser.NewContextAsync(options ?? new BrowserNewContextOptions + { + IgnoreHTTPSErrors = true + }); + } + + /// + /// Creates a new page in a fresh browser context for test isolation. + /// + public async Task NewPageAsync() + { + var context = await NewContextAsync(); + return await context.NewPageAsync(); + } + + public async ValueTask InitializeAsync() + { + try + { + // Step 1: Initialize Aspire AppHost + _builder = await DistributedApplicationTestingBuilder + .CreateAsync(CancellationToken.None); + + _builder.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.AddStandardResilienceHandler(); + }); + + _app = await _builder.BuildAsync(CancellationToken.None); + + _notificationService = _app.Services.GetRequiredService(); + + // Start the distributed application + await _app.StartAsync(CancellationToken.None); + + // Wait for the web app to be running + await _notificationService.WaitForResourceAsync( + "web", + KnownResourceStates.Running, + CancellationToken.None).WaitAsync(TimeSpan.FromMinutes(3)); + + // Get the web app URL + _webUrl = _app.GetEndpoint("web", "https")?.ToString() + ?? _app.GetEndpoint("web", "http")?.ToString(); + + if (string.IsNullOrEmpty(_webUrl)) + { + IsAvailable = false; + UnavailableReason = "Could not get web app endpoint URL"; + return; + } + + // Step 2: Initialize Playwright + _playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + + // Launch Chromium in headless mode + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + + IsAvailable = true; + } + catch (Exception ex) + { + IsAvailable = false; + UnavailableReason = $"Fixture initialization failed: {ex.Message}"; + } + } + + public async ValueTask DisposeAsync() + { + if (_browser is not null) + { + await _browser.CloseAsync(); + } + + _playwright?.Dispose(); + + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } +} diff --git a/tests/AppHost.Tests.E2E/GlobalUsings.cs b/tests/AppHost.Tests.E2E/GlobalUsings.cs index 903eb6e..5832816 100644 --- a/tests/AppHost.Tests.E2E/GlobalUsings.cs +++ b/tests/AppHost.Tests.E2E/GlobalUsings.cs @@ -10,6 +10,7 @@ global using System.Diagnostics.CodeAnalysis; global using AppHost.Tests.E2E.Fixtures; +global using AppHost.Tests.E2E.Helpers; global using Aspire.Hosting; global using Aspire.Hosting.ApplicationModel; @@ -21,6 +22,7 @@ global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Diagnostics.HealthChecks; global using Microsoft.Extensions.Hosting; +global using Microsoft.Playwright; global using OpenTelemetry.Metrics; global using OpenTelemetry.Trace; diff --git a/tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs b/tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs new file mode 100644 index 0000000..295b11f --- /dev/null +++ b/tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs @@ -0,0 +1,134 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : Auth0LoginHelper.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +namespace AppHost.Tests.E2E.Helpers; + +/// +/// Helper for authenticating with Auth0 via browser-based OIDC flow in Playwright tests. +/// Test credentials are read from environment variables for security. +/// +[ExcludeFromCodeCoverage] +public static class Auth0LoginHelper +{ + /// + /// Gets test credentials from environment variables. + /// + public static (string Email, string Password)? GetTestCredentials(string role) + { + var suffix = role.ToUpperInvariant(); + var email = Environment.GetEnvironmentVariable($"E2E_TEST_{suffix}_EMAIL"); + var password = Environment.GetEnvironmentVariable($"E2E_TEST_{suffix}_PASSWORD"); + + if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password)) + { + return null; + } + + return (email, password); + } + + /// + /// Performs Auth0 login via the browser-based OIDC flow. + /// Navigates to /auth/login, fills Auth0 form, and waits for redirect back. + /// + /// The Playwright page instance. + /// The application base URL. + /// The test user email. + /// The test user password. + /// Timeout for waiting operations (default 30 seconds). + /// True if login succeeded, false otherwise. + public static async Task LoginAsync( + IPage page, + string baseUrl, + string email, + string password, + int timeout = 30000) + { + try + { + // Navigate to the login endpoint which triggers Auth0 redirect + await page.GotoAsync($"{baseUrl.TrimEnd('/')}/auth/login", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = timeout + }); + + // Wait for Auth0 login page + await page.WaitForURLAsync("**/u/login**", new PageWaitForURLOptions { Timeout = timeout }); + + // Fill in the Auth0 Universal Login form + await page.FillAsync("input[name='username'], input[name='email'], input#username, input[type='email']", email); + await page.FillAsync("input[name='password'], input#password, input[type='password']", password); + + // Click the submit button + await page.ClickAsync("button[type='submit'], button[name='action'], button[data-action-button-primary='true']"); + + // Wait for redirect back to our app (should redirect to home or return URL) + await page.WaitForURLAsync(url => + url.Contains(new Uri(baseUrl).Host) && !url.Contains("auth0.com"), + new PageWaitForURLOptions { Timeout = timeout }); + + // Wait for the page to stabilize + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + return true; + } + catch (TimeoutException) + { + return false; + } + catch (PlaywrightException) + { + return false; + } + } + + /// + /// Performs logout by navigating to /auth/logout. + /// + /// The Playwright page instance. + /// The application base URL. + /// Timeout for waiting operations (default 30 seconds). + public static async Task LogoutAsync(IPage page, string baseUrl, int timeout = 30000) + { + await page.GotoAsync($"{baseUrl.TrimEnd('/')}/auth/logout", new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = timeout + }); + + // Wait for redirect back to home + await page.WaitForURLAsync(url => + url.Contains(new Uri(baseUrl).Host) && !url.Contains("auth0.com"), + new PageWaitForURLOptions { Timeout = timeout }); + } + + /// + /// Checks if the current page shows the user as logged in. + /// + /// The Playwright page instance. + /// True if logged in (greeting visible), false otherwise. + public static async Task IsLoggedInAsync(IPage page) + { + // Look for the "Hello, username!" text or "Log out" link + var logoutLink = page.Locator("a[href='/auth/logout']"); + return await logoutLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 5000 }); + } + + /// + /// Checks if the login button is visible (indicates logged out state). + /// + /// The Playwright page instance. + /// True if login button is visible, false otherwise. + public static async Task IsLoginButtonVisibleAsync(IPage page) + { + var loginLink = page.Locator("a[href='/auth/login']"); + return await loginLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 5000 }); + } +} diff --git a/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs new file mode 100644 index 0000000..a7a331b --- /dev/null +++ b/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs @@ -0,0 +1,250 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : AdminNavigationTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +using AppHost.Tests.E2E.Helpers; + +namespace AppHost.Tests.E2E.Navigation; + +/// +/// E2E tests for Admin role navigation behavior. +/// Verifies that Admin users can see and access all admin-only menu items. +/// +[ExcludeFromCodeCoverage] +[Collection("PlaywrightE2E")] +public class AdminNavigationTests(PlaywrightFixture fixture) +{ + private const string AdminRole = "ADMIN"; + + /// + /// Verifies that an Admin user can successfully log in via Auth0. + /// + [Fact] + public async Task Admin_CanLoginSuccessfully() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + var loginSuccess = await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Assert + loginSuccess.Should().BeTrue("Admin should be able to log in successfully"); + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Page should show logged-in state"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Admin user sees all admin menu items (Categories, Statuses, Admin, Sample Data). + /// + [Fact] + public async Task Admin_SeesAllAdminMenuItems() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act - Navigate to home page to see the nav menu + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Admin should see all admin menu items + var categoriesLink = page.Locator("a[href='/categories']"); + var statusesLink = page.Locator("a[href='/statuses']"); + var adminLink = page.Locator("a[href='/admin']"); + var sampleDataLink = page.Locator("a[href='/sample-data']"); + + (await categoriesLink.IsVisibleAsync()).Should().BeTrue("Admin should see Categories link"); + (await statusesLink.IsVisibleAsync()).Should().BeTrue("Admin should see Statuses link"); + (await adminLink.IsVisibleAsync()).Should().BeTrue("Admin should see Admin link"); + (await sampleDataLink.IsVisibleAsync()).Should().BeTrue("Admin should see Sample Data link"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Admin user can navigate to the Categories page. + /// + [Fact] + public async Task Admin_CanNavigateToCategoriesPage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act + await page.ClickAsync("a[href='/categories']"); + await page.WaitForURLAsync("**/categories**"); + + // Assert + page.Url.Should().Contain("/categories"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Admin user can navigate to the Statuses page. + /// + [Fact] + public async Task Admin_CanNavigateToStatusesPage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act + await page.ClickAsync("a[href='/statuses']"); + await page.WaitForURLAsync("**/statuses**"); + + // Assert + page.Url.Should().Contain("/statuses"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Admin user can navigate to the Admin page. + /// + [Fact] + public async Task Admin_CanNavigateToAdminPage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act + await page.ClickAsync("a[href='/admin']"); + await page.WaitForURLAsync("**/admin**"); + + // Assert + page.Url.Should().Contain("/admin"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Admin user can navigate to the Sample Data page. + /// + [Fact] + public async Task Admin_CanNavigateToSampleDataPage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act + await page.ClickAsync("a[href='/sample-data']"); + await page.WaitForURLAsync("**/sample-data**"); + + // Assert + page.Url.Should().Contain("/sample-data"); + } + finally + { + await page.Context.CloseAsync(); + } + } +} diff --git a/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs new file mode 100644 index 0000000..de287dc --- /dev/null +++ b/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs @@ -0,0 +1,222 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : AuthorNavigationTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +using AppHost.Tests.E2E.Helpers; + +namespace AppHost.Tests.E2E.Navigation; + +/// +/// E2E tests for Author role navigation behavior. +/// Verifies that Author users see appropriate menu items and do NOT see admin-only items. +/// +[ExcludeFromCodeCoverage] +[Collection("PlaywrightE2E")] +public class AuthorNavigationTests(PlaywrightFixture fixture) +{ + private const string AuthorRole = "AUTHOR"; + + /// + /// Verifies that an Author user can successfully log in via Auth0. + /// + [Fact] + public async Task Author_CanLoginSuccessfully() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AuthorRole); + if (credentials is null) + throw SkipException.ForSkip("Author test credentials not configured (E2E_TEST_AUTHOR_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + var loginSuccess = await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Assert + loginSuccess.Should().BeTrue("Author should be able to log in successfully"); + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Page should show logged-in state"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Author user sees appropriate menu items (Home, Issues, New Issue). + /// + [Fact] + public async Task Author_SeesAppropriateMenuItems() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AuthorRole); + if (credentials is null) + throw SkipException.ForSkip("Author test credentials not configured (E2E_TEST_AUTHOR_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act - Navigate to home page to see the nav menu + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Author should see basic menu items + var homeLink = page.Locator("a[href='/']").First; + var issuesLink = page.Locator("a[href='/issues']"); + var newIssueLink = page.Locator("a[href='/issues/create']"); + + (await homeLink.IsVisibleAsync()).Should().BeTrue("Author should see Home link"); + (await issuesLink.IsVisibleAsync()).Should().BeTrue("Author should see Issues link"); + (await newIssueLink.IsVisibleAsync()).Should().BeTrue("Author should see New Issue link"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Author user does NOT see admin-only menu items. + /// + [Fact] + public async Task Author_DoesNotSeeAdminOnlyMenuItems() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AuthorRole); + if (credentials is null) + throw SkipException.ForSkip("Author test credentials not configured (E2E_TEST_AUTHOR_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act - Navigate to home page to see the nav menu + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Author should NOT see admin-only menu items + var categoriesLink = page.Locator("a[href='/categories']"); + var statusesLink = page.Locator("a[href='/statuses']"); + var adminLink = page.Locator("a[href='/admin']"); + var sampleDataLink = page.Locator("a[href='/sample-data']"); + + (await categoriesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Author should NOT see Categories link"); + (await statusesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Author should NOT see Statuses link"); + (await adminLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Author should NOT see Admin link"); + (await sampleDataLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Author should NOT see Sample Data link"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Author user can navigate to the Issues page. + /// + [Fact] + public async Task Author_CanNavigateToIssuesPage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AuthorRole); + if (credentials is null) + throw SkipException.ForSkip("Author test credentials not configured (E2E_TEST_AUTHOR_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act + await page.ClickAsync("a[href='/issues']"); + await page.WaitForURLAsync("**/issues**"); + + // Assert + page.Url.Should().Contain("/issues"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Author user can navigate to the New Issue page. + /// + [Fact] + public async Task Author_CanNavigateToNewIssuePage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AuthorRole); + if (credentials is null) + throw SkipException.ForSkip("Author test credentials not configured (E2E_TEST_AUTHOR_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act + await page.ClickAsync("a[href='/issues/create']"); + await page.WaitForURLAsync("**/issues/create**"); + + // Assert + page.Url.Should().Contain("/issues/create"); + } + finally + { + await page.Context.CloseAsync(); + } + } +} diff --git a/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs b/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs new file mode 100644 index 0000000..f565c48 --- /dev/null +++ b/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs @@ -0,0 +1,293 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : LogoutTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +using AppHost.Tests.E2E.Helpers; + +namespace AppHost.Tests.E2E.Navigation; + +/// +/// E2E tests for logout functionality. +/// Verifies that logout works correctly and protected content is hidden after logout. +/// +[ExcludeFromCodeCoverage] +[Collection("PlaywrightE2E")] +public class LogoutTests(PlaywrightFixture fixture) +{ + private const string AdminRole = "ADMIN"; + private const string AuthorRole = "AUTHOR"; + private const string UserRole = "USER"; + + /// + /// Verifies that an Admin user can log out successfully. + /// + [Fact] + public async Task Admin_CanLogoutSuccessfully() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // First login + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Verify logged in + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Should be logged in before logout test"); + + // Act - Log out + await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); + + // Assert + (await Auth0LoginHelper.IsLoginButtonVisibleAsync(page)).Should().BeTrue("Login button should be visible after logout"); + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeFalse("Should not show logged-in state after logout"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that admin menu items are hidden after logout. + /// + [Fact] + public async Task Admin_MenuItemsHiddenAfterLogout() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AdminRole); + if (credentials is null) + throw SkipException.ForSkip("Admin test credentials not configured (E2E_TEST_ADMIN_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // First login + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Verify admin menu items are visible + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + var adminLinkBefore = page.Locator("a[href='/admin']"); + (await adminLinkBefore.IsVisibleAsync()).Should().BeTrue("Admin link should be visible before logout"); + + // Act - Log out + await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Admin menu items should be hidden + var categoriesLink = page.Locator("a[href='/categories']"); + var statusesLink = page.Locator("a[href='/statuses']"); + var adminLink = page.Locator("a[href='/admin']"); + var sampleDataLink = page.Locator("a[href='/sample-data']"); + + (await categoriesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Categories link should be hidden after logout"); + (await statusesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Statuses link should be hidden after logout"); + (await adminLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Admin link should be hidden after logout"); + (await sampleDataLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Sample Data link should be hidden after logout"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an Author user can log out successfully. + /// + [Fact] + public async Task Author_CanLogoutSuccessfully() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AuthorRole); + if (credentials is null) + throw SkipException.ForSkip("Author test credentials not configured (E2E_TEST_AUTHOR_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // First login + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Verify logged in + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Should be logged in before logout test"); + + // Act - Log out + await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); + + // Assert + (await Auth0LoginHelper.IsLoginButtonVisibleAsync(page)).Should().BeTrue("Login button should be visible after logout"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that the "New Issue" link is hidden after logout. + /// + [Fact] + public async Task Author_NewIssueLinkHiddenAfterLogout() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(AuthorRole); + if (credentials is null) + throw SkipException.ForSkip("Author test credentials not configured (E2E_TEST_AUTHOR_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // First login + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Verify "New Issue" link is visible while logged in + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + var newIssueLinkBefore = page.Locator("a[href='/issues/create']"); + (await newIssueLinkBefore.IsVisibleAsync()).Should().BeTrue("New Issue link should be visible before logout"); + + // Act - Log out + await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert + var newIssueLinkAfter = page.Locator("a[href='/issues/create']"); + (await newIssueLinkAfter.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("New Issue link should be hidden after logout"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that a User can log out successfully. + /// + [Fact] + public async Task User_CanLogoutSuccessfully() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(UserRole); + if (credentials is null) + throw SkipException.ForSkip("User test credentials not configured (E2E_TEST_USER_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // First login + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Verify logged in + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Should be logged in before logout test"); + + // Act - Log out + await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); + + // Assert + (await Auth0LoginHelper.IsLoginButtonVisibleAsync(page)).Should().BeTrue("Login button should be visible after logout"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that protected routes redirect to login after logout. + /// + [Fact] + public async Task User_ProtectedRouteRedirectsAfterLogout() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(UserRole); + if (credentials is null) + throw SkipException.ForSkip("User test credentials not configured (E2E_TEST_USER_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // First login + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Log out + await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); + + // Act - Try to access protected page after logout + await page.GotoAsync($"{fixture.WebUrl.TrimEnd('/')}/issues/create", + new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Should redirect to login + var currentUrl = page.Url; + var isRedirectedToLogin = currentUrl.Contains("auth0.com") || + currentUrl.Contains("/auth/login") || + currentUrl.Contains("/u/login"); + + isRedirectedToLogin.Should().BeTrue( + $"Protected route should redirect to login after logout, but URL is: {currentUrl}"); + } + finally + { + await page.Context.CloseAsync(); + } + } +} diff --git a/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs new file mode 100644 index 0000000..094ba10 --- /dev/null +++ b/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs @@ -0,0 +1,230 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : UnauthenticatedNavigationTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +using AppHost.Tests.E2E.Helpers; + +namespace AppHost.Tests.E2E.Navigation; + +/// +/// E2E tests for unauthenticated user navigation behavior. +/// Verifies that anonymous users see login button and protected routes redirect to login. +/// +[ExcludeFromCodeCoverage] +[Collection("PlaywrightE2E")] +public class UnauthenticatedNavigationTests(PlaywrightFixture fixture) +{ + /// + /// Verifies that an unauthenticated user sees the login button. + /// + [Fact] + public async Task Unauthenticated_SeesLoginButton() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert + (await Auth0LoginHelper.IsLoginButtonVisibleAsync(page)).Should().BeTrue("Unauthenticated user should see login button"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an unauthenticated user does NOT see the logout button. + /// + [Fact] + public async Task Unauthenticated_DoesNotSeeLogoutButton() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeFalse("Unauthenticated user should NOT see logout button"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an unauthenticated user does NOT see the "New Issue" link. + /// + [Fact] + public async Task Unauthenticated_DoesNotSeeNewIssueLink() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert + var newIssueLink = page.Locator("a[href='/issues/create']"); + (await newIssueLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Unauthenticated user should NOT see New Issue link"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that an unauthenticated user does NOT see admin-only menu items. + /// + [Fact] + public async Task Unauthenticated_DoesNotSeeAdminOnlyMenuItems() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert + var categoriesLink = page.Locator("a[href='/categories']"); + var statusesLink = page.Locator("a[href='/statuses']"); + var adminLink = page.Locator("a[href='/admin']"); + var sampleDataLink = page.Locator("a[href='/sample-data']"); + + (await categoriesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Unauthenticated user should NOT see Categories link"); + (await statusesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Unauthenticated user should NOT see Statuses link"); + (await adminLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Unauthenticated user should NOT see Admin link"); + (await sampleDataLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("Unauthenticated user should NOT see Sample Data link"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that navigating to a protected route redirects to login. + /// The /issues/create route requires authorization. + /// + [Fact] + public async Task Unauthenticated_ProtectedRouteRedirectsToLogin() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act - Try to navigate directly to a protected page + await page.GotoAsync($"{fixture.WebUrl.TrimEnd('/')}/issues/create", + new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Should redirect to login or show redirect-to-login component + // The app uses a RedirectToLoginPage component that navigates to /auth/login + var currentUrl = page.Url; + + // Either redirected to Auth0 login or to our /auth/login endpoint + var isRedirectedToLogin = currentUrl.Contains("auth0.com") || + currentUrl.Contains("/auth/login") || + currentUrl.Contains("/u/login"); + + isRedirectedToLogin.Should().BeTrue( + $"Protected route should redirect to login, but URL is: {currentUrl}"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that the Issues page is accessible without authentication. + /// + [Fact] + public async Task Unauthenticated_CanAccessIssuesPage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + await page.GotoAsync($"{fixture.WebUrl.TrimEnd('/')}/issues", + new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Should stay on issues page (not redirect) + page.Url.Should().Contain("/issues"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that the home page is accessible without authentication. + /// + [Fact] + public async Task Unauthenticated_CanAccessHomePage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - Should not redirect to login + page.Url.Should().NotContain("auth0.com"); + page.Url.Should().NotContain("/auth/login"); + } + finally + { + await page.Context.CloseAsync(); + } + } +} diff --git a/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs new file mode 100644 index 0000000..ab15e45 --- /dev/null +++ b/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs @@ -0,0 +1,220 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : UserNavigationTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : IssueManager +// Project Name : AppHost.Tests.E2E +// ============================================= + +using AppHost.Tests.E2E.Helpers; + +namespace AppHost.Tests.E2E.Navigation; + +/// +/// E2E tests for User role navigation behavior. +/// Verifies that basic User role sees appropriate menu items and does NOT see admin-only items. +/// +[ExcludeFromCodeCoverage] +[Collection("PlaywrightE2E")] +public class UserNavigationTests(PlaywrightFixture fixture) +{ + private const string UserRole = "USER"; + + /// + /// Verifies that a User can successfully log in via Auth0. + /// + [Fact] + public async Task User_CanLoginSuccessfully() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(UserRole); + if (credentials is null) + throw SkipException.ForSkip("User test credentials not configured (E2E_TEST_USER_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + // Act + var loginSuccess = await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Assert + loginSuccess.Should().BeTrue("User should be able to log in successfully"); + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Page should show logged-in state"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that a User sees appropriate menu items (Home, Issues). + /// + [Fact] + public async Task User_SeesAppropriateMenuItems() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(UserRole); + if (credentials is null) + throw SkipException.ForSkip("User test credentials not configured (E2E_TEST_USER_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act - Navigate to home page to see the nav menu + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - User should see basic menu items + var homeLink = page.Locator("a[href='/']").First; + var issuesLink = page.Locator("a[href='/issues']"); + + (await homeLink.IsVisibleAsync()).Should().BeTrue("User should see Home link"); + (await issuesLink.IsVisibleAsync()).Should().BeTrue("User should see Issues link"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that a User does NOT see admin-only menu items. + /// + [Fact] + public async Task User_DoesNotSeeAdminOnlyMenuItems() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(UserRole); + if (credentials is null) + throw SkipException.ForSkip("User test credentials not configured (E2E_TEST_USER_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act - Navigate to home page to see the nav menu + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - User should NOT see admin-only menu items + var categoriesLink = page.Locator("a[href='/categories']"); + var statusesLink = page.Locator("a[href='/statuses']"); + var adminLink = page.Locator("a[href='/admin']"); + var sampleDataLink = page.Locator("a[href='/sample-data']"); + + (await categoriesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("User should NOT see Categories link"); + (await statusesLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("User should NOT see Statuses link"); + (await adminLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("User should NOT see Admin link"); + (await sampleDataLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) + .Should().BeFalse("User should NOT see Sample Data link"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that a User can navigate to the Issues page. + /// + [Fact] + public async Task User_CanNavigateToIssuesPage() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(UserRole); + if (credentials is null) + throw SkipException.ForSkip("User test credentials not configured (E2E_TEST_USER_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act + await page.ClickAsync("a[href='/issues']"); + await page.WaitForURLAsync("**/issues**"); + + // Assert + page.Url.Should().Contain("/issues"); + } + finally + { + await page.Context.CloseAsync(); + } + } + + /// + /// Verifies that a User sees the "New Issue" link after login (Authorized users can create issues). + /// + [Fact] + public async Task User_SeesNewIssueLinkAfterLogin() + { + // Arrange + if (!fixture.IsAvailable) + throw SkipException.ForSkip(fixture.UnavailableReason ?? "Playwright fixture unavailable"); + + var credentials = Auth0LoginHelper.GetTestCredentials(UserRole); + if (credentials is null) + throw SkipException.ForSkip("User test credentials not configured (E2E_TEST_USER_EMAIL/PASSWORD)"); + + var page = await fixture.NewPageAsync(); + + try + { + await Auth0LoginHelper.LoginAsync( + page, + fixture.WebUrl, + credentials.Value.Email, + credentials.Value.Password); + + // Act - Navigate to home page to see the nav menu + await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + + // Assert - All authenticated users can see "New Issue" link + var newIssueLink = page.Locator("a[href='/issues/create']"); + (await newIssueLink.IsVisibleAsync()).Should().BeTrue("Authenticated User should see New Issue link"); + } + finally + { + await page.Context.CloseAsync(); + } + } +} From a2e4c268a0c091714ec07f5c6b989790238c168a Mon Sep 17 00:00:00 2001 From: Scribe Date: Thu, 12 Mar 2026 00:11:34 -0700 Subject: [PATCH 2/3] docs: Update Gimli history with E2E navigation test learnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/gimli/history.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.squad/agents/gimli/history.md b/.squad/agents/gimli/history.md index 694a0aa..58d134d 100644 --- a/.squad/agents/gimli/history.md +++ b/.squad/agents/gimli/history.md @@ -757,3 +757,32 @@ History file currently at 37KB. If exceeded 12KB limit in future, will require s - This separation ensures explicit ID ownership at the application layer **Outcome:** All 35 Create handler unit tests pass. Integration tests verified fixed (skipped in CI due to no Docker). + +--- + +## 2026-03-12 — Playwright E2E Role-Based Navigation Tests + +**Task:** Issue #109 — Implement Playwright E2E tests for role-based navigation. + +**New Test Structure:** +- `tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs` — Shared fixture initializing Aspire AppHost + Playwright browser +- `tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs` — xUnit collection definition for shared fixture +- `tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs` — Browser-based Auth0 OIDC authentication helper +- `tests/AppHost.Tests.E2E/Navigation/` — 5 test classes with 30 total tests + +**Key Patterns:** +1. **PlaywrightFixture** extends Aspire testing — builds the distributed app, starts it, waits for `web` resource to be running, then initializes Playwright browser +2. **Test credentials from environment variables** — `E2E_TEST_{ROLE}_EMAIL` / `E2E_TEST_{ROLE}_PASSWORD` for Admin, Author, User roles; tests skip gracefully when not configured +3. **Browser context isolation** — Each test creates a fresh context via `fixture.NewPageAsync()` with `IgnoreHTTPSErrors = true` +4. **Auth0 Universal Login flow** — Navigate to `/auth/login`, fill form on Auth0 hosted page, wait for redirect back to app +5. **Graceful skip pattern** — Tests check `fixture.IsAvailable` and throw `SkipException.ForSkip()` when Aspire/Docker initialization fails +6. **LocatorIsVisibleOptions.Timeout is obsolete** — Causes warnings but still works; the property is deprecated in newer Playwright + +**Test Coverage:** +- AdminNavigationTests (6): Login, 4 admin menu items visible, navigation to each admin page +- AuthorNavigationTests (5): Login, basic menu items visible, admin items NOT visible, navigation +- UserNavigationTests (5): Login, basic menu items visible, admin items NOT visible, New Issue visible +- UnauthenticatedNavigationTests (7): Login button visible, logout NOT visible, protected routes redirect, public pages accessible +- LogoutTests (7): Logout works for each role, protected content hidden after logout + +**PR:** #112 (closes #109) From ceb1bf47598d933572d4f994d76fcda077386c74 Mon Sep 17 00:00:00 2001 From: Scribe Date: Thu, 12 Mar 2026 00:35:21 -0700 Subject: [PATCH 3/3] fix: Address PR review feedback for E2E test robustness - Fix PlaywrightFixture to use Website constant instead of hardcoded 'web' - Update PlaywrightFixture doc comment to not reference non-existent class - Add login verification before negative assertions in test methods - Add wait for unauthenticated state (login button visible) before asserting menu items are hidden in UnauthenticatedNavigationTests and LogoutTests Addresses Copilot reviewer feedback on PR #112. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs | 8 ++++---- .../AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs | 3 +++ .../AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs | 3 +++ tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs | 8 ++++++++ .../Navigation/UnauthenticatedNavigationTests.cs | 4 ++++ tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs | 3 +++ 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs b/tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs index 55f5222..671e037 100644 --- a/tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs +++ b/tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs @@ -10,7 +10,7 @@ namespace AppHost.Tests.E2E.Fixtures; /// -/// Playwright fixture that extends DistributedApplicationFixture. +/// Playwright fixture that hosts the Aspire AppHost for end-to-end tests. /// Initializes Playwright and provides browser/page instances for E2E tests. /// Starts the Aspire AppHost and captures the Web app URL for browser navigation. /// @@ -100,13 +100,13 @@ public async ValueTask InitializeAsync() // Wait for the web app to be running await _notificationService.WaitForResourceAsync( - "web", + Website, KnownResourceStates.Running, CancellationToken.None).WaitAsync(TimeSpan.FromMinutes(3)); // Get the web app URL - _webUrl = _app.GetEndpoint("web", "https")?.ToString() - ?? _app.GetEndpoint("web", "http")?.ToString(); + _webUrl = _app.GetEndpoint(Website, "https")?.ToString() + ?? _app.GetEndpoint(Website, "http")?.ToString(); if (string.IsNullOrEmpty(_webUrl)) { diff --git a/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs index a7a331b..95aad2d 100644 --- a/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs +++ b/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs @@ -80,6 +80,9 @@ await Auth0LoginHelper.LoginAsync( credentials.Value.Email, credentials.Value.Password); + // Verify login succeeded + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Admin should be logged in before checking navigation"); + // Act - Navigate to home page to see the nav menu await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); diff --git a/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs index de287dc..f54e21e 100644 --- a/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs +++ b/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs @@ -122,6 +122,9 @@ await Auth0LoginHelper.LoginAsync( credentials.Value.Email, credentials.Value.Password); + // Verify login succeeded before checking menu visibility + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Author should be logged in before checking navigation"); + // Act - Navigate to home page to see the nav menu await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); diff --git a/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs b/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs index f565c48..2a6b283 100644 --- a/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs +++ b/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs @@ -98,6 +98,10 @@ await Auth0LoginHelper.LoginAsync( await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + // Verify we are in unauthenticated state before checking menu visibility + var loginLink = page.Locator("a[href='/auth/login']"); + await loginLink.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 5000 }); + // Assert - Admin menu items should be hidden var categoriesLink = page.Locator("a[href='/categories']"); var statusesLink = page.Locator("a[href='/statuses']"); @@ -193,6 +197,10 @@ await Auth0LoginHelper.LoginAsync( await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + // Verify we are in unauthenticated state before checking menu visibility + var loginLink = page.Locator("a[href='/auth/login']"); + await loginLink.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 5000 }); + // Assert var newIssueLinkAfter = page.Locator("a[href='/issues/create']"); (await newIssueLinkAfter.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 2000 })) diff --git a/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs index 094ba10..b969cd8 100644 --- a/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs +++ b/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs @@ -116,6 +116,10 @@ public async Task Unauthenticated_DoesNotSeeAdminOnlyMenuItems() // Act await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + // Verify we are in unauthenticated state before checking menu visibility + var loginLink = page.Locator("a[href='/auth/login']"); + await loginLink.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 5000 }); + // Assert var categoriesLink = page.Locator("a[href='/categories']"); var statusesLink = page.Locator("a[href='/statuses']"); diff --git a/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs b/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs index ab15e45..6b58961 100644 --- a/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs +++ b/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs @@ -120,6 +120,9 @@ await Auth0LoginHelper.LoginAsync( credentials.Value.Email, credentials.Value.Password); + // Verify login succeeded before checking menu visibility + (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("User should be logged in before checking navigation"); + // Act - Navigate to home page to see the nav menu await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });