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 VerifyAuditChainHeads management command (ScadaBridge#107 item 1): role gate,
/// site-scope refusal, input bounds, and the verdicts reaching the wire with enums as names.
///
public class VerifyAuditChainHeadsCommandTests : TestKit
{
private readonly IAuditChainHeadStore _store = Substitute.For();
private static readonly AuditChainHeadRecord Record = new(
1, "2026-05", 12, new string('a', 64), null, null, 12, 0,
new DateTime(2026, 5, 3, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 5, 3, 1, 0, 0, DateTimeKind.Utc), "central-a");
private IActorRef CreateActor()
{
var services = new ServiceCollection().AddTestConfigurationTransaction();
services.AddScoped(_ => _store);
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"));
/// A Viewer gets the store's verdicts; the records reach the store unchanged.
[Fact]
public void Viewer_GetsTheVerdicts_ForTheSuppliedRecords()
{
var check = new AuditChainHeadCheck("2026-05", 12, Record.ExportedAtUtc, "central-a",
AuditChainHeadStatus.Mismatch, 12, "rewritten");
_store.VerifyExportedHeadsAsync(Arg.Any>(), Arg.Any())
.Returns(new AuditChainHeadVerificationResult(false, 1, [check]));
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Viewer"));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
using var json = JsonDocument.Parse(response.JsonData);
Assert.False(json.RootElement.GetProperty("verified").GetBoolean());
Assert.Equal("Mismatch", json.RootElement.GetProperty("checks")[0].GetProperty("status").GetString());
_store.Received(1).VerifyExportedHeadsAsync(
Arg.Is>(r => r.Count == 1 && r[0] == Record), Arg.Any());
}
/// A Designer/Deployer without an OperationalAudit role is refused.
[Fact]
public void NonAuditRole_IsUnauthorized()
{
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Designer", "Deployer"));
ExpectMsg(TimeSpan.FromSeconds(5));
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
}
/// A site-scoped Viewer is refused: the chain spans every site.
[Fact]
public void SiteScopedViewer_IsRefused()
{
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), ["3"], "Viewer"));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Contains("spans every site", response.Message);
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
}
/// No records, or more than the cap, is a caller error.
/// Records supplied.
[Theory]
[InlineData(0)]
[InlineData(ManagementActor.MaxChainHeadRecords + 1)]
public void EmptyOrOversizedInput_IsInvalidArgument(int count)
{
CreateActor().Tell(Envelope(
new VerifyAuditChainHeadsCommand(Enumerable.Repeat(Record, count).ToList()), [], "Administrator"));
var response = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("INVALID_ARGUMENT", response.ErrorCode);
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
}
/// A chain lock that cannot be had surfaces as CHAIN_BUSY.
[Fact]
public void BusyChain_IsChainBusy()
{
_store.VerifyExportedHeadsAsync(Arg.Any>(), Arg.Any())
.Returns>(_ => throw new TimeoutException("busy"));
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Administrator"));
Assert.Equal("CHAIN_BUSY", ExpectMsg(TimeSpan.FromSeconds(5)).ErrorCode);
}
/// Runs under the long-running Ask timeout.
[Fact]
public void VerifyAuditChainHeads_UsesLongRunningAskTimeout()
{
Assert.Equal(TimeSpan.FromMinutes(5),
ManagementEndpoints.ResolveAskTimeout(new ManagementServiceOptions(), typeof(VerifyAuditChainHeadsCommand)));
}
}