feat(cert): refine certificate renewal endpoints, remove TEST_EVIDENCE.md, and add 401 rejection unit test

This commit is contained in:
ochenstarik-ui 2026-08-10 00:32:45 +07:00
parent 25a93f7b35
commit c30a1fc56e
5 changed files with 72 additions and 80 deletions

View file

@ -1,46 +0,0 @@
# Test Evidence - Client Certificate Lifecycle Management
## 1. Automated Test Execution
Command executed:
```powershell
dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release
```
Output summary:
```text
Тестовый запуск для C:\Users\Ochenstarik\projects\smm-antigravity\tests\ServerMonitorManager.Control.Tests\bin\Release\net10.0\ServerMonitorManager.Control.Tests.dll (.NETCoreApp,Version=v10.0)
Пройден! : не пройдено 0, пройдено 101, пропущено 0, всего 101, длительность 11 s. - ServerMonitorManager.Control.Tests.dll (net10.0)
```
Includes test cases in `CertificateLifecycleTests.cs`:
1. `CertWithLessThanOneThirdRemainingIsRenewed_AndOldCertReplaced` - PASS
2. `CertWithSufficientRemainingLifetime_IsNotRenewed` - PASS
3. `HubUnavailable_AgentContinuesUsingExistingCert_MetricsPreserved` - PASS
4. `RevokedCert_CannotBeRenewed` - PASS
5. `RenewalRequest_WithDifferentNodeId_IsRejected` - PASS
6. `InterruptedPfxReplacement_ActiveCertRemainsIntact` - PASS
7. `OutOfRangeClientCertificateDays_ValidationFailsOnStart` - PASS
---
## 2. Trimmed Self-Contained Binary Publish & Startup Validation
Publish Command:
```powershell
dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj -c Release -r win-x64 --self-contained -p:PublishTrimmed=true
```
Validation Test Command with out-of-range option (`ClientCertificateDays = 999`):
```powershell
.\src\ServerMonitorManager.Control\bin\Release\net10.0\win-x64\publish\ochenstarik-smm-control.exe --Control:ClientCertificateDays 999
```
Output:
```text
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: Invalid Control paths, heartbeat, retention, maintenance, expiration, reconciliation, or backup settings.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Program.<Main>$(String[] args) in C:\Users\Ochenstarik\projects\smm-antigravity\src\ServerMonitorManager.Control\Program.cs:line 127
```
Result: Startup validation cleanly catches values out of range [1..90] on self-contained trimmed binary.

View file

@ -410,39 +410,6 @@ agents.MapPost("/certificate/renew", async (
return Results.Conflict(new ProblemDetails { Title = exception.Message }); return Results.Conflict(new ProblemDetails { Title = exception.Message });
} }
}); });
app.MapPost("/api/v1/certificates/renew", async (
CertificateRenewalRequest request,
HttpContext context,
CertificateLifecycleService lifecycle,
CancellationToken cancellationToken) =>
{
var entityId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
var role = context.User.FindFirstValue(ClaimTypes.Role);
if (string.IsNullOrWhiteSpace(entityId) || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = ["Invalid entity id or idempotency key."]
});
}
try
{
IssuedCertificate issued = role switch
{
"Agent" => await lifecycle.RenewAgentCertificateAsync(entityId, request, entityId, cancellationToken),
"Operator" => await lifecycle.RenewDeviceCertificateAsync(entityId, request, entityId, cancellationToken),
_ => throw new InvalidOperationException("Unauthorized role for certificate renewal.")
};
return Results.Ok(new CertificateRenewalResponse(
entityId, issued.CertificatePem, issued.CertificateAuthorityPem, issued.ExpiresAt));
}
catch (InvalidOperationException exception)
{
return Results.Conflict(new ProblemDetails { Title = exception.Message });
}
}).RequireAuthorization();
agents.MapGet("/provisioning/jobs/next", async ( agents.MapGet("/provisioning/jobs/next", async (
HttpContext context, HttpContext context,
ControlStore controlStore, ControlStore controlStore,
@ -606,6 +573,35 @@ agents.MapPost("/provisioning/jobs/{id}/execution-grant", async (
}); });
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator"); var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
control.MapPost("/certificates/renew", async (
CertificateRenewalRequest request,
HttpContext context,
CertificateLifecycleService lifecycle,
CancellationToken cancellationToken) =>
{
var deviceId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(deviceId)
|| !NodeIdValidator.IsValid(deviceId)
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = ["Invalid device id or idempotency key."]
});
}
try
{
var issued = await lifecycle.RenewDeviceCertificateAsync(
deviceId, request, deviceId, cancellationToken);
return Results.Ok(new CertificateRenewalResponse(
deviceId, issued.CertificatePem, issued.CertificateAuthorityPem, issued.ExpiresAt));
}
catch (InvalidOperationException exception)
{
return Results.Conflict(new ProblemDetails { Title = exception.Message });
}
});
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) => control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray())); Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray()));
control.MapGet("/provisioning/catalogs/system-base-install/1", () => control.MapGet("/provisioning/catalogs/system-base-install/1", () =>

View file

@ -318,7 +318,7 @@ public sealed partial class ControlClientService
var csrRequest = new CertificateRequest($"CN={deviceId}", key, HashAlgorithmName.SHA256); var csrRequest = new CertificateRequest($"CN={deviceId}", key, HashAlgorithmName.SHA256);
var req = new CertificateRenewalRequest(deviceId, csrRequest.CreateSigningRequestPem(), Guid.NewGuid().ToString()); var req = new CertificateRenewalRequest(deviceId, csrRequest.CreateSigningRequestPem(), Guid.NewGuid().ToString());
using var renewClient = CreateHttpClient(controlUrl, ca, certificate); using var renewClient = CreateHttpClient(controlUrl, ca, certificate);
using var resp = await renewClient.PostAsJsonAsync("api/v1/certificates/renew", req, SmmJsonContext.Default.CertificateRenewalRequest, cancellationToken); using var resp = await renewClient.PostAsJsonAsync("api/v1/control/certificates/renew", req, SmmJsonContext.Default.CertificateRenewalRequest, cancellationToken);
if (resp.IsSuccessStatusCode) if (resp.IsSuccessStatusCode)
{ {
var renewal = await resp.Content.ReadFromJsonAsync(SmmJsonContext.Default.CertificateRenewalResponse, cancellationToken); var renewal = await resp.Content.ReadFromJsonAsync(SmmJsonContext.Default.CertificateRenewalResponse, cancellationToken);

View file

@ -240,6 +240,24 @@ public sealed class CertificateLifecycleTests : IDisposable
Assert.True(optionsValid.ClientCertificateDays is >= 1 and <= 90); Assert.True(optionsValid.ClientCertificateDays is >= 1 and <= 90);
} }
[Fact]
public void CertificateDays45_IssuesCertificateWith45DaysValidity()
{
var caPath = Path.Combine(_directory, "ca45.pfx");
CreateCaPfx(caPath);
var options = new ControlOptions { ClientCertificateDays = 45, CertificateAuthorityPath = caPath };
using var ca = new CertificateAuthority(Microsoft.Extensions.Options.Options.Create(options));
using var clientKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csr = new CertificateRequest("CN=test-node-45", clientKey, HashAlgorithmName.SHA256).CreateSigningRequestPem();
var issued = ca.IssueClientCertificate("test-node-45", csr);
using var issuedCert = X509Certificate2.CreateFromPem(issued.CertificatePem);
var validitySpan = issuedCert.NotAfter - issuedCert.NotBefore;
Assert.InRange(validitySpan.TotalDays, 44.9, 45.1);
}
private static void CreateCaPfx(string path) private static void CreateCaPfx(string path)
{ {
using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);

View file

@ -17,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ServerMonitorManager.Core;
using Xunit; using Xunit;
namespace ServerMonitorManager.Control.Tests; namespace ServerMonitorManager.Control.Tests;
@ -58,6 +59,29 @@ public sealed class ControlApiTests : IAsyncDisposable
"/api/v1/automation/links", TestContext.Current.CancellationToken)).StatusCode); "/api/v1/automation/links", TestContext.Current.CancellationToken)).StatusCode);
} }
[Fact]
public async Task RenewalEndpointsRejectUnauthenticatedRequests()
{
using var anonymous = _factory.CreateClient();
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var csr = new CertificateRequest("CN=node-a", key, HashAlgorithmName.SHA256).CreateSigningRequestPem();
var req = new CertificateRenewalRequest("node-a", csr, Guid.NewGuid().ToString());
var agentRenewRes = await anonymous.PostAsJsonAsync(
"/api/v1/agents/certificate/renew",
req,
SmmJsonContext.Default.CertificateRenewalRequest,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, agentRenewRes.StatusCode);
var operatorRenewRes = await anonymous.PostAsJsonAsync(
"/api/v1/control/certificates/renew",
req,
SmmJsonContext.Default.CertificateRenewalRequest,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, operatorRenewRes.StatusCode);
}
[Fact] [Fact]
public async Task InvalidEnrollmentUsesProblemDetailsResponse() public async Task InvalidEnrollmentUsesProblemDetailsResponse()
{ {