Files
jobtrackingapp/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs
T
cesnimda a6cffe0473
CI and Deploy / test (pull_request) Successful in 4m48s
CI and Deploy / deploy (pull_request) Has been skipped
feat(admin): show deployed app version
2026-08-15 16:47:43 +02:00

1124 lines
62 KiB
C#

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<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<UnauthorizedResult>(attempt);
}
Assert.True(lockedOut);
var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(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<OkObjectResult>(result);
var session = Assert.IsType<AuthController.AuthSessionResult>(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<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(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<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(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<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
}
}
[Fact]
public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<ApplicationUser>(), "password123"))
.Callback<ApplicationUser, string>((u, _) => created = u)
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny<ApplicationUser>())).ReturnsAsync("confirm-token");
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var emailSender = new Mock<IAppEmailSender>();
using var db = TestHostFactory.CreateInMemoryDb();
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<ObjectResult>(result);
Assert.Equal(StatusCodes.Status202Accepted, accepted.StatusCode);
Assert.True(Assert.IsType<AuthController.RegistrationPendingResult>(accepted.Value).VerificationRequired);
Assert.NotNull(created);
Assert.False(created!.EmailConfirmed);
Assert.Empty(db.UserSessions);
tokenService.Verify(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny<string>(), It.Is<string>(b => b.Contains("verify-email")), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Register_is_unchanged_when_flag_off()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["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<ApplicationUser>(), "password123"))
.Callback<ApplicationUser, string>((u, _) => created = u)
.ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var emailSender = new Mock<IAppEmailSender>();
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<OkObjectResult>(result);
Assert.NotNull(created);
Assert.True(created!.EmailConfirmed);
emailSender.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Login_rejects_unconfirmed_local_account_when_flag_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["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<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<ObjectResult>(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<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<OkObjectResult>(result);
var session = Assert.IsType<AuthController.AuthSessionResult>(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<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token"));
Assert.IsType<NoContentResult>(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<IAppEmailSender>();
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<NoContentResult>(realResult);
Assert.IsType<NoContentResult>(fakeResult);
emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string, string?>
{
["Auth:AllowRegistration"] = "true",
["Auth:RequireEmailVerification"] = "true",
})
.Build();
var userManager = CreateUserManager();
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(u => created = u)
.ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var googleValidator = new Mock<IGoogleTokenValidator>();
googleValidator
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.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<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
Assert.NotNull(created);
Assert.True(created!.EmailConfirmed);
}
private static JobTrackerContext BuildDb(string dbName, string? currentUserId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(dbName).Options;
var currentUser = new Mock<ICurrentUserService>();
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<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
db = BuildDb(dbName, null);
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
Assert.IsType<NoContentResult>(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<System.Security.Claims.ClaimsPrincipal>())).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<string>())).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<IAppEmailSender>();
var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), email.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<AcceptedResult>(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<string>(), It.Is<string>(body => body.Contains("confirm-email-change")), It.IsAny<CancellationToken>()), Times.Once);
email.Verify(x => x.SendAsync("old@example.com", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string>())).Returns((string value) => value.ToUpperInvariant());
var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.ConfirmEmailChange(new AuthController.ConfirmEmailChangeRequest("user-1", "superseded@example.com", "old-token"), CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
users.Verify(x => x.ChangeEmailAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<string>()), 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<string>())).Returns((string value) => value.ToUpperInvariant());
users.Setup(x => x.NormalizeName(It.IsAny<string>())).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<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<NoContentResult>(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<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
return new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<NoContentResult>(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<NoContentResult>(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<NoContentResult>(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<NoContentResult>(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<IAppEmailSender>();
emailSender
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<ObjectResult>(result);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode);
var details = Assert.IsType<ProblemDetails>(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<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("dj@cesnimda.co.uk")).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var googleValidator = new Mock<IGoogleTokenValidator>();
googleValidator
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.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<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(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<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(u => created = u)
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
microsoftValidator
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "new.hire@example.com", false, "New", "Hire", "New Hire", tenantId, objectId));
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build();
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(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<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("nobody@example.com")).ReturnsAsync((ApplicationUser?)null);
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
microsoftValidator
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "nobody@example.com", false, null, null, null, tenantId, objectId));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result);
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), 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<ApplicationUser>(new List<ApplicationUser>()));
users.Setup(x => x.FindByEmailAsync("same@example.com")).ReturnsAsync(existing);
var microsoft = new Mock<IMicrosoftTokenValidator>();
microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "same@example.com", false, null, null, null, tenantId, objectId));
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" }).Build();
var controller = new AuthController(config, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.Null(existing.MicrosoftTenantId);
users.Verify(x => x.UpdateAsync(existing), Times.Never);
users.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), 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<ApplicationUser>(new[] { userA, userB }));
users.Setup(x => x.UpdateAsync(userB)).ReturnsAsync(IdentityResult.Success);
var tokens = new Mock<ITokenService>();
tokens.Setup(x => x.CreateAccessTokenAsync(userB, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var microsoft = new Mock<IMicrosoftTokenValidator>();
microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "shared@example.com", false, null, null, null, tenantB, objectId));
var controller = new AuthController(BuildConfig(), users.Object, tokens.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
tokens.Verify(x => x.CreateAccessTokenAsync(userB, It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Once);
tokens.Verify(x => x.CreateAccessTokenAsync(userA, It.IsAny<string?>(), It.IsAny<CancellationToken>()), 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<ApplicationUser>(new[] { legacy }));
users.Setup(x => x.GenerateUserTokenAsync(legacy, TokenOptions.DefaultProvider, It.Is<string>(p => p.Contains(tenantId) && p.Contains(objectId)))).ReturnsAsync("recovery-token");
var microsoft = new Mock<IMicrosoftTokenValidator>();
microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "legacy@example.com", false, null, null, null, tenantId, objectId));
var email = new Mock<IAppEmailSender>();
var appTokens = new Mock<ITokenService>();
var controller = new AuthController(BuildConfig(), users.Object, appTokens.Object, email.Object, Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("token"), CancellationToken.None);
Assert.IsType<AcceptedResult>(result);
Assert.Null(legacy.MicrosoftTenantId);
appTokens.Verify(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(x => x.SendAsync("legacy@example.com", It.IsAny<string>(), It.Is<string>(body => body.Contains("microsoft-legacy-relink")), It.IsAny<CancellationToken>()), 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<System.Security.Claims.ClaimsPrincipal>())).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<ApplicationUser>(Array.Empty<ApplicationUser>()));
users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var microsoft = new Mock<IMicrosoftTokenValidator>();
microsoft.Setup(x => x.ValidateAsync("token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "metadata@example.com", false, null, null, null, tenantId, objectId));
var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.LinkMicrosoft(new AuthController.MicrosoftTokenRequest("token", CurrentPassword: "correct-password"), CancellationToken.None);
Assert.IsType<OkObjectResult>(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<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(false);
var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.UnlinkMicrosoft(new AuthController.MicrosoftUnlinkRequest("irrelevant"), CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(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<string>(p => p.Contains(tenantId) && p.Contains(objectId)), "recovery-token")).ReturnsAsync(true);
users.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new[] { user }));
users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var microsoft = new Mock<IMicrosoftTokenValidator>();
microsoft.Setup(x => x.ValidateAsync("fresh-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal(objectId, "metadata@example.com", false, null, null, null, tenantId, objectId));
var controller = new AuthController(BuildConfig(), users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoft.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<NoContentResult>(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<string> { "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<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string> { "Admin" });
var cfg = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["App:Version"] = "2.4.1",
["App:CommitSha"] = "abc1234",
})
.Build();
var controller = new AuthController(cfg, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var response = await controller.Me(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(response);
var result = Assert.IsType<AuthController.MeResult>(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<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(x => x.GetRolesAsync(user)).ReturnsAsync(new List<string>());
var cfg = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["App:Version"] = "2.4.1",
["App:CommitSha"] = "abc1234",
})
.Build();
var controller = new AuthController(cfg, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var response = await controller.Me(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(response);
var result = Assert.IsType<AuthController.MeResult>(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<IEmailSettingsResolver>();
emailSettings.Setup(x => x.GetAdminDtoAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("missing SystemEmailSettings"));
var cfg = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Email:Enabled"] = "false",
["Email:FromName"] = "Jobbjakt"
})
.Build();
var controller = new AdminSystemController(
cfg,
new AppPaths(cfg, new FakeHostEnv()),
null!,
Mock.Of<ISummarizerService>(),
new FakeEnv(),
emailSettings.Object);
var result = await controller.GetEmailSettings(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var dto = Assert.IsType<EmailSettingsAdminDto>(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<ISummarizerService>();
summarizer.Setup(x => x.RunProbeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
var controller = new AdminSystemController(
BuildConfig(),
new AppPaths(BuildConfig(), new FakeHostEnv()),
null!,
summarizer.Object,
new FakeEnv(),
Mock.Of<IEmailSettingsResolver>());
var result = await controller.RunSummarizerProbe(CancellationToken.None);
Assert.IsType<NoContentResult>(result);
summarizer.Verify(x => x.RunProbeAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Login_rejects_missing_turnstile_token_when_configured()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Turnstile:SiteKey"] = "site-key",
["Turnstile:SecretKey"] = "secret-key",
}).Build();
var users = CreateUserManager();
var controller = new AuthController(config, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb(), httpClients: Mock.Of<IHttpClientFactory>());
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "password"), CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Security verification failed. Please try again.", badRequest.Value);
users.Verify(x => x.FindByEmailAsync(It.IsAny<string>()), Times.Never);
}
private static IConfiguration BuildConfig()
{
return new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
}
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
{
return TestHostFactory.CreateUserManager();
}
private sealed class TestAsyncQueryProvider<TEntity> : IAsyncQueryProvider
{
private readonly IQueryProvider _inner;
public TestAsyncQueryProvider(IQueryProvider inner)
{
_inner = inner;
}
public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable<TEntity>(expression);
public IQueryable<TElement> CreateQuery<TElement>(Expression expression) => new TestAsyncEnumerable<TElement>(expression);
public object? Execute(Expression expression) => _inner.Execute(expression);
public TResult Execute<TResult>(Expression expression) => _inner.Execute<TResult>(expression);
public TResult ExecuteAsync<TResult>(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<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
{
public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
public TestAsyncEnumerable(Expression expression) : base(expression) { }
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
=> new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider<T>(this);
}
private sealed class TestAsyncEnumerator<T> : IAsyncEnumerator<T>
{
private readonly IEnumerator<T> _inner;
public TestAsyncEnumerator(IEnumerator<T> inner)
{
_inner = inner;
}
public T Current => _inner.Current;
public ValueTask DisposeAsync()
{
_inner.Dispose();
return ValueTask.CompletedTask;
}
public ValueTask<bool> 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!;
}
}