using System.Net; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Admin.Security; using ZB.MOM.WW.OtOpcUa.Admin.Services; namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; /// /// Regression coverage for Admin-003 / Admin-005. /// /// Admin-005 — the login is a static-rendered form posting to the /auth/login /// minimal-API endpoint, which performs the LDAP bind, cookie SignInAsync and /// redirect while it still owns the HTTP response (no interactive Blazor circuit). /// /// Admin-003 — the three SignalR hubs reject anonymous connections. /// /// These are HTTP-pipeline tests with a stubbed , so they /// run without LDAP or the central SQL Server. /// public sealed class AuthEndpointsTests : IClassFixture { private readonly StubbedAuthAppFactory _factory; public AuthEndpointsTests(StubbedAuthAppFactory factory) => _factory = factory; /// /// Admin app host with the LDAP service stubbed (a fixed-credential pass/fail) and the /// background poller removed so the host starts clean without DB or directory access. /// public sealed class StubbedAuthAppFactory : WebApplicationFactory { protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureServices(services => { var poller = services.SingleOrDefault(d => d.ImplementationType?.Name == "FleetStatusPoller"); if (poller is not null) services.Remove(poller); var ldap = services.SingleOrDefault(d => d.ServiceType == typeof(ILdapAuthService)); if (ldap is not null) services.Remove(ldap); services.AddScoped(); var resolver = services.SingleOrDefault(d => d.ServiceType == typeof(IAdminRoleGrantResolver)); if (resolver is not null) services.Remove(resolver); services.AddScoped(); }); return base.CreateHost(builder); } public HttpClient CreateNonRedirectingClient() => CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); } /// Stub LDAP: good/pw binds; anything else fails. private sealed class StubLdapAuthService : ILdapAuthService { public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) => Task.FromResult(username == "good" && password == "pw" ? new LdapAuthResult(true, "Good Operator", "good", ["FleetAdmins"], ["FleetAdmin"], null) : new LdapAuthResult(false, null, username, [], [], "Invalid username or password")); } /// Stub resolver: any non-empty group set yields a FleetAdmin grant. private sealed class StubRoleGrantResolver : IAdminRoleGrantResolver { public Task ResolveAsync(IReadOnlyList ldapGroups, CancellationToken cancellationToken) => Task.FromResult(ldapGroups.Count == 0 ? AdminRoleGrants.Empty : new AdminRoleGrants([AdminRoles.FleetAdmin], [])); } private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) => new(fields.Select(f => new KeyValuePair(f.Key, f.Value))); // ── Admin-005: /auth/login endpoint ───────────────────────────────────────── [Fact] public async Task Valid_login_issues_the_auth_cookie_and_redirects_home() { using var client = _factory.CreateNonRedirectingClient(); var response = await client.PostAsync("/auth/login", Form(("username", "good"), ("password", "pw"))); // The endpoint owns the response, so the Set-Cookie header is actually emitted. response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); response.Headers.Location!.OriginalString.ShouldBe("/"); response.Headers.TryGetValues("Set-Cookie", out var cookies).ShouldBeTrue( "a successful /auth/login must emit the auth cookie"); string.Join(';', cookies!).ShouldContain("OtOpcUa.Admin"); } [Fact] public async Task Invalid_login_redirects_back_to_login_with_an_error() { using var client = _factory.CreateNonRedirectingClient(); var response = await client.PostAsync("/auth/login", Form(("username", "bad"), ("password", "wrong"))); response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); response.Headers.Location!.OriginalString.ShouldContain("/login"); response.Headers.Location!.OriginalString.ShouldContain("error"); response.Headers.TryGetValues("Set-Cookie", out var cookies); (cookies is null || !string.Join(';', cookies).Contains("OtOpcUa.Admin")).ShouldBeTrue( "a failed bind must not issue the auth cookie"); } [Fact] public async Task Login_with_missing_credentials_redirects_back_to_login() { using var client = _factory.CreateNonRedirectingClient(); var response = await client.PostAsync("/auth/login", Form(("username", ""), ("password", ""))); response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); response.Headers.Location!.OriginalString.ShouldContain("/login"); } [Fact] public async Task Login_redirect_target_is_open_redirect_safe() { using var client = _factory.CreateNonRedirectingClient(); // A returnUrl pointing off-site must be ignored — the post lands at the site root. var response = await client.PostAsync("/auth/login", Form(("username", "good"), ("password", "pw"), ("returnUrl", "https://evil.example.com/"))); response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); response.Headers.Location!.OriginalString.ShouldBe("/"); } [Fact] public async Task Login_honours_a_local_return_url() { using var client = _factory.CreateNonRedirectingClient(); var response = await client.PostAsync("/auth/login", Form(("username", "good"), ("password", "pw"), ("returnUrl", "/fleet"))); response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); response.Headers.Location!.OriginalString.ShouldBe("/fleet"); } [Fact] public async Task Logout_without_antiforgery_token_is_rejected() { // Admin-006: the logout endpoint no longer calls .DisableAntiforgery(), so the // UseAntiforgery() middleware must reject a POST that carries no token with 400. // This regression guards against CSRF-logout (attacker tricking the browser into // signing the operator out by posting to /auth/logout from a foreign origin). using var client = _factory.CreateNonRedirectingClient(); var response = await client.PostAsync("/auth/logout", new FormUrlEncodedContent(Array.Empty>())); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, "/auth/logout without an antiforgery token must be rejected (Admin-006)"); } // ── Admin-003: SignalR hubs reject anonymous connections ──────────────────── [Theory] [InlineData("/hubs/fleet")] [InlineData("/hubs/alerts")] [InlineData("/hubs/script-log")] public async Task Anonymous_hub_negotiate_is_rejected(string hubPath) { using var client = _factory.CreateNonRedirectingClient(); // The SignalR negotiate handshake is a POST to /negotiate. An [Authorize]'d hub // must refuse it for an unauthenticated caller (302 to login or 401). var response = await client.PostAsync($"{hubPath}/negotiate", new FormUrlEncodedContent(Array.Empty>())); response.StatusCode.ShouldNotBe(HttpStatusCode.OK, $"anonymous negotiate of {hubPath} must not succeed — the hub is [Authorize]-gated"); response.StatusCode.ShouldBeOneOf( HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.Redirect, HttpStatusCode.Found); } }