fix(centralui): bound script-analysis payload/concurrency; bound Test Run needs Deployer, site scope and audit row (review 2026-10-21 07/P-4, 07/C-2)

This commit is contained in:
Joseph Doherty
2026-10-22 08:44:26 -04:00
parent 6644cf917d
commit 5c8f6ff7aa
4 changed files with 465 additions and 1 deletions
@@ -0,0 +1,220 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.ScriptAnalysis;
/// <summary>
/// Review 2026-10-21 07/P-4 (payload + concurrency bounds) and 07/C-2 (bound Test Run needs the
/// Deployer role, site scope and an operator-attributed audit row).
/// </summary>
public class ScriptAnalysisEndpointGuardTests
{
private sealed class StubService : ScriptAnalysisService
{
public StubService(IServiceProvider sp)
: base(Substitute.For<ISharedScriptCatalog>(), new MemoryCache(new MemoryCacheOptions { SizeLimit = 200 }), sp) { }
public int Runs;
public override Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
Interlocked.Increment(ref Runs);
return Task.FromResult(new SandboxRunResult(true, null, null, "", null, SandboxErrorKind.None, 7, null));
}
}
private sealed class HeaderAuth(
IOptionsMonitor<AuthenticationSchemeOptions> o, ILoggerFactory l, UrlEncoder e)
: AuthenticationHandler<AuthenticationSchemeOptions>(o, l, e)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, Request.Headers["X-User"].ToString()) };
foreach (var r in Request.Headers["X-Roles"].ToString().Split(',', StringSplitOptions.RemoveEmptyEntries))
claims.Add(new Claim(JwtTokenService.RoleClaimType, r));
foreach (var s in Request.Headers["X-Sites"].ToString().Split(',', StringSplitOptions.RemoveEmptyEntries))
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, s));
var id = new ClaimsIdentity(claims, "Hdr");
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(id), "Hdr")));
}
}
private static async Task<(HttpClient Client, IHost Host, StubService Svc, ICentralAuditWriter Audit)> BuildAsync()
{
var audit = Substitute.For<ICentralAuditWriter>();
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync("Pump01", Arg.Any<CancellationToken>()).Returns("site-a");
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>
{
new("Site A", "site-a") { Id = 1 },
new("Site B", "site-b") { Id = 2 },
});
StubService? svc = null;
var host = await new HostBuilder().ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(s =>
{
s.AddRouting();
s.AddLogging();
s.AddAuthentication("Hdr").AddScheme<AuthenticationSchemeOptions, HeaderAuth>("Hdr", _ => { });
s.AddScadaBridgeAuthorization();
s.AddSingleton(audit);
s.AddSingleton(locator);
s.AddSingleton(siteRepo);
s.AddSingleton<ScriptAnalysisService>(sp => svc = new StubService(sp));
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(e => e.MapScriptAnalysisEndpoints());
});
}).StartAsync();
var client = host.GetTestClient();
_ = host.Services.GetRequiredService<ScriptAnalysisService>();
return (client, host, svc!, audit);
}
private static HttpRequestMessage Post(string url, object body, string user, string roles, string sites = "")
{
var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
req.Headers.Add("X-User", user);
req.Headers.Add("X-Roles", roles);
req.Headers.Add("X-Sites", sites);
return req;
}
/// <summary>Script text over 256 KB is refused with 413 before any compilation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OversizedCode_Returns413()
{
var (client, host, _, _) = await BuildAsync();
using var _h = host;
var big = new string('x', ScriptAnalysisRequestGuard.MaxCodeChars + 1);
var resp = await client.SendAsync(Post("/api/script-analysis/format", new FormatRequest(big), "alice", "Designer"));
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, resp.StatusCode);
}
/// <summary>A caller over the per-user concurrency ceiling gets 429; others are unaffected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OverConcurrencyCeiling_Returns429_ForThatUserOnly()
{
var (client, host, _, _) = await BuildAsync();
using var _h = host;
var leases = Enumerable.Range(0, ScriptAnalysisRequestGuard.MaxConcurrentPerUser)
.Select(_ => ScriptAnalysisRequestGuard.Shared.TryEnter("busy-user")).ToList();
try
{
var busy = await client.SendAsync(Post("/api/script-analysis/format", new FormatRequest("var a = 1;"), "busy-user", "Designer"));
var other = await client.SendAsync(Post("/api/script-analysis/format", new FormatRequest("var a = 1;"), "other-user", "Designer"));
Assert.Equal(HttpStatusCode.TooManyRequests, busy.StatusCode);
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
}
finally { foreach (var l in leases) l?.Dispose(); }
}
/// <summary>The guard's per-user map holds no entry once its requests finish.</summary>
[Fact]
public void Guard_ReleasesLeases_AndEnforcesCeiling()
{
var guard = new ScriptAnalysisRequestGuard(2);
var a = guard.TryEnter("u");
var b = guard.TryEnter("u");
Assert.NotNull(a); Assert.NotNull(b);
Assert.Null(guard.TryEnter("u"));
a!.Dispose(); a.Dispose(); // double dispose must not release twice
Assert.NotNull(guard.TryEnter("u"));
}
/// <summary>A Designer without Deployer cannot run a bound Test Run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BoundRun_WithoutDeployerRole_Is403_AndNotExecuted()
{
var (client, host, svc, audit) = await BuildAsync();
using var _h = host;
var body = new SandboxRunRequest("return 1;", null, null, ScriptKind.Template, "Pump01");
var resp = await client.SendAsync(Post("/api/script-analysis/run", body, "alice", "Designer"));
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
Assert.Equal(0, svc.Runs);
await audit.DidNotReceive().WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
}
/// <summary>An unbound Test Run stays available to a Designer and writes no operator row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnboundRun_ByDesigner_StillWorks()
{
var (client, host, svc, audit) = await BuildAsync();
using var _h = host;
var body = new SandboxRunRequest("return 1;", null, null, ScriptKind.Template, null);
var resp = await client.SendAsync(Post("/api/script-analysis/run", body, "alice", "Designer"));
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal(1, svc.Runs);
await audit.DidNotReceive().WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
}
/// <summary>A site-scoped Deployer is refused for an instance at another site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BoundRun_AtSiteOutsideScope_Is403()
{
var (client, host, svc, _) = await BuildAsync();
using var _h = host;
var body = new SandboxRunRequest("return 1;", null, null, ScriptKind.Template, "Pump01");
// Pump01 lives at site-a (Id 1); this operator is scoped to site Id 2.
var resp = await client.SendAsync(Post("/api/script-analysis/run", body, "dana", "Designer,Deployer", "2"));
Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode);
Assert.Equal(0, svc.Runs);
}
/// <summary>A permitted Deployer's bound run executes and writes one operator-attributed audit row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BoundRun_ByPermittedDeployer_RunsAndAuditsTheOperator()
{
var (client, host, svc, audit) = await BuildAsync();
using var _h = host;
var body = new SandboxRunRequest("return 1;", null, null, ScriptKind.Template, "Pump01");
var resp = await client.SendAsync(Post("/api/script-analysis/run", body, "dana", "Designer,Deployer", "1"));
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
Assert.Equal(1, svc.Runs);
await audit.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e => e.Actor == "dana" && e.Target == "Pump01"), Arg.Any<CancellationToken>());
}
}