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:
+220
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user