diff --git a/TEST_EVIDENCE.md b/TEST_EVIDENCE.md deleted file mode 100644 index 2c7f4cf..0000000 --- a/TEST_EVIDENCE.md +++ /dev/null @@ -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.
$(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. diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index d8c4f39..94b18de 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -410,39 +410,6 @@ agents.MapPost("/certificate/renew", async ( 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 - { - ["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 ( HttpContext context, ControlStore controlStore, @@ -606,6 +573,35 @@ agents.MapPost("/provisioning/jobs/{id}/execution-grant", async ( }); 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 + { + ["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) => Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray())); control.MapGet("/provisioning/catalogs/system-base-install/1", () => diff --git a/src/ServerMonitorManager.Desktop/ControlClientService.cs b/src/ServerMonitorManager.Desktop/ControlClientService.cs index 9bd98bd..3a05533 100644 --- a/src/ServerMonitorManager.Desktop/ControlClientService.cs +++ b/src/ServerMonitorManager.Desktop/ControlClientService.cs @@ -318,7 +318,7 @@ public sealed partial class ControlClientService var csrRequest = new CertificateRequest($"CN={deviceId}", key, HashAlgorithmName.SHA256); var req = new CertificateRenewalRequest(deviceId, csrRequest.CreateSigningRequestPem(), Guid.NewGuid().ToString()); 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) { var renewal = await resp.Content.ReadFromJsonAsync(SmmJsonContext.Default.CertificateRenewalResponse, cancellationToken); diff --git a/tests/ServerMonitorManager.Control.Tests/CertificateLifecycleTests.cs b/tests/ServerMonitorManager.Control.Tests/CertificateLifecycleTests.cs index e037026..add0e6a 100644 --- a/tests/ServerMonitorManager.Control.Tests/CertificateLifecycleTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/CertificateLifecycleTests.cs @@ -240,6 +240,24 @@ public sealed class CertificateLifecycleTests : IDisposable 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) { using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); diff --git a/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs b/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs index defbeb8..20ce5da 100644 --- a/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ServerMonitorManager.Core; using Xunit; namespace ServerMonitorManager.Control.Tests; @@ -58,6 +59,29 @@ public sealed class ControlApiTests : IAsyncDisposable "/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] public async Task InvalidEnrollmentUsesProblemDetailsResponse() {