ce9679b671
The deploy API's 202 pointed at a route that did not exist. The GET reads the deployment (projected, no artifact blob) and its per-node apply states, and carries the POST's own gate: API key in X-Api-Key, AllowAnonymous against the cookie fallback, 503 while Security:DeployApiKey is unset. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
117 lines
5.3 KiB
C#
117 lines
5.3 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
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;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Api;
|
|
|
|
/// <summary>
|
|
/// R04 S5: the POST's 202 <c>Location</c> points at <c>GET /api/deployments/{id}</c>, which now exists,
|
|
/// carries the same API-key gate as the POST, and reports the deployment plus its per-node states.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DeploymentStatusEndpointTests
|
|
{
|
|
private static OtOpcUaConfigDbContext NewDb(string name) =>
|
|
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
|
|
|
[Fact]
|
|
public async Task GetDeploymentStatusAsync_returns_the_deployment_and_its_node_states()
|
|
{
|
|
var name = Guid.NewGuid().ToString("N");
|
|
var id = Guid.NewGuid();
|
|
await using (var seed = NewDb(name))
|
|
{
|
|
seed.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id, RevisionHash = "rev1", CreatedBy = "ci", Status = DeploymentStatus.Dispatching,
|
|
ArtifactBlob = new byte[1024],
|
|
});
|
|
seed.Deployments.Add(new Deployment { DeploymentId = Guid.NewGuid(), RevisionHash = "other", CreatedBy = "x" });
|
|
seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-b", DeploymentId = id, Status = NodeDeploymentStatus.Applying });
|
|
seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-a", DeploymentId = id, Status = NodeDeploymentStatus.Applied, AppliedAtUtc = DateTime.UtcNow });
|
|
await seed.SaveChangesAsync();
|
|
}
|
|
|
|
await using var db = NewDb(name);
|
|
var status = await DeployApiEndpoints.GetDeploymentStatusAsync(db, id);
|
|
|
|
status.ShouldNotBeNull();
|
|
status!.DeploymentId.ShouldBe(id);
|
|
status.RevisionHash.ShouldBe("rev1");
|
|
status.Status.ShouldBe(nameof(DeploymentStatus.Dispatching));
|
|
status.Nodes.Select(n => (n.NodeId, n.Status)).ShouldBe(
|
|
[("node-a", nameof(NodeDeploymentStatus.Applied)), ("node-b", nameof(NodeDeploymentStatus.Applying))]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDeploymentStatusAsync_returns_null_for_an_unknown_id()
|
|
{
|
|
await using var db = NewDb(Guid.NewGuid().ToString("N"));
|
|
(await DeployApiEndpoints.GetDeploymentStatusAsync(db, Guid.NewGuid())).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Get_route_exists_and_is_gated_like_the_post()
|
|
{
|
|
var endpoints = MapDeployApi();
|
|
|
|
var get = endpoints.Single(e => NameOf(e) == "GetDeployment");
|
|
get.RoutePattern.RawText.ShouldBe("/api/deployments/{deploymentId:guid}");
|
|
get.Metadata.GetMetadata<IHttpMethodMetadata>()!.HttpMethods.ShouldBe(["GET"]);
|
|
|
|
var post = endpoints.Single(e => NameOf(e) == "StartDeployment");
|
|
|
|
// Both are API-key gated in the handler: AllowAnonymous so the cookie fallback policy does not 401
|
|
// the automation client, and no cookie policy of their own.
|
|
foreach (var e in new[] { get, post })
|
|
{
|
|
e.Metadata.GetMetadata<IAllowAnonymous>().ShouldNotBeNull(NameOf(e));
|
|
e.Metadata.GetOrderedMetadata<IAuthorizeData>().Where(a => !string.IsNullOrEmpty(a.Policy)).ShouldBeEmpty(NameOf(e));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Accepted_location_matches_the_get_route()
|
|
{
|
|
var id = Guid.NewGuid();
|
|
var result = DeployApiEndpoints.ToResult(new StartDeploymentResult(
|
|
StartDeploymentOutcome.Accepted, new DeploymentId(id), new RevisionHash("rev"), null, CorrelationId.NewId()));
|
|
|
|
var accepted = result.ShouldBeOfType<Microsoft.AspNetCore.Http.HttpResults.Accepted<DeployApiEndpoints.DeployResponse>>();
|
|
accepted.Location.ShouldBe($"/api/deployments/{id}");
|
|
Guid.TryParse(accepted.Location!["/api/deployments/".Length..], out _).ShouldBeTrue();
|
|
}
|
|
|
|
private static string? NameOf(Endpoint e) => e.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName;
|
|
|
|
private static IReadOnlyList<RouteEndpoint> MapDeployApi()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
// Registered only so the handlers' service parameters are inferred as services; the metadata
|
|
// scan below never invokes a handler, so nothing is resolved.
|
|
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Commons.Interfaces.IAdminOperationsClient>(_ => null!);
|
|
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("deploy-api-metadata"));
|
|
var app = builder.Build();
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { [DeployApiEndpoints.ConfigKey] = "k" })
|
|
.Build();
|
|
app.MapOtOpcUaDeployApi(config);
|
|
return ((IEndpointRouteBuilder)app).DataSources
|
|
.SelectMany(d => d.Endpoints)
|
|
.OfType<RouteEndpoint>()
|
|
.ToList();
|
|
}
|
|
}
|