using System.Net; using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.IntegrationTests; /// /// Auth flow integration tests. /// Tests that require a running LDAP server are marked with Integration trait. /// public class AuthFlowTests : IClassFixture { private readonly ScadaBridgeWebApplicationFactory _factory; /// Initializes the test with the shared web application factory. /// The web application factory hosting the app under test. public AuthFlowTests(ScadaBridgeWebApplicationFactory factory) { _factory = factory; } /// Verifies that the login endpoint with empty credentials redirects to the login page with an error. /// A task that represents the asynchronous operation. [Fact] public async Task LoginEndpoint_WithEmptyCredentials_RedirectsToLoginWithError() { var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var content = await CreateLoginFormAsync(client, "", ""); var response = await client.PostAsync("/auth/login", content); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); var location = response.Headers.Location?.ToString() ?? ""; Assert.Contains("/login", location); Assert.Contains("error", location, StringComparison.OrdinalIgnoreCase); } /// Verifies that the logout endpoint clears the cookie and redirects. /// A task that represents the asynchronous operation. [Fact] public async Task LogoutEndpoint_ClearsCookieAndRedirects() { var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); var response = await client.PostAsync("/auth/logout", null); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); var location = response.Headers.Location?.ToString() ?? ""; Assert.Contains("/login", location); } /// Verifies that the JWT token service round-trips a generated token through validation. [Fact] public void JwtTokenService_GenerateAndValidate_RoundTrips() { using var scope = _factory.Services.CreateScope(); var jwtService = scope.ServiceProvider.GetRequiredService(); var token = jwtService.GenerateToken( displayName: "Test User", username: "testuser", roles: new[] { "Administrator", "Designer" }, permittedSiteIds: null); Assert.NotNull(token); var principal = jwtService.ValidateToken(token); Assert.NotNull(principal); var displayName = principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value; var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value; var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); Assert.Equal("Test User", displayName); Assert.Equal("testuser", username); Assert.Contains("Administrator", roles); Assert.Contains("Designer", roles); } /// Verifies that tokens generated with site scopes include site id claims. [Fact] public void JwtTokenService_WithSiteScopes_IncludesSiteIdClaims() { using var scope = _factory.Services.CreateScope(); var jwtService = scope.ServiceProvider.GetRequiredService(); var token = jwtService.GenerateToken( displayName: "Deployer", username: "deployer1", roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "3" }); var principal = jwtService.ValidateToken(token); Assert.NotNull(principal); var siteIds = principal!.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList(); Assert.Contains("1", siteIds); Assert.Contains("3", siteIds); } /// Verifies that the login endpoint with valid LDAP credentials sets the cookie and redirects. /// A task that represents the asynchronous operation. [Trait("Category", "Integration")] [Fact] public async Task LoginEndpoint_WithValidLdapCredentials_SetsCookieAndRedirects() { // Requires a local GLAuth on localhost:3893 — the redundant test pair: // cd ~/Desktop/scadaproj/infra/glauth-redundant && docker compose up -d // (baseDN dc=zb,dc=local, user passwords "password"; the old infra/docker-compose.yml // glauth service no longer exists — LDAP moved out of that stack.) if (!await IsLdapAvailableAsync()) { // Skip gracefully if GLAuth not running — not a test failure return; } var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var content = await CreateLoginFormAsync(client, "admin", "password"); var response = await client.PostAsync("/auth/login", content); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); var location = response.Headers.Location?.ToString() ?? ""; Assert.Equal("/", location); // Verify auth cookie was set var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault(); Assert.NotNull(setCookieHeader); Assert.Contains("ZB.MOM.WW.ScadaBridge.Auth", setCookieHeader); } /// Verifies that the login endpoint without an antiforgery token is rejected without an auth cookie. /// A task that represents the asynchronous operation. [Fact] public async Task LoginEndpoint_WithoutAntiforgeryToken_RejectsWithoutAuthCookie() { using var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using var content = new FormUrlEncodedContent(new Dictionary { ["username"] = "admin", ["password"] = "password" }); using var response = await client.PostAsync("/auth/login", content); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); if (response.Headers.TryGetValues("Set-Cookie", out var cookies)) Assert.DoesNotContain(cookies, cookie => cookie.StartsWith(SecurityOptions.DefaultCookieName + "=", StringComparison.Ordinal)); } private static async Task CreateLoginFormAsync(HttpClient client, string username, string password) { // Fetch the actual rendered form. WebApplicationFactory's cookie handler retains // its antiforgery cookie, so the POST exercises the same pair as a browser login. using var page = await client.GetAsync("/login"); Assert.Equal(HttpStatusCode.OK, page.StatusCode); var html = await page.Content.ReadAsStringAsync(); var input = Regex.Match(html, """]*name="__RequestVerificationToken"[^>]*>""", RegexOptions.IgnoreCase); Assert.True(input.Success, "The login form must render an antiforgery input."); var value = Regex.Match(input.Value, "\\bvalue=\"([^\"]+)\"", RegexOptions.IgnoreCase); Assert.True(value.Success, "The login form must provide a nonempty antiforgery token."); return new FormUrlEncodedContent(new Dictionary { ["username"] = username, ["password"] = password, ["__RequestVerificationToken"] = WebUtility.HtmlDecode(value.Groups[1].Value) }); } private static async Task IsLdapAvailableAsync() { try { using var tcp = new System.Net.Sockets.TcpClient(); await tcp.ConnectAsync("localhost", 3893).WaitAsync(TimeSpan.FromSeconds(2)); return true; } catch { return false; } } }