using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; using System.Collections; using System.Linq.Expressions; using Xunit; namespace JobTrackerApi.Tests; public sealed class AuthAndSystemControllerTests { [Fact] public async Task Login_locks_account_after_five_failed_attempts_and_rejects_sixth_even_with_correct_password() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null); var failedCount = 0; var lockedOut = false; userManager.Setup(x => x.IsLockedOutAsync(user)).Returns(() => Task.FromResult(lockedOut)); userManager.Setup(x => x.AccessFailedAsync(user)) .Callback(() => { failedCount++; if (failedCount >= 5) lockedOut = true; }) .ReturnsAsync(IdentityResult.Success); userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false); userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); for (var i = 0; i < 5; i++) { var attempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "wrong-password"), CancellationToken.None); Assert.IsType(attempt); } Assert.True(lockedOut); var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); Assert.IsType(sixthAttempt); userManager.Verify(x => x.CheckPasswordAsync(user, "correct-password"), Times.Never); } [Fact] public async Task Login_skips_two_factor_when_trusted_device_cookie_matches_current_user() { var dbName = Guid.NewGuid().ToString(); var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" }; const string token = "trusted-device-token"; using (var seedDb = BuildDb(dbName, null)) { seedDb.TrustedDevices.Add(new TrustedDevice { UserId = "user-1", TokenHash = TrustedDeviceService.HashToken(token), CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30), }); seedDb.SaveChanges(); } var controller = BuildLoginController(user, "correct-password", out var db, dbName, token); using (db) { var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); var ok = Assert.IsType(result); var session = Assert.IsType(ok.Value); Assert.True(session.Authenticated); } } [Fact] public async Task Login_does_not_skip_two_factor_when_trusted_device_belongs_to_different_user() { var dbName = Guid.NewGuid().ToString(); var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" }; const string token = "trusted-device-token"; using (var seedDb = BuildDb(dbName, null)) { seedDb.TrustedDevices.Add(new TrustedDevice { UserId = "user-2", // a different account -- must not skip 2FA for user-1 TokenHash = TrustedDeviceService.HashToken(token), CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30), }); seedDb.SaveChanges(); } var controller = BuildLoginController(user, "correct-password", out var db, dbName, token); using (db) { var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); var ok = Assert.IsType(result); Assert.IsType(ok.Value); } } [Fact] public async Task Login_does_not_skip_two_factor_when_trusted_device_is_expired() { var dbName = Guid.NewGuid().ToString(); var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" }; const string token = "trusted-device-token"; using (var seedDb = BuildDb(dbName, null)) { seedDb.TrustedDevices.Add(new TrustedDevice { UserId = "user-1", TokenHash = TrustedDeviceService.HashToken(token), CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-31), LastSeenAtUtc = DateTimeOffset.UtcNow.AddDays(-31), ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(-1), // expired yesterday }); seedDb.SaveChanges(); } var controller = BuildLoginController(user, "correct-password", out var db, dbName, token); using (db) { var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); var ok = Assert.IsType(result); Assert.IsType(ok.Value); } } [Fact] public async Task Login_does_not_skip_two_factor_when_no_trusted_device_cookie_present() { var dbName = Guid.NewGuid().ToString(); var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" }; var controller = BuildLoginController(user, "correct-password", out var db, dbName, cookieToken: null); using (db) { var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); var ok = Assert.IsType(result); Assert.IsType(ok.Value); } } [Fact] public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true", ["Auth:RequireEmailVerification"] = "true", }) .Build(); var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null); ApplicationUser? created = null; userManager .Setup(x => x.CreateAsync(It.IsAny(), "password123")) .Callback((u, _) => created = u) .ReturnsAsync(IdentityResult.Success); userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny())).ReturnsAsync("confirm-token"); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var emailSender = new Mock(); using var db = TestHostFactory.CreateInMemoryDb(); var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None); var accepted = Assert.IsType(result); Assert.Equal(StatusCodes.Status202Accepted, accepted.StatusCode); Assert.True(Assert.IsType(accepted.Value).VerificationRequired); Assert.NotNull(created); Assert.False(created!.EmailConfirmed); Assert.Empty(db.UserSessions); tokenService.Verify(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny(), It.Is(b => b.Contains("verify-email")), It.IsAny()), Times.Once); } [Fact] public async Task Register_is_unchanged_when_flag_off() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }) .Build(); var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null); ApplicationUser? created = null; userManager .Setup(x => x.CreateAsync(It.IsAny(), "password123")) .Callback((u, _) => created = u) .ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var emailSender = new Mock(); var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None); Assert.IsType(result); Assert.NotNull(created); Assert.True(created!.EmailConfirmed); emailSender.Verify(x => x.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Login_rejects_unconfirmed_local_account_when_flag_on() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Auth:RequireEmailVerification"] = "true" }) .Build(); var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false }; var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null); userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false); userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); var controller = new AuthController(config, userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); var obj = Assert.IsType(result); Assert.Equal(StatusCodes.Status403Forbidden, obj.StatusCode); } [Fact] public async Task Login_allows_unconfirmed_local_account_when_flag_off() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false }; var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null); userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false); userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); var ok = Assert.IsType(result); var session = Assert.IsType(ok.Value); Assert.True(session.Authenticated); } [Fact] public async Task VerifyEmail_confirms_account_on_valid_token() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false }; var userManager = CreateUserManager(); userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); userManager.Setup(x => x.ConfirmEmailAsync(user, "good-token")).ReturnsAsync(IdentityResult.Success); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token")); Assert.IsType(result); } [Fact] public async Task ResendVerificationEmail_returns_identical_response_for_real_and_fake_accounts() { var user = new ApplicationUser { Id = "user-1", Email = "real@example.com", UserName = "real@example.com", EmailConfirmed = false }; var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync("real@example.com")).ReturnsAsync(user); userManager.Setup(x => x.FindByEmailAsync("fake@example.com")).ReturnsAsync((ApplicationUser?)null); userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true); userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)).ReturnsAsync("confirm-token"); var emailSender = new Mock(); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var realResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("real@example.com"), CancellationToken.None); var fakeResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("fake@example.com"), CancellationToken.None); Assert.IsType(realResult); Assert.IsType(fakeResult); emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Exchange_google_token_new_user_stays_EmailConfirmed_true_even_when_verification_flag_is_on() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true", ["Auth:RequireEmailVerification"] = "true", }) .Build(); var userManager = CreateUserManager(); userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null); ApplicationUser? created = null; userManager .Setup(x => x.CreateAsync(It.IsAny())) .Callback(u => created = u) .ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var googleValidator = new Mock(); googleValidator .Setup(x => x.ValidateAsync("google-token", It.IsAny())) .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "new.hire@example.com", true, "New", "Hire", "New Hire")); var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); Assert.IsType(result); Assert.NotNull(created); Assert.True(created!.EmailConfirmed); } private static JobTrackerContext BuildDb(string dbName, string? currentUserId) { var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; var currentUser = new Mock(); currentUser.SetupGet(x => x.UserId).Returns(currentUserId); return new JobTrackerContext(options, currentUser.Object); } private static AuthController BuildLoginController(ApplicationUser user, string password, out JobTrackerContext db, string dbName, string? cookieToken) { var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync(user.Email!)).ReturnsAsync(user); userManager.Setup(x => x.FindByNameAsync(user.Email!)).ReturnsAsync((ApplicationUser?)null); userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false); userManager.Setup(x => x.CheckPasswordAsync(user, password)).ReturnsAsync(true); userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); db = BuildDb(dbName, null); var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; if (cookieToken is not null) { controller.Request.Headers["Cookie"] = $"{AuthSessionOptions.TrustedDeviceCookieName}={cookieToken}"; } return controller; } [Fact] public async Task Update_profile_applies_trimmed_profile_fields() { var user = new ApplicationUser { Email = "old@example.com", UserName = "olduser" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null)); Assert.IsType(result); Assert.Equal("old@example.com", user.Email); Assert.Equal("newuser", user.UserName); Assert.Equal("Ada", user.FirstName); Assert.Equal("Lovelace", user.LastName); Assert.Equal("Ada L.", user.DisplayName); } [Fact] public async Task Request_email_change_keeps_current_email_and_sends_confirmation_to_new_address() { var user = new ApplicationUser { Id = "user-1", Email = "old@example.com", UserName = "old@example.com", EmailConfirmed = true, SecurityStamp = "old-stamp" }; var users = CreateUserManager(); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true); users.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); users.Setup(x => x.NormalizeEmail(It.IsAny())).Returns((string value) => value.ToUpperInvariant()); users.Setup(x => x.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null); users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); users.Setup(x => x.GenerateChangeEmailTokenAsync(user, "new@example.com")).ReturnsAsync("change-token"); var email = new Mock(); var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), email.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.RequestEmailChange(new AuthController.RequestEmailChangeRequest("new@example.com", "correct-password"), CancellationToken.None); Assert.IsType(result); Assert.Equal("old@example.com", user.Email); Assert.Equal("new@example.com", user.PendingEmail); Assert.NotNull(user.PendingEmailRequestedAtUtc); Assert.NotEqual("old-stamp", user.SecurityStamp); email.Verify(x => x.SendAsync("new@example.com", It.IsAny(), It.Is(body => body.Contains("confirm-email-change")), It.IsAny()), Times.Once); email.Verify(x => x.SendAsync("old@example.com", It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task Confirm_email_change_rejects_a_superseded_address() { var user = new ApplicationUser { Id = "user-1", Email = "old@example.com", PendingEmail = "latest@example.com" }; var users = CreateUserManager(); users.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); users.Setup(x => x.NormalizeEmail(It.IsAny())).Returns((string value) => value.ToUpperInvariant()); var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); var result = await controller.ConfirmEmailChange(new AuthController.ConfirmEmailChangeRequest("user-1", "superseded@example.com", "old-token"), CancellationToken.None); Assert.IsType(result); users.Verify(x => x.ChangeEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Confirm_email_change_updates_default_username_and_revokes_sessions_and_devices() { var user = new ApplicationUser { Id = "user-1", Email = "old@example.com", UserName = "old@example.com", PendingEmail = "new@example.com", PendingEmailRequestedAtUtc = DateTimeOffset.UtcNow }; var users = CreateUserManager(); users.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); users.Setup(x => x.NormalizeEmail(It.IsAny())).Returns((string value) => value.ToUpperInvariant()); users.Setup(x => x.NormalizeName(It.IsAny())).Returns((string value) => value.ToUpperInvariant()); users.Setup(x => x.ChangeEmailAsync(user, "new@example.com", "change-token")) .Callback(() => { user.Email = "new@example.com"; user.EmailConfirmed = true; }) .ReturnsAsync(IdentityResult.Success); users.Setup(x => x.SetUserNameAsync(user, "new@example.com")) .Callback(() => user.UserName = "new@example.com") .ReturnsAsync(IdentityResult.Success); users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); using var db = TestHostFactory.CreateInMemoryDb(); db.UserSessions.Add(new UserSession { Id = "sid-1", UserId = user.Id, CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1) }); db.TrustedDevices.Add(new TrustedDevice { UserId = user.Id, TokenHash = "hash", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(1) }); await db.SaveChangesAsync(); var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ConfirmEmailChange(new AuthController.ConfirmEmailChangeRequest("user-1", "new@example.com", "change-token"), CancellationToken.None); Assert.IsType(result); Assert.Equal("new@example.com", user.Email); Assert.Equal("new@example.com", user.UserName); Assert.Null(user.PendingEmail); Assert.NotNull((await db.UserSessions.SingleAsync()).RevokedAtUtc); Assert.Empty(await db.TrustedDevices.ToListAsync()); } private static AuthController BuildProfileController(ApplicationUser user) { var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); return new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); } [Fact] public async Task Identity_only_save_does_not_wipe_the_master_cv() { // The Phase 2 /profile save omits the CV fields. A null field must leave the master CV // untouched -- the data-integrity guarantee behind splitting the profile/career save. var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada", ProfileCvText = "Existing master CV text", ProfileCvStructureJson = "{\"jobs\":[]}", }; var controller = BuildProfileController(user); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, "Ada", "Lovelace", "Ada L.", null, null)); Assert.IsType(result); Assert.Equal("Ada", user.FirstName); Assert.Equal("Existing master CV text", user.ProfileCvText); Assert.Equal("{\"jobs\":[]}", user.ProfileCvStructureJson); } [Fact] public async Task Career_only_save_does_not_wipe_identity() { // The mirror: /career saves the master profile and omits identity fields. var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada", FirstName = "Ada", LastName = "Lovelace", DisplayName = "Ada L.", }; var controller = BuildProfileController(user); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, null, "New CV text", "{\"jobs\":[1]}")); Assert.IsType(result); Assert.Equal("Ada", user.FirstName); Assert.Equal("Lovelace", user.LastName); Assert.Equal("Ada L.", user.DisplayName); Assert.Equal("New CV text", user.ProfileCvText); Assert.Equal("{\"jobs\":[1]}", user.ProfileCvStructureJson); } [Fact] public async Task Empty_string_clears_a_field_but_null_leaves_it() { var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada", DisplayName = "Ada L.", FirstName = "Ada" }; var controller = BuildProfileController(user); // DisplayName "" -> cleared; FirstName null -> unchanged. var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(null, null, null, null, "", null, null)); Assert.IsType(result); Assert.Null(user.DisplayName); Assert.Equal("Ada", user.FirstName); } [Fact] public async Task Email_and_username_are_never_cleared_to_empty() { var user = new ApplicationUser { Email = "ada@example.com", UserName = "ada" }; var controller = BuildProfileController(user); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest("", "", null, null, null, null, null)); Assert.IsType(result); Assert.Equal("ada@example.com", user.Email); Assert.Equal("ada", user.UserName); } [Fact] public async Task Request_password_reset_returns_service_unavailable_when_email_send_fails() { var user = new ApplicationUser { Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = true }; var userManager = CreateUserManager(); userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true); userManager.Setup(x => x.GeneratePasswordResetTokenAsync(user)).ReturnsAsync("reset-token"); var emailSender = new Mock(); emailSender .Setup(x => x.SendAsync(user.Email!, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SMTP unavailable")); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; controller.Request.Scheme = "https"; controller.Request.Host = new HostString("jobtracker.test"); var result = await controller.RequestPasswordReset(new AuthController.RequestPasswordResetRequest("person@example.com"), CancellationToken.None); var problem = Assert.IsType(result); Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode); var details = Assert.IsType(problem.Value); Assert.Equal("Email delivery unavailable", details.Title); } [Fact] public async Task Exchange_google_token_auto_links_single_verified_email_match() { var user = new ApplicationUser { Id = "user-1", Email = "dj@cesnimda.co.uk", UserName = "dj@cesnimda.co.uk", }; var userManager = CreateUserManager(); userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); userManager.Setup(x => x.FindByEmailAsync("dj@cesnimda.co.uk")).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var googleValidator = new Mock(); googleValidator .Setup(x => x.ValidateAsync("google-token", It.IsAny())) .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones")); var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); var ok = Assert.IsType(result); var payload = Assert.IsType(ok.Value); Assert.True(payload.Authenticated); Assert.Equal("google", payload.Provider); Assert.Equal("google-subject", user.GoogleSubject); Assert.Equal("dj@cesnimda.co.uk", user.GoogleEmail); Assert.NotNull(user.GoogleLinkedAt); } [Fact] public async Task Exchange_microsoft_token_creates_new_user_when_registration_allowed() { const string tenantId = "11111111-1111-1111-1111-111111111111"; const string objectId = "22222222-2222-2222-2222-222222222222"; var userManager = CreateUserManager(); userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null); ApplicationUser? created = null; userManager .Setup(x => x.CreateAsync(It.IsAny())) .Callback(u => created = u) .ReturnsAsync(IdentityResult.Success); userManager.Setup(x => x.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var microsoftValidator = new Mock(); microsoftValidator .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "new.hire@example.com", false, "New", "Hire", "New Hire", tenantId, objectId)); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }) .Build(); var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); var ok = Assert.IsType(result); var payload = Assert.IsType(ok.Value); Assert.True(payload.Authenticated); Assert.Equal("microsoft", payload.Provider); Assert.NotNull(created); Assert.Equal("new.hire@example.com", created!.Email); Assert.False(created.EmailConfirmed); Assert.Equal(tenantId, created.MicrosoftTenantId); Assert.Equal(objectId, created.MicrosoftObjectId); Assert.Null(created.MicrosoftSubject); } [Fact] public async Task Exchange_microsoft_token_rejects_unmatched_account_when_registration_disabled() { const string tenantId = "11111111-1111-1111-1111-111111111111"; const string objectId = "22222222-2222-2222-2222-222222222222"; var userManager = CreateUserManager(); userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null); var microsoftValidator = new Mock(); microsoftValidator .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "nobody@example.com", false, null, null, null, tenantId, objectId)); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); Assert.IsType(result); userManager.Verify(x => x.CreateAsync(It.IsAny()), Times.Never); } [Fact] public async Task Exchange_microsoft_token_never_auto_links_an_existing_email() { const string tenantId = "11111111-1111-1111-1111-111111111111"; const string objectId = "22222222-2222-2222-2222-222222222222"; var existing = new ApplicationUser { Id = "local-1", Email = "same@example.com", UserName = "same@example.com", EmailConfirmed = true }; var users = CreateUserManager(); users.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); users.Setup(x => x.FindByEmailAsync("same@example.com")).ReturnsAsync(existing); var microsoft = new Mock(); microsoft.Setup(x => x.ValidateAsync("token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "same@example.com", false, null, null, null, tenantId, objectId)); var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }).Build(); var controller = new AuthController(config, users.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoft.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None); Assert.IsType(result); Assert.Null(existing.MicrosoftTenantId); users.Verify(x => x.UpdateAsync(existing), Times.Never); users.Verify(x => x.CreateAsync(It.IsAny()), Times.Never); } [Fact] public async Task Exchange_microsoft_token_uses_the_full_tenant_object_pair() { const string tenantA = "11111111-1111-1111-1111-111111111111"; const string tenantB = "33333333-3333-3333-3333-333333333333"; const string objectId = "22222222-2222-2222-2222-222222222222"; var userA = new ApplicationUser { Id = "user-a", Email = "a@example.com", UserName = "a", EmailConfirmed = true, MicrosoftTenantId = tenantA, MicrosoftObjectId = objectId }; var userB = new ApplicationUser { Id = "user-b", Email = "b@example.com", UserName = "b", EmailConfirmed = true, MicrosoftTenantId = tenantB, MicrosoftObjectId = objectId }; var users = CreateUserManager(); users.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new[] { userA, userB })); users.Setup(x => x.UpdateAsync(userB)).ReturnsAsync(IdentityResult.Success); var tokens = new Mock(); tokens.Setup(x => x.CreateAccessTokenAsync(userB, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var microsoft = new Mock(); microsoft.Setup(x => x.ValidateAsync("token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "shared@example.com", false, null, null, null, tenantB, objectId)); var controller = new AuthController(BuildConfig(), users.Object, tokens.Object, Mock.Of(), Mock.Of(), microsoft.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None); Assert.IsType(result); tokens.Verify(x => x.CreateAccessTokenAsync(userB, It.IsAny(), It.IsAny()), Times.Once); tokens.Verify(x => x.CreateAccessTokenAsync(userA, It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Exchange_legacy_microsoft_link_sends_proof_but_does_not_sign_in() { const string tenantId = "11111111-1111-1111-1111-111111111111"; const string objectId = "22222222-2222-2222-2222-222222222222"; var legacy = new ApplicationUser { Id = "legacy-1", Email = "legacy@example.com", EmailConfirmed = true, MicrosoftSubject = objectId, MicrosoftEmail = "legacy@example.com" }; var users = CreateUserManager(); users.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new[] { legacy })); users.Setup(x => x.GenerateUserTokenAsync(legacy, TokenOptions.DefaultProvider, It.Is(p => p.Contains(tenantId) && p.Contains(objectId)))).ReturnsAsync("recovery-token"); var microsoft = new Mock(); microsoft.Setup(x => x.ValidateAsync("token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "legacy@example.com", false, null, null, null, tenantId, objectId)); var email = new Mock(); var appTokens = new Mock(); var controller = new AuthController(BuildConfig(), users.Object, appTokens.Object, email.Object, Mock.Of(), microsoft.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None); Assert.IsType(result); Assert.Null(legacy.MicrosoftTenantId); appTokens.Verify(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); email.Verify(x => x.SendAsync("legacy@example.com", It.IsAny(), It.Is(body => body.Contains("microsoft-legacy-relink")), It.IsAny()), Times.Once); } [Fact] public async Task Link_microsoft_requires_current_password_and_uses_canonical_pair_only() { const string tenantId = "11111111-1111-1111-1111-111111111111"; const string objectId = "22222222-2222-2222-2222-222222222222"; var user = new ApplicationUser { Id = "user-1", Email = "local@example.com", EmailConfirmed = true }; var users = CreateUserManager(); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true); users.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); users.Setup(x => x.Users).Returns(new TestAsyncEnumerable(Array.Empty())); users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var microsoft = new Mock(); microsoft.Setup(x => x.ValidateAsync("token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "metadata@example.com", false, null, null, null, tenantId, objectId)); var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoft.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.LinkMicrosoft(new AuthController.MicrosoftTokenRequest("token", CurrentPassword: "correct-password"), CancellationToken.None); Assert.IsType(result.Result); Assert.Equal(tenantId, user.MicrosoftTenantId); Assert.Equal(objectId, user.MicrosoftObjectId); Assert.Null(user.MicrosoftSubject); } [Fact] public async Task Unlink_microsoft_refuses_a_passwordless_last_credential() { var user = new ApplicationUser { Id = "user-1", MicrosoftTenantId = "11111111-1111-1111-1111-111111111111", MicrosoftObjectId = "22222222-2222-2222-2222-222222222222" }; var users = CreateUserManager(); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(false); var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); var result = await controller.UnlinkMicrosoft(new AuthController.MicrosoftUnlinkRequest("irrelevant"), CancellationToken.None); Assert.IsType(result); Assert.NotNull(user.MicrosoftTenantId); users.Verify(x => x.UpdateAsync(user), Times.Never); } [Fact] public async Task Confirm_legacy_relink_requires_matching_microsoft_proof() { const string tenantId = "11111111-1111-1111-1111-111111111111"; const string objectId = "22222222-2222-2222-2222-222222222222"; var user = new ApplicationUser { Id = "legacy-1", Email = "legacy@example.com", EmailConfirmed = true, MicrosoftSubject = objectId }; var users = CreateUserManager(); users.Setup(x => x.FindByIdAsync(user.Id)).ReturnsAsync(user); users.Setup(x => x.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, It.Is(p => p.Contains(tenantId) && p.Contains(objectId)), "recovery-token")).ReturnsAsync(true); users.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new[] { user })); users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var microsoft = new Mock(); microsoft.Setup(x => x.ValidateAsync("fresh-token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "metadata@example.com", false, null, null, null, tenantId, objectId)); var controller = new AuthController(BuildConfig(), users.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoft.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.ConfirmMicrosoftLegacyRelink(new AuthController.ConfirmMicrosoftLegacyRelinkRequest(user.Id, tenantId, objectId, "recovery-token", "fresh-token"), CancellationToken.None); Assert.IsType(result); Assert.Equal(tenantId, user.MicrosoftTenantId); Assert.Equal(objectId, user.MicrosoftObjectId); Assert.Equal("metadata@example.com", user.MicrosoftEmail); } [Fact] public void Me_result_includes_google_link_details_for_local_users() { var method = typeof(AuthController).GetMethod("ToMeResult", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); Assert.NotNull(method); var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person", FirstName = "Ada", LastName = "Lovelace", DisplayName = "Ada Lovelace", GoogleSubject = "sub-123", GoogleEmail = "person@example.com", GoogleLinkedAt = DateTimeOffset.UtcNow, }; var result = (AuthController.MeResult)method!.Invoke(null, new object[] { user, new List { "Admin" } })!; Assert.Equal("local", result.Provider); Assert.Equal("Ada", result.FirstName); Assert.Equal("Lovelace", result.LastName); Assert.Equal("Ada Lovelace", result.DisplayName); Assert.NotNull(result.GoogleLink); Assert.True(result.GoogleLink!.Linked); Assert.Equal("person@example.com", result.GoogleLink.Email); } [Fact] public async Task Me_result_includes_configured_build_metadata() { var user = new ApplicationUser { Id = "admin-1", Email = "admin@example.com", UserName = "admin" }; var users = CreateUserManager(); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List { "Admin" }); var cfg = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["App:Version"] = "2.4.1", ["App:CommitSha"] = "abc1234", }) .Build(); var controller = new AuthController(cfg, users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var response = await controller.Me(CancellationToken.None); var ok = Assert.IsType(response); var result = Assert.IsType(ok.Value); Assert.Equal("2.4.1", result.AppVersion); Assert.Equal("abc1234", result.AppCommitSha); } [Fact] public async Task Me_result_limits_build_metadata_to_admins() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person" }; var users = CreateUserManager(); users.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List()); var cfg = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["App:Version"] = "2.4.1", ["App:CommitSha"] = "abc1234", }) .Build(); var controller = new AuthController(cfg, users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var response = await controller.Me(CancellationToken.None); var ok = Assert.IsType(response); var result = Assert.IsType(ok.Value); Assert.Equal("unknown", result.AppVersion); Assert.Null(result.AppCommitSha); } [Fact] public async Task Admin_system_email_settings_falls_back_when_override_store_is_unavailable() { var emailSettings = new Mock(); emailSettings.Setup(x => x.GetAdminDtoAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("missing SystemEmailSettings")); var cfg = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Email:Enabled"] = "false", ["Email:FromName"] = "Jobbjakt" }) .Build(); var controller = new AdminSystemController( cfg, new AppPaths(cfg, new FakeHostEnv()), null!, Mock.Of(), new FakeEnv(), emailSettings.Object); var result = await controller.GetEmailSettings(CancellationToken.None); var ok = Assert.IsType(result.Result); var dto = Assert.IsType(ok.Value); Assert.False(dto.Enabled); Assert.Contains("fallback", dto.FromName, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Admin_system_probe_endpoint_runs_probe_once() { var summarizer = new Mock(); summarizer.Setup(x => x.RunProbeAsync(It.IsAny())).Returns(Task.CompletedTask); var controller = new AdminSystemController( BuildConfig(), new AppPaths(BuildConfig(), new FakeHostEnv()), null!, summarizer.Object, new FakeEnv(), Mock.Of()); var result = await controller.RunSummarizerProbe(CancellationToken.None); Assert.IsType(result); summarizer.Verify(x => x.RunProbeAsync(It.IsAny()), Times.Once); } [Fact] public async Task Login_rejects_missing_turnstile_token_when_configured() { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Turnstile:SiteKey"] = "site-key", ["Turnstile:SecretKey"] = "secret-key", }).Build(); var users = CreateUserManager(); var controller = new AuthController(config, users.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb(), httpClients: Mock.Of()); var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "password"), CancellationToken.None); var badRequest = Assert.IsType(result); Assert.Equal("Security verification failed. Please try again.", badRequest.Value); users.Verify(x => x.FindByEmailAsync(It.IsAny()), Times.Never); } private static IConfiguration BuildConfig() { return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary()) .Build(); } private static Mock> CreateUserManager() { return TestHostFactory.CreateUserManager(); } private sealed class TestAsyncQueryProvider : IAsyncQueryProvider { private readonly IQueryProvider _inner; public TestAsyncQueryProvider(IQueryProvider inner) { _inner = inner; } public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); public object? Execute(Expression expression) => _inner.Execute(expression); public TResult Execute(Expression expression) => _inner.Execute(expression); public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) => (TResult)typeof(Task) .GetMethod(nameof(Task.FromResult))! .MakeGenericMethod(typeof(TResult).GetGenericArguments()[0]) .Invoke(null, new[] { Execute(expression) })!; } private sealed class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable { public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } public TestAsyncEnumerable(Expression expression) : base(expression) { } public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); } private sealed class TestAsyncEnumerator : IAsyncEnumerator { private readonly IEnumerator _inner; public TestAsyncEnumerator(IEnumerator inner) { _inner = inner; } public T Current => _inner.Current; public ValueTask DisposeAsync() { _inner.Dispose(); return ValueTask.CompletedTask; } public ValueTask MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext()); } private sealed class FakeHostEnv : Microsoft.Extensions.Hosting.IHostEnvironment { public string EnvironmentName { get; set; } = "Test"; public string ApplicationName { get; set; } = "JobTrackerApi"; public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; } private sealed class FakeEnv : Microsoft.AspNetCore.Hosting.IWebHostEnvironment { public string ApplicationName { get; set; } = "JobTrackerApi"; public Microsoft.Extensions.FileProviders.IFileProvider WebRootFileProvider { get; set; } = null!; public string WebRootPath { get; set; } = string.Empty; public string EnvironmentName { get; set; } = "Test"; public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; } }