Files
jobtrackingapp/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs
T
cesnimda 66cc6a7db4 feat(phase-2): separate /profile (identity) from /career (master profile)
Phase 2 — Career/Profile separation. The master career profile is the source of
truth; identity and career data are now saved independently so neither wipes the
other. CV Builder deliberately not built yet.

Backend — PUT /auth/profile is now a partial update:
- null/omitted field -> unchanged; "" -> cleared; value -> set (trimmed).
- Email/UserName never cleared to empty (login identifiers).
This lets /profile save identity fields and /career save the master-profile
fields through the same endpoint without one nulling the other. 4 new tests
cover the data-integrity guarantees (identity save keeps the CV, career save
keeps identity, empty clears, null leaves).

Frontend:
- ProfilePage save payload is now scoped by careerOnly: /career sends only
  { profileCvText, profileCvStructureJson }, /profile sends only identity.
- CareerWorkspacePage: removed the inert "CV Builder" tab (careerView) — Phase 2
  establishes the master profile only; the builder is Phase 4.
- Dropped the dead careerView prop.
- Updated the CV-save test to render career mode and assert identity is excluded.

Source-of-truth flip (CareerProfileService authoritative) stays deferred to F5
per the branch design; CareerProfileService keeps mirroring via its dual-write.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 19:59:38 +02:00

808 lines
40 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);
}
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" };
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!;
}
}