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.Security; 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; /// /// Inbound-API key re-arch (C2): the ManagementActor API-key management path now drives the /// Commons seam (added in C1) instead of the SQL Server /// IInboundApiRepository. These tests exercise the actor against an in-memory fake of /// the seam (the real LibraryInboundApiKeyAdmin + SQLite mapping is covered end-to-end /// by the Security project's LibraryInboundApiKeyAdminTests). They verify the actor's /// dispatch, response shapes (string keyId, one-time token, methods), the preserved ScadaBridge /// management-audit calls, and that the "Administrator" role gate still applies to all five commands. /// public class ApiKeyCreationTests : TestKit, IDisposable { private readonly ServiceCollection _services = new(); private readonly FakeInboundApiKeyAdmin _admin = new(); private readonly IAuditService _auditService = Substitute.For(); public ApiKeyCreationTests() { _services.AddScoped(_ => _admin); _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("admin", "Admin User", roles, Array.Empty()), command, Guid.NewGuid().ToString("N")); void IDisposable.Dispose() => Shutdown(); [Fact] public void CreateApiKey_ReturnsKeyIdAndOneTimeToken() { var actor = CreateActor(); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA", "MethodB" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); using var doc = JsonDocument.Parse(response.JsonData); var root = doc.RootElement; var keyId = root.GetProperty("keyId").GetString(); var token = root.GetProperty("token").GetString(); var name = root.GetProperty("name").GetString(); Assert.False(string.IsNullOrWhiteSpace(keyId)); Assert.False(string.IsNullOrWhiteSpace(token)); Assert.StartsWith($"sbk_{keyId}_", token); Assert.Equal("MES-Production", name); // The seam was driven with the supplied name + methods. var created = Assert.Single(_admin.Keys.Values); Assert.Equal("MES-Production", created.Name); Assert.Equal(new[] { "MethodA", "MethodB" }, created.Methods); } [Fact] public void CreateApiKey_AuditsTheCreate() { var actor = CreateActor(); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Administrator")); ExpectMsg(TimeSpan.FromSeconds(5)); _auditService.Received(1).LogAsync( "admin", "Create", "ApiKey", Arg.Any(), "MES-Production", Arg.Any()); } [Fact] public void CreateApiKey_ResponseDoesNotEchoAHash() { var actor = CreateActor(); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); // The hash-free seam never surfaces a stored hash; the only secret in the body // is the one-time token (sbk__). Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase); } [Fact] public void ListApiKeys_ReturnsKeysWithMethods() { _admin.Seed("key-1", "Service A", enabled: true, "M1", "M2"); _admin.Seed("key-2", "Service B", enabled: false, "M3"); var actor = CreateActor(); actor.Tell(Envelope(new ListApiKeysCommand(), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); using var doc = JsonDocument.Parse(response.JsonData); var items = doc.RootElement.EnumerateArray().ToList(); Assert.Equal(2, items.Count); var first = items.Single(i => i.GetProperty("keyId").GetString() == "key-1"); Assert.Equal("Service A", first.GetProperty("name").GetString()); Assert.True(first.GetProperty("enabled").GetBoolean()); Assert.Equal(new[] { "M1", "M2" }, first.GetProperty("methods").EnumerateArray().Select(m => m.GetString()).ToArray()); // The list path must not surface a hash / token credential artifact. Assert.DoesNotContain("token", response.JsonData, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase); } [Fact] public void UpdateApiKey_TogglesEnabled_AndAudits() { _admin.Seed("key-1", "Service A", enabled: true, "M1"); var actor = CreateActor(); actor.Tell(Envelope(new UpdateApiKeyCommand("key-1", false), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.False(_admin.Keys["key-1"].Enabled); using var doc = JsonDocument.Parse(response.JsonData); Assert.Equal("key-1", doc.RootElement.GetProperty("keyId").GetString()); Assert.False(doc.RootElement.GetProperty("isEnabled").GetBoolean()); _auditService.Received(1).LogAsync( "admin", "Update", "ApiKey", "key-1", Arg.Any(), Arg.Any()); } [Fact] public void DeleteApiKey_RemovesKey_AndAudits() { _admin.Seed("key-1", "Service A", enabled: true, "M1"); var actor = CreateActor(); actor.Tell(Envelope(new DeleteApiKeyCommand("key-1"), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.DoesNotContain("key-1", _admin.Keys.Keys); Assert.Equal("true", response.JsonData); _auditService.Received(1).LogAsync( "admin", "Delete", "ApiKey", "key-1", "key-1", null); } [Fact] public void SetApiKeyMethods_ReplacesScopes_AndAudits() { _admin.Seed("key-1", "Service A", enabled: true, "Old1", "Old2"); var actor = CreateActor(); actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", new[] { "New1", "New2", "New3" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(new[] { "New1", "New2", "New3" }, _admin.Keys["key-1"].Methods); // Fix 3 (review): response is now the richer { KeyId, Methods } shape. using var doc = JsonDocument.Parse(response.JsonData); Assert.Equal("key-1", doc.RootElement.GetProperty("keyId").GetString()); var methods = doc.RootElement.GetProperty("methods").EnumerateArray() .Select(m => m.GetString()).ToArray(); Assert.Equal(new[] { "New1", "New2", "New3" }, methods); _auditService.Received(1).LogAsync( "admin", "Update", "ApiKey", "key-1", Arg.Any(), Arg.Any()); } // ========================================================================= // Fix 1 (review): not-found on mutating ops → ManagementError, no audit // ========================================================================= [Fact] public void UpdateApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { // No keys seeded — "key-unknown" does not exist. var actor = CreateActor(); actor.Tell(Envelope(new UpdateApiKeyCommand("key-unknown", false), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("key-unknown", response.Error); // Seam returned false → audit must not have fired. _auditService.DidNotReceive().LogAsync( Arg.Any(), "Update", "ApiKey", Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { var actor = CreateActor(); actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-unknown", new[] { "M1" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("key-unknown", response.Error); _auditService.DidNotReceive().LogAsync( Arg.Any(), "Update", "ApiKey", Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { var actor = CreateActor(); actor.Tell(Envelope(new DeleteApiKeyCommand("key-unknown"), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("key-unknown", response.Error); _auditService.DidNotReceive().LogAsync( Arg.Any(), "Delete", "ApiKey", Arg.Any(), Arg.Any(), Arg.Any()); } // ========================================================================= // Fix 2 (review): empty methods set is rejected before seam + audit // ========================================================================= [Fact] public void CreateApiKey_EmptyMethods_ReturnsManagementError() { var actor = CreateActor(); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", Array.Empty()), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("method", response.Error, StringComparison.OrdinalIgnoreCase); // No key should have been created. Assert.Empty(_admin.Keys); _auditService.DidNotReceive().LogAsync( Arg.Any(), "Create", "ApiKey", Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public void SetApiKeyMethods_EmptyMethods_ReturnsManagementError() { _admin.Seed("key-1", "Service A", enabled: true, "M1"); var actor = CreateActor(); actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", Array.Empty()), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("method", response.Error, StringComparison.OrdinalIgnoreCase); // Existing scope set must be unchanged. Assert.Equal(new[] { "M1" }, _admin.Keys["key-1"].Methods); _auditService.DidNotReceive().LogAsync( Arg.Any(), "Update", "ApiKey", Arg.Any(), Arg.Any(), Arg.Any()); } [Theory] [MemberData(nameof(AllApiKeyCommands))] public void EveryApiKeyCommand_RequiresAdminRole(object command) { var actor = CreateActor(); // A Designer-role caller (not Administrator) must be rejected for every API-key command. actor.Tell(Envelope(command, "Designer")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Contains("Administrator", response.Message); } public static IEnumerable AllApiKeyCommands() => new[] { new object[] { new ListApiKeysCommand() }, new object[] { new CreateApiKeyCommand("X", new[] { "M1" }) }, new object[] { new DeleteApiKeyCommand("key-1") }, new object[] { new UpdateApiKeyCommand("key-1", true) }, new object[] { new SetApiKeyMethodsCommand("key-1", new[] { "M1" }) }, }; /// /// Minimal in-memory fake of the C1 management seam. Models only what the /// ManagementActor handlers exercise: create (assigns a keyId + assembles a /// sbk_<keyId>_<secret> token), list, toggle-enabled, replace-methods, /// and delete. Mirrors the real seam's bool/return semantics (false when a key is absent). /// private sealed class FakeInboundApiKeyAdmin : IInboundApiKeyAdmin { public sealed record Entry(string KeyId, string Name, bool Enabled, IReadOnlyList Methods); public Dictionary Keys { get; } = new(StringComparer.Ordinal); public void Seed(string keyId, string name, bool enabled, params string[] methods) => Keys[keyId] = new Entry(keyId, name, enabled, methods.ToList()); public Task CreateAsync( string name, IReadOnlyCollection methods, CancellationToken ct = default) { var keyId = Guid.NewGuid().ToString("N"); Keys[keyId] = new Entry(keyId, name, Enabled: true, methods.ToList()); return Task.FromResult(new InboundApiKeyCreated(keyId, $"sbk_{keyId}_secret")); } public Task> ListAsync(CancellationToken ct = default) { IReadOnlyList result = Keys.Values .Select(e => new InboundApiKeyInfo( e.KeyId, e.Name, e.Enabled, e.Methods, DateTimeOffset.UnixEpoch, LastUsedUtc: null)) .ToList(); return Task.FromResult(result); } public Task SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default) { if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false); Keys[keyId] = e with { Enabled = enabled }; return Task.FromResult(true); } public Task SetMethodsAsync(string keyId, IReadOnlyCollection methods, CancellationToken ct = default) { if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false); Keys[keyId] = e with { Methods = methods.ToList() }; return Task.FromResult(true); } public Task DeleteAsync(string keyId, CancellationToken ct = default) => Task.FromResult(Keys.Remove(keyId)); public Task> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default) { IReadOnlyList methods = Keys.TryGetValue(keyId, out var e) ? e.Methods : Array.Empty(); return Task.FromResult(methods); } public Task> GetKeysForMethodAsync(string methodName, CancellationToken ct = default) { IReadOnlyList keys = Keys.Values .Where(e => e.Methods.Contains(methodName, StringComparer.Ordinal)) .Select(e => e.KeyId) .ToList(); return Task.FromResult(keys); } } }