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)
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..671e037
--- /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 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.
+///
+[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(
+ Website,
+ KnownResourceStates.Running,
+ CancellationToken.None).WaitAsync(TimeSpan.FromMinutes(3));
+
+ // Get the web app URL
+ _webUrl = _app.GetEndpoint(Website, "https")?.ToString()
+ ?? _app.GetEndpoint(Website, "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..95aad2d
--- /dev/null
+++ b/tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs
@@ -0,0 +1,253 @@
+// ============================================
+// 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);
+
+ // 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 });
+
+ // 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..f54e21e
--- /dev/null
+++ b/tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs
@@ -0,0 +1,225 @@
+// ============================================
+// 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);
+
+ // 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 });
+
+ // 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..2a6b283
--- /dev/null
+++ b/tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs
@@ -0,0 +1,301 @@
+// ============================================
+// 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 });
+
+ // 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']");
+ 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 });
+
+ // 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 }))
+ .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..b969cd8
--- /dev/null
+++ b/tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs
@@ -0,0 +1,234 @@
+// ============================================
+// 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 });
+
+ // 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']");
+ 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..6b58961
--- /dev/null
+++ b/tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs
@@ -0,0 +1,223 @@
+// ============================================
+// 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);
+
+ // 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 });
+
+ // 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();
+ }
+ }
+}