c6918cbeea
JWTs were previously fully stateless -- the token alone was the credential until its own expiry, with no way to list or kill a session server-side. Add a UserSession table alongside every JWT issued (AppSessionIssuer), embed its id as a "sid" claim, and check that claim against the DB on every "local" scheme request (Program.cs OnTokenValidated) so a session can actually be revoked before its JWT naturally expires. New /api/auth/sessions endpoints (list, revoke one, revoke-others) plus a Sessions card on the profile page. Fails closed on a missing "sid" claim: every JWT issued going forward has one, so a token without it is either pre-deploy (forces one re-login for already-signed-in users at deploy time, same additive-forward cost the 2FA/trusted-device work on this branch already paid) or forged.
727 lines
36 KiB
C#
727 lines
36 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>();
|
|
|
|
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.False(created!.EmailConfirmed);
|
|
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("new@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_password_reset_returns_service_unavailable_when_email_send_fails()
|
|
{
|
|
var user = new ApplicationUser { 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.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()
|
|
{
|
|
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("ms-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
|
|
|
|
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.Equal("ms-subject", created.MicrosoftSubject);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Exchange_microsoft_token_rejects_unmatched_account_when_registration_disabled()
|
|
{
|
|
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("ms-subject", "nobody@example.com", true, null, null, null));
|
|
|
|
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 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 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);
|
|
}
|
|
|
|
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!;
|
|
}
|
|
}
|