fix(ui): schema-library delete-audit name + busy guard + edit-row guard + sanitized create-race test (#260)
This commit is contained in:
@@ -187,6 +187,26 @@ public class SchemaLibraryPageTests : BunitContext
|
||||
9, "Address", Arg.Any<string?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editing_DisablesRowActions_SoTheRowUnderEditCannotBeDeleted()
|
||||
{
|
||||
// #260: while the edit form is open the list still shows the row, but its
|
||||
// Edit/Delete actions must be disabled so the row under edit (or a sibling)
|
||||
// can't be deleted out from under the form.
|
||||
SeedSchemas(new SharedSchema { Id = 9, Name = "Address", Scope = "us", SchemaJson = "{\"type\":\"object\"}" });
|
||||
|
||||
var cut = Render<SchemaLibraryPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Address"));
|
||||
|
||||
cut.FindAll("tbody tr button").First(b => b.TextContent.Contains("Edit")).Click();
|
||||
Assert.Contains("Edit Schema: Address", cut.Markup);
|
||||
|
||||
// Every row action button is now disabled while the editor is open.
|
||||
var rowButtons = cut.FindAll("tbody tr button");
|
||||
Assert.NotEmpty(rowButtons);
|
||||
Assert.All(rowButtons, b => Assert.True(b.HasAttribute("disabled")));
|
||||
}
|
||||
|
||||
/// <summary>A dialog service that auto-confirms, so the delete path runs end-to-end.</summary>
|
||||
private sealed class AlwaysConfirmDialogService : IDialogService
|
||||
{
|
||||
|
||||
@@ -302,6 +302,28 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
_schemaRepo.Received(1).DeleteAsync(5, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteSharedSchema_RecordsHumanReadableNameInAudit()
|
||||
{
|
||||
// #260: the delete handler pre-fetches the row name BEFORE deleting so the
|
||||
// audit EntityName records "Addr" rather than the numeric id (mirroring the
|
||||
// Site delete handler).
|
||||
_schemaRepo.GetByIdAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
// LogAsync(user, action, entityType, entityId, entityName, afterState, ct):
|
||||
// entityId is the id ("5"), entityName is the resolved name ("Addr").
|
||||
_auditService.Received(1).LogAsync(
|
||||
Arg.Any<string>(), "Delete", "SharedSchema", "5", "Addr",
|
||||
Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListSharedSchemas_WhenRepoThrows_ReturnsError()
|
||||
{
|
||||
@@ -317,4 +339,48 @@ public class SchemaLibraryHandlerTests : TestKit, IDisposable
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.DoesNotContain("db down secret detail", response.Error);
|
||||
}
|
||||
|
||||
// ── Concurrent-create race (#260) ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CreateSharedSchema_WhenInsertRacesUniqueViolation_SurfacesSanitizedError()
|
||||
{
|
||||
// Race: GetByNameAsync sees no row, but a concurrent create commits the
|
||||
// same unique Name before our AddAsync, so the INSERT hits the DB unique
|
||||
// constraint. EF surfaces this as a DbUpdateException whose message carries
|
||||
// raw DB internals (server/constraint/SQL detail). The handler does NOT
|
||||
// catch this as a curated ManagementCommandException, so the actor's
|
||||
// top-level failure path must sanitize it to a generic, correlation-tagged
|
||||
// message — never leaking the raw SQL/DB text to the caller.
|
||||
//
|
||||
// Modeled with a SqlException-shaped fault rather than a real EF
|
||||
// DbUpdateException so the ManagementService.Tests project takes no new
|
||||
// EntityFrameworkCore package reference; the actor's sanitization keys on
|
||||
// the exception NOT being a ManagementCommandException, which is identical
|
||||
// for both types — so this exercises the exact same code path.
|
||||
const string rawDbDetail =
|
||||
"Cannot insert duplicate key row in object 'dbo.SharedSchemas' with unique " +
|
||||
"index 'IX_SharedSchemas_Name'. The duplicate key value is (Addr). " +
|
||||
"INSERT INTO [SharedSchemas] ...";
|
||||
|
||||
_schemaRepo.GetByNameAsync("Addr", Arg.Any<CancellationToken>())
|
||||
.Returns((SharedSchema?)null);
|
||||
_schemaRepo.AddAsync(Arg.Any<SharedSchema>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException(rawDbDetail));
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Designer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
// Sanitized: the generic internal-error message, NOT the raw DB detail.
|
||||
Assert.Contains("internal error", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("SharedSchemas", response.Error);
|
||||
Assert.DoesNotContain("IX_SharedSchemas_Name", response.Error);
|
||||
Assert.DoesNotContain("INSERT INTO", response.Error);
|
||||
Assert.DoesNotContain("duplicate key", response.Error);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user