using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Data.Common; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class DashboardPreferencesTests { private const string Route = "/api/auth/preferences/dashboard"; private const string Layout = "{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}]}"; [Fact] public async Task Http_roundtrip_is_owner_scoped_and_preserves_other_preferences() { await using var host = await Host.Start(); using var owner = host.Client("owner"); using var other = host.Client("other"); using var anonymous = host.Client(null); Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(Route)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PutAsync(Route, Json(Layout))).StatusCode); var initial = await owner.GetFromJsonAsync(Route); Assert.Equal(0, initial.GetProperty("revision").GetInt64()); Assert.Empty(initial.GetProperty("widgets").EnumerateArray()); var saved = await owner.PutAsync(Route + "?userId=other", Json(Layout)); Assert.Equal(HttpStatusCode.OK, saved.StatusCode); Assert.Equal(1, (await saved.Content.ReadFromJsonAsync()).GetProperty("revision").GetInt64()); Assert.Equal(HttpStatusCode.Conflict, (await owner.PutAsync(Route, Json(Layout))).StatusCode); Assert.Equal(0, (await other.GetFromJsonAsync(Route)).GetProperty("revision").GetInt64()); await using var db = host.Db(); var user = await db.Users.SingleAsync(x => x.Id == "owner"); Assert.Equal("nb-NO", user.UiLanguage); Assert.False(user.EmailFollowUpRemindersEnabled); Assert.Equal("security", user.SecurityStamp); Assert.Equal("profile", user.DisplayName); Assert.Equal("concurrency", user.ConcurrencyStamp); } [Fact] public async Task Simultaneous_first_writes_have_exactly_one_winner() { await using var host = await Host.Start(); using var first = host.Client("owner"); using var second = host.Client("owner"); var results = await Task.WhenAll(first.PutAsync(Route, Json(Layout)), second.PutAsync(Route, Json(Layout))); Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, results.Select(x => x.StatusCode).OrderBy(x => x)); Assert.Equal(1, (await first.GetFromJsonAsync(Route)).GetProperty("revision").GetInt64()); } [Fact] public async Task Layout_write_succeeds_while_WAL_reader_remains_open() { await using var host = await Host.Start(); await using var db = host.Db(); await db.Database.OpenConnectionAsync(); await using var command = db.Database.GetDbConnection().CreateCommand(); command.CommandText = "SELECT Id FROM AspNetUsers ORDER BY Id"; await using var reader = await command.ExecuteReaderAsync(); Assert.True(await reader.ReadAsync()); using var client = host.Client("owner"); Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json(Layout))).StatusCode); Assert.True(await reader.ReadAsync()); } [Fact] public async Task Unknown_authenticated_subject_cannot_access_or_write_preferences() { await using var host = await Host.Start(); using var client = host.Client("missing-account"); Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync(Route)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await client.PutAsync(Route, Json(Layout))).StatusCode); } [Fact] public async Task Stale_identity_profile_update_cannot_overwrite_new_layout() { await using var host = await Host.Start(); await using var staleDb = host.Db(); var staleUser = await staleDb.Users.SingleAsync(x => x.Id == "owner"); using var client = host.Client("owner"); Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json(Layout))).StatusCode); // Identity's store marks every user property modified, even an unchanged stale layout. staleUser.UiLanguage = "en"; staleDb.Update(staleUser); await staleDb.SaveChangesAsync(); Assert.Equal(1, (await client.GetFromJsonAsync(Route)).GetProperty("revision").GetInt64()); } [Fact] public async Task Read_normalises_corrupt_unknown_and_invalid_stored_widgets_without_resetting_revision() { await using var host = await Host.Start(); using var client = host.Client("owner"); await using var db = host.Db(); var stored = "{\"version\":1,\"widgets\":[{\"id\":\"future\",\"visible\":true,\"size\":\"full\"},{\"id\":\"cards\",\"visible\":false,\"size\":\"bad\"},{\"id\":\"funnel\",\"visible\":true,\"size\":\"bad\"}]}"; await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={stored}, DashboardLayoutRevision=4 WHERE Id='owner'"); var normalised = await client.GetFromJsonAsync(Route); Assert.Equal(4, normalised.GetProperty("revision").GetInt64()); var widgets = normalised.GetProperty("widgets").EnumerateArray().ToArray(); Assert.Equal(2, widgets.Length); Assert.Equal("full", widgets[0].GetProperty("size").GetString()); Assert.False(widgets[0].GetProperty("visible").GetBoolean()); Assert.Equal("standard", widgets[1].GetProperty("size").GetString()); await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={"{"} WHERE Id='owner'"); var corrupt = await client.GetFromJsonAsync(Route); Assert.Equal(4, corrupt.GetProperty("revision").GetInt64()); Assert.Empty(corrupt.GetProperty("widgets").EnumerateArray()); } [Fact] public async Task Future_schema_is_exposed_and_cannot_be_overwritten_by_version_one() { await using var host = await Host.Start(); using var client = host.Client("owner"); await using var db = host.Db(); const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}"; await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=4 WHERE Id='owner'"); var response = await client.GetFromJsonAsync(Route); Assert.Equal(2, response.GetProperty("version").GetInt32()); Assert.Equal(4, response.GetProperty("revision").GetInt64()); Assert.Empty(response.GetProperty("widgets").EnumerateArray()); Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":4,\"widgets\":[]}"))).StatusCode); var stored = await db.Users.AsNoTracking().Where(x => x.Id == "owner").Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleAsync(); Assert.Equal(future, stored.DashboardLayoutJson); Assert.Equal(4, stored.DashboardLayoutRevision); } [Fact] public async Task Future_schema_installed_while_body_is_paused_cannot_be_overwritten_with_guessed_revision() { await using var host = await Host.Start(); using var client = host.Client("owner"); var preRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseBody = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); host.ReadObserver.OnLayoutRead = () => preRead.TrySetResult(); using var body = new PausedJson("{\"version\":1,\"revision\":1,\"widgets\":[]}", releaseBody.Task); var pending = client.PutAsync(Route, body); try { await preRead.Task.WaitAsync(TimeSpan.FromSeconds(10)); await using var db = host.Db(); const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}"; await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=1 WHERE Id='owner'"); releaseBody.TrySetResult(); Assert.Equal(HttpStatusCode.Conflict, (await pending).StatusCode); var stored = await db.Users.AsNoTracking().Where(x => x.Id == "owner").Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleAsync(); Assert.Equal(future, stored.DashboardLayoutJson); Assert.Equal(1, stored.DashboardLayoutRevision); } finally { releaseBody.TrySetResult(); } } [Fact] public async Task All_known_widgets_and_empty_reset_save_roundtrip_in_order() { await using var host = await Host.Start(); using var client = host.Client("owner"); var ids = new[] { "cards", "attention", "funnel", "activity", "stageAge", "sources", "salary", "companies", "skills" }; var body = new { version = 1, revision = 0, widgets = ids.Select(id => new { id, visible = false, size = "full" }) }; Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync(Route, body)).StatusCode); var saved = await client.GetFromJsonAsync(Route); Assert.Equal(ids, saved.GetProperty("widgets").EnumerateArray().Select(x => x.GetProperty("id").GetString())); Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":1,\"widgets\":[]}"))).StatusCode); var reset = await client.GetFromJsonAsync(Route); Assert.Equal(2, reset.GetProperty("revision").GetInt64()); Assert.Empty(reset.GetProperty("widgets").EnumerateArray()); } [Theory] [InlineData("{", 400)] [InlineData("{\"version\":\"1\",\"revision\":0,\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":\"0\",\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":9223372036854775807,\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":9007199254740991,\"widgets\":[]}", 400)] [InlineData("{\"version\":2,\"revision\":0,\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":-1,\"widgets\":[]}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":null}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"unknown\",\"visible\":true,\"size\":\"full\"}]}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"standard\"}]}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"visible\":true,\"size\":\"huge\"}]}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"size\":\"full\"}]}", 400)] [InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"visible\":\"true\",\"size\":\"full\"}]}", 400)] public async Task Invalid_payload_does_not_save(string payload, int expected) { await using var host = await Host.Start(); using var client = host.Client("owner"); Assert.Equal(expected, (int)(await client.PutAsync(Route, Json(payload))).StatusCode); Assert.Equal(0, (await client.GetFromJsonAsync(Route)).GetProperty("revision").GetInt64()); } [Fact] public async Task Duplicate_excessive_and_oversized_payloads_are_bounded() { await using var host = await Host.Start(); using var client = host.Client("owner"); const string widget = "{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}"; foreach (var count in new[] { 2, 33 }) Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":0,\"widgets\":[" + string.Join(',', Enumerable.Repeat(widget, count)) + "]}"))).StatusCode); Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.PutAsync(Route, Json(Layout + new string(' ', 16384)))).StatusCode); Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.PutAsync(Route, new ChunkedJson(Layout + new string(' ', 16384)))).StatusCode); } [Fact] public async Task Additive_migration_preserves_legacy_account_and_defaults_and_generates_MariaDb_SQL() { await using var host = await Host.Start(legacy: true); await using var db = host.Db(); var user = await db.Users.SingleAsync(x => x.Id == "owner"); Assert.Null(db.Entry(user).Property("DashboardLayoutJson").CurrentValue); Assert.Equal(0L, db.Entry(user).Property("DashboardLayoutRevision").CurrentValue); Assert.Equal("nb-NO", user.UiLanguage); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); var options = new DbContextOptionsBuilder().UseMySql("Server=127.0.0.1;Database=script_only;User=none;Password=none;", new MariaDbServerVersion(new Version(11, 0, 0))).Options; await using var maria = new JobTrackerContext(options, Mock.Of()); var script = maria.GetService().GenerateScript("20260830137000_AdoptSavedJobSearchSchema"); Assert.Contains("`DashboardLayoutJson` longtext NULL", script); Assert.Contains("`DashboardLayoutRevision` bigint NOT NULL DEFAULT 0", script); } private static StringContent Json(string value) => new(value, Encoding.UTF8, "application/json"); private sealed class ChunkedJson(string value) : HttpContent { protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(Encoding.UTF8.GetBytes(value)).AsTask(); protected override bool TryComputeLength(out long length) { length = 0; return false; } } private sealed class PausedJson(string value, Task release) : HttpContent { protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) { await stream.WriteAsync(new byte[] { (byte)' ' }); await stream.FlushAsync(); await release; await stream.WriteAsync(Encoding.UTF8.GetBytes(value)); } protected override bool TryComputeLength(out long length) { length = 0; return false; } } private sealed class LayoutReadObserver : DbCommandInterceptor { public Action? OnLayoutRead { get; set; } public override InterceptionResult DataReaderDisposing(DbCommand command, DataReaderDisposingEventData eventData, InterceptionResult result) { if (command.CommandText.StartsWith("SELECT", StringComparison.Ordinal) && command.CommandText.Contains("DashboardLayoutJson", StringComparison.Ordinal)) OnLayoutRead?.Invoke(); return result; } } private sealed class Host : IAsyncDisposable { private readonly WebApplication app; private readonly string directory; private readonly string connection; public LayoutReadObserver ReadObserver { get; } private Host(WebApplication app, string directory, string connection, LayoutReadObserver readObserver) => (this.app, this.directory, this.connection, ReadObserver) = (app, directory, connection, readObserver); public JobTrackerContext Db() => new(new DbContextOptionsBuilder().UseSqlite(connection).Options, Mock.Of()); public HttpClient Client(string? user) { var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService().Features.Get()!.Addresses.Single()) }; if (user is not null) client.DefaultRequestHeaders.Add("X-Test-User", user); return client; } public static async Task Start(bool legacy = false) { var directory = Path.Combine(Path.GetTempPath(), "dashboard-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); var connection = $"Data Source={Path.Combine(directory, "test.db")};Pooling=False;Default Timeout=5"; var builder = WebApplication.CreateBuilder(); builder.Configuration.AddInMemoryCollection(new Dictionary { ["Data:Root"] = directory, ["Database:Provider"] = "sqlite", ["ConnectionStrings:JobTracker"] = connection, ["Auth:AdminEmail"] = "", ["Auth:AdminPassword"] = "" }); builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly); builder.Services.AddAuthentication("local").AddScheme("local", _ => { }); builder.Services.AddAuthorization(); var readObserver = new LayoutReadObserver(); builder.Services.AddDbContext(x => x.UseSqlite(connection).AddInterceptors(readObserver)); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddIdentityCore().AddRoles().AddEntityFrameworkStores(); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); builder.Services.AddSingleton(Mock.Of()); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); var host = new Host(app, directory, connection, readObserver); await using (var db = host.Db()) { if (legacy) { await db.GetService().MigrateAsync("20260830137000_AdoptSavedJobSearchSchema"); await db.Database.ExecuteSqlRawAsync("INSERT INTO AspNetUsers (Id, UiLanguage, EmailConfirmed, PhoneNumberConfirmed, TwoFactorEnabled, LockoutEnabled, AccessFailedCount) VALUES ('owner', 'nb-NO', 0, 0, 0, 0, 0)"); } await app.InitializeJobTrackerAsync(); if (!legacy) { db.Users.AddRange(new ApplicationUser { Id = "owner", UiLanguage = "nb-NO", EmailFollowUpRemindersEnabled = false, SecurityStamp = "security", ConcurrencyStamp = "concurrency", DisplayName = "profile" }, new ApplicationUser { Id = "other" }); await db.SaveChangesAsync(); } } await app.StartAsync(); return host; } public async ValueTask DisposeAsync() { await app.DisposeAsync(); Directory.Delete(directory, true); } } private sealed class TestAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { protected override Task HandleAuthenticateAsync() => Task.FromResult(Request.Headers.TryGetValue("X-Test-User", out var id) ? AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, id.ToString()) }, "local")), "local")) : AuthenticateResult.NoResult()); } }