using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; namespace ScadaLink.ConfigurationDatabase.Tests; public class InboundApiRepositoryTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly CapturingLogger _logger = new(); private readonly InboundApiRepository _repository; public InboundApiRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new InboundApiRepository(_context, hasherAccessor: null, logger: _logger); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task AddApiKey_AndGetById_RoundTrips() { var key = new ApiKey("Key1", "secret-value-1") { IsEnabled = true }; await _repository.AddApiKeyAsync(key); await _repository.SaveChangesAsync(); var loaded = await _repository.GetApiKeyByIdAsync(key.Id); Assert.NotNull(loaded); Assert.Equal("Key1", loaded!.Name); var byValue = await _repository.GetApiKeyByValueAsync("secret-value-1"); Assert.NotNull(byValue); Assert.Equal(key.Id, byValue!.Id); } [Fact] public async Task CD016_GetApiKeyByValue_UsesInjectedPepperedHasher_NotDefault() { // CD-016 regression: stored KeyHash is produced by a peppered hasher. // A repository whose lookup uses ApiKeyHasher.Default (the pre-fix // behaviour) would compute a different digest and return null. With the // pepper-aware hasherAccessor wired in, the lookup must round-trip. var peppered = new Commons.Types.InboundApi.ApiKeyHasher("a-strong-test-pepper-of-sufficient-length"); var pepperedHash = peppered.Hash("secret-with-pepper"); var key = ApiKey.FromHash("Peppered", pepperedHash); key.IsEnabled = true; using var ctx = SqliteTestHelper.CreateInMemoryContext(); var repo = new InboundApiRepository(ctx, hasherAccessor: () => peppered, logger: _logger); await repo.AddApiKeyAsync(key); await repo.SaveChangesAsync(); var byValue = await repo.GetApiKeyByValueAsync("secret-with-pepper"); Assert.NotNull(byValue); Assert.Equal(key.Id, byValue!.Id); // And: a repository wired with the Default (unpeppered) hasher MUST // NOT find the same key — proving the lookup actually uses the // injected hasher and the original bug shape. var defaultRepo = new InboundApiRepository(ctx, hasherAccessor: () => Commons.Types.InboundApi.ApiKeyHasher.Default, logger: _logger); var missByDefault = await defaultRepo.GetApiKeyByValueAsync("secret-with-pepper"); Assert.Null(missByDefault); } [Fact] public async Task AddApiMethod_AndGetByName_RoundTrips() { var method = new ApiMethod("DoThing", "return 1;"); await _repository.AddApiMethodAsync(method); await _repository.SaveChangesAsync(); var loaded = await _repository.GetMethodByNameAsync("DoThing"); Assert.NotNull(loaded); Assert.Equal(method.Id, loaded!.Id); } [Fact] public async Task GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys() { var k1 = new ApiKey("K1", "v1"); var k2 = new ApiKey("K2", "v2"); await _repository.AddApiKeyAsync(k1); await _repository.AddApiKeyAsync(k2); await _repository.SaveChangesAsync(); var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id}, {k2.Id}" }; await _repository.AddApiMethodAsync(method); await _repository.SaveChangesAsync(); var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id); Assert.Equal(2, keys.Count); Assert.Empty(_logger.Warnings); } [Fact] public async Task GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken() { // Regression guard for ConfigurationDatabase-008: a corrupt token (a name where an // integer id is expected) must not be dropped silently — the corruption must be // observable via a logged warning, while the valid ids still resolve. var k1 = new ApiKey("K1", "v1"); await _repository.AddApiKeyAsync(k1); await _repository.SaveChangesAsync(); var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id},not-an-id" }; await _repository.AddApiMethodAsync(method); await _repository.SaveChangesAsync(); var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id); Assert.Single(keys); Assert.Equal(k1.Id, keys[0].Id); Assert.Single(_logger.Warnings); Assert.Contains("not-an-id", _logger.Warnings[0]); } [Fact] public async Task GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning() { var method = new ApiMethod("M", "return 1;"); await _repository.AddApiMethodAsync(method); await _repository.SaveChangesAsync(); var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id); Assert.Empty(keys); Assert.Empty(_logger.Warnings); } [Fact] public async Task DeleteApiMethod_RemovesEntity() { var method = new ApiMethod("ToDelete", "return 1;"); await _repository.AddApiMethodAsync(method); await _repository.SaveChangesAsync(); await _repository.DeleteApiMethodAsync(method.Id); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetApiMethodByIdAsync(method.Id)); } [Fact] public void Constructor_NullContext_Throws() { Assert.Throws(() => new InboundApiRepository(null!)); } } /// Minimal ILogger that captures warning-level messages for assertions. internal sealed class CapturingLogger : ILogger { public List Warnings { get; } = new(); public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (logLevel == LogLevel.Warning) { Warnings.Add(formatter(state, exception)); } } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } }