fix(dashboard): protect schema snapshots during delayed writes

This commit is contained in:
cesnimda
2026-10-21 21:05:40 +02:00
parent 5edbe59692
commit a24e6b3159
3 changed files with 64 additions and 6 deletions
@@ -1,6 +1,7 @@
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;
@@ -14,6 +15,7 @@ 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;
@@ -141,6 +143,31 @@ public sealed class DashboardPreferencesTests
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()
{
@@ -216,12 +243,35 @@ public sealed class DashboardPreferencesTests
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;
private Host(WebApplication app, string directory, string connection) => (this.app, this.directory, this.connection) = (app, directory, 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)
{
@@ -241,7 +291,8 @@ public sealed class DashboardPreferencesTests
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddAuthentication("local").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("local", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddDbContext<JobTrackerContext>(x => x.UseSqlite(connection));
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>();
@@ -255,7 +306,7 @@ public sealed class DashboardPreferencesTests
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
var host = new Host(app, directory, connection);
var host = new Host(app, directory, connection, readObserver);
await using (var db = host.Db())
{
if (legacy)