using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// The ListAuditArchives / VerifyAuditArchive management commands /// (ScadaBridge#42): role gate, site-scope refusal, an unconfigured archive directory, bad /// names, and the results reaching the wire with enums as names. /// public class AuditArchiveCommandTests : TestKit { private const string Name = "auditlog-2025-10-20261109T120000123Z"; private readonly IAuditLogArchive _archive = Substitute.For(); /// Configures the archive substitute as a configured, enabled archive. public AuditArchiveCommandTests() { _archive.IsConfigured.Returns(true); _archive.IsEnabled.Returns(true); } private IActorRef CreateActor(bool registerArchive = true) { var services = new ServiceCollection().AddTestConfigurationTransaction(); if (registerArchive) { services.AddScoped(_ => _archive); } return Sys.ActorOf(Props.Create(() => new ManagementActor( services.BuildServiceProvider(), NullLogger.Instance))); } private static ManagementEnvelope Envelope(object command, string[] sites, params string[] roles) => new(new AuthenticatedUser("u", "U", roles, sites), command, Guid.NewGuid().ToString("N")); private static AuditArchiveManifest Manifest() => new( 1, Name, new DateTime(2025, 10, 1, 0, 0, 0, DateTimeKind.Utc), "2025-10", new DateTime(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc), AuditArchiveStatus.Purged, new DateTime(2026, 11, 9, 12, 0, 1, DateTimeKind.Utc), 10, 9, 1, null, null, Name + ".parquet", "ab", 1234, Name + ".tombstones.parquet", "cd", 0, [new AuditArchiveChainState("2025-10", 9, new string('0', 64), null, null, 0)]); /// A Viewer lists the archives; status renders as a name. [Fact] public void Viewer_ListsArchives() { _archive.ListAsync(Arg.Any()).Returns([Manifest()]); CreateActor().Tell(Envelope(new ListAuditArchivesCommand(), [], "Viewer")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); using var json = JsonDocument.Parse(response.JsonData); Assert.True(json.RootElement.GetProperty("enabled").GetBoolean()); var archive = json.RootElement.GetProperty("archives")[0]; Assert.Equal(Name, archive.GetProperty("name").GetString()); Assert.Equal("Purged", archive.GetProperty("status").GetString()); } /// Verification results reach the wire with the anchor and chain status as names. [Fact] public void Viewer_GetsVerificationResult() { var chain = new AuditChainVerificationResult("2025-10", AuditChainStatus.Intact, 9, 0, null, 9, 1, null); _archive.VerifyAsync(Name, Arg.Any()).Returns(new AuditArchiveVerificationResult( Name, "2025-10", AuditArchiveStatus.Purged, true, true, null, 10, [new AuditArchiveChainVerification(chain, AuditArchiveAnchorStatus.Matches, "ends at the anchor")])); CreateActor().Tell(Envelope(new VerifyAuditArchiveCommand(Name), [], "Viewer")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); using var json = JsonDocument.Parse(response.JsonData); Assert.True(json.RootElement.GetProperty("verified").GetBoolean()); var c = json.RootElement.GetProperty("chains")[0]; Assert.Equal("Matches", c.GetProperty("anchor").GetString()); Assert.Equal("Intact", c.GetProperty("chain").GetProperty("status").GetString()); } /// Designer/Deployer without an OperationalAudit role are refused both commands. [Fact] public void NonAuditRole_IsUnauthorized() { var actor = CreateActor(); actor.Tell(Envelope(new ListAuditArchivesCommand(), [], "Designer", "Deployer")); ExpectMsg(TimeSpan.FromSeconds(5)); actor.Tell(Envelope(new VerifyAuditArchiveCommand(Name), [], "Designer", "Deployer")); ExpectMsg(TimeSpan.FromSeconds(5)); _archive.DidNotReceiveWithAnyArgs().ListAsync(default); _archive.DidNotReceiveWithAnyArgs().VerifyAsync(default!, default); } /// A site-scoped Viewer is refused: an archive holds every site's rows. [Fact] public void SiteScopedViewer_IsRefused() { CreateActor().Tell(Envelope(new VerifyAuditArchiveCommand(Name), ["3"], "Viewer")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("every site", response.Message); _archive.DidNotReceiveWithAnyArgs().VerifyAsync(default!, default); } /// No archive directory configured is an explicit error code, not an empty list. [Fact] public void UnconfiguredArchive_IsArchiveNotConfigured() { _archive.IsConfigured.Returns(false); CreateActor().Tell(Envelope(new ListAuditArchivesCommand(), [], "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("ARCHIVE_NOT_CONFIGURED", response.ErrorCode); } /// A malformed name is INVALID_ARGUMENT; an unknown one is NOT_FOUND. [Fact] public void BadNames_MapToCallerErrors() { _archive.VerifyAsync("../etc", Arg.Any()) .Returns>(_ => throw new ArgumentException("not an archive name")); _archive.VerifyAsync(Name, Arg.Any()) .Returns>(_ => throw new FileNotFoundException("no archive")); var actor = CreateActor(); actor.Tell(Envelope(new VerifyAuditArchiveCommand("../etc"), [], "Administrator")); Assert.Equal("INVALID_ARGUMENT", ExpectMsg(TimeSpan.FromSeconds(5)).ErrorCode); actor.Tell(Envelope(new VerifyAuditArchiveCommand(Name), [], "Administrator")); Assert.Equal("NOT_FOUND", ExpectMsg(TimeSpan.FromSeconds(5)).ErrorCode); } /// Archive verification runs under the long-running Ask timeout. [Fact] public void VerifyAuditArchive_UsesLongRunningAskTimeout() { Assert.Equal(TimeSpan.FromMinutes(5), ManagementEndpoints.ResolveAskTimeout(new ManagementServiceOptions(), typeof(VerifyAuditArchiveCommand))); } }