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.
This commit is contained in:
2026-08-30 20:28:32 -05:00
parent cfa83d91d5
commit e384719ecb
5 changed files with 424 additions and 4 deletions
+121 -3
View File
@@ -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) { function Edit-Entity($entityName) {
$config = Get-EntityConfig $entityName $config = Get-EntityConfig $entityName
if (-not $config) { if (-not $config) {
@@ -340,15 +455,17 @@ function Edit-Entity($entityName) {
while ($true) { while ($true) {
Write-Header "$entityName - current settings" Write-Header "$entityName - current settings"
Show-Settings $config Show-Settings $config
Show-PracticeLogins $entityName $config
$choice = Read-Choice "What do you want to change?" @( $choice = Read-Choice "What do you want to change?" @(
"The daily-upload server (address, protocol, login)", "The daily-upload server (address, protocol, login)",
"Where the daily image files go on the server", "Where the daily image files go on the server",
"Where PT STMT files from the dated folder go", "Where PT STMT files from the dated folder go",
"Patient files (server and folder)", "Patient files (server and folder)",
"Give one practice its own login or folder",
"Add practice folders", "Add practice folders",
"Nothing - save and go back" "Nothing - save and go back"
) 6 ) 7
switch ($choice) { switch ($choice) {
1 { 1 {
@@ -396,8 +513,9 @@ function Edit-Entity($entityName) {
Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray
} }
} }
5 { Add-Practices $entityName } 5 { Set-PracticeLogin $entityName $config }
6 { 6 { Add-Practices $entityName }
7 {
$file = Save-EntityConfig $entityName $config $file = Save-EntityConfig $entityName $config
Copy-RunBat $entityName Copy-RunBat $entityName
Write-Host "" Write-Host ""
+224
View File
@@ -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 ""
+1 -1
View File
@@ -1 +1 @@
490c308 2026-08-26 setup wizard + any-file-type upload PLACEHOLDER 2026-08-30 per-practice logins + login test
Executable
+74
View File
@@ -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
+4
View File
@@ -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