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;
}
}