Files
jobtrackingapp/JobTrackerApi.Tests/DashboardPreferencesTests.cs
T

341 lines
20 KiB
C#

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<JsonElement>(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<JsonElement>()).GetProperty("revision").GetInt64());
Assert.Equal(HttpStatusCode.Conflict, (await owner.PutAsync(Route, Json(Layout))).StatusCode);
Assert.Equal(0, (await other.GetFromJsonAsync<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JsonElement>(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<JobTrackerContext>().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<ICurrentUserService>());
var script = maria.GetService<IMigrator>().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<JobTrackerContext>().UseSqlite(connection).Options, Mock.Of<ICurrentUserService>());
public HttpClient Client(string? user)
{
var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()) };
if (user is not null) client.DefaultRequestHeaders.Add("X-Test-User", user);
return client;
}
public static async Task<Host> 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<string, string?> { ["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<AuthenticationSchemeOptions, TestAuthentication>("local", _ => { });
builder.Services.AddAuthorization();
var readObserver = new LayoutReadObserver();
builder.Services.AddDbContext<JobTrackerContext>(x => x.UseSqlite(connection).AddInterceptors(readObserver));
builder.Services.AddSingleton(Mock.Of<ICurrentUserService>());
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
builder.Services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
builder.Services.AddSingleton(Mock.Of<ITokenService>());
builder.Services.AddSingleton(Mock.Of<IAppEmailSender>());
builder.Services.AddSingleton(Mock.Of<IGoogleTokenValidator>());
builder.Services.AddSingleton(Mock.Of<IMicrosoftTokenValidator>());
builder.Services.AddSingleton(Mock.Of<ITwoFactorPendingTokenService>());
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<IMigrator>().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<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> 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());
}
}