using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Management; using ScadaLink.Commons.Types.InboundApi; using ScadaLink.ManagementService; namespace ScadaLink.ManagementService.Tests; /// /// ConfigurationDatabase-012: creating an API key must generate a random key, /// persist only its peppered hash, and return the plaintext to the caller exactly /// once. The plaintext must never reach the stored entity. /// public class ApiKeyCreationTests : TestKit, IDisposable { private const string Pepper = "a-sufficiently-long-server-side-pepper-value"; private readonly ServiceCollection _services = new(); private readonly IInboundApiRepository _apiRepo = Substitute.For(); private readonly IAuditService _auditService = Substitute.For(); public ApiKeyCreationTests() { _services.AddScoped(_ => _apiRepo); _services.AddScoped(_ => _auditService); _services.AddSingleton(new ApiKeyHasher(Pepper)); } 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_PersistsOnlyHash_NeverPlaintext() { ApiKey? persisted = null; _apiRepo.AddApiKeyAsync(Arg.Do(k => persisted = k)) .Returns(Task.CompletedTask); var actor = CreateActor(); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); // The response must carry the one-time plaintext key shown to the operator. var plaintext = ExtractPlaintextKey(response.JsonData); Assert.False(string.IsNullOrWhiteSpace(plaintext)); // The stored entity must carry a hash, never the plaintext. Assert.NotNull(persisted); Assert.NotEqual(plaintext, persisted!.KeyHash); // The persisted hash must equal the peppered hash of the returned plaintext. var hasher = new ApiKeyHasher(Pepper); Assert.Equal(hasher.Hash(plaintext!), persisted.KeyHash); } [Fact] public void CreateApiKey_ResponseDoesNotEchoTheHash() { ApiKey? persisted = null; _apiRepo.AddApiKeyAsync(Arg.Do(k => persisted = k)) .Returns(Task.CompletedTask); var actor = CreateActor(); actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.NotNull(persisted); // The serialized response must not leak the stored hash as a usable artifact. Assert.DoesNotContain(persisted!.KeyHash, response.JsonData); } [Fact] public void CreateApiKey_TwoKeys_GenerateDistinctRandomValues() { var hashes = new List(); _apiRepo.AddApiKeyAsync(Arg.Do(k => hashes.Add(k.KeyHash))) .Returns(Task.CompletedTask); var actor = CreateActor(); actor.Tell(Envelope(new CreateApiKeyCommand("KeyA"), "Admin")); ExpectMsg(TimeSpan.FromSeconds(5)); actor.Tell(Envelope(new CreateApiKeyCommand("KeyB"), "Admin")); ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(2, hashes.Count); Assert.NotEqual(hashes[0], hashes[1]); } /// /// The create response is JSON carrying the one-time plaintext key under a /// PlaintextKey (or Key) property. /// private static string? ExtractPlaintextKey(string json) { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (root.TryGetProperty("plaintextKey", out var p) || root.TryGetProperty("PlaintextKey", out p)) return p.GetString(); if (root.TryGetProperty("key", out var k) || root.TryGetProperty("Key", out k)) return k.GetString(); return null; } }