diff --git a/README.md b/README.md index 6cb10a1..3693252 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,12 @@ 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 after the lightweight -NTLM root has accepted the credential. +page does not expose it. Professors use the portal menu name as their safe base +profile and then attempt the same personal and location modules as administrative +staff. A missing route, non-success response, timeout, or absent element ID merely +skips that optional field. Missing or changed presentation HTML never blocks +authentication or password synchronization after the lightweight NTLM root has +accepted the credential. Operational documentation: diff --git a/docs/architecture.md b/docs/architecture.md index ff61d79..49e2342 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,9 +28,11 @@ administrative incident overview for `AD`, the student information page for `AL`, or the portal menu for `DO`. After the incident page confirms an `AD` 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 +`datos/ubicacion.aspx`. Docentes keep the menu name as a base and attempt those +same two shared staff modules without requiring them to exist. A supplemental +404, changed/missing element ID, other failure, or timeout 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 kept only in an in-memory per-request container and are never persisted or returned to the client. @@ -60,9 +62,12 @@ updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`, `postalCode` attributes. Administrative and student numbers must match the six numeric digits of the requested identity before any role-specific metadata is 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 -values and never changes the password outcome. +that incident-page match. A docente's supplemental fields remain tied to the +fresh NTLM-authenticated portal session and are optional; the menu display name +remains usable if neither shared page is available. 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 @@ -91,7 +96,11 @@ synthetic Windows account to imitate that title or ordering. The managed hierarchy is rooted at `OU=Usuarios-SGU`: `Docentes`, `Alumnos`, and `Administrativos` are direct child OUs beneath it. The domain GPO `SGU - User session restrictions` is linked to this root and enables the -per-user `DisableLockWorkstation` policy for the complete hierarchy. +per-user `DisableLockWorkstation` policy and disables screen savers for the +complete hierarchy. The computer GPO `SGU - Windows client experience` is linked +to `OU=Laboratorio`; it suppresses first-logon/privacy/diagnostic prompts, +disables location, and enforces always-on display, sleep, and hibernation +settings for managed clients. Per-user synchronization is serialized inside the broker to prevent concurrent create/reset races. Production deployments should run the broker as a gMSA with diff --git a/docs/broker-operations.md b/docs/broker-operations.md index 76c4899..b86106d 100644 --- a/docs/broker-operations.md +++ b/docs/broker-operations.md @@ -50,8 +50,9 @@ Eso es comportamiento esperado, no una caída del servicio. - El broker permite hasta **20 segundos** para el desafío NTLM ligero de `/psulsa/`. El enriquecimiento usa el límite total independiente `ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio— - y conserva los campos que alcance a obtener si una página administrativa se - retrasa o falla. El Credential Provider mantiene su propio límite de **90 + y conserva los campos que alcance a obtener si una página de personal se + retrasa, no existe o cambia sus IDs. Esto incluye los módulos opcionales de + nombre y ubicación para docentes. El Credential Provider mantiene su propio límite de **90 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 diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md index 66c2282..7880d23 100644 --- a/docs/client-enrollment.md +++ b/docs/client-enrollment.md @@ -82,17 +82,29 @@ siguiente arranque síncrono y el fondo se aplica al siguiente inicio de sesión del usuario SGU. En el controlador de dominio, aplicar una vez la directiva de usuario al árbol -completo `Usuarios-SGU`: +completo `Usuarios-SGU` y la directiva de equipo a `Laboratorio`: ```powershell .\Set-SguDomainUserPolicies.ps1 +.\Set-SguDomainComputerPolicies.ps1 ``` El script crea y vincula de forma idempotente el GPO `SGU - User session restrictions` y configura `DisableLockWorkstation=1` para -Administrativos, Alumnos y Docentes. La directiva elimina la acción manual de -bloqueo; no sustituye otras directivas independientes de protector de pantalla, -inactividad o desconexión de RDP. +Administrativos, Alumnos y Docentes; también deshabilita el protector de +pantalla. El segundo crea `SGU - Windows client experience` y, antes del primer +inicio de sesión de dominio, suprime la experiencia de privacidad, las +notificaciones y controles de telemetría, la animación **Hi / Preparing +Windows**, y el acceso a ubicación. Además fija en **Nunca** el apagado de +pantalla, suspensión, hibernación y suspensión híbrida, conectado a corriente o +batería. El guard de enrolamiento vuelve a aplicar `powercfg /hibernate off` y +los tiempos en cero al inicio y diariamente. + +`HideEULAPage` no forma parte de esta GPO: es una opción de archivo Unattend para +la fase OOBE y Microsoft la reserva para pruebas de OEM/System Builder. La GPO +usa las alternativas soportadas `DisablePrivacyExperience=1` y +`EnableFirstLogonAnimation=0`, que son las que afectan a cada cuenta de dominio +nueva. ## 3. Verificación y reparación @@ -134,5 +146,10 @@ Referencias de Microsoft: - - - +- +- +- +- +- - - diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md index 6665fb2..29c9645 100644 --- a/docs/lab-runbook.md +++ b/docs/lab-runbook.md @@ -4,8 +4,8 @@ Validated lab inventory: - Domain controller/broker: Windows Server 2025 Standard, `WIN-1AIQMMA1EPR.lci.lasalle.mx`, `192.168.50.10`. -- Client: Windows 10 Pro 22H2, - `DESKTOP-U1I3BNN.lci.lasalle.mx`, `192.168.50.20`. +- Client: no active Windows client VM. The next enrollment target is a clean + Windows 11 Enterprise VM placed in `OU=Laboratorio`. - Domain: `lci.lasalle.mx` / `LCI`. - Private switch: `Laboratorio AD`. @@ -133,14 +133,18 @@ Use Lithnet's `Invoke-CredUI` test utility when available, or sign out/restart the VM and select **Acceso institucional SGU**. Keep the built-in Windows password provider available through **Other user**. -On the domain controller, apply the SGU user policy once: +On the domain controller, apply the SGU user and computer policies once: ```powershell .\Set-SguDomainUserPolicies.ps1 +.\Set-SguDomainComputerPolicies.ps1 ``` It links `SGU - User session restrictions` to `OU=Usuarios-SGU` and prevents -managed users from manually locking the workstation. +managed users from manually locking the workstation or starting a screen saver. +It also links `SGU - Windows client experience` to `OU=Laboratorio` to suppress +first-logon privacy/diagnostic/location screens and keep display, sleep, and +hibernation disabled. Before testing through Hyper-V Enhanced Session/RDP, enable the dedicated lab group and Windows PowerShell Remoting: diff --git a/docs/security.md b/docs/security.md index e96c323..32778c0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -43,12 +43,16 @@ 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. +- Professor enrichment keeps the menu display name as its base and optionally + reads only the same name and postal-address element IDs used by staff pages. + A missing professor route or element never makes authentication fail. - Incident details, calendars, photographs, manager names, and manager positions are deliberately ignored. - The employee or student number must match the authenticated `AD` or `AL` key before role-specific metadata is synchronized. The two supplemental administrative pages are never requested unless the incident page supplied - the matching employee number. + the matching employee number. Professor supplemental data comes from the + same fresh, request-scoped NTLM session as its menu fallback. - If SGU changes its HTML, authentication and exact-password synchronization continue without enrichment; existing AD metadata is not erased. - Slow profile pages cannot change an accepted credential into a rejection. The diff --git a/docs/windows-client-onboarding.md b/docs/windows-client-onboarding.md index 762e6fe..e781e02 100644 --- a/docs/windows-client-onboarding.md +++ b/docs/windows-client-onboarding.md @@ -63,6 +63,8 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass ` El script realiza de forma idempotente lo siguiente: +- fija pantalla, suspensión e hibernación en **Nunca** para corriente y batería, + y ejecuta `powercfg /hibernate off`; - habilita RDP y conserva Network Level Authentication; - habilita las reglas RDP solo para el perfil Domain; - autoriza el grupo SGU dedicado; @@ -121,6 +123,12 @@ contraseña incorrecta. Comprobar que la cuenta pertenece a `SG-Laboratorio-Usuarios-RDP` y que ese grupo aparece en **Remote Desktop Users**. Las directivas de grupo de dominio prevalecen sobre la política local. +En el controlador de dominio, `Set-SguDomainComputerPolicies.ps1` crea y vincula +la GPO `SGU - Windows client experience` a `OU=Laboratorio`. Esa GPO refuerza la +configuración siempre activa y evita las experiencias iniciales de privacidad, +telemetría, ubicación y **Hi / Preparing Windows** antes de que un usuario SGU +entre por primera vez. + Microsoft documenta este derecho en: y PowerShell Remoting en: diff --git a/scripts/Enable-LabRemoteAccess.ps1 b/scripts/Enable-LabRemoteAccess.ps1 index aedd79a..ca3d5d4 100644 --- a/scripts/Enable-LabRemoteAccess.ps1 +++ b/scripts/Enable-LabRemoteAccess.ps1 @@ -20,6 +20,23 @@ $remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32- $remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1] if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) { + foreach ($powerChange in @( + @('monitor-timeout-ac', '0'), + @('monitor-timeout-dc', '0'), + @('standby-timeout-ac', '0'), + @('standby-timeout-dc', '0'), + @('hibernate-timeout-ac', '0'), + @('hibernate-timeout-dc', '0'))) { + & powercfg.exe /change $powerChange[0] $powerChange[1] + if ($LASTEXITCODE -ne 0) { + throw "powercfg /change $($powerChange[0]) failed with exit code $LASTEXITCODE." + } + } + & powercfg.exe /hibernate off + if ($LASTEXITCODE -ne 0) { + throw "powercfg /hibernate off failed with exit code $LASTEXITCODE." + } + Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' ` -Name fDenyTSConnections -Type DWord -Value 0 Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' ` @@ -87,4 +104,5 @@ $rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorActio TermService = (Get-Service TermService).Status WinRM = (Get-Service WinRM).Status FirewallProfile = 'Domain' + AlwaysOnPowerPolicyApplied = $true } diff --git a/scripts/Set-SguDomainComputerPolicies.ps1 b/scripts/Set-SguDomainComputerPolicies.ps1 new file mode 100644 index 0000000..1056205 --- /dev/null +++ b/scripts/Set-SguDomainComputerPolicies.ps1 @@ -0,0 +1,124 @@ +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx', + [string]$GpoName = 'SGU - Windows client experience', + [string]$DomainController = $env:COMPUTERNAME +) + +$ErrorActionPreference = 'Stop' +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this script from an elevated Windows PowerShell session on a domain controller or management host.' +} + +Import-Module ActiveDirectory -ErrorAction Stop +Import-Module GroupPolicy -ErrorAction Stop + +$targetOu = Get-ADOrganizationalUnit ` + -Identity $TargetOuDn ` + -Server $DomainController ` + -ErrorAction Stop +$domainDn = ($targetOu.DistinguishedName -split ',DC=', 2)[1] +if (-not $domainDn) { + throw 'TargetOuDn does not contain a domain distinguished name.' +} +$domainName = ($domainDn -replace ',DC=', '.') + +$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue +if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) { + $gpo = New-GPO -Name $GpoName -Domain $domainName -Server $DomainController +} +if (-not $gpo) { + throw "The GPO '$GpoName' does not exist and was not created." +} + +$existingLink = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks | + Where-Object DisplayName -eq $GpoName | + Select-Object -First 1 +$existingLinkEnabled = $existingLink -and ( + $existingLink.Enabled -eq $true -or + [string]$existingLink.Enabled -eq 'Yes') +if (-not $existingLink) { + if ($PSCmdlet.ShouldProcess($TargetOuDn, "Link and enable '$GpoName'")) { + New-GPLink ` + -Name $GpoName ` + -Target $TargetOuDn ` + -Domain $domainName ` + -Server $DomainController ` + -LinkEnabled Yes | Out-Null + } +} +elseif (-not $existingLinkEnabled -and + $PSCmdlet.ShouldProcess($TargetOuDn, "Enable the '$GpoName' link")) { + Set-GPLink ` + -Name $GpoName ` + -Target $TargetOuDn ` + -Domain $domainName ` + -Server $DomainController ` + -LinkEnabled Yes | Out-Null +} + +$dataCollectionKey = 'HKLM\Software\Policies\Microsoft\Windows\DataCollection' +$powerPolicyRoot = 'HKLM\Software\Policies\Microsoft\Power\PowerSettings' +$policies = @( + @{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Value = 0 }, + @{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Value = 1 }, + @{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInChangeNotification'; Value = 1 }, + @{ Key = $dataCollectionKey; Name = 'DisableDiagnosticDataViewer'; Value = 1 }, + @{ Key = 'HKLM\Software\Policies\Microsoft\Windows\OOBE'; Name = 'DisablePrivacyExperience'; Value = 1 }, + @{ Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'; Name = 'EnableFirstLogonAnimation'; Value = 0 }, + @{ Key = 'HKLM\Software\Policies\Microsoft\Windows\LocationAndSensors'; Name = 'DisableLocation'; Value = 1 }, + @{ Key = 'HKLM\Software\Policies\Microsoft\Windows\AppPrivacy'; Name = 'LetAppsAccessLocation'; Value = 2 } +) + +$powerSettingIds = @( + '3c0bc021-c8a8-4e07-a973-6b14cbcb2b7e', # Turn off display after + '29f6c1db-86da-48c5-9fdb-f2b67b1f44da', # Sleep after + '9d7815a6-7ee4-497e-8888-515a05f02364', # Hibernate after + '94ac6d29-73ce-41a6-809f-6363ba21b47e' # Allow hybrid sleep +) +foreach ($settingId in $powerSettingIds) { + $settingKey = "$powerPolicyRoot\$settingId" + $policies += @{ Key = $settingKey; Name = 'ACSettingIndex'; Value = 0 } + $policies += @{ Key = $settingKey; Name = 'DCSettingIndex'; Value = 0 } +} + +foreach ($policy in $policies) { + if ($PSCmdlet.ShouldProcess($GpoName, "Set $($policy.Key)\$($policy.Name)=$($policy.Value)")) { + Set-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $policy.Key ` + -ValueName $policy.Name ` + -Type DWord ` + -Value $policy.Value | Out-Null + } +} + +$configuredPolicies = [ordered]@{} +foreach ($policy in $policies) { + $configured = Get-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $policy.Key ` + -ValueName $policy.Name + $configuredPolicies[$policy.Name + '@' + $policy.Key] = [int]$configured.Value +} +$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks | + Where-Object DisplayName -eq $GpoName | + Select-Object -First 1 +$linkEnabled = $link -and ( + $link.Enabled -eq $true -or + [string]$link.Enabled -eq 'Yes') + +[pscustomobject]@{ + GpoName = $GpoName + GpoId = $gpo.Id + TargetOu = $TargetOuDn + LinkEnabled = [bool]$linkEnabled + PolicyCount = $configuredPolicies.Count + Policies = [pscustomobject]$configuredPolicies +} diff --git a/scripts/Set-SguDomainUserPolicies.ps1 b/scripts/Set-SguDomainUserPolicies.ps1 index 22b87e9..43cc0b8 100644 --- a/scripts/Set-SguDomainUserPolicies.ps1 +++ b/scripts/Set-SguDomainUserPolicies.ps1 @@ -8,6 +8,7 @@ param( $ErrorActionPreference = 'Stop' $policyKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System' $policyValueName = 'DisableLockWorkstation' +$desktopPolicyKey = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop' $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) @@ -71,6 +72,15 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w -ValueName $policyValueName ` -Type DWord ` -Value 1 | Out-Null + + Set-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $desktopPolicyKey ` + -ValueName 'ScreenSaveActive' ` + -Type String ` + -Value '0' | Out-Null } $configuredValue = Get-GPRegistryValue ` @@ -79,6 +89,12 @@ $configuredValue = Get-GPRegistryValue ` -Server $DomainController ` -Key $policyKey ` -ValueName $policyValueName +$screenSaverValue = Get-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $desktopPolicyKey ` + -ValueName 'ScreenSaveActive' $link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks | Where-Object DisplayName -eq $GpoName | Select-Object -First 1 @@ -92,4 +108,5 @@ $linkEnabled = $link -and ( TargetOu = $TargetOuDn LinkEnabled = [bool]$linkEnabled DisableLockWorkstation = [int]$configuredValue.Value + ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0' } diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index 967b595..46fbe39 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -325,15 +325,19 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator response, identity, timeout.Token).ConfigureAwait(false); - if (identity.Role == InstitutionalRole.Administrative && - string.Equals( - profile?.EmployeeNumber, - identity.NumericId, - StringComparison.Ordinal)) + bool mayEnrichStaffProfile = + (identity.Role == InstitutionalRole.Administrative && + string.Equals( + profile?.EmployeeNumber, + identity.NumericId, + StringComparison.Ordinal)) || + (identity.Role == InstitutionalRole.Professor && profile is not null); + if (mayEnrichStaffProfile) { - profile = await TryEnrichAdministrativeProfileAsync( + profile = await TryEnrichStaffProfileAsync( client, profile!, + identity.Role, allowedHosts, timeout.Token, cancellationToken, @@ -394,15 +398,16 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator return null; } - private async Task TryEnrichAdministrativeProfileAsync( + private async Task TryEnrichStaffProfileAsync( HttpClient client, - InstitutionalProfile verifiedProfile, + InstitutionalProfile baseProfile, + InstitutionalRole role, HashSet allowedHosts, CancellationToken timeoutToken, CancellationToken requestCancellationToken, Stopwatch elapsed) { - InstitutionalProfile profile = verifiedProfile; + InstitutionalProfile profile = baseProfile; (string Path, Func Parser)[] pages = [ (options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal), @@ -423,7 +428,8 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested) { logger.LogWarning( - "SGU administrative profile enrichment reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.", + "SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.", + role, elapsed.ElapsedMilliseconds); break; } @@ -431,7 +437,8 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator { logger.LogWarning( exception, - "An optional SGU administrative profile page failed after {ElapsedMilliseconds} ms; preserving fields already collected.", + "An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.", + role, elapsed.ElapsedMilliseconds); } } diff --git a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs index 7adc271..a36caee 100644 --- a/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs +++ b/tests/SGU.AuthBroker.Tests/NtlmCredentialValidatorTests.cs @@ -23,6 +23,12 @@ public sealed class NtlmCredentialValidatorTests "123456", InstitutionalRole.Student); + private static readonly UserIdentity Professor = new( + "DO123456", + "DO", + "123456", + InstitutionalRole.Professor); + [Fact] public async Task RedirectAndSuccessWithoutChallengeNeverAuthorizes() { @@ -192,6 +198,94 @@ public sealed class NtlmCredentialValidatorTests Assert.Equal(3, handler.RequestPaths.Count); } + [Fact] + public async Task ProfessorProfileIsEnrichedFromSharedStaffPages() + { + SequenceHandler handler = new( + Challenge(), + Response(HttpStatusCode.OK), + Response( + HttpStatusCode.OK, + """ + MARÍA DEL CARMEN + """), + Response( + HttpStatusCode.OK, + """ + + + + """), + Response( + HttpStatusCode.OK, + """ + + + + + + + """)); + NtlmCredentialValidator validator = CreateValidator(handler); + + NtlmValidationResult result = await validator.ValidateAsync( + Professor, + "test-password", + TestContext.Current.CancellationToken); + + Assert.Equal(NtlmValidationStatus.Valid, result.Status); + Assert.NotNull(result.Profile); + 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("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/menu.aspx", + "/psulsa/gadmon/capitalhumano/datos/personales.aspx", + "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx" + ], + handler.RequestPaths); + } + + [Fact] + public async Task MissingProfessorStaffPageOrElementIdsPreservesMenuProfile() + { + SequenceHandler handler = new( + Challenge(), + Response(HttpStatusCode.OK), + Response( + HttpStatusCode.OK, + """ + MIGUEL DE CERVANTES + """), + Response(HttpStatusCode.OK, "Unrecognized layout"), + Response(HttpStatusCode.NotFound)); + NtlmCredentialValidator validator = CreateValidator(handler); + + NtlmValidationResult result = await validator.ValidateAsync( + Professor, + "test-password", + TestContext.Current.CancellationToken); + + Assert.Equal(NtlmValidationStatus.Valid, result.Status); + Assert.NotNull(result.Profile); + Assert.Equal("Miguel de Cervantes", result.Profile.DisplayName); + Assert.Null(result.Profile.StreetAddress); + Assert.Equal(5, handler.RequestPaths.Count); + } + private static NtlmCredentialValidator CreateValidator(SequenceHandler handler) { BrokerOptions options = new()