The integrity check mapped four outcomes onto a single null: no response, a 404, a rejected key, and a genuine answer. Only "rejected key" is recoverable, and it was being reported as "unreachable" and never repaired — so an instance registered against the wrong URL stayed broken until someone edited the database by hand. That is exactly what happened locally. GetRegisteredMasterUrlAsync now returns an outcome alongside the URL. Unauthorized triggers registration; 404 is reported as "this host does not serve the master/slave protocol", which names the actual mistake instead of hiding it behind a generic contact failure; unreachable and server errors behave as before. Registering on a rejected key cannot hijack a slave that belongs to another master: the slave accepts a registration only when it has none, and refuses any key that does not match an existing one. So it succeeds exactly in the case worth recovering and fails harmlessly otherwise. That guarantee lives on the slave, so the test asserting the refusal now says out loud that the master depends on it. Found while diagnosing a status push that failed against a frontend URL. Small and contained, so fixed here rather than filed as tech debt. 372 tests pass, up from 366. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
362 lines
12 KiB
C#
362 lines
12 KiB
C#
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<IMasterRegistrationRepository>();
|
|
_protector = Substitute.For<IMasterApiKeyProtector>();
|
|
|
|
var deps = new MasterAvailabilityServiceDependencies(
|
|
_repo,
|
|
_protector,
|
|
NullLogger<MasterAvailabilityService>.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<MasterRegistration>(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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A slave that already belongs to a master refuses any other key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Do not relax this.</b> The master's integrity check registers automatically when a slave
|
|
/// rejects its key (<c>CmsInstanceService.VerifyIntegrityAsync</c>), 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.
|
|
/// </remarks>
|
|
[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();
|
|
}
|
|
}
|