using System.Text.RegularExpressions; using Microsoft.Playwright; using Microsoft.Playwright.NUnit; using Tests.PageObjects; namespace Tests; [Parallelizable(ParallelScope.Self)] [TestFixture] public class PasskeyTests : PageTest { private const string BaseUrl = "http://localhost:5256"; private LoginPage _loginPage = null!; [SetUp] public async Task SetUp() { _loginPage = new LoginPage(Page); // Clear any auth cookies and wipe all stored credentials for a clean slate await Page.Context.ClearCookiesAsync(); using var http = new HttpClient(); await http.DeleteAsync($"{BaseUrl}/api/auth/credentials"); } [TearDown] public async Task TearDown() { using var http = new HttpClient(); await http.DeleteAsync($"{BaseUrl}/api/auth/credentials"); await Page.Context.ClearCookiesAsync(); } [Test] public async Task LoginPage_WhenNoCredentials_ShowsEnrollButton() { await _loginPage.GotoAsync(); await _loginPage.WaitForInteractiveAsync(); await Expect(_loginPage.EnrollButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); await Expect(_loginPage.SignInButton).Not.ToBeVisibleAsync(); } [Test] public async Task EnrollPasskey_CompletesSuccessfully_AndRedirectsHome() { var authenticatorId = await Page.Context.AddVirtualAuthenticatorAsync(new VirtualAuthenticatorOptions { Protocol = "ctap2", Transport = "internal", HasResidentKey = true, HasUserVerification = true, IsUserVerified = true, }); try { await _loginPage.GotoAsync(); await _loginPage.WaitForInteractiveAsync(); await Expect(_loginPage.EnrollButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); await _loginPage.EnrollButton.ClickAsync(); // After enrollment + auto sign-in, redirects away from /login await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); } finally { await Page.Context.RemoveVirtualAuthenticatorAsync(authenticatorId); } } [Test] public async Task LoginPage_AfterEnrollment_ShowsSignInButton() { var authenticatorId = await Page.Context.AddVirtualAuthenticatorAsync(new VirtualAuthenticatorOptions { Protocol = "ctap2", Transport = "internal", HasResidentKey = true, HasUserVerification = true, IsUserVerified = true, }); try { // Enroll first await _loginPage.GotoAsync(); await _loginPage.WaitForInteractiveAsync(); await _loginPage.EnrollButton.ClickAsync(); await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); // Clear the auth cookie and navigate back to /login await Page.Context.ClearCookiesAsync(); await _loginPage.GotoAsync(); await _loginPage.WaitForInteractiveAsync(); // Should show Sign In button (credential is enrolled) await Expect(_loginPage.SignInButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); } finally { await Page.Context.RemoveVirtualAuthenticatorAsync(authenticatorId); } } [Test] public async Task SignInWithPasskey_AfterEnrollment_AuthenticatesSuccessfully() { var authenticatorId = await Page.Context.AddVirtualAuthenticatorAsync(new VirtualAuthenticatorOptions { Protocol = "ctap2", Transport = "internal", HasResidentKey = true, HasUserVerification = true, IsUserVerified = true, }); try { // Enroll await _loginPage.GotoAsync(); await _loginPage.WaitForInteractiveAsync(); await _loginPage.EnrollButton.ClickAsync(); await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); // Sign out (clear cookie) and return to login await Page.Context.ClearCookiesAsync(); await _loginPage.GotoAsync(); await _loginPage.WaitForInteractiveAsync(); await Expect(_loginPage.SignInButton).ToBeVisibleAsync(new() { Timeout = 10_000 }); await _loginPage.SignInButton.ClickAsync(); // After sign-in, redirects away from /login await Page.WaitForURLAsync(new Regex(@"^http://localhost:5256(?!/login)"), new() { Timeout = 20_000 }); } finally { await Page.Context.RemoveVirtualAuthenticatorAsync(authenticatorId); } } }