fix(dashboard): protect schema snapshots during delayed writes
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user