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; /// /// 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). /// public class ScriptAnalysisEndpointGuardTests { private sealed class StubService : ScriptAnalysisService { public StubService(IServiceProvider sp) : base(Substitute.For(), new MemoryCache(new MemoryCacheOptions { SizeLimit = 200 }), sp) { } public int Runs; public override Task 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 o, ILoggerFactory l, UrlEncoder e) : AuthenticationHandler(o, l, e) { protected override Task HandleAuthenticateAsync() { var claims = new List { 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(); var locator = Substitute.For(); locator.GetSiteIdForInstanceAsync("Pump01", Arg.Any()).Returns("site-a"); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List { 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("Hdr", _ => { }); s.AddScadaBridgeAuthorization(); s.AddSingleton(audit); s.AddSingleton(locator); s.AddSingleton(siteRepo); s.AddSingleton(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(); 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; } /// Script text over 256 KB is refused with 413 before any compilation. /// A task that represents the asynchronous operation. [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); } /// A caller over the per-user concurrency ceiling gets 429; others are unaffected. /// A task that represents the asynchronous operation. [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(); } } /// The guard's per-user map holds no entry once its requests finish. [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")); } /// A Designer without Deployer cannot run a bound Test Run. /// A task that represents the asynchronous operation. [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(), Arg.Any()); } /// An unbound Test Run stays available to a Designer and writes no operator row. /// A task that represents the asynchronous operation. [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(), Arg.Any()); } /// A site-scoped Deployer is refused for an instance at another site. /// A task that represents the asynchronous operation. [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); } /// A permitted Deployer's bound run executes and writes one operator-attributed audit row. /// A task that represents the asynchronous operation. [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(e => e.Actor == "dana" && e.Target == "Pump01"), Arg.Any()); } }