fix(app): harden account and workflow state
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user