feat(deploy): token-gated internal deployment-config fetch endpoint
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the pure decision resolver behind the token-gated internal
|
||||
/// deployment-config fetch endpoint (<see cref="DeploymentConfigEndpoints.Resolve"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// This endpoint is machine-to-machine: a site presents a per-deployment fetch
|
||||
/// token in the <c>X-Deployment-Token</c> header and the token is the entire
|
||||
/// security boundary. The resolver is extracted so the security-critical
|
||||
/// decision logic is unit-testable without standing up a web host. The mapping
|
||||
/// from the resolver result to the documented HTTP status codes is:
|
||||
/// <list type="bullet">
|
||||
/// <item>unknown / superseded (null row) → 404 NotFound</item>
|
||||
/// <item>expired row → 404 NotFound (does not leak existence to a wrong token)</item>
|
||||
/// <item>missing / wrong token → 401 Unauthorized</item>
|
||||
/// <item>valid row + correct token + not expired → 200 with the staged JSON</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DeploymentConfigEndpointsTests
|
||||
{
|
||||
private const string ValidToken = "test-deployment-token-abc123";
|
||||
private const string ConfigJson = "{\"instanceId\":42,\"attributes\":[]}";
|
||||
|
||||
private static readonly DateTimeOffset Now = new(2026, 6, 26, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static PendingDeployment Row(string token = ValidToken, DateTimeOffset? expiresAtUtc = null) =>
|
||||
new(
|
||||
deploymentId: "deploy-1",
|
||||
instanceId: 42,
|
||||
revisionHash: "hash-1",
|
||||
configurationJson: ConfigJson,
|
||||
token: token,
|
||||
createdAtUtc: Now.AddMinutes(-1),
|
||||
expiresAtUtc: expiresAtUtc ?? Now.AddMinutes(5));
|
||||
|
||||
private static int? StatusOf(IResult result) => (result as IStatusCodeHttpResult)?.StatusCode;
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NullRow_ReturnsNotFound()
|
||||
{
|
||||
var result = DeploymentConfigEndpoints.Resolve(row: null, token: ValidToken, nowUtc: Now);
|
||||
|
||||
Assert.IsType<NotFound>(result);
|
||||
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ExpiredRow_WithCorrectToken_ReturnsNotFound()
|
||||
{
|
||||
// Expired exactly at now: ExpiresAtUtc <= now is the boundary that purges.
|
||||
var row = Row(expiresAtUtc: Now);
|
||||
|
||||
var result = DeploymentConfigEndpoints.Resolve(row, token: ValidToken, nowUtc: Now);
|
||||
|
||||
Assert.IsType<NotFound>(result);
|
||||
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ExpiredRowInThePast_WithCorrectToken_ReturnsNotFound()
|
||||
{
|
||||
var row = Row(expiresAtUtc: Now.AddSeconds(-1));
|
||||
|
||||
var result = DeploymentConfigEndpoints.Resolve(row, token: ValidToken, nowUtc: Now);
|
||||
|
||||
Assert.IsType<NotFound>(result);
|
||||
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_WrongToken_ReturnsUnauthorized()
|
||||
{
|
||||
var row = Row();
|
||||
|
||||
var result = DeploymentConfigEndpoints.Resolve(row, token: "wrong-token", nowUtc: Now);
|
||||
|
||||
Assert.IsType<UnauthorizedHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_EmptyToken_ReturnsUnauthorized()
|
||||
{
|
||||
var row = Row();
|
||||
|
||||
var result = DeploymentConfigEndpoints.Resolve(row, token: string.Empty, nowUtc: Now);
|
||||
|
||||
Assert.IsType<UnauthorizedHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_NullToken_ReturnsUnauthorized()
|
||||
{
|
||||
var row = Row();
|
||||
|
||||
var result = DeploymentConfigEndpoints.Resolve(row, token: null!, nowUtc: Now);
|
||||
|
||||
Assert.IsType<UnauthorizedHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ValidRow_CorrectToken_NotExpired_ReturnsConfigJson()
|
||||
{
|
||||
var row = Row();
|
||||
|
||||
var result = DeploymentConfigEndpoints.Resolve(row, token: ValidToken, nowUtc: Now);
|
||||
|
||||
var content = Assert.IsType<ContentHttpResult>(result);
|
||||
Assert.Equal(ConfigJson, content.ResponseContent);
|
||||
Assert.Equal("application/json", content.ContentType);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user