using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using SlpModularCms.Modules.Availability.Data.Entities; using SlpModularCms.Modules.Availability.Repositories; using SlpModularCms.Modules.Availability.Services; namespace SlpModularCms.Modules.Availability.Tests.Services; public class MasterAvailabilityServiceTests : IDisposable { private readonly IMasterRegistrationRepository _repo; private readonly IMasterApiKeyProtector _protector; private readonly MasterAvailabilityService _svc; public MasterAvailabilityServiceTests() { _repo = Substitute.For(); _protector = Substitute.For(); var deps = new MasterAvailabilityServiceDependencies( _repo, _protector, NullLogger.Instance); _svc = new MasterAvailabilityService(deps); MasterAvailabilityService.ResetStaticCacheForTest(); } public void Dispose() => MasterAvailabilityService.ResetStaticCacheForTest(); // --- RegisterAsync --- [Fact] public async Task RegisterAsync_CreatesNewRegistration_WhenNoneExists() { _repo.GetAsync().Returns((MasterRegistration?)null); _protector.Protect("key123").Returns("enc:key123"); var result = await _svc.RegisterAsync("https://master.example.com", "key123"); result.Should().BeTrue(); await _repo.Received(1).AddAsync(Arg.Is(r => r.MasterUrl == "https://master.example.com" && r.ApiKey == "enc:key123" && r.Id == MasterRegistration.SingletonId)); await _repo.Received(1).SaveChangesAsync(); } [Fact] public async Task RegisterAsync_ReturnsTrue_OnReregistrationWithMatchingKey() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://old.example.com", ApiKey = "enc:key123", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key123").Returns("key123"); var result = await _svc.RegisterAsync("https://new.example.com", "key123"); result.Should().BeTrue(); existing.MasterUrl.Should().Be("https://new.example.com"); _repo.Received(1).Update(existing); await _repo.Received(1).SaveChangesAsync(); } /// /// A slave that already belongs to a master refuses any other key. /// /// /// Do not relax this. The master's integrity check registers automatically when a slave /// rejects its key (CmsInstanceService.VerifyIntegrityAsync), which is safe only because /// this refusal holds: registration then succeeds exactly for a slave that has no registration /// yet, and fails for one that belongs to someone else. Weaken it and that automatic /// registration becomes a way for one master to take over another master's slave. /// [Fact] public async Task RegisterAsync_ReturnsFalse_WhenKeyMismatch() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key123", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key123").Returns("key123"); var result = await _svc.RegisterAsync("https://master.example.com", "wrong-key"); result.Should().BeFalse(); await _repo.DidNotReceive().SaveChangesAsync(); } [Fact] public async Task RegisterAsync_ReturnsFalse_WhenDecryptionFails() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "corrupt", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("corrupt").Returns((string?)null); var result = await _svc.RegisterAsync("https://master.example.com", "key123"); result.Should().BeFalse(); } // --- PushStatusAsync --- [Fact] public async Task PushStatusAsync_UpdatesStaticCache_WhenKeyValid() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("key"); var result = await _svc.PushStatusAsync("key", false, "Under maintenance"); result.Should().BeTrue(); var status = _svc.GetMasterStatus(); status.IsAvailable.Should().BeFalse(); status.DisableMessage.Should().Be("Under maintenance"); } [Fact] public async Task PushStatusAsync_ReturnsFalse_WhenNoRegistration() { _repo.GetAsync().Returns((MasterRegistration?)null); var result = await _svc.PushStatusAsync("key", false, null); result.Should().BeFalse(); await _repo.DidNotReceive().SaveChangesAsync(); } [Fact] public async Task PushStatusAsync_ReturnsFalse_WhenKeyMismatch() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("correct-key"); var result = await _svc.PushStatusAsync("wrong-key", false, null); result.Should().BeFalse(); } // --- GetRegisteredUrlAsync --- [Fact] public async Task GetRegisteredUrlAsync_ReturnsMasterUrl_WhenKeyValid() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("key"); var result = await _svc.GetRegisteredUrlAsync("key"); result.Should().Be("https://master.example.com"); _repo.Received(1).Update(existing); await _repo.Received(1).SaveChangesAsync(); } [Fact] public async Task GetRegisteredUrlAsync_ReturnsNull_WhenNoRegistration() { _repo.GetAsync().Returns((MasterRegistration?)null); var result = await _svc.GetRegisteredUrlAsync("key"); result.Should().BeNull(); } [Fact] public async Task GetRegisteredUrlAsync_ReturnsNull_WhenKeyMismatch() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("correct-key"); var result = await _svc.GetRegisteredUrlAsync("wrong-key"); result.Should().BeNull(); } // --- GetMasterStatus --- [Fact] public async Task GetMasterStatus_ReflectsLastPushedStatus() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("key"); await _svc.PushStatusAsync("key", false, "Down for maintenance"); var status = _svc.GetMasterStatus(); status.IsAvailable.Should().BeFalse(); status.DisableMessage.Should().Be("Down for maintenance"); } [Fact] public void GetMasterStatus_ReturnsAvailableByDefault() { var status = _svc.GetMasterStatus(); status.IsAvailable.Should().BeTrue(); status.DisableMessage.Should().BeNull(); } // --- GetPollTargetAsync --- [Fact] public async Task GetPollTargetAsync_ReturnsMasterUrlAndPlainKey_WhenRegistered() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("key"); var target = await _svc.GetPollTargetAsync(); target.Should().NotBeNull(); target!.MasterUrl.Should().Be("https://master.example.com"); target.PlainApiKey.Should().Be("key"); } [Fact] public async Task GetPollTargetAsync_ReturnsNull_WhenNoRegistration() { _repo.GetAsync().Returns((MasterRegistration?)null); var target = await _svc.GetPollTargetAsync(); target.Should().BeNull(); } // --- ApplyPolledStatusAsync --- [Fact] public async Task ApplyPolledStatusAsync_UpdatesGateAndPersistsPollTimestamp() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); await _svc.ApplyPolledStatusAsync(false, "Onderhoud"); var status = _svc.GetMasterStatus(); status.IsAvailable.Should().BeFalse(); status.DisableMessage.Should().Be("Onderhoud"); existing.LastPolledAt.Should().NotBeNull(); _repo.Received(1).Update(existing); await _repo.Received(1).SaveChangesAsync(); } // --- RecordPollFailureAsync --- [Fact] public async Task RecordPollFailureAsync_DoesNotFailOpen_WhenWithinGracePeriod() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow, LastPolledAt = DateTimeOffset.UtcNow }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("key"); await _svc.PushStatusAsync("key", false, "Down"); await _svc.RecordPollFailureAsync(TimeSpan.FromMinutes(5)); var status = _svc.GetMasterStatus(); status.IsAvailable.Should().BeFalse(); } [Fact] public async Task RecordPollFailureAsync_FailsOpen_WhenMasterUnreachableTooLong() { var existing = new MasterRegistration { Id = MasterRegistration.SingletonId, MasterUrl = "https://master.example.com", ApiKey = "enc:key", RegisteredAt = DateTimeOffset.UtcNow.AddMinutes(-30), LastPolledAt = DateTimeOffset.UtcNow.AddMinutes(-10) }; _repo.GetAsync().Returns(existing); _protector.Unprotect("enc:key").Returns("key"); await _svc.PushStatusAsync("key", false, "Down"); await _svc.RecordPollFailureAsync(TimeSpan.FromMinutes(5)); var status = _svc.GetMasterStatus(); status.IsAvailable.Should().BeTrue(); status.DisableMessage.Should().BeNull(); } [Fact] public async Task RecordPollFailureAsync_DoesNothing_WhenNoRegistration() { _repo.GetAsync().Returns((MasterRegistration?)null); var act = async () => await _svc.RecordPollFailureAsync(TimeSpan.FromMinutes(5)); await act.Should().NotThrowAsync(); } }