Adds 2 units and docs for unit 3. nfr-requirements plan
This commit is contained in:
@@ -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>
|
||||
|
||||
+145
@@ -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>());
|
||||
}
|
||||
|
||||
+28
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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();
|
||||
}
|
||||
}
|
||||
+240
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
@@ -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>
|
||||
|
||||
+80
@@ -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>
|
||||
+39
@@ -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>
|
||||
Reference in New Issue
Block a user