Tests/Sources.Tests.ps1

BeforeDiscovery {
    $script:Root = Split-Path -Parent (Split-Path -Parent $PSCommandPath)

    # Todos los ficheros de codigo del repositorio, pruebas incluidas.
    $script:Todos = @(
        Get-ChildItem -LiteralPath $script:Root -Recurse -Include '*.ps1', '*.psm1' -File |
            Where-Object { $_.FullName -notmatch '[\\/](out|\.git)[\\/]' } |
            Sort-Object -Property FullName |
            ForEach-Object { @{ Ruta = $_.FullName; Nombre = $_.Name } }
    )

    # Solo el modulo y el script de publicacion. Las pruebas quedan fuera de los
    # controles de contenido porque contienen a proposito los patrones prohibidos
    # (en expresiones regulares) para poder vigilarlos.
    $script:Motor = @($script:Todos | Where-Object { $_.Ruta -notmatch '[\\/]Tests[\\/]' })
}

BeforeAll {
    $script:Root = Split-Path -Parent (Split-Path -Parent $PSCommandPath)
}

# Estas pruebas no ejercitan comportamiento: vigilan el codigo fuente para que
# los defectos ya corregidos no puedan volver a entrar en una contribucion.
Describe 'Higiene de todo el codigo' {

    It '<Nombre> no pasa -and ni -or como argumento de un comando' -ForEach $script:Todos {
        # "if (Test-TechCareAdmin -and (Test-Path $x))" no es un AND: PowerShell
        # pasa -and y el resultado de Test-Path como argumentos posicionales a la
        # funcion, que no los declara, y descarta su VALOR. Medido con contadores:
        # la segunda condicion SI se ejecuta (1 llamada), solo se tira el
        # resultado, y se pierde el cortocircuito. En un modulo que borra ficheros
        # y para servicios, eso significa que cualquier segunda condicion con
        # efecto lateral se ejecutaria sin la guarda que el autor creia tener.
        $errores = $null
        $tokens = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile($Ruta, [ref]$tokens, [ref]$errores)
        @($errores).Count | Should -Be 0 -Because "$Nombre debe parsear sin errores"

        $comandos = @($ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true))
        $sospechosos = @(
            foreach ($c in $comandos) {
                foreach ($e in $c.CommandElements) {
                    if ($e -is [System.Management.Automation.Language.CommandParameterAst] -and
                        @('and', 'or') -contains $e.ParameterName) {
                        '{0}:{1}' -f $Nombre, $e.Extent.StartLineNumber
                    }
                }
            }
        )
        $sospechosos -join ' | ' | Should -BeNullOrEmpty -Because 'el operador logico necesita parentesis alrededor de la llamada'
    }

    It '<Nombre> empieza por BOM UTF-8 y es ASCII puro despues' -ForEach $script:Todos {
        # Sin BOM, Windows PowerShell 5.1 lee el fichero con la pagina de codigos
        # ANSI del sistema y cualquier byte no ASCII sale corrupto en la consola.
        $bytes = [System.IO.File]::ReadAllBytes($Ruta)
        $bytes.Length | Should -BeGreaterThan 3
        "$($bytes[0]),$($bytes[1]),$($bytes[2])" | Should -Be '239,187,191' -Because "$Nombre debe llevar BOM UTF-8"
        $noAscii = @($bytes[3..($bytes.Length - 1)] | Where-Object { $_ -gt 127 })
        $noAscii.Count | Should -Be 0 -Because "$Nombre debe ser ASCII puro tras el BOM"
    }
}

Describe 'Higiene del codigo del modulo' {

    It '<Nombre> no usa sintaxis que no exista en Windows PowerShell 5.1' -ForEach $script:Motor {
        $texto = Get-Content -LiteralPath $Ruta -Raw
        $texto | Should -Not -Match '\?\?' -Because 'el operador de fusion de nulos no existe en 5.1'
        $texto | Should -Not -Match '-Parallel\b' -Because 'ForEach-Object -Parallel no existe en 5.1'
        $texto | Should -Not -Match 'Join-String' -Because 'Join-String no existe en 5.1'
        $texto | Should -Not -Match '\$using:' -Because 'se usa con -Parallel, que esta prohibido'
    }

    It '<Nombre> no deja marcadores de trabajo pendiente' -ForEach $script:Motor {
        # Se usa [regex]::IsMatch y no -Match porque el operador -match de
        # PowerShell ignora las mayusculas: '\bTODO\b' casaba con la palabra
        # castellana "todo", que aparece de forma legitima en los comentarios.
        $texto = Get-Content -LiteralPath $Ruta -Raw
        foreach ($marcador in @('\bTODO\b', '\bFIXME\b', '\bXXX\b', 'TU[- ]?NOMBRE', 'TU[- ]?EMPRESA', 'TU[- ]?USUARIO')) {
            [regex]::IsMatch($texto, $marcador) | Should -BeFalse -Because "$Nombre no debe contener $marcador"
        }
    }
}

Describe 'Promesas del README verificadas contra el codigo' {

    BeforeAll {
        $script:Ficheros = @(
            Get-ChildItem -LiteralPath (Join-Path -Path $script:Root -ChildPath 'Public') -Filter '*.ps1' -File
            Get-ChildItem -LiteralPath (Join-Path -Path $script:Root -ChildPath 'Private') -Filter '*.ps1' -File
        )
        $script:Codigo = ($script:Ficheros | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }) -join "`n"
        # Version sin comentarios: un comentario que menciona un argumento
        # prohibido no es una llamada que lo use.
        $script:CodigoSinComentarios = (
            ($script:Codigo -split "`n") | Where-Object { $_ -notmatch '^\s*#' }
        ) -join "`n"
    }

    It 'no reinicia ni apaga el equipo en ningun punto' {
        $script:CodigoSinComentarios | Should -Not -Match 'Restart-Computer'
        $script:CodigoSinComentarios | Should -Not -Match 'Stop-Computer'
        $script:CodigoSinComentarios | Should -Not -Match 'shutdown(\.exe)?['']?\s'
    }

    It 'no desinstala aplicaciones integradas' {
        $script:CodigoSinComentarios | Should -Not -Match 'Remove-Appx'
    }

    It 'no desactiva Defender' {
        $script:CodigoSinComentarios | Should -Not -Match 'DisableRealtimeMonitoring'
        $script:CodigoSinComentarios | Should -Not -Match 'Set-MpPreference'
        $script:CodigoSinComentarios | Should -Not -Match 'Remove-MpPreference'
    }

    It 'no deshabilita Windows Update, y rearranca lo que para' {
        $script:CodigoSinComentarios | Should -Not -Match 'Set-Service[^\n]*Disabled'
        $script:CodigoSinComentarios | Should -Not -Match 'NoAutoUpdate'
        $script:CodigoSinComentarios | Should -Match 'Start-Service' -Because 'si se para un servicio, hay codigo que lo rearranca'
    }

    It 'chkdsk solo se invoca en modo escaneo, nunca con /f, /r ni /spotfix' {
        foreach ($prohibido in @("'/f'", "'/r'", "'/spotfix'")) {
            $script:CodigoSinComentarios | Should -Not -BeLike ('*' + $prohibido + '*')
        }
        $script:CodigoSinComentarios | Should -BeLike "*'/scan'*" -Because 'chkdsk se usa en modo solo lectura'
    }

    It 'DISM no usa /ResetBase, que haria irreversibles las actualizaciones' {
        $script:CodigoSinComentarios | Should -Not -BeLike "*'/ResetBase'*"
    }

    It 'no contacta ningun servidor: no hay descargas ni llamadas web' {
        # Sin telemetria es una promesa del README, y esta es su comprobacion.
        $script:CodigoSinComentarios | Should -Not -Match 'Invoke-WebRequest'
        $script:CodigoSinComentarios | Should -Not -Match 'Invoke-RestMethod'
        $script:CodigoSinComentarios | Should -Not -Match 'System\.Net\.WebClient'
        $script:CodigoSinComentarios | Should -Not -Match 'Start-BitsTransfer'
        $script:CodigoSinComentarios | Should -Not -Match 'New-Object\s+Net\.Sockets'
    }

    It 'Write-Host solo aparece en el unico sumidero de consola' {
        foreach ($f in $script:Ficheros) {
            if ($f.BaseName -eq 'Write-TechCareLog') { continue }
            (Get-Content -LiteralPath $f.FullName -Raw) | Should -Not -Match 'Write-Host' -Because "$($f.Name) debe registrar con Write-TechCareLog"
        }
    }

    It 'todo cmdlet publico que escribe consulta ShouldProcess de verdad' {
        # Declarar SupportsShouldProcess y no llamar a ShouldProcess seria una
        # promesa falsa: -WhatIf se aceptaria y no haria nada.
        foreach ($f in (Get-ChildItem -LiteralPath (Join-Path -Path $script:Root -ChildPath 'Public') -Filter '*.ps1' -File)) {
            $texto = Get-Content -LiteralPath $f.FullName -Raw
            if ($texto -notmatch 'SupportsShouldProcess') { continue }
            $texto | Should -Match 'ShouldProcess\(' -Because "$($f.Name) declara ShouldProcess y debe consultarlo"
        }
    }
}