using System.Net; using System.Net.Http.Headers; using Microsoft.Extensions.Logging.Abstractions; using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Options; using SGU.AuthBroker.Services; using Xunit; namespace SGU.AuthBroker.Tests; public sealed class NtlmCredentialValidatorTests { private static readonly UserIdentity Administrative = new( "AD017045", "AD", "017045", InstitutionalRole.Administrative); private static readonly UserIdentity Student = new( "AL123456", "AL", "123456", InstitutionalRole.Student); [Fact] public async Task RedirectAndSuccessWithoutChallengeNeverAuthorizes() { SequenceHandler handler = new( Redirect("/psulsa/login.aspx?AspxAutoDetectCookieSupport=1"), Response(HttpStatusCode.OK)); NtlmCredentialValidator validator = CreateValidator(handler); NtlmValidationResult result = await validator.ValidateAsync( Student, "test-password", TestContext.Current.CancellationToken); Assert.Equal(NtlmValidationStatus.Unavailable, result.Status); Assert.Equal("NTLM_CHALLENGE_MISSING", result.ErrorCode); Assert.Equal(2, handler.RequestPaths.Count); } [Fact] public async Task CredentialsAreAcceptedOnlyAfterExplicitNtlmChallenge() { SequenceHandler handler = new( Challenge(), Response(HttpStatusCode.OK), Response(HttpStatusCode.OK)); NtlmCredentialValidator validator = CreateValidator(handler); NtlmValidationResult result = await validator.ValidateAsync( Student, "test-password", TestContext.Current.CancellationToken); Assert.Equal(NtlmValidationStatus.Valid, result.Status); Assert.Equal(3, handler.RequestPaths.Count); Assert.Equal("/psulsa/", handler.RequestPaths[0]); Assert.Equal("/psulsa/", handler.RequestPaths[1]); Assert.Equal( "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx", handler.RequestPaths[2]); } [Fact] public async Task RejectedCredentialsAfterChallengeAreInvalid() { SequenceHandler handler = new( Challenge(), Challenge()); NtlmCredentialValidator validator = CreateValidator(handler); NtlmValidationResult result = await validator.ValidateAsync( Student, "wrong-password", TestContext.Current.CancellationToken); Assert.Equal(NtlmValidationStatus.Invalid, result.Status); Assert.Equal("INVALID_INSTITUTIONAL_CREDENTIALS", result.ErrorCode); Assert.Equal(2, handler.RequestPaths.Count); } [Fact] public async Task SessionBootstrapRetriesOriginalRoleProfile() { SequenceHandler handler = new( Challenge(), Redirect("/psulsa/login.aspx?AspxAutoDetectCookieSupport=1"), Redirect("/psulsa/menu.aspx"), Response(HttpStatusCode.OK), Response(HttpStatusCode.OK)); NtlmCredentialValidator validator = CreateValidator(handler); NtlmValidationResult result = await validator.ValidateAsync( Student, "test-password", TestContext.Current.CancellationToken); Assert.Equal(NtlmValidationStatus.Valid, result.Status); Assert.Equal(5, handler.RequestPaths.Count); Assert.Equal("/psulsa/login.aspx", handler.RequestPaths[2]); Assert.Equal("/psulsa/menu.aspx", handler.RequestPaths[3]); Assert.Equal( "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx", handler.RequestPaths[4]); } [Fact] public async Task VerifiedAdministrativeProfileIsEnrichedFromPersonalAndLocationPages() { SequenceHandler handler = new( Challenge(), Response(HttpStatusCode.OK), Response( HttpStatusCode.OK, """ 017045 - NOMBRE ANTERIOR persona@lasalle.mx ANALISTA """), Response( HttpStatusCode.OK, """ """), Response( HttpStatusCode.OK, """ """)); NtlmCredentialValidator validator = CreateValidator(handler); NtlmValidationResult result = await validator.ValidateAsync( Administrative, "test-password", TestContext.Current.CancellationToken); Assert.Equal(NtlmValidationStatus.Valid, result.Status); Assert.NotNull(result.Profile); Assert.Equal("017045", result.Profile.EmployeeNumber); Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName); Assert.Equal("María del Carmen", result.Profile.GivenName); Assert.Equal("de la Fuente O'Connor", result.Profile.Surname); Assert.Equal("persona@lasalle.mx", result.Profile.Email); Assert.Equal("Analista", result.Profile.JobTitle); Assert.Equal("Calle del Sol 15\r\nFlorida", result.Profile.StreetAddress); Assert.Equal("Álvaro Obregón", result.Profile.City); Assert.Equal("Ciudad de México", result.Profile.State); Assert.Equal("01000", result.Profile.PostalCode); Assert.Equal( [ "/psulsa/", "/psulsa/", "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", "/psulsa/gadmon/capitalhumano/datos/personales.aspx", "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx" ], handler.RequestPaths); } [Fact] public async Task AdministrativeSupplementalPagesRequireAVerifiedEmployeeNumber() { SequenceHandler handler = new( Challenge(), Response(HttpStatusCode.OK), Response(HttpStatusCode.OK, "Datos inesperados")); NtlmCredentialValidator validator = CreateValidator(handler); NtlmValidationResult result = await validator.ValidateAsync( Administrative, "test-password", TestContext.Current.CancellationToken); Assert.Equal(NtlmValidationStatus.Valid, result.Status); Assert.Null(result.Profile); Assert.Equal(3, handler.RequestPaths.Count); } private static NtlmCredentialValidator CreateValidator(SequenceHandler handler) { BrokerOptions options = new() { Ntlm = new NtlmOptions { Endpoint = "https://sgu.example/", AuthenticationPath = "/psulsa/", AdministrativeProfilePath = "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", AdministrativePersonalProfilePath = "/psulsa/gadmon/capitalhumano/datos/personales.aspx", AdministrativeLocationProfilePath = "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx", StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx", AllowedRedirectHosts = ["sgu.example"], TimeoutSeconds = 5, ProfileTimeoutSeconds = 5 } }; return new NtlmCredentialValidator( options, NullLogger.Instance, (_, _) => handler); } private static HttpResponseMessage Challenge() { HttpResponseMessage response = Response(HttpStatusCode.Unauthorized); response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Negotiate")); response.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("NTLM")); return response; } private static HttpResponseMessage Redirect(string location) { HttpResponseMessage response = Response(HttpStatusCode.Found); response.Headers.Location = new Uri(location, UriKind.Relative); return response; } private static HttpResponseMessage Response( HttpStatusCode statusCode, string content = "") => new(statusCode) { Content = new StringContent(content) }; private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler { private readonly Queue responses = new(responses); public List RequestPaths { get; } = []; protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); RequestPaths.Add(request.RequestUri!.AbsolutePath); if (responses.Count == 0) { throw new InvalidOperationException("The validator sent more requests than expected."); } HttpResponseMessage response = responses.Dequeue(); response.RequestMessage = request; return Task.FromResult(response); } } }