feat(admin): headless POST /api/deployments REST endpoint (API-key gated)
v2-ci / build (push) Failing after 50s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

A thin gateway over the admin-operations cluster singleton so CI/scripts can trigger a
deployment without the Blazor button. Forwards to the same IAdminOperationsClient.
StartDeploymentAsync; mounted on admin-role nodes. Auth is a fixed-time X-Api-Key check
against Security:DeployApiKey (orthogonal to the cookie-only web auth); AllowAnonymous so the
auth fallback doesn't 401 it, self-disabling (503) until the key is set. Outcome->status:
202/200/409/422. Unit tests for the key check + outcome mapping; HTTP E2E (real auth + real
deploy via the 2-node harness). Documented in docs/security.md.
This commit is contained in:
Joseph Doherty
2026-06-06 15:54:51 -04:00
parent a5d857d5b2
commit ad7f9e731f
6 changed files with 253 additions and 0 deletions
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Http;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Api;
/// <summary>Unit tests for the pure helpers behind the headless deploy REST endpoint —
/// <see cref="DeployApiEndpoints.IsAuthorized"/> (fixed-time API-key check) and
/// <see cref="DeployApiEndpoints.ToResult"/> (outcome → HTTP status mapping).</summary>
public sealed class DeployApiEndpointsTests
{
// ── IsAuthorized ────────────────────────────────────────────────────────────
/// <summary>Verifies the exact configured key authorizes.</summary>
[Fact]
public void IsAuthorized_true_for_matching_key()
=> DeployApiEndpoints.IsAuthorized("s3cret", "s3cret").ShouldBeTrue();
/// <summary>Verifies a wrong key is rejected.</summary>
[Fact]
public void IsAuthorized_false_for_wrong_key()
=> DeployApiEndpoints.IsAuthorized("nope", "s3cret").ShouldBeFalse();
/// <summary>Verifies a missing/empty provided key is rejected.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
public void IsAuthorized_false_for_missing_provided_key(string? provided)
=> DeployApiEndpoints.IsAuthorized(provided, "s3cret").ShouldBeFalse();
/// <summary>Verifies that when no key is configured nothing authorizes (endpoint stays closed).</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
public void IsAuthorized_false_when_no_key_configured(string? configured)
=> DeployApiEndpoints.IsAuthorized("anything", configured).ShouldBeFalse();
// ── ToResult ────────────────────────────────────────────────────────────────
/// <summary>Verifies each deployment outcome maps to the right HTTP status.</summary>
[Theory]
[InlineData(StartDeploymentOutcome.Accepted, StatusCodes.Status202Accepted)]
[InlineData(StartDeploymentOutcome.NoChanges, StatusCodes.Status200OK)]
[InlineData(StartDeploymentOutcome.AnotherDeploymentInFlight, StatusCodes.Status409Conflict)]
[InlineData(StartDeploymentOutcome.Rejected, StatusCodes.Status422UnprocessableEntity)]
public void ToResult_maps_outcome_to_status(StartDeploymentOutcome outcome, int expectedStatus)
{
var result = DeployApiEndpoints.ToResult(
new StartDeploymentResult(outcome, DeploymentId: null, RevisionHash: null,
Message: "x", CorrelationId: CorrelationId.NewId()));
result.ShouldBeAssignableTo<IStatusCodeHttpResult>();
((IStatusCodeHttpResult)result).StatusCode.ShouldBe(expectedStatus);
}
}
@@ -0,0 +1,51 @@
using System.Net;
using System.Net.Http.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// End-to-end test of the headless deploy REST endpoint over real HTTP against an in-process admin
/// node: an unauthenticated/wrong-key POST is rejected, and a correctly-keyed POST triggers a real
/// deployment through the same AdminOperations singleton the AdminUI button uses.
/// </summary>
public sealed class DeployApiE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>Verifies the deploy endpoint enforces the API key and accepts a correctly-keyed call.</summary>
[Fact]
public async Task Deploy_endpoint_enforces_api_key_and_triggers_a_deployment()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri(harness.NodeABaseAddress) };
// No key → 401
var noKey = await http.PostAsJsonAsync("/api/deployments", new { createdBy = "ci" }, Ct);
noKey.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
// Wrong key → 401
(await SendWithKeyAsync(http, "wrong-key")).StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
// Correct key → 202 Accepted (an empty config still composes + seals an empty deployment)
var accepted = await SendWithKeyAsync(http, TwoNodeClusterHarness.HarnessDeployApiKey);
accepted.StatusCode.ShouldBe(HttpStatusCode.Accepted);
var payload = await accepted.Content.ReadFromJsonAsync<DeployApiEndpoints.DeployResponse>(Ct);
payload.ShouldNotBeNull();
payload!.Outcome.ShouldBe("Accepted");
payload.DeploymentId.ShouldNotBeNull();
}
private static Task<HttpResponseMessage> SendWithKeyAsync(HttpClient http, string key)
{
var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments")
{
Content = JsonContent.Create(new { createdBy = "ci-bot" }),
};
req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, key);
return http.SendAsync(req, Ct);
}
}
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.OtOpcUa.AdminUI;
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Cluster;
@@ -48,9 +49,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
public sealed class TwoNodeClusterHarness : IAsyncDisposable
{
public const string TestRoles = "admin,driver";
/// <summary>The deploy-API key both harness nodes are configured with (Security:DeployApiKey).</summary>
public const string HarnessDeployApiKey = "test-deploy-key";
/// <summary>Gets the shared database name for both cluster nodes.</summary>
public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}";
/// <summary>Resolves node A's bound HTTP base address (Kestrel binds an ephemeral port), for
/// driving the REST surface over real HTTP in tests.</summary>
public string NodeABaseAddress => ResolveBaseAddress(NodeA);
private static string ResolveBaseAddress(WebApplication app)
{
var feature = app.Services.GetRequiredService<Microsoft.AspNetCore.Hosting.Server.IServer>()
.Features.Get<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>();
return feature?.Addresses.FirstOrDefault()
?? throw new InvalidOperationException("Node A has no bound HTTP address.");
}
/// <summary>Gets the harness mode configuration from environment variables.</summary>
public HarnessMode Mode { get; } = HarnessMode.FromEnvironment();
private string? _sqlDbName;
@@ -173,6 +188,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
["Cluster:Roles:1"] = "driver",
["Security:Jwt:SigningKey"] = "two-node-harness-test-signing-key-with-enough-bytes-for-hs256",
["Security:Jwt:Issuer"] = "otopcua-test",
["Security:DeployApiKey"] = HarnessDeployApiKey,
["Security:Jwt:Audience"] = "otopcua-test",
};
@@ -227,6 +243,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
app.MapOtOpcUaAuth();
app.MapOtOpcUaHubs();
app.MapOtOpcUaHealth();
app.MapOtOpcUaDeployApi(app.Configuration);
await app.StartAsync();
return app;