513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
197 lines
8.3 KiB
C#
197 lines
8.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Auth flow integration tests.
|
|
/// Tests that require a running LDAP server are marked with Integration trait.
|
|
/// </summary>
|
|
public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
|
{
|
|
private readonly ScadaBridgeWebApplicationFactory _factory;
|
|
|
|
/// <summary>Initializes the test with the shared web application factory.</summary>
|
|
/// <param name="factory">The web application factory hosting the app under test.</param>
|
|
public AuthFlowTests(ScadaBridgeWebApplicationFactory factory)
|
|
{
|
|
_factory = factory;
|
|
}
|
|
|
|
/// <summary>Verifies that the login endpoint with empty credentials redirects to the login page with an error.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the logout endpoint clears the cookie and redirects.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the JWT token service round-trips a generated token through validation.</summary>
|
|
[Fact]
|
|
public void JwtTokenService_GenerateAndValidate_RoundTrips()
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Verifies that tokens generated with site scopes include site id claims.</summary>
|
|
[Fact]
|
|
public void JwtTokenService_WithSiteScopes_IncludesSiteIdClaims()
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>Verifies that the login endpoint with valid LDAP credentials sets the cookie and redirects.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the login endpoint without an antiforgery token is rejected without an auth cookie.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<string, string>
|
|
{
|
|
["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<FormUrlEncodedContent> 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, """<input\b[^>]*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<string, string>
|
|
{
|
|
["username"] = username,
|
|
["password"] = password,
|
|
["__RequestVerificationToken"] = WebUtility.HtmlDecode(value.Groups[1].Value)
|
|
});
|
|
}
|
|
|
|
private static async Task<bool> IsLdapAvailableAsync()
|
|
{
|
|
try
|
|
{
|
|
using var tcp = new System.Net.Sockets.TcpClient();
|
|
await tcp.ConnectAsync("localhost", 3893).WaitAsync(TimeSpan.FromSeconds(2));
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|