using JobTrackerApi.Controllers; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using System.Collections; using System.Linq.Expressions; using Xunit; namespace JobTrackerApi.Tests; public sealed class AuthAndSystemControllerTests { [Fact] public async Task Update_profile_applies_trimmed_profile_fields() { var user = new ApplicationUser { Email = "old@example.com", UserName = "olduser" }; var userManager = CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null)); Assert.IsType(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(); emailSender .Setup(x => x.SendAsync(user.Email!, It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SMTP unavailable")); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), NullLogger.Instance) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; controller.Request.Scheme = "https"; controller.Request.Host = new HostString("jobtracker.test"); var result = await controller.RequestPasswordReset(new AuthController.RequestPasswordResetRequest("person@example.com"), CancellationToken.None); var problem = Assert.IsType(result); Assert.Equal(StatusCodes.Status503ServiceUnavailable, problem.StatusCode); var details = Assert.IsType(problem.Value); Assert.Equal("Email delivery unavailable", details.Title); } [Fact] public async Task Exchange_google_token_auto_links_single_verified_email_match() { var user = new ApplicationUser { Id = "user-1", Email = "dj@cesnimda.co.uk", UserName = "dj@cesnimda.co.uk", }; var userManager = CreateUserManager(); userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); userManager.Setup(x => x.FindByEmailAsync("dj@cesnimda.co.uk")).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); var googleValidator = new Mock(); googleValidator .Setup(x => x.ValidateAsync("google-token", It.IsAny())) .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones")); var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, NullLogger.Instance); var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); var ok = Assert.IsType(result.Result); var payload = Assert.IsType(ok.Value); Assert.Equal("app-token", payload.AccessToken); Assert.Equal("google-subject", user.GoogleSubject); Assert.Equal("dj@cesnimda.co.uk", user.GoogleEmail); Assert.NotNull(user.GoogleLinkedAt); } [Fact] public void Me_result_includes_google_link_details_for_local_users() { var method = typeof(AuthController).GetMethod("ToMeResult", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); Assert.NotNull(method); var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person", FirstName = "Ada", LastName = "Lovelace", DisplayName = "Ada Lovelace", GoogleSubject = "sub-123", GoogleEmail = "person@example.com", GoogleLinkedAt = DateTimeOffset.UtcNow, }; var result = (AuthController.MeResult)method!.Invoke(null, new object[] { user, new List { "Admin" } })!; Assert.Equal("local", result.Provider); Assert.Equal("Ada", result.FirstName); Assert.Equal("Lovelace", result.LastName); Assert.Equal("Ada Lovelace", result.DisplayName); Assert.NotNull(result.GoogleLink); Assert.True(result.GoogleLink!.Linked); Assert.Equal("person@example.com", result.GoogleLink.Email); } [Fact] public async Task Admin_system_probe_endpoint_runs_probe_once() { var summarizer = new Mock(); summarizer.Setup(x => x.RunProbeAsync(It.IsAny())).Returns(Task.CompletedTask); var controller = new AdminSystemController(BuildConfig(), new AppPaths(BuildConfig(), new FakeHostEnv()), null!, summarizer.Object, new FakeEnv()); var result = await controller.RunSummarizerProbe(CancellationToken.None); Assert.IsType(result); summarizer.Verify(x => x.RunProbeAsync(It.IsAny()), Times.Once); } private static IConfiguration BuildConfig() { return new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary()) .Build(); } private static Mock> CreateUserManager() { var store = new Mock>(); return new Mock>( store.Object, Options.Create(new IdentityOptions()), new PasswordHasher(), Array.Empty>(), Array.Empty>(), new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, new NullLogger>() ); } private sealed class TestAsyncQueryProvider : IAsyncQueryProvider { private readonly IQueryProvider _inner; public TestAsyncQueryProvider(IQueryProvider inner) { _inner = inner; } public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); public object? Execute(Expression expression) => _inner.Execute(expression); public TResult Execute(Expression expression) => _inner.Execute(expression); public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) => (TResult)typeof(Task) .GetMethod(nameof(Task.FromResult))! .MakeGenericMethod(typeof(TResult).GetGenericArguments()[0]) .Invoke(null, new[] { Execute(expression) })!; } private sealed class TestAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable { public TestAsyncEnumerable(IEnumerable enumerable) : base(enumerable) { } public TestAsyncEnumerable(Expression expression) : base(expression) { } public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); } private sealed class TestAsyncEnumerator : IAsyncEnumerator { private readonly IEnumerator _inner; public TestAsyncEnumerator(IEnumerator inner) { _inner = inner; } public T Current => _inner.Current; public ValueTask DisposeAsync() { _inner.Dispose(); return ValueTask.CompletedTask; } public ValueTask MoveNextAsync() => ValueTask.FromResult(_inner.MoveNext()); } private sealed class FakeHostEnv : Microsoft.Extensions.Hosting.IHostEnvironment { public string EnvironmentName { get; set; } = "Test"; public string ApplicationName { get; set; } = "JobTrackerApi"; public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; } private sealed class FakeEnv : Microsoft.AspNetCore.Hosting.IWebHostEnvironment { public string ApplicationName { get; set; } = "JobTrackerApi"; public Microsoft.Extensions.FileProviders.IFileProvider WebRootFileProvider { get; set; } = null!; public string WebRootPath { get; set; } = string.Empty; public string EnvironmentName { get; set; } = "Test"; public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; } }