Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -21,6 +21,7 @@
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
<ProjectReference Include="..\SlpModularCms.Modules.Identity\SlpModularCms.Modules.Identity.csproj" />
<ProjectReference Include="..\SlpModularCms.Modules.Master\SlpModularCms.Modules.Master.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,145 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Tests;
public class AvailabilityMiddlewareMasterGateTests
{
private readonly IAvailabilityService _localSvc;
private readonly IMasterAvailabilityService _masterSvc;
private readonly AvailabilityMiddleware _middleware;
private readonly RequestDelegate _next;
public AvailabilityMiddlewareMasterGateTests()
{
_localSvc = Substitute.For<IAvailabilityService>();
_masterSvc = Substitute.For<IMasterAvailabilityService>();
_next = Substitute.For<RequestDelegate>();
_middleware = new AvailabilityMiddleware(_next, NullLogger<AvailabilityMiddleware>.Instance);
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.Available);
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(true, null));
}
[Fact]
public async Task InvokeAsync_Passes_WhenMasterAndLocalBothAvailable()
{
var context = new DefaultHttpContext();
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_Returns503_WhenMasterGateBlocks()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, "Down for maintenance"));
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task InvokeAsync_DoesNotCheckLocalGate_WhenMasterGateBlocks()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _localSvc.DidNotReceive().IsAvailableAsync();
}
[Fact]
public async Task InvokeAsync_Returns503_WhenLocalGateBlocksAndMasterPasses()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
[Fact]
public async Task InvokeAsync_BypassesMasterAndLocalGate_ForMasterEndpoints()
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/master/register";
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.Received(1).Invoke(context);
_masterSvc.DidNotReceive().GetMasterStatus();
}
[Fact]
public async Task InvokeAsync_BypassesBothGates_ForMasterStatusEndpoint()
{
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/master/status";
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task InvokeAsync_BypassesBothGates_WhenAdminJwtPresent()
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.Received(1).Invoke(context);
_masterSvc.DidNotReceive().GetMasterStatus();
}
[Fact]
public async Task InvokeAsync_DoesNotBypass_WhenUserRoleJwtAndMasterBlocks()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
}
private static string CreateJwtWithRole(string role)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: [new Claim(ClaimTypes.Role, role)],
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -8,6 +8,7 @@ using Microsoft.IdentityModel.Tokens;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Services;
using Xunit;
namespace SlpModularCms.Modules.Availability.Tests;
@@ -15,14 +16,19 @@ namespace SlpModularCms.Modules.Availability.Tests;
public class AvailabilityMiddlewareTests
{
private readonly IAvailabilityService _service;
private readonly IMasterAvailabilityService _masterService;
private readonly AvailabilityMiddleware _middleware;
private readonly RequestDelegate _next;
public AvailabilityMiddlewareTests()
{
_service = Substitute.For<IAvailabilityService>();
_masterService = Substitute.For<IMasterAvailabilityService>();
_next = Substitute.For<RequestDelegate>();
_middleware = new AvailabilityMiddleware(_next, NullLogger<AvailabilityMiddleware>.Instance);
// Master gate passes by default in these local gate tests
_masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null));
}
[Fact]
@@ -31,7 +37,7 @@ public class AvailabilityMiddlewareTests
var context = new DefaultHttpContext();
_service.IsAvailableAsync().Returns(AvailabilityStatus.Available);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
@@ -43,7 +49,7 @@ public class AvailabilityMiddlewareTests
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
@@ -56,7 +62,7 @@ public class AvailabilityMiddlewareTests
context.Request.Path = "/api/v1/Availability/status";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
@@ -68,7 +74,7 @@ public class AvailabilityMiddlewareTests
context.Request.Path = "/api/v1/Auth/login";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
@@ -80,7 +86,7 @@ public class AvailabilityMiddlewareTests
context.Request.Path = "/api/v1/Setup/status";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
@@ -92,7 +98,7 @@ public class AvailabilityMiddlewareTests
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
@@ -105,7 +111,7 @@ public class AvailabilityMiddlewareTests
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
@@ -117,7 +123,7 @@ public class AvailabilityMiddlewareTests
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Administrator")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.Received(1).Invoke(context);
}
@@ -130,7 +136,7 @@ public class AvailabilityMiddlewareTests
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
@@ -143,7 +149,7 @@ public class AvailabilityMiddlewareTests
context.Response.Body = new MemoryStream();
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
}
@@ -156,7 +162,7 @@ public class AvailabilityMiddlewareTests
context.Request.Headers.Authorization = "Bearer not-a-valid-jwt";
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
await _middleware.InvokeAsync(context, _service);
await _middleware.InvokeAsync(context, _service, _masterService);
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
}
@@ -2,8 +2,11 @@ using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Data;
using SlpModularCms.Modules.Availability.Controllers;
using SlpModularCms.Modules.Availability.Services;
@@ -66,4 +69,29 @@ public class AvailabilityControllerTests
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task UpdateStatus_ReturnsOk_WhenServiceIsPersistentAvailabilityService()
{
var dbOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
using var context = new ApplicationDbContext(dbOptions);
var availabilityOptions = Substitute.For<IOptions<AvailabilityOptions>>();
availabilityOptions.Value.Returns(new AvailabilityOptions { CircuitBreakerSeconds = 30 });
var persistentService = new PersistentAvailabilityService(context, availabilityOptions);
var controller = new AvailabilityController(persistentService);
var httpContext = new DefaultHttpContext();
httpContext.User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin@test.com")], "TestAuth"));
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
var result = await controller.UpdateStatus(
new UpdateStatusRequest(AvailabilityStatus.Maintenance, "Test maintenance"));
result.Should().BeOfType<OkResult>();
}
}
@@ -0,0 +1,131 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using SlpModularCms.Modules.Availability.Controllers;
using SlpModularCms.Modules.Availability.Models;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Tests.Controllers;
public class MasterControllerTests
{
private readonly IMasterAvailabilityService _svc;
private readonly MasterController _controller;
public MasterControllerTests()
{
_svc = Substitute.For<IMasterAvailabilityService>();
_controller = new MasterController(_svc);
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
}
private void SetApiKeyHeader(string? value)
{
if (value != null)
_controller.HttpContext.Request.Headers["X-Master-Api-Key"] = value;
}
// --- Register ---
[Fact]
public async Task Register_Returns200_WhenSuccess()
{
SetApiKeyHeader("key123");
_svc.RegisterAsync("https://master.example.com", "key123").Returns(true);
var result = await _controller.Register(new RegisterMasterRequest("https://master.example.com"));
result.Should().BeOfType<OkResult>();
}
[Fact]
public async Task Register_Returns401_WhenKeyMismatch()
{
SetApiKeyHeader("wrong-key");
_svc.RegisterAsync(Arg.Any<string>(), "wrong-key").Returns(false);
var result = await _controller.Register(new RegisterMasterRequest("https://master.example.com"));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Register_Returns401_WhenMissingHeader()
{
var result = await _controller.Register(new RegisterMasterRequest("https://master.example.com"));
result.Should().BeOfType<UnauthorizedResult>();
await _svc.DidNotReceive().RegisterAsync(Arg.Any<string>(), Arg.Any<string>());
}
// --- PushStatus ---
[Fact]
public async Task PushStatus_Returns200_WhenSuccess()
{
SetApiKeyHeader("key123");
_svc.PushStatusAsync("key123", false, "maintenance").Returns(true);
var result = await _controller.PushStatus(new PushStatusRequest(false, "maintenance"));
result.Should().BeOfType<OkResult>();
}
[Fact]
public async Task PushStatus_Returns401_WhenKeyMismatch()
{
SetApiKeyHeader("wrong");
_svc.PushStatusAsync("wrong", Arg.Any<bool>(), Arg.Any<string?>()).Returns(false);
var result = await _controller.PushStatus(new PushStatusRequest(false, null));
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task PushStatus_Returns401_WhenMissingHeader()
{
var result = await _controller.PushStatus(new PushStatusRequest(false, null));
result.Should().BeOfType<UnauthorizedResult>();
await _svc.DidNotReceive().PushStatusAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>());
}
// --- GetRegisteredUrl ---
[Fact]
public async Task GetRegisteredUrl_Returns200WithUrl_WhenSuccess()
{
SetApiKeyHeader("key123");
_svc.GetRegisteredUrlAsync("key123").Returns("https://master.example.com");
var result = await _controller.GetRegisteredUrl();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new { MasterUrl = "https://master.example.com" });
}
[Fact]
public async Task GetRegisteredUrl_Returns401_WhenKeyMismatch()
{
SetApiKeyHeader("wrong");
_svc.GetRegisteredUrlAsync("wrong").Returns((string?)null);
var result = await _controller.GetRegisteredUrl();
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task GetRegisteredUrl_Returns401_WhenMissingHeader()
{
var result = await _controller.GetRegisteredUrl();
result.Should().BeOfType<UnauthorizedResult>();
await _svc.DidNotReceive().GetRegisteredUrlAsync(Arg.Any<string>());
}
}
@@ -98,4 +98,18 @@ public class PersistentAvailabilityServiceTests
details.Status.Should().Be(AvailabilityStatus.Available); // Fallback
}
[Fact]
public async Task UpdateStatusAsync_UpdatesExistingRecord_WhenCalledTwice()
{
await _service.UpdateStatusAsync(AvailabilityStatus.Maintenance, "First update", "Admin1");
await _service.UpdateStatusAsync(AvailabilityStatus.Available, "Reset", "Admin2");
var status = await _service.IsAvailableAsync();
status.Should().Be(AvailabilityStatus.Available);
var dbState = await _context.AvailabilityStates.SingleAsync();
dbState.Status.Should().Be(AvailabilityStatus.Available);
dbState.UpdatedBy.Should().Be("Admin2");
}
}
@@ -0,0 +1,99 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Availability.Data;
using SlpModularCms.Modules.Availability.Data.Entities;
using SlpModularCms.Modules.Availability.Repositories;
namespace SlpModularCms.Modules.Availability.Tests.Repositories;
public class MasterRegistrationRepositoryTests
{
private static AvailabilityDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<AvailabilityDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new AvailabilityDbContext(options);
}
[Fact]
public async Task GetAsync_ReturnsNull_WhenNoRegistrationExists()
{
await using var context = CreateContext();
var repo = new MasterRegistrationRepository(context);
var result = await repo.GetAsync();
result.Should().BeNull();
}
[Fact]
public async Task GetAsync_ReturnsRegistration_WhenExists()
{
await using var context = CreateContext();
var reg = new MasterRegistration
{
Id = MasterRegistration.SingletonId,
MasterUrl = "https://master.example.com",
ApiKey = "enc:key123",
RegisteredAt = DateTimeOffset.UtcNow
};
context.MasterRegistrations.Add(reg);
await context.SaveChangesAsync();
var repo = new MasterRegistrationRepository(context);
var result = await repo.GetAsync();
result.Should().NotBeNull();
result!.MasterUrl.Should().Be("https://master.example.com");
}
[Fact]
public async Task AddAsync_PersistsRegistration()
{
await using var context = CreateContext();
var repo = new MasterRegistrationRepository(context);
var reg = new MasterRegistration
{
Id = MasterRegistration.SingletonId,
MasterUrl = "https://master.example.com",
ApiKey = "enc:key123",
RegisteredAt = DateTimeOffset.UtcNow
};
await repo.AddAsync(reg);
await repo.SaveChangesAsync();
context.MasterRegistrations.Should().HaveCount(1);
}
[Fact]
public async Task Update_ModifiesExistingRegistration()
{
await using var context = CreateContext();
var reg = new MasterRegistration
{
Id = MasterRegistration.SingletonId,
MasterUrl = "https://old.example.com",
ApiKey = "enc:key",
RegisteredAt = DateTimeOffset.UtcNow
};
context.MasterRegistrations.Add(reg);
await context.SaveChangesAsync();
var repo = new MasterRegistrationRepository(context);
var existing = await repo.GetAsync();
existing!.MasterUrl = "https://new.example.com";
repo.Update(existing);
await repo.SaveChangesAsync();
var updated = await context.MasterRegistrations.FindAsync(MasterRegistration.SingletonId);
updated!.MasterUrl.Should().Be("https://new.example.com");
}
[Fact]
public void SingletonId_IsExpectedValue()
{
MasterRegistration.SingletonId.Should().Be(new Guid("00000000-0000-0000-0000-000000000001"));
}
}
@@ -0,0 +1,60 @@
using FluentAssertions;
using Microsoft.AspNetCore.DataProtection;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Tests.Services;
public class MasterApiKeyProtectorTests
{
private static MasterApiKeyProtector CreateProtector()
{
var provider = new EphemeralDataProtectionProvider();
return new MasterApiKeyProtector(provider);
}
[Fact]
public void Protect_Unprotect_RoundTrip_ReturnsOriginalValue()
{
var protector = CreateProtector();
var plain = "my-secret-api-key";
var encrypted = protector.Protect(plain);
var decrypted = protector.Unprotect(encrypted);
decrypted.Should().Be(plain);
}
[Fact]
public void Protect_ReturnsNonEmptyString_DifferentFromInput()
{
var protector = CreateProtector();
var plain = "my-secret-api-key";
var encrypted = protector.Protect(plain);
encrypted.Should().NotBeNullOrEmpty();
encrypted.Should().NotBe(plain);
}
[Fact]
public void Unprotect_ReturnsNull_WhenGivenInvalidCiphertext()
{
var protector = CreateProtector();
var result = protector.Unprotect("this-is-not-valid-ciphertext");
result.Should().BeNull();
}
[Fact]
public void Unprotect_ReturnsNull_WhenProtectedWithDifferentProvider()
{
var protector1 = CreateProtector();
var protector2 = CreateProtector();
var encrypted = protector1.Protect("key");
var result = protector2.Unprotect(encrypted);
result.Should().BeNull();
}
}
@@ -0,0 +1,240 @@
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();
}
[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();
}
}
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.11" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
@@ -1,9 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Modules;
using SlpModularCms.Modules.Availability.Data;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Repositories;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability;
@@ -18,10 +22,25 @@ public class AvailabilityModule : IModule
{
services.AddScoped<IAvailabilityService, PersistentAvailabilityService>();
services.AddScoped<PersistentAvailabilityService>();
services.AddDbContext<AvailabilityDbContext>((serviceProvider, options) =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"));
});
services.AddDataProtection();
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
}
public void UseModule(IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
scope.ServiceProvider.GetRequiredService<AvailabilityDbContext>().Database.Migrate();
app.UseMiddleware<AvailabilityMiddleware>();
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using SlpModularCms.Modules.Availability.Models;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Controllers;
[ApiController]
[Route("[controller]")]
public class MasterController : ControllerBase
{
private readonly IMasterAvailabilityService _svc;
public MasterController(IMasterAvailabilityService svc) => _svc = svc;
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterMasterRequest request)
{
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
var success = await _svc.RegisterAsync(request.MasterUrl, apiKey);
return success ? Ok() : Unauthorized();
}
[HttpPost("status")]
public async Task<IActionResult> PushStatus([FromBody] PushStatusRequest request)
{
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
var success = await _svc.PushStatusAsync(apiKey, request.IsAvailable, request.DisableMessage);
return success ? Ok() : Unauthorized();
}
[HttpGet("registered-url")]
public async Task<IActionResult> GetRegisteredUrl()
{
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
var url = await _svc.GetRegisteredUrlAsync(apiKey);
return url is not null ? Ok(new { MasterUrl = url }) : Unauthorized();
}
}
@@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Data;
[ExcludeFromCodeCoverage]
public class AvailabilityDbContext(DbContextOptions<AvailabilityDbContext> options) : DbContext(options)
{
public DbSet<MasterRegistration> MasterRegistrations => Set<MasterRegistration>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<MasterRegistration>(entity =>
{
entity.ToTable("AvailabilityMasterRegistrations");
entity.HasKey(e => e.Id);
entity.Property(e => e.MasterUrl).IsRequired().HasMaxLength(500);
entity.Property(e => e.ApiKey).IsRequired().HasMaxLength(2000);
entity.Property(e => e.RegisteredAt).IsRequired();
});
}
}
@@ -0,0 +1,12 @@
namespace SlpModularCms.Modules.Availability.Data.Entities;
public class MasterRegistration
{
public static readonly Guid SingletonId = new("00000000-0000-0000-0000-000000000001");
public Guid Id { get; set; }
public string MasterUrl { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public DateTimeOffset RegisteredAt { get; set; }
public DateTimeOffset? LastContactedAt { get; set; }
}
@@ -4,13 +4,10 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Middleware;
/// <summary>
/// Middleware die de beschikbaarheid van het systeem controleert en requests blokkeert indien nodig.
/// </summary>
public class AvailabilityMiddleware
{
private readonly RequestDelegate _next;
@@ -25,14 +22,19 @@ public class AvailabilityMiddleware
// Paths that are always accessible regardless of system availability.
// Auth and Setup must stay open so admins can log in and the frontend
// can determine whether the system is initialized.
// Master endpoints bypass so master can always push status or re-register.
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/",
];
public async Task InvokeAsync(HttpContext context, IAvailabilityService availabilityService)
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService availabilityService,
IMasterAvailabilityService masterAvailabilityService)
{
var path = context.Request.Path.Value ?? string.Empty;
if (_bypassPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
@@ -41,35 +43,47 @@ public class AvailabilityMiddleware
return;
}
var status = await availabilityService.IsAvailableAsync();
if (status == AvailabilityStatus.Available)
{
await _next(context);
return;
}
// Check voor Beheerder Bypass (Owner of Administrator)
if (IsAdminBypass(context))
{
await _next(context);
return;
}
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
var masterStatus = masterAvailabilityService.GetMasterStatus();
if (!masterStatus.IsAvailable)
{
_logger.LogWarning(
"Request blocked by master gate. Path: {Path}, DisableMessage: {DisableMessage}",
path, masterStatus.DisableMessage);
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "Service Unavailable",
Detail = masterStatus.DisableMessage ?? "De service is tijdelijk niet beschikbaar.",
Instance = context.Request.Path
});
return;
}
var status = await availabilityService.IsAvailableAsync();
if (status == AvailabilityStatus.Available)
{
await _next(context);
return;
}
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
var problemDetails = new ProblemDetails
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "Service Unavailable",
Detail = status == AvailabilityStatus.Maintenance
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
Detail = status == AvailabilityStatus.Maintenance
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
: "De service is tijdelijk niet beschikbaar.",
Instance = context.Request.Path
};
await context.Response.WriteAsJsonAsync(problemDetails);
});
}
private bool IsAdminBypass(HttpContext context)
@@ -91,7 +105,6 @@ public class AvailabilityMiddleware
}
catch (Exception)
{
// Ongeldig token of parsing fout: geen bypass
return false;
}
}
@@ -0,0 +1,9 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Availability.Models;
[ExcludeFromCodeCoverage]
public record RegisterMasterRequest(string MasterUrl);
[ExcludeFromCodeCoverage]
public record PushStatusRequest(bool IsAvailable, string? DisableMessage);
@@ -0,0 +1,11 @@
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Repositories;
public interface IMasterRegistrationRepository
{
Task<MasterRegistration?> GetAsync();
Task AddAsync(MasterRegistration registration);
void Update(MasterRegistration registration);
Task SaveChangesAsync();
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Availability.Data;
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Repositories;
public class MasterRegistrationRepository(AvailabilityDbContext context) : IMasterRegistrationRepository
{
public Task<MasterRegistration?> GetAsync()
=> context.MasterRegistrations.FirstOrDefaultAsync(r => r.Id == MasterRegistration.SingletonId);
public async Task AddAsync(MasterRegistration registration)
=> await context.MasterRegistrations.AddAsync(registration);
public void Update(MasterRegistration registration)
=> context.MasterRegistrations.Update(registration);
public Task SaveChangesAsync()
=> context.SaveChangesAsync();
}
@@ -0,0 +1,7 @@
namespace SlpModularCms.Modules.Availability.Services;
public interface IMasterApiKeyProtector
{
string Protect(string plainApiKey);
string? Unprotect(string encryptedApiKey);
}
@@ -0,0 +1,9 @@
namespace SlpModularCms.Modules.Availability.Services;
public interface IMasterAvailabilityService
{
Task<bool> RegisterAsync(string masterUrl, string apiKey);
Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus();
}
@@ -0,0 +1,21 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
namespace SlpModularCms.Modules.Availability.Services;
public class MasterApiKeyProtector : IMasterApiKeyProtector
{
private readonly IDataProtector _protector;
public MasterApiKeyProtector(IDataProtectionProvider provider)
=> _protector = provider.CreateProtector("SlpModularCms.Availability.MasterApiKey");
public string Protect(string plainApiKey)
=> _protector.Protect(plainApiKey);
public string? Unprotect(string encryptedApiKey)
{
try { return _protector.Unprotect(encryptedApiKey); }
catch (CryptographicException) { return null; }
}
}
@@ -0,0 +1,103 @@
using Microsoft.Extensions.Logging;
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Services;
public class MasterAvailabilityService : IMasterAvailabilityService
{
private readonly MasterAvailabilityServiceDependencies _deps;
private static volatile bool _masterIsAvailable = true;
private static volatile string? _masterDisableMessage;
public MasterAvailabilityService(MasterAvailabilityServiceDependencies deps)
=> _deps = deps;
public MasterGateStatus GetMasterStatus()
=> new(_masterIsAvailable, _masterDisableMessage);
public async Task<bool> RegisterAsync(string masterUrl, string apiKey)
{
var existing = await _deps.Repository.GetAsync();
if (existing is null)
{
var protectedKey = _deps.KeyProtector.Protect(apiKey);
var reg = new MasterRegistration
{
Id = MasterRegistration.SingletonId,
MasterUrl = masterUrl,
ApiKey = protectedKey,
RegisteredAt = DateTimeOffset.UtcNow,
LastContactedAt = DateTimeOffset.UtcNow
};
await _deps.Repository.AddAsync(reg);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogInformation("Master registered for the first time at {MasterUrl}", masterUrl);
return true;
}
if (!IsKeyValid(existing, apiKey))
{
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "POST /api/v1/master/register");
return false;
}
existing.MasterUrl = masterUrl;
existing.LastContactedAt = DateTimeOffset.UtcNow;
_deps.Repository.Update(existing);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogInformation("Master re-registered at {MasterUrl}", masterUrl);
return true;
}
public async Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage)
{
var existing = await _deps.Repository.GetAsync();
if (!IsKeyValid(existing, apiKey))
{
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "POST /api/v1/master/status");
return false;
}
_masterIsAvailable = isAvailable;
_masterDisableMessage = disableMessage;
existing!.LastContactedAt = DateTimeOffset.UtcNow;
_deps.Repository.Update(existing);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogInformation(
"Master status update received: IsAvailable={IsAvailable}, DisableMessage={DisableMessage}",
isAvailable, disableMessage);
return true;
}
public async Task<string?> GetRegisteredUrlAsync(string apiKey)
{
var existing = await _deps.Repository.GetAsync();
if (!IsKeyValid(existing, apiKey))
{
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "GET /api/v1/master/registered-url");
return null;
}
existing!.LastContactedAt = DateTimeOffset.UtcNow;
_deps.Repository.Update(existing);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogDebug("Get-registered-url called");
return existing.MasterUrl;
}
private bool IsKeyValid(MasterRegistration? registration, string apiKey)
{
if (registration is null) return false;
var stored = _deps.KeyProtector.Unprotect(registration.ApiKey);
return stored is not null && stored == apiKey;
}
internal static void ResetStaticCacheForTest()
{
_masterIsAvailable = true;
_masterDisableMessage = null;
}
}
@@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using SlpModularCms.Modules.Availability.Repositories;
namespace SlpModularCms.Modules.Availability.Services;
[ExcludeFromCodeCoverage]
public record MasterAvailabilityServiceDependencies(
IMasterRegistrationRepository Repository,
IMasterApiKeyProtector KeyProtector,
ILogger<MasterAvailabilityService> Logger
);
@@ -0,0 +1,6 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Availability.Services;
[ExcludeFromCodeCoverage]
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
@@ -1,13 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SlpModularCms.Modules.Availability.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,80 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SlpModularCms.Modules.Master.BackgroundServices;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Tests.BackgroundServices;
public class IntegrityCheckBackgroundServiceTests
{
[Fact]
public async Task ExecuteTickAsync_CallsVerifyIntegrityAsync()
{
var cmsService = Substitute.For<ICmsInstanceService>();
var sut = CreateSut(cmsService);
await sut.ExecuteTickAsync(CancellationToken.None);
await cmsService.Received(1).VerifyIntegrityAsync();
}
[Fact]
public async Task ExecuteTickAsync_DoesNotThrow_WhenVerifyIntegrityThrows()
{
var cmsService = Substitute.For<ICmsInstanceService>();
cmsService.VerifyIntegrityAsync().ThrowsAsync(new InvalidOperationException("boom"));
var sut = CreateSut(cmsService);
var act = async () => await sut.ExecuteTickAsync(CancellationToken.None);
await act.Should().NotThrowAsync();
}
[Fact]
public async Task ExecuteTickAsync_LogsError_WhenVerifyIntegrityThrows()
{
var cmsService = Substitute.For<ICmsInstanceService>();
cmsService.VerifyIntegrityAsync().ThrowsAsync(new InvalidOperationException("boom"));
var logger = Substitute.For<ILogger<IntegrityCheckBackgroundService>>();
var sut = CreateSut(cmsService, logger);
await sut.ExecuteTickAsync(CancellationToken.None);
logger.Received().Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}
private static IntegrityCheckBackgroundService CreateSut(
ICmsInstanceService cmsService,
ILogger<IntegrityCheckBackgroundService>? logger = null)
{
var scopeFactory = BuildScopeFactory(cmsService);
var options = Options.Create(new MasterModuleOptions { IntegrityCheckIntervalMinutes = 60 });
return new IntegrityCheckBackgroundService(
scopeFactory,
options,
logger ?? Substitute.For<ILogger<IntegrityCheckBackgroundService>>());
}
private static IServiceScopeFactory BuildScopeFactory(ICmsInstanceService cmsService)
{
var provider = Substitute.For<IServiceProvider>();
provider.GetService(typeof(ICmsInstanceService)).Returns(cmsService);
var scope = Substitute.For<IServiceScope>();
scope.ServiceProvider.Returns(provider);
var factory = Substitute.For<IServiceScopeFactory>();
factory.CreateAsyncScope().Returns(new AsyncServiceScope(scope));
return factory;
}
}
@@ -0,0 +1,96 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SlpModularCms.Modules.Master.Controllers;
using SlpModularCms.Modules.Master.Data.Entities;
using SlpModularCms.Modules.Master.Models;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Tests.Controllers;
public class CmsInstanceControllerTests
{
private readonly ICmsInstanceService _service = Substitute.For<ICmsInstanceService>();
private CmsInstanceController CreateSut() => new(_service);
private static CmsInstanceDto SampleDto() => new(
Guid.NewGuid(), "Slave", "https://slave.test",
CmsInstanceStatus.Available, null, null, null, null);
// --- GetAll ---
[Fact]
public async Task GetAll_Returns200_WithList()
{
_service.GetAllAsync().Returns(new List<CmsInstanceDto> { SampleDto() });
var result = await CreateSut().GetAll();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(200);
}
// --- Add ---
[Fact]
public async Task Add_Returns201Created_OnSuccess()
{
var dto = SampleDto();
_service.AddAsync(Arg.Any<CreateCmsInstanceRequest>()).Returns(dto);
var result = await CreateSut().Add(new CreateCmsInstanceRequest("Slave", "https://slave.test", "key"));
var created = result.Should().BeOfType<CreatedAtActionResult>().Subject;
created.StatusCode.Should().Be(201);
}
[Fact]
public async Task Add_Returns400_WhenArgumentExceptionThrown()
{
_service.AddAsync(Arg.Any<CreateCmsInstanceRequest>()).ThrowsAsync(new ArgumentException("Name is required."));
var result = await CreateSut().Add(new CreateCmsInstanceRequest("", "https://slave.test", "key"));
result.Should().BeOfType<BadRequestObjectResult>().Which.StatusCode.Should().Be(400);
}
// --- UpdateStatus ---
[Fact]
public async Task UpdateStatus_Returns200_WithResult()
{
var id = Guid.NewGuid();
_service.UpdateStatusAsync(id, Arg.Any<UpdateStatusRequest>())
.Returns(new UpdateStatusResult(true, true));
var result = await CreateSut().UpdateStatus(id, new UpdateStatusRequest(CmsInstanceStatus.Available, null));
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(200);
}
[Fact]
public async Task UpdateStatus_Returns404_WhenKeyNotFoundExceptionThrown()
{
var id = Guid.NewGuid();
_service.UpdateStatusAsync(id, Arg.Any<UpdateStatusRequest>())
.ThrowsAsync(new KeyNotFoundException("Not found."));
var result = await CreateSut().UpdateStatus(id, new UpdateStatusRequest(CmsInstanceStatus.Available, null));
result.Should().BeOfType<NotFoundObjectResult>().Which.StatusCode.Should().Be(404);
}
[Fact]
public async Task UpdateStatus_Returns400_WhenArgumentExceptionThrown()
{
var id = Guid.NewGuid();
_service.UpdateStatusAsync(id, Arg.Any<UpdateStatusRequest>())
.ThrowsAsync(new ArgumentException("DisableMessage required."));
var result = await CreateSut().UpdateStatus(id, new UpdateStatusRequest(CmsInstanceStatus.NotAvailable, null));
result.Should().BeOfType<BadRequestObjectResult>().Which.StatusCode.Should().Be(400);
}
}
@@ -0,0 +1,106 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Master.Data;
using SlpModularCms.Modules.Master.Data.Entities;
using SlpModularCms.Modules.Master.Repositories;
namespace SlpModularCms.Modules.Master.Tests.Repositories;
public class CmsInstanceRepositoryTests : IDisposable
{
private readonly MasterDbContext _context;
private readonly CmsInstanceRepository _sut;
public CmsInstanceRepositoryTests()
{
var options = new DbContextOptionsBuilder<MasterDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new MasterDbContext(options);
_sut = new CmsInstanceRepository(_context);
}
[Fact]
public async Task GetAllAsync_ReturnsAllInstances()
{
await _sut.AddAsync(Instance("A", CmsInstanceStatus.Available));
await _sut.AddAsync(Instance("B", CmsInstanceStatus.Inactive));
await _sut.SaveChangesAsync();
var result = await _sut.GetAllAsync();
result.Should().HaveCount(2);
}
[Fact]
public async Task GetActiveAsync_ExcludesInactive()
{
await _sut.AddAsync(Instance("Active", CmsInstanceStatus.Available));
await _sut.AddAsync(Instance("Inactive", CmsInstanceStatus.Inactive));
await _sut.SaveChangesAsync();
var result = await _sut.GetActiveAsync();
result.Should().HaveCount(1);
result[0].Name.Should().Be("Active");
}
[Fact]
public async Task GetByIdAsync_ReturnsInstance_WhenFound()
{
var inst = Instance("Test", CmsInstanceStatus.Available);
await _sut.AddAsync(inst);
await _sut.SaveChangesAsync();
var result = await _sut.GetByIdAsync(inst.Id);
result.Should().NotBeNull();
result!.Name.Should().Be("Test");
}
[Fact]
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
{
var result = await _sut.GetByIdAsync(Guid.NewGuid());
result.Should().BeNull();
}
[Fact]
public async Task AddAsync_AndSaveChangesAsync_PersistsInstance()
{
var inst = Instance("New", CmsInstanceStatus.Available);
await _sut.AddAsync(inst);
await _sut.SaveChangesAsync();
var all = await _sut.GetAllAsync();
all.Should().ContainSingle(i => i.Name == "New");
}
[Fact]
public async Task Update_PersistsChanges()
{
var inst = Instance("Original", CmsInstanceStatus.Available);
await _sut.AddAsync(inst);
await _sut.SaveChangesAsync();
inst.Name = "Updated";
_sut.Update(inst);
await _sut.SaveChangesAsync();
var result = await _sut.GetByIdAsync(inst.Id);
result!.Name.Should().Be("Updated");
}
private static CmsInstance Instance(string name, CmsInstanceStatus status) => new()
{
Id = Guid.NewGuid(),
Name = name,
Url = "https://slave.test",
ApiKey = "encrypted-key",
Status = status
};
public void Dispose() => _context.Dispose();
}
@@ -0,0 +1,46 @@
using FluentAssertions;
using Microsoft.AspNetCore.DataProtection;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Tests.Services;
public class ApiKeyProtectorTests
{
private readonly ApiKeyProtector _sut;
public ApiKeyProtectorTests()
{
var provider = new EphemeralDataProtectionProvider();
_sut = new ApiKeyProtector(provider);
}
[Fact]
public void Protect_ReturnsNonPlaintext()
{
var plain = "my-secret-api-key";
var result = _sut.Protect(plain);
result.Should().NotBe(plain);
result.Should().NotBeNullOrEmpty();
}
[Fact]
public void Unprotect_ReturnsOriginalValue_AfterProtect()
{
var plain = "my-secret-api-key";
var encrypted = _sut.Protect(plain);
var decrypted = _sut.Unprotect(encrypted);
decrypted.Should().Be(plain);
}
[Fact]
public void Unprotect_Throws_WhenGivenInvalidCiphertext()
{
var act = () => _sut.Unprotect("not-a-valid-ciphertext");
act.Should().Throw<Exception>();
}
}
@@ -0,0 +1,250 @@
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using SlpModularCms.Modules.Master.Data.Entities;
using SlpModularCms.Modules.Master.Models;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Repositories;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Tests.Services;
public class CmsInstanceServiceTests
{
private readonly ICmsInstanceRepository _repo = Substitute.For<ICmsInstanceRepository>();
private readonly ISlaveApiClient _slaveClient = Substitute.For<ISlaveApiClient>();
private readonly IApiKeyProtector _protector = Substitute.For<IApiKeyProtector>();
private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For<IHttpContextAccessor>();
private readonly ILogger<CmsInstanceService> _logger = Substitute.For<ILogger<CmsInstanceService>>();
private readonly IOptions<MasterModuleOptions> _options = Options.Create(new MasterModuleOptions { MasterUrl = "https://master.test" });
private CmsInstanceService CreateSut() => new(new MasterServiceDependencies(
_repo, _slaveClient, _protector, _options, _httpContextAccessor, _logger));
// --- GetAllAsync ---
[Fact]
public async Task GetAllAsync_ReturnsDtos_WithoutApiKey()
{
var instance = ActiveInstance();
_repo.GetAllAsync().Returns([instance]);
var result = await CreateSut().GetAllAsync();
result.Should().HaveCount(1);
result[0].Id.Should().Be(instance.Id);
}
// --- AddAsync ---
[Fact]
public async Task AddAsync_ThrowsArgumentException_WhenNameIsEmpty()
{
var sut = CreateSut();
var act = async () => await sut.AddAsync(new CreateCmsInstanceRequest("", "https://slave.test", "key"));
await act.Should().ThrowAsync<ArgumentException>().WithMessage("*Name*");
}
[Fact]
public async Task AddAsync_ThrowsArgumentException_WhenUrlIsEmpty()
{
var sut = CreateSut();
var act = async () => await sut.AddAsync(new CreateCmsInstanceRequest("Slave", "", "key"));
await act.Should().ThrowAsync<ArgumentException>().WithMessage("*Url*");
}
[Fact]
public async Task AddAsync_ThrowsArgumentException_WhenApiKeyIsEmpty()
{
var sut = CreateSut();
var act = async () => await sut.AddAsync(new CreateCmsInstanceRequest("Slave", "https://slave.test", ""));
await act.Should().ThrowAsync<ArgumentException>().WithMessage("*ApiKey*");
}
[Fact]
public async Task AddAsync_EncryptsAndPersists_AndRegistersWithSlave()
{
_protector.Protect("plain").Returns("encrypted");
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(true);
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
var sut = CreateSut();
var result = await sut.AddAsync(new CreateCmsInstanceRequest("Slave", "https://slave.test", "plain"));
result.Should().NotBeNull();
await _repo.Received(1).AddAsync(Arg.Is<CmsInstance>(i => i.ApiKey == "encrypted"));
await _slaveClient.Received(1).RegisterMasterAsync("https://slave.test", "plain", "https://master.test");
}
[Fact]
public async Task AddAsync_StillPersists_WhenSlaveRegistrationFails()
{
_protector.Protect("plain").Returns("encrypted");
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(false);
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
var sut = CreateSut();
var result = await sut.AddAsync(new CreateCmsInstanceRequest("Slave", "https://slave.test", "plain"));
result.Should().NotBeNull();
await _repo.Received(1).AddAsync(Arg.Any<CmsInstance>());
}
[Fact]
public async Task AddAsync_UsesMasterUrlFromHttpContext_WhenAvailable()
{
_protector.Protect(Arg.Any<string>()).Returns("enc");
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(true);
var httpContext = Substitute.For<HttpContext>();
var httpRequest = Substitute.For<HttpRequest>();
httpRequest.Scheme.Returns("https");
httpRequest.Host.Returns(new HostString("my-master.test"));
httpContext.Request.Returns(httpRequest);
_httpContextAccessor.HttpContext.Returns(httpContext);
var sut = CreateSut();
await sut.AddAsync(new CreateCmsInstanceRequest("Slave", "https://slave.test", "plain"));
await _slaveClient.Received(1).RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), "https://my-master.test");
}
// --- UpdateStatusAsync ---
[Fact]
public async Task UpdateStatusAsync_ThrowsKeyNotFoundException_WhenInstanceNotFound()
{
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns((CmsInstance?)null);
var act = async () => await CreateSut().UpdateStatusAsync(Guid.NewGuid(), new UpdateStatusRequest(CmsInstanceStatus.Available, null));
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task UpdateStatusAsync_ThrowsArgumentException_WhenDisableMessageMissingForNotAvailable()
{
_repo.GetByIdAsync(Arg.Any<Guid>()).Returns(ActiveInstance());
var act = async () => await CreateSut().UpdateStatusAsync(Guid.NewGuid(), new UpdateStatusRequest(CmsInstanceStatus.NotAvailable, null));
await act.Should().ThrowAsync<ArgumentException>().WithMessage("*DisableMessage*");
}
[Fact]
public async Task UpdateStatusAsync_DoesNotPushToSlave_WhenStatusIsInactive()
{
var instance = ActiveInstance();
_repo.GetByIdAsync(instance.Id).Returns(instance);
var result = await CreateSut().UpdateStatusAsync(instance.Id, new UpdateStatusRequest(CmsInstanceStatus.Inactive, null));
result.Success.Should().BeTrue();
result.SlaveContactSuccess.Should().BeTrue();
await _slaveClient.DidNotReceive().PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>());
}
[Fact]
public async Task UpdateStatusAsync_PushesToSlave_AndReturnsSlaveContactSuccess_WhenPushSucceeds()
{
var instance = ActiveInstance();
_repo.GetByIdAsync(instance.Id).Returns(instance);
_protector.Unprotect("encrypted-key").Returns("plain");
_slaveClient.PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(true);
var result = await CreateSut().UpdateStatusAsync(instance.Id, new UpdateStatusRequest(CmsInstanceStatus.Available, null));
result.Success.Should().BeTrue();
result.SlaveContactSuccess.Should().BeTrue();
}
[Fact]
public async Task UpdateStatusAsync_ReturnsSlaveContactFalse_WhenPushFails()
{
var instance = ActiveInstance();
_repo.GetByIdAsync(instance.Id).Returns(instance);
_protector.Unprotect("encrypted-key").Returns("plain");
_slaveClient.PushStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<string?>()).Returns(false);
var result = await CreateSut().UpdateStatusAsync(instance.Id, new UpdateStatusRequest(CmsInstanceStatus.Available, null));
result.Success.Should().BeTrue();
result.SlaveContactSuccess.Should().BeFalse();
}
// --- VerifyIntegrityAsync ---
[Fact]
public async Task VerifyIntegrityAsync_SkipsCheck_WhenMasterUrlNotConfigured()
{
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
var optionsNoUrl = Options.Create(new MasterModuleOptions { MasterUrl = null });
var sut = new CmsInstanceService(new MasterServiceDependencies(
_repo, _slaveClient, _protector, optionsNoUrl, _httpContextAccessor, _logger));
await sut.VerifyIntegrityAsync();
await _repo.DidNotReceive().GetActiveAsync();
}
[Fact]
public async Task VerifyIntegrityAsync_SetsLastIntegrityCheckFailedAt_WhenSlaveUnreachable()
{
var instance = ActiveInstance();
_repo.GetActiveAsync().Returns([instance]);
_protector.Unprotect("encrypted-key").Returns("plain");
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns((string?)null);
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
await CreateSut().VerifyIntegrityAsync();
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt.HasValue));
}
[Fact]
public async Task VerifyIntegrityAsync_ReregistersAndClearsFlag_WhenUrlMismatch()
{
var instance = ActiveInstance();
_repo.GetActiveAsync().Returns([instance]);
_protector.Unprotect("encrypted-key").Returns("plain");
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns("https://old-master.test");
_slaveClient.RegisterMasterAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()).Returns(true);
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
await CreateSut().VerifyIntegrityAsync();
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt == null && i.LastContactedAt.HasValue));
}
[Fact]
public async Task VerifyIntegrityAsync_ClearsFlag_WhenUrlMatches()
{
var instance = ActiveInstance();
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow.AddHours(-1);
_repo.GetActiveAsync().Returns([instance]);
_protector.Unprotect("encrypted-key").Returns("plain");
_slaveClient.GetRegisteredMasterUrlAsync(Arg.Any<string>(), Arg.Any<string>()).Returns("https://master.test");
_httpContextAccessor.HttpContext.Returns((HttpContext?)null);
await CreateSut().VerifyIntegrityAsync();
_repo.Received().Update(Arg.Is<CmsInstance>(i => i.LastIntegrityCheckFailedAt == null));
}
private static CmsInstance ActiveInstance() => new()
{
Id = Guid.NewGuid(),
Name = "Slave",
Url = "https://slave.test",
ApiKey = "encrypted-key",
Status = CmsInstanceStatus.Available
};
}
@@ -0,0 +1,137 @@
using FluentAssertions;
using SlpModularCms.Modules.Master.Services;
using System.Net;
using System.Text;
using System.Text.Json;
namespace SlpModularCms.Modules.Master.Tests.Services;
public class SlaveApiClientTests
{
private const string SlaveUrl = "https://slave.test";
private const string ApiKey = "plain-key";
private const string MasterUrl = "https://master.test";
private static SlaveApiClient CreateSut(HttpMessageHandler handler)
=> new(new HttpClient(handler));
[Fact]
public async Task RegisterMasterAsync_ReturnsTrue_WhenResponseIsSuccess()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK);
var sut = CreateSut(handler);
var result = await sut.RegisterMasterAsync(SlaveUrl, ApiKey, MasterUrl);
result.Should().BeTrue();
}
[Fact]
public async Task RegisterMasterAsync_ReturnsFalse_WhenResponseIsFailure()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.InternalServerError);
var sut = CreateSut(handler);
var result = await sut.RegisterMasterAsync(SlaveUrl, ApiKey, MasterUrl);
result.Should().BeFalse();
}
[Fact]
public async Task RegisterMasterAsync_ReturnsFalse_WhenExceptionIsThrown()
{
var handler = new ThrowingHttpMessageHandler();
var sut = CreateSut(handler);
var result = await sut.RegisterMasterAsync(SlaveUrl, ApiKey, MasterUrl);
result.Should().BeFalse();
}
[Fact]
public async Task PushStatusAsync_ReturnsTrue_WhenResponseIsSuccess()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK);
var sut = CreateSut(handler);
var result = await sut.PushStatusAsync(SlaveUrl, ApiKey, isAvailable: false, disableMessage: "Maintenance");
result.Should().BeTrue();
}
[Fact]
public async Task PushStatusAsync_ReturnsFalse_WhenResponseIsFailure()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.BadGateway);
var sut = CreateSut(handler);
var result = await sut.PushStatusAsync(SlaveUrl, ApiKey, isAvailable: true, disableMessage: null);
result.Should().BeFalse();
}
[Fact]
public async Task GetRegisteredMasterUrlAsync_ReturnsMasterUrl_WhenResponseIsSuccess()
{
var json = JsonSerializer.Serialize(new { MasterUrl = MasterUrl });
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
var sut = CreateSut(handler);
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
result.Should().Be(MasterUrl);
}
[Fact]
public async Task GetRegisteredMasterUrlAsync_ReturnsNull_WhenResponseIsFailure()
{
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound);
var sut = CreateSut(handler);
var result = await sut.GetRegisteredMasterUrlAsync(SlaveUrl, ApiKey);
result.Should().BeNull();
}
[Fact]
public async Task RegisterMasterAsync_SetsApiKeyHeader()
{
string? capturedKey = null;
var handler = new CapturingHttpMessageHandler(req =>
{
req.Headers.TryGetValues("X-Master-Api-Key", out var values);
capturedKey = values?.FirstOrDefault();
});
var sut = CreateSut(handler);
await sut.RegisterMasterAsync(SlaveUrl, ApiKey, MasterUrl);
capturedKey.Should().Be(ApiKey);
}
private class FakeHttpMessageHandler(HttpStatusCode statusCode, string? content = null) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(statusCode);
if (content is not null)
response.Content = new StringContent(content, Encoding.UTF8, "application/json");
return Task.FromResult(response);
}
}
private class ThrowingHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> throw new HttpRequestException("Connection refused");
}
private class CapturingHttpMessageHandler(Action<HttpRequestMessage> capture) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
capture(request);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Modules.Master\SlpModularCms.Modules.Master.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.BackgroundServices;
public class IntegrityCheckBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<MasterModuleOptions> options,
ILogger<IntegrityCheckBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMinutes(options.Value.IntegrityCheckIntervalMinutes);
using var timer = new PeriodicTimer(interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ExecuteTickAsync(stoppingToken);
}
}
internal async Task ExecuteTickAsync(CancellationToken stoppingToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<ICmsInstanceService>();
await service.VerifyIntegrityAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled error during integrity check tick.");
}
}
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SlpModularCms.Modules.Master.Models;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master.Controllers;
[ApiController]
[Route("[controller]")]
[Authorize(Policy = "OwnerOnly")]
public class CmsInstanceController(ICmsInstanceService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
var instances = await service.GetAllAsync();
return Ok(instances);
}
[HttpPost]
public async Task<IActionResult> Add([FromBody] CreateCmsInstanceRequest request)
{
try
{
var dto = await service.AddAsync(request);
return CreatedAtAction(nameof(GetAll), null, dto);
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("{id:guid}/status")]
public async Task<IActionResult> UpdateStatus(Guid id, [FromBody] UpdateStatusRequest request)
{
try
{
var result = await service.UpdateStatusAsync(id, request);
return Ok(result);
}
catch (KeyNotFoundException ex)
{
return NotFound(new { error = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
@@ -0,0 +1,14 @@
namespace SlpModularCms.Modules.Master.Data.Entities;
public class CmsInstance
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public CmsInstanceStatus Status { get; set; } = CmsInstanceStatus.Available;
public string? DisableMessage { get; set; }
public DateTimeOffset? LastContactedAt { get; set; }
public DateTimeOffset? LastStatusPushedAt { get; set; }
public DateTimeOffset? LastIntegrityCheckFailedAt { get; set; }
}
@@ -0,0 +1,8 @@
namespace SlpModularCms.Modules.Master.Data.Entities;
public enum CmsInstanceStatus
{
Available = 0,
NotAvailable = 1,
Inactive = 2
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Data;
public class MasterDbContext(DbContextOptions<MasterDbContext> options) : DbContext(options)
{
public DbSet<CmsInstance> CmsInstances => Set<CmsInstance>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<CmsInstance>(entity =>
{
entity.ToTable("MasterCmsInstances");
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Url).IsRequired().HasMaxLength(500);
entity.Property(e => e.ApiKey).IsRequired().HasMaxLength(1000);
entity.Property(e => e.Status).IsRequired();
entity.Property(e => e.DisableMessage).HasMaxLength(500);
});
}
}
@@ -0,0 +1,64 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using SlpModularCms.Core.Modules;
using SlpModularCms.Modules.Master.BackgroundServices;
using SlpModularCms.Modules.Master.Data;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Repositories;
using SlpModularCms.Modules.Master.Services;
namespace SlpModularCms.Modules.Master;
[ExcludeFromCodeCoverage]
public class MasterModule : IModule
{
public string Name => "Master";
public string Version => "1.0.0";
public void RegisterServices(IServiceCollection services)
{
services.AddDataProtection();
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
services.AddOptions<MasterModuleOptions>().BindConfiguration("MasterModule");
services.AddDbContext<MasterDbContext>((serviceProvider, options) =>
{
var config = serviceProvider.GetRequiredService<IConfiguration>();
options.UseSqlServer(config.GetConnectionString("DefaultConnection"));
});
services.AddScoped<ICmsInstanceRepository, CmsInstanceRepository>();
services.AddScoped<MasterServiceDependencies>();
services.AddScoped<ICmsInstanceService, CmsInstanceService>();
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", (builder, context) =>
{
var opts = context.ServiceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<MasterModuleOptions>>();
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
});
builder.AddTimeout(TimeSpan.FromSeconds(opts.Value.HttpTimeoutSeconds));
});
services.AddHostedService<IntegrityCheckBackgroundService>();
services.AddHttpContextAccessor();
}
public void UseModule(IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MasterDbContext>();
db.Database.Migrate();
}
}
@@ -0,0 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record CmsInstanceDto(
Guid Id,
string Name,
string Url,
CmsInstanceStatus Status,
string? DisableMessage,
DateTimeOffset? LastContactedAt,
DateTimeOffset? LastStatusPushedAt,
DateTimeOffset? LastIntegrityCheckFailedAt
);
@@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record CreateCmsInstanceRequest(
string Name,
string Url,
string ApiKey
);
@@ -0,0 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record UpdateStatusRequest(
CmsInstanceStatus Status,
string? DisableMessage
);
@@ -0,0 +1,9 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Master.Models;
[ExcludeFromCodeCoverage]
public record UpdateStatusResult(
bool Success,
bool SlaveContactSuccess
);
@@ -0,0 +1,10 @@
namespace SlpModularCms.Modules.Master.Config;
public class MasterModuleOptions
{
public int IntegrityCheckIntervalMinutes { get; set; } = 60;
public int HttpTimeoutSeconds { get; set; } = 10;
public string? MasterUrl { get; set; }
public int CacheMinutes { get; set; } = 60;
public string? ApiKey { get; set; }
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Master.Data;
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Repositories;
public class CmsInstanceRepository(MasterDbContext context) : ICmsInstanceRepository
{
public async Task<IReadOnlyList<CmsInstance>> GetAllAsync()
=> await context.CmsInstances.ToListAsync();
public async Task<IReadOnlyList<CmsInstance>> GetActiveAsync()
=> await context.CmsInstances
.Where(c => c.Status != CmsInstanceStatus.Inactive)
.ToListAsync();
public async Task<CmsInstance?> GetByIdAsync(Guid id)
=> await context.CmsInstances.FindAsync(id);
public async Task AddAsync(CmsInstance instance)
=> await context.CmsInstances.AddAsync(instance);
public void Update(CmsInstance instance)
=> context.CmsInstances.Update(instance);
public async Task SaveChangesAsync()
=> await context.SaveChangesAsync();
}
@@ -0,0 +1,13 @@
using SlpModularCms.Modules.Master.Data.Entities;
namespace SlpModularCms.Modules.Master.Repositories;
public interface ICmsInstanceRepository
{
Task<IReadOnlyList<CmsInstance>> GetAllAsync();
Task<IReadOnlyList<CmsInstance>> GetActiveAsync();
Task<CmsInstance?> GetByIdAsync(Guid id);
Task AddAsync(CmsInstance instance);
void Update(CmsInstance instance);
Task SaveChangesAsync();
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.DataProtection;
namespace SlpModularCms.Modules.Master.Services;
public class ApiKeyProtector(IDataProtectionProvider provider) : IApiKeyProtector
{
private readonly IDataProtector _protector = provider.CreateProtector("SlpModularCms.Master.ApiKey");
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string Unprotect(string encryptedApiKey) => _protector.Unprotect(encryptedApiKey);
}
@@ -0,0 +1,175 @@
using Microsoft.Extensions.Logging;
using SlpModularCms.Modules.Master.Data.Entities;
using SlpModularCms.Modules.Master.Models;
namespace SlpModularCms.Modules.Master.Services;
public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceService
{
public async Task<IReadOnlyList<CmsInstanceDto>> GetAllAsync()
{
var instances = await deps.Repository.GetAllAsync();
return instances.Select(ToDto).ToList();
}
public async Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new ArgumentException("Name is required.", nameof(request));
if (string.IsNullOrWhiteSpace(request.Url))
throw new ArgumentException("Url is required.", nameof(request));
if (string.IsNullOrWhiteSpace(request.ApiKey))
throw new ArgumentException("ApiKey is required.", nameof(request));
var encryptedKey = deps.ApiKeyProtector.Protect(request.ApiKey);
var instance = new CmsInstance
{
Id = Guid.NewGuid(),
Name = request.Name,
Url = request.Url,
ApiKey = encryptedKey,
Status = CmsInstanceStatus.Available
};
await deps.Repository.AddAsync(instance);
await deps.Repository.SaveChangesAsync();
var masterUrl = ResolveMasterUrl();
if (masterUrl is not null)
{
var registered = await deps.SlaveClient.RegisterMasterAsync(instance.Url, request.ApiKey, masterUrl);
if (registered)
{
instance.LastContactedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
else
{
deps.Logger.LogWarning("Master registration failed for slave {SlaveUrl}", instance.Url);
}
}
else
{
deps.Logger.LogWarning("MasterUrl could not be determined; skipping slave registration for {SlaveUrl}", instance.Url);
}
return ToDto(instance);
}
public async Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request)
{
var instance = await deps.Repository.GetByIdAsync(id)
?? throw new KeyNotFoundException($"CMS instance {id} not found.");
if (request.Status == CmsInstanceStatus.NotAvailable && string.IsNullOrWhiteSpace(request.DisableMessage))
throw new ArgumentException("DisableMessage is required when setting status to NotAvailable.", nameof(request));
instance.Status = request.Status;
instance.DisableMessage = request.Status == CmsInstanceStatus.Available ? null : request.DisableMessage;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
if (request.Status == CmsInstanceStatus.Inactive)
return new UpdateStatusResult(Success: true, SlaveContactSuccess: true);
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
var pushed = await deps.SlaveClient.PushStatusAsync(
instance.Url,
plainKey,
isAvailable: request.Status == CmsInstanceStatus.Available,
disableMessage: instance.DisableMessage);
if (pushed)
{
instance.LastStatusPushedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
else
{
deps.Logger.LogError("Status push failed for slave {SlaveUrl}", instance.Url);
}
return new UpdateStatusResult(Success: true, SlaveContactSuccess: pushed);
}
public async Task VerifyIntegrityAsync()
{
var masterUrl = ResolveMasterUrl();
if (masterUrl is null)
{
deps.Logger.LogWarning("MasterUrl not configured; skipping integrity check.");
return;
}
var activeInstances = await deps.Repository.GetActiveAsync();
foreach (var instance in activeInstances)
{
try
{
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
var registeredUrl = await deps.SlaveClient.GetRegisteredMasterUrlAsync(instance.Url, plainKey);
if (registeredUrl is null)
{
deps.Logger.LogWarning("Slave {SlaveUrl} unreachable during integrity check.", instance.Url);
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
continue;
}
if (!string.Equals(registeredUrl, masterUrl, StringComparison.OrdinalIgnoreCase))
{
deps.Logger.LogWarning("Slave {SlaveUrl} has wrong master URL '{RegisteredUrl}'; re-registering.", instance.Url, registeredUrl);
var reRegistered = await deps.SlaveClient.RegisterMasterAsync(instance.Url, plainKey, masterUrl);
if (reRegistered)
{
instance.LastContactedAt = DateTimeOffset.UtcNow;
instance.LastIntegrityCheckFailedAt = null;
}
else
{
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
}
}
else
{
instance.LastIntegrityCheckFailedAt = null;
}
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
}
catch (Exception ex)
{
deps.Logger.LogError(ex, "Integrity check error for slave {SlaveUrl}", instance.Url);
}
}
}
private string? ResolveMasterUrl()
{
var httpContext = deps.HttpContextAccessor.HttpContext;
if (httpContext is not null)
{
var request = httpContext.Request;
return $"{request.Scheme}://{request.Host}";
}
return deps.Options.Value.MasterUrl;
}
private static CmsInstanceDto ToDto(CmsInstance instance) => new(
instance.Id,
instance.Name,
instance.Url,
instance.Status,
instance.DisableMessage,
instance.LastContactedAt,
instance.LastStatusPushedAt,
instance.LastIntegrityCheckFailedAt
);
}
@@ -0,0 +1,7 @@
namespace SlpModularCms.Modules.Master.Services;
public interface IApiKeyProtector
{
string Protect(string plainApiKey);
string Unprotect(string encryptedApiKey);
}
@@ -0,0 +1,11 @@
using SlpModularCms.Modules.Master.Models;
namespace SlpModularCms.Modules.Master.Services;
public interface ICmsInstanceService
{
Task<IReadOnlyList<CmsInstanceDto>> GetAllAsync();
Task<CmsInstanceDto> AddAsync(CreateCmsInstanceRequest request);
Task<UpdateStatusResult> UpdateStatusAsync(Guid id, UpdateStatusRequest request);
Task VerifyIntegrityAsync();
}
@@ -0,0 +1,8 @@
namespace SlpModularCms.Modules.Master.Services;
public interface ISlaveApiClient
{
Task<bool> RegisterMasterAsync(string slaveUrl, string plainApiKey, string masterUrl);
Task<bool> PushStatusAsync(string slaveUrl, string plainApiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey);
}
@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SlpModularCms.Modules.Master.Config;
using SlpModularCms.Modules.Master.Repositories;
namespace SlpModularCms.Modules.Master.Services;
[ExcludeFromCodeCoverage]
public record MasterServiceDependencies(
ICmsInstanceRepository Repository,
ISlaveApiClient SlaveClient,
IApiKeyProtector ApiKeyProtector,
IOptions<MasterModuleOptions> Options,
IHttpContextAccessor HttpContextAccessor,
ILogger<CmsInstanceService> Logger
);
@@ -0,0 +1,65 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace SlpModularCms.Modules.Master.Services;
public class SlaveApiClient(HttpClient httpClient) : ISlaveApiClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<bool> RegisterMasterAsync(string slaveUrl, string plainApiKey, string masterUrl)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{slaveUrl.TrimEnd('/')}/api/v1/master/register");
request.Headers.Add("X-Master-Api-Key", plainApiKey);
request.Content = JsonContent.Create(new { MasterUrl = masterUrl }, options: JsonOptions);
var response = await httpClient.SendAsync(request);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public async Task<bool> PushStatusAsync(string slaveUrl, string plainApiKey, bool isAvailable, string? disableMessage)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{slaveUrl.TrimEnd('/')}/api/v1/master/status");
request.Headers.Add("X-Master-Api-Key", plainApiKey);
request.Content = JsonContent.Create(new { IsAvailable = isAvailable, DisableMessage = disableMessage }, options: JsonOptions);
var response = await httpClient.SendAsync(request);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
public async Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{slaveUrl.TrimEnd('/')}/api/v1/master/registered-url");
request.Headers.Add("X-Master-Api-Key", plainApiKey);
var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var result = await response.Content.ReadFromJsonAsync<RegisteredMasterUrlResponse>(JsonOptions);
return result?.MasterUrl;
}
catch
{
return null;
}
}
private record RegisteredMasterUrlResponse(string? MasterUrl);
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SlpModularCms.Modules.Master.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.6.0" />
</ItemGroup>
</Project>