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;
///
/// WP-22: Auth flow integration tests.
/// Tests that require a running LDAP server are marked with Integration trait.
///
public class AuthFlowTests : IClassFixture
{
private readonly ScadaBridgeWebApplicationFactory _factory;
public AuthFlowTests(ScadaBridgeWebApplicationFactory factory)
{
_factory = factory;
}
[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);
}
[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);
}
[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);
}
[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);
}
[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);
}
[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;
}
}
}