fix(app): harden account and workflow state

This commit is contained in:
cesnimda
2026-08-24 20:21:09 +02:00
parent e7cacad7d6
commit dca5daa1a2
32 changed files with 811 additions and 86 deletions
@@ -290,6 +290,45 @@ public sealed class BackgroundWorkerTenantTests
Assert.All(jobs, job => Assert.Equal(FixedNow.DateTime, job.LastReminderEmailSentAt));
}
[Fact]
public async Task Reminder_worker_respects_each_owners_persisted_email_opt_out()
{
var email = new Mock<IAppEmailSender>();
await using var fixture = await Fixture.CreateAsync(
new Dictionary<string, string?>
{
["Workers:FollowUpRemindersEnabled"] = "true",
["Email:FollowUpReminders:Enabled"] = "true",
["App:PublicBaseUrl"] = "http://localhost:3000",
},
services => services.AddSingleton(email.Object));
await fixture.SeedJobsAsync(includeUsers: true);
await using (var scope = fixture.Provider.CreateAsyncScope())
{
var user = await scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>().FindByIdAsync("user-2");
Assert.NotNull(user);
user!.EmailFollowUpRemindersEnabled = false;
Assert.True((await scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>().UpdateAsync(user)).Succeeded);
}
var worker = new FollowUpReminderHostedService(
fixture.Runner,
fixture.Configuration,
NullLogger<FollowUpReminderHostedService>.Instance,
Mock.Of<IStartupReadiness>(),
ExternalOrigin.FromConfiguration(fixture.Configuration),
new MutableTimeProvider(FixedNow));
Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default));
email.Verify(x => x.SendAsync("one@example.test", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
email.Verify(x => x.SendAsync("two@example.test", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
await using var verificationScope = fixture.Provider.CreateAsyncScope();
var jobs = await verificationScope.ServiceProvider.GetRequiredService<JobTrackerContext>().JobApplications
.IgnoreQueryFilters().AsNoTracking().OrderBy(job => job.OwnerUserId).ToListAsync();
Assert.Equal(FixedNow.DateTime, jobs[0].LastReminderEmailSentAt);
Assert.Null(jobs[1].LastReminderEmailSentAt);
}
[Fact]
public async Task Rules_worker_uses_injected_clock_across_threshold_and_restart()
{
@@ -64,6 +64,26 @@ public sealed class JobImportServiceTests
Assert.Equal("No JobPosting schema found.", result.Error);
}
[Fact]
public async Task Preview_stops_reading_a_chunked_response_at_the_download_limit()
{
var resolver = new Mock<IHostAddressResolver>();
resolver.Setup(x => x.ResolveAsync("example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync([IPAddress.Parse("93.184.216.34")]);
var contentStream = new GeneratedStream(10_000_000);
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(contentStream)
});
var service = CreateService(resolver.Object, handler);
var result = await service.PreviewAsync("https://example.com/job", CancellationToken.None);
Assert.False(result.Success);
Assert.Equal("fetch", result.Parser);
Assert.InRange(contentStream.BytesRead, 4_000_001, 4_065_536);
}
private static JobImportService CreateService(IHostAddressResolver resolver, HttpMessageHandler? handler = null)
{
handler ??= new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
@@ -95,4 +115,37 @@ public sealed class JobImportServiceTests
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_handler(request));
}
private sealed class GeneratedStream(long length) : Stream
{
private long _position;
public long BytesRead { get; private set; }
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => length;
public override long Position { get => _position; set => throw new NotSupportedException(); }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count)
{
var read = (int)Math.Min(count, length - _position);
if (read <= 0) return 0;
Array.Fill<byte>(buffer, (byte)'x', offset, read);
_position += read;
BytesRead += read;
return read;
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var read = (int)Math.Min(buffer.Length, length - _position);
if (read <= 0) return ValueTask.FromResult(0);
buffer.Span[..read].Fill((byte)'x');
_position += read;
BytesRead += read;
return ValueTask.FromResult(read);
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,67 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class NotificationSettingsControllerTests
{
[Fact]
public async Task Get_returns_the_authenticated_users_preference()
{
var user = new ApplicationUser { Id = "user-1", EmailFollowUpRemindersEnabled = false };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var result = await CreateController(users).Get();
var ok = Assert.IsType<OkObjectResult>(result.Result);
var settings = Assert.IsType<NotificationSettingsController.NotificationSettingsDto>(ok.Value);
Assert.False(settings.EmailFollowUpRemindersEnabled);
}
[Fact]
public async Task Put_persists_the_authenticated_users_preference()
{
var user = new ApplicationUser { Id = "user-1", EmailFollowUpRemindersEnabled = true };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var result = await CreateController(users).Put(new(false));
var ok = Assert.IsType<OkObjectResult>(result.Result);
var settings = Assert.IsType<NotificationSettingsController.NotificationSettingsDto>(ok.Value);
Assert.False(settings.EmailFollowUpRemindersEnabled);
users.Verify(x => x.UpdateAsync(It.Is<ApplicationUser>(candidate => !candidate.EmailFollowUpRemindersEnabled)), Times.Once);
}
[Fact]
public async Task Put_does_not_update_when_the_request_has_no_authenticated_user()
{
var users = TestHostFactory.CreateUserManager();
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync((ApplicationUser?)null);
var result = await CreateController(users).Put(new(false));
Assert.IsType<UnauthorizedResult>(result.Result);
users.Verify(x => x.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
private static NotificationSettingsController CreateController(Mock<UserManager<ApplicationUser>> users) => new(users.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "user-1")], "local"))
}
}
};
}
@@ -57,6 +57,51 @@ public sealed class UsersControllerTests
users.Verify(x => x.RemoveFromRolesAsync(admin, It.IsAny<IEnumerable<string>>()), Times.Once);
}
[Fact]
public async Task SetRoles_revokes_existing_sessions_and_trusted_devices_when_roles_change()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("admin-1");
var options = new DbContextOptionsBuilder<JobTrackerContext>()
.UseSqlite(connection)
.Options;
await using var db = new JobTrackerContext(options, currentUser.Object);
await db.Database.EnsureCreatedAsync();
var member = User("user-1");
db.Users.Add(member);
db.UserSessions.Add(new UserSession
{
Id = "member-session",
UserId = member.Id,
CreatedAtUtc = DateTimeOffset.UtcNow,
LastSeenAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(1)
});
db.TrustedDevices.Add(new TrustedDevice
{
UserId = member.Id,
TokenHash = "device-hash",
CreatedAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30)
});
await db.SaveChangesAsync();
var users = TestHostFactory.CreateUserManager(member);
users.Setup(x => x.GetRolesAsync(member)).ReturnsAsync([]);
users.Setup(x => x.AddToRoleAsync(member, "Admin")).ReturnsAsync(IdentityResult.Success);
var controller = CreateController(users, "admin-1", db);
var result = await controller.SetRoles(member.Id, new UsersController.SetRolesRequest(["Admin"]), CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var session = await db.UserSessions.IgnoreQueryFilters().SingleAsync(x => x.Id == "member-session");
Assert.NotNull(session.RevokedAtUtc);
Assert.Empty(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == member.Id).ToListAsync());
}
[Fact]
public async Task Delete_rejects_deleting_the_final_administrator()
{
@@ -159,6 +204,7 @@ public sealed class UsersControllerTests
new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(),
new NullLogger<RoleManager<IdentityRole>>());
roles.Setup(manager => manager.RoleExistsAsync(It.IsAny<string>())).ReturnsAsync(true);
var controller = new UsersController(
users.Object,