fix(central-ui): resolve CentralUI-002/003/004 — site-scope enforcement, per-circuit console capture, cached auth state
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user