diff --git a/JobTrackerApi.Tests/DashboardPreferencesTests.cs b/JobTrackerApi.Tests/DashboardPreferencesTests.cs new file mode 100644 index 0000000..3fafe40 --- /dev/null +++ b/JobTrackerApi.Tests/DashboardPreferencesTests.cs @@ -0,0 +1,289 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +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.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 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 Host : IAsyncDisposable + { + private readonly WebApplication app; + private readonly string directory; + private readonly string connection; + private Host(WebApplication app, string directory, string connection) => (this.app, this.directory, this.connection) = (app, directory, connection); + 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(); + builder.Services.AddDbContext(x => x.UseSqlite(connection)); + 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); + 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()); + } +} diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index e05beb6..f3ce5f9 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -544,6 +544,54 @@ public sealed class AuthController : ControllerBase public sealed record UpdateLanguagePreferenceRequest(string? Language); + [HttpGet("preferences/dashboard")] + [Authorize(AuthenticationSchemes = "local")] + public async Task GetDashboardPreference(CancellationToken cancellationToken) + { + var userId = LocalAuthIdentity.GetRequiredUserId(User); + if (userId is null) return Unauthorized(); + var user = await _db.Users.AsNoTracking().Where(x => x.Id == userId) + .Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken); + return user is null ? Unauthorized() : Ok(DashboardLayout.Read(user.DashboardLayoutJson, user.DashboardLayoutRevision)); + } + + [HttpPut("preferences/dashboard")] + [Authorize(AuthenticationSchemes = "local")] + public async Task UpdateDashboardPreference(CancellationToken cancellationToken) + { + var userId = LocalAuthIdentity.GetRequiredUserId(User); + if (userId is null) return Unauthorized(); + var existing = await _db.Users.AsNoTracking().Where(x => x.Id == userId) + .Select(x => new { x.DashboardLayoutJson }).SingleOrDefaultAsync(cancellationToken); + if (existing is null) return Unauthorized(); + if (DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version > 1) + return Conflict(new { error = "dashboard_version_unsupported" }); + if (Request.ContentLength > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge); + // Bound actual bytes as well as Content-Length, including chunked bodies. + var bytes = new byte[DashboardLayout.MaxBytes + 1]; + var length = 0; + while (length < bytes.Length) + { + var count = await Request.Body.ReadAsync(bytes.AsMemory(length), cancellationToken); + if (count == 0) break; + length += count; + } + if (length > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge); + DashboardLayout? layout; + try + { + using var document = JsonDocument.Parse(bytes.AsMemory(0, length)); + layout = DashboardLayout.Parse(document.RootElement); + } + catch (JsonException) { return BadRequest("Invalid dashboard layout JSON."); } + if (layout is null) return BadRequest("Invalid dashboard layout version, revision or widgets."); + var json = JsonSerializer.Serialize(layout, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var updated = await _db.Users.Where(x => x.Id == userId && x.DashboardLayoutRevision == layout.Revision) + .ExecuteUpdateAsync(set => set.SetProperty(x => x.DashboardLayoutJson, json) + .SetProperty(x => x.DashboardLayoutRevision, x => x.DashboardLayoutRevision + 1), cancellationToken); + return updated == 0 ? Conflict(new { error = "dashboard_revision_conflict" }) : Ok(layout with { Revision = layout.Revision + 1 }); + } + [HttpPut("preferences/language")] [Authorize(AuthenticationSchemes = "local")] public async Task UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request) diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index 54b673f..9da0627 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -101,6 +101,15 @@ namespace JobTrackerApi.Data .Property(x => x.UiLanguage) .HasMaxLength(16); + modelBuilder.Entity() + .Property(x => x.DashboardLayoutRevision) + .HasDefaultValue(0L); + // Layout writes use the revision-guarded endpoint; Identity's whole-user update must not replay stale values. + modelBuilder.Entity().Property(x => x.DashboardLayoutJson) + .Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + modelBuilder.Entity().Property(x => x.DashboardLayoutRevision) + .Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity().Property(x => x.OwnerKey).HasMaxLength(64); modelBuilder.Entity().Property(x => x.RequestedByUserId).HasMaxLength(255); diff --git a/JobTrackerApi/Migrations/20261021090000_AddDashboardLayoutPreference.cs b/JobTrackerApi/Migrations/20261021090000_AddDashboardLayoutPreference.cs new file mode 100644 index 0000000..24dfd4b --- /dev/null +++ b/JobTrackerApi/Migrations/20261021090000_AddDashboardLayoutPreference.cs @@ -0,0 +1,32 @@ +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations; + +[DbContext(typeof(JobTrackerContext))] +[Migration("20261021090000_AddDashboardLayoutPreference")] +public partial class AddDashboardLayoutPreference : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + var mysql = ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase); + migrationBuilder.AddColumn(name: "DashboardLayoutJson", table: "AspNetUsers", type: mysql ? "longtext" : "TEXT", nullable: true); + migrationBuilder.AddColumn(name: "DashboardLayoutRevision", table: "AspNetUsers", type: mysql ? "bigint" : "INTEGER", nullable: false, defaultValue: 0L); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + if (ActiveProvider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase)) + { + // These independent columns need no table rebuild (SQLite 3.35+ supports DROP COLUMN). + migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutJson\";"); + migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutRevision\";"); + return; + } + migrationBuilder.DropColumn(name: "DashboardLayoutJson", table: "AspNetUsers"); + migrationBuilder.DropColumn(name: "DashboardLayoutRevision", table: "AspNetUsers"); + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index b813899..db0bd2c 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -485,6 +485,16 @@ namespace JobTrackerApi.Migrations .HasMaxLength(16) .HasColumnType("TEXT"); + b.Property("DashboardLayoutJson") + .HasColumnType("TEXT") + .Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + + b.Property("DashboardLayoutRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0L) + .Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore); + b.Property("TotpEnabledAtUtc") .HasColumnType("TEXT"); diff --git a/JobTrackerApi/Models/ApplicationUser.cs b/JobTrackerApi/Models/ApplicationUser.cs index 3f38250..1be5c6a 100644 --- a/JobTrackerApi/Models/ApplicationUser.cs +++ b/JobTrackerApi/Models/ApplicationUser.cs @@ -35,6 +35,8 @@ public sealed class ApplicationUser : IdentityUser public bool EmailFollowUpRemindersEnabled { get; set; } = true; // BCP 47 application UI preference. Null/legacy values safely fall back to English. public string? UiLanguage { get; set; } + public string? DashboardLayoutJson { get; set; } + public long DashboardLayoutRevision { get; set; } public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active; public DateTimeOffset? DeletionRequestedAtUtc { get; set; } } diff --git a/JobTrackerApi/Models/DashboardLayout.cs b/JobTrackerApi/Models/DashboardLayout.cs new file mode 100644 index 0000000..85be34c --- /dev/null +++ b/JobTrackerApi/Models/DashboardLayout.cs @@ -0,0 +1,60 @@ +using System.Text; +using System.Text.Json; + +namespace JobTrackerApi.Models; + +public sealed record DashboardWidget(string Id, bool Visible, string Size); +public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList Widgets) +{ + public const int MaxBytes = 16 * 1024; + // Responses cross a JavaScript client; the incremented revision must remain an exact integer there. + private const long MaxRevision = 9_007_199_254_740_991L; + private static readonly HashSet KnownIds = new(StringComparer.Ordinal) + { "cards", "attention", "funnel", "activity", "stageAge", "sources", "salary", "companies", "skills" }; + + public static DashboardLayout? Parse(JsonElement root, bool reading = false, long revision = 0) + { + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v != 1 + || !root.TryGetProperty("widgets", out var widgets) || widgets.ValueKind != JsonValueKind.Array || widgets.GetArrayLength() > 32) return null; + if (!reading && (!root.TryGetProperty("revision", out var rev) || rev.ValueKind != JsonValueKind.Number || !rev.TryGetInt64(out revision) || revision < 0 || revision >= MaxRevision)) return null; + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var widget in widgets.EnumerateArray()) + { + if (widget.ValueKind != JsonValueKind.Object || !widget.TryGetProperty("id", out var idValue) || idValue.ValueKind != JsonValueKind.String + || !KnownIds.Contains(idValue.GetString()!) || !seen.Add(idValue.GetString()!) + || !widget.TryGetProperty("visible", out var visible) || (visible.ValueKind != JsonValueKind.True && visible.ValueKind != JsonValueKind.False)) + { + if (reading) continue; + return null; + } + var id = idValue.GetString()!; + var size = widget.TryGetProperty("size", out var sizeValue) && sizeValue.ValueKind == JsonValueKind.String ? sizeValue.GetString() : null; + if (size is not ("standard" or "full") || (id == "cards" && size != "full")) + { + if (!reading) return null; + size = id == "cards" ? "full" : "standard"; + } + result.Add(new DashboardWidget(id, visible.GetBoolean(), size)); + } + return new DashboardLayout(1, revision, result); + } + + public static DashboardLayout Read(string? json, long revision) + { + if (json is not null && Encoding.UTF8.GetByteCount(json) <= MaxBytes) + { + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.Number + && version.TryGetInt32(out var futureVersion) && futureVersion > 1) + return new(futureVersion, revision, []); + return Parse(document.RootElement, reading: true, revision) ?? new(1, revision, []); + } + catch (JsonException) { } + } + return new(1, revision, []); + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 9e20044..a43a7be 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -320,6 +320,7 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;"); // UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before // the per-migration loop makes a fresh database fail when that migration runs. + // DashboardLayoutJson/Revision likewise belong to AddDashboardLayoutPreference. static void ReconcileGmailConnectionColumns(DbConnection c) { @@ -539,6 +540,7 @@ public static class StartupInitializationExtensions EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;"); // UiLanguage is migration-owned; the final reconciliation pass observes it // after AddUiLanguagePreference instead of racing the migration. + // DashboardLayoutJson/Revision are also migration-owned for both providers. // RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler // used to create it too, which made a clean install fail with "Table 'RuleSettings' diff --git a/docs/redesign/phase-8/p8-00/BASELINE.md b/docs/redesign/phase-8/p8-00/BASELINE.md new file mode 100644 index 0000000..910c1dd --- /dev/null +++ b/docs/redesign/phase-8/p8-00/BASELINE.md @@ -0,0 +1,22 @@ +# P8-00 — Baseline and architecture + +Completed 21 October 2026. Entry `ef94d579cbfec62d1ac38346f556f80276cee95a`, clean working tree, local and direct remote `codex/ui-redesign-phase4` equal. Requested Phase 8 branch did not exist locally or remotely; created `feature/phase8-dashboard-customisation` from that entry. Planning checkpoint `421f902d21c08a814de12d7c32bc6ab8d4c01e15` pushed normally; `git ls-remote` directly confirms it. Main remains outside this work. + +Reviewed Phase 7 final acceptance and Phase 8 handoff, Phase 6 handoff, original implementation plan, dashboard/Phase 3 architecture, design system, responsive/accessibility, guardrails, information architecture, flows and master spec. The current implementation confirms only five local visibility flags; attention, stage-age, source-response and salary sections were outside them. The new nine-entry [catalogue and design](../README.md) preserves these sections and all existing data owners. + +## Fresh starting tests + +| Command | Result | Evidence | +|---|---|---| +| Pinned .NET `test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore` | 1,060 passed; 0 failed, skipped; 16s test duration | `.artifacts/phase8/baseline/p8-baseline.trx`, `baseline-backend.log` | +| UI `npm test -- --runInBand --json --outputFile=../.artifacts/phase8/baseline-frontend.json` | 899 passed / 101 suites; 0 failed, pending; 284.546s | `.artifacts/phase8/baseline-frontend.json`, `baseline-frontend.log` | + +These executions precede product source changes. PowerShell's native stderr wrapping is retained in the raw log; Jest's result JSON records the actual outcomes. Historic Phase 7 browser351, separate23 and sidecar56 are inherited evidence, not fresh Phase 8 runs. + +## Prerequisites and decisions + +Account persistence is a necessary extension explicitly approved by the current brief, superseding Phase 3's proposed device-first option. Extend ApplicationUser with two additive fields and a narrow owner-only GET/PUT. Compare-and-update revision in one SQL statement; avoid whole-Identity updates for layout saves. Supported sizes are standard/full with a fixed full-width summary, one logical DOM order and single-column mobile. Reuse installed native drag helper and touch/keyboard move buttons; no dependency comparison/download needed. + +The known failure investigation reviewed P7's deterministic held-reader reproduction against DELETE journaling, its WAL-before-bootstrap repair, and finite passing regression. Add actual layout reader/write and competing-save tests on that baseline. Do not infer permanent immunity to writer contention or identify the historical browser's exact competing connection. + +No mandatory missing dependency blocks safe development. Retained conditions remain: real MariaDB runtime, human screen reader/physical devices/complete enlargement, other-browser/output and production release checks. Production NO-GO, P5-13 withheld. The [risk register](../README.md) carries these forward. diff --git a/docs/redesign/phase-8/p8-03/BACKEND.md b/docs/redesign/phase-8/p8-03/BACKEND.md new file mode 100644 index 0000000..d2e130a --- /dev/null +++ b/docs/redesign/phase-8/p8-03/BACKEND.md @@ -0,0 +1,61 @@ +# P8-03 backend persistence evidence + +Verified locally on 21 October 2026 against the Phase 7 WAL baseline. No production database or external service was used. + +## Contract and ownership + +`GET` and `PUT /api/auth/preferences/dashboard` require the existing `local` authentication scheme. The principal supplies the account ID; query/body account IDs have no authority. Missing/unknown subjects return 401. DTO: `{version:1, revision, widgets:[{id,visible,size}]}`. No saved layout returns revision 0 and an empty widget array. Successful PUT returns the canonical DTO with revision incremented by one; a stale revision returns 409 with `error: dashboard_revision_conflict`. + +Writes accept at most 16,384 actual UTF-8 bytes, including chunked requests; larger bodies return 413. Invalid JSON, unsupported/missing version, non-integer/negative/exhausted revision, absent widgets, more than 32 entries, unknown/duplicate IDs, absent/non-boolean visibility, and invalid sizes return 400 without saving. Incoming revision must be below 9,007,199,254,740,991 so the incremented revision remains an exact JavaScript integer. Supported IDs are the nine in the Phase 8 catalogue. `cards` accepts only `full`; every other widget accepts `standard` or `full`. Unsupported sizes are rejected on writes. Only validated canonical fields are stored; extra request fields are discarded. An empty saved list is valid, but has a positive revision and therefore remains distinct from an unsaved account. The frontend owns missing-widget defaults and explicit reset behaviour. + +Reads retain the database revision. Unknown, duplicate or malformed saved widgets are omitted; unsupported saved sizes become `full` for cards and `standard` otherwise. Corrupt or oversized stored JSON becomes an empty list without resetting the revision. This permits a deliberate revision-guarded repair save and prevents corrupt storage from rendering arbitrary content. A bounded JSON document declaring an integer schema version greater than 1 instead exposes that actual version and an empty widget list. PUT rejects such stored future schemas with 409 `dashboard_version_unsupported`, even when a version-1 request supplies the matching revision; newer settings remain intact and the frontend can disable editing. + +`ApplicationUser` owns nullable `DashboardLayoutJson` and default-zero `DashboardLayoutRevision`. The endpoint uses one SQL `ExecuteUpdateAsync` guarded by owner ID and expected revision; it updates exactly these two columns. Simultaneous first writes cannot both win. No layout write invokes `UserManager.UpdateAsync`. + +A reproduced additional race showed that an unrelated stale Identity whole-user update could otherwise replay the old layout/revision. Both layout properties ignore ordinary EF tracked updates after insertion. Their conditional endpoint update remains effective. The regression marks every property modified, matching Identity's store behaviour, then proves the newer layout survives. Profile, language, notification settings and Identity stamps remain unchanged by layout saves. + +## Migration and startup + +Additive migration `20261021090000_AddDashboardLayoutPreference` adds nullable TEXT/longtext JSON and non-null INTEGER/bigint revision default 0. No new table or backfill of layout choices. The snapshot includes both properties. SQLite and MariaDB startup reconciliation intentionally leave these columns to the migration, matching the existing language preference owner; adding them before the per-migration loop would collide on fresh startup. Actual fresh and legacy file startup plus existing complete migration-chain and repeated-startup tests pass. + +The explicit SQLite downgrade drops only these independent columns using native SQLite `DROP COLUMN` (requires SQLite 3.35+, satisfied by the tested bundled runtime). MariaDB uses normal EF DropColumn operations. Migration-chain downgrade/upgrade tests pass; downgrade necessarily removes saved layouts. No downgrade was performed outside disposable tests. + +## Test evidence + +All commands used the pinned executable `C:\Users\Connor\.dotnet\dotnet.exe` through `rtk`, project `JobTrackerApi.Tests/JobTrackerApi.Tests.csproj`, flags `--configuration Release --no-restore`, and a TRX logger/results directory below `.artifacts/phase8/`. + +| Run | Filter | Actual result | TRX | +|---|---|---|---| +| Meaningful pre-implementation RED | `FullyQualifiedName~DashboardPreferencesTests` | 0 pass / 13 fail / 0 skipped; absent routes/model | `backend-red/dashboard-red.trx` | +| Initial implementation | same | 13 pass / 0 fail / 0 skipped | `backend-green/dashboard-green.trx` | +| Stale Identity race RED | `FullyQualifiedName~Stale_identity_profile_update` | 0 pass / 1 fail; saved revision 1 overwritten by 0 | `backend-stale-red/dashboard-stale-red.trx` | +| First expanded run | dashboard + migration chain + SQLite startup + schema ownership | 25 pass / 16 fail; 15 downgrade SQL generation failures and one malformed fixture SQL | `backend-focused/dashboard-focused.trx` | +| Downgrade fix successor | same | 40 pass / 1 fail; remaining malformed fixture SQL | `backend-focused-successor/dashboard-focused.trx` | +| Final expanded run | same, plus two further dashboard HTTP cases | **43 pass / 0 fail / 0 skipped** | `backend-focused-final/dashboard-focused.trx` | +| Complete backend | no filter | **1081 pass / 0 fail / 0 skipped**, 29 seconds | `backend-full/dashboard-full.trx` | +| Future-schema / JS-safe revision RED | `FullyQualifiedName~Future_schema\|FullyQualifiedName~Invalid_payload_does_not_save` | 12 pass / 2 fail; future version hidden and unsafe exhausted revision accepted | `backend-future-red/dashboard-future-red.trx` | +| Complete backend after future-schema repair | no filter | **1083 pass / 0 fail / 0 skipped**, 32 seconds | `backend-full-final/dashboard-full.trx` | + +The initial test-harness attempt omitted historical startup reconciliation and failed before reaching endpoints (missing historical AvatarImageDataUrl column). The meaningful RED above reran after wiring the actual startup owner. The subsequent malformed fixture SQL used a literal brace through EF's formatting API; parameterising it corrected the fixture without changing the product expectation. + +Final focused command: + +```powershell +rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --filter "FullyQualifiedName~DashboardPreferencesTests|FullyQualifiedName~MigrationChainTests|FullyQualifiedName~SqliteStartupConcurrencyTests|FullyQualifiedName~StartupSchemaOwnershipTests" --logger "trx;LogFileName=dashboard-focused.trx" --results-directory .artifacts/phase8/backend-focused-final +``` + +Final complete command: + +```powershell +rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=dashboard-full.trx" --results-directory .artifacts/phase8/backend-full-final +``` + +The 23 new dashboard cases use an actual local Kestrel HTTP listener, real EF SQLite files, the complete migrations and actual startup reconciliation. They verify 401, owner isolation despite an attempted query account override, GET/PUT acknowledgement, 409, concurrent first saves, unchanged unrelated account fields/stamps, stale Identity preservation, invalid/oversized/chunked payloads, all catalogue IDs/order, empty saved lists, corrupt reads, future-schema downgrade protection, exact JavaScript revision bounds, legacy defaults and provider SQL generation. They also hold a SELECT reader open while saving the actual layout against the WAL file. + +## Limits + +The HTTP fixture substitutes a test-only local authentication handler; it proves endpoint scheme/owner enforcement, not production cookie issuance, CSRF/session expiry or proxy behaviour. It uses the existing minimal EF host conventions; TestHostFactory has no reusable real HTTP host, so the fixture adds only the services needed by AuthController/startup. Existing auth tests remain in the passing complete suite. Browser/session integration belongs to the UI acceptance lane. + +MariaDB migration SQL is generated with the installed Pomelo provider and explicitly checks `longtext NULL` and `bigint NOT NULL DEFAULT 0`. This is SQL inspection, not MariaDB runtime evidence. `rtk docker ps --format "{{.Names}} {{.Image}} {{.Ports}}"` failed because the Docker Desktop Linux engine pipe was unavailable; no disposable MariaDB instance was exercised or created. + +The held-reader and simultaneous-save tests establish finite regression evidence. WAL does not eliminate every form of writer contention; no retries, swallowed provider failures or permanent reliability claim were introduced. Human/device, deployed configuration, backup/restore and production gates remain outstanding; production remains NO-GO. diff --git a/docs/roadmap/todo.md b/docs/roadmap/todo.md index 3beed48..ac55539 100644 --- a/docs/roadmap/todo.md +++ b/docs/roadmap/todo.md @@ -134,3 +134,7 @@ Recent-notification presentation committed and normally pushed **40cfe04df51654c ### P7-08 publication and P7-09 entry Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action. +# Phase 8 active checkpoint + +- [ ] Complete dashboard customisation P8-00–P8-05 on `feature/phase8-dashboard-customisation`; [scope, catalogue, plan and live checklist](../redesign/phase-8/README.md). Preserve Phase 7 conditional requirements; no main merge or production deployment. +