using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using NSubstitute.ExceptionExtensions; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.ManagementService; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// ManagementActor handler tests for the M9-T32c schema-library CRUD commands. The /// reusable named JSON-Schema library ( + /// , T32a) gains its authoring surface here: /// list/get are read-only (any authenticated user), create/update/delete are /// Designer-gated, the schema JSON is validated as a JSON Schema, and the unique /// Name is enforced with a clear duplicate-name error. Mirrors the repo-backed /// DatabaseConnection handler idiom (direct repo writes + audit). /// public class SchemaLibraryHandlerTests : TestKit, IDisposable { private readonly ISharedSchemaRepository _schemaRepo; private readonly IAuditService _auditService; private readonly ServiceCollection _services; public SchemaLibraryHandlerTests() { _schemaRepo = Substitute.For(); _auditService = Substitute.For(); _services = new ServiceCollection(); _services.AddScoped(_ => _schemaRepo); _services.AddScoped(_ => _auditService); } private IActorRef CreateActor() { var sp = _services.BuildServiceProvider(); return Sys.ActorOf(Props.Create(() => new ManagementActor( sp, NullLogger.Instance))); } private static ManagementEnvelope Envelope(object command, params string[] roles) => new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty()), command, Guid.NewGuid().ToString("N")); void IDisposable.Dispose() => Shutdown(); private const string ValidSchema = """{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}"""; // ── Gating ────────────────────────────────────────────────────────────── [Fact] public void CreateSharedSchema_WithDeployerRole_ReturnsUnauthorized() { var actor = CreateActor(); var envelope = Envelope(new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Deployer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("Designer", response.Message); } [Fact] public void UpdateSharedSchema_WithNoRoles_ReturnsUnauthorized() { var actor = CreateActor(); var envelope = Envelope(new UpdateSharedSchemaCommand(1, "Addr", null, ValidSchema)); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("Designer", response.Message); } [Fact] public void DeleteSharedSchema_WithDeployerRole_ReturnsUnauthorized() { var actor = CreateActor(); var envelope = Envelope(new DeleteSharedSchemaCommand(1), "Deployer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("Designer", response.Message); } // ── List / Get (read-gated) ───────────────────────────────────────────── [Fact] public void ListSharedSchemas_WithNoRoles_ReturnsSuccess() { _schemaRepo.ListAsync(Arg.Any()) .Returns(new List { new() { Id = 1, Name = "Addr", SchemaJson = ValidSchema }, }); var actor = CreateActor(); var envelope = Envelope(new ListSharedSchemasCommand()); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(envelope.CorrelationId, response.CorrelationId); Assert.Contains("Addr", response.JsonData); } [Fact] public void GetSharedSchema_WithNoRoles_ReturnsSuccess() { _schemaRepo.GetByIdAsync(7, Arg.Any()) .Returns(new SharedSchema { Id = 7, Name = "Addr", SchemaJson = ValidSchema }); var actor = CreateActor(); var envelope = Envelope(new GetSharedSchemaCommand(7)); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("Addr", response.JsonData); } // ── Create round-trip + validation ────────────────────────────────────── [Fact] public void CreateSharedSchema_WithDesignerRole_PersistsAndReturnsEntity() { SharedSchema? saved = null; _schemaRepo.GetByNameAsync("Addr", Arg.Any()) .Returns((SharedSchema?)null); _schemaRepo.AddAsync(Arg.Any(), Arg.Any()) .Returns(ci => { saved = ci.Arg(); saved.Id = 42; return 42; }); var actor = CreateActor(); var envelope = Envelope( new CreateSharedSchemaCommand("Addr", "global", ValidSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(envelope.CorrelationId, response.CorrelationId); Assert.NotNull(saved); Assert.Equal("Addr", saved!.Name); Assert.Equal("global", saved.Scope); Assert.Equal(ValidSchema, saved.SchemaJson); // Returned payload carries the store-generated id. Assert.Contains("42", response.JsonData); } [Fact] public void CreateSharedSchema_WithDuplicateName_ReturnsClearError() { _schemaRepo.GetByNameAsync("Addr", Arg.Any()) .Returns(new SharedSchema { Id = 1, Name = "Addr", SchemaJson = ValidSchema }); var actor = CreateActor(); var envelope = Envelope( new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("COMMAND_FAILED", response.ErrorCode); Assert.Contains("Addr", response.Error); Assert.Contains("already exists", response.Error); } [Fact] public void CreateSharedSchema_WithEmptyName_ReturnsClearError() { var actor = CreateActor(); var envelope = Envelope( new CreateSharedSchemaCommand(" ", null, ValidSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("COMMAND_FAILED", response.ErrorCode); Assert.Contains("name", response.Error, StringComparison.OrdinalIgnoreCase); } [Fact] public void CreateSharedSchema_WithInvalidJson_ReturnsClearError() { var actor = CreateActor(); var envelope = Envelope( new CreateSharedSchemaCommand("Addr", null, "{ not valid json"), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("COMMAND_FAILED", response.ErrorCode); Assert.Contains("schema", response.Error, StringComparison.OrdinalIgnoreCase); // The repo must never be hit when the schema fails validation. _schemaRepo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); } // ── Update round-trip + validation ────────────────────────────────────── [Fact] public void UpdateSharedSchema_WithDesignerRole_PersistsChanges() { var existing = new SharedSchema { Id = 5, Name = "Addr", Scope = null, SchemaJson = ValidSchema }; _schemaRepo.GetByIdAsync(5, Arg.Any()).Returns(existing); _schemaRepo.GetByNameAsync("Address", Arg.Any()) .Returns((SharedSchema?)null); const string updatedSchema = """{"type":"object","properties":{"city":{"type":"string"}}}"""; var actor = CreateActor(); var envelope = Envelope( new UpdateSharedSchemaCommand(5, "Address", "us", updatedSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(envelope.CorrelationId, response.CorrelationId); Assert.Equal("Address", existing.Name); Assert.Equal("us", existing.Scope); Assert.Equal(updatedSchema, existing.SchemaJson); } [Fact] public void UpdateSharedSchema_NotFound_ReturnsClearError() { _schemaRepo.GetByIdAsync(99, Arg.Any()) .Returns((SharedSchema?)null); var actor = CreateActor(); var envelope = Envelope( new UpdateSharedSchemaCommand(99, "Addr", null, ValidSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("COMMAND_FAILED", response.ErrorCode); Assert.Contains("99", response.Error); } [Fact] public void UpdateSharedSchema_RenameToExistingName_ReturnsClearError() { var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema }; _schemaRepo.GetByIdAsync(5, Arg.Any()).Returns(existing); // A DIFFERENT row already owns "Other". _schemaRepo.GetByNameAsync("Other", Arg.Any()) .Returns(new SharedSchema { Id = 6, Name = "Other", SchemaJson = ValidSchema }); var actor = CreateActor(); var envelope = Envelope( new UpdateSharedSchemaCommand(5, "Other", null, ValidSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("COMMAND_FAILED", response.ErrorCode); Assert.Contains("already exists", response.Error); } [Fact] public void UpdateSharedSchema_SameName_IsAllowed() { var existing = new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema }; _schemaRepo.GetByIdAsync(5, Arg.Any()).Returns(existing); // The unique-name check finds THIS row — must not be treated as a collision. _schemaRepo.GetByNameAsync("Addr", Arg.Any()).Returns(existing); const string updatedSchema = """{"type":"object","properties":{"zip":{"type":"string"}}}"""; var actor = CreateActor(); var envelope = Envelope( new UpdateSharedSchemaCommand(5, "Addr", null, updatedSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(updatedSchema, existing.SchemaJson); } // ── Delete round-trip ─────────────────────────────────────────────────── [Fact] public void DeleteSharedSchema_WithDesignerRole_Deletes() { var actor = CreateActor(); var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(envelope.CorrelationId, response.CorrelationId); _schemaRepo.Received(1).DeleteAsync(5, Arg.Any()); } [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()) .Returns(new SharedSchema { Id = 5, Name = "Addr", SchemaJson = ValidSchema }); var actor = CreateActor(); var envelope = Envelope(new DeleteSharedSchemaCommand(5), "Designer"); actor.Tell(envelope); ExpectMsg(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(), "Delete", "SharedSchema", "5", "Addr", Arg.Any(), Arg.Any()); } [Fact] public void ListSharedSchemas_WhenRepoThrows_ReturnsError() { _schemaRepo.ListAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException("db down secret detail")); var actor = CreateActor(); var envelope = Envelope(new ListSharedSchemasCommand()); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); 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()) .Returns((SharedSchema?)null); _schemaRepo.AddAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException(rawDbDetail)); var actor = CreateActor(); var envelope = Envelope( new CreateSharedSchemaCommand("Addr", null, ValidSchema), "Designer"); actor.Tell(envelope); var response = ExpectMsg(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); } }