From e384719ecb75eaa6f2ac83fdffd40577e1fd2c1c Mon Sep 17 00:00:00 2001 From: Brad Lance Date: Sun, 30 Aug 2026 20:28:32 -0500 Subject: [PATCH] Add per-practice logins, a standalone login test, and pre-commit checks The ChatGPT transcript showed each CAMBS practice has its own SFTP account (YSMD is YAM62682 on io.myallylogin.com:14147), not one shared entity login - so the wizard needed a way to set those without hand- writing nine practice.json files. Option 5 does that, and the settings screen now lists which login each practice will actually use. test-login.bat tests one account on its own, with the details typed in rather than read from a config, which isolates the account from whatever a script is doing to the credentials. It reports password length, stray whitespace, and $ / backtick characters - the last being the usual reason a login works in the WinSCP window but fails from a script, since "$pass" interpolates in PowerShell while '$pass' does not. On failure it prints the authentication methods the server offered. check.sh runs what has actually caught bugs here: non-ASCII characters, "$name?" (? is legal in an unbraced variable name, so it renders empty - this bit twice), parse errors, 5.1 syntax compatibility, and JSON validity. --- Setup-Entity.ps1 | 124 +++++++++++++++++++++++++- Test-Login.ps1 | 224 +++++++++++++++++++++++++++++++++++++++++++++++ VERSION.txt | 2 +- check.sh | 74 ++++++++++++++++ test-login.bat | 4 + 5 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 Test-Login.ps1 create mode 100755 check.sh create mode 100644 test-login.bat diff --git a/Setup-Entity.ps1 b/Setup-Entity.ps1 index 88bf9e9..c4d5820 100644 --- a/Setup-Entity.ps1 +++ b/Setup-Entity.ps1 @@ -323,6 +323,121 @@ function Add-Practices($entityName) { } } +function Get-PracticeNames($entityName) { + $export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1" + if (-not (Test-Path $export1)) { return @() } + return @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name }) +} + +# Same rules engine.ps1 uses: practice.json replaces or adds to entity.json for +# that one practice, so what gets tested here is what will actually be sent. +function Get-MergedPracticeConfig($entityName, $practiceName, $entityConfig) { + $file = Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName) "practice.json" + $merged = $entityConfig | ConvertTo-Json -Depth 10 | ConvertFrom-Json + if (-not (Test-Path $file)) { return $merged } + + $override = Get-Content $file -Raw | ConvertFrom-Json + foreach ($section in $override.PSObject.Properties) { + if ($section.Name -like "_*") { continue } + if ($null -eq $merged.PSObject.Properties[$section.Name]) { + $merged | Add-Member -NotePropertyName $section.Name -NotePropertyValue $section.Value + } elseif ($section.Value -is [System.Management.Automation.PSCustomObject]) { + foreach ($prop in $section.Value.PSObject.Properties) { + $merged.$($section.Name) | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force + } + } else { + Set-Field $merged $section.Name $section.Value + } + } + return $merged +} + +function Set-PracticeLogin($entityName, $entityConfig) { + $practices = Get-PracticeNames $entityName + if ($practices.Count -eq 0) { + Write-Host "" + Write-Host "No practice folders yet - add some first." -ForegroundColor Yellow + return + } + + $choice = Read-Choice "Which practice?" $practices 1 + $practiceName = $practices[$choice - 1] + $dir = Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $practiceName + $file = Join-Path $dir "practice.json" + + $existing = $null + if (Test-Path $file) { $existing = Get-Content $file -Raw | ConvertFrom-Json } + + Write-Section "$practiceName" + Write-Host "Anything you set here is used for $practiceName only." -ForegroundColor DarkGray + Write-Host "Anything you leave alone still comes from $entityName." -ForegroundColor DarkGray + + $ftps = [ordered]@{} + if ($existing -and $existing.ftps) { + foreach ($p in $existing.ftps.PSObject.Properties) { $ftps[$p.Name] = $p.Value } + } + + Write-Host "" + if (Read-YesNo "Does $practiceName have its own username and password?" "y") { + Write-Host "" + $ftps["username"] = Read-Answer "Username for $practiceName" $ftps["username"] + $keep = $false + if ($ftps["password"]) { + Write-Host "" + Write-Host "A password is already saved for $practiceName." -ForegroundColor DarkGray + $keep = Read-YesNo "Keep it?" "y" + } + if (-not $keep) { $ftps["password"] = Read-Answer "Password for $practiceName" $null } + } + + Write-Host "" + if (Read-YesNo "Does $practiceName use a different server than the rest of ${entityName}?" "n") { + $srv = Read-ServerDetails "$practiceName" $existing.ftps + $ftps["host"] = $srv.host + $ftps["protocol"] = $srv.protocol + $ftps["username"] = $srv.username + $ftps["password"] = $srv.password + } + + Write-Host "" + if (Read-YesNo "Does $practiceName use a different folder on the server?" "n") { + $ftps["tiff_path"] = Read-RemotePath "$practiceName's daily image files" $ftps["tiff_path"] + } + + if ($ftps.Count -eq 0) { + if (Test-Path $file) { + Remove-Item $file -Force + Write-Host "" + Write-Host "Nothing set - removed $practiceName's practice.json." -ForegroundColor Gray + } else { + Write-Host "" + Write-Host "Nothing set for $practiceName." -ForegroundColor Gray + } + return + } + + $out = [ordered]@{ + "_note" = "Settings for $practiceName only. Anything not listed comes from ..\..\entity.json." + ftps = [PSCustomObject]$ftps + } + ([PSCustomObject]$out) | ConvertTo-Json -Depth 10 | Set-Content -Path $file -Encoding UTF8 + Write-Host "" + Write-Host "Saved $file" -ForegroundColor Green +} + +function Show-PracticeLogins($entityName, $entityConfig) { + $practices = Get-PracticeNames $entityName + if ($practices.Count -eq 0) { return } + Write-Host "" + Write-Host " Practices" -ForegroundColor White + foreach ($p in $practices) { + $merged = Get-MergedPracticeConfig $entityName $p $entityConfig + $own = Test-Path (Join-Path (Join-Path (Join-Path (Join-Path $entitiesDir $entityName) "Export1") $p) "practice.json") + $tag = if ($own) { "own settings" } else { "uses $entityName's" } + Write-Host (" {0,-12} {1,-18} login: {2}" -f $p, $tag, (Show-Value $merged.ftps.username)) + } +} + function Edit-Entity($entityName) { $config = Get-EntityConfig $entityName if (-not $config) { @@ -340,15 +455,17 @@ function Edit-Entity($entityName) { while ($true) { Write-Header "$entityName - current settings" Show-Settings $config + Show-PracticeLogins $entityName $config $choice = Read-Choice "What do you want to change?" @( "The daily-upload server (address, protocol, login)", "Where the daily image files go on the server", "Where PT STMT files from the dated folder go", "Patient files (server and folder)", + "Give one practice its own login or folder", "Add practice folders", "Nothing - save and go back" - ) 6 + ) 7 switch ($choice) { 1 { @@ -396,8 +513,9 @@ function Edit-Entity($entityName) { Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray } } - 5 { Add-Practices $entityName } - 6 { + 5 { Set-PracticeLogin $entityName $config } + 6 { Add-Practices $entityName } + 7 { $file = Save-EntityConfig $entityName $config Copy-RunBat $entityName Write-Host "" diff --git a/Test-Login.ps1 b/Test-Login.ps1 new file mode 100644 index 0000000..5d0ea50 --- /dev/null +++ b/Test-Login.ps1 @@ -0,0 +1,224 @@ +# Standalone SFTP/FTP login test. Nothing is read from a config file - you type +# the details in - so it isolates the account from whatever a script is doing +# with it. Called by test-login.bat. + +$scriptRoot = Split-Path -Parent $PSCommandPath +$winscpDll = Join-Path $scriptRoot "WinSCP\WinSCPnet.dll" + +function Read-Answer($question, $default) { + while ($true) { + if ($default) { + Write-Host "$question " -NoNewline -ForegroundColor White + Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray + Write-Host ": " -NoNewline -ForegroundColor White + } else { + Write-Host "${question}: " -NoNewline -ForegroundColor White + } + $answer = Read-Host + if ([string]::IsNullOrWhiteSpace($answer)) { + if ($default) { return $default } + Write-Host " (required)" -ForegroundColor Yellow + continue + } + return $answer + } +} + +Write-Host "" +Write-Host ("=" * 64) -ForegroundColor Cyan +Write-Host " Login test" -ForegroundColor Cyan +Write-Host ("=" * 64) -ForegroundColor Cyan +Write-Host "" +Write-Host "Type the details exactly as they work in WinSCP or FileZilla." -ForegroundColor Gray + +if (-not (Test-Path $winscpDll)) { + Write-Host "" + Write-Host "WinSCPnet.dll not found at $winscpDll" -ForegroundColor Red + Write-Host "Run setup.bat first, or copy this script next to the WinSCP folder." -ForegroundColor Yellow + exit 1 +} +Get-ChildItem -Path (Join-Path $scriptRoot "WinSCP") -Recurse -File | + Unblock-File -ErrorAction SilentlyContinue +try { + Add-Type -Path $winscpDll +} catch { + Write-Host "Could not load WinSCPnet.dll - $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} + +Write-Host "" +$serverHost = Read-Answer "Server" $null +$serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim() + +$protoChoice = 0 +Write-Host "" +Write-Host "Protocol:" -ForegroundColor White +Write-Host " 1. SFTP" +Write-Host " 2. FTPS" +Write-Host " 3. FTP" +while ($protoChoice -lt 1 -or $protoChoice -gt 3) { + Write-Host "Choose 1-3 " -NoNewline -ForegroundColor White + Write-Host "[1]" -NoNewline -ForegroundColor DarkGray + Write-Host ": " -NoNewline -ForegroundColor White + $a = Read-Host + if ([string]::IsNullOrWhiteSpace($a)) { $a = "1" } + [int]::TryParse($a.Trim(), [ref]$protoChoice) | Out-Null +} +$protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1] +$defaultPort = if ($protocol -eq "sftp") { 22 } else { 21 } + +$port = [int](Read-Answer "Port" $defaultPort) +$username = Read-Answer "Username" $null +Write-Host "" +Write-Host "Paste the password. It is not echoed and not saved anywhere." -ForegroundColor DarkGray +Write-Host "Password: " -NoNewline -ForegroundColor White +$secure = Read-Host -AsSecureString +$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) +$password = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) +[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + +# -- What the credentials actually contain ------------------------------------ +Write-Host "" +Write-Host ("-" * 64) +Write-Host "What was entered" -ForegroundColor Cyan +Write-Host ("-" * 64) +Write-Host " Server : [$serverHost] port $port over $($protocol.ToUpper())" +Write-Host " Username : [$username] ($($username.Length) characters)" +Write-Host " Password : $($password.Length) characters" + +$warned = $false +if ($username -ne $username.Trim()) { + Write-Host " !! The username has a leading or trailing space." -ForegroundColor Yellow + $warned = $true +} +if ($password -ne $password.Trim()) { + Write-Host " !! The password has a leading or trailing space." -ForegroundColor Yellow + $warned = $true +} +if ($password -match '[\$`]') { + Write-Host " !! The password contains a `$ or a backtick." -ForegroundColor Yellow + Write-Host " In a PowerShell script these MUST be in single quotes:" -ForegroundColor Yellow + Write-Host " `$Password = 'the`$password' <- correct" -ForegroundColor Yellow + Write-Host " `$Password = `"the`$password`" <- WRONG, PowerShell eats `$password" -ForegroundColor Yellow + Write-Host " This is the most common reason a login works in the WinSCP" -ForegroundColor Yellow + Write-Host " window but fails from a script with the same credentials." -ForegroundColor Yellow + $warned = $true +} +if ($password -match '[^\x20-\x7E]') { + Write-Host " !! The password contains a non-standard character." -ForegroundColor Yellow + $warned = $true +} +if (-not $warned) { + Write-Host " Nothing unusual about either string." -ForegroundColor Green +} + +# -- Try it ------------------------------------------------------------------- +$logFile = Join-Path $scriptRoot "login-test.log" +if (Test-Path $logFile) { Remove-Item $logFile -Force } + +$opts = New-Object WinSCP.SessionOptions +$opts.HostName = $serverHost +$opts.PortNumber = $port +$opts.UserName = $username +$opts.Password = $password +if ($protocol -eq "sftp") { + $opts.Protocol = [WinSCP.Protocol]::Sftp + $opts.GiveUpSecurityAndAcceptAnySshHostKey = $true +} else { + $opts.Protocol = [WinSCP.Protocol]::Ftp + if ($protocol -eq "ftps") { + $opts.FtpSecure = [WinSCP.FtpSecure]::Explicit + $opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true + } else { + $opts.FtpSecure = [WinSCP.FtpSecure]::None + } +} + +Write-Host "" +Write-Host ("-" * 64) +Write-Host "Connecting" -ForegroundColor Cyan +Write-Host ("-" * 64) + +$session = New-Object WinSCP.Session +$session.SessionLogPath = $logFile +$connected = $false +try { + $session.Open($opts) + $connected = $true + Write-Host " CONNECTED" -ForegroundColor Green +} catch { + Write-Host " FAILED" -ForegroundColor Red + Write-Host "" + Write-Host " $($_.Exception.Message)" -ForegroundColor Red +} + +if ($connected) { + try { + Write-Host "" + Write-Host " Home directory: $($session.HomePath)" -ForegroundColor Gray + Write-Host " Folders visible at the top level:" -ForegroundColor Gray + $listing = $session.ListDirectory($session.HomePath) + $shown = 0 + foreach ($item in $listing.Files) { + if ($item.Name -eq "." -or $item.Name -eq "..") { continue } + $kind = if ($item.IsDirectory) { "[dir] " } else { " " } + Write-Host " $kind$($item.Name)" + $shown++ + if ($shown -ge 25) { Write-Host " ... (more)"; break } + } + if ($shown -eq 0) { Write-Host " (nothing - the account may be locked to an empty folder)" } + } catch { + Write-Host " Connected, but could not list the folder: $($_.Exception.Message)" -ForegroundColor Yellow + } + + Write-Host "" + $checkPath = Read-Answer "Remote folder to check (or . to skip)" "." + if ($checkPath -ne ".") { + try { + if ($session.FileExists($checkPath)) { + Write-Host " $checkPath ... found" -ForegroundColor Green + } else { + Write-Host " $checkPath ... NOT THERE" -ForegroundColor Red + } + } catch { + Write-Host " Could not check: $($_.Exception.Message)" -ForegroundColor Yellow + } + } +} +$session.Dispose() + +# -- The authentication conversation ------------------------------------------ +if (-not $connected -and (Test-Path $logFile)) { + Write-Host "" + Write-Host ("-" * 64) + Write-Host "What the server actually said" -ForegroundColor Cyan + Write-Host ("-" * 64) + $interesting = Select-String -Path $logFile -Pattern "authentic|password|keyboard|publickey|banner|denied|method" -AllMatches + if ($interesting) { + foreach ($line in ($interesting | Select-Object -First 30)) { + Write-Host " $($line.Line.Trim())" -ForegroundColor Gray + } + Write-Host "" + Write-Host " Look for the line listing which methods the server offers." -ForegroundColor Yellow + Write-Host " If it offers only 'publickey', a password will never work and" -ForegroundColor Yellow + Write-Host " the partner has to either enable password auth or issue a key." -ForegroundColor Yellow + } else { + Write-Host " (nothing recognisable - see the full log)" -ForegroundColor Gray + } + Write-Host "" + Write-Host " Full log: $logFile" -ForegroundColor Cyan + Write-Host " It contains no password. Send it to Brad." -ForegroundColor Cyan +} + +Write-Host "" +Write-Host ("=" * 64) -ForegroundColor Cyan +if ($connected) { + Write-Host " Login works. The account is fine - so if a script using these" -ForegroundColor Green + Write-Host " same details still fails, the script is mangling one of them." -ForegroundColor Green +} else { + Write-Host " Login failed. Compare against the WinSCP window: File > Session" -ForegroundColor Yellow + Write-Host " > Generate Session URL/Code > .NET assembly code. That shows the" -ForegroundColor Yellow + Write-Host " exact settings the working connection uses." -ForegroundColor Yellow +} +Write-Host ("=" * 64) -ForegroundColor Cyan +Write-Host "" diff --git a/VERSION.txt b/VERSION.txt index 9bcac86..b59f150 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -490c308 2026-08-26 setup wizard + any-file-type upload +PLACEHOLDER 2026-08-30 per-practice logins + login test diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..055a391 --- /dev/null +++ b/check.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Pre-commit checks for the PowerShell scripts. Run from the repo root. +# Needs pwsh on PATH (see the project notes for how it was installed). +set -uo pipefail +cd "$(dirname "$0")" +fail=0 + +echo "== ASCII only ==" +# Windows PowerShell 5.1 reads a BOM-less .ps1 in the system codepage, which +# turns any multi-byte character into mojibake - including quote characters +# that break string literals mid-script. +for f in *.ps1; do + if LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" >/dev/null; then + echo " FAIL $f contains non-ASCII:" + LC_ALL=C grep -nP '[^\x00-\x7F]' "$f" | head -5 + fail=1 + else + echo " ok $f" + fi +done + +echo +echo "== \$var? in a string ==" +# ? is a legal character in an unbraced PowerShell variable name, so "$name?" +# looks up a variable called name? and silently renders empty. Use ${name}?. +if grep -nP '\$\w+\?' ./*.ps1; then + echo " FAIL - wrap those as \${name}? or the text disappears at runtime" + fail=1 +else + echo " ok none found" +fi + +echo +echo "== parse + Windows PowerShell 5.1 compatibility ==" +pwsh -NoProfile -Command ' +$bad = 0 +foreach ($f in Get-ChildItem -Filter *.ps1) { + $e = $null + [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$e) | Out-Null + if ($e.Count) { + $bad++ + Write-Host " FAIL $($f.Name)" + $e | ForEach-Object { " line $($_.Extent.StartLineNumber): $($_.Message)" } + } else { Write-Host " ok $($f.Name)" } +} +if (Get-Module -ListAvailable PSScriptAnalyzer) { + Import-Module PSScriptAnalyzer + $s = @{ Rules = @{ PSUseCompatibleSyntax = @{ Enable = $true; TargetVersions = @("5.1","7.0") } } } + $c = Invoke-ScriptAnalyzer -Path . -Recurse -Settings $s -IncludeRule PSUseCompatibleSyntax + if ($c) { $bad++; $c | ForEach-Object { " FAIL 5.1: $($_.ScriptName):$($_.Line) $($_.Message)" } } + else { Write-Host " ok 5.1 syntax clean" } +} else { + Write-Host " skip PSScriptAnalyzer not installed" +} +exit $bad +' || fail=1 + +echo +echo "== JSON templates ==" +python3 - <<'PY' || fail=1 +import glob, json, sys +bad = 0 +for f in glob.glob("Entities/**/*.json", recursive=True) + ["settings.json"]: + try: + json.load(open(f)) + print(" ok %s" % f) + except Exception as e: + print(" FAIL %s: %s" % (f, e)); bad = 1 +sys.exit(bad) +PY + +echo +if [ "$fail" -eq 0 ]; then echo "ALL CHECKS PASSED"; else echo "CHECKS FAILED"; fi +exit $fail diff --git a/test-login.bat b/test-login.bat new file mode 100644 index 0000000..675d3e9 --- /dev/null +++ b/test-login.bat @@ -0,0 +1,4 @@ +@echo off +REM Tests one SFTP/FTP login on its own. Nothing is read from a config file. +powershell.exe -ExecutionPolicy Bypass -File "%~dp0Test-Login.ps1" +pause