Add student SGU profile synchronization

This commit is contained in:
2026-09-01 14:23:20 -06:00
parent 3b5cf723cc
commit fd3eb537a1
21 changed files with 345 additions and 43 deletions
+8 -5
View File
@@ -16,7 +16,7 @@ Directory synchronization, deployment scripts, and tests.
NTLM endpoint. The same logical authenticated request reads the minimum
available SGU profile fields.
4. On success, the broker creates or moves the AD user, updates the available
name/mail/title/department metadata, and sets the AD password
name/mail/title/department/address metadata when available, and sets the AD password
to the exact submitted password.
5. The Credential Provider serializes the original `SecureString` to Windows.
@@ -26,10 +26,13 @@ event log, application log, command line, or response.
For administrative accounts, profile enrichment targets the read-only incident
overview and reads only the employee number, name, account type/status, email,
job title, and department from their stable element IDs. Incident, calendar,
photo, and manager fields are ignored. For students and professors, the menu
display name is a conservative fallback until a richer role-specific page is
verified. Missing or changed presentation HTML never blocks authentication or
password synchronization.
photo, and manager fields are ignored. Student enrichment targets the read-only
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
form `Estudiante de ...`; faculty/department remains unset because the verified
page does not expose it. Professors retain the menu display-name fallback until
a richer role-specific page is verified. Missing or changed presentation HTML
never blocks authentication or password synchronization.
Operational documentation:
+12 -9
View File
@@ -21,10 +21,11 @@ which prevents credential forwarding to an unexpected redirect target. HTTP/1.1
is forced because NTLM authentication is connection-bound.
The logical GET is sent directly to the administrative incident overview for
`AD` identities or to the portal menu for `DO`/`AL` identities. NTLM may still
require its normal challenge/response round trips on that connection. The
broker keeps any transient portal cookie in an in-memory per-request container;
it is never persisted or returned to the client.
`AD` identities, the student information page for `AL` identities, or the
portal menu for `DO` identities. NTLM may still require its normal
challenge/response round trips on that connection. The broker keeps any
transient portal cookie in an in-memory per-request container; it is never
persisted or returned to the client.
## Offline authentication
@@ -46,11 +47,13 @@ absent, moves it to the mapped OU when required, sets `userPrincipalName`, and
passes the submitted password directly to ADSI `SetPassword`.
When the authenticated HTML exposes recognized stable IDs, the broker also
updates `displayName`, `mail`, `title`, `department`, `employeeType`, and
`employeeID`. The administrative employee number must match the six numeric
digits of the requested `AD` identity before any scraped metadata is trusted.
Missing metadata does not clear existing AD values and never changes the
password outcome.
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
`postalCode` attributes. Administrative and student numbers must match the six
numeric digits of the requested identity before any scraped metadata is
trusted. Student faculty/department is deliberately left unset because the
verified page does not expose it. Missing metadata does not clear existing AD
values and never changes the password outcome.
Human-readable SGU values are decoded with BOM/header/meta detection, strict
UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and
+5 -4
View File
@@ -46,10 +46,11 @@ Eso es comportamiento esperado, no una caída del servicio.
## Timeouts y recuperación
- El Credential Provider espera hasta **20 segundos** por el broker.
- El broker espera hasta **15 segundos** por SGU. Un portal que normalmente
tarda alrededor de seis segundos queda dentro del margen sin bloquear LogonUI
indefinidamente.
- El Credential Provider espera hasta **35 segundos** por el broker.
- El broker espera hasta **30 segundos** por SGU. Este margen cubre las
degradaciones observadas del portal sin bloquear LogonUI indefinidamente; el
cliente conserva cinco segundos adicionales para que el broker cierre la
respuesta de manera limpia.
- 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.
- Si el broker o SGU no está disponible, el Credential Provider entrega la
+1 -1
View File
@@ -124,7 +124,7 @@ On Windows 10:
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-TimeoutSeconds 20 `
-TimeoutSeconds 35 `
-InstallDotNetRuntime `
-DotNetRuntimeInstallerPath C:\SGUDeploy\prerequisites\dotnet-runtime-10.0.11-win-x64.exe
```
+7 -2
View File
@@ -32,10 +32,15 @@
- Administrative enrichment reads only employee number, display name,
employee type/status, email, job title, and department from known element IDs.
- Student enrichment reads only the matching student number, given names,
paternal/maternal surnames, email, career, street, neighborhood,
city/municipality, state, and postal code from known element IDs.
- Student CURP, birth date, sex, blood type, marital status, telephone, mobile,
guardian, medical, financial, and academic-history values are ignored.
- Incident details, calendars, photographs, manager names, and manager positions
are deliberately ignored.
- The employee number must match the authenticated `AD` key before metadata is
synchronized.
- The employee or student number must match the authenticated `AD` or `AL` key
before metadata is synchronized.
- If SGU changes its HTML, authentication and exact-password synchronization
continue without enrichment; existing AD metadata is not erased.
+1 -1
View File
@@ -82,7 +82,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass `
-BrokerEndpoint https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate `
-ClientCertificateThumbprint CLIENT_CERT_THUMBPRINT `
-ServerCertificateThumbprint SERVER_CERT_THUMBPRINT `
-TimeoutSeconds 20
-TimeoutSeconds 35
```
Los binarios se guardan en un subdirectorio `versions` identificado por su
+4 -1
View File
@@ -15,6 +15,8 @@ param(
[ValidatePattern('^/')]
[string]$AdministrativeProfilePath = '/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx',
[ValidatePattern('^/')]
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
[ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx',
[ValidateRange(32768, 2097152)]
[int]$MaxProfileBytes = 524288,
@@ -26,7 +28,7 @@ param(
[ValidateLength(1, 64)]
[string]$DefaultCompany = 'Universidad La Salle',
[ValidateRange(10, 60)]
[int]$NtlmTimeoutSeconds = 15,
[int]$NtlmTimeoutSeconds = 30,
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
@@ -141,6 +143,7 @@ $productionSettings = @{
TimeoutSeconds = $NtlmTimeoutSeconds
MaxRedirects = 5
AdministrativeProfilePath = $AdministrativeProfilePath
StudentProfilePath = $StudentProfilePath
MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes
AllowedRedirectHosts = $AllowedNtlmRedirectHosts
+2 -2
View File
@@ -55,7 +55,7 @@ $installParams = @{
ClientCertificateThumbprint = $ClientCertificateThumbprint
ServerCertificateThumbprint = $ServerCertificateThumbprint
DomainNetbios = $DomainNetbios
TimeoutSeconds = 20
TimeoutSeconds = 35
}
if ($DotNetRuntimeInstallerPath) {
$installParams.InstallDotNetRuntime = $true
@@ -68,7 +68,7 @@ $guardParams = @{
ClientCertificateThumbprint = $ClientCertificateThumbprint
ServerCertificateThumbprint = $ServerCertificateThumbprint
DomainNetbios = $DomainNetbios
TimeoutSeconds = 20
TimeoutSeconds = 35
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
}
+1 -1
View File
@@ -18,7 +18,7 @@ param(
[string]$DomainNetbios = 'LCI',
[ValidateRange(2, 60)]
[int]$TimeoutSeconds = 20,
[int]$TimeoutSeconds = 35,
[switch]$DoNotSetAsDefaultCredentialProvider,
+1 -1
View File
@@ -17,7 +17,7 @@ param(
[string]$DomainNetbios = 'LCI',
[ValidateRange(2, 60)]
[int]$TimeoutSeconds = 20,
[int]$TimeoutSeconds = 35,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath
)
@@ -3,16 +3,28 @@ namespace SGU.AuthBroker.Core.Profiles;
public sealed record InstitutionalProfile(
string? EmployeeNumber = null,
string? DisplayName = null,
string? GivenName = null,
string? Surname = null,
string? Email = null,
string? EmployeeType = null,
string? JobTitle = null,
string? Department = null)
string? Department = null,
string? StreetAddress = null,
string? City = null,
string? State = null,
string? PostalCode = null)
{
public bool HasValues =>
EmployeeNumber is not null ||
DisplayName is not null ||
GivenName is not null ||
Surname is not null ||
Email is not null ||
EmployeeType is not null ||
JobTitle is not null ||
Department is not null;
Department is not null ||
StreetAddress is not null ||
City is not null ||
State is not null ||
PostalCode is not null;
}
@@ -12,6 +12,18 @@ public static class SguProfileParser
private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto";
private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia";
private const string MenuNameId = "ctl00_lblNombreUsuario";
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
private const string StudentPaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApPatAlumnoHP";
private const string StudentMaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApMatAlumnoHP";
private const string StudentEmailId = "ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP";
private const string StudentCareerId = "ctl00_contenedor_HistorialAlumno1_Header1_lblCarrera";
private const string StudentStreetId = "ctl00_contenedor_HistorialAlumno1_lblDomicilioAlumnoHP";
private const string StudentNeighborhoodId = "ctl00_contenedor_HistorialAlumno1_lblColoniaAlumnoHP";
private const string StudentStateId = "ctl00_contenedor_HistorialAlumno1_lblEstadoAlumnoHP";
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
{
@@ -44,6 +56,44 @@ public static class SguProfileParser
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedStudentNumber);
string? studentNumber = NormalizeInstitutionalNumber(ExtractSpanText(html, StudentNumberId));
if (!string.Equals(studentNumber, expectedStudentNumber, StringComparison.Ordinal))
{
return null;
}
string? givenName = NormalizeName(ExtractSpanText(html, StudentGivenNameId), 64);
string? paternalSurname = NormalizeSurname(ExtractSpanText(html, StudentPaternalSurnameId), 64);
string? maternalSurname = NormalizeSurname(ExtractSpanText(html, StudentMaternalSurnameId), 64);
string? surname = NormalizeSurname(JoinNonEmpty(" ", paternalSurname, maternalSurname), 64);
string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256);
string? street = NormalizeTitle(ExtractSpanText(html, StudentStreetId), 512);
string? neighborhood = NormalizeTitle(ExtractSpanText(html, StudentNeighborhoodId), 256);
string? city = NormalizeTitle(ExtractSpanText(html, StudentCityId), 128);
string? municipality = NormalizeTitle(ExtractSpanText(html, StudentMunicipalityId), 128);
string? streetAddress = BuildStreetAddress(street, neighborhood, municipality, city);
InstitutionalProfile profile = new(
EmployeeNumber: studentNumber,
DisplayName: displayName,
GivenName: givenName,
Surname: surname,
Email: NormalizeEmail(ExtractSpanText(html, StudentEmailId)),
EmployeeType: "Alumno",
JobTitle: BuildStudentJobTitle(ExtractSpanText(html, StudentCareerId)),
StreetAddress: streetAddress,
City: city ?? municipality,
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)));
return profile.HasValues ? profile : null;
}
private static bool TrySplitAdministrativeIdentity(
string? value,
out string? employeeNumber,
@@ -176,6 +226,104 @@ public static class SguProfileParser
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static string? NormalizeName(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
}
private static string? NormalizeSurname(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
return candidate is null ? null : SpanishTextNormalizer.ToSurnameCase(candidate);
}
private static string? NormalizeInstitutionalNumber(string? value)
{
string? candidate = value?.Trim();
return candidate is { Length: 6 } && candidate.All(char.IsAsciiDigit)
? candidate
: null;
}
private static string? BuildStudentJobTitle(string? value)
{
string? candidate = Limit(value, 256);
if (candidate is null)
{
return null;
}
candidate = candidate.Replace(
"COMPUTACIO NALES",
"COMPUTACIONALES",
StringComparison.OrdinalIgnoreCase);
string career = SpanishTextNormalizer.ToTitleCase(candidate);
const string degreePrefix = "Licenciatura en ";
if (career.StartsWith(degreePrefix, StringComparison.OrdinalIgnoreCase))
{
career = career[degreePrefix.Length..];
}
return TruncateAtWordBoundary($"Estudiante de {career}", 64);
}
private static string? BuildStreetAddress(
string? street,
string? neighborhood,
string? municipality,
string? city)
{
List<string> lines = [];
AddDistinct(lines, street);
AddDistinct(lines, neighborhood);
if (!string.Equals(municipality, city, StringComparison.OrdinalIgnoreCase))
{
AddDistinct(lines, municipality);
}
return Limit(string.Join("\r\n", lines), 1024);
}
private static void AddDistinct(List<string> values, string? candidate)
{
if (!string.IsNullOrWhiteSpace(candidate) &&
!values.Contains(candidate, StringComparer.OrdinalIgnoreCase))
{
values.Add(candidate);
}
}
private static string? NormalizePostalCode(string? value)
{
string? candidate = value?.Trim();
if (candidate is null ||
candidate.Length is < 4 or > 5 ||
!candidate.All(char.IsAsciiDigit))
{
return null;
}
return candidate.PadLeft(5, '0');
}
private static string? JoinNonEmpty(string separator, params string?[] values)
{
string result = string.Join(separator, values.Where(value => !string.IsNullOrWhiteSpace(value)));
return string.IsNullOrWhiteSpace(result) ? null : result;
}
private static string TruncateAtWordBoundary(string value, int maximumLength)
{
if (value.Length <= maximumLength)
{
return value;
}
int boundary = value.LastIndexOf(' ', maximumLength - 1, maximumLength);
return value[..(boundary > 0 ? boundary : maximumLength)].TrimEnd();
}
private static string? NormalizeSentence(string? value, int maximumLength)
{
string? candidate = Limit(value, maximumLength);
@@ -15,6 +15,16 @@ public static class SpanishTextNormalizer
StringComparer.OrdinalIgnoreCase);
public static string ToTitleCase(string value)
{
return ToTitleCase(value, lowercaseLeadingParticle: false);
}
public static string ToSurnameCase(string value)
{
return ToTitleCase(value, lowercaseLeadingParticle: true);
}
private static string ToTitleCase(string value, bool lowercaseLeadingParticle)
{
ArgumentNullException.ThrowIfNull(value);
@@ -23,7 +33,7 @@ public static class SpanishTextNormalizer
{
string lowercase = words[index].ToLower(SpanishCulture);
string comparisonToken = lowercase.Trim('(', ')', '[', ']', '{', '}', ',', '.', ';', ':');
words[index] = index > 0 && LowercaseParticles.Contains(comparisonToken)
words[index] = (index > 0 || lowercaseLeadingParticle) && LowercaseParticles.Contains(comparisonToken)
? lowercase
: CapitalizeCompound(lowercase);
}
+10 -2
View File
@@ -41,7 +41,12 @@ public sealed class BrokerOptions
throw new InvalidOperationException("The SGU profile response limit is outside the supported range.");
}
foreach (string profilePath in new[] { Ntlm.AdministrativeProfilePath, Ntlm.MenuProfilePath })
foreach (string profilePath in new[]
{
Ntlm.AdministrativeProfilePath,
Ntlm.StudentProfilePath,
Ntlm.MenuProfilePath
})
{
if (string.IsNullOrWhiteSpace(profilePath))
{
@@ -112,13 +117,16 @@ public sealed class NtlmOptions
public string Domain { get; init; } = string.Empty;
public int TimeoutSeconds { get; init; } = 15;
public int TimeoutSeconds { get; init; } = 30;
public int MaxRedirects { get; init; } = 5;
public string AdministrativeProfilePath { get; init; } =
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
public string StudentProfilePath { get; init; } =
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
public int MaxProfileBytes { get; init; } = 512 * 1024;
@@ -128,10 +128,16 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
if (profile is not null)
{
SetOptionalProperty(user, "displayName", profile.DisplayName);
SetOptionalProperty(user, "givenName", profile.GivenName);
SetOptionalProperty(user, "sn", profile.Surname);
SetOptionalProperty(user, "mail", profile.Email);
SetOptionalProperty(user, "title", profile.JobTitle);
SetOptionalProperty(user, "department", profile.Department);
SetOptionalProperty(user, "employeeType", profile.EmployeeType);
SetOptionalProperty(user, "streetAddress", profile.StreetAddress);
SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
@@ -133,9 +133,13 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
private Uri GetProfileUri(InstitutionalRole role)
{
Uri endpoint = new(options.Endpoint, UriKind.Absolute);
string path = role == InstitutionalRole.Administrative
? options.AdministrativeProfilePath
: options.MenuProfilePath;
string path = role switch
{
InstitutionalRole.Administrative => options.AdministrativeProfilePath,
InstitutionalRole.Student => options.StudentProfilePath,
InstitutionalRole.Professor => options.MenuProfilePath,
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
};
return new Uri(endpoint, path);
}
@@ -151,10 +155,17 @@ public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCreden
response.Content,
options.MaxProfileBytes,
timeoutToken).ConfigureAwait(false);
return identity.Role == InstitutionalRole.Administrative
? SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html)
: SguProfileParser.ParseMenu(html);
return identity.Role switch
{
InstitutionalRole.Administrative =>
SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Student =>
SguProfileParser.ParseStudent(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Professor => SguProfileParser.ParseMenu(html),
_ => null
};
}
catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
{
+2 -1
View File
@@ -29,9 +29,10 @@
"Ntlm": {
"Endpoint": "https://sgu.ulsa.edu.mx/",
"Domain": "",
"TimeoutSeconds": 15,
"TimeoutSeconds": 30,
"MaxRedirects": 5,
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
"MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288,
"AllowedRedirectHosts": [
@@ -10,7 +10,7 @@ internal sealed class ProviderSettings
public string DomainNetbios { get; init; } = "LCI";
public int TimeoutSeconds { get; init; } = 20;
public int TimeoutSeconds { get; init; } = 35;
public string ClientCertificateThumbprint { get; init; } = string.Empty;
@@ -1,7 +1,7 @@
{
"BrokerEndpoint": "https://sgu-auth.lci.lasalle.mx:8443/v1/authenticate",
"DomainNetbios": "LCI",
"TimeoutSeconds": 20,
"TimeoutSeconds": 35,
"ClientCertificateThumbprint": "0000000000000000000000000000000000000000",
"ServerCertificateThumbprint": "0000000000000000000000000000000000000000"
}
@@ -56,6 +56,97 @@ public sealed class SguProfileParserTests
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
}
[Fact]
public void ParsesTheRequiredStudentIdentityCareerAndAddressFields()
{
const string html = """
<html><body>
<div id="ctl00_contenedor_HistorialAlumno1_div1">
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP">MARÍA DEL CARMEN</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblApPatAlumnoHP">DE LA FUENTE</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblApMatAlumnoHP">O'CONNOR</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
</span>
</div>
<span id="ctl00_contenedor_HistorialAlumno1_Header1_lblCarrera">
LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIO NALES
</span>
<div id="ctl00_contenedor_HistorialAlumno1_div2">
<span id="ctl00_contenedor_HistorialAlumno1_lblDomicilioAlumnoHP">
AVENIDA DE LA UNIVERSIDAD 123
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblColoniaAlumnoHP">
COLONIA DEL VALLE
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblEstadoAlumnoHP">
CIUDAD DE MÉXICO
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP">
CIUDAD DE MÉXICO
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP">
ALCALDÍA BENITO JUÁREZ
</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP">8500</span>
</div>
</body></html>
""";
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
Assert.NotNull(profile);
Assert.Equal("123456", profile.EmployeeNumber);
Assert.Equal("María del Carmen de la Fuente O'Connor", profile.DisplayName);
Assert.Equal("María del Carmen", profile.GivenName);
Assert.Equal("de la Fuente O'Connor", profile.Surname);
Assert.Equal("alumna@lasalle.mx", profile.Email);
Assert.Equal("Alumno", profile.EmployeeType);
Assert.Equal(
"Estudiante de Ingeniería Cibernética y Sistemas Computacionales",
profile.JobTitle);
Assert.Null(profile.Department);
Assert.Equal(
"Avenida de la Universidad 123\r\nColonia del Valle\r\nAlcaldía Benito Juárez",
profile.StreetAddress);
Assert.Equal("Ciudad de México", profile.City);
Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode);
}
[Fact]
public void RejectsStudentMetadataForADifferentStudentNumber()
{
const string html = """
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP">PERSONA INCORRECTA</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">incorrecta@lasalle.mx</span>
""";
Assert.Null(SguProfileParser.ParseStudent(html, "654321"));
}
[Fact]
public void UsesMunicipalityAsStudentCityWhenThePortalCityIsMissing()
{
const string html = """
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP">SAN PEDRO CHOLULA</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP">72760</span>
""";
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
Assert.NotNull(profile);
Assert.Equal("San Pedro Cholula", profile.City);
Assert.Equal("San Pedro Cholula", profile.StreetAddress);
Assert.Equal("72760", profile.PostalCode);
}
[Fact]
public void ParsesTheMenuNameAsAConservativeFallback()
{
@@ -10,7 +10,7 @@ public sealed class BrokerClientTests
[Fact]
public void DefaultClientTimeoutLeavesMarginForThePortalAndBroker()
{
Assert.Equal(20, new ProviderSettings().TimeoutSeconds);
Assert.Equal(35, new ProviderSettings().TimeoutSeconds);
}
[Fact]