341 lines
20 KiB
C#
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());
|
|
}
|
|
}
|