using System.Security.Claims; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; using OtpNet; using Xunit; namespace JobTrackerApi.Tests; public sealed class SessionsControllerTests { private static IConfiguration BuildConfig() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); private static SessionsController BuildController(JobTrackerContext db, ApplicationUser user, string? currentSid = null) { var userManager = TestHostFactory.CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); var claims = new List { new(ClaimTypes.NameIdentifier, user.Id) }; if (currentSid is not null) claims.Add(new Claim("sid", currentSid)); return new SessionsController(userManager.Object, db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(claims, "local")) } } }; } private static UserSession NewSession(string id, string userId, DateTimeOffset? expiresAtUtc = null, DateTimeOffset? revokedAtUtc = null, DateTimeOffset? lastSeenAtUtc = null) { var now = DateTimeOffset.UtcNow; return new UserSession { Id = id, UserId = userId, DeviceLabel = "Chrome on Windows", CreatedAtUtc = now, LastSeenAtUtc = lastSeenAtUtc ?? now, ExpiresAtUtc = expiresAtUtc ?? now.AddHours(12), RevokedAtUtc = revokedAtUtc, }; } // --- Session creation on every sign-in path ----------------------------------------------- [Fact] public async Task Login_creates_a_user_session_row_and_threads_its_id_into_the_token() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; var userManager = TestHostFactory.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); string? sessionIdPassedToToken = null; var tokenService = new Mock(); tokenService .Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())) .Callback((_, sid, _) => sessionIdPassedToToken = sid) .ReturnsAsync("app-token"); using var db = TestHostFactory.CreateInMemoryDb(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() } }; await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == "user-1").ToListAsync(); var created = Assert.Single(sessions); Assert.False(string.IsNullOrWhiteSpace(created.Id)); Assert.Equal(created.Id, sessionIdPassedToToken); Assert.Null(created.RevokedAtUtc); Assert.True(created.ExpiresAtUtc > DateTimeOffset.UtcNow); } [Fact] public async Task Register_creates_a_user_session_row_when_it_completes_sign_in() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }) .Build(); var userManager = TestHostFactory.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, _) => { u.Id = "new-user-1"; created = u; }) .ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); using var db = TestHostFactory.CreateInMemoryDb(null); var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None); Assert.NotNull(created); var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == created!.Id).ToListAsync(); Assert.Single(sessions); } [Fact] public async Task TwoFactor_challenge_creates_a_user_session_row_on_completion() { var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; var userManager = TestHostFactory.CreateUserManager(); userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); userManager.Setup(x => x.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())); var protector = new EphemeralDataProtectionProvider(); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); using var db = TestHostFactory.CreateInMemoryDb("user-1"); var controller = new TwoFactorController(userManager.Object, tokenService.Object, db, pending, protector, BuildConfig()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var setupResult = Assert.IsType(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None)); var setup = Assert.IsType(setupResult.Value); var setupCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp(); await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(setupCode), CancellationToken.None); var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false); var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp(); Assert.IsType(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None)); var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == "user-1").ToListAsync(); Assert.Single(sessions); } // --- List/revoke ---------------------------------------------------------------------------- [Fact] public async Task List_returns_only_the_callers_own_active_sessions() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); db.UserSessions.Add(NewSession("sid-mine", "user-1")); db.UserSessions.Add(NewSession("sid-other-user", "user-2")); db.UserSessions.Add(NewSession("sid-mine-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddHours(-1))); db.UserSessions.Add(NewSession("sid-mine-revoked", "user-1", revokedAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1))); await db.SaveChangesAsync(); var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-mine"); var ok = Assert.IsType(await controller.List(CancellationToken.None)); var list = Assert.IsType>(ok.Value); var only = Assert.Single(list); Assert.Equal("sid-mine", only.Id); Assert.True(only.IsCurrentSession); } [Fact] public async Task Revoke_enforces_ownership_and_blocks_a_subsequent_request_using_that_sessions_token() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); db.UserSessions.Add(NewSession("sid-mine", "user-1")); db.UserSessions.Add(NewSession("sid-not-mine", "user-2")); await db.SaveChangesAsync(); var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-mine"); // Can't revoke someone else's session. var forbidden = await controller.Revoke("sid-not-mine", CancellationToken.None); Assert.IsType(forbidden); Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-not-mine", "user-2"), DateTimeOffset.UtcNow)); // Revoking your own session actually blocks it going forward. Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-mine"), DateTimeOffset.UtcNow)); var ownResult = await controller.Revoke("sid-mine", CancellationToken.None); Assert.IsType(ownResult); Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-mine"), DateTimeOffset.UtcNow)); } [Fact] public async Task RevokeOthers_revokes_every_other_session_but_leaves_the_current_one_usable() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); db.UserSessions.Add(NewSession("sid-current", "user-1")); db.UserSessions.Add(NewSession("sid-other-device", "user-1")); db.UserSessions.Add(NewSession("sid-not-mine", "user-2")); await db.SaveChangesAsync(); var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-current"); Assert.IsType(await controller.RevokeOthers(CancellationToken.None)); Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-current"), DateTimeOffset.UtcNow)); Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-other-device"), DateTimeOffset.UtcNow)); // Untouched: revoke-others must never reach across users. Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-not-mine", "user-2"), DateTimeOffset.UtcNow)); } [Fact] public async Task LocalSessionValidator_rejects_an_expired_session() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); db.UserSessions.Add(NewSession("sid-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddSeconds(-1))); await db.SaveChangesAsync(); Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-expired"), DateTimeOffset.UtcNow)); } [Fact] public async Task LocalSessionValidator_rejects_a_token_with_no_sid_claim() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")); Assert.False(await LocalSessionValidator.IsValidAsync(db, principal, DateTimeOffset.UtcNow)); } private static ClaimsPrincipal PrincipalWithSid(string sid, string userId = "user-1") => new(new ClaimsIdentity(new[] { new Claim("sid", sid), new Claim(ClaimTypes.NameIdentifier, userId) }, "local")); }