273 lines
12 KiB
C#
273 lines
12 KiB
C#
using JobTrackerApi.Controllers;
|
|
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.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 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>(), NullLogger<AuthController>.Instance);
|
|
|
|
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>(), NullLogger<AuthController>.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<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<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, NullLogger<AuthController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext()
|
|
}
|
|
};
|
|
|
|
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result.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 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!;
|
|
}
|
|
}
|