fix(central-ui): resolve CentralUI-002/003/004 — site-scope enforcement, per-circuit console capture, cached auth state

This commit is contained in:
Joseph Doherty
2026-05-16 19:33:09 -04:00
parent 5a08b04535
commit 87f14c190a
17 changed files with 693 additions and 40 deletions

View File

@@ -0,0 +1,79 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using ScadaLink.CentralUI.Auth;
namespace ScadaLink.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-004. The provider used to read
/// <see cref="IHttpContextAccessor.HttpContext"/> on every call; once the Blazor
/// circuit is established that context is gone, so later re-evaluations saw an
/// unauthenticated principal. The provider must snapshot the principal once at
/// construction (during the initial HTTP request) and serve it for the circuit.
/// </summary>
public class CookieAuthenticationStateProviderTests
{
private static ClaimsPrincipal AuthenticatedUser(string name)
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, name) },
authenticationType: "TestCookie");
return new ClaimsPrincipal(identity);
}
[Fact]
public async Task GetAuthenticationStateAsync_ReturnsAuthenticatedUser_WhenHttpContextPresent()
{
var accessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("alice") }
};
var provider = new CookieAuthenticationStateProvider(accessor);
var state = await provider.GetAuthenticationStateAsync();
Assert.True(state.User.Identity?.IsAuthenticated);
Assert.Equal("alice", state.User.Identity?.Name);
}
[Fact]
public async Task GetAuthenticationStateAsync_StillReturnsUser_AfterHttpContextIsGone()
{
// The circuit is built during the HTTP request: HttpContext is valid then.
var accessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("bob") }
};
var provider = new CookieAuthenticationStateProvider(accessor);
// After the request completes, IHttpContextAccessor.HttpContext is null for
// the life of the long-lived SignalR circuit.
accessor.HttpContext = null;
var state = await provider.GetAuthenticationStateAsync();
// The pre-fix implementation returned an anonymous principal here.
Assert.True(state.User.Identity?.IsAuthenticated);
Assert.Equal("bob", state.User.Identity?.Name);
}
[Fact]
public async Task GetAuthenticationStateAsync_IsStableAcrossCalls_IgnoringStaleForeignContext()
{
var accessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext { User = AuthenticatedUser("carol") }
};
var provider = new CookieAuthenticationStateProvider(accessor);
// A stale/foreign context leaking through the AsyncLocal accessor must NOT
// change what this circuit's provider reports.
accessor.HttpContext = new DefaultHttpContext { User = AuthenticatedUser("intruder") };
var first = await provider.GetAuthenticationStateAsync();
var second = await provider.GetAuthenticationStateAsync();
Assert.Equal("carol", first.User.Identity?.Name);
Assert.Equal("carol", second.User.Identity?.Name);
}
}

View File

@@ -0,0 +1,93 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using ScadaLink.CentralUI.Auth;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Security;
namespace ScadaLink.CentralUI.Tests.Auth;
/// <summary>
/// Regression tests for CentralUI-002. Site-scoped Deployment permissions are
/// written as <c>SiteId</c> claims at login but were never read — Deployment
/// pages listed and acted on every site. <see cref="SiteScopeService"/> is the
/// shared helper that reads those claims; these tests pin its behaviour.
/// </summary>
public class SiteScopeServiceTests
{
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public StubAuthStateProvider(ClaimsPrincipal user) => _user = user;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
}
private static SiteScopeService ForUser(params Claim[] claims)
{
var identity = new ClaimsIdentity(claims, authenticationType: "TestCookie");
return new SiteScopeService(new StubAuthStateProvider(new ClaimsPrincipal(identity)));
}
private static Claim Role(string role) => new(JwtTokenService.RoleClaimType, role);
private static Claim SiteClaim(int id) => new(JwtTokenService.SiteIdClaimType, id.ToString());
private static List<Site> Sites(params int[] ids)
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
[Fact]
public async Task DeploymentUserWithNoSiteClaims_IsSystemWide()
{
var svc = ForUser(Role("Deployment"));
Assert.True(await svc.IsSystemWideAsync());
Assert.Empty(await svc.PermittedSiteIdsAsync());
}
[Fact]
public async Task SystemWideUser_FilterSites_ReturnsAllSites()
{
var svc = ForUser(Role("Deployment"));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3));
Assert.Equal(new[] { 1, 2, 3 }, filtered.Select(s => s.Id));
}
[Fact]
public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites()
{
// Regression: a Deployment user scoped to sites 1 and 3 must NOT see site 2.
var svc = ForUser(Role("Deployment"), SiteClaim(1), SiteClaim(3));
var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3, 4));
Assert.Equal(new[] { 1, 3 }, filtered.Select(s => s.Id).OrderBy(x => x));
}
[Fact]
public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites()
{
var svc = ForUser(Role("Deployment"), SiteClaim(5));
Assert.True(await svc.IsSiteAllowedAsync(5));
Assert.False(await svc.IsSiteAllowedAsync(6));
}
[Fact]
public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds()
{
var svc = ForUser(Role("Deployment"), SiteClaim(7), SiteClaim(9));
Assert.False(await svc.IsSystemWideAsync());
Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x));
}
[Fact]
public async Task SystemWideUser_IsSiteAllowed_ForAnySite()
{
var svc = ForUser(Role("Deployment"));
Assert.True(await svc.IsSiteAllowedAsync(1));
Assert.True(await svc.IsSiteAllowedAsync(999));
}
}

View File

@@ -465,4 +465,89 @@ public class ScriptAnalysisServiceTests
Assert.True(result.Success);
Assert.Equal("42", result.ReturnValueJson);
}
[Fact]
public async Task RunInSandbox_CapturesConsoleOutput()
{
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"System.Console.WriteLine(\"hello-sandbox\"); return 1;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
Assert.Contains("hello-sandbox", result.ConsoleOutput);
}
[Fact]
public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput()
{
// Regression test for CentralUI-003. RunInSandboxAsync used to redirect the
// process-global Console.Out/Error to a per-call StringWriter. While one run
// is mid-flight, any concurrent run's `finally` restores Console.Out to the
// ORIGINAL writer — so the long run loses every Console.WriteLine it makes
// after that point, and short runs cross-contaminate each other. The fix
// routes capture per-call via an AsyncLocal writer without mutating
// process-global Console state.
// A long-running script: writes its tag, then burns CPU, then writes again,
// repeatedly. While it spins, many short runs start and finish around it.
async Task<string> RunLong()
{
var code = @"
for (int i = 0; i < 40; i++)
{
System.Console.WriteLine(""LONG"");
long acc = 0;
for (long j = 0; j < 2_000_000; j++) acc += j;
System.Console.WriteLine(""LONG"" + acc);
}
return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
async Task<string> RunShort(int id)
{
var code = $"for (int i = 0; i < 30; i++) System.Console.WriteLine(\"S{id}\"); return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
var longTask = RunLong();
var shortTasks = new List<Task<string>>();
for (var round = 0; round < 12; round++)
{
for (var k = 0; k < 4; k++)
shortTasks.Add(RunShort(round * 4 + k));
await Task.Yield();
}
var longOut = await longTask;
var shortOuts = await Task.WhenAll(shortTasks);
// The long run must have captured ALL 80 of its own writes (40 plain + 40 acc).
var longLines = longOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Count(l => l.StartsWith("LONG"));
Assert.Equal(80, longLines);
// No short run's output must have leaked into the long run's capture.
for (var i = 0; i < shortOuts.Length; i++)
Assert.DoesNotContain($"S{i}", longOut);
// Each short run captured exactly its own 30 lines and nothing else.
for (var i = 0; i < shortOuts.Length; i++)
{
var lines = shortOuts[i].Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(30, lines.Length);
Assert.All(lines, l => Assert.Equal($"S{i}", l.Trim()));
}
}
}

View File

@@ -65,6 +65,10 @@ public class TopologyPageTests : BunitContext
// DI registration still has to satisfy the [Inject].
Services.AddScoped<IDialogService, DialogService>();
// Site scoping (CentralUI-002): Topology injects SiteScopeService to
// filter the tree by the user's permitted sites.
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
// TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
@@ -194,6 +198,52 @@ public class TopologyPageTests : BunitContext
Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
}
[Fact]
public void SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites()
{
// Regression test for CentralUI-002. The SiteId claims issued at login were
// never read, so a Deployment user scoped to one site could view (and act
// on) every site's topology. Topology now filters the tree by the user's
// permitted sites via SiteScopeService.
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("Username", "scoped-tester"),
new Claim(ScadaLink.Security.JwtTokenService.RoleClaimType, "Deployment"),
// Permitted on site 1 only.
new Claim(ScadaLink.Security.JwtTokenService.SiteIdClaimType, "1"),
}, "TestAuth"));
// Last AuthenticationStateProvider registration wins on resolution.
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
SeedRepos(sites: new[]
{
new Site("Plant-A", "plant-a") { Id = 1 },
new Site("Plant-B", "plant-b") { Id = 2 },
});
var cut = Render<TopologyPage>();
// The permitted site is rendered; the non-permitted site is not.
Assert.Contains("Plant-A", cut.Markup);
Assert.DoesNotContain("Plant-B", cut.Markup);
}
[Fact]
public void SiteScoping_SystemWideDeploymentUser_SeesAllSites()
{
// A Deployment user with no SiteId claims is system-wide and sees every site.
SeedRepos(sites: new[]
{
new Site("Plant-A", "plant-a") { Id = 1 },
new Site("Plant-B", "plant-b") { Id = 2 },
});
var cut = Render<TopologyPage>();
Assert.Contains("Plant-A", cut.Markup);
Assert.Contains("Plant-B", cut.Markup);
}
[Fact]
public void DoubleClick_OnAreaLabel_EntersRenameMode()
{