Enrich administrative profiles from SGU
This commit is contained in:
@@ -24,9 +24,12 @@ Directory synchronization, deployment scripts, and tests.
|
|||||||
No derived password is created. Passwords are not written to a database, file,
|
No derived password is created. Passwords are not written to a database, file,
|
||||||
event log, application log, command line, or response.
|
event log, application log, command line, or response.
|
||||||
|
|
||||||
For administrative accounts, profile enrichment targets the read-only incident
|
For administrative accounts, profile enrichment first verifies the employee
|
||||||
overview and reads only the employee number, name, account type/status, email,
|
number against the incident overview, then reads the structured given names and
|
||||||
job title, and department from their stable element IDs. Incident, calendar,
|
surnames from `datos/personales.aspx` and the postal address from
|
||||||
|
`datos/ubicacion.aspx` in the same authenticated session. Account type/status,
|
||||||
|
email, job title, and department remain sourced from the incident overview.
|
||||||
|
Birth date, identifiers, telephone, emergency-contact, incident, calendar,
|
||||||
photo, and manager fields are ignored. Student enrichment targets the read-only
|
photo, and manager fields are ignored. Student enrichment targets the read-only
|
||||||
student information page and reads only the matching student number, structured
|
student information page and reads only the matching student number, structured
|
||||||
name, email, career, and postal address. The career becomes an AD title in the
|
name, email, career, and postal address. The career becomes an AD title in the
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ returns the NTLM challenge without waiting for the slow application pages. A
|
|||||||
`401` or `403` rejects the credential; an allowed `2xx` or `3xx` proves that IIS
|
`401` or `403` rejects the credential; an allowed `2xx` or `3xx` proves that IIS
|
||||||
accepted it. The broker then makes a separately bounded, best-effort GET to the
|
accepted it. The broker then makes a separately bounded, best-effort GET to the
|
||||||
administrative incident overview for `AD`, the student information page for
|
administrative incident overview for `AD`, the student information page for
|
||||||
`AL`, or the portal menu for `DO`. A profile timeout does not invalidate an
|
`AL`, or the portal menu for `DO`. After the incident page confirms an `AD`
|
||||||
already authenticated credential. NTLM may still require its normal
|
employee number, two additional GETs in the same in-memory session read the
|
||||||
|
structured name from `datos/personales.aspx` and the address from
|
||||||
|
`datos/ubicacion.aspx`. A supplemental failure preserves fields already
|
||||||
|
collected, and a profile timeout does not invalidate an already authenticated
|
||||||
|
credential. NTLM may still require its normal
|
||||||
challenge/response round trips on the connection. Transient portal cookies are
|
challenge/response round trips on the connection. Transient portal cookies are
|
||||||
kept only in an in-memory per-request container and are never persisted or
|
kept only in an in-memory per-request container and are never persisted or
|
||||||
returned to the client.
|
returned to the client.
|
||||||
@@ -54,8 +58,9 @@ When the authenticated HTML exposes recognized stable IDs, the broker also
|
|||||||
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
|
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
|
||||||
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
|
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
|
||||||
`postalCode` attributes. Administrative and student numbers must match the six
|
`postalCode` attributes. Administrative and student numbers must match the six
|
||||||
numeric digits of the requested identity before any scraped metadata is
|
numeric digits of the requested identity before any role-specific metadata is
|
||||||
trusted. Student faculty/department is deliberately left unset because the
|
trusted. Administrative personal and location pages are accepted only after
|
||||||
|
that incident-page match. Student faculty/department is deliberately left unset because the
|
||||||
verified page does not expose it. Missing metadata does not clear existing AD
|
verified page does not expose it. Missing metadata does not clear existing AD
|
||||||
values and never changes the password outcome.
|
values and never changes the password outcome.
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ Eso es comportamiento esperado, no una caída del servicio.
|
|||||||
|
|
||||||
## Timeouts y recuperación
|
## Timeouts y recuperación
|
||||||
|
|
||||||
- El Credential Provider espera hasta **35 segundos** por el broker.
|
- El Credential Provider espera hasta **90 segundos** por el broker.
|
||||||
- El broker permite hasta **20 segundos** para el desafío NTLM ligero de
|
- El broker permite hasta **20 segundos** para el desafío NTLM ligero de
|
||||||
`/psulsa/` y hasta **10 segundos totales** adicionales para enriquecer el
|
`/psulsa/`. El enriquecimiento usa el límite total independiente
|
||||||
perfil. La consulta de perfil es best effort: si la página pesada queda
|
`ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio—
|
||||||
congelada después de que NTLM aceptó la contraseña, el usuario se sincroniza
|
y conserva los campos que alcance a obtener si una página administrativa se
|
||||||
sin metadatos y puede iniciar sesión. El máximo combinado queda por debajo de
|
retrasa o falla. El Credential Provider mantiene su propio límite de **90
|
||||||
los 35 segundos del cliente.
|
segundos**: si SGU excede ese presupuesto, Windows continúa por el fallback
|
||||||
|
normal de AD o credenciales de dominio en caché.
|
||||||
- El instalador configura recuperación del servicio con reinicios a los 5, 15
|
- El instalador configura recuperación del servicio con reinicios a los 5, 15
|
||||||
y 60 segundos y reinicia el contador de fallos después de 24 horas.
|
y 60 segundos y reinicia el contador de fallos después de 24 horas.
|
||||||
- Si el broker o SGU no está disponible, el Credential Provider entrega la
|
- Si el broker o SGU no está disponible, el Credential Provider entrega la
|
||||||
|
|||||||
+12
-4
@@ -30,8 +30,14 @@
|
|||||||
|
|
||||||
## Profile minimization
|
## Profile minimization
|
||||||
|
|
||||||
- Administrative enrichment reads only employee number, display name,
|
- Administrative enrichment first verifies the employee number and reads
|
||||||
employee type/status, email, job title, and department from known element IDs.
|
employee type/status, email, job title, and department from the incident
|
||||||
|
overview. Only after that match, it reads given names and paternal/maternal
|
||||||
|
surnames from the personal page plus street, exterior/interior number,
|
||||||
|
neighborhood, locality, state, and postal code from the location page.
|
||||||
|
- Administrative birth date, RFC, CURP, sex, blood type, marital status,
|
||||||
|
nationality, telephone, email lists, housing type, and emergency-contact
|
||||||
|
fields are ignored.
|
||||||
- Student enrichment reads only the matching student number, given names,
|
- Student enrichment reads only the matching student number, given names,
|
||||||
paternal/maternal surnames, email, career, street, neighborhood,
|
paternal/maternal surnames, email, career, street, neighborhood,
|
||||||
city/municipality, state, and postal code from known element IDs.
|
city/municipality, state, and postal code from known element IDs.
|
||||||
@@ -40,11 +46,13 @@
|
|||||||
- Incident details, calendars, photographs, manager names, and manager positions
|
- Incident details, calendars, photographs, manager names, and manager positions
|
||||||
are deliberately ignored.
|
are deliberately ignored.
|
||||||
- The employee or student number must match the authenticated `AD` or `AL` key
|
- The employee or student number must match the authenticated `AD` or `AL` key
|
||||||
before metadata is synchronized.
|
before role-specific metadata is synchronized. The two supplemental
|
||||||
|
administrative pages are never requested unless the incident page supplied
|
||||||
|
the matching employee number.
|
||||||
- If SGU changes its HTML, authentication and exact-password synchronization
|
- If SGU changes its HTML, authentication and exact-password synchronization
|
||||||
continue without enrichment; existing AD metadata is not erased.
|
continue without enrichment; existing AD metadata is not erased.
|
||||||
- Slow profile pages cannot change an accepted credential into a rejection. The
|
- Slow profile pages cannot change an accepted credential into a rejection. The
|
||||||
lightweight NTLM root is authoritative; enrichment has its own shorter total
|
lightweight NTLM root is authoritative; enrichment has its own independent total
|
||||||
timeout.
|
timeout.
|
||||||
|
|
||||||
Lab self-signed certificates are appropriate only for the isolated VM network.
|
Lab self-signed certificates are appropriate only for the isolated VM network.
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ param(
|
|||||||
[ValidatePattern('^/')]
|
[ValidatePattern('^/')]
|
||||||
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
|
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
|
||||||
[ValidatePattern('^/')]
|
[ValidatePattern('^/')]
|
||||||
|
[string]$AdministrativePersonalProfilePath = '/psulsa/gadmon/capitalhumano/datos/personales.aspx',
|
||||||
|
[ValidatePattern('^/')]
|
||||||
|
[string]$AdministrativeLocationProfilePath = '/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx',
|
||||||
|
[ValidatePattern('^/')]
|
||||||
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
|
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
|
||||||
[ValidatePattern('^/')]
|
[ValidatePattern('^/')]
|
||||||
[string]$MenuProfilePath = '/psulsa/menu.aspx',
|
[string]$MenuProfilePath = '/psulsa/menu.aspx',
|
||||||
@@ -32,7 +36,7 @@ param(
|
|||||||
[ValidateRange(10, 60)]
|
[ValidateRange(10, 60)]
|
||||||
[int]$NtlmTimeoutSeconds = 20,
|
[int]$NtlmTimeoutSeconds = 20,
|
||||||
[ValidateRange(2, 90)]
|
[ValidateRange(2, 90)]
|
||||||
[int]$ProfileTimeoutSeconds = 60,
|
[int]$ProfileTimeoutSeconds = 90,
|
||||||
[switch]$CreateMissingOus,
|
[switch]$CreateMissingOus,
|
||||||
[switch]$DisableCertificateRevocationCheckForLab
|
[switch]$DisableCertificateRevocationCheckForLab
|
||||||
)
|
)
|
||||||
@@ -149,6 +153,8 @@ $productionSettings = @{
|
|||||||
MaxRedirects = 5
|
MaxRedirects = 5
|
||||||
AuthenticationPath = $AuthenticationPath
|
AuthenticationPath = $AuthenticationPath
|
||||||
AdministrativeProfilePath = $AdministrativeProfilePath
|
AdministrativeProfilePath = $AdministrativeProfilePath
|
||||||
|
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
|
||||||
|
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
|
||||||
StudentProfilePath = $StudentProfilePath
|
StudentProfilePath = $StudentProfilePath
|
||||||
MenuProfilePath = $MenuProfilePath
|
MenuProfilePath = $MenuProfilePath
|
||||||
MaxProfileBytes = $MaxProfileBytes
|
MaxProfileBytes = $MaxProfileBytes
|
||||||
|
|||||||
@@ -27,4 +27,23 @@ public sealed record InstitutionalProfile(
|
|||||||
City is not null ||
|
City is not null ||
|
||||||
State is not null ||
|
State is not null ||
|
||||||
PostalCode is not null;
|
PostalCode is not null;
|
||||||
|
|
||||||
|
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
|
||||||
|
values is null
|
||||||
|
? this
|
||||||
|
: this with
|
||||||
|
{
|
||||||
|
EmployeeNumber = values.EmployeeNumber ?? EmployeeNumber,
|
||||||
|
DisplayName = values.DisplayName ?? DisplayName,
|
||||||
|
GivenName = values.GivenName ?? GivenName,
|
||||||
|
Surname = values.Surname ?? Surname,
|
||||||
|
Email = values.Email ?? Email,
|
||||||
|
EmployeeType = values.EmployeeType ?? EmployeeType,
|
||||||
|
JobTitle = values.JobTitle ?? JobTitle,
|
||||||
|
Department = values.Department ?? Department,
|
||||||
|
StreetAddress = values.StreetAddress ?? StreetAddress,
|
||||||
|
City = values.City ?? City,
|
||||||
|
State = values.State ?? State,
|
||||||
|
PostalCode = values.PostalCode ?? PostalCode
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ public static class SguProfileParser
|
|||||||
private const string EmailId = "ctl00_contenedor_decEncabezado_lblCorreo";
|
private const string EmailId = "ctl00_contenedor_decEncabezado_lblCorreo";
|
||||||
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
|
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
|
||||||
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
|
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
|
||||||
|
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
|
||||||
|
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
|
||||||
|
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
|
||||||
|
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
|
||||||
|
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
|
||||||
|
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
|
||||||
|
private const string AdministrativePostalCodeId = "ctl00_contenedor_txtCP";
|
||||||
|
private const string AdministrativeStateId = "ctl00_contenedor_ddlEstado";
|
||||||
|
private const string AdministrativeCityId = "ctl00_contenedor_ddlLocalidad";
|
||||||
|
private const string AdministrativeNeighborhoodId = "ctl00_contenedor_ddlColonia";
|
||||||
private const string MenuNameId = "ctl00_lblNombreUsuario";
|
private const string MenuNameId = "ctl00_lblNombreUsuario";
|
||||||
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
|
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
|
||||||
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
|
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
|
||||||
@@ -56,6 +66,51 @@ public static class SguProfileParser
|
|||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static InstitutionalProfile? ParseAdministrativePersonal(string html)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
|
|
||||||
|
string? givenName = NormalizeName(ExtractInputValue(html, AdministrativeGivenNameId), 64);
|
||||||
|
string? paternalSurname = NormalizeSurname(
|
||||||
|
ExtractInputValue(html, AdministrativePaternalSurnameId),
|
||||||
|
64);
|
||||||
|
string? maternalSurname = NormalizeSurname(
|
||||||
|
ExtractInputValue(html, AdministrativeMaternalSurnameId),
|
||||||
|
64);
|
||||||
|
string? surname = NormalizeSurname(
|
||||||
|
JoinNonEmpty(" ", paternalSurname, maternalSurname),
|
||||||
|
64);
|
||||||
|
string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256);
|
||||||
|
|
||||||
|
InstitutionalProfile profile = new(
|
||||||
|
DisplayName: displayName,
|
||||||
|
GivenName: givenName,
|
||||||
|
Surname: surname);
|
||||||
|
return profile.HasValues ? profile : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
|
|
||||||
|
string? street = NormalizeTitle(ExtractInputValue(html, AdministrativeStreetId), 512);
|
||||||
|
string? exteriorNumber = NormalizeAddressUnit(
|
||||||
|
ExtractInputValue(html, AdministrativeExteriorNumberId));
|
||||||
|
string? interiorNumber = NormalizeAddressUnit(
|
||||||
|
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
||||||
|
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
||||||
|
string? neighborhood = NormalizeTitle(
|
||||||
|
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
|
||||||
|
256);
|
||||||
|
|
||||||
|
InstitutionalProfile profile = new(
|
||||||
|
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
||||||
|
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
|
||||||
|
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
|
||||||
|
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||||
|
return profile.HasValues ? profile : null;
|
||||||
|
}
|
||||||
|
|
||||||
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
@@ -162,6 +217,158 @@ public static class SguProfileParser
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ExtractInputValue(string html, string id)
|
||||||
|
{
|
||||||
|
string? openingTag = FindOpeningTag(html, "input", id);
|
||||||
|
return openingTag is null
|
||||||
|
? null
|
||||||
|
: NormalizeText(ExtractAttributeValue(openingTag, "value") ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractSelectedOptionText(string html, string id)
|
||||||
|
{
|
||||||
|
string? openingTag = FindOpeningTag(html, "select", id);
|
||||||
|
if (openingTag is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
int contentStart = openingTagIndex + openingTag.Length;
|
||||||
|
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (openingTagIndex < 0 || contentEnd < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string optionsHtml = html[contentStart..contentEnd];
|
||||||
|
string? selectedValue = ExtractAttributeValue(openingTag, "value");
|
||||||
|
List<string> nonPlaceholderOptions = [];
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (searchFrom < optionsHtml.Length)
|
||||||
|
{
|
||||||
|
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (optionStart < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
|
||||||
|
int optionEnd = optionTagEnd < 0
|
||||||
|
? -1
|
||||||
|
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (optionTagEnd < 0 || optionEnd < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
|
||||||
|
string? optionText = NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
|
||||||
|
string? optionValue = ExtractAttributeValue(optionTag, "value");
|
||||||
|
if (optionText is not null &&
|
||||||
|
!optionText.StartsWith("Seleccione", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
nonPlaceholderOptions.Add(optionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionText is not null &&
|
||||||
|
(HasAttribute(optionTag, "selected") ||
|
||||||
|
(selectedValue is not null &&
|
||||||
|
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
|
||||||
|
{
|
||||||
|
return optionText;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFrom = optionEnd + "</option".Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindOpeningTag(string html, string tagName, string id)
|
||||||
|
{
|
||||||
|
foreach (char quote in new[] { '"', '\'' })
|
||||||
|
{
|
||||||
|
string marker = $"id={quote}{id}{quote}";
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (searchFrom < html.Length)
|
||||||
|
{
|
||||||
|
int idIndex = html.IndexOf(marker, searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idIndex < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tagStart = html.LastIndexOf($"<{tagName}", idIndex, StringComparison.OrdinalIgnoreCase);
|
||||||
|
int precedingTagEnd = html.LastIndexOf('>', idIndex);
|
||||||
|
if (tagStart > precedingTagEnd)
|
||||||
|
{
|
||||||
|
int tagEnd = html.IndexOf('>', idIndex);
|
||||||
|
if (tagEnd >= 0)
|
||||||
|
{
|
||||||
|
return html[tagStart..(tagEnd + 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFrom = idIndex + marker.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractAttributeValue(string openingTag, string attributeName)
|
||||||
|
{
|
||||||
|
foreach (char quote in new[] { '"', '\'' })
|
||||||
|
{
|
||||||
|
string marker = $"{attributeName}={quote}";
|
||||||
|
int valueStart = openingTag.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (valueStart < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
valueStart += marker.Length;
|
||||||
|
int valueEnd = openingTag.IndexOf(quote, valueStart);
|
||||||
|
if (valueEnd >= 0)
|
||||||
|
{
|
||||||
|
return WebUtility.HtmlDecode(openingTag[valueStart..valueEnd]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasAttribute(string openingTag, string attributeName)
|
||||||
|
{
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (searchFrom < openingTag.Length)
|
||||||
|
{
|
||||||
|
int index = openingTag.IndexOf(attributeName, searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validStart = index == 0 ||
|
||||||
|
char.IsWhiteSpace(openingTag[index - 1]) ||
|
||||||
|
openingTag[index - 1] == '<';
|
||||||
|
int after = index + attributeName.Length;
|
||||||
|
bool validEnd = after >= openingTag.Length ||
|
||||||
|
char.IsWhiteSpace(openingTag[after]) ||
|
||||||
|
openingTag[after] is '=' or '>' or '/';
|
||||||
|
if (validStart && validEnd)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFrom = after;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? NormalizeText(string htmlFragment)
|
private static string? NormalizeText(string htmlFragment)
|
||||||
{
|
{
|
||||||
StringBuilder withoutTags = new(htmlFragment.Length);
|
StringBuilder withoutTags = new(htmlFragment.Length);
|
||||||
@@ -285,6 +492,28 @@ public static class SguProfileParser
|
|||||||
return Limit(string.Join("\r\n", lines), 1024);
|
return Limit(string.Join("\r\n", lines), 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? BuildAdministrativeStreetLine(
|
||||||
|
string? street,
|
||||||
|
string? exteriorNumber,
|
||||||
|
string? interiorNumber)
|
||||||
|
{
|
||||||
|
string? line = JoinNonEmpty(" ", street, exteriorNumber);
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return interiorNumber is null
|
||||||
|
? line
|
||||||
|
: $"{line}, Int. {interiorNumber}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeAddressUnit(string? value)
|
||||||
|
{
|
||||||
|
string? candidate = Limit(value, 32);
|
||||||
|
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddDistinct(List<string> values, string? candidate)
|
private static void AddDistinct(List<string> values, string? candidate)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(candidate) &&
|
if (!string.IsNullOrWhiteSpace(candidate) &&
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public sealed class BrokerOptions
|
|||||||
{
|
{
|
||||||
Ntlm.AuthenticationPath,
|
Ntlm.AuthenticationPath,
|
||||||
Ntlm.AdministrativeProfilePath,
|
Ntlm.AdministrativeProfilePath,
|
||||||
|
Ntlm.AdministrativePersonalProfilePath,
|
||||||
|
Ntlm.AdministrativeLocationProfilePath,
|
||||||
Ntlm.StudentProfilePath,
|
Ntlm.StudentProfilePath,
|
||||||
Ntlm.MenuProfilePath
|
Ntlm.MenuProfilePath
|
||||||
})
|
})
|
||||||
@@ -122,7 +124,7 @@ public sealed class NtlmOptions
|
|||||||
|
|
||||||
public int TimeoutSeconds { get; init; } = 20;
|
public int TimeoutSeconds { get; init; } = 20;
|
||||||
|
|
||||||
public int ProfileTimeoutSeconds { get; init; } = 60;
|
public int ProfileTimeoutSeconds { get; init; } = 90;
|
||||||
|
|
||||||
public int MaxRedirects { get; init; } = 5;
|
public int MaxRedirects { get; init; } = 5;
|
||||||
|
|
||||||
@@ -131,6 +133,12 @@ public sealed class NtlmOptions
|
|||||||
public string AdministrativeProfilePath { get; init; } =
|
public string AdministrativeProfilePath { get; init; } =
|
||||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
|
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
|
||||||
|
|
||||||
|
public string AdministrativePersonalProfilePath { get; init; } =
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/personales.aspx";
|
||||||
|
|
||||||
|
public string AdministrativeLocationProfilePath { get; init; } =
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx";
|
||||||
|
|
||||||
public string StudentProfilePath { get; init; } =
|
public string StudentProfilePath { get; init; } =
|
||||||
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
|
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,21 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
response,
|
response,
|
||||||
identity,
|
identity,
|
||||||
timeout.Token).ConfigureAwait(false);
|
timeout.Token).ConfigureAwait(false);
|
||||||
|
if (identity.Role == InstitutionalRole.Administrative &&
|
||||||
|
string.Equals(
|
||||||
|
profile?.EmployeeNumber,
|
||||||
|
identity.NumericId,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
profile = await TryEnrichAdministrativeProfileAsync(
|
||||||
|
client,
|
||||||
|
profile!,
|
||||||
|
allowedHosts,
|
||||||
|
timeout.Token,
|
||||||
|
cancellationToken,
|
||||||
|
elapsed).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (profile is null)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
@@ -379,6 +394,104 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<InstitutionalProfile> TryEnrichAdministrativeProfileAsync(
|
||||||
|
HttpClient client,
|
||||||
|
InstitutionalProfile verifiedProfile,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CancellationToken timeoutToken,
|
||||||
|
CancellationToken requestCancellationToken,
|
||||||
|
Stopwatch elapsed)
|
||||||
|
{
|
||||||
|
InstitutionalProfile profile = verifiedProfile;
|
||||||
|
(string Path, Func<string, InstitutionalProfile?> Parser)[] pages =
|
||||||
|
[
|
||||||
|
(options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal),
|
||||||
|
(options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation)
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? html = await TryFetchAdditionalProfilePageAsync(
|
||||||
|
client,
|
||||||
|
GetProfileUri(path),
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
profile = profile.Overlay(html is null ? null : parser(html));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"SGU administrative profile enrichment reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"An optional SGU administrative profile page failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryFetchAdditionalProfilePageAsync(
|
||||||
|
HttpClient client,
|
||||||
|
Uri requestedUri,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Uri current = requestedUri;
|
||||||
|
for (int hop = 0; hop <= options.MaxRedirects; hop++)
|
||||||
|
{
|
||||||
|
if (!IsAllowedHttpsUri(current, allowedHosts))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpRequestMessage request = new(HttpMethod.Get, current);
|
||||||
|
using HttpResponseMessage response = await client
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
int statusCode = (int)response.StatusCode;
|
||||||
|
if (statusCode is >= 300 and < 400)
|
||||||
|
{
|
||||||
|
Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts);
|
||||||
|
if (redirect is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DrainResponseAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
current = redirect;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode is >= 200 and < 300)
|
||||||
|
{
|
||||||
|
return await ReadLimitedStringAsync(
|
||||||
|
response.Content,
|
||||||
|
options.MaxProfileBytes,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
|
||||||
|
requestedUri.AbsolutePath,
|
||||||
|
statusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
"Optional SGU profile page {Path} exceeded the redirect limit.",
|
||||||
|
requestedUri.AbsolutePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddCredential(
|
private static void AddCredential(
|
||||||
Uri uri,
|
Uri uri,
|
||||||
CredentialCache credentialCache,
|
CredentialCache credentialCache,
|
||||||
@@ -472,6 +585,9 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
return new Uri(endpoint, path);
|
return new Uri(endpoint, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Uri GetProfileUri(string path) =>
|
||||||
|
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
||||||
|
|
||||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
|
|||||||
@@ -30,10 +30,12 @@
|
|||||||
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
||||||
"Domain": "",
|
"Domain": "",
|
||||||
"TimeoutSeconds": 20,
|
"TimeoutSeconds": 20,
|
||||||
"ProfileTimeoutSeconds": 60,
|
"ProfileTimeoutSeconds": 90,
|
||||||
"MaxRedirects": 5,
|
"MaxRedirects": 5,
|
||||||
"AuthenticationPath": "/psulsa/",
|
"AuthenticationPath": "/psulsa/",
|
||||||
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
"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",
|
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
||||||
"MenuProfilePath": "/psulsa/menu.aspx",
|
"MenuProfilePath": "/psulsa/menu.aspx",
|
||||||
"MaxProfileBytes": 524288,
|
"MaxProfileBytes": 524288,
|
||||||
|
|||||||
@@ -56,6 +56,100 @@ public sealed class SguProfileParserTests
|
|||||||
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
|
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesStructuredAdministrativeNameWithoutReadingOtherPersonalData()
|
||||||
|
{
|
||||||
|
const string html = """
|
||||||
|
<html><body>
|
||||||
|
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||||
|
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||||
|
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
||||||
|
<input id="ctl00_contenedor_txtCURP" value="DATO-QUE-NO-DEBE-EXTRAERSE" />
|
||||||
|
<select id="ctl00_contenedor_ddlNacimientoFecha_ddlYear">
|
||||||
|
<option selected="selected">1990</option>
|
||||||
|
</select>
|
||||||
|
</body></html>
|
||||||
|
""";
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
|
||||||
|
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal("María del Carmen", profile.GivenName);
|
||||||
|
Assert.Equal("de la Fuente O'Connor", profile.Surname);
|
||||||
|
Assert.Equal("María del Carmen de la Fuente O'Connor", profile.DisplayName);
|
||||||
|
Assert.Null(profile.EmployeeNumber);
|
||||||
|
Assert.Null(profile.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
|
||||||
|
{
|
||||||
|
const string html = """
|
||||||
|
<html><body>
|
||||||
|
<input id='ctl00_contenedor_txtCalle' value='AVENIDA DE LA UNIVERSIDAD' />
|
||||||
|
<input id='ctl00_contenedor_txtNoExt' value='123' />
|
||||||
|
<input id='ctl00_contenedor_txtNoInt' value='B-4' />
|
||||||
|
<input id='ctl00_contenedor_txtCP' value='8500' />
|
||||||
|
<select id='ctl00_contenedor_ddlEstado'>
|
||||||
|
<option value='0'>Seleccione...</option>
|
||||||
|
<option selected='selected' value='9'>CIUDAD DE MÉXICO</option>
|
||||||
|
</select>
|
||||||
|
<select id='ctl00_contenedor_ddlLocalidad'>
|
||||||
|
<option value='1'>AZCAPOTZALCO</option>
|
||||||
|
<option selected='selected' value='4'>BENITO JUÁREZ</option>
|
||||||
|
</select>
|
||||||
|
<select id='ctl00_contenedor_ddlColonia'>
|
||||||
|
<option value='10'>COLONIA DEL VALLE</option>
|
||||||
|
</select>
|
||||||
|
<input id='ctl00_contenedor_txtTelefono' value='5555555555' />
|
||||||
|
<textarea id='ctl00_contenedor_txtDirEmergencia'>NO EXTRAER</textarea>
|
||||||
|
</body></html>
|
||||||
|
""";
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(html);
|
||||||
|
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal(
|
||||||
|
"Avenida de la Universidad 123, Int. B-4\r\nColonia del Valle",
|
||||||
|
profile.StreetAddress);
|
||||||
|
Assert.Equal("Benito Juárez", profile.City);
|
||||||
|
Assert.Equal("Ciudad de México", profile.State);
|
||||||
|
Assert.Equal("08500", profile.PostalCode);
|
||||||
|
Assert.Null(profile.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
||||||
|
{
|
||||||
|
InstitutionalProfile verified = new(
|
||||||
|
EmployeeNumber: "017045",
|
||||||
|
DisplayName: "Nombre Anterior",
|
||||||
|
Email: "persona@lasalle.mx",
|
||||||
|
JobTitle: "Analista",
|
||||||
|
Department: "Ingeniería");
|
||||||
|
InstitutionalProfile personal = new(
|
||||||
|
DisplayName: "María del Carmen de la Fuente",
|
||||||
|
GivenName: "María del Carmen",
|
||||||
|
Surname: "de la Fuente");
|
||||||
|
InstitutionalProfile location = new(
|
||||||
|
StreetAddress: "Calle Uno 10",
|
||||||
|
City: "Ciudad de México",
|
||||||
|
State: "Ciudad de México",
|
||||||
|
PostalCode: "01000");
|
||||||
|
|
||||||
|
InstitutionalProfile combined = verified.Overlay(personal).Overlay(location);
|
||||||
|
|
||||||
|
Assert.Equal("017045", combined.EmployeeNumber);
|
||||||
|
Assert.Equal("María del Carmen de la Fuente", combined.DisplayName);
|
||||||
|
Assert.Equal("María del Carmen", combined.GivenName);
|
||||||
|
Assert.Equal("de la Fuente", combined.Surname);
|
||||||
|
Assert.Equal("persona@lasalle.mx", combined.Email);
|
||||||
|
Assert.Equal("Analista", combined.JobTitle);
|
||||||
|
Assert.Equal("Ingeniería", combined.Department);
|
||||||
|
Assert.Equal("Calle Uno 10", combined.StreetAddress);
|
||||||
|
Assert.Equal("01000", combined.PostalCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParsesTheRequiredStudentIdentityCareerAndAddressFields()
|
public void ParsesTheRequiredStudentIdentityCareerAndAddressFields()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ namespace SGU.AuthBroker.Tests;
|
|||||||
|
|
||||||
public sealed class NtlmCredentialValidatorTests
|
public sealed class NtlmCredentialValidatorTests
|
||||||
{
|
{
|
||||||
|
private static readonly UserIdentity Administrative = new(
|
||||||
|
"AD017045",
|
||||||
|
"AD",
|
||||||
|
"017045",
|
||||||
|
InstitutionalRole.Administrative);
|
||||||
|
|
||||||
private static readonly UserIdentity Student = new(
|
private static readonly UserIdentity Student = new(
|
||||||
"AL123456",
|
"AL123456",
|
||||||
"AL",
|
"AL",
|
||||||
@@ -101,6 +107,91 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
handler.RequestPaths[4]);
|
handler.RequestPaths[4]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifiedAdministrativeProfileIsEnrichedFromPersonalAndLocationPages()
|
||||||
|
{
|
||||||
|
SequenceHandler handler = new(
|
||||||
|
Challenge(),
|
||||||
|
Response(HttpStatusCode.OK),
|
||||||
|
Response(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
"""
|
||||||
|
<span id="ctl00_contenedor_decEncabezado_lblNombre">017045 - NOMBRE ANTERIOR</span>
|
||||||
|
<span id="ctl00_contenedor_decEncabezado_lblCorreo">persona@lasalle.mx</span>
|
||||||
|
<span id="ctl00_contenedor_decEncabezado_lblPuesto">ANALISTA</span>
|
||||||
|
"""),
|
||||||
|
Response(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
"""
|
||||||
|
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||||
|
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||||
|
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
||||||
|
"""),
|
||||||
|
Response(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
"""
|
||||||
|
<input id="ctl00_contenedor_txtCalle" value="CALLE DEL SOL" />
|
||||||
|
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||||
|
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||||
|
<select id="ctl00_contenedor_ddlEstado">
|
||||||
|
<option selected="selected">CIUDAD DE MÉXICO</option>
|
||||||
|
</select>
|
||||||
|
<select id="ctl00_contenedor_ddlLocalidad">
|
||||||
|
<option selected="selected">ÁLVARO OBREGÓN</option>
|
||||||
|
</select>
|
||||||
|
<select id="ctl00_contenedor_ddlColonia">
|
||||||
|
<option>FLORIDA</option>
|
||||||
|
</select>
|
||||||
|
"""));
|
||||||
|
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, "<html><body>Datos inesperados</body></html>"));
|
||||||
|
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)
|
private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
|
||||||
{
|
{
|
||||||
BrokerOptions options = new()
|
BrokerOptions options = new()
|
||||||
@@ -109,6 +200,9 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
{
|
{
|
||||||
Endpoint = "https://sgu.example/",
|
Endpoint = "https://sgu.example/",
|
||||||
AuthenticationPath = "/psulsa/",
|
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",
|
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
||||||
AllowedRedirectHosts = ["sgu.example"],
|
AllowedRedirectHosts = ["sgu.example"],
|
||||||
TimeoutSeconds = 5,
|
TimeoutSeconds = 5,
|
||||||
@@ -136,10 +230,12 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpResponseMessage Response(HttpStatusCode statusCode) =>
|
private static HttpResponseMessage Response(
|
||||||
|
HttpStatusCode statusCode,
|
||||||
|
string content = "<html></html>") =>
|
||||||
new(statusCode)
|
new(statusCode)
|
||||||
{
|
{
|
||||||
Content = new StringContent("<html></html>")
|
Content = new StringContent(content)
|
||||||
};
|
};
|
||||||
|
|
||||||
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
|
|||||||
Reference in New Issue
Block a user