fix(ui): schema-library delete-audit name + busy guard + edit-row guard + sanitized create-race test (#260)

This commit is contained in:
Joseph Doherty
2026-06-19 03:28:11 -04:00
parent e3b83f8561
commit 8f85cce298
5 changed files with 121 additions and 11 deletions
@@ -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);
}
}