diff --git a/.editorconfig b/.editorconfig index 91083fa..15887d6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,7 +29,7 @@ indent_size = 4 tab_width = 4 # New line preferences -insert_final_newline = false +insert_final_newline = true #### .NET Coding Conventions #### [*.{cs,vb}] diff --git a/.es-upgrade-89.ps1 b/.es-upgrade-89.ps1 new file mode 100644 index 0000000..3cd046c --- /dev/null +++ b/.es-upgrade-89.ps1 @@ -0,0 +1,187 @@ +# Ticket #89 row: MSI upgrade over a Copy-Item-strayed trust helper on the Event Server +# machine. Mirrors the IN-3 row-1 scenario WITH the fix: the helper now carries the build's +# nbgv stamp, so seeding it with an OLDER stamped build's helper (Copy-Item stray) and +# upgrading to a NEWER build must end with every drop file hash-identical to the local drop +# (the IN-3 original ended 12/13 because the unstamped 1.0.0.0 helper shadowed the upgrade). +# Sequence: pre-state -> seed stray helper from .cache\seed89 -> stage+run the new MSI +# (default options) -> post-state + full local-vs-remote drop hash table. +$ErrorActionPreference = 'Stop' +$root = 'C:\repos\WebContentPlugin' +. (Join-Path $root 'scripts\helpers.ps1') +Import-Environment (Join-Path $root '.env') +Unlock-SecretStore -Password (Import-Clixml (Join-Path $root '.secretstore')) +$cred = Get-Secret -Name $env:VMS_CREDENTIAL + +$msi = Get-ChildItem (Join-Path $root 'output\Setup\en-US\*.msi') | Sort-Object LastWriteTime | Select-Object -Last 1 +$localMsiHash = (Get-FileHash $msi.FullName -Algorithm SHA256).Hash +Write-Host "LOCAL MSI: $($msi.Name) SHA256=$localMsiHash" + +$seedHelper = Join-Path $root '.cache\seed89\WebContentTrustHelper.exe' +$seedHash = (Get-FileHash $seedHelper -Algorithm SHA256).Hash +$seedVer = (Get-Item $seedHelper).VersionInfo +Write-Host ('SEED HELPER: {0}.{1}.{2}.{3} SHA256={4}' -f $seedVer.FileMajorPart, $seedVer.FileMinorPart, $seedVer.FileBuildPart, $seedVer.FilePrivatePart, $seedHash) + +$s = New-PSSession -ComputerName $env:VMS_ADDRESS -Credential $cred +$s2 = New-PSSession -ComputerName $env:VMS_ADDRESS -Credential $cred + +# --- pre-state (read-only) --- +$stateBlock = { + $drop = 'C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin' + $svc = Get-Service -Name MilestoneEventServerService + $regs = Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like '*Web Content*' } | + ForEach-Object { [pscustomobject]@{ Name = $_.DisplayName; Version = $_.DisplayVersion; Code = $_.PSChildName } } + $cas = @() + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('My', 'LocalMachine') + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly -bor [System.Security.Cryptography.X509Certificates.OpenFlags]::OpenExistingOnly) + try { + $found = @($store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindBySubjectName, 'XProtect WebContent Plugin Loopback Proxy', $false)) + foreach ($cert in $found) { + $container = $null + try { if ($cert.PrivateKey -and $cert.PrivateKey.CspKeyContainerInfo) { $container = $cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName } } catch { $container = 'READ-REFUSED' } + $cas += [pscustomobject]@{ Thumb = $cert.Thumbprint; NotAfter = $cert.NotAfter.ToString('o'); Container = $container } + } + } finally { $store.Close() } + $cacl = 'n/a' + if ($cas.Count -ge 1 -and $cas[0].Container -and $cas[0].Container -ne 'READ-REFUSED') { + $keyFile = Get-ChildItem 'C:\ProgramData\Microsoft\Crypto\RSA\MachineKeys' -Filter $cas[0].Container -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($keyFile) { + $cacl = (@(Get-Acl -LiteralPath $keyFile.FullName).Access | ForEach-Object { "$($_.IdentityReference) $($_.FileSystemRights) $($_.AccessControlType)" }) -join ' | ' + } else { $cacl = 'KEYFILE-NOT-FOUND' } + } + $files = @() + if (Test-Path -LiteralPath $drop) { + $trim = $drop.TrimEnd('\').Length + 1 + $files = @(Get-ChildItem -LiteralPath $drop -File -Recurse | ForEach-Object { + $v = $_.VersionInfo + [pscustomobject]@{ + File = $_.FullName.Substring($trim) + Sha = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + Ver = "$($v.FileMajorPart).$($v.FileMinorPart).$($v.FileBuildPart).$($v.FilePrivatePart)" + } + }) + } + $mipTail = @() + $mipLog = 'C:\ProgramData\Milestone\XProtect Event Server\logs\MIP.log' + if (Test-Path -LiteralPath $mipLog) { $mipTail = @(Get-Content -LiteralPath $mipLog -Tail 400 -ErrorAction SilentlyContinue | Where-Object { $_ -match 'Proxy fetcher ready|Loaded deployment proxy CA|Minted loopback proxy leaf' }) } + [pscustomobject]@{ + Service = $svc.Status + Regs = $regs + Cas = $cas + Dacl = $cacl + Files = $files + Mip = $mipTail + } +} +$pre = Invoke-Command -Session $s -ScriptBlock $stateBlock +Write-Host "PRE service=$($pre.Service)" +$pre.Regs | ForEach-Object { Write-Host "PRE reg: $($_.Version) [$($_.Code)]" } +$pre.Cas | ForEach-Object { Write-Host "PRE CA: $($_.Thumb) notAfter=$($_.NotAfter) container=$($_.Container)" } +Write-Host "PRE key DACL: $($pre.Dacl)" +Write-Host "PRE drop files=$($pre.Files.Count)" + +# --- seed the Copy-Item stray: older stamped helper over the installed one --- +$remoteDrop = Invoke-Command -Session $s { 'C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin' } +Copy-Item -ToSession $s -Path $seedHelper -Destination (Join-Path $remoteDrop 'WebContentTrustHelper.exe') -Force +$seeded = Invoke-Command -Session $s { + param($drop) + $h = Join-Path $drop 'WebContentTrustHelper.exe' + $v = (Get-Item -LiteralPath $h).VersionInfo + [pscustomobject]@{ Ver = "$($v.FileMajorPart).$($v.FileMinorPart).$($v.FileBuildPart).$($v.FilePrivatePart)"; Sha = (Get-FileHash -LiteralPath $h -Algorithm SHA256).Hash } +} -ArgumentList $remoteDrop +Write-Host "SEEDED helper on server: Ver=$($seeded.Ver) Sha=$($seeded.Sha)" +if ($seeded.Sha -ne $seedHash) { throw "seed copy hash mismatch on the server: $($seeded.Sha) != $seedHash" } + +# --- stage the MSI --- +$remoteTempRoot = Invoke-Command -Session $s { [IO.Path]::GetTempPath() } +if ([string]::IsNullOrWhiteSpace($remoteTempRoot)) { throw 'remote GetTempPath returned nothing' } +$temp = Join-Path $remoteTempRoot 'wces89' +$null = Invoke-Command -Session $s -ArgumentList $temp { param($t) $null = New-Item -ItemType Directory -Path $t -Force; $t } +if ([string]::IsNullOrWhiteSpace($temp)) { throw 'remote temp folder creation failed' } +Copy-Item -ToSession $s -Path $msi.FullName -Destination $temp +$remoteMsi = Join-Path $temp $msi.Name +$remoteHash = Invoke-Command -Session $s -ArgumentList $remoteMsi { param($p) (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash } +if ($remoteHash -ne $localMsiHash) { throw "remote MSI hash $remoteHash != local $localMsiHash" } +Write-Host "staged + hash-verified: $remoteMsi" + +# --- service journal from the second session --- +$journalFile = Join-Path $temp 'service-journal.txt' +$journal = Invoke-Command -Session $s2 -AsJob -ArgumentList $journalFile { + param($f) + $lines = @() + $end = (Get-Date).AddMinutes(6) + while ((Get-Date) -lt $end) { + $lines += ('{0:HH:mm:ss.fff} {1}' -f (Get-Date), (Get-Service -Name MilestoneEventServerService -ErrorAction SilentlyContinue).Status) + Start-Sleep -Milliseconds 500 + } + Set-Content -LiteralPath $f -Value $lines +} + +# --- the upgrade itself: defaults (RESTART_EVENT_SERVER=1, PROXY_SUPPORT=1) --- +$log = Join-Path $temp 'upgrade.log' +$exit = Invoke-Command -Session $s -ArgumentList @($remoteMsi, $log) { + param($m, $l) + $p = Start-Process msiexec.exe -ArgumentList "/i `"$m`" /qn /l*v `"$l`"" -Wait -PassThru + $p.ExitCode +} +Write-Host "MSIEXEC EXIT=$exit" +if ($exit -notin 0, 3010, 1641) { throw "msiexec exit $exit - upgrade failed" } + +Wait-Job -Job $journal -Timeout 420 | Out-Null +Receive-Job -Job $journal | Out-Null + +# --- post-state --- +$post = Invoke-Command -Session $s -ScriptBlock $stateBlock +Write-Host "POST service=$($post.Service)" +$post.Regs | ForEach-Object { Write-Host "POST reg: $($_.Version) [$($_.Code)]" } +$post.Cas | ForEach-Object { Write-Host "POST CA: $($_.Thumb) notAfter=$($_.NotAfter) container=$($_.Container)" } +Write-Host "POST key DACL: $($post.Dacl)" +$post.Mip | ForEach-Object { Write-Host "MIP: $_" } + +$journalText = Invoke-Command -Session $s -ArgumentList $journalFile { param($f) Get-Content -LiteralPath $f } +Write-Host '--- journal transitions ---' +$prev = '' +foreach ($line in $journalText) { + $st = ($line -split ' ')[-1] + if ($st -ne $prev) { Write-Host $line; $prev = $st } +} + +Write-Host '--- MSI log action evidence ---' +Invoke-Command -Session $s -ArgumentList $log { + param($l) + Get-Content -LiteralPath $l | Where-Object { + $_ -match 'CustomActionSchedule|Skipping action: (StopEventServerService|RestartEventServerService|CreateProxyAuthority)|Doing action: (StopEventServerService|RestartEventServerService|CreateProxyAuthority)|MainEngineThread is returning|Wix4CloseApplications|Shutting down application:|ExitCode' + } | Select-Object -First 60 +} + +# --- the #89 verdict: local drop vs remote drop, every file, hash + version --- +Write-Host '--- local drop (output\WebContent) ---' +$localDrop = Join-Path $root 'output\WebContent' +$trim = $localDrop.TrimEnd('\').Length + 1 +$localFiles = @(Get-ChildItem -LiteralPath $localDrop -File -Recurse | ForEach-Object { + $v = $_.VersionInfo + [pscustomobject]@{ + File = $_.FullName.Substring($trim) + Sha = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + Ver = "$($v.FileMajorPart).$($v.FileMinorPart).$($v.FileBuildPart).$($v.FilePrivatePart)" + } +}) +Write-Host '--- local-vs-remote file comparison ---' +$remoteByFile = @{} +foreach ($f in $post.Files) { $remoteByFile[$f.File] = $f } +$mismatch = 0 +foreach ($lf in $localFiles) { + $rf = $remoteByFile[$lf.File] + if ($null -eq $rf) { Write-Host "MISSING-REMOTE $($lf.File)"; $mismatch++; continue } + $tag = if ($rf.Sha -eq $lf.Sha) { 'IDENTICAL' } else { $mismatch++; 'DIFFERS' } + Write-Host ('{0,-9} {1} local={2} {3} remote={4} {5}' -f $tag, $lf.File, $lf.Ver, $lf.Sha.Substring(0, 8), $rf.Ver, $rf.Sha.Substring(0, 8)) +} +foreach ($rf in $post.Files) { + if (-not ($localFiles.File -contains $rf.File)) { Write-Host "EXTRA-REMOTE $($rf.File) $($rf.Ver) $($rf.Sha.Substring(0,8))"; $mismatch++ } +} +Write-Host "COMPARISON: local=$($localFiles.Count) remote=$($post.Files.Count) mismatches=$mismatch" + +# --- cleanup --- +Invoke-Command -Session $s -ArgumentList $temp { param($t) Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction SilentlyContinue } +Remove-PSSession $s, $s2 +Write-Host 'DONE' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4531f14..0540d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,9 +62,6 @@ jobs: path: .\.cache key: ${{ runner.os }}-webview - - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3 - - name: Build id: build shell: powershell diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 489b9bf..2f0ae52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,6 @@ jobs: path: .\.cache key: ${{ runner.os }}-webview - - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3 - - name: Restore .NET tools shell: pwsh run: dotnet tool restore diff --git a/.gitignore b/.gitignore index 1f66ac9..44cc68e 100644 --- a/.gitignore +++ b/.gitignore @@ -407,3 +407,5 @@ FodyWeavers.xsd .env tests/ux/uxconfig.json artifacts/ + +.secretstore \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 013007b..3348e68 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,46 @@ { - "dotnet.preferCSharpExtension": true + "powershell.codeFormatting.preset": "OTBS", + "powershell.scriptAnalysis.settingsPath": "PSScriptAnalyzerSettings.psd1", + "files.encoding": "utf8", + "[powershell]": { + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.formatOnSave": true + }, + "[csharp]": { + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.formatOnSave": true + }, + "[json]": { + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.formatOnSave": true + }, + "[xaml]": { + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.formatOnSave": true + }, + "[xml]": { + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.formatOnSave": true + }, + "cSpell.words": [ + "Cascadia", + "correlator", + "FQID", + "LASTEXITCODE", + "mintable", + "nbgv", + "nologo", + "Spki", + "unicasted", + "unicasts", + "vdproj", + "WEBCONTENT", + "wixproj", + "xunit" + ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 9820e3d..7be4571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,32 +1,49 @@ # WebContentPlugin -Smart Client MIP plugin (WebView2 browser view). Plugin project `src/WebContent` targets .NET Framework 4.7.2 in legacy csproj style; `src/WebContent.Tests` targets net10.0. +Smart Client MIP plugin (WebView2 browser view). Plugin project `src/WebContent` targets .NET Framework 4.7.2 (SDK-style csproj); `src/WebContent.Tests` targets net10.0. ## Build -- Build the plugin with VS 2022 MSBuild; `dotnet build` on `src/WebContent/WebContent.csproj` cannot resolve the `MilestoneSystems.VideoOS.Platform` PackageReference and dies with ~110 `CS0246` errors. +- Task runner is `psakefile.ps1` (psake module — `Install-Module psake`). Tasks: `ToolRestore` (`dotnet tool restore`), `Build` (depends on ToolRestore), `Lint` (`dotnet format --verify-no-changes`), `Test` (depends on Build), `TestUI`, `DeployLocally`, `DeployToVms`; `Deploy` = DeployLocally + DeployToVms. ```powershell - msbuild src/WebContent/WebContent.csproj /t:Build /p:Configuration=Debug /p:Platform=x64 - # msbuild path when not on PATH: C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe + Invoke-psake -taskList Build, Deploy # build + deploy to local machine and the VMS server + Invoke-psake -taskList Build, DeployLocally # local-only (when the Event Server plugin is unaffected) + Invoke-psake -taskList Build, DeployToVms # VMS server only + Invoke-psake -taskList Lint # dotnet format --verify-no-changes + Invoke-psake -taskList Test # unit suite (builds first) + Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~ProbeProxied' } # filtered UI probes ``` -- `PostBuildEvent` `sbom-tool` runs with `-D true` (stale `_manifest` self-overwrites) and `-bc "$(MSBuildProjectDirectory)"` (quoted absolute component path — build events run with CWD = output dir, so a relative `-bc .\` would scan the drop and detect zero packages; `$(ProjectDir)` ends in a backslash that breaks quoting). A sbom-tool failure now fails the build with MSB3073. Judge success by exit code plus files in `output\WebContent\` (9 files + `runtimes` folder). -- Full installer: `.\build.ps1` restores dotnet tools, caches the WebView2 runtime into `.cache`, then `msbuild src/Setup/setup.wixproj /t:Rebuild` (needs msbuild on PATH; CI adds it via setup-msbuild). -- WiX version lockstep (ticket #31): the `wix` CLI version in `.config/dotnet-tools.json`, the `WixToolset.Sdk/` attribute on `src/Setup/setup.wixproj`, and every `WixToolset.*` extension `PackageReference` in that project must all be bumped together to the same version — moving only one breaks the installer build. WiX major bumps also need the `` value refreshed (v7: `wix7`), and the v7 CLI refuses every command until that EULA is accepted (MSBuild passes it from the project; ad hoc CLI usage needs `-acceptEula wix7`). Heat was removed in v7; the drop harvest in `src/Setup/files.wxs` uses the built-in `Files` element instead. Dependabot PRs that bump only some WiX surfaces must never be merged alone — close them and do a coordinated bump. + All native commands run inside psake `Exec`, so a non-zero exit code fails the task — check `$psake.build_success` (or the exit code), not just the console. `Lint` is green since the `chore: run dotnet format` commit; keep it green (run `dotnet format` and land reformatting as its own commit). +- The plugin csproj is SDK-style (``, `TargetFramework` net472) — raw `dotnet build` also works directly; full MSBuild (VS 2022) also still works but nothing in this repo needs it anymore. The psake `Build` task builds with the default (Debug) config; pass `-c Release` manually for release drops. + + ```powershell + dotnet build src/WebContent/WebContent.csproj -c Debug # or -c Release + ``` + +- x64: the project itself is `Platform=Any CPU` with `x64` set unconditionally; `src/WebContent.sln` maps its x64 solution platform onto the project's Any CPU config (same remap as UxTests). Never set per-`Platform` config conditions in that csproj — `dotnet build` defaults to Any CPU. +- The drop lives at `output\WebContent\` (`AppendTargetFrameworkToOutputPath=false` - the Setup harvest expects that exact path, no `net472` subfolder). Judge success by exit code plus files there (10 files + `runtimes` folder - the #72 helper added `WebContentTrustHelper.exe/.config/.pdb`; the WiX `Files` harvest picks them up automatically). +- Post-build steps are real MSBuild targets, not cmd `PostBuildEvent`s: `CleanHostAssemblies` deletes host-owned `VideoOS.*` (and satellite folders) from the drop — `$(OutDir)` is relative in SDK-style while wildcard items come back absolute, both are anchored via a `DropDirAbsolute` property, keep that or `RemoveDir` wipes the whole drop. `RestoreDotNetTools` + `GenerateSbom` run sbom-tool; a failure fails the build. `SbomGenerationGuardTests` regex-pin the sbom invocation (quoted absolute `-bc "$(MSBuildProjectDirectory)"`, `-D true`, one line) — update the guards together with the target. The `-b` drop argument must be quoted without a trailing backslash (a trailing `\` before the closing quote corrupts the rest of the command line). +- Full installer: `.\build.ps1` restores dotnet tools, caches the WebView2 runtime into `.cache`, then `dotnet build src/Setup/setup.wixproj -t:Rebuild` (WiX v7 wixproj builds under the dotnet CLI; no msbuild on PATH needed — the setup-msbuild CI step was removed with this). +- WiX version lockstep (ticket #31): the `wix` CLI version in `.config/dotnet-tools.json`, the `WixToolset.Sdk/` attribute on `src/Setup/setup.wixproj`, and every `WixToolset.*` extension `PackageReference` in that project must all be bumped together to the same version — moving only one breaks the installer build. WiX major bumps also need the `` value refreshed (v7: `wix7`), and the v7 CLI refuses every command until that EULA is accepted (`dotnet build` passes it from the project property; ad hoc CLI usage needs `-acceptEula wix7`). Heat was removed in v7; the drop harvest in `src/Setup/files.wxs` uses the built-in `Files` element instead. Dependabot PRs that bump only some WiX surfaces must never be merged alone — close them and do a coordinated bump. ## Test -- `dotnet test src/WebContent.Tests` works (xunit). The test project is not in `src/WebContent.sln`; point at the csproj. +- `Invoke-psake -taskList Test` runs the unit suite (builds first); `dotnet test src/WebContent.Tests` works too (xunit). The test project is not in `src/WebContent.sln`; point at the csproj. +- `Invoke-psake -taskList TestUI` runs the whole `tests/ux/WebContent.UxTests` project, live probes included; pass `-parameters @{ TestFilter = 'FullyQualifiedName~...' }` to select rows. Do not run the UI suite casually: probes drive real desktop apps against the live VMS and some rows mutate live state (see `docs/verification-matrix.md`). - The test project links pure source files via `` and never project-references the plugin csproj. Register every new testable class in both places: `Compile` item in `WebContent.csproj` and `Compile` link in `WebContent.Tests.csproj`. Land it in one only and the plugin or the tests silently miss the class. ## Deploy for live-host testing -1. Kill plugin hosts first, or the copy fails on locked DLLs: `Stop-Process -Name client,VideoOS.Administration` (escalate to `-Force` for survivors). -2. Copy `output\WebContent\*` to `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin` — the `INSTALLFOLDER` from `src/Setup/setup.wxs`. The `VideoOS` root is deliberate: both `Milestone\MIPPlugins` and `VideoOS\MIPPlugins` are valid MIP roots (OEM compatibility). -3. Elevate the copy: `Start-Process -Verb runas -FilePath pwsh -ArgumentList '-NoProfile','-Command',''`. Leave off `-Wait` under a shell timeout; the UAC prompt is interactive, so launch and poll the destination. -4. Verify by file hash, not timestamp: `Copy-Item` preserves the source `LastWriteTime`, so a good copy can still look stale. -5. Logs: Smart Client plugin `EnvironmentManager.Log` output lands in `%ProgramData%\Milestone\XProtect Smart Client\MIPLog.txt`; Management Client logs to `Logs\MIP.log` under its own ProgramData folder. `MIPLog.txt` can be absent mid-session or when client logging is disabled, so missing logs are no proof the plugin never ran. +The SUPPORTED deployment path (since #68) is the release MSI run on each machine — including the Event Server machine, where the installer's #82 flow stops/starts `MilestoneEventServerService` for existing installs (see README "The server side is the XProtect EVENT SERVER" + verification-matrix IN-3). The psake deploy tasks below (Copy-Item over PSSession) are a **dev/test shortcut** — fine for lab iteration, explicitly not supported for real deployments, and they do not set up proxy support. They also leave unregistered files that shadow a later MSI upgrade at equal file version (the #82 `WEBCONTENTPLUGINPRESENT` probe covers the service-stop; the supported repair for a drifted machine is a pure-`msiexec` stop-service → `/x {product-code} /qn` → `/i /qn` cycle, verified live in IN-3). + +Use the psake deploy tasks (see Build); they encode the procedure below. + +1. Local machine: `Invoke-psake -taskList Build, DeployLocally` — kills plugin hosts (`Client`, `VideoOS.Administration`), wipes and recreates `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin` (the `INSTALLFOLDER` from `src/Setup/setup.wxs`), copies the drop. No elevation needed: the current user has full control on that folder (deliberate ACL grant on this machine — `C:\Program Files\VideoOS\MIPPlugins` is the MIP root; both `Milestone\MIPPlugins` and `VideoOS\MIPPlugins` are valid roots, OEM compatibility). If a copy ever fails on access denied, re-check the ACL before reaching for `Start-Process -Verb runas`; if hosts survive the Stop-Process, escalate to `-Force`. +2. Verify by file hash, not timestamp: `Copy-Item` preserves the source `LastWriteTime`, so a good copy can still look stale. +3. The Event Server machine (the proxy fetcher's home): `Invoke-psake -taskList Build, DeployToVms` — needs `.env` (`VMS_ADDRESS`, `VMS_CREDENTIAL`, …) and `.secretstore` (Clixml-protected password unlocking the SecretStore that holds the credential named `$env:VMS_CREDENTIAL`); both are gitignored. `Deploy-MipPlugin` in `scripts/helpers.ps1` copies the drop to a remote temp folder, kills the listed processes, stops `MilestoneEventServerService`, retries the destination wipe for up to a minute (the service stop is slow — the retry window absorbs the still-locked dll), copies into place, and restarts only the services it stopped. WinRM works with those credentials (bare negotiate/Kerberos does not). An ES restart disposes every running client's `MessageCommunication` — desktop clients must be restarted afterwards or every `TransmitMessage` dies with `Call to TransmitMessage after call to Dispose?`. `scripts/deploy-event-server-plugin.ps1` predates the psake flow and is superseded by `DeployToVms`. +4. Logs (verified on BEAST): Smart Client plugin `EnvironmentManager.Log` output lands in `%ProgramData%\Milestone\XProtect Smart Client\MIPLog.txt` (top level, plus rolling `MIPLogs\MIPLog.N.txt` — there is no Smart Client `Logs\` folder there); Management Client logs to `Logs\MIP.log` under its own ProgramData folder; the Event Server writes its MIP log to `%ProgramData%\Milestone\XProtect Event Server\logs\MIP.log` (rolled archives under `logs\MIPLogs\`; Event Server tray icon → "Show MIP logs"). Caveat: at least one Smart Client install has been observed loading the plugin view into a separate `VideoOS.OAuth2TokenGenerator.exe` process rather than `Client.exe` — if a session's plugin entries are missing from `MIPLog.txt` but the view visibly runs, hunt for the actual hosting process before concluding the plugin never loaded. `MIP.log`/`MIPLog.txt` can be absent mid-session or when client logging is disabled, so missing logs are no proof the plugin never ran. ## Agent skills diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..c8324ee --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,25 @@ +# Web Content plugin (Milestone XProtect) + +A Smart Client plugin that shows web content in XProtect workspaces, optionally routed through a VMS-side proxy so the Smart Client machine never connects to the web target directly. + +## Language + +**Proxied boundary**: +The (Windows user, XProtect identity, VMS server) tuple inside which proxied browser state and proxy fetching are shared, and across which they are never shared. +_Avoid_: profile scope, user scope (both under-specify the server leg) + +**Listener ownership**: +The state in which exactly one client process serves the loopback fetching for one proxied profile; other processes on the same boundary join it rather than routing their own. +_Avoid_: master/slave, winner takes all + +**Joined session**: +A second client process on the same proxied boundary reusing the owner's browser profile and fetching channel. Only ever permitted within one boundary. +_Avoid_: piggyback (kept out of code and docs as a term; it reads as a bug) + +**Handoff**: +The transition in which a boundary's listener owner disappears (clean exit or death) and a surviving attached process takes the slot through the claim protocol at a fresh generation (epoch), after which the boundary's open proxied views are re-created so the browser process re-launches with the new port. Rides the claim protocol's own audits and the standard WebView re-creation route - never a claim side-door, never an argument hot-swap on a running browser process. +_Avoid_: failover (suggests hot standby), takeover alone (in the claim protocol it names only the slot write, not the view re-creation the generation implies) + +**Profile sweep**: +The bounded startup cleanup of one Windows user's plugin data root: reaps orphaned proxied browser-profile folders past the retention window, stale listener-claim files, and claim-create temp litter - shape-bounded, journalled, and never forcing a folder a live browser holds. +_Avoid_: garbage collection, cleanup job (both suggest it runs continuously or may touch anything) diff --git a/DownloadSetupPrerequites.ps1 b/DownloadSetupPrerequites.ps1 index 051be23..5ddfbfe 100644 --- a/DownloadSetupPrerequites.ps1 +++ b/DownloadSetupPrerequites.ps1 @@ -31,8 +31,7 @@ foreach ($dependency in $dependencies) { $argList += $MyInvocation.UnboundArguments Start-Process PowerShell.exe -Verb Runas -WorkingDirectory $pwd -ArgumentList $argList return - } - else { + } else { throw "You must be administrator to run this script" } } @@ -47,16 +46,14 @@ foreach ($dependency in $dependencies) { $argList += $MyInvocation.UnboundArguments Start-Process PowerShell.exe -Verb Runas -WorkingDirectory $pwd -ArgumentList $argList return - } - else { + } else { throw "You must be administrator to run this script" } } Write-Host "Downloading $($dependency.Name) to $($dependency.Destination)" $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $dependency.Url -OutFile $dependency.Destination - } - else { + } else { Write-Host "$($dependency.Name) already available at $($dependency.Destination)" } } diff --git a/LICENSE b/LICENSE index 3ee3680..9765283 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Cascadia Technology +Copyright (c) 2026 Cascadia Technology Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..85eda87 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,49 @@ +@{ + Rules = @{ + PSPlaceOpenBrace = @{ + Enable = $true + OnSameLine = $true + NewLineAfter = $true + IgnoreOneLineBlock = $false + } + + PSPlaceCloseBrace = @{ + Enable = $true + NewLineBefore = $false + NewLineAfter = $false + IgnoreOneLineBlock = $false + } + + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 4 + Kind = 'space' + # Extension default (powershell.codeFormatting.pipelineIndentation = + # 'noIndentation'); 'IncreaseIndentationForFirstPipeline' is the + # PSScriptAnalyzer default if you prefer indented pipelines. + PipelineIndentation = 'NoIndentation' + } + + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + CheckOpenBrace = $true + CheckOpenParen = $true + CheckOperator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $false + CheckSeparator = $true + CheckParameter = $false + IgnoreAssignmentOperatorInsideHashTable = $true + } + + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $true + AlignHashtableKvpWithInterveningComment = $true + CheckEnum = $true + AlignEnumMemberWithInterveningComment = $true + IncludeValuelessEnumMembers = $true + } + } +} diff --git a/README.md b/README.md index b31ebad..4158713 100644 --- a/README.md +++ b/README.md @@ -54,15 +54,105 @@ analysis. Download the installer from the [latest release](https://github.com/joshooaj/WebContentPlugin/releases/latest). Run the file named `CascadiaWebContentPlugin..msi`. +The installer **closes running Milestone applications** (Smart Client, Management Client) so +upgrades never fail on locked plugin files. These apps do not cooperate with Windows' restart +manager, so they are closed without a chance to save — close Smart Client yourself if you have +unsaved view or layout changes. In the full UI the installer shows a warning first and lets you +abort, retry, or proceed. + The installer is not currently signed, so your browser will warn you that the file is potentially unsafe. The release also carries a `CascadiaWebContentPlugin..msi.sha256.txt` file that you can use to validate the installer hash. Click **Keep**, and if your browser asks again, click **Keep anyway**. The installation experience will improve when I get a code signing certificate setup. -The installer is very simple and has no options. Acknowledge the license, and then it will install the Microsoft Edge +The installer has one page beyond the license: when the machine runs an **Event Server** (`MilestoneEventServerService`) +and the plugin is already installed there, the wizard shows an **Event Server options** page with a +**Restart the Event Server now?** checkbox (checked by default). Upgrading the plugin on that machine stops the service +first (waiting for the real stop), copies the files, and the checkbox decides whether the installer starts it again at +the end — clearing it leaves the service stopped so you can schedule the restart yourself. The restart matters: the new +plugin files only load when the Event Server (re)starts, and **restarting it disconnects every connected Smart Client's +plugin channel — restart those Smart Clients afterwards**. On machines without an Event Server, or when the plugin is +being installed on that Event Server for the first time, that page never appears and the service is never stopped. +For unattended installs the choice is a property: `msiexec /i /qn RESTART_EVENT_SERVER=0` declines the restart. +One deliberate caveat: **uninstalling** the plugin while the Event Server is running does not stop the service either — +stop `MilestoneEventServerService` first or the service keeps the plugin files locked (see the Event Server deployment +notes below). + +Beyond that the installer is simple: acknowledge the license, and it will install the Microsoft Edge WebView2 Runtime if necessary, and place the plugin files in `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin\`. +### HTTPS targets: deployment certificate authority (one-time setup) + +A workspace proxy allowlist may include `https://` targets (tick **Accept untrusted** on the row +to tolerate a device's self-signed certificate — the flag is per target, never global). +To make those pages render without browser certificate errors, the plugin uses one long-lived certificate +authority per deployment (see `docs/adr/0003-event-server-mints-loopback-leaf-certs.md`): + +1. **Create the CA once on the Event Server machine — the installer does it.** The Event + Server service runs as a restricted built-in account (`NT AUTHORITY\NETWORK SERVICE` on + current Milestone installs) that cannot create the CA itself, so the MSI does it elevated + at install time: on the **Event Server options** page (the page that appears only when the + machine runs the Event Server service), tick **Enable web content proxy support**. The + installer then creates the CA certificate named + `XProtect WebContent Plugin Loopback Proxy` in the Local Machine Personal store, grants + the Event Server service account read access to its private key so leaf minting works at + runtime, and leaves any existing CA untouched (upgrade re-runs reuse it, thumbprint and + key permissions included). The private key never leaves that machine. Scripted installs + are opted in by default; pass `PROXY_SUPPORT=0` on the `msiexec` command line to decline + (an Event Server machine installed with the box unticked never has its certificate stores + touched — start the installer again or run the legacy script below if proxy support is + wanted later). + *Legacy/repair fallback:* if the plugin reached the Event Server machine without the MSI + (the dev `Copy-Item` shortcut), run this once from an elevated Windows PowerShell 5.1 + session on that machine instead: + + ```powershell + powershell -ExecutionPolicy Bypass -File scripts/provision-event-server-authority.ps1 + ``` +2. **Each Smart Client machine trusts the CA once, elevated.** Get the public certificate onto each + Smart Client machine and import it into the machine's trusted-root store. The Management Client + ships it at any time: under **Cascadia + Technology > Web Content Workspaces**, the **Export proxy certificate** button saves the CA's + public certificate (`*.cer`, DER) — read from the Event Server over MIP messaging, with no + private key in sight, and it works no matter how the CA was created (the installer's proxy opt-in + checkbox, lazy first-use, or the legacy script). + + - **Recommended for more than one machine: push it with Active Directory Group Policy.** Import + the exported `.cer` into a GPO at *Computer Configuration > Policies > Windows Settings > + Security Settings > Public Key Policies > Trusted Root Certification Authorities*. Every + domain-joined Smart Client machine picks the CA up on its next policy refresh — no per-machine + logon, and new machines get it automatically. + - One machine at a time, copy the exported `.cer` to it and run: + + ```powershell + Import-Certificate -FilePath C:\Users\Public\WebContent-Proxy-Authority.cer -CertStoreLocation Cert:\LocalMachine\Root + ``` + + Until that import is done, proxied HTTPS pages load but show a browser certificate error — the plugin + never bypasses certificate validation on its own. The Smart Client view also says so directly: any + proxied view whose deployment CA is missing from the machine's trusted-root stores shows a banner + to every user: *"The proxy certificate is not trusted on this PC - contact your administrator."* + (dismissible for the session; it returns next session until the CA is trusted). An administrator in + **setup mode** additionally gets a **Fix certificate trust** button on that banner: it launches the + bundled `WebContentTrustHelper.exe` elevated. Windows shows its elevation consent *and* the + certificate-store warning, both deliberately and never suppressed, and the tool imports exactly + the reported CA (thumbprint-verified, nothing else). Once the CA is trusted the banner clears and + proxied HTTPS pages reload without restarting Smart Client. If the + button answers that the proxy authority certificate does not exist yet, no CA has been created on the + Event Server machine — complete step 1 first. + + You do not have to wait for users to report those banners: each client machine that hits the + condition reports it to the Event Server once (per machine, not per view), and the Management + Client's **Diagnostics** tab for the workspace then shows an aggregate warning — "N client + machines report the proxy CA is not trusted. Export it from here and distribute it." — listing + each affected machine, with the same **Export proxy certificate** button inline. The list lives + only in the Event Server's memory (it resets when the service restarts), so it is a live signal, + not an audit trail. + +To remove the plugin completely, also delete the CA: from `LocalMachine\My` on the Event Server machine +(`Remove-Item Cert:\LocalMachine\My\`) and from `LocalMachine\Root` on each Smart Client machine. + ### Add Web Content to a view 1. Open a new Smart Client instance (plugins are only discovered during startup) and login. @@ -80,6 +170,13 @@ WebView2 Runtime if necessary, and place the plugin files in `C:\Program Files\V ![Screenshot of web content view item properties](./docs/assets/images/properties.png) +These per-view properties apply to a **manually placed** Web Content view. A view that lives on +an admin-defined workspace (see below) keeps its **home/target URL and proxy posture locked**: +the field is still shown, but rendered read-only with a "Managed by Administrator" note, and +any stored override of those security-relevant settings is ignored at every load. Cosmetic and +navigation options (toolbar, address bar, zoom, audio, history, isolation, developer tools) +stay editable per instance. + ## Workspace administration (Management Client) The plugin also integrates with the XProtect Management Client. Under **Cascadia Technology**, a @@ -97,6 +194,179 @@ the web content workspace. ![Cascadia Technology security tab for a role in the Management Client](./docs/assets/images/mc-role-security.png) +## Proxied web access (route workspace traffic through the VMS server) + +Some devices (cameras, intercoms, vendor portals) serve their web pages on a network segment the +operator's computer cannot reach. The **workspace proxy** fixes this: for a proxied workspace the +browser's web traffic is routed away from the operator machine and out of the VMS server's network +instead. The Smart Client fronts the browser with a machine-local forwarding proxy, relays every +request over classic MIP messaging, and the VMS-side plugin component performs the actual fetch. +The operator's computer never contacts the device, and a proxied workspace never silently falls +back to a direct connection. + +### What the admin toggles, and where + +On the workspace's details page in the **Management Client** under +**Cascadia Technology > Web Content Workspaces**: + +1. Open the workspace's **Proxy** tab and tick **Route web traffic through the VMS server proxy**. +2. Fill the **Proxy targets** grid — one row per allowed origin: pick the **scheme**, enter the + **host** (a wildcard may be `*.foo.example.com`, which covers that domain's subdomains but never + the apex, or a lone `*`), and add a **port** only when the target is not on the scheme default. + Tick **Accept untrusted** on the row whose device serves a self-signed certificate (the + untrusted-certificate override is per target, never global; see the certificate authority + section above before using `https://` targets). **Add target** and the per-row **Remove** button + change the row count; a lone `*` row raises a warning banner while it is in the grid. +3. Save. A row the matcher rejects is flagged in its **Problem** column and the whole save is + refused by name — nothing is silently dropped. A proxied workspace with no valid targets + stays proxied and refuses everything — the allowlist is never bypassed by direct access. + +The toggle takes effect when the workspace tab is (re)opened in Smart Client; the Smart Client +properties page shows the effective proxy state for the workspace. + +### The computer's own proxy (WinINET): inherited when off, ignored when on + +A view **without** the workspace proxy injects no proxy arguments into its browser, so the +browser simply follows the computer's own web-proxy configuration — on Windows that is the +per-user WinINET proxy ("Internet Options → LAN settings"; the `ProxyEnable`/`ProxyServer` +values, settable machine-wide through Group Policy or ProxyConfig-style tools). To put such +views' traffic through a corporate proxy, configure that OS setting for the users running the +Smart Client. The plugin deliberately has **no proxy-URL setting of its own** — the operating +system already owns that (including proxy authentication and PAC), and non-proxied views +inherit it for free. + +A **proxied** workspace deliberately ignores that OS proxy. Its browser profile launches with +every scheme pinned to the machine-local loopback proxy (`--proxy-server=http=http://127.0.0.1:;https=...;ws=...;wss=...`) +and the implicit localhost bypass disabled (`--proxy-bypass-list=<-loopback>`), so a system +proxy can never widen what a proxied workspace loads — the allowlist posture is decided by +the VMS server, and nothing reaches the network except through it. Verified live (SC-8 in +`docs/verification-matrix.md`): with the user's WinINET proxy pointed at a local logging +harness, a non-proxied view renders the harness's marker page for a host that can never +resolve directly, while the proxied view of the same url shows the plugin's own 403 refusal +page and the OS-proxy harness never sees a single request for that origin. + +### The server side is the XProtect EVENT SERVER + +The component that performs the fetches runs **inside the XProtect Event Server process — not the +Management Server**. The machine running the Event Server needs the plugin drop installed on it as +well as on every Smart Client machine. The plugin definition loads in the `Service` environment, and +the Event Server only loads MIP plugins at **service start**, so a deployment includes a service +restart. + +**The supported way to deploy to that machine is the release MSI, run on the machine itself** — the +same `CascadiaWebContentPlugin..msi` as everywhere else. On an Event Server machine it: + +- stops `MilestoneEventServerService` (waiting for the real stop, no fixed sleep) **only** when the + plugin is already installed there, copies the files into + `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin`, and restarts the service per the + wizard's **Restart the Event Server now?** choice — first installs never stop the running service, + and machines without an Event Server see none of this (see the install section above); +- on a first install with the service running, installs the files without touching the service — + the plugin then loads at the next Event Server start; +- runs unattended as `msiexec /i CascadiaWebContentPlugin..msi /qn` (add + `RESTART_EVENT_SERVER=0` to leave the restart scheduled by hand). + +Verify any deployment by **SHA-256 hash against the build drop**, never by timestamp (the copy +preserves `LastWriteTime`), and confirm the fetcher loaded by checking the Event Server's MIP log +for `Proxy fetcher ready (concurrency=3, request id=WebContent.Proxy.FetchRequest)` — or use the +Management Client workspace page's **Test proxy fetch** row for an end-to-end check. + +One caveat for a machine whose drop was previously placed by file copy rather than by the installer: +files a `Copy-Item` left behind are untracked by MSI, and a stray at the **same file version** as the +MSI's payload shadows it during an upgrade — the registration bumps while the stray stays. The +supported migration is a pure-`msiexec` uninstall/install cycle: stop `MilestoneEventServerService`, +`msiexec /x {product-code} /qn` (this also removes the strays, which the upgrade has now registered), +confirm the drop folder is gone, then run the new MSI. Verified live in `docs/verification-matrix.md` +(IN-3). + +`scripts/deploy-event-server-plugin.ps1` (and the psake `DeployToVms` task) are **dev/test shortcuts**: +the remote, repeatable file-copy form of the old procedure for lab machines. They are not the +supported deployment path, and they explicitly do **not** set up proxy support — the certificate +authority for `https://` targets is the separate one-time setup above either way. + +Two follow-ups matter after any Event Server plugin deployment: + +- **Restart every Smart Client that was connected before the restart.** The Event Server restart + disposes each client's messaging channel; without a Smart Client restart every proxied page + fails with the proxy's "server proxy component is not available" error page (and the log shows + `Call to TransmitMessage after call to Dispose?`). + +### Rule triggers (fire VMS rules from proxied requests) + +A **rule trigger** turns one served proxy request into a VMS event the normal rule engine can +react to — an operator notification, a saved view, an export, whatever the rule builder offers. +Each workspace keeps its own list of trigger rows; when a proxied fetch **completes with a +response** and matches a row, the plugin raises one analytics event on the Event Server bus. +Matching runs on the Event Server after the response exists, so transport failures (no status) +and CONNECT tunnels never trigger, and a match never changes the browser's response. + +The event type is fixed — **`WebContent proxy request`** — for every trigger in every workspace. +Per-request detail rides inside the event (workspace name, method, full url, HTTP status, the +matching row), so the admin writes **one** Analytics Event definition +(*Management Client → Event definitions*, matching the message text `WebContent proxy request`, +source = the workspace item) and then builds as many rules on it as desired. + +A trigger row has three parts: + +| Part | Written as | Matches | +|---|---|---| +| **url** | `scheme://host[:port][/path-glob]` | the FULL fetched url. Scheme `http`, `https`, or `*`; host exact, `*.tail` (one or more labels below the tail — never the apex, same wildcard as the allowlist), or a lone `*` after the scheme; a **port only when the url names that port** (an absent port means the scheme default, so `:8443` traffic needs `:8443` in the row); the path glob is literal text where `*` spans any run **including `/`** and `?` is exactly one character — an absent path means any path. Query strings and fragments never take part. A lone `*` as the whole url matches anything. | +| **method** | `GET`, `POST`, … (case-insensitive) | exact verb; blank means any method. | +| **status** | `4xx` or `404` | the response status: a class (`1xx`…`5xx`) or an exact code; blank means any status. | + +A row the matcher refuses is reported once per configuration refresh in the Event Server's MIP +log (`Workspace rows refreshed: N row(s)...` plus a named per-row reason) and never takes down the +workspace's other rows. Trigger rows live in the workspace configuration alongside the proxy +allowlist. Administrators maintain them from the **Proxy tab** of the workspace configuration +dialog, in the **Rule triggers** grid between the permission headers and the test fetch: each row +has a URL pattern, a Method dropdown (`*` wildcard first, then the relayable verbs), a Status code +(blank = any, `1xx`–`5xx` class, or an exact code), a read-only **Event raised** column showing +which event type the row fires (`WebContent proxy request`), and a live problem column. Rows are +checked against the same grammar the Event Server plugin enforces when it audits proxy traffic, so +a row the dialog accepts can never be refused at runtime; an invalid row is marked in place and +refuses the whole save, naming it. Rows are evaluated in the order shown — one request raises at +most the first matching row's event — and saving with no rows stores no trigger policy at all. To confirm the +event channel on a freshly deployed Event Server before any rules exist, place a +`trigger-selftest.flag` file next to the deployed `WebContent.dll` and restart the service — the +Event Server MIP log shows the probe event raised and seen on the bus with the same id (see +`docs/verification-matrix.md`, ES-3). + +### Versions, logs, and failure pages + +- The relay rides classic MIP message communication (Event Server message service, default port + 22333). Event Server plugins have been able to register message filters from `Init()` since + **XProtect 2019 R3** (earlier releases silently failed that registration), so proxied workspaces + require XProtect 2019 R3 or later. Verified against **XProtect 2024 R2** in the lab where this + document's deployment records were made. +- Logs for the proxy path: Smart Client plugin activity lands in + `%ProgramData%\Milestone\XProtect Smart Client\MIPLog.txt` (rolling `MIPLogs\MIPLog.N.txt`); + the Event Server writes its MIP log to `%ProgramData%\Milestone\XProtect Event Server\logs\MIP.log` + with rolled archives under `logs\MIPLogs\` (the Event Server tray icon's "Show MIP logs" opens + them); Management Client diagnostics (including the Test proxy fetch + harness) go to `Logs\MIP.log` under its ProgramData folder. +- When a proxied page fails, the **browser itself shows an explicit proxy error page** naming the + failing side — never a silent direct load and never an endless spinner. Each page carries a + friendly headline, exactly one **cause category** chip, and a collapsed "Technical details" + section (exact origin, the canonical allowlist entry that would admit it, the byte cap, the + verbatim refusal text, a "Copy diagnostics" button). Off-allowlist **https** refusals ride the + page too (#88): Chromium transports every https target to the loopback proxy only as a CONNECT + and drops the body of a refused CONNECT, so the proxy's own page can never reach the screen for + those — the browser view answers the same 403 page itself, at the requested URL, before any + CONNECT is opened, using the exact same allowlist the proxy gates on. The one remaining + asymmetry: **ws:// / wss:// websocket upgrades** are refused at the CONNECT layer and still + render Chromium's native error page (websocket requests are not surfaced to the view's + interception); fail-closed is identical everywhere — nothing is ever loaded direct. + - **Not allowed by the administrator** (403) — the target origin is not in the workspace + allowlist (the details name the origin and the exact entry to add). The same 403 also appears + when the VMS server and the Smart Client disagree because their allowlists drifted. + - **VMS proxy component unavailable** (503) — the proxy component on the Event Server is absent, + unreachable, or not answering (deploy check above; Smart Client restart needed after an Event + Server restart). + - **Target unreachable / Response too large (proxy size cap) / Certificate not trusted / + Proxy tunnel failure** (502) — the VMS server reached the messaging layer but the target + connection, the response size cap, the target's TLS certificate, or the proxy tunnel refused. + - **Timeout** (504) — the VMS server fetched the target but the target timed out. + ## License The Web Content Plugin is free to use with all features. No license, diff --git a/build.ps1 b/build.ps1 index e663090..88000bb 100644 --- a/build.ps1 +++ b/build.ps1 @@ -25,7 +25,7 @@ if (!$PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Version')) { } Write-Host "Building CascadiaWebContentPlugin.$Version.msi" -& msbuild src/Setup/setup.wixproj /t:Rebuild /restore:True /p:Configuration=Release /p:Platform=x64 /p:BuildVersion=$Version +& dotnet build src/Setup/setup.wixproj -t:Rebuild -p:Configuration=Release -p:Platform=x64 -p:BuildVersion=$Version if (!$?) { throw "MSBuild exited with code $LASTEXITCODE" @@ -40,6 +40,191 @@ if (@($detectedPackages).Count -eq 0) { throw "SBOM at $sbomPath lists no referenced packages; sbom-tool component detection is broken (check the -bc component path in the PostBuildEvent)." } Write-Host "SBOM OK: $($detectedPackages.Count) referenced packages detected." -ForegroundColor Green + +# #70: structural guard for the force-close-authoring. The upgrade only succeeds with +# clients running if the kill lands inside the script, before RemoveFiles/InstallFiles, +# and RemoveExistingProducts is late (afterInstallExecute). Pinned here against the +# intermediate wixpdb (zip) so the check needs no MSI COM API. +Add-Type -AssemblyName System.IO.Compression.FileSystem +$pdb = Get-ChildItem $PSScriptRoot\src\Setup\obj -Recurse -Filter "*$Version.wixpdb" | Sort-Object LastWriteTime | Select-Object -Last 1 +if ($null -eq $pdb) { + throw "No wixpdb found for version $Version under src\Setup\obj; cannot verify #70 installer sequencing." +} +$zip = [System.IO.Compression.ZipFile]::OpenRead($pdb.FullName) +try { + $widEntry = $zip.Entries | Where-Object { $_.FullName -eq 'wix-wid.xml' } + if ($null -eq $widEntry) { + throw "wixpdb $pdb has no wix-wid.xml; cannot verify #70 installer sequencing." + } + $reader = New-Object System.IO.StreamReader($widEntry.Open()) + $widXml = $reader.ReadToEnd() + $reader.Close() + + function Get-MsiTableRows([string]$xml, [string]$table) { + $m = [regex]::Match($xml, '[\s\S]*?
') + if (!$m.Success) { return @() } + $rows = @() + foreach ($rowMatch in [regex]::Matches($m.Value, ']*>([\s\S]*?)')) { + $fields = @() + foreach ($field in [regex]::Matches($rowMatch.Groups[1].Value, '([\s\S]*?)')) { + $fields += [System.Net.WebUtility]::HtmlDecode($field.Groups[1].Value) + } + $rows += ,$fields + } + return ,$rows + } + + # InstallExecuteSequence rows: Action, Condition, Sequence (wid column order). + $ies = Get-MsiTableRows $widXml 'InstallExecuteSequence' + $seq = @{} + foreach ($row in $ies) { if ($row.Count -ge 3 -and $row[2] -ne '') { $seq[$row[0]] = [int]$row[2] } } + $close = $ies | Where-Object { $_.Count -ge 1 -and $_[0] -like 'Wix4CloseApplications_*' } | Select-Object -First 1 + if ($null -eq $close) { + throw "Installer sequencing guard (#70): InstallExecuteSequence has no Wix4CloseApplications_* action - the util:CloseApplication authoring in setup.wxs is missing or not scheduled." + } + $closeSeq = [int]$close[2] + foreach ($anchor in 'InstallInitialize', 'RemoveFiles', 'InstallFiles') { + if (-not $seq.ContainsKey($anchor)) { throw "Installer sequencing guard (#70): $anchor missing from InstallExecuteSequence." } + } + if ($closeSeq -le $seq['InstallInitialize']) { + throw "Installer sequencing guard (#70): Wix4CloseApplications ($closeSeq) must run after InstallInitialize ($($seq['InstallInitialize'])) so its deferred kill is inside the script." + } + if ($closeSeq -ge $seq['RemoveFiles'] -or $closeSeq -ge $seq['InstallFiles']) { + throw "Installer sequencing guard (#70): Wix4CloseApplications ($closeSeq) must run before RemoveFiles ($($seq['RemoveFiles'])) and InstallFiles ($($seq['InstallFiles'])) so locked drop DLLs are killed first." + } + if (-not $seq.ContainsKey('RemoveExistingProducts')) { + throw "Installer sequencing guard (#70): RemoveExistingProducts missing - MajorUpgrade Schedule=afterInstallExecute is required (#70)." + } + if ($seq['RemoveExistingProducts'] -lt $seq['InstallFiles']) { + throw "Installer sequencing guard (#70): RemoveExistingProducts ($($seq['RemoveExistingProducts'])) must be scheduled after InstallFiles ($($seq['InstallFiles'])) - MajorUpgrade Schedule=afterInstallExecute was lost." + } + + $closeApps = Get-MsiTableRows $widXml 'Wix4CloseApplication' + $targets = $closeApps | Where-Object { $_.Count -ge 2 } | ForEach-Object { $_[1] } + foreach ($target in 'Client.exe', 'VideoOS.Administration.exe', 'VideoOS.OAuth2TokenGenerator.exe') { + if ($targets -notcontains $target) { + throw "Installer sequencing guard (#70): Wix4CloseApplication table does not force-close $target (found: $($targets -join ', '))." + } + } + + # #82: the Event Server service flow. The stop action must sit inside the script + # (after InstallInitialize, after the #70 client-kill scan) and before both file + # actions - a still-running Event Server locks the drop DLLs. The restart action + # (finish-page choice) must land after InstallFiles so the service comes back on + # the new files, and before InstallFinalize so it stays a deferred SYSTEM action. + $ca = Get-MsiTableRows $widXml 'CustomAction' + foreach ($action in 'StopEventServerService', 'RestartEventServerService') { + $row = $ca | Where-Object { $_.Count -ge 3 -and $_[0] -eq $action } | Select-Object -First 1 + if ($null -eq $row) { + throw "Installer service-flow guard (#82): CustomAction table has no $action row." + } + # 1026 = exe streamed from the Binary table, deferred, return check, non-impersonating. + if ($row[1] -notmatch '1026') { + throw "Installer service-flow guard (#82): $action must be a deferred (SYSTEM) exe custom action (type 1026), found row: $($row -join ' / ')." + } + if ($row -notcontains 'WebContentServiceCtl.exe') { + throw "Installer service-flow guard (#82): $action must run the WebContentServiceCtl.exe binary stream, found row: $($row -join ' / ')." + } + if (-not $seq.ContainsKey($action)) { + throw "Installer service-flow guard (#82): $action missing from InstallExecuteSequence." + } + } + $stopCondition = ($ies | Where-Object { $_.Count -ge 1 -and $_[0] -eq 'StopEventServerService' } | ForEach-Object { if ($_.Count -ge 2) { $_[1] } else { '' } }) -join ';' + foreach ($needle in 'NOT REMOVE', 'EVENTSERVERSERVICEPRESENT', 'WIX_UPGRADE_DETECTED', 'WEBCONTENTPLUGINPRESENT') { + if ($stopCondition -notlike "*$needle*") { + throw "Installer service-flow guard (#82): StopEventServerService condition '$stopCondition' lost '$needle' - the stop must fire only on install/upgrade, only when the Event Server service exists, and only when plugin files are already installed." + } + } + if ($seq['StopEventServerService'] -le $seq['InstallInitialize'] -or $seq['StopEventServerService'] -le $closeSeq) { + throw "Installer service-flow guard (#82): StopEventServerService ($($seq['StopEventServerService'])) must run after InstallInitialize ($($seq['InstallInitialize'])) and the client-kill scan ($closeSeq)." + } + if ($seq['StopEventServerService'] -ge $seq['RemoveFiles'] -or $seq['StopEventServerService'] -ge $seq['InstallFiles']) { + throw "Installer service-flow guard (#82): StopEventServerService ($($seq['StopEventServerService'])) must run before RemoveFiles ($($seq['RemoveFiles'])) and InstallFiles ($($seq['InstallFiles'])) - the Event Server still holds the drop dlls until it is Stopped." + } + if ($seq['RestartEventServerService'] -lt $seq['InstallFiles'] -or $seq['RestartEventServerService'] -ge $seq['InstallFinalize']) { + throw "Installer service-flow guard (#82): RestartEventServerService ($($seq['RestartEventServerService'])) must run after InstallFiles ($($seq['InstallFiles'])) and before InstallFinalize ($($seq['InstallFinalize']))." + } + $restartCondition = ($ies | Where-Object { $_.Count -ge 1 -and $_[0] -eq 'RestartEventServerService' } | ForEach-Object { if ($_.Count -ge 2) { $_[1] } else { '' } }) -join ';' + if ($restartCondition -notlike '*RESTART_EVENT_SERVER*') { + throw "Installer service-flow guard (#82): RestartEventServerService condition '$restartCondition' lost the RESTART_EVENT_SERVER choice." + } + + # #82: the UI half - the welcome page must branch to the choice page (and to the old + # one-click end) on the ES-detected property, the choice page carries the checkbox, + # and its Install button ends the dialog loop. + $controlEvents = Get-MsiTableRows $widXml 'ControlEvent' + $branch = $controlEvents | Where-Object { + $_.Count -ge 4 -and $_[0] -eq 'WebContentWelcomeEulaDlg' -and $_[1] -eq 'Install' -and $_[2] -eq 'NewDialog' -and $_[3] -eq 'EventServerOptionsDlg' + } | Select-Object -First 1 + if ($null -eq $branch) { + throw "Installer service-flow guard (#82): no WebContentWelcomeEulaDlg/Install -> EventServerOptionsDlg branch - setupui.wxs lost the Event Server page hop." + } + if ($branch[4] -notlike '*EVENTSERVERSERVICEPRESENT*') { + throw "Installer service-flow guard (#82): the Event Server page hop is not gated on EVENTSERVERSERVICEPRESENT - the page must never appear on machines without the Event Server." + } + $endNoEs = $controlEvents | Where-Object { + $_.Count -ge 4 -and $_[0] -eq 'WebContentWelcomeEulaDlg' -and $_[1] -eq 'Install' -and $_[2] -eq 'EndDialog' -and $_[3] -eq 'Return' -and $_.Count -ge 5 -and $_[4] -like '*NOT EVENTSERVERSERVICEPRESENT*' + } | Select-Object -First 1 + if ($null -eq $endNoEs) { + throw "Installer service-flow guard (#82): the no-Event-Server branch lost the direct EndDialog Return (the old WixUI_Minimal one-click behaviour)." + } + $installChoice = $controlEvents | Where-Object { + $_.Count -ge 4 -and $_[0] -eq 'EventServerOptionsDlg' -and $_[1] -eq 'Install' -and $_[2] -eq 'EndDialog' -and $_[3] -eq 'Return' + } | Select-Object -First 1 + if ($null -eq $installChoice) { + throw "Installer service-flow guard (#82): EventServerOptionsDlg/Install no longer ends the dialog loop." + } + $checkboxes = Get-MsiTableRows $widXml 'Control' + $restartCheckbox = $checkboxes | Where-Object { $_.Count -ge 9 -and $_[0] -eq 'EventServerOptionsDlg' -and $_[8] -eq 'RESTART_EVENT_SERVER' } | Select-Object -First 1 + if ($null -eq $restartCheckbox) { + throw "Installer service-flow guard (#82): EventServerOptionsDlg carries no CheckBox bound to RESTART_EVENT_SERVER." + } + + # #77: the install-time deployment CA. The action must be a deferred (SYSTEM) exe CA + # streaming WebContentProxyCa.exe, scheduled inside the script AFTER the drop is on + # disk (the helper reflects into the installed WebContent.dll) and before the service + # restart, and its condition must carry BOTH gates - Event Server present AND the + # proxy opt-in. Machines without ES, or with the box unticked, never run it; the + # checkbox lives on the same Event Server options page. + $caRow = $ca | Where-Object { $_.Count -ge 3 -and $_[0] -eq 'CreateProxyAuthority' } | Select-Object -First 1 + if ($null -eq $caRow) { + throw "Installer proxy-CA guard (#77): CustomAction table has no CreateProxyAuthority row." + } + if ($caRow[1] -notmatch '1026') { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority must be a deferred (SYSTEM) exe custom action (type 1026), found row: $($caRow -join ' / ')." + } + if ($caRow -notcontains 'WebContentProxyCa.exe') { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority must run the WebContentProxyCa.exe binary stream, found row: $($caRow -join ' / ')." + } + if (($caRow -join ' / ') -notlike '*provision MilestoneEventServerService*') { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority lost its 'provision MilestoneEventServerService' command line, found row: $($caRow -join ' / ')." + } + if (-not $seq.ContainsKey('CreateProxyAuthority')) { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority missing from InstallExecuteSequence." + } + $caCondition = ($ies | Where-Object { $_.Count -ge 1 -and $_[0] -eq 'CreateProxyAuthority' } | ForEach-Object { if ($_.Count -ge 2) { $_[1] } else { '' } }) -join ';' + foreach ($needle in 'NOT REMOVE', 'EVENTSERVERSERVICEPRESENT', 'PROXY_SUPPORT') { + if ($caCondition -notlike "*$needle*") { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority condition '$caCondition' lost '$needle' - the CA must be created only on install/upgrade, only when the Event Server service exists, and only with the proxy opt-in ticked." + } + } + if ($seq['CreateProxyAuthority'] -le $seq['InstallFiles']) { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority ($($seq['CreateProxyAuthority'])) must run after InstallFiles ($($seq['InstallFiles'])) - the helper drives the plugin's load-or-create through the just-installed WebContent.dll." + } + if ($seq['CreateProxyAuthority'] -ge $seq['InstallFinalize']) { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority ($($seq['CreateProxyAuthority'])) must run before InstallFinalize ($($seq['InstallFinalize'])) so it stays a deferred SYSTEM action inside the script." + } + if ($seq['CreateProxyAuthority'] -ge $seq['RestartEventServerService']) { + throw "Installer proxy-CA guard (#77): CreateProxyAuthority ($($seq['CreateProxyAuthority'])) must run before RestartEventServerService ($($seq['RestartEventServerService'])) so the Event Server comes back on a provisioned CA." + } + $proxyCheckbox = $checkboxes | Where-Object { $_.Count -ge 9 -and $_[0] -eq 'EventServerOptionsDlg' -and $_[8] -eq 'PROXY_SUPPORT' } | Select-Object -First 1 + if ($null -eq $proxyCheckbox) { + throw "Installer proxy-CA guard (#77): EventServerOptionsDlg carries no CheckBox bound to PROXY_SUPPORT - the opt-in must be on the same page as the restart choice." + } +} finally { + $zip.Dispose() +} +Write-Host "Installer sequencing OK: force-close before file removal, RemoveExistingProducts late (#70); Event Server stop/restart flow gated, inside the script, choice-carrying (#82); install-time deployment CA gated on ES + proxy opt-in after the file copy (#77)." -ForegroundColor Green Write-Host "Build completed successfully." -ForegroundColor Green [pscustomobject]@{ diff --git a/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md b/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md new file mode 100644 index 0000000..5c753ad --- /dev/null +++ b/docs/adr/0002-terminate-proxied-https-at-loopback-listener.md @@ -0,0 +1,73 @@ +# 0002 — Terminate proxied HTTPS at the loopback listener; validate target TLS on the Event Server + +Date: 2026-10-31 +Status: Accepted; Decision §1 trust lifecycle (per-session CA in the CurrentUser root store) and +the browser-trust consequence are superseded by ADR-0003 (fixed deployment CA in LocalMachine +stores, leaves minted by the Event Server). Everything else stands. + +## Context + +The workspace proxy (#52–#55) pins each proxied WebView2 browser to a loopback forwarding +listener (`--proxy-server` for every scheme) and replays allowlisted requests from the Event +Server network, because the Smart Client machine cannot reach the camera/VMS-network targets +directly. Ticket #56 adds HTTPS targets with an admin "accept untrusted certificate" override +stored per allowlisted target. + +Chromium always sends `CONNECT host:port` for `https://` URLs routed through an HTTP proxy — +there is no absolute-form HTTPS proxy request. A blind CONNECT tunnel is useless here: the +client machine has no network route to the target, so the tunnel cannot be opened from the +client, and if TLS were tunneled to the browser the Event Server would never see the encrypted +traffic — a per-target validation override on the Event Server fetcher would be meaningless +(the browser would show its own certificate error that plugin code cannot bless). + +The alternatives considered: + +- **CONNECT tunnel + browser-side trust** (`--ignore-certificate-errors` variants): the + override would have to reach Chromium, no Event Server-side chain validation happens, and + the acceptance criteria for a validated fetch with a scoped override cannot be met. +- **Never tunnel; forward `https://` in absolute form**: not producible by Chromium through an + HTTP proxy; page-level URL rewriting would be fragile against in-page links, scripts, and + redirects. +- **Event-Server-side trust only**: impossible without terminating TLS somewhere in the plugin + chain first. + +## Decision + +Terminate TLS at the loopback listener and keep the security-critical validation hop on the +Event Server: + +1. An allowlisted `CONNECT` is answered with `200 Connection established` and then terminated + by the listener with an ephemeral certificate for the target host, issued from a + per-session CA that is installed into the CurrentUser Trusted Root store for the life of + the proxied browser and removed afterwards. The browser therefore sees a genuine + `https://target` origin (no mixed-content or cert errors). +2. The decrypted inner requests (origin-form + `Host`, resolved against the CONNECT origin) + ride the existing `ProxyFetchQuery` → MIP messaging → Event Server fetch pipeline + unchanged — the wire contract does not change. +3. The Event Server fetcher performs the real TLS leg to the target with **full chain + validation by default** (no callback set), and applies the per-target override only via a + per-request `HttpWebRequest.ServerCertificateValidationCallback` — never + `ServicePointManager` globally — when the fetched origin carries the stored + `acceptUntrusted` flag on its allowlist entry. +4. A non-allowlisted `CONNECT` stays refused (403), and validation failure without the flag + produces a dedicated `TlsValidation` fetch error with an actionable message. + +## Consequences + +- Two TLS legs exist for an https target: browser ↔ loopback (local IPC, ephemeral cert, + trust scoped to one session's certificate store entries) and Event Server ↔ target (the + real, validated leg). "Accept untrusted certificate" therefore scopes exactly to the + target origin and never to the whole system or to the browser's cert checking. +- The per-session CA/leaf plumbing (`SslStream` server auth, `CertificateRequest`) is new + plugin surface; leaves are cached per host and both store writes and removal are logged. + Store access is CurrentUser (no elevation). +- WebSocket (`wss://`) rides CONNECT too: the TLS-terminated inner request carrying an + `Upgrade:` header is refused with a clear 405 — relaying WebSocket through the fetch + pipeline stays out of scope. +- Config format: `proxyTargets` entries gain an `acceptUntrusted` member; MC admin page rows + gain an `accept-untrusted` suffix token. Version skew degrades fail-closed (an old Event + Server ignores the unknown member and validates normally; a new Event Server reading old + JSON sees the flag absent = off). +- The browser must trust the ephemeral CA. WebView2 honors the CurrentUser Trusted Root + store, so no Chromium flags are needed; the CA install/remove lifetime is tied to the + proxied browser's listener. diff --git a/docs/adr/0003-event-server-mints-loopback-leaf-certs.md b/docs/adr/0003-event-server-mints-loopback-leaf-certs.md new file mode 100644 index 0000000..e6dfd12 --- /dev/null +++ b/docs/adr/0003-event-server-mints-loopback-leaf-certs.md @@ -0,0 +1,109 @@ +# 0003 — Event Server mints the loopback leaf certs; deployment CA trusted via LocalMachine stores + +Date: 2026-10-31 +Status: Accepted; amended 2026-11-03 by #77 (see **Amendment** below — CA *creation* +moved into the Event Server-side MSI as a gated install-time custom action; Decision §1's +provisioning-script sentence and §4's "not a WiX custom action" objection are superseded, +everything else stands). +Amends: ADR-0002 (the CA/trust lifecycle in its Decision §1 and Consequences; the +loopback-termination, fetch-pipeline, and per-target override parts of ADR-0002 stand). + +## Context + +ADR-0002 trusted a **per-session** CA by writing it to the CurrentUser Trusted Root store. +Live probing showed two blockers: + +1. `X509Store.Add` into the user root store raises crypt32's modal "Security Warning" consent + dialog. There is no silent path into the user trust store; an unattended Smart Client session + hangs on the prompt. +2. A Smart Client machine may serve several Windows users; per-user trust would have to repeat + per login and still prompts. User-root trust is the wrong scope. + +The alternative is a **fixed CA present in the machine's trust stores**. Because Smart Client runs +non-elevated, the plugin can never write to `LocalMachine\Root` (writes need elevation; the +installer or an admin shell is the only legitimate writer). If the CA's **private key** also sat +in `LocalMachine\My` on every client, its ACL would have to be opened to interactive users (the +listener signs leaves in-process) — any user/process on that box could then forge certificates for +any site. Name constraints were evaluated as the mitigation: a hand-built `nameConstraints` +extension (RFC 5280 §4.2.1.10, OID 2.5.29.30) verifies byte-identical to openssl's encoding, and +net472 crypt32 enforces it on the SChannel path the browser uses (`NotNameConstraintValid` for +out-of-scope leaves). But permitted subtrees are static while proxied targets are runtime +allowlist config: any DNS/IP subtree set at install time breaks legitimate public-HTTPS targets. + +## Decision + +Split the CA's two halves by trust boundary: + +1. **One long-lived CA per deployment, created on the Event Server.** The Service-environment + background plugin loads it from `LocalMachine\Personal` by common name on start and creates it + on first run when the service account can write the machine store. Live installs run the Event + Server service as NETWORK SERVICE, which cannot create it; an administrator then runs + scripts/provision-event-server-authority.ps1 once, elevated, which creates the CA, grants the + service account read access to its private key, and exports the certificate for client import. + The key lives in a fixed machine key container readable only by machine-privileged accounts + plus that granted service read access. Client machines never hold the CA private key. +2. **Leaves are minted by the Event Server on request.** A new message pair + (`WebContent.Proxy.LeafRequest` / `WebContent.Proxy.LeafResponse`, same JSON-string-on-the-wire + rule as #54) carries the client's base64 **SubjectPublicKeyInfo**: the client generates the + leaf key pair (CAPI `AT_KEYEXCHANGE` provider — Schannel rejects signature keys) and the + Event Server signs that public key with the CA. No private key ever crosses the wire. +3. **The allowlist IS the minting policy; no name constraints.** The Event Server mints only for + origins present in the same workspace allowlist union that gates fetches (`NotAllowed` + otherwise, fail-closed like the fetcher). Constraining subtrees was rejected as above. + Residual risk: whoever owns the Event Server machine's machine-level key store can mint certs + for any site — that principal already controls every proxied fetch, so the risk adds nothing + material. +4. **Clients trust the CA public certificate via `LocalMachine\Root` as a one-time elevated + admin step** (`Import-Certificate -CertStoreLocation Cert:\LocalMachine\Root` with the CA DER + the LeafResponse carries). Not a WiX custom action: the CA does not exist until the Event + Server plugin first starts, so the client MSI cannot carry it (ordering), and non-elevated + runtime writes to Root are by design impossible. The listener checks for the CA's presence + (thumbprint from the LeafResponse) and logs actionable guidance when it is missing; until + trusted, tunnels still serve but the browser shows its certificate error — fail-closed, + documented. +5. The CurrentUser-root install/remove lifecycle of ADR-0002 (`ProxyLoopbackTrust`) is removed. + +## Consequences + +- Leaf issuance adds one Event Server roundtrip per (host, browser session); leaves are cached + per host exactly as before, and every proxied byte already requires the Event Server, so no + new dependency or failure mode appears beyond "LeafRequest refused/not trusted", both logged. +- The CA outlives browser sessions and ES restarts: same certificates across sessions means + leaf-cache semantics stay simple, and uninstall cleanup on the ES machine (delete from + `LocalMachine\My`) is a documented manual step alongside the client-side `Root` removal. +- Client-side minting code (`ProxyLoopbackAuthority`'s authority-creation half) survives only as + the Event Server's minting backend; the client half shrinks to key generation (SPKI export), + leaf import, and local exchange-key ownership. +- A machine that has not run the trust step shows Chromium certificate errors for proxied https + targets — the same fail-closed posture as any untrusted CA, with plugin log guidance. + +## Amendment (#77, 2026-11-03) — CA creation moves into the Event Server-side MSI + +Ticket #77 (epic #64) inverts this ADR's rejection of MSI custom-action creation. The original +§4 objection was an **ordering** one: a *client* MSI can't carry a CA that does not exist until +the Event Server plugin first starts. That objection is answered by moving creation to the +**Event Server machine's own install**, where the CA is created *during* the install instead of +at first plugin start: + +1. The plugin installer's Event Server options page (only shown when `MilestoneEventServerService` + exists) carries an "Enable web content proxy support" checkbox (public property + `PROXY_SUPPORT`, default on, scripted decline `PROXY_SUPPORT=0`). A deferred exe custom action + (`CreateProxyAuthority`, type 1026 SYSTEM, `Return="check"`, built by `src/WebContentProxyCa`, + streamed as an MSI `Binary`, never part of the drop) runs **only when the Event Server service + is present AND the checkbox is ticked** — machines without an Event Server, and declined + installs, never have `LocalMachine\Personal` touched at all. +2. The action does not mint certificates itself: it drives the plugin's **own** + `LoadOrCreateDeploymentAuthority` by reflection into the just-installed `WebContent.dll` + (the same posture the provision script had), so the CA shape stays single-sourced — fixed CN, + fixed machine key container, reuse of an existing valid CA untouched. +3. It then grants the Event Server service's own logon account (SCM `ObjectName`) read access to + the CA private key, locating the key file through the CAPI handle + (`CspKeyContainerInfo.UniqueKeyContainerName`) instead of the script's hash-name guess. Failure + to provision or to grant fails the install (`Return="check"`), never a half-set-up deployment. +4. `scripts/provision-event-server-authority.ps1` is retired to a legacy/repair fallback (dev + `Copy-Item` deployments, hand-repaired stores). The plugin's lazy first-mint creation in + `LoadOrCreateDeploymentAuthority` stays as the fallback for declined/legacy installs — no + behavior change there. +5. Client trust (§4's remaining content) is unchanged: never automatic, one-time elevated admin + step via the Management Client export (ticket #75), Group Policy, or the Smart Client setup-mode + trust helper (ticket #72). diff --git a/docs/adr/0004-proxied-boundary-and-listener-claim.md b/docs/adr/0004-proxied-boundary-and-listener-claim.md new file mode 100644 index 0000000..4da4fd8 --- /dev/null +++ b/docs/adr/0004-proxied-boundary-and-listener-claim.md @@ -0,0 +1,35 @@ +# ADR-0004: Scope proxied browser state and listener ownership to the proxied boundary + +- **Status:** Accepted (point 5 settled by #90's measurements, 2026-11-03) +- **Tickets:** [joshooaj/WebContentPlugin#87] (symptom and interim fix), [joshooaj/WebContentPlugin#90] (browser-process lifetime probe), [joshooaj/WebContentPlugin#91] (enablement) — builds on ADR-0001, ADR-0002, ADR-0003 + +Proxied views run a loopback listener whose port is baked into the WebView2 browser arguments, while the proxied user-data folder is deterministic per workspace item. WebView2 shares one user-data folder, and one browser process, across host processes only when the environment options match exactly, so a second Smart Client process on the same machine fails initialization and shows a blank view (#87). We decided to *enable* that second process rather than fragment the profile: one shared proxied profile per workspace item, with ownership made explicit by a claim record instead of implicit in process race order. Cross-process profile sharing (cookies, cache, logins) is then free, which fragmenting the profile would throw away and laboriously rebuild. + +The sharing is fenced by the **proxied boundary**: the (Windows user, XProtect identity, VMS server) tuple. The proxied user-data folder and the listener claim are both keyed on the full tuple, so proxied browser state and proxy fetching are never shared across it. This matters most for the server leg: two Smart Clients logged into *different* VMS servers on one machine must never share a proxied profile or a fetch channel, because the joined process's traffic rides the owner's Event Server messaging connection and the owner's session identity. Today the server enters the profile key only accidentally (the identity value differs per server only when the identity source happens to differ); the boundary makes it explicit. + +## Decision + +1. **Keep one shared proxied profile per browser id.** One browser process per proxied profile per machine, shared by every Smart Client process inside the boundary, not one per process. +2. **Key the proxied profile and the listener claim on the full proxied boundary** (Windows user, already implicit in the per-user data root, plus XProtect identity plus VMS server). Crossing the boundary is impossible by construction, so a client can never ride another identity's or another server's proxy session. +3. **Keep the ephemeral loopback port; publish a claim.** The owning process records browser id, port, owning process id, and config fingerprint under the boundary-scoped data root, written atomically. A joining process reads the claim and composes the identical `--proxy-server` argument rather than binding its own listener. A claim whose owner process is gone or whose port is unresponsive is stale. +4. **Accept the within-boundary consequence.** Inside one boundary every proxied fetch rides the owner process's Event Server connection, and #69 diagnostics show one session per proxied profile rather than per client process. This is deliberate, not an oversight. +5. **Handoff (measured by #90).** The browser process survives the owner host's clean exit AND crash while any attached host remains, so at handoff the views are alive but their baked-in port is dead: the rule is stale claim (dead pid / unresponsive port) -> one survivor re-claims a fresh listener, the claim epoch bumps, attached processes release their proxied WebViews, and the browser process recycles to carry the new port. A fresh init respawns a destroyed collection in ~13 s measured. #87's in-view ownership error covers the window. Until enablement lands, #87's error is also the whole contract. + +## Consequences + +- Two Smart Client processes on the same machine, same user, same server both get working proxied views sharing cookies and cache (once #91 lands). +- #69 diagnostics granularity is per proxied profile, not per process. +- The claim file is shared mutable cross-process state: it needs atomic creation and staleness detection, and the proxied profile folders need an owner for disk cleanup, which nothing does today. +- A comparable keying gap exists for **non-proxied** profiles (same username on two servers can collide via the username fallback); fixing it is outside this decision. +- The claim's port target is the TLS-terminating listener of ADR-0002; a joined session's traffic is still intercepted and allowlisted by the owner's listener, so #65 rule triggers and #83 block reporting keep working, attributed to the owner's session. + +## Alternatives considered + +- **Per-process user-data folders with cookie replay (CookieManager hydrate/dehydrate):** full isolation, but no shared cache, localStorage, or passwords; a web app keeping login state in localStorage would re-log-in per process, and a plugin-owned cookie jar with merge rules is new state to guard. Rejected: the isolation it buys is already provided by the boundary. +- **Deterministic port per browser id:** arguments match without a claim file, but two processes cannot both bind one port, so ownership still has to be decided; the claim file just makes the race explicit, discoverable, and stale-detectable. Rejected as a bare mechanism. +- **Machine-level proxy helper with its own VMS connection:** decouples the listener from any client's lifetime and makes the argument trivially constant, but breaks per-identity fetch attribution and adds an installed component. Rejected: heavier than the boundary design buys back. +- **Degrade only, never enable:** cheap, but the shared-browser-process model is supported and nearly free once options match, so the blank view is a self-inflicted mismatch rather than a platform wall. Kept only as the interim contract (#87). + +[joshooaj/WebContentPlugin#87]: https://github.com/joshooaj/WebContentPlugin/issues/87 +[joshooaj/WebContentPlugin#90]: https://github.com/joshooaj/WebContentPlugin/issues/90 +[joshooaj/WebContentPlugin#91]: https://github.com/joshooaj/WebContentPlugin/issues/91 diff --git a/docs/verification-matrix.md b/docs/verification-matrix.md index 026503f..784e499 100644 --- a/docs/verification-matrix.md +++ b/docs/verification-matrix.md @@ -8,11 +8,14 @@ named log marker. An area is **verified** only when all of its rows pass. | Gate | Command | Pass criterion | |---|---|---| -| Regression suite (all automated rows) | `dotnet test src/WebContent.Tests` | exit 0, zero failed | -| One-command gate script | `.\verify.ps1` (add `-IncludePluginBuild` on a VS2022 machine) | exit 0, prints `Release gate: PASS` | +| Plugin build | `Invoke-psake -taskList Build` (raw equivalent: `dotnet build src/WebContent/WebContent.csproj -c Debug`) | exit 0, no sbom-tool warnings (the GenerateSbom target passes `-D true`, so stale `_manifest` dirs self-overwrite) | +| Regression suite (all automated rows) | `Invoke-psake -taskList Test` (builds first; raw equivalent: `dotnet test src/WebContent.Tests`) | exit 0, zero failed | +| UI probe suite (live-host rows) | `Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~ProbeProxied' }`; bare `TestUI` runs the whole `tests/ux` project | probes go red on failure, never silently skip; needs the deployed drop on both machines and the per-row env flags | +| One-command gate script | `.\verify.ps1` (add `-IncludePluginBuild` to also run the plugin build) | exit 0, prints `Release gate: PASS` | | CI | `Tests` job in `.github/workflows/ci.yml` (runs on PRs and pushes) | job green before merge to `main`; the installer only publishes from `main` | -| Plugin build | `msbuild src/WebContent/WebContent.csproj /t:Build /p:Configuration=Debug /p:Platform=x64` | exit 0, no sbom-tool warnings (PostBuildEvent passes `-D true`, so stale `_manifest` dirs self-overwrite) | +| Deploy | `Invoke-psake -taskList DeployLocally, DeployToVms` (see `AGENTS.md`) | local drop hash-identical to `output\WebContent\`; ES restarts and logs its ready marker | | Manual host checks | Steps below, run against a deployed plugin per `AGENTS.md` | each row's expected outcome and log marker observed | +| Full installer build | `.\build.ps1 [-Version x.y.z]` (also caches the WebView2 runtime and builds `src/Setup/setup.wixproj`) | exit 0; SBOM guard green; #70 sequencing guard throws unless the MSI force-closes the Milestone clients before RemoveFiles/InstallFiles with RemoveExistingProducts late; #82 service-flow guard pins the stop/restart actions (type 1026 at 1502/4001), their conditions, and the choice-page dialog hop; #77 proxy-CA guard pins the `CreateProxyAuthority` action (1026 on the streamed `WebContentProxyCa.exe`, command line, slot after InstallFiles and before the restart, ES + `PROXY_SUPPORT` condition needles, checkbox Control row) | Manual-check logs: Smart Client → `%ProgramData%\Milestone\XProtect Smart Client\MIPLog.txt` (no `Logs\` subfolder); Management Client → `Logs\MIP.log` under its own ProgramData folder. @@ -32,11 +35,50 @@ is not evidence the plugin never ran. | Session state (last-URL restore) | #9 | `WorkspaceSessionStateTests` (record/overwrite per instance key, fingerprint guard makes a definition edit unreadable, reset, blank-fingerprint standalone exclusion, restore precedence recorded → persisted → home, definition-update detection from fingerprints) | See MC-7 below: workspace switch restores the page, restart resets to home | | Configuration error visibility | #9 | `WorkspaceDefinitionTests` (`ConfigurationError` classifies malformed JSON, blob-without-items, no-home-address item, nothing configured; malformed JSON reported even when legacy properties would rescue it; `SelectVisible` keeps named-but-broken tabs and still skips unnamed/unpermitted) | See MC-8 below: broken tab appears with a WorkSpaceNotification warning | | SBOM integrity | #25 | `SbomGenerationGuardTests` (PostBuildEvent keeps a quoted absolute `-bc "$(MSBuildProjectDirectory)"` component path and `-D true`; the original bug let the CWD-relative `-bc .\` point at the build drop, so zero packages were detected) | `.\build.ps1` throws unless `output\WebContent\_manifest\spdx_2.2\manifest.spdx.json` lists referenced packages; CI Build job shows `SBOM OK` and no "no packages detected" annotation | +| Proxy configuration roundtrip | #52 | `ProxyConfigurationTests` (definitions saved before the proxy existed mean proxy-off; toggle+targets survive the serialized roundtrip), `ProxyPolicyTests` (admin entries canonicalize to scheme/host/port origins; garbage rejected; exact-origin matching; duplicates/overlaps collapse), `WorkspacePropertiesTests` (JSON-blob and legacy paths emit canonical `ProxyEnabled`/`ProxyTargets`, hand-crafted target gibberish skipped) | See MC-9 below: admin toggle round-trips to the Smart Client properties page; invalid target save refused with log marker | +| Proxy fetch messaging (Event Server fetcher) | #53 | `ProxyFetchContractGuardTests` (message ids never end in "Command" so SecurityLevel.Normal clients can send; payload types stay plugin-namespace `[Serializable]` with public fields — wire shape itself is pinned by `ProxyFetchWireCodecTests` since #54; frame-kind/error-code constants stable), `ProxyFetchFramerTests` (meta frame Sequence=-1 then base64 chunk frames 0..N-1 with IsLastChunk; failure is a lone frame), `ProxyFetchCorrelationTests` (meta+chunks→complete body; out-of-order buffered; failure frame surfaces error code; foreign correlation ids ignored; deadline silence → `ServerUnavailable` not target-leg `Timeout` since #58), `ProxyFetchTransportTests` (real HttpListener: status+headers captured, size cap aborts, connect/read budgets, refused connection maps to Unreachable; #55: method/form body/Cookie+custom headers replayed at the target, 302+Set-Cookie relayed instead of followed, repeated Set-Cookie values captured separately), `EventServerLoadGuardTests` (plugin.def load env includes `Service`; the definition exposes `BackgroundPlugins` with a Service-targeted plugin and declares both fetch message ids in `PluginDefinedMessageIds`) | See ES-1 and MC-10 below: Event Server loads the fetcher, and the Management Client harness fetches a page through it with failures logged | +| Loopback forwarding proxy (Smart Client view consumes the fetch seam) | #54, write verbs #55 | `ProxyForwardCodecTests` (absolute-form proxied request parsing; origin-form/non-http refused; CONNECT recognized by authority target; outbound request-header filter keeps Cookie/Authorization/custom headers and drops hop-by-hop+Host+Content-Length; forwarded-response rendering: status line, content headers relayed, hop-by-hop dropped, Content-Length restated from the reassembled body; refusal pages), `ProxyForwardPolicyTests` (decision table via `ProxyPolicy`: allowlisted GET/HEAD/POST/PUT/DELETE/OPTIONS/PATCH forward, other origins 403, TRACE/junk verbs 405, CONNECT 502, malformed 400 — address-bar navigation follows the same rule), `ProxyForwardConnectionTests` (full stream flow: forward+relay, POST method/form body/Cookie to the fetch seam, 302+Set-Cookie relayed back, repeated Set-Cookie rendered as separate lines, 403 without touching the fetcher, oversized declared body 413, chunked body 411, Unreachable→502, Timeout→504), `ProxyBrowserArgumentsTests` (every http/https/ws scheme pinned to the listener, `<-loopback` bypass off — never direct), `ProxyWorkspaceProfileTests` (enabled+targets → listener allowlist; duplicates collapse; enabled-with-no-targets still proxies and allows nothing; disabled → no profile), `ProxyServerAllowlistTests` (ES refuses origins no proxied workspace allowlists, proxy-off workspaces don't count, closed before first resolve, stale-on-error keeps serving the last snapshot) | See SC-1 below: with the drop deployed on both the Event Server machine and the Smart Client machine, and the client's route to the target killed, the proxied page still loads — proof the traffic source is the server, not the client. See SC-2: a login POST round-trips with its session cookie in a live Smart Client session | +| HTTPS targets: target TLS verdict, per-target override, loopback leaf mint (ADR-0002/0003) | #56 | `ProxyFetchTlsDecisionTests` (https targets get full chain validation on the Event Server; an untrusted chain refuses with `TlsValidation` unless the allowlist entry carries the per-target accept-untrusted override — never a global bypass), `ProxyPolicyTests` (target-line grammar: `https://host:port accept-untrusted` in either token order; unknown tokens or a second origin reject the whole entry), `ProxyServerAllowlistTests` (the `acceptUntrusted` flag round-trips with its entry through the workspace JSON), `ProxyLeafContractTests` (LeafRequest carries ONLY the client-generated SPKI; the allowlist union is the minting gate — `NotAllowed` fail-closed like the fetcher, `BadRequest` for malformed origin/key, `NoAuthority` when the deployment CA cannot load/create; minted leaf chains to the deployment CA with host SAN + serverAuth EKU; single-frame correlation with deadline), `ProxyLoopbackTlsTests` (leaves cached per host:port, one mint exchange per origin; refused mint fails the tunnel with the server's code; seeded `AcceptTunnelAsync` + real Schannel server handshake on a channel-minted leaf), `ProxyLoopbackClientKeyTests` (exported SPKI is public-only; `AttachLeaf` yields a private key Schannel accepts; failed attach keeps the key usable), `ProxyFetchReplyGuardTests` (leaf answers unicasted to the requester endpoint, never broadcast), `EventServerLoadGuardTests` (leaf message ids declared) | See SC-3 below: both-machine drop plus the one-time elevated CA import into `LocalMachine\Root`; a self-signed https target fails closed WITHOUT the override (proxy error page names the exact per-target switch) and the full login flow completes WITH it | +| WebSocket CONNECT tunnels through the proxy (raw bytes both directions, outside the fetch chunking path) | #57 | `ProxyForwardPolicyTests` (absolute ws:// upgrade to an allowlisted http origin tunnels; CONNECT to an http-twin-allowlisted origin accepted as the ws-tunnel posture live Chromium actually uses — https entries keep precedence for the #56 TLS posture; wss:// and in-tunnel upgrades refused 501, never fetched — the 501 posture was lifted by #60, see the #60 row); missing Sec-WebSocket-Key / wrong version 400; h2c upgrades stay ordinary forwards), `ProxyWebSocketHandshakeTests` (upgrade recognized only for GET+Connection upgrade token+Upgrade websocket; rebuilt origin-form handshake: uri-derived Host, browser's Sec-WebSocket-Key byte-identical, explicit Connection/Upgrade pair, header-injection gate; 101-only admission; head terminator), `ProxyTunnelContractTests` (six directional ids; one JSON-string payload shape for the whole protocol; Data/Close kinds; limits), `ProxyForwardConnectionTests` (upgrade head pipelined bytes ride into the tunnel first; the target's verbatim 101 written to the browser before the first pump byte; both pumps; either side ending ends both; in-tunnel wss refused 501 through the tunnel stream — superseded by #60's real in-tunnel tunneling), `ProxyTunnelServerTests` (in-process ws target: 101 admits and both pumps echo; non-101 refused HandshakeRefused; dead target Unreachable; ES cap rejects; duplicate id rejected; client bytes reach the target in order through the serialized writer; idle reclaim closes and notifies; CloseAll), `ProxyTunnelCorrelationTests` (client registry: ordered pump queue, open deadline completes as failure, half-open + idle reclaim return ids for close notification, orphan open-ack closes its handle, teardown closes all), `EventServerLoadGuardTests` (tunnel ids declared in PluginDefinedMessageIds) | See SC-4 below: the ws-echo page in a proxied Smart Client browser shows its token echoed (client-side proof) while the ws server journals WS-HANDSHAKE + WS-ECHO (server-side proof the upgrade arrived as a tunnel, not a fetch) | +| Explicit proxy error pages in the view, no direct fallback (#58), friendly cause categories + collapsible technical detail on those pages (#66) | #58, #66 | `ProxyForwardConnectionTests` (each failure kind renders its own page with the failing side named: `ServerUnavailable` → 503 page naming the VMS proxy component and forbidding direct fallback; server-side `NotAllowed` → 403 allowlist posture distinct from target failure; `Unreachable` → 502 "VMS server could not reach the target"; `Timeout` frame → 504 target-leg; `TooLarge` → page naming the size cap), `ProxyErrorPageTests` (the #66 wording layer: every `ProxyFetchErrorCodes` value maps to its OWN distinct cause category - collapsing distinct causes fails the test; headline + cause chip + explanation carry the #58 side-attribution sentences verbatim; technical detail collapsed by default with the canonical allowlist entry that would admit the origin, the byte cap, the verbatim refusal text, and the Copy diagnostics affordance), `ProxyFetchCorrelationTests` (deadline silence = `ServerUnavailable`, never the target-leg `Timeout` code; `Fail` completes the exchange with the channel reason) | See SC-5 below - **all live rows PASSED 2026-11-01 (records 3 + 4 + 5)**: stopped Event Server + reload renders `Web Content proxy: 503` naming the component with the `ServerUnavailable` MIPLog marker (run `20261101-082437`), and an off-allowlist start URL renders the 403 page as the workspace's first document with the home address restored afterwards (run `20261101-084616`). Recovery is asserted in the same row, not narrated: a fresh Smart Client session after the restart gets the real document back (run `20261101-130629`). **The #66 friendly page re-passed the start-URL row live 2026-11-02 (record 6, run `20261102-232047`)**: headline + cause chip rendered, technical section collapsed by default, Copy diagnostics button present, canonical allowlist entry in the diagnostics - all asserted, not screenshot-only. Both-machine drop in place (record 3) | +| Off-allowlist **https** refusals render the friendly page at the VIEW layer (Chromium transports https only as CONNECT and DROPS a refused CONNECT's body - the proxy's own 403 page can never reach the screen for those) | #88 | `ProxyViewRefusalTests` (the gate answers https only - plain http is never double-answered and stays on the proxy's absolute-form path; an off-allowlist https origin gets the #66 page: `NotAllowed` cause chip + the verbatim "not in the workspace proxy allowlist" sentence + the canonical **https** entry that would admit it; the http twin of an allowlist entry never unlocks its https sibling; allowlisted https origins/ports/wildcard subdomains fall through untouched; wildcard apex and the SSRF floor refuse at the view exactly as at the listener; null/empty entry sets are the fail-closed reading; a divergence-guard row pins the view gate and the forward CONNECT gate to identical verdicts), `ProxyAllowlistGateAgreementTests` extended (the view gate joins the four-gate matrix: https-only scheme rule on top, verdicts identical to the ES fetch gate / forward gate / leaf-mint gate incl. the VMS floor and both entry shapes), `ProxyForwardConnectionTests` (root-cause pin: the refused CONNECT still gets the complete 403 + friendly page written as the CONNECT answer body on the wire - Chromium drops that body, the live half is the SC-5 record 7 page; the fail-closed CONNECT refusal and the #69 block report stay untouched) | See SC-5 below - **the https start-URL row PASSED live 2026-11-03 (record 7, run `20261103-020728`)**, both-machine drop `E17995E1…5479BDFC`: the refusal page renders committed AT `https://neverssl.com/` with all #66 assertions, the browser's own log carries the `view-level https refusal ... No CONNECT was opened` marker, and the ES diagnostics ring records the origin; the http sibling row re-passed green in the same run set (`20261103-020505`). The ws/wss CONNECT asymmetry (native Chromium page) is documented in README | +| Server-side deployment + rollout docs for proxied connections (#59) | #59 | `scripts/deploy-event-server-plugin.ps1` refuses a drop whose `plugin.def` lacks the `Service` environment before touching the server; stop-wait/stop, copy, per-file SHA-256 verification against the source, restart-wait, and the two rollout follow-ups (client restart, fetcher-ready marker) are printed by the script itself | See DP-1 below: the script validated live end-to-end against the Event Server machine (11 files hash-identical, `Proxy fetcher ready` after restart); README/log-path/version claims checked against the lab; fresh-both-machine blocked-egress smoke PASSED with both machines at the same hash-verified drop | +| wss:// websockets tunnelled through a terminated TLS tunnel (Event Server dials a validated TLS leg, replays the rebuilt handshake, admits only 101) | #60 | `ProxyForwardPolicyTests` (the entry scheme IS the posture: an in-tunnel upgrade GET against an allowlisted https origin — or absolute wss:// against an https entry — becomes `WebSocketTunnel`, never 501; an http twin alone is 403 for the upgrade; the `WebSocketTunnelRefused` posture is deleted outright), `ProxyForwardConnectionTests` (in-tunnel upgrade opens the byte tunnel, the target's verbatim 101 rides back ON the terminated tunnel, then both directions pump decrypted frames over the tunnel stream; open failure renders an explicit 502 page on the tunnel and the tunnel keeps serving), `ProxyTunnelServerTests` (in-process `TlsWsTarget` with a self-signed SAN certificate: an https-scheme tunnel target gets a TLS leg authenticated against the target host before the handshake is replayed, 101-only admission, frames echo over both pumps; untrusted chain → explicit `TlsValidation` error naming the origin and the Event Server as the failing side; production resolver is null = SslStream full chain validation), `ProxyWebSocketHandshakeTests` (rebuilt handshake pins the https origin's Host for https-scheme tunnel targets) | See SC-6 below - **both live rows PASSED 2026-11-01** (runs `20261101-174151` + `20261101-174657`): with the probe certificate imported into the Event Server's trust store, a wss-echo page in a proxied Smart Client browser shows `WS-OK:` while the wss server journals TLS-HANDSHAKE + WS-HANDSHAKE + WS-ECHO; the fail-closed row's target journals the TLS leg dialed + negotiated but never the upgrade - the Event Server refused the chain there | +| wss tunnel legs honour the per-target accept-untrusted override (the #56 flag reaches the tunnel leg, scoped to its own origin) | #61 | `ProxyServerAllowlistTests` (`TunnelTlsValidationCallback`: null = SslStream default full-chain validation for every unflagged posture - no flag, explicit false, the http twin of another origin, a never-resolved closed gate - while the flagged entry's callback accepts chain errors; the flag rides wildcard entries to subdomains with scheme/port exactness, never the apex or another port), `ProxyTunnelServerTests` (allowlist-WIRED resolver end to end: the self-signed `TlsWsTarget` opens and pumps frames only when its entry carries the flag; without it the same wiring fails with the SAME explicit named-side `TlsValidation` error; two self-signed targets with ONE flag → the flagged tunnel opens, the neighbour's TLS dial never replays the upgrade - the flag is never a global pass), `EventServerTunnelTlsWiringGuardTests` (the ES constructs the tunnel server with the resolver, never the null #60 slot; the resolver delegates to the single scoped allowlist lookup; the fetcher source never touches `ServicePointManager`) | See SC-6 below - **override row PASSED 2026-11-02** (run `20261102-113540`), and the two #60 twin rows re-passed on the same drop (fail-closed `20261102-114705`, trusted-echo `20261102-115053`) | +| MC workspace config tab shell (Settings \| Proxy \| Diagnostics \| Status; Status and Diagnostics behind the SecurityActions edit-rights gate) | #74 | `WorkspaceTabShellGuardTests` (the XAML declares exactly the four tabs in shell order with stable AutomationIds; Status and Diagnostics each default `Collapsed` so the gate fails closed before any fill; EVERY pre-existing settings/proxy control still lives on the Settings tab under the same AutomationId the ux probes read; no placeholder tab is left since #84 - both live read tabs are guarded down to their buttons' exact handlers instead), `WorkspaceTabPolicy` decision table (Status AND Diagnostics render only on an explicit `Granted` - `Denied`/`LookupFailed` hide the whole tab) | See MC-11 below - **admin half live row PASSED 2026-11-02** (run `20261102-012719`): four tabs render on the live MC with Status granted to the admin, walking the tabs never enables Save, and the Settings fields read back identically; the View-only denial half stays manual | +| Proxy diagnostics substrate (block reports, session lifecycle, Event Server aggregation, status query) | #69 | `ProxyDiagnosticsAggregatorTests` (blocked ring dedup + accumulating counts + recency eviction at the 200-origin cap, new-entry-only transition signal; session open/heartbeat/close and the idle sweep; fetch/tunnel traffic counters and req/min; workspaces stay isolated), `ProxySessionRegistryTests` (client-side per-view registry: open/close, heartbeat payloads, bounded), `ProxyBlockReporterTests` (report carries the workspace identity + canonical origin; blank or workspace-less reports drop; a throwing send seam never reaches the browser flow), `ProxyDiagnosticsWireTests` (every diagnostics payload round-trips as one JSON string; an unset session is an explicit null member; a frame shaped by another build is refused, never half-bound), `ProxyForwardConnectionTests` (each client-visible refusal reaches the reporter — local 403, tunnel-open fault, dead fetch channel — while an ES-answered refusal is never re-reported, so counts do not double), `ProxyFetchContractGuardTests` (four new payloads stay plugin-namespace `[Serializable]` public-field types inside the whitelist; the new ids declared in `PluginDefinedMessageIds`) | See ES-2 below - **load half observed live 2026-11-02**: the restarted Event Server logs `Proxy fetcher ready (..., diagnostics=WebContent.Proxy.BlockReport/WebContent.Proxy.SessionEvent/WebContent.Proxy.StatusRequest)` on a drop hash-identical to the Smart Client machine's. The ingress markers (session open, block recorded) need a proxied Smart Client session, which only an interactive desktop can drive | +| MC Status tab: live proxy sessions (polling tied to the open tab, read-only, role-gated by the #74 grant) | #76 | `ProxyStatusViewTests` (failure postures: a lost query is never rendered as an empty table, the Event Server's own code survives, a blank code falls back to `ServerUnavailable`; zero sessions is an honest success with no rows; rows arrive as pre-formatted display strings with `(unknown)` placeholders; rate differencing over the ES's cumulative counters needs two sightings, impossible deltas (non-later snapshot, backwards counters, unknown ids) answer zero, `Reset` drops the baseline; culture-invariant byte/rate/per-minute/duration formatting incl. NaN/Infinity/negative clamps), `WorkspaceTabShellGuardTests` (the Status tab is no longer a placeholder: exactly one `IsReadOnly` DataGrid with add/delete off, every button on the tab wired to `OnStatusRefresh`, zero configuration input controls - walking it can never dirty the pane; probe-selectable surface ids pinned) | See MC-12 below - **live row PASSED 2026-11-02** (run `20261102-074407`): opening the Status tab starts the pull loop (MIP.log markers at cadence), this machine's proxied Smart Client view renders as a session row within the cadence, leaving the tab flattens the markers, re-selecting resumes, and Save never enabled through the whole probe | +| MC structured allowlist grid on the Proxy tab (scheme/host/port/accept-untrusted rows, inline refusal, wildcard banner) | #81 | `ProxyAllowlistEditorTests` (row validation IS the shared grammar — mid-label wildcard, `*x`, `*.`, path, inner space, port out of range, port-with-blank-host all refused while exact hosts/IPv6/`*.tail`/the lone `*` pass; blank rows are not entries; canonicalization of case and scheme-default ports; only the lone `*` is the full-host hatch; both stored shapes render forever with corrupt records dropped and counted; a legacy `host`-shape workspace opens and saves back to structured `pattern` records without losing entries or flags; invalid rows refuse the build BY NAME and never vanish; duplicate rows collapse with any flagged duplicate flagging the entry; touching the flag never changes validation), `WorkspaceTabShellGuardTests` (the Proxy tab carries the grid with the six pinned columns in order and every per-row AutomationId, the warning banner starts `Collapsed`, rows are added/removed only by explicit buttons with `CanUserAddRows`/`CanUserDeleteRows` off, the proxy section is asserted ABSENT from the Settings tab, and neither `txtProxyTargets` nor any admin-facing `accept-untrusted` token survives in the page or its strings) | See MC-14 below | +| Proxy allowlist wildcards: ONE shared matcher for all three gates, structured entry shape, wildcard SSRF floor | #73 | `ProxyPolicyTests` (wildcard grammar: leftmost-label `*.tail` + the lone `*` hatch only - mid-label wildcards, `*x`, `*.`, bad labels, non-http schemes rejected; matching: subdomains covered, apex never, label-boundary safe, scheme/port exact, case-insensitive; SSRF floor: literal IPv4 (dotted-quad + numeric shorthand), IPv6 (bracketed/colon), `localhost`, and the VMS's own hosts are never wildcard-matched while explicit exact entries may still name them; seeded pseudo-random host/IPv4 property pins over 300+200 rounds), `ProxyAllowlistGateAgreementTests` (the same (entries, request-origin) matrix drives the Event Server fetch gate, the client forward gate, and the leaf-mint gate to identical verdicts; legacy `host` records and new `pattern` records gate identically), `ProxyServerAllowlistTests` (pattern and legacy shapes gate identically at the ES; VMS floor fed through a resolver seam and kept across resolver faults; the accept-untrusted flag rides wildcard entries under the same floor), `ProxyForwardPolicyTests` (client gate consumes the shared matcher in every posture), `ProxyWorkspaceProfileTests` (pattern entries and the floor hosts travel together through the seeded profile property), `ProxyConfigurationTests` (the stored entry gains `pattern`; readers accept BOTH shapes forever, no migration write-back - a read never rewrites the stored shape), `WorkspaceTabShellGuardTests` (boolean-attribute pins learned the hard way in the MC-13 live walk) | See MC-13 below - **live rows PASSED 2026-11-02** (run `20261102-073648`): wildcard/hatch lines save and read back verbatim through the live Management Client (structured `pattern` shape round-trip); `http://*.neverssl.com` admits `http://online.neverssl.com/` (a `HTTP 200 - 3961 bytes` banking in run `20261102-071520`) while the apex refuses `NotAllowed` in 0.0s; the `http://*` hatch admits the apex (200 in run `20261102-073010`) yet the literal IP and the VMS's own host both refuse `NotAllowed` in 0.0s - refused before any socket opens; allowlist restored to its four production lines with verified readback | + +| Rule triggers: a matched proxied fetch raises the fixed `WebContent proxy request` analytics event on the Event Server bus | #65 | `ProxyTriggerPolicyTests` (url glob grammar: absolute or the lone `*` hatch only; scheme http/https/`*`; host exact — empty labels refused — leftmost-label `*.tail`, or lone `*`; port explicit 1-65535 or absent = the url's own scheme default, so an explicit non-default port never matches an absent-port row; anchored path glob where `*` spans `/` and `?` is exactly one char, query/fragment never participate; method verb tokens; status class `1xx..5xx` or exact 100-599; seeded-Random property pins over the glob engine with a wildcard-safe alphabet on the pattern side), `ProxyTriggerMatcherTests` (store closed before the first resolve; rows evaluate against their own workspace only; first matching row wins — one fetch raises at most one event; absent sections tolerated silently; a refused row is audited by reason without taking down the workspace's other rows; a resolver failure after a good resolve keeps serving last-known-good rows; `Prime` logs what the configuration actually loaded), `ProxyConfigurationTests` (raw `proxyTriggers` rows survive the serialized roundtrip — grammar is decided only on read; a pre-#65 definition reads `ProxyTriggers=null` with no error; empty `method`/`status` members optional; a workspace without rows never grows an empty section), `ProxyFetchWireCodecTests` + `ProxyDiagnosticsWireTests` (#65-revised skew contract: the request wire's members are explicitly optional on read so an older Smart Client's frame decodes as an unattributed fetch, while a frame that names no url stays an unusable null; the tunnel wire keeps the strict inferred contract), `EventServerTriggerWiringGuardTests` (event header carries ID/Timestamp/Message=the one fixed event type with per-row context in CustomTag; the raise goes through `EnvironmentManager.SendMessage` with a TYPED platform `AnalyticsEvent` + `RelatedFQID` — never raw `TransmitMessage`, which the live transmit layer refuses for system Command ids; the plugin observes `Server.NewEventIndication` and detaches at Close; the store is primed in `Init` and logs; the self-test probe stays behind the env/flag switch; attribution chain: stamped request → diagnostics session map → fail closed) | See ES-3 below - **live gate PASSED 2026-11-02** (run `20261102-141512`, drop `WebContent.dll` SHA-256 `2B974839…`): the flag-file probe raised with a real workspace source and the SAME event id came back through the plugin's NewEventIndication observer (`d9ea7200…` on all three markers); the two refusal postures were learned live - raw `TransmitMessage` of a system Command id (`Discarding message Server.NewEventCommand`) and a sourceless event (`ArgumentException: Invalid event`) | +| MC exports the deployment proxy CA certificate over MIP messaging (provenance-independent, read-only, size-capped single frame) | #75 | `ProxyCaExportTests` (responder postures: authority present → Success with base64 DER + thumbprint + validity window; absent → `NoAuthority` with the actionable "does not exist yet" sentence, never an empty answer; expired/not-yet-valid and malformed entries refused by code, never silently skipped past; the fixed-CN finder opens `LocalMachine\Personal` with `ReadOnly | OpenExistingOnly` only — a source guard pins that no write/create flag can enter the export path — and several copies prefer the private-key one; wire codec round-trips the payloads as one JSON string with optional-member tolerance; correlation deadline silence → `ServerUnavailable`; the MC view builder NEVER yields a save for a success that carries no bytes — it renders the error copy instead; size caps: a REAL 2048-bit CA's DER frames under `MaxAuthorityDerBytes` (8 KiB) and its full response wire form stays under `MaxResponseWireChars` (16 KiB), the caps themselves pinned far under the proven ~87K-char single fetch-chunk headroom), `ProxyFetchContractGuardTests` extended (both new payloads stay plugin-namespace `[Serializable]` public-field types; `WebContent.Proxy.CaExportRequest`/`…Response` declared in `PluginDefinedMessageIds`; ids never end in "Command" so SecurityLevel.Normal clients can send) | See MC-15 below - **live row PASSED 2026-11-02** (run `20261102-154554`): the button answers for a CA this build did NOT create (legacy-script provenance, thumbprint `E4D254D5…`), and the `.cer` the Save As dialog writes is byte-identical to the certificate in the Event Server machine's live store | +| MC Diagnostics tab: recently-blocked origins from the ES ring, one-click allowlist promotion into the Proxy grid (tab-gated polling on the Status query pair, same Manage gate, fail-closed) | #84 | `ProxyDiagnosticsViewTests` (failure postures mirror the Status tab - a lost query is never an empty table, the ES code survives, unknown workspace and no-channel read their own sentences; zero blocked origins is the honest empty message naming the in-memory/reset semantics; rows arrive pre-formatted newest-first with an origin-ordinal tiebreak, hits floored at 1, future timestamps clamp to "just now", never negative; the allowlist annotation runs the SAME shared `ProxyPolicy.IsRequestAllowed` matcher as the three gates - a wildcard entry or the VMS floor hosts marks a row already covered and drops its button; `CertUntrusted` rows never get the button, every other certified posture does; an unparsable origin names itself and refuses promotion), `ProxyAllowlistEditorTests` promotion section (the promotion IS a grid row: exact scheme+host+port, accept-untrusted never set, default ports canonicalise, dedupe is by canonical entry not typing, junk outside the exact-entry grammar refuses without touching the grid, the promoted row saves through the ordinary build as a structured record, `ValidCanonicalEntries` covers unsaved edits and skips broken rows), `WorkspaceTabShellGuardTests` (Diagnostics starts `Collapsed` + the identical Granted-only gate theory; the live tab carries exactly one read-only grid, its buttons carry only the two pinned handlers, the promote button hides via its `CanPromote` converter rather than disabling, no configuration input control anywhere on the tab, and the in-memory/resets copy is pinned as prose) | See MC-16 below - **live row PASSED 2026-11-02** (run `20261102-182628`): a live Smart Client refusal renders `http://172.16.128.210:18131 | not allowed by administrator` within the cadence while only the Diagnostics tab arms the marker loop; one click lands the exact origin in the Proxy grid UNSAVED, annotates the row `on allowlist`, hides its own button, arms Save, and the ordinary save discipline (audit line + reloaded-pane readback) stores it; after a pane reload the row re-annotates from stored config; allowlist restored with verified readback | + +| Per-workspace proxy response cap (5 MB default, hard 20 MB ceiling, clamp-on-read, MC Proxy-tab field) | #85 | `ProxyResponseCapStoreTests` (never-resolved store / unknown workspace / absent member / malformed 0-negative all answer the 5 MB default, never a refusal; a stored cap answers verbatim; a past-ceiling stored value clamps to 20 MB and gets exactly one audit line naming the workspace per refresh; a resolver failure after a good resolve keeps last-known-good caps; flap resilience across alternating refreshes; the 60 s window means one resolve per burst; `ForResponseCapBytes` factories clamp identically), `ProxyConfigurationTests` cap section (pre-#85 definitions read `ProxyMaxResponseBytes=0`; a stored value round-trips VERBATIM even past the ceiling - the clamp is on every read, never a silent rewrite at save time; the default stores 0 and the blob never grows the member - `EmitDefaultValue=false`), `ConfigResilienceTests` cap section (blank/whole-MB-inside-1..20 accepted, everything else refused BY NAME with the rejected text - no silent zoom-style clamp in the UI; MB→bytes mapping stores 0 for the default; bytes→display clamps honestly both ways; the admin ceiling and the contract's `MaxAllowedResponseBytes` are pinned in lockstep), `ProxyFetchCorrelationTests` (the client receive guard bounds allocation against the 20 MB PROTOCOL ceiling, not the old 5 MB default - a hostile `ceiling+1` declaration still errors, while a 6 MB framed answer reassembles), `WorkspaceTabShellGuardTests` (the Proxy tab carries `lblProxyResponseCap`/`txtProxyResponseCapMB`/`lblProxyResponseCapHint`, the field's explicit AutomationId and `OnProxyResponseCapTextChanged` handler pinned) | See MC-17 below - **live rows PASSED 2026-11-02** (run `20261102-210935`): the default posture refuses a REAL 10 MB download with `TooLarge ... exceeded the 5242880 byte response cap`; saving 12 MB flips the SAME download to `HTTP 200 - 10485760 bytes fetched` inside the 60 s ES refresh window (which also proves the raised client receive guard); `99999` refuses the whole save by name with zero new cap-audit lines while the ES keeps serving the stored cap; restoring 5 re-arms the refusal | +| Per-workspace proxy header policy: add/override/strip rows on BOTH fetch legs, forbidden-header guardrail enforced at save AND at runtime | #86 | `ProxyHeaderPolicyTests` (the canonical forbidden set: exact hop-by-hop/Host/Content-Length/Transfer-Encoding/TE/Upgrade names + every `Proxy-*` prefix, case-insensitive; RFC 9110 token-name grammar, CR/LF in a value refused; the apply model: add/override replaces all same-name entries case-insensitively, rules apply in configuration order with LAST rule winning, forbidden rules never applied even when hand-fed past the store), `ProxyHeaderPolicyStoreTests` (60 s refresh / last-known-good / Prime-at-Init; forbidden and malformed stored rows refused with one audit line per refresh naming workspace + raw text; corrupt blobs = empty policy, never a refusal), `ProxyHeaderEditorTests` (the MC build refuses the WHOLE policy by name with nothing partial returned; strip-without-value is valid; strip does NOT unlock a forbidden name; Load drops unrenderable stored rows counted for the log), `ProxyConfigurationTests` header section (round-trips keep forbidden names VERBATIM on the wire - readers refuse, nobody censors; empty grids store no section, pre-#86 wire shape preserved), `EventServerHeaderPolicyWiringGuardTests` (request rules applied before the fetch, response rules before the frame and only for a real upstream status, store Prime at Init, all three block sets derived from the one canonical set), `WorkspaceTabShellGuardTests` (both grids on the Proxy tab: name-keyed lookups, per-AutomationId column pinning for both legs, the PropertyChanged binding guard across every Proxy-tab grid) | See MC-18 below - **live rows PASSED 2026-11-02** (run `20261102-224611`): a two-rule policy per leg stores through the audit line AND order-sensitive reloaded-pane readback; four forbidden-row cycles (request+response, add+strip) each pin the live Problem text, the notice naming the row verbatim, the refusal marker, and zero new audit lines; empty grids store the empty policy | +| Per-workspace proxy rule triggers: MC Proxy-tab "Rule triggers" grid (URL/method/status), whole-save grammar guardrail sharing the Event Server's `ProxyTriggerPolicy` matcher grammar | #78 | `ProxyTriggerEditorTests` (blank rows skipped not refused; url-required / mid-host-wildcard url / out-of-grammar status each refused by code; the WHOLE build refused by name with nothing partial returned; configuration order preserved and null members kept compact (any-method `*` and blank status store as null); stored rows with unrenderable method verbs dropped at Load and counted for the log; the fixed event type and the method dropdown set pinned to the matcher's constants; the grid's validation and the Event Server's matcher ACCEPT AND REFUSE THE SAME ROWS - `ProxyTriggerPolicy`'s normalizers are the single grammar both call), `WorkspaceTabShellGuardTests` (the trigger grid + Add button + hint carry their AutomationIds on the Proxy tab, not the Settings tab; every row's Event cell is a non-editable TextBlock; the XAML method array never drifts from `ProxyTriggerEditor.ProxyTriggerMethods`) | See MC-19 below - **live rows PASSED 2026-11-03** (run `20261103-031310`): a two-row trigger list (an exact-status row + an any-method wildcard-host row) stores through the audit line AND order-sensitive reloaded-grid readback with the fixed event type shown in the read-only Event column on every row, typed AND reloaded; three invalid-row cycles (mid-host-wildcard url / status `600` / a method with no url) each pin the live Problem text, the notice naming the row verbatim, the `invalid proxy trigger row(s)` MIP.log marker, and zero new audit lines; an emptied grid stores no trigger section (`0 row(s) []`) | +| Installer upgrade with Milestone clients running (MSI force-closes Smart Client / Management Client instead of failing 1603 on locked plugin dlls) | #70 | `build.ps1` structural guard (parses `wix-wid.xml` out of the versioned `.wixpdb`: the `Wix4CloseApplications_*` action is scheduled after InstallInitialize and before RemoveFiles/InstallFiles, RemoveExistingProducts is after InstallFiles, and the three kill targets - `Client.exe`, `VideoOS.Administration.exe`, `VideoOS.OAuth2TokenGenerator.exe` - are in the `Wix4CloseApplication` table) | See IN-1 below - **all live rows PASSED 2026-11-03** on the VMS machine with the clients launched in a real interactive session and `WebContent.dll` proven LOCKED first: uninstall, fresh install, and an upgrade - each with `Client.exe` + `VideoOS.Administration.exe` running - answered exit code 0, killed the clients, and left a hash-verified drop | +| Plugin upgrade on an Event Server machine dying on the running service's locked drop, or racing a half-stopped service: the MSI stops `MilestoneEventServerService` only for existing installs, waits for the REAL Stopped state (no fixed sleep), installs, and restarts it per the wizard's finish-page choice - first installs and no-ES machines never stop or prompt | #82 | `EventServerServiceCtlTests` (exit-code decision table), `InstallerEsServiceFlowGuardTests` (authored condition/sequence/Binary pins), `build.ps1` compiled-package guard (1026 actions at 1502/4001, condition needles, choice-page hop) | See IN-2 below - **all live rows PASSED 2026-11-03** on the VMS machine with a 250 ms service-state journal: upgrade-Yes (stop->copy->start), upgrade-No (left Stopped), first-install (never stopped, start only on Yes), uninstall (untouched), and the no-ES machine (both actions skipped) - each exit 0 with the drop hash-verified | +| MSI on the Event Server machine is THE supported deployment path; remote Copy-Item (psake `DeployToVms`, `deploy-event-server-plugin.ps1`) is a dev/test shortcut that never sets up proxy support and whose unregistered strays shadow an upgrade at equal file version | #68 | Docs + live rows (no new code surface); consumes the #70/#82 build-time guards | See IN-3 below - **live rows 2026-11-03** on the VMS machine: upgrade `0.1.282.22971` (+`0.1.284.59742` Copy-Item drift) → `0.1.287.14301` exit 1641 caught the shadow (12/13 hash-identical, the `1.0.0.0`-versioned trust-helper stray survived); the supported pure-`msiexec` uninstall/install migration then ended **13/13 hash-identical**, single registration, stop-action skipped, restart-action started the service, `Proxy fetcher ready` marker after the install, ES left Running | +| Install-time deployment-CA provisioning: the MSI itself creates/reuses the proxy CA in `LocalMachine\Personal` and grants the Event Server service account private-key read - ONLY on an ES machine with the proxy opt-in ticked; the CA shape stays single-sourced (the action drives the plugin's own load-or-create by reflection, never carries a minting recipe of its own); machines without ES, or with the box unticked, never have cert stores touched | #77 | `ProxyCaProvisionCoreTests` (bad-vector refusal before anything is touched; SCM ObjectName → account mapping incl LocalSystem/built-in normalization; gateway failure skips the grant; unconfirmed grant fails the install; the authored `provision MilestoneEventServerService` vector parses to the fixed drop), `InstallerProxyCaGuardTests` (authored Binary/CA/condition/sequence pins, checkbox on the same options page, CN never drifts from `ProxyLoopbackTls.AuthorityCommonName`, NO CertificateRequest/CreateSelfSigned/MD5 in the helper, reused DACL logs untouched / missing grants repaired, the exe is never harvested into the drop), `build.ps1` compiled-package guard (1026 exe CA on the streamed `WebContentProxyCa.exe` binary with its command line, scheduled after InstallFiles / before InstallFinalize / before the restart, condition needles NOT REMOVE + EVENTSERVERSERVICEPRESENT + PROXY_SUPPORT, the PROXY_SUPPORT CheckBox Control row) | See IN-4 below - **live rows PASSED 2026-11-03** on the VMS machine: default-opt-in upgrade RAN `CreateProxyAuthority` inside the script (exit 1641, CA thumbprint `E4D2…`/key container/DACL byte-identical, drop hash-verified); the streamed exe run directly logged the plugin's own `Loaded deployment proxy CA…` + `already holds read access… DACL untouched`, refused every bad vector with exit 1, and after DACL surgery re-WROTE the `NETWORK SERVICE` read grant restoring the identical ACL; a second upgrade with `PROXY_SUPPORT=0` logged `Skipping action: CreateProxyAuthority` with stores byte-identical; the post-install mint probe re-passed live - plugin's own `Loaded deployment proxy CA…` + `Minted loopback proxy leaf` with the script never run. No-ES-machine rows honest-SKIPPED (no unattended elevation on BEAST): the same presence gate is live-proven skipped there by IN-2 row 5 and the CA condition is compiled-guard-pinned | +| Epic #64 integration: the DEFAULT-option MSI run on the Event Server machine IS the provisioning story - clients force-closed, stop/wait/copy/CA-provision/restart inside one script, one hash-identical drop on both machines, and the client trust surface re-green on that drop | #64 | `.\build.ps1` re-green at the epic tip with all three compiled guards (#70/#82/#77) in one build; 1155 unit rows + Lint at the same commit | See IN-5 below - **all rows PASSED 2026-11-03**: the `0.1.291.10692` default-option upgrade (exit 0, 10.1 s real Stopped wait, CA byte-identical incl. key DACL, `Proxy fetcher ready` after), 10/10 drop files hash-identical ES-vs-local, and the one-run client-side re-pass MC-15 + MC-20 row 1 + SC-10 rows 1-3 (MC-15's two reds were a harness edit-pick bug, fixed and re-proven); the drifted unrecorded `0.1.290` registration was disclosed and cleaned by the upgrade | +| OS-configured (WinINET) proxy inheritance for non-proxied views, and the deliberate contrast for proxied views (loopback pin wins; no admin-facing proxy-URL setting exists because the OS owns that layer) (#67) | #67 | `ProxyBrowserArgumentsTests` (the pin itself: every scheme routed to the loopback listener, `<-loopback` bypass off - nothing falls back to the system path); the inheritance claim is a live-host fact, pinned by the SC-8 probe rows below (`ScOsProxyInheritanceProbeTests`: WinINET snapshot/apply/restore, a logging forward-proxy harness on loopback, live `Win32_Process` command-line reads of the plugin's browser processes) | See SC-8 below: routing OFF + the per-user WinINET proxy pointed at the harness - the non-proxied view renders the harness marker page for an RFC 2606 `.invalid` host (which can never resolve, so a served page can only have ridden the OS proxy) with the browser's absolute-form GET in the journal and NO proxy switch on any browser command line; routing ON + the SAME OS proxy + the SAME url - the plugin's 403 page renders, the harness journals nothing for the origin, and the live browser command lines carry `--proxy-server=http=http://127.0.0.1:;...` plus `--proxy-bypass-list=<-loopback>`. **BEAST 2026-11-03: both rows PASSED** (`20261103-035256`, `20261103-040006`) | +| Managed workspaces: security-relevant Smart Client view settings locked with "Managed by Administrator", cosmetic settings stay per-instance editable (locked-key overrides can never reach runtime) | #71 | `WorkspaceSettingLocksTests` (managed decision from the persisted WorkspaceId/DefinitionFingerprint; the locked set is exactly HomeAddress + legacy Address + ProxyEnabled + ProxyTargets, cosmetic/navigation keys never locked, identity keys never settings; seed decision table - locked/identity keys always enforced, cosmetic keys take the definition as initial value or re-initialise on definition change but a per-instance override survives inside an unchanged definition), `WorkspaceInstancePropertiesTests` #71 rows (hand-edited HomeAddress overwritten; definition proxy posture + allowlist enforced over stored values; cosmetic override kept, absent cosmetic seeded, changed definition re-initialises it; identity/fingerprint always enforced), `ManagedViewPropertiesPaneGuardTests` (home TextBox pins IsEnabled/ToolTip bindings + probe AutomationIds; both admin notices render only for managed items via one converter; NO cosmetic control declares an IsEnabled binding) | See SC-9 below - **live row PASSED 2026-11-03** (run `20261103-043150`, drop `3C777113…F63475`): Home renders disabled with the pinned URL visible + the admin note under Home AND Proxy, the checkbox stays enabled and its per-instance ON-state survives a full exit/re-enter-setup re-seed, locks still locked afterwards, `WebContent workspace seed:` marker in MIPLog; the standalone zero-change branch is pinned by the unit table + pane guard (no standalone view item in the lab) | +| Untrusted deployment CA: banner for every user on proxied views, setup-mode-only elevated fix through a thumbprint-locked bundled helper, mid-session heal without restart (#72) | #72 | `ProxyCaTrustStateTests` (posture holder: Changed fires only on real flips, case-insensitive thumbprint equality, proxy-off Clear, throwing subscriber swallowed) + `ProxyLoopbackTlsTrustPostureTests` (every mint publishes from the LOCAL root-store probe not the wire, cache short-circuits republication, `InvalidateLeafCache` re-mint flips the posture with no restart); `ProxyTrustHelperTests` (usage matrix, unreadable 3, garbage/oversize/empty refused 2 before parsing, thumbprint mismatch = store never touched, matched = exactly one import with the verified cert, store exit propagates); `ProxyCaTrustBannerWiringGuardTests` (banner elements + Collapsed defaults, setup-only fix gate, `Verb="runas"` + NO in-process LocalMachine write in the view code, 1223 quiet + independent re-verification, invalidate+reload on confirmed fix, navigation re-check heals external fixes, drop ships but never links the helper) | See SC-10 below - **live rows PASSED 2026-11-03** (runs `20261103-053747`/`20261103-053809`/`20261103-053825`, drop `01D9D8A8…3789` both machines): deployed helper refuses every negative path with the pinned exit codes and zero dialogs (incl. the unprivileged store refusal = 4); the helper's FULL success path verified end-to-end ON the Event Server (import -> present -> remove -> absent, self-cleaned); trusted lab state shows no CA chrome on the live proxied view. The untrusted-live row (SC-10 row 4) is an elevated human step: both root stores refuse unattended removal on the client machine | +| Proxied browser profiles keyed to the FULL proxied boundary — (Windows user, XProtect identity, VMS server) — so two Smart Clients on one machine logged into different VMS servers can never share a proxied profile or fetch channel (ADR-0004 child A; the claim/joining/handoff children are #93/#91, folder sweeping #94) | #92 | `BrowserProfileBoundaryTests` (server-key table: host+port fold, host case-insensitivity vs port sensitivity, sanitized graffiti, empty-host empty key; path table: same tuple identical path, SAME identity + DIFFERENT server different path, different browser id different path, exact `\\server-\proxied-` shape, lowercase-D leaf pin, all three DeriveUserKey fallback legs still name their user segment, missing-user keeps server separation at root level, missing-server uses the one `server-unknown` placeholder that never matches a resolved server, hostile `..\..\..\evil` legs stay three sanitized segments under the root, the `server-` prefix can never BE a traversal token) | See SC-11 below: the live proxied browser process's OWN `--user-data-dir` command line carries the boundary shape naming the configured VMS host, and no old-shape folder is written during the run | +| Startup sweep reaps orphaned proxied profile folders and stale listener-claim files under the plugin data root — off the UI thread, journalled, never fatal, live browser's folder never forced (ADR-0004 consequence; #94) | #94 | `ProfileSweepCoreTests` (pure decision table: fresh keep / stale reap / locked skip / undetermined-activity keep / retention boundary, claim rows delegating staleness to the #93 seam — dead owner / refusing port / corrupt / foreign schema / cross-id reap, live owner + own-pid claim keep, sibling-gone reap, temp-litter age table, shape-recognition table: proxied/server/claim/claim-temp names, non-shape names invisible to the sweep); `ProfileSweepDiskTests` (same rows over a real temp directory: old-shape + boundary-shape reaps, EBWebView tree never descended, root-level fallback walk, claim/temp outcomes, once-per-deletion log lines, own-pid live claim kept, hostile foreign files under the root untouched, lockfile probe held only while an exclusive open fails); `ProfileSweepStartupGuardTests` (Init schedules the sweep on a one-shot threadpool timer and runs nothing synchronously, the callback swallows and journals every failure and composes BOTH lock witnesses, the timer is torn down in finally plus stopped in `Close()`, both sweep files cross into the test Compile list while the WMI witness + `System.Management` stay plugin-only) | See SC-12 below — **live row PASSED 2026-11-03** (run `20261103-220433`): a 12-row decoy table planted under the REAL data root resolves exactly per the decision table in the live Smart Client's startup sweep, journal line `11 scanned, 2 reaped, 1 skipped locked, 3 claims + 1 temp reaped` in MIPLog.txt, and real user state untouched | +| Version-stamped trust helper: `WebContentTrustHelper.exe` carries the build's nbgv FileVersion like `WebContent.dll`, so an upgrade to a newer build replaces Copy-Item helper strays and the drop self-heals (IN-3's 12/13 gap closed) | #89 | `TrustHelperVersionStampingGuardTests` (the helper csproj carries the nbgv PackageReference with the plugin's IncludeAssets/PrivateAssets posture, NO second version.json beside the csproj, the plugin keeps its own reference so the lockstep check is not vacuous, helper/plugin nbgv versions equal) | See IN-6 below — **live row PASSED 2026-11-03 22:30**: ES seeded by Copy-Item with a stamped `0.1.298.22478` helper stray, `0.1.300.30776` default-option upgrade (exit 1641, stop/provision/restart chain inside the script, old-product REMOVE script skipped all three) ended **13/13 drop files hash-identical, mismatches=0** — the stray replaced, CA/DACL byte-identical, one registration, `Proxy fetcher ready` after; machines still carrying the pre-stamp `1.0.0.0` helper stay on the README's pure-`msiexec` migration (out of scope by design) | +| A second concurrent Smart Client on the same proxied boundary: its failed CoreWebView2 initialization shows an honest in-view profile-ownership notice instead of a blank WebView, journals ONE line per failure naming the ownership cause + browser id, and answers Refresh by re-attempting creation through the WebView re-creation path (never Reload on a dead control; ADR-0004 point 5 interim contract - actually letting the second session JOIN is the separate #91/#95/#96 enablement work) | #87 | `BrowserInitFailureTests` (pure classifier: non-proxied failure presents nothing - the existing log-only surface untouched; proxied failure presents the ownership cause/headline/explanation with the browser id + verbatim technical message in the detail; missing message still presents; the ONE log line names cause + id + message; the ownership category is never mapped from any per-request wire code); `ProxyErrorPageTests` (#87 wording rows for the ownership cause/headline/explanation); `BrowserCommandAdmissionTests` (a failed PROXIED view admits Refresh alone via `canRecreateProfile` with every other action refused; the non-proxied crash state keeps its old table); `ProxiedInitErrorWiringGuardTests` (a failed proxied init publishes the contract through `PublishInitFailure` exactly once, Refresh on an errored proxied view re-creates and never Reloads dead, the recreation follows the same restore-address/isolation rule as view Init, the recreated WebView announces itself and the open view re-attaches it, the notice elements exist start-collapsed with probe AutomationIds and step the blank WebView aside, the classifier lands in both Compile lists) | See SC-13 below - **live row PASSED 2026-11-03** (run `20261103-233457`): a second live Smart Client onto the same boundary rendered the in-view notice (cause/headline/text + detail naming browser id `2963db34...` and the verbatim `0x8007139F`), the owner view stayed notice-free, and Refresh re-attempted creation with the notice reappearing and NEITHER client dying; the one-line journal is enforced only when the run proves the joiner can log at all (disclosed single-writer MIPLog measurement in SC-13); SUPERSEDED live shape by #95 (2026-11-04) - a second Smart Client now JOINS the boundary owner's listener, the notice's SC+SC live shape is retired (SC-13 keeps the historical record, the presentation stays unit-pinned for the browser-held fallback, live witness moved to SC-14) | +| A second Smart Client on the same proxied boundary JOINS the boundary owner's claimed loopback listener instead of colliding on the browser profile: every proxied creation consults the claim BEFORE binding anything, the owner publishes its claim and binds exactly the claimed port, the joiner bakes that port into byte-identical `--proxy-server` arguments and binds NO listener of its own - one browser process, one loopback listener, one #69 session (joiner traffic counts in the owner's session); a live claim whose config fingerprint differs is joined anyway with exactly ONE audit (never refused, never re-claimed), and a claim file is deleted only when it parses Trusted AND names the releaser's own pid (live-foreign, future-schema, and corrupt slots are never evicted) (#95, ADR-0004 points 3-5) | #95 | `ProxyListenerLeaseTests` (no-claim path reserves an ephemeral port FIRST and names it in the claim, live-owner join on the owner's port holding nothing, fingerprint-mismatch join with exactly one audit and no reclaim, dead-owner-pid and refusing-port takeover each with one slot-naming audit, corrupt claim replaced+audited, own earlier claim on another port replaced quietly, unsettled protocol falls back unclaimed with the giveUp audit, port-reservation failure falls back without touching the slot, release deletes only the own parse-Trusted own-pid claim / never evicts another live owner / tolerates a missing slot / leaves a foreign-schema slot alone, two providers racing one slot resolve to exactly one owner + one joiner); `ListenerJoinWiringGuardTests` (source pins for the wiring BrowserManager cannot unit-execute: every proxied creation AND the proxy-enabled-since re-enable path consult the claim before anything binds, a joined browser binds no listener and carries the owner's port verbatim, the owner binds exactly the claimed port and every claim write has its paired release, the joined browser keeps the #88 view https-gate with targets tracking config edits, the claim path derives only from the SAME boundary legs as the profile folder, the lease class lands in both Compile lists); `ProxyBrowserArgumentsTests` (port-independent fingerprint rows: equal For(portA)/For(portB) args hash equal, the hash is one claim-file-safe token) | See SC-14 below - **live rows PASSED 2026-11-04** (runs `20261104-011831`/`012050`/`013207`, drop `2CC41544…E518` via `Build, DeployLocally`): two live Smart Clients render proxied pages on ONE browser process, one claim file naming owner pid + port and exactly ONE loopback `--proxy-server` port (== the claimed port), ZERO msedgewebview2 parented to the second client and zero `--parent-pid=` naming it, no ownership notice on either view, a join attempted mid the owner's re-navigation lands, and the MC Status tab shows exactly ONE live proxy session from a proven-zero baseline | + +| When the boundary's listener OWNER dies mid-session (clean close or hard kill) while joined clients sit open, the survivors heal WITHOUT user action in a bounded window: the claim watch (10 s/5 s heartbeat) re-reads each claimed browser's slot through the SAME claim protocol, the survivor takes a fresh GENERATION - the v2 claim's required `epoch` bumps on every takeover so all attached processes notice - and the boundary's proxied views are force-re-created (process-level browser arguments cannot hot-swap ports) onto the new port; a vanished own claim is re-published at the SAME port+generation (joiners never move), a transient read error never evicts a live owner, retries are bounded (5/generation, 15 s grace) with one GiveUp leaving the #87 notice, closed views are never resurrected, and the last view out the door leaves NO claim file (#96, ADR-0004 point 4) | #96 | `ClaimWatchCoreTests` (the whole pure watch table: dead-owner-pid handoff trigger, refusing-port staleness, vanished-slot owner Republish / joiner Rewire, foreign-schema Rewire through the protocol, own-pid-adoption + step-down-to-join posture rows, epoch/port/owner-pid drift under a joiner, recycle-window Retry with grace refusal + never-resurrect-closed-views + one-GiveUp termination + owner-dies-mid-handoff bounded restart); `ListenerClaimCoreTests`/`ListenerClaimStoreTests` (v2 epoch round-trip, pre-epoch v1 parses foreign, malformed epoch untrusted, takeover publishes displaced epoch + 1, on-disk epoch monotone, floor never regresses); `ProxyListenerLeaseTests` (lease surfaces epoch + owner pid, dead-owner takeover bumps the epoch through the lease, pinned-port republish holds port + generation); `ClaimHandoffWiringGuardTests` (watchdog armed at every proxied entry / stopped exactly once, the watch decides through the PRODUCTION liveness probes, UI-thread dispatch only, one re-creation call site = the #87 route, republish pins the served port, view-liveness read dispatcher-hops off the UI thread, core test-linked / timer plugin-only). See SC-15 below - **live rows PASSED 2026-11-04** (runs `20261104-033614`/`20261104-033857`): clean close (epoch 3→4, port 56765→60383, survivor pid owns the claim, page re-rendered, one WMI proxy port == new claim port) and hard kill (epoch 1→2, port 55582→52424, attempt 2 of 5 rendered); last-view teardown left zero claim files both times | ## Manual host checks -Run after deploying new build output per `AGENTS.md` (kill `client`/`VideoOS.Administration` -hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp). +Run after deploying new build output (`Invoke-psake -taskList Build, DeployLocally, DeployToVms`; +the deploy tasks kill the `client`/`VideoOS.Administration` hosts and restart the Event Server +service, and the local copy needs no elevation). Verify each deployed file by hash, not timestamp. ### MC-1 View open/close/reopen cycles (#10, #15) @@ -88,6 +130,2224 @@ hosts first; copy to the MIPPlugins root; verify by file hash, not timestamp). 3. Expected log marker in MIPLog.txt: `Configuration error for workspace ''`. 4. Expected browser content: the tab renders the fallback home page. Open the workspace in the Management Client: a **red banner** appears on the details page naming the configuration defect, and the **Save button becomes enabled on its own** (no need to nudge any setting — ticket #27 dirty-after-fill); one Save rewrites a clean definition, and re-logging in clears the Smart Client notification. MC log marker: `loaded with a configuration error (MalformedJson)`. +### MC-9 Proxy config roundtrip and refusal (#52) + +1. In Management Client, open the workspace's **Proxy** tab (#81), enable "Route web traffic through the VMS server proxy" and add one target row (scheme `https`, host `10.0.0.5`, port `8443`); Save. +2. In Smart Client, open the workspace tab's properties page. Expected: the Proxy section reports the enabled state and lists the allowlisted target (canonical form). +3. Toggle the proxy off, Save, and check the target rows are still there (off-toggle preserves allowlisted targets; MC-9 pin for the wipe bug found in review). +4. With the proxy enabled, type an invalid entry into a grid row (a mid-label wildcard `foo.*.bar.com`, or a valid host with port `99999`) and Save: the row is flagged inline, the save is refused with a notice naming the row(s), and the rows are not dropped; MC log marker: `Rejected save for workspace '' (FQID: ...): invalid proxy target row(s): …`. Enabled with no target rows at all is refused likewise (`proxy enabled but no target allowlisted`). MC-14 drives the grid half of this live. + +### ES-1 Event Server fetcher load and answer (#53) + +1. Deploy the new drop with `Invoke-psake -taskList DeployToVms` (it stops and restarts + `MilestoneEventServerService` around the copy; the service only loads MIP plugins at service + start; plugin.def gained the `Service` load environment). +2. Expected marker in the Event Server's `Logs\MIP.log` (ProgramData for the Event Server): + `Proxy fetcher ready (concurrency=3, request id=WebContent.Proxy.FetchRequest)`. A startup + failure logs `Proxy fetcher failed to start: ...` instead. +3. No `MessageCommunicationSettings.config` whitelist edit is needed — and since #54 no custom + payload type is ever bound by a host process: every wire payload is a plain JSON STRING + (`ProxyFetchWireCodec`). The platform rehydrates `ExternalMessage.Data` via + `new DataContractSerializer(Type.GetType(DataClass))`; Smart Client's plugin loading leaves + `WebContent.dll` invisible to that type resolution, `Type.GetType` returns null, the platform + logs `ExtenalMessage.Deserialize ():Unable to deserialize class:... , Exception:Value cannot + be null.` and hands the receiver `Data == null` — a silent black hole (MC and the Event Server + resolve plugin types fine, which is why only Smart Client failed). Never put plugin types on + the wire. +4. Response routing is unicast: the fetcher answers on `Message.ExternalMessageSourceEndPoint`, + which the messaging layer fills with the requester's endpoint FQID on every received message. + (The receive callback's `source` parameter is null for our requests — it only carries an + explicitly passed identity. Answering to a null destination instead filter-routes response + frames to EVERY client subscribed to `WebContent.Proxy.FetchResponse`: refusals time out + silently on the requester, fetched bodies leak to unrelated hosts, and processes without + WebContent.dll log `Unable to deserialize class:...ProxyFetchResponsePayload`. This was the + #54 live-host failure: both clients timed out despite a healthy fetcher.) + +### ES-2 Event Server proxy diagnostics ingress (#69) + +1. Deploy both halves from one drop (`Invoke-psake -taskList Build, Deploy`). Since #65 the fetch + REQUEST payload tolerates members missing from an older Smart Client's frame (it decodes as an + unattributed fetch instead of dying), but every other wire payload is still strict about its + member set, so a Smart Client plugin and an Event Server plugin from very different builds can + still fail to decode each other's frames — ship from one drop. Restart desktop clients + afterwards: an Event Server restart disposes every running client's `MessageCommunication`. +2. Load marker in the Event Server's `logs\MIP.log`: + `Proxy fetcher ready (concurrency=3, request id=WebContent.Proxy.FetchRequest, diagnostics=WebContent.Proxy.BlockReport/WebContent.Proxy.SessionEvent/WebContent.Proxy.StatusRequest)`. + The diagnostics clause only appears once the four new ids in `plugin.def` are accepted and all + three filters registered. **Observed 2026-11-02** on the deployed drop (`WebContent.dll` SHA-256 + `21365D5DBE27…` hash-identical on both machines). +3. Ingress markers, produced by any live proxied view: + - `Proxy diagnostics session open: workspace='' user='' machine=''` + when a proxied view's loopback listener starts — the user comes from the platform endpoint + identity table, never from the client's own claim; + - one `Proxy block recorded: workspace='' origin='' reason= + client=''` per NEW blocked origin; the dedup refreshes that follow stay quiet so a + refusal storm cannot bury the log. + The row that produces them on demand: + `Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~ScProxyErrorProbeTests.ProbeProxiedStartUrlOutsideAllowlistShows403Page' }` + — the probe makes an off-allowlist origin the workspace home, so the block is a real client-side + gate refusal, and it restores the workspace home address afterwards. +4. **Observed live since that note (2026-11-02/03):** the `Proxy block recorded` marker landed + in the Event Server's `logs\MIP.log` twice - the SC-6 fail-closed row (`20261102-114705`) + logged `Proxy block recorded origin='https://172.16.128.210:18127'`, and SC-5 record 7 + (`20261103-020728`) logged `... origin='https://neverssl.com' reason=NotAllowed + client='BEAST'`. The session-open half is proven end-to-end rather than by its log text: the + MC-12/#76 Status probe saw this machine's proxied view render as a live session row - a + never-opened session cannot appear in a snapshot. The status-query answer (a + `WebContent.Proxy.StatusRequest` answered with a snapshot) has its sender since #76 landed in + the Management Client drop (2026-11-02): the Status tab sends one query every ~3 s while it is + the selected tab and logs one MC marker per pull (`Proxy status snapshot for workspace '...' + answered: N live session(s), M blocked origin(s).` - the blocked clause joined the line with the #84 + Diagnostics tab; the MC-12 probe counts the unchanged prefix). The MC↔ES round trip went live with MC-12's probe (run + `20261102-074407`): the deployed ES answered the tab's queries and this machine's proxied view + rendered as a session row; its remaining semantics (unknown workspace answers `Success=false` + + `BadRequest`, client-side deadline silence completes as `ServerUnavailable`) stay pinned by the + unit suites. + + +### ES-3 Rule-trigger event raise and observation (#65) + +1. Deploy the Event Server half (`Invoke-psake -taskList Build, DeployToVms`) and verify the drop + by hash. Restart desktop clients afterwards (the service restart disposes their + `MessageCommunication`). +2. Load markers in the Event Server's `logs\MIP.log`: the ready line now ends with + `triggers=WebContent proxy request`, preceded by `Workspace rows refreshed: N row(s) across + M workspace(s).` — the eager prime of the trigger store. `0 row(s) across 0 workspace(s)` is + the expected state until the Management Client trigger editor (#78) writes rows; the matcher + stays closed and simply never fires. +3. Self-test probe (the raise-path round trip on demand): create a `trigger-selftest.flag` file + next to the deployed `WebContent.dll` and restart the service. Expected, in order: + - `Analytics event raised: "WebContent proxy request"` — the documented + `EnvironmentManager.SendMessage` + typed-`AnalyticsEvent` route (live-proven: raw + `MessageCommunication.TransmitMessage` of a system Command id is refused by the Event Server + MessageBroker, `Discarding message 'Server.NewEventCommand' as sending Indication and Command + messages are not allowed`, and surfaces to the plugin as `Unable to Transmit`); + - `Trigger self-test probe raised: "WebContent proxy request" source=`; + - `Proxy trigger event seen on the bus: "WebContent proxy request" (selftest)` — the + SAME guid as the raise line, arriving through the plugin's `Server.NewEventIndication` + receiver after the Event Server's own event processor accepted the event. A sourceless + analytics event does NOT survive that processor (`ArgumentException: Invalid event`), which is + why the probe carries a real workspace item as the event source, exactly like a match event. + Remove the flag and restart again: the markers must go silent. +4. **Observed 2026-11-02 (run `20261102-141512`):** all three markers with + `d9ea7200-5c25-4b65-ab74-5d23613a31f4` on a drop hash-identical to the build output + (`WebContent.dll` SHA-256 `2B97483955E8…`); clean flagless restart at `20261102-141621` logged + only the prime + ready lines. +5. **Not yet run:** the full end-to-end (a served fetch matching a stored row, and an + administrator's Analytics Event definition + rule reacting to it) waits on the #78 trigger + editor; the event definition/rule walk in Management Client stays a manual row then. + +### MC-10 Management Client proxy-fetch harness (#53, hardened by #54) + +1. Restart Management Client with the new drop. Open a Web Content workspace definition — the + proxy section now has a "Test proxy fetch" row (URL textbox + button). +2. Enter a URL whose origin is allowlisted on some proxy-enabled workspace (e.g. + `http://10.0.0.5:8080/`) and click "Fetch via Event Server". Expected: status line shows 200 + with body size and duration within a few seconds. +3. Failure cases: enter an origin no proxied workspace allowlists (e.g. `https://www.example.com/`) + → `NotAllowed` refusal, Event Server log `Proxy fetch request ... refused: The origin '...' is + not in the proxy allowlist of any proxied workspace.` (#54 server-side gate). Enter + `http://10.255.255.1/` on an allowlisted origin (unreachable) → the result shows `Unreachable` + after the ~5 s connect budget, and the Event Server log gains `Proxy fetch failed + (code=Unreachable, target=...)`. Enter `not a url` → `BadRequest` refusal logged as + `Proxy fetch request ... refused: the url was not an absolute http(s) uri`. Successes are + deliberately not logged (fetches become high-frequency once views use the proxy). +4. If the Event Server has no plugin loaded, the harness shows the not-connected message without + sending anything. + +### MC-11 Workspace config tab shell (#74) + +1. Restart Management Client with the new drop. Select a Web Content workspace. Expected: the + detail pane is a four-tab shell — **Settings | Proxy | Diagnostics | Status** — opening on + Settings; every settings field (name, home address, display/behavior/privacy/advanced toggles) + is on Settings and behaves exactly as before (MC-9 steps still pass without any tab click). + Since #81 the proxy routing toggle, the allowlist grid and the test-fetch harness live on the + **Proxy** tab instead — Settings must realize no proxy control at all. +2. The Proxy tab is live since #81 (structured allowlist grid - MC-14), the Diagnostics tab since + #84 (read-only blocked-origin grid - MC-16), and Status since #76 (MC-12). Click through all + tabs: + the shell's **Save must never become enabled** (selecting a tab holds no inputs and fires no + change events); returning to Settings shows the same values as before the walk. +3. Status-tab role gate: with a full administrator the Status tab is visible. Assign a test role + WITHOUT 'Manage Web Content Workspace' (but with View) and log in as that user: the workspace + opens (read rights) but the **Status tab is absent entirely**. Denied/failed permission + lookups hide the tab (fail closed); MC log marker when denied: + `does not grant the Manage security action to this user`. +4. Automatable (admin half): `McWorkspaceTabsProbeTests.ProbeWorkspaceTabsShellRendersAndStaysReadOnly` + (probe category, read-only — it never clicks Save and never mutates live state); artifacts + `mc-tabs-{settings,proxy,diagnostics,status,final}.png` + `mc-tabs-probe-results.txt` under + `%TEMP%\webcontent-mc-tabs-probe\`. The view-only denial half stays manual (needs a + second role/user). + + Record (MC-11 admin half) - **live row PASSED 2026-11-02**, run `20261102-012719`, drop + `WebContent.dll` SHA-256 `57F6C12B…1C8B6` hash-verified in the local MIP root: the four tabs + rendered (Status included — the admin's Manage grant round-trips through the live security + system), Settings kept every control on the default tab (txtName 'Demo Workspace', the four + live allowlist lines read back identically after the walk), each placeholder tab rendered its + marker, and **Save stayed disabled through the whole tab walk**. Screenshots confirm the shell + visually in the MC detail pane. Re-run after the #81 grid migration: green run + `20261102-105728` on drop `F1F5D7E8…` - the Proxy-tab half of the walk now reads the + structured grid. + +### MC-12 MC Status tab live proxy sessions (#76) + +1. Restart the Management Client with the new drop. `Invoke-psake -taskList Build, DeployLocally` + is enough — #76 is MC-side only and the Event Server's #69 drop already answers + `StatusRequest`. Select a Web Content workspace as a user holding its Manage action: the + Status tab shows the session grid (`dgStatusSessions`), a summary line, and a "Refresh now" + button — no placeholder. +2. Selecting the tab starts the ~3 s pull loop, and **every pull logs exactly one line** in the + Management Client's `Logs\MIP.log`: + - `Proxy status snapshot for workspace '' answered: N live session(s).` + - or, on a failed pull, `Proxy status query for workspace '' failed (code=...)` — + and the UI banner names the kind: unknown workspace ("does not know this workspace any + more") vs no channel ("no proxy messaging channel to it"). +3. Open any proxied Smart Client view: within a cadence the session appears as a row — XProtect + user from the platform sender identity, client machine from the Open event, duration/idle, + requests + req/min from the ES's correlation traffic, and send/receive bitrates differencing + the ES's cumulative byte counters across two consecutive polls (a first sighting shows + `0 B/s` — the honest no-baseline zero, never the session's whole life as a spike). + Zero sessions is rendered as its own quiet message, not a failure banner. +4. **Leave the tab: the marker count goes FLAT** — the timer is tied to tab selection, and a + closing role gate or a workspace-item switch tears it down with the rate baseline. Re-selecting + re-arms it. Nothing is persisted; there is no push channel. +5. Automatable: `McProxyStatusProbeTests.ProbeStatusTabPollsLiveSessionsAndStopsWhenLeft` + (probe category, read-only — drives Management Client AND Smart Client on one machine: + cadence markers appear while the tab is open, this machine's proxied view surfaces as a + session row within 25 s, the marker count stays flat across 10 s of tab-away, re-selecting + resumes the pulls, Save never enables); artifacts `mc-status-{quiet,live}.png` + + `mc-status-probe-results.txt` under `%TEMP%\webcontent-mc-status-probe\`. The role-gate + denial half stays manual (MC-11 step 3 — needs a second restricted user). + + Record (MC-12) - **live row PASSED 2026-11-02**: 626 unit tests green, + drop `3D90CE93…` hash-verified on both machines (Management Client + Event Server). The first + live attempt (run `20261102-040119`) died at the FIRST mouse call (`Win32Exception: Access is + denied` in `CollapseTreeToRoot` — the driving session had gone disconnected; desktop input is + refused there, the same wall ES-2 step 4 documents) — before any tree click or Save, so no + workspace state was touched. Meanwhile the MC-13 walk found the `dgStatusSessions` + tri-state-`Disabled` BAML bug (#76) that would have kept this page from loading at all on that + drop; fixed and pinned (`f142514`), rebuilt, redeployed, and the re-run (run `20261102-074407`) + is green end to end: cadence markers appear on tab-open, this machine's proxied Smart Client + view surfaces as a session row within 25 s ('1 live proxy session(s) - 2.0 requests/min'), + the marker count stays flat across 10 s of tab-away, re-selecting resumes the pulls, Save + never enables. The tabs walk re-ran green too (run `20261102-074319`), its Status step now + expecting the live grid. Artifacts under `%TEMP%\webcontent-mc-status-probe\20261102-074407`. + Re-run with: + + ```powershell + Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~McProxyStatusProbeTests' } + Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~McWorkspaceTabsProbeTests' } # its Status step now expects the live grid + ``` + +### MC-13 Proxy allowlist wildcards live at the Event Server gate (#73) + +1. Restart Management Client with the new drop (both machines need it: the ES gate and the MC + save path both changed). Allowlist a wildcard line on a proxy-enabled workspace, e.g. + `http://*.neverssl.com`, and Save. The page must re-render the line **verbatim** — that + readback is the structured `pattern` storage record round-tripping (older builds store it as + `host`; readers accept both forever). +2. Use the workspace page's "Test proxy fetch" row. The Event Server's allowlist snapshot + re-resolves at most once per 60 s window, so the first fetch after a save can still answer + with the PREVIOUS list — a stale-snapshot `NotAllowed` that flips to admission after the + window is normal; pin verdicts only from post-window fetches. +3. Wildcard posture with only `http://*.neverssl.com` allowlisted: `http://online.neverssl.com/` + is admitted (a 200, or a `Timeout`/`Unreachable` that NAMES the dialled target — the + dial itself proves admission, only the matcher can answer `NotAllowed`), while the apex + `http://neverssl.com/` refuses `NotAllowed` in 0.0s (the wildcard covers subdomains, never + the apex itself). +4. Hatch + SSRF floor posture with `http://*` allowlisted: normal hosts are admitted (the apex + from step 3 flips from `NotAllowed` to fetchable — one entry, two verdicts, one refresh + window apart), while `http://10.20.30.40/` (literal IP) and `http://vms.joshooaj.com/` + (the VMS's own host, from `EnvironmentManager`) both refuse `NotAllowed` in 0.0s. The 0.0s + matters: a missing floor would DIAL and answer `Unreachable` after seconds. Restore the + workspace's production lines afterwards. +5. Automatable: `McWildcardAllowlistProbeTests.ProbeWildcardAndHatchAtTheLiveEventServerGate` + (probe category — drives the whole protocol above through the live MC, self-healing: it strips + any earlier dead run's probe lines on save and always restores the production lines with + verified readback); artifacts `mc-wildcard-phase-b.png` + `mc-wildcard-probe-results.txt` + under `%TEMP%\webcontent-mc-wildcard-probe\`. + + Record (MC-13) - **live rows PASSED 2026-11-02** (green run `20261102-073648`, both machines + on drop `3D90CE93…` hash-verified): phase A readback carried `http://*.neverssl.com` verbatim; + the subdomain was admitted (with the `HTTP 200 - 3961 bytes` success shape banked in run + `20261102-071520` before the public target began throttling the Event Server's 5 s header + window), the apex refused `NotAllowed` 0.0s; phase B readback carried `http://*` verbatim, + the apex then fetched `HTTP 200` (run `20261102-073010`), and `http://10.20.30.40/` plus + `http://vms.joshooaj.com/` both refused `NotAllowed` 0.0s — the syntax floor and the + EnvironmentManager floor each refusing before any socket opened. The allowlist ended restored + to its four production lines (the run also cleaned a `http://*` left behind by a run the + desktop-disconnect killed mid-restore). Re-walked after the #81 grid migration on the same + day: green run `20261102-105816` on drop `F1F5D7E8…` - every allowlist line is now typed + through the structured grid (there is no free-text box left to type into) and the ES gate + verdicts are unchanged. Epic integration re-pass 2026-11-03 (run `20261103-113046`, one of + four cluster probes green in ONE 24 m 33 s `TestUI` run on the merged drop `BA938406…`): + wildcard/hatch lines round-trip verbatim; the public target now throttles the ES's 5 s header + window, so admission is proven by the DIAL - `http://online.neverssl.com/` and the + hatch-opened apex both answer `Timeout ... No response headers ...` at 5.1 s while the apex + under the wildcard refuses in 0.0s, and `http://10.20.30.40/` plus `http://vms.joshooaj.com/` + still refuse `NotAllowed` 0.0s under `http://*` (the 0.0s-vs-dial contrast is the point). + Eight production lines restored with audit + reloaded-pane readback. + + LIVE FINDING from this walk: the deployed #76 `dgStatusSessions` carried + `CanUserResizeRows="Disabled"` — a WinForms tri-state string on a WPF-BOOLEAN property — so + `GenerateDetailUserControl` threw (`FormatException: Disabled is not a valid value for + Boolean`) and the ENTIRE workspace config page failed to load: no Settings controls at all, + which is why the first two MC-13 attempts died at `txtProxyTargets`/tree-walk steps, not at + anything #73 changed. Fixed in the `fix(mc): dgStatusSessions carried tri-state…` commit; the + shell guard now pins every WPF-boolean `CanUser*` attribute on every DataGrid of the page to + a real bool, and the #76/#74 MC rows should be re-walked on the fixed drop. + +### MC-14 Structured allowlist grid on the Proxy tab (#81) + +1. Restart Management Client on the new drop and open a Web Content workspace item. Select the + **Proxy** tab: the whole proxy section lives there now — the routing toggle, the allowlist + **grid** (Scheme | Host | Port | Accept untrusted | Problem | Remove), the full-host-wildcard + warning banner (collapsed until a `*` row is in the grid), the hint line, and the #53 fetch + harness. The Settings tab carries no proxy control at all, and the old free-text **Proxy + targets** box is gone: the stored allowlist renders one entry per row, from either stored shape + (legacy `host` records and #73 `pattern` records both render). +2. **Add target**, type a rejected entry (`https://foo.*.bar.com`, or a valid host with port + `99999`): the row highlights and its **Problem** cell says why. Press Save: the save is + REFUSED — a notice names the offending rows, the Management Client MIP.log gains + `Rejected save for workspace '' (FQID: …): invalid proxy target row(s): `, the rows + stay in the grid flagged (never silently dropped), and re-selecting the item shows the stored + allowlist exactly as it was — nothing partial reached storage. +3. Fix the rows and Save; re-select the item so the pane is rebuilt FROM THE STORED ITEM. The entry + reads back with its scheme, port and **Accept untrusted** ticked — the flag lives in the stored + record, never in a typed token. Add `http://*`: the amber warning banner comes up while that row + is in the grid and goes back down when the row's **Remove** button drops it. Restore the + workspace's own rows and verify. +4. Automatable: `McAllowlistGridProbeTests.ProbeStructuredAllowlistGridEditsRefusesAndStores` + (probe category — drives all of the above through the live MC: the absent free-text box, the + inline Problem cells, the refusal notice text, the MIP.log marker delta keyed on the probe's own + bad host, the flag round-trip read only from a RELOADED pane, the banner on/off pair, and the + per-row Remove affordance; restores the workspace's own allowlist with verified readback); + artifacts `mc-grid-refused-rows.png`, `mc-grid-restored.png` + + `mc-allowlist-grid-probe-results.txt` under `%TEMP%\webcontent-mc-allowlist-grid-probe\`. + + Record (MC-14) - **live row PASSED 2026-11-02**, green run `20261102-105226` on drop + `F1F5D7E8…` (hash-verified against `output\WebContent`): no `txtProxyTargets` control exists in + the live pane and the hint teaches no typed token; both refused rows (`foo.*.bar.com`, port + `99999`) carried inline Problem cells (`'foo.*.bar.com' is not a valid host or wildcard + pattern.'`, `Port must be a number between 0 and 65535; …`), and the save was refused with the + notice `Invalid address: https://foo.*.bar.com; http://ok.gridprobe.invalid:99999 is not a + valid proxy target. Fix the highlighted row or remove it before saving.` - each row quoted in + exactly the shape the ROW MODEL holds, which is also the pre-save proof that the scheme + dropdown reached the row. The MIP.log refusal marker moved 17 -> 18 keyed on the probe's own + bad host; the refused rows stayed in the grid flagged; the follow-up save's audit line + (`Stored proxy targets for workspace 'Demo Workspace': routing on; …`) carried only the fixed + set, and the reloaded pane agreed. The `accept-untrusted` flag round-tripped: stored as + `https://gridprobe.invalid:8443 accept-untrusted`, read back ticked from the RELOADED pane and + stated by the audit line. `http://*` raised the warning banner (read via the banner's TextBlock + - the Border itself has no UIA peer), and the row's Remove button dropped both row and banner. + The workspace's own four lines were restored with audit-line + reloaded-pane double + verification. The live walk caught three defects the unit layer could not see: the scheme + ComboBox and the accept-untrusted CheckBox lost their edits to the DataGrid's deferred source + update until the bindings said `UpdateSourceTrigger=PropertyChanged` (the admin saw the picked + value while the row stored the old one - now pinned by a guard test over every `Mode=TwoWay` + binding in the grid); the storage de-dupes so probe leftovers double up (the harness now + treats its own host/hatch rows as not-baseline); and the wildcard banner probe must read the + TextBlock, not the peerless Border. Epic integration re-pass 2026-11-03 (run + `20261103-113716`, drop `BA938406…`): the whole-pane walk green on the merged drop - both + refused rows kept inline Problem cells and the notice quoted each row exactly as the row model + holds it, the MIP.log marker moved 18 -> 19 keyed on the probe's bad host, refused rows never + reached storage, the flag round-tripped (`https://gridprobe.invalid:8443 accept-untrusted` read + back ticked from a reloaded pane), the banner was asserted up while the `http://*` row was in + the grid and down after its Remove, and the production lines were restored with double + verification. (The workspace's production allowlist has since grown to eight lines; the + harness treats its own rows as not-baseline either way.) + +### MC-15 Export the deployment CA certificate over MIP messaging (#75) + +1. Deploy the drop to BOTH machines (`Invoke-psake -taskList Build, Deploy` — the Event Server half + matters: the responder lives in the background plugin there). Confirm the Event Server loaded it: + `logs\MIP.log` gains `Proxy fetcher ready (…, export=WebContent.Proxy.CaExportRequest, …)`. +2. In Management Client select the plugin's **Web Content Workspaces** node: the overview page carries + the **Export proxy certificate** button. Press it: the pane shows the "asking the Event Server" + line, then a **Save the proxy authority certificate** Save As dialog with default name + `WebContent-Proxy-Authority.cer` and a `*.cer` (DER) filter. +3. Expected (success half): MC `Logs\MIP.log` gains `Proxy authority certificate answered: thumbprint + …, valid to …, N DER bytes.` then `Proxy authority certificate exported to '' (thumbprint + …)`; the Event Server log gains the paired `Exported the deployment proxy authority certificate + (thumbprint …, valid to …)`; the saved file parses as X.509 (first byte `30`, NOT PEM text) and its + thumbprint equals the store entry's. The pane then states the saved path + thumbprint and the + LocalMachine\Root / AD-GPO follow-up (README "HTTPS targets"). +4. Expected (no-CA half): on a machine whose `LocalMachine\Personal` has no `XProtect WebContent + Plugin Loopback Proxy` certificate, the button opens NO dialog and the pane banner says the proxy + authority certificate does not exist yet (with the provisioning pointer); no file is written — + the ticket's empty-file prohibition. Proven live server-side by driving the deployed dll's + responder against an empty finder: it answers `NoAuthority` with exactly that sentence. +5. Provenance independence: the live proof used a CA created by the OLD provisioning script, not by + this build — the responder resolves by fixed CN only. Read-only: the export path opens the store + `ReadOnly | OpenExistingOnly`; the live responder probe left the store count unchanged (6→6). + + Automatable: `McCaExportProbeTests.ProbeProxyCaExportSavesTheEventServerAuthorityAsDer` (probe + category — reads the ES store FIRST over WinRM through `EventServerAuthority.ReadDeploymentAuthority` + (read-only) so the assertion is against the live store, not a stale view; drives the button, the + Save As dialog, the file-name box and the commit; then asserts the saved bytes are BYTE-IDENTICAL + to the store copy, the thumbprint matches, the DER parses, and both MC MIP.log markers carry this + run's own artifacts path). Artifacts `mc-ca-export-overview.png`, `mc-ca-export-save-dialog.png`, + `mc-ca-export-result.png`, `exported-authority.cer` + `mc-ca-export-probe-results.txt` under + `%TEMP%\webcontent-mc-ca-export-probe\`. + + Record (MC-15) - **live row PASSED 2026-11-02**, green run `20261102-154554` on drop `FACA878B…` + (hash-verified on the local install folder AND the Event Server copy): ES ready marker at + 14:51:49 carried `export=WebContent.Proxy.CaExportRequest`; the store held exactly one authority + (thumbprint `E4D254D58CB1BDA6E894C0F484F3C5AA718D2377`, 816 DER bytes, to 2036-10-30), and the + legacy script's own `C:\Users\Public\WebContent-Proxy-Authority.cer` (written 2026-10-31, before + this build existed) carries the same thumbprint - the provenance AC. Pressing the button paired + ES 15:46:15.598 `Exported the deployment proxy authority certificate (thumbprint E4D254D5…, valid + to 2036-10-30 15:33:01Z)` with MC 15:46:16.213 `Proxy authority certificate answered: … 816 DER + bytes` and 15:46:18.967 `exported to '…\20261102-154554\exported-authority.cer'`; the written file + was byte-identical to the live store copy (asserted, not eyeballed). The no-CA posture, the + read-only store open (count 6→6), and the DER round-trip were additionally proven by driving the + DEPLOYED dll's responder in-process on the ES machine (`verify-ca-export.ps1`). The live walk + caught two harness truths worth remembering: the modal Win32 common dialog is not reliably under + the client's own window list (desktop-wide window scan needed), and typing the path via + `ValuePattern.SetValue` gets honoured only for the NAME - the dialog saves into its OWN folder, so + the probe types the path with keystrokes and commits with ENTER. + + Record (MC-15 re-pass, IN-5) - **live row PASSED 2026-11-03**, run `20261103-180100`, both + machines on drop `WebContent.dll 6B281447…07FD` (ES via the `0.1.291.10692` MSI, local box via + the Copy-Item shortcut): store read FIRST (one CA `E4D2…2377`, 816 DER bytes), answered/exported + marker pair, saved file byte-identical to the live store copy. This run exposed and fixed a + HARNESS selection bug: the common dialog's shell file-list carries a `System.ItemNameDisplay` + edit that also echoes the default name, and the old "first edit whose value matches" rule could + pick it - its UIA provider dies mid-read (`0x80040201`) and it is NOT the edit the dialog's + parser reads. The probe now prefers the Win32 dialog's own aid-`1001` File name edit + (red runs `20261103-173915`/`20261103-175427` both failed only in the harness - the ES answer + and the correctly-opened dialog are in both logs). + +### MC-16 MC Diagnostics tab: live blocked list + one-click promotion (#84) + +1. Restart the Management Client with the new drop. `Invoke-psake -taskList Build, DeployLocally` + is enough - #84 is MC-side only; the Event Server's deployed drop already accepts block reports + and answers `StatusRequest` snapshots with the `Blocked` ring (#69). Open a Web Content workspace + as a user holding its Manage action: the **Diagnostics tab is visible** (the identical gate as + Status - MC-11 step 3 applies to it verbatim), rendering `dgDiagnosticsBlocked`, a summary line, + and a "Refresh now" button. The summary and the empty-state copy both say out loud that the list + lives in the Event Server's memory only and **resets when the Event Server service restarts**. +2. Selecting Diagnostics (like selecting Status) arms the ONE status query pair; every pull logs + exactly one MC marker - `Proxy status snapshot for workspace '' answered: N live + session(s), M blocked origin(s).` or the query-failure variant - and leaving BOTH tabs stops the + markers. The tab never writes: walking it, "Refresh now" included, cannot enable Save (guard- + pinned: the grid is read-only, the only handlers are `OnDiagnosticsRefresh`/`OnDiagnosticsPromote`). +3. A live Smart Client view whose subresources point at a non-allowlisted origin (the SC-7 page + pair: origin 18125 allowlisted, asset origin 18131 not) puts a row on the grid within the + cadence: canonical origin, shared reason label (`not allowed by administrator`), hit count, + recency, and an "Add to allowlist" button. Rows order newest-first, ties stable by origin. +4. One click on the button: MC marker `Proxy allowlist promotion for workspace '': origin + '' added to the allowlist grid (grid edit, not yet saved - Save writes it).`; the row + re-annotates `on allowlist` and its button is GONE (hidden, not disabled - a double click cannot + stack duplicates; the probe's rule for "gone" is no VISIBLE promote peer, since a collapsed WPF + button leaves an off-screen UIA peer with a zero rect); the Proxy tab's grid now carries the + exact scheme+host+port row **unsaved**, and Save is armed. Rows the promotion cannot help - + `certificate not trusted`, already covered (exact or wildcard, through the same shared matcher + incl. the SSRF/VMS floor), unparsable - never get the button in the first place. +5. Saving goes through the ordinary allowlist save discipline (MC-9/MC-14): audit line + `Stored proxy targets for workspace '' (FQID: ...)` names the promoted entry, and the grid + re-renders it from a RELOADED pane. Re-opening Diagnostics after the reload re-annotates the row + `on allowlist` from STORED config - the "survives a workspace re-read" acceptance. +6. Automatable: `McDiagnosticsTabProbeTests.ProbeDiagnosticsTabPromotesLiveBlockedOriginIntoTheSavedAllowlist` + (probe category; it MUTATES the workspace allowlist briefly and restores it verbatim with the + audit+reloaded-pane verification, run alone; drives Smart Client + Management Client on one + machine); artifacts `mc-diagnostics-{row,after-promote,annotated}.png` + `mc-diagnostics-probe-results.txt` + (which ends with a cell-by-cell `grid dump` of the Diagnostics grid: every promote peer UIA can + see, with its row, rect and on/off-screen state) under `%TEMP%\webcontent-mc-diagnostics-probe\`. + The probe's own order is part of the record: it clears any earlier run's probe line from the + allowlist BEFORE the Smart Client page load, and its teardown answers the shell's "Save changes?" + prompt with NO (`ManagementClientUi.RejectPendingModal`), never Yes. + + Record (MC-16) - **live row PASSED 2026-11-02**, green run `20261102-182628` (3 m 20 s), drop + `WebContent.dll` SHA-256 `A718A87B.` hash-verified in the local install folder: the ES ring + carried 5 blocked origins (other views' refusals included); the fresh refusal rendered + `http://172.16.128.210:18131 | not allowed by administrator | 3 | 35s ago` with its button while + the Diagnostics tab alone drove the snapshot markers; the Invoke logged the promotion marker at + 18:27:45.653, annotated the row `on allowlist`, and its button vanished (screenshot: among the 5 + rows only 18131 shows `on allowlist` and carries no button - the other four keep theirs); the + Proxy grid carried `http://172.16.128.210:18131` before any save; the save audit enumerated all + seven entries including the promoted one, the reloaded-pane readback matched, and re-opening + Diagnostics re-annotated from stored config (`1m 16s ago`); the six-line allowlist restored with + audit+readback verified. The `CertUntrusted` no-button posture stays pinned in the unit layer + (a live untrusted-https blocked origin would need the SC-6 certificate posture end to end). + + Failure signatures: opening Diagnostics with no markers = the tab never armed the shared timer + (MC-12's cadence discipline is the twin); a plain `not allowed` row WITHOUT a button = the + annotation matcher thinks an entry already covers the origin - check the grid and the floor hosts + (unit matrix pins the matcher, the live row names the entry); Save still disabled after the + click = the promotion bypassed the grid's row-collection chain; the promoted origin absent from + the audit line = the promotion wrote somewhere other than the editor's grid. + + Harness truths the live rows taught (each one burned a run, MC-16): + * **UIA containment lies.** Flattening a DataGrid row with `FindAllDescendants` surfaces + SIBLING rows' buttons, so row-scoped Name joins are not evidence. The Grid pattern is the + addressing authority: `dgDiagnosticsBlocked` answers `GridPattern` (6 columns for its 6 + columns) and `GetItem(row, column)` isolates one cell; the geometry check (the button's + centre inside the row's own rect) is only the fallback when the pattern is missing. + * **A hidden WPF button still has a UIA peer.** `Visibility=Collapsed` leaves the peer with a + zero rect and `IsOffscreen=true` - live, the promoted row's `diagnosticsRowPromote` peer is + still enumerable right after its annotation flips to `on allowlist`. "Peer exists" is not + "button offered": the probe's rule is a VISIBLE button (non-zero rect, not off-screen), and + the results file prints a cell-by-cell `grid dump` naming every promote peer with its rect + and on/off-screen state - in the green run the four uncovered origins are + `on-screen rect={X=2305,...}` and the two covered ones `offscreen + rect={X=0,Y=0,Width=0,Height=0}`. + * **Empty WPF `DataGridCell`s answer a SYNTHESIZED Name** (`Item: + WebContent.Admin.ProxyBlockedRowView, Column Display Index: 4`), which leaks into any + Name-join of a row - the blank Status cell showed exactly that. Cell text comes from the + `Text` peers first, with a synthesized fallback name read as empty. + * **Probe order is load-bearing, and teardown must not save.** Clear any earlier run's probe + line from the allowlist BEFORE the Smart Client page load: with the line still stored the + listener FORWARDS the subresources and the ring row arrives as `target timed out`, not + `not allowed by administrator`. And a teardown that answers "Save changes?" with YES + persists a mid-run promotion - the ring keeps same-origin entries until the Event Server + restarts, so the stale row then satisfies a reason-blind row lookup. Teardown uses + `ManagementClientUi.RejectPendingModal` (No/Cancel), and the row lookup filters on the + reason fragment. + * Known residue for a future sweep: `McAllowlistGridProbeTests`, `McProxyStatusProbeTests` and + `McWildcardAllowlistProbeTests` still close with `AcceptPendingModal`, i.e. they would + persist an interrupted edit the way MC-16's first run did. + + Record (MC-16, hardened probe) - **live row PASSED again 2026-11-02**, green run + `20261102-193021` (3 m 07 s, the run of exactly this probe source; the identical chain went + green one run earlier as `20261102-192322`), drop `WebContent.dll` SHA-256 `6271ED81.` + (1 381 376 bytes) hash-verified in the local install folder - the plugin source is unchanged + since the first green run, only the probe was hardened. The ring held 6 rows (three unrelated + live browser refusals included); the probe's fresh refusal was row 0 - `http://172.16.128.210:18131 + | not allowed by administrator | 15 | just now` with an on-screen button - while the stale + same-origin `target timed out` entry from the mis-ordered run sat at row 4 already annotated + `on allowlist` (the shared matcher annotates every covering entry; the reason fragment is what + keeps the probe on its OWN row). One Invoke logged the promotion marker, the row re-annotated + `on allowlist`, and the cell dump (`rows=6 cols=6`) shows both covered rows' promote peers + `offscreen rect={X=0,Y=0,Width=0,Height=0}` while the four uncovered origins kept + `on-screen rect={X=2305,...}` buttons; the unsaved Proxy grid carried the exact origin, the save + audit enumerated all seven entries, the reloaded pane read it back, re-opening Diagnostics + re-annotated `on allowlist` from stored config (`41s ago`), and the six-line allowlist was + restored with audit+readback verified. + +### MC-17 Per-workspace response cap: default, raise, refusal, restore (#85) + +Needs the drop deployed on BOTH machines (the cap store lives on the Event Server; the field and +the fetch harness live in the MC). The probe (`McProxyResponseCapProbeTests`, run via +`Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~McProxyResponseCap' }`) +pins five things, each by a live round trip: + +1. Provisioning: the probe origin (`https://proof.ovh.net`) goes into the Proxy grid AND the cap + field is normalized to the default in ONE save — the idempotent baseline, since an interrupted + earlier run may have left a raised cap stored. +2. Default posture: fetching `https://proof.ovh.net/files/10Mb.dat` through the Proxy-tab harness + answers `Proxy fetch TooLarge ... exceeded the 5242880 byte response cap.` — the refusal names + the cap that produced it. +3. Raised cap: typing `12` + Save writes the `Stored proxy response cap ... : 12 MB.` audit line + and the reloaded pane reads back `12`; polling the SAME fetch flips to + `HTTP 200 - 10485760 bytes fetched` within the store's 60 s refresh window. The success also + proves the CLIENT receive guard moved to the 20 MB protocol ceiling — a client still guarding + at the old default would reject the 10 MB answer on the way back in. +4. Refusal: typing `99999` + Save raises the `Invalid address` notice naming the rejected text, + the MC MIP.log gains `invalid proxy response cap '99999'`, NO new cap-audit line appears, and + the ES keeps serving the stored cap (the fetch still succeeds) — nothing partial reached storage. +5. Restore: typing `5` + Save re-arms the TooLarge refusal at 5242880 within a refresh window — + the cap is live in BOTH directions; then the workspace's own allowlist and cap are restored, + verified audit + reloaded pane, teardown answers `Save changes?` with NO (`RejectPendingModal`). + +Harness truths the live rows taught (each one burned a run, MC-17): + +* `proof.ovh.net` answers `HTTP 301` on plain http (the harness does not follow cross-scheme + redirects) — use the https origin and allowlist it as `https://...`; and the classic `6Mb.dat` + is gone (404) while `10Mb.dat` (10 485 760 bytes) is the live fixed-size file. +* A refused cap edit proves storage untouched SERVER-SIDE, not by pane readback: on reselecting + the same tree item the MC runs the platform fill through a state where its dirty chain throws + `Not valid to set dirty state at this time!!!` — a TextBox interrupted mid-fill keeps whatever + the user typed, so the "reloaded pane still reads the old value" claim is only honest when the + stored value and the typed value differ ONLY via audit lines and ES behavior. Phase 5 asserts + the cap-audit line count does NOT move and the fetch keeps succeeding instead. +* The baseline must self-heal: a stored cap equal to the probe's own raise value is leftover from + an interrupted run and normalizes to the default, exactly like probe-host allowlist rows. +* The ES cap store refreshes at most every 60 s, and the window may have just been armed by the + pre-change fetch — the raise phase polls the fetch itself (up to 120 s) and the poll trail is + the evidence of the refresh cadence, not a sleep. + +Record (MC-17) - **live rows PASSED 2026-11-02**, green run `20261102-210935` (6 m 14 s), drop +`WebContent.dll` SHA-256 `3627C762.` (1 388 032 bytes) hash-verified in the local install folder +and deployed to the Event Server via `DeployToVms`. Chain from the results file: baseline cap `5` +after leftover normalization; `default-cap fetch -> Proxy fetch TooLarge after 2.1s - Response body +from https://proof.ovh.net exceeded the 5242880 byte response cap.`; `stored cap '12' MB verified +from the audit line AND a reloaded pane`; three `TooLarge ... (re-polling)` rows inside the refresh +window then `raised-cap fetch -> HTTP 200 - 10485760 bytes fetched in 9.4s`; `refusal notice: +Invalid address OK '99999' is not a valid proxy response size ...`; `cap audit lines after the +refused save: 12 -> 12` with `post-refusal fetch -> HTTP 200 - 10485760 bytes fetched in 9.2s`; +`restored-cap fetch -> ... TooLarge ... 5242880 ... (cap live in both directions)`; `allowlist and +cap ('5') restored (verified from reloaded panes)`. Four earlier red runs contributed the harness +truths above (301 on http, 404 on 6Mb.dat, the fill-interrupt readback lie, and the missing +restore-phase grid edit). Epic integration re-pass 2026-11-03 (run `20261103-114406`, drop +`BA938406…`): all five phases green on the merged drop. The stored baseline read 20 MB - the +production cap had been raised since the first run and the harness restores what it finds - so +the run normalized to the 5 default for the `TooLarge ... 5242880 byte response cap` posture, +saved 12 (audit line + reloaded pane) and re-polled the SAME 10 MB download to +`HTTP 200 - 10485760 bytes fetched` inside the 60 s window, refused `99999` BY NAME with the +cap-audit lines unmoved 55 -> 55 while the ES kept serving the stored cap, re-armed the refusal +at 5, then restored the 20 MB baseline - the cap live in both directions. + +### MC-18 Per-workspace header policy: store, guardrail, restore (#86) + +`McProxyHeaderPolicyProbeTests.ProbePerWorkspaceHeaderPolicyStoreRefusalAndRestore` — drives the +Proxy tab's two "Advanced: headers" grids (`proxyRequestHeadersGrid`, +`proxyResponseHeadersGrid`) through a live Management Client against the live VMS: + +1. Baseline: both grids read; leftover rows from an interrupted run normalize to the empty + policy first (the MC-17 self-heal lesson) so the run is idempotent. +2. Store: an add/override row plus a strip row in EACH grid saves; verification is two-channel + like the allowlist — the plugin's `Stored proxy header policy ... request 2 rule(s) [X-...; + -X-...]; response 2 rule(s) [...].` audit line in the MC MIP.log AND both grids re-read from + a RELOADED pane, ORDER-SENSITIVELY (configuration order is part of the policy: last rule for + one name wins, here and on the Event Server). +3. Guardrail, four cycles — `Host` (request leg) and `Proxy-Connection` (response leg), each in + the add form and the strip form: the Problem cell explains while the row is typed (`'Host' is + reserved for the proxy connection and cannot be configured.`), Save raises the plugin's + refusal notice NAMING the row verbatim (`Host=evil.example.com` / `-Host`), the MC MIP.log + gains the matching `invalid proxy (request|response) header row(s)` marker, and the + `Stored proxy header policy` audit-line count never moves — the WHOLE save is refused, + nothing partial is stored. Strip never unlocks a forbidden name: the guardrail is on the + name, not on the direction. +4. Storage untouched: after all four refusals the grids still display exactly the stored + policy and the audit count is unmoved. +5. Restore: emptied grids store the EMPTY policy (`request 0 rule(s) []; response 0 rule(s) []`) + the same verified way — the pre-#86 posture; teardown answers `Save changes?` with NO. + +Harness shape (no new live truths — MC-17's rules carried over first try): `HeaderGridUi` scopes +every cell lookup to the grid element first, because BOTH grids' rows share the same per-row +AutomationIds (`proxyHeaderRowName`/`Value`/`Strip`/`Problem`/`Remove`) — a window-wide find +would interleave the two policies; within a grid, rows are addressed by document order like the +allowlist grid, and virtualization is off in both grids' XAML (pinned by the shell guard). + +The live FETCH effect of the policy (headers actually added/stripped against a real target) is +unit-pinned (`ProxyHeaderPolicyTests` + `EventServerHeaderPolicyWiringGuardTests`), not +probe-pinned: the Proxy-tab fetch result label reports status/bytes/elapsed only, so no UIA read +could show headers; the ES-side application sits in the fetch leg the SC probes exercise without +header visibility either. + +Record (MC-18) - **live rows PASSED 2026-11-02**, green run `20261102-224611` (3 m 26 s), drop +`WebContent.dll` SHA-256 `3889A67F.` (1 413 632 bytes) hash-verified in the local install folder. +Chain from the results file: baseline `request=[] response=[]`; `stored header policy audit +line: ... request 2 rule(s) [X-Probe-Request=probe-request-value; -X-Probe-Strip-Request]; +response 2 rule(s) [X-Probe-Response=probe-response-value; -X-Probe-Strip-Response].`; four +`refusal notice (names the row verbatim): Invalid address OK The proxy header policy has invalid +row(s): Host=evil.example.com / -Host / Proxy-Connection=close / -Proxy-Connection. ...`; four +`nothing stored by the refusal: ... lines 1 -> 1`; `storage untouched through all four refusals: +audit lines 1 -> 1`; `saved header policy request=[] response=[] (verified from the audit line +AND reloaded panes, order-sensitive)`. Epic integration re-pass 2026-11-03 (run +`20261103-115132`, drop `BA938406…`): five phases green on the merged drop - two rules per leg +stored through the audit line AND order-sensitive reloaded grids, four forbidden-row cycles +(`Host=evil.example.com` / `-Host` / `Proxy-Connection=close` / `-Proxy-Connection`) each +pinning the live Problem text, the notice naming the row verbatim, and audit lines unmoved +44 -> 44; the emptied grids stored `request 0 rule(s) []; response 0 rule(s) []`. + +### MC-19 Per-workspace rule triggers: store, grammar guardrail, restore (#78) + +`McProxyTriggersProbeTests.ProbeProxyRuleTriggersStoreRefusalAndRestore` — drives the Proxy tab's +"Rule triggers" grid (`proxyTriggersGrid`, between the permission headers and the test fetch) +through a live Management Client against the live VMS: + +1. Baseline: the grid reads the stored rows; leftover rows from an interrupted run normalize to + no triggers first (the MC-17 self-heal lesson) so the run is idempotent. +2. Store: two differently-shaped rows — `http://probe.triggers.example.com/videos/*` + GET + 404 + and `https://*.probe.triggers.example.com/*` + any-method + any-status — save; verification is + two-channel like the other grids — the plugin's `Stored proxy triggers for workspace '' + ...: 2 row(s) [url=... method=GET status=404; url=... method=any status=any].` audit line in + the MC MIP.log AND the grid re-read from a RELOADED pane, ORDER-SENSITIVELY (the Event + Server's matcher evaluates top-down and raises at most the FIRST matching row's event). Every + row — typed AND reloaded — shows the read-only Event raised column with the fixed event type + `WebContent proxy request`, the one event type the plugin raises (pinned to + `ProxyTriggerPolicy.EventType`). +3. Grammar guardrail, three cycles — one per failing member: a mid-host wildcard url + (`http://foo.*.bar.com/`; the host grammar is exact / `*.tail` / lone `*`), status `600` (no + 6xx class, outside 100-599), and a POST verb chosen with NO url (a verb without a url is a + refusal, not a blank row). Each: the Problem cell explains the failing member while the row is + typed, Save raises the plugin's refusal notice NAMING the row verbatim + (`url=... method=... status=...`), the MC MIP.log gains the `invalid proxy trigger row(s)` + marker, and the `Stored proxy triggers` audit-line count never moves — the WHOLE save is + refused, nothing partial is stored. This grammar IS the Event Server's grammar (the same + `ProxyTriggerPolicy` normalizers the matcher runs over stored rows on every refresh — the + grid-vs-matcher agreement is unit-pinned), so a row the MC refuses is exactly a row the server + would refuse and audit anyway. +4. Storage untouched: after all three refusals the grid still displays exactly the stored policy + and the audit count is unmoved. +5. Restore: an emptied grid stores NO trigger section (`0 row(s) []`) — the pre-#78 posture; + teardown answers `Save changes?` with NO. + +Harness shape: `TriggerGridUi` scopes every lookup to the grid element (every per-row id is shared +by all rows, and the trigger grid shares its tab with the allowlist and the two header grids). +The Method cell is a plain WPF ComboBox in the CellTemplate — three live UIA lessons: the closed +combo's selection must be read from the ComboBoxItem peers' `IsSelected` flags, and those peers +exist only once the dropdown has been opened at least once (a freshly loaded closed combo exposes +ZERO item peers — the harness expands once, reads, collapses); the collapsed popup stays inside +the combo's UIA subtree, so Name/Text scans read the popup's rows, not the selection; and the +write route is ExpandCollapse + SelectionItem.Select, escalating to focus-gated Up/Down steps over +the pinned verb order. + +The live EVENT effect (a matching fetch raising the XProtect event and rules reacting) is +unit-pinned (`ProxyTriggerMatcherTests` etc.) and was ES-verified at the #65 deploy with the +`trigger-selftest.flag` probe (ES-3); the Proxy-tab fetch label cannot show events, so this probe +pins the MC UI, the guardrail, and the stored shape. + +Record (MC-19) - **live rows PASSED 2026-11-03**, green run `20261103-031310` (3 m 20 s), drop +`WebContent.dll` SHA-256 `35D35764.` (1 448 448 bytes) hash-verified in the local install folder +(the ES drop already carries the #65 matcher; MC-only change, no `DeployToVms` needed). Chain +from the results file: baseline carried the two rows a debug cycle had left behind and normalized +them to `0 row(s) []` (self-heal earning its keep); `stored triggers audit line: ... 2 row(s) +[url=http://probe.triggers.example.com/videos/* method=GET status=404; url=https://*.probe. +triggers.example.com/* method=any status=any].`; `every typed row shows the read-only Event +column: 'WebContent proxy request'` and the reloaded rows read the same; three `refusal notice +(names the row verbatim): Invalid address OK The proxy trigger list has invalid row(s): +url=http://foo.*.bar.com/ method=any status=any / ... status=600 / url= method=POST status=any. +...`; three `nothing stored by the refusal: ... lines 7 -> 7`; `storage untouched through all +three refusals: audit lines 7 -> 7`; `saved triggers [] (verified from the audit line AND the +reloaded grid, order-sensitive)`. + +### MC-20 Untrusted-CA machine aggregate on the Diagnostics banner (#80) + +Needs the drop deployed on BOTH machines: clients file the reports, the Event Server keeps the +per-MACHINE table in memory, and the MC Diagnostics banner reads it from the same `StatusRequest` +snapshot the blocked ring rides. Row 1 (`McCaUntrustedAggregateProbeTests. +ProbeDiagnosticsTabShowsNoCaBannerWhileTheCaIsTrusted`, runs in every `TestUI` pass, mutates +nothing): open Diagnostics in the healthy lab posture - the `caUntrustedBanner` peer reports +off-screen, its collapsed `btnCaUntrustedExport` peer is off-screen, no visible pane text carries +the aggregate wording, and `dgDiagnosticsBlocked`/`txtDiagnosticsSummary` render normally. A +banner false-positive on a healthy machine is exactly the alarm fatigue the design refuses, so +this half is unguarded. + +Row 2 (`ProbeUntrustedCaReportAggregatesIntoTheDiagnosticsBanner`, guarded +`WEBCONTENT_CA_UNTRUST_LIVE=1` like the SC-10 row, mutates THIS machine's root stores): pull the +deployment CA from BOTH root stores, load a Smart Client proxied view (the listener's posture +flips and the ONE CertUntrusted report files through the block channel - never the #83 tally), +the ES logs exactly one `Proxy CA trust report recorded: workspace='' machine='' +needs the deployment CA imported into its trusted roots.` per NEW machine, then the Management +Client's Diagnostics banner must be VISIBLE and name the aggregate as machine reports and name +THIS machine; clicking the inline `btnCaUntrustedExport` opens the #75 Save-As and writes a file +whose thumbprint equals the deployment CA's. The banner lists this workstation exactly ONCE no +matter how many sessions flipped (dedup is per MACHINE, not per view). Teardown re-imports the CA +into both stores (CurrentUser direct, LocalMachine via the elevated localhost WinRM channel); the +machine TABLE itself survives an Event Server restart-free restore BY DESIGN (in-memory aggregate +state, no persistence - only `DeployToVms`/a service restart clears it, and re-flips dedup into +the same single row). The CurrentUser\Root half is store-protected and Access-Denies for an +unattended shell (the SC-10 lesson, re-verified live): the row then restores what it touched, +records SKIPPED with the exact reason and passes - the elevated human step is: run +`Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~McCaUntrustedAggregate' }` +with `WEBCONTENT_CA_UNTRUST_LIVE=1` from an ELEVATED joshooaj shell (the direct CurrentUser\Root +remove/add then succeeds; the LocalMachine half already works through the WinRM channel). + +Failure signatures: banner visible while the CA is trusted = aggregate text built from the wrong +snapshot member (unit pins the empty-list path); a proxied view loads untrusted but the ES logs +no marker = the listener's Changed subscription or the ES attribution gate is dead (both +guard-pinned by source regex); the banner shows but never names the machine = the report lost +its `ClientMachine` attribution (the ES attributes ONLY client-decided CertUntrusted reports - +ES-decided target-chain failures never populate the table, by design and guard-pinned); the +banner lists one row PER SESSION instead of per machine = the dedup key broke (unit pins +case-insensitive machine dedup). + +Record (MC-20 row 1) - **live PASSED 2026-11-03**, run `20261103-075140`, drop deployed to both +machines by `Invoke-psake -taskList Build, Deploy` (local install folder and remote ES drop both +hash-verified on `WebContent.dll`). Results file: `banner not shown while the CA is trusted +(off-screen banner + no visible aggregate text)` / `the inline export button is parked inside the +collapsed banner while healthy` / `Diagnostics tab renders normally with the banner collapsed`; +artifact `mc-ca-healthy.png`. +Record (MC-20 row 2) - **SKIPPED-unattended 2026-11-03** (`CurrentUser\Root removal refused +unattended (Access is denied.)`, stores untouched, artifact `mc-ca-aggregate-untrusted-results.txt`); +the elevated human-step route above is the outstanding operator task for the full cross-machine +live chain - the shape matches the SC-10 row 4 precedent, and every link of the chain besides +the store surgery is pinned by unit rows + source guards (report contract skew included: the +explicit `[DataContract]` payload decodes pre-#80 frames with the machine list null). + +Record (MC-20 row 1 re-pass, IN-5) - **live PASSED 2026-11-03**, run `20261103-173817`, both +machines on the one merged drop (`WebContent.dll 6B281447…07FD`): the healthy-posture trio +(no banner while trusted, parked export button, normally-rendering Diagnostics tab) again green +on the post-#77/#82 installer-current Event Server. + +### SC-1 End-to-end proxied page load (#54) + +Requires the drop deployed on BOTH machines (Event Server machine + Smart Client machine per +`AGENTS.md`) and a target device that the Smart Client machine can reach (a monitored-segment +device or any http page — https is refused by design, see below). + +1. In Management Client, enable the proxy on a workspace and allowlist the target origin in + http form (e.g. `http://10.0.0.5:8080` — https targets would need CONNECT tunnels, which this + forwarder deliberately refuses). Save; log in to Smart Client and open the workspace. A proxy + toggle made mid-session is reconciled when the workspace tab is reopened (the retained browser + is rewired and its WebView recreated — expected log: `rewired to loopback listener` or + `rewired to direct routing`). +2. Expected Smart Client log markers (MIPLog.txt): `Proxied browser : loopback listener on + 127.0.0.1:, N allowlisted target(s)`. The proxied profile gets its own WebView2 + user-data folder (`...\proxied-`) and is isolated even when the isolate checkbox is off: + one browser process is shared per user-data folder, and the `--proxy-server` argument must not + collide with other workspaces' WebViews. +3. Expected: the home page renders, and its images/CSS load — subresources go through the same + listener. Then block the client machine's route to the target (`New-NetFirewallRule` block, a + hosts-file entry pointing the target at 127.0.0.1, or pull the client off that segment). + Reload: the page MUST still render, while a direct `Invoke-WebRequest http://10.0.0.5:8080/` + from the client machine now fails. That asymmetry is the proof the traffic originates on the + Event Server machine. +4. Address-bar navigation to a NON-allowlisted origin in the proxied workspace shows the proxy's + 403 page ("not in the workspace proxy allowlist"), and no request leaves for it (the fetcher + is never asked). Navigation to an https:// origin shows the 502 CONNECT-refusal page — https + is never taken direct. Since #55 a POST/HTML form submit is NOT a 405: it forwards with its + body (see SC-2); only verbs outside the relay set (TRACE and friends) show the 405 page. +5. Event Server `Logs\MIP.log` during 3: successes are silent; failure cases (4) log the + refusal/`Proxy fetch failed` markers from ES-1/MC-10. +6. Remove the view item from the layout, or exit the Smart Client: the listener stops with the + browser (`Proxy listener` log entries only on faults); `netstat -an | findstr ` shows + nothing listening afterwards. (A mere close of the view tab keeps a background-retained + browser — and its listener — alive for re-attach; that is existing browser lifecycle behavior.) + + Automatable harness: `tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs` (xunit + `Category=probe`) launches Smart Client, logs in, selects the proxied workspace, navigates the + allowlisted target, and writes `sc-proxy-probe-results.txt` (+ page screenshot) under + `%TEMP%\webcontent-sc-proxy-probe\`. The probe goes red — not just records — when the + render is a proxy error page or the body is smaller than the real target page. For row 3's + negative control, add the client-machine hosts entry `127.0.0.1 # + webcontent-sc-1-block` and run with `WEBCONTENT_SC1_BLOCKED=1`: the probe first pre-checks + that a direct client-side GET already fails (control established BEFORE the render), then + requires the proxied page to render while the direct route stays broken. + Record (2026-10-31, drop `AF65D193…7FF9` on both machines, target `http://httpforever.com/`): + rows 1–3 pass — page rendered (`title='HTTP Forever - A reliably insecure connection'`, 2103 + body chars); the blocked control rendered identically while the same process's direct GET + failed with `actively refused it` — server-origin proof satisfied. Rows 4–6 are pinned by the + unit table (403/405/502 policy table, listener teardown) rather than a live probe run. + Regression re-pass on the #56 drop `683FA932…A47A9C58` (run `20261031-235301`): same title + and body size — and the fetch probe now provisions its own allowlist entry through the + shared Management Client phase (a recreated workspace item proved assuming the entry wrong: + the probe went red with 403 until it self-provisioned). + +### SC-2 Vendor login flow through the proxy (#55) + +Requires the same both-machine drop as SC-1. The #55 wire contract carries method/headers/body, +so client and Event Server MUST run the same build — DataContractJsonSerializer ignores unknown +members, so a skewed pair does NOT fail loudly: a new client against an old Event Server silently +degrades every POST to a body-less GET (the login loops back to the form). Deploys are +simultaneous. + +1. Allowlist an http target that serves a login form: a real camera page, or the probe login + server (`tests/ux/WebContent.UxTests`, `Category=probe` serves `/login` → POST → 302 + + `Set-Cookie` → `/dashboard` gated on the cookie) on a machine the Event Server can reach. +2. In the proxied Smart Client workspace, submit the login form. Expected: the POST is relayed + (no 405), the 302 + `Set-Cookie` come back untouched, and the follow-up GET of the landing + page carries the browser's `Cookie` header to the fetcher — the landing page shows the + signed-in state, not the form again. +3. The session cookie lives ONLY in the WebView2 profile: no cookie store exists on the Event + Server side (`UseCookies`/`AllowAutoRedirect` off — pinned by `ProxyFetchTransportTests`). + A login form that submits with a chunked body (modern `fetch()` + `ReadableStream` style — + ordinary forms and XHR always carry Content-Length) answers 411 "Length Required" by design. +4. Automatable harness: `ScProxyLoginProbeTests` (probe category) — it allowlists its own + login-server origin through Management Client, drives the login via CDP, and writes + `sc-login-probe-results.txt` (+ dashboard screenshot) under + `%TEMP%\webcontent-sc-proxy-probe\`; the probe goes red when the dashboard is not + reached or the cookie round-trip fails. + + Record (2026-10-31, drop `6C373E4D…FFE1` on both machines, target `http://172.16.128.210:18123` + served by the probe's vendor-style login server): pass — login form POST relayed through + messaging with its body (`server: POST /login body=35B`), the 302 + `Set-Cookie` round-tripped + (browser shows `title='ACME Portal - Dashboard'`, `SIGNED IN`, `cookieEcho='COOKIE-HEADER + [[SID=…]]'` — never the form again), and the page's background `fetch()` XHR reached the target + with the session cookie attached (`server: GET /api/status cookie='SID=…'`, rendered as + `XHR-OK status=online cookie='SID=…'`). Full probe run ≈1 min. + +### SC-3 Proxied HTTPS targets: target-TLS verdict and machine trust (#56, ADR-0002/ADR-0003) + +Requires the same both-machine drop as SC-1/SC-2 (the leaf contract is new on BOTH sides: an +old Smart Client never asks for leaves, an old Event Server never answers LeafRequest — the +tunnel then fails closed with a logged mint refusal), plus the one-time machine trust setup: + +1. **Event Server loads its CA; an administrator provisions it when the service cannot create + it**: the plugin loads (or, where the service account may write the machine store, creates) + `CN=XProtect WebContent Plugin Loopback Proxy` in `LocalMachine\Personal`, private key in the + machine key container `WebContent-Proxy-CA`. Live installs run the service as + `NT AUTHORITY\NETWORK SERVICE` (read-only on that store), so the README's elevated + `scripts/provision-event-server-authority.ps1` creates the CA once and grants the service read + access to the key. A missing/unloadable CA answers every LeafRequest `NoAuthority` (logged, + retried per request) — tunnels stay fail-closed. +2. **Each Smart Client machine trusts the CA once, elevated** (README "deployment certificate + authority"): export the CA DER from the Event Server and + `Import-Certificate -FilePath -CertStoreLocation Cert:\LocalMachine\Root`. The plugin + only READS the root stores: when the CA thumbprint is absent it logs the exact import command + and the browser shows its own certificate error — never a silent bypass, never a store write. +3. **Fail-closed row**: allowlist `https://host:port` (NO flag) against a self-signed target. + Expected: browser tunnel TLS to the loopback listener succeeds (trusted leaf), the Event + Server refuses the target chain, and the browser shows the `Web Content proxy:` error page + whose body names the per-target `accept untrusted certificate` switch. The target machine + journals the TLS attempt. +4. **Override row**: same entry plus ` accept-untrusted`. Expected: full flow (form, POST, + 302+Set-Cookie, cookie-gated dashboard, background XHR) through the terminated tunnel. +5. Automatable: `ScProxyLoginProbeTests` probe facts `ProbeProxiedHttpsTargetFailsClosedWithout- + Override` and `ProbeProxiedHttpsLoginFlowWithAcceptUntrustedOverride` (self-signed HTTPS + `LoginTestServer` on `UX_HTTPS_LOGIN_TARGET`, default `https://172.16.128.210:18124`; each + row rewrites its own allowlist line — same origin with a different flag state replaces, never + contradicts — and writes `sc-login-probe-tls-{closed,open}-*.png/.txt` artifacts). +6. Live net472 plumbing proof (2026-10-31, compiled plugin DLL reflected under PS 5.1): + unprivileged CA load throws (NoAuthority path); an elevated process creates the CA and a + SECOND process reloads the SAME thumbprint with a usable `PrivateKey` (the machine CAPI + container survives the process); product `ProxyLoopbackClientKey` SPKI → product + `MintLeafForPublicKey` → `AttachLeaf` → real Tls12 `AuthenticateAsServer` handshake + completes; chain builds leaf→CA (UntrustedRoot until the LM\Root import exists — correct + fail-closed); `ProxyLoopbackTrust.IsRootTrusted` reads both root stores unelevated and + reports False when the CA is absent. + + Record (SC-3 live rows) - PASSED live both-host (2026-10-31, drop `WebContent.dll` + SHA-256 `683FA932...A47A9C58` hash-verified on Smart Client + Event Server): + + - Trust setup executed live: `scripts/provision-event-server-authority.ps1` (elevated, on + the ES) created CA thumb `E4D254D58CB1BDA6E894C0F484F3C5AA718D2377` and granted + `NT AUTHORITY\NETWORK SERVICE` read on the key file; the exported .cer was + `Import-Certificate`d into the Smart Client machine's `LocalMachine\Root`. + - Override row PASS (`ProbeProxiedHttpsLoginFlowWithAcceptUntrustedOverride`): ES log + `Loaded deployment proxy CA... (thumbprint E4D254D5...)` + `Minted loopback proxy leaf...`; + client log `deployment CA ... is present in this machine's trusted roots`, `tunnel TLS + established`; the browser completed form -> POST /login -> 302+Set-Cookie -> cookie-gated + dashboard -> background XHR: `signedIn='SIGNED IN' xhr='XHR-OK'`, cookie echo + round-tripped; the target server journaled `GET /`, `POST /login`, `GET /dashboard`, + `GET /api/status` carrying the session cookie. + - Fail-closed row PASS (`ProbeProxiedHttpsTargetFailsClosedWithoutOverride`): the same + allowlisted origin WITHOUT the flag - browser document `title='Web Content proxy: 502'` + naming the per-target accept-untrusted switch; the target server journaled + `TLS handshake failed` (the fetcher refused the self-signed chain mid-handshake). + No login form, no bypass. + + Live defects these rows caught and fixed: the restricted service account's CA store open + (read-only load fallback, step 1) and tunnel responses rendered as plaintext onto the raw + socket instead of into the TLS stream (browser decryption failure; pinned by + `ProxyForwardConnectionTests.WritesInnerTunnelResponsesIntoTheTunnelNotOntoTheRawSocket`). + + Artifacts: `%TEMP%\webcontent-sc-proxy-probe\\sc-login-probe-tls-{open,closed}-*.png/.txt. + + Full-suite green (2026-10-31, drop `683FA932…A47A9C58` hash-verified on both hosts, one + detached xunit run, filter `~ProbeProxied`): fetch `20261031-235301`, login + `20261031-235408`, tls-open `20261031-235514`, tls-closed `20261031-235635`. The override + row rewrote its own allowlist line through the live Management Client edit → guarded Save + → verified-readback path, and the suite left the live allowlist at the fail-closed default + (no flags). + +### SC-4 WebSocket CONNECT tunnel round trip (#57) + +Requires the same both-machine drop as SC-1 (the six tunnel message ids are new on BOTH sides: +an old Smart Client never sends TunnelOpenRequest, an old Event Server never answers one - the +upgrade then fails closed as a 502 page, never smuggled into the fetch path). One allowlist +entry: the ws origin spelled as its http twin (default `http://172.16.128.210:18125`). + +1. **Tunnel row**: open `http://172.16.128.210:18125/` in a proxied workspace view. The served + page issues `new WebSocket('ws://172.16.128.210:18125/echo')` and sends a random token; + live Chromium proxies that as `CONNECT 172.16.128.210:18125` plus a PLAINTEXT websocket + handshake inside the tunnel (live repro - not the absolute `GET ws://…` form RFC 6455 + describes; both are handled). The listener accepts the CONNECT only because the origin is + allowlisted as its http twin, verifies the inner head is a valid ws upgrade before any Event + Server socket opens, and opens the byte tunnel. Expected: the page shows `WS-OK:` - + its own token echoed - while the ws server journals `WS-HANDSHAKE /echo` plus + `WS-ECHO bytes`. Client-side proof and server-side + proof together: the upgrade crossed browser -> loopback listener (decision: tunnel, not + fetch) -> TunnelOpenRequest over MIP messaging -> Event Server raw socket + rebuilt + origin-form handshake -> target 101 relayed VERBATIM -> raw-frame pumps in both directions, + with no HTTP request/response chunking anywhere on the byte path. +2. **Idle/half-open reclaim + caps**: unit-pinned (`ProxyTunnelCorrelationTests`, + `ProxyTunnelServerTests`); live, the ES `EsMaxTunnels` cap answers the 9th simultaneous + open with a logged refusal while the first eight keep streaming. +3. Automatable: `ScProxyWebSocketProbeTests.ProbeProxiedWebSocketEchoRoundTrip` (probe + category, self-provisioning allowlist entry, raw-socket `WebSocketEchoServer` on + `UX_WS_TARGET`, default `http://172.16.128.210:18125`; writes `sc-ws-probe-*.png/.txt` + artifacts under `%TEMP%\webcontent-sc-proxy-probe\\`). + + Record (SC-4 live row) - **passed 2026-11-01** on drop `49E4F8C7…A6EEE3` hash-verified on + both hosts (BEAST Smart Client + vms.joshooaj.com Event Server), one detached xunit run + (filter `~ProbeProxied`), all five live rows green: ws `20261101-021941`, fetch + `20261101-022105`, login `20261101-022211`, tls-open `20261101-022318`, tls-closed + `20261101-022437`. The ws row closed both ways: page verdict `WS-OK:WS-TOKEN-aro1gp694g` + (token echoed through the whole chain) and ws-server journal `WS-HANDSHAKE /echo` + + `WS-ECHO 19 bytes` + `WS-CLOSE client`. Smart Client plugin log for the same session: + `Proxy CONNECT to '172.16.128.210:18125' allowlisted as an http twin; expecting a plaintext + ws:// handshake in the tunnel` then `Proxy websocket CONNECT tunnel to + '172.16.128.210:18125' established; pumping bytes in both directions.` — the live repro + that found the CONNECT-carry posture: an earlier run on the pre-fix drop (`B4EAE436…488466`) + showed the browser's upgrade arriving as CONNECT and being refused, which is what drove + the `ConnectWebSocketTunnel` disposition. + +### SC-5 Explicit proxy error pages and the Event Server outage demo (#58) + +Requires the same both-machine drop as SC-1. The refusal posture: a proxied workspace NEVER +loads anything direct from the Smart Client machine; every failure kind renders its own page in +the view (title `Web Content proxy: `) whose body names which side failed. + +1. **Start-URL refusal row**: set the workspace home address (Management Client pane + `txtHomeAddress`) to an http origin the workspace does NOT allowlist (e.g. + `http://neverssl.com/`). Open the workspace: the start document itself is the 403 page + ("not in the workspace proxy allowlist") - no address-bar interaction, no fallback page, + no spinner. Since #66 the page is the friendly cause-category page (cause chip "Not allowed + by the administrator" + plain headline up front; the exact origin, the canonical allowlist + entry that would admit it, and the verbatim refusal text in a collapsed "Technical details" + section with a Copy diagnostics button). #88 adds the **https sibling**: an off-allowlist + https home (e.g. `https://neverssl.com/`) renders the SAME page, committed at the requested + URL - but answered at the VIEW layer, because Chromium transports https to the loopback + listener only as CONNECT and drops the body of a refused CONNECT, so the proxy's own page + can never reach the screen for https. Restore the home address afterwards. +2. **Outage row (the ticket demo)**: with a healthy proxied page rendered, stop + `MilestoneEventServerService` on the Event Server machine (WinRM, jhadmin creds per + `AGENTS.md`; poll to `Stopped` - the stop is slow). Reload the proxied page: within + seconds (transmit fails on the disposed messaging channel) or at most the 30 s client + deadline (silence = no fetcher listening) the view shows the 503 page naming the missing + component ("The VMS server proxy component is not available... never loaded direct from + this computer"). Smart Client MIPLog marker: `Proxy fetch for failed on the Smart + Client side before any VMS fetcher answered (code=ServerUnavailable)`. +3. **Recovery**: start the service again (poll `Running`; its plugin load needs a few + seconds). The running client's messaging channel stays disposed (an ES restart disposes + every client's `MessageCommunication` - `AGENTS.md`), so recovery is a fresh Smart Client + session: relaunch, log in, navigate - the page loads again, no error page. +4. Distinctions pinned at the unit table: `NotAllowed` (server allowlist drift) → 403, never + the "could not reach" wording; `TooLarge` → page naming the byte cap; target-leg `Timeout` + failure frame → 504 with "VMS server could not get an answer from the target"; local + allowlist refusal → the same 403 page as (1). +5. Automatable: `ScProxyErrorProbeTests` (probe category; rows + `ProbeProxiedStartUrlOutsideAllowlistShows403Page`, + `ProbeProxiedStartUrlHttpsOutsideAllowlistShows403Page` (#88), and + `ProbeEventServerOutageShowsProxyErrorPage`; artifacts + `sc-error-probe-{starturl403,starturl403-https,healthy,es-down,recovered}.png` + results .txt under + `%TEMP%\webcontent-sc-proxy-probe\\`). The outage row mutates the live VMS and only + runs with `WEBCONTENT_ES_STOP=1` (row goes red without the flag, never silently skips); it + restarts the service itself for the recovery half, and the finally block restarts it again if + anything aborted while it was down. The start-URL row restores the previous home address + through the guarded Management Client path. + + Deployment prerequisite (2026-11-01 loop): the rows need the Smart Client machine's MIP root + at the same drop the Event Server runs — the 503 `ServerUnavailable` page is #58 *client* + code, and BEAST is still on the #57 drop (`49E4F8C7…A6EEE3`). The ES drop + (`84623C10…697AA41`, DP-1 record 1) was staged byte-identically via `Copy-Item + -FromSession` under `.cache\es-drop-84623C10\`, so the SC copy only needs the one elevated + consent already handed off in DP-1 record 2. Elevation attempts 2026-11-01 ~04:15/~04:25 + (AGENTS.md `Start-Process -Verb runas`, no `-Wait`): the RDP session is connected, so unlike + the DP-1 attempts (auto-cancelled) the consent prompt stayed PENDING until the idle timeout + cancelled it — nobody was at the desktop. The unattended loop re-attempts the elevated copy + each wake; once the deployed `WebContent.dll` hashes to `84623C10…697AA41`, run the rows: + `dotnet test tests/ux/WebContent.UxTests --filter "FullyQualifiedName~ScProxyErrorProbeTests"` + plus `$env:WEBCONTENT_ES_STOP='1'` for the outage row. + +2. **Live-host attempt (2026-11-01, not verified - needs one unlocked desktop).** `vms.joshooaj.com` + is a second candidate view machine: it already runs the current drop (`84623C10.697AA41` verified + by file hash in `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin`) and has Smart + Client + Management Client installed, so the rows can run there without the DP-1 consent. That + splits the Management Client phase from the view phase - only the first box needs to drive MC + comfortably, and the workspace state lives on the VMS, so the view machine may trust it. Added + for that: `UX_SKIP_MC_PROVISIONING=1` (the row logs the control, never skips it silently, and + whoever sets it owns restoring the home address afterwards). + + What the attempt found, and the thing that stops it: **an idle-locked interactive session makes + every injected-input route fail, and the probe cannot tell that from a broken login.** With the + session at its lock screen, UIA reads and `Value`-pattern writes still work, but UIA `SetFocus` + throws "Operation is not valid due to the current state of the object", FlaUI `Mouse.MoveTo` / + `LeftDoubleClick` are dropped (the cursor does not move), and `SetCursorPos` returns false while + `GetCursorPos` keeps reporting the old position. So the row reaches the login dialog, fills it, + and then dies in the Management Client tree (`Could not collapse the Management Client tree to + the Home root`) because the clicks land nowhere. Both candidate desktops lock on idle (BEAST + session 1 after ~2 h, the vms session after ~40 min) and the lock lives on the secure desktop: + there is no unattended route past it (`consent.exe` auto-cancels; winlogon owns the desktop). + No live workspace state was mutated by any of these attempts - every run failed before the first + Management Client Save - so no home-address restore is outstanding. + + Human step for the SC-5 rows: on a machine whose MIP root hashes to `84623C10.697AA41` and whose + interactive desktop stays unlocked for the run (screensaver/lock off), with the repo `.env` + present, run + + ```powershell + $env:WEBCONTENT_ES_STOP = '1' # only for the outage row; it stops/starts the live Event Server + Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~ScProxyErrorProbeTests' } + ``` + + Harness hardening landed while diagnosing it (`LoginFlow`, `ManagementClientUi`): the password is + entered through the UIA `Value` pattern before the focus+keyboard route (a dialog with no + foreground to give is the normal case for a probe launched beside a busy desktop), `AutomationId` + reads are guarded because the focused element can be a window whose provider does not implement + the property, the Management Client window is raised (attach to the foreground thread + topmost) + before any mouse-driven tree route - a click at tree coordinates otherwise lands on whatever + window overlaps it - and the auto-login race catch also accepts `NoClickablePoint` / + `ElementNotEnabled` as "the dialog vanished because the client signed itself in". + +3. **Live pass, outage row (2026-11-01 ~08:20-08:26) - PASSED, run `20261101-082437`.** The + deployment prerequisite is closed: at ~08:19 a fresh elevation was granted on BEAST (the + hardened `-File` payload transcript records `elevated: True`) and the staged drop copied in - + BEAST's MIP root now verifies per file to `84623C10…697AA41`, identical to the Event Server. + The outage row ran both-machine: baseline proxied page rendered (`title='HTTP Forever - A + reliably insecure connection'`), `MilestoneEventServerService` stopped over WinRM, reload + surfaced `503` with title `Web Content proxy: 503` and the body naming the missing component + ("...No proxy-fetch answer arrived from the VMS Event Server within 30s (exchange + 96d6169fd93b4f95ad2a911130591288)..."), MIPLog.txt carried the `ServerUnavailable` marker, and + the row's finally restored the service (`Running`; the next row's healthy baseline proves the + plugin reloaded). The allowlist `Ensure` short-circuited ("already allowlisted; no Management + Client edit needed") - the #54-#57 entries are still live on the workspace. + The start-URL row, run FIRST in the same `dotnet test`, failed before its first live change: + `CollapseTreeToRoot` timed out with every level-1 node reporting four no-op strikes, while the + identical route succeeded minutes later in the outage row (`tree collapsed to root (realized + items=18...)`). Same locked-desktop injection window as record 2, but intermittent: the session + flips between usable and no-op windows (consent-era secure-desktop churn / RDP re-lock), and + `SetCursorPos` succeeding is a necessary but not sufficient usable-window signal. No workspace + state was changed by that failed attempt; next wake re-runs only that row. + +4. **Live pass, start-URL row (2026-11-01 ~08:46-08:48) - PASSED, run `20261101-084616`.** + Re-run alone in a fresh usable-desktop window. Management Client phase clean: the tree collapse + succeeded at strike 1 (`tree collapsed to root (realized items=18...)`) - the intermittency of + record 3 confirmed, nothing in the product at fault. Home address `https://joshooaj.com/` -> + `http://neverssl.com/` (verified readback), and the workspace's FIRST document - no address-bar + interaction - was the refusal page: title `Web Content proxy: 403`, body "The request origin + 'http://neverssl.com' is not in the workspace proxy allowlist." Home restored to + `https://joshooaj.com/` (verified readback) by the row's finally. With record 3 both SC-5 rows + are green: the ticket's live demo and the start-URL refusal. + +5. **Recovery half automated + live PASS (2026-11-01 ~13:06-13:09), run `20261101-130629`.** Step 3 + above was only described in records 3-4, not asserted, so the outage row now finishes the demo + itself: after the service is back (row starts it itself, 10 s for the fetcher's plugin load) it + relaunches the Smart Client, signs in, selects the workspace and navigates - the proxied document + must come back, not an error page. Live result: `baseline title='HTTP Forever - A reliably + insecure connection'` -> ES stopped over WinRM -> reload `503`, committed title + `Web Content proxy: 503` naming the component (exchange `56ddae5fcbb243a4b8b8018eccaceaca`), + `ServerUnavailable` marker found in MIPLog.txt -> ES `Running` -> **fresh session title back to + `HTTP Forever - A reliably insecure connection`**, body carrying the real page text, screenshot + `sc-error-probe-recovered.png` (the green HTTP Forever header, unlocked padlock). New assertions: + the recovered title must not start with `Web Content proxy:` and must equal the baseline title; + the recovered body must not contain `Web Content proxy:`. The finally block now restarts the + service only if it is still down (the row normally restarts it mid-row for this half), so the + "manual recovery required" line can no longer fire after a clean run. + Both rows were also re-run green by hand immediately before, after a couple of attempts aborted + with `No addressBar in the proxied workspace view` (the outage row refuses rather than silently + navigating elsewhere; its finally restarted the Event Server each time, so the service was never + left down): start-URL row `20261101-120323` (home `http://httpforever.com/` -> + `http://neverssl.com/` -> title `Web Content proxy: 403` -> restored, verified readback) and + outage row `20261101-120540` (same 503 posture, `ServerUnavailable` marker, service `Running` + afterwards). BEAST's deployed `WebContent.dll` for these runs: `760F1A69…5B507C`, + `FileVersion 0.1.186.31842` - a later build of the same sources as the Event Server's + `84623C10…697AA41`, which is why the hashes differ (the assembly version is stamped per build). + +6. **#66 friendly refusal page - start-URL row PASSED 2026-11-02 ~23:20 (run `20261102-232047`).** + The refusal page re-designed by #66 (explicit cause categories, collapsed technical detail) on + both machines (both `WebContent.dll` SHA-256 `5269DCEA…1326AD` before the run, verified by file + hash not timestamp). Management Client phase clean (allowlist `Ensure` short-circuited; home + `http://httpforever.com/` -> `http://neverssl.com/` verified readback), and the row's NEW + assertions - not just the old title/body ones - all held on the live page: title byte-compatible + `Web Content proxy: 403`; cause chip "Not allowed by the administrator" and headline "This web + address is not allowed for this view" in the body; the `details` element exists and reads + `open == false` (technical section collapsed by default); the `#wdc-copy` Copy-diagnostics + button is present; the collapsed section carries "not in the workspace proxy allowlist" (the + verbatim refusal, proving the #58 sentences still ship) AND "Allowlist entry that would admit + this origin: http://neverssl.com" (the actionable fix). Screenshot `sc-error-probe-starturl403.png` + shows the rendered card. Home address restored to `http://httpforever.com/` (verified readback). + The outage row's assertions (title `Web Content proxy: 503`, "VMS server proxy component is not + available", "never loaded direct from this computer") were kept verbatim through the wording + layer - the explanation texts carry those exact sentences - so the outage posture needs no + re-run beyond the unit table for this change. + +7. **#88 https refusals at the view layer - https start-URL row PASSED 2026-11-03 ~02:07-02:09 + (run `20261103-020728`), http sibling re-passed (`20261103-020505`).** Root cause confirmed as + the ticket suspected: the forwarder DOES write the full 403 + friendly page as the refused + CONNECT's answer body (unit pin: `ProxyForwardConnectionTests.WritesTheFullForbiddenPageAsTheConnectAnswerBody`), + and Chromium discards that body and renders its native `ERR_TUNNEL_CONNECTION_FAILED` page - + the pre-fix live half of the pair showed exactly that page at `https://neverssl.com/`. Fix: + proxied views register a `WebResourceRequested` filter (`https://*/*`, only when a listener + exists); the shared-grammar gate `ProxyViewRefusal` answers the off-allowlist https refusal + with the identical #66 403 page BEFORE any CONNECT opens, committed at the requested URL. + The http row never double-answers (absolute-form http stays on the proxy's own path) and + ws/wss upgrades keep the CONNECT posture (the event does not surface websocket requests) - + documented asymmetry in README. Decision record: option 2 (view-level interception) chosen over + terminate-then-mint-for-blocked-hosts (would widen the deliberately allowlist-gated leaf-mint + gate) and over accepting the limitation (the http row already proves the user-facing posture + the https row now matches). Both machines at drop `E17995E1…5479BDFC` (`FileVersion + 0.1.261.61565`, verified by file hash). Live facts asserted, not screenshot-only: home + `http://httpforever.com/` -> `https://neverssl.com/` (verified readback); the workspace's first + document URL starts `https://neverssl.com` (same committed-history posture as the http row); + title byte-identical `Web Content proxy: 403`; cause chip + headline in the body; `details` + reads `open == false`; `#wdc-copy` present; the collapsed section carries "not in the workspace + proxy allowlist" AND "Allowlist entry that would admit this origin: https://neverssl.com" (the + https twin, the scheme actually requested); home restored to `http://httpforever.com/` + (verified readback). Smart Client MIPLog marker: `WebContent proxy: view-level https refusal + for browser : GET 'https://neverssl.com/' ... No CONNECT was opened and the browser is + never taken direct.` (also fires for the favicon subresource). #69/ES side: the view's report + rides the ordinary tally - the Event Server log gained `Proxy block recorded: ... + origin='https://neverssl.com' reason=NotAllowed client='BEAST'`. The http row re-passed on the + same drop with the new event handler registered, so the absolute-form path is unregressed. + Screenshot `sc-error-probe-starturl403-https.png`. + +### DP-1 Event Server deployment script and rollout records (#59) + +> **Superseded as the deployment path by #68 (2026-11-03):** the SUPPORTED way to deploy to the +> Event Server machine is the MSI run on that machine (see IN-3); the script below remains the +> dev/test remote shortcut. The records below stay as the historical rollout evidence. + +The proxied feature is both-machine; the Event Server side has a deployment the installer +cannot shortcut (plugins load only at service start). `scripts/deploy-event-server-plugin.ps1` +is the remote, repeatable form of the documented procedure: local pre-check that the drop +carries a `plugin.def` declaring the `Service` environment (an old def copies fine and then +silently never loads - refused up front), WinRM with explicit credentials, stop +`MilestoneEventServerService` and WAIT for `Stopped` (the stop is slow and the dll is locked +until then), copy into the installer's `INSTALLFOLDER`, verify EVERY copied file by SHA-256 +against the local source (never by timestamp - the copy preserves it), start and wait for +`Running`, then print the rollout follow-ups: fetcher-ready marker check, mandatory Smart +Client restart after the ES restart, and the one-time CA provisioning pointer. + +1. **Record (script validated live end-to-end) - PASSED 2026-11-01** against + `vms.joshooaj.com`: drop built from HEAD (post-#58), `WebContent.dll` SHA-256 + `84623C10…697AA41`, all 11 drop files hash-identical in + `C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin`, service back to `Running`, + and the ES `logs\MIP.log` gained `Proxy fetcher ready (concurrency=3, + request id=WebContent.Proxy.FetchRequest)` at startup. ES machine product: **XProtect 2024 + R2** (event server binaries file-version 24.2a) - the README's "verified against" line. + - Live defect the run caught and fixed: the script's stop-wait loop hung while the service + was actually stopped - WinRM deserializes `ServiceControllerStatus` as its underlying + **Int32**, so the remote `$state -eq 'Stopped'` comparison never matched; the poll now + calls `.ToString()` remotely. The script's log-location note was also corrected to the + observed layout (`%ProgramData%\Milestone\XProtect Event Server\logs\MIP.log` plus + `logs\MIPLogs\` rolled archives). +2. **Fresh-both-machine smoke row (SC-1 procedure against a fresh drop) - PASSED 2026-11-01 + 09:12.** Both MIP roots hash-verified at the same HEAD drop (`84623C10…697AA41`): the Event + Server side via record 1, BEAST side staged byte-identically (`.cache\es-drop-84623C10\`) and + elevated-copied by the #58 loop (~08:30, first UAC consent a human approved - the overnight + unattended attempts all auto-cancelled; see SC-5 record 2 for the locked-desktop diagnosis). + With the elevated hosts control `127.0.0.1 httpforever.com # webcontent-sc-1-block` in place, + `WEBCONTENT_SC1_BLOCKED=1 dotnet test tests/ux/WebContent.UxTests --filter + FullyQualifiedName~ScProxyFetchProbeTests`: the pre-check confirmed a direct client-side GET + already fails, a fresh Smart Client session then RENDERED `http://httpforever.com/` + (`title='HTTP Forever - A reliably insecure connection'`, 2103 body chars) while the + post-render direct client GET still failed - the page arrived only over + loopback listener -> MIP messaging -> Event Server fetcher. Artifacts: + `%TEMP%\webcontent-sc-proxy-probe\20261101-091122\sc-proxy-probe-page.png` + + `sc-proxy-probe-results.txt`. Hosts entry removed after the row. (The 09:09 first attempt + died in the Management Client phase on a transient UIA `0x80040201`; the retry passed.) + - **Elevation received (2026-11-01 ~08:19)**: a fresh elevated copy was granted on BEAST and + the staged drop landed - every file in `CascadiaWebContentPlugin` verifies SHA-256-identical + to `84623C10…697AA41` (same per-file verification this script does remotely). Steps 2-4 + (blocked smoke row) still need an unlocked desktop on that machine; the hosts entry itself + needs one more elevated write. + 3. **Fresh-both-machine smoke row - PASSED 2026-11-01 ~09:03-09:04 (run `20261101-090329`).** + Against the fresh drop on both machines (BEAST Smart Client + `vms.joshooaj.com` Event + Server): the second elevation of the cycle added the hosts control + (`127.0.0.1 httpforever.com # webcontent-sc-1-block`), the row's pre-check confirmed the + direct client-side GET fails BEFORE navigating, the proxied workspace browser then rendered + `title='HTTP Forever - A reliably insecure connection'` (2103 body chars) - the page provably + originates on the Event Server machine while the client itself cannot reach the target. + Afterwards the elevated payload removed the hosts entry (record 2 step 4) and the workspace + home address is untouched (`https://joshooaj.com/`, last verified by the SC-5 restore). + The last open acceptance row of #59 is thereby satisfied. + +### SC-6 wss:// websockets through the terminated TLS tunnel (#60) + +Requires the same both-machine drop as SC-1 (both machines must carry the #60 build - an older +Event Server keeps refusing https-scheme tunnel targets, which makes these rows fail loudly, +never silently) plus the SC-3 one-time deployment CA provisioning, so the browser's tunnel TLS +to the loopback listener validates cleanly and any visible failure is the TARGET certificate +decision, not the loopback leaf. + +The posture lifted from #57: a wss:// upgrade riding an allowlisted HTTPS origin is now +tunnelled - never fetched, no longer refused 501. The loopback listener accepts the CONNECT and +terminates the tunnel TLS with an ES-minted leaf; the in-tunnel upgrade GET rides the existing +byte-tunnel protocol; the Event Server dials a FRESH TLS leg to the target, admits only a 101, +relays it verbatim, then pumps decrypted frames both ways. #61 wired the per-target +accept-untrusted override into that leg: the entry for exactly this origin decides whether the +tunnel's own SslStream accepts an untrusted chain - full chain validation everywhere else, never +a global bypass, never the browser-side loopback termination. The chain-trust success row +imports trust instead: `WssTargetTrust` copies the probe certificate's public DER into the Event +Server machine's `LocalMachine\Root` over WinRM (jhadmin creds from `.env`, per `AGENTS.md`; +idempotent and self-cleaning by probe CN), skipped with `UX_WSS_TRUST_IMPORT=0` when the target +is trusted by other means. + +Automatable: `ScProxyWssProbeTests` (probe category; artifacts +`sc-wss-probe-page.png` / `sc-wss-probe-tls-closed-page.png` / `sc-wss-probe-accept-untrusted-page.png` ++ results .txt under `%TEMP%\webcontent-sc-proxy-probe\\`): + +1. **Success row** `ProbeProxiedWssEchoRoundTrip`: the page is served from the plain http page + origin and dials `wss:///echo`; the workspace allowlist carries BOTH origins + (the https entry WITHOUT any flag - the entry scheme is the posture). Expected: the page + shows `WS-OK:` (client-side proof) while the wss server journals `TLS-HANDSHAKE`, + `WS-HANDSHAKE` and `WS-ECHO` (server-side proof the ES dialed TLS and replayed the upgrade). +2. **Fail-closed row** `ProbeProxiedWssTlsChainFailureRefusesBeforeTheUpgradeArrives`: a second + https origin, same entry posture, certificate deliberately NOT imported. Expected: the page + reports `WS-FAIL` (the browser never gets a tunneled TLS 101) and the target journals the TLS + leg being dialed (`TLS-CONNECT`) but never the upgrade handshake - the Event Server refused + the chain before replaying it. The page itself renders fine (plain http origin). +3. **Override row** (#61) `ProbeProxiedWssAcceptUntrustedOverrideAdmitsSelfSignedTarget`: the + SAME self-signed origin as the fail-closed row, certificate still deliberately NOT imported - + but the workspace allowlist entry now carries the per-target accept-untrusted flag (probe + entry shape ` accept-untrusted`; the MC grid renders it as the row checkbox, and the + provisioning readback pins the flag). Expected: the Event Server's tunnel TLS leg takes the + flag instead of chain trust - the page shows `WS-OK:` and the target journals + TLS-HANDSHAKE + WS-HANDSHAKE + WS-ECHO. Each row re-Ensures its own flag state for the + origin, so the flag never leaks between rows; the Event Server refreshes its allowlist union + at most once per 60 s, and the minutes between rows' Management Client phases comfortably + exceed that window. + +Defaults are the lab's server-network addresses; override with `UX_WSS_PAGE_TARGET` (default +`http://172.16.128.210:18125`), `UX_WSS_TARGET` (default `https://172.16.128.210:18126`), +`UX_WSS_UNTRUSTED_TARGET` (default `https://172.16.128.210:18127`), `UX_ES_HOST` (defaults to +the `VMS_ADDRESS` host): + +```powershell +Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~ScProxyWssProbeTests' } +``` + +**Live rows PASSED 2026-11-01** (Event Server deployed via `scripts/deploy-event-server-plugin.ps1 +-Confirm:$False` - pwsh 7.6.9 NREs on the non-interactive `ShouldProcess` prompt; the copy-lock +retry of `053e22e` was earned on this rollout. BEAST at the matching #60 drop, `WebContent.dll +EFBA5B80…52D8` hash-verified, via a human-consented elevated copy - consent requests from an +unattended agent chain never surface on the desktop, so the copy itself was the one human step). +**Fail-closed row**, run +`20261101-174151`: page verdict `WS-FAIL:error` while the untrusted wss target journal read +`TLS-CONNECT peer` -> `TLS-HANDSHAKE ok` -> `connection fault: IOException: The decryption +operation failed` - the Event Server negotiated the TLS leg, refused the chain, and aborted +before replaying the upgrade (`WS-HANDSHAKE` never appeared; the journal shape also confirms the +review's note that Schannel surfaces the refusal as a wrapped IOException, which is exactly what +the `ProxyTunnelServer` inner-chain catch classifies as `TlsValidation`). **Echo row**, run +`20261101-174657`: `WssTargetTrust` imported the probe certificate (`B7304600…5FE6`) into the +Event Server's `LocalMachine\Root` over WinRM, then the proxied page showed +`WS-OK:WSS-TOKEN-do7amc35w2i` while the wss server journal read `TLS-CONNECT` -> +`TLS-HANDSHAKE ok` -> `WS-HANDSHAKE /echo` -> `WS-ECHO 21 bytes` -> `WS-CLOSE client` - +browser -> CONNECT -> loopback-terminated TLS -> in-tunnel upgrade -> MIP byte tunnel -> +Event Server TLS leg -> target 101 -> decrypted frames both ways, end to end. Artifacts +`sc-wss-probe-tls-closed-page.png` / `sc-wss-probe-page.png` + results .txt in those run dirs. +Found and fixed during the run: `WssTargetTrust` treated the bare `VMS_ADDRESS` hostname as a +uri and died before the import (`7813b73`). + +**#61 override row PASSED 2026-11-02** (run `20261102-113540`, both machines hash-identical at +the new drop, ES `Proxy fetcher ready` 11:34:34): the live Management Client saved +`https://172.16.128.210:18127 accept-untrusted` through the Proxy-tab grid (audit line + +reloaded-pane double verification), the probe certificate was deliberately NOT imported +anywhere, and the proxied page showed `WS-OK:WSS-TOKEN-yu4ub8qkkhh` while the wss server journal +read `TLS-CONNECT peer` -> `TLS-HANDSHAKE ok` -> `WS-HANDSHAKE /echo` -> `WS-ECHO 21 bytes` -> +`WS-CLOSE client` - the accept-untrusted flag, scoped to its own origin, admitted the +self-signed chain on the Event Server's tunnel leg. The two #60 twin rows re-passed on the same +drop, pinning that the wiring neither widens nor narrows the default posture: **fail-closed +row** run `20261102-114705` (same origin, flag removed, `WS-FAIL:error`; the target journal +read only the TLS dial + negotiate then `connection fault: IOException: The decryption +operation failed`, and the Event Server logged `Proxy block recorded +origin='https://172.16.128.210:18127'` at 11:48:45 while minting the loopback leaf for the +same origin worked); **trusted-echo row** run `20261102-115053` (no flag, certificate imported +into the Event Server's `LocalMachine\Root` over WinRM, `WS-OK:WSS-TOKEN-n71gu47y88` with the +full journal) - full chain validation still ACCEPTS valid chains through the wired resolver, so +the fail-closed row's refusal is the chain, not the wiring. Environmental flake along the way: +the first fail-closed attempt (`20261102-114002`) hit the known Smart Client launch window +while the Event Server service restarted (fetcher-stopped 11:43:29 / fetcher-ready 11:43:47); +it passed on re-run. Artifacts `sc-wss-probe-accept-untrusted-page.png` / `-results.txt` in +those run dirs. + +The override row passed once more as an independent confirmation on the `90625f3`-hardened +harness: run `20261102-115638` (drop `7015417E…DAEDA9` hash-verified on both machines via the +psake `Deploy` task, no elevation) - the MC grid saved `https://172.16.128.210:18127 +accept-untrusted`, certificate in NO trust store, page +`WS-OK:WSS-TOKEN-fmd8ql29fy8`, journal TLS-CONNECT -> TLS-HANDSHAKE ok -> WS-HANDSHAKE /echo -> +WS-ECHO 21 bytes -> WS-CLOSE client. Housekeeping from that session: a failed probe orphans a +`testhost` process that locks the UxTests dll - kill stale `testhost` processes before +re-running TestUI. + +### SC-7 Setup-mode blocked-resources indicator (#83) + +Automatable, unit: `ProxyBlockTallyTests` — the per-view tally in front of the #69 +`ProxyBlockReporter`: one entry per origin+reason with hit counts (canonical origin display, +"(N hits)" label), most-recently-seen-first snapshot promoted on re-sighting, bounded at 200 +entries with least-recently-seen eviction, `Reset` (per navigation) clears + raises `Changed` +once and no-ops quietly when empty, null/empty refusals count nothing, a throwing ES forwarder +(dead channel) never reaches the browser flow and never costs a local count, forwarder swap to +null (listener stop) detaches cleanly, and the production composition (tally → `ProxyBlockReporter`) +sends exactly one `ProxyBlockReportPayload` per refusal while also counting locally. + +Live: `ScProxyBlockedIndicatorProbeTests.ProbeSetupModeBlockedResourcesIndicator` (probe +category; artifacts under `%TEMP%\webcontent-sc-proxy-probe\\`) — `BlockedResourcePageServer` +serves `/blocked-page/` (document + two ``s + a `"; + + private sealed class Journal + { + private readonly string _path; + + public Journal(string path) + { + _path = path; + } + + public void Write(string line) + { + var stamped = DateTime.UtcNow.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture) + " " + line; + using var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + using var writer = new StreamWriter(stream); + writer.WriteLine(stamped); + } + } +} diff --git a/spike/WebView2LifetimeProbe/README.md b/spike/WebView2LifetimeProbe/README.md new file mode 100644 index 0000000..53016ec --- /dev/null +++ b/spike/WebView2LifetimeProbe/README.md @@ -0,0 +1,34 @@ +# WebView2 lifetime probe (#90, throwaway) + +Answers one question for the #91 enablement design: when two host processes attach to one +WebView2 user data folder with identical options, what happens to the shared browser process +when the host that CREATED it exits, cleanly or crashed? + +Not shipped. Never referenced by the plugin. Built automatically with the UxTests project. + +## Run + +Through the repo's TestUI flow (the rows need no VMS): + +```powershell +Invoke-psake -taskList TestUI -parameters @{ TestFilter = 'FullyQualifiedName~ProbeLifetime' } +``` + +Standalone host roles (the orchestrating tests do this; rarely needed by hand): + +```powershell +spike\WebView2LifetimeProbe\bin\Debug\net10.0-windows\WebView2LifetimeProbe.exe --role creator --dir C:\somewhere\artifacts +spike\WebView2LifetimeProbe\bin\Debug\net10.0-windows\WebView2LifetimeProbe.exe --role joiner --dir C:\somewhere\artifacts [--spawn-second-after 15000] +``` + +Each role journals `INIT_OK` / `TICK` / `PROCESS_FAILED` / `TICK_FAIL` / `SECOND_INIT_OK|FAIL` +to `status-.log` in the artifact dir. The test rows read the journals plus a live +`msedgewebview2` WMI snapshot as independent witnesses. + +## Answer (measured 2026-11-03, see #90 findings) + +The shared browser process is tied to the attached hosts as a set, not to the creator: + +- Creator exits cleanly, joiner still attached: browser process survives, joiner's page keeps running. +- Creator host hard-killed (host-only kill; Chromium children untouched): browser process STILL survives, joiner keeps ticking; no ProcessFailed. +- Browser process actually gone (row 6 tears it down via tree-kill of the creator host, since rows 2/3 prove WebView2 itself never ends the collection on creator exit; or when the last attached host leaves): attached views die with `ProcessFailed(BrowserProcessExited)`, and a FRESH `EnsureCoreWebView2Async` in any surviving process respawns the collection (row 6 asserts `SECOND_INIT_OK`, observed ~13 s). diff --git a/spike/WebView2LifetimeProbe/WebView2LifetimeProbe.csproj b/spike/WebView2LifetimeProbe/WebView2LifetimeProbe.csproj new file mode 100644 index 0000000..36f4682 --- /dev/null +++ b/spike/WebView2LifetimeProbe/WebView2LifetimeProbe.csproj @@ -0,0 +1,24 @@ + + + + + net10.0-windows + WinExe + true + disable + enable + latest + WebView2LifetimeProbe + WebView2LifetimeProbe + + + + + + + diff --git a/src/Setup/setup.en-us.wxl b/src/Setup/setup.en-us.wxl index debea7d..bee3e15 100644 --- a/src/Setup/setup.en-us.wxl +++ b/src/Setup/setup.en-us.wxl @@ -1,4 +1,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Setup/setup.wixproj b/src/Setup/setup.wixproj index 2bcb169..1b32ef3 100644 --- a/src/Setup/setup.wixproj +++ b/src/Setup/setup.wixproj @@ -9,7 +9,13 @@ 0.1.1 ..\..\output\WebContent - Version=$(BuildVersion);HarvestPath=$(HarvestPath) + + ..\WebContentServiceCtl\bin\$(Configuration)\WebContentServiceCtl.exe + ..\WebContentProxyCa\bin\$(Configuration)\WebContentProxyCa.exe + Version=$(BuildVersion);HarvestPath=$(HarvestPath);ServiceCtlExePath=$(ServiceCtlExePath);ProxyCaExePath=$(ProxyCaExePath) ..\..\output\Setup CascadiaWebContentPlugin.$(BuildVersion) ICE61 @@ -19,10 +25,19 @@ + + + + false + + + + false + diff --git a/src/Setup/setup.wxs b/src/Setup/setup.wxs index 4cf26f8..c83f990 100644 --- a/src/Setup/setup.wxs +++ b/src/Setup/setup.wxs @@ -1,15 +1,73 @@ + xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui" + xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util"> - + + + + + + + + + + + + + + + + + + + + + @@ -34,15 +92,80 @@ + + + + + + + + + + + + + + + - - + + + + + \ No newline at end of file diff --git a/src/Setup/setupui.wxs b/src/Setup/setupui.wxs new file mode 100644 index 0000000..6f62097 --- /dev/null +++ b/src/Setup/setupui.wxs @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WebContent.Tests/AddressBarInteractionTests.cs b/src/WebContent.Tests/AddressBarInteractionTests.cs index 37badc6..e79b9a8 100644 --- a/src/WebContent.Tests/AddressBarInteractionTests.cs +++ b/src/WebContent.Tests/AddressBarInteractionTests.cs @@ -1,5 +1,7 @@ using System; + using WebContent.Client; + using Xunit; namespace WebContent.Tests diff --git a/src/WebContent.Tests/AddressNormalizationTests.cs b/src/WebContent.Tests/AddressNormalizationTests.cs index 74bc9a6..a1b7a63 100644 --- a/src/WebContent.Tests/AddressNormalizationTests.cs +++ b/src/WebContent.Tests/AddressNormalizationTests.cs @@ -1,4 +1,5 @@ using WebContent; + using Xunit; namespace WebContent.Tests diff --git a/src/WebContent.Tests/BrowserCommandAdmissionTests.cs b/src/WebContent.Tests/BrowserCommandAdmissionTests.cs new file mode 100644 index 0000000..799edd1 --- /dev/null +++ b/src/WebContent.Tests/BrowserCommandAdmissionTests.cs @@ -0,0 +1,96 @@ +using System; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Pins the toolbar admission rule that keeps a failed CoreWebView2 initialization from + /// crashing the Smart Client: every command is a call into the live CoreWebView2, and the + /// WebView2 wrapper throws InvalidOperationException from VerifyCoreWebView2 (Reload, GoBack, + /// GoForward, ShowPrintUI) whenever CoreWebView2 is null. The live failure (2026-11-02): a + /// second Smart Client process failed init against the shared proxied user-data folder, the + /// old gate only checked the WPF-level IsLoaded - which stays true through an init failure - + /// and a Refresh click died in Reload with an exception dialog. + /// #87 adds ONE narrow exception: a PROXIED browser in the failed-init state admits Refresh + /// through the canRecreateProfile route (Execute then re-creates the WebView instead of + /// calling Reload). Every other command, and every non-proxied failed view, stays exactly + /// as the crash rule requires. + /// + public class BrowserCommandAdmissionTests + { + // The crash state itself: control alive, IsLoaded-style checks satisfied, navigation + // history flags stale - but no CoreWebView2. Nothing may be admitted. BrowserAction is + // internal, so the theory rows carry it as a string (public test methods cannot take it). + [Theory] + [InlineData("Refresh")] + [InlineData("NavigateBack")] + [InlineData("NavigateForward")] + [InlineData("NavigateHome")] + [InlineData("Print")] + public void UninitializedView_admitsNothing(string actionName) + { + var action = (BrowserAction)Enum.Parse(typeof(BrowserAction), actionName); + // canGoBack/canGoForward deliberately true: the rule must not trust stale flags. + // canRecreateProfile false: this is the unchanged (non-proxied) crash state. + Assert.False(BrowserCommandAdmission.IsAvailable(action, coreReady: false, canGoBack: true, canGoForward: true, canRecreateProfile: false)); + } + + // #87: the errored proxied view's Refresh re-attempts creation through the re-creation + // path - admitted. No other command takes the route, and only when the caller vouches + // for proxied-plus-needs-recreation. + [Theory] + [InlineData("Refresh", true)] + [InlineData("NavigateBack", false)] + [InlineData("NavigateForward", false)] + [InlineData("NavigateHome", false)] + [InlineData("Print", false)] + public void FailedProxiedView_admitsRefreshForRecreationOnly(string actionName, bool expected) + { + var action = (BrowserAction)Enum.Parse(typeof(BrowserAction), actionName); + Assert.Equal(expected, BrowserCommandAdmission.IsAvailable(action, coreReady: false, canGoBack: false, canGoForward: false, canRecreateProfile: true)); + } + + [Theory] + [InlineData("Refresh")] + [InlineData("NavigateHome")] + [InlineData("Print")] + public void InitializedView_admitsStatelessCommands(string actionName) + { + var action = (BrowserAction)Enum.Parse(typeof(BrowserAction), actionName); + Assert.True(BrowserCommandAdmission.IsAvailable(action, coreReady: true, canGoBack: false, canGoForward: false, canRecreateProfile: false)); + } + + [Fact] + public void BackAndForward_needTheirHistoryFlag() + { + Assert.True(BrowserCommandAdmission.IsAvailable(BrowserAction.NavigateBack, coreReady: true, canGoBack: true, canGoForward: false, canRecreateProfile: false)); + Assert.True(BrowserCommandAdmission.IsAvailable(BrowserAction.NavigateForward, coreReady: true, canGoBack: false, canGoForward: true, canRecreateProfile: false)); + Assert.False(BrowserCommandAdmission.IsAvailable(BrowserAction.NavigateBack, coreReady: true, canGoBack: false, canGoForward: true, canRecreateProfile: false)); + Assert.False(BrowserCommandAdmission.IsAvailable(BrowserAction.NavigateForward, coreReady: true, canGoBack: true, canGoForward: false, canRecreateProfile: false)); + } + + [Fact] + public void EveryAction_isKnownToTheGate() + { + // A new BrowserAction value must be decided about explicitly: an unknown member + // admits nothing even on a healthy view, so the matrix test above cannot silently + // pass a future command through. + foreach (BrowserAction action in Enum.GetValues(typeof(BrowserAction))) + { + var admitted = BrowserCommandAdmission.IsAvailable(action, coreReady: true, canGoBack: true, canGoForward: true, canRecreateProfile: false); + if (action == BrowserAction.NavigateTo) + { + // NavigateTo has no toolbar command and an empty Execute - never admitted. + Assert.False(admitted); + } + else + { + Assert.True(admitted); + } + } + } + } +} diff --git a/src/WebContent.Tests/BrowserInitFailureTests.cs b/src/WebContent.Tests/BrowserInitFailureTests.cs new file mode 100644 index 0000000..30045d6 --- /dev/null +++ b/src/WebContent.Tests/BrowserInitFailureTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Reflection; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #87 interim contract (ADR-0004 point 5): a failed PROXIED CoreWebView2 + /// initialization must produce an honest in-view profile-ownership presentation plus ONE + /// explanatory log line naming the cause and the browser id - and nothing at all for a + /// non-proxied failure (its error surface is unchanged). The whole classifier runs without + /// a live WebView2, filesystem, or process. + /// + public class BrowserInitFailureTests + { + private static readonly Guid BrowserId = Guid.Parse("12345678-1234-4234-8234-9876543210ab"); + + [Fact] + public void NonProxiedFailure_PresentsNothingAndKeepsTheLogOnlySurface() + { + BrowserInitFailure.Presentation presentation; + Assert.False(BrowserInitFailure.TryPresent(proxied: false, BrowserId, "whatever the wrapper threw", out presentation)); + Assert.Null(presentation); + } + + [Fact] + public void ProxiedFailure_PresentsTheProfileOwnershipCause() + { + BrowserInitFailure.Presentation presentation; + Assert.True(BrowserInitFailure.TryPresent(proxied: true, BrowserId, "The WebView2 environment could not be created because another process is using the user data folder with different options.", out presentation)); + + // The distinct cause category from the shared error-page wording layer - never one + // of the per-request proxy refusal categories, so it reads as profile ownership. + Assert.Equal(ProxyErrorPage.CategoryProfileOwned, presentation.Category); + Assert.DoesNotContain("gateway", presentation.Explanation, StringComparison.OrdinalIgnoreCase); + + // The user-facing wording names the other session ON THIS MACHINE and the profile, + // and points at the honest remedy (close the other session / reload after it exits). + Assert.False(string.IsNullOrWhiteSpace(presentation.CauseLabel)); + Assert.Contains("Another Smart Client session", presentation.Headline, StringComparison.Ordinal); + Assert.Contains("profile", presentation.Explanation, StringComparison.OrdinalIgnoreCase); + Assert.Contains("this computer", presentation.Explanation, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Smart Client session", presentation.Explanation, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TheOneLogLineNamesTheOwnershipCauseTheBrowserIdAndTheTechnicalMessage() + { + const string technical = "Initialization failed: options mismatch on the shared user data folder."; + + BrowserInitFailure.Presentation presentation; + Assert.True(BrowserInitFailure.TryPresent(proxied: true, BrowserId, " " + technical + " ", out presentation)); + + // One line (no embedded newlines) naming cause + browser id + verbatim message. + Assert.DoesNotContain(Environment.NewLine, presentation.LogLine); + Assert.DoesNotContain("\n", presentation.LogLine); + Assert.Contains("proxied browser " + BrowserId, presentation.LogLine, StringComparison.Ordinal); + Assert.Contains("profile", presentation.LogLine, StringComparison.OrdinalIgnoreCase); + Assert.Contains("another Smart Client session on this machine", presentation.LogLine, StringComparison.OrdinalIgnoreCase); + Assert.Contains(technical, presentation.LogLine, StringComparison.Ordinal); + } + + [Fact] + public void MissingExceptionMessage_StillPresentsAndNamesBrowserId() + { + BrowserInitFailure.Presentation presentation; + Assert.True(BrowserInitFailure.TryPresent(proxied: true, BrowserId, null, out presentation)); + Assert.Contains("proxied browser " + BrowserId, presentation.LogLine, StringComparison.Ordinal); + Assert.Contains("no initialization exception was reported", presentation.LogLine, StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(presentation.Detail)); + } + + [Fact] + public void TheOwnershipCategoryIsNeverMappedFromAPerRequestWireCode() + { + // The category belongs to the browser-level contract, not the refusal pipeline: + // no ProxyFetchErrorCodes value may map onto it, and it maps onto nothing itself. + var codes = typeof(ProxyFetchErrorCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(string)) + .Select(f => (string)f.GetValue(null)) + .ToList(); + + foreach (var code in codes) + { + string category; + if (ProxyErrorPage.TryCategoryForErrorCode(code, out category)) + { + Assert.NotEqual(ProxyErrorPage.CategoryProfileOwned, category); + } + } + + string unmapped; + Assert.False(ProxyErrorPage.TryCategoryForErrorCode(ProxyErrorPage.CategoryProfileOwned, out unmapped)); + } + } +} diff --git a/src/WebContent.Tests/BrowserProfileBoundaryTests.cs b/src/WebContent.Tests/BrowserProfileBoundaryTests.cs new file mode 100644 index 0000000..96431c0 --- /dev/null +++ b/src/WebContent.Tests/BrowserProfileBoundaryTests.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Tests for the proxied-boundary keying of proxied browser profiles (ADR-0004, #92): + /// the profile path is keyed to the FULL (Windows user, XProtect identity, VMS server) + /// tuple, so same identity on two servers never shares a proxied profile or fetch + /// channel, while every leg still maps to one sanitized, filesystem-safe segment. + /// + public class BrowserProfileBoundaryTests + { + private const string Root = @"C:\Users\someone\AppData\Roaming\SmartClient.WebContentData"; + + private static readonly Guid BrowserId = Guid.Parse("2963db34-185a-4383-84c5-c9f7d780b425"); + + // ---- server-key derivation ------------------------------------------------------- + + [Fact] + public void Server_Key_Folds_Host_And_Port() + { + Assert.Equal("vms.example.com-80", BrowserProfileKey.DeriveServerKey("vms.example.com", 80)); + } + + [Fact] + public void Server_Key_Is_Case_Insensitive_On_Host_But_Port_Sensitive() + { + Assert.Equal( + BrowserProfileKey.DeriveServerKey("VMS.Example.COM", 443), + BrowserProfileKey.DeriveServerKey("vms.example.com", 443)); + Assert.NotEqual( + BrowserProfileKey.DeriveServerKey("vms.example.com", 443), + BrowserProfileKey.DeriveServerKey("vms.example.com", 8443)); + } + + [Fact] + public void Server_Key_Sanitizes_Host_Graffiti() + { + // A DNS host never carries these; a hostile/foreign ServerId value must still + // never escape its folder level. + var key = BrowserProfileKey.DeriveServerKey(@"..\. \evil:443", 0); + + Assert.DoesNotContain('\\', key); + Assert.DoesNotContain('/', key); + Assert.DoesNotContain(':', key); + Assert.DoesNotContain(' ', key); + } + + [Fact] + public void Server_Key_Empty_When_Host_Missing() + { + Assert.Equal(string.Empty, BrowserProfileKey.DeriveServerKey(null, 443)); + Assert.Equal(string.Empty, BrowserProfileKey.DeriveServerKey(" ", 443)); + } + + [Fact] + public void Server_Key_Without_Port_When_Port_Unknown() + { + Assert.Equal("vms.example.com", BrowserProfileKey.DeriveServerKey("vms.example.com", -1)); + Assert.Equal("vms.example.com", BrowserProfileKey.DeriveServerKey("vms.example.com", 0)); + } + + // ---- path composition decision table ---------------------------------------------- + + [Fact] + public void Same_Tuple_Produces_Identical_Proxied_Path() + { + var first = BrowserProfileKey.ComposeProxiedProfilePath(Root, "D6BB5695", "vms.example.com-80", BrowserId); + var second = BrowserProfileKey.ComposeProxiedProfilePath(Root, "D6BB5695", "vms.example.com-80", BrowserId); + + Assert.Equal(first, second); + } + + [Fact] + public void Same_Identity_Different_Server_Produces_Different_Proxied_Path() + { + // The #92 regression this ticket exists for: one Windows user, one XProtect + // identity, TWO VMS servers - the paths must not share a folder. + var serverA = BrowserProfileKey.ComposeProxiedProfilePath(Root, "D6BB5695", "vms-a.example.com-80", BrowserId); + var serverB = BrowserProfileKey.ComposeProxiedProfilePath(Root, "D6BB5695", "vms-b.example.com-80", BrowserId); + + Assert.NotEqual(serverA, serverB); + } + + [Fact] + public void Same_People_Different_Browser_Id_Produce_Different_Proxied_Path() + { + var one = BrowserProfileKey.ComposeProxiedProfilePath(Root, "D6BB5695", "vms.example.com-80", BrowserId); + var other = BrowserProfileKey.ComposeProxiedProfilePath(Root, "D6BB5695", "vms.example.com-80", Guid.NewGuid()); + + Assert.NotEqual(one, other); + } + + [Fact] + public void Path_Shape_Is_Root_User_Server_Proxied() + { + var path = BrowserProfileKey.ComposeProxiedProfilePath(Root, "D6BB5695", "vms.example.com-80", BrowserId); + + Assert.Equal( + Path.Combine(Root, "D6BB5695", "server-vms.example.com-80", "proxied-2963db34-185a-4383-84c5-c9f7d780b425"), + path); + } + + [Fact] + public void Proxied_Leaf_Keeps_Lowercase_D_Guid_Format() + { + // The folder name must match the browser id the plugin logs (and #94's sweep + // matches on this prefix). + var path = BrowserProfileKey.ComposeProxiedProfilePath(Root, "User1", "vms.example.com-80", BrowserId); + + Assert.EndsWith("proxied-" + BrowserId.ToString("D"), path, StringComparison.Ordinal); + Assert.Contains("proxied-2963db34-185a-4383-84c5-c9f7d780b425", path, StringComparison.Ordinal); + } + + [Fact] + public void Identity_Fallbacks_Still_Name_The_User_Segment() + { + // The user leg arrives through DeriveUserKey's fallback chain; whatever leg won, + // the composition keeps it as its own segment. + var viaIdentity = BrowserProfileKey.DeriveUserKey("D6BB5695-05C9", "WORKGROUP\\jhadmin", "jhadmin"); + var viaFqn = BrowserProfileKey.DeriveUserKey(null, "WORKGROUP\\jhadmin", "jhadmin"); + var viaName = BrowserProfileKey.DeriveUserKey(null, null, "jhadmin"); + + Assert.Equal(Path.Combine(Root, "D6BB5695-05C9", "server-vms.example.com-80", "proxied-" + BrowserId.ToString("D")), + BrowserProfileKey.ComposeProxiedProfilePath(Root, viaIdentity, "vms.example.com-80", BrowserId)); + Assert.Equal(Path.Combine(Root, "WORKGROUP_jhadmin", "server-vms.example.com-80", "proxied-" + BrowserId.ToString("D")), + BrowserProfileKey.ComposeProxiedProfilePath(Root, viaFqn, "vms.example.com-80", BrowserId)); + Assert.Equal(Path.Combine(Root, "jhadmin", "server-vms.example.com-80", "proxied-" + BrowserId.ToString("D")), + BrowserProfileKey.ComposeProxiedProfilePath(Root, viaName, "vms.example.com-80", BrowserId)); + } + + [Fact] + public void Missing_User_Key_Falls_Back_To_Root_Level_But_Keeps_Server_Separation() + { + // Pre-login/design-time identity loss keeps the existing shared-root fallback + // shape, and the server leg STILL fences the boundary at that level. + var noUser = BrowserProfileKey.ComposeProxiedProfilePath(Root, string.Empty, "vms-a.example.com-80", BrowserId); + + Assert.Equal(Path.Combine(Root, "server-vms-a.example.com-80", "proxied-" + BrowserId.ToString("D")), noUser); + Assert.NotEqual(noUser, BrowserProfileKey.ComposeProxiedProfilePath(Root, string.Empty, "vms-b.example.com-80", BrowserId)); + } + + [Fact] + public void Missing_Server_Key_Uses_Placeholder_And_Never_Matches_A_Resolved_Server() + { + var unknown = BrowserProfileKey.ComposeProxiedProfilePath(Root, "User1", string.Empty, BrowserId); + var resolved = BrowserProfileKey.ComposeProxiedProfilePath(Root, "User1", "vms.example.com-80", BrowserId); + + Assert.Equal(Path.Combine(Root, "User1", "server-unknown", "proxied-" + BrowserId.ToString("D")), unknown); + Assert.NotEqual(unknown, resolved); + + // Processes that all fail to resolve agree on the one placeholder. + Assert.Equal(unknown, BrowserProfileKey.ComposeProxiedProfilePath(Root, "User1", null, BrowserId)); + } + + [Fact] + public void Hostile_Legs_Never_Escape_Their_Folder_Level() + { + var path = BrowserProfileKey.ComposeProxiedProfilePath( + Root, + @"..\..\..\evil", + @"..\..\..\evil:443\", + BrowserId); + + // Everything under the root stays exactly three segment levels down: no traversal. + Assert.StartsWith(Root + Path.DirectorySeparatorChar, path, StringComparison.Ordinal); + var tail = path.Substring(Root.Length + 1); + Assert.Equal(3, tail.Split(Path.DirectorySeparatorChar).Length); + + // And the composition re-sanitizes, so even a caller that skipped its own + // sanitize step cannot break out. + Assert.DoesNotContain(@"..\..\..\", path, StringComparison.Ordinal); + } + + [Fact] + public void Server_Segment_Prefix_Guarantees_It_Is_Never_Dot_Or_DotDot() + { + // The prefix is what makes the placeholder discussion moot: the segment can never + // BE a traversal token regardless of what the key sanitizes to. + var path = BrowserProfileKey.ComposeProxiedProfilePath(Root, "User1", "..", BrowserId); + + Assert.Equal(Path.Combine(Root, "User1", "server-..", "proxied-" + BrowserId.ToString("D")), path); + } + } +} diff --git a/src/WebContent.Tests/BrowserProfileKeyTests.cs b/src/WebContent.Tests/BrowserProfileKeyTests.cs index 8b8d16c..c2702c1 100644 --- a/src/WebContent.Tests/BrowserProfileKeyTests.cs +++ b/src/WebContent.Tests/BrowserProfileKeyTests.cs @@ -1,8 +1,10 @@ using System; using System.Linq; -using Xunit; + using WebContent.Client; +using Xunit; + namespace WebContent.Tests { /// @@ -124,4 +126,4 @@ public void Sanitize_Handles_Unicode_Identities() Assert.False(string.IsNullOrEmpty(key)); } } -} \ No newline at end of file +} diff --git a/src/WebContent.Tests/BrowserRegistryTests.cs b/src/WebContent.Tests/BrowserRegistryTests.cs index 54770c1..055803d 100644 --- a/src/WebContent.Tests/BrowserRegistryTests.cs +++ b/src/WebContent.Tests/BrowserRegistryTests.cs @@ -1,7 +1,9 @@ using System; -using Xunit; + using WebContent.Client; +using Xunit; + namespace WebContent.Tests { /// diff --git a/src/WebContent.Tests/CallbackOwnershipTests.cs b/src/WebContent.Tests/CallbackOwnershipTests.cs index 91ffadc..f590d0c 100644 --- a/src/WebContent.Tests/CallbackOwnershipTests.cs +++ b/src/WebContent.Tests/CallbackOwnershipTests.cs @@ -1,9 +1,11 @@ using System; using System.Threading; using System.Threading.Tasks; -using Xunit; + using WebContent.Client; +using Xunit; + namespace WebContent.Tests { /// diff --git a/src/WebContent.Tests/ClaimHandoffWiringGuardTests.cs b/src/WebContent.Tests/ClaimHandoffWiringGuardTests.cs new file mode 100644 index 0000000..c99d13a --- /dev/null +++ b/src/WebContent.Tests/ClaimHandoffWiringGuardTests.cs @@ -0,0 +1,146 @@ +using System.IO; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #96 wiring guards: when a boundary's listener owner dies mid-session, the surviving + /// joined view heals WITHOUT user action. The heartbeat, the pure decision call, the UI-thread + /// dispatch, and the healing routes all live in the WPF/socket-coupled classes the unit harness + /// cannot host, so these source pins hold the composition the SC-15 live probes drive: every + /// proxied entry point arms the watchdog, the watch consults through the SAME claim protocol + /// (never a release side-door), handoff re-creation rides ONLY the #87 RecreateWebView route, + /// and the retry budget/grace bound the cycle. The decisions themselves sit in + /// ClaimWatchCoreTests; the live rows sit in docs/verification-matrix.md SC-15. + /// + public class ClaimHandoffWiringGuardTests + { + private static string ReadClient(string file) + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "Client", file)); + } + + private static string Collapse(string source) + { + return System.Text.RegularExpressions.Regex.Replace(source, @"\s+", " "); + } + + [Fact] + public void EveryProxiedEntryPointArmsTheWatchAndTeardownStopsIt() + { + var collapsed = Collapse(ReadClient("BrowserManager.cs")); + + // Both proxied entry points (creation and the mid-session proxy re-enable) arm the + // idempotent heartbeat, and exactly ONE stop site exists: the plugin teardown. + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(collapsed, @"ProxyListenerClaimWatchdog\.EnsureStarted\(\);").Count); + Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(collapsed, @"ProxyListenerClaimWatchdog\.Stop\(\);").Count); + Assert.Contains("public static void StopAllProxyListeners()", collapsed); + + // The watchdog's tick is guarded end to end - a throwing watch never kills the timer. + var watchdog = Collapse(ReadClient("ProxyListenerClaimWatchdog.cs")); + Assert.Contains("BrowserManager.WatchBoundaryClaims();", watchdog); + } + + [Fact] + public void TheWatchDecidesThroughThePureCoreWithTheProductionLivenessProbes() + { + var collapsed = Collapse(ReadClient("BrowserManager.cs")); + + // One Decide call, fed the #93 store's OWN production probes - the watch's staleness + // verdict can never disagree with the claim protocol's own liveness rule. + Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(collapsed, @"ClaimWatchCore\.Decide\(").Count); + Assert.Contains("ClaimWatchCore.Decide(file, facts, ListenerClaimStore.ProcessExists, ListenerClaimStore.LoopbackPortConnectable)", collapsed); + + // Only CLAIMED browsers are watched (epoch 0 = unclaimed ephemeral fallback: nothing + // to hold), and the healing branches all exist in the dispatch switch. + Assert.Contains("browser.ProxyClaimEpoch < 1", collapsed); + Assert.Contains("case ClaimWatchAction.Republish:", collapsed); + Assert.Contains("case ClaimWatchAction.Rewire:", collapsed); + Assert.Contains("case ClaimWatchAction.Retry:", collapsed); + + // WebView work is dispatched to the UI thread - the timer thread never touches views. + Assert.Contains("app.Dispatcher.BeginInvoke(new Action(() => PerformHandoffRewire(browser, claimPath)))", collapsed); + + // (measured live, #96) the view-liveness read the watch performs FROM THE TIMER THREAD + // hops through the control's dispatcher - a raw CoreWebView2 read off the owning + // thread throws VerifyAccess and silently kills every watch tick. + var instance = Collapse(ReadClient("BrowserInstance.cs")); + Assert.Contains("web.Dispatcher.CheckAccess()", instance); + Assert.Contains("web.Dispatcher.Invoke(() => IsInitialized && web.CoreWebView2 != null && InitFailure == null)", instance); + } + + [Fact] + public void TheHandoffReclaimsThroughTheSeamAndRecreatesThroughTheOnlyRoute() + { + var collapsed = Collapse(ReadClient("BrowserManager.cs")); + + // The owner-side slot repair pins the port ACTUALLY served (joiners' baked arguments + // stay truthful) and keeps the generation instead of resetting it. + Assert.Contains("ProxyBrowserArguments.PortIndependentFingerprint(), browser.ProxyClaimEpoch, served)", collapsed); + + // The joined-side handoff re-claims at a bumped floor and pins its own live port when + // it is the owner repairing the slot. + Assert.Contains("Math.Max(1, browser.ProxyClaimEpoch + 1)", collapsed); + Assert.Contains("browser.ProxyClaimPath != null ? (int?)(browser.ProxyListener?.Port ?? browser.ProxyClaimPort) : null", collapsed); + + // A settle failure changes NOTHING (a claim never outlives a lie, neither does a rewire). + Assert.Contains("if (lease.Role == ProxyListenerLeaseRole.UnclaimedFallback)", collapsed); + + // ONE re-creation call site in the whole manager - the #87 route - and it is the + // handoff's. No second recreation path exists. + Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(collapsed, @"RecreateWebView\(").Count); + Assert.Contains("browser.RecreateWebView(", collapsed); + Assert.Contains("browser.ProxyHandoffAttempts = argumentsChanged ? 1 : browser.ProxyHandoffAttempts + 1;", collapsed); + } + + [Fact] + public void TheConsumedPostureCarriesTheFreshLeasesEpochAndOwner() + { + var collapsed = Collapse(ReadClient("BrowserManager.cs")); + + // Both posture branches stamp what the browser now actually rides; the joined branch + // records the FOREIGN owner pid (the watch's liveness rule rides it next tick). + Assert.Contains("browser.ProxyClaimEpoch = lease.Epoch", collapsed); + Assert.Contains("browser.ProxyClaimOwnerPid = lease.OwnerPid", collapsed); + } + + [Fact] + public void TheCoreIsPureAndItsBudgetOutlastsTheMeasuredRespawnWindow() + { + var core = Collapse(ReadClient("ClaimWatchCore.cs")); + + // #90 measured ~13 s for a destroyed collection to respawn with fresh browser + // arguments; the grace must stay above that, and the budget is finite per generation. + Assert.Contains("ReCreationGraceMs = 15000", core); + Assert.Contains("MaxHandoffRetries = 5", core); + + // Pure means unit-testable: no timer, no WPF, no filesystem, no sockets in the core. + Assert.DoesNotContain("Timer", core); + Assert.DoesNotContain("Dispatcher", core); + Assert.DoesNotContain("System.Windows", core); + Assert.DoesNotContain("File.", core); + Assert.DoesNotContain("TcpClient", core); + } + + [Fact] + public void TheDecisionCoreLandsInBothCompileListsButTheTimerStaysPluginOnly() + { + var root = RepoLocator.FindRepoRoot(); + + // Plugin side: SDK-style default glob - both files must simply exist in the plugin tree. + Assert.True(File.Exists(Path.Combine(root, "src", "WebContent", "Client", "ClaimWatchCore.cs"))); + Assert.True(File.Exists(Path.Combine(root, "src", "WebContent", "Client", "ProxyListenerClaimWatchdog.cs"))); + + // Test side: the CORE is linked (AGENTS rule - land it in BOTH or one side silently + // misses the class); the WPF-adjacent timer is deliberately NOT (nothing to test there + // beyond the pins above). + var testCsproj = File.ReadAllText(Path.Combine(root, "src", "WebContent.Tests", "WebContent.Tests.csproj")); + Assert.Contains( + "", + testCsproj); + Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(testCsproj, "Compile Include=\\\"[^\\\"]*ClaimWatchCore\\.cs").Count); + Assert.DoesNotContain("ProxyListenerClaimWatchdog.cs", testCsproj); + } + } +} diff --git a/src/WebContent.Tests/ClaimWatchCoreTests.cs b/src/WebContent.Tests/ClaimWatchCoreTests.cs new file mode 100644 index 0000000..4963528 --- /dev/null +++ b/src/WebContent.Tests/ClaimWatchCoreTests.cs @@ -0,0 +1,316 @@ +using System; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #96: the pure decision table of the boundary-claim watch. Every row is what one + /// watch tick ASKS about one proxied browser - does its recorded claim posture still describe + /// the live boundary, and if not, which existing route heals it? The heart of the ticket, + /// "the owner died while a joined client sat open", is the dead-owner row: a joined posture + /// facing a dead-pid claim must Rewire (re-claim through the #93 protocol, then force the + /// view re-creation through the #87 route). Liveness here is an injected seam; the production + /// probes are the #93 store's own, pinned by wiring guards. + /// + public class ClaimWatchCoreTests + { + private const int OwnPid = 1000; + private const int ForeignPid = 2000; + private const int OwnPort = 40000; + private const int ForeignPort = 41000; + private const long Now = 1_700_000_000_000L; + + private static string ClaimContent(int ownerPid, int port, long epoch = 1) + { + return new ListenerClaim(Guid.NewGuid(), port, ownerPid, "fp", Now, epoch).Serialize(); + } + + /// A joined browser riding the foreign owner, view up - the steady state. + private static ClaimWatchFacts Joined(Action tweak = null) + { + var facts = new ClaimWatchFacts + { + OwnPid = OwnPid, + OwnsClaimSlot = false, + OwnListenerPort = 0, + RecordedOwnerPid = ForeignPid, + RecordedPort = ForeignPort, + RecordedEpoch = 1, + ViewLive = true, + InitFailurePending = false, + NowUnixMs = Now, + LastInitAttemptUnixMs = 0, + HandoffAttempts = 0, + }; + tweak?.Invoke(facts); + return facts; + } + + /// THIS process owning the slot on its own served port, view up - the steady state. + private static ClaimWatchFacts Owning(Action tweak = null) + { + var facts = new ClaimWatchFacts + { + OwnPid = OwnPid, + OwnsClaimSlot = true, + OwnListenerPort = OwnPort, + RecordedOwnerPid = OwnPid, + RecordedPort = OwnPort, + RecordedEpoch = 1, + ViewLive = true, + InitFailurePending = false, + NowUnixMs = Now, + LastInitAttemptUnixMs = 0, + HandoffAttempts = 0, + }; + tweak?.Invoke(facts); + return facts; + } + + private static ClaimWatchVerdict Decide(ListenerClaimFileRead file, ClaimWatchFacts facts, bool foreignAlive = true, bool portConnectable = true) + { + return ClaimWatchCore.Decide( + file, + facts, + pid => pid == OwnPid || (pid == ForeignPid && foreignAlive), + port => portConnectable); + } + + [Fact] + public void NullInputsDecideNothing() + { + Assert.Equal(ClaimWatchAction.None, Decide(null, Joined()).Action); + Assert.Equal(ClaimWatchAction.None, Decide(ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)), null).Action); + } + + [Fact] + public void ATransientReadErrorIsNeverTreatedAsStaleContent() + { + // Deleting or re-claiming on a momentary read refusal would evict a LIVE owner's + // claim - the release discipline refuses this on the write side too. + var verdict = Decide(ListenerClaimFileRead.Error("reading failed: sharing violation"), Joined()); + Assert.Equal(ClaimWatchAction.None, verdict.Action); + Assert.Null(verdict.Audit); + } + + [Fact] + public void AVanishedSlotRepublishesForAnOwnerButRewiresForAJoiner() + { + var owner = Decide(ListenerClaimFileRead.NotFound(), Owning()); + Assert.Equal(ClaimWatchAction.Republish, owner.Action); + Assert.Contains("vanished while this process was its owner", owner.Audit); + + // #96's acceptance shape (clean exit released the slot): the joiner takes the handoff. + var joined = Decide(ListenerClaimFileRead.NotFound(), Joined()); + Assert.Equal(ClaimWatchAction.Rewire, joined.Action); + Assert.Contains("the owner left the boundary", joined.Audit); + } + + [Fact] + public void ASlotThatNoLongerParsesToThisBuildRewiresThroughTheProtocol() + { + // Foreign schema (incl. pre-epoch v1) and corrupt content ride the #93 stale-with- + // audit rule, not a watch-side special case. + foreach (var content in new[] { "complete garbage", "webcontent-listener-claim/3\nx=1\n" }) + { + var verdict = Decide(ListenerClaimFileRead.Found(content), Joined()); + Assert.Equal(ClaimWatchAction.Rewire, verdict.Action); + Assert.Contains("no longer parses to this build's schema", verdict.Audit); + } + } + + [Fact] + public void AForeignPidClaimingOurSlotAdoptsTheOwnershipPosture() + { + // The slot names US although this browser thinks it joined someone (a won race or a + // rewire that promoted us): re-consult to adopt the posture honestly. + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(OwnPid, OwnPort)), + Joined(f => f.RecordedOwnerPid = ForeignPid)); + Assert.Equal(ClaimWatchAction.Rewire, verdict.Action); + Assert.Contains("now names this process", verdict.Audit); + } + + [Fact] + public void AnOwnSlotNamingAnotherPortRepublishesForThePortActuallyServed() + { + // The listener never stopped serving; only the RECORD is wrong (swept and rewritten + // against a stale read). Joiners keep their argument; no view has to move. + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(OwnPid, ForeignPort)), + Owning()); + Assert.Equal(ClaimWatchAction.Republish, verdict.Action); + Assert.Contains("re-publishing the claim for the port actually served", verdict.Audit); + } + + [Fact] + public void TheOwnerFacingItsOwnHealthyMatchActsOnTheClaimNotAtAll() + { + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(OwnPid, OwnPort)), + Owning()); + Assert.Equal(ClaimWatchAction.None, verdict.Action); + Assert.Null(verdict.Audit); + } + + [Fact] + public void TheDeadOwnerIsTheHandoffTrigger() + { + // THE row of the ticket: joined posture, live-looking slot, owner process GONE. The + // survivor rewires - re-claims via the protocol (epoch bumps) and the rewire forces + // the view re-creation with the new port baked in. + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)), + Joined(), + foreignAlive: false); + Assert.Equal(ClaimWatchAction.Rewire, verdict.Action); + Assert.Contains("no longer running", verdict.Audit); + Assert.Contains("handoff", verdict.Audit); + } + + [Fact] + public void ARefusingOwnerPortIsStaleByTheSameRuleEvenWhileThePidLives() + { + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)), + Joined(), + portConnectable: false); + Assert.Equal(ClaimWatchAction.Rewire, verdict.Action); + Assert.Contains("refused a connect probe", verdict.Audit); + } + + [Fact] + public void AnOwnedPostureFacingALiveForeignOwnerStepsDownToJoin() + { + // Another process's staleness verdict took the slot first: no fight, no side-door - + // the next consult joins it. + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)), + Owning()); + Assert.Equal(ClaimWatchAction.Rewire, verdict.Action); + Assert.Contains("stepping down to join it", verdict.Audit); + } + + [Fact] + public void AChangedGenerationUnderAJoinerRewiresEvenWhenTheOwnerIsAlive() + { + // The epoch/port/owner-pid agreement check: any of the three drifting from what this + // browser RECORDS means its baked --proxy-server argument describes a dead generation. + foreach (var facts in new[] + { + Joined(), // epoch drift: slot is at 2, we joined under 1 + Joined(f => f.RecordedPort = ForeignPort + 1), + Joined(f => f.RecordedOwnerPid = ForeignPid + 1), + }) + { + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort, epoch: 2)), + facts); + Assert.Equal(ClaimWatchAction.Rewire, verdict.Action); + Assert.Contains("changed hands while this browser was joined", verdict.Audit); + } + } + + [Fact] + public void TheRecycledBrowserWindowRetriesWithinBudgetInsteadOfReWiring() + { + // The claim STILL describes exactly what the browser carries, but the WebView never + // attached (browser process recycling after the collection exit, ~13 s measured) - + // that is the retry budget's business, not the claim's. + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)), + Joined(f => + { + f.ViewLive = false; + f.InitFailurePending = true; + f.LastInitAttemptUnixMs = Now - ClaimWatchCore.ReCreationGraceMs - 1; + f.HandoffAttempts = 1; + })); + Assert.Equal(ClaimWatchAction.Retry, verdict.Action); + Assert.Contains("attempt 2 of " + ClaimWatchCore.MaxHandoffRetries, verdict.Audit); + } + + [Fact] + public void TheGraceWindowRefusesToPileUpAttempts() + { + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)), + Joined(f => + { + f.ViewLive = false; + f.InitFailurePending = true; + f.LastInitAttemptUnixMs = Now - 1000; // attempt barely started + })); + Assert.Equal(ClaimWatchAction.None, verdict.Action); + Assert.Null(verdict.Audit); + } + + [Fact] + public void AClosedViewIsNeverResurrected() + { + // Not-init-failure dead view = the user closed it. The watch heals boundaries, not + // deliberate closures. + var verdict = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)), + Joined(f => f.ViewLive = false)); + Assert.Equal(ClaimWatchAction.None, verdict.Action); + Assert.Null(verdict.Audit); + } + + [Fact] + public void TheRetryBudgetTerminatesTheCycleWithOneGiveUpAudit() + { + ListenerClaimFileRead file = ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort)); + ClaimWatchFacts FailedInFlight(int attempts) => Joined(f => + { + f.ViewLive = false; + f.InitFailurePending = true; + f.LastInitAttemptUnixMs = Now - ClaimWatchCore.ReCreationGraceMs - 1; + f.HandoffAttempts = attempts; + }); + + for (int attempts = 0; attempts < ClaimWatchCore.MaxHandoffRetries - 1; attempts++) + { + Assert.Equal(ClaimWatchAction.Retry, Decide(file, FailedInFlight(attempts)).Action); + } + + var giveUp = Decide(file, FailedInFlight(ClaimWatchCore.MaxHandoffRetries)); + Assert.Equal(ClaimWatchAction.GiveUp, giveUp.Action); + Assert.Contains("leaving the in-view notice", giveUp.Audit); + + // Later ticks stay quiet: no second give-up, no endless retries - the cycle ends. + var quiet = Decide(file, FailedInFlight(ClaimWatchCore.MaxHandoffRetries + 3)); + Assert.Equal(ClaimWatchAction.None, quiet.Action); + Assert.Null(quiet.Audit); + } + + [Fact] + public void OwnerDeathMidHandoffRestartsABoundedCycleNotAnEndlessOne() + { + // Survivor A rewired (epoch 2), then DIED itself while B still watches: B faces a + // dead-pid epoch-2 claim -> the same Rewire row (its Acquire will bump to 3). The + // retry budget belongs to each generation's re-creation, so termination holds. + var firstDeath = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort, epoch: 2)), + Joined(f => { f.RecordedEpoch = 2; }), + foreignAlive: false); + Assert.Equal(ClaimWatchAction.Rewire, firstDeath.Action); + + // And B's own view failing under the settled generation walks the SAME bounded budget. + var retry = Decide( + ListenerClaimFileRead.Found(ClaimContent(ForeignPid, ForeignPort, epoch: 3)), + Joined(f => + { + f.RecordedEpoch = 3; + f.ViewLive = false; + f.InitFailurePending = true; + f.LastInitAttemptUnixMs = Now - ClaimWatchCore.ReCreationGraceMs - 1; + f.HandoffAttempts = ClaimWatchCore.MaxHandoffRetries - 1; + })); + Assert.Equal(ClaimWatchAction.Retry, retry.Action); + } + } +} diff --git a/src/WebContent.Tests/ConfigResilienceTests.cs b/src/WebContent.Tests/ConfigResilienceTests.cs index 831c99c..46edfbf 100644 --- a/src/WebContent.Tests/ConfigResilienceTests.cs +++ b/src/WebContent.Tests/ConfigResilienceTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; + using WebContent; using WebContent.Client; + using Xunit; namespace WebContent.Tests @@ -256,5 +258,78 @@ public void GarbageInLegacyPropertiesStillProducesCanonicalSafeValues() Assert.Equal("False", built["HideToolbar"]); Assert.Equal(FallbackWorkspaceId.ToString(), built["InstanceId"]); } + + // #85: the Proxy tab's whole-megabytes response-cap field. Unlike zoom (silent clamp), + // an out-of-range cap REFUSES the save and names the rejected text - the server-side + // clamp (ProxyFetchLimits/ProxyResponseCapStore) is the enforcement, so the UI never + // needs to pretend a 99999 became something else. + + [Theory] + [InlineData(null, 0)] + [InlineData("", 0)] + [InlineData(" ", 0)] + [InlineData("1", 1)] + [InlineData("5", 5)] + [InlineData(" 12 ", 12)] + [InlineData("20", 20)] + public void ProxyCapAcceptsBlankOrWholeMegabytesInsideTheAdminRange(string value, int expected) + { + Assert.True(WebContentConfiguration.TryParseProxyResponseCapMegabytes(value, out var megabytes, out var rejected)); + + Assert.Equal(expected, megabytes); + Assert.Null(rejected); + } + + [Theory] + [InlineData("0")] + [InlineData("-3")] + [InlineData("21")] + [InlineData("99999")] + [InlineData("8.5")] + [InlineData("eight")] + [InlineData("8 MB")] + public void ProxyCapRefusesAnythingElseAndNamesTheRejectedText(string value) + { + Assert.False(WebContentConfiguration.TryParseProxyResponseCapMegabytes(value, out var megabytes, out var rejected)); + + Assert.Equal(0, megabytes); + Assert.Equal(value.Trim(), rejected); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(5, 0)] + [InlineData(1, 1048576)] + [InlineData(8, 8388608)] + [InlineData(20, 20971520)] + [InlineData(-4, 0)] + public void ProxyCapAdminMegabytesStoreBytesWithTheDefaultStoredAsOmitted(int megabytes, int expectedStoredBytes) + { + // 5 MB stores 0 so the optional member never appears for default-cap workspaces. + Assert.Equal(expectedStoredBytes, WebContentConfiguration.ProxyResponseCapBytes(megabytes)); + } + + [Theory] + [InlineData(0, 5)] // absent / default + [InlineData(-1, 5)] // nonsense stored value reads as the default + [InlineData(1048576, 1)] // hand-edited sub-MB value displays the minimum + [InlineData(8388608, 8)] + [InlineData(20971520, 20)] + [InlineData(40 * 1024 * 1024, 20)] // hand-edited past the ceiling displays the ceiling + public void ProxyCapStoredBytesReadBackAsDisplayMegabytes(int storedBytes, int expectedMegabytes) + { + Assert.Equal(expectedMegabytes, WebContentConfiguration.ResolveProxyResponseCapMegabytes(storedBytes)); + } + + [Fact] + public void ProxyCapAdminCeilingMatchesTheProtocolCeilingConstant() + { + // The admin field's range and the server-side clamp must move together; without + // this guard they can drift apart silently across the two assemblies' files. + Assert.Equal( + ProxyFetchLimits.MaxAllowedResponseBytes, + WebContentConfiguration.MaxProxyResponseCapMegabytes * 1024 * 1024); + Assert.Equal(ProxyFetchLimits.DefaultMaxResponseBytes, WebContentConfiguration.DefaultProxyResponseCapMegabytes * 1024 * 1024); + } } } diff --git a/src/WebContent.Tests/EventServerHeaderPolicyWiringGuardTests.cs b/src/WebContent.Tests/EventServerHeaderPolicyWiringGuardTests.cs new file mode 100644 index 0000000..aacc46e --- /dev/null +++ b/src/WebContent.Tests/EventServerHeaderPolicyWiringGuardTests.cs @@ -0,0 +1,99 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Pins the #86 Event Server header-policy wiring by regex over the REAL source files: the + /// composition root constructs and primes the store, ServeFetchAsync applies the request-leg + /// rules to query.Headers BEFORE the fetch and the response-leg rules to result.Headers + /// BEFORE framing (only for a real upstream status - a transport failure stays a pure error + /// frame), and all three unconditional block sets derive from the one canonical + /// ProxyHeaderPolicy.ForbiddenHeaderNames instead of restating the list. The runtime half of + /// the acceptance (a forbidden stored row never reaches the wire) lives in the store tests; + /// these guards keep the shape those tests describe actually wired into the plugin. + /// + public class EventServerHeaderPolicyWiringGuardTests + { + private static readonly string Source = ReadSource("ProxyFetchBackgroundPlugin.cs"); + private static readonly string CodecSource = ReadSource("ProxyForwardCodec.cs"); + private static readonly string TransportSource = ReadSource("ProxyFetchTransport.cs"); + + private static string ReadSource(string fileName) + { + var directory = AppContext.BaseDirectory; + while (directory != null && !Directory.Exists(Path.Combine(directory, "src"))) + { + directory = Path.GetDirectoryName(directory); + } + + var path = Path.Combine(directory ?? throw new InvalidOperationException("no src root found"), "src", "WebContent", fileName); + Assert.True(File.Exists(path), $"Expected to find the plugin source at {path}."); + return File.ReadAllText(path); + } + + [Fact] + public void CompositionRootConstructsAndPrimesTheHeaderPolicyStore() + { + Assert.Matches( + new Regex(@"_headerPolicies\s*=\s*new ProxyHeaderPolicyStore\(\s*ResolveWorkspaceHeaderSnapshots", RegexOptions.Singleline), + Source); + Assert.Contains("_headerPolicies.Prime();", Source); + } + + [Fact] + public void RequestRulesRewriteTheQueryHeadersBeforeTheFetcherRuns() + { + // Order is the point: the apply must sit between the query build and FetchAsync. + var apply = Source.IndexOf("ProxyHeaderPolicy.ApplyRules(query.Headers", StringComparison.Ordinal); + var fetch = Source.IndexOf("new ProxyHttpFetcher().FetchAsync(query", StringComparison.Ordinal); + Assert.True(apply > 0, "request-leg apply missing"); + Assert.True(fetch > 0, "fetch call missing"); + Assert.True(apply < fetch, "request rules must be applied BEFORE the fetch"); + } + + [Fact] + public void ResponseRulesRewriteTheResultHeadersBeforeFramingAndOnlyForARealStatus() + { + Assert.Matches( + new Regex(@"policy\.ResponseRules\.Count > 0 && result != null && result\.StatusCode > 0\s*\)\s*\{\s*ProxyHeaderPolicy\.ApplyRules\(result\.Headers, policy\.ResponseRules\)", RegexOptions.Singleline), + Source); + + var apply = Source.IndexOf("ProxyHeaderPolicy.ApplyRules(result.Headers", StringComparison.Ordinal); + var frame = Source.IndexOf("ProxyFetchFramer.Frame(payload.CorrelationId", StringComparison.Ordinal); + Assert.True(apply > 0 && frame > 0 && apply < frame, "response rules must be applied BEFORE the framer"); + } + + [Fact] + public void WorkspaceAttributionForHeaderRulesMatchesTheCapAttribution() + { + // Same fallback chain as ResolveWorkspaceResponseCapBytes: payload stamp, then the + // diagnostics session map, and a fault answers the EMPTY policy - never a refusal. + Assert.Matches( + new Regex(@"private WorkspaceHeaderPolicy ResolveWorkspaceHeaderPolicy\(ProxyFetchRequestPayload payload\)[\s\S]*?WorkspaceHeaderPolicy\.Empty;", RegexOptions.Singleline), + Source); + Assert.Contains("store.Resolve(workspaceItemId)", Source); + Assert.Contains("_diagnostics.TryGetSessionWorkspace(payload.SessionId, out mapped)", Source); + } + + [Fact] + public void EveryUnconditionalBlockSetDerivesFromTheOneCanonicalForbiddenSet() + { + // The #86 explicitness requirement: hop-by-hop stripping exists in exactly one list, + // and the three consumers derive from it instead of restating names. + Assert.Matches(new Regex(@"new HashSet\(ProxyHeaderPolicy\.ForbiddenHeaderNames, StringComparer\.OrdinalIgnoreCase\)", RegexOptions.Singleline), CodecSource); + Assert.Contains("BuildRelayBlockedHeaders", CodecSource); + Assert.Matches(new Regex(@"blocked\.Remove\(""Host""\)", RegexOptions.Singleline), CodecSource); + + Assert.Contains("BuildTransportBlockedRequestHeaders", TransportSource); + Assert.Matches(new Regex(@"blocked\.Add\(""Expect""\)", RegexOptions.Singleline), TransportSource); + + // No consumer may restate the old literal sets beside the derived ones. + Assert.DoesNotContain("\"Proxy-Authenticate\"", CodecSource); + Assert.DoesNotContain("\"Proxy-Authenticate\"", TransportSource); + } + } +} diff --git a/src/WebContent.Tests/EventServerLoadGuardTests.cs b/src/WebContent.Tests/EventServerLoadGuardTests.cs new file mode 100644 index 0000000..89b1083 --- /dev/null +++ b/src/WebContent.Tests/EventServerLoadGuardTests.cs @@ -0,0 +1,74 @@ +using System.IO; +using System.Text.RegularExpressions; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #53: the same assembly loads into the XProtect Event Server to host the proxy-fetch + /// background plugin. Loading is governed by plugin.def's ; the fetcher activates + /// only for the Service environment; Smart Client view/WebView2 code paths stay in their own + /// case so the Event Server never touches them. These source guards pin the load contract; + /// the live proof is ES-1 in docs/verification-matrix.md. + /// + public class EventServerLoadGuardTests + { + [Fact] + public void PluginDefDeclaresTheServiceEnvironmentForEventServerLoading() + { + var pluginDef = File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "plugin.def")); + + Assert.Matches(@"\s+BackgroundPlugins", definition); + Assert.Matches(@"override\s+List\s+PluginDefinedMessageIds", definition); + Assert.Matches("FetchRequestId", definition); + Assert.Matches("FetchResponseId", definition); + Assert.Matches("TunnelOpenRequestId", definition); + Assert.Matches("TunnelCloseFromTargetId", definition); + } + + [Fact] + public void SmartClientViewCodeStaysGuardedToTheSmartClientCase() + { + var definition = ReadDefinition(); + + // ViewItemPlugins population may only happen inside the SmartClient case; the Event + // Server path must never reach the WebView2 view stack. + var smartClientCase = Regex.Match( + definition, + @"case EnvironmentType\.SmartClient:(.*?)(?=case EnvironmentType\.Standalone)", + RegexOptions.Singleline); + Assert.True(smartClientCase.Success); + Assert.Matches(@"ViewItemPlugins\.Add\(new WebContentViewItemPlugin\(\)\)", smartClientCase.Groups[1].Value); + + var outsideSmartClientCase = definition.Replace(smartClientCase.Value, string.Empty); + Assert.DoesNotMatch(@"ViewItemPlugins\.Add\(new WebContentViewItemPlugin\(\)\)", outsideSmartClientCase); + } + + private static string ReadDefinition() + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "WebContentPluginDefinition.cs")); + } + } +} diff --git a/src/WebContent.Tests/EventServerServiceCtlTests.cs b/src/WebContent.Tests/EventServerServiceCtlTests.cs new file mode 100644 index 0000000..0ebb6ee --- /dev/null +++ b/src/WebContent.Tests/EventServerServiceCtlTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; + +using WebContent.ServiceCtl; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// #82: decision-table tests for the MSI's Event Server service helper (ServiceCtlCore, + /// linked from src/WebContentServiceCtl). The live MSI rows run the real SCM; these rows + /// pin the wait semantics the acceptance criteria hinge on: a stop is only successful + /// once the SCM reports Stopped (a pending transition is waited out, never assumed), a + /// declined/first-install flow never starts anything, and every failure exit is + /// distinguishable. No real service is touched here. + /// + public class EventServerServiceCtlTests + { + private const string Service = "MilestoneEventServerService"; + + // ---------------------------------------------------------------- parsing + + public static TheoryData MalformedArgumentVectors => new TheoryData + { + null, + new string[0], + new[] { "stop" }, + new[] { "stop", Service, "extra" }, + new[] { "status", Service }, + new[] { string.Empty, Service }, + new[] { "stop", " " }, + }; + + [Theory] + [MemberData(nameof(MalformedArgumentVectors))] + public void RefusalsNeverReachTheServiceControlManager(string[] args) + { + var access = new FakeService(); + var parsed = ServiceCtlCore.TryParse(args, out var command, out var name); + + Assert.False(parsed); + Assert.Equal(0, access.Calls); + } + + [Theory] + [InlineData("stop", ServiceCtlCommand.Stop)] + [InlineData("STOP", ServiceCtlCommand.Stop)] + [InlineData(" Restart ", ServiceCtlCommand.Restart)] + public void BothCommandsParseCaseAndWhitespaceInsensitively(string arg, ServiceCtlCommand expected) + { + Assert.True(ServiceCtlCore.TryParse(new[] { arg, Service }, out var command, out var name)); + Assert.Equal(expected, command); + Assert.Equal(Service, name); + } + + // ---------------------------------------------------------------- stop + + [Fact] + public void StopOnRunningServiceWaitsForTheRealStoppedTransition() + { + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.Running); + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Stop, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitDone, result); + Assert.True(access.WaitedForStopped); + // The wait, not a sleep: the core asked the SCM to drive the transition and + // then demanded the Stopped verdict before claiming success. + Assert.Equal(1, access.StopCalls); + Assert.Equal(0, access.StartCalls); // never start during a stop + } + + [Fact] + public void StopOnAlreadyStoppedServiceIsANoOp() + { + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.Stopped); + + Assert.Equal(ServiceCtlCore.ExitDone, ServiceCtlCore.Run(ServiceCtlCommand.Stop, Service, access, null)); + Assert.Equal(0, access.StopCalls); + Assert.False(access.WaitedForStopped); + } + + [Fact] + public void StopToleratesASlowStopAlreadyInFlight() + { + // Service was stopped by someone else moments ago and is still StopPending: + // the core must NOT re-issue Stop and must NOT assume - it waits for Stopped. + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.StopPending); + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Stop, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitDone, result); + Assert.Equal(0, access.StopCalls); + Assert.True(access.WaitedForStopped); + } + + [Fact] + public void StopFailureIsTheWaitTimeoutVerdictNotASilentSuccess() + { + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.Running); + access.StopReachesStopped = false; // never reaches Stopped within the wait + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Stop, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitWaitTimeout, result); + } + + [Fact] + public void MissingServiceIsABenignNoOp() + { + // The MSI conditions already gate on presence; a service removed mid-session + // must not fail the install. + var access = new FakeService(); + access.ExistsResult = false; + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Stop, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitDone, result); + Assert.Equal(0, access.StopCalls); + } + + // ---------------------------------------------------------------- restart (the finish-page choice) + + [Fact] + public void RestartStopsARunningServiceThenWaitsForRunning() + { + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.Running); + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Restart, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitDone, result); + Assert.Equal(1, access.StopCalls); + Assert.Equal(1, access.StartCalls); + Assert.True(access.WaitedForStopped); + Assert.True(access.WaitedForRunning); + } + + [Fact] + public void RestartOnStoppedServiceJustStartsIt() + { + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.Stopped); + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Restart, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitDone, result); + Assert.Equal(0, access.StopCalls); + Assert.Equal(1, access.StartCalls); + Assert.True(access.WaitedForRunning); + } + + [Fact] + public void RestartRefusesToClaimSuccessWithoutSeeingRunning() + { + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.Stopped); + access.StartReachesRunning = false; + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Restart, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitWaitTimeout, result); + } + + [Fact] + public void RestartFirstStopCanTimeoutBeforeAnyStartIsIssued() + { + // The upgrade branch's hazard: the service will not come down, so the core must + // never press Start on top of a half-stopped service. + var access = new FakeService(); + access.States.Enqueue(ServiceCtlState.Running); + access.StopReachesStopped = false; + + var result = ServiceCtlCore.Run(ServiceCtlCommand.Restart, Service, access, null); + + Assert.Equal(ServiceCtlCore.ExitWaitTimeout, result); + Assert.Equal(0, access.StartCalls); + } + + [Fact] + public void TheWaitBudgetsOutrankASleepGuess() + { + // Documented intent: the Event Server's slow stop (the psake flow allows a + // minute just for the file-unlock retry window) fits inside the core's budget. + Assert.True(ServiceCtlCore.StopWaitTimeout >= TimeSpan.FromMinutes(2)); + Assert.True(ServiceCtlCore.StartWaitTimeout >= TimeSpan.FromMinutes(1)); + } + + // ---------------------------------------------------------------- fake + + private sealed class FakeService : IServiceAccess + { + public Queue States { get; } = new Queue(); + + public bool ExistsResult = true; + + // Whether the SCM-side wait for the transition would succeed; false models a + // service that never reaches the requested state within the timeout. + public bool StopReachesStopped = true; + + public bool StartReachesRunning = true; + + public int StopCalls; + public int StartCalls; + public bool WaitedForStopped; + public bool WaitedForRunning; + + public int Calls => StopCalls + StartCalls; + + public bool Exists(string serviceName) => ExistsResult; + + public ServiceCtlState GetState(string serviceName) + { + return States.Count > 0 ? States.Dequeue() : ServiceCtlState.Stopped; + } + + public void Stop(string serviceName) + { + StopCalls++; + } + + public void Start(string serviceName) + { + StartCalls++; + } + + public bool WaitForState(string serviceName, ServiceCtlState state, TimeSpan timeout) + { + if (state == ServiceCtlState.Stopped) + { + WaitedForStopped = true; + return StopReachesStopped; + } + + WaitedForRunning = true; + return StartReachesRunning; + } + } + } +} diff --git a/src/WebContent.Tests/EventServerTriggerWiringGuardTests.cs b/src/WebContent.Tests/EventServerTriggerWiringGuardTests.cs new file mode 100644 index 0000000..c95c9a7 --- /dev/null +++ b/src/WebContent.Tests/EventServerTriggerWiringGuardTests.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Pins the Event Server rule-trigger wiring (#65) in ProxyFetchBackgroundPlugin.cs: the + /// plugin raises its match events as the system-defined Server.NewEventCommand message with a + /// full event header (the Event Server matches administrator Analytics Event definitions + /// against the header's Message field, and a header without ID/Timestamp is dropped), the + /// receiver contract is the matching NewEventIndication with detach at Close, and the payload + /// is the platform's own typed AnalyticsEvent (the documented raise route; the JSON-string + /// codec remains only as the receive-side fallback). These are text guards over the + /// real source file, not stubs, because the platform event types cannot be exercised in a + /// unit test and the live gate only proves one labeled run, not the permanent shape. + /// + public class EventServerTriggerWiringGuardTests + { + private static readonly string PluginSourcePath = FindPluginSourcePath(); + private static readonly string PluginSource = File.ReadAllText(PluginSourcePath); + + private static string FindPluginSourcePath() + { + var directory = AppContext.BaseDirectory; + while (directory != null && !Directory.Exists(Path.Combine(directory, "src"))) + { + directory = Path.GetDirectoryName(directory); + } + + var path = Path.Combine(directory ?? throw new InvalidOperationException("no src root found"), "src", "WebContent", "ProxyFetchBackgroundPlugin.cs"); + Assert.True(File.Exists(path), $"Expected to find the Event Server plugin source at {path}."); + return path; + } + + [Fact] + public void FetchMatchRaisesTheFixedEventTypeWithACompleteEventHeader() + { + // The admin's Analytics Event definition matches on the header's Message; ID and + // Timestamp must be filled or the event is dropped by the platform. + Assert.Matches( + new Regex(@"EventHeader\s*=\s*new VideoOS\.Platform\.Data\.EventHeader\s*\{[^}]*ID\s*=\s*Guid\.NewGuid\(\)[^}]*Timestamp\s*=[^}]*Message\s*=\s*ProxyTriggerPolicy\.EventType", RegexOptions.Singleline), + PluginSource); + + // One fixed event type for every row; per-row context rides in the CustomTag. + Assert.Contains("CustomTag = $\"{workspaceName} |", PluginSource); + } + + [Fact] + public void MatchEventsRideSystemNewEventCommandWithATypedAnalyticsEvent() + { + // The documented raise route for plugin-generated analytics events: EnvironmentManager. + // SendMessage with Server.NewEventCommand and a TYPED platform AnalyticsEvent (every + // host binds that type; the #54 JSON-string discipline was for plugin-namespace types, + // and raw TransmitMessage of a system message id is refused by the transmit layer - + // proven on the live Event Server, "Unable to Transmit"). + Assert.Matches( + new Regex(@"EnvironmentManager\.Instance\.SendMessage\(\s*new Message\(MessageId\.Server\.NewEventCommand\)\s*\{\s*Data\s*=\s*analyticsEvent", RegexOptions.Singleline), + PluginSource); + Assert.Contains("RelatedFQID = analyticsEvent.EventHeader.Source", PluginSource); + + // The matcher evaluates the served response per workspace, after the outcome exists. + Assert.Contains("matcher.TryMatch(workspaceItemId, query.Uri, query.Method, responseStatus, out match)", PluginSource); + Assert.Contains("RaiseTriggerEventsSafely(payload, query, result.StatusCode);", PluginSource); + } + + [Fact] + public void PluginObservesNewEventIndicationAndDetachesItOnClose() + { + Assert.Contains("new MessageIdFilter(MessageId.Server.NewEventIndication)", PluginSource); + Assert.Contains("EnvironmentManager.Instance.RegisterReceiver(", PluginSource); + Assert.Contains("_newEventIndicationRegistration = null;", PluginSource); + } + + [Fact] + public void WorkspaceAttributionAndFallbackSurviveTheFetchPath() + { + // rows come from the per-workspace configuration snapshot ... + Assert.Contains("_triggerMatcher.Prime();", PluginSource); + // ... the Smart Client listener stamps the workspace item id on every fetch ... + var listenerSource = File.ReadAllText(Path.Combine(Path.GetDirectoryName(PluginSourcePath), "Client", "LoopbackProxyListener.cs")); + Assert.Contains("query.WorkspaceItemId = _session.WorkspaceItemId;", listenerSource); + // ... and the fallback attribution reads the diagnostics session map (fail closed). + Assert.Contains("_diagnostics.TryGetSessionWorkspace(payload.SessionId, out mapped)", PluginSource); + var aggregatorSource = File.ReadAllText(Path.Combine(Path.GetDirectoryName(PluginSourcePath), "ProxyDiagnosticsAggregator.cs")); + Assert.Contains("internal bool TryGetSessionWorkspace(string sessionId, out string workspaceItemId)", aggregatorSource); + } + + [Fact] + public void TriggerStoreRefreshAndSelfTestStayGuardedAgainstSilentDrop() + { + // A silently broken row load would mean zero triggers forever with no trace: Init + // primes the store and logs what loaded. + Assert.Contains("Workspace rows refreshed: ", ReadMatcherSource()); + // The live-gate self-test fires only under an explicit operator switch. + Assert.Contains("WEBCONTENT_TRIGGER_SELFTEST", PluginSource); + Assert.Contains("trigger-selftest.flag", PluginSource); + } + + private static string ReadMatcherSource() + { + var path = Path.Combine(Path.GetDirectoryName(PluginSourcePath), "ProxyTriggerMatcher.cs"); + Assert.True(File.Exists(path), $"Expected to find the trigger matcher at {path}."); + return File.ReadAllText(path); + } + } +} diff --git a/src/WebContent.Tests/EventServerTunnelTlsWiringGuardTests.cs b/src/WebContent.Tests/EventServerTunnelTlsWiringGuardTests.cs new file mode 100644 index 0000000..7c584d6 --- /dev/null +++ b/src/WebContent.Tests/EventServerTunnelTlsWiringGuardTests.cs @@ -0,0 +1,61 @@ +using System.IO; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #61 live-shape guards: the Event Server must WIRE the wss tunnel's TLS validation + /// seam to the allowlist's per-target accept-untrusted flag. The wiring itself lives in the + /// host-loaded BackgroundPlugin (unreachable by behavioural unit tests), so these source pins + /// hold the composition: the tunnel server is constructed with the resolver (never the null + /// #60 slot), the resolver asks the allowlist for exactly this target's callback (the same + /// scoped decision the fetch path makes), and nothing in the fetcher touches the process-wide + /// ServicePointManager. The behavioural postures sit in ProxyServerAllowlistTests and the + /// wired-resolver rows of ProxyTunnelServerTests; the live rows are SC-6 in + /// docs/verification-matrix.md. + /// + public class EventServerTunnelTlsWiringGuardTests + { + private static string ReadFetcher() + { + return File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyFetchBackgroundPlugin.cs")); + } + + [Fact] + public void TheTunnelServerIsConstructedWithTheTlsResolverNotTheNullSlot() + { + var collapsed = System.Text.RegularExpressions.Regex.Replace(ReadFetcher(), @"\s+", " "); + + // The exact #61 construction: limits, log, the resolver, the traffic seam - the + // resolver slot is never null again (that was the #60 pre-wiring posture). + Assert.Contains( + "new ProxyTunnelServer( ProxyTunnelLimits.Default(), message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message), ResolveTunnelTlsValidation,", + collapsed); + } + + [Fact] + public void TheResolverAsksTheAllowlistForThisTargetsCallbackAlone() + { + var fetcher = ReadFetcher(); + + // One scoped lookup: the allowlist factory, keyed by THIS tunnel target's uri. A + // hand-written always-true callback here would be the global-bypass shape the ticket + // forbids, so the delegation is what is pinned. + Assert.Contains("RemoteCertificateValidationCallback ResolveTunnelTlsValidation(Uri target)", fetcher); + Assert.Contains("allowlist.TunnelTlsValidationCallback(target)", fetcher); + } + + [Fact] + public void NeitherTunnelNorFetchTlsDecisionTouchesTheProcessWideValidationCallback() + { + // #56 pinned this for the fetch leg; #61 must not break it for the tunnel leg: the + // plugin lives inside the Event Server process and may never weaken TLS for anyone + // else who shares it. + var fetcher = ReadFetcher(); + + Assert.DoesNotContain("ServicePointManager", fetcher); + } + } +} diff --git a/src/WebContent.Tests/InstallerEsServiceFlowGuardTests.cs b/src/WebContent.Tests/InstallerEsServiceFlowGuardTests.cs new file mode 100644 index 0000000..f633356 --- /dev/null +++ b/src/WebContent.Tests/InstallerEsServiceFlowGuardTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// #82: source pins for the MSI Event Server service flow. The compiled-MSI shape + /// (sequences, CA types, ControlEvent/Control rows) is gated by build.ps1's structural + /// guard at installer-build time; these rows pin the AUTHORING that guard cannot see: + /// the three-branch condition text, the single detection property shared by UI and + /// custom actions, the fork wiring that keeps WixUI_Minimal semantics on machines + /// without an Event Server, the SC-aware dialog copy, and the CA project's place in + /// the build (built before the wixproj, never harvested into the drop). + /// + public class InstallerEsServiceFlowGuardTests + { + private static string SetupWxs() + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "Setup", "setup.wxs")); + } + + private static string SetupUiWxs() + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "Setup", "setupui.wxs")); + } + + private static string Wxl() + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "Setup", "setup.en-us.wxl")); + } + + private static string Collapsed(string text) + { + return Regex.Replace(text, @"\s+", " "); + } + + // ---------------------------------------------------------------- detection + branches + + [Fact] + public void EsDetectionIsTheServiceControlManagerKey() + { + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + " ", + wxs); + } + + [Fact] + public void StopConditionCarriesAllThreeBranchGuards() + { + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + "", + wxs); + } + + [Fact] + public void RestartConditionCarriesTheChoiceAndThePresenceGate() + { + // #77 moved the restart one slot later (After="CreateProxyAuthority") so the + // service comes back on a provisioned CA; the presence + choice condition is + // exactly the #82 shape. + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + "", + wxs); + } + + [Fact] + public void RestartCheckboxIsCheckedByDefaultAndPublic() + { + // Public name: the UI checkbox value must flow into the execute sequence, and + // scripted installs get RESTART_EVENT_SERVER=0 as the documented decline. + var wxs = Collapsed(SetupWxs()); + Assert.Contains("", wxs); + } + + [Fact] + public void BothServiceActionsRunFromTheSameStreamedDeferredExe() + { + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + "", + wxs); + Assert.Contains( + "", + wxs); + } + + [Fact] + public void DeclinedRestartIsStatedOnTheFinishPage() + { + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + "", + wxs); + Assert.Contains( + "", + wxs); + } + + [Fact] + public void TheChoicePageCarriesTheCheckboxAndTheMessageCommunicationWarning() + { + var wxs = Collapsed(SetupUiWxs()); + Assert.Contains("Property=\"RESTART_EVENT_SERVER\"", wxs); + + // The ticket demands the dialog spell out the dispose consequence. + var wxl = Collapsed(Wxl()); + Assert.Contains("WebContentEsOptionsWarning", wxl); + Assert.Contains("disconnects the plugin connections held by every running XProtect client", wxl); + Assert.Contains("Restart Smart Client and Management Client afterwards", wxl); + } + + [Fact] + public void TheForkStillEndsTheDialogLoopLikeWixUiMinimal() + { + var wxs = Collapsed(SetupUiWxs()); + Assert.Contains( + " ", + wxs); + } + + [Fact] + public void MaintenanceFlowsArePreservedFromWixUiMinimal() + { + var wxs = Collapsed(SetupUiWxs()); + Assert.Contains("", wxs); + Assert.Contains("", wxs); + Assert.Contains("Condition=\"Installed AND PATCH\"", wxs); + } + + // ---------------------------------------------------------------- build plumbing + + [Fact] + public void ServiceCtlExeIsBuiltForTheInstallerButNeverHarvestedIntoTheDrop() + { + var root = RepoLocator.FindRepoRoot(); + var wixproj = Collapsed(File.ReadAllText(Path.Combine(root, "src", "Setup", "setup.wixproj"))); + Assert.Contains( + " false", + wixproj); + + var csproj = File.ReadAllText(Path.Combine(root, "src", "WebContentServiceCtl", "WebContentServiceCtl.csproj")); + // The helper exe must NOT share the harvested drop output path (unlike the #72 + // trust helper); it rides the MSI as a Binary stream only. + Assert.DoesNotContain("output\\WebContent", csproj); + } + + [Fact] + public void TheCoreDecisionTableIsTestedThroughTheLinkedSource() + { + var tests = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent.Tests", "WebContent.Tests.csproj")); + Assert.Contains( + "", + tests); + } + + [Fact] + public void TheBuildScriptGatesTheSequencing() + { + var build = File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "build.ps1")); + Assert.Contains("StopEventServerService", build); + Assert.Contains("RestartEventServerService", build); + Assert.Contains("WebContentServiceCtl.exe", build); + } + } +} diff --git a/src/WebContent.Tests/InstallerProxyCaGuardTests.cs b/src/WebContent.Tests/InstallerProxyCaGuardTests.cs new file mode 100644 index 0000000..d7b99c9 --- /dev/null +++ b/src/WebContent.Tests/InstallerProxyCaGuardTests.cs @@ -0,0 +1,207 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +using WebContent.ProxyCa; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #77: source pins for the install-time deployment CA. The compiled-MSI shape + /// (CustomAction type/binary, sequence slot, condition needles, checkbox Control row) + /// is gated by build.ps1's structural guard at installer-build time; these rows pin + /// the AUTHORING that guard cannot see: the condition text and ordering in setup.wxs, + /// the checkbox on the same options page, the CN staying equal to the plugin's, the + /// CA exe being built for the installer but never harvested into the drop, the core + /// being linked into this test project, and the script being retired to legacy duty. + /// + public class InstallerProxyCaGuardTests + { + private static string SetupWxs() + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "Setup", "setup.wxs")); + } + + private static string SetupUiWxs() + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "Setup", "setupui.wxs")); + } + + private static string Wxl() + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "Setup", "setup.en-us.wxl")); + } + + private static string Collapsed(string text) + { + return Regex.Replace(text, @"\s+", " "); + } + + // ---------------------------------------------------------------- action + sequence + + [Fact] + public void CaActionIsADeferredSystemActionFromTheStreamedBinary() + { + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + "", + wxs); + Assert.Contains( + "", + wxs); + } + + [Fact] + public void CaActionIsGatedOnEsPresentAndTheProxyOptInAndScheduledAfterTheFileCopy() + { + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + "", + wxs); + } + + [Fact] + public void ServiceRestartsAfterTheCaIsProvisioned() + { + // The restart action moves one slot later so the Event Server comes back on a + // provisioned CA; its own condition text (the #82 pin) is unchanged. + var wxs = Collapsed(SetupWxs()); + Assert.Contains( + "", + wxs); + } + + [Fact] + public void ProxyOptInDefaultsToOnAndIsPublicForScriptedDecline() + { + var wxs = Collapsed(SetupWxs()); + Assert.Contains("", wxs); + } + + // ---------------------------------------------------------------- UI + + [Fact] + public void OptInCheckboxLivesOnTheSameEventServerOptionsPage() + { + var wxs = Collapsed(SetupUiWxs()); + Assert.Contains( + "Type=\"CheckBox\" X=\"130\" Y=\"70\" Width=\"226\" Height=\"18\" CheckBoxValue=\"1\" Property=\"PROXY_SUPPORT\" Text=\"!(loc.WebContentEsOptionsProxy)\"", + wxs); + } + + [Fact] + public void DialogCopyNamesTheReuseSemanticsAndTheClientTrustStep() + { + // The epic's posture: the CA is created here, private key never leaves, and the + // per-machine Root trust is still an admin step spelled out on the page. + var wxl = Collapsed(Wxl()); + Assert.Contains("creates the proxy certificate; an existing certificate is reused", wxl); + Assert.Contains("private key never leaves here", wxl); + Assert.Contains("service account is granted read access", wxl); + Assert.Contains("Every Smart Client machine must still trust the certificate once", wxl); + } + + // ---------------------------------------------------------------- drift pins + + [Fact] + public void TheCoreCnMatchesThePluginsAuthorityCn() + { + // The grant step finds the CA by CN; the plugin loads it by the same name. A + // rename on one side without the other strands the grant (or the load). + Assert.Equal("XProtect WebContent Plugin Loopback Proxy", ProxyCaCore.AuthorityCommonName); + + var tls = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyLoopbackTls.cs")); + Assert.Contains( + "internal const string AuthorityCommonName = \"XProtect WebContent Plugin Loopback Proxy\";", + tls); + } + + [Fact] + public void TheGatewayDrivesThePluginsOwnLoadOrCreateByReflection() + { + // One minting recipe (ADR-0003): the helper never builds certificates itself. + var program = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContentProxyCa", "Program.cs")); + Assert.Contains("Assembly.LoadFrom(pluginDllPath)", program); + Assert.Contains("GetType(\"WebContent.Authority\")", program); + Assert.Contains("LoadOrCreateDeploymentAuthority", program); + Assert.DoesNotContain("CertificateRequest", program); + Assert.DoesNotContain("CreateSelfSigned", program); + + // The key grant locates the key file through the CAPI handle, NOT the legacy + // md5-of-container-name guess that forced the script's creation-time fallback. + Assert.Contains("CspKeyContainerInfo.UniqueKeyContainerName", program); + Assert.DoesNotContain("MD5", program); + } + + [Fact] + public void ReusedDaclIsUntouchedAndMissingGrantsAreRepaired() + { + // Idempotent grant: already-granted logs "DACL untouched" and writes nothing; + // the write path exists for legacy/broken states. + var program = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContentProxyCa", "Program.cs")); + Assert.Contains("DACL untouched", program); + Assert.Contains("AddAccessRule", program); + Assert.Contains("SetAccessControl", program); + } + + // ---------------------------------------------------------------- build plumbing + + [Fact] + public void ProxyCaExeIsBuiltForTheInstallerButNeverHarvestedIntoTheDrop() + { + var root = RepoLocator.FindRepoRoot(); + var wixproj = Collapsed(File.ReadAllText(Path.Combine(root, "src", "Setup", "setup.wixproj"))); + Assert.Contains( + " false", + wixproj); + Assert.Contains("..\\WebContentProxyCa\\bin\\$(Configuration)\\WebContentProxyCa.exe", wixproj); + Assert.Contains("ProxyCaExePath=$(ProxyCaExePath)", wixproj); + + var csproj = File.ReadAllText(Path.Combine(root, "src", "WebContentProxyCa", "WebContentProxyCa.csproj")); + // The helper exe must NOT share the harvested drop output path (unlike the #72 + // trust helper); it rides the MSI as a Binary stream only. + Assert.DoesNotContain("output\\WebContent", csproj); + // x64: it must load the x64 plugin assembly. + Assert.Contains("x64", csproj); + Assert.Contains("net472", csproj); + } + + [Fact] + public void TheCoreDecisionTableIsTestedThroughTheLinkedSource() + { + var tests = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent.Tests", "WebContent.Tests.csproj")); + Assert.Contains( + "", + tests); + } + + [Fact] + public void TheBuildScriptGatesTheProxyCaWiring() + { + var build = File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "build.ps1")); + Assert.Contains("CreateProxyAuthority", build); + Assert.Contains("WebContentProxyCa.exe", build); + Assert.Contains("PROXY_SUPPORT", build); + } + + [Fact] + public void LegacyProvisionScriptIsRetiredButStillShipsForLegacySetups() + { + // The ticket: retire it from the supported path, keep it documented for + // legacy/repair use. The README must point at the installer checkbox, not the + // script, as step 1. + var script = File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "scripts", "provision-event-server-authority.ps1")); + Assert.Contains("LEGACY", script); + Assert.Contains("#77", script); + + var readme = File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "README.md")); + Assert.Contains("Enable web content proxy support", readme); + } + } +} diff --git a/src/WebContent.Tests/ListenerClaimCoreTests.cs b/src/WebContent.Tests/ListenerClaimCoreTests.cs new file mode 100644 index 0000000..8155a10 --- /dev/null +++ b/src/WebContent.Tests/ListenerClaimCoreTests.cs @@ -0,0 +1,304 @@ +using System; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Decision-table tests for the listener-claim core (ADR-0004 point 3, #93): the schema + /// parse, and the what-to-do decisions over the injectable liveness seams. No filesystem, + /// no process, no socket is touched here - those live in . + /// + public class ListenerClaimCoreTests + { + private static readonly Guid BrowserId = Guid.Parse("2963db34-185a-4383-84c5-c9f7d780b425"); + + // ---- schema round-trip and versioning ------------------------------------------------- + + [Fact] + public void Serialize_Parse_Roundtrips_Every_Field() + { + var claim = new ListenerClaim(BrowserId, 49321, 4242, "fp-abc-123", 1762000000123L, 7); + + var read = ListenerClaim.Read(claim.Serialize()); + + Assert.Equal(ListenerClaimReadKind.Trusted, read.Kind); + Assert.Equal(BrowserId, read.Claim.BrowserId); + Assert.Equal(49321, read.Claim.Port); + Assert.Equal(4242, read.Claim.OwnerPid); + Assert.Equal("fp-abc-123", read.Claim.ConfigFingerprint); + Assert.Equal(1762000000123L, read.Claim.ClaimedAtUnixMs); + Assert.Equal(7, read.Claim.Epoch); + } + + [Fact] + public void Foreign_Schema_Version_Is_Untrusted_Never_A_Crash() + { + // A future build's version: an old reader must treat it as untrusted, never brick. + var future = "webcontent-listener-claim/3\nbrowserId=" + BrowserId.ToString("D") + + "\nport=49321\nownerPid=4242\nconfigFingerprint=fp\nclaimedAtUnixMs=1\nepoch=1\n"; + + var read = ListenerClaim.Read(future); + + Assert.Equal(ListenerClaimReadKind.Untrusted, read.Kind); + Assert.Contains("version", read.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Pre_Epoch_Schema_One_Is_An_Older_Version_Not_Trusted() + { + // #96: schema v1 had no epoch. Per the versioning contract an OLDER schema parses + // exactly like a foreign one - untrusted, treated as stale with an audit - which is + // how a build predating the handoff generation is displaced on first consult. + var v1 = "webcontent-listener-claim/1\nbrowserId=" + BrowserId.ToString("D") + + "\nport=49321\nownerPid=4242\nconfigFingerprint=fp\nclaimedAtUnixMs=1\n"; + + var read = ListenerClaim.Read(v1); + + Assert.Equal(ListenerClaimReadKind.Untrusted, read.Kind); + Assert.Contains("version", read.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Unknown_Future_Fields_In_The_Current_Version_Are_Tolerated() + { + // The version header is the compatibility gate; extra fields inside the known version + // must not brick the reader (that is what "future fields don't brick old readers" means). + var withFutureField = "webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + + "\nport=49321\nownerPid=4242\nconfigFingerprint=fp\nclaimedAtUnixMs=1\nepoch=7\nsomeFutureField=hello\n"; + + var read = ListenerClaim.Read(withFutureField); + + Assert.Equal(ListenerClaimReadKind.Trusted, read.Kind); + } + + [Fact] + public void Corrupt_Content_Is_Untrusted_With_A_Reason() + { + Assert.Equal(ListenerClaimReadKind.Untrusted, ListenerClaim.Read("this is not a claim").Kind); + Assert.Equal(ListenerClaimReadKind.Untrusted, ListenerClaim.Read(string.Empty).Kind); + + var missingField = "webcontent-listener-claim/2\nport=49321\nownerPid=4242\n"; + var read = ListenerClaim.Read(missingField); + Assert.Equal(ListenerClaimReadKind.Untrusted, read.Kind); + Assert.Contains("missing", read.Reason, StringComparison.OrdinalIgnoreCase); + + // #96: an epoch-less body inside schema 2 is a missing REQUIRED field, not a default. + var missingEpoch = "webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + + "\nport=49321\nownerPid=4242\nconfigFingerprint=fp\nclaimedAtUnixMs=1\n"; + var epochRead = ListenerClaim.Read(missingEpoch); + Assert.Equal(ListenerClaimReadKind.Untrusted, epochRead.Kind); + Assert.Contains("missing", epochRead.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Malformed_Values_Are_Untrusted() + { + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=not-a-guid\nport=1\nownerPid=1\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=1\n").Kind); + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + "\nport=99999\nownerPid=1\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=1\n").Kind); + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + "\nport=-5\nownerPid=1\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=1\n").Kind); + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + "\nport=1\nownerPid=0\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=1\n").Kind); + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + "\nport=1\nport=2\nownerPid=1\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=1\n").Kind); + + // #96: the epoch must be a positive count - zero, negatives, and garbage are all + // unusable generations (a takeover can never "reset" the boundary's history). + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + "\nport=1\nownerPid=1\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=0\n").Kind); + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + "\nport=1\nownerPid=1\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=-3\n").Kind); + Assert.Equal(ListenerClaimReadKind.Untrusted, + ListenerClaim.Read("webcontent-listener-claim/2\nbrowserId=" + BrowserId.ToString("D") + "\nport=1\nownerPid=1\nconfigFingerprint=x\nclaimedAtUnixMs=0\nepoch=seven\n").Kind); + } + + // ---- the decision table ---------------------------------------------------------------- + + private static ListenerClaim Intended(int port = 49321, string fingerprint = "fp") + { + return new ListenerClaim(BrowserId, port, 1000, fingerprint, 1762000000000L); + } + + private static bool Alive(int pid) + { + return true; + } + + private static bool PortOk(int port) + { + return true; + } + + [Fact] + public void No_Claim_At_All_Claims_New() + { + var move = ListenerClaimCore.Decide(ListenerClaimRead.NotFound(), Intended(), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimNew, move.Step); + Assert.Null(move.Audit); + } + + [Fact] + public void Live_Claim_Of_Another_Live_Process_Is_Joined() + { + var other = new ListenerClaim(BrowserId, 49321, 777, "fp", 1L); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(other), Intended(), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.Join, move.Step); + Assert.Equal(49321, move.JoinClaim.Port); + Assert.True(move.JoinFingerprintMatches); + } + + [Fact] + public void Owner_Pid_Gone_Is_Stale_By_Pid() + { + var deadOwner = new ListenerClaim(BrowserId, 49321, 777, "fp", 1L); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(deadOwner), Intended(), pid => false, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Contains("777", move.Audit); + Assert.Contains("no longer running", move.Audit); + } + + [Fact] + public void Owner_Port_Refusing_Is_Stale_By_Port_Even_When_The_Pid_Lives() + { + var deadPort = new ListenerClaim(BrowserId, 49321, 777, "fp", 1L); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(deadPort), Intended(), Alive, port => false); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Contains("49321", move.Audit); + Assert.Contains("connect probe", move.Audit); + } + + [Fact] + public void Corrupt_Or_Foreign_Claim_Is_Stale_With_An_Audit_Reason() + { + var move = ListenerClaimCore.Decide(ListenerClaim.Read("nonsense"), Intended(), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.False(string.IsNullOrEmpty(move.Audit)); + } + + [Fact] + public void Claim_Naming_A_Different_Browser_Id_Is_Never_Trusted() + { + // The slot path already keys the browser id; a file naming a DIFFERENT id is foreign + // litter and gets the stale treatment, never a join. + var foreign = new ListenerClaim(Guid.NewGuid(), 49321, 777, "fp", 1L); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(foreign), Intended(), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Contains("browser", move.Audit, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Own_Claim_With_A_Different_Port_Is_Replaced() + { + // Same pid, different port: this process's own stale leftover (recreated browser bound + // a new port) - overwrite rather than join our own dead record. + var ownStale = new ListenerClaim(BrowserId, 49321, 1000, "fp", 1L); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(ownStale), Intended(port: 50111), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Contains("own earlier claim", move.Audit); + } + + [Fact] + public void Join_Fingerprint_Mismatch_Is_Reported_Not_Silently_Accepted() + { + var other = new ListenerClaim(BrowserId, 49321, 777, "fp-other-config", 1L); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(other), Intended(fingerprint: "fp"), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.Join, move.Step); + Assert.False(move.JoinFingerprintMatches); + } + + // ---- #96: generation (epoch) rules the handoff rides ------------------------------------- + + [Fact] + public void ClaimNew_Has_No_Generated_Epoch_The_Caller_Floor_Governs() + { + var move = ListenerClaimCore.Decide(ListenerClaimRead.NotFound(), Intended(), Alive, PortOk); + + Assert.Null(move.NewEpoch); + } + + [Fact] + public void Untrusted_Slot_Has_No_Knowable_Previous_Generation() + { + var move = ListenerClaimCore.Decide(ListenerClaim.Read("garbage"), Intended(), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Null(move.NewEpoch); + } + + [Fact] + public void Taking_Over_A_Dead_Owner_Publishes_The_Next_Generation() + { + // THE #96 handoff rule: the replacement claim names the displaced claim's epoch + 1, + // so every process that joined the dead owner notices the change from its own watch. + var deadOwner = new ListenerClaim(BrowserId, 49321, 777, "fp", 1L, 4); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(deadOwner), Intended(), pid => false, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Equal(5, move.NewEpoch); + } + + [Fact] + public void Taking_Over_A_Refusing_Port_Publishes_The_Next_Generation() + { + var deadPort = new ListenerClaim(BrowserId, 49321, 777, "fp", 1L, 9); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(deadPort), Intended(), Alive, port => false); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Equal(10, move.NewEpoch); + } + + [Fact] + public void Replacing_Own_Earlier_Claim_Also_Advances_The_Generation() + { + var ownStale = new ListenerClaim(BrowserId, 49321, 1000, "fp", 1L, 3); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(ownStale), Intended(port: 50111), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Equal(4, move.NewEpoch); + } + + [Fact] + public void Cross_Browser_Id_Litter_Takeover_Keeps_The_Generation_Monotone() + { + var foreign = new ListenerClaim(Guid.NewGuid(), 49321, 777, "fp", 1L, 6); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(foreign), Intended(), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.ClaimOverStale, move.Step); + Assert.Equal(7, move.NewEpoch); + } + + [Fact] + public void Join_Hands_Back_The_Owner_Record_With_Its_Generation() + { + var live = new ListenerClaim(BrowserId, 49321, 777, "fp", 1L, 12); + + var move = ListenerClaimCore.Decide(ListenerClaimRead.Trusted(live), Intended(), Alive, PortOk); + + Assert.Equal(ListenerClaimStep.Join, move.Step); + Assert.Equal(12, move.JoinClaim.Epoch); + } + } +} diff --git a/src/WebContent.Tests/ListenerClaimStoreTests.cs b/src/WebContent.Tests/ListenerClaimStoreTests.cs new file mode 100644 index 0000000..9215f87 --- /dev/null +++ b/src/WebContent.Tests/ListenerClaimStoreTests.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// File-backed tests for the listener-claim store (ADR-0004 point 3, #93): the full + /// claim/join/stale-takeover protocol over REAL files in a temp directory, with the two + /// liveness probes injected (the process and socket checks themselves are exercised by + /// ). The race row hammers the atomic create from + /// two would-be owners concurrently and requires exactly one file to survive the winner's + /// record with the loser joining it. + /// + public class ListenerClaimStoreTests : IDisposable + { + private static readonly Guid BrowserId = Guid.Parse("2963db34-185a-4383-84c5-c9f7d780b425"); + + private readonly string _dir; + private readonly string _claimPath; + + public ListenerClaimStoreTests() + { + _dir = Path.Combine(Path.GetTempPath(), "webcontent-listener-claim-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + _claimPath = BrowserProfileKey.ComposeListenerClaimPath(_dir, "S-1-5-21-test", "vms.test-80", BrowserId); + + // The boundary dir under the slot is created lazily by the store's atomic create; the + // corrupt/foreign rows pre-seed the slot themselves, so give them the parent first. + Directory.CreateDirectory(Path.GetDirectoryName(_claimPath)); + } + + public void Dispose() + { + try + { + Directory.Delete(_dir, true); + } + catch (IOException) + { + } + } + + /// Store under test: fake pids are alive/dead per the row, ports always answer. + private static ListenerClaimStore StoreFor(int ownPid, Func pidAlive = null, Func portAlive = null) + { + return new ListenerClaimStore( + ownerPid: ownPid, + ownerProcessAlive: pidAlive ?? (pid => true), + loopbackPortConnectable: portAlive ?? (port => true)); + } + + private string ReadClaimFile() + { + return File.ReadAllText(_claimPath); + } + + [Fact] + public void No_Claim_Becomes_Owner_And_Writes_The_Claim_File() + { + var store = StoreFor(ownPid: 1000); + + var attempt = store.Claim(_claimPath, BrowserId, 49321, "fp"); + + Assert.Equal(ListenerClaimOutcome.Owner, attempt.Outcome); + Assert.Empty(attempt.AuditLines); + Assert.True(File.Exists(_claimPath)); + + var parsed = ListenerClaim.Read(ReadClaimFile()); + Assert.Equal(ListenerClaimReadKind.Trusted, parsed.Kind); + Assert.Equal(1000, parsed.Claim.OwnerPid); + Assert.Equal(49321, parsed.Claim.Port); + } + + [Fact] + public void Live_Owner_Claim_Is_Joined_By_A_Second_Process() + { + StoreFor(ownPid: 1000).Claim(_claimPath, BrowserId, 49321, "fp"); + + var joiner = StoreFor(ownPid: 2000).Claim(_claimPath, BrowserId, 50999, "fp"); + + Assert.Equal(ListenerClaimOutcome.Joined, joiner.Outcome); + Assert.Equal(49321, joiner.Claim.Port); + Assert.Empty(joiner.AuditLines); + // The owner's file is untouched - a join never rewrites the slot. + Assert.Contains("ownerPid=1000", ReadClaimFile()); + } + + [Fact] + public void Dead_Owner_Pid_Is_Replaced_Stale_By_Pid() + { + StoreFor(ownPid: 1000).Claim(_claimPath, BrowserId, 49321, "fp"); + + var taker = StoreFor(ownPid: 2000, pidAlive: pid => false) + .Claim(_claimPath, BrowserId, 50999, "fp"); + + Assert.Equal(ListenerClaimOutcome.Owner, taker.Outcome); + var audit = Assert.Single(taker.AuditLines); + Assert.Contains(_claimPath, audit); + Assert.Contains("no longer running", audit); + Assert.Contains("ownerPid=2000", ReadClaimFile()); + Assert.Contains("port=50999", ReadClaimFile()); + } + + [Fact] + public void Refusing_Owner_Port_Is_Replaced_Stale_By_Port() + { + StoreFor(ownPid: 1000).Claim(_claimPath, BrowserId, 49321, "fp"); + + var taker = StoreFor(ownPid: 2000, pidAlive: pid => true, portAlive: port => false) + .Claim(_claimPath, BrowserId, 50999, "fp"); + + Assert.Equal(ListenerClaimOutcome.Owner, taker.Outcome); + Assert.Contains("connect probe", taker.AuditLines.Single()); + Assert.Contains("ownerPid=2000", ReadClaimFile()); + } + + [Fact] + public void Corrupt_Claim_File_Is_Never_Trusted_And_Is_Replaced_With_An_Audit_Line() + { + File.WriteAllText(_claimPath, "i am not a claim at all"); + + var taker = StoreFor(ownPid: 2000).Claim(_claimPath, BrowserId, 49321, "fp"); + + Assert.Equal(ListenerClaimOutcome.Owner, taker.Outcome); + Assert.Contains(_claimPath, taker.AuditLines.Single()); + Assert.Contains("ownerPid=2000", ReadClaimFile()); + } + + [Fact] + public void Foreign_Schema_Claim_File_Is_Never_Trusted_And_Is_Replaced_With_An_Audit_Line() + { + File.WriteAllText(_claimPath, + "webcontent-listener-claim/3\nbrowserId=" + BrowserId.ToString("D") + + "\nport=49321\nownerPid=777\nconfigFingerprint=fp\nclaimedAtUnixMs=1\nepoch=1\n"); + + var taker = StoreFor(ownPid: 2000).Claim(_claimPath, BrowserId, 49321, "fp"); + + Assert.Equal(ListenerClaimOutcome.Owner, taker.Outcome); + Assert.Contains("version", taker.AuditLines.Single(), StringComparison.OrdinalIgnoreCase); + } + + // ---- #96: the generation the handoff rides, end to end over real files --------------------- + + [Fact] + public void First_Claim_Of_A_Boundary_Is_Generation_One() + { + StoreFor(ownPid: 1000).Claim(_claimPath, BrowserId, 49321, "fp"); + + var parsed = ListenerClaim.Read(ReadClaimFile()); + Assert.Equal(1, parsed.Claim.Epoch); + } + + [Fact] + public void Owner_Death_Takeover_Publishes_Previous_Epoch_Plus_One_On_Disk() + { + // The #96 handoff witness on real files: the displaced generation is one lower than + // what the survivor writes, and the survivor's record hands the new epoch back. + StoreFor(ownPid: 1000).Claim(_claimPath, BrowserId, 49321, "fp"); + var displaced = ListenerClaim.Read(ReadClaimFile()).Claim.Epoch; + + var survivor = StoreFor(ownPid: 2000, pidAlive: pid => pid != 1000) + .Claim(_claimPath, BrowserId, 50999, "fp", floorEpoch: displaced); + + Assert.Equal(ListenerClaimOutcome.Owner, survivor.Outcome); + Assert.Equal(displaced + 1, survivor.Claim.Epoch); + Assert.Contains("epoch=" + (displaced + 1), ReadClaimFile()); + } + + [Fact] + public void A_Known_Floor_Keeps_The_Generation_Monotone_Across_A_Corrupt_Handoff() + { + // Corrupt content erases the on-disk epoch, but a watcher that REMEMBERS generation 5 + // re-claims with a bumped floor, and the floor is what lands on disk - a joiner that + // saw 5 always notices the 6. + File.WriteAllText(_claimPath, "scrambled slot"); + + var taker = StoreFor(ownPid: 2000).Claim(_claimPath, BrowserId, 50999, "fp", floorEpoch: 6); + + Assert.Equal(ListenerClaimOutcome.Owner, taker.Outcome); + Assert.Equal(6, ListenerClaim.Read(ReadClaimFile()).Claim.Epoch); + } + + [Fact] + public void Floor_Never_Regresses_A_Known_Higher_On_Disk_Epoch() + { + // A stale floor (a watcher that lost track) must not reset the boundary's history: + // the parsed epoch + 1 outranks it. + File.WriteAllText(_claimPath, + new ListenerClaim(BrowserId, 49321, 777, "fp", 1, 9).Serialize()); + + var taker = StoreFor(ownPid: 2000, pidAlive: pid => pid != 777) + .Claim(_claimPath, BrowserId, 50999, "fp", floorEpoch: 1); + + Assert.Equal(ListenerClaimOutcome.Owner, taker.Outcome); + Assert.Equal(10, ListenerClaim.Read(ReadClaimFile()).Claim.Epoch); + } + + [Fact] + public void Idempotent_Self_Reclaim_Reports_The_On_Disk_Generation_Not_A_New_One() + { + var store = StoreFor(ownPid: 1000); + store.Claim(_claimPath, BrowserId, 49321, "fp"); + + var again = store.Claim(_claimPath, BrowserId, 49321, "fp"); + + Assert.Equal(ListenerClaimOutcome.Owner, again.Outcome); + Assert.Equal(1, again.Claim.Epoch); + Assert.Equal(1, ListenerClaim.Read(ReadClaimFile()).Claim.Epoch); + } + + [Fact] + public void Own_Idempotent_Reclaim_Comes_Back_As_Owner_Not_Join() + { + var store = StoreFor(ownPid: 1000); + store.Claim(_claimPath, BrowserId, 49321, "fp"); + + var again = store.Claim(_claimPath, BrowserId, 49321, "fp"); + + Assert.Equal(ListenerClaimOutcome.Owner, again.Outcome); + } + + [Fact] + public async Task Two_Concurrent_Claimers_Resolve_To_Exactly_One_Owner_With_The_Loser_Joining() + { + // The acceptance race: two processes hit the empty slot at (as close to) the same + // instant as the filesystem allows. Exactly one atomic create wins; the loser + // re-reads and joins the winner's port. One file survives, naming one owner. + var start = new ManualResetEventSlim(false); + var a = Task.Run(() => + { + start.Wait(); + return StoreFor(ownPid: 1000).Claim(_claimPath, BrowserId, 49321, "fp"); + }); + var b = Task.Run(() => + { + start.Wait(); + return StoreFor(ownPid: 2000).Claim(_claimPath, BrowserId, 50999, "fp"); + }); + + start.Set(); + var attemptA = await a; + var attemptB = await b; + + var outcomes = new[] { attemptA.Outcome, attemptB.Outcome }; + Assert.Equal(1, outcomes.Count(o => o == ListenerClaimOutcome.Owner)); + Assert.Equal(1, outcomes.Count(o => o == ListenerClaimOutcome.Joined)); + + var winner = attemptA.Outcome == ListenerClaimOutcome.Owner ? attemptA : attemptB; + var loser = attemptA.Outcome == ListenerClaimOutcome.Owner ? attemptB : attemptA; + Assert.Equal(winner.Claim.Port, loser.Claim.Port); + + // Exactly ONE claim file under the slot's directory, and it parses to the winner. + var claimFiles = Directory.GetFiles(_dir, "*" + BrowserProfileKey.ListenerClaimExtension, SearchOption.AllDirectories); + Assert.Single(claimFiles); + var survivor = ListenerClaim.Read(File.ReadAllText(claimFiles[0])); + Assert.Equal(ListenerClaimReadKind.Trusted, survivor.Kind); + Assert.Equal(winner.Claim.OwnerPid, survivor.Claim.OwnerPid); + } + + [Fact] + public async Task Concurrent_Attempts_On_A_Live_Owner_All_Join_Without_Touching_The_Slot() + { + StoreFor(ownPid: 1000).Claim(_claimPath, BrowserId, 49321, "fp"); + var ownerBytes = File.ReadAllBytes(_claimPath); + + var joiners = Enumerable.Range(0, 4).Select(i => Task.Run(() => + StoreFor(ownPid: 2000 + i).Claim(_claimPath, BrowserId, 50999 + i, "fp"))).ToArray(); + var results = await Task.WhenAll(joiners); + + Assert.All(results, result => + { + Assert.Equal(ListenerClaimOutcome.Joined, result.Outcome); + Assert.Equal(49321, result.Claim.Port); + }); + Assert.Equal(ownerBytes, File.ReadAllBytes(_claimPath)); + } + + [Fact] + public void Path_Composition_Puts_The_Claim_Beside_The_Profile_Folder() + { + var profile = BrowserProfileKey.ComposeProxiedProfilePath(_dir, "S-1-5-21-test", "vms.test-80", BrowserId); + var claim = BrowserProfileKey.ComposeListenerClaimPath(_dir, "S-1-5-21-test", "vms.test-80", BrowserId); + + // Sibling in the server- dir: same parent, one extension apart, never inside the profile. + Assert.Equal(Path.GetDirectoryName(profile), Path.GetDirectoryName(claim)); + Assert.Equal(Path.GetFileName(profile) + BrowserProfileKey.ListenerClaimExtension, Path.GetFileName(claim)); + } + + [Fact] + public void Production_Pid_Probe_Sees_This_Process_And_Not_A_Ghost() + { + Assert.True(ListenerClaimStore.ProcessExists(System.Diagnostics.Process.GetCurrentProcess().Id)); + Assert.False(ListenerClaimStore.ProcessExists(int.MaxValue)); + } + + [Fact] + public void Production_Port_Probe_Distinguishes_A_Real_Listener_From_A_Dead_Port() + { + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + Assert.True(ListenerClaimStore.LoopbackPortConnectable(port)); + + // Nothing listens on 1: a connect is refused, which IS the stale-by-port signal. + Assert.False(ListenerClaimStore.LoopbackPortConnectable(1)); + Assert.False(ListenerClaimStore.LoopbackPortConnectable(-5)); + } + finally + { + listener.Stop(); + } + } + } +} diff --git a/src/WebContent.Tests/ListenerJoinWiringGuardTests.cs b/src/WebContent.Tests/ListenerJoinWiringGuardTests.cs new file mode 100644 index 0000000..b53ec2b --- /dev/null +++ b/src/WebContent.Tests/ListenerJoinWiringGuardTests.cs @@ -0,0 +1,130 @@ +using System.IO; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #95 wiring guards: the claim consult, the join/own branch, and the release side all + /// live in the WPF/socket-coupled classes the unit harness cannot host, so these source pins + /// hold the composition the SC-14 live probe drives - the proxied browser consults the claim + /// BEFORE binding anything, a JOINED browser binds no listener and carries the owner's port, + /// an OWNER binds exactly the claimed port, every claim write is paired with a release path, + /// and the #88 view gate survives a listener-less joined browser. The consult/claim/join + /// decisions themselves sit in ProxyListenerLeaseTests; the live rows sit in + /// docs/verification-matrix.md SC-14. + /// + public class ListenerJoinWiringGuardTests + { + private static string ReadClient(string file) + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "Client", file)); + } + + private static string Collapse(string source) + { + return System.Text.RegularExpressions.Regex.Replace(source, @"\s+", " "); + } + + [Fact] + public void EveryProxiedCreationAndReenableConsultsTheClaimBeforeAnythingBinds() + { + var collapsed = Collapse(ReadClient("BrowserManager.cs")); + + // One lease provider, consulted from every claim-protocol entry point - creation, the + // mid-session proxy re-enable, and both #96 healing routes (re-publish, handoff + // rewire) - each keyed to the boundary claim path. + Assert.Contains("private static readonly ProxyListenerLeaseProvider _leases = new ProxyListenerLeaseProvider();", collapsed); + Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(collapsed, @"BrowserProfileIdentity\.GetListenerClaimPath\(").Count); + Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(collapsed, @"_leases\.Acquire\(").Count); + Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(collapsed, @"ProxyBrowserArguments\.PortIndependentFingerprint\(\)").Count); + + // Every claim audit the store handed back is journalled verbatim, at every entry point. + Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(collapsed, @"foreach \(var audit in lease\.AuditLines\)").Count); + } + + [Fact] + public void AJoinedBrowserBindsNoListenerAndCarriesTheOwnersPortVerbatim() + { + var collapsed = Collapse(ReadClient("BrowserManager.cs")); + + // Every entry point and both #96 healing routes branch on the lease role, and the JOIN + // branch composes the identical argument from the owner's port with NO listener + // construction and no claim path recorded (the joiner owns nothing). + Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(collapsed, @"if \(lease\.Role == ProxyListenerLeaseRole\.Joined\)").Count); + Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(collapsed, @"ProxyBrowserArguments\.For\(lease\.Port\)").Count); + Assert.Contains("joined the boundary owner's loopback listener", collapsed); + + // The owner route always runs through the lease-aware starter, never a bare Start(). + Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(collapsed, @"StartListenerOnLeasedPort\(listener, lease, claimPath, ").Count); + } + + [Fact] + public void TheOwnerBindsExactlyTheClaimedPortAndReleaseIsPairedWithEveryClaimWrite() + { + var manager = Collapse(ReadClient("BrowserManager.cs")); + var listener = Collapse(ReadClient("LoopbackProxyListener.cs")); + + // Owner: bind the exact published port; a refused bind releases and falls back - + // a claim never outlives a port nobody serves. + Assert.Contains("listener.Start(lease.Port);", manager); + Assert.Contains("_leases.Release(claimPath);", manager); + Assert.Contains("listener.Start();", manager); // the pre-#95 ephemeral fallback + + // The listener honors the port it is handed (the ONLY change to it). + Assert.Contains("internal void Start(int port = 0)", listener); + Assert.Contains("_listener = new TcpListener(IPAddress.Loopback, port);", listener); + + // Release paths: browser creation failure, proxy disable, plugin teardown, and browser + // Dispose all reach a release, and both #96 handoff posture changes pair every claim + // drop with a release; the shared rule only deletes OWN-pid slots. + Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(manager, @"_leases\.Release\(").Count); + Assert.Equal(4, System.Text.RegularExpressions.Regex.Matches(manager, @"ReleaseOwnClaim\(browser\);").Count); + var instance = Collapse(ReadClient("BrowserInstance.cs")); + Assert.Contains("ProxyListenerLeaseProvider.ReleaseOwnClaimFile(claimPath);", instance); + } + + [Fact] + public void TheJoinedBrowserKeepsTheViewGateAndItsTargetsTrackConfigEdits() + { + var instance = Collapse(ReadClient("BrowserInstance.cs")); + var manager = Collapse(ReadClient("BrowserManager.cs")); + + // The #88 gate registers for a listener-less joined browser too (ProxyViewTargets + // stand in), and the handler resolves the live listener set when one exists. + Assert.Contains("if (ProxyListener != null || ProxyViewTargets != null)", instance); + Assert.Contains("listener != null ? listener.CanonicalTargets : ProxyViewTargets", instance); + Assert.Contains("listener != null ? listener.VmsFloorHosts : ProxyViewFloorHosts", instance); + + // Target edits keep the joined browser's gate copies current (already-proxied branch). + Assert.Contains("browser.ProxyViewTargets = proxyProfile.CanonicalTargets;", manager); + Assert.Contains("browser.ProxyViewFloorHosts = proxyProfile.VmsFloorHosts;", manager); + } + + [Fact] + public void TheClaimPathIsDeriveOnlyFromTheSameBoundaryLegsAsTheProfile() + { + var identity = Collapse(ReadClient("BrowserProfileIdentity.cs")); + + // One leg-resolution call each, identical inputs to GetProxiedUserDataFolder - a + // claim can never key a different boundary than the profile it sits beside. + Assert.Contains("BrowserProfileKey.ComposeListenerClaimPath(root, GetCurrentUserKey(), GetCurrentServerKey(), browserId)", identity); + } + + [Fact] + public void TheLeaseClassLandsInBothCompileLists() + { + var root = RepoLocator.FindRepoRoot(); + + // Plugin side: SDK-style default glob - the file must simply exist in the plugin tree. + Assert.True(File.Exists(Path.Combine(root, "src", "WebContent", "Client", "ProxyListenerLease.cs"))); + + // Test side: the explicit Compile link (AGENTS rule - land it in BOTH or one side + // silently misses the class). + var testCsproj = File.ReadAllText(Path.Combine(root, "src", "WebContent.Tests", "WebContent.Tests.csproj")); + Assert.Contains( + "", + testCsproj); + } + } +} diff --git a/src/WebContent.Tests/ManagedViewPropertiesPaneGuardTests.cs b/src/WebContent.Tests/ManagedViewPropertiesPaneGuardTests.cs new file mode 100644 index 0000000..b0f48e5 --- /dev/null +++ b/src/WebContent.Tests/ManagedViewPropertiesPaneGuardTests.cs @@ -0,0 +1,103 @@ +using System.IO; +using System.Linq; +using System.Xml.Linq; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #71: the Smart Client properties pane of a view item inside a managed workspace + /// renders the security-relevant field (the home/target URL) disabled with a "Managed by + /// Administrator" indicator, while every cosmetic/navigation control stays enabled and saves + /// per instance. These source guards pin the XAML shape the live SC probe drives: the locked + /// TextBox bindings and stable AutomationIds, the two admin notices wired to the managed + /// flag, and the guard that no cosmetic control declares an IsEnabled binding - a cosmetic + /// control that learned to disable itself would silently break "unlocked controls still + /// save per instance". + /// + public class ManagedViewPropertiesPaneGuardTests + { + private static readonly XNamespace Presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml"; + + private static XDocument PropertiesPaneXaml() + { + var path = Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "Client", "WebContentPropertiesUserControl.xaml"); + return XDocument.Load(path); + } + + private static string Name(XElement element) => element.Attribute(Xaml + "Name")?.Value; + + [Fact] + public void HomeAddressFieldIsDrivenByTheEditableFlagAndCarriesTheProbeAutomationId() + { + var home = PropertiesPaneXaml() + .Descendants(Presentation + "TextBox") + .Single(e => Name(e) == "txtHomeAddress"); + + Assert.Equal("{Binding IsHomeAddressEditable}", home.Attribute("IsEnabled")?.Value); + Assert.Equal("{Binding HomeAddressToolTip}", home.Attribute("ToolTip")?.Value); + // The two-way value binding must stay: standalone items keep the editable field. + Assert.Contains("HomeAddress", home.Attribute("Text")?.Value ?? string.Empty); + } + + [Fact] + public void BothAdminNoticesRenderOnlyForManagedItems() + { + var xaml = PropertiesPaneXaml(); + foreach (var noticeId in new[] { "managedHomeNotice", "managedProxyNotice" }) + { + var notice = xaml.Descendants(Presentation + "TextBlock").Single(e => Name(e) == noticeId); + Assert.Equal("{Binding ManagedByAdministratorLabel, Mode=OneTime}", notice.Attribute("Text")?.Value); + Assert.Equal("{Binding IsManagedWorkspace, Converter={StaticResource BoolToVisibility}}", notice.Attribute("Visibility")?.Value); + } + + Assert.NotNull(xaml.Descendants(Presentation + "BooleanToVisibilityConverter") + .Single(c => c.Attribute(Xaml + "Key")?.Value == "BoolToVisibility")); + } + + [Fact] + public void NoCosmeticControlDeclaresAnIsEnabledBinding() + { + // Only the locked home-address TextBox may condition its own enabled state. Every + // checkbox, the zoom slider, and the zoom text box stay unconditionally enabled so + // per-instance choices keep saving in a managed workspace. + var xaml = PropertiesPaneXaml(); + + foreach (var checkbox in xaml.Descendants(Presentation + "CheckBox")) + { + Assert.Null(checkbox.Attribute("IsEnabled")); + } + + foreach (var other in xaml.Descendants(Presentation + "Slider") + .Concat(xaml.Descendants(Presentation + "Button")) + .Concat(xaml.Descendants(Presentation + "TextBox").Where(e => Name(e) != "txtHomeAddress"))) + { + Assert.Null(other.Attribute("IsEnabled")); + } + } + + [Fact] + public void EveryProbeTargetCarriesAStableAutomationId() + { + var names = PropertiesPaneXaml() + .Descendants() + .Select(Name) + .Where(n => n != null) + .ToHashSet(); + + foreach (var required in new[] + { + "txtHomeAddress", "managedHomeNotice", "managedProxyNotice", + "chkRememberLastAddress", "chkHideToolbar", "chkHideAddress", + "chkClearBrowsingDataOnExit", "chkMuteAudio", "chkAllowAudioNotifications", + "chkAllowBackgroundBrowser", "chkIsolateBrowserInstances", "chkEnableDevTools", + "txtZoomLevel", "zoomSlider", "btnClearBrowsingData", + }) + { + Assert.Contains(required, names); + } + } + } +} diff --git a/src/WebContent.Tests/NewWorkspacePlaceholderTests.cs b/src/WebContent.Tests/NewWorkspacePlaceholderTests.cs index 6aadd2d..9a88cf7 100644 --- a/src/WebContent.Tests/NewWorkspacePlaceholderTests.cs +++ b/src/WebContent.Tests/NewWorkspacePlaceholderTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; + using WebContent; + using Xunit; namespace WebContent.Tests diff --git a/src/WebContent.Tests/PluginLicenseHygieneTests.cs b/src/WebContent.Tests/PluginLicenseHygieneTests.cs index c71ce16..8cb8227 100644 --- a/src/WebContent.Tests/PluginLicenseHygieneTests.cs +++ b/src/WebContent.Tests/PluginLicenseHygieneTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; + using Xunit; namespace WebContent.Tests diff --git a/src/WebContent.Tests/ProfileSweepCoreTests.cs b/src/WebContent.Tests/ProfileSweepCoreTests.cs new file mode 100644 index 0000000..562b1a7 --- /dev/null +++ b/src/WebContent.Tests/ProfileSweepCoreTests.cs @@ -0,0 +1,252 @@ +using System; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// The pure decision table of the #94 disk sweep (acceptance rows: fresh folder keep, stale + /// unreaped reap, locked-folder skip, dead-owner claim reap, live-owner claim keep) with NO + /// filesystem and NO process: the IO seam () supplies the + /// facts, this class pins what they mean. Claim staleness rows deliberately exercise the + /// SHARED #93 seam (via ProfileSweepCore.DecideClaimFile -> ListenerClaimCore.Decide), so + /// sweep and claim protocol cannot drift apart. + /// + public class ProfileSweepCoreTests + { + private static readonly Guid BrowserId = Guid.Parse("2963db34-185a-4383-84c5-c9f7d780b425"); + private static readonly Guid OtherBrowserId = Guid.Parse("9f55098a-7d87-4d1e-842e-f1d9bc41e633"); + + private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 11, 3, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan Retention = TimeSpan.FromDays(30); + + private static DateTimeOffset Days(int days) => Now.AddDays(-days); + + // ---- shape recognition ---- + + [Fact] + public void Shape_Predicates_Recognize_Only_The_Plant_Own_Proxied_Classes() + { + Assert.True(ProfileSweepCore.IsProxiedFolderName("proxied-" + BrowserId.ToString("D"))); + Assert.True(ProfileSweepCore.IsProxiedFolderName("PROXIED-" + BrowserId.ToString("D"))); + Assert.False(ProfileSweepCore.IsProxiedFolderName("server-vms.test-80")); + Assert.False(ProfileSweepCore.IsProxiedFolderName("EBWebView")); + Assert.False(ProfileSweepCore.IsProxiedFolderName(null)); + + Assert.True(ProfileSweepCore.IsServerFolderName("server-vms.test-80")); + Assert.False(ProfileSweepCore.IsServerFolderName("proxied-" + BrowserId.ToString("D"))); + Assert.False(ProfileSweepCore.IsServerFolderName(null)); + } + + [Fact] + public void Claim_Name_Shapes_Separate_The_Slot_From_Its_Atomic_Create_Litter() + { + var claim = "proxied-" + BrowserId.ToString("D") + BrowserProfileKey.ListenerClaimExtension; + var litter = claim + ".tmp-1234-deadbeef"; + + Assert.True(ProfileSweepCore.IsListenerClaimName(claim)); + Assert.False(ProfileSweepCore.IsListenerClaimName(litter)); // litter is its own class + Assert.False(ProfileSweepCore.IsListenerClaimName("somethingelse.claim")); + Assert.False(ProfileSweepCore.IsListenerClaimName("proxied-x.claimx")); + + Assert.True(ProfileSweepCore.IsListenerClaimTempName(litter)); + Assert.False(ProfileSweepCore.IsListenerClaimTempName(claim)); + } + + [Fact] + public void Browser_Id_Is_Parsed_From_Both_Folder_And_Claim_Names_And_Nothing_Else() + { + Assert.Equal(BrowserId, ProfileSweepCore.TryParseProxiedLeafBrowserId("proxied-" + BrowserId.ToString("D"))); + Assert.Equal(BrowserId, ProfileSweepCore.TryParseProxiedLeafBrowserId("proxied-" + BrowserId.ToString("D") + ".claim")); + Assert.Equal(Guid.Empty, ProfileSweepCore.TryParseProxiedLeafBrowserId("proxied-not-a-guid")); + Assert.Equal(Guid.Empty, ProfileSweepCore.TryParseProxiedLeafBrowserId("server-vms.test-80")); + Assert.Equal(Guid.Empty, ProfileSweepCore.TryParseProxiedLeafBrowserId(null)); + } + + // ---- folder table: fresh keep, stale reap, locked skip ---- + + [Fact] + public void Fresh_Folder_Is_Quietly_Kept() + { + var move = ProfileSweepCore.DecideFolder(Days(1), false, Now, Retention); + + Assert.Equal(ProxiedFolderStep.Keep, move.Step); + Assert.Null(move.Audit); + } + + [Fact] + public void Stale_Unlocked_Folder_Is_Reaped_With_Its_Age_Named() + { + var move = ProfileSweepCore.DecideFolder(Days(31), false, Now, Retention); + + Assert.Equal(ProxiedFolderStep.Reap, move.Step); + Assert.Contains("31 day(s)", move.Audit); + Assert.Contains("30 day(s)", move.Audit); + } + + [Fact] + public void Locked_Folder_Is_Skipped_Whatever_Its_Age_Never_Forced() + { + var stale = ProfileSweepCore.DecideFolder(Days(90), true, Now, Retention); + var fresh = ProfileSweepCore.DecideFolder(Days(1), true, Now, Retention); + + Assert.Equal(ProxiedFolderStep.SkipLocked, stale.Step); + Assert.Equal(ProxiedFolderStep.SkipLocked, fresh.Step); + Assert.Contains("never forced", stale.Audit); + } + + [Fact] + public void Unknown_Activity_Is_Never_Evidence_Of_Staleness() + { + var move = ProfileSweepCore.DecideFolder(null, false, Now, Retention); + + Assert.Equal(ProxiedFolderStep.Keep, move.Step); + Assert.Contains("could not be established", move.Audit); + } + + [Fact] + public void Activity_Exactly_On_The_Retention_Boundary_Is_Stale() + { + var move = ProfileSweepCore.DecideFolder(Now - Retention, false, Now, Retention); + + Assert.Equal(ProxiedFolderStep.Reap, move.Step); + } + + [Fact] + public void A_Future_Timestamp_Is_Kept_Not_Crashed_On() + { + var move = ProfileSweepCore.DecideFolder(Now.AddDays(5), false, Now, Retention); + + Assert.Equal(ProxiedFolderStep.Keep, move.Step); + } + + // ---- claim table: dead-owner reap, live-owner keep (through the shared #93 seam) ---- + + private static ListenerClaimRead TrustedClaim(int ownerPid, int port, Guid browserId = default) + { + var claim = new ListenerClaim(browserId == default ? BrowserId : browserId, port, ownerPid, "fp", 1); + return ListenerClaim.Read(claim.Serialize()); + } + + private static bool AlwaysAlive(int pid) => true; + + private static bool AlwaysConnectable(int port) => true; + + [Fact] + public void Live_Owner_Claim_Is_Kept() + { + var move = ProfileSweepCore.DecideClaimFile( + TrustedClaim(1000, 49321), BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + AlwaysAlive, AlwaysConnectable); + + Assert.Equal(ClaimFileStep.Keep, move.Step); + Assert.Null(move.Audit); + } + + [Fact] + public void Dead_Owner_Claim_Is_Reaped_Naming_The_Shared_Staleness_Reason() + { + var move = ProfileSweepCore.DecideClaimFile( + TrustedClaim(1000, 49321), BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + pid => false, AlwaysConnectable); + + Assert.Equal(ClaimFileStep.Reap, move.Step); + Assert.Contains("no longer running", move.Audit); + } + + [Fact] + public void Refusing_Owner_Port_Claim_Is_Reaped_Even_With_A_Live_Pid() + { + var move = ProfileSweepCore.DecideClaimFile( + TrustedClaim(1000, 49321), BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + AlwaysAlive, port => false); + + Assert.Equal(ClaimFileStep.Reap, move.Step); + Assert.Contains("connect probe", move.Audit); + } + + [Fact] + public void Corrupt_Claim_Is_Never_Trusted_And_Is_Reaped() + { + var move = ProfileSweepCore.DecideClaimFile( + ListenerClaim.Read("i am not a claim at all"), BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + AlwaysAlive, AlwaysConnectable); + + Assert.Equal(ClaimFileStep.Reap, move.Step); + Assert.Contains("schema header", move.Audit); + } + + [Fact] + public void Foreign_Schema_Claim_Is_Reaped_As_Untrusted() + { + var move = ProfileSweepCore.DecideClaimFile( + ListenerClaim.Read("webcontent-listener-claim/9\nbrowserId=" + BrowserId.ToString("D") + "\n"), + BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + AlwaysAlive, AlwaysConnectable); + + Assert.Equal(ClaimFileStep.Reap, move.Step); + Assert.Contains("version", move.Audit); + } + + [Fact] + public void Claim_Naming_A_Different_Browser_Than_Its_Name_Is_Reaped_Even_When_Live() + { + var move = ProfileSweepCore.DecideClaimFile( + TrustedClaim(1000, 49321, OtherBrowserId), BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + AlwaysAlive, AlwaysConnectable); + + Assert.Equal(ClaimFileStep.Reap, move.Step); + Assert.Contains("names browser", move.Audit); + } + + [Fact] + public void This_Process_Own_Claim_Is_Never_Reaped_Even_If_The_Probes_Call_It_Dead() + { + var move = ProfileSweepCore.DecideClaimFile( + TrustedClaim(4242, 49321), BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + pid => false, port => false); + + Assert.Equal(ClaimFileStep.Keep, move.Step); + Assert.Contains("own live claim", move.Audit); + } + + [Fact] + public void Claim_Whose_Sibling_Profile_Folder_Is_Gone_Is_Reaped_Even_If_It_Presents_Live() + { + var move = ProfileSweepCore.DecideClaimFile( + TrustedClaim(1000, 49321), BrowserId, selfPid: 4242, siblingProfileFolderExists: false, + AlwaysAlive, AlwaysConnectable); + + Assert.Equal(ClaimFileStep.Reap, move.Step); + Assert.Contains("sibling profile folder is gone", move.Audit); + } + + [Fact] + public void Claim_That_Vanished_Between_Enumeration_And_Read_Is_Quietly_Dropped() + { + var move = ProfileSweepCore.DecideClaimFile( + ListenerClaimRead.NotFound(), BrowserId, selfPid: 4242, siblingProfileFolderExists: true, + AlwaysAlive, AlwaysConnectable); + + Assert.Equal(ClaimFileStep.Keep, move.Step); + Assert.Contains("vanished", move.Audit); + } + + // ---- temp-litter table ---- + + [Fact] + public void Old_Claim_Create_Litter_Is_Reaped_Young_Litter_Is_Kept() + { + var litter = TimeSpan.FromHours(1); + + var old = ProfileSweepCore.DecideClaimTemp(Days(0).AddHours(-3), Now, litter); + var young = ProfileSweepCore.DecideClaimTemp(Now.AddMinutes(-5), Now, litter); + + Assert.Equal(ClaimFileStep.Reap, old.Step); + Assert.Contains("temp litter", old.Audit); + Assert.Equal(ClaimFileStep.Keep, young.Step); + } + } +} diff --git a/src/WebContent.Tests/ProfileSweepDiskTests.cs b/src/WebContent.Tests/ProfileSweepDiskTests.cs new file mode 100644 index 0000000..ef8e155 --- /dev/null +++ b/src/WebContent.Tests/ProfileSweepDiskTests.cs @@ -0,0 +1,315 @@ +using System; +using System.IO; +using System.Linq; + +using WebContent.Client; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// The IO seam over REAL files in a temp directory: the walk only + /// touches the plugin's proxied shape classes at both the boundary (server-<leg>) and the + /// pre-#92 old shape, deletes what the pure decision table says to reap (each deletion + /// journalled exactly once), skips what it must (locked folders, live claims, fresh + /// anything, the non-proxied EBWebView tree), and survives hostile content without throwing. + /// All liveness is injected: no processes, no sockets, no Chromium here. + /// + public class ProfileSweepDiskTests : IDisposable + { + private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 11, 3, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan Retention = TimeSpan.FromDays(30); + private static readonly TimeSpan LitterRetention = TimeSpan.FromHours(1); + private const int SelfPid = 4242; + + private static readonly Guid FreshId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + private static readonly Guid StaleId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"); + private static readonly Guid LockedId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000003"); + private static readonly Guid OldShapeId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000004"); + private static readonly Guid LiveClaimId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000005"); + private static readonly Guid DeadClaimId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000006"); + private static readonly Guid ClaimWithoutSiblingId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000007"); + private static readonly Guid CorruptClaimId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000008"); + private static readonly Guid StaleFolderWithClaimId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000009"); + private static readonly Guid OtherBrowserId = Guid.Parse("aaaaaaaa-0000-0000-0000-00000000000a"); + + private readonly string _root; + private readonly string _userDir; + private readonly string _serverDir; + private string _lockedFolder; + + public ProfileSweepDiskTests() + { + _root = Path.Combine(Path.GetTempPath(), "webcontent-profile-sweep-" + Guid.NewGuid().ToString("N")); + _userDir = Path.Combine(_root, "S-1-5-21-test"); + _serverDir = Path.Combine(_userDir, "server-vms.test-80"); + Directory.CreateDirectory(_serverDir); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) + { + } + } + + // ---- fixtures ---- + + private string CreateProxiedProfile(string parent, Guid id, DateTime markerUtc) + { + var folder = Path.Combine(parent, "proxied-" + id.ToString("D")); + var web = Path.Combine(folder, "EBWebView"); + var profile = Path.Combine(web, "Default"); + Directory.CreateDirectory(profile); + var file = Path.Combine(web, "Local State"); + File.WriteAllText(file, "chatter"); + + // Stamp leaf-first: a parent stamped afterwards keeps the stamp (the walk checks + // every entry's timestamp, so all of them must read as this marker). + File.SetLastWriteTimeUtc(file, markerUtc); + Directory.SetLastWriteTimeUtc(profile, markerUtc); + Directory.SetLastWriteTimeUtc(web, markerUtc); + Directory.SetLastWriteTimeUtc(folder, markerUtc); + return folder; + } + + private string WriteClaim(Guid folderStemId, string content) + { + var path = Path.Combine(_serverDir, "proxied-" + folderStemId.ToString("D") + BrowserProfileKey.ListenerClaimExtension); + File.WriteAllText(path, content); + return path; + } + + private static string TrustedClaimContent(Guid browserId, int ownerPid, int port) + { + return new ListenerClaim(browserId, port, ownerPid, "fp", 1).Serialize(); + } + + /// pid 1001 is dead, port 4321 refuses, everything else lives; the locked row's folder is lock-held. + private ProfileSweep SweepFor() + { + return new ProfileSweep( + ownerProcessAlive: pid => pid != 1001, + loopbackPortConnectable: port => port != 4321, + browserLockHeld: folder => string.Equals(folder, _lockedFolder, StringComparison.OrdinalIgnoreCase), + selfPid: SelfPid); + } + + static string Dump(ProfileSweepReport report) => + "seen=" + report.ProxiedFoldersSeen + " reaped=" + report.ProxiedFoldersReaped + + " skipped=" + report.ProxiedFoldersSkippedLocked + " claims=" + report.ClaimFilesReaped + + " lines[" + string.Join(" || ", report.LogLines) + "]"; + + // ---- folder rows over the boundary shape ---- + + [Fact] + public void Fresh_Stale_Locked_Rows_Keep_Reap_And_Skip_On_Real_Files() + { + var nowUtc = Now.UtcDateTime; + var fresh = CreateProxiedProfile(_serverDir, FreshId, nowUtc.AddDays(-1)); + var stale = CreateProxiedProfile(_serverDir, StaleId, nowUtc.AddDays(-31)); + _lockedFolder = CreateProxiedProfile(_serverDir, LockedId, nowUtc.AddDays(-45)); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.True(Directory.Exists(fresh), "the in-retention folder must survive"); + Assert.False(Directory.Exists(stale), "the 31-day-orphan folder must be reaped: " + Dump(report)); + Assert.True(Directory.Exists(_lockedFolder), "the lock-held folder is never forced"); + + Assert.Equal(3, report.ProxiedFoldersSeen); + Assert.Equal(1, report.ProxiedFoldersReaped); + Assert.Equal(1, report.ProxiedFoldersSkippedLocked); + + // Each deletion logged exactly once; the skip journalled too. + Assert.Single(report.LogLines, line => line.Contains("Swept orphaned proxied browser profile '" + stale + "'")); + Assert.Single(report.LogLines, line => line.Contains("Skipped locked proxied browser profile '" + _lockedFolder + "'")); + Assert.DoesNotContain(report.LogLines, line => line.Contains(fresh, StringComparison.Ordinal)); + } + + [Fact] + public void Old_Shape_Folders_Directly_Under_The_User_Key_Are_Reaped_Too() + { + // The #92 handoff: no migration, the pre-#92 \proxied-* shape simply becomes sweep orphans. + var oldShape = CreateProxiedProfile(_userDir, OldShapeId, Now.UtcDateTime.AddDays(-40)); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.False(Directory.Exists(oldShape), "old-shape folder survived: " + Dump(report)); + Assert.Single(report.LogLines, line => line.Contains("Swept orphaned proxied browser profile '" + oldShape + "'")); + } + + [Fact] + public void The_Nonproxied_Ebwebview_Tree_Is_Never_Touched_However_Old() + { + var web = Path.Combine(_userDir, "EBWebView"); + var deep = Path.Combine(web, "Default", "OldCache"); + Directory.CreateDirectory(deep); + var file = Path.Combine(deep, "ancient.txt"); + File.WriteAllText(file, "do not delete me"); + File.SetLastWriteTimeUtc(file, Now.UtcDateTime.AddDays(-365)); + Directory.SetLastWriteTimeUtc(deep, Now.UtcDateTime.AddDays(-365)); + Directory.SetLastWriteTimeUtc(web, Now.UtcDateTime.AddDays(-365)); + Directory.SetLastWriteTimeUtc(_userDir, Now.UtcDateTime.AddDays(-365)); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.True(File.Exists(file)); + Assert.Equal(0, report.ProxiedFoldersReaped); + } + + [Fact] + public void A_Root_Level_Boundary_Dir_Is_Walked_Without_Any_User_Key_Layer() + { + // Identity-unresolvable fallback (#92: missing-user keeps server separation at root level). + var rootServer = Path.Combine(_root, "server-unknown"); + var stale = CreateProxiedProfile(rootServer, StaleId, Now.UtcDateTime.AddDays(-31)); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.False(Directory.Exists(stale), "root-level stale folder survived: " + Dump(report)); + Assert.Equal(1, report.ProxiedFoldersReaped); + } + + // ---- claim rows over the boundary shape ---- + + [Fact] + public void Live_Claim_Kept_Dead_Owner_Claim_Reaped_With_The_Shared_Audit() + { + // Both siblings are FRESH folders, so only the claim staleness can differ (pid probes). + CreateProxiedProfile(_serverDir, LiveClaimId, Now.UtcDateTime.AddDays(-1)); + CreateProxiedProfile(_serverDir, DeadClaimId, Now.UtcDateTime.AddDays(-1)); + var liveClaim = WriteClaim(LiveClaimId, TrustedClaimContent(LiveClaimId, 1000, 49321)); + var deadClaim = WriteClaim(DeadClaimId, TrustedClaimContent(DeadClaimId, 1001, 49321)); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.True(File.Exists(liveClaim), "the live-owner claim must survive"); + Assert.False(File.Exists(deadClaim), "the dead-owner claim must be reaped"); + Assert.Contains(report.LogLines, line => + line.Contains("Swept listener claim '" + deadClaim + "'") && line.Contains("no longer running")); + Assert.DoesNotContain(report.LogLines, line => line.Contains(liveClaim, StringComparison.Ordinal)); + } + + [Fact] + public void Refusing_Port_Claim_Is_Reaped_And_Cross_Id_Claim_Is_Reaped_As_Litter() + { + CreateProxiedProfile(_serverDir, LiveClaimId, Now.UtcDateTime.AddDays(-1)); + CreateProxiedProfile(_serverDir, DeadClaimId, Now.UtcDateTime.AddDays(-1)); + var byPort = WriteClaim(LiveClaimId, TrustedClaimContent(LiveClaimId, 1000, 4321)); // port refuses + var crossId = WriteClaim(DeadClaimId, TrustedClaimContent(OtherBrowserId, 1000, 49321)); // names another browser + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.False(File.Exists(byPort)); + Assert.False(File.Exists(crossId)); + Assert.Contains(report.LogLines, line => line.Contains("connect probe")); + Assert.Contains(report.LogLines, line => line.Contains("names browser")); + } + + [Fact] + public void Claim_Without_Its_Sibling_Profile_Is_Reaped_Corrupt_Claim_Is_Reaped() + { + CreateProxiedProfile(_serverDir, CorruptClaimId, Now.UtcDateTime.AddDays(-1)); + var orphanClaim = WriteClaim(ClaimWithoutSiblingId, TrustedClaimContent(ClaimWithoutSiblingId, 1000, 49321)); + var corruptClaim = WriteClaim(CorruptClaimId, "i am not a claim at all"); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.False(File.Exists(orphanClaim)); + Assert.False(File.Exists(corruptClaim)); + Assert.Contains(report.LogLines, line => line.Contains("sibling profile folder is gone")); + Assert.Contains(report.LogLines, line => line.Contains("schema header")); + } + + [Fact] + public void Reaping_A_Stale_Folder_Leaves_Its_Sibling_Claim_Reaped_Through_The_Sibling_Rule() + { + var folder = CreateProxiedProfile(_serverDir, StaleFolderWithClaimId, Now.UtcDateTime.AddDays(-31)); + var claim = WriteClaim(StaleFolderWithClaimId, TrustedClaimContent(StaleFolderWithClaimId, 1000, 49321)); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.False(Directory.Exists(folder)); + Assert.False(File.Exists(claim)); + Assert.Equal(2, report.ClaimFilesReaped + report.ProxiedFoldersReaped); + Assert.Contains(report.LogLines, line => line.Contains("sibling profile folder is gone")); + } + + [Fact] + public void This_Process_Own_Live_Claim_Is_Never_Reaped() + { + CreateProxiedProfile(_serverDir, LiveClaimId, Now.UtcDateTime.AddDays(-31)); // folder stale... + var ownClaim = WriteClaim(LiveClaimId, TrustedClaimContent(LiveClaimId, SelfPid, 49321)); // ...but this pid owns the claim + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.True(File.Exists(ownClaim), "the sweep never reaps its own process's slot"); + Assert.Contains(report.LogLines, line => line.Contains("own live claim")); + } + + [Fact] + public void Old_Claim_Create_Litter_Is_Reaped_While_Young_Litter_Survives() + { + var oldLitter = Path.Combine(_serverDir, "proxied-" + StaleId.ToString("D") + ".claim.tmp-777-" + Guid.NewGuid().ToString("N")); + var youngLitter = Path.Combine(_serverDir, "proxied-" + FreshId.ToString("D") + ".claim.tmp-778-" + Guid.NewGuid().ToString("N")); + File.WriteAllText(oldLitter, "half-written"); + File.WriteAllText(youngLitter, "mid-create"); + File.SetLastWriteTimeUtc(oldLitter, Now.UtcDateTime.AddHours(-3)); + File.SetLastWriteTimeUtc(youngLitter, Now.UtcDateTime.AddMinutes(-5)); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.False(File.Exists(oldLitter)); + Assert.True(File.Exists(youngLitter), "young litter may belong to a create racing this sweep"); + Assert.Equal(1, report.ClaimTempFilesReaped); + Assert.Contains(report.LogLines, line => line.Contains("temp litter '" + oldLitter + "'")); + } + + [Fact] + public void Hostile_Foreign_Files_Under_The_Root_Are_Left_Alon_Completely() + { + // Anything not matching a plugin shape class - even named to tempt a naive walk - + // is never read, moved, or deleted. + var foreign = Path.Combine(_userDir, "not-a-proxy"); + var foreignFile = Path.Combine(foreign, "keep.txt"); + Directory.CreateDirectory(foreign); + File.WriteAllText(foreignFile, "keep"); + File.SetLastWriteTimeUtc(foreignFile, Now.UtcDateTime.AddDays(-365)); + Directory.SetLastWriteTimeUtc(foreign, Now.UtcDateTime.AddDays(-365)); + File.WriteAllText(Path.Combine(_serverDir, "notes.txt"), "keep too"); + + var report = SweepFor().Run(_root, Now, Retention, LitterRetention); + + Assert.True(File.Exists(foreignFile)); + Assert.Equal(0, report.ProxiedFoldersSeen); + Assert.Equal(0, report.ProxiedFoldersReaped); + Assert.Empty(report.LogLines); + } + + [Fact] + public void The_Lockfile_Probe_Reports_Held_Only_While_An_Exclusive_Open_Fails() + { + var folder = Path.Combine(_serverDir, "proxied-" + LockedId.ToString("D")); + var lockfile = Path.Combine(folder, "EBWebView", "lockfile"); + Directory.CreateDirectory(Path.GetDirectoryName(lockfile)); + + // No lockfile at all: quiet. + Assert.False(ProfileSweep.BrowserLockFileHeld(folder)); + + // A stale leftover a crashed browser never deleted: opens cleanly -> not held. + File.WriteAllText(lockfile, "stale"); + Assert.False(ProfileSweep.BrowserLockFileHeld(folder)); + + // Held by somebody: exclusive open refuses -> held. + using (var hold = new FileStream(lockfile, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + Assert.True(ProfileSweep.BrowserLockFileHeld(folder)); + } + } + } +} diff --git a/src/WebContent.Tests/ProfileSweepStartupGuardTests.cs b/src/WebContent.Tests/ProfileSweepStartupGuardTests.cs new file mode 100644 index 0000000..f8e6c4b --- /dev/null +++ b/src/WebContent.Tests/ProfileSweepStartupGuardTests.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// #94 wiring guards: the sweep's decision core and IO seam are unit-tested directly, but + /// the STARTUP HOOK lives in plugin Init inside a host no unit harness boots. These rows pin + /// the acceptance shape of that hook - scheduled off the UI thread on a one-shot timer, every + /// failure path journalled and never rethrown, the production lock witness composed from + /// BOTH probes, and the two new files linked into the test assembly (the AGENTS.md both- + /// Compile-lists rule, whose silent failure mode is the plugin or the tests missing a class). + /// + public class ProfileSweepStartupGuardTests + { + private static string ReadPluginSource(string name) + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", name)); + } + + private static string Collapse(string source) + { + return Regex.Replace(source, @"\s+", " "); + } + + [Fact] + public void SmartClient_Init_Schedules_The_Sweep_But_Runs_Nothing_Synchronously() + { + var collapsed = Collapse(ReadPluginSource("WebContentPluginDefinition.cs")); + + var smartClientCase = Regex.Match( + collapsed, + @"case EnvironmentType\.SmartClient: (?.*?) case EnvironmentType\.Standalone", + RegexOptions.Singleline); + Assert.True(smartClientCase.Success, "the Smart Client Init branch was not found."); + Assert.Contains("ScheduleProxiedProfileSweep();", smartClientCase.Groups["body"].Value); + + // The scheduling itself is a one-shot threadpool timer - no disk walk on the Init + // (UI) thread, and nothing repeats it per Init call. + Assert.Contains( + "new Timer(ProfileSweepCallback, null, TimeSpan.FromSeconds(10), Timeout.InfiniteTimeSpan)", + collapsed); + } + + [Fact] + public void The_Sweep_Callback_Catches_Everything_And_Never_Breaks_A_View() + { + var collapsed = Collapse(ReadPluginSource("WebContentPluginDefinition.cs")); + + var callback = Regex.Match( + collapsed, + @"private void ProfileSweepCallback\(object state\) \{ (?.*?) private void RegisterWorkspaceRecoveryReceivers", + RegexOptions.Singleline); + Assert.True(callback.Success, "ProfileSweepCallback was not found before RegisterWorkspaceRecoveryReceivers."); + var body = callback.Groups["body"].Value; + + // Any throw is journalled through the plugin logger, whose own failure is swallowed + // too; the callback never rethrows onto the threadpool (crashing the host). + Assert.Contains("catch (Exception ex)", body); + Assert.Contains("EnvironmentManager.Instance.Log( true, nameof(ProfileSweep), \"Startup proxied-profile sweep failed", body); + Assert.DoesNotContain("throw", body, StringComparison.Ordinal); + + // The sweep root is this Windows user's plugin data root, never a machine-wide path. + Assert.Contains( + "Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), BrowserProfileKey.DataFolderRootName", + body); + + // Production composes BOTH lock witnesses: the lockfile probe and the live + // msedgewebview2 command-line probe over the same folder. + Assert.Contains("ProfileSweep.BrowserLockFileHeld(folder) || processWitness.NamesFolder(folder)", body); + + // The one-shot timer is torn down in finally, and Close() stops a not-yet-fired one. + Assert.Contains("finally { var timer = _profileSweepTimer;", body); + Assert.Contains("StopWorkspaceRetryTimer(); StopProxiedProfileSweepTimer();", collapsed); + } + + [Fact] + public void Both_Sweep_Classes_Are_Linked_Into_The_Test_Assembly_Too() + { + var testProject = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent.Tests", "WebContent.Tests.csproj")); + + // AGENTS.md both-Compile-lists rule: the pure core AND the IO seam cross, the + // WMI-backed witness deliberately does not (System.Management stays plugin-only). + Assert.Contains(@"Client\ProfileSweepCore.cs", testProject); + Assert.Contains(@"Client\ProfileSweep.cs", testProject); + Assert.DoesNotContain("BrowserProcessLockWitness.cs", testProject); + } + + [Fact] + public void The_Production_Witness_Is_Not_Linked_And_System_Management_Stay_In_The_Plugin() + { + var pluginProject = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent", "WebContent.csproj")); + var witness = Collapse(ReadPluginSource(Path.Combine("Client", "BrowserProcessLockWitness.cs"))); + var sweep = Collapse(ReadPluginSource(Path.Combine("Client", "ProfileSweep.cs"))); + + // The linked ProfileSweep seam compiles without System.Management; only the + // plugin-only witness pulls it (csproj reference above). + Assert.Contains("", pluginProject); + Assert.Contains("using System.Management;", witness); + Assert.DoesNotContain("System.Management", sweep); + + // The witness reads the runtime's own command lines once per sweep - the same + // Win32_Process witness the SC-8/SC-11 live rows trust. + Assert.Contains("SELECT CommandLine FROM Win32_Process WHERE Name = 'msedgewebview2.exe'", witness); + Assert.Contains("if (_commandLines == null)", witness); + } + } +} diff --git a/src/WebContent.Tests/ProxiedInitErrorWiringGuardTests.cs b/src/WebContent.Tests/ProxiedInitErrorWiringGuardTests.cs new file mode 100644 index 0000000..fdba11e --- /dev/null +++ b/src/WebContent.Tests/ProxiedInitErrorWiringGuardTests.cs @@ -0,0 +1,148 @@ +using System.IO; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #87 live-shape guards: the ownership notice is rendered by WPF views and driven by + /// WebView2 init events the unit harness can never host, so these source pins hold the + /// composition the live two-client probe (SC-13) drives - the failed PROXIED initialization + /// routes through the pure classifier into ONE browser-owned log line and the in-view + /// notice, the notice elements exist and start collapsed, the blank WebView steps aside + /// while the notice stands, Refresh on an errored proxied view re-attempts creation through + /// the re-creation path (and NEVER Reload), and a recreation while the view is open re-attaches + /// the fresh control. The decisions themselves sit in BrowserInitFailureTests and + /// BrowserCommandAdmissionTests; the live row is in docs/verification-matrix.md. + /// + public class ProxiedInitErrorWiringGuardTests + { + private static string ReadClient(string file) + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "Client", file)); + } + + private static string Collapse(string source) + { + return System.Text.RegularExpressions.Regex.Replace(source, @"\s+", " "); + } + + [Fact] + public void AFailedProxiedInitializationPublishesTheContractExactlyOnce() + { + var collapsed = Collapse(ReadClient("BrowserInstance.cs")); + + // The event handler routes the failure through the pure classifier... + Assert.Contains("if (BrowserInitFailure.TryPresent(IsProxied, Id, e.InitializationException?.Message, out var presentation))", collapsed); + // ...through the publisher that logs the ONE presentation line and raises PropertyChanged, + // and the non-proxied branch keeps its unchanged log-only line. + Assert.Contains("PublishInitFailure(presentation);", collapsed); + Assert.Contains("EnvironmentManager.Instance.Log(true, nameof(BrowserInstance), presentation.LogLine);", collapsed); + Assert.Contains("OnPropertyChanged(nameof(InitFailure));", collapsed); + Assert.Contains( + "EnvironmentManager.Instance.Log(true, nameof(BrowserInstance), $\"CoreWebView2 initialization failed. InitializationException: {e.InitializationException?.Message}\");", + collapsed); + + // A new attempt retires the stale notice; success clears it. + Assert.Contains("if (InitFailure != null) { InitFailure = null; OnPropertyChanged(nameof(InitFailure)); }", collapsed); + } + + [Fact] + public void RefreshOnAnErroredProxiedViewRecreatesAndNeverReloadsDead() + { + var collapsed = Collapse(ReadClient("BrowserCommand.cs")); + + // Admission: the recreation route is vouched for only by proxied + NeedsRecreation. + Assert.Contains("instance != null && instance.IsProxied && instance.NeedsRecreation;", collapsed); + Assert.Contains("CanRecreateProxiedProfile(_instance));", collapsed); + + // Execute: Reload only with a live CoreWebView2; the proxied error state routes to + // the ViewModel's re-creation entry point; everything else keeps the refused-log path. + Assert.Contains("_instance.WebView.Reload();", collapsed); + Assert.Contains("else if (CanRecreateProxiedProfile(_instance))", collapsed); + Assert.Contains("_viewModel.RecreateBrowserForRefresh();", collapsed); + Assert.Contains("LogUninitialized(_action);", collapsed); + } + + [Fact] + public void TheRefreshRecreationUsesTheSameAddressAndIsolationRuleAsViewInit() + { + var collapsed = Collapse(ReadClient("WebContentViewModel.cs")); + + // Same restore-address fallback and same isolation setting the view's Init path uses. + Assert.Contains("var address = ResolveRestoreAddress() ?? Home;", collapsed); + Assert.Contains("browser.RecreateWebView(", collapsed); + Assert.Contains("_viewItemManager?.Settings.IsolateBrowserInstances ?? false);", collapsed); + + // Failed-init/WebView changes re-query the Refresh gate (no navigation event exists there). + Assert.Contains("_reloadCommand?.RaiseCanExecuteChanged();", collapsed); + } + + [Fact] + public void TheRecreatedWebViewAnnouncesItselfAndTheOpenViewReAttachesIt() + { + var instance = Collapse(ReadClient("BrowserInstance.cs")); + var control = Collapse(ReadClient("WebContentUserControl.xaml.cs")); + + // Recreation fires PropertyChanged(WebView) before re-initializing... + Assert.Contains("OnPropertyChanged(nameof(WebView));", instance); + // ...and the open control rebinds + re-adds the fresh control without a re-selection. + Assert.Contains("if (e.PropertyName == nameof(BrowserInstance.WebView))", control); + Assert.Contains("AttachRecreatedWebView();", control); + } + + [Fact] + public void TheNoticeElementsExistStartCollapsedAndCarryProbeAutomationIds() + { + var xaml = ReadClient("WebContentUserControl.xaml"); + + // The browser host became a Grid so the notice can occupy the WebView's area. + Assert.Contains("", + testCsproj); + } + } +} diff --git a/src/WebContent.Tests/ProxyAllowlistEditorTests.cs b/src/WebContent.Tests/ProxyAllowlistEditorTests.cs new file mode 100644 index 0000000..394af2c --- /dev/null +++ b/src/WebContent.Tests/ProxyAllowlistEditorTests.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using WebContent.Admin; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #81: the structured allowlist grid's row model and its save-time build. The grid + /// is the MC's only allowlist editor now, so the invariants the old free-text path pinned + /// (grammar, canonicalization, duplicate collapse with flag-wins, the two stored shapes) + /// must hold here, plus the #81-specific ones: inline row flags for refused entries, the + /// full-host-wildcard detection that keys the warning banner, blank-row skipping, and the + /// "legacy workspace opens and saves back to the structured shape without losing entries or + /// flags" acceptance round-trip. + /// + public class ProxyAllowlistEditorTests + { + // ---- row validation: the shared grammar decides, never a UI-side copy ---------------- + + [Theory] + [InlineData("https", "example.com", "", "None")] + [InlineData("https", "Example.COM", "", "None")] + [InlineData("http", "*.foo.bar.com", "", "None")] + [InlineData("http", "*", "", "None")] + [InlineData("https", "10.0.0.5", "8443", "None")] + [InlineData("https", "example.com", "443", "None")] // scheme default = valid, canonicalises away + [InlineData("https", "[::1]", "8443", "None")] // exact IPv6 stays storable (floor only hits wildcards) + [InlineData("https", "foo.*.bar.com", "", "HostOrPatternInvalid")] + [InlineData("https", "*x.foo.com", "", "HostOrPatternInvalid")] + [InlineData("https", "*.", "", "HostOrPatternInvalid")] + [InlineData("https", "example.com/path", "", "HostOrPatternInvalid")] + [InlineData("https", "exa mple.com", "", "HostOrPatternInvalid")] + [InlineData("http", "example.com", "0", "None")] + [InlineData("http", "example.com", "65536", "PortInvalid")] + [InlineData("http", "example.com", "8o8o", "PortInvalid")] + [InlineData("http", "example.com", "-1", "PortInvalid")] + [InlineData("http", "", "8080", "HostRequired")] // port typed, host blank: refusal, not blank + public void RowValidationFollowsTheSharedGrammar(string scheme, string host, string port, string expectedName) + { + var expected = (ProxyTargetRowError)System.Enum.Parse(typeof(ProxyTargetRowError), expectedName); + var row = new ProxyTargetEditorRow { Scheme = scheme, Host = host, PortText = port }; + Assert.Equal(expected, row.ErrorCode); + Assert.Equal(expected != ProxyTargetRowError.None, row.HasError); + } + + [Fact] + public void BlankRowsAreNotEntries() + { + var row = new ProxyTargetEditorRow(); + Assert.True(row.IsEmpty); + Assert.Equal(ProxyTargetRowError.None, row.ErrorCode); + + row.PortText = " "; + row.Host = " "; + Assert.True(row.IsEmpty); + + row.Host = "example.com"; + Assert.False(row.IsEmpty); + } + + [Fact] + public void ValidRowsCanonicalizeIntoTheSharedEntryForm() + { + var upper = new ProxyTargetEditorRow { Scheme = "HTTPS", Host = "Example.COM", PortText = "8443" }; + Assert.Equal("https://example.com:8443", upper.CanonicalEntry); + + var defaultPort = new ProxyTargetEditorRow { Scheme = "https", Host = "example.com", PortText = "443" }; + Assert.Equal("https://example.com", defaultPort.CanonicalEntry); + + var wildcard = new ProxyTargetEditorRow { Scheme = "http", Host = "*.FOO.bar.com" }; + Assert.Equal("http://*.foo.bar.com", wildcard.CanonicalEntry); + } + + [Fact] + public void OnlyTheLoneStarIsAFullHostWildcard() + { + Assert.True(new ProxyTargetEditorRow { Host = "*" }.IsFullHostWildcard); + Assert.True(new ProxyTargetEditorRow { Host = " * " }.IsFullHostWildcard); // trimmed like the canonicalizer trims + Assert.False(new ProxyTargetEditorRow { Host = "*.foo.com" }.IsFullHostWildcard); + Assert.False(new ProxyTargetEditorRow { Host = "" }.IsFullHostWildcard); + } + + // ---- stored-shape <-> row translation -------------------------------------------------- + + [Fact] + public void ValidRowsWriteTheStructuredRecordShape() + { + var row = new ProxyTargetEditorRow { Scheme = "http", Host = "*.foo.com", PortText = "8080", AcceptUntrusted = true }; + Assert.True(row.TryToStoredRecord(out var record)); + Assert.Equal("http", record.Scheme); + Assert.Equal("*.foo.com", record.Pattern); + Assert.Equal(8080, record.Port); + Assert.True(record.AcceptUntrusted); + Assert.Null(record.Host); // #73: writers emit the pattern member, never the legacy host + } + + [Fact] + public void LoadRendersBothStoredShapesForever() + { + var editor = new ProxyAllowlistEditor(); + var dropped = editor.Load(new List + { + // Legacy #52 record: exact host member, port, flag. + new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 8443, AcceptUntrusted = true }, + // Structured #73 record: pattern member (wildcard). + new ProxyTargetConfiguration { Scheme = "http", Pattern = "*.cam.example", Port = 0 }, + // Corrupt: no host at all -> dropped, never rendered as a poisoned row. + new ProxyTargetConfiguration { Scheme = "https", Port = 443 }, + // Corrupt: bogus scheme -> dropped. + new ProxyTargetConfiguration { Scheme = "ftp", Host = "example.com" }, + }); + + Assert.Equal(2, dropped); + Assert.Equal(2, editor.Count); + + Assert.Equal("https", editor.Rows[0].Scheme); + Assert.Equal("10.0.0.5", editor.Rows[0].Host); + Assert.Equal("8443", editor.Rows[0].PortText); + Assert.True(editor.Rows[0].AcceptUntrusted); + + Assert.Equal("http", editor.Rows[1].Scheme); + Assert.Equal("*.cam.example", editor.Rows[1].Host); + Assert.Equal(string.Empty, editor.Rows[1].PortText); // 0 renders as the empty (scheme-default) cell + Assert.False(editor.Rows[1].AcceptUntrusted); + Assert.Equal(ProxyTargetRowError.None, editor.Rows[0].ErrorCode); + Assert.Equal(ProxyTargetRowError.None, editor.Rows[1].ErrorCode); + } + + [Fact] + public void LegacyWorkspaceOpensAndSavesBackToTheStructuredShapeWithoutLosingEntriesOrFlags() + { + // The #81 acceptance round-trip, end to end through the editor. + var legacy = new List + { + new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 8443, AcceptUntrusted = true }, + new ProxyTargetConfiguration { Scheme = "https", Host = "httpforever.com", Port = 0, AcceptUntrusted = false }, + new ProxyTargetConfiguration { Scheme = "http", Host = "10.0.0.9", Port = 80, AcceptUntrusted = true }, + }; + + var editor = new ProxyAllowlistEditor(); + Assert.Equal(0, editor.Load(legacy)); + Assert.True(editor.TryBuildStoredTargets(out var saved, out var rejected)); + Assert.Empty(rejected); + + Assert.Equal(3, saved.Count); + Assert.All(saved, record => Assert.Null(record.Host)); // every re-saved record is the new shape + Assert.Equal("10.0.0.5", saved[0].Pattern); + Assert.Equal(8443, saved[0].Port); + Assert.True(saved[0].AcceptUntrusted); + Assert.Equal("httpforever.com", saved[1].Pattern); + Assert.Equal(0, saved[1].Port); + Assert.False(saved[1].AcceptUntrusted); + Assert.Equal("10.0.0.9", saved[2].Pattern); + Assert.Equal(0, saved[2].Port); // :80 canonicalises to the http default + Assert.True(saved[2].AcceptUntrusted); + } + + // ---- save-time build ------------------------------------------------------------------- + + [Fact] + public void InvalidRowsRefuseTheBuildByNameAndNeverVanish() + { + var editor = new ProxyAllowlistEditor(); + editor.AddBlankRow(); + editor.Rows[0].Host = "good.example.com"; + editor.AddBlankRow(); + editor.Rows[1].Host = "foo.*.bar.com"; + editor.AddBlankRow(); + editor.Rows[2].Scheme = "http"; + editor.Rows[2].Host = "ok.example.com"; + editor.Rows[2].PortText = "99999"; + + Assert.False(editor.TryBuildStoredTargets(out var saved, out var rejected)); + Assert.Empty(saved); + Assert.Equal(2, rejected.Count); + Assert.Contains("foo.*.bar.com", rejected[0], StringComparison.Ordinal); + Assert.Contains("http://ok.example.com:99999", rejected[1], StringComparison.Ordinal); + } + + [Fact] + public void BlankRowsAreSkippedAndOrderSurvives() + { + var editor = new ProxyAllowlistEditor(); + editor.AddBlankRow(); + editor.Rows[0].Host = "one.example.com"; + editor.AddBlankRow(); // untouched blank + editor.AddBlankRow(); + editor.Rows[2].Host = "two.example.com"; + + Assert.True(editor.TryBuildStoredTargets(out var saved, out var rejected)); + Assert.Empty(rejected); + Assert.Equal(2, saved.Count); + Assert.Equal("one.example.com", saved[0].Pattern); + Assert.Equal("two.example.com", saved[1].Pattern); + } + + [Fact] + public void DuplicateRowsCollapseAndAnyFlaggedDuplicateFlagsTheEntry() + { + var rows = new List + { + new ProxyTargetEditorRow { Scheme = "https", Host = "Example.com" }, + new ProxyTargetEditorRow { Scheme = "https", Host = "example.com", AcceptUntrusted = true }, + new ProxyTargetEditorRow { Scheme = "https", Host = "example.com", PortText = "443" }, + }; + + Assert.True(ProxyAllowlistEditor.TryBuildStoredTargets(rows, out var saved, out var rejected)); + Assert.Empty(rejected); + Assert.Single(saved); + Assert.Equal("example.com", saved[0].Pattern); + Assert.True(saved[0].AcceptUntrusted); + } + + [Fact] + public void HasFullHostWildcardTracksTheRows() + { + var editor = new ProxyAllowlistEditor(); + Assert.False(editor.HasFullHostWildcard); + + editor.AddBlankRow().Host = "example.com"; + Assert.False(editor.HasFullHostWildcard); + + var hatch = editor.AddBlankRow(); + hatch.Host = "*"; + editor.Rows[1].Scheme = "http"; + Assert.True(editor.HasFullHostWildcard); + + editor.Remove(hatch); + Assert.False(editor.HasFullHostWildcard); + } + + [Fact] + public void RemovingAndClearingRowsLeavesASavableEmptyList() + { + var editor = new ProxyAllowlistEditor(); + editor.AddBlankRow().Host = "one.example.com"; + editor.AddBlankRow().Host = "two.example.com"; + editor.Clear(); + + Assert.Equal(0, editor.Count); + Assert.True(editor.TryBuildStoredTargets(out var saved, out var rejected)); + Assert.Empty(saved); + // ("proxy enabled with zero targets" is the item manager's refusal about its toggle, + // not the rows'; it stays where it was: MC-9 posture, pinned in the matrix.) + } + + [Fact] + public void DescribeKeepsTheProbeRecordShape() + { + var row = new ProxyTargetEditorRow { Scheme = "http", Host = "*.neverssl.com", AcceptUntrusted = true }; + Assert.Equal("http://*.neverssl.com accept-untrusted", row.Describe()); + + var plain = new ProxyTargetEditorRow { Scheme = "https", Host = "10.0.0.5", PortText = "8443" }; + Assert.Equal("https://10.0.0.5:8443", plain.Describe()); + } + + [Fact] + public void EditingTheFlagNeverChangesValidation() + { + var row = new ProxyTargetEditorRow { Scheme = "https", Host = "foo.*.bar.com" }; + var before = row.ErrorCode; + row.AcceptUntrusted = true; + row.AcceptUntrusted = false; + Assert.Equal(before, row.ErrorCode); + } + + /// The save's audit line is the storage-side view of the grid: it names every + /// stored record in the admin's own entry shape (scheme, port, and the flag token), which + /// is what the #81 live probe compares the grid against. A record it cannot parse is named + /// as unparseable rather than left out, so the line never overstates the allowlist. + [Fact] + public void StoredTargetsAreDescribedInShapeForTheAuditLine() + { + var described = ProxyAllowlistEditor.DescribeStoredTargets(new List + { + new ProxyTargetConfiguration { Scheme = "http", Pattern = "httpforever.com", Port = 0 }, + new ProxyTargetConfiguration { Scheme = "https", Pattern = "*.cam.example", Port = 8443 }, + new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 18125, AcceptUntrusted = true }, + new ProxyTargetConfiguration { Scheme = "ftp", Host = "example.com", Port = 21 }, + }); + + Assert.Equal( + "http://httpforever.com; https://*.cam.example:8443; https://10.0.0.5:18125 accept-untrusted; ftp://example.com:21 (unparseable)", + described); + + Assert.Equal(string.Empty, ProxyAllowlistEditor.DescribeStoredTargets(null)); + } + + // ---- #84: the one-click promotion write path ---------------------------------------- + + [Fact] + public void PromotionAddsAnExactRowInTheGridsOwnShape() + { + // The Diagnostics tab's Add-to-allowlist button lands the blocked origin as an exact + // scheme+host+port row - no wildcard, no accept-untrusted flag, no stored-record + // shortcut around the grid. + var editor = new ProxyAllowlistEditor(); + var changed = 0; + editor.Rows.CollectionChanged += (s, e) => changed++; + + var outcome = editor.TryAddExactOrigin("http://172.16.128.210:18131", out var row); + + Assert.Equal(ProxyPromotionOutcome.Added, outcome); + Assert.NotNull(row); + Assert.Equal("http", row.Scheme); + Assert.Equal("172.16.128.210", row.Host); + Assert.Equal("18131", row.PortText); + Assert.False(row.AcceptUntrusted); + Assert.Equal("http://172.16.128.210:18131", row.CanonicalEntry); + Assert.Equal(1, editor.Count); + Assert.True(changed > 0, "the row-collection chain the page dirties the pane through never fired."); + } + + [Theory] + [InlineData("https://Corp.Example", "https://corp.example", "")] // scheme default port canonicalises away + [InlineData("http://cdn.example.com:8080", "http://cdn.example.com:8080", "8080")] + [InlineData("https://[::1]:8443", "https://[::1]:8443", "8443")] + public void PromotionRowsCarryTheCanonicalOriginAndItsParts(string origin, string canonical, string portText) + { + var editor = new ProxyAllowlistEditor(); + Assert.Equal(ProxyPromotionOutcome.Added, editor.TryAddExactOrigin(origin, out var row)); + Assert.Equal(canonical, row.CanonicalEntry); + Assert.Equal(portText, row.PortText); + } + + [Fact] + public void PromotionDedupesAgainstAnEquivalentRowNotItsTyping() + { + // The grid already carries the entry (loaded from storage, differently typed): the + // promotion must not double it - the canonical entry is the identity, exactly like + // the save's duplicate collapse. + var editor = new ProxyAllowlistEditor(); + editor.Load(new List + { + new ProxyTargetConfiguration { Scheme = "HTTPS", Host = "Example.COM", Port = 443 }, + }); + + var outcome = editor.TryAddExactOrigin("https://example.com", out var row); + + Assert.Equal(ProxyPromotionOutcome.AlreadyPresent, outcome); + Assert.Null(row); + Assert.Equal(1, editor.Count); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not an origin")] + [InlineData("ftp://files.example")] + [InlineData("http://*.cdn.example")] // the wildcard grammar is the admin's edit, not the button's + [InlineData("http://*")] + [InlineData("http://example.com/path")] + public void PromotionRefusesAnythingOutsideTheExactEntryGrammarWithoutTouchingTheGrid(string origin) + { + var editor = new ProxyAllowlistEditor(); + editor.AddBlankRow(); + + var outcome = editor.TryAddExactOrigin(origin, out var row); + + Assert.Equal(ProxyPromotionOutcome.Refused, outcome); + Assert.Null(row); + Assert.Equal(1, editor.Count); + } + + [Fact] + public void APromotedRowSavesAsTheStructuredRecordTheEditorItselfWouldWrite() + { + // #84 AC: "promotion produces a config write equivalent to the allowlist editor's + // save". The promoted row is an ordinary grid row, so the ordinary save build lands + // it as a structured pattern record - identical to what typing the row in by hand + // would store. + var editor = new ProxyAllowlistEditor(); + Assert.Equal(ProxyPromotionOutcome.Added, editor.TryAddExactOrigin("http://172.16.128.210:18131", out _)); + + Assert.True(editor.TryBuildStoredTargets(out var targets, out var rejected)); + Assert.Empty(rejected); + var record = Assert.Single(targets); + Assert.Equal("http", record.Scheme); + Assert.Equal("172.16.128.210", record.EffectivePattern); + Assert.Equal(18131, record.Port); + Assert.False(record.AcceptUntrusted); + } + + [Fact] + public void ValidCanonicalEntriesCoverUnsavedEditsAndSkipBrokenRows() + { + // The Diagnostics annotation feeds this through ProxyPolicy.IsRequestAllowed, so it + // must list every row that PROMISES an entry - saved rows, hand-added rows, and + // promoted rows alike - and never a row the save would refuse. + var editor = new ProxyAllowlistEditor(); + editor.Load(new List + { + new ProxyTargetConfiguration { Scheme = "http", Pattern = "*.cam.example", Port = 0 }, + }); + editor.AddBlankRow(); // blank: promises nothing + editor.AddBlankRow().Host = "bad.*.host"; // invalid: refused at save + editor.TryAddExactOrigin("https://promoted.example", out _); // unsaved promotion + + var entries = editor.ValidCanonicalEntries(); + + Assert.Contains("http://*.cam.example", entries); + Assert.Contains("https://promoted.example", entries); + Assert.Equal(2, entries.Count); + } + } +} diff --git a/src/WebContent.Tests/ProxyAllowlistGateAgreementTests.cs b/src/WebContent.Tests/ProxyAllowlistGateAgreementTests.cs new file mode 100644 index 0000000..3ee12c0 --- /dev/null +++ b/src/WebContent.Tests/ProxyAllowlistGateAgreementTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #73 AC2, extended by #88: the gates that must never disagree - the Event Server + /// fetch gate (, which is also what the leaf-mint gate + /// consults, i.e. the allowlist IS the minting policy), the + /// client forward policy (), and the view-layer https refusal + /// (, #88) - consume ONE canonical matcher over ONE canonical + /// entry shape. This matrix feeds the identical (entries, request-origin) pairs through all + /// of them and pins identical verdicts, legacy host records and new pattern records alike. + /// The view gate adds its own scheme rule on top: it answers https only (http rides the + /// proxy's absolute-form 403), never wider than the forward gate would. + /// + public class ProxyAllowlistGateAgreementTests + { + private static readonly Action Sink = _ => { }; + + // One entry set covering both stored shapes (legacy "host", new "pattern") and both + // entry kinds (exact origin, leftmost-label wildcard). + private static List> WorkspaceEntries() => + new List> + { + Workspace(true, + "{\"scheme\":\"https\",\"host\":\"cam1.test\",\"port\":8443}", + "{\"scheme\":\"http\",\"host\":\"10.0.0.5\",\"port\":8080}", + "{\"scheme\":\"https\",\"pattern\":\"*.portal.test\",\"port\":443}", + "{\"scheme\":\"http\",\"pattern\":\"*.cams.local\",\"port\":8080}"), + }; + + private static Dictionary Workspace(bool enabled, params string[] targetJsonObjects) + { + var targetsJson = "[" + string.Join(",", targetJsonObjects) + "]"; + return new Dictionary + { + ["WorkspaceConfig"] = + "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0,\"settings\":" + + "{\"homeAddress\":\"http://x/\",\"proxyEnabled\":" + (enabled ? "true" : "false") + + ",\"proxyTargets\":" + targetsJson + "}}]}", + }; + } + + // The canonical entries the client listener was seeded with - the same set, canonical form. + private static readonly string[] SeededEntries = + { + "https://cam1.test:8443", + "http://10.0.0.5:8080", + "https://*.portal.test", + "http://*.cams.local:8080", + }; + + private static readonly string[] VmsFloorHosts = { "vms.joshooaj.com" }; + + private static readonly Uri[] Probes = + { + new Uri("https://cam1.test:8443/live/index.html"), // legacy exact entry + new Uri("https://sub.cam1.test:8443/"), // exact entry never covers subdomains + new Uri("https://cam1.test:9443/"), // port stays exact + new Uri("http://10.0.0.5:8080/monitor/live"), // explicit exact entry MAY name an IP + new Uri("https://10.0.0.5:8080/"), // https-scheme at that IP: no entry, refused + new Uri("https://a.portal.test/dashboard"), // wildcard covers a subdomain + new Uri("https://a.b.portal.test/dashboard"), // ...and a deep subdomain + new Uri("https://portal.test/"), // ...but never the apex + new Uri("https://evilportal.test/"), // ...and never across a label boundary + new Uri("http://cam9.cams.local:8080/live"), // wildcard with an explicit port + new Uri("http://cam9.cams.local:9090/live"), // port exact across wildcards too + }; + + private static bool ExpectedAllowed(Uri probe) + { + // The truth table the matcher itself documents; the three gates must each reproduce it. + switch (probe.Host.ToLowerInvariant()) + { + case "cam1.test": + return probe.Port == 8443 && probe.Scheme == "https"; + case "10.0.0.5": + return probe.Scheme == "http" && probe.Port == 8080; + case "a.portal.test": + case "a.b.portal.test": + return probe.Scheme == "https" && probe.IsDefaultPort; + case "portal.test": + case "evilportal.test": + return false; + case "cam9.cams.local": + return probe.Scheme == "http" && probe.Port == 8080; + default: + return false; + } + } + + [Fact] + public void AllThreeGatesReachTheSameVerdictOnEveryProbe() + { + var serverGate = new ProxyServerAllowlist(WorkspaceEntries, Sink, () => VmsFloorHosts); + var mintGate = new ProxyLeafIssuer( + serverGate, + () => throw new InvalidOperationException("authority seam: a refused mint never reaches the CA"), + Sink); + + foreach (var probe in Probes) + { + var expected = ExpectedAllowed(probe); + + var serverVerdict = serverGate.IsAllowed(probe, out _); + Assert.Equal(expected, serverVerdict); + + var head = $"GET {probe.AbsoluteUri} HTTP/1.1\r\nHost: host\r\n\r\n"; + var clientVerdict = ProxyForwardPolicy.Decide(SeededEntries, head, null, VmsFloorHosts, out _, out _); + Assert.Equal(expected ? ProxyForwardDisposition.Forward : ProxyForwardDisposition.OriginNotAllowed, clientVerdict); + + // #88: the view-layer https gate joins the matrix. It answers https only (plain + // http stays on the absolute-form forward path), and on its scheme its verdict + // matches every other gate: allowed origins reach the network, refused origins + // get the view's own 403 page. + var viewRefused = ProxyViewRefusal.TryRefuseHttpsRequest(SeededEntries, VmsFloorHosts, probe, "GET", out _, out var viewHtml); + if (probe.Scheme == Uri.UriSchemeHttps) + { + Assert.Equal(!expected, viewRefused); + if (viewRefused) + { + Assert.Contains("not in the workspace proxy allowlist", viewHtml); + } + else + { + Assert.Null(viewHtml); + } + } + else + { + Assert.False(viewRefused); + } + + // The mint gate is https-only; on its scheme it must follow the identical policy: + // allowed mints proceed past the gate and fail only at the authority seam, + // refused mints stop at NotAllowed before the authority is ever consulted. + if (probe.Scheme == Uri.UriSchemeHttps) + { + var mint = mintGate.Issue(new ProxyLeafRequestPayload + { + CorrelationId = Guid.NewGuid().ToString("N"), + Host = probe.Host, + Port = probe.Port, + SpkiBase64 = "AQID", + }); + Assert.False(mint.Success); + Assert.Equal(expected ? ProxyFetchErrorCodes.NoAuthority : ProxyFetchErrorCodes.NotAllowed, mint.ErrorCode); + } + } + } + + [Fact] + public void FullHostHatchAgreesAcrossTheGatesUnderTheSameFloor() + { + var entries = new[] { "https://*" }; + var serverGate = new ProxyServerAllowlist( + () => new List> + { + Workspace(true, "{\"scheme\":\"https\",\"pattern\":\"*\",\"port\":443}"), + }, + Sink, + () => VmsFloorHosts); + + foreach (var host in new[] { "anything.example.test", "deep.sub.example.test", "vms.joshooaj.com", "10.20.30.40", "localhost", "[::1]" }) + { + var expected = host != "vms.joshooaj.com" && host != "10.20.30.40" && host != "localhost" && host != "[::1]"; + var probe = new Uri($"https://{host}/"); + + Assert.Equal(expected, serverGate.IsAllowed(probe, out _)); + + var head = $"GET {probe.AbsoluteUri} HTTP/1.1\r\nHost: host\r\n\r\n"; + Assert.Equal( + expected ? ProxyForwardDisposition.Forward : ProxyForwardDisposition.OriginNotAllowed, + ProxyForwardPolicy.Decide(entries, head, null, VmsFloorHosts, out _, out _)); + + // #88: the view gate honours the same floor - the full-host hatch admits hosts + // at the view exactly as at the other gates, floored hosts stay refused. + Assert.Equal(!expected, ProxyViewRefusal.TryRefuseHttpsRequest(entries, VmsFloorHosts, probe, "GET", out _, out _)); + } + } + } +} diff --git a/src/WebContent.Tests/ProxyBlockIndicatorWiringGuardTests.cs b/src/WebContent.Tests/ProxyBlockIndicatorWiringGuardTests.cs new file mode 100644 index 0000000..851402a --- /dev/null +++ b/src/WebContent.Tests/ProxyBlockIndicatorWiringGuardTests.cs @@ -0,0 +1,84 @@ +using System.IO; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #83 live-shape guards for the setup-mode blocked-resources indicator. The first + /// live SC-7 run (2026-11-03) exposed the defect these pins forbid: the "N resources + /// blocked" TextBlock sat as the CONTENT of a VideoOSButtonTransparentSmall, an icon-only + /// control whose template never presents text content - the label rendered nothing, had no + /// UIA peer, and the toolbar affordance was an anonymous icon. The label must therefore be + /// a toolbar SIBLING of the button with an explicit AutomationId, and both elements must + /// toggle visibility in lockstep in UpdateBlockedIndicator (the live probe asserts on both + /// ids; a label that outlives the button, or a button that outlives the label, re-opens + /// the ambiguity the fix removed). + /// + public class ProxyBlockIndicatorWiringGuardTests + { + private static string Read(string file) + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "Client", file)); + } + + private static string Collapse(string source) + { + return System.Text.RegularExpressions.Regex.Replace(source, @"\s+", " "); + } + + [Fact] + public void TheLabelIsAToolbarSiblingNotContentInsideTheIconButton() + { + var xaml = Read("WebContentUserControl.xaml"); + + // The button keeps an icon (an icon-only button with no IconSource renders as an + // anonymous box) and the label sits beside it with the AutomationId the probe reads. + var button = System.Text.RegularExpressions.Regex.Match( + xaml, + "", + System.Text.RegularExpressions.RegexOptions.Singleline); + Assert.True(button.Success, "blockedIndicator button not found in the toolbar XAML"); + Assert.Contains("Icon=\"Warning_Combined\"", button.Value); + Assert.DoesNotContain("blockedIndicatorText", button.Value, System.StringComparison.Ordinal); + + var collapsed = Collapse(xaml); + Assert.Contains( + "Name=\"blockedIndicatorText\" DockPanel.Dock=\"Right\" Visibility=\"Collapsed\"", + collapsed); + Assert.Contains("AutomationProperties.AutomationId=\"blockedIndicatorText\"", collapsed); + } + + [Fact] + public void BothChromeElementsStartCollapsedAndToggleInLockstep() + { + var collapsed = Collapse(Read("WebContentUserControl.xaml.cs")); + + // Show: text, then label, then button - all three assignments or none. + Assert.Contains( + "blockedIndicatorText.Text = total == 1", + collapsed); + Assert.Contains( + "blockedIndicatorText.Visibility = Visibility.Visible; blockedIndicator.Visibility = Visibility.Visible;", + collapsed); + + // Hide: button and label collapse together (a label left behind would keep the + // setup-mode chrome claiming blocks that no longer exist). + Assert.Contains( + "blockedIndicator.Visibility = Visibility.Collapsed; blockedIndicatorText.Visibility = Visibility.Collapsed; blockedResourcesPanel.Visibility = Visibility.Collapsed;", + collapsed); + } + + [Fact] + public void TheIndicatorRemainsSetupModeOnlyBehindTheTallyGate() + { + var collapsed = Collapse(Read("WebContentUserControl.xaml.cs")); + + // The gate itself (setup mode AND a non-zero tally) is the whole visibility + // decision; ordinary users in runtime mode never see the label or the button. + Assert.Contains( + "if (_blockTally != null && _viewModel?.IsSetupState == true && total > 0)", + collapsed); + } + } +} diff --git a/src/WebContent.Tests/ProxyBlockReporterTests.cs b/src/WebContent.Tests/ProxyBlockReporterTests.cs new file mode 100644 index 0000000..e4bf7b4 --- /dev/null +++ b/src/WebContent.Tests/ProxyBlockReporterTests.cs @@ -0,0 +1,74 @@ +using System; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #69: the per-listener block reporter. It must build a complete payload from its + /// session context, stay silent when the view has no workspace to file under, and swallow + /// every send fault - the diagnostics channel must never disturb the browser flow. + /// + public class ProxyBlockReporterTests + { + private static ProxyViewSessionContext Context() + { + return new ProxyViewSessionContext + { + SessionId = "s-1", + WorkspaceItemId = "aaaaaaaa-0000-0000-0000-000000000001", + WorkspaceFingerprint = "fp-1", + ClientMachine = "BEAST", + }; + } + + [Fact] + public void ReportBuildsAPayloadCarryingSessionAttributionAndCanonicalOrigin() + { + ProxyBlockReportPayload sent = null; + var reporter = new ProxyBlockReporter(Context(), payload => sent = payload, null); + + reporter.Report(new Uri("https://Vendor.Test:8443/some/deep/path?query=1"), ProxyBlockReasons.NotAllowed); + + Assert.NotNull(sent); + Assert.Equal("aaaaaaaa-0000-0000-0000-000000000001", sent.WorkspaceItemId); + Assert.Equal("fp-1", sent.WorkspaceFingerprint); + Assert.Equal("BEAST", sent.ClientMachine); + Assert.Equal("https://vendor.test:8443", sent.Origin); + Assert.Equal(ProxyBlockReasons.NotAllowed, sent.Reason); + Assert.Equal(1, sent.Count); + } + + [Fact] + public void StandaloneViewsWithoutAWorkspaceFileNothing() + { + var sends = 0; + var reporter = new ProxyBlockReporter(new ProxyViewSessionContext { SessionId = "s-1" }, payload => sends++, null); + reporter.Report(new Uri("http://one.test:80/"), ProxyBlockReasons.NotAllowed); + Assert.Equal(0, sends); + } + + [Fact] + public void EmptyReasonOrOriginSendsNothing() + { + var sends = 0; + var reporter = new ProxyBlockReporter(Context(), payload => sends++, null); + reporter.Report(new Uri("http://one.test:80/"), null); + reporter.Report(null, ProxyBlockReasons.NotAllowed); + Assert.Equal(0, sends); + } + + [Fact] + public void AThrowingSendIsSwallowedAndLogged() + { + var log = string.Empty; + var reporter = new ProxyBlockReporter( + Context(), + payload => throw new InvalidOperationException("channel gone"), + message => log = message); + + reporter.Report(new Uri("http://one.test:80/"), ProxyBlockReasons.TunnelRefused); + Assert.Contains("channel gone", log); + } + } +} diff --git a/src/WebContent.Tests/ProxyBlockTallyTests.cs b/src/WebContent.Tests/ProxyBlockTallyTests.cs new file mode 100644 index 0000000..aebded4 --- /dev/null +++ b/src/WebContent.Tests/ProxyBlockTallyTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #83: the per-view blocked-resource tally. It must count every client-side refusal + /// locally (regardless of the ES forwarder's health), dedup per canonical origin + reason, + /// stay bounded, reset on demand, and never throw or bury the log during a refusal storm. + /// + public class ProxyBlockTallyTests + { + private static readonly DateTime T0 = new DateTime(2026, 11, 2, 8, 0, 0, DateTimeKind.Utc); + + private static ProxyBlockTally Tally(List logs, Func clock = null) + { + return new ProxyBlockTally(message => logs.Add(message), clock ?? (() => T0)); + } + + [Fact] + public void RefusalsCountLocallyAndDedupPerCanonicalOriginAndReason() + { + var logs = new List(); + var tally = Tally(logs); + + tally.Report(new Uri("http://cdn.test/img/one.png"), ProxyBlockReasons.NotAllowed); + tally.Report(new Uri("http://cdn.test/img/two.png"), ProxyBlockReasons.NotAllowed); + tally.Report(new Uri("http://cdn.test/"), ProxyBlockReasons.Timeout); + + Assert.Equal(3, tally.TotalBlocks); + var rows = tally.Snapshot(); + Assert.Equal(2, rows.Count); + Assert.All(rows, row => Assert.Equal("http://cdn.test", row.Origin)); + var notAllowed = rows.Find(row => row.Reason == ProxyBlockReasons.NotAllowed); + Assert.Equal(2, notAllowed.Count); + Assert.Equal(1, rows.Find(row => row.Reason == ProxyBlockReasons.Timeout).Count); + + // One marker per NEW origin+reason - the repeat of the first key stayed quiet. + Assert.Equal(2, logs.Count); + Assert.Contains("origin='http://cdn.test' reason=NotAllowed", string.Join("\n", logs)); + } + + [Fact] + public void SnapshotIsMostRecentlySeenFirst() + { + var ticks = 0; + var tally = Tally(new List(), () => T0.AddSeconds(++ticks)); + + tally.Report(new Uri("http://first.test/"), ProxyBlockReasons.NotAllowed); + tally.Report(new Uri("http://second.test/"), ProxyBlockReasons.NotAllowed); + // A fresh sighting of the older row promotes it to the top. + tally.Report(new Uri("http://first.test/"), ProxyBlockReasons.NotAllowed); + + var rows = tally.Snapshot(); + Assert.Equal("http://first.test", rows[0].Origin); + Assert.Equal("http://second.test", rows[1].Origin); + Assert.Equal(2, rows[0].Count); + } + + [Fact] + public void ResetForgetsThePageLoadAndRaisesTheChange() + { + var tally = Tally(new List()); + var changes = 0; + tally.Changed += () => changes++; + + tally.Report(new Uri("http://cdn.test/x.png"), ProxyBlockReasons.NotAllowed); + tally.Report(new Uri("http://cdn.test/x.png"), ProxyBlockReasons.NotAllowed); + Assert.Equal(2, changes); + + tally.Reset(); + + Assert.Equal(0, tally.TotalBlocks); + Assert.Empty(tally.Snapshot()); + Assert.Equal(3, changes); + + // Reset of an already-empty tally is a quiet no-op (no spurious indicator churn). + tally.Reset(); + Assert.Equal(3, changes); + } + + [Fact] + public void NullAndEmptyRefusalsCountNothing() + { + var tally = Tally(new List()); + tally.Report(null, ProxyBlockReasons.NotAllowed); + tally.Report(new Uri("http://cdn.test/"), null); + tally.Report(new Uri("http://cdn.test/"), ""); + + Assert.Equal(0, tally.TotalBlocks); + Assert.Empty(tally.Snapshot()); + } + + [Fact] + public void EntriesAreBoundedAndEvictTheLeastRecentlySeen() + { + var ticks = 0; + var tally = Tally(new List(), () => T0.AddSeconds(++ticks)); + + for (var i = 0; i < ProxyBlockTally.MaxEntries; i++) + { + tally.Report(new Uri($"http://host{i:D3}.test/"), ProxyBlockReasons.NotAllowed); + } + + Assert.Equal(ProxyBlockTally.MaxEntries, tally.Snapshot().Count); + var total = tally.TotalBlocks; + + // One more distinct origin: the row evicted is the least recently SEEN (host000). + tally.Report(new Uri("http://newcomer.test/"), ProxyBlockReasons.NotAllowed); + + Assert.Equal(ProxyBlockTally.MaxEntries, tally.Snapshot().Count); + Assert.Equal(total + 1, tally.TotalBlocks); + var rows = tally.Snapshot(); + Assert.NotNull(rows.Find(row => row.Origin == "http://newcomer.test")); + Assert.Null(rows.Find(row => row.Origin == "http://host000.test")); + Assert.NotNull(rows.Find(row => row.Origin == "http://host001.test")); + } + + [Fact] + public void RefusalsForwardToTheEsReporterAndLocalCountsSurviveADeadForwarder() + { + var forwarded = new List(); + var logs = new List(); + var tally = Tally(logs); + tally.SetForwarder(new RecordingReporter(forwarded)); + + tally.Report(new Uri("https://Vendor.Test:8443/deep/path"), ProxyBlockReasons.TunnelRefused); + Assert.Equal(new[] { "https://vendor.test:8443|" + ProxyBlockReasons.TunnelRefused }, forwarded); + Assert.Equal(1, tally.TotalBlocks); + + // A throwing forwarder (dead messaging channel) must never reach the browser flow, + // and the local count - what the indicator reads - stays intact. + tally.SetForwarder(new ThrowingReporter()); + tally.Report(new Uri("http://cdn.test/x.png"), ProxyBlockReasons.NotAllowed); + + Assert.Equal(2, tally.TotalBlocks); + Assert.NotNull(tally.Snapshot().Find(row => row.Reason == ProxyBlockReasons.NotAllowed)); + Assert.Contains("forward", string.Join("\n", logs).ToLowerInvariant()); + } + + [Fact] + public void TallyComposedOverTheEsReporterSendsBothHalves() + { + // The production composition: the tally's forwarder IS the #69 ProxyBlockReporter. + // One refusal must produce one ES payload AND one local row. + ProxyBlockReportPayload sent = null; + var esReporter = new ProxyBlockReporter( + new ProxyViewSessionContext + { + SessionId = "s-1", + WorkspaceItemId = "aaaaaaaa-0000-0000-0000-000000000001", + WorkspaceFingerprint = "fp-1", + ClientMachine = "BEAST", + }, + payload => sent = payload, + null); + var tally = Tally(new List()); + tally.SetForwarder(esReporter); + + tally.Report(new Uri("http://cdn.test/x.png"), ProxyBlockReasons.NotAllowed); + + Assert.NotNull(sent); + Assert.Equal("http://cdn.test", sent.Origin); + Assert.Equal(ProxyBlockReasons.NotAllowed, sent.Reason); + Assert.Equal(1, tally.TotalBlocks); + } + + [Fact] + public void ForwarderSwapDetachesTheStoppedListenerReporter() + { + var forwarded = new List(); + var tally = Tally(new List()); + tally.SetForwarder(new RecordingReporter(forwarded)); + tally.SetForwarder(null); + + tally.Report(new Uri("http://cdn.test/x.png"), ProxyBlockReasons.NotAllowed); + + Assert.Empty(forwarded); + Assert.Equal(1, tally.TotalBlocks); + } + + [Fact] + public void SubscriberFaultsNeverBreakTheCountOrThrowIntoTheFlow() + { + var logs = new List(); + var tally = Tally(logs); + tally.Changed += () => throw new InvalidOperationException("indicator went boom"); + + tally.Report(new Uri("http://cdn.test/x.png"), ProxyBlockReasons.NotAllowed); + + Assert.Equal(1, tally.TotalBlocks); + Assert.Contains("indicator went boom", string.Join("\n", logs)); + } + + [Fact] + public void ReasonLabelsMatchTheSharedCauseCategories() + { + Assert.Equal("not allowed by administrator", ProxyBlockDisplay.ReasonLabel(ProxyBlockReasons.NotAllowed)); + Assert.Equal("response too large", ProxyBlockDisplay.ReasonLabel(ProxyBlockReasons.TooLarge)); + Assert.Equal("certificate not trusted", ProxyBlockDisplay.ReasonLabel(ProxyBlockReasons.CertUntrusted)); + Assert.Equal("target unreachable", ProxyBlockDisplay.ReasonLabel(ProxyBlockReasons.Unreachable)); + Assert.Equal("target timed out", ProxyBlockDisplay.ReasonLabel(ProxyBlockReasons.Timeout)); + Assert.Equal("proxy tunnel refused", ProxyBlockDisplay.ReasonLabel(ProxyBlockReasons.TunnelRefused)); + Assert.Equal("(unknown)", ProxyBlockDisplay.ReasonLabel("SomethingElse")); + } + + private sealed class RecordingReporter : IProxyBlockReporter + { + private readonly List _seen; + + internal RecordingReporter(List seen) => _seen = seen; + + public void Report(Uri origin, string reason) => _seen.Add(ProxyPolicy.CanonicalOrigin(origin) + "|" + reason); + } + + private sealed class ThrowingReporter : IProxyBlockReporter + { + public void Report(Uri origin, string reason) => throw new InvalidOperationException("messaging channel is dead"); + } + } +} diff --git a/src/WebContent.Tests/ProxyBrowserArgumentsTests.cs b/src/WebContent.Tests/ProxyBrowserArgumentsTests.cs new file mode 100644 index 0000000..f028a1d --- /dev/null +++ b/src/WebContent.Tests/ProxyBrowserArgumentsTests.cs @@ -0,0 +1,66 @@ +using System; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #54 AC1: the proxied workspace's WebView2 environment is created with the browser + /// proxy-server argument pointed at the loopback listener. These tests pin the argument + /// builder: every http and https scheme is pinned to the listener (never direct) and + /// Chromium's implicit localhost bypass is turned off so even loopback targets follow the rule. + /// + public class ProxyBrowserArgumentsTests + { + [Fact] + public void PinsHttpAndHttpsTrafficToTheLoopbackListener() + { + var args = ProxyBrowserArguments.For(49312); + + Assert.Contains("--proxy-server=http=http://127.0.0.1:49312;https=http://127.0.0.1:49312", args); + } + + [Fact] + public void RemovesChromiumImplicitLoopbackBypass() + { + // By default Chromium bypasses proxies for localhost URLs; "<-loopback" re-enlists + // them so an allowlisted device on loopback still routes through the VMS fetcher. + // Values carry no spaces, so no quoting layer is involved. + var args = ProxyBrowserArguments.For(49312); + + Assert.Contains("--proxy-bypass-list=<-loopback>", args); + } + + [Fact] + public void ThePortIndependentFingerprintIsTheHashOfThePortSubstitutedArgumentTemplate() + { + // #95: every process on this build derives the identical value (join check), and it + // covers EVERY port-independent part of the arguments - recompute the template by + // hand and the hash must match, so changing For() changes the fingerprint. + var fp = ProxyBrowserArguments.PortIndependentFingerprint(); + var template = ProxyBrowserArguments.For(0).Replace("127.0.0.1:0", "127.0.0.1:{port}"); + + string expected; + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + expected = BitConverter.ToString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(template))) + .Replace("-", "") + .ToLowerInvariant(); + } + + Assert.Equal(expected, fp); + } + + [Fact] + public void TheFingerprintIsAClaimFileSafeSingleHexToken() + { + // The claim line format is name=value split on the FIRST '='; the value must carry no + // spaces, newlines or leading structure the strict parser could trip on. + var fp = ProxyBrowserArguments.PortIndependentFingerprint(); + + Assert.Matches("^[0-9a-f]{64}$", fp); + Assert.DoesNotContain(" ", fp); + Assert.DoesNotContain("\n", fp); + } + } +} diff --git a/src/WebContent.Tests/ProxyCaExportTests.cs b/src/WebContent.Tests/ProxyCaExportTests.cs new file mode 100644 index 0000000..d9f977e --- /dev/null +++ b/src/WebContent.Tests/ProxyCaExportTests.cs @@ -0,0 +1,429 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; + +using WebContent.Admin; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #75: the Event Server half of the deployment CA public-certificate export — + /// provenance-independent resolve from the LocalMachine\Personal authority store, the + /// "does not exist yet" refusal posture, and the never-an-empty-file discipline on the + /// Management Client side. The store itself is never touched: the responder runs against a + /// fake finder of minted certificates, and source guards pin the read-only store access. + /// + public class ProxyCaExportTests + { + // ---------------------------------------------------------------- responses + + [Fact] + public void AMissingCorrelationIdIsRefusedAsBadRequest() + { + var responder = new ProxyCaExportResponder(() => new List(), null); + + var noRequest = responder.Respond(null); + Assert.False(noRequest.Success); + Assert.Equal(ProxyFetchErrorCodes.BadRequest, noRequest.ErrorCode); + + var noCorrelation = responder.Respond(new ProxyCaExportRequestPayload()); + Assert.False(noCorrelation.Success); + Assert.Equal(ProxyFetchErrorCodes.BadRequest, noCorrelation.ErrorCode); + } + + [Fact] + public void NoCertificateYetRefusesWithTheActionableDoesNotExistMessage() + { + var responder = new ProxyCaExportResponder(() => new List(), null); + + var response = responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-1" }); + + Assert.False(response.Success); + Assert.Equal(ProxyFetchErrorCodes.NoAuthority, response.ErrorCode); + Assert.Contains("does not exist yet", response.ErrorMessage, StringComparison.Ordinal); + Assert.Contains("provision-event-server-authority.ps1", response.ErrorMessage, StringComparison.Ordinal); + Assert.Null(response.AuthorityDerBase64); + } + + [Fact] + public void OnlyExpiredCopiesRefuseAsExpiredNotAsMissing() + { + using var expired = MintAuthorityCertificate( + DateTimeOffset.UtcNow.AddYears(-3), DateTimeOffset.UtcNow.AddYears(-1)); + + var responder = new ProxyCaExportResponder( + () => new List { ClonePublic(expired) }, null); + + var response = responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-2" }); + + Assert.False(response.Success); + Assert.Equal(ProxyFetchErrorCodes.NoAuthority, response.ErrorCode); + Assert.Contains("expired", response.ErrorMessage, StringComparison.Ordinal); + Assert.Null(response.AuthorityDerBase64); + } + + [Fact] + public void AValidCertificateExportsDerThumbprintAndValidityProvenanceIndependently() + { + // Provenance-independence: the responder never asks who created the copy — the fake + // finder hands it a minted certificate exactly as the store read would, and the DER + // that comes back is byte-identical to what was stored. + using var ca = MintAuthorityCertificate(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(3650)); + + var responder = new ProxyCaExportResponder( + () => new List { ClonePublic(ca) }, null); + + var response = responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-3" }); + + Assert.True(response.Success); + Assert.Equal("c-3", response.CorrelationId); + Assert.Equal(Convert.ToBase64String(ca.RawData), response.AuthorityDerBase64); + Assert.Equal(ca.Thumbprint, response.AuthorityThumbprint); + var caNotBeforeUtc = ca.NotBefore.ToUniversalTime(); + var caNotAfterUtc = ca.NotAfter.ToUniversalTime(); + Assert.InRange(response.NotBeforeUtc, caNotBeforeUtc.AddMinutes(-1), caNotBeforeUtc.AddMinutes(1)); + Assert.InRange(response.NotAfterUtc, caNotAfterUtc.AddMinutes(-1), caNotAfterUtc.AddMinutes(1)); + + // The AC's no-chunking proof: a production-shaped 2048-bit CA frame stays far under + // the cap, and the cap itself is under the proven single-frame chunk headroom. + Assert.True(ca.RawData.Length <= ProxyCaExportLimits.MaxAuthorityDerBytes, + $"CA DER {ca.RawData.Length} bytes exceeds the sanity cap."); + var wire = ProxyFetchWireCodec.ToWire(response); + Assert.True(wire.Length <= ProxyCaExportLimits.MaxResponseWireChars, + $"Export frame {wire.Length} chars exceeds the single-frame cap."); + var provenChunkHeadroom = (int)Math.Ceiling(ProxyFetchLimits.DefaultChunkSizeBytes / 3d) * 4 + 256; + Assert.True(ProxyCaExportLimits.MaxResponseWireChars < provenChunkHeadroom, + "The cap must stay under the proven fetch-chunk single-frame size."); + } + + [Fact] + public void ExpiredAndValidCopiesInAnyOrderExportTheValidOne() + { + using var expired = MintAuthorityCertificate( + DateTimeOffset.UtcNow.AddYears(-5), DateTimeOffset.UtcNow.AddYears(-1)); + using var fresh = MintAuthorityCertificate( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(3650)); + + foreach (var order in new[] + { + new List { ClonePublic(expired), ClonePublic(fresh) }, + new List { ClonePublic(fresh), ClonePublic(expired) }, + }) + { + var responder = new ProxyCaExportResponder(() => order, null); + + var response = responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-4" }); + + Assert.True(response.Success); + Assert.Equal(fresh.Thumbprint, response.AuthorityThumbprint); + } + } + + [Fact] + public void ThePrivateKeyCopyOfTheAuthorityIsPreferredWhenBothCopiesExist() + { + // Two DIFFERENT valid certificates sit in the store (a re-imported public-only copy + // and the minting path's copy whose private key lives here). Distinct thumbprints + // make the preference observable: the live authority must answer, never the shadow. + using var publicAuthority = MintAuthorityCertificate(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(3650)); + using var keyedAuthority = MintAuthorityCertificate(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(3650)); + + foreach (var order in new[] + { + new List { ClonePublic(publicAuthority), WithPrivateKey(keyedAuthority) }, + new List { WithPrivateKey(keyedAuthority), ClonePublic(publicAuthority) }, + }) + { + var responder = new ProxyCaExportResponder(() => order, null); + + var response = responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-5" }); + + Assert.True(response.Success); + Assert.Equal(keyedAuthority.Thumbprint, response.AuthorityThumbprint); + } + } + + [Fact] + public void AStoreFaultAnswersErrorAndNeverThrowsOnTheMessagingThread() + { + var responder = new ProxyCaExportResponder( + () => throw new InvalidOperationException("Access denied to the machine store."), null); + + var response = responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-6" }); + + Assert.False(response.Success); + Assert.Equal(ProxyFetchErrorCodes.Error, response.ErrorCode); + Assert.Contains("Access denied", response.ErrorMessage, StringComparison.Ordinal); + } + + [Fact] + public void AnImplausiblyLargeCertificateIsRefusedNotTransmitted() + { + using var bloated = MintAuthorityCertificate( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(3650), paddingBytes: 12 * 1024); + Assert.True(bloated.RawData.Length > ProxyCaExportLimits.MaxAuthorityDerBytes, + "The fixture must exceed the cap for this test to mean anything."); + + var responder = new ProxyCaExportResponder( + () => new List { ClonePublic(bloated) }, null); + + var response = responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-7" }); + + Assert.False(response.Success); + Assert.Equal(ProxyFetchErrorCodes.Error, response.ErrorCode); + Assert.Contains("implausibly large", response.ErrorMessage, StringComparison.Ordinal); + } + + // ---------------------------------------------------------------- wire codec + + [Fact] + public void ExportRequestRoundTripsAsOneJsonString() + { + var wire = ProxyFetchWireCodec.ToWire(new ProxyCaExportRequestPayload { CorrelationId = "c-8" }); + + var back = ProxyFetchWireCodec.CaExportRequestFromWire(wire); + Assert.NotNull(back); + Assert.Equal("c-8", back.CorrelationId); + } + + [Fact] + public void ExportSuccessFrameRoundTripsWithDerThumbprintAndValidity() + { + using var ca = MintAuthorityCertificate(DateTimeOffset.UtcNow.AddDays(-10), DateTimeOffset.UtcNow.AddDays(100)); + + var wire = ProxyFetchWireCodec.ToWire(new ProxyCaExportResponsePayload + { + CorrelationId = "c-9", + Success = true, + AuthorityDerBase64 = Convert.ToBase64String(ca.RawData), + AuthorityThumbprint = ca.Thumbprint, + NotBeforeUtc = ca.NotBefore.ToUniversalTime(), + NotAfterUtc = ca.NotAfter.ToUniversalTime(), + }); + + var back = ProxyFetchWireCodec.CaExportResponseFromWire(wire); + Assert.NotNull(back); + Assert.True(back.Success); + Assert.Equal(ca.Thumbprint, back.AuthorityThumbprint); + Assert.Equal(Convert.ToBase64String(ca.RawData), back.AuthorityDerBase64); + Assert.Equal(ca.NotBefore.ToUniversalTime(), back.NotBeforeUtc); + Assert.Equal(ca.NotAfter.ToUniversalTime(), back.NotAfterUtc); + } + + [Fact] + public void ExportFailureFrameRoundTripsAndForeignDataStaysUnusable() + { + var wire = ProxyFetchWireCodec.ToWire(new ProxyCaExportResponsePayload + { + CorrelationId = "c-10", + Success = false, + ErrorCode = ProxyFetchErrorCodes.NoAuthority, + ErrorMessage = "The proxy authority certificate does not exist yet.", + }); + + var back = ProxyFetchWireCodec.CaExportResponseFromWire(wire); + Assert.NotNull(back); + Assert.False(back.Success); + Assert.Equal(ProxyFetchErrorCodes.NoAuthority, back.ErrorCode); + + Assert.Null(ProxyFetchWireCodec.CaExportRequestFromWire(42)); + Assert.Null(ProxyFetchWireCodec.CaExportResponseFromWire("not-json")); + } + + // ---------------------------------------------------------------- correlation + + [Fact] + public void AnUnansweredPullCompletesAsServerUnavailable() + { + var correlation = new ProxyCaExportCorrelation(); + + var completion = correlation.Begin("c-11", TimeSpan.FromMilliseconds(50)); + var completed = completion.Wait(2000); + + Assert.True(completed, "the deadline never fired"); + var response = completion.Result; + Assert.False(response.Success); + Assert.Equal(ProxyFetchErrorCodes.ServerUnavailable, response.ErrorCode); + } + + [Fact] + public void OnlyThePendingExchangeOwnsAResponseFrame() + { + var correlation = new ProxyCaExportCorrelation(); + var completion = correlation.Begin("c-12", TimeSpan.FromSeconds(30)); + + Assert.False(correlation.Feed(new ProxyCaExportResponsePayload { CorrelationId = "other" })); + Assert.True(correlation.Feed(new ProxyCaExportResponsePayload + { + CorrelationId = "c-12", + Success = true, + AuthorityThumbprint = "AA", + })); + Assert.False(correlation.Feed(new ProxyCaExportResponsePayload { CorrelationId = "c-12" })); + + Assert.True(completion.Wait(1000)); + Assert.True(completion.Result.Success); + } + + // ---------------------------------------------------------------- MC view + + [Fact] + public void ThePageNeverSeesDecodableBytesForAnyFailurePosture() + { + // The ticket's failure to avoid: an empty .cer. Every non-answer — silence, an + // absent CA, even a "success" frame with a blank or corrupt DER — decodes to a + // failure with no bytes to save. + var cases = new[] + { + null, + new ProxyCaExportResponsePayload { Success = false, ErrorCode = ProxyFetchErrorCodes.NoAuthority }, + new ProxyCaExportResponsePayload { Success = false, ErrorCode = ProxyFetchErrorCodes.ServerUnavailable }, + new ProxyCaExportResponsePayload { Success = true }, + new ProxyCaExportResponsePayload { Success = true, AuthorityDerBase64 = string.Empty }, + new ProxyCaExportResponsePayload { Success = true, AuthorityDerBase64 = "not base64 !!" }, + new ProxyCaExportResponsePayload { Success = true, AuthorityDerBase64 = Convert.ToBase64String(Array.Empty()) }, + }; + + foreach (var payload in cases) + { + var view = ProxyCaExportViewBuilder.Build(payload); + Assert.False(view.ExportAvailable); + Assert.NotNull(view.FailureCode); + Assert.Null(view.DerBytes); + } + } + + [Fact] + public void AUsableCertificateDecodesToBytesWithThumbprintAndValidity() + { + using var ca = MintAuthorityCertificate(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(90)); + + var view = ProxyCaExportViewBuilder.Build(new ProxyCaExportResponsePayload + { + Success = true, + AuthorityDerBase64 = Convert.ToBase64String(ca.RawData), + AuthorityThumbprint = ca.Thumbprint, + NotBeforeUtc = ca.NotBefore.ToUniversalTime(), + NotAfterUtc = ca.NotAfter.ToUniversalTime(), + }); + + Assert.True(view.ExportAvailable); + Assert.Equal(ca.RawData, view.DerBytes); + Assert.Equal(ca.Thumbprint, view.Thumbprint); + Assert.Equal(ca.NotAfter.ToUniversalTime(), view.NotAfterUtc); + Assert.Null(view.FailureCode); + } + + [Fact] + public void TheMissingCaCopyNamesTheTicketPhraseInBothEndpointStrings() + { + // The AC's exact posture lives in two places: the Event Server payload message and + // the Management Client's rendered sentence. Pin both wordings here. + var responder = new ProxyCaExportResponder(() => new List(), null); + Assert.Contains( + "proxy authority certificate does not exist yet", + responder.Respond(new ProxyCaExportRequestPayload { CorrelationId = "c-13" }).ErrorMessage, + StringComparison.OrdinalIgnoreCase); + + var resx = File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "Strings.resx")); + var missingEntry = Regex.Match(resx, @"]*>\s*(.*?)", RegexOptions.Singleline); + Assert.True(missingEntry.Success, "ProxyCaExportMissing is not in Strings.resx."); + Assert.Contains( + "proxy authority certificate does not exist yet", + missingEntry.Groups[1].Value, + StringComparison.OrdinalIgnoreCase); + } + + // ---------------------------------------------------------------- source guards + + [Fact] + public void TheStoreReadIsReadOnlyAgainstTheFixedAuthorityName() + { + // Export must never provision or mutate (that stays the minting path's job) and must + // resolve the exact same authority the leaf issuer loads: fixed CN, LocalMachine\Personal. + var responderSource = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyCaExportResponder.cs")); + + Assert.Contains("OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly", responderSource); + Assert.Contains("FindBySubjectName, ProxyLoopbackTls.AuthorityCommonName, false", responderSource); + Assert.Contains("StoreName.My, StoreLocation.LocalMachine", responderSource); + Assert.DoesNotContain("store.Add(", responderSource); + Assert.DoesNotContain("ReadWrite", responderSource); + } + + [Fact] + public void TheExportAnswerIsUnicastToTheRequesterNeverBroadcast() + { + var fetcher = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyFetchBackgroundPlugin.cs")); + var collapsed = Regex.Replace(fetcher, @"\s+", " "); + + Assert.Contains("new CommunicationIdFilter(ProxyMessageIds.CaExportRequestId)", fetcher); + Assert.Contains("ProxyCaExportResponder.FindDeploymentAuthorityCertificates", fetcher); + Assert.Contains("ProxyMessageIds.CaExportResponseId, ProxyFetchWireCodec.ToWire(response)), requester, null, null);", collapsed); + } + + [Fact] + public void TheRequesterSideRegistersTheAnswerFilterAndTransmitsThroughTheCodec() + { + var client = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent", "ProxyFetchClient.cs")); + + Assert.Contains("new CommunicationIdFilter(ProxyMessageIds.CaExportResponseId)", client); + Assert.Contains("ProxyFetchWireCodec.CaExportResponseFromWire(message.Data)", client); + Assert.Contains("ProxyFetchWireCodec.ToWire(new ProxyCaExportRequestPayload", client); + } + + // ---------------------------------------------------------------- fixtures + + /// A deployment-CA-shaped self-signed RSA certificate (same shape the mint path builds). + private static X509Certificate2 MintAuthorityCertificate( + DateTimeOffset notBefore, DateTimeOffset notAfter, int paddingBytes = 0) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest( + new X500DistinguishedName("CN=" + ProxyLoopbackTls.AuthorityCommonName), + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, + true)); + if (paddingBytes > 0) + { + var padding = new byte[paddingBytes]; + for (var i = 0; i < padding.Length; i++) + { + padding[i] = (byte)(i % 251); + } + + request.CertificateExtensions.Add(new X509Extension(new Oid("1.3.6.1.4.1.99999.1"), padding, false)); + } + + return request.CreateSelfSigned(notBefore, notAfter); + } + + /// The public part of a certificate, the way a re-imported copy sits in the store. + private static X509Certificate2 ClonePublic(X509Certificate2 certificate) + { + return X509CertificateLoader.LoadCertificate(certificate.RawData); + } + + /// + /// A copy carrying the private key, the way the minting path's authority copy sits in + /// the store (PFX roundtrip - the same recipe the modern backend's creation path uses). + /// + private static X509Certificate2 WithPrivateKey(X509Certificate2 certificate) + { + var password = Guid.NewGuid().ToString("N"); + var portable = certificate.Export(X509ContentType.Pkcs12, password); + return X509CertificateLoader.LoadPkcs12(portable, password); + } + } +} diff --git a/src/WebContent.Tests/ProxyCaProvisionCoreTests.cs b/src/WebContent.Tests/ProxyCaProvisionCoreTests.cs new file mode 100644 index 0000000..b9abffb --- /dev/null +++ b/src/WebContent.Tests/ProxyCaProvisionCoreTests.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; + +using WebContent.ProxyCa; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #77: decision table for the MSI deployment-CA custom action's core + /// (src/WebContentProxyCa, linked here exactly like the #82 ServiceCtlCore). The + /// fakes stand in for the SCM reader, the plugin load-or-create gateway, and the + /// private-key grant - the real implementations live in Program.cs (they touch the + /// registry, the installed plugin assembly, and MachineKeys ACLs) and are exercised + /// live on the Event Server machine (verification-matrix IN-3). + /// + public class ProxyCaProvisionCoreTests + { + private sealed class RecordingLog + { + public List Lines { get; } = new List(); + public Action Act => Lines.Add; + } + + private sealed class FakeAccounts : IServiceAccountReader + { + public FakeAccounts(string objectName) + { + ObjectName = objectName; + } + + public string ObjectName { get; } + public string SeenServiceName { get; private set; } + + public string ReadObjectName(string serviceName) + { + SeenServiceName = serviceName; + return ObjectName; + } + } + + private sealed class FakeGateway : IAuthorityGateway + { + public Exception Throw { get; set; } + public string SeenPluginPath { get; private set; } + public int Calls { get; private set; } + + public void Provision(string pluginDllPath, Action log) + { + Calls++; + SeenPluginPath = pluginDllPath; + if (Throw != null) + { + throw Throw; + } + } + } + + private sealed class FakeGrant : IPrivateKeyAccessGrant + { + public bool Result { get; set; } = true; + public string SeenAccount { get; private set; } + public int Calls { get; private set; } + + public bool EnsureServiceAccountCanReadKey(string account, Action log) + { + Calls++; + SeenAccount = account; + return Result; + } + } + + // ---------------------------------------------------------------- parsing + + public static IEnumerable BadArgumentVectors() + { + yield return new object[] { null }; // no args at all + yield return new object[] { new string[0] }; // empty vector + yield return new object[] { new[] { "provision" } }; // no service name + yield return new object[] { new[] { "provision", "svc", "" } }; // blank override + yield return new object[] { new[] { "provision", "svc", "a", "b" } }; // too many + yield return new object[] { new[] { "start", "svc" } }; // unknown verb + yield return new object[] { new[] { "", "svc" } }; // blank verb + yield return new object[] { new[] { "provision", "" } }; // blank service + yield return new object[] { new[] { "provision", "..\\Other" } }; // path chars cannot escape the registry subkey + yield return new object[] { new[] { "provision", "a=b" } }; // name/value separator + } + + [Theory] + [MemberData(nameof(BadArgumentVectors))] + public void RefusesBadArgumentVectorsBeforeTouchingAnything(string[] args) + { + var gateway = new FakeGateway(); + var grant = new FakeGrant(); + var exit = ProxyCaCore.Run(args, new FakeAccounts("NT AUTHORITY\\NetworkService"), gateway, grant, new RecordingLog().Act); + Assert.Equal(ProxyCaCore.ExitUsage, exit); + Assert.Equal(0, gateway.Calls); + Assert.Equal(0, grant.Calls); + } + + [Fact] + public void ProvisionVerbIsCaseInsensitiveAndDefaultsThePluginPath() + { + string serviceName; + string pluginDllPath; + Assert.True(ProxyCaCore.TryParse(new[] { "Provision", "MilestoneEventServerService" }, out serviceName, out pluginDllPath)); + Assert.Equal("MilestoneEventServerService", serviceName); + Assert.Equal(ProxyCaCore.DefaultPluginDllPath, pluginDllPath); + } + + [Fact] + public void OverridePluginPathFlowsThroughForManualRuns() + { + string serviceName; + string pluginDllPath; + Assert.True(ProxyCaCore.TryParse(new[] { "provision", "svc", @"D:\scratch\WebContent.dll" }, out serviceName, out pluginDllPath)); + Assert.Equal(@"D:\scratch\WebContent.dll", pluginDllPath); + } + + // ---------------------------------------------------------------- account mapping + + [Theory] + [InlineData(null, @"NT AUTHORITY\SYSTEM")] // no ObjectName value: the SCM default + [InlineData("", @"NT AUTHORITY\SYSTEM")] + [InlineData("LocalSystem", @"NT AUTHORITY\SYSTEM")] + [InlineData("NT AUTHORITY\\NetworkService", @"NT AUTHORITY\NetworkService")] + [InlineData("NetworkService", @"NT AUTHORITY\NetworkService")] // case-insensitive suffix normalization + [InlineData("nt authority\\networkservice", @"NT AUTHORITY\NetworkService")] + [InlineData("NT AUTHORITY\\LocalService", @"NT AUTHORITY\LocalService")] + [InlineData(".\\farmsvc", ".\\farmsvc")] // anything else flows through verbatim + public void ServiceAccountMappingCoversTheScmObjectNamespaces(string objectName, string expected) + { + Assert.Equal(expected, ProxyCaCore.MapServiceAccount(objectName)); + } + + // ---------------------------------------------------------------- the run itself + + [Fact] + public void HappyPathFlowsServiceAccountIntoTheGrantAndExitsDone() + { + var accounts = new FakeAccounts("NT AUTHORITY\\NetworkService"); + var gateway = new FakeGateway(); + var grant = new FakeGrant(); + var log = new RecordingLog(); + + var exit = ProxyCaCore.Run(new[] { "provision", "MilestoneEventServerService" }, accounts, gateway, grant, log.Act); + + Assert.Equal(ProxyCaCore.ExitDone, exit); + Assert.Equal("MilestoneEventServerService", accounts.SeenServiceName); + Assert.Equal(ProxyCaCore.DefaultPluginDllPath, gateway.SeenPluginPath); + Assert.Equal(@"NT AUTHORITY\NetworkService", grant.SeenAccount); + Assert.Contains(log.Lines, line => line.Contains("NT AUTHORITY\\NetworkService")); + Assert.Contains(log.Lines, line => line.Contains(ProxyCaCore.AuthorityCommonName)); + } + + [Fact] + public void GatewayFailureSkipsTheGrantAndFailsTheInstall() + { + var gateway = new FakeGateway { Throw = new InvalidOperationException("no CA and this account cannot create one") }; + var grant = new FakeGrant(); + var log = new RecordingLog(); + + var exit = ProxyCaCore.Run(new[] { "provision", "svc" }, new FakeAccounts("LocalSystem"), gateway, grant, log.Act); + + Assert.Equal(ProxyCaCore.ExitProvisionFailed, exit); + Assert.Equal(0, grant.Calls); // no grant against a CA that was never made + Assert.Contains(log.Lines, line => line.Contains("FAILED") && line.Contains("cannot create one")); + } + + [Fact] + public void UnconfirmedKeyGrantFailsTheInstallEvenAfterProvisioning() + { + var gateway = new FakeGateway(); + var grant = new FakeGrant { Result = false }; + var log = new RecordingLog(); + + var exit = ProxyCaCore.Run(new[] { "provision", "svc" }, new FakeAccounts("NT AUTHORITY\\NetworkService"), gateway, grant, log.Act); + + Assert.Equal(ProxyCaCore.ExitProvisionFailed, exit); + Assert.Equal(1, gateway.Calls); + Assert.Equal(1, grant.Calls); + Assert.Contains(log.Lines, line => line.Contains("leaf minting would fail")); + } + + [Fact] + public void NullCollaboratorsThrowInsteadOfSilentlyPassing() + { + Assert.Throws(() => ProxyCaCore.Run(new[] { "provision", "svc" }, null, new FakeGateway(), new FakeGrant(), null)); + Assert.Throws(() => ProxyCaCore.Run(new[] { "provision", "svc" }, new FakeAccounts(null), null, new FakeGrant(), null)); + Assert.Throws(() => ProxyCaCore.Run(new[] { "provision", "svc" }, new FakeAccounts(null), new FakeGateway(), null, null)); + } + + [Fact] + public void NullLogIsTolerated() + { + var exit = ProxyCaCore.Run(new[] { "provision", "svc" }, new FakeAccounts(null), new FakeGateway(), new FakeGrant(), null); + Assert.Equal(ProxyCaCore.ExitDone, exit); + } + + // ---------------------------------------------------------------- constants the MSI depends on + + [Fact] + public void MsiCommandLineAndFixedDropMatchTheAuthoredWxs() + { + // setup.wxs authors ExeCommand="provision MilestoneEventServerService" with no + // override: the fixed drop path and the plain service name must parse. + string serviceName; + string pluginDllPath; + Assert.True(ProxyCaCore.TryParse( + new[] { "provision", "MilestoneEventServerService" }, out serviceName, out pluginDllPath)); + Assert.Equal(@"C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin\WebContent.dll", pluginDllPath); + } + } +} diff --git a/src/WebContent.Tests/ProxyCaTrustBannerWiringGuardTests.cs b/src/WebContent.Tests/ProxyCaTrustBannerWiringGuardTests.cs new file mode 100644 index 0000000..436327f --- /dev/null +++ b/src/WebContent.Tests/ProxyCaTrustBannerWiringGuardTests.cs @@ -0,0 +1,128 @@ +using System.IO; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #72 live-shape guards: the banner is rendered by WPF views the unit harness can + /// never host, so these source pins hold the composition the live probes drive - the named + /// banner elements, the setup-only visibility gate for the fix button, the elevated helper + /// invocation that must never suppress either OS dialog, the post-fix re-verification that + /// never trusts the helper's own claim, and the leaf-cache invalidation that makes the fix + /// take effect without a Smart Client restart. The behavioural decisions sit in + /// ProxyCaTrustStateTests and ProxyTrustHelperTests; the live rows are in + /// docs/verification-matrix.md. + /// + public class ProxyCaTrustBannerWiringGuardTests + { + private static string Read(string file) + { + return File.ReadAllText(Path.Combine(RepoLocator.FindRepoRoot(), "src", "WebContent", "Client", file)); + } + + private static string Collapse(string source) + { + return System.Text.RegularExpressions.Regex.Replace(source, @"\s+", " "); + } + + [Fact] + public void TheBannerElementsExistAndStartCollapsed() + { + var xaml = Read("WebContentUserControl.xaml"); + + // The names are the AutomationIds the UIA probe finds; all three start collapsed so a + // view that never minted through the proxy shows no CA chrome at all. + Assert.Contains("Name=\"proxyCaBanner\" DockPanel.Dock=\"Top\" Visibility=\"Collapsed\"", xaml); + Assert.Contains("Name=\"proxyCaBannerText\"", xaml); + Assert.Contains("Name=\"proxyCaFixButton\" Grid.Column=\"1\" Visibility=\"Collapsed\"", xaml); + Assert.Contains("Name=\"proxyCaDismissButton\"", xaml); + } + + [Fact] + public void TheFixButtonIsSetupStateOnlyWhileTheBannerFollowsThePosture() + { + var collapsed = Collapse(Read("WebContentUserControl.xaml.cs")); + + // Standard users see the banner but never the fix affordance (the ticket's explicit + // posture); the fix click additionally re-gates on setup state, belt and braces. + Assert.Contains( + "proxyCaFixButton.Visibility = _viewModel?.IsSetupState == true ? Visibility.Visible : Visibility.Collapsed;", + collapsed); + Assert.Contains( + "if (trust == null || !trust.IsUntrustedConfirmed || _viewModel?.IsSetupState != true)", + collapsed); + } + + [Fact] + public void TheFixGoesThroughTheBundledHelperViaUacNeverAnInProcessStoreWrite() + { + var collapsed = Collapse(Read("WebContentUserControl.xaml.cs")); + + Assert.Contains("private const string TrustHelperExecutableName = \"WebContentTrustHelper.exe\";", collapsed); + Assert.Contains("Verb = \"runas\",", collapsed); + + // The plugin itself must not reach the machine root store from the fix flow: the only + // LocalMachine involvement in the view code is the READ-only verification probe. + Assert.DoesNotContain("StoreLocation.LocalMachine", Read("WebContentUserControl.xaml.cs")); + } + + [Fact] + public void DecliningUacIsHandledQuietlyAndTheResultIsReVerifiedNotTrusted() + { + var collapsed = Collapse(Read("WebContentUserControl.xaml.cs")); + + // 1223 = the user cancelled the UAC prompt: a logged note, never an exception surface. + Assert.Contains("private const int ErrorCancelled = 1223;", collapsed); + Assert.Contains( + "catch (System.ComponentModel.Win32Exception exception) when (exception.NativeErrorCode == ErrorCancelled)", + collapsed); + + // The helper's exit 0 alone never flips the banner: the plugin re-reads the root stores. + Assert.Contains("var nowTrusted = exitCode == 0 && ProxyLoopbackTrust.IsRootTrusted(", collapsed); + } + + [Fact] + public void AConfirmedFixDropsCachedLeavesAndReloadsHttpsContentWithoutARestart() + { + var collapsed = Collapse(Read("WebContentUserControl.xaml.cs")); + + Assert.Contains("browser?.ProxyListener?.InvalidateLeafCache();", collapsed); + Assert.Contains("_viewModel?.ReloadCommand?.Execute(null);", collapsed); + } + + [Fact] + public void NavigationReChecksTheTrustSoAnExternalFixHealsTheBannerToo() + { + var collapsed = Collapse(Read("BrowserInstance.cs")); + + // The fix helper is not the only way the CA becomes trusted (an admin may import it + // manually); the next navigation re-probes and heals the posture + leaf cache itself. + Assert.Contains("RecheckProxyCaTrust();", collapsed); + Assert.Contains("ProxyListener.InvalidateLeafCache();", collapsed); + } + + [Fact] + public void ProxiedBrowsersGetATrustStateAndProxyOffClearsIt() + { + var collapsed = Collapse(Read("BrowserManager.cs")); + + Assert.Contains("var caTrust = proxyProfile == null ? null : new ProxyCaTrustState();", collapsed); + Assert.Contains("browser.ProxyCaTrust?.Clear();", collapsed); + } + + [Fact] + public void TheHelperShipsInTheDropButThePluginNeverLinksIt() + { + var csproj = File.ReadAllText(Path.Combine( + RepoLocator.FindRepoRoot(), "src", "WebContent", "WebContent.csproj")); + var collapsed = Collapse(csproj); + + // The build order rides the ProjectReference; the plugin must not compile against the + // helper (its store-write code belongs in an elevated separate process only). + Assert.Contains( + " false", + collapsed); + } + } +} diff --git a/src/WebContent.Tests/ProxyCaTrustStateTests.cs b/src/WebContent.Tests/ProxyCaTrustStateTests.cs new file mode 100644 index 0000000..363476f --- /dev/null +++ b/src/WebContent.Tests/ProxyCaTrustStateTests.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #72: the per-browser deployment-CA trust posture the Smart Client banner renders. + /// The holder decides nothing about trust itself - the pins here are that it fires only on + /// real changes (per-page-load mint traffic never re-triggers banner work), that the banner + /// condition IsUntrustedConfirmed needs BOTH a reported CA and a negative verdict, and that + /// switching the proxy off clears the posture so a non-proxied view can never show a CA banner. + /// + public class ProxyCaTrustStateTests + { + [Fact] + public void FreshStateHasNoBannerCondition() + { + var state = new ProxyCaTrustState(); + + Assert.False(state.HasAuthority); + Assert.False(state.IsTrusted); + Assert.False(state.IsUntrustedConfirmed); + } + + [Fact] + public void FirstUntrustedReportRaisesChangeAndConfirmsTheBannerCondition() + { + var state = new ProxyCaTrustState(); + var changes = 0; + state.Changed += () => changes++; + + var changed = state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false); + + Assert.True(changed); + Assert.Equal(1, changes); + Assert.True(state.HasAuthority); + Assert.True(state.IsUntrustedConfirmed); + Assert.Equal("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", state.AuthorityThumbprint); + Assert.Equal("ZGVy", state.AuthorityDerBase64); + } + + [Fact] + public void SamePostureAgainFiresNothing() + { + var state = new ProxyCaTrustState(); + state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false); + var changes = 0; + state.Changed += () => changes++; + + var changed = state.Publish("aaaabbbbccccddddeeeeffff0000111122223333", "ZGVy", false); + + // Case-insensitive thumbprint equality (the store prints hex uppercase): every page + // load re-publishes the same posture and must stay silent. + Assert.False(changed); + Assert.Equal(0, changes); + } + + [Fact] + public void TrustFlipRaisesChangeAndClearsTheBannerCondition() + { + var state = new ProxyCaTrustState(); + state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false); + var changes = 0; + state.Changed += () => changes++; + + var changed = state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", true); + + Assert.True(changed); + Assert.Equal(1, changes); + Assert.True(state.IsTrusted); + Assert.False(state.IsUntrustedConfirmed); + } + + [Fact] + public void DifferentAuthorityThumbprintRaisesChangeEvenWithTheSameVerdict() + { + var state = new ProxyCaTrustState(); + state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false); + var changes = 0; + state.Changed += () => changes++; + + var changed = state.Publish("FFFFEEEDDDCCCBBBAAA000011112222333344445", "YWJj", false); + + // A redeployed CA is a different CA: the per-session banner dismissal was keyed to the + // old thumbprint, so the new authority re-surfaces the banner. + Assert.True(changed); + Assert.Equal(1, changes); + } + + [Fact] + public void ClearAfterAReportRaisesChangeAndKillsTheBannerCondition() + { + var state = new ProxyCaTrustState(); + state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false); + var changes = 0; + state.Changed += () => changes++; + + state.Clear(); + + Assert.Equal(1, changes); + Assert.False(state.HasAuthority); + Assert.False(state.IsUntrustedConfirmed); + Assert.Null(state.AuthorityThumbprint); + Assert.Null(state.AuthorityDerBase64); + } + + [Fact] + public void ClearWhenNothingWasRecordedFiresNothing() + { + var state = new ProxyCaTrustState(); + var changes = 0; + state.Changed += () => changes++; + + state.Clear(); + + Assert.Equal(0, changes); + } + + [Fact] + public void EmptyThumbprintIsNeverPublished() + { + var state = new ProxyCaTrustState(); + + var changed = state.Publish(null, "ZGVy", false); + + Assert.False(changed); + Assert.False(state.HasAuthority); + } + + [Fact] + public void ThrowingSubscriberNeverReachesThePublisher() + { + var state = new ProxyCaTrustState(); + state.Changed += () => throw new InvalidOperationException("banner callback blew up"); + + // The mint thread calls Publish; a throwing UI subscriber must not fault the tunnel. + state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false); + } + + [Fact] + public void LastMintOriginRefreshesQuietlyAndNeverEntersChangeDetection() + { + // #80: the flip-to-untrusted report names the mint that produced the verdict, so + // Publish carries an origin - but the origin is bookkeeping, NOT posture. Every page + // load mints a different origin against the same known posture and must stay silent. + var state = new ProxyCaTrustState(); + var changes = 0; + state.Changed += () => changes++; + + Assert.True(state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false, "crm.example:443")); + Assert.Equal(1, changes); + Assert.Equal("crm.example:443", state.LastMintOrigin); + + Assert.False(state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false, "api.example:443")); + Assert.Equal(1, changes); + Assert.Equal("api.example:443", state.LastMintOrigin); + + // Origin is optional on Publish (the #72 callers predate it); a blank origin keeps + // the last known one rather than wiping the detail the report needs. + Assert.False(state.Publish("AAAABBBBCCCCDDDDEEEEFFFF0000111122223333", "ZGVy", false)); + Assert.Equal("api.example:443", state.LastMintOrigin); + } + } + + /// + /// Ticket #72: the mint flow feeds the posture. Pins: every real mint publishes (the leaf + /// cache short-circuits otherwise), the root-store probe is consulted per mint rather than + /// trusted from the wire, and InvalidateLeafCache forces a re-mint that re-checks a flipped + /// posture - which is what lets a mid-session CA fix work without restarting Smart Client. + /// + public class ProxyLoopbackTlsTrustPostureTests : IDisposable + { + private static readonly Action Sink = _ => { }; + + private readonly Authority _authority = Authority.Create(Sink); + private readonly List _probes = new List(); + private bool _trusted = true; + private readonly ProxyCaTrustState _state = new ProxyCaTrustState(); + private readonly ProxyLoopbackTls _tls; + + public ProxyLoopbackTlsTrustPostureTests() + { + _tls = new ProxyLoopbackTls( + (host, port, spki) => Task.FromResult(new ProxyLeafResponsePayload + { + Success = true, + LeafDerBase64 = Convert.ToBase64String(_authority.MintLeafForPublicKey(host, spki)), + AuthorityDerBase64 = Convert.ToBase64String(_authority.Certificate.RawData), + AuthorityThumbprint = _authority.Certificate.Thumbprint, + }), + Sink, + _state, + thumbprint => { lock (_probes) _probes.Add(thumbprint); return _trusted; }); + } + + [Fact] + public async Task EveryMintPublishesThePostureFromTheLocalRootStoreProbe() + { + var leaf = await _tls.GetOrCreateLeafCertificateAsync("cam1.test", 8443); + + Assert.NotNull(leaf); + Assert.True(_state.HasAuthority); + Assert.Equal(_authority.Certificate.Thumbprint, _state.AuthorityThumbprint); + Assert.Equal(Convert.ToBase64String(_authority.Certificate.RawData), _state.AuthorityDerBase64); + Assert.True(_state.IsTrusted); + Assert.Equal(new[] { _authority.Certificate.Thumbprint }, _probes.ToArray()); + } + + [Fact] + public async Task CachedLeavesDoNotRepublishButAnUntrustedDeploymentConfirmsTheBanner() + { + _trusted = false; + await _tls.GetOrCreateLeafCertificateAsync("cam1.test", 8443); + await _tls.GetOrCreateLeafCertificateAsync("cam1.test", 8443); + + // The cache serves the second origin request without a mint, so the probe ran once. + Assert.Single(_probes); + Assert.True(_state.IsUntrustedConfirmed); + } + + [Fact] + public async Task InvalidateLeafCacheMakesTheNextMintReCheckAFippedPosture() + { + _trusted = false; + var untrusted = await _tls.GetOrCreateLeafCertificateAsync("cam1.test", 8443); + var untrustedThumbprint = untrusted.Thumbprint; + Assert.True(_state.IsUntrustedConfirmed); + + _tls.InvalidateLeafCache(); + _trusted = true; + var reminted = await _tls.GetOrCreateLeafCertificateAsync("cam1.test", 8443); + + // The fix imported the CA; the invalidated cache means the next tunnel gets a fresh + // leaf AND the posture flips without any restart. (InvalidateLeafCache disposes the + // old leaf's handle, so the identity comparison uses the captured thumbprint.) + Assert.NotEqual(untrustedThumbprint, reminted.Thumbprint); + Assert.True(_state.IsTrusted); + Assert.False(_state.IsUntrustedConfirmed); + Assert.Equal(2, _probes.Count); + } + + [Fact] + public async Task MintPublishesCarryTheMintOriginTheUntrustedReportNeeds() + { + // #80: the flip-to-untrusted report names the https origin behind the browser's + // certificate error, sourced from the mint's own cache key - so the posture holder + // must know it even on the FIRST mint (the flip fires there, not later). + await _tls.GetOrCreateLeafCertificateAsync("cam1.test", 8443); + + Assert.Equal("cam1.test:8443", _state.LastMintOrigin); + } + + public void Dispose() + { + _tls?.Dispose(); + _authority?.Dispose(); + } + } +} diff --git a/src/WebContent.Tests/ProxyCaUntrustedReportWiringGuardTests.cs b/src/WebContent.Tests/ProxyCaUntrustedReportWiringGuardTests.cs new file mode 100644 index 0000000..f2a9323 --- /dev/null +++ b/src/WebContent.Tests/ProxyCaUntrustedReportWiringGuardTests.cs @@ -0,0 +1,162 @@ +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #80 live-shape guards: the aggregate crosses three surfaces no unit harness can + /// host end to end - the Smart Client listener that turns a #72 posture FLIP into exactly one + /// CertUntrusted block report named after the mint's origin, the Event Server ingress that + /// attributes only client-decided untrusted reports to a machine, and the Management Client + /// Diagnostics banner that aggregates per machine and carries the #75 export inline. The + /// aggregation maths itself is pinned in ProxyDiagnosticsAggregatorTests and the row render + /// in ProxyDiagnosticsViewTests; the live rows sit in docs/verification-matrix.md. + /// + public class ProxyCaUntrustedReportWiringGuardTests + { + private static string Read(params string[] parts) + { + var segments = new[] { RepoLocator.FindRepoRoot(), "src", "WebContent" }.Concat(parts).ToArray(); + return File.ReadAllText(Path.Combine(segments)); + } + + private static string Collapse(string source) + { + return Regex.Replace(source, @"\s+", " "); + } + + // ---- client: the flip-to-untrusted report ------------------------------------------------ + + [Fact] + public void TheListenerSubscribesToThePostureAndUnsubscribesOnStop() + { + var collapsed = Collapse(Read("Client", "LoopbackProxyListener.cs")); + + // One subscription per listener lifetime, torn down with it: a re-wired listener does + // not double-report, and a stopped browser never reports from beyond its own grave. + Assert.Contains("if (_caTrust != null) { _caTrust.Changed += OnProxyCaTrustChanged; }", collapsed); + Assert.Contains("if (_caTrust != null) { _caTrust.Changed -= OnProxyCaTrustChanged; }", collapsed); + } + + [Fact] + public void OnlyTheConfirmedUntrustedPostureReportsAndItNamesTheMintOrigin() + { + var collapsed = Collapse(Read("Client", "LoopbackProxyListener.cs")); + + // Fixed, cleared, or never-confirmed postures send nothing; the report can only exist + // when a mint named an origin, and it files under the https origin the browser + // actually dialled (the mint cacheKey is host:port). + Assert.Contains("if (_caTrust == null || !_caTrust.IsUntrustedConfirmed)", collapsed); + Assert.Contains("var mintOrigin = _caTrust.LastMintOrigin;", collapsed); + Assert.Contains("Uri.TryCreate(\"https://\" + mintOrigin, UriKind.Absolute, out var origin)", collapsed); + } + + [Fact] + public void TheReportGoesThroughTheBlockReporterNeverTheBlockedResourceTally() + { + var collapsed = Collapse(Read("Client", "LoopbackProxyListener.cs")); + + var handler = Regex.Match( + collapsed, + @"private void OnProxyCaTrustChanged\(\) \{ (?.*?) InvalidateLeafCache", + RegexOptions.Singleline); + Assert.True(handler.Success, "OnProxyCaTrustChanged not found before InvalidateLeafCache."); + + // The ES-side machine attribution consumes block reports; the #83 tally counts URL + // refusals for the user indicator, and a CA trust problem is not one - double + // counting would mislabel the condition (the #72 banner already surfaces it locally). + Assert.Contains("_blocks.Report(origin, ProxyBlockReasons.CertUntrusted);", handler.Groups["body"].Value); + Assert.DoesNotContain("_tally", handler.Groups["body"].Value, System.StringComparison.Ordinal); + } + + // ---- Event Server: attribution only for client-decided untrusted reports ------------------ + + [Fact] + public void TheIngressAttributesMachineReportsOnlyOnTheNewMachineTransition() + { + var collapsed = Collapse(Read("ProxyFetchBackgroundPlugin.cs")); + + // Client-decided CertUntrusted WITH a machine name, and RecordUntrustedAuthorityReport + // returns true only for the FIRST report from that machine - so the operator-facing + // log line fires once per machine, not once per view per page load. + Assert.Contains( + "if (string.Equals(report.Reason, ProxyBlockReasons.CertUntrusted, StringComparison.Ordinal)", + collapsed); + Assert.Contains("&& !string.IsNullOrEmpty(report.ClientMachine)", collapsed); + Assert.Contains( + "&& diagnostics.RecordUntrustedAuthorityReport(report.WorkspaceItemId, report.ClientMachine, DateTime.UtcNow))", + collapsed); + Assert.Contains( + "Proxy CA trust report recorded: workspace='{report.WorkspaceItemId}' machine='{report.ClientMachine}'", + Read("ProxyFetchBackgroundPlugin.cs")); + } + + [Fact] + public void ExactlyOneCallSiteFeedsTheMachineTable() + { + var source = Read("ProxyFetchBackgroundPlugin.cs"); + + // The ES's own TLS failures go through RecordBlockForSession (target-chain at the + // SERVER), and must never attribute a workstation to the machine table. Counting the + // single sanctioned call site keeps a future second call site from creeping in + // unreviewed - it would need a deliberate edit here too. + var calls = Regex.Matches(source, "RecordUntrustedAuthorityReport\\(").Count; + Assert.Equal(1, calls); + Assert.True(Regex.Matches(source, "RecordBlockForSession\\(").Count >= 1); + } + + // ---- Management Client: the aggregate banner and its inline export ------------------------ + + [Fact] + public void TheBannerSurfaceExistsOnTheDiagnosticsTabAndStartsCollapsed() + { + var xaml = Read("Admin", "WorkspaceConfigPage.xaml"); + + Assert.Contains("{0} client machine(s) report that the proxy certificate is NOT trusted", resx); + Assert.Contains("Machines: {0}", resx); + Assert.Contains("{0} ({1} report(s))", resx); + } + } +} diff --git a/src/WebContent.Tests/ProxyConfigurationTests.cs b/src/WebContent.Tests/ProxyConfigurationTests.cs new file mode 100644 index 0000000..8618e7a --- /dev/null +++ b/src/WebContent.Tests/ProxyConfigurationTests.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; + +using WebContent; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #52: the workspace configuration carries a proxy toggle plus an allowlist of proxy + /// targets (scheme, host, port). Definitions saved before the proxy existed — including the + /// v1 blobs already stored by older plugin versions — must mean "proxy off" with no targets, + /// and a corrupt or partial proxy section must never take down the config pipeline. + /// + public class ProxyConfigurationTests + { + private const string LegacyV1Blob = + "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0," + + "\"settings\":{\"homeAddress\":\"https://legacy.example/\",\"hideToolbar\":false," + + "\"hideAddress\":false,\"zoomLevel\":100,\"rememberLastAddress\":false," + + "\"clearBrowsingDataOnExit\":false,\"allowBackgroundBrowser\":true," + + "\"allowAudioNotifications\":true,\"muteAudio\":false,\"isolateBrowserInstances\":false," + + "\"enableDevTools\":false}}]}"; + + [Fact] + public void DefinitionSavedBeforeProxyExistedMeansProxyOff() + { + var settings = WorkspaceConfiguration.Deserialize(LegacyV1Blob).GetFirstWebContentSettings(); + + Assert.NotNull(settings); + Assert.False(settings.ProxyEnabled); + Assert.Null(settings.ProxyTargets); + } + + [Fact] + public void DefaultWorkspaceConfigurationLeavesProxyOff() + { + var settings = WorkspaceConfiguration.CreateDefault("https://example.com/").GetFirstWebContentSettings(); + + Assert.False(settings.ProxyEnabled); + Assert.Null(settings.ProxyTargets); + } + + [Fact] + public void ProxyToggleAndTargetsSurviveTheSerializedRoundtrip() + { + var config = WorkspaceConfiguration.CreateDefault("https://camera-ui.example/"); + var settings = config.GetFirstWebContentSettings(); + settings.ProxyEnabled = true; + settings.ProxyTargets = new List + { + new ProxyTargetConfiguration { Scheme = "https", Host = "10.0.0.5", Port = 8443 }, + new ProxyTargetConfiguration { Scheme = "http", Host = "cam2.local", Port = 80 } + }; + + var parsed = WorkspaceConfiguration.Deserialize(config.Serialize()); + + var proxied = parsed.GetFirstWebContentSettings(); + Assert.True(proxied.ProxyEnabled); + Assert.Equal(2, proxied.ProxyTargets.Count); + Assert.Equal("https", proxied.ProxyTargets[0].Scheme); + Assert.Equal("10.0.0.5", proxied.ProxyTargets[0].Host); + Assert.Equal(8443, proxied.ProxyTargets[0].Port); + Assert.Equal("http", proxied.ProxyTargets[1].Scheme); + Assert.Equal("cam2.local", proxied.ProxyTargets[1].Host); + Assert.Equal(80, proxied.ProxyTargets[1].Port); + } + + // #73: the stored entry shape gains a structured pattern member. Readers accept both + // shapes forever (no migration write-back); a save writes the new one. + + private const string LegacyShapeBlob = + "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0," + + "\"settings\":{\"homeAddress\":\"https://x/\",\"proxyEnabled\":true,\"proxyTargets\":[" + + "{\"scheme\":\"https\",\"host\":\"portal.test\",\"port\":443,\"acceptUntrusted\":true}]}}]}"; + + private const string PatternShapeBlob = + "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0," + + "\"settings\":{\"homeAddress\":\"https://x/\",\"proxyEnabled\":true,\"proxyTargets\":[" + + "{\"scheme\":\"https\",\"pattern\":\"*.portal.test\",\"port\":443,\"acceptUntrusted\":true}," + + "{\"scheme\":\"https\",\"pattern\":\"*\",\"port\":443}]}}]}"; + + [Fact] + public void LegacyHostShapeStillReadsAndCanonicalizesThroughThePatternPath() + { + var settings = WorkspaceConfiguration.Deserialize(LegacyShapeBlob).GetFirstWebContentSettings(); + + Assert.Single(settings.ProxyTargets); + var entry = settings.ProxyTargets[0]; + Assert.Null(entry.Pattern); + Assert.Equal("portal.test", entry.Host); + Assert.Equal("portal.test", entry.EffectivePattern); + Assert.True(entry.AcceptUntrusted); + } + + [Fact] + public void NewPatternShapeReadsIncludingWildcardEntries() + { + var settings = WorkspaceConfiguration.Deserialize(PatternShapeBlob).GetFirstWebContentSettings(); + + Assert.Equal(2, settings.ProxyTargets.Count); + Assert.Equal("*.portal.test", settings.ProxyTargets[0].EffectivePattern); + Assert.True(settings.ProxyTargets[0].AcceptUntrusted); + Assert.Equal("*", settings.ProxyTargets[1].EffectivePattern); + Assert.False(settings.ProxyTargets[1].AcceptUntrusted); + } + + [Fact] + public void SavingAWorkspaceWritesTheNewStructuredPatternShape() + { + var config = WorkspaceConfiguration.CreateDefault("https://x/"); + var settings = config.GetFirstWebContentSettings(); + settings.ProxyEnabled = true; + settings.ProxyTargets = new List + { + new ProxyTargetConfiguration { Scheme = "https", Pattern = "*.portal.test", Port = 443, AcceptUntrusted = true }, + new ProxyTargetConfiguration { Scheme = "https", Pattern = "exact.host", Port = 0 }, + }; + + var json = config.Serialize(); + Assert.Contains("\"pattern\":\"*.portal.test\"", json); + Assert.Contains("\"pattern\":\"exact.host\"", json); + Assert.DoesNotContain("\"host\"", json); + + var reparsed = WorkspaceConfiguration.Deserialize(json).GetFirstWebContentSettings(); + Assert.Equal("*.portal.test", reparsed.ProxyTargets[0].EffectivePattern); + Assert.True(reparsed.ProxyTargets[0].AcceptUntrusted); + Assert.Equal("exact.host", reparsed.ProxyTargets[1].EffectivePattern); + Assert.Equal(0, reparsed.ProxyTargets[1].Port); + } + + [Fact] + public void ReadingALegacyWorkspaceNeverRewritesTheStoredShapeByItself() + { + // No migration write-back: deserializing a legacy blob and re-serializing it (the + // read path does this on inspection) keeps the legacy host member. + var config = WorkspaceConfiguration.Deserialize(LegacyShapeBlob); + var json = config.Serialize(); + + Assert.Contains("\"host\":\"portal.test\"", json); + Assert.DoesNotContain("\"pattern\"", json); + Assert.Equal("portal.test", WorkspaceConfiguration.Deserialize(json).GetFirstWebContentSettings().ProxyTargets[0].EffectivePattern); + } + + // #65: the per-workspace proxyTriggers section. Raw rows on the wire - grammar is decided + // only by ProxyTriggerPolicy on read - and the section's absence (every pre-#65 + // definition) reads as null without an error. + + private const string TriggerSectionBlob = + "{\"version\":1,\"layout\":\"1x1\",\"items\":[{\"type\":\"WebContent\",\"position\":0," + + "\"settings\":{\"homeAddress\":\"https://x/\",\"proxyEnabled\":true,\"proxyTriggers\":[" + + "{\"url\":\"https://*.onvif.org/*\",\"method\":\"GET\",\"status\":\"4xx\"}," + + "{\"url\":\"https://10.0.0.5:9225/snapshot\"}]}}]}"; + + [Fact] + public void TriggerRowsSurviveTheSerializedRoundtrip() + { + var config = WorkspaceConfiguration.CreateDefault("https://camera-ui.example/"); + var settings = config.GetFirstWebContentSettings(); + settings.ProxyTriggers = new List + { + new ProxyTriggerConfiguration { Url = "https://*.onvif.org/*", Method = "GET", Status = "4xx" }, + new ProxyTriggerConfiguration { Url = "https://10.0.0.5:9225/snapshot" }, + }; + + var json = config.Serialize(); + // DCJS escapes '/' in string values, so the raw-wire assertion carries the escaped + // form; the semantic assertions below read the re-parsed shape. + Assert.Contains("\"url\":\"https:\\/\\/*.onvif.org\\/*\"", json); + Assert.Contains("\"method\":\"GET\"", json); + Assert.Contains("\"status\":\"4xx\"", json); + + var reparsed = WorkspaceConfiguration.Deserialize(json).GetFirstWebContentSettings(); + Assert.Equal(2, reparsed.ProxyTriggers.Count); + Assert.Equal("https://*.onvif.org/*", reparsed.ProxyTriggers[0].Url); + Assert.Equal("GET", reparsed.ProxyTriggers[0].Method); + Assert.Equal("4xx", reparsed.ProxyTriggers[0].Status); + Assert.Equal("https://10.0.0.5:9225/snapshot", reparsed.ProxyTriggers[1].Url); + Assert.Null(reparsed.ProxyTriggers[1].Method); + Assert.Null(reparsed.ProxyTriggers[1].Status); + } + + [Fact] + public void AbsentTriggerSectionReadsAsNullAndEmptyMethodStatusMembersAreOptional() + { + // The readers' tolerance contract: a definition that predates #65 loads exactly as + // before - ProxyTriggers null, no parse error, and the rest of the settings intact. + var preTrigger = WorkspaceConfiguration.Deserialize(LegacyShapeBlob).GetFirstWebContentSettings(); + Assert.Null(preTrigger.ProxyTriggers); + + var settings = WorkspaceConfiguration.Deserialize(TriggerSectionBlob).GetFirstWebContentSettings(); + Assert.Equal(2, settings.ProxyTriggers.Count); + Assert.Equal("https://10.0.0.5:9225/snapshot", settings.ProxyTriggers[1].Url); + Assert.Null(settings.ProxyTriggers[1].Method); + } + + [Fact] + public void AWorkspaceWithoutTriggerRowsDoesNotWriteTheSectionAtAll() + { + // EmitDefaultValue=false: pre-#65 stored shapes are never rewritten into blobs that + // grow an empty section just because a newer plugin serialized them. + var config = WorkspaceConfiguration.Deserialize(LegacyShapeBlob); + var json = config.Serialize(); + + Assert.DoesNotContain("proxyTriggers", json); + } + + // #85: the per-workspace response cap rides the same settings object as an optional + // int member. Absent means "the 5 MB default"; a stored value round-trips verbatim + // (the clamp happens on every READ, at serve time, not at save time); and a default-cap + // save never grows the blob. + + [Fact] + public void DefinitionSavedBeforeTheCapExistedMeansNoStoredCap() + { + var settings = WorkspaceConfiguration.Deserialize(LegacyShapeBlob).GetFirstWebContentSettings(); + + Assert.Equal(0, settings.ProxyMaxResponseBytes); + } + + [Fact] + public void StoredResponseCapSurvivesTheSerializedRoundtripVerbatim() + { + // Even a past-ceiling value round-trips verbatim on purpose: the reader contract is + // "store what you were given, clamp forever at serve time" (ProxyFetchLimits / + // ProxyResponseCapStore), so a hand-edited blob is never silently rewritten by MC. + var config = WorkspaceConfiguration.CreateDefault("https://camera-ui.example/"); + var settings = config.GetFirstWebContentSettings(); + settings.ProxyEnabled = true; + settings.ProxyMaxResponseBytes = 8 * 1024 * 1024; + + var json = config.Serialize(); + Assert.Contains("\"proxyMaxResponseBytes\":8388608", json); + + var reparsed = WorkspaceConfiguration.Deserialize(json).GetFirstWebContentSettings(); + Assert.Equal(8 * 1024 * 1024, reparsed.ProxyMaxResponseBytes); + } + + [Fact] + public void DefaultCapStoresZeroAndWritesNoCapMember() + { + var config = WorkspaceConfiguration.CreateDefault("https://camera-ui.example/"); + var settings = config.GetFirstWebContentSettings(); + settings.ProxyEnabled = true; // proxy on, cap left at the default + + var json = config.Serialize(); + + Assert.DoesNotContain("proxyMaxResponseBytes", json); + var reparsed = WorkspaceConfiguration.Deserialize(json).GetFirstWebContentSettings(); + Assert.Equal(0, reparsed.ProxyMaxResponseBytes); + } + + // #86: the per-workspace header policy rides the same settings object as two optional + // list sections. Absent means "no rules" (every pre-#86 definition); rows round-trip + // verbatim INCLUDING forbidden names - the wire never censors, the readers refuse + // (ProxyHeaderPolicyStore at snapshot, the MC grid at fill/save), so a hand-edited blob + // stays visible-and-refused rather than silently rewritten. + + [Fact] + public void DefinitionSavedBeforeTheHeaderPolicyExistedMeansNoSections() + { + var settings = WorkspaceConfiguration.Deserialize(LegacyShapeBlob).GetFirstWebContentSettings(); + + Assert.Null(settings.ProxyRequestHeaders); + Assert.Null(settings.ProxyResponseHeaders); + } + + [Fact] + public void WorkspaceWithoutHeaderRulesWritesNoSectionAtAll() + { + var config = WorkspaceConfiguration.CreateDefault("https://camera-ui.example/"); + var settings = config.GetFirstWebContentSettings(); + settings.ProxyEnabled = true; // proxy on, no header rules + + var json = config.Serialize(); + + Assert.DoesNotContain("proxyRequestHeaders", json); + Assert.DoesNotContain("proxyResponseHeaders", json); + } + + [Fact] + public void HeaderRuleSectionsRoundTripVerbatimIncludingForbiddenNames() + { + var config = WorkspaceConfiguration.CreateDefault("https://camera-ui.example/"); + var settings = config.GetFirstWebContentSettings(); + settings.ProxyEnabled = true; + settings.ProxyRequestHeaders = new List + { + new ProxyHeaderRuleConfiguration { Name = "X-Edge", Value = "vms" }, + new ProxyHeaderRuleConfiguration { Name = "X-Internal", Remove = true }, + new ProxyHeaderRuleConfiguration { Name = "Host", Value = "hand-edited.example" }, + }; + settings.ProxyResponseHeaders = new List + { + new ProxyHeaderRuleConfiguration { Name = "Server", Remove = true }, + }; + + var json = config.Serialize(); + var reparsed = WorkspaceConfiguration.Deserialize(json).GetFirstWebContentSettings(); + + Assert.Equal(3, reparsed.ProxyRequestHeaders.Count); + Assert.Equal("X-Edge", reparsed.ProxyRequestHeaders[0].Name); + Assert.Equal("vms", reparsed.ProxyRequestHeaders[0].Value); + Assert.False(reparsed.ProxyRequestHeaders[0].Remove); + Assert.True(reparsed.ProxyRequestHeaders[1].Remove); + Assert.Null(reparsed.ProxyRequestHeaders[1].Value); + Assert.Equal("Host", reparsed.ProxyRequestHeaders[2].Name); // stored verbatim; refused on read + Assert.Single(reparsed.ProxyResponseHeaders); + Assert.True(reparsed.ProxyResponseHeaders[0].Remove); + } + } +} diff --git a/src/WebContent.Tests/ProxyDiagnosticsAggregatorTests.cs b/src/WebContent.Tests/ProxyDiagnosticsAggregatorTests.cs new file mode 100644 index 0000000..125838c --- /dev/null +++ b/src/WebContent.Tests/ProxyDiagnosticsAggregatorTests.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #69: the Event Server's in-memory diagnostics aggregation - per-workspace blocked + /// rings (dedup, counts, ring eviction) and per-session live tables (lifecycle, idle sweep, + /// traffic counters, req/min). Pure logic on caller-supplied timestamps, so every rule is + /// exercised without MIP. + /// + public class ProxyDiagnosticsAggregatorTests + { + private static readonly Guid WorkspaceA = new Guid("aaaaaaaa-0000-0000-0000-000000000001"); + private static readonly Guid WorkspaceB = new Guid("bbbbbbbb-0000-0000-0000-000000000002"); + private static readonly DateTime T0 = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void SameOriginAndReasonDedupOntoOneEntryAccumulatingCount() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", ProxyBlockReasons.NotAllowed, 1, T0); + aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", ProxyBlockReasons.NotAllowed, 3, T0.AddMinutes(2)); + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(5)); + Assert.Single(snapshot.Blocked); + Assert.Equal(4, snapshot.Blocked[0].Count); + Assert.Equal(T0.AddMinutes(2), snapshot.Blocked[0].LastSeenUtc); + } + + [Fact] + public void SameOriginWithDifferentReasonsStaysTwoEntries() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", ProxyBlockReasons.NotAllowed, 1, T0); + aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", ProxyBlockReasons.Timeout, 1, T0); + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0); + Assert.Equal(2, snapshot.Blocked.Count); + } + + [Fact] + public void BlockedRingEvictsOldestFirstSeenPastTheCap() + { + var aggregator = new ProxyDiagnosticsAggregator(); + var cap = ProxyDiagnosticsAggregator.MaxBlockedOriginsPerWorkspace; + for (var i = 0; i < cap + 5; i++) + { + aggregator.RecordBlock(WorkspaceA.ToString(), $"http://host{i}.test:80", ProxyBlockReasons.NotAllowed, 1, T0.AddSeconds(i)); + } + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0); + Assert.Equal(cap, snapshot.Blocked.Count); + Assert.Equal("http://host5.test:80", snapshot.Blocked[0].Origin); + Assert.Equal($"http://host{cap + 4}.test:80", snapshot.Blocked[cap - 1].Origin); + } + + [Fact] + public void OnlyANewRingEntryIsReportedAsATransition() + { + // The Event Server logs the transition it cares about (an origin newly on the ring) and + // stays quiet on every dedup refresh; the return value is what makes that possible. + var aggregator = new ProxyDiagnosticsAggregator(); + Assert.True(aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", ProxyBlockReasons.NotAllowed, 1, T0)); + Assert.False(aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", ProxyBlockReasons.NotAllowed, 5, T0.AddMinutes(1))); + Assert.True(aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", ProxyBlockReasons.Timeout, 1, T0.AddMinutes(2))); + Assert.False(aggregator.RecordBlock("", "http://one.test:80", ProxyBlockReasons.NotAllowed, 1, T0)); + } + + [Fact] + public void RefreshingAnEntryAccumulatesIntoTheRecencyOrder() + { + var aggregator = new ProxyDiagnosticsAggregator(); + var cap = ProxyDiagnosticsAggregator.MaxBlockedOriginsPerWorkspace; + aggregator.RecordBlock(WorkspaceA.ToString(), "http://first.test:80", ProxyBlockReasons.NotAllowed, 1, T0); + for (var i = 0; i < cap - 1; i++) + { + aggregator.RecordBlock(WorkspaceA.ToString(), $"http://fill{i}.test:80", ProxyBlockReasons.NotAllowed, 1, T0); + } + + aggregator.RecordBlock(WorkspaceA.ToString(), "http://first.test:80", ProxyBlockReasons.NotAllowed, 10, T0.AddMinutes(1)); + aggregator.RecordBlock(WorkspaceA.ToString(), "http://newer.test:80", ProxyBlockReasons.NotAllowed, 1, T0.AddMinutes(2)); + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(3)); + Assert.Equal(cap, snapshot.Blocked.Count); + // The refreshed entry keeps its identity, accumulates its count, and moves to the + // fresh end - the eviction that follows drops the least recently seen origin instead. + var byId = snapshot.Blocked.ToDictionary(entry => entry.Origin, entry => entry); + Assert.Equal(11, byId["http://first.test:80"].Count); + Assert.False(byId.ContainsKey("http://fill0.test:80")); + Assert.Equal("http://newer.test:80", snapshot.Blocked[cap - 1].Origin); + } + + [Fact] + public void BlankFieldsAndUnknownWorkspaceSnapshotsAreHarmless() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.RecordBlock(WorkspaceA.ToString(), "", ProxyBlockReasons.NotAllowed, 1, T0); + aggregator.RecordBlock(WorkspaceA.ToString(), "http://one.test:80", "", 1, T0); + aggregator.RecordBlock(null, "http://one.test:80", ProxyBlockReasons.NotAllowed, 1, T0); + + Assert.Empty(aggregator.Snapshot(WorkspaceA.ToString(), T0).Blocked); + + var empty = aggregator.Snapshot(WorkspaceB.ToString(), T0); + Assert.True(empty.Success); + Assert.Empty(empty.Sessions); + Assert.Empty(empty.Blocked); + } + + [Fact] + public void SessionLifecycleCountsAndDuplicateOpenRefreshesInPlace() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("s1", WorkspaceA.ToString(), "alice", "BEAST", T0); + aggregator.OpenSession("s1", WorkspaceA.ToString(), "alice-renamed", "BEAST2", T0.AddMinutes(1)); + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(2)); + Assert.Single(snapshot.Sessions); + Assert.Equal("alice-renamed", snapshot.Sessions[0].UserName); + Assert.Equal("BEAST2", snapshot.Sessions[0].ClientMachine); + + Assert.True(aggregator.CloseSession("s1", T0.AddMinutes(3))); + Assert.False(aggregator.CloseSession("s1", T0.AddMinutes(4))); + Assert.Empty(aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(5)).Sessions); + } + + [Fact] + public void HeartbeatAndTrafficKeepSessionsOffTheIdleSweepWhileStaleOnesGo() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("live", WorkspaceA.ToString(), "alice", "BEAST", T0); + aggregator.OpenSession("stale", WorkspaceA.ToString(), "bob", "BEAST", T0); + + for (var minute = 1; minute <= 5; minute++) + { + Assert.True(aggregator.HeartbeatSession("live", T0.AddMinutes(minute))); + aggregator.RecordFetchTraffic("live", 10, 20, T0.AddMinutes(minute)); + } + + var reclaimed = aggregator.SweepIdleSessions(TimeSpan.FromMinutes(3), T0.AddMinutes(5)); + Assert.Equal(new[] { "stale" }, reclaimed); + Assert.True(aggregator.HasSessions(WorkspaceA.ToString())); + + Assert.False(aggregator.HeartbeatSession("gone", T0.AddMinutes(6))); + Assert.False(aggregator.RecordFetchTraffic("gone", 1, 1, T0.AddMinutes(6))); + Assert.False(aggregator.RecordTunnelTraffic("gone", 1, 1, T0.AddMinutes(6))); + } + + [Fact] + public void SweepReclaimsSessionCountersWithTheSession() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("doomed", WorkspaceA.ToString(), null, null, T0); + aggregator.RecordFetchTraffic("doomed", 5, 7, T0); + + Assert.Single(aggregator.SweepIdleSessions(TimeSpan.FromMinutes(3), T0.AddMinutes(4))); + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(5)); + Assert.Empty(snapshot.Sessions); + // Blocked rings never die with the sweep. + aggregator.RecordBlock(WorkspaceA.ToString(), "http://keep.test:80", ProxyBlockReasons.NotAllowed, 1, T0.AddMinutes(5)); + Assert.Single(aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(6)).Blocked); + } + + [Fact] + public void FetchTrafficCountsRequestAndBothByteDirectionsTowardOneSession() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("s1", WorkspaceA.ToString(), "alice", "BEAST", T0); + Assert.True(aggregator.RecordFetchTraffic("s1", 100, 5000, T0.AddMinutes(1))); + Assert.True(aggregator.RecordFetchTraffic("s1", 50, 2500, T0.AddMinutes(2))); + + var entry = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(4)).Sessions[0]; + Assert.Equal(2, entry.Requests); + Assert.Equal(150, entry.BytesSent); + Assert.Equal(7500, entry.BytesReceived); + Assert.Equal(T0.AddMinutes(2), entry.LastActivityUtc); + Assert.Equal(T0, entry.OpenedUtc); + Assert.Equal(0.5, entry.RequestsPerMinute, 9); + } + + [Fact] + public void JustOpenedSessionNeverShowsAnInflatedRate() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("fresh", WorkspaceA.ToString(), "alice", "BEAST", T0.AddMinutes(29)); + aggregator.RecordFetchTraffic("fresh", 0, 0, T0.AddMinutes(29.5)); + + // 30 seconds of life, one request: the window floor of one minute keeps the rate at 1, + // not the misleading 2-per-minute a raw divide would show. + var entry = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(29.75)).Sessions[0]; + Assert.Equal(1.0, entry.RequestsPerMinute, 9); + } + + [Fact] + public void TunnelTrafficAddsBytesWithoutCountingAnotherRequest() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("s1", WorkspaceA.ToString(), "alice", "BEAST", T0); + aggregator.RecordFetchTraffic("s1", 0, 0, T0); + Assert.True(aggregator.RecordTunnelTraffic("s1", 400, 900, T0.AddMinutes(1))); + Assert.True(aggregator.RecordTunnelTraffic("s1", 0, 25, T0.AddMinutes(2))); + + var entry = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(3)).Sessions[0]; + Assert.Equal(1, entry.Requests); + Assert.Equal(400, entry.BytesSent); + Assert.Equal(925, entry.BytesReceived); + } + + [Fact] + public void BlocksFiledForSessionsLandUnderTheSessionsWorkspace() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("s1", WorkspaceA.ToString(), "alice", "BEAST", T0); + + Assert.True(aggregator.RecordBlockForSession("s1", "http://gate.test:80", ProxyBlockReasons.NotAllowed, T0.AddMinutes(1))); + Assert.False(aggregator.RecordBlockForSession("nope", "http://gate.test:80", ProxyBlockReasons.NotAllowed, T0.AddMinutes(1))); + + Assert.Single(aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(2)).Blocked); + Assert.Empty(aggregator.Snapshot(WorkspaceB.ToString(), T0.AddMinutes(2)).Blocked); + } + + [Fact] + public void WorkspacesKeepSeparateRingsAndSessionTables() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.RecordBlock(WorkspaceA.ToString(), "http://a.test:80", ProxyBlockReasons.NotAllowed, 1, T0); + aggregator.RecordBlock(WorkspaceB.ToString(), "http://b.test:80", ProxyBlockReasons.Timeout, 1, T0); + aggregator.OpenSession("sa", WorkspaceA.ToString(), "alice", "BEAST", T0); + aggregator.OpenSession("sb", WorkspaceB.ToString(), "bob", "WORKSTATION", T0); + + var a = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(1)); + var b = aggregator.Snapshot(WorkspaceB.ToString(), T0.AddMinutes(1)); + Assert.Equal(new[] { "http://a.test:80" }, new[] { a.Blocked[0].Origin }); + Assert.Equal(new[] { "http://b.test:80" }, new[] { b.Blocked[0].Origin }); + Assert.Equal("alice", a.Sessions[0].UserName); + Assert.Equal("bob", b.Sessions[0].UserName); + } + + [Fact] + public void UntrustedMachineReportsDedupPerMachineAccumulatingAndRefreshing() + { + // #80: the aggregate is per client MACHINE, not per view or per report - a fleet of + // proxied views on one PC is ONE line for the operator, and every later report from + // that PC refreshes its recency and bumps its count. + var aggregator = new ProxyDiagnosticsAggregator(); + Assert.True(aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), "BEAST", T0)); + Assert.False(aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), "BEAST", T0.AddMinutes(1))); + Assert.False(aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), "beast", T0.AddMinutes(2))); + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(3)); + Assert.Single(snapshot.UntrustedMachines); + Assert.Equal("BEAST", snapshot.UntrustedMachines[0].Machine); + Assert.Equal(3, snapshot.UntrustedMachines[0].Count); + Assert.Equal(T0.AddMinutes(2), snapshot.UntrustedMachines[0].LastSeenUtc); + } + + [Fact] + public void UntrustedMachinesArePerWorkspaceAndBlankFieldsAreDropped() + { + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), "BEAST", T0); + aggregator.RecordUntrustedAuthorityReport(WorkspaceB.ToString(), "WORKSTATION", T0); + aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), " ", T0); + aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), null, T0); + aggregator.RecordUntrustedAuthorityReport(null, "BEAST", T0); + + var a = aggregator.Snapshot(WorkspaceA.ToString(), T0); + var b = aggregator.Snapshot(WorkspaceB.ToString(), T0); + Assert.Single(a.UntrustedMachines); // the blank/null-machine reports never landed + Assert.Equal("BEAST", a.UntrustedMachines[0].Machine); + Assert.Equal("WORKSTATION", b.UntrustedMachines[0].Machine); + } + + [Fact] + public void UntrustedMachineTableEvictsLeastRecentlySeenPastTheCap() + { + var aggregator = new ProxyDiagnosticsAggregator(); + var cap = ProxyDiagnosticsAggregator.MaxUntrustedMachinesPerWorkspace; + for (var i = 0; i < cap + 3; i++) + { + aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), $"PC{i}", T0.AddSeconds(i)); + } + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0); + Assert.Equal(cap, snapshot.UntrustedMachines.Count); + Assert.DoesNotContain(snapshot.UntrustedMachines, entry => entry.Machine == "PC0"); + Assert.Equal($"PC{cap + 2}", snapshot.UntrustedMachines[cap - 1].Machine); + } + + [Fact] + public void SessionFiledBlocksNeverTouchTheUntrustedMachineTable() + { + // A session-attributed block's machine is the session's PC, and only client-decided + // CertUntrusted reports belong in the machine table. RecordBlockForSession (the + // Event Server's own fetch/gate decisions) must not populate it. + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("s1", WorkspaceA.ToString(), "alice", "BEAST", T0); + aggregator.RecordBlockForSession("s1", "https://gate.test:443", ProxyBlockReasons.CertUntrusted, T0.AddMinutes(1)); + + Assert.Empty(aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(2)).UntrustedMachines); + } + + [Fact] + public void UntrustedMachineTableSurvivesTheIdleSweepLikeTheBlockedRings() + { + // Nothing re-registers a machine's posture until the client's next genuine flip, so + // sweeping sessions must not silently drop the operator's deployment checklist. + var aggregator = new ProxyDiagnosticsAggregator(); + aggregator.OpenSession("s1", WorkspaceA.ToString(), "alice", "BEAST", T0); + aggregator.RecordUntrustedAuthorityReport(WorkspaceA.ToString(), "BEAST", T0); + + aggregator.SweepIdleSessions(TimeSpan.FromMinutes(3), T0.AddMinutes(4)); + + var snapshot = aggregator.Snapshot(WorkspaceA.ToString(), T0.AddMinutes(5)); + Assert.Empty(snapshot.Sessions); + Assert.Single(snapshot.UntrustedMachines); + } + + [Fact] + public void TryMapErrorCodeFunnelsWireCodesIntoBlockCategories() + { + string reason; + Assert.True(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.NotAllowed, out reason)); + Assert.Equal(ProxyBlockReasons.NotAllowed, reason); + Assert.True(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.TlsValidation, out reason)); + Assert.Equal(ProxyBlockReasons.CertUntrusted, reason); + Assert.True(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.NoAuthority, out reason)); + Assert.Equal(ProxyBlockReasons.CertUntrusted, reason); + Assert.True(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.Unreachable, out reason)); + Assert.Equal(ProxyBlockReasons.Unreachable, reason); + Assert.True(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.Timeout, out reason)); + Assert.Equal(ProxyBlockReasons.Timeout, reason); + Assert.True(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.TooLarge, out reason)); + Assert.Equal(ProxyBlockReasons.TooLarge, reason); + Assert.True(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.HandshakeRefused, out reason)); + Assert.Equal(ProxyBlockReasons.TunnelRefused, reason); + + // Component faults and protocol noise are never per-origin blocks. + Assert.False(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.ServerUnavailable, out reason)); + Assert.False(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.BadRequest, out reason)); + Assert.False(ProxyBlockReasons.TryMapErrorCode(ProxyFetchErrorCodes.Error, out reason)); + Assert.False(ProxyBlockReasons.TryMapErrorCode(null, out reason)); + Assert.Null(reason); + } + } +} diff --git a/src/WebContent.Tests/ProxyDiagnosticsViewTests.cs b/src/WebContent.Tests/ProxyDiagnosticsViewTests.cs new file mode 100644 index 0000000..d5a030e --- /dev/null +++ b/src/WebContent.Tests/ProxyDiagnosticsViewTests.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using WebContent.Admin; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #84: the Management Client Diagnostics tab's snapshot → view transform pinned as + /// pure logic — the failure postures shared with the Status tab (a lost query is never an + /// empty list), newest-first row order, the shared cause labels and recency strings, the + /// already-allowlisted annotation driven through THE shared matcher (wildcard cover AND the + /// SSRF floor), and the acceptance-critical rule that CertUntrusted rows never offer an + /// "Add to allowlist" promotion. + /// + public class ProxyDiagnosticsViewTests + { + private static readonly DateTime T0 = new DateTime(2026, 11, 2, 12, 0, 0, DateTimeKind.Utc); + + private static ProxyBlockEntry Blocked( + string origin = "http://cdn.example.com", + string reason = ProxyBlockReasons.NotAllowed, + long count = 1, + DateTime? seenUtc = null) + { + return new ProxyBlockEntry + { + Origin = origin, + Reason = reason, + Count = count, + LastSeenUtc = seenUtc ?? T0.AddSeconds(-47), + }; + } + + private static ProxyStatusResponsePayload Answer(params ProxyBlockEntry[] blocked) + { + return new ProxyStatusResponsePayload + { + Success = true, + WorkspaceItemId = "aaaaaaaa-0000-0000-0000-000000000001", + Sessions = new List(), + Blocked = new List(blocked), + }; + } + + private static ProxyDiagnosticsView Build( + ProxyStatusResponsePayload payload, + IEnumerable entries = null, + IEnumerable floorHosts = null) + { + return ProxyDiagnosticsViewBuilder.Build(payload, entries, floorHosts, T0); + } + + // ---- failure postures: identical discipline to the Status tab ------------------------- + + [Fact] + public void NoAnswerIsAFailureNamedServerUnavailable() + { + var view = Build(null); + Assert.False(view.QuerySucceeded); + Assert.Equal(ProxyFetchErrorCodes.ServerUnavailable, view.FailureCode); + Assert.Empty(view.Rows); + } + + [Fact] + public void FailedAnswerKeepsItsOwnCodeAndAnEmptyCodeFallsBack() + { + var gone = Build(new ProxyStatusResponsePayload { Success = false, ErrorCode = ProxyFetchErrorCodes.BadRequest }); + Assert.False(gone.QuerySucceeded); + Assert.Equal(ProxyFetchErrorCodes.BadRequest, gone.FailureCode); + + var blank = Build(new ProxyStatusResponsePayload { Success = false, ErrorCode = " " }); + Assert.False(blank.QuerySucceeded); + Assert.Equal(ProxyFetchErrorCodes.ServerUnavailable, blank.FailureCode); + } + + [Fact] + public void EmptyRingIsAnHonestSuccessWithNoRows() + { + var view = Build(Answer()); + Assert.True(view.QuerySucceeded); + Assert.Equal(0, view.BlockedCount); + Assert.Empty(view.Rows); + + var nullList = Build(new ProxyStatusResponsePayload { Success = true, Blocked = null }); + Assert.True(nullList.QuerySucceeded); + Assert.Equal(0, nullList.BlockedCount); + } + + // ---- row rendering --------------------------------------------------------------------- + + [Fact] + public void RowsSortNewestRefusalFirst() + { + // The ring answers oldest-first (its eviction order); the admin reads newest-first. + var view = Build(Answer( + Blocked("https://oldest.example", seenUtc: T0.AddMinutes(-30)), + Blocked("https://middle.example", seenUtc: T0.AddMinutes(-5)), + Blocked("https://newest.example", seenUtc: T0.AddSeconds(-12)))); + + Assert.Equal(3, view.BlockedCount); + Assert.Equal( + new[] { "https://newest.example", "https://middle.example", "https://oldest.example" }, + view.Rows.Select(r => r.Origin).ToArray()); + } + + [Fact] + public void EqualTimestampsOrderByOriginOrdinalSoRefreshesAreStable() + { + var seen = T0.AddMinutes(-2); + var view = Build(Answer( + Blocked("https://b.example", seenUtc: seen), + Blocked("https://a.example", seenUtc: seen))); + + Assert.Equal(new[] { "https://a.example", "https://b.example" }, view.Rows.Select(r => r.Origin).ToArray()); + } + + [Fact] + public void BlankAndNullRingEntriesNeverRender() + { + var view = Build(Answer( + null, + new ProxyBlockEntry { Origin = " ", Reason = ProxyBlockReasons.Timeout, Count = 4, LastSeenUtc = T0 }, + Blocked("https://real.example"))); + + Assert.Single(view.Rows); + Assert.Equal("https://real.example", view.Rows[0].Origin); + } + + [Fact] + public void EveryCauseCategoryGetsItsSharedLabel() + { + // Same vocabulary as the Smart Client's blocked list (#83) and the error pages (#66). + var view = Build(Answer( + Blocked("https://a.example", ProxyBlockReasons.NotAllowed), + Blocked("https://b.example", ProxyBlockReasons.TooLarge), + Blocked("https://c.example", ProxyBlockReasons.CertUntrusted), + Blocked("https://d.example", ProxyBlockReasons.Unreachable), + Blocked("https://e.example", ProxyBlockReasons.Timeout), + Blocked("https://f.example", ProxyBlockReasons.TunnelRefused), + Blocked("https://g.example", "SomethingFuture"))); + + Assert.Equal("not allowed by administrator", view.Rows.Single(r => r.Origin == "https://a.example").ReasonLabel); + Assert.Equal("response too large", view.Rows.Single(r => r.Origin == "https://b.example").ReasonLabel); + Assert.Equal("certificate not trusted", view.Rows.Single(r => r.Origin == "https://c.example").ReasonLabel); + Assert.Equal("target unreachable", view.Rows.Single(r => r.Origin == "https://d.example").ReasonLabel); + Assert.Equal("target timed out", view.Rows.Single(r => r.Origin == "https://e.example").ReasonLabel); + Assert.Equal("proxy tunnel refused", view.Rows.Single(r => r.Origin == "https://f.example").ReasonLabel); + Assert.Equal("(unknown)", view.Rows.Single(r => r.Origin == "https://g.example").ReasonLabel); + } + + [Fact] + public void HitsAndLastSeenArePreFormattedDisplayStrings() + { + var view = Build(Answer( + new ProxyBlockEntry { Origin = "https://a.example", Reason = ProxyBlockReasons.Timeout, Count = 0, LastSeenUtc = T0.AddSeconds(-3) }, + new ProxyBlockEntry { Origin = "https://b.example", Reason = ProxyBlockReasons.Timeout, Count = 4711, LastSeenUtc = T0.AddSeconds(-42) }, + new ProxyBlockEntry { Origin = "https://c.example", Reason = ProxyBlockReasons.Timeout, Count = 9, LastSeenUtc = T0.AddMinutes(-4).AddSeconds(-7) }, + new ProxyBlockEntry { Origin = "https://d.example", Reason = ProxyBlockReasons.Timeout, Count = 1, LastSeenUtc = T0.AddHours(-2).AddMinutes(-5) })); + + Assert.Equal("1", view.Rows.Single(r => r.Origin == "https://a.example").Hits); // count floor is one sighting + Assert.Equal("just now", view.Rows.Single(r => r.Origin == "https://a.example").LastSeen); + Assert.Equal("4711", view.Rows.Single(r => r.Origin == "https://b.example").Hits); + Assert.Equal("42s ago", view.Rows.Single(r => r.Origin == "https://b.example").LastSeen); + Assert.Equal("4m 07s ago", view.Rows.Single(r => r.Origin == "https://c.example").LastSeen); + Assert.Equal("2h 05m ago", view.Rows.Single(r => r.Origin == "https://d.example").LastSeen); + } + + [Fact] + public void FutureTimestampsReadJustNowNeverNegative() + { + var view = Build(Answer(Blocked("https://skew.example", seenUtc: T0.AddMinutes(5)))); + Assert.Equal("just now", view.Rows[0].LastSeen); + } + + // ---- the promotion decision --------------------------------------------------------------- + + [Fact] + public void CertUntrustedRowsNeverOfferAnAllowlistPromotion() + { + // #84 AC, pinned in the pure layer: the fix there is the CA trust path. The row still + // renders (with its label and an annotated Status cell), it simply has no button. + var view = Build(Answer(Blocked("https://corp.example", ProxyBlockReasons.CertUntrusted))); + + var row = view.Rows[0]; + Assert.Equal(ProxyBlockedRowStatus.CertTrust, row.StatusCode); + Assert.False(row.CanPromote); + } + + [Fact] + public void CertTrustedPosturesAllOfferPromotion() + { + var view = Build(Answer( + Blocked("https://a.example", ProxyBlockReasons.NotAllowed), + Blocked("https://b.example", ProxyBlockReasons.TooLarge), + Blocked("https://c.example", ProxyBlockReasons.Unreachable), + Blocked("https://d.example", ProxyBlockReasons.Timeout), + Blocked("https://e.example", ProxyBlockReasons.TunnelRefused))); + + Assert.All(view.Rows, row => + { + Assert.Equal(ProxyBlockedRowStatus.None, row.StatusCode); + Assert.True(row.CanPromote); + }); + } + + [Fact] + public void ExactEntryCoveringTheOriginAnnotatesAndRemovesTheButton() + { + var view = Build( + Answer(Blocked("https://10.0.0.5:8443")), + new[] { "https://10.0.0.5:8443" }); + + var row = view.Rows[0]; + Assert.Equal(ProxyBlockedRowStatus.Allowlisted, row.StatusCode); + Assert.False(row.CanPromote); + } + + [Fact] + public void WildcardEntryCoveringTheOriginAnnotatesTooThroughTheSameMatcher() + { + var view = Build( + Answer(Blocked("https://cdn.example.com")), + new[] { "https://*.example.com" }); + + Assert.Equal(ProxyBlockedRowStatus.Allowlisted, view.Rows[0].StatusCode); + Assert.False(view.Rows[0].CanPromote); + } + + [Fact] + public void WildcardFloorKeepsTheButtonOnOriginsTheEntryCouldNeverServe() + { + // The annotation runs through the gates' matcher INCLUDING the wildcard SSRF floor, so + // "https://*" does not annotate a localhost block as allowed - and the promotion button + // stays, because an exact entry is precisely what a floored origin needs. + var view = Build( + Answer( + Blocked("https://localhost:8443"), + Blocked("https://10.0.0.9")), + new[] { "https://*" }); + + Assert.All(view.Rows, row => + { + Assert.Equal(ProxyBlockedRowStatus.None, row.StatusCode); + Assert.True(row.CanPromote); + }); + + // ... while the same origins WOULD annotate under explicit exact entries. + var explicitView = Build( + Answer(Blocked("https://localhost:8443")), + new[] { "https://localhost:8443" }); + Assert.Equal(ProxyBlockedRowStatus.Allowlisted, explicitView.Rows[0].StatusCode); + } + + [Fact] + public void VmsFloorHostsFeedTheAnnotationLikeEveryGate() + { + var view = Build( + Answer(Blocked("https://vms.example.com")), + new[] { "https://*" }, + new[] { "vms.example.com" }); + + Assert.Equal(ProxyBlockedRowStatus.None, view.Rows[0].StatusCode); + Assert.True(view.Rows[0].CanPromote); + } + + [Fact] + public void UnparsableOriginRendersButIsNotPromotable() + { + var view = Build(Answer(Blocked("gibberish-origin"))); + var row = view.Rows[0]; + Assert.Equal("gibberish-origin", row.Origin); // the admin still sees what was blocked + Assert.Equal(ProxyBlockedRowStatus.None, row.StatusCode); + Assert.False(row.CanPromote); + } + + [Fact] + public void NoEntriesAtAllStillOffersPromotionOnPlainBlocks() + { + var view = Build(Answer(Blocked("https://cdn.example.com")), null); + Assert.True(view.Rows[0].CanPromote); + } + + // ---- #80: the untrusted-machine aggregate ------------------------------------------------ + + [Fact] + public void UntrustedMachineRowsRenderWithReportCountsAndRecency() + { + // One row per MACHINE (the Event Server already deduped), pre-formatted like the + // blocked rows, newest report first. + var payload = Answer(); + payload.UntrustedMachines = new List + { + new ProxyCaUntrustedMachineEntry + { + Machine = "WORKSTATION7", + Count = 2, + LastSeenUtc = T0.AddMinutes(-3), + }, + new ProxyCaUntrustedMachineEntry + { + Machine = "BEAST", + Count = 41, + LastSeenUtc = T0.AddSeconds(-30), + }, + }; + + var view = Build(payload); + Assert.Equal(2, view.UntrustedMachineCount); + Assert.Equal(new[] { "BEAST", "WORKSTATION7" }, view.UntrustedMachineRows.Select(r => r.Machine).ToArray()); + Assert.Equal("41", view.UntrustedMachineRows[0].Reports); + Assert.Equal("30s ago", view.UntrustedMachineRows[0].LastSeen); + Assert.Equal("3m 00s ago", view.UntrustedMachineRows[1].LastSeen); + } + + [Fact] + public void MissingOrNullUntrustedListReadsAsZeroMachines() + { + // A pre-#80 status frame (member absent → null) and an explicit empty list both read + // as "nothing to warn about", never a failure and never a crash. + var noMember = Build(new ProxyStatusResponsePayload + { + Success = true, + Blocked = new List(), + UntrustedMachines = null, + }); + Assert.True(noMember.QuerySucceeded); + Assert.Equal(0, noMember.UntrustedMachineCount); + Assert.Empty(noMember.UntrustedMachineRows); + + var emptyList = Build(Answer()); + Assert.Equal(0, emptyList.UntrustedMachineCount); + } + + [Fact] + public void BlankMachineEntriesNeverRender() + { + var payload = Answer(); + payload.UntrustedMachines = new List + { + null, + new ProxyCaUntrustedMachineEntry { Machine = " ", Count = 1, LastSeenUtc = T0 }, + new ProxyCaUntrustedMachineEntry { Machine = "BEAST", Count = 1, LastSeenUtc = T0 }, + }; + + var view = Build(payload); + Assert.Single(view.UntrustedMachineRows); + Assert.Equal("BEAST", view.UntrustedMachineRows[0].Machine); + } + + [Fact] + public void UntrustedRowsRenderEvenWhenNothingWasBlocked() + { + // The banner's whole point: an https workspace whose views all die on the SAME CA + // verdict can produce few blocked-origin rows (client-decided refusals arrive under + // CertUntrusted) yet the machine aggregate must still surface. + var payload = Answer(); + payload.UntrustedMachines = new List + { + new ProxyCaUntrustedMachineEntry { Machine = "PC1", Count = 1, LastSeenUtc = T0 }, + }; + + var view = Build(payload); + Assert.Equal(0, view.BlockedCount); + Assert.Equal(1, view.UntrustedMachineCount); + } + } +} diff --git a/src/WebContent.Tests/ProxyDiagnosticsWireTests.cs b/src/WebContent.Tests/ProxyDiagnosticsWireTests.cs new file mode 100644 index 0000000..c1a395b --- /dev/null +++ b/src/WebContent.Tests/ProxyDiagnosticsWireTests.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #69: the diagnostics payloads ride the same JSON-string-on-the-wire discipline as + /// every other proxy message (#54). These guards pin the round trips and the strictness that + /// comes with them: an unset session is an explicit null member, and a frame whose shape came + /// from a different build is refused (null) rather than half-bound - so both plugin halves + /// always ship from the same drop. + /// + public class ProxyDiagnosticsWireTests + { + [Fact] + public void BlockReportRoundTripsEveryField() + { + var wire = ProxyFetchWireCodec.ToWire(new ProxyBlockReportPayload + { + WorkspaceItemId = "aaaaaaaa-0000-0000-0000-000000000001", + WorkspaceFingerprint = "fp-9", + Origin = "https://vendor.test:8443", + Reason = ProxyBlockReasons.NotAllowed, + Count = 3, + ClientMachine = "BEAST", + }); + + var back = ProxyFetchWireCodec.BlockReportFromWire(wire); + Assert.NotNull(back); + Assert.Equal("aaaaaaaa-0000-0000-0000-000000000001", back.WorkspaceItemId); + Assert.Equal("fp-9", back.WorkspaceFingerprint); + Assert.Equal("https://vendor.test:8443", back.Origin); + Assert.Equal(ProxyBlockReasons.NotAllowed, back.Reason); + Assert.Equal(3, back.Count); + Assert.Equal("BEAST", back.ClientMachine); + } + + [Fact] + public void SessionEventRoundTripsAndGarbageDecodesToNull() + { + var wire = ProxyFetchWireCodec.ToWire(new ProxySessionEventPayload + { + SessionId = "s-1", + Kind = ProxySessionEventKinds.Open, + WorkspaceItemId = "aaaaaaaa-0000-0000-0000-000000000001", + ClientMachine = "BEAST", + }); + + var back = ProxyFetchWireCodec.SessionEventFromWire(wire); + Assert.NotNull(back); + Assert.Equal("s-1", back.SessionId); + Assert.Equal(ProxySessionEventKinds.Open, back.Kind); + Assert.Equal("BEAST", back.ClientMachine); + + Assert.Null(ProxyFetchWireCodec.SessionEventFromWire(42)); + Assert.Null(ProxyFetchWireCodec.SessionEventFromWire("not json at all")); + Assert.Null(ProxyFetchWireCodec.BlockReportFromWire(null)); + } + + [Fact] + public void StatusResponseRoundTripsSessionsAndBlockedList() + { + var opened = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var wire = ProxyFetchWireCodec.ToWire(new ProxyStatusResponsePayload + { + CorrelationId = "c-1", + Success = true, + WorkspaceItemId = "aaaaaaaa-0000-0000-0000-000000000001", + Sessions = new System.Collections.Generic.List + { + new ProxySessionStatusEntry + { + SessionId = "s-1", + UserName = "alice", + ClientMachine = "BEAST", + OpenedUtc = opened, + LastActivityUtc = opened.AddMinutes(2), + Requests = 7, + RequestsPerMinute = 3.5, + BytesSent = 120, + BytesReceived = 45000, + }, + }, + Blocked = new System.Collections.Generic.List + { + new ProxyBlockEntry + { + Origin = "http://gate.test:80", + Reason = ProxyBlockReasons.TunnelRefused, + Count = 2, + LastSeenUtc = opened.AddMinutes(1), + }, + }, + }); + + var back = ProxyFetchWireCodec.StatusResponseFromWire(wire); + Assert.NotNull(back); + Assert.True(back.Success); + Assert.Single(back.Sessions); + Assert.Equal("alice", back.Sessions[0].UserName); + Assert.Equal(7, back.Sessions[0].Requests); + Assert.Equal(3.5, back.Sessions[0].RequestsPerMinute); + Assert.Equal(45000, back.Sessions[0].BytesReceived); + Assert.Single(back.Blocked); + Assert.Equal(ProxyBlockReasons.TunnelRefused, back.Blocked[0].Reason); + Assert.Equal(opened.AddMinutes(1), back.Blocked[0].LastSeenUtc); + } + + [Fact] + public void UntrustedMachinesRoundTripAndAMissingMemberDecodesAsNull() + { + // #80 member: entries round trip, and the SKEW direction is pinned the same way #65 + // pinned the request wire - a frame that predates the member (no UntrustedMachines + // key at all) still decodes, with the list null, so a mismatched pair degrades to + // "no untrusted machines reported" instead of dropping the whole status response. + var seen = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var wire = ProxyFetchWireCodec.ToWire(new ProxyStatusResponsePayload + { + CorrelationId = "c-1", + Success = true, + WorkspaceItemId = "aaaaaaaa-0000-0000-0000-000000000001", + Sessions = new List(), + Blocked = new List(), + UntrustedMachines = new List + { + new ProxyCaUntrustedMachineEntry + { + Machine = "BEAST", + Count = 4, + LastSeenUtc = seen, + }, + }, + }); + + var back = ProxyFetchWireCodec.StatusResponseFromWire(wire); + Assert.NotNull(back); + Assert.Single(back.UntrustedMachines); + Assert.Equal("BEAST", back.UntrustedMachines[0].Machine); + Assert.Equal(4, back.UntrustedMachines[0].Count); + Assert.Equal(seen, back.UntrustedMachines[0].LastSeenUtc); + + // The serializer orders members alphabetically; the skew frame is what an older + // Event Server would actually send: the member absent, the JSON around it intact. + var withoutMember = Regex.Replace( + wire, + @",""UntrustedMachines"":\[.*?\]", + string.Empty, + RegexOptions.Singleline); + Assert.DoesNotContain("UntrustedMachines", withoutMember); + + var old = ProxyFetchWireCodec.StatusResponseFromWire(withoutMember); + Assert.True(old != null, "withoutMember=" + withoutMember); + Assert.Null(old.UntrustedMachines); + } + + [Fact] + public void StatusRequestRoundTrips() + { + var wire = ProxyFetchWireCodec.ToWire(new ProxyStatusRequestPayload + { + CorrelationId = "c-9", + WorkspaceItemId = "bbbbbbbb-0000-0000-0000-000000000002", + }); + + var back = ProxyFetchWireCodec.StatusRequestFromWire(wire); + Assert.NotNull(back); + Assert.Equal("c-9", back.CorrelationId); + Assert.Equal("bbbbbbbb-0000-0000-0000-000000000002", back.WorkspaceItemId); + } + + [Fact] + public void UnsetSessionIdTravelsAsAnExplicitNullMember() + { + // DataContractJsonSerializer treats every implicit-contract member as REQUIRED, so an + // unset session rides as an explicit "SessionId":null and decodes back to null - the + // shape both ends of a matching drop always see. + var wire = ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload + { + CorrelationId = "c-1", + Url = "http://one.test:80/", + Method = "GET", + }); + Assert.Contains("\"SessionId\":null", wire); + + var back = ProxyFetchWireCodec.RequestFromWire(wire); + Assert.NotNull(back); + Assert.Null(back.SessionId); + } + + [Fact] + public void AFrameFromAnOlderBuildDecodesAsUnattributedRatherThanDying() + { + // Skew contract for the request wire, revised by #65: Smart Clients on workstations + // can lag the Event Server, and a member added later (SessionId in #69, + // WorkspaceItemId in #65) is OPTIONAL on read. An older client's frame decodes as an + // unattributed fetch - null attribution, which downstream already treats as "no + // session/no workspace", never a refusal - instead of every fetch from that client + // dying on a strict read. Frames that are not fetch requests at all (no url, no + // decodable shape) stay unusable nulls. The tunnel wire deliberately keeps the + // strict inferred contract: a tunnel is negotiated inside one live browser session + // on one matching pair of plugin builds, so no drift case exists to tolerate. + var wire = ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload + { + CorrelationId = "c-1", + Url = "http://one.test:80/", + Method = "GET", + }); + var preDiagnostics = wire.Replace("\"SessionId\":null,", string.Empty); + Assert.DoesNotContain("SessionId", preDiagnostics); + + var old = ProxyFetchWireCodec.RequestFromWire(preDiagnostics); + Assert.NotNull(old); + Assert.Equal("http://one.test:80/", old.Url); + Assert.Null(old.SessionId); + Assert.Null(old.WorkspaceItemId); + + var tunnelWire = ProxyFetchWireCodec.ToWire(new ProxyTunnelPayload + { + CorrelationId = "t-1", + Url = "http://one.test:80/ws", + Headers = new List { new ProxyFetchHeader { Name = "Sec-WebSocket-Key", Value = "k" } }, + }); + var preTunnel = tunnelWire.Replace("\"SessionId\":null,", string.Empty); + Assert.DoesNotContain("SessionId", preTunnel); + Assert.Null(ProxyFetchWireCodec.TunnelFromWire(preTunnel)); + + var intact = ProxyFetchWireCodec.TunnelFromWire(tunnelWire); + Assert.NotNull(intact); + Assert.Equal("t-1", intact.CorrelationId); + Assert.Null(intact.SessionId); + Assert.Single(intact.Headers); + } + } +} diff --git a/src/WebContent.Tests/ProxyErrorPageTests.cs b/src/WebContent.Tests/ProxyErrorPageTests.cs new file mode 100644 index 0000000..fae87ab --- /dev/null +++ b/src/WebContent.Tests/ProxyErrorPageTests.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; + +using Xunit; + +namespace WebContent.Tests +{ + /// + /// Ticket #66: the friendly proxy error page. Pins the wording layer as pure logic - + /// every distinct refusal cause carries its own explicit cause category mapped 1:1 onto + /// ProxyFetchErrorCodes (no collapsing distinct causes into one generic page), and the page + /// renders headline + cause with the technical detail collapsed by default plus the + /// actionable facts (exact origin, canonical allowlist entry, byte cap, verbatim message). + /// + public class ProxyErrorPageTests + { + [Fact] + public void EveryWireErrorCodeMapsToItsOwnDistinctCategory() + { + // 1:1: every failure code names a category, and no two codes share one. Collapsing + // (say) TlsValidation and Unreachable onto one generic cause is the exact failure + // mode #66 forbids. + var codes = new[] + { + ProxyFetchErrorCodes.BadRequest, + ProxyFetchErrorCodes.Unreachable, + ProxyFetchErrorCodes.Timeout, + ProxyFetchErrorCodes.TooLarge, + ProxyFetchErrorCodes.Error, + ProxyFetchErrorCodes.NotAllowed, + ProxyFetchErrorCodes.TlsValidation, + ProxyFetchErrorCodes.NoAuthority, + ProxyFetchErrorCodes.HandshakeRefused, + ProxyFetchErrorCodes.ServerUnavailable, + }; + + var categories = new HashSet(StringComparer.Ordinal); + foreach (var code in codes) + { + string category; + Assert.True(ProxyErrorPage.TryCategoryForErrorCode(code, out category), $"no category mapped for wire code '{code}'"); + Assert.True(categories.Add(category), $"wire code '{code}' shares category '{category}' with another code"); + } + + Assert.Equal(codes.Length, categories.Count); + } + + [Fact] + public void UnknownAndNullCodesMapToNothingForTheCallerToDefault() + { + string category; + Assert.False(ProxyErrorPage.TryCategoryForErrorCode("NoSuchCode", out category)); + Assert.Null(category); + Assert.False(ProxyErrorPage.TryCategoryForErrorCode(null, out category)); + } + + [Fact] + public void EveryCategoryCarriesFriendlyHeadlineCauseAndExplanation() + { + var categories = new[] + { + ProxyErrorPage.CategoryNotAllowed, + ProxyErrorPage.CategoryResponseTooLarge, + ProxyErrorPage.CategoryRequestTooLarge, + ProxyErrorPage.CategoryTargetUnreachable, + ProxyErrorPage.CategoryTargetTimeout, + ProxyErrorPage.CategoryCertificateUntrusted, + ProxyErrorPage.CategoryAuthorityUnavailable, + ProxyErrorPage.CategoryTunnelFailed, + ProxyErrorPage.CategoryComponentUnavailable, + ProxyErrorPage.CategoryMalformedRequest, + ProxyErrorPage.CategoryMethodNotAllowed, + ProxyErrorPage.CategoryUnknownFailure, + ProxyErrorPage.CategoryProfileOwned, + }; + + foreach (var category in categories) + { + Assert.False(string.IsNullOrWhiteSpace(ProxyErrorPage.CauseLabel(category)), category); + Assert.False(string.IsNullOrWhiteSpace(ProxyErrorPage.Headline(category)), category); + Assert.False(string.IsNullOrWhiteSpace(ProxyErrorPage.Explanation(category)), category); + } + } + + [Fact] + public void CategoryNamesMatchTheMapTargets() + { + // The friendly six named in the ticket must be exactly the categories the + // diagnostics-surface readers see for those wire codes (shared substrate, #69). + string category; + Assert.True(ProxyErrorPage.TryCategoryForErrorCode(ProxyFetchErrorCodes.NotAllowed, out category)); + Assert.Equal(ProxyErrorPage.CategoryNotAllowed, category); + Assert.True(ProxyErrorPage.TryCategoryForErrorCode(ProxyFetchErrorCodes.TooLarge, out category)); + Assert.Equal(ProxyErrorPage.CategoryResponseTooLarge, category); + Assert.True(ProxyErrorPage.TryCategoryForErrorCode(ProxyFetchErrorCodes.Unreachable, out category)); + Assert.Equal(ProxyErrorPage.CategoryTargetUnreachable, category); + Assert.True(ProxyErrorPage.TryCategoryForErrorCode(ProxyFetchErrorCodes.Timeout, out category)); + Assert.Equal(ProxyErrorPage.CategoryTargetTimeout, category); + Assert.True(ProxyErrorPage.TryCategoryForErrorCode(ProxyFetchErrorCodes.TlsValidation, out category)); + Assert.Equal(ProxyErrorPage.CategoryCertificateUntrusted, category); + Assert.True(ProxyErrorPage.TryCategoryForErrorCode(ProxyFetchErrorCodes.HandshakeRefused, out category)); + Assert.Equal(ProxyErrorPage.CategoryTunnelFailed, category); + } + + [Fact] + public void PageShowsHeadlineAndCauseWithTechnicalDetailCollapsedByDefault() + { + var html = ProxyErrorPage.RenderHtml( + 403, + "Forbidden", + ProxyErrorPage.CategoryNotAllowed, + new ProxyErrorPage.Details { TechnicalMessage = "The request origin 'http://evil.test' is not in the workspace proxy allowlist." }); + + // Title shape kept byte-compatible with the live probes' "Web Content proxy:" match. + Assert.Contains("Web Content proxy: 403", html); + + // Friendly layer visible up front: one cause category + headline + explanation. + Assert.Contains("Not allowed by the administrator", html); + Assert.Contains("This web address is not allowed for this view", html); + Assert.Contains("only loads web addresses the administrator put on its allowlist", html); + + // Technical detail behind an expandable section, COLLAPSED by default (no open attr). + Assert.Contains("
", html); + Assert.DoesNotContain("
Copy diagnostics", html); + + // Fail-closed posture stated, decision NOT influenced by the page. + Assert.Contains("Nothing was loaded outside the proxy", html); + } + + [Fact] + public void DiagnosticsBlockCarriesActionableFacts() + { + var request = new ProxyForwardedRequest + { + Method = "GET", + RawTarget = "http://10.0.0.5:8080/monitor/live", + Uri = new Uri("http://10.0.0.5:8080/monitor/live"), + }; + + var html = ProxyErrorPage.RenderHtml( + 403, + "Forbidden", + ProxyErrorPage.CategoryNotAllowed, + new ProxyErrorPage.Details + { + Method = request.Method, + RequestUri = request.Uri, + RawTarget = request.RawTarget, + DecidedBy = ProxyErrorPage.DecidedByLocalListener, + TechnicalMessage = "The request origin 'http://10.0.0.5:8080' is not in the workspace proxy allowlist.", + SuggestedAllowlistEntry = ProxyPolicy.CanonicalOrigin(request.Uri), + }); + + // Exact origin, the canonical allowlist entry that would fix it, method+url, side, timestamp. + Assert.Contains("Allowlist entry that would admit this origin: http://10.0.0.5:8080", html); + Assert.Contains("Target origin: http://10.0.0.5:8080", html); + Assert.Contains("GET http://10.0.0.5:8080/monitor/live", html); + Assert.Contains("this computer (the workspace browser proxy)", html); + Assert.Contains("When (UTC):", html); + Assert.Contains("The request origin 'http://10.0.0.5:8080' is not in the workspace proxy allowlist.", html); + } + + [Fact] + public void SizeCapRefusalsNameTheCapAndTheVerbatimFetcherText() + { + var html = ProxyErrorPage.RenderHtml( + 502, + "Bad Gateway", + ProxyErrorPage.CategoryResponseTooLarge, + new ProxyErrorPage.Details + { + DecidedBy = ProxyErrorPage.DecidedByVmsServer, + ByteCap = 5242880, + TechnicalMessage = "Response body from 10.0.0.5:8080 exceeded the 5242880 byte response cap.", + }); + + Assert.Contains("Response too large (proxy size cap)", html); + Assert.Contains("exceeded the VMS proxy response size cap", html); + Assert.Contains("Proxy byte cap: 5242880", html); + Assert.Contains("exceeded the 5242880 byte response cap.", html); + } + + [Fact] + public void ServerSideAllowlistDriftKeepsTheDriftContextOnThePage() + { + // The 403 the VMS answers despite the local gate allowing it stays distinguishable + // from the local 403 (#58 posture preserved through the #66 wording layer). + var html = ProxyErrorPage.RenderHtml( + 403, + "Forbidden", + ProxyErrorPage.CategoryNotAllowed, + new ProxyErrorPage.Details + { + DecidedBy = ProxyErrorPage.DecidedByVmsServer, + Note = "The VMS server also refuses this target, although the local workspace decision allowed it (the two machines' proxy allowlists differ).", + TechnicalMessage = "The origin 'http://10.0.0.9:80' is not in the proxy allowlist of any proxied workspace.", + }); + + Assert.Contains("the VMS Event Server (the server-side proxy component)", html); + Assert.Contains("the two machines' proxy allowlists differ", html); + Assert.Contains("not in the proxy allowlist of any proxied workspace.", html); + } + + [Fact] + public void AllInterpolatedTextIsHtmlEncoded() + { + var html = ProxyErrorPage.RenderHtml( + 502, + "Bad Gateway", + "custom--category", + new ProxyErrorPage.Details { TechnicalMessage = "" }); + + Assert.DoesNotContain(""); + + return builder.ToString(); + } + + /// + /// The plain-text diagnostics block: both what the collapsed section shows and what the + /// "Copy diagnostics" button copies. Exact origin, method, the canonical allowlist entry + /// that would admit it, byte cap, verbatim technical message, timestamp, machine. + /// + private static string BuildDiagnostics(int statusCode, string reasonPhrase, string category, Details details) + { + var builder = new StringBuilder(); + builder.AppendLine($"Web Content proxy diagnostics (status {statusCode} {reasonPhrase})"); + builder.AppendLine($" When (UTC): {DateTime.UtcNow.ToString("u")}"); + builder.AppendLine($" Cause category: {category} - {CauseLabel(category)}"); + + if (details != null) + { + if (!string.IsNullOrWhiteSpace(details.DecidedBy)) + { + builder.AppendLine($" Decided by: {details.DecidedBy}"); + } + + var request = string.IsNullOrWhiteSpace(details.Method) + ? (details.RequestUri != null ? details.RequestUri.AbsoluteUri : details.RawTarget) + : $"{details.Method} {(details.RequestUri != null ? details.RequestUri.AbsoluteUri : details.RawTarget)}"; + if (!string.IsNullOrWhiteSpace(request)) + { + builder.AppendLine($" Request: {request}"); + } + + if (details.RequestUri != null) + { + builder.AppendLine($" Target origin: {ProxyPolicy.CanonicalOrigin(details.RequestUri)}"); + } + + if (!string.IsNullOrWhiteSpace(details.SuggestedAllowlistEntry)) + { + builder.AppendLine($" Allowlist entry that would admit this origin: {details.SuggestedAllowlistEntry}"); + } + + if (details.ByteCap.HasValue) + { + builder.AppendLine($" Proxy byte cap: {details.ByteCap.Value}"); + } + + if (!string.IsNullOrWhiteSpace(details.Note)) + { + builder.AppendLine($" Context: {details.Note}"); + } + + if (!string.IsNullOrWhiteSpace(details.TechnicalMessage)) + { + builder.AppendLine($" Technical message: {details.TechnicalMessage}"); + } + } + + builder.Append($" Client machine: {Environment.MachineName}"); + return builder.ToString(); + } + + private static string Normalize(string category) + { + return string.IsNullOrWhiteSpace(category) ? CategoryUnknownFailure : category; + } + + /// + /// The factual context a refusal page carries: what was requested, which side decided, + /// and the actionable numbers an admin needs (canonical allowlist entry, byte cap) plus + /// the verbatim technical message. All fields optional - a page with no details still + /// renders its cause category cleanly. + /// + internal sealed class Details + { + internal string Method; + internal Uri RequestUri; + internal string RawTarget; + internal string DecidedBy; + internal string Note; + internal string SuggestedAllowlistEntry; + internal long? ByteCap; + internal string TechnicalMessage; + + /// + /// Seed from the parsed request plus the refusal's verbatim technical message and the + /// deciding side - the three things every refusal page must carry. + /// + internal static Details ForRequest(ProxyForwardedRequest request, string technicalMessage, string decidedBy) + { + return new Details + { + Method = request == null ? null : request.Method, + RequestUri = request == null ? null : request.Uri, + RawTarget = request == null ? null : request.RawTarget, + TechnicalMessage = technicalMessage, + DecidedBy = decidedBy, + }; + } + + /// The canonical exact entry an admin could add to admit this origin. + internal Details WithSuggestedAllowlistEntry() + { + if (RequestUri != null && ProxyPolicy.IsHttpUri(RequestUri)) + { + SuggestedAllowlistEntry = ProxyPolicy.CanonicalOrigin(RequestUri); + } + + return this; + } + } + } +} diff --git a/src/WebContent/ProxyFetchBackgroundPlugin.cs b/src/WebContent/ProxyFetchBackgroundPlugin.cs new file mode 100644 index 0000000..c26bfc3 --- /dev/null +++ b/src/WebContent/ProxyFetchBackgroundPlugin.cs @@ -0,0 +1,1898 @@ +using System; +using System.Collections.Generic; +using System.Net.Security; +using System.Threading; +using System.Threading.Tasks; + +using VideoOS.Platform; +using VideoOS.Platform.Background; +using VideoOS.Platform.Messaging; + +namespace WebContent +{ + /// + /// Ticket #53: the server-side half of the proxy-fetch contract. The same WebContent.dll + /// loads into the XProtect Event Server (plugin.def load env "Service"), where this + /// BackgroundPlugin answers WebContent.Proxy.FetchRequest messages: it performs the HTTP(S) + /// GET from the server network under the ProxyHttpFetcher budgets (connect timeout, read + /// timeout, maximum response size) and streams the outcome back to the requester as framed, + /// plugin-namespaced response messages (metadata frame with status line and content headers, + /// then base64 body chunks). + /// + /// Failure classes (unreachable target, size cap exceeded, timeout) are logged to the Event + /// Server MIP log with actionable context; successful fetches are silent because proxied page + /// views make that stream high-frequency. + /// + internal sealed class ProxyFetchBackgroundPlugin : BackgroundPlugin + { + private static readonly Guid PluginId = new Guid("ced898c8-25f6-406e-b4fa-aecae669fbb0"); + + private const int MaxConcurrentFetches = 3; + private static readonly TimeSpan FetchSlotWait = TimeSpan.FromSeconds(10); + + private ServerId _serverId; + private MessageCommunication _messageCommunication; + private object _requestFilterRegistration; + private object _leafFilterRegistration; + private object _tunnelOpenFilterRegistration; + private object _tunnelDataFrameFilterRegistration; + private object _tunnelCloseFrameFilterRegistration; + private object _blockReportFilterRegistration; + private object _sessionEventFilterRegistration; + private object _statusRequestFilterRegistration; + private object _caExportRequestFilterRegistration; + private object _onlineTableFilterRegistration; + private object _newEndPointFilterRegistration; + private object _newEventIndicationRegistration; + private SemaphoreSlim _fetchSlots; + private ProxyServerAllowlist _allowlist; + private ProxyLeafIssuer _leafIssuer; + private ProxyCaExportResponder _caExportResponder; + private ProxyTunnelServer _tunnels; + + /// + /// #65: per-workspace rule-trigger rows (URL/method/status matchers) matched against + /// completed proxy fetches; a match raises the fixed "WebContent proxy request" platform + /// event so administrators can build Event Server rules on it. + /// + private ProxyTriggerMatcher _triggerMatcher; + + /// + /// #85: per-workspace fetch-response size caps (the "proxyMaxResponseBytes" setting the + /// Management Client's Proxy tab stores). Resolved per served fetch off the request's + /// workspace attribution, clamped server-side to the fixed 20 MB ceiling. + /// + private ProxyResponseCapStore _responseCaps; + + // #86: per-workspace header add/override/remove policy for BOTH fetch legs, refreshed on + // the same 60 s workspace snapshot cadence; forbidden (hop-by-hop/framing) rows are + // refused at snapshot time with an audit line, never applied. + private ProxyHeaderPolicyStore _headerPolicies; + private int _selfTestProbeDone; + + /// + /// Ticket #69: the diagnostics substrate - per-workspace blocked-origin rings and live + /// session tables, in memory only (an Event Server restart resets it by design). Fed by + /// client block reports and session events plus the fetch/tunnel paths' own accounting; + /// read by the unicast workspace status snapshots. Every path swallows faults: + /// diagnostics must never take proxy traffic down with it. + /// + private ProxyDiagnosticsAggregator _diagnostics; + private Timer _sessionSweeper; + private static readonly TimeSpan SessionIdleBackstop = TimeSpan.FromMinutes(3); + private static readonly TimeSpan SessionSweepPeriod = TimeSpan.FromSeconds(30); + + /// + /// #69: messaging endpoint FQID id → XProtect login name, mirrored from the platform's own + /// endpoint identity table (WhoAreOnlineResponse + NewEndPointIndication). Session users + /// come from here - the messaging sender identity the platform stamped - never from a + /// client-sent claim. + /// + private readonly object _identitySync = new object(); + private readonly Dictionary _endpointUsers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// ADR-0003: the deployment CA is loaded/created lazily on the first leaf request and + /// then reused - its private key lives in this machine's LocalMachine\Personal store and + /// outlives the service process. + /// + private readonly object _authorityGate = new object(); + private Authority _deploymentAuthority; + + public override Guid Id => PluginId; + + public override string Name => "Web Content proxy fetcher"; + + public override List TargetEnvironments { get; } = new List { EnvironmentType.Service }; + + public override void Init() + { + _fetchSlots = new SemaphoreSlim(MaxConcurrentFetches, MaxConcurrentFetches); + + try + { + // Same opt-in messaging trace as the client side: with WEBCONTENT_MIP_TRACE=1 on + // the Event Server machine, the platform logs every message this process's + // messaging client transmits and receives. + if (Environment.GetEnvironmentVariable("WEBCONTENT_MIP_TRACE") == "1") + { + EnvironmentManager.Instance.TraceMessageCommunication = true; + } + + _serverId = EnvironmentManager.Instance.MasterSite.ServerId; + + // Per the MIP messaging docs the Event Server plug-in may initialize the message + // communication client directly in Init(). + MessageCommunicationManager.Start(_serverId); + _messageCommunication = MessageCommunicationManager.Get(_serverId); + _requestFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyFetchRequest, + new CommunicationIdFilter(ProxyMessageIds.FetchRequestId)); + + // Ticket #54 server-side hardening: the fetcher only reaches origins some proxied + // workspace actually allowlists, so the server network cannot be driven at + // arbitrary targets by a crafted request. #73: the same plugin supplies the VMS's + // own host names so the shared matcher floors every wildcard entry here too. + _allowlist = new ProxyServerAllowlist( + ResolveWorkspacePropertySnapshots, + message => EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), message), + ResolveVmsFloorHosts); + + // #85: the per-workspace response-size cap rides the same workspace snapshot the + // allowlist and trigger rows use (same 60 s refresh semantics), and the same + // resolution clamps it server-side: no workspace can obtain a cap past the + // fixed 20 MB ceiling regardless of what its stored config claims. + _responseCaps = new ProxyResponseCapStore( + ResolveWorkspaceCapSnapshots, + message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message)); + _responseCaps.Prime(); + + // #86: the per-workspace header policy rides the same workspace snapshot (same + // 60 s refresh, last-known-good on config faults). The forbidden-header guardrail + // is enforced here at snapshot time - a stored row naming Host, Content-Length, + // Transfer-Encoding, a Proxy-* pseudo-header etc. is refused with an audit line + // and never reaches the wire, whatever the client sent. + _headerPolicies = new ProxyHeaderPolicyStore( + ResolveWorkspaceHeaderSnapshots, + message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message)); + _headerPolicies.Prime(); + + // ADR-0003: the same plugin answers leaf-mint requests for the loopback TLS + // termination; the same allowlist union gates minting exactly like fetching. + _leafIssuer = new ProxyLeafIssuer( + _allowlist, + ResolveDeploymentAuthority, + message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message)); + _leafFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyLeafRequest, + new CommunicationIdFilter(ProxyMessageIds.LeafRequestId)); + + // Ticket #57/#60/#61: the Event Server half of the websocket CONNECT tunnel. The client + // has already allowlist-gated the upgrade; this side re-gates against its own + // allowlist union, replays the handshake to the target (raw socket for http-scheme + // targets, TLS leg for https-scheme wss targets), admits only a 101, and + // pumps raw bytes both directions. The open runs on the thread pool (connect + + // handshake is seconds long), so the receive thread stays free. #61: the TLS leg + // honours the same per-target accept-untrusted override as the fetch path — the + // resolver hands the tunnel its own SslStream's callback, never a global bypass. + _tunnels = new ProxyTunnelServer( + ProxyTunnelLimits.Default(), + message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message), + ResolveTunnelTlsValidation, + (sessionId, clientBytes, targetBytes) => RecordTunnelTrafficSafe(sessionId, clientBytes, targetBytes)); + _tunnelOpenFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyTunnelOpen, + new CommunicationIdFilter(ProxyMessageIds.TunnelOpenRequestId)); + _tunnelDataFrameFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyTunnelDataFrame, + new CommunicationIdFilter(ProxyMessageIds.TunnelDataToTargetId)); + _tunnelCloseFrameFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyTunnelCloseFrame, + new CommunicationIdFilter(ProxyMessageIds.TunnelCloseToTargetId)); + + // Ticket #69: the diagnostics substrate. One-way block reports and session events + // arrive fire-and-forget; the status query is answered unicast like every other + // proxy response. The sweep timer backstops Smart Clients that die without a + // SessionClose. + _diagnostics = new ProxyDiagnosticsAggregator(); + _blockReportFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyBlockReport, + new CommunicationIdFilter(ProxyMessageIds.BlockReportId)); + _sessionEventFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxySessionEvent, + new CommunicationIdFilter(ProxyMessageIds.SessionEventId)); + _statusRequestFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyStatusRequest, + new CommunicationIdFilter(ProxyMessageIds.StatusRequestId)); + + // Ticket #75: the Management Client's pull of the deployment CA's PUBLIC + // certificate for client-machine distribution. Read-only against + // LocalMachine\Personal and provenance-independent (MSI action, legacy script, + // or lazy first-use mint all land the CA in the same store); the private key + // never participates, so answering needs no store write access. + _caExportResponder = new ProxyCaExportResponder( + ProxyCaExportResponder.FindDeploymentAuthorityCertificates, + message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message)); + _caExportRequestFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnProxyCaExportRequest, + new CommunicationIdFilter(ProxyMessageIds.CaExportRequestId)); + + // #69 user attribution: mirror the platform's endpoint identity table so a session's + // XProtect login name comes from the messaging sender identity the platform itself + // stamped. The initial WhoAreOnlineRequest fills the table for already-connected + // clients; NewEndPointIndication covers logins from here on. + _onlineTableFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnWhoAreOnlineResponse, + new CommunicationIdFilter(MessageCommunication.WhoAreOnlineResponse)); + _newEndPointFilterRegistration = _messageCommunication.RegisterCommunicationFilter( + OnNewEndPointIndication, + new CommunicationIdFilter(MessageCommunication.NewEndPointIndication)); + try + { + _messageCommunication.TransmitMessage(new Message(MessageCommunication.WhoAreOnlineRequest), null, null, null); + } + catch (Exception identityException) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy diagnostics online-user query not sent (session user names will fill in as endpoints change): {identityException.Message}"); + } + + _sessionSweeper = new Timer(_ => SweepSessionsSafely(), null, SessionSweepPeriod, SessionSweepPeriod); + + // #65: rule triggers. Per-workspace URL/method/status rows (the "proxyTriggers" + // section of the workspace's WebContent item settings) are matched against + // completed proxy fetches; each match raises the fixed "WebContent proxy request" + // platform event as a system-defined Server.NewEventCommand message (the raise + // route available to a component-hosted plugin - see the EventTracer sample's + // matching NewEventIndication receiver). The receiver logs every WebContent event + // as it comes back on the bus, which is the end-to-end proof the raise path is + // live in this process. + _triggerMatcher = new ProxyTriggerMatcher( + ResolveWorkspaceTriggerSnapshots, + message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message)); + _triggerMatcher.Prime(); + _newEventIndicationRegistration = EnvironmentManager.Instance.RegisterReceiver( + OnPlatformNewEvent, + new MessageIdFilter(MessageId.Server.NewEventIndication)); + RaiseTriggerSelfTestProbeIfRequested(); + + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy fetcher ready (concurrency={MaxConcurrentFetches}, request id={ProxyMessageIds.FetchRequestId}, diagnostics={ProxyMessageIds.BlockReportId}/{ProxyMessageIds.SessionEventId}/{ProxyMessageIds.StatusRequestId}, export={ProxyMessageIds.CaExportRequestId}, triggers={ProxyTriggerPolicy.EventType})."); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy fetcher failed to start: {exception.Message}"); + } + } + + public override void Close() + { + if (_messageCommunication != null && _requestFilterRegistration != null) + { + try + { + _messageCommunication.UnRegisterCommunicationFilter(_requestFilterRegistration); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy fetch filter detach failed: {exception.Message}"); + } + } + + if (_messageCommunication != null && _leafFilterRegistration != null) + { + try + { + _messageCommunication.UnRegisterCommunicationFilter(_leafFilterRegistration); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy leaf filter detach failed: {exception.Message}"); + } + } + + _requestFilterRegistration = null; + _leafFilterRegistration = null; + _leafIssuer = null; + + // Tear tunnels down while messaging is still alive: each close rides a message + // notice back to its requester, and the sockets must not outlive the plugin. + var tunnels = _tunnels; + _tunnels = null; + tunnels?.Dispose(); + + if (_messageCommunication != null) + { + foreach (var registration in new[] { _tunnelOpenFilterRegistration, _tunnelDataFrameFilterRegistration, _tunnelCloseFrameFilterRegistration, _blockReportFilterRegistration, _sessionEventFilterRegistration, _statusRequestFilterRegistration, _caExportRequestFilterRegistration, _onlineTableFilterRegistration, _newEndPointFilterRegistration, _newEventIndicationRegistration }) + { + if (registration == null) + { + continue; + } + + try + { + _messageCommunication.UnRegisterCommunicationFilter(registration); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy filter detach failed: {exception.Message}"); + } + } + } + + _tunnelOpenFilterRegistration = null; + _tunnelDataFrameFilterRegistration = null; + _tunnelCloseFrameFilterRegistration = null; + _blockReportFilterRegistration = null; + _sessionEventFilterRegistration = null; + _statusRequestFilterRegistration = null; + _caExportRequestFilterRegistration = null; + _caExportResponder = null; + _onlineTableFilterRegistration = null; + _newEndPointFilterRegistration = null; + _newEventIndicationRegistration = null; + _triggerMatcher = null; + + // #69: stop sweeping and drop the in-memory diagnostics tables (by design they never + // outlive the service process). + var sweeper = _sessionSweeper; + _sessionSweeper = null; + sweeper?.Dispose(); + _diagnostics = null; + lock (_identitySync) + { + _endpointUsers.Clear(); + } + + _messageCommunication = null; + + // Deliberately no MessageCommunicationManager.Stop: the messaging session is + // process-wide and may be shared by other plugins (no MIP sample ever stops it). + _serverId = null; + + var slots = _fetchSlots; + _fetchSlots = null; + slots?.Dispose(); + + EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), "Proxy fetcher stopped."); + } + + /// + /// Message-receiver thread: keep it non-blocking. The fetch runs on the thread pool and + /// the response stream is pushed back to the requester's endpoint FQID. + /// + /// The requester endpoint comes from Message.ExternalMessageSourceEndPoint, which the MIP + /// messaging layer fills on every received message with the sender's endpoint FQID (the + /// callback's source parameter only carries an explicitly passed sender identity, which + /// ProxyFetchClient does not pass). Replying to that endpoint unicasts the response frames + /// to the requesting client; replying with a null destination would filter-route them to + /// every client subscribed to the response message id - leaking page bodies into unrelated + /// hosts and letting one client's correlation ids swallow another's frames. + /// + private object OnProxyFetchRequest(Message message, FQID destination, FQID source) + { + var requester = ResolveRequester(source, message); + + var payload = ProxyFetchWireCodec.RequestFromWire(message?.Data); + if (payload == null || string.IsNullOrEmpty(payload.CorrelationId) || string.IsNullOrEmpty(payload.Url)) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Proxy fetch request carried no usable payload; refused."); + return null; + } + + Uri target; + if (!Uri.TryCreate(payload.Url, UriKind.Absolute, out target) || !ProxyPolicy.IsHttpUri(target)) + { + LogRefused(payload, "the url was not an absolute http(s) uri"); + TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri."); + return null; + } + + // #55: method and body decode against the same verb table the client forwarder uses, + // so a crafted or version-skewed payload can never drive the VMS server with an + // arbitrary verb. A null method is a pre-#55 GET sender. + var method = string.IsNullOrWhiteSpace(payload.Method) ? "GET" : payload.Method.Trim().ToUpperInvariant(); + if (!ProxyForwardPolicy.IsForwardedMethod(method)) + { + LogRefused(payload, $"'{method}' is outside the relayed method set"); + TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, $"The proxy does not relay '{method}' requests."); + return null; + } + + byte[] body = null; + if (!string.IsNullOrEmpty(payload.BodyBase64)) + { + try + { + body = Convert.FromBase64String(payload.BodyBase64); + } + catch (FormatException) + { + LogRefused(payload, "the request body was not valid base64"); + TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch request body was not valid base64."); + return null; + } + + // The client forwarder caps bodies before relaying; the fetcher re-caps what the + // wire actually carried, so no sender — old build or crafted payload — can make + // the VMS server replay an unbounded body at a target. + if (body.Length > ProxyForwardConnection.MaxRequestBodyBytes) + { + LogRefused(payload, $"the request body ({body.Length} bytes) is past the {ProxyForwardConnection.MaxRequestBodyBytes} byte request cap"); + TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.BadRequest, "The proxy-fetch request body exceeds the proxy's request cap."); + return null; + } + } + + var query = new ProxyFetchQuery + { + Uri = target, + Method = method, + Headers = payload.Headers, + Body = body, + }; + + string allowReason = null; + if (_allowlist == null || !_allowlist.IsAllowed(target, out allowReason)) + { + LogRefused(payload, allowReason); + + // #69: the server-side gate refusal is a per-origin block - recorded where it is + // decided (no client report needed for a decision the Event Server made itself). + var gateDiagnostics = _diagnostics; + if (gateDiagnostics != null && !string.IsNullOrEmpty(payload.SessionId)) + { + try + { + var gateNow = DateTime.UtcNow; + gateDiagnostics.RecordFetchTraffic(payload.SessionId, body == null ? 0 : body.Length, 0, gateNow); + gateDiagnostics.RecordBlockForSession(payload.SessionId, ProxyPolicy.CanonicalOrigin(target), ProxyBlockReasons.NotAllowed, gateNow); + } + catch (Exception diagnosticsException) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy gate refusal not recorded: {diagnosticsException.Message}"); + } + } + + TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.NotAllowed, allowReason); + return null; + } + + var communication = _messageCommunication; + if (communication == null) + { + LogRefused(payload, "the fetcher is not connected to the Event Server"); + TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.Error, "The proxy fetcher is not ready."); + return null; + } + + // #56: the certificate mode comes from this target's own allowlist entry — the union + // snapshot resolved it to exactly this canonical origin. Every other target, proxied + // or not, keeps full TLS chain validation. + var tlsMode = target.Scheme == Uri.UriSchemeHttps && _allowlist.AcceptsUntrustedCertificate(target) + ? ProxyFetchTlsMode.AcceptUntrusted + : ProxyFetchTlsMode.ValidateNormally; + + var slots = _fetchSlots; + if (slots == null) + { + return null; + } + + _ = Task.Run(async () => + { + if (!await slots.WaitAsync(FetchSlotWait)) + { + LogRefused(payload, "the fetcher queue was saturated"); + TransmitFailure(payload.CorrelationId, requester, ProxyFetchErrorCodes.Error, "The proxy fetcher is saturated; try again later."); + return; + } + + try + { + await ServeFetchAsync(communication, payload, query, requester, tlsMode); + } + finally + { + slots.Release(); + } + }); + + return null; + } + + private async Task ServeFetchAsync(MessageCommunication communication, ProxyFetchRequestPayload payload, ProxyFetchQuery query, FQID requesterFqid, ProxyFetchTlsMode tlsMode) + { + var target = query.Uri; + try + { + // #85: the fetch runs under this workspace's stored response cap (5 MB default, + // server-side 20 MB clamp); ProxyFetchTransport refuses a body past the cap with + // TooLarge exactly as before. Tunnels never reach this path - CONNECT/websocket + // streams stay uncapped by design. + var limits = ProxyFetchLimits.ForResponseCapBytes(ResolveWorkspaceResponseCapBytes(payload)); + + // #86: this workspace's header policy on BOTH legs. Request rules rewrite what is + // replayed upstream (after the codec already stripped the browser's hop-by-hop + // headers, so only end-to-end headers reach the rules); response rules rewrite + // what the framer carries back. A workspace with no policy (or an unresolvable + // store) changes nothing. + var policy = ResolveWorkspaceHeaderPolicy(payload); + if (policy.RequestRules.Count > 0) + { + ProxyHeaderPolicy.ApplyRules(query.Headers, policy.RequestRules); + } + + var result = await new ProxyHttpFetcher().FetchAsync(query, limits, tlsMode); + + // #86 response leg: rules apply only to a real upstream response (status known); + // a transport failure stays a pure error frame - injecting configured headers onto + // a failure the target never answered would misattribute them to the origin. + if (policy.ResponseRules.Count > 0 && result != null && result.StatusCode > 0) + { + ProxyHeaderPolicy.ApplyRules(result.Headers, policy.ResponseRules); + } + + if (!result.Success) + { + // Ticket acceptance: failure cases produce clear log entries in the Event + // Server log. Successes stay quiet: proxied page views make fetches frequent. + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy fetch failed (code={result.ErrorCode}, target={ProxyPolicy.CanonicalOrigin(target)}): {result.ErrorMessage}"); + } + + // #69: every served request counts toward its session's live status; per-origin + // failure codes also land in the workspace's blocked ring (component faults like + // Error map to nothing and are not per-origin blocks). + var diagnostics = _diagnostics; + if (diagnostics != null && !string.IsNullOrEmpty(payload.SessionId)) + { + try + { + var now = DateTime.UtcNow; + diagnostics.RecordFetchTraffic(payload.SessionId, query.Body == null ? 0 : query.Body.Length, result.Success ? result.BodyLength : 0, now); + string reason; + if (!result.Success && ProxyBlockReasons.TryMapErrorCode(result.ErrorCode, out reason)) + { + diagnostics.RecordBlockForSession(payload.SessionId, ProxyPolicy.CanonicalOrigin(target), reason, now); + } + } + catch (Exception diagnosticsException) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy fetch traffic not recorded: {diagnosticsException.Message}"); + } + } + + // #65: rule triggers run on the fetch path AFTER the response is known - the row's + // status filter matches the actual response status. A transport failure (status 0) + // never matches; the response frames are already on their way and an event fault + // must not touch them. + RaiseTriggerEventsSafely(payload, query, result.StatusCode); + + if (requesterFqid == null) + { + // Nowhere to answer: broadcasting fetched page bodies to every messaging + // client is a leak, not a fallback. The requester's own deadline closes its + // exchange. + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy fetch response for {payload.CorrelationId} not sent: the request carried no requester endpoint."); + return; + } + + foreach (var frame in ProxyFetchFramer.Frame(payload.CorrelationId, result, limits.ChunkSizeBytes)) + { + communication.TransmitMessage(new Message(ProxyMessageIds.FetchResponseId, ProxyFetchWireCodec.ToWire(frame)), requesterFqid, null, null); + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy fetch aborted (target={(target == null ? "unknown" : ProxyPolicy.CanonicalOrigin(target))}): {exception.Message}"); + + // #69: an aborted fetch still counted as one request for its session; the generic + // Error fault is not a per-origin block, so no ring entry. + var aborted = _diagnostics; + if (aborted != null && !string.IsNullOrEmpty(payload.SessionId)) + { + try + { + aborted.RecordFetchTraffic(payload.SessionId, query.Body == null ? 0 : query.Body.Length, 0, DateTime.UtcNow); + } + catch (Exception diagnosticsException) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy fetch traffic not recorded: {diagnosticsException.Message}"); + } + } + + try + { + TransmitFailure(payload.CorrelationId, requesterFqid, ProxyFetchErrorCodes.Error, exception.Message); + } + catch (Exception transmitException) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy failure frame not sent: {transmitException.Message}"); + } + } + } + + /// + /// ADR-0003: answer one loopback-leaf mint request. Issuance is local CPU plus a store + /// lookup - fast enough for the messaging receive thread, unlike the fetch path. The + /// response is unicasted to the requester endpoint exactly like fetch frames: a null + /// destination would broadcast a certificate to every subscribed host. + /// + private object OnProxyLeafRequest(Message message, FQID destination, FQID source) + { + var payload = ProxyFetchWireCodec.LeafRequestFromWire(message?.Data); + if (payload == null || string.IsNullOrEmpty(payload.CorrelationId)) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Leaf request carried no usable payload; refused."); + return null; + } + + var requester = source ?? message?.ExternalMessageSourceEndPoint; + if (requester == null) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Leaf request {payload.CorrelationId} carried no requester endpoint; its response is logged rather than broadcast."); + return null; + } + + var communication = _messageCommunication; + if (communication == null) + { + return null; + } + + ProxyLeafResponsePayload response; + try + { + response = _leafIssuer.Issue(payload); + } + catch (Exception exception) + { + // Issue is written not to throw; anything escaping still answers as a failure + // frame rather than leaving the requester to time out. + response = new ProxyLeafResponsePayload + { + CorrelationId = payload.CorrelationId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = exception.Message, + }; + } + + try + { + communication.TransmitMessage( + new Message(ProxyMessageIds.LeafResponseId, ProxyFetchWireCodec.ToWire(response)), + requester, + null, + null); + } + catch (Exception transmitException) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Leaf response {response.CorrelationId} not sent: {transmitException.Message}"); + } + + return null; + } + + /// + /// ADR-0003: the long-lived deployment CA for this Event Server machine, loaded from (or + /// created in) LocalMachine\Personal on first use. The private key never leaves this + /// machine's machine-level key storage; a service that cannot write that store answers + /// NoAuthority and recovers once an operator provisions the CA (the creation is retried + /// per request until it succeeds). + /// + private Authority ResolveDeploymentAuthority() + { + lock (_authorityGate) + { + if (_deploymentAuthority == null) + { + _deploymentAuthority = Authority.LoadOrCreateDeploymentAuthority( + message => EnvironmentManager.Instance.Log(false, nameof(ProxyFetchBackgroundPlugin), message)); + } + + return _deploymentAuthority; + } + } + + /// + /// Ticket #57: answer one tunnel-open. Re-gated against this process's own allowlist + /// union (the client's decision table is the first gate, not the only one), then the open + /// runs on the thread pool: connect + handshake replay is seconds long and must never + /// block the messaging receive thread. Frames for this tunnel unicast to the requester + /// endpoint - a null destination would broadcast one device's websocket frames to every + /// subscribed host. + /// + private object OnProxyTunnelOpen(Message message, FQID destination, FQID source) + { + var requester = ResolveRequester(source, message); + + var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data); + if (payload == null || string.IsNullOrEmpty(payload.CorrelationId)) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Tunnel open carried no usable payload; refused."); + return null; + } + + var communication = _messageCommunication; + var tunnels = _tunnels; + if (communication == null || tunnels == null) + { + TransmitTunnelOpenAck(communication, requester, payload.CorrelationId, new ProxyTunnelOpenOutcome + { + Success = false, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = "The proxy tunnel server is not ready.", + }); + return null; + } + + Uri target; + string allowReason = null; + if (!Uri.TryCreate(payload.Url, UriKind.Absolute, out target) + || !ProxyPolicy.IsHttpUri(target) + || _allowlist == null + || !_allowlist.IsAllowed(target, out allowReason)) + { + // #60: https-scheme tunnel targets (wss upgrades) are re-gated against this + // process's own allowlist exactly like http ones - the entry's scheme decides + // whether the dialer adds a validated TLS leg, never whether the target is asked. + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Tunnel open {payload.CorrelationId} refused: {(allowReason ?? "the tunnel target was not an absolute http(s) origin")}."); + RecordTunnelOpenAttempt(payload.SessionId, target, ProxyFetchErrorCodes.NotAllowed); + TransmitTunnelOpenAck(communication, requester, payload.CorrelationId, new ProxyTunnelOpenOutcome + { + Success = false, + ErrorCode = ProxyFetchErrorCodes.NotAllowed, + ErrorMessage = allowReason ?? "The tunnel target is not an absolute allowlisted http(s) origin.", + }); + return null; + } + + if (requester == null) + { + // Nowhere to unicast the frame stream: a broadcast tunnel leaks every frame to + // every subscriber, so the tunnel must not open at all. + return null; + } + + _ = Task.Run(async () => + { + Func toClient = frame => + { + var id = frame.Kind == ProxyTunnelFrameKinds.Close + ? ProxyMessageIds.TunnelCloseFromTargetId + : ProxyMessageIds.TunnelDataFromTargetId; + try + { + communication.TransmitMessage(new Message(id, ProxyFetchWireCodec.ToWire(frame)), requester, null, null); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Tunnel {frame.CorrelationId} frame ({id}) not sent: {exception.Message}"); + } + + return Task.CompletedTask; + }; + + ProxyTunnelOpenOutcome outcome; + try + { + outcome = await tunnels.OpenAsync(payload, toClient); + } + catch (Exception exception) + { + // OpenAsync is written not to throw; anything escaping still answers as a + // failure rather than leaving the browser waiting for a 101 that never comes. + outcome = new ProxyTunnelOpenOutcome + { + Success = false, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = exception.Message, + }; + } + + // #69: the open attempt counts as one request for the session; a target-side + // failure (unreachable, TLS refusal, non-101 answer) is a per-origin block. + RecordTunnelOpenAttempt(payload.SessionId, target, outcome.Success ? null : outcome.ErrorCode); + + TransmitTunnelOpenAck(communication, requester, payload.CorrelationId, outcome); + }); + + return null; + } + + private object OnProxyTunnelDataFrame(Message message, FQID destination, FQID source) + { + var tunnels = _tunnels; + if (tunnels != null) + { + tunnels.OnClientData(ProxyFetchWireCodec.TunnelFromWire(message?.Data)); + } + + return null; + } + + private object OnProxyTunnelCloseFrame(Message message, FQID destination, FQID source) + { + var tunnels = _tunnels; + if (tunnels != null) + { + tunnels.OnClientClose(ProxyFetchWireCodec.TunnelFromWire(message?.Data)); + } + + return null; + } + + /// + /// #61: the per-target certificate posture for the wss tunnel's TLS leg. The allowlist's + /// accept-untrusted decision for exactly this canonical origin (the same scoped #56 + /// decision the fetch path applies at line "OnProxyFetchRequest") becomes the validation + /// callback on THIS tunnel's own SslStream; everything else — no flag, an http-scheme + /// target, a closed gate, or version skew hiding the flag — resolves to null, SslStream's + /// default full chain validation. Never a process-wide bypass, never the browser-side + /// loopback termination. + /// + private RemoteCertificateValidationCallback ResolveTunnelTlsValidation(Uri target) + { + var allowlist = _allowlist; + return allowlist == null ? null : allowlist.TunnelTlsValidationCallback(target); + } + + /// + /// #69: one client-decided proxy block. The workspace must be a configured workspace item on + /// this server (a stale view item or a crafted payload has nowhere to be filed), and the + /// reason must be one of the six wire categories. Dedup and ring eviction live in the + /// aggregator; successes stay quiet - a refusal storm must not spam the MIP log. + /// + private object OnProxyBlockReport(Message message, FQID destination, FQID source) + { + var report = ProxyFetchWireCodec.BlockReportFromWire(message?.Data); + if (report == null || string.IsNullOrEmpty(report.Origin) || string.IsNullOrEmpty(report.Reason)) + { + return null; + } + + var diagnostics = _diagnostics; + if (diagnostics == null || !IsKnownBlockReason(report.Reason) || !IsKnownWorkspaceItemId(report.WorkspaceItemId)) + { + return null; + } + + try + { + if (diagnostics.RecordBlock(report.WorkspaceItemId, report.Origin, report.Reason, report.Count, DateTime.UtcNow)) + { + // One line per newly blocked origin (workspace+origin+reason); the dedup + // refreshes that follow stay quiet, so a refusal storm cannot bury the log. + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy block recorded: workspace='{report.WorkspaceItemId}' origin='{report.Origin}' reason={report.Reason} client='{report.ClientMachine}'"); + } + + // #80: a client-decided CertUntrusted report names a machine whose proxied views + // cannot trust the deployment CA (the #72 posture flip reports exactly this, and + // the forwarder's TLS-termination-failure sites do when the mint itself dies). + // Attribute it to that machine - deduped per machine, once each no matter how + // many views report - and log the NEW-machine transition once. ES-decided TLS + // failures never land here: they say the target's chain failed at the server, + // not that a workstation distrusts the proxy CA. + if (string.Equals(report.Reason, ProxyBlockReasons.CertUntrusted, StringComparison.Ordinal) + && !string.IsNullOrEmpty(report.ClientMachine) + && diagnostics.RecordUntrustedAuthorityReport(report.WorkspaceItemId, report.ClientMachine, DateTime.UtcNow)) + { + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy CA trust report recorded: workspace='{report.WorkspaceItemId}' machine='{report.ClientMachine}' needs the deployment CA imported into its trusted roots."); + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy block report not recorded: {exception.Message}"); + } + + return null; + } + + /// + /// #69: one session-lifecycle event. Open registers the session under its (validated) + /// workspace with the user resolved from the platform's endpoint identity table - never + /// from a client-sent claim; Heartbeat keeps it off the idle backstop; Close retires it. + /// + private object OnProxySessionEvent(Message message, FQID destination, FQID source) + { + var payload = ProxyFetchWireCodec.SessionEventFromWire(message?.Data); + if (payload == null || string.IsNullOrEmpty(payload.SessionId) || string.IsNullOrEmpty(payload.Kind)) + { + return null; + } + + var diagnostics = _diagnostics; + if (diagnostics == null) + { + return null; + } + + try + { + var now = DateTime.UtcNow; + switch (payload.Kind) + { + case ProxySessionEventKinds.Open: + if (!IsKnownWorkspaceItemId(payload.WorkspaceItemId)) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy session open for unknown workspace '{payload.WorkspaceItemId}' dropped."); + return null; + } + + var openUser = ResolveEndpointUser(message); + diagnostics.OpenSession(payload.SessionId, payload.WorkspaceItemId, openUser, payload.ClientMachine, now); + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy diagnostics session open: workspace='{payload.WorkspaceItemId}' user='{openUser ?? "(unknown)"}' machine='{payload.ClientMachine}'"); + break; + + case ProxySessionEventKinds.Heartbeat: + diagnostics.HeartbeatSession(payload.SessionId, now); + break; + + case ProxySessionEventKinds.Close: + diagnostics.CloseSession(payload.SessionId, now); + break; + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy session event ({payload.Kind}) not recorded: {exception.Message}"); + } + + return null; + } + + /// + /// #69: the pull of one workspace's live proxy state. Answered unicast to the requester + /// endpoint like every other proxy response (a broadcast snapshot would leak one customer's + /// browsing pattern to every subscriber). An unknown workspace answers as a failure, not an + /// empty snapshot, so the MC page can tell "gone" from "quiet". + /// + private object OnProxyStatusRequest(Message message, FQID destination, FQID source) + { + var payload = ProxyFetchWireCodec.StatusRequestFromWire(message?.Data); + if (payload == null || string.IsNullOrEmpty(payload.CorrelationId)) + { + return null; + } + + var requester = ResolveRequester(source, message); + ProxyStatusResponsePayload response; + var diagnostics = _diagnostics; + if (diagnostics == null) + { + response = new ProxyStatusResponsePayload + { + CorrelationId = payload.CorrelationId, + WorkspaceItemId = payload.WorkspaceItemId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = "The proxy fetcher is not ready.", + }; + } + else if (!IsKnownWorkspaceItemId(payload.WorkspaceItemId)) + { + response = new ProxyStatusResponsePayload + { + CorrelationId = payload.CorrelationId, + WorkspaceItemId = payload.WorkspaceItemId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.BadRequest, + ErrorMessage = "The workspace item id is not a configured workspace on this server.", + }; + } + else + { + response = diagnostics.Snapshot(payload.WorkspaceItemId, DateTime.UtcNow); + response.CorrelationId = payload.CorrelationId; + } + + var communication = _messageCommunication; + if (communication == null || requester == null) + { + return null; + } + + try + { + communication.TransmitMessage( + new Message(ProxyMessageIds.StatusResponseId, ProxyFetchWireCodec.ToWire(response)), + requester, + null, + null); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy status response {payload.CorrelationId} not sent: {exception.Message}"); + } + + return null; + } + + /// + /// Ticket #75: answer one Management Client pull of the deployment CA's public + /// certificate. The lookup is a store read - fast enough for the messaging receive + /// thread, like the leaf mint. The answer is unicasted to the requester endpoint: a + /// null destination would deliver the certificate to every host subscribed to the + /// export response id. Export never creates the CA and never reads a private key - an + /// absent CA is a refusal the admin can act on, not an empty success. + /// + private object OnProxyCaExportRequest(Message message, FQID destination, FQID source) + { + var payload = ProxyFetchWireCodec.CaExportRequestFromWire(message?.Data); + if (payload == null || string.IsNullOrEmpty(payload.CorrelationId)) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), "Proxy certificate export request carried no usable payload; refused."); + return null; + } + + var requester = ResolveRequester(source, message); + if (requester == null) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy certificate export request {payload.CorrelationId} carried no requester endpoint; its response is logged rather than broadcast."); + return null; + } + + var communication = _messageCommunication; + if (communication == null) + { + return null; + } + + ProxyCaExportResponsePayload response; + try + { + // Respond is written not to throw; anything escaping still answers as a failure + // frame rather than leaving the Management Client to time out. + response = _caExportResponder.Respond(payload); + } + catch (Exception exception) + { + response = new ProxyCaExportResponsePayload + { + CorrelationId = payload.CorrelationId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = exception.Message, + }; + } + + try + { + communication.TransmitMessage( + new Message(ProxyMessageIds.CaExportResponseId, ProxyFetchWireCodec.ToWire(response)), + requester, + null, + null); + } + catch (Exception transmitException) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy certificate export response {response.CorrelationId} not sent: {transmitException.Message}"); + } + + return null; + } + + /// + /// #69: traffic from the tunnel server's per-chunk seam. Fire-and-swallow: accounting must + /// never break a byte pump. + /// + private void RecordTunnelTrafficSafe(string sessionId, long clientBytes, long targetBytes) + { + var diagnostics = _diagnostics; + if (diagnostics == null || string.IsNullOrEmpty(sessionId)) + { + return; + } + + try + { + diagnostics.RecordTunnelTraffic(sessionId, clientBytes, targetBytes, DateTime.UtcNow); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy tunnel traffic not recorded: {exception.Message}"); + } + } + + /// + /// #69: count one tunnel-open attempt toward its session (+1 request) and file a block when + /// the failure is a per-origin category (the gate refusal, an unreachable target, a refused + /// handshake). Successes and component faults (Error) count but never block. + /// + private void RecordTunnelOpenAttempt(string sessionId, Uri target, string errorCode) + { + var diagnostics = _diagnostics; + if (diagnostics == null || string.IsNullOrEmpty(sessionId)) + { + return; + } + + try + { + var now = DateTime.UtcNow; + diagnostics.RecordFetchTraffic(sessionId, 0, 0, now); + string reason; + if (target != null && !string.IsNullOrEmpty(errorCode) && ProxyBlockReasons.TryMapErrorCode(errorCode, out reason)) + { + diagnostics.RecordBlockForSession(sessionId, ProxyPolicy.CanonicalOrigin(target), reason, now); + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy tunnel open not recorded: {exception.Message}"); + } + } + + private void SweepSessionsSafely() + { + try + { + var diagnostics = _diagnostics; + if (diagnostics == null) + { + return; + } + + var reclaimed = diagnostics.SweepIdleSessions(SessionIdleBackstop, DateTime.UtcNow); + if (reclaimed.Count > 0) + { + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy diagnostics: {reclaimed.Count} session(s) idle past {SessionIdleBackstop.TotalMinutes:0} minutes reclaimed (a Smart Client died without a session close)."); + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy diagnostics sweep faulted: {exception.Message}"); + } + } + + /// + /// #85: the response cap that governs one served fetch, resolved from the stored config + /// of the workspace the request is attributed to. A pre-#65 Smart Client that stamps no + /// workspace id falls back to the session's workspace in the diagnostics session map (the + /// same attribution rule #65 uses); still unattributable means the 5 MB default. A lookup + /// fault is not a reason to change a fetch's posture - it answers the default too. + /// + private int ResolveWorkspaceResponseCapBytes(ProxyFetchRequestPayload payload) + { + try + { + var store = _responseCaps; + if (store == null) + { + return ProxyFetchLimits.DefaultMaxResponseBytes; + } + + var workspaceItemId = payload.WorkspaceItemId; + if (string.IsNullOrEmpty(workspaceItemId)) + { + string mapped; + workspaceItemId = _diagnostics != null && _diagnostics.TryGetSessionWorkspace(payload.SessionId, out mapped) + ? mapped + : null; + } + + return store.ResolveEffectiveBytes(workspaceItemId); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy response cap lookup failed (fetching at the default cap): {exception.Message}"); + return ProxyFetchLimits.DefaultMaxResponseBytes; + } + } + + /// + /// #86: the workspace a fetch's header policy is attributed to - identical attribution to + /// (payload stamp first, then the session's + /// workspace in the diagnostics map). An unattributable or unresolved lookup answers the + /// EMPTY policy: plain pass-through, never a refusal and never invented headers. + /// + private WorkspaceHeaderPolicy ResolveWorkspaceHeaderPolicy(ProxyFetchRequestPayload payload) + { + try + { + var store = _headerPolicies; + if (store == null) + { + return WorkspaceHeaderPolicy.Empty; + } + + var workspaceItemId = payload.WorkspaceItemId; + if (string.IsNullOrEmpty(workspaceItemId)) + { + string mapped; + workspaceItemId = _diagnostics != null && _diagnostics.TryGetSessionWorkspace(payload.SessionId, out mapped) + ? mapped + : null; + } + + return store.Resolve(workspaceItemId); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy header policy lookup failed (fetching with no header rules): {exception.Message}"); + return WorkspaceHeaderPolicy.Empty; + } + } + + /// + /// #65: raise the platform event for one rule-trigger match on the event bus + /// (Server.NewEventCommand, the system-defined raise the Event Server processes through its + /// matcher chain). Fire-and-swallow: a trigger event must never alter or break the fetch + /// response that is already on its way back to the browser. The fetch payload's workspace + /// attribution wins; an older Smart Client that stamps no workspace id falls back to the + /// session's workspace in the diagnostics session map; an unattributable request is fail- + /// closed (no event), mirroring how #69 treats unattributed traffic. + /// + private void RaiseTriggerEventsSafely(ProxyFetchRequestPayload payload, ProxyFetchQuery query, int responseStatus) + { + var matcher = _triggerMatcher; + if (matcher == null || query == null || responseStatus <= 0) + { + return; + } + + try + { + var workspaceItemId = payload.WorkspaceItemId; + if (string.IsNullOrEmpty(workspaceItemId)) + { + string mapped; + workspaceItemId = _diagnostics != null && _diagnostics.TryGetSessionWorkspace(payload.SessionId, out mapped) + ? mapped + : null; + } + + ProxyTriggerMatcher.TriggerMatch match; + if (!matcher.TryMatch(workspaceItemId, query.Uri, query.Method, responseStatus, out match)) + { + return; + } + + var workspaceName = match.WorkspaceName ?? match.WorkspaceItemId; + var row = match.RowDescription ?? "row"; + var analyticsEvent = new VideoOS.Platform.Data.AnalyticsEvent + { + EventHeader = new VideoOS.Platform.Data.EventHeader + { + ID = Guid.NewGuid(), + Timestamp = DateTime.Now, + Message = ProxyTriggerPolicy.EventType, + // The admin's Analytics Event definition matches on Message; the fixed + // property text (workspace | method | url | status) rides in CustomTag for + // the rule's condition values and action text. + CustomTag = $"{workspaceName} | {query.Method} {query.Uri.AbsoluteUri} | HTTP {responseStatus} | row {row}", + Source = new VideoOS.Platform.Data.EventSource + { + FQID = WorkspaceItemFqid(match.WorkspaceItemId), + Name = workspaceName, + }, + }, + Description = $"{workspaceName} | {query.Method} {query.Uri.AbsoluteUri} | HTTP {responseStatus}", + }; + RaiseAnalyticsEvent(analyticsEvent); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy trigger evaluation faulted: {exception.Message}"); + } + } + + /// + /// #65: workspace item id (the FQID ObjectId string) → the workspace's FQID for the event + /// Source. Live-proven on the Event Server: its event processor validates the source and + /// answers a sourceless event with ArgumentException "Invalid event", so a match whose + /// workspace item vanished mid-flight produces a raise the Event Server rejects (logged + /// by , never a fault in the fetch path). + /// + private static FQID WorkspaceItemFqid(string workspaceItemId) + { + if (string.IsNullOrEmpty(workspaceItemId)) + { + return null; + } + + try + { + List workspaces; + Exception error; + if (!Admin.WorkspaceItemManager.TryGetWorkspaces(out workspaces, out error)) + { + return null; + } + + foreach (var workspace in workspaces) + { + if (workspace != null && workspace.FQID != null && + string.Equals(workspace.FQID.ObjectId.ToString(), workspaceItemId, StringComparison.OrdinalIgnoreCase)) + { + return workspace.FQID; + } + } + } + catch (Exception) + { + // Attribution is best-effort. + } + + return null; + } + + private static bool SelfTestFlagFilePresent() + { + try + { + var directory = System.IO.Path.GetDirectoryName( + new System.Uri(typeof(ProxyFetchBackgroundPlugin).Assembly.CodeBase).LocalPath); + return !string.IsNullOrEmpty(directory) && + System.IO.File.Exists(System.IO.Path.Combine(directory, "trigger-selftest.flag")); + } + catch (Exception) + { + return false; + } + } + + /// + /// #65: the single raise route for this plugin's analytics events. The documented way for + /// a plugin to generate an analytics event is EnvironmentManager.Instance.SendMessage + /// with a message whose Data is a TYPED + /// VideoOS.Platform.Data.AnalyticsEvent: unlike the plugin-namespace payloads that + /// forced the #54 JSON-string discipline, AnalyticsEvent is a platform type every host + /// binds through its own type resolution. (Raw MessageCommunication.TransmitMessage of a + /// system-defined message id is refused by the transmit layer outright.) Replies, if any, + /// are logged: an empty reply set is the Event Server core consuming the event normally. + /// + private void RaiseAnalyticsEvent(VideoOS.Platform.Data.AnalyticsEvent analyticsEvent) + { + try + { + var results = EnvironmentManager.Instance.SendMessage( + new Message(MessageId.Server.NewEventCommand) + { + Data = analyticsEvent, + RelatedFQID = analyticsEvent.EventHeader.Source != null + ? analyticsEvent.EventHeader.Source.FQID + : null, + }); + + string replies = null; + if (results != null && results.Count > 0) + { + var first = results[0]; + replies = $" replies={results.Count} first={first}"; + } + + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Analytics event raised: {analyticsEvent.EventHeader.ID} \"{analyticsEvent.EventHeader.Message}\"{replies}"); + } + catch (Exception raiseException) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Analytics event not raised ({analyticsEvent.EventHeader.CustomTag ?? analyticsEvent.EventHeader.Message}): {raiseException.GetType().Name}: {raiseException.Message}"); + } + } + + /// + /// #65 live-gate self-test: one probe event rides the same raise path the trigger matches + /// use, and the NewEventIndication receiver logs its return; the pair of log lines proves + /// the plugin's event registration (raise + observe) is alive inside the running service. + /// Requested either by WEBCONTENT_TRIGGER_SELFTEST=1 in the service environment or by a + /// trigger-selftest.flag file next to the plugin dll (the deployed folder's write ACL is a + /// deliberate operator grant, so a lab run can request the probe without touching machine + /// state). One-shot per process; never fires without one of the two switches. + /// + private void RaiseTriggerSelfTestProbeIfRequested() + { + if (Environment.GetEnvironmentVariable("WEBCONTENT_TRIGGER_SELFTEST") != "1" && !SelfTestFlagFilePresent()) + { + return; + } + + if (Interlocked.Exchange(ref _selfTestProbeDone, 1) != 0) + { + return; + } + + try + { + // The probe carries the SAME source shape as a real trigger event (an existing + // VMS item as the event source): the Event Server's event processor validates the + // source and rejects sourceless events with ArgumentException "Invalid event" - + // proven live. The first workspace item in the system is as faithful as any. + FQID sourceFqid = null; + string sourceName = null; + List workspaces; + Exception lookupError; + if (Admin.WorkspaceItemManager.TryGetWorkspaces(out workspaces, out lookupError) && + workspaces != null && workspaces.Count > 0) + { + sourceFqid = workspaces[0].FQID; + sourceName = workspaces[0].Name; + } + + var probe = new VideoOS.Platform.Data.AnalyticsEvent + { + EventHeader = new VideoOS.Platform.Data.EventHeader + { + ID = Guid.NewGuid(), + Timestamp = DateTime.Now, + Message = ProxyTriggerPolicy.EventType, + CustomTag = "selftest", + Source = new VideoOS.Platform.Data.EventSource + { + FQID = sourceFqid, + Name = sourceName ?? "WebContent self-test", + }, + }, + Description = "WebContent trigger self-test probe", + }; + RaiseAnalyticsEvent(probe); + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Trigger self-test probe raised: {probe.EventHeader.ID} \"{ProxyTriggerPolicy.EventType}\" source={sourceName ?? "(none)"}"); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Trigger self-test probe failed: {exception.Message}"); + } + } + + /// + /// #65: the platform event bus echo for this process. Only WebContent's own event type is + /// logged (the bus carries every VMS event; logging them all would bury the MIP log); the + /// self-test probe id logs with it as the raise-path round trip marker. + /// + private object OnPlatformNewEvent(Message message, FQID sourceFqid, FQID destinationFqid) + { + try + { + if (message == null) + { + return null; + } + + // Typed-first: the raise path sends the platform's own AnalyticsEvent object, and + // every host binds that type through its own resolution (the #54 silent-null + // hazard was plugin-namespace types only). The string branch is the belt for a + // host that hands back the serialized form. + var evt = message.Data as VideoOS.Platform.Data.AnalyticsEvent; + if (evt == null) + { + var json = message.Data as string; + if (!string.IsNullOrEmpty(json)) + { + evt = ProxyTriggerEventCodec.FromWire(json); + } + } + + if (evt == null || evt.EventHeader == null) + { + return null; + } + + if (string.Equals(evt.EventHeader.Message, ProxyTriggerPolicy.EventType, StringComparison.OrdinalIgnoreCase)) + { + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy trigger event seen on the bus: {evt.EventHeader.ID} \"{evt.EventHeader.Message}\" ({evt.EventHeader.CustomTag ?? "no tag"})"); + } + } + catch (Exception) + { + // Observation only; a fault here must not touch messaging. + } + + return null; + } + + /// + /// #65: per-workspace trigger rows for the matcher, read from the WebContent item settings + /// the Management Client page stores (the proxyTriggers section). Invalid rows are refused + /// by ProxyTriggerPolicy with a specific reason logged; one bad row never drops the + /// workspace's other rows. + /// + private static System.Collections.Generic.IEnumerable ResolveWorkspaceTriggerSnapshots() + { + List workspaces; + Exception error; + if (!Admin.WorkspaceItemManager.TryGetWorkspaces(out workspaces, out error)) + { + throw error ?? new InvalidOperationException("Workspace configuration lookup failed."); + } + + var sources = new List(); + foreach (var workspace in workspaces) + { + if (workspace != null && workspace.FQID != null && workspace.Properties != null) + { + sources.Add(new ProxyWorkspaceTriggerSource + { + ItemId = workspace.FQID.ObjectId.ToString(), + Name = workspace.Name, + Properties = workspace.Properties, + }); + } + } + + return sources; + } + + /// + /// #69: the XProtect login name of the endpoint that sent this message, from the platform's + /// own endpoint identity table. Null for endpoints the table does not name yet. + /// + private object OnWhoAreOnlineResponse(Message message, FQID destination, FQID source) + { + FeedEndpointIdentities(message == null ? null : message.Data); + return null; + } + + private object OnNewEndPointIndication(Message message, FQID destination, FQID source) + { + FeedEndpointIdentities(message == null ? null : message.Data); + return null; + } + + private void FeedEndpointIdentities(object data) + { + try + { + if (data == null) + { + return; + } + + var single = data as EndPointIdentityData; + if (single != null) + { + UpsertEndpointUser(single); + return; + } + + var collection = data as System.Collections.IEnumerable; + if (collection == null) + { + return; + } + + foreach (var item in collection) + { + var identity = item as EndPointIdentityData; + if (identity != null) + { + UpsertEndpointUser(identity); + } + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy endpoint identity table update failed: {exception.Message}"); + } + } + + private void UpsertEndpointUser(EndPointIdentityData identity) + { + var fqid = identity.EndPointFQID; + if (fqid == null || string.IsNullOrEmpty(identity.IdentityName)) + { + return; + } + + lock (_identitySync) + { + _endpointUsers[fqid.ObjectId.ToString()] = identity.IdentityName; + } + } + + private string ResolveEndpointUser(Message message) + { + var endpoint = message == null ? null : message.ExternalMessageSourceEndPoint; + if (endpoint == null) + { + return null; + } + + lock (_identitySync) + { + string name; + return _endpointUsers.TryGetValue(endpoint.ObjectId.ToString(), out name) ? name : null; + } + } + + private static bool IsKnownBlockReason(string reason) + { + return reason == ProxyBlockReasons.NotAllowed + || reason == ProxyBlockReasons.TooLarge + || reason == ProxyBlockReasons.CertUntrusted + || reason == ProxyBlockReasons.Unreachable + || reason == ProxyBlockReasons.Timeout + || reason == ProxyBlockReasons.TunnelRefused; + } + + /// + /// #69: is this a workspace item this server actually owns? The same Configuration lookup + /// the allowlist union uses; a failed lookup fails closed (the report or query is dropped) - + /// unlike the allowlist there is nothing last-known-good to keep serving. + /// + private bool IsKnownWorkspaceItemId(string workspaceItemId) + { + Guid id; + if (string.IsNullOrEmpty(workspaceItemId) || !Guid.TryParse(workspaceItemId, out id) || id == Guid.Empty) + { + return false; + } + + try + { + List workspaces; + Exception error; + if (!Admin.WorkspaceItemManager.TryGetWorkspaces(out workspaces, out error)) + { + return false; + } + + foreach (var workspace in workspaces) + { + if (workspace != null && workspace.FQID != null && workspace.FQID.ObjectId.ToString() == workspaceItemId) + { + return true; + } + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchBackgroundPlugin), $"Proxy diagnostics workspace lookup failed: {exception.Message}"); + } + + return false; + } + + private static void TransmitTunnelOpenAck(MessageCommunication communication, FQID requester, string tunnelId, ProxyTunnelOpenOutcome outcome) + { + if (communication == null || requester == null) + { + return; + } + + try + { + communication.TransmitMessage( + new Message( + ProxyMessageIds.TunnelOpenResponseId, + ProxyFetchWireCodec.ToWire(new ProxyTunnelPayload + { + CorrelationId = tunnelId, + Success = outcome.Success, + StatusCode = outcome.StatusCode, + DataBase64 = outcome.HandshakeBytes == null || outcome.HandshakeBytes.Length == 0 + ? null + : Convert.ToBase64String(outcome.HandshakeBytes), + ErrorCode = outcome.ErrorCode, + ErrorMessage = outcome.ErrorMessage, + })), + requester, + null, + null); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Tunnel open ack {tunnelId} not sent: {exception.Message}"); + } + } + + /// + /// The endpoint to answer a request on. The explicit source FQID (rarely sent) wins; the + /// platform-filled ExternalMessageSourceEndPoint is the normal answer path. When neither + /// exists there is no unicast route, and responses must not fall back to a broadcast. + /// + private static FQID ResolveRequester(FQID source, Message message) + { + var requester = source ?? message?.ExternalMessageSourceEndPoint; + if (requester == null) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + "Proxy fetch request carried no requester endpoint; any response for it will be logged rather than sent."); + } + + return requester; + } + + /// + /// Snapshot of every workspace item's property dictionary for the allowlist union. The + /// same Configuration lookup the Smart Client workspace provisioning uses; if it fails, + /// the allowlist keeps its last-known-good gate (and stays closed before its first success). + /// + private static System.Collections.Generic.IEnumerable> ResolveWorkspacePropertySnapshots() + { + if (!Admin.WorkspaceItemManager.TryGetWorkspaces(out var workspaces, out var error)) + { + throw error ?? new InvalidOperationException("Workspace configuration lookup failed."); + } + + var snapshots = new List>(); + foreach (var workspace in workspaces) + { + if (workspace != null && workspace.Properties != null) + { + snapshots.Add(workspace.Properties); + } + } + + return snapshots; + } + + /// + /// #85: the workspace items the response-cap store reads, item id and all - the same + /// Configuration lookup every other per-workspace resolver uses. + /// + private static System.Collections.Generic.IEnumerable ResolveWorkspaceCapSnapshots() + { + List workspaces; + Exception error; + if (!Admin.WorkspaceItemManager.TryGetWorkspaces(out workspaces, out error)) + { + throw error ?? new InvalidOperationException("Workspace configuration lookup failed."); + } + + var sources = new List(); + foreach (var workspace in workspaces) + { + if (workspace != null && workspace.FQID != null && workspace.Properties != null) + { + sources.Add(new ProxyWorkspaceCapSource + { + ItemId = workspace.FQID.ObjectId.ToString(), + Name = workspace.Name, + Properties = workspace.Properties, + }); + } + } + + return sources; + } + + /// + /// #86: the workspace items the header-policy store reads, item id and all - the same + /// Configuration lookup every other per-workspace resolver uses. + /// + private static System.Collections.Generic.IEnumerable ResolveWorkspaceHeaderSnapshots() + { + List workspaces; + Exception error; + if (!Admin.WorkspaceItemManager.TryGetWorkspaces(out workspaces, out error)) + { + throw error ?? new InvalidOperationException("Workspace configuration lookup failed."); + } + + var sources = new List(); + foreach (var workspace in workspaces) + { + if (workspace != null && workspace.FQID != null && workspace.Properties != null) + { + sources.Add(new ProxyWorkspaceHeaderSource + { + ItemId = workspace.FQID.ObjectId.ToString(), + Name = workspace.Name, + Properties = workspace.Properties, + }); + } + } + + return sources; + } + + /// + /// #73: the VMS's own host names for the wildcard SSRF floor - the Management Server / + /// API Gateway host the platform knows from EnvironmentManager (the very server + /// this fetcher runs beside / on). Wildcard allowlist entries never match a host listed + /// here; explicit exact entries still may. + /// + private static System.Collections.Generic.IEnumerable ResolveVmsFloorHosts() + { + var host = EnvironmentManager.Instance?.MasterSite?.ServerId?.Uri?.Host; + return string.IsNullOrWhiteSpace(host) ? new string[0] : new[] { host }; + } + + private void TransmitFailure(string correlationId, FQID requester, string errorCode, string message) + { + var communication = _messageCommunication; + // A null requester is never broadcast: the condition is already logged once by + // ResolveRequester, and the client-side deadline closes the exchange. + if (communication == null || requester == null) + { + return; + } + + communication.TransmitMessage( + new Message( + ProxyMessageIds.FetchResponseId, + ProxyFetchWireCodec.ToWire(new ProxyFetchResponsePayload + { + CorrelationId = correlationId, + Kind = ProxyFetchFrameKinds.Failure, + Sequence = -1, + ErrorCode = errorCode, + ErrorMessage = message, + })), + requester, + null, + null); + } + + private static void LogRefused(ProxyFetchRequestPayload payload, string reason) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProxyFetchBackgroundPlugin), + $"Proxy fetch request {(payload == null ? "" : payload.CorrelationId)} refused: {reason}."); + } + } +} diff --git a/src/WebContent/ProxyFetchClient.cs b/src/WebContent/ProxyFetchClient.cs new file mode 100644 index 0000000..42b4c12 --- /dev/null +++ b/src/WebContent/ProxyFetchClient.cs @@ -0,0 +1,984 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using VideoOS.Platform; +using VideoOS.Platform.Messaging; + +namespace WebContent +{ + /// + /// Requester side of the proxy-fetch contract (#53): sends WebContent.Proxy.FetchRequest over + /// classic MIP messaging and hands back the reassembled outcome from the framed response + /// stream. This is the client the ticket's "Management Client-driven harness" drives, and the + /// seam the Smart Client view consumes in #54. + /// + /// The response receiver feeds the correlator; correlation identity (Guid) is local, so no + /// other host's in-flight exchanges leak into this requester's results. + /// + internal sealed class ProxyFetchClient + { + /// + /// Wired up during PluginDefinition.Init in the Administration environment; null until an + /// environment provisions it. + /// + internal static ProxyFetchClient Active; + + private static readonly TimeSpan DefaultDeadline = TimeSpan.FromSeconds(30); + + private ServerId _serverId; + private MessageCommunication _communication; + private object _responseFilterRegistration; + private object _leafResponseFilterRegistration; + private object _echoFilterRegistration; + private object _tunnelOpenResponseFilterRegistration; + private object _tunnelDataFrameFilterRegistration; + private object _tunnelCloseFrameFilterRegistration; + private object _statusResponseFilterRegistration; + private object _caExportResponseFilterRegistration; + private ProxyFetchCorrelation _correlation; + private ProxyLeafCorrelation _leafCorrelation; + private ProxyTunnelCorrelation _tunnels; + private ProxyStatusCorrelation _statusCorrelation; + private ProxyCaExportCorrelation _caExportCorrelation; + private Timer _tunnelReaper; + + /// #69: keeps live proxied-view sessions visible to the Event Server between its + /// idle backstops; also the beat that lets an Event Server restart re-adopt them. + private Timer _sessionHeartbeat; + private static readonly TimeSpan HeartbeatPeriod = TimeSpan.FromSeconds(30); + + private readonly ProxyTunnelLimits _tunnelLimits = ProxyTunnelLimits.Default(); + private bool _trace; + + internal bool IsReady => _communication != null; + + /// + /// Start MIP message communication for the master site and subscribe to proxy-fetch + /// responses. Failures are logged, not thrown: an unready harness must not fail the host. + /// + internal void Setup(ServerId masterSite) + { + if (masterSite == null) + { + EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Proxy fetch harness skipped: no master site is connected."); + return; + } + + try + { + // Messaging black holes (a request that transmits but never gets answered) are + // invisible otherwise: the platform's own CommunicationClient traces every + // transmit/receive when this flag is on. Opt in with WEBCONTENT_MIP_TRACE=1. + if (Environment.GetEnvironmentVariable("WEBCONTENT_MIP_TRACE") == "1") + { + _trace = true; + EnvironmentManager.Instance.TraceMessageCommunication = true; + EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Messaging tracing enabled (WEBCONTENT_MIP_TRACE=1)."); + } + + MessageCommunicationManager.Start(masterSite); + _serverId = masterSite; + _communication = MessageCommunicationManager.Get(masterSite); + _correlation = new ProxyFetchCorrelation(); + _leafCorrelation = new ProxyLeafCorrelation(); + _responseFilterRegistration = _communication.RegisterCommunicationFilter( + OnProxyFetchResponse, + new CommunicationIdFilter(ProxyMessageIds.FetchResponseId)); + + // ADR-0003: the same client hosts the leaf-mint exchange the loopback listener's + // TLS termination needs; its single-frame responses correlate separately. + _leafResponseFilterRegistration = _communication.RegisterCommunicationFilter( + OnProxyLeafResponse, + new CommunicationIdFilter(ProxyMessageIds.LeafResponseId)); + + // #57: websocket tunnels. The open ack and every target-pushed frame arrive on + // these ids; the correlator owns the whole lifetime model and a sweep reclaims + // idle/half-open tunnels (ticket criterion 4). + _tunnels = new ProxyTunnelCorrelation(); + _tunnelOpenResponseFilterRegistration = _communication.RegisterCommunicationFilter( + OnTunnelOpenResponse, + new CommunicationIdFilter(ProxyMessageIds.TunnelOpenResponseId)); + _tunnelDataFrameFilterRegistration = _communication.RegisterCommunicationFilter( + OnTunnelDataFromTarget, + new CommunicationIdFilter(ProxyMessageIds.TunnelDataFromTargetId)); + _tunnelCloseFrameFilterRegistration = _communication.RegisterCommunicationFilter( + OnTunnelCloseFromTarget, + new CommunicationIdFilter(ProxyMessageIds.TunnelCloseFromTargetId)); + var sweepPeriod = TimeSpan.FromMilliseconds(Math.Max(1000, Math.Min(30_000, _tunnelLimits.IdleTimeoutMs / 2))); + _tunnelReaper = new Timer(_ => SweepTunnels(), null, sweepPeriod, sweepPeriod); + + // #69: diagnostics substrate. One-way block reports and session events are plain + // transmits; the workspace status query answers like every other proxy response - + // unicast, single frame, correlated, deadline-completed as ServerUnavailable. + _statusCorrelation = new ProxyStatusCorrelation(); + _statusResponseFilterRegistration = _communication.RegisterCommunicationFilter( + OnStatusResponse, + new CommunicationIdFilter(ProxyMessageIds.StatusResponseId)); + _sessionHeartbeat = new Timer(_ => HeartbeatSessions(), null, HeartbeatPeriod, HeartbeatPeriod); + + // #75: deployment CA public-certificate pull (the MC root page's Export button). + // Single unicast response frame, correlated like the status query. + _caExportCorrelation = new ProxyCaExportCorrelation(); + _caExportResponseFilterRegistration = _communication.RegisterCommunicationFilter( + OnCaExportResponse, + new CommunicationIdFilter(ProxyMessageIds.CaExportResponseId)); + + // Diagnostic (trace only): our own requests are filter-routed back to the sender's + // endpoint queue. Inspecting that echo tests whether THIS process deserializes our + // payload types at all: an intact echo isolates payload stripping to the response + // leg, a null-Data echo isolates it to this process's message plumbing. + if (_trace) + { + _echoFilterRegistration = _communication.RegisterCommunicationFilter( + OnFetchRequestEcho, + new CommunicationIdFilter(ProxyMessageIds.FetchRequestId)); + } + + EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Proxy fetch harness ready."); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch harness failed to start: {exception.Message}"); + _communication = null; + _correlation = null; + } + } + + internal void Close() + { + var communication = _communication; + if (communication != null) + { + foreach (var registration in new[] { _responseFilterRegistration, _leafResponseFilterRegistration, _echoFilterRegistration, _tunnelOpenResponseFilterRegistration, _tunnelDataFrameFilterRegistration, _tunnelCloseFrameFilterRegistration, _statusResponseFilterRegistration, _caExportResponseFilterRegistration }) + { + if (registration == null) + { + continue; + } + + try + { + communication.UnRegisterCommunicationFilter(registration); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch filter detach failed: {exception.Message}"); + } + } + } + + _responseFilterRegistration = null; + _leafResponseFilterRegistration = null; + _echoFilterRegistration = null; + _tunnelOpenResponseFilterRegistration = null; + _tunnelDataFrameFilterRegistration = null; + _tunnelCloseFrameFilterRegistration = null; + _statusResponseFilterRegistration = null; + _caExportResponseFilterRegistration = null; + + // #69: stop beating; the registry itself survives - a re-Setup resumes heartbeats for + // sessions whose listeners are still alive, and the Event Server idle-sweeps the rest. + var heartbeat = _sessionHeartbeat; + _sessionHeartbeat = null; + heartbeat?.Dispose(); + + // #57: every live tunnel ends with an explicit close so the Event Server releases + // its target sockets now, not at the ES-side idle sweep; then the sweep stops. + var reaper = _tunnelReaper; + _tunnelReaper = null; + reaper?.Dispose(); + + var tunnels = _tunnels; + _tunnels = null; + if (tunnels != null) + { + foreach (var tunnelId in tunnels.CloseAll("the Smart Client proxy client shut down")) + { + TryTransmitTunnelClose(tunnelId, "the Smart Client closed"); + } + } + + _communication = null; + _correlation = null; + _leafCorrelation = null; + _statusCorrelation = null; + _caExportCorrelation = null; + + // Deliberately no MessageCommunicationManager.Stop: the messaging session is + // process-wide and may be shared by other plugins (no MIP sample ever stops it). + _serverId = null; + } + + /// + /// Send one GET proxy-fetch request (the Management Client harness path, #53). The + /// browser-facing flows use the full overload below. + /// #65: the MC page passes the workspace item id it is editing, so a test fetch can prove + /// a rule-trigger row end-to-end; the id is attribution only, never a gate. + /// + internal Task FetchAsync(string url, string workspaceItemId = null, TimeSpan? deadline = null) + { + Uri target; + if (!Uri.TryCreate(url, UriKind.Absolute, out target)) + { + return Task.FromResult(Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri.")); + } + + return FetchAsync(new ProxyFetchQuery { Uri = target, Method = "GET", WorkspaceItemId = workspaceItemId }, deadline); + } + + /// + /// Send one proxy-fetch request and return the task completed with the reassembled + /// outcome. The deadline bounds the whole exchange, not each frame. Since #55 the method, + /// request headers, and body ride along — the JSON-string payload stays one string. + /// + internal Task FetchAsync(ProxyFetchQuery query, TimeSpan? deadline = null) + { + var communication = _communication; + if (communication == null) + { + // #58: this is a component/channel failure on the Smart Client side, not a target + // failure - the distinct code renders the distinct proxy error page. + return Task.FromResult(Failure( + ProxyFetchErrorCodes.ServerUnavailable, + "The Smart Client has no proxy messaging channel to the VMS Event Server (the Event Server may be stopped or restarted since this session connected).")); + } + + Uri target = query == null ? null : query.Uri; + if (!ProxyPolicy.IsHttpUri(target)) + { + return Task.FromResult(Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch url must be an absolute http(s) uri.")); + } + + var correlationId = Guid.NewGuid().ToString("N"); + var completion = _correlation.Begin(correlationId, deadline ?? DefaultDeadline); + + try + { + // destinationEndPoint null: the Event Server filter-routes this to the proxy + // fetcher plugin. The source argument stays null because the messaging layer + // stamps every transmit with the sender's endpoint FQID (ExternalMessageSource- + // EndPoint on the receiver), which is what the fetcher answers back to. + // Data is a JSON string, never the payload class: the platform binds custom + // payload types via Type.GetType and silently delivers null Data on hosts whose + // type resolution cannot see WebContent.dll (Smart Client). See ProxyFetchWireCodec. + communication.TransmitMessage( + new Message( + ProxyMessageIds.FetchRequestId, + ProxyFetchWireCodec.ToWire(new ProxyFetchRequestPayload + { + CorrelationId = correlationId, + Url = target.AbsoluteUri, + SessionId = query.SessionId, + WorkspaceItemId = query.WorkspaceItemId, + Method = string.IsNullOrWhiteSpace(query.Method) ? null : query.Method.Trim().ToUpperInvariant(), + Headers = query.Headers != null && query.Headers.Count > 0 ? query.Headers : null, + BodyBase64 = query.Body != null && query.Body.Length > 0 ? Convert.ToBase64String(query.Body) : null, + })), + null, + null, + null); + } + catch (Exception exception) + { + // MIP messaging is down (a stopped/restarted Event Server disposes every client's + // MessageCommunication): name the channel failure for the view (#58) instead of + // letting the request time out as an anonymous 30-second wait. + _correlation.Fail( + correlationId, + ProxyFetchErrorCodes.ServerUnavailable, + $"The proxy fetch request could not be sent over the VMS messaging channel (the Event Server proxy component is down, or an Event Server restart disposed this session's messaging channel): {exception.Message}"); + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy fetch request not sent: {exception.Message}"); + } + + return completion; + } + + /// + /// ADR-0003: send one loopback-leaf mint request over the shared channel and return the + /// task the single response frame completes. The leaf exchange is far less frequent than + /// fetches (one per origin per browser session), so it shares the fetcher's messaging + /// session and JSON-string discipline rather than owning a separate connection. + /// + internal Task RequestLeafAsync(string host, int port, byte[] subjectPublicKeyInfo, TimeSpan? deadline = null) + { + var correlation = _leafCorrelation; + var communication = _communication; + if (communication == null || correlation == null) + { + return Task.FromResult(new ProxyLeafResponsePayload + { + Success = false, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = "The proxy channel is not connected to the Event Server.", + }); + } + + var correlationId = Guid.NewGuid().ToString("N"); + var completion = correlation.Begin(correlationId, deadline ?? DefaultDeadline); + + try + { + communication.TransmitMessage( + new Message( + ProxyMessageIds.LeafRequestId, + ProxyFetchWireCodec.ToWire(new ProxyLeafRequestPayload + { + CorrelationId = correlationId, + Host = host, + Port = port, + SpkiBase64 = Convert.ToBase64String(subjectPublicKeyInfo), + })), + null, + null, + null); + } + catch (Exception exception) + { + correlation.Abandon(correlationId); + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy leaf request not sent: {exception.Message}"); + } + + return completion; + } + + /// + /// Ticket #69: fire one block report at the Event Server (via ). + /// Fire-and-forget by contract: a failed transmit is logged and swallowed - the diagnostics + /// channel dying must never disturb the browser flow that triggered the report. + /// + internal void SendBlockReport(ProxyBlockReportPayload payload) + { + var communication = _communication; + if (communication == null || payload == null) + { + return; + } + + try + { + communication.TransmitMessage( + new Message(ProxyMessageIds.BlockReportId, ProxyFetchWireCodec.ToWire(payload)), + null, + null, + null); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy block report not sent: {exception.Message}"); + } + } + + /// + /// Ticket #69: fire one session-lifecycle event (Open/Close from the listener; Heartbeat + /// from the timer below). Same swallow-and-log posture as the block report. + /// + internal void SendSessionEvent(ProxySessionEventPayload payload) + { + var communication = _communication; + if (communication == null || payload == null) + { + return; + } + + try + { + communication.TransmitMessage( + new Message(ProxyMessageIds.SessionEventId, ProxyFetchWireCodec.ToWire(payload)), + null, + null, + null); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy session event ({payload.Kind}) not sent: {exception.Message}"); + } + } + + /// + /// #69 heartbeat tick: one SessionEvent(Heartbeat) per live proxied-view session. A down + /// channel simply drops this tick - the Event Server's idle backstop prunes what a + /// reconnect never refreshes, and the next successful tick re-adopts everything still open. + /// + private void HeartbeatSessions() + { + if (_communication == null) + { + return; + } + + foreach (var payload in ProxySessionRegistry.Active.HeartbeatPayloads(DateTime.UtcNow)) + { + SendSessionEvent(payload); + } + } + + /// + /// Ticket #69: pull one workspace's live proxy status from the Event Server (the MC + /// diagnostics page's query path, #76). Never throws: an unconnected channel or an + /// unanswered query completes as a ServerUnavailable failure the page renders distinctly. + /// + internal Task RequestWorkspaceStatusAsync(string workspaceItemId, TimeSpan? deadline = null) + { + var communication = _communication; + var correlation = _statusCorrelation; + if (communication == null || correlation == null) + { + return Task.FromResult(new ProxyStatusResponsePayload + { + Success = false, + WorkspaceItemId = workspaceItemId, + ErrorCode = ProxyFetchErrorCodes.ServerUnavailable, + ErrorMessage = "The Smart Client has no proxy messaging channel to the VMS Event Server.", + }); + } + + var correlationId = Guid.NewGuid().ToString("N"); + var completion = correlation.Begin(correlationId, deadline ?? DefaultDeadline); + + try + { + communication.TransmitMessage( + new Message( + ProxyMessageIds.StatusRequestId, + ProxyFetchWireCodec.ToWire(new ProxyStatusRequestPayload + { + CorrelationId = correlationId, + WorkspaceItemId = workspaceItemId, + })), + null, + null, + null); + } + catch (Exception exception) + { + correlation.Feed(new ProxyStatusResponsePayload + { + CorrelationId = correlationId, + Success = false, + WorkspaceItemId = workspaceItemId, + ErrorCode = ProxyFetchErrorCodes.ServerUnavailable, + ErrorMessage = $"The proxy status query could not be sent: {exception.Message}", + }); + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy status request not sent: {exception.Message}"); + } + + return completion; + } + + private object OnStatusResponse(Message message, FQID destination, FQID source) + { + try + { + if (message == null || message.Data == null) + { + return null; + } + + var payload = ProxyFetchWireCodec.StatusResponseFromWire(message.Data); + if (payload == null) + { + return null; + } + + var correlation = _statusCorrelation; + if (correlation != null) + { + correlation.Feed(payload); + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy status response frame failed: {exception.Message}"); + } + + return null; + } + + /// + /// Ticket #75: pull the deployment CA's public certificate from the Event Server (the MC + /// root page's "Export proxy certificate" button). Never throws: an unconnected channel + /// or an unanswered pull completes as a ServerUnavailable failure the page renders + /// distinctly from the Event Server's own "certificate does not exist yet" refusal. + /// + internal Task RequestProxyCertificateAsync(TimeSpan? deadline = null) + { + var communication = _communication; + var correlation = _caExportCorrelation; + if (communication == null || correlation == null) + { + return Task.FromResult(new ProxyCaExportResponsePayload + { + Success = false, + ErrorCode = ProxyFetchErrorCodes.ServerUnavailable, + ErrorMessage = "This process has no proxy messaging channel to the VMS Event Server.", + }); + } + + var correlationId = Guid.NewGuid().ToString("N"); + var completion = correlation.Begin(correlationId, deadline ?? DefaultDeadline); + + try + { + communication.TransmitMessage( + new Message( + ProxyMessageIds.CaExportRequestId, + ProxyFetchWireCodec.ToWire(new ProxyCaExportRequestPayload + { + CorrelationId = correlationId, + })), + null, + null, + null); + } + catch (Exception exception) + { + correlation.Feed(new ProxyCaExportResponsePayload + { + CorrelationId = correlationId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.ServerUnavailable, + ErrorMessage = $"The proxy certificate export could not be sent: {exception.Message}", + }); + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy certificate export request not sent: {exception.Message}"); + } + + return completion; + } + + private object OnCaExportResponse(Message message, FQID destination, FQID source) + { + try + { + if (message == null || message.Data == null) + { + return null; + } + + var payload = ProxyFetchWireCodec.CaExportResponseFromWire(message.Data); + if (payload == null) + { + return null; + } + + var correlation = _caExportCorrelation; + if (correlation != null) + { + correlation.Feed(payload); + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy certificate export response frame failed: {exception.Message}"); + } + + return null; + } + + /// + /// Ticket #57: open one websocket tunnel for a decided upgrade request. The Event Server + /// connects to the target, replays the handshake, and answers with its verbatim 101; the + /// returned handle then pumps raw tunnel bytes in both directions through messaging. + /// Never throws — every failure is an outcome the forwarder renders as 502. + /// + internal async Task OpenTunnelAsync(ProxyForwardedRequest request) + { + var communication = _communication; + var tunnels = _tunnels; + if (communication == null || tunnels == null) + { + return TunnelFailure(ProxyFetchErrorCodes.Error, "The proxy channel is not connected to the Event Server."); + } + + var target = request == null ? null : request.Uri; + if (target == null) + { + return TunnelFailure(ProxyFetchErrorCodes.BadRequest, "The websocket tunnel request carried no target uri."); + } + + if (tunnels.Count >= _tunnelLimits.ClientMaxTunnels) + { + return TunnelFailure( + ProxyFetchErrorCodes.Error, + $"This Smart Client already has {_tunnelLimits.ClientMaxTunnels} websocket tunnels open; close a proxied page and retry."); + } + + var tunnelId = Guid.NewGuid().ToString("N"); + tunnels.TryRegister(tunnelId); + + try + { + TransmitTunnel(ProxyMessageIds.TunnelOpenRequestId, new ProxyTunnelPayload + { + CorrelationId = tunnelId, + SessionId = request.SessionId, + Url = target.AbsoluteUri, + Headers = ProxyWebSocketHandshake.ToTunnelWireHeaders(request), + }); + } + catch (Exception exception) + { + tunnels.EndLocal(tunnelId); + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel open not sent: {exception.Message}"); + return TunnelFailure(ProxyFetchErrorCodes.Error, "The tunnel open request could not be sent to the Event Server."); + } + + var openTask = tunnels.AwaitOpenAsync(tunnelId); + var winner = await Task.WhenAny(openTask, Task.Delay(_tunnelLimits.ClientOpenDeadlineMs)); + if (winner != openTask) + { + // Half-open reclaim (ticket criterion 4): the Event Server may still be dialing the + // target, so release it too rather than leaving a socket to the idle sweep. + if (tunnels.EndLocal(tunnelId)) + { + TryTransmitTunnelClose(tunnelId, "the Smart Client stopped waiting for the websocket handshake"); + } + + return TunnelFailure( + ProxyFetchErrorCodes.Timeout, + $"The Event Server did not answer the websocket tunnel open within {_tunnelLimits.ClientOpenDeadlineMs / 1000} seconds."); + } + + var outcome = openTask.Result; + if (outcome == null) + { + return TunnelFailure(ProxyFetchErrorCodes.Error, "The websocket tunnel closed before it opened."); + } + + if (outcome.Success) + { + outcome.Handle = new MessagingTunnelHandle(this, tunnelId); + } + + return outcome; + } + + /// Relay browser bytes toward the target; unknown/closed tunnels drop quietly. + private void SendTunnelData(string tunnelId, byte[] bytes) + { + var tunnels = _tunnels; + if (tunnels == null || tunnels.IsClosed(tunnelId)) + { + return; + } + + tunnels.Touch(tunnelId); + var frame = new ProxyTunnelPayload + { + CorrelationId = tunnelId, + Kind = ProxyTunnelFrameKinds.Data, + DataBase64 = Convert.ToBase64String(bytes), + }; + + try + { + TransmitTunnel(ProxyMessageIds.TunnelDataToTargetId, frame); + } + catch (Exception exception) + { + // Messaging is dying: end the tunnel so both pumps unwind instead of spin-failing. + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel data not sent ({tunnelId}): {exception.Message}"); + tunnels.OnTargetClose(tunnelId, "the proxy messaging channel failed"); + } + } + + /// The browser side ended the tunnel: drop the session and tell the Event Server. + private void CloseTunnel(string tunnelId, string reason) + { + var tunnels = _tunnels; + if (tunnels == null) + { + return; + } + + if (tunnels.EndLocal(tunnelId)) + { + TryTransmitTunnelClose(tunnelId, reason); + } + } + + private void TryTransmitTunnelClose(string tunnelId, string reason) + { + try + { + TransmitTunnel(ProxyMessageIds.TunnelCloseToTargetId, new ProxyTunnelPayload + { + CorrelationId = tunnelId, + Kind = ProxyTunnelFrameKinds.Close, + ErrorMessage = reason, + }); + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel close not sent ({tunnelId}): {exception.Message}"); + } + } + + private static void TransmitTunnelAt(MessageCommunication communication, string messageId, ProxyTunnelPayload payload) + { + // destinationEndPoint null routes to the Event Server fetcher plugin, exactly like the + // fetch requests; the messaging layer stamps the sender endpoint for the unicast answer. + communication.TransmitMessage(new Message(messageId, ProxyFetchWireCodec.ToWire(payload)), null, null, null); + } + + private void TransmitTunnel(string messageId, ProxyTunnelPayload payload) + { + var communication = _communication; + if (communication == null) + { + return; + } + + TransmitTunnelAt(communication, messageId, payload); + } + + private void SweepTunnels() + { + var tunnels = _tunnels; + if (tunnels == null) + { + return; + } + + try + { + var reclaimed = tunnels.ReclaimIdle(TimeSpan.FromMilliseconds(_tunnelLimits.IdleTimeoutMs)); + foreach (var tunnelId in reclaimed) + { + TryTransmitTunnelClose(tunnelId, "the Smart Client reclaimed an idle websocket tunnel"); + } + } + catch (Exception exception) + { + EnvironmentManager.Instance.Log(true, nameof(ProxyFetchClient), $"Proxy tunnel sweep faulted: {exception.Message}"); + } + } + + private object OnTunnelOpenResponse(Message message, FQID destination, FQID source) + { + var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data); + var tunnels = _tunnels; + if (payload == null || tunnels == null) + { + if (_trace && message != null) + { + EnvironmentManager.Instance.Log(false, nameof(ProxyFetchClient), "Proxy tunnel open response not recognized."); + } + + return null; + } + + // A success ack for a session the client already gave up on (open deadline fired, or + // the sweep reclaimed it mid-handshake) must still release the Event Server's fresh + // socket: a chatty target keeps the ES-side idle sweep from ever firing. + if (payload.Success && tunnels.IsClosed(payload.CorrelationId)) + { + TryTransmitTunnelClose(payload.CorrelationId, "the Smart Client stopped waiting before the tunnel opened"); + return null; + } + + tunnels.CompleteOpen(payload.CorrelationId, new ProxyTunnelOpenOutcome + { + Success = payload.Success, + StatusCode = payload.StatusCode, + HandshakeBytes = DecodeTunnelBytes(payload.DataBase64), + ErrorCode = payload.ErrorCode, + ErrorMessage = payload.ErrorMessage, + }); + return null; + } + + private object OnTunnelDataFromTarget(Message message, FQID destination, FQID source) + { + var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data); + var tunnels = _tunnels; + if (payload == null || tunnels == null) + { + return null; + } + + var bytes = DecodeTunnelBytes(payload.DataBase64); + if (bytes != null) + { + tunnels.OnTargetData(payload.CorrelationId, bytes); + } + else if (!string.IsNullOrEmpty(payload.DataBase64)) + { + tunnels.OnTargetClose(payload.CorrelationId, "the tunnel data frame was malformed"); + } + + return null; + } + + private object OnTunnelCloseFromTarget(Message message, FQID destination, FQID source) + { + var payload = ProxyFetchWireCodec.TunnelFromWire(message?.Data); + var tunnels = _tunnels; + if (payload != null && tunnels != null) + { + tunnels.OnTargetClose(payload.CorrelationId, payload.ErrorMessage ?? "the target closed the websocket"); + } + + return null; + } + + private static byte[] DecodeTunnelBytes(string base64) + { + if (string.IsNullOrEmpty(base64)) + { + return null; + } + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + return null; + } + } + + /// + /// The browser-connection seam over the messaging channel: reads drain the correlator's + /// ordered queue, writes relay base64-wrapped raw bytes, and Close ends the session once + /// and notifies the Event Server. The 16KB pump chunks keep every message inside the + /// classic messaging size budget. + /// + private sealed class MessagingTunnelHandle : ProxyTunnelHandle + { + private readonly ProxyFetchClient _client; + private int _closeSent; + + internal MessagingTunnelHandle(ProxyFetchClient client, string tunnelId) + { + _client = client; + TunnelId = tunnelId; + } + + internal override bool IsClosed => _client._tunnels == null || _client._tunnels.IsClosed(TunnelId); + + internal override Task SendAsync(byte[] bytes) + { + _client.SendTunnelData(TunnelId, bytes); + return Task.CompletedTask; + } + + internal override Task NextDataAsync() + { + var tunnels = _client._tunnels; + return tunnels == null ? Task.FromResult(null) : tunnels.NextDataAsync(TunnelId); + } + + internal override void Close(string reason) + { + if (Interlocked.Exchange(ref _closeSent, 1) == 0) + { + _client.CloseTunnel(TunnelId, reason); + } + } + } + + private static ProxyTunnelOpenOutcome TunnelFailure(string errorCode, string message) + { + return new ProxyTunnelOpenOutcome + { + Success = false, + ErrorCode = errorCode, + ErrorMessage = message, + }; + } + + private object OnProxyLeafResponse(Message message, FQID destination, FQID source) + { + var response = ProxyFetchWireCodec.LeafResponseFromWire(message?.Data); + if (response != null) + { + var correlation = _leafCorrelation; + if (correlation != null && !correlation.Feed(response) && _trace) + { + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchClient), + $"Leaf response unmatched: correlationId='{response.CorrelationId}'."); + } + + return null; + } + + if (_trace && message != null) + { + var actual = message.Data == null ? "null Data" : message.Data.GetType().FullName; + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchClient), + $"Leaf response not recognized: {actual} destination={destination} source={source}"); + } + + return null; + } + + private object OnProxyFetchResponse(Message message, FQID destination, FQID source) + { + var frame = ProxyFetchWireCodec.FrameFromWire(message?.Data); + if (frame != null) + { + var correlation = _correlation; + if (correlation != null && !correlation.Feed(frame) && _trace) + { + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchClient), + $"Proxy fetch response frame unmatched: kind={frame.Kind} correlationId='{frame.CorrelationId}'."); + } + + return null; + } + + // A FetchResponse message we cannot decode is the classic silent black hole. With + // tracing on, name what actually arrived (null Data = the platform failed to bind the + // wire type; a non-string type = foreign or pre-codec-format sender). + if (_trace && message != null) + { + var actual = message.Data == null ? "null Data" : message.Data.GetType().FullName; + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchClient), + $"Proxy fetch response frame not recognized: {actual} destination={destination} source={source}"); + } + + return null; + } + + private object OnFetchRequestEcho(Message message, FQID destination, FQID source) + { + if (_trace) + { + var request = ProxyFetchWireCodec.RequestFromWire(message?.Data); + EnvironmentManager.Instance.Log( + false, + nameof(ProxyFetchClient), + "Proxy fetch request echo: " + + (message?.Data == null + ? "null Data" + : request != null + ? "payload intact, id=" + request.CorrelationId + : "undecodable content of type " + message.Data.GetType().FullName) + + $" destination={destination} source={source}"); + } + + return null; + } + + private static ProxyFetchResult Failure(string errorCode, string message) + { + return new ProxyFetchResult + { + Success = false, + ErrorCode = errorCode, + ErrorMessage = message, + }; + } + } +} diff --git a/src/WebContent/ProxyFetchContract.cs b/src/WebContent/ProxyFetchContract.cs new file mode 100644 index 0000000..72e9d31 --- /dev/null +++ b/src/WebContent/ProxyFetchContract.cs @@ -0,0 +1,827 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// Ticket #53: classic MIP message ids for the proxy-fetch request/response pair. The MIP + /// messaging contract (MIP message communication, routed by the Event Server) requires one id + /// for the request and another for the response, transmitted as two operations. These ids are + /// also registered via PluginDefinition.PluginDefinedMessageIds. + /// + /// Payload whitelist: the Event Server discards messages whose Data object is outside the + /// allowed type set; since #54 the wire Data is always a plain JSON STRING (see + /// ProxyFetchWireCodec) - custom payload classes are never bound by host processes. The + /// platform rehydrates Data via Type.GetType(DataClass), which silently yields null on hosts + /// (Smart Client) whose type resolution cannot see WebContent.dll. + /// + internal static class ProxyMessageIds + { + internal const string FetchRequestId = "WebContent.Proxy.FetchRequest"; + internal const string FetchResponseId = "WebContent.Proxy.FetchResponse"; + + /// ADR-0003: client asks the Event Server to mint a loopback leaf for one + /// allowlisted origin, carrying the client-generated SubjectPublicKeyInfo. + internal const string LeafRequestId = "WebContent.Proxy.LeafRequest"; + internal const string LeafResponseId = "WebContent.Proxy.LeafResponse"; + + /// + /// Ticket #57: the websocket tunnel's directional message pairs. Open is a request/ + /// response handshake exchange (the Event Server reports whether the target accepted the + /// upgrade); data and close each get one id PER DIRECTION, so tunnel bytes flow as their + /// own messages — never through the #53 Response/Chunk fetch framing, and one client's + /// frames can never be swallowed by another client's filter. + /// + internal const string TunnelOpenRequestId = "WebContent.Proxy.TunnelOpenRequest"; + internal const string TunnelOpenResponseId = "WebContent.Proxy.TunnelOpenResponse"; + internal const string TunnelDataToTargetId = "WebContent.Proxy.TunnelDataToTarget"; + internal const string TunnelDataFromTargetId = "WebContent.Proxy.TunnelDataFromTarget"; + internal const string TunnelCloseToTargetId = "WebContent.Proxy.TunnelCloseToTarget"; + internal const string TunnelCloseFromTargetId = "WebContent.Proxy.TunnelCloseFromTarget"; + + /// + /// Ticket #69: the diagnostics substrate. Block reports and session lifecycle events are + /// fire-and-forget one-way messages (no response id); the status query is a request/ + /// response pair unicasted like every other proxy response. Blocks decided client-side + /// never reach the fetcher path, so the client reports them itself. + /// + internal const string BlockReportId = "WebContent.Proxy.BlockReport"; + internal const string SessionEventId = "WebContent.Proxy.SessionEvent"; + internal const string StatusRequestId = "WebContent.Proxy.StatusRequest"; + internal const string StatusResponseId = "WebContent.Proxy.StatusResponse"; + + /// + /// Ticket #75: the Management Client's pull of the deployment CA's PUBLIC certificate, + /// for distribution to client machines (AD/GPO into LocalMachine\Root). Request/response + /// pair unicasted like every other proxy exchange. Public material only — the CA's + /// private key never leaves the Event Server machine (ADR-0003), and this exchange never + /// creates or mutates anything, so it answers from any provisioning provenance (MSI + /// action, the legacy script, or the lazy first-use creation). + /// + internal const string CaExportRequestId = "WebContent.Proxy.CaExportRequest"; + internal const string CaExportResponseId = "WebContent.Proxy.CaExportResponse"; + } + + /// + /// Frame kinds within the response stream. The requester treats these as the terminal states + /// of a proxy-fetch exchange: Response + Chunk* on success, a lone Failure otherwise. + /// + internal static class ProxyFetchFrameKinds + { + /// First frame: status line, response headers, body length, and chunk count. + internal const string Response = "Response"; + + /// One base64 body slice, sent in sequence after the metadata frame. + internal const string Chunk = "Chunk"; + + /// Terminal failure frame: no chunks follow. + internal const string Failure = "Failure"; + } + + /// + /// Machine-readable failure codes carried in Failure frames and in unsuccessful results. + /// + internal static class ProxyFetchErrorCodes + { + /// The request payload was missing, the url blank, or not an http(s) uri. + internal const string BadRequest = "BadRequest"; + + /// DNS, connect, or TLS failure: the target could not be reached. + internal const string Unreachable = "Unreachable"; + + /// Connect or read timeout per the fetcher's enforced limits. + internal const string Timeout = "Timeout"; + + /// The response body exceeded the configured maximum response size. + internal const string TooLarge = "TooLarge"; + + /// Any other transport-level failure. + internal const string Error = "Error"; + + /// + /// Ticket #54 server-side hardening: the fetched origin is not in the proxy allowlist of + /// any proxied workspace, so the Event Server refuses to reach it. + /// + internal const string NotAllowed = "NotAllowed"; + + /// + /// Ticket #56: the https target's certificate failed chain validation and the target has + /// no per-target "accept untrusted certificate" override, so the fetch is refused. + /// + internal const string TlsValidation = "TlsValidation"; + + /// + /// Ticket #56 (ADR-0003): the Event Server could not load or create the deployment CA, + /// so no loopback leaf certificate can be minted. + /// + internal const string NoAuthority = "NoAuthority"; + + /// + /// Ticket #57: the tunnel's target answered the websocket handshake with something other + /// than 101 (a plain 200, a vendor 401) — the upgrade is refused, no tunnel exists. + /// + internal const string HandshakeRefused = "HandshakeRefused"; + + /// + /// Ticket #58 (CLIENT-SIDE only — never carried by a wire Failure frame): the VMS-side + /// proxy component itself is unavailable. Raised when the Smart Client's messaging channel + /// to the Event Server is not connected, a transmit fails (an Event Server restart disposes + /// every client's MessageCommunication), or the Event Server never answers a request within + /// the response deadline (no fetcher plugin loaded there). It separates "proxy component + /// absent/unreachable" from "server side reached the messaging layer but the target failed". + /// + internal const string ServerUnavailable = "ServerUnavailable"; + } + + /// + /// How the Event Server fetcher treats TLS certificate validation on the https leg to the + /// target (#56). The caller decides per request from the target's allowlist entry — + /// AcceptUntrusted for exactly the origin whose entry carries the admin override, + /// ValidateNormally for everything else. Never a process-wide or browser-wide bypass. + /// + internal enum ProxyFetchTlsMode + { + /// Full chain validation; an untrusted certificate fails with TlsValidation. + ValidateNormally, + + /// Validation errors accepted for this one already-allowlisted target. + AcceptUntrusted, + } + + /// + /// One response header from the fetched target, as the fetcher's HTTP layer captured it. + /// This type NEVER travels on the wire as an object: it is a member of + /// , and the whole payload crosses messaging only as a + /// JSON string via (#54 - hosts like Smart Client cannot + /// bind plugin-namespace types through the platform's Type.GetType rehydration). + /// + [Serializable] + public class ProxyFetchHeader + { + public string Name; + public string Value; + } + + /// + /// Request payload: one correlation id per request so the requester can match the response + /// frames that come back over the shared message channel. Since #55 it also carries the HTTP + /// method, the browser's request headers, and a base64 request body — the proxy is a pure + /// passthrough for authentication (Cookie/Authorization cross untouched; the plugin never + /// injects or stores target credentials). A null Method means GET; null Headers/BodyBase64 + /// mean none. Still ONE JSON string on the wire via . + /// + /// The explicit [DataContract]/[DataMember] shape matters for version skew: Smart Clients on + /// user workstations lag the Event Server, and a request from an older client simply lacks + /// members added later (#65's WorkspaceItemId, #69's SessionId). Data members declared this + /// way are optional on read — the missing member decodes to null instead of poisoning the + /// whole frame. (Without the attributes the inferred contract marks every member required, + /// and a strict reader refuses the frame outright.) + /// + [Serializable] + [DataContract] + public class ProxyFetchRequestPayload + { + [DataMember] + public string CorrelationId; + + [DataMember] + public string Url; + + /// + /// Ticket #69: the browser-view session that issued this fetch, so the Event Server can + /// attribute the traffic it already serves to that session's live-status counters. Null + /// from pre-#69 senders (and for unattributed requests) — an absent session never + /// refuses the fetch itself. + /// + [DataMember] + public string SessionId; + + /// + /// Ticket #65: the workspace item (Guid string) whose browser view issued this fetch — + /// the attribution rule-trigger rows are evaluated against. Null from pre-#65 senders; + /// the Event Server then falls back to the session's workspace from its diagnostics + /// session map, and an unattributable fetch simply never triggers. + /// + [DataMember] + public string WorkspaceItemId; + + /// Uppercase HTTP method (POST/PUT/DELETE/OPTIONS/...); null or blank is GET. + [DataMember] + public string Method; + + /// Request headers to replay at the target, hop-by-hop headers already stripped + /// by the requester side; null when the request carried none worth relaying. + [DataMember] + public List Headers; + + /// Request body as base64 (login forms, XHR payloads); null when there is no body. + [DataMember] + public string BodyBase64; + } + + /// + /// Response payload for every frame kind (Response / Chunk / Failure). Fields irrelevant to a + /// kind stay null/default. Serialized to a JSON string by + /// before transmit; the platform must never be asked to bind this type itself. + /// + [Serializable] + public class ProxyFetchResponsePayload + { + public string CorrelationId; + public string Kind; + + /// 0-based chunk order; -1 on metadata and failure frames. + public int Sequence; + + /// True on the final chunk frame so a consumer can notice the stream ended. + public bool IsLastChunk; + + // Metadata-frame fields. + public int StatusCode; + + /// Human-readable status text (the status code name; the wire reason phrase is + /// not exposed by HttpWebResponse). Display metadata only. + public string StatusText; + public List Headers; + + /// Actual fetched body size in bytes (metadata frame). + public long BodyLength; + + /// How many chunk frames follow the metadata frame (metadata frame). + public int ChunkCount; + + // Chunk-frame fields. + public string ContentBase64; + + // Failure-frame fields. + public string ErrorCode; + public string ErrorMessage; + } + + /// + /// One browser request the loopback forwarding proxy decided to relay, as it travels the + /// client-side seam: listener -> BrowserManager fetch seam -> ProxyFetchClient -> messaging. + /// Not a wire payload; is its serialized form. Since + /// #55 the method, browser request headers, and request body ride along, so a vendor login + /// POST reaches the Event Server fetcher with its credentials and form body intact. + /// + internal sealed class ProxyFetchQuery + { + internal Uri Uri; + + /// Uppercase HTTP method; null/blank means GET. + internal string Method; + + /// Browser request headers to replay at the target, hop-by-hop ones stripped. + internal List Headers = new List(); + + /// Request body bytes; null when the request carried no body. + internal byte[] Body; + + /// + /// Ticket #69: the browser-view session that issued this request, stamped by the listener; + /// copied onto the wire payload so the Event Server counts the fetch toward that session's + /// live status. Null for unattributed requests. + /// + internal string SessionId; + + /// + /// Ticket #65: the workspace item id the issuing view belongs to, stamped by the listener; + /// copied onto the wire payload so the Event Server can evaluate rule-trigger rows against + /// the served request. Null for unattributed requests. + /// + internal string WorkspaceItemId; + } + + /// + /// The fetcher's own outcome model, shared by the server-side fetcher, the framing seam, and + /// the requester-side reassembly. Not a message payload: the framer serializes it into frames, + /// the correlator deserializes frames back. + /// + internal class ProxyFetchResult + { + internal bool Success; + + /// Target HTTP status code; 0 when the transport never produced a response. + internal int StatusCode; + + /// Human-readable status text (status code name), carried through the meta frame. + internal string StatusText; + internal List Headers = new List(); + internal long BodyLength; + + /// The fetched bytes; null while not yet assembled, empty-array when body was empty. + internal byte[] Body; + + // Failure fields. + internal string ErrorCode; + internal string ErrorMessage; + } + + /// + /// Ticket #56 (ADR-0003): one loopback-leaf mint request. The client generates the leaf key + /// pair and sends only the public half (DER SubjectPublicKeyInfo, base64) — no private key + /// ever crosses the wire. Host/Port identify the CONNECT origin; the Event Server mints only + /// for origins some proxied workspace allowlists (the allowlist IS the minting policy). + /// One JSON string on the wire like every other proxy payload (#54). + /// + [Serializable] + public class ProxyLeafRequestPayload + { + public string CorrelationId; + + /// CONNECT target host as the browser resolved it (IdnHost form). + public string Host; + + /// CONNECT target port; the allowlist gate matches scheme+host+port exactly. + public int Port; + + public string SpkiBase64; + } + + /// + /// Ticket #56 (ADR-0003): the leaf exchange's single response frame. Success carries the minted + /// leaf DER (no private key — the client attaches its own) plus the deployment CA certificate + /// and its thumbprint, so the client can verify its machine trust roots hold the CA and show + /// actionable guidance when it does not. + /// + [Serializable] + public class ProxyLeafResponsePayload + { + public string CorrelationId; + public bool Success; + + public string LeafDerBase64; + public string AuthorityDerBase64; + public string AuthorityThumbprint; + + // Failure fields. + public string ErrorCode; + public string ErrorMessage; + } + + /// + /// Bounds the fetcher enforces per request (ticket #53 acceptance: connect timeout, read + /// timeout, maximum response size). Since #85 the response-size bound is per workspace: + /// governs workspaces that store no cap, and every + /// stored cap clamps server-side to the fixed ceiling. + /// The cap governs fetch-mode (buffered) responses only - CONNECT-tunnelled and websocket + /// byte streams are never size-capped (they are already allowlist-gated). + /// + internal class ProxyFetchLimits + { + internal const int DefaultConnectTimeoutMs = 5000; + internal const int DefaultReadTimeoutMs = 15000; + internal const int DefaultMaxResponseBytes = 5 * 1024 * 1024; + + /// + /// #85: the hard server-side ceiling for per-workspace response caps. No stored config + /// can obtain a cap above it - malformed or huge values clamp on every read. + /// + internal const int MaxAllowedResponseBytes = 20 * 1024 * 1024; + + internal const int DefaultChunkSizeBytes = 64 * 1024; + + internal int ConnectTimeoutMs = DefaultConnectTimeoutMs; + internal int ReadTimeoutMs = DefaultReadTimeoutMs; + internal int MaxResponseBytes = DefaultMaxResponseBytes; + internal int ChunkSizeBytes = DefaultChunkSizeBytes; + + internal static ProxyFetchLimits Default() + { + return new ProxyFetchLimits(); + } + + /// + /// #85: limits for one fetch under the workspace's effective response cap. The stored + /// value always passes through first. + /// + internal static ProxyFetchLimits ForResponseCapBytes(int storedMaxResponseBytes) + { + return new ProxyFetchLimits + { + MaxResponseBytes = NormalizeResponseCapBytes(storedMaxResponseBytes), + }; + } + + /// + /// #85: the one clamp every reader of a stored cap goes through: absent/0/negative is + /// the 5 MB default, anything past the 20 MB ceiling is the ceiling. + /// + internal static int NormalizeResponseCapBytes(int storedMaxResponseBytes) + { + if (storedMaxResponseBytes <= 0) + { + return DefaultMaxResponseBytes; + } + + return storedMaxResponseBytes > MaxAllowedResponseBytes ? MaxAllowedResponseBytes : storedMaxResponseBytes; + } + } + + /// + /// Frame kinds inside the tunnel messages the Event Server pushes toward the client: raw + /// tunnel bytes versus the end-of-tunnel notice. The client's receiving side picks the + /// directional message id from this kind. + /// + internal static class ProxyTunnelFrameKinds + { + internal const string Data = "Data"; + internal const string Close = "Close"; + } + + /// + /// Ticket #57: the single payload shape for every websocket-tunnel message (open request, + /// open response, data frame in either direction, close in either direction). Which fields + /// matter is decided by the message id the payload rides on, so one JSON string covers the + /// whole protocol like every other proxy payload (#54). The namespace/whitelist guards in + /// ProxyFetchContractGuardTests pin the field types. + /// + [Serializable] + public class ProxyTunnelPayload + { + /// The tunnel id; one Guid for the whole life of one tunnel. + public string CorrelationId; + + /// + /// Ticket #69: the browser-view session that opened this tunnel, so its bytes count toward + /// that session's live-status traffic. Null from pre-#69 senders; never gates the tunnel. + /// + public string SessionId; + + /// Server-pushed frames: Data or Close (see ). + public string Kind; + + /// + /// Open request: the target url. Plain ws:// upgrades arrive as their http twin; wss:// + /// upgrades (#60) as the https origin - the scheme decides whether the Event Server's + /// dialer adds a validated TLS leg. + /// + public string Url; + + /// + /// Open request: the browser's handshake headers minus per-hop/proxy decoration, casing + /// and values intact — the target computes Sec-WebSocket-Accept from the relayed + /// Sec-WebSocket-Key, so the key must not be touched. + /// + public List Headers; + + /// + /// Data frame: raw tunnel bytes base64-encoded at the JSON edge only. Open response: + /// the target's raw handshake answer (101 head plus any already-buffered frame bytes), + /// relayed verbatim so the browser sees exactly the target's Sec-WebSocket-Accept. + /// + public string DataBase64; + + /// Open response: whether the tunnel is up. Close frames leave it default. + public bool Success; + + /// Open response: the target's handshake status line code (101, or whatever else). + public int StatusCode; + + // Failure/close fields. + public string ErrorCode; + public string ErrorMessage; + } + + /// + /// Ticket #57: bounds the websocket tunnel enforces on both sides — the Event Server's + /// connect/handshake budget for the target leg, the idle timeout that reclaims stalled or + /// half-open tunnels, the per-message raw byte cap, and the concurrency caps that keep the + /// Event Server from accumulating sockets. + /// + internal sealed class ProxyTunnelLimits + { + internal const int DefaultConnectTimeoutMs = 5000; + internal const int DefaultHandshakeTimeoutMs = 10000; + internal const int DefaultIdleTimeoutMs = 5 * 60 * 1000; + internal const int DefaultHandshakeMaxBytes = 64 * 1024; + internal const int DefaultFrameMaxBytes = 16 * 1024; + internal const int DefaultEsMaxTunnels = 8; + internal const int DefaultClientMaxTunnels = 16; + internal const int DefaultClientOpenDeadlineMs = 30000; + + internal int ConnectTimeoutMs = DefaultConnectTimeoutMs; + internal int HandshakeTimeoutMs = DefaultHandshakeTimeoutMs; + internal int IdleTimeoutMs = DefaultIdleTimeoutMs; + internal int HandshakeMaxBytes = DefaultHandshakeMaxBytes; + internal int FrameMaxBytes = DefaultFrameMaxBytes; + internal int EsMaxTunnels = DefaultEsMaxTunnels; + internal int ClientMaxTunnels = DefaultClientMaxTunnels; + internal int ClientOpenDeadlineMs = DefaultClientOpenDeadlineMs; + + internal static ProxyTunnelLimits Default() + { + return new ProxyTunnelLimits(); + } + } + + /// + /// Ticket #57: the client-side seam a tunnelled browser connection pumps against. The + /// production implementation rides MIP messaging (ProxyFetchClient); tests substitute an + /// in-memory tunnel. Byte-oriented, not frame-oriented: everything after the 101 is opaque + /// RFC 6455 framing to the proxy, exactly like a CONNECT tunnel carries opaque TLS. + /// + internal abstract class ProxyTunnelHandle + { + internal string TunnelId; + + /// True once either side closed the tunnel (no further data will arrive). + internal abstract bool IsClosed { get; } + + /// Relay browser bytes toward the target; no-op once closed. + internal abstract Task SendAsync(byte[] bytes); + + /// + /// Await the next target→browser bytes; null once the tunnel is closed (either side). + /// + internal abstract Task NextDataAsync(); + + /// Close the tunnel and tell the Event Server; idempotent. + internal abstract void Close(string reason); + } + + /// + /// Ticket #57: outcome of a tunnel-open attempt on the client side. Success carries the + /// handle to pump plus the target's raw handshake answer to write to the browser socket + /// before the first pump byte; failure carries the refusal to render as 502. + /// + internal sealed class ProxyTunnelOpenOutcome + { + internal bool Success; + internal ProxyTunnelHandle Handle; + + /// The target's 101 answer (head + buffered bytes), relayed verbatim. + internal byte[] HandshakeBytes; + + internal int StatusCode; + internal string ErrorCode; + internal string ErrorMessage; + } + + /// + /// Ticket #69: the machine-readable kinds of the session-lifecycle message. One id covers the + /// whole lifecycle; the kind field decides which. Sessions are per proxied browser-view + /// instance, client-generated, and backstopped by an Event Server idle timeout. + /// + internal static class ProxySessionEventKinds + { + internal const string Open = "Open"; + internal const string Heartbeat = "Heartbeat"; + internal const string Close = "Close"; + } + + /// + /// Ticket #69: the cause categories of a client-side proxy block, shared by the block report, + /// the Event Server ring buffer, and (tickets #83/#84/#66) the surfaces that render them. + /// funnels the wire failure codes into the same categories so + /// the ES-decided failures (size cap, TLS chain, unreachable target) land beside the ones the + /// loopback listener decided itself. Codes that are not per-origin blocks — ServerUnavailable + /// (component outage), BadRequest, Error — map to nothing and are never reported. + /// + internal static class ProxyBlockReasons + { + internal const string NotAllowed = "NotAllowed"; + internal const string TooLarge = "TooLarge"; + internal const string CertUntrusted = "CertUntrusted"; + internal const string Unreachable = "Unreachable"; + internal const string Timeout = "Timeout"; + internal const string TunnelRefused = "TunnelRefused"; + + internal static bool TryMapErrorCode(string errorCode, out string reason) + { + switch (errorCode) + { + case ProxyFetchErrorCodes.NotAllowed: + reason = NotAllowed; + return true; + case ProxyFetchErrorCodes.TooLarge: + reason = TooLarge; + return true; + case ProxyFetchErrorCodes.TlsValidation: + case ProxyFetchErrorCodes.NoAuthority: + reason = CertUntrusted; + return true; + case ProxyFetchErrorCodes.Unreachable: + reason = Unreachable; + return true; + case ProxyFetchErrorCodes.Timeout: + reason = Timeout; + return true; + case ProxyFetchErrorCodes.HandshakeRefused: + reason = TunnelRefused; + return true; + default: + reason = null; + return false; + } + } + } + + /// + /// Ticket #69: one fire-and-forget block report (client → Event Server). The listener/ + /// forwarder emits it where refusals are decided client-side — before the Event Server ever + /// sees the request — and for ES-decided failures read off the wire failure codes. Low + /// frequency by nature; every field is a whitelisted primitive. ClientMachine is self- + /// reported display context; the acting user comes from the messaging sender identity the + /// Event Server resolves itself, never from the payload. + /// + [Serializable] + public class ProxyBlockReportPayload + { + /// Workspace item id (Guid string) the blocked view belongs to. + public string WorkspaceItemId; + + /// Fingerprint of the admin definition that seeded the view (freshness context). + public string WorkspaceFingerprint; + + /// The blocked origin in ProxyPolicy canonical form ("scheme://host[:port]"). + public string Origin; + + /// One of . + public string Reason; + + /// Occurrence count this report stands for; 0 or 1 means one. + public int Count; + + /// Machine name of the client that decided the block (display only). + public string ClientMachine; + } + + /// + /// Ticket #69: one session-lifecycle event (client → Event Server): Open when a proxied + /// browser-view instance starts, Heartbeat while it lives, Close on dispose. The Event + /// Server attributes fetch/tunnel traffic carrying the same SessionId to this session and + /// backstops missed Closes with its own idle timeout. + /// + [Serializable] + public class ProxySessionEventPayload + { + /// Client-generated session id (Guid string), one per proxied view instance. + public string SessionId; + + /// One of . + public string Kind; + + public string WorkspaceItemId; + public string WorkspaceFingerprint; + + /// Machine name of the hosting Smart Client (display only; sent on Open). + public string ClientMachine; + } + + /// + /// Ticket #69: the Management Client's pull of one workspace's live proxy state. Answered + /// unicasted to the requester endpoint like every other proxy response; the polling cadence + /// lives in the MC page (#76), not here. + /// + [Serializable] + public class ProxyStatusRequestPayload + { + public string CorrelationId; + + /// Workspace item id (Guid string) to snapshot. + public string WorkspaceItemId; + } + + /// + /// Ticket #69: one workspace's live-proxy snapshot — open sessions with the traffic the + /// Event Server already sees on the correlation channel, plus the recently-blocked list. + /// In-memory only: everything resets on an Event Server restart. + /// + /// The explicit [DataContract]/[DataMember] shape is the #65 skew rule, applied when #80 + /// added UntrustedMachines: an inferred contract makes EVERY member required on read (the + /// serializer refuses a frame that predates a later member), an explicit one makes every + /// member optional. Frames from a pre-#80 Event Server therefore decode with a null list. + /// + [Serializable] + [DataContract] + public class ProxyStatusResponsePayload + { + [DataMember] + public string CorrelationId; + + [DataMember] + public bool Success; + + /// Workspace item id the snapshot describes (echoed from the request). + [DataMember] + public string WorkspaceItemId; + + [DataMember] + public List Sessions; + + [DataMember] + public List Blocked; + + /// + /// Ticket #80: the client machines that reported their proxy CA posture as untrusted + /// (a CertUntrusted block report carrying a machine name). Deduped per MACHINE, not per + /// view instance; in-memory like the rest of the snapshot. Absent (null) on failure + /// frames and on frames from a pre-#80 Event Server. + /// + [DataMember] + public List UntrustedMachines; + + // Failure fields. + [DataMember] + public string ErrorCode; + + [DataMember] + public string ErrorMessage; + } + + /// + /// Ticket #69: one live proxy session as the Event Server sees it. UserName is the XProtect + /// login name resolved from the messaging sender endpoint identity — never a client claim. + /// RequestsPerMinute is computed over the session's open-to-now window (minimum one minute + /// so a just-opened session never shows an inflated rate). + /// + [Serializable] + public class ProxySessionStatusEntry + { + public string SessionId; + public string UserName; + public string ClientMachine; + public DateTime OpenedUtc; + public DateTime LastActivityUtc; + public long Requests; + public double RequestsPerMinute; + + /// Bytes browser → targets (fetch bodies relayed + tunnel client frames). + public long BytesSent; + + /// Bytes targets → browser (fetched bodies + tunnel target frames). + public long BytesReceived; + } + + /// + /// Ticket #69: one dedup'd entry of a workspace's recently-blocked ring buffer: how often + /// the origin was refused for this reason and when it was last seen. + /// + [Serializable] + public class ProxyBlockEntry + { + public string Origin; + public string Reason; + public long Count; + public DateTime LastSeenUtc; + } + + /// + /// Ticket #80: one CLIENT MACHINE whose proxied views reported the deployment CA as not + /// trusted on that machine (a CertUntrusted block report naming its machine). Dedup is per + /// machine — a machine with ten untrusted view instances is still ONE entry — and Count + /// accumulates the reports, so the MC can show admins "N machines need the CA" rather than + /// a pile of per-view noise. In-memory only, like every other diagnostics surface. + /// + [Serializable] + public class ProxyCaUntrustedMachineEntry + { + /// The reporting machine's name (Environment.MachineName as the client sends it). + public string Machine; + + /// How many CertUntrusted reports this machine's views have sent. + public long Count; + + public DateTime LastSeenUtc; + } + + /// + /// Ticket #75: the MC pull of this Event Server machine's deployment CA public certificate. + /// No workspace context: the CA is machine-level, resolved from the same authority the leaf + /// issuer loads (LocalMachine\Personal, fixed CN) regardless of who created it. + /// + [Serializable] + public class ProxyCaExportRequestPayload + { + public string CorrelationId; + } + + /// + /// Ticket #75: the export answer. Success carries the CA DER (base64 — public part only), + /// its thumbprint, and the validity window for the admin's confirmation copy. DER is a + /// ~1 KB certificate inside the proven single-frame messaging headroom (see + /// ), so no chunk framing is needed. Absent/expired CA + /// answers NoAuthority with an actionable message, never an empty success. + /// + [Serializable] + public class ProxyCaExportResponsePayload + { + public string CorrelationId; + public bool Success; + + public string AuthorityDerBase64; + public string AuthorityThumbprint; + + /// Validity window of the exported certificate (display/audit copy). + public DateTime NotBeforeUtc; + public DateTime NotAfterUtc; + + // Failure fields. + public string ErrorCode; + public string ErrorMessage; + } +} diff --git a/src/WebContent/ProxyFetchCorrelation.cs b/src/WebContent/ProxyFetchCorrelation.cs new file mode 100644 index 0000000..c716ac4 --- /dev/null +++ b/src/WebContent/ProxyFetchCorrelation.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// Requester-side protocol state for the proxy-fetch response stream (#53). Response frames + /// arrive over the shared MIP message channel; only the correlation id tells the requester + /// which request they belong to. The correlator accumulates pending exchanges and completes + /// one exactly when the protocol says the stream ended — every counted chunk arrived, or a + /// single Failure frame arrived — and enforces the requester's response deadline so stalled + /// exchanges are bounded. + /// + /// Thread-safe: Feed is called from the message receiver thread, Begin from the plugin thread. + /// + internal sealed class ProxyFetchCorrelation + { + internal sealed class Pending + { + internal string CorrelationId; + internal CancellationTokenSource Deadline; + internal TaskCompletionSource Completion; + internal readonly Dictionary Chunks = new Dictionary(); + internal ProxyFetchResponsePayload Meta; + internal bool Completed; + } + + private readonly object _sync = new object(); + private readonly Dictionary _pending = new Dictionary(StringComparer.Ordinal); + + /// + /// Register a pending proxy-fetch request and return the task its reassembled outcome + /// completes on. If no terminal frame arrives before the deadline, the task completes with + /// a Timeout error result rather than staying pending forever. + /// + internal Task Begin(string correlationId, TimeSpan timeout) + { + lock (_sync) + { + var pending = new Pending + { + CorrelationId = correlationId, + Deadline = new CancellationTokenSource(timeout), + Completion = new TaskCompletionSource(), + }; + + var id = correlationId; + pending.Deadline.Token.Register(() => + { + // #58: silence from the whole exchange is not a target timeout — it means no + // VMS-side fetcher answered at all (component absent, or the messaging channel + // to the Event Server is down). The distinct code renders a distinct page. + Complete(pending, new ProxyFetchResult + { + Success = false, + ErrorCode = ProxyFetchErrorCodes.ServerUnavailable, + ErrorMessage = $"No proxy-fetch answer arrived from the VMS Event Server within {timeout.TotalSeconds:0}s (exchange {id}): the server-side Web Content proxy component did not answer, or the messaging channel to the Event Server is down.", + }); + }); + + _pending[correlationId] = pending; + return pending.Completion.Task; + } + } + + /// + /// Route one response frame. Frames for correlation ids no longer pending (timed out or + /// already completed) or with malformed content are ignored, not thrown. Returns true when + /// the frame was addressed to a pending exchange (even if this frame itself was malformed + /// and the exchange stays open), false when no pending exchange owns the correlation id. + /// + internal bool Feed(ProxyFetchResponsePayload frame) + { + if (frame == null || string.IsNullOrEmpty(frame.CorrelationId)) + { + return false; + } + + lock (_sync) + { + if (!_pending.TryGetValue(frame.CorrelationId, out var pending) || pending.Completed) + { + return false; + } + + if (frame.Kind == ProxyFetchFrameKinds.Failure) + { + Complete(pending, new ProxyFetchResult + { + Success = false, + StatusCode = frame.StatusCode, + ErrorCode = string.IsNullOrEmpty(frame.ErrorCode) ? ProxyFetchErrorCodes.Error : frame.ErrorCode, + ErrorMessage = frame.ErrorMessage, + }); + return true; + } + + if (frame.Kind == ProxyFetchFrameKinds.Response) + { + pending.Meta = frame; + } + else if (frame.Kind == ProxyFetchFrameKinds.Chunk) + { + try + { + pending.Chunks[frame.Sequence] = string.IsNullOrEmpty(frame.ContentBase64) + ? new byte[0] + : Convert.FromBase64String(frame.ContentBase64); + } + catch (FormatException) + { + return true; + } + } + else + { + return true; + } + + TryFinishByCount(pending); + return true; + } + } + + /// + /// Drop a pending request (the requester lost interest), completing its task cancelled so + /// nobody waits forever on it. + /// + internal void Abandon(string correlationId) + { + Fail(correlationId, ProxyFetchErrorCodes.Error, "The proxy-fetch request was abandoned locally."); + } + + /// + /// Complete a pending exchange with an explicit local failure (#58): the requester knows + /// WHY the exchange died before any answer arrived (messaging channel down, Event Server + /// restarting), and that reason — not a vague timeout — is what the view must show. + /// + internal void Fail(string correlationId, string errorCode, string message) + { + lock (_sync) + { + if (_pending.TryGetValue(correlationId, out var pending)) + { + Complete(pending, new ProxyFetchResult + { + Success = false, + ErrorCode = errorCode, + ErrorMessage = message, + }); + } + } + } + + private void TryFinishByCount(Pending pending) + { + if (pending.Meta == null) + { + return; + } + + // A meta frame with a negative or oversize declaration is hostile/garbled input; + // allocating from it could throw on the message-receiver thread. The server-side + // fetcher never declares more than its own response cap - and since #85 that cap is + // per workspace, up to the fixed 20 MB protocol ceiling. The per-workspace cap is + // enforced where the bytes are actually read (the Event Server fetcher); this guard + // only bounds the client's allocation against the wire. + if (pending.Meta.ChunkCount < 0 || + pending.Meta.BodyLength < 0 || + pending.Meta.BodyLength > ProxyFetchLimits.MaxAllowedResponseBytes) + { + Complete(pending, new ProxyFetchResult + { + Success = false, + StatusCode = pending.Meta.StatusCode, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = "The proxy-fetch metadata frame declared an unusable body size.", + }); + return; + } + + if (pending.Chunks.Count < pending.Meta.ChunkCount) + { + return; + } + + var body = new byte[pending.Meta.BodyLength]; + var offset = 0; + for (var sequence = 0; sequence < pending.Meta.ChunkCount; sequence++) + { + if (!pending.Chunks.TryGetValue(sequence, out var chunk)) + { + return; + } + + // Garbled or hostile frames must never throw on the message-receiver thread: + // chunk bytes that overrun the declared body length complete the exchange with + // an error outcome instead. + if (offset + chunk.Length > body.Length) + { + Complete(pending, new ProxyFetchResult + { + Success = false, + StatusCode = pending.Meta.StatusCode, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = "The proxy-fetch chunk stream overran the declared body length.", + }); + return; + } + + Buffer.BlockCopy(chunk, 0, body, offset, chunk.Length); + offset += chunk.Length; + } + + if (offset < body.Length) + { + Complete(pending, new ProxyFetchResult + { + Success = false, + StatusCode = pending.Meta.StatusCode, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = "The proxy-fetch chunk stream carried less data than declared.", + }); + return; + } + + Complete(pending, new ProxyFetchResult + { + Success = true, + StatusCode = pending.Meta.StatusCode, + StatusText = pending.Meta.StatusText, + Headers = pending.Meta.Headers ?? new List(), + BodyLength = pending.Meta.BodyLength, + Body = body, + }); + } + + private void Complete(Pending pending, ProxyFetchResult result) + { + lock (_sync) + { + if (pending.Completed) + { + return; + } + + pending.Completed = true; + _pending.Remove(pending.CorrelationId); + } + + try + { + pending.Completion.TrySetResult(result); + } + finally + { + pending.Deadline.Cancel(); + pending.Deadline.Dispose(); + } + } + } +} diff --git a/src/WebContent/ProxyFetchFramer.cs b/src/WebContent/ProxyFetchFramer.cs new file mode 100644 index 0000000..8638cda --- /dev/null +++ b/src/WebContent/ProxyFetchFramer.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// Pure seam between the fetcher's outcome and the message stream: turns a ProxyFetchResult + /// into the ordered frames the Event Server transmits back to the requester — one metadata + /// frame (status line + response headers + body length + chunk count), then base64 body + /// chunks. Ticket #53 acceptance: the body comes back chunked with status code and content + /// headers. + /// + internal static class ProxyFetchFramer + { + /// + /// Frame a successful fetch outcome. Chunk frames carry a 0-based sequence and the final + /// chunk is flagged, so a consumer can detect end of stream without waiting for the count. + /// + internal static List Frame(string correlationId, ProxyFetchResult result, int chunkSizeBytes) + { + var frames = new List(); + + if (!result.Success) + { + frames.Add(new ProxyFetchResponsePayload + { + CorrelationId = correlationId, + Kind = ProxyFetchFrameKinds.Failure, + Sequence = -1, + StatusCode = result.StatusCode, + ErrorCode = result.ErrorCode, + ErrorMessage = result.ErrorMessage, + }); + + return frames; + } + + var body = result.Body ?? new byte[0]; + if (chunkSizeBytes <= 0) + { + chunkSizeBytes = ProxyFetchLimits.DefaultChunkSizeBytes; + } + + var chunkCount = (int)((body.Length + chunkSizeBytes - 1) / chunkSizeBytes); + + frames.Add(new ProxyFetchResponsePayload + { + CorrelationId = correlationId, + Kind = ProxyFetchFrameKinds.Response, + Sequence = -1, + StatusCode = result.StatusCode, + StatusText = result.StatusText, + Headers = result.Headers ?? new List(), + BodyLength = result.BodyLength, + ChunkCount = chunkCount, + }); + + for (var index = 0; index < chunkCount; index++) + { + var offset = index * chunkSizeBytes; + var length = Math.Min(chunkSizeBytes, body.Length - offset); + var chunk = new byte[length]; + Array.Copy(body, offset, chunk, 0, length); + + frames.Add(new ProxyFetchResponsePayload + { + CorrelationId = correlationId, + Kind = ProxyFetchFrameKinds.Chunk, + Sequence = index, + IsLastChunk = index == chunkCount - 1, + ContentBase64 = Convert.ToBase64String(chunk), + }); + } + + return frames; + } + } +} diff --git a/src/WebContent/ProxyFetchTransport.cs b/src/WebContent/ProxyFetchTransport.cs new file mode 100644 index 0000000..51fd255 --- /dev/null +++ b/src/WebContent/ProxyFetchTransport.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// Server-side transport for the proxy-fetch contract (#53; write verbs, request headers, + /// and request bodies since #55): replays one proxied browser request + /// from the server network and enforces the ticket's bounds — a connect budget, a per-read stall + /// budget, and a maximum response size — independent of which runtime hosts the plugin (the + /// .NET Framework HttpWebRequest timeouts double as native bounds, but the explicit + /// task-budget races below make the behavior identical everywhere). + /// + /// A target HTTP error status (404 and friends) is still a successful fetch: the proxy + /// relays the target's status line, headers, and page. So is a 3xx: redirects and their + /// Set-Cookie headers ride back to the browser untouched (#55 — the session cookie lives in + /// the browser, never in the VMS server: UseCookies and AllowAutoRedirect are both off). + /// + internal sealed class ProxyHttpFetcher + { + /// + /// Replay the request carried by the query under the given limits, validating the target's + /// TLS certificate normally (#56 default). Never throws: transport outcomes are reported in + /// the returned ProxyFetchResult so framing/logging can react to each failure class. + /// + internal Task FetchAsync(ProxyFetchQuery query, ProxyFetchLimits limits) + { + return FetchAsync(query, limits, ProxyFetchTlsMode.ValidateNormally); + } + + /// + /// Replay the request with the given TLS decision for the https leg to the target (#56). + /// AcceptUntrusted comes from the target's own allowlist entry — the caller scopes it per + /// origin — and is applied ONLY to this request's own callback, never to the process-wide + /// ServicePointManager and never to any other target. + /// + internal async Task FetchAsync(ProxyFetchQuery query, ProxyFetchLimits limits, ProxyFetchTlsMode tlsMode) + { + var target = query == null ? null : query.Uri; + if (!ProxyPolicy.IsHttpUri(target)) + { + return Failure(ProxyFetchErrorCodes.BadRequest, "The proxy-fetch target must be an http(s) url."); + } + + var request = (HttpWebRequest)WebRequest.Create(target); + request.Method = string.IsNullOrWhiteSpace(query.Method) ? "GET" : query.Method.Trim().ToUpperInvariant(); + request.UserAgent = "CascadiaWebContentPlugin/MIP-proxy-fetcher"; + + // #56: certificate validation on the target leg. Default is the runtime's full chain + // validation; a callback that always returns false when it rejects records WHY the + // fetch died (the exception class differs across runtimes, the callback firing does + // not), so the refusal can name the certificate instead of a vague transport error. + var tlsDecision = new TlsDecision(tlsMode); + if (target.Scheme == Uri.UriSchemeHttps) + { + request.ServerCertificateValidationCallback = tlsDecision.Evaluate; + } + + // #55 passthrough rules: the browser owns the session. A 3xx and its Set-Cookie values + // relay back to the browser instead of being followed or stored here, so no target + // credential ever persists on the VMS server. UseCookies only exists on the .NET + // Framework host (and matters there: it auto-creates a CookieContainer that would + // swallow Set-Cookie); the compat shim is cookie-less by construction. + request.AllowAutoRedirect = false; + try + { + var useCookies = typeof(HttpWebRequest).GetProperty("UseCookies"); + if (useCookies != null && useCookies.CanWrite) + { + useCookies.SetValue(request, false); + } + } + catch (Exception) + { + // Property may be unsupported or read-only on compat stacks. + } + + try + { + // Vendor device servers often stall ~3 s waiting for it; browsers only send it + // when they choose to, and that choice is relayed as a header instead. + request.ServicePoint.Expect100Continue = false; + } + catch (Exception) + { + } + + ApplyRequestHeaders(request, query.Headers); + + // On the .NET Framework host these are native enforcement; elsewhere the explicit + // budgets below enforce the same bounds. The properties sit a full second past the + // budgets so native enforcement can never race (and pre-empt) the budgeted checks. + try + { + request.Timeout = limits.ConnectTimeoutMs + 1000; + request.ReadWriteTimeout = limits.ReadTimeoutMs + 1000; + } + catch (Exception) + { + // Property may be unsupported on compat stacks; the budgets cover enforcement. + } + + if (query.Body != null && query.Body.Length > 0) + { + var writeFailure = await WriteRequestBodyAsync(request, query.Body, target, limits, tlsDecision); + if (writeFailure != null) + { + return writeFailure; + } + } + + HttpWebResponse response; + try + { + var responseTask = request.GetResponseAsync(); + var winner = await Task.WhenAny(responseTask, Task.Delay(limits.ConnectTimeoutMs)); + if (winner != responseTask) + { + try + { + request.Abort(); + } + catch (Exception) + { + } + + return Failure(ProxyFetchErrorCodes.Timeout, $"No response headers from {ProxyPolicy.CanonicalOrigin(target)} within {limits.ConnectTimeoutMs} ms."); + } + + response = (HttpWebResponse)await responseTask; + } + catch (WebException webException) when (webException.Response is HttpWebResponse errorResponse) + { + // The target answered with an error status: relay it like any other response. + response = errorResponse; + } + catch (WebException webException) + { + if (tlsDecision.Rejected) + { + return TlsValidationFailure(target); + } + + return MapWebException(webException, target, limits); + } + catch (IOException ioException) + { + if (tlsDecision.Rejected) + { + return TlsValidationFailure(target); + } + + // The compat stack surfaces a refused/reset connection as IOException where the + // .NET Framework host reports WebException(ConnectFailure). + return Failure(ProxyFetchErrorCodes.Unreachable, $"{ProxyPolicy.CanonicalOrigin(target)}: {ioException.Message}"); + } + catch (Exception exception) + { + if (tlsDecision.Rejected) + { + return TlsValidationFailure(target); + } + + try + { + request.Abort(); + } + catch (Exception) + { + } + + return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: {exception.Message}"); + } + + using (response) + { + return await ReadCappedBodyAsync(response, target, limits); + } + } + + /// + /// #55: replay the browser's request headers on the fetcher's own request. Property-backed + /// headers go through their HttpWebRequest properties, connection-level headers are the + /// fetcher's own to state (never relayed from the wire — a crafted payload cannot make the + /// VMS server speak hop-by-hop verbs at a target), and .NET Framework header restrictions + /// degrade to a skip, never a throw. + /// + private static void ApplyRequestHeaders(HttpWebRequest request, List headers) + { + if (headers == null) + { + return; + } + + foreach (var header in headers) + { + if (header == null || string.IsNullOrEmpty(header.Name) || header.Value == null) + { + continue; + } + + // A wire-supplied value with line breaks could inject headers into the ES's own + // request to the target; property setters do not reject these the way + // WebHeaderCollection does, so the gate lives here. + if (header.Value.IndexOf('\r') >= 0 || header.Value.IndexOf('\n') >= 0 || + header.Name.IndexOf(':') >= 0) + { + continue; + } + + try + { + switch (header.Name.ToUpperInvariant()) + { + case "USER-AGENT": + request.UserAgent = header.Value; + break; + case "REFERER": + request.Referer = header.Value; + break; + case "CONTENT-TYPE": + request.ContentType = header.Value; + break; + default: + if (TransportBlockedRequestHeaders.Contains(header.Name)) + { + break; + } + + request.Headers[header.Name] = header.Value; + break; + } + } + catch (Exception) + { + // Restricted names or host-stack value validation (e.g. ContentType rejects + // non-printables on .NET Framework): skip the single header, never fail the + // fetch - FetchAsync's never-throws contract covers every wire-supplied value. + } + } + } + + /// + /// Request headers the fetcher never replays regardless of what arrived on the wire: they + /// describe a connection or body framing the fetcher states itself. Cookie, Authorization, + /// Content-Type (property), Accept-*, and custom app headers deliberately pass (#55). + /// + private static readonly HashSet TransportBlockedRequestHeaders = BuildTransportBlockedRequestHeaders(); + + private static HashSet BuildTransportBlockedRequestHeaders() + { + // The canonical forbidden set plus Expect: the fetcher states Expect: 100-continue + // itself when it streams a body, so a wire-supplied one never reaches the target. + var blocked = new HashSet(ProxyHeaderPolicy.ForbiddenHeaderNames, StringComparer.OrdinalIgnoreCase); + blocked.Add("Expect"); + return blocked; + } + + private static async Task WriteRequestBodyAsync(HttpWebRequest request, byte[] body, Uri target, ProxyFetchLimits limits, TlsDecision tlsDecision) + { + request.ContentLength = body.Length; + try + { + var streamTask = request.GetRequestStreamAsync(); + var winner = await Task.WhenAny(streamTask, Task.Delay(limits.ConnectTimeoutMs)); + if (winner != streamTask) + { + try + { + request.Abort(); + } + catch (Exception) + { + } + + return Failure(ProxyFetchErrorCodes.Timeout, $"Could not start sending the request body to {ProxyPolicy.CanonicalOrigin(target)} within {limits.ConnectTimeoutMs} ms."); + } + + using (var requestStream = await streamTask) + { + await requestStream.WriteAsync(body, 0, body.Length); + } + + return null; + } + catch (WebException webException) + { + if (tlsDecision.Rejected) + { + return TlsValidationFailure(target); + } + + return MapWebException(webException, target, limits); + } + catch (Exception exception) + { + if (tlsDecision.Rejected) + { + return TlsValidationFailure(target); + } + + return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: request body not sent: {exception.Message}"); + } + } + + private static async Task ReadCappedBodyAsync(HttpWebResponse response, Uri target, ProxyFetchLimits limits) + { + var result = new ProxyFetchResult + { + Success = false, + StatusCode = (int)response.StatusCode, + StatusText = GetStatusText(response), + Headers = ReadHeaders(response), + }; + + var body = new MemoryStream(); + var buffer = new byte[16 * 1024]; + + try + { + using (var stream = response.GetResponseStream()) + { + while (true) + { + var readTask = stream.ReadAsync(buffer, 0, buffer.Length); + var winner = await Task.WhenAny(readTask, Task.Delay(limits.ReadTimeoutMs)); + if (winner != readTask) + { + return Failure(ProxyFetchErrorCodes.Timeout, $"Body of {ProxyPolicy.CanonicalOrigin(target)} stalled for more than {limits.ReadTimeoutMs} ms."); + } + + var read = await readTask; + if (read == 0) + { + break; + } + + if (body.Length + read > limits.MaxResponseBytes) + { + return Failure(ProxyFetchErrorCodes.TooLarge, $"Response body from {ProxyPolicy.CanonicalOrigin(target)} exceeded the {limits.MaxResponseBytes} byte response cap."); + } + + body.Write(buffer, 0, read); + } + } + } + catch (WebException webException) + { + return MapWebException(webException, target, limits); + } + catch (IOException ioException) + { + return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: stream failure: {ioException.Message}"); + } + + result.Success = true; + result.Body = body.ToArray(); + result.BodyLength = body.Length; + return result; + } + + private static List ReadHeaders(HttpWebResponse response) + { + var headers = new List(); + foreach (var name in response.Headers.AllKeys) + { + // One entry per raw value (#55): indexer access joins repeated values with ", ", + // which corrupts repeated Set-Cookie headers (and the comma inside their Expires + // dates) — a session cookie that fails to round-trip kills vendor logins. + string[] values; + try + { + values = response.Headers.GetValues(name); + } + catch (Exception) + { + values = new[] { response.Headers[name] }; + } + + foreach (var value in values ?? new[] { response.Headers[name] }) + { + headers.Add(new ProxyFetchHeader { Name = name, Value = value }); + } + } + + // Content-Type/Content-Encoding live on the response object itself and may be missing + // from the raw collection on some stacks; carry them explicitly so content headers pass. + if (!string.IsNullOrEmpty(response.ContentType) && !HasHeader(headers, "Content-Type")) + { + headers.Add(new ProxyFetchHeader { Name = "Content-Type", Value = response.ContentType }); + } + + return headers; + } + + private static bool HasHeader(List headers, string name) + { + foreach (var header in headers) + { + if (string.Equals(header.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string GetStatusText(HttpWebResponse response) + { + // The wire reason phrase is not publicly exposed on HttpWebResponse, so the status + // code name ("NotFound") is carried as the human-readable status text. The requester + // keys off StatusCode; this is display metadata only. + return response.StatusCode.ToString(); + } + + private static ProxyFetchResult MapWebException(WebException webException, Uri target, ProxyFetchLimits limits) + { + switch (webException.Status) + { + // A budget breach normally surfaces through the explicit task races above; a + // native Timeout that still reaches here is reported as a timeout, not confusion. + case WebExceptionStatus.Timeout: + return Failure(ProxyFetchErrorCodes.Timeout, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}"); + + case WebExceptionStatus.ConnectFailure: + case WebExceptionStatus.NameResolutionFailure: + case WebExceptionStatus.TrustFailure: + case WebExceptionStatus.SecureChannelFailure: + return Failure(ProxyFetchErrorCodes.Unreachable, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}"); + + // Compat stacks classify a refused socket as UnknownError; the socket exception + // inside separates those from genuinely unknown faults. + case WebExceptionStatus.UnknownError when IsSocketRefusedOrUnreachable(webException): + return Failure(ProxyFetchErrorCodes.Unreachable, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}"); + + default: + return Failure(ProxyFetchErrorCodes.Error, $"{ProxyPolicy.CanonicalOrigin(target)}: {webException.Message}"); + } + } + + private static bool IsSocketRefusedOrUnreachable(WebException webException) + { + for (var inner = webException.InnerException; inner != null; inner = inner.InnerException) + { + if (inner is SocketException socketException && + (socketException.SocketErrorCode == SocketError.ConnectionRefused || + socketException.SocketErrorCode == SocketError.NetworkUnreachable || + socketException.SocketErrorCode == SocketError.HostUnreachable)) + { + return true; + } + } + + return false; + } + + private static ProxyFetchResult Failure(string errorCode, string message) + { + return new ProxyFetchResult + { + Success = false, + ErrorCode = errorCode, + ErrorMessage = message, + }; + } + + /// + /// #56: the per-request certificate-validation decision. The callback is attached to THIS + /// HttpWebRequest only — ServicePointManager's process-wide callback is never touched, so a + /// plugin running inside the Event Server cannot weaken TLS for any other caller, and the + /// override stays scoped to the one allowlisted origin the caller checked. A rejecting + /// callback records that fact so the resulting transport exception maps to TlsValidation + /// no matter which exception shape the host runtime produces. + /// + private sealed class TlsDecision + { + private readonly ProxyFetchTlsMode _mode; + internal bool Rejected; + + internal TlsDecision(ProxyFetchTlsMode mode) + { + _mode = mode; + } + + internal bool Evaluate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + { + if (_mode == ProxyFetchTlsMode.AcceptUntrusted) + { + return true; + } + + if (sslPolicyErrors != SslPolicyErrors.None) + { + Rejected = true; + } + + return sslPolicyErrors == SslPolicyErrors.None; + } + } + + private static ProxyFetchResult TlsValidationFailure(Uri target) + { + var origin = ProxyPolicy.CanonicalOrigin(target); + return Failure( + ProxyFetchErrorCodes.TlsValidation, + $"{origin}: the TLS certificate failed validation (untrusted chain, expired, or hostname mismatch). The proxy validates target certificates and refuses this one; enable the per-target 'accept untrusted certificate' override for '{origin}' on the workspace page to relay this device."); + } + } +} diff --git a/src/WebContent/ProxyFetchWireCodec.cs b/src/WebContent/ProxyFetchWireCodec.cs new file mode 100644 index 0000000..a6703a4 --- /dev/null +++ b/src/WebContent/ProxyFetchWireCodec.cs @@ -0,0 +1,288 @@ +using System; +using System.IO; +using System.Runtime.Serialization.Json; +using System.Text; + +namespace WebContent +{ + /// + /// Wire codec for proxy-fetch payloads (#54 live-host fix). The platform's + /// ExternalMessage.Data rehydrates the payload via + /// "new DataContractSerializer(Type.GetType(DataClass))"; when the plugin assembly is not + /// visible to the HOST process's type resolution (Smart Client loads plugins differently + /// from Management Client), Type.GetType returns null, the platform swallows the resulting + /// ArgumentNullException ("Value cannot be null"), and hands the receiver Data == null - a + /// silent black hole that looks exactly like a lost response. Putting a plain string on the + /// wire removes the type-binding requirement from every host: payloads travel as JSON + /// strings, encoded/decoded only inside this plugin. + /// + internal static class ProxyFetchWireCodec + { + // DataContractJsonSerializer instances are documented as thread-safe for concurrent + // ReadObject/WriteObject when the type is fixed at construction (as here): the fetcher + // runs up to its concurrency cap on the thread pool and the client receives on the + // messaging thread, all against these same instances. + private static readonly DataContractJsonSerializer RequestSerializer = + new DataContractJsonSerializer(typeof(ProxyFetchRequestPayload)); + + private static readonly DataContractJsonSerializer FrameSerializer = + new DataContractJsonSerializer(typeof(ProxyFetchResponsePayload)); + + private static readonly DataContractJsonSerializer LeafRequestSerializer = + new DataContractJsonSerializer(typeof(ProxyLeafRequestPayload)); + + private static readonly DataContractJsonSerializer LeafResponseSerializer = + new DataContractJsonSerializer(typeof(ProxyLeafResponsePayload)); + + private static readonly DataContractJsonSerializer TunnelSerializer = + new DataContractJsonSerializer(typeof(ProxyTunnelPayload)); + + // Ticket #69: diagnostics payloads — same JSON-string-on-the-wire discipline. + private static readonly DataContractJsonSerializer BlockReportSerializer = + new DataContractJsonSerializer(typeof(ProxyBlockReportPayload)); + + private static readonly DataContractJsonSerializer SessionEventSerializer = + new DataContractJsonSerializer(typeof(ProxySessionEventPayload)); + + private static readonly DataContractJsonSerializer StatusRequestSerializer = + new DataContractJsonSerializer(typeof(ProxyStatusRequestPayload)); + + private static readonly DataContractJsonSerializer StatusResponseSerializer = + new DataContractJsonSerializer(typeof(ProxyStatusResponsePayload)); + + // Ticket #75: deployment CA public-certificate export payloads. + private static readonly DataContractJsonSerializer CaExportRequestSerializer = + new DataContractJsonSerializer(typeof(ProxyCaExportRequestPayload)); + + private static readonly DataContractJsonSerializer CaExportResponseSerializer = + new DataContractJsonSerializer(typeof(ProxyCaExportResponsePayload)); + + /// Encode a request payload for Message.Data. + internal static string ToWire(ProxyFetchRequestPayload payload) + { + return Serialize(payload, RequestSerializer); + } + + /// Encode one response frame for Message.Data. + internal static string ToWire(ProxyFetchResponsePayload frame) + { + return Serialize(frame, FrameSerializer); + } + + /// + /// Decode a request payload from a received Message.Data. Null for anything that is not + /// a decodable request JSON string (foreign/host values, garbage, version skew). Members + /// added by later plugin versions are optional on read (explicit DataContract), so an + /// older Smart Client's frame still decodes; a frame that decodes but names no url was + /// never a fetch request, and stays an unusable frame. + /// + internal static ProxyFetchRequestPayload RequestFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + var payload = Deserialize(json, RequestSerializer); + if (payload == null || string.IsNullOrEmpty(payload.Url)) + { + return null; + } + + return payload; + } + + /// Decode one response frame from a received Message.Data; null when unusable. + internal static ProxyFetchResponsePayload FrameFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, FrameSerializer); + } + + /// Encode a leaf-mint request for Message.Data (ADR-0003). + internal static string ToWire(ProxyLeafRequestPayload request) + { + return Serialize(request, LeafRequestSerializer); + } + + /// Encode a leaf-mint response for Message.Data (ADR-0003). + internal static string ToWire(ProxyLeafResponsePayload response) + { + return Serialize(response, LeafResponseSerializer); + } + + internal static ProxyLeafRequestPayload LeafRequestFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, LeafRequestSerializer); + } + + internal static ProxyLeafResponsePayload LeafResponseFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, LeafResponseSerializer); + } + + /// Encode one websocket-tunnel message (#57) for Message.Data. + internal static string ToWire(ProxyTunnelPayload payload) + { + return Serialize(payload, TunnelSerializer); + } + + /// Decode one websocket-tunnel message from Message.Data; null when unusable. + internal static ProxyTunnelPayload TunnelFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, TunnelSerializer); + } + + /// Encode a block report (#69) for Message.Data. + internal static string ToWire(ProxyBlockReportPayload report) + { + return Serialize(report, BlockReportSerializer); + } + + internal static ProxyBlockReportPayload BlockReportFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, BlockReportSerializer); + } + + /// Encode a session-lifecycle event (#69) for Message.Data. + internal static string ToWire(ProxySessionEventPayload sessionEvent) + { + return Serialize(sessionEvent, SessionEventSerializer); + } + + internal static ProxySessionEventPayload SessionEventFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, SessionEventSerializer); + } + + /// Encode a workspace status query (#69) for Message.Data. + internal static string ToWire(ProxyStatusRequestPayload request) + { + return Serialize(request, StatusRequestSerializer); + } + + internal static ProxyStatusRequestPayload StatusRequestFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, StatusRequestSerializer); + } + + /// Encode a workspace status snapshot (#69) for Message.Data. + internal static string ToWire(ProxyStatusResponsePayload response) + { + return Serialize(response, StatusResponseSerializer); + } + + internal static ProxyStatusResponsePayload StatusResponseFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, StatusResponseSerializer); + } + + /// Encode a deployment CA export request (#75) for Message.Data. + internal static string ToWire(ProxyCaExportRequestPayload request) + { + return Serialize(request, CaExportRequestSerializer); + } + + internal static ProxyCaExportRequestPayload CaExportRequestFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, CaExportRequestSerializer); + } + + /// Encode a deployment CA export answer (#75) for Message.Data. + internal static string ToWire(ProxyCaExportResponsePayload response) + { + return Serialize(response, CaExportResponseSerializer); + } + + internal static ProxyCaExportResponsePayload CaExportResponseFromWire(object data) + { + var json = data as string; + if (json == null) + { + return null; + } + + return Deserialize(json, CaExportResponseSerializer); + } + + private static string Serialize(object payload, DataContractJsonSerializer serializer) + { + using (var stream = new MemoryStream()) + { + serializer.WriteObject(stream, payload); + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + + private static T Deserialize(string json, DataContractJsonSerializer serializer) where T : class + { + try + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json))) + { + return serializer.ReadObject(stream) as T; + } + } + catch (Exception) + { + // Malformed or foreign content: an unusable frame, not an exception on the + // message-receiver thread. + return null; + } + } + } +} diff --git a/src/WebContent/ProxyForwardCodec.cs b/src/WebContent/ProxyForwardCodec.cs new file mode 100644 index 0000000..a1bfb40 --- /dev/null +++ b/src/WebContent/ProxyForwardCodec.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace WebContent +{ + /// + /// One proxied request the browser sent to the loopback forwarding proxy (#54), as parsed + /// from the absolute-form request line plus headers. The body itself is read separately by + /// per Content-Length (#55) and travels to the fetcher + /// on . + /// + internal sealed class ProxyForwardedRequest + { + internal string Method; + + /// The request-target exactly as it arrived; CONNECT tunnels carry an authority here. + internal string RawTarget; + + /// + /// Absolute uri for forwarded requests; for CONNECT (#56) the https origin the tunnel + /// targets, resolved from the authority — the inner TLS-terminated requests extend it + /// with paths. + /// + internal Uri Uri; + + /// Request headers, first value per name, original casing preserved. + internal Dictionary Headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Ticket #69: the browser-view session that issued this request, stamped by the listener + /// before the request crosses the fetch/tunnel seams; relayed on the tunnel-open payload so + /// the Event Server attributes tunnel traffic to the right session. Null on unrouted paths. + /// + internal string SessionId; + } + + /// + /// Pure HTTP codec for the loopback forwarding proxy (#54): parses the absolute-form request + /// head Chromium sends to an HTTP proxy, renders a reassembled + /// back onto the socket as a forwarded response, and renders locally-generated refusal + /// responses. The socket plumbing lives in LoopbackProxyListener; this class never + /// touches the network so every decision is unit-testable. + /// + internal static class ProxyForwardCodec + { + internal static bool TryParseRequestHead(string head, out ProxyForwardedRequest request, out string rejectReason) + { + return TryParseRequestHead(head, null, out request, out rejectReason); + } + + /// + /// Parse a request head, optionally inside a TLS tunnel whose origin the caller + /// terminated (#56). With a tunnel origin the request-target is origin-form + /// ("GET /path HTTP/1.1") and combines with the tunnel origin — the tunnel IS the origin + /// the browser believes it is talking to, so the Host header is never believed. Without a + /// tunnel origin an absolute-form target stays required, exactly as an HTTP proxy request. + /// + internal static bool TryParseRequestHead(string head, Uri tunnelOrigin, out ProxyForwardedRequest request, out string rejectReason) + { + request = null; + rejectReason = null; + + if (string.IsNullOrEmpty(head)) + { + rejectReason = "The proxy received an empty request head."; + return false; + } + + var lines = head.Replace("\n", "\r\n").Split(new[] { "\r\n" }, StringSplitOptions.None); + var requestLine = lines[0].Split(new[] { ' ' }, 3); + if (requestLine.Length != 3 || !requestLine[2].StartsWith("HTTP/1", StringComparison.Ordinal)) + { + rejectReason = "The proxy request line was malformed."; + return false; + } + + var parsed = new ProxyForwardedRequest + { + Method = requestLine[0].ToUpperInvariant(), + RawTarget = requestLine[1], + }; + + // CONNECT carries an authority ("host:port"), not a uri. #56 resolves it to the https + // origin the tunnel would open so the decision layer can allowlist-gate the tunnel + // termination; a malformed authority is a malformed request. + Uri uri = null; + if (parsed.Method == "CONNECT") + { + // A CONNECT request-target is strictly an authority - a path or query in it is + // not something a browser sends and is rejected rather than guessed at. + if (requestLine[1].IndexOf('/') >= 0 || + !Uri.TryCreate($"https://{requestLine[1]}", UriKind.Absolute, out uri) || + !ProxyPolicy.IsHttpUri(uri)) + { + rejectReason = $"The CONNECT target '{requestLine[1]}' was not a host:port authority."; + return false; + } + } + else if (tunnelOrigin != null) + { + // Inside a terminated tunnel the tunnel IS the origin: origin-form targets + // resolve against it, and an absolute-form target may only name the tunnel's + // own origin (never smuggle another through the accepted gate). + if (!Uri.TryCreate(tunnelOrigin, requestLine[1], out uri) || + !ProxyPolicy.IsHttpUri(uri) || + ProxyPolicy.CanonicalOrigin(uri) != ProxyPolicy.CanonicalOrigin(tunnelOrigin)) + { + rejectReason = $"The in-tunnel request target '{requestLine[1]}' does not belong to the tunnel origin '{tunnelOrigin.OriginalString}'."; + return false; + } + } + else if (!Uri.TryCreate(requestLine[1], UriKind.Absolute, out uri)) + { + rejectReason = $"The proxy request target '{requestLine[1]}' was not an absolute uri."; + return false; + } + else if (uri.Scheme == "ws" || uri.Scheme == "wss") + { + // RFC 6455 5.1: a websocket upgrade reaches an HTTP proxy with the ABSOLUTE ws:// + // or wss:// target, not the http one. The scheme letter carries no gating meaning + // here (ws is to http what wss is to https), so rebase the uri onto its transport + // twin: the origin allowlist, the decision table, and the tunnel handshake all + // speak http origins, and RawTarget keeps exactly what the wire carried. + var transportScheme = uri.Scheme == "ws" ? Uri.UriSchemeHttp : Uri.UriSchemeHttps; + if (!Uri.TryCreate(new UriBuilder(uri) { Scheme = transportScheme }.ToString(), UriKind.Absolute, out uri)) + { + rejectReason = $"The proxy websocket target '{requestLine[1]}' was not a usable uri."; + return false; + } + } + + if (uri != null && !ProxyPolicy.IsHttpUri(uri)) + { + rejectReason = $"The proxy request target '{requestLine[1]}' uses the unsupported scheme '{uri.Scheme}'."; + return false; + } + + parsed.Uri = uri; + + for (var line = 1; line < lines.Length; line++) + { + if (lines[line].Length == 0) + { + break; + } + + var colon = lines[line].IndexOf(':'); + if (colon <= 0) + { + continue; + } + + var name = lines[line].Substring(0, colon).Trim(); + var value = lines[line].Substring(colon + 1).Trim(); + if (!parsed.Headers.ContainsKey(name)) + { + parsed.Headers[name] = value; + } + } + + request = parsed; + return true; + } + + /// + /// Headers that describe the fetcher's own connection (or body framing the framer already + /// replaced), never the browser's — they must not be relayed onto the loopback socket. + /// Content-Length is in the set because the proxy restates it from the reassembled body. + /// + private static readonly HashSet RelayBlockedHeaders = BuildRelayBlockedHeaders(); + + private static HashSet BuildRelayBlockedHeaders() + { + var blocked = new HashSet(ProxyHeaderPolicy.ForbiddenHeaderNames, StringComparer.OrdinalIgnoreCase); + // Host is NOT relay-blocked: it is the request line's authority the codec parses for + // policy; the fetcher derives its own from the target uri. + blocked.Remove("Host"); + return blocked; + } + + /// + /// Browser request headers the fetcher must NOT replay at the target (#55): the hop-by-hop + /// set plus Host (the fetcher's HttpWebRequest derives it from the uri) and Content-Length + /// (restated from the relayed body). Everything else — Cookie, Authorization, Content-Type, + /// Accept-*, custom app verbs' headers — passes through unmodified; the proxy never + /// injects or inspects credentials. + /// + private static readonly HashSet OutboundRequestBlockedHeaders = + new HashSet(ProxyHeaderPolicy.ForbiddenHeaderNames, StringComparer.OrdinalIgnoreCase); + + /// + /// The request headers to carry on the wire to the Event Server fetcher: the parsed + /// headers (first value per name) minus the outbound-blocked set, original casing and + /// values intact. + /// + internal static List ToOutboundRequestHeaders(ProxyForwardedRequest request) + { + var outbound = new List(); + foreach (var header in request.Headers) + { + if (OutboundRequestBlockedHeaders.Contains(header.Key)) + { + continue; + } + + outbound.Add(new ProxyFetchHeader { Name = header.Key, Value = header.Value }); + } + + return outbound; + } + + /// + /// Render a reassembled fetch outcome (AC3: status code and headers intact) as an HTTP/1.1 + /// response for the browser connection. Body framing is the proxy's own Content-Length; + /// the connection closes after the response. + /// + internal static byte[] RenderForwardedResponse(ProxyFetchResult result) + { + var body = result.Body ?? new byte[0]; + var head = new StringBuilder(); + head.Append($"HTTP/1.1 {result.StatusCode} {result.StatusText ?? "OK"}\r\n"); + + foreach (var header in result.Headers) + { + if (header == null || string.IsNullOrEmpty(header.Name) || RelayBlockedHeaders.Contains(header.Name)) + { + continue; + } + + head.Append($"{header.Name}: {header.Value}\r\n"); + } + + head.Append($"Content-Length: {body.Length}\r\n"); + head.Append("Connection: close\r\n"); + head.Append("\r\n"); + + return Combine(Encoding.UTF8.GetBytes(head.ToString()), body); + } + + /// + /// Render a locally-generated refusal page (400/403/405/413/502/503/504) the browser can + /// display (#54 framing; friendly cause categories + collapsed technical detail since #66). + /// The document itself is ; this owns only the HTTP framing. + /// + internal static byte[] RenderErrorResponse(int statusCode, string reasonPhrase, string category, ProxyErrorPage.Details details = null) + { + var bodyBytes = Encoding.UTF8.GetBytes(ProxyErrorPage.RenderHtml(statusCode, reasonPhrase, category, details)); + + var head = + $"HTTP/1.1 {statusCode} {reasonPhrase}\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + $"Content-Length: {bodyBytes.Length}\r\n" + + "Connection: close\r\n" + + "\r\n"; + + return Combine(System.Text.Encoding.UTF8.GetBytes(head), bodyBytes); + } + + private static byte[] Combine(byte[] head, byte[] body) + { + var combined = new byte[head.Length + body.Length]; + Buffer.BlockCopy(head, 0, combined, 0, head.Length); + Buffer.BlockCopy(body, 0, combined, head.Length, body.Length); + return combined; + } + } +} diff --git a/src/WebContent/ProxyForwardConnection.cs b/src/WebContent/ProxyForwardConnection.cs new file mode 100644 index 0000000..99648ce --- /dev/null +++ b/src/WebContent/ProxyForwardConnection.cs @@ -0,0 +1,984 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// One proxied browser connection handled end to end (#54; write verbs and request bodies + /// since #55): read the request head from the stream, run the decision table + /// (), read the request body per its declared Content-Length, + /// relay allowlisted requests through the caller-supplied fetch seam (MIP messaging to the + /// Event Server fetcher in production, a fake in tests), and render the answer back. Head + /// reading is capped so a chatty or hostile local client cannot pin unbounded memory, and a + /// request body is only buffered when its declared size fits the request cap. + /// + internal static class ProxyForwardConnection + { + /// Request heads past this size are refused outright: browser requests are far smaller. + internal const int MaxHeadBytes = 64 * 1024; + + /// + /// Request-body cap (#55): login forms and XHR payloads are kilobytes; a declared body + /// past this bound is refused with 413 before any of it is taken from the socket. + /// + internal const int MaxRequestBodyBytes = 1024 * 1024; + + internal static Task HandleAsync( + Stream input, + Stream output, + System.Collections.Generic.IEnumerable canonicalTargets, + Func> fetch, + Action log) + { + return HandleAsync(input, output, canonicalTargets, fetch, log, null); + } + + /// + /// Optional tunnel-termination seam (#56): given the raw socket stream, any bytes the + /// head reader already pulled past the CONNECT head (a TLS ClientHello can pipeline into + /// the same segment), and the allowlisted https origin, wrap the stream in TLS with an + /// ephemeral local certificate and return the decrypted stream (null when termination is + /// impossible). The pure flow stays testable with an identity seam; production passes + /// ProxyLoopbackTls. + /// + internal static async Task HandleAsync( + Stream input, + Stream output, + System.Collections.Generic.IEnumerable canonicalTargets, + Func> fetch, + Action log, + Func> unwrapTunnelTls) + { + await HandleAsync(input, output, canonicalTargets, fetch, log, unwrapTunnelTls, null); + } + + /// + /// Optional websocket-tunnel seam (#57): given the decided upgrade request, open the + /// CONNECT-style byte tunnel to the target through the Event Server and hand back a + /// handle to pump plus the target's verbatim 101 answer. Production passes + /// ProxyFetchClient.OpenTunnelAsync; the pure flow stays testable with an + /// in-memory handle. + /// + internal static async Task HandleAsync( + Stream input, + Stream output, + System.Collections.Generic.IEnumerable canonicalTargets, + Func> fetch, + Action log, + Func> unwrapTunnelTls, + Func> openTunnel) + { + await HandleAsync(input, output, canonicalTargets, fetch, log, unwrapTunnelTls, openTunnel, null); + } + + /// + /// Optional diagnostics seam (#69): every refusal this flow decides client-side — local + /// 403, refused tunnel, TLS termination failure, and the mapped failure codes coming back + /// on the fetch path — is filed with the reporter before the browser sees its error page. + /// Null reporter (tests, non-proxied wiring) changes nothing about the traffic itself. + /// + /// #73: the canonical entries may be wildcard patterns; vmsFloorHosts carries the + /// VMS's own host names for the matcher's SSRF floor. Both ride the one shared + /// matcher inside . + /// + internal static async Task HandleAsync( + Stream input, + Stream output, + System.Collections.Generic.IEnumerable canonicalTargets, + Func> fetch, + Action log, + Func> unwrapTunnelTls, + Func> openTunnel, + IProxyBlockReporter blocks, + System.Collections.Generic.IEnumerable vmsFloorHosts = null) + { + var head = await ReadRequestHeadAsync(input); + if (head == null) + { + // Chromium pre-opens sockets speculatively and may never write to them: an empty + // connection is closed quietly, with no response. + return; + } + + var disposition = ProxyForwardPolicy.Decide(canonicalTargets, head.Text, null, vmsFloorHosts, out var request, out var rejectReason); + switch (disposition) + { + case ProxyForwardDisposition.BadRequest: + log(rejectReason); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 400, "Bad Request", ProxyErrorPage.CategoryMalformedRequest, + ProxyErrorPage.Details.ForRequest(request, rejectReason, ProxyErrorPage.DecidedByLocalListener))); + return; + + case ProxyForwardDisposition.OriginNotAllowed: + log(rejectReason); + blocks?.Report(request?.Uri, ProxyBlockReasons.NotAllowed); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 403, "Forbidden", ProxyErrorPage.CategoryNotAllowed, + ProxyErrorPage.Details.ForRequest(request, rejectReason, ProxyErrorPage.DecidedByLocalListener).WithSuggestedAllowlistEntry())); + return; + + case ProxyForwardDisposition.MethodNotAllowed: + log(rejectReason); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 405, "Method Not Allowed", ProxyErrorPage.CategoryMethodNotAllowed, + ProxyErrorPage.Details.ForRequest(request, rejectReason, ProxyErrorPage.DecidedByLocalListener))); + return; + + case ProxyForwardDisposition.ConnectTerminate: + if (unwrapTunnelTls == null) + { + // Defensive wiring: no ephemeral-certificate plumbing on this listener. The + // tunnel is refused outright - never blind-tunnelled, never taken direct. + var reason = $"The CONNECT to '{request.RawTarget}' cannot be terminated by this proxy; https targets are fetched by the VMS server over tunnels this listener terminates."; + log(reason); + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", ProxyErrorPage.CategoryTunnelFailed, + ProxyErrorPage.Details.ForRequest(request, reason, ProxyErrorPage.DecidedByLocalListener))); + return; + } + + log($"Proxy tunnel to '{request.RawTarget}' allowlisted; terminating TLS at the loopback listener."); + await WriteAsync(output, Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n")); + + Stream tunnel; + try + { + tunnel = await unwrapTunnelTls(input, head.Leftover, request.Uri); + } + catch (Exception exception) + { + log($"Proxy TLS termination for '{request.RawTarget}' failed: {exception.Message}"); + blocks?.Report(request.Uri, ProxyBlockReasons.CertUntrusted); + return; + } + + if (tunnel == null) + { + log($"Proxy TLS termination for '{request.RawTarget}' produced no tunnel; the connection is closed."); + blocks?.Report(request.Uri, ProxyBlockReasons.CertUntrusted); + return; + } + + try + { + await ServeTunnelRequestsAsync(tunnel, canonicalTargets, request.Uri, fetch, log, openTunnel, blocks, vmsFloorHosts); + } + finally + { + // The TLS wrapper is ours once termination succeeded; the raw socket stream + // stays the caller's (its using-block closes underneath). + try { tunnel.Dispose(); } catch { } + } + + return; + + case ProxyForwardDisposition.ConnectWebSocketTunnel: + if (openTunnel == null) + { + var connectWiring = $"The CONNECT to '{request.RawTarget}' cannot be tunnelled by this listener; ws:// targets ride CONNECT-style byte tunnels the VMS server opens."; + log(connectWiring); + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", ProxyErrorPage.CategoryTunnelFailed, + ProxyErrorPage.Details.ForRequest(request, connectWiring, ProxyErrorPage.DecidedByLocalListener))); + return; + } + + // Live repro (#57): Chromium proxies plain ws:// as CONNECT plus a PLAINTEXT + // websocket handshake inside the tunnel. The 200 must go out before the + // browser writes that handshake, and inside a CONNECT tunnel no HTTP status + // can follow it - so a tunnel whose first bytes are not a valid ws upgrade + // (wss ClientHello, junk) is simply closed, before any Event Server socket + // or target dial exists. + log($"Proxy CONNECT to '{request.RawTarget}' allowlisted as an http twin; expecting a plaintext ws:// handshake in the tunnel."); + await WriteAsync(output, Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n")); + + var inner = await ReadRequestHeadAsync(input, head.Leftover); + if (inner == null) + { + log($"The CONNECT tunnel to '{request.RawTarget}' produced no request head; the tunnel is closed."); + return; + } + + var wsTwinOrigin = new UriBuilder(request.Uri) { Scheme = Uri.UriSchemeHttp }.Uri; + ProxyForwardedRequest upgrade; + string upgradeReason; + if (!ProxyForwardCodec.TryParseRequestHead(inner.Text, wsTwinOrigin, out upgrade, out upgradeReason) + || !ProxyWebSocketHandshake.IsWebSocketUpgrade(upgrade) + || !ProxyWebSocketHandshake.HasValidClientHandshake(upgrade, out upgradeReason)) + { + log($"The CONNECT tunnel to '{request.RawTarget}' did not carry a valid plain ws:// handshake ({upgradeReason}); the tunnel is closed."); + return; + } + + ProxyTunnelOpenOutcome connectOutcome; + try + { + connectOutcome = await openTunnel(upgrade); + } + catch (Exception exception) + { + log($"Proxy websocket CONNECT tunnel open for '{request.RawTarget}' threw: {exception.Message}"); + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + return; + } + + if (connectOutcome == null || !connectOutcome.Success) + { + var connectFailure = connectOutcome == null + ? "The Event Server tunnel opener returned no outcome." + : connectOutcome.ErrorMessage; + log($"Proxy websocket CONNECT tunnel to '{request.RawTarget}' refused (code={connectOutcome?.ErrorCode}): {connectFailure}"); + ReportTunnelOpenFailure(blocks, request, connectOutcome); + return; + } + + log($"Proxy websocket CONNECT tunnel to '{request.RawTarget}' established; pumping bytes in both directions."); + await ServeWebSocketTunnelAsync(inner.Leftover, input, output, connectOutcome, log); + return; + + case ProxyForwardDisposition.ConnectRefused: + log(rejectReason); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", ProxyErrorPage.CategoryNotAllowed, + ProxyErrorPage.Details.ForRequest(request, rejectReason + " The workspace proxy supports http(s) allowlisted targets fetched by the VMS server; https pages need a CONNECT tunnel this proxy does not provide.", ProxyErrorPage.DecidedByLocalListener).WithSuggestedAllowlistEntry())); + return; + + case ProxyForwardDisposition.WebSocketTunnel: + if (openTunnel == null) + { + var tunnelWiring = $"The websocket upgrade to '{request.RawTarget}' cannot be tunnelled by this listener; ws:// targets ride CONNECT-style byte tunnels the VMS server opens."; + log(tunnelWiring); + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", ProxyErrorPage.CategoryTunnelFailed, + ProxyErrorPage.Details.ForRequest(request, tunnelWiring, ProxyErrorPage.DecidedByLocalListener))); + return; + } + + ProxyTunnelOpenOutcome outcome; + try + { + outcome = await openTunnel(request); + } + catch (Exception exception) + { + log($"Proxy websocket tunnel open for '{request.RawTarget}' threw: {exception.Message}"); + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", ProxyErrorPage.CategoryTunnelFailed, + ProxyErrorPage.Details.ForRequest(request, $"The VMS proxy tunnel failed to open: {exception.Message}", ProxyErrorPage.DecidedByLocalListener))); + return; + } + + if (outcome == null || !outcome.Success) + { + var openFailure = outcome == null + ? "The Event Server tunnel opener returned no outcome." + : outcome.ErrorMessage; + log($"Proxy websocket tunnel to '{request.RawTarget}' refused (code={outcome?.ErrorCode}): {openFailure}"); + ReportTunnelOpenFailure(blocks, request, outcome); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", CategoryForFailure(outcome == null ? null : outcome.ErrorCode, ProxyErrorPage.CategoryTunnelFailed), + ProxyErrorPage.Details.ForRequest( + request, + openFailure, + outcome == null ? ProxyErrorPage.DecidedByLocalListener : ProxyErrorPage.DecidedByVmsServer))); + return; + } + + log($"Proxy websocket tunnel to '{request.RawTarget}' established; pumping bytes in both directions."); + await ServeWebSocketTunnelAsync(head.Leftover, input, output, outcome, log); + return; + } + + await ServeForwardedRequestAsync(request, head.Leftover, input, output, fetch, log, blocks); + } + + /// + /// #66: the cause category for a failure the Event Server answered with - its wire code + /// maps onto its own distinct category (1:1, never collapsed). Codes the map does not + /// know (and the null no-outcome case) fall back to the caller's posture default. + /// + private static string CategoryForFailure(string errorCode, string fallbackCategory) + { + string category; + return ProxyErrorPage.TryCategoryForErrorCode(errorCode, out category) ? category : fallbackCategory; + } + + /// + /// #69: translate a tunnel-open failure into a client-side block reason. Codes the Event + /// Server itself answered with (an allowlist refusal, a target dial or handshake failure) + /// are recorded server-side where they happened - re-reporting them from the client would + /// double-count. Codes the client produced on its own (no outcome at all, the open deadline + /// expiring on a silent channel) are invisible to the Event Server and surface as + /// . + /// + private static string MapTunnelOpenBlockReason(ProxyTunnelOpenOutcome outcome) + { + if (outcome == null) + { + return ProxyBlockReasons.TunnelRefused; + } + + switch (outcome.ErrorCode) + { + case ProxyFetchErrorCodes.NotAllowed: + case ProxyFetchErrorCodes.Unreachable: + case ProxyFetchErrorCodes.Timeout: + case ProxyFetchErrorCodes.TooLarge: + case ProxyFetchErrorCodes.TlsValidation: + case ProxyFetchErrorCodes.BadRequest: + case ProxyFetchErrorCodes.Error: + return null; + + default: + return ProxyBlockReasons.TunnelRefused; + } + } + + /// + /// File the tunnel-open failure with the client-side reporter only when the failure is one + /// the Event Server cannot have recorded itself; an ES-answered refusal is already in its + /// ring, and reporting it again would double the count the admin sees. + /// + private static void ReportTunnelOpenFailure(IProxyBlockReporter blocks, ProxyForwardedRequest request, ProxyTunnelOpenOutcome outcome) + { + var reason = MapTunnelOpenBlockReason(outcome); + if (reason != null) + { + blocks?.Report(request.Uri, reason); + } + } + + /// + /// The request loop inside one terminated tunnel (#56): Chromium pipelines several inner + /// requests over a single TLS connection, each origin-form against the tunnel origin that + /// the allowlist already admitted when the tunnel was accepted. After the CONNECT 200 the + /// browser only reads TLS records, so every inner answer goes back through the tunnel + /// stream - writing plaintext to the raw socket makes the browser fail the tunnel with a + /// decryption error (live repro, #56). + /// + private static async Task ServeTunnelRequestsAsync( + Stream tunnel, + System.Collections.Generic.IEnumerable canonicalTargets, + Uri tunnelOrigin, + Func> fetch, + Action log, + Func> openTunnel, + IProxyBlockReporter blocks, + System.Collections.Generic.IEnumerable vmsFloorHosts) + { + var pending = new byte[0]; + while (true) + { + var head = await ReadRequestHeadAsync(tunnel, pending); + if (head == null) + { + return; + } + + var disposition = ProxyForwardPolicy.Decide(canonicalTargets, head.Text, tunnelOrigin, vmsFloorHosts, out var request, out var rejectReason); + switch (disposition) + { + case ProxyForwardDisposition.Forward: + break; + + case ProxyForwardDisposition.BadRequest: + // A head this broken leaves the stream position untrustworthy: answer and close. + log(rejectReason); + await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse( + 400, "Bad Request", ProxyErrorPage.CategoryMalformedRequest, + ProxyErrorPage.Details.ForRequest(request, rejectReason, ProxyErrorPage.DecidedByLocalListener))); + return; + + case ProxyForwardDisposition.MethodNotAllowed: + log(rejectReason); + await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse( + 405, "Method Not Allowed", ProxyErrorPage.CategoryMethodNotAllowed, + ProxyErrorPage.Details.ForRequest(request, rejectReason, ProxyErrorPage.DecidedByLocalListener))); + pending = head.Leftover; + continue; + + case ProxyForwardDisposition.WebSocketTunnel: + // #60: a wss:// upgrade inside this terminated tunnel gets its own byte + // tunnel: the Event Server dials a validated TLS leg to the tunnel's + // origin, replays the rebuilt handshake over it, and only a 101 admits + // the tunnel. From there both sides pump decrypted websocket frames. + // Every open failure is an explicit 502 page ON the tunnel; the tunnel + // stays usable, the upgrade is never fetched and never taken direct. + string upgradeFailure = null; + string upgradeCategory = ProxyErrorPage.CategoryTunnelFailed; + string upgradeDecidedBy = ProxyErrorPage.DecidedByLocalListener; + ProxyTunnelOpenOutcome upgradeOutcome = null; + if (openTunnel == null) + { + upgradeFailure = $"The websocket upgrade to '{request.RawTarget}' cannot be tunnelled by this listener; ws(s):// targets ride CONNECT-style byte tunnels the VMS server opens."; + log(upgradeFailure); + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + } + else + { + try + { + upgradeOutcome = await openTunnel(request); + } + catch (Exception exception) + { + // Same explicit page as the top-level ws posture (#57): the open + // exception is named on the page, not swallowed into the generic + // no-outcome wording. + log($"Proxy in-tunnel websocket open for '{request.RawTarget}' threw: {exception.Message}"); + upgradeFailure = $"The VMS proxy tunnel failed to open: {exception.Message}"; + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + } + + if (upgradeFailure == null && (upgradeOutcome == null || !upgradeOutcome.Success)) + { + // An open the Event Server refuses (an old ES still answering + // https-scheme targets NotAllowed, an unreachable target, a + // non-101) keeps its own error text on the page. + upgradeFailure = upgradeOutcome == null + ? "The Event Server tunnel opener returned no outcome." + : upgradeOutcome.ErrorMessage; + upgradeDecidedBy = ProxyErrorPage.DecidedByVmsServer; + upgradeCategory = CategoryForFailure(upgradeOutcome == null ? null : upgradeOutcome.ErrorCode, ProxyErrorPage.CategoryTunnelFailed); + log($"Proxy in-tunnel websocket tunnel to '{request.RawTarget}' refused (code={upgradeOutcome?.ErrorCode}): {upgradeFailure}"); + ReportTunnelOpenFailure(blocks, request, upgradeOutcome); + } + } + + if (upgradeFailure != null) + { + await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", upgradeCategory, + ProxyErrorPage.Details.ForRequest(request, upgradeFailure, upgradeDecidedBy))); + pending = head.Leftover; + continue; + } + + log($"Proxy in-tunnel websocket tunnel to '{request.RawTarget}' established; pumping bytes in both directions."); + await ServeWebSocketTunnelAsync(head.Leftover, tunnel, tunnel, upgradeOutcome, log); + return; + + default: + // OriginNotAllowed inside a tunnel means the inner target drifted off the + // accepted origin (codec already blocks that) - refuse and keep the tunnel. + log(rejectReason ?? $"In-tunnel request '{request?.RawTarget}' refused."); + blocks?.Report(request?.Uri, ProxyBlockReasons.NotAllowed); + await WriteAsync(tunnel, ProxyForwardCodec.RenderErrorResponse( + 403, "Forbidden", ProxyErrorPage.CategoryNotAllowed, + ProxyErrorPage.Details.ForRequest(request, rejectReason ?? "The in-tunnel request was refused.", ProxyErrorPage.DecidedByLocalListener).WithSuggestedAllowlistEntry())); + pending = head.Leftover; + continue; + } + + var remainder = await ServeForwardedRequestAsync(request, head.Leftover, tunnel, tunnel, fetch, log, blocks); + if (remainder == null) + { + return; + } + + pending = remainder; + } + } + + /// + /// The #57 tunnel flow after a successful open: the target's raw 101 answer goes to the + /// browser socket verbatim (the browser validates Sec-WebSocket-Accept against the key + /// the tunnel relayed untouched), then the connection becomes a pure byte pump in both + /// directions — RFC 6455 framing is opaque here, exactly like CONNECT carrying opaque TLS. + /// The traffic never enters the HTTP forward/chunking path, so nothing is re-encoded. + /// + private static async Task ServeWebSocketTunnelAsync( + byte[] leftover, + Stream input, + Stream output, + ProxyTunnelOpenOutcome outcome, + Action log) + { + await WriteAsync(output, outcome.HandshakeBytes ?? new byte[0]); + + var browserToTarget = PumpBrowserToTunnelAsync(leftover, input, outcome.Handle, log); + var targetToBrowser = PumpTunnelToBrowserAsync(outcome.Handle, output, log); + + // Whichever side ends first ends the connection: browser EOF or socket fault, or the + // target/messaging side closing. The other pump unwinds on its own - the socket + // disposal in the caller's using-block breaks its pending read, or the closed handle + // answers the next wait - and its task is intentionally not awaited here so a silent + // half-open browser socket cannot park this flow forever. + await Task.WhenAny(browserToTarget, targetToBrowser); + outcome.Handle.Close("the proxied websocket connection ended"); + } + + private static async Task PumpBrowserToTunnelAsync(byte[] leftover, Stream input, ProxyTunnelHandle handle, Action log) + { + try + { + if (leftover != null && leftover.Length > 0) + { + // The upgrade head's pipelined bytes are the browser's first websocket frame(s). + await handle.SendAsync(leftover); + } + + var buffer = new byte[ProxyTunnelLimits.DefaultFrameMaxBytes]; + while (!handle.IsClosed) + { + var read = await input.ReadAsync(buffer, 0, buffer.Length); + if (read <= 0) + { + return; + } + + var bytes = new byte[read]; + Buffer.BlockCopy(buffer, 0, bytes, 0, read); + await handle.SendAsync(bytes); + } + } + catch (Exception exception) + { + // Socket disposal at teardown is the normal ending of the losing direction. + log($"Proxy websocket tunnel browser-to-target pump ended: {exception.Message}"); + } + } + + private static async Task PumpTunnelToBrowserAsync(ProxyTunnelHandle handle, Stream output, Action log) + { + try + { + while (true) + { + var bytes = await handle.NextDataAsync(); + if (bytes == null) + { + return; + } + + await output.WriteAsync(bytes, 0, bytes.Length); + await output.FlushAsync(); + } + } + catch (Exception exception) + { + log($"Proxy websocket tunnel target-to-browser pump ended: {exception.Message}"); + } + } + + /// + /// Body-resolve, fetch, and render one decided-Forward request. Returns the pipelined + /// remainder bytes past this request's body for the caller's next request (#56 tunnels), + /// or null when the connection must close. + /// + private static async Task ServeForwardedRequestAsync( + ProxyForwardedRequest request, + byte[] leftover, + Stream input, + Stream output, + Func> fetch, + Action log, + IProxyBlockReporter blocks) + { + // #55: the request body must reach the fetcher for login POSTs and XHR writes to work. + var bodyOutcome = await TryResolveRequestBodyAsync(request, leftover, input, log); + if (bodyOutcome.RefusalPage != null) + { + if (bodyOutcome.BlockReason != null) + { + // The request never left this machine, so this is one of the blocks only the + // client can report (#69); the 411/400 body noise carries no reason and stays + // out of the admin's blocked list. + blocks?.Report(request.Uri, bodyOutcome.BlockReason); + } + + await WriteAsync(output, bodyOutcome.RefusalPage); + return null; + } + + var query = new ProxyFetchQuery + { + Uri = request.Uri, + Method = request.Method, + Headers = ProxyForwardCodec.ToOutboundRequestHeaders(request), + Body = bodyOutcome.Body, + }; + + ProxyFetchResult result; + try + { + result = await fetch(query); + } + catch (Exception exception) + { + log($"Proxy fetch for {request.Uri} threw: {exception.Message}"); + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, "Bad Gateway", ProxyErrorPage.CategoryTunnelFailed, + ProxyErrorPage.Details.ForRequest(request, $"The VMS proxy fetcher failed: {exception.Message}", ProxyErrorPage.DecidedByLocalListener))); + return null; + } + + if (result != null && result.Success) + { + await WriteAsync(output, ProxyForwardCodec.RenderForwardedResponse(result)); + return bodyOutcome.Remainder; + } + + // #58: every failure kind renders its own explicit page in the view, and every log line + // names WHICH side failed - the Smart Client's channel to the VMS, the VMS server + // itself, or the target behind it. A proxied workspace never falls back to a direct + // connection, so an unexplained page would be a dead end for the admin. + // #66: the page itself is the friendly cause-category page; the failure's verbatim + // text rides in the collapsed technical section. + var message = result == null ? "The VMS proxy fetcher returned no outcome." : result.ErrorMessage; + if (result == null) + { + // The seam itself produced nothing - the request never reached the Event Server, so + // no server-side record exists either (#69). + blocks?.Report(request.Uri, ProxyBlockReasons.TunnelRefused); + } + + switch (result == null ? null : result.ErrorCode) + { + case ProxyFetchErrorCodes.ServerUnavailable: + log($"Proxy fetch for {request.Uri} failed on the Smart Client side before any VMS fetcher answered (code={ProxyFetchErrorCodes.ServerUnavailable}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 503, + "Service Unavailable", + ProxyErrorPage.CategoryComponentUnavailable, + ProxyErrorPage.Details.ForRequest(request, message, ProxyErrorPage.DecidedByLocalAboutVms))); + return bodyOutcome.Remainder; + + case ProxyFetchErrorCodes.NotAllowed: + // Server-side allowlist gate disagreeing with the local one (config drift + // between the two machines): same refusal posture as the local 403. + log($"Proxy fetch for {request.Uri} refused by the VMS server (code={ProxyFetchErrorCodes.NotAllowed}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 403, + "Forbidden", + ProxyErrorPage.CategoryNotAllowed, + new ProxyErrorPage.Details + { + Method = request.Method, + RequestUri = request.Uri, + RawTarget = request.RawTarget, + TechnicalMessage = message, + DecidedBy = ProxyErrorPage.DecidedByVmsServer, + Note = "The VMS server also refuses this target, although the local workspace decision allowed it (the two machines' proxy allowlists differ).", + SuggestedAllowlistEntry = ProxyPolicy.CanonicalOrigin(request.Uri), + })); + return bodyOutcome.Remainder; + + case ProxyFetchErrorCodes.Timeout: + // A Failure FRAME carrying Timeout means the messaging round trip worked and the + // VMS server gave up on the TARGET - distinct from ServerUnavailable silence above. + log($"Proxy fetch for {request.Uri} timed out on the VMS server side (code={ProxyFetchErrorCodes.Timeout}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 504, + "Gateway Timeout", + ProxyErrorPage.CategoryTargetTimeout, + ProxyErrorPage.Details.ForRequest(request, message, ProxyErrorPage.DecidedByVmsServer))); + return bodyOutcome.Remainder; + + case ProxyFetchErrorCodes.Unreachable: + log($"Proxy fetch for {request.Uri} failed on the VMS server side (code={ProxyFetchErrorCodes.Unreachable}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, + "Bad Gateway", + ProxyErrorPage.CategoryTargetUnreachable, + ProxyErrorPage.Details.ForRequest(request, message, ProxyErrorPage.DecidedByVmsServer))); + return bodyOutcome.Remainder; + + case ProxyFetchErrorCodes.TooLarge: + log($"Proxy fetch for {request.Uri} failed on the VMS server side (code={ProxyFetchErrorCodes.TooLarge}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, + "Bad Gateway", + ProxyErrorPage.CategoryResponseTooLarge, + ProxyErrorPage.Details.ForRequest(request, message, ProxyErrorPage.DecidedByVmsServer))); + return bodyOutcome.Remainder; + + case ProxyFetchErrorCodes.TlsValidation: + log($"Proxy fetch for {request.Uri} failed on the VMS server side (code={ProxyFetchErrorCodes.TlsValidation}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, + "Bad Gateway", + ProxyErrorPage.CategoryCertificateUntrusted, + ProxyErrorPage.Details.ForRequest(request, message, ProxyErrorPage.DecidedByVmsServer))); + return bodyOutcome.Remainder; + + case ProxyFetchErrorCodes.NoAuthority: + log($"Proxy fetch for {request.Uri} failed on the VMS server side (code={ProxyFetchErrorCodes.NoAuthority}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, + "Bad Gateway", + ProxyErrorPage.CategoryAuthorityUnavailable, + ProxyErrorPage.Details.ForRequest(request, message, ProxyErrorPage.DecidedByVmsServer))); + return bodyOutcome.Remainder; + + default: + log($"Proxy fetch for {request.Uri} failed (code={result?.ErrorCode}): {message}"); + await WriteAsync(output, ProxyForwardCodec.RenderErrorResponse( + 502, + "Bad Gateway", + result == null + ? ProxyErrorPage.CategoryTunnelFailed + : CategoryForFailure(result.ErrorCode, ProxyErrorPage.CategoryUnknownFailure), + ProxyErrorPage.Details.ForRequest( + request, + message, + result == null ? ProxyErrorPage.DecidedByLocalListener : ProxyErrorPage.DecidedByVmsServer))); + return bodyOutcome.Remainder; + } + } + + /// + /// Decide and read the request body (#55). A chunked body carries no declared size to + /// bound, so it is refused (411); a declared length past the cap is refused before any + /// body bytes are taken (413); a body that contradicts its declaration is refused (400). + /// A refusal comes back as an already-rendered page. + /// + private static async Task TryResolveRequestBodyAsync( + ProxyForwardedRequest request, + byte[] leftover, + Stream input, + Action log) + { + string transferEncoding; + request.Headers.TryGetValue("Transfer-Encoding", out transferEncoding); + if (!string.IsNullOrEmpty(transferEncoding) && + transferEncoding.IndexOf("chunked", StringComparison.OrdinalIgnoreCase) >= 0) + { + var reason = $"The proxy requires a Content-Length on request bodies; the chunked body for '{request.RawTarget}' is refused."; + log(reason); + return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse( + 411, "Length Required", ProxyErrorPage.CategoryMalformedRequest, + ProxyErrorPage.Details.ForRequest(request, reason, ProxyErrorPage.DecidedByLocalListener))); + } + + string contentLengthText; + request.Headers.TryGetValue("Content-Length", out contentLengthText); + if (string.IsNullOrEmpty(contentLengthText)) + { + // No body to resolve; any bytes already in hand belong to a pipelined next request. + return RequestBodyOutcome.Accept(null, leftover); + } + + int declared; + if (!int.TryParse(contentLengthText.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out declared) || declared < 0) + { + var reason = $"The Content-Length '{contentLengthText}' for '{request.RawTarget}' is not a usable size."; + log(reason); + return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse( + 400, "Bad Request", ProxyErrorPage.CategoryMalformedRequest, + ProxyErrorPage.Details.ForRequest(request, reason, ProxyErrorPage.DecidedByLocalListener))); + } + + if (declared > MaxRequestBodyBytes) + { + var reason = $"The request body for '{request.RawTarget}' declares {declared} bytes, past the proxy's {MaxRequestBodyBytes} byte request cap."; + log(reason); + return RequestBodyOutcome.Refuse( + ProxyForwardCodec.RenderErrorResponse( + 413, "Payload Too Large", ProxyErrorPage.CategoryRequestTooLarge, + new ProxyErrorPage.Details + { + Method = request.Method, + RequestUri = request.Uri, + RawTarget = request.RawTarget, + TechnicalMessage = reason, + DecidedBy = ProxyErrorPage.DecidedByLocalListener, + ByteCap = MaxRequestBodyBytes, + }), + ProxyBlockReasons.TooLarge); + } + + if (declared == 0) + { + return RequestBodyOutcome.Accept(null, leftover); + } + + var buffer = new byte[declared]; + var fromLeftover = Math.Min(leftover.Length, declared); + var remainder = leftover.Length > declared + ? new byte[leftover.Length - declared] + : new byte[0]; + if (fromLeftover > 0) + { + Buffer.BlockCopy(leftover, 0, buffer, 0, fromLeftover); + } + + if (remainder.Length > 0) + { + // The client pipelined its next request into the same segment (#56 tunnels): + // take exactly the declared body, hand the rest to the next request's head read. + Buffer.BlockCopy(leftover, declared, remainder, 0, remainder.Length); + } + + var taken = fromLeftover; + while (taken < declared) + { + var read = await input.ReadAsync(buffer, taken, declared - taken); + if (read <= 0) + { + break; + } + + taken += read; + } + + if (taken < declared) + { + var reason = $"The request body for '{request.RawTarget}' ended after {taken} bytes, short of its declared {declared}."; + log(reason); + return RequestBodyOutcome.Refuse(ProxyForwardCodec.RenderErrorResponse( + 400, "Bad Request", ProxyErrorPage.CategoryMalformedRequest, + ProxyErrorPage.Details.ForRequest(request, reason, ProxyErrorPage.DecidedByLocalListener))); + } + + return RequestBodyOutcome.Accept(buffer, remainder); + } + + /// + /// Outcome of request-body resolution: either the bytes plus any pipelined remainder + /// (#56), or a rendered refusal page. + /// + private sealed class RequestBodyOutcome + { + internal readonly byte[] Body; + internal readonly byte[] Remainder; + internal readonly byte[] RefusalPage; + + /// Set only when the refusal is a per-origin block worth reporting (#69); + /// protocol noise (411/400) leaves it null. + internal readonly string BlockReason; + + private RequestBodyOutcome(byte[] body, byte[] remainder, byte[] refusalPage, string blockReason) + { + Body = body; + Remainder = remainder ?? new byte[0]; + RefusalPage = refusalPage; + BlockReason = blockReason; + } + + internal static RequestBodyOutcome Accept(byte[] body, byte[] remainder = null) + { + return new RequestBodyOutcome(body, remainder, null, null); + } + + internal static RequestBodyOutcome Refuse(byte[] page, string blockReason = null) + { + return new RequestBodyOutcome(null, null, page, blockReason); + } + } + + /// + /// The parsed request head plus any body bytes the client pipelined into the same read. + /// + private sealed class RequestHead + { + internal string Text; + internal byte[] Leftover = new byte[0]; + } + + private static Task ReadRequestHeadAsync(Stream input) + { + return ReadRequestHeadAsync(input, null); + } + + /// + /// Read one request head, optionally seeded with bytes the previous request's reader + /// already pulled past its own boundary (#56: the next pipelined head of a tunnel, or a + /// ClientHello before TLS termination). + /// + private static async Task ReadRequestHeadAsync(Stream input, byte[] seed) + { + var head = new RequestHead(); + var buffer = new byte[8 * 1024]; + var received = new MemoryStream(); + if (seed != null && seed.Length > 0) + { + received.Write(seed, 0, seed.Length); + } + + while (!ContainsHeadTerminator(received.GetBuffer(), 0, (int)received.Length)) + { + var read = await input.ReadAsync(buffer, 0, buffer.Length); + if (read <= 0) + { + break; + } + + received.Write(buffer, 0, read); + if (received.Length > MaxHeadBytes) + { + return null; + } + } + + if (received.Length == 0) + { + return null; + } + + // Head bytes are cut at the header terminator; whatever the client pipelined after it + // (a POST body that arrived in the same segment) becomes leftover for the body reader + // (#55). Latin-1 keeps byte values faithful per RFC 7230. + var bytes = received.ToArray(); + var terminator = IndexOfHeadTerminator(bytes, 0, bytes.Length); + var textLength = terminator >= 0 ? terminator : bytes.Length; + var leftoverOffset = terminator >= 0 ? terminator + TerminatorLength(bytes, terminator) : bytes.Length; + if (leftoverOffset < bytes.Length) + { + head.Leftover = new byte[bytes.Length - leftoverOffset]; + Buffer.BlockCopy(bytes, leftoverOffset, head.Leftover, 0, head.Leftover.Length); + } + + head.Text = Encoding.GetEncoding("ISO-8859-1").GetString(bytes, 0, textLength); + return head; + } + + private static bool ContainsHeadTerminator(byte[] bytes, int offset, int length) + { + return IndexOfHeadTerminator(bytes, offset, length) >= 0; + } + + private static int IndexOfHeadTerminator(byte[] bytes, int offset, int length) + { + // CRLFCRLF, tolerating bare-LF clients. Rescanned from 0 each round: the leftover + // split for the request-body reader (#55) needs the terminator's absolute position, + // and a partially-arrived terminator in one read is only re-found once its tail lands. + for (var i = offset; i + 1 < length; i++) + { + if (bytes[i] == '\n' && bytes[i + 1] == '\n') + { + return i; + } + + if (i + 3 < length && + bytes[i] == '\r' && bytes[i + 1] == '\n' && bytes[i + 2] == '\r' && bytes[i + 3] == '\n') + { + return i; + } + } + + return -1; + } + + private static int TerminatorLength(byte[] bytes, int terminator) + { + return bytes[terminator] == '\r' ? 4 : 2; + } + + private static async Task WriteAsync(Stream output, byte[] response) + { + await output.WriteAsync(response, 0, response.Length); + await output.FlushAsync(); + } + } +} diff --git a/src/WebContent/ProxyForwardPolicy.cs b/src/WebContent/ProxyForwardPolicy.cs new file mode 100644 index 0000000..6c2ddb0 --- /dev/null +++ b/src/WebContent/ProxyForwardPolicy.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// What the loopback forwarding proxy does with one parsed browser request (#54). + internal enum ProxyForwardDisposition + { + /// Forward the request to the Event Server fetcher and relay the answer. + Forward, + + /// Malformed proxy request — 400. + BadRequest, + + /// Origin not in the workspace allowlist (or address-bar navigation to it) — 403. + OriginNotAllowed, + + /// Verb outside the browser-flow method set (#55) — 405. + MethodNotAllowed, + + /// + /// CONNECT to an allowlisted https origin (#56): the loopback listener accepts the tunnel + /// and terminates TLS with an ephemeral local certificate (ADR-0002). + /// + ConnectTerminate, + + /// CONNECT that cannot be terminated here — 502, never taken direct. + ConnectRefused, + + /// + /// A websocket upgrade to an allowlisted origin (#57 plain ws://, #60 wss:// inside a + /// terminated TLS tunnel or absolute wss:// against an https entry): carried as a + /// CONNECT-style byte tunnel to the Event Server, never through the HTTP request/response + /// forward path. An https-scheme tunnel target gets a validated TLS leg at the target. + /// + WebSocketTunnel, + + /// + /// CONNECT to an origin whose HTTP twin is allowlisted (#57): live Chromium proxies plain + /// ws:// as CONNECT + a plaintext websocket handshake inside the tunnel (live repro - + /// not the absolute GET ws:// form RFC 6455 describes). The listener answers the CONNECT, + /// then admits the tunnel ONLY when its first bytes are a valid ws:// upgrade head; the + /// tunnel is closed before any Event Server socket opens for anything else. + /// + ConnectWebSocketTunnel, + } + + /// + /// Pure decision layer for the loopback forwarding proxy (#54, write verbs since #55): every + /// browser request that reaches the listener — page document, subresources, login-form POSTs, + /// background XHR, address-bar navigation alike — goes through the same table. Allowlisted + /// requests in the browser-flow method set forward (via ProxyPolicy from the config ticket + /// #52); everything else is refused with an explicit status, never sent direct. + /// + internal static class ProxyForwardPolicy + { + /// + /// Verbs the proxy relays (#55): the set a browser page actually generates — document GET, + /// vendor login POSTs, XHR PUT/DELETE/PATCH, CORS preflight OPTIONS. CONNECT is refused as + /// a tunnel, not treated as a verb; TRACE and other verbs are refused (405), never relayed. + /// + private static readonly HashSet ForwardedMethods = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", + }; + + /// + /// True for the verbs the whole proxy chain relays. The Event Server fetcher re-checks + /// decoded request payloads against this same set, so client and server agree on one table. + /// + internal static bool IsForwardedMethod(string method) + { + return !string.IsNullOrWhiteSpace(method) && ForwardedMethods.Contains(method); + } + + internal static ProxyForwardDisposition Decide( + IEnumerable canonicalTargets, + string requestHead, + out ProxyForwardedRequest request, + out string rejectReason) + { + return Decide(canonicalTargets, requestHead, null, null, out request, out rejectReason); + } + + /// + /// Decide a request head, optionally one read from inside a TLS tunnel the listener itself + /// terminated (#56). A tunnel origin makes origin-form targets legal — resolved against + /// the tunnel — and outlaws a nested CONNECT. + /// + internal static ProxyForwardDisposition Decide( + IEnumerable canonicalTargets, + string requestHead, + Uri tunnelOrigin, + out ProxyForwardedRequest request, + out string rejectReason) + { + return Decide(canonicalTargets, requestHead, tunnelOrigin, null, out request, out rejectReason); + } + + /// + /// #73: the full decision form - the workspace's canonical allowlist entries (exact + /// origins or wildcard patterns) plus the VMS host names the wildcard SSRF floor is + /// applied against. Every origin comparison below is the one shared + /// matcher; this gate keeps no comparison + /// logic of its own. + /// + internal static ProxyForwardDisposition Decide( + IEnumerable canonicalTargets, + string requestHead, + Uri tunnelOrigin, + IEnumerable vmsFloorHosts, + out ProxyForwardedRequest request, + out string rejectReason) + { + if (!ProxyForwardCodec.TryParseRequestHead(requestHead, tunnelOrigin, out request, out rejectReason)) + { + return ProxyForwardDisposition.BadRequest; + } + + if (request.Method == "CONNECT") + { + if (tunnelOrigin != null) + { + rejectReason = "A CONNECT inside an already-terminated TLS tunnel is refused; the tunnel's origin is the only endpoint."; + return ProxyForwardDisposition.MethodNotAllowed; + } + + // #56: an allowlisted https origin gets the tunnel TERMINATED at this listener + // (ephemeral local certificate, ADR-0002); anything else is refused with the same + // origin posture as a plain request, and the browser is never let go direct. + if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, request.Uri, vmsFloorHosts)) + { + // #57 (live repro): plain ws:// reaches the browser proxy as CONNECT too, so + // an origin allowlisted as its http twin gets the tunnel accepted - but only + // for a plaintext ws handshake, which the connection flow validates before + // any target socket is opened. The allowlist entry's scheme IS the posture: + // https entries terminate as MITM TLS, http entries may only ride a verified + // ws:// byte tunnel. + var httpTwin = new UriBuilder(request.Uri) { Scheme = Uri.UriSchemeHttp }.Uri; + if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, httpTwin, vmsFloorHosts)) + { + rejectReason = $"The CONNECT target '{ProxyPolicy.CanonicalOrigin(request.Uri)}' is not an allowlisted https proxy origin (nor an http twin for a ws:// CONNECT tunnel); the tunnel is refused and the browser is never taken direct."; + return ProxyForwardDisposition.OriginNotAllowed; + } + + return ProxyForwardDisposition.ConnectWebSocketTunnel; + } + + return ProxyForwardDisposition.ConnectTerminate; + } + + if (!ForwardedMethods.Contains(request.Method)) + { + rejectReason = $"The proxy relays browser-flow verbs (GET/HEAD/POST/PUT/DELETE/OPTIONS/PATCH); '{request.Method}' to '{request.RawTarget}' is refused."; + return ProxyForwardDisposition.MethodNotAllowed; + } + + // #57/#60: a websocket upgrade is not an HTTP forward. A plain ws:// upgrade + // (http-scheme origin) to an allowlisted target tunnels as raw bytes over a plain + // socket; a wss:// upgrade — absolute form or inside a TLS tunnel this listener + // terminated (#56) — tunnels too, with the Event Server dialing a validated TLS leg + // to the target. Either way the bytes never ride the chunked fetch path, never direct. + if (ProxyWebSocketHandshake.IsWebSocketUpgrade(request)) + { + if (!ProxyWebSocketHandshake.HasValidClientHandshake(request, out rejectReason)) + { + return ProxyForwardDisposition.BadRequest; + } + + if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, request.Uri, vmsFloorHosts)) + { + rejectReason = $"The websocket origin '{ProxyPolicy.CanonicalOrigin(request.Uri)}' is not in the workspace proxy allowlist; the tunnel is refused and the browser is never taken direct."; + return ProxyForwardDisposition.OriginNotAllowed; + } + + return ProxyForwardDisposition.WebSocketTunnel; + } + + if (!ProxyPolicy.IsRequestAllowed(canonicalTargets, request.Uri, vmsFloorHosts)) + { + rejectReason = $"The request origin '{ProxyPolicy.CanonicalOrigin(request.Uri)}' is not in the workspace proxy allowlist."; + return ProxyForwardDisposition.OriginNotAllowed; + } + + return ProxyForwardDisposition.Forward; + } + } +} diff --git a/src/WebContent/ProxyHeaderPolicy.cs b/src/WebContent/ProxyHeaderPolicy.cs new file mode 100644 index 0000000..1c15d65 --- /dev/null +++ b/src/WebContent/ProxyHeaderPolicy.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// #86: why one configured header rule is refused. is the one + /// grammar authority; the Event Server audit lines and the Management Client's Problem column + /// are two renderings of the same code, so a row the MC would refuse and a row the ES would + /// skip are by construction the same rows. + /// + internal enum ProxyHeaderRuleError + { + None = 0, + NameEmpty = 1, + NameInvalid = 2, + Forbidden = 3, + ValueEmpty = 4, + ValueLineBreak = 5, + } + + /// + /// The per-workspace proxy header policy (#86): the canonical HTTP hop-by-hop/forbidden header + /// set plus the add/override/remove rule model applied to BOTH legs of the proxy fetch — + /// request rules rewrite what the Event Server sends upstream, response rules rewrite what it + /// returns to the plugin client. is the HARD guardrail: the + /// configuration can never add, override, or remove these — the Management Client refuses such + /// rows at save time and the runtime skips such rules (with a MIP log line) even if a stored + /// configuration somehow carries them. + /// + internal static class ProxyHeaderPolicy + { + /// + /// The single canonical forbidden set every proxy leg derives from (#86): the RFC 9110 + /// hop-by-hop headers plus the ones that must stay with the connection owner + /// (Host, Content-Length, Transfer-Encoding, TE, Upgrade). The Event Server proxy STRIPS + /// these on both legs unconditionally — no configuration can add, override or remove them — + /// because each is either connection-scoped (Connection, Keep-Alive, TE, Trailer, Upgrade, + /// Proxy-*) or owned by the HTTP stack itself (Content-Length, Transfer-Encoding, Host): + /// a configured Host/Content-Length/Transfer-Encoding would desynchronize the upstream + /// connection framing or route the request at the wrong origin. The relay additionally + /// strips the end-to-end Connection/Keep-Alive/Upgrade pair (ProxyForwardCodec's + /// relay/transport boundary), and the fetcher additionally sends nothing else (the + /// HttpClientHandler owns Cookie, User-Agent, Referer, Accept-Encoding, Authorization). + /// + internal static readonly HashSet ForbiddenHeaderNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "Proxy-Connection", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", + "Host", + "Content-Length", + }; + + /// + /// Whether a header name is forbidden to the header policy: the canonical set plus every + /// Proxy-* pseudo-header (RFC 9110 keeps the whole prefix hop-by-hop). + /// + internal static bool IsForbiddenHeader(string name) + { + if (string.IsNullOrEmpty(name)) + { + return false; + } + return ForbiddenHeaderNames.Contains(name) + || name.StartsWith("Proxy-", StringComparison.OrdinalIgnoreCase); + } + + /// + /// One configured header rule. = true means "never let this header + /// through" (removal); otherwise the rule adds or overrides the header with + /// — last rule wins for one name, in configuration order. + /// + internal sealed class HeaderRule + { + public string Name { get; set; } + public string Value { get; set; } + public bool Remove { get; set; } + } + + /// + /// Validates raw rule text into a : RFC 9110 field-name token + /// grammar for the name, no CR/LF anywhere (response-header injection), and the forbidden + /// guardrail. returns the machine-readable refusal code — the + /// Event Server audit lines and the Management Client's Problem column both render it + /// through , so save-time refusal and runtime refusal can + /// never disagree about what is wrong with a row. + /// + internal static ProxyHeaderRuleError ValidateRule(string name, string value, bool remove) + { + if (string.IsNullOrWhiteSpace(name)) + { + return ProxyHeaderRuleError.NameEmpty; + } + + name = name.Trim(); + if (!IsValidHeaderName(name)) + { + return ProxyHeaderRuleError.NameInvalid; + } + + if (IsForbiddenHeader(name)) + { + return ProxyHeaderRuleError.Forbidden; + } + + if (!remove && string.IsNullOrWhiteSpace(value)) + { + return ProxyHeaderRuleError.ValueEmpty; + } + + if (!remove && value != null && (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0)) + { + return ProxyHeaderRuleError.ValueLineBreak; + } + + return ProxyHeaderRuleError.None; + } + + internal static bool TryBuildRule(string name, string value, bool remove, out HeaderRule rule) + { + rule = null; + if (ValidateRule(name, value, remove) != ProxyHeaderRuleError.None) + { + return false; + } + + rule = new HeaderRule { Name = name.Trim(), Value = remove ? null : value, Remove = remove }; + return true; + } + + /// + /// The human sentence for a refusal code, naming the offending header. The invariant-culture + /// audit voice (MIP.log); the MC Problem column localizes the same codes from Strings. + /// + internal static string DescribeRuleError(ProxyHeaderRuleError error, string name) + { + switch (error) + { + case ProxyHeaderRuleError.NameEmpty: + return "header name is empty"; + case ProxyHeaderRuleError.NameInvalid: + return $"header '{name}' is not a valid RFC 9110 field name"; + case ProxyHeaderRuleError.Forbidden: + return $"header '{name}' is reserved for the proxy connection and cannot be configured"; + case ProxyHeaderRuleError.ValueEmpty: + return $"header '{name}' has no value"; + case ProxyHeaderRuleError.ValueLineBreak: + return $"header '{name}' value contains a line break"; + default: + return string.Empty; + } + } + + /// + /// RFC 9110 field-name token grammar (tchars only) — also the CR/LF injection guardrail. + /// + private static bool IsValidHeaderName(string name) + { + if (name.Length == 0) + { + return false; + } + foreach (var c in name) + { + if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && + !(c >= '0' && c <= '9') && + c != '!' && c != '#' && c != '$' && c != '%' && c != '&' && + c != '\'' && c != '*' && c != '+' && c != '-' && c != '.' && + c != '^' && c != '_' && c != '`' && c != '|' && c != '~') + { + return false; + } + } + return true; + } + + /// + /// Applies configured rules to one header collection (request or response leg) in + /// configuration order - last rule for a name wins: each rule first strips every existing + /// entry of its name, then (unless it is a removal) appends its own single entry; rules + /// whose name is forbidden are skipped (the store already audited them). Mutates + /// in place (one entry per configured name, the rule's own + /// casing); the returned list is the rules actually applied (names + action) for audit + /// lines. Removal of an absent name is a no-op and is not recorded. + /// + internal static List ApplyRules(List headers, IList rules) + { + var applied = new List(); + if (headers == null || rules == null) + { + return applied; + } + foreach (var rule in rules) + { + if (rule == null || IsForbiddenHeader(rule.Name)) + { + continue; + } + var removed = RemoveAll(headers, rule.Name); + if (rule.Remove) + { + if (removed) + { + applied.Add("-" + rule.Name); + } + + continue; + } + + headers.Add(new ProxyFetchHeader { Name = rule.Name, Value = rule.Value }); + applied.Add(rule.Name + "=" + rule.Value); + } + return applied; + } + + /// + /// Removes every entry whose name matches case-insensitively; true if anything went. + /// + private static bool RemoveAll(List headers, string name) + { + var removed = false; + for (var i = headers.Count - 1; i >= 0; i--) + { + var header = headers[i]; + if (header != null && string.Equals(header.Name, name, StringComparison.OrdinalIgnoreCase)) + { + headers.RemoveAt(i); + removed = true; + } + } + + return removed; + } + } +} diff --git a/src/WebContent/ProxyHeaderPolicyStore.cs b/src/WebContent/ProxyHeaderPolicyStore.cs new file mode 100644 index 0000000..1cda840 --- /dev/null +++ b/src/WebContent/ProxyHeaderPolicyStore.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// One workspace item as the header-policy store sees it: the item id fetch payloads are + /// attributed to, the display name the audit lines carry, and the raw property dictionary the + /// WorkspaceConfig blob lives in. Built by the Event Server plugin's composition root + /// from the MIP Configuration API; tests hand-build it. Same shape as + /// on purpose. + /// + internal sealed class ProxyWorkspaceHeaderSource + { + internal string ItemId; + internal string Name; + internal Dictionary Properties; + } + + /// + /// Ticket #86: the Event Server's per-workspace proxy header policies for BOTH legs of the + /// fetch. The snapshot policy mirrors : a row whose + /// name is forbidden or whose grammar fails is REFUSED (dropped from the snapshot) with one + /// audit line per refused row per refresh naming the workspace and the raw text — that is the + /// runtime guardrail the acceptance asks for: a hand-edited stored configuration can never + /// put Host: or Content-Length: on the wire, it only earns a log line. + /// + internal sealed class ProxyHeaderPolicyStore + { + private static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(60); + + private readonly Func> _snapshotResolver; + private readonly Action _log; + private readonly object _sync = new object(); + private Dictionary _snapshot; // null until the first successful resolve + private DateTime _nextRefreshUtc = DateTime.MinValue; + + internal ProxyHeaderPolicyStore(Func> snapshotResolver, Action log) + { + _snapshotResolver = snapshotResolver ?? throw new ArgumentNullException(nameof(snapshotResolver)); + _log = log ?? (_ => { }); + } + + /// + /// The effective header policy for the workspace a fetch is attributed to. Unknown item + /// and the never-resolved store both answer an empty policy (plain pass-through besides + /// the unconditional hop-by-hop stripping) — a configuration fault never changes what is + /// served beyond the no-policy posture. + /// + internal WorkspaceHeaderPolicy Resolve(string workspaceItemId) + { + EnsureCurrent(); + + lock (_sync) + { + WorkspaceHeaderPolicy policy; + if (_snapshot == null || string.IsNullOrEmpty(workspaceItemId) || + !_snapshot.TryGetValue(workspaceItemId, out policy)) + { + return WorkspaceHeaderPolicy.Empty; + } + + return policy; + } + } + + /// + /// Force one resolve at composition time (the Event Server plugin's Init): refused stored + /// rows are audited at startup rather than at an arbitrary first fetch, and the counts + /// show what the configuration actually loaded. + /// + internal void Prime() + { + lock (_sync) + { + _nextRefreshUtc = DateTime.MinValue; + } + + EnsureCurrent(); + + lock (_sync) + { + var configured = 0; + if (_snapshot != null) + { + foreach (var pair in _snapshot) + { + if (pair.Value.RequestRules.Count > 0 || pair.Value.ResponseRules.Count > 0) + { + configured++; + } + } + } + + _log($"Workspace header policies refreshed: {configured} workspace(s) with header rules."); + } + } + + private void EnsureCurrent() + { + var now = DateTime.UtcNow; + lock (_sync) + { + if (now < _nextRefreshUtc) + { + return; + } + + _nextRefreshUtc = now.Add(DefaultRefreshInterval); + } + + Dictionary resolved; + try + { + resolved = BuildSnapshot(_snapshotResolver()); + } + catch (Exception exception) + { + // Transient config-service failure: keep serving the last known policies. Never + // resolved at all just keeps every workspace at the no-policy posture. + _log($"Proxy header policy refresh failed: {exception.Message}"); + return; + } + + lock (_sync) + { + _snapshot = resolved; + } + } + + private Dictionary BuildSnapshot(IEnumerable workspaces) + { + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (workspaces == null) + { + return snapshot; + } + + foreach (var workspace in workspaces) + { + if (workspace == null || string.IsNullOrEmpty(workspace.ItemId)) + { + continue; + } + + // Tolerant read: no WorkspaceConfig blob, no WebContent item, or no header + // sections (every pre-#86 definition) is simply "no policy" for that leg. + var settings = WorkspaceConfiguration.GetFirstWebContentSettings(workspace.Properties); + if (settings == null) + { + continue; + } + + var name = string.IsNullOrWhiteSpace(workspace.Name) ? workspace.ItemId : workspace.Name; + var request = BuildRules(name, workspace.ItemId, "request", settings.ProxyRequestHeaders); + var response = BuildRules(name, workspace.ItemId, "response", settings.ProxyResponseHeaders); + if (request.Count == 0 && response.Count == 0) + { + continue; + } + + snapshot[workspace.ItemId] = new WorkspaceHeaderPolicy + { + RequestRules = request, + ResponseRules = response, + }; + } + + return snapshot; + } + + private List BuildRules(string workspaceName, string itemId, string leg, List rows) + { + var rules = new List(); + if (rows == null) + { + return rules; + } + + foreach (var row in rows) + { + if (row == null) + { + continue; + } + + var errorCode = ProxyHeaderPolicy.ValidateRule(row.Name, row.Value, row.Remove); + if (errorCode != ProxyHeaderRuleError.None) + { + // One audit line per refused row per refresh - the runtime guardrail is + // observable by name, mirroring how over-ceiling caps are audited. + var raw = (row.Remove ? "-" : string.Empty) + (row.Name ?? string.Empty) + + (row.Remove || string.IsNullOrEmpty(row.Value) ? string.Empty : "=" + row.Value); + _log($"Proxy {leg} header rule for workspace '{workspaceName}' ({itemId}) refused: {ProxyHeaderPolicy.DescribeRuleError(errorCode, row.Name)} (stored: '{raw}')"); + continue; + } + + ProxyHeaderPolicy.HeaderRule rule; + if (!ProxyHeaderPolicy.TryBuildRule(row.Name, row.Value, row.Remove, out rule)) + { + continue; + } + + rules.Add(rule); + } + + return rules; + } + } + + /// + /// The validated, ready-to-apply header policy for one workspace: request-leg rules and + /// response-leg rules, both in configuration order. + /// + internal sealed class WorkspaceHeaderPolicy + { + internal static readonly WorkspaceHeaderPolicy Empty = new WorkspaceHeaderPolicy + { + RequestRules = new List(), + ResponseRules = new List(), + }; + + internal List RequestRules { get; set; } + internal List ResponseRules { get; set; } + } +} diff --git a/src/WebContent/ProxyLeafCorrelation.cs b/src/WebContent/ProxyLeafCorrelation.cs new file mode 100644 index 0000000..4aa20c6 --- /dev/null +++ b/src/WebContent/ProxyLeafCorrelation.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// Requester-side protocol state for the leaf-mint exchange (#56, ADR-0003). Unlike the fetch + /// stream this exchange is exactly one response frame, so the correlator is a pending-task + /// table with deadlines: Feed completes the waiting task with the first frame that carries a + /// known correlation id, and an unanswered exchange completes as a Timeout failure rather + /// than staying pending forever. + /// + /// Thread-safe: Feed runs on the message receiver thread, Begin on the listener's thread. + /// + internal sealed class ProxyLeafCorrelation + { + private sealed class Pending + { + internal string CorrelationId; + internal CancellationTokenSource Deadline; + internal TaskCompletionSource Completion; + internal bool Completed; + } + + private readonly object _sync = new object(); + private readonly Dictionary _pending = new Dictionary(StringComparer.Ordinal); + + /// Register a pending leaf request; the task completes on the response frame or + /// the deadline. + internal Task Begin(string correlationId, TimeSpan timeout) + { + lock (_sync) + { + var pending = new Pending + { + CorrelationId = correlationId, + Deadline = new CancellationTokenSource(timeout), + Completion = new TaskCompletionSource(), + }; + + pending.Deadline.Token.Register(() => + Complete(pending, new ProxyLeafResponsePayload + { + CorrelationId = correlationId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.Timeout, + ErrorMessage = "No leaf-mint response within the response deadline.", + })); + + _pending[correlationId] = pending; + return pending.Completion.Task; + } + } + + /// Route one response frame; true when a pending exchange owned it. + internal bool Feed(ProxyLeafResponsePayload response) + { + if (response == null || string.IsNullOrEmpty(response.CorrelationId)) + { + return false; + } + + lock (_sync) + { + if (!_pending.TryGetValue(response.CorrelationId, out var pending) || pending.Completed) + { + return false; + } + + Complete(pending, response); + return true; + } + } + + /// Drop a pending request locally (the listener lost interest). + internal void Abandon(string correlationId) + { + lock (_sync) + { + if (_pending.TryGetValue(correlationId, out var pending)) + { + Complete(pending, new ProxyLeafResponsePayload + { + CorrelationId = correlationId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.Error, + ErrorMessage = "The leaf-mint request was abandoned locally.", + }); + } + } + } + + private void Complete(Pending pending, ProxyLeafResponsePayload response) + { + lock (_sync) + { + if (pending.Completed) + { + return; + } + + pending.Completed = true; + _pending.Remove(pending.CorrelationId); + } + + try + { + pending.Completion.TrySetResult(response); + } + finally + { + pending.Deadline.Cancel(); + pending.Deadline.Dispose(); + } + } + } +} diff --git a/src/WebContent/ProxyLeafIssuer.cs b/src/WebContent/ProxyLeafIssuer.cs new file mode 100644 index 0000000..bbfc79a --- /dev/null +++ b/src/WebContent/ProxyLeafIssuer.cs @@ -0,0 +1,142 @@ +using System; + +namespace WebContent +{ + /// + /// Ticket #56 (ADR-0003): the Event Server half of the loopback-leaf mint. A Smart Client + /// whose loopback listener is terminating an allowlisted https CONNECT asks the Event Server + /// to sign a leaf certificate for that origin; the request carries only the client-generated + /// SubjectPublicKeyInfo, so the leaf's private half never leaves the client machine. + /// + /// The workspace allowlist IS the minting policy: an origin is mintable exactly when the same + /// union snapshot that gates fetches allowlists it (fail-closed before its first successful + /// resolve, like the fetcher). The authority is the long-lived deployment CA held in the + /// Event Server machine's LocalMachine\Personal store; when it cannot be loaded or created + /// (store permissions, a service account that is not LocalSystem), the answer is the + /// NoAuthority error, never an exception on the messaging thread. + /// + internal sealed class ProxyLeafIssuer + { + private readonly ProxyServerAllowlist _allowlist; + private readonly Func _authorityProvider; + private readonly Action _log; + + /// The same union-gated allowlist that gates fetches. + /// Supplies the deployment authority; throws when it is + /// unavailable, which is answered as NoAuthority rather than propagating. + internal ProxyLeafIssuer(ProxyServerAllowlist allowlist, Func authorityProvider, Action log) + { + _allowlist = allowlist ?? throw new ArgumentNullException(nameof(allowlist)); + _authorityProvider = authorityProvider ?? throw new ArgumentNullException(nameof(authorityProvider)); + _log = log ?? delegate { }; + } + + /// + /// Validate and mint. Never throws: every rejection becomes a failure payload with a + /// machine-readable code (BadRequest / NotAllowed / NoAuthority / Error). + /// + internal ProxyLeafResponsePayload Issue(ProxyLeafRequestPayload request) + { + if (request == null || string.IsNullOrEmpty(request.CorrelationId)) + { + return Failure(null, ProxyFetchErrorCodes.BadRequest, "The leaf request carried no usable payload."); + } + + // Host/Port bounds: the CONNECT origin as the browser resolved it. Port must be a real + // TCP port; a blank host would mint a certificate for nothing. + if (string.IsNullOrWhiteSpace(request.Host) || request.Port <= 0 || request.Port > 65535) + { + return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest, + "The leaf request origin (host and port) is malformed."); + } + + byte[] spki; + if (string.IsNullOrEmpty(request.SpkiBase64)) + { + return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest, + "The leaf request carried no client public key."); + } + + try + { + spki = Convert.FromBase64String(request.SpkiBase64); + } + catch (FormatException) + { + return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest, + "The leaf request carried a public key that is not valid base64."); + } + + // The minting gate: exactly the origins some proxied workspace allowlists, matched on + // scheme+host+port like every fetch. The authority signs for the origin the allowlist + // blessed, so a crafted host can never acquire a trusted certificate for elsewhere. + Uri origin; + if (!Uri.TryCreate($"https://{request.Host}:{request.Port}/", UriKind.Absolute, out origin)) + { + return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest, + "The leaf request origin (host and port) is malformed."); + } + + string refuseReason; + if (!_allowlist.IsAllowed(origin, out refuseReason)) + { + _log($"Leaf mint refused for {request.Host}:{request.Port} - {refuseReason}"); + return Failure(request.CorrelationId, ProxyFetchErrorCodes.NotAllowed, refuseReason); + } + + Authority authority; + try + { + authority = _authorityProvider(); + } + catch (Exception exception) + { + _log($"Loopback proxy authority unavailable: {exception.Message}"); + return Failure(request.CorrelationId, ProxyFetchErrorCodes.NoAuthority, + "The Event Server cannot load or create the proxy certificate authority."); + } + + if (authority == null) + { + return Failure(request.CorrelationId, ProxyFetchErrorCodes.NoAuthority, + "The Event Server has no proxy certificate authority."); + } + + try + { + var leafDer = authority.MintLeafForPublicKey(request.Host, spki); + var authorityDer = authority.Certificate.RawData; + _log($"Minted loopback proxy leaf for {request.Host}:{request.Port}."); + return new ProxyLeafResponsePayload + { + CorrelationId = request.CorrelationId, + Success = true, + LeafDerBase64 = Convert.ToBase64String(leafDer), + AuthorityDerBase64 = Convert.ToBase64String(authorityDer), + AuthorityThumbprint = authority.Certificate.Thumbprint, + }; + } + catch (ArgumentException argumentException) + { + return Failure(request.CorrelationId, ProxyFetchErrorCodes.BadRequest, argumentException.Message); + } + catch (Exception exception) + { + _log($"Loopback proxy leaf mint failed for {request.Host}:{request.Port}: {exception.Message}"); + return Failure(request.CorrelationId, ProxyFetchErrorCodes.Error, + "The Event Server could not mint the loopback proxy leaf: " + exception.Message); + } + } + + private static ProxyLeafResponsePayload Failure(string correlationId, string errorCode, string message) + { + return new ProxyLeafResponsePayload + { + CorrelationId = correlationId, + Success = false, + ErrorCode = errorCode, + ErrorMessage = message, + }; + } + } +} diff --git a/src/WebContent/ProxyLoopbackAuthority.cs b/src/WebContent/ProxyLoopbackAuthority.cs new file mode 100644 index 0000000..d222a3e --- /dev/null +++ b/src/WebContent/ProxyLoopbackAuthority.cs @@ -0,0 +1,745 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WebContent +{ + /// + /// Certificate minting backend for . Two implementations behind + /// one internal shape, selected by target framework: + /// + /// * .NET Framework 4.7.2 (the plugin, where the browser actually runs): no System.Security + /// certificate creation API exists (no CertificateRequest), so the certificates are built as + /// raw DER and signed through a persisted CAPI key container; the key is attached to the + /// parsed X509Certificate2 through the PrivateKey property (Framework-only). This recipe was + /// proven by probe: the minted leaf completes a real SslStream server handshake, which the + /// ephemeral-key in-memory certificates fail ("the platform does not support ephemeral keys"). + /// + /// * modern .NET (where the test project compiles this file): CertificateRequest, with the same + /// PFX roundtrip the production Smart Client code uses so the private key is usable by + /// Schannel for server authentication. + /// + /// The CA deliberately carries NO extended key usage: an EKU on a certificate authority makes + /// Windows chain validation of its leaves fail with NotValidForUsage. + /// +#if NETFRAMEWORK + internal sealed class Authority : IDisposable + { + private const int KeyBits = 2048; + + // PROV_RSA_AES: the modern enhanced provider; SHA-256 signing needs it. + private const int EnhancedProviderType = 24; + private const string EnhancedProviderName = "Microsoft Enhanced RSA and AES Cryptographic Provider"; + + private readonly RSACryptoServiceProvider _authorityKey; + private readonly X509Certificate2 _authorityCertificate; + private readonly byte[] _authorityNameDer; + private readonly byte[] _authoritySpkiDer; + + internal static Authority Create(Action log) + { + return new Authority(); + } + + /// Fixed CAPI machine container name for the deployment CA (ADR-0003). + internal const string DeploymentCaContainer = "WebContent-Proxy-CA"; + + /// + /// ADR-0003: the long-lived deployment authority of this Event Server machine. Loads the + /// existing CA from LocalMachine\Personal (any valid copy whose key this process can use) + /// and otherwise creates it: a machine-key-store CAPI container under a fixed name, signed + /// self, stored with its private key. The live Event Server service runs as NETWORK SERVICE, + /// which cannot write the machine certificate store, so the store is opened read-write + /// first (creating when allowed) and otherwise read-only for the admin-provisioned CA; + /// a missing CA in that mode names the one-time elevated provisioning script. Access + /// failures propagate so the leaf issuer can answer NoAuthority and retry on the next request. + /// + internal static Authority LoadOrCreateDeploymentAuthority(Action log) + { + using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) + { + var canWrite = true; + try + { + store.Open(OpenFlags.ReadWrite); + } + catch (Exception) + { + // Restricted service accounts (NETWORK SERVICE) may read but never write the + // machine store; an administrator provisions the CA once with the README script. + store.Open(OpenFlags.ReadOnly); + canWrite = false; + } + + var now = DateTime.Now; + foreach (var existing in store.Certificates.Find( + X509FindType.FindBySubjectName, ProxyLoopbackTls.AuthorityCommonName, false)) + { + if (existing.NotAfter <= now) + { + log($"Deployment proxy CA '{existing.Subject}' has expired; a fresh authority will be minted and the machine trust roots must be updated to match."); + continue; + } + + if (!existing.HasPrivateKey) + { + continue; + } + + RSACryptoServiceProvider key; + try + { + key = (RSACryptoServiceProvider)existing.PrivateKey; + } + catch (Exception) + { + // A CNG-backed copy would return a different provider type; this minting + // path signs through CAPI, so skip and let provisioning replace it. + continue; + } + + log($"Loaded deployment proxy CA from LocalMachine\\Personal (thumbprint {existing.Thumbprint})."); + return new Authority(existing, key); + } + + if (!canWrite) + { + throw new InvalidOperationException( + "No deployment proxy CA exists in LocalMachine\\Personal and this service account cannot create one; run the elevated provisioning step from the plugin README (scripts/provision-event-server-authority.ps1) on the Event Server machine."); + } + + var parameters = new CspParameters + { + KeyContainerName = DeploymentCaContainer, + ProviderName = EnhancedProviderName, + ProviderType = EnhancedProviderType, + KeyNumber = (int)KeyNumber.Exchange, + Flags = CspProviderFlags.UseMachineKeyStore, + }; + var createdKey = new RSACryptoServiceProvider(KeyBits, parameters); + + // The deployment CA outlives the process: the container must survive disposal, and + // the store keeps the certificate+key together. + createdKey.PersistKeyInCsp = true; + try + { + var certificate = MintAuthorityCertificate(createdKey); + store.Add(certificate); + log($"Created deployment proxy CA in LocalMachine\\Personal (thumbprint {certificate.Thumbprint}); client machines must import this certificate into LocalMachine\\Root to trust proxied https targets."); + return new Authority(certificate, createdKey); + } + catch + { + try + { + createdKey.PersistKeyInCsp = false; + createdKey.Dispose(); + } + catch { } + + throw; + } + } + } + + private Authority(X509Certificate2 certificate, RSACryptoServiceProvider key) + { + _authorityCertificate = certificate; + _authorityKey = key; + _authorityNameDer = certificate.SubjectName.RawData; + _authoritySpkiDer = Der.SubjectPublicKeyInfo(key); + } + + /// Self-signed CA certificate DER signed by , key attached. + private static X509Certificate2 MintAuthorityCertificate(RSACryptoServiceProvider key) + { + var nameDer = Der.Name(ProxyLoopbackTls.AuthorityCommonName); + var spkiDer = Der.SubjectPublicKeyInfo(key); + + var notBefore = DateTime.UtcNow.AddDays(-1); + var notAfter = notBefore.AddYears(10); + + var tbs = Der.Sequence( + Der.Tagged(0xA0, Der.Integer(2)), // version v3 + Der.Integer(RandomPositiveBytes(8)), // serial + Der.Sha256RsaAlgorithmIdentifier(), + nameDer, // issuer = subject (self-signed) + Der.Validity(notBefore, notAfter), + nameDer, + spkiDer, + Der.Extensions( + Der.BasicConstraints(true), + Der.AuthorityKeyUsage(), + Der.SubjectKeyIdentifier(spkiDer))); // no EKU by design + + var certificate = new X509Certificate2(Sign(tbs, key)); + certificate.PrivateKey = key; + return certificate; + } + + private Authority() + { + _authorityKey = NewPersistedProvider("WCProxyCA-" + Guid.NewGuid().ToString("N").Substring(0, 12)); + _authorityCertificate = MintAuthorityCertificate(_authorityKey); + _authorityNameDer = Der.Name(ProxyLoopbackTls.AuthorityCommonName); + _authoritySpkiDer = Der.SubjectPublicKeyInfo(_authorityKey); + } + + internal X509Certificate2 Certificate => _authorityCertificate; + + internal ProxyLoopbackLeafOwner CreateLeaf(string host) + { + // Per-host persisted container: Schannel needs a provider-backed key for server + // authentication, and the container goes away when the owner is disposed. + var key = NewPersistedProvider( + "WCProxyLeaf-" + ShortHash(host) + "-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + try + { + var certificateDer = MintLeafForPublicKey(host, Der.SubjectPublicKeyInfo(key)); + var certificate = new X509Certificate2(certificateDer); + certificate.PrivateKey = key; + return new ProxyLoopbackLeafOwner(certificate, key); + } + catch + { + key.Dispose(); + throw; + } + } + + /// + /// ADR-0003: signs a leaf for this host around a caller-supplied SubjectPublicKeyInfo — + /// the Event Server mint path, where the private half lives on the requesting client. + /// Validation here is structural (the Framework has no SPKI parser); an unusable public + /// key surfaces as ArgumentException, which the issuer maps to a BadRequest refusal. + /// + internal byte[] MintLeafForPublicKey(string host, byte[] subjectPublicKeyInfoDer) + { + if (subjectPublicKeyInfoDer == null || + subjectPublicKeyInfoDer.Length < 50 || + subjectPublicKeyInfoDer.Length > 4096 || + subjectPublicKeyInfoDer[0] != 0x30) + { + throw new ArgumentException("The client public key is not a usable SubjectPublicKeyInfo.", nameof(subjectPublicKeyInfoDer)); + } + + var subjectDer = Der.Name(host); + var notBefore = DateTime.UtcNow.AddDays(-1); + var notAfter = notBefore.AddYears(2); + + var tbs = Der.Sequence( + Der.Tagged(0xA0, Der.Integer(2)), + Der.Integer(RandomPositiveBytes(8)), + Der.Sha256RsaAlgorithmIdentifier(), + _authorityNameDer, // issuer = the deployment CA + Der.Validity(notBefore, notAfter), + subjectDer, + subjectPublicKeyInfoDer, + Der.Extensions( + Der.BasicConstraints(false), + Der.LeafKeyUsage(), + Der.ServerAuthEku(), + Der.SubjectKeyIdentifier(subjectPublicKeyInfoDer), + Der.SubjectAlternativeName(host))); + + return Sign(tbs, _authorityKey); + } + + public void Dispose() + { + try { _authorityCertificate.Dispose(); } catch { } + try { _authorityKey.Dispose(); } catch { } + } + + private static byte[] Sign(byte[] tbs, RSACryptoServiceProvider key) + { + // SignHash(hash, oid) treats the input as the digest and frames the PKCS#1 DigestInfo + // itself - the resulting signature is exactly sha256RSA over tbs. + var signature = key.SignHash(DerSha256(tbs), CryptoConfig.MapNameToOID("SHA256")); + return Der.Sequence( + tbs, + Der.Sha256RsaAlgorithmIdentifier(), + Der.BitStringBytes(signature)); + } + + private static RSACryptoServiceProvider NewPersistedProvider(string containerName) + { + var parameters = new CspParameters + { + KeyContainerName = containerName, + ProviderName = EnhancedProviderName, + ProviderType = EnhancedProviderType, + KeyNumber = (int)KeyNumber.Exchange + }; + var provider = new RSACryptoServiceProvider(KeyBits, parameters); + + // Persisted in a named container while the certificate uses it (Schannel needs a + // provider-backed key), but the container is deleted when this object is disposed. + provider.PersistKeyInCsp = false; + return provider; + } + + private static byte[] RandomPositiveBytes(int count) + { + var bytes = new byte[count]; + using (var generator = RandomNumberGenerator.Create()) + { + generator.GetBytes(bytes); + } + + bytes[0] &= 0x7F; // keeps the INTEGER positive without a pad byte + return bytes; + } + + private static string ShortHash(string host) + { + using (var hash = SHA1.Create()) + { + var digest = hash.ComputeHash(Encoding.UTF8.GetBytes(host.ToLowerInvariant())); + var builder = new StringBuilder(10); + for (var i = 0; i < 5; i++) + { + builder.Append(digest[i].ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + } + + private static byte[] DerSha256(byte[] data) + { + using (var hash = SHA256.Create()) + { + return hash.ComputeHash(data); + } + } + } + + /// Hand-built DER for the Framework minting path (no CertificateRequest on 4.7.2). + internal static class Der + { + internal static byte[] Sequence(params byte[][] parts) => Tag(0x30, Concat(parts)); + internal static byte[] Set(params byte[][] parts) => Tag(0x31, Concat(parts)); + internal static byte[] Tagged(byte tag, params byte[][] parts) => Tag(tag, Concat(parts)); + + internal static byte[] Tag(byte tag, byte[] value) + { + var length = Length(value.Length); + var result = new byte[1 + length.Length + value.Length]; + result[0] = tag; + Buffer.BlockCopy(length, 0, result, 1, length.Length); + Buffer.BlockCopy(value, 0, result, 1 + length.Length, value.Length); + return result; + } + + internal static byte[] Integer(int value) + { + var bytes = new List(); + do + { + bytes.Insert(0, (byte)(value & 0xFF)); + value >>= 8; + } + while (value > 0); + + if ((bytes[0] & 0x80) != 0) + { + bytes.Insert(0, 0); + } + + return Tag(0x02, bytes.ToArray()); + } + + internal static byte[] Integer(byte[] magnitude) + { + var start = 0; + while (start < magnitude.Length - 1 && magnitude[start] == 0) + { + start++; + } + + var value = new byte[magnitude.Length - start]; + Buffer.BlockCopy(magnitude, start, value, 0, value.Length); + if ((value[0] & 0x80) != 0) + { + var padded = new byte[value.Length + 1]; + Buffer.BlockCopy(value, 0, padded, 1, value.Length); + value = padded; + } + + return Tag(0x02, value); + } + + internal static byte[] Boolean(bool value) => Tag(0x01, new[] { (byte)(value ? 0xFF : 0x00) }); + internal static byte[] Null() => new byte[] { 0x05, 0x00 }; + internal static byte[] OctetString(byte[] value) => Tag(0x04, value); + internal static byte[] BitStringBytes(byte[] data) => Tag(0x03, Concat(new[] { new byte[] { 0 }, data })); + internal static byte[] BitString(byte unusedBits, byte data) => Tag(0x03, new[] { unusedBits, data }); + + internal static byte[] ObjectIdentifier(string dotted) => Tag(0x06, ObjectIdentifierValue(dotted)); + + internal static byte[] UtcTime(DateTime utc) + { + var text = utc.ToString("yyMMddHHmmss", CultureInfo.InvariantCulture) + "Z"; + return Tag(0x17, Encoding.ASCII.GetBytes(text)); + } + + internal static byte[] Name(string commonName) => + Sequence(Set(Sequence(ObjectIdentifier("2.5.4.3"), Tag(0x0C, Encoding.UTF8.GetBytes(commonName))))); + + internal static byte[] Validity(DateTime notBefore, DateTime notAfter) => + Sequence(UtcTime(notBefore), UtcTime(notAfter)); + + internal static byte[] Sha256RsaAlgorithmIdentifier() => + Sequence(ObjectIdentifier("1.2.840.113549.1.1.11"), Null()); + + internal static byte[] SubjectPublicKeyInfo(RSA key) + { + var parameters = key.ExportParameters(false); + var rsaPublicKey = Sequence(Integer(parameters.Modulus), Integer(parameters.Exponent)); + return Sequence( + Sequence(ObjectIdentifier("1.2.840.113549.1.1.1"), Null()), + BitStringBytes(rsaPublicKey)); + } + + internal static byte[] Extensions(params byte[][] extensions) => Tagged(0xA3, Sequence(extensions)); + + internal static byte[] BasicConstraints(bool isCertificateAuthority) => + Extension("2.5.29.19", true, isCertificateAuthority ? Sequence(Boolean(true)) : Sequence()); + + // keyCertSign | cRLSign: DER bit string, bits 5 and 6, one pad bit. + internal static byte[] AuthorityKeyUsage() => Extension("2.5.29.15", true, BitString(1, 0x06)); + + // digitalSignature | keyEncipherment: DER bits 0 and 2, five pad bits. + internal static byte[] LeafKeyUsage() => Extension("2.5.29.15", false, BitString(5, 0xA0)); + + internal static byte[] ServerAuthEku() => + Extension("2.5.29.37", true, Sequence(ObjectIdentifier("1.3.6.1.5.5.7.3.1"))); + + internal static byte[] SubjectKeyIdentifier(byte[] subjectPublicKeyInfo) + { + using (var hash = SHA1.Create()) + { + return Extension("2.5.29.14", false, OctetString(hash.ComputeHash(subjectPublicKeyInfo))); + } + } + + internal static byte[] SubjectAlternativeName(string host) + { + IPAddress address; + byte[] entry = IPAddress.TryParse(host, out address) + ? Tag(0x87, address.GetAddressBytes()) + : Tag(0x82, Encoding.ASCII.GetBytes(host)); + return Extension("2.5.29.17", false, Sequence(entry)); + } + + private static byte[] Extension(string oid, bool critical, byte[] value) + { + var parts = critical + ? new[] { ObjectIdentifier(oid), Boolean(true), OctetString(value) } + : new[] { ObjectIdentifier(oid), OctetString(value) }; + return Sequence(parts); + } + + private static byte[] ObjectIdentifierValue(string dotted) + { + var parts = dotted.Split('.'); + var bytes = new List(); + bytes.Add((byte)(int.Parse(parts[0], CultureInfo.InvariantCulture) * 40 + + int.Parse(parts[1], CultureInfo.InvariantCulture))); + for (var i = 2; i < parts.Length; i++) + { + var value = int.Parse(parts[i], CultureInfo.InvariantCulture); + var group = new List(); + do + { + group.Insert(0, (byte)(value & 0x7F)); + value >>= 7; + } + while (value > 0); + + // Sub-OID continuation bit: set on every byte except the last. + for (var j = 0; j < group.Count - 1; j++) + { + group[j] |= 0x80; + } + + bytes.AddRange(group); + } + + return bytes.ToArray(); + } + + private static byte[] Length(int length) + { + if (length < 0x80) + { + return new[] { (byte)length }; + } + + var bytes = new List(); + do + { + bytes.Insert(0, (byte)(length & 0xFF)); + length >>= 8; + } + while (length > 0); + + var result = new byte[bytes.Count + 1]; + result[0] = (byte)(0x80 | bytes.Count); + for (var i = 0; i < bytes.Count; i++) + { + result[i + 1] = bytes[i]; + } + + return result; + } + + private static byte[] Concat(byte[][] parts) + { + var total = 0; + foreach (var part in parts) + { + total += part.Length; + } + + var result = new byte[total]; + var offset = 0; + foreach (var part in parts) + { + Buffer.BlockCopy(part, 0, result, offset, part.Length); + offset += part.Length; + } + + return result; + } + } +#else + internal sealed class Authority : IDisposable + { + private readonly RSA _authorityKey; + private readonly X509Certificate2 _authorityCertificate; + + internal static Authority Create(Action log) + { + return new Authority(); + } + + /// + /// ADR-0003: load (or first-run create) the deployment CA in LocalMachine\Personal, the + /// modern-.NET counterpart of the Framework machine-container path. Production minting + /// runs on the Framework Event Server; this keeps both backends contract-complete. + /// + internal static Authority LoadOrCreateDeploymentAuthority(Action log) + { + using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) + { + var canWrite = true; + try + { + store.Open(OpenFlags.ReadWrite); + } + catch (Exception) + { + // Restricted service accounts may read but never write the machine store; + // an administrator provisions the CA once (README script). + store.Open(OpenFlags.ReadOnly); + canWrite = false; + } + + var now = DateTime.Now; + foreach (var existing in store.Certificates.Find( + X509FindType.FindBySubjectName, ProxyLoopbackTls.AuthorityCommonName, false)) + { + if (existing.NotAfter > now && existing.HasPrivateKey) + { + log($"Loaded deployment proxy CA from LocalMachine\\Personal (thumbprint {existing.Thumbprint})."); + return new Authority(existing); + } + } + + if (!canWrite) + { + throw new InvalidOperationException( + "No deployment proxy CA exists in LocalMachine\\Personal and this service account cannot create one; run the elevated provisioning step from the plugin README (scripts/provision-event-server-authority.ps1) on the Event Server machine."); + } + + var key = RSA.Create(2048); + var request = new CertificateRequest( + new X500DistinguishedName("CN=" + ProxyLoopbackTls.AuthorityCommonName), + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, + true)); + + using (var created = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(3650))) + { + // Land the key in machine-level storage (the store Add persists it), matching + // the Framework path's machine key container. + var password = Guid.NewGuid().ToString("N"); + var portable = created.Export(X509ContentType.Pkcs12, password); + var machineCopy = X509CertificateLoader.LoadPkcs12( + portable, + password, + X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); + store.Add(machineCopy); + log($"Created deployment proxy CA in LocalMachine\\Personal (thumbprint {machineCopy.Thumbprint}); client machines must import this certificate into LocalMachine\\Root to trust proxied https targets."); + return new Authority(machineCopy); + } + } + } + + private Authority(X509Certificate2 certificateWithKey) + { + _authorityCertificate = certificateWithKey; + _authorityKey = certificateWithKey.GetRSAPrivateKey(); + } + + private Authority() + { + _authorityKey = RSA.Create(2048); + var request = new CertificateRequest( + new X500DistinguishedName("CN=" + ProxyLoopbackTls.AuthorityCommonName), + _authorityKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, + true)); + // no EKU by design + + _authorityCertificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(3650)); + } + + internal X509Certificate2 Certificate => _authorityCertificate; + + internal ProxyLoopbackLeafOwner CreateLeaf(string host) + { + var key = RSA.Create(2048); + var certificateDer = MintLeafForPublicKey(host, key.ExportSubjectPublicKeyInfo()); + var certificate = X509CertificateLoader.LoadCertificate(certificateDer); + + // The request key is not attached automatically when the issuer's own key is not + // copyable, so attach it here; either way the result must hold a private key. + var withKey = certificate.HasPrivateKey ? certificate : certificate.CopyWithPrivateKey(key); + + // Schannel refuses ephemeral CNG keys for server authentication; the PFX roundtrip + // lands the key in a persisted form it accepts (same recipe as production). + var password = Guid.NewGuid().ToString("N"); + var portable = withKey.Export(X509ContentType.Pkcs12, password); + var usable = X509CertificateLoader.LoadPkcs12(portable, password); + if (!ReferenceEquals(withKey, certificate)) + { + withKey.Dispose(); + } + + return new ProxyLoopbackLeafOwner(usable, key); + } + + /// + /// ADR-0003: signs a leaf for this host around a caller-supplied SubjectPublicKeyInfo — + /// the Event Server mint path, where the private half lives on the requesting client. An + /// unusable public key surfaces as ArgumentException, which the issuer maps to a + /// BadRequest refusal. + /// + internal byte[] MintLeafForPublicKey(string host, byte[] subjectPublicKeyInfoDer) + { + var publicKey = RSA.Create(); + try + { + publicKey.ImportSubjectPublicKeyInfo(subjectPublicKeyInfoDer ?? Array.Empty(), out _); + } + catch (Exception exception) + { + publicKey.Dispose(); + throw new ArgumentException("The client public key is not a usable RSA SubjectPublicKeyInfo.", nameof(subjectPublicKeyInfoDer), exception); + } + + try + { + var request = new CertificateRequest( + new X500DistinguishedName("CN=" + host), + publicKey, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, + true)); + + var builder = new SubjectAlternativeNameBuilder(); + IPAddress address; + if (IPAddress.TryParse(host, out address)) + { + builder.AddIpAddress(address); + } + else + { + builder.AddDnsName(host); + } + + request.CertificateExtensions.Add(builder.Build(false)); + + using (var certificate = request.Create( + _authorityCertificate, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(730), + RandomNumberGenerator.GetBytes(8))) + { + return certificate.RawData; + } + } + finally + { + publicKey.Dispose(); + } + } + + public void Dispose() + { + try { _authorityCertificate.Dispose(); } catch { } + try { _authorityKey.Dispose(); } catch { } + } + } +#endif + + /// + /// Ownership bundle for one minted leaf: the usable certificate plus the key owner that must + /// outlive it (the Framework PrivateKey attachment references the provider handle/container, + /// and a client-generated key is wrapped in ). + /// + internal sealed class ProxyLoopbackLeafOwner : IDisposable + { + private readonly X509Certificate2 _certificate; + private readonly IDisposable _key; + + internal ProxyLoopbackLeafOwner(X509Certificate2 certificate, IDisposable key) + { + _certificate = certificate; + _key = key; + } + + internal X509Certificate2 Certificate => _certificate; + + public void Dispose() + { + try { _certificate.Dispose(); } catch { } + try { _key.Dispose(); } catch { } + } + } +} diff --git a/src/WebContent/ProxyLoopbackClientKey.cs b/src/WebContent/ProxyLoopbackClientKey.cs new file mode 100644 index 0000000..b26dd46 --- /dev/null +++ b/src/WebContent/ProxyLoopbackClientKey.cs @@ -0,0 +1,98 @@ +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace WebContent +{ + /// + /// Ticket #56 (ADR-0003): the client-side key behind one loopback leaf certificate. The key + /// pair is generated where the TLS listener runs (the Smart Client machine) and only the + /// SubjectPublicKeyInfo is handed to the Event Server for signing - the private half never + /// leaves this process. binds the minted certificate DER to the + /// local key in a form Schannel accepts for AuthenticateAsServer: a persisted CAPI exchange + /// container on .NET Framework, a PFX roundtrip on modern .NET (both recipes are the ones + /// the certificate probes proved; ephemeral in-memory keys fail server auth). + /// + internal sealed class ProxyLoopbackClientKey : IDisposable + { +#if NETFRAMEWORK + private const int KeyBits = 2048; + private const string EnhancedProviderName = "Microsoft Enhanced RSA and AES Cryptographic Provider"; + + private readonly RSACryptoServiceProvider _key; + + internal static ProxyLoopbackClientKey Create() + { + // AT_KEYEXCHANGE on the enhanced provider: Schannel rejects signature-usage CAPI keys + // for server authentication. The container outlives nothing - PersistKeyInCsp=false + // deletes it when this object is disposed, matching the per-session leaf lifetime. + var parameters = new CspParameters + { + KeyContainerName = "WCProxyLeaf-" + Guid.NewGuid().ToString("N").Substring(0, 12), + ProviderName = EnhancedProviderName, + ProviderType = 24, // PROV_RSA_AES + KeyNumber = (int)KeyNumber.Exchange, + }; + var key = new RSACryptoServiceProvider(KeyBits, parameters); + key.PersistKeyInCsp = false; + return new ProxyLoopbackClientKey(key, Der.SubjectPublicKeyInfo(key)); + } + + private ProxyLoopbackClientKey(RSACryptoServiceProvider key, byte[] subjectPublicKeyInfoDer) + { + _key = key; + SubjectPublicKeyInfoDer = subjectPublicKeyInfoDer; + } + + internal X509Certificate2 AttachLeaf(byte[] leafCertificateDer) + { + var certificate = new X509Certificate2(leafCertificateDer ?? throw new ArgumentNullException(nameof(leafCertificateDer))); + certificate.PrivateKey = _key; // Framework-only attachment; keeps _key alive via the owner bundle + return certificate; + } + + public void Dispose() + { + try { _key.Dispose(); } catch { } + } +#else + private readonly RSA _key; + + internal static ProxyLoopbackClientKey Create() + { + var key = RSA.Create(2048); + return new ProxyLoopbackClientKey(key, key.ExportSubjectPublicKeyInfo()); + } + + private ProxyLoopbackClientKey(RSA key, byte[] subjectPublicKeyInfoDer) + { + _key = key; + SubjectPublicKeyInfoDer = subjectPublicKeyInfoDer; + } + + internal X509Certificate2 AttachLeaf(byte[] leafCertificateDer) + { + var certificate = X509CertificateLoader.LoadCertificate( + leafCertificateDer ?? throw new ArgumentNullException(nameof(leafCertificateDer))); + var withKey = certificate.CopyWithPrivateKey(_key); + + // Schannel refuses ephemeral CNG keys for server authentication; the PFX roundtrip + // persists the key material into a form it accepts (same recipe as the minting side). + var password = Guid.NewGuid().ToString("N"); + var portable = withKey.Export(X509ContentType.Pkcs12, password); + var usable = X509CertificateLoader.LoadPkcs12(portable, password); + withKey.Dispose(); + certificate.Dispose(); + return usable; + } + + public void Dispose() + { + try { _key.Dispose(); } catch { } + } +#endif + + /// The public half safe to send to the Event Server for minting. + internal byte[] SubjectPublicKeyInfoDer { get; } + } +} diff --git a/src/WebContent/ProxyLoopbackTls.cs b/src/WebContent/ProxyLoopbackTls.cs new file mode 100644 index 0000000..e6865db --- /dev/null +++ b/src/WebContent/ProxyLoopbackTls.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; + +using WebContent.Client; + +namespace WebContent +{ + /// + /// Asks the Event Server to mint a loopback leaf for one origin, carrying the locally + /// generated SubjectPublicKeyInfo (ADR-0003). Implemented over MIP messaging by + /// ; injectable for tests. + /// + internal delegate Task ProxyLeafMintChannel(string host, int port, byte[] subjectPublicKeyInfoDer); + + /// + /// Ticket #56 (ADR-0003): owns the certificates that let the loopback listener terminate a + /// browser's CONNECT tunnel for an allowlisted https target. + /// + /// Shape: the signing authority lives on the Event Server (its private key never reaches this + /// machine); per origin this process generates an exchange key pair, sends only the public + /// half over the mint channel, and binds the returned certificate DER to the local key. The + /// chain the browser validates is leaf -> deployment CA, whose certificate the operator has + /// imported into the machine's trusted-root store. + /// + /// Leaves are cached per (host, port) for the browser session; key containers and certificates + /// are disposed with the manager. When a mint succeeds but the deployment CA is missing from + /// the trust stores, the tunnels still serve - the browser shows its own certificate error + /// (fail-closed) and the log names the one-time elevated import that fixes it. + /// + internal sealed class ProxyLoopbackTls : IDisposable + { + /// Common name of the deployment CA; matches what the Event Server provisions. + internal const string AuthorityCommonName = "XProtect WebContent Plugin Loopback Proxy"; + + private readonly ProxyLeafMintChannel _mint; + private readonly Action _log; + private readonly Func _rootTrustProbe; + + /// #72: per-browser-view trust posture this manager feeds on every mint; null + /// when nobody observes it (unit seams, non-view listeners). + private readonly ProxyCaTrustState _trustState; + private readonly object _gate = new object(); + private readonly Dictionary _leaves = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly List _leafOwners = new List(); + private readonly HashSet _authorityTrustReported = new HashSet(StringComparer.OrdinalIgnoreCase); + private bool _disposed; + + internal ProxyLoopbackTls( + ProxyLeafMintChannel mint, + Action log, + ProxyCaTrustState trustState = null, + Func rootTrustProbe = null) + { + _mint = mint ?? throw new ArgumentNullException(nameof(mint)); + _log = log ?? delegate { }; + _trustState = trustState; + _rootTrustProbe = rootTrustProbe ?? (thumbprint => ProxyLoopbackTrust.IsRootTrusted(thumbprint, _log)); + } + + /// + /// The leaf presented for tunnels to this origin. Cached per host:port (host part + /// case-insensitive): a browser reconnecting reuses the certificate instead of growing + /// the process or re-asking the Event Server. + /// + internal async Task GetOrCreateLeafCertificateAsync(string host, int port) + { + if (string.IsNullOrWhiteSpace(host)) + { + throw new ArgumentException("Leaf host must be non-empty.", nameof(host)); + } + + var cacheKey = host + ":" + port; + lock (_gate) + { + ThrowIfDisposed(); + + X509Certificate2 cached; + if (_leaves.TryGetValue(cacheKey, out cached)) + { + return cached; + } + } + + // Mint outside the lock: the exchange is a network roundtrip, and a duplicate mint + // under a race is harmless (the first insertion wins). + var key = ProxyLoopbackClientKey.Create(); + ProxyLeafResponsePayload response; + try + { + response = await _mint(host, port, key.SubjectPublicKeyInfoDer).ConfigureAwait(false); + } + catch + { + key.Dispose(); + throw; + } + + if (response == null || !response.Success || string.IsNullOrEmpty(response.LeafDerBase64)) + { + key.Dispose(); + throw new InvalidOperationException( + "The Event Server refused the loopback leaf for " + cacheKey + + " (code=" + (response == null ? "null" : response.ErrorCode) + + "): " + (response == null ? "no response" : response.ErrorMessage)); + } + + X509Certificate2 leaf; + try + { + leaf = key.AttachLeaf(Convert.FromBase64String(response.LeafDerBase64)); + } + catch + { + key.Dispose(); + throw; + } + + lock (_gate) + { + if (_disposed) + { + leaf.Dispose(); + key.Dispose(); + throw new ObjectDisposedException(nameof(ProxyLoopbackTls)); + } + + X509Certificate2 existing; + if (_leaves.TryGetValue(cacheKey, out existing)) + { + leaf.Dispose(); + key.Dispose(); + return existing; + } + + _leaves[cacheKey] = leaf; + _leafOwners.Add(new ProxyLoopbackLeafOwner(leaf, key)); + } + + _log("WebContent proxy: Event Server minted loopback TLS leaf for " + cacheKey); + // #80: the cacheKey (host:port) rides into the posture so a flip to untrusted can + // file its block report under the https origin the browser actually dialled. + PublishAuthorityTrustState(response.AuthorityThumbprint, response.AuthorityDerBase64, cacheKey); + return leaf; + } + + /// + /// #72: drop every cached leaf (and its local key container) so the next proxied https + /// tunnel re-mints. The trigger is a deployment CA that became trusted after the leaves + /// were minted (the setup-mode fix helper, or a manual Import-Certificate mid-session): + /// the re-mint re-publishes the trust posture against the fresh root stores, and the + /// browser re-validates each new tunnel against the now-anchored chain - no Smart Client + /// restart. A live tunnel already handshake-d on a disposed leaf drops; it was riding the + /// untrusted posture anyway, and the next request reconnects cleanly. + /// + internal void InvalidateLeafCache() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _leaves.Clear(); + foreach (var owner in _leafOwners) + { + try { owner.Dispose(); } catch { } + } + + _leafOwners.Clear(); + } + + _log("WebContent proxy: loopback TLS leaf cache invalidated (a fresh mint will re-check the deployment CA trust posture)."); + } + + /// + /// Wraps an established CONNECT tunnel in a server-side TLS stream using the leaf for the + /// tunnel origin's host. carries the bytes already pulled off the + /// socket while framing the CONNECT request (the first fragment of the browser's ClientHello + /// when it pipelined behind the CONNECT); they are replayed into the TLS read. + /// On success the returned stream owns the tunnel's TLS traffic; the raw stream stays owned + /// by the caller (ProxyForwardConnection closes it). + /// + internal async Task AcceptTunnelAsync(Stream tunnel, byte[] seed, Uri origin) + { + if (tunnel == null) + { + throw new ArgumentNullException(nameof(tunnel)); + } + + if (origin == null || !string.Equals(origin.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Tunnel origin must be an https URI.", nameof(origin)); + } + + var certificate = await GetOrCreateLeafCertificateAsync(origin.IdnHost, origin.Port).ConfigureAwait(false); + + var source = seed != null && seed.Length > 0 + ? (Stream)new PrefixedStream(seed, tunnel) + : tunnel; + + var ssl = new SslStream(source, true); + try + { + await ssl.AuthenticateAsServerAsync(certificate, false, SslProtocols.Tls12, false) + .ConfigureAwait(false); + } + catch + { + ssl.Dispose(); + throw; + } + + _log("WebContent proxy: tunnel TLS established for " + origin.IdnHost); + return ssl; + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + _leaves.Clear(); + + foreach (var owner in _leafOwners) + { + try { owner.Dispose(); } catch { } + } + + _leafOwners.Clear(); + } + } + + /// + /// #72 (was #56's log-only report): on EVERY mint, read this machine's trust posture for + /// the reported deployment CA and publish it (thumbprint + public DER + verdict) to the + /// per-view , which raises Changed only on a real flip. + /// The log names the exact one-time elevated step exactly once per (thumbprint, verdict) + /// pair so a CA that becomes trusted mid-session gets its confirmation line too. Reading + /// the root stores never needs elevation; only the import does, and that path lives in + /// the setup-mode banner fix (the bundled elevated helper), never here. + /// #80: the mint's host:port rides along (quietly - never part of the flip detection) so + /// the listener's untrusted-posture report can name the origin the browser dialled. + /// + private void PublishAuthorityTrustState(string authorityThumbprint, string authorityDerBase64, string mintOrigin) + { + if (string.IsNullOrEmpty(authorityThumbprint)) + { + return; + } + + var trusted = _rootTrustProbe(authorityThumbprint); + if (_trustState != null) + { + _trustState.Publish(authorityThumbprint, authorityDerBase64, trusted, mintOrigin); + } + + bool logLine; + lock (_gate) + { + logLine = _authorityTrustReported.Add(authorityThumbprint + "|" + (trusted ? "trusted" : "untrusted")); + } + + if (!logLine) + { + return; + } + + if (trusted) + { + _log("WebContent proxy: deployment CA " + authorityThumbprint + " is present in this machine's trusted roots."); + return; + } + + _log( + "WebContent proxy: deployment CA " + authorityThumbprint + + " is NOT in this machine's trusted roots. Proxied https targets will show a browser certificate error until an administrator imports the CA certificate exported by the Event Server: " + + "Import-Certificate -FilePath -CertStoreLocation Cert:\\LocalMachine\\Root"); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ProxyLoopbackTls)); + } + } + + /// + /// Feeds the bytes the CONNECT framing already consumed before anything else is read from + /// the underlying stream. Writes go straight through; the underlying stream stays owned by + /// the caller. + /// + private sealed class PrefixedStream : Stream + { + private readonly byte[] _prefix; + private readonly Stream _inner; + private int _offset; + + internal PrefixedStream(byte[] prefix, Stream inner) + { + _prefix = prefix; + _inner = inner; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + { + var remaining = _prefix.Length - _offset; + if (remaining <= 0) + { + return _inner.Read(buffer, offset, count); + } + + var take = Math.Min(remaining, count); + Buffer.BlockCopy(_prefix, _offset, buffer, offset, take); + _offset += take; + return take; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + // The raw tunnel stream is the caller's (ProxyForwardConnection disposes it); + // only the prefix buffer is ours, and GC handles it. + if (disposing) + { + // SslStream disposes this wrapper; deliberately do not dispose _inner. + } + + base.Dispose(disposing); + } + } + } +} diff --git a/src/WebContent/ProxyPolicy.cs b/src/WebContent/ProxyPolicy.cs new file mode 100644 index 0000000..7ddc27d --- /dev/null +++ b/src/WebContent/ProxyPolicy.cs @@ -0,0 +1,562 @@ +using System; + +namespace WebContent +{ + /// + /// Pure policy for the proxy allowlist (#52, wildcards since #73): canonicalizes admin-entered + /// targets to a comparable origin string (lower-case scheme and host, explicit port unless it is + /// the scheme default) and matches request origins against the allowlist. Since #73 a host part + /// may also be a -shaped pattern: a leftmost-label wildcard + /// ("*.foo.bar.com" matches subdomains but never the apex itself) or the lone full-host + /// escape hatch ("*"). Origin-level only - paths, mid-label wildcards, and everything else + /// malformed never enter the allowlist; duplicates collapse onto one canonical entry. + /// is THE matcher every gate consumes (#73): the Event Server + /// fetch/mint gate, the client forward policy, and the leaf-issuance gate - no gate compares + /// entries by itself, and the SSRF floor (a wildcard never matches a literal IP, "localhost", + /// or the VMS's own hosts; explicit exact entries still may) lives here, not in any gate or UI. + /// + internal static class ProxyPolicy + { + /// #73: the full-host escape-hatch pattern (matches any host on the entry's scheme). + internal const string HostWildcard = "*"; + + /// #73: the leftmost-label wildcard prefix ("*.tail" matches subdomains of tail). + internal const string LabelWildcardPrefix = "*."; + + /// #73: the one loopback/VMS name every wildcard is floored against. + internal const string LocalhostName = "localhost"; + + /// + /// Canonicalizes one admin allowlist entry (a scheme/host/port origin, with the bare + /// host and host:port shorthands taken as https) to "scheme://host[:port]". Since #73 a + /// host may also be a wildcard pattern. Returns false for anything that is not a valid + /// http(s) origin or wildcard origin; paths, queries, and fragments are ignored. + /// + internal static bool TryNormalizeTarget(string entry, out string normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(entry)) + { + return false; + } + + // #73: a '*' in the entry is pattern syntax, never something the Uri parser is + // asked to make sense of, so wildcard lines take the pattern path exclusively. + if (entry.IndexOf('*') >= 0) + { + return TryNormalizeWildcardEntryLine(entry, out normalized); + } + + if (!WebContentConfiguration.TryCreateNavigationUri(entry, out var uri)) + { + return false; + } + + normalized = CanonicalOrigin(uri); + return true; + } + + /// + /// True for absolute http(s) uris — the scheme gate every proxy-fetch flow (admin harness, + /// Event Server request handling, transport) applies before touching the network. + /// + internal static bool IsHttpUri(Uri uri) + { + return uri != null && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } + + /// + /// The canonical origin form used for both stored allowlist entries and request matching. + /// + internal static string CanonicalOrigin(Uri uri) + { + // Uri.Host carries IPv6 hosts already bracketed ("[::1]"), so it needs no re-wrapping. + var host = uri.Host.ToLowerInvariant(); + var port = uri.IsDefaultPort ? 0 : uri.Port; + return FormatOrigin(uri.Scheme.ToLowerInvariant(), host, port); + } + + /// + /// Canonicalizes a stored allowlist entry from its scheme/pattern/port parts (the shape + /// the workspace JSON carries since #73; the legacy host part of pre-#73 records feeds + /// the same path). Invalid parts - a non-http scheme, a blank or malformed host or + /// pattern, an out-of-range port - reject the entry instead of poisoning the allowlist. + /// A zero port means the scheme default. + /// + internal static bool TryNormalizeTargetParts(string scheme, string host, int port, out string normalized) + { + normalized = null; + var schemePart = (scheme ?? string.Empty).Trim().ToLowerInvariant(); + if (schemePart != Uri.UriSchemeHttp && schemePart != Uri.UriSchemeHttps) + { + return false; + } + + var hostPart = (host ?? string.Empty).Trim().ToLowerInvariant(); + if (hostPart.Length == 0 || hostPart.IndexOfAny(new[] { ' ', '/', '?', '#', '@' }) >= 0) + { + return false; + } + + // #73: a stored pattern entry ("*" or "*.tail") validates against the wildcard + // grammar; a '*' anywhere else rejects the whole entry. + if (hostPart.IndexOf('*') >= 0) + { + if (!IsValidWildcardHostPattern(hostPart) || port < 0 || port > 65535) + { + return false; + } + + normalized = FormatOrigin(schemePart, hostPart, port); + return true; + } + + if (hostPart.IndexOf(':') >= 0 && + !(hostPart.StartsWith("[", StringComparison.Ordinal) && hostPart.EndsWith("]", StringComparison.Ordinal))) + { + hostPart = $"[{hostPart}]"; + } + + if (port < 0 || port > 65535) + { + return false; + } + + normalized = FormatOrigin(schemePart, hostPart, port); + return true; + } + + /// + /// #73: the wildcard host grammar, exactly: the lone "*" or "*." followed by a valid + /// multi-part lower-case domain tail. Mid-label wildcards ("foo.*.bar"), "*x", "**", + /// "*.tail with a bad label", and bare "*." are all invalid entries. + /// + internal static bool IsValidWildcardHostPattern(string host) + { + if (host == HostWildcard) + { + return true; + } + + if (!host.StartsWith(LabelWildcardPrefix, StringComparison.Ordinal) || host.Length == LabelWildcardPrefix.Length) + { + return false; + } + + return IsValidDomainLabels(host.Substring(LabelWildcardPrefix.Length)); + } + + // Domain label validation for wildcard tails: non-empty labels of letters, digits, and + // hyphens (hyphens never leading or trailing); no '*' - the only wildcard position is + // the leftmost label already checked by the caller. + private static bool IsValidDomainLabels(string host) + { + if (string.IsNullOrEmpty(host) || host[0] == '.' || host[host.Length - 1] == '.' || host.IndexOf("..") >= 0) + { + return false; + } + + foreach (var label in host.Split('.')) + { + if (label.Length == 0 || label[0] == '-' || label[label.Length - 1] == '-') + { + return false; + } + + foreach (var c in label) + { + if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '-') + { + return false; + } + } + } + + return true; + } + + // #73: one typed allowlist line whose host part carries '*' (scheme://*.tail[:port], + // scheme://*[:port], or the bare shorthands taken as https). Paths are ignored like the + // exact-origin path ignores them; anything outside the grammar is refused. + private static bool TryNormalizeWildcardEntryLine(string entry, out string normalized) + { + normalized = null; + var text = entry.Trim().ToLowerInvariant(); + + // Scheme first - cutting at '/' before this point would chop "scheme://host" at the + // "://" itself and leave nothing parseable behind. + var scheme = Uri.UriSchemeHttps; + if (text.IndexOf("://", StringComparison.Ordinal) >= 0) + { + var separator = text.IndexOf("://", StringComparison.Ordinal); + scheme = text.Substring(0, separator); + if (scheme != Uri.UriSchemeHttp && scheme != Uri.UriSchemeHttps) + { + return false; + } + + text = text.Substring(separator + 3); + } + else if (text.IndexOf(':') >= 0 && text.IndexOf('*') > text.IndexOf(':')) + { + // A ':' before the wildcard means the admin typed no scheme and no port pattern + // we recognize ("host-like:*") - refuse rather than guess. + return false; + } + + var pathCut = text.IndexOfAny(new[] { '/', '?', '#' }); + if (pathCut >= 0) + { + text = text.Substring(0, pathCut); + } + + var port = 0; + var lastColon = text.LastIndexOf(':'); + if (lastColon > 0) + { + var portPart = text.Substring(lastColon + 1); + if (IsAllDigits(portPart)) + { + if (!int.TryParse(portPart, out port) || port < 0 || port > 65535) + { + return false; + } + + text = text.Substring(0, lastColon); + } + } + + if (!IsValidWildcardHostPattern(text)) + { + return false; + } + + normalized = FormatOrigin(scheme, text, port); + return true; + } + + private static bool IsAllDigits(string value) + { + if (value.Length == 0) + { + return false; + } + + foreach (var c in value) + { + if (c < '0' || c > '9') + { + return false; + } + } + + return true; + } + + /// + /// The one origin formatter everything else funnels through: port 0 or the scheme + /// default renders without an explicit port. + /// + private static string FormatOrigin(string scheme, string host, int port) + { + var schemeDefault = scheme == Uri.UriSchemeHttp ? 80 : 443; + var portPart = port == 0 || port == schemeDefault ? string.Empty : $":{port}"; + return $"{scheme}://{host}{portPart}"; + } + + /// + /// #56: the optional per-entry flag token marking "accept an untrusted TLS certificate for + /// this target". The flag is stored with the entry, never as a global toggle. + /// + internal const string AcceptUntrustedFlag = "accept-untrusted"; + + /// + /// Parses one admin proxy-target line (#56): an origin entry plus the optional + /// token, in either order. An unknown token or a second + /// origin on the line rejects the whole entry — a typo must not silently drop the flag or + /// smuggle an extra origin past the admin. + /// + internal static bool TryParseTargetEntry(string line, out string normalized, out bool acceptUntrusted) + { + normalized = null; + acceptUntrusted = false; + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + string originEntry = null; + foreach (var token in line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (string.Equals(token, AcceptUntrustedFlag, StringComparison.OrdinalIgnoreCase)) + { + acceptUntrusted = true; + continue; + } + + if (originEntry != null) + { + return false; + } + + originEntry = token; + } + + if (originEntry == null) + { + return false; + } + + if (!TryNormalizeTarget(originEntry, out normalized)) + { + normalized = null; + return false; + } + + return true; + } + + /// + /// Renders a stored entry back as the admin-page line that created it (#56): the canonical + /// origin plus the flag token only when the entry carries the override. + /// + internal static string DescribeTargetEntry(string canonicalOrigin, bool acceptUntrusted) + { + return acceptUntrusted ? $"{canonicalOrigin} {AcceptUntrustedFlag}" : canonicalOrigin; + } + + /// + /// True when the request origin already appears in the allowlisted canonical entries — + /// the exact-match semantics of the pre-#73 world when no entry is a wildcard. Kept as the + /// two-argument convenience form of the shared matcher (an empty VMS floor: wildcard + /// entries still never match literal IPs or "localhost"). + /// + internal static bool IsRequestAllowed(System.Collections.Generic.IEnumerable canonicalTargets, Uri requestUri) + { + return IsRequestAllowed(canonicalTargets, requestUri, null); + } + + /// + /// #73: THE canonical allowlist matcher every gate consumes - the Event Server fetch/mint + /// gate (, which is also the leaf-minting gate + /// consults) and the client forward policy + /// (). Entries are canonical "scheme://pattern[:port]" + /// strings; a request matches an entry when scheme and port match exactly and the host + /// part is either the exact host or a wildcard pattern covering it. + /// + /// The wildcard SSRF floor lives here, not in any gate or the UI: a wildcard entry never + /// matches a literal IPv4/IPv6 host, "localhost", or any of the VMS's own host names + /// carries (from EnvironmentManager in + /// production). Exact entries may still name those explicitly. + /// + internal static bool IsRequestAllowed( + System.Collections.Generic.IEnumerable canonicalTargets, + Uri requestUri, + System.Collections.Generic.IEnumerable vmsFloorHosts) + { + if (canonicalTargets == null || requestUri == null) + { + return false; + } + + var requestScheme = requestUri.Scheme.ToLowerInvariant(); + var requestHost = requestUri.Host.ToLowerInvariant(); + var requestPort = requestUri.IsDefaultPort ? 0 : requestUri.Port; + + foreach (var entry in canonicalTargets) + { + if (!TrySplitCanonicalEntry(entry, out var scheme, out var pattern, out var port)) + { + continue; + } + + if (scheme != requestScheme || port != requestPort) + { + continue; + } + + if (pattern == HostWildcard) + { + if (!IsWildcardFlooredHost(requestHost, vmsFloorHosts)) + { + return true; + } + + continue; + } + + if (pattern.StartsWith(LabelWildcardPrefix, StringComparison.Ordinal)) + { + // "*.tail" matches one-or-more-label subdomains of tail, never the apex: + // the request host must end with ".tail" and carry a leftmost label. + var tail = pattern.Substring(LabelWildcardPrefix.Length); + if (!IsWildcardFlooredHost(requestHost, vmsFloorHosts) && + requestHost.Length > tail.Length + 1 && + requestHost.EndsWith("." + tail, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + continue; + } + + if (string.Equals(pattern, requestHost, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// #73: splits a canonical entry back into scheme, host pattern (lower-cased; wildcard + /// patterns validate here so a malformed entry can never smuggle a looser comparison), + /// and port (0 = scheme default). Entries the canonicalizer could not have produced are + /// refused, so every comparison downstream is grammar-checked. + /// + internal static bool TrySplitCanonicalEntry(string entry, out string scheme, out string pattern, out int port) + { + scheme = null; + pattern = null; + port = 0; + if (string.IsNullOrWhiteSpace(entry)) + { + return false; + } + + var text = entry.Trim().ToLowerInvariant(); + var separator = text.IndexOf("://", StringComparison.Ordinal); + if (separator <= 0) + { + return false; + } + + scheme = text.Substring(0, separator); + if (scheme != Uri.UriSchemeHttp && scheme != Uri.UriSchemeHttps) + { + scheme = null; + return false; + } + + var rest = text.Substring(separator + 3); + if (rest.Length == 0) + { + scheme = null; + return false; + } + + var lastColon = rest.LastIndexOf(':'); + if (lastColon > 0 && lastColon < rest.Length - 1 && IsAllDigits(rest.Substring(lastColon + 1))) + { + if (!int.TryParse(rest.Substring(lastColon + 1), out port) || port < 0 || port > 65535) + { + scheme = null; + return false; + } + + rest = rest.Substring(0, lastColon); + } + + if (rest.Length == 0 || rest.IndexOf('/') >= 0) + { + scheme = null; + port = 0; + return false; + } + + if (rest.IndexOf('*') >= 0) + { + if (!IsValidWildcardHostPattern(rest)) + { + scheme = null; + return false; + } + + pattern = rest; + return true; + } + + pattern = rest; + return true; + } + + // #73: the wildcard floor - hosts a pattern entry is never allowed to reach. Literal IP + // hosts are recognized syntactically (bracketed or colon-bearing IPv6, dotted-quad and + // all-numeric IPv4 forms) so the answer never depends on DNS or runtime parse quirks. + private static bool IsWildcardFlooredHost(string host, System.Collections.Generic.IEnumerable vmsFloorHosts) + { + if (host == LocalhostName || IsLiteralIpHost(host)) + { + return true; + } + + if (vmsFloorHosts != null) + { + foreach (var vmsHost in vmsFloorHosts) + { + if (!string.IsNullOrWhiteSpace(vmsHost) && + string.Equals(vmsHost.Trim(), host, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + private static bool IsLiteralIpHost(string host) + { + var text = host; + if (text.StartsWith("[", StringComparison.Ordinal) && text.EndsWith("]", StringComparison.Ordinal)) + { + text = text.Substring(1, text.Length - 2); + } + + if (text.IndexOf(':') >= 0) + { + return true; + } + + var isDottedNumeric = text.Length > 0; + var hasDot = false; + var allDigits = text.Length > 0; + foreach (var c in text) + { + if (c == '.') + { + hasDot = true; + } + else if (c < '0' || c > '9') + { + allDigits = false; + break; + } + } + + // "127.0.0.1" and the shorthand "3232235777" are both literal-IP forms. + return allDigits || (hasDot && IsDottedQuad(text)); + } + + private static bool IsDottedQuad(string text) + { + var parts = text.Split('.'); + if (parts.Length != 4) + { + return false; + } + + foreach (var part in parts) + { + if (!IsAllDigits(part) || part.Length > 3 || int.Parse(part) > 255) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/WebContent/ProxyResponseCapStore.cs b/src/WebContent/ProxyResponseCapStore.cs new file mode 100644 index 0000000..558d023 --- /dev/null +++ b/src/WebContent/ProxyResponseCapStore.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// One workspace item as the response-cap store sees it: the item id fetch payloads are + /// attributed to, the display name the audit lines carry, and the raw property dictionary the + /// WorkspaceConfig blob lives in. Built by the Event Server plugin's composition root + /// from the MIP Configuration API; tests hand-build it. + /// + internal sealed class ProxyWorkspaceCapSource + { + internal string ItemId; + internal string Name; + internal Dictionary Properties; + } + + /// + /// Ticket #85: the Event Server's per-workspace max-response-bytes caps. The snapshot policy + /// mirrors and : the + /// workspace snapshot is re-resolved through a caller-supplied seam at most every 60 s and a + /// transient configuration failure keeps serving the last-known-good caps. Unlike the + /// allowlist there is no closed posture here: a workspace with no configured cap - every + /// definition saved before #85, an unknown item, or a store that never resolved - means the + /// 5 MB default, never a refusal. A configuration fault must not change what is served + /// beyond the default posture. + /// + /// Stored values clamp on read: 0/absent/negative is the default, anything past the fixed + /// 20 MB ceiling () is the ceiling, so + /// no workspace can obtain a cap above 20 MB regardless of what its stored config claims. + /// An oversized stored value gets one audit line per refresh naming the workspace - that is + /// the server-side proof the acceptance asks for, visible in the Event Server MIP log. + /// + internal sealed class ProxyResponseCapStore + { + private static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(60); + + private readonly Func> _snapshotResolver; + private readonly Action _log; + private readonly object _sync = new object(); + private Dictionary _snapshot; // null until the first successful resolve + private DateTime _nextRefreshUtc = DateTime.MinValue; + + internal ProxyResponseCapStore(Func> snapshotResolver, Action log) + { + _snapshotResolver = snapshotResolver ?? throw new ArgumentNullException(nameof(snapshotResolver)); + _log = log ?? (_ => { }); + } + + /// + /// The effective fetch-response cap in bytes for the workspace a fetch is attributed to. + /// Unknown item, no stored cap, a malformed (0/negative) value, and the never-resolved + /// store all answer the 5 MB default; a stored cap answers itself clamped to the ceiling. + /// + internal int ResolveEffectiveBytes(string workspaceItemId) + { + EnsureCurrent(); + + lock (_sync) + { + int stored; + if (_snapshot == null || + string.IsNullOrEmpty(workspaceItemId) || + !_snapshot.TryGetValue(workspaceItemId, out stored)) + { + return ProxyFetchLimits.DefaultMaxResponseBytes; + } + + return ProxyFetchLimits.NormalizeResponseCapBytes(stored); + } + } + + /// + /// Force one resolve at composition time (the Event Server plugin's Init): oversized + /// hand-edited values are audited at startup rather than at an arbitrary first fetch, + /// and the counts show what the configuration actually loaded. + /// + internal void Prime() + { + lock (_sync) + { + _nextRefreshUtc = DateTime.MinValue; + } + + EnsureCurrent(); + + lock (_sync) + { + var capped = 0; + if (_snapshot != null) + { + foreach (var pair in _snapshot) + { + if (pair.Value > 0) + { + capped++; + } + } + } + + _log($"Workspace response caps refreshed: {capped} capped workspace(s); default {ProxyFetchLimits.DefaultMaxResponseBytes} bytes, ceiling {ProxyFetchLimits.MaxAllowedResponseBytes} bytes."); + } + } + + private void EnsureCurrent() + { + var now = DateTime.UtcNow; + lock (_sync) + { + if (now < _nextRefreshUtc) + { + return; + } + + _nextRefreshUtc = now.Add(DefaultRefreshInterval); + } + + Dictionary resolved; + try + { + resolved = BuildSnapshot(_snapshotResolver()); + } + catch (Exception exception) + { + // Transient config-service failure: keep serving the last known caps. Never + // resolved at all just keeps every workspace at the default posture. + _log($"Proxy response cap refresh failed: {exception.Message}"); + return; + } + + lock (_sync) + { + _snapshot = resolved; + } + } + + private Dictionary BuildSnapshot(IEnumerable workspaces) + { + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (workspaces == null) + { + return snapshot; + } + + foreach (var workspace in workspaces) + { + if (workspace == null || string.IsNullOrEmpty(workspace.ItemId)) + { + continue; + } + + // Tolerant read: no WorkspaceConfig blob, no WebContent item, or no cap member + // (every pre-#85 definition) is simply "the default" - no snapshot entry, no + // audit line. + var settings = WorkspaceConfiguration.GetFirstWebContentSettings(workspace.Properties); + var stored = settings == null ? 0 : settings.ProxyMaxResponseBytes; + if (stored <= 0) + { + continue; + } + + if (stored > ProxyFetchLimits.MaxAllowedResponseBytes) + { + // One audit line per oversized workspace per refresh - the server-side clamp + // is observable by name, mirroring how refused trigger rows are audited. + var name = string.IsNullOrWhiteSpace(workspace.Name) ? workspace.ItemId : workspace.Name; + _log($"Proxy response cap for workspace '{name}' ({workspace.ItemId}) exceeds the {ProxyFetchLimits.MaxAllowedResponseBytes} byte ceiling ({stored} stored); serving the ceiling."); + stored = ProxyFetchLimits.MaxAllowedResponseBytes; + } + + snapshot[workspace.ItemId] = stored; + } + + return snapshot; + } + } +} diff --git a/src/WebContent/ProxyServerAllowlist.cs b/src/WebContent/ProxyServerAllowlist.cs new file mode 100644 index 0000000..055ca9c --- /dev/null +++ b/src/WebContent/ProxyServerAllowlist.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Net.Security; + +namespace WebContent +{ + /// + /// Server-side hardening for the Event Server fetcher (#54): a fetch is only served when its + /// origin appears in the union of every proxied workspace's admin allowlist, so the fetcher + /// can no longer be driven at arbitrary targets of the server network. The workspace snapshot + /// is re-resolved through a caller-supplied seam (the MIP configuration API in production) + /// and kept across transient resolution failures; before the first successful resolve the + /// gate is closed. Since #73 the union carries structured pattern entries and every decision + /// goes through the one shared matcher - including the wildcard + /// SSRF floor, fed with the VMS's own host names through a second seam. This gate is also + /// the minting gate the leaf issuer () consults, so fetch and + /// mint stay one policy. + /// + internal sealed class ProxyServerAllowlist + { + private static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(60); + + private readonly Func>> _snapshotResolver; + private readonly Func> _vmsFloorHostsResolver; + private readonly Action _log; + private readonly object _sync = new object(); + private Snapshot _snapshot; // null until the first successful resolve + private IReadOnlyList _floorHosts = new string[0]; + private DateTime _nextRefreshUtc = DateTime.MinValue; + + /// One refresh's union: allowlisted canonical entries (exact origins or #73 + /// wildcard patterns), and the flagged subset whose entries carry the #56 per-target + /// accept-untrusted-certificate override. + private sealed class Snapshot + { + internal readonly HashSet Origins; + internal readonly HashSet UntrustedOrigins; + + internal Snapshot(HashSet origins, HashSet untrustedOrigins) + { + Origins = origins; + UntrustedOrigins = untrustedOrigins; + } + } + + internal ProxyServerAllowlist(Func>> snapshotResolver, Action log) + : this(snapshotResolver, log, null) + { + } + + /// #73: supplies the VMS's own host names (Management + /// Server / API Gateway) that wildcard entries must never match. A resolver failure + /// keeps the last known floor rather than silently widening every wildcard. + internal ProxyServerAllowlist( + Func>> snapshotResolver, + Action log, + Func> vmsFloorHostsResolver) + { + _snapshotResolver = snapshotResolver ?? throw new ArgumentNullException(nameof(snapshotResolver)); + _vmsFloorHostsResolver = vmsFloorHostsResolver; + _log = log ?? throw new ArgumentNullException(nameof(log)); + } + + /// + /// True when the target origin is allowlisted by at least one proxied workspace - through + /// the shared #73 matcher, so an exact entry matches exactly and a wildcard entry matches + /// with the SSRF floor applied. Refuses with an actionable reason when the gate is closed + /// or the origin misses every list. + /// + internal bool IsAllowed(Uri target, out string refuseReason) + { + EnsureCurrent(); + + lock (_sync) + { + if (_snapshot == null) + { + refuseReason = "The proxy fetcher has not been able to read the Configuration of proxied workspaces, so its allowlist gate is closed."; + return false; + } + + if (ProxyPolicy.IsRequestAllowed(_snapshot.Origins, target, _floorHosts)) + { + refuseReason = null; + return true; + } + + var origin = ProxyPolicy.CanonicalOrigin(target); + refuseReason = $"The origin '{origin}' is not in the proxy allowlist of any proxied workspace."; + return false; + } + } + + /// + /// Ticket #56: True only when the allowlist entry for THIS origin carries the admin's + /// accept-untrusted-certificate flag in at least one proxied workspace. An override for + /// one target never reaches a neighbouring origin, and while the gate is closed (never + /// resolved) no override exists — validation is the fail-closed default. #73: the entry + /// may be a wildcard, and the same floor applies - a flagged wildcard never relaxes + /// validation for a floored host. + /// + internal bool AcceptsUntrustedCertificate(Uri target) + { + EnsureCurrent(); + + lock (_sync) + { + return _snapshot != null && + ProxyPolicy.IsRequestAllowed(_snapshot.UntrustedOrigins, target, _floorHosts); + } + } + + /// + /// Ticket #61: the certificate-validation posture for the Event Server's wss tunnel leg + /// (see ). Returns the validation callback to attach to + /// THIS tunnel's own SslStream: the accept-untrusted posture (#56) only when the allowlist + /// entry for exactly this origin carries the flag — the identical scoped decision the fetch + /// path makes — and null otherwise, which is SslStream's default FULL chain validation, + /// the exact #60 production posture. A flag for one target never reaches a neighbouring + /// origin, a non-https tunnel target, the browser-side loopback termination, or the + /// process-wide ServicePointManager. A closed gate, an unresolved config, or version skew + /// that hides the flag all resolve to null: fail-closed. + /// + internal RemoteCertificateValidationCallback TunnelTlsValidationCallback(Uri target) + { + if (target == null || target.Scheme != Uri.UriSchemeHttps) + { + return null; + } + + if (!AcceptsUntrustedCertificate(target)) + { + return null; + } + + return (sender, certificate, chain, sslPolicyErrors) => true; + } + + private void EnsureCurrent() + { + var now = DateTime.UtcNow; + lock (_sync) + { + if (now < _nextRefreshUtc) + { + return; + } + + _nextRefreshUtc = now.Add(DefaultRefreshInterval); + } + + Snapshot resolved; + try + { + resolved = BuildUnion(_snapshotResolver()); + } + catch (Exception exception) + { + // Transient config-service failure: keep serving the last known allowlist rather + // than flapping every proxied page. Never resolved at all stays closed. + _log($"Proxy allowlist refresh failed: {exception.Message}"); + return; + } + + // #73: the wildcard floor. A resolver failure keeps the previous floor - widening + // wildcards on a transient fault would be the one direction this must never flap. + if (_vmsFloorHostsResolver != null) + { + try + { + var hosts = new List(); + foreach (var host in _vmsFloorHostsResolver() ?? new string[0]) + { + if (!string.IsNullOrWhiteSpace(host)) + { + hosts.Add(host.Trim().ToLowerInvariant()); + } + } + + _floorHosts = hosts; + } + catch (Exception exception) + { + _log($"Proxy allowlist VMS host floor refresh failed: {exception.Message}"); + } + } + + lock (_sync) + { + _snapshot = resolved; + } + } + + private static Snapshot BuildUnion(IEnumerable> workspaceProperties) + { + var origins = new HashSet(StringComparer.OrdinalIgnoreCase); + var untrustedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase); + if (workspaceProperties == null) + { + return new Snapshot(origins, untrustedOrigins); + } + + foreach (var properties in workspaceProperties) + { + var config = WorkspaceConfiguration.FromProperties(properties); + var settings = config?.GetFirstWebContentSettings(); + if (settings == null || !settings.ProxyEnabled || settings.ProxyTargets == null) + { + continue; + } + + foreach (var target in settings.ProxyTargets) + { + // #73: EffectivePattern is the one reader seam - legacy host records and new + // pattern records canonicalize through the identical path. + if (target != null && + ProxyPolicy.TryNormalizeTargetParts(target.Scheme, target.EffectivePattern, target.Port, out var origin)) + { + origins.Add(origin); + if (target.AcceptUntrusted) + { + untrustedOrigins.Add(origin); + } + } + } + } + + return new Snapshot(origins, untrustedOrigins); + } + } +} diff --git a/src/WebContent/ProxySessionRegistry.cs b/src/WebContent/ProxySessionRegistry.cs new file mode 100644 index 0000000..3307f0e --- /dev/null +++ b/src/WebContent/ProxySessionRegistry.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// One proxied browser-view instance's diagnostics identity (#69): the client-generated + /// session id carried on every fetch/tunnel request and lifecycle event, plus the workspace + /// item and definition fingerprint block reports are filed under, and a creation stamp for + /// the heartbeat bookkeeping. Created by BrowserManager when a proxied view's + /// loopback listener starts and retired when that listener stops. + /// + internal sealed class ProxyViewSessionContext + { + internal string SessionId; + internal string WorkspaceItemId; + internal string WorkspaceFingerprint; + internal string ClientMachine; + + internal bool HasWorkspaceIdentity + { + get + { + Guid parsed; + return WorkspaceItemId != null && Guid.TryParse(WorkspaceItemId, out parsed) && parsed != Guid.Empty; + } + } + } + + /// + /// Ticket #69: the Smart Client's registry of live proxied-view sessions. Pure bookkeeping: + /// it hands the caller the lifecycle payloads owed (heartbeat fan-out) and answers session + /// lookups; the actual transmission rides the seam the caller supplies. + /// is the process-wide instance the browser manager and the messaging client share — sessions + /// outlive a messaging-channel re-provision, so heartbeats resume on the new channel. + /// + internal sealed class ProxySessionRegistry + { + internal static readonly ProxySessionRegistry Active = new ProxySessionRegistry(); + + private sealed class Entry + { + internal ProxyViewSessionContext Context; + internal DateTime LastRefreshUtc; + } + + private readonly object _sync = new object(); + private readonly Dictionary _sessions = new Dictionary(StringComparer.Ordinal); + + /// Register an opened session; no-op for contextless (legacy) listeners. + internal void Open(ProxyViewSessionContext context) + { + if (context == null || string.IsNullOrEmpty(context.SessionId)) + { + return; + } + + lock (_sync) + { + _sessions[context.SessionId] = new Entry + { + Context = context, + LastRefreshUtc = DateTime.UtcNow, + }; + } + } + + /// Retire a session (idempotent). + internal void Close(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) + { + return; + } + + lock (_sync) + { + _sessions.Remove(sessionId); + } + } + + /// Retire every session (plugin teardown). + internal void CloseAll() + { + lock (_sync) + { + _sessions.Clear(); + } + } + + internal int Count + { + get + { + lock (_sync) + { + return _sessions.Count; + } + } + } + + /// Snapshot of the live contexts (diagnostics/tests). + internal IReadOnlyList SnapshotContexts() + { + lock (_sync) + { + var contexts = new List(_sessions.Count); + foreach (var entry in _sessions) + { + contexts.Add(entry.Value.Context); + } + + return contexts; + } + } + + /// + /// Build one Heartbeat payload per live session. A caller that cannot send (channel + /// down) simply drops the payloads: the Event Server's idle backstop prunes the stale + /// sessions, and the next successful tick refreshes them again. + /// + internal IReadOnlyList HeartbeatPayloads(DateTime utcNow) + { + lock (_sync) + { + var payloads = new List(_sessions.Count); + foreach (var entry in _sessions) + { + entry.Value.LastRefreshUtc = utcNow; + payloads.Add(new ProxySessionEventPayload + { + SessionId = entry.Value.Context.SessionId, + Kind = ProxySessionEventKinds.Heartbeat, + WorkspaceItemId = entry.Value.Context.WorkspaceItemId, + WorkspaceFingerprint = entry.Value.Context.WorkspaceFingerprint, + }); + } + + return payloads; + } + } + } +} diff --git a/src/WebContent/ProxyStatusCorrelation.cs b/src/WebContent/ProxyStatusCorrelation.cs new file mode 100644 index 0000000..3c1708e --- /dev/null +++ b/src/WebContent/ProxyStatusCorrelation.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// Ticket #69: requester-side protocol state for the workspace status query. Exactly one + /// response frame per request (unicasted by the Event Server), so this is the leaf-mint + /// pattern again: a pending-task table with deadlines. An unanswered query completes as a + /// ServerUnavailable failure — silence from this id means the VMS-side plugin never answered, + /// which the MC page renders distinctly from an empty snapshot. + /// + /// Thread-safe: Feed runs on the message receiver thread, Begin on the UI thread. + /// + internal sealed class ProxyStatusCorrelation + { + private sealed class Pending + { + internal string CorrelationId; + internal CancellationTokenSource Deadline; + internal TaskCompletionSource Completion; + internal bool Completed; + } + + private readonly object _sync = new object(); + private readonly Dictionary _pending = new Dictionary(StringComparer.Ordinal); + + internal Task Begin(string correlationId, TimeSpan timeout) + { + lock (_sync) + { + var pending = new Pending + { + CorrelationId = correlationId, + Deadline = new CancellationTokenSource(timeout), + Completion = new TaskCompletionSource(), + }; + + pending.Deadline.Token.Register(() => + Complete(pending, new ProxyStatusResponsePayload + { + CorrelationId = correlationId, + Success = false, + ErrorCode = ProxyFetchErrorCodes.ServerUnavailable, + ErrorMessage = "The VMS Event Server did not answer the proxy status query within the deadline (the plugin's diagnostics channel is absent or the messaging session is down).", + })); + + _pending[correlationId] = pending; + return pending.Completion.Task; + } + } + + /// Route one response frame; true when a pending exchange owned it. + internal bool Feed(ProxyStatusResponsePayload response) + { + if (response == null || string.IsNullOrEmpty(response.CorrelationId)) + { + return false; + } + + lock (_sync) + { + if (!_pending.TryGetValue(response.CorrelationId, out var pending) || pending.Completed) + { + return false; + } + + Complete(pending, response); + return true; + } + } + + private void Complete(Pending pending, ProxyStatusResponsePayload response) + { + lock (_sync) + { + if (pending.Completed) + { + return; + } + + pending.Completed = true; + _pending.Remove(pending.CorrelationId); + } + + try + { + pending.Completion.TrySetResult(response); + } + finally + { + pending.Deadline.Cancel(); + pending.Deadline.Dispose(); + } + } + } +} diff --git a/src/WebContent/ProxyTriggerEventCodec.cs b/src/WebContent/ProxyTriggerEventCodec.cs new file mode 100644 index 0000000..8ec67d6 --- /dev/null +++ b/src/WebContent/ProxyTriggerEventCodec.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Runtime.Serialization.Json; + +using VideoOS.Platform.Data; + +namespace WebContent +{ + /// + /// Ticket #65: wire codec for the rule-trigger event raise. The event travels as ONE JSON + /// string on the system Server.NewEventCommand message - the same codec discipline as + /// every proxy payload: the platform binds message payloads via Type.GetType, and a receiver + /// that cannot resolve the payload type silently gets null. AnalyticsEvent is a platform type + /// (so a typed object would survive on matching builds), but the JSON string keeps the raise + /// working identically across Event Server versions and makes the NewEventIndication echo + /// readable by this plugin's own receiver. + /// + internal static class ProxyTriggerEventCodec + { + // DataContractJsonSerializer instances are documented as thread-safe for concurrent + // ReadObject/WriteObject calls, so one static instance is enough for the fetch path. + private static readonly DataContractJsonSerializer EventSerializer = + new DataContractJsonSerializer(typeof(AnalyticsEvent)); + + internal static string ToWire(AnalyticsEvent analyticsEvent) + { + if (analyticsEvent == null) + { + throw new ArgumentNullException(nameof(analyticsEvent)); + } + + using (var stream = new MemoryStream()) + { + EventSerializer.WriteObject(stream, analyticsEvent); + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + } + + /// Null for anything that is not a readable AnalyticsEvent JSON string. + internal static AnalyticsEvent FromWire(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json))) + { + return EventSerializer.ReadObject(stream) as AnalyticsEvent; + } + } + catch (Exception) + { + return null; + } + } + } +} diff --git a/src/WebContent/ProxyTriggerMatcher.cs b/src/WebContent/ProxyTriggerMatcher.cs new file mode 100644 index 0000000..ab55527 --- /dev/null +++ b/src/WebContent/ProxyTriggerMatcher.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// One workspace item as the trigger store sees it: the item id the fetch payloads attribute + /// traffic to, the display name the raised event carries, and the raw property dictionary the + /// WorkspaceConfig blob lives in. Built by the Event Server plugin's composition root + /// from the MIP Configuration API; tests hand-build it. + /// + internal sealed class ProxyWorkspaceTriggerSource + { + internal string ItemId; + internal string Name; + internal Dictionary Properties; + } + + /// + /// Ticket #65: the Event Server's per-workspace proxy-trigger row store. Mirrors + /// 's resilience policy exactly: the workspace snapshot is + /// re-resolved through a caller-supplied seam at most every 60 s, a transient configuration + /// failure keeps serving the last-known-good rows, and before the first successful resolve + /// there is nothing to match against — every fetch evaluates as no-match (closed). Unlike the + /// allowlist, closed here means "no events", never "no traffic": a configuration fault must + /// not change what is served, only what is reported. + /// + /// Rows parse through ; a row outside the grammar is refused + /// per refresh with one actionable audit line and never matches. Workspaces whose definition + /// carries no proxyTriggers section — every definition saved before #65 — load as + /// "no rows" without an audit line: the readers are tolerant of its absence by design. + /// + internal sealed class ProxyTriggerMatcher + { + private static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(60); + + private readonly Func> _snapshotResolver; + private readonly Action _log; + private readonly object _sync = new object(); + private Dictionary _snapshot; // null until the first successful resolve + private DateTime _nextRefreshUtc = DateTime.MinValue; + + private sealed class WorkspaceTriggers + { + internal string Name; + internal readonly List Rows = new List(); + } + + /// One match's outcome: the triggering workspace and row, enough context for the + /// raised event's fixed properties and the audit line. + internal sealed class TriggerMatch + { + internal string WorkspaceItemId; + internal string WorkspaceName; + internal string RowDescription; + } + + internal ProxyTriggerMatcher(Func> snapshotResolver, Action log) + { + _snapshotResolver = snapshotResolver ?? throw new ArgumentNullException(nameof(snapshotResolver)); + _log = log ?? (_ => { }); + } + + /// + /// Evaluates one served fetch (url + method + actual response status) against the trigger + /// rows of the workspace the request was attributed to. The first matching row wins — a + /// fetch raises at most one event. No workspace id, an unconfigured workspace, rows the + /// admin never defined, and the pre-first-resolve closed state all answer no-match. + /// + internal bool TryMatch(string workspaceItemId, Uri url, string method, int statusCode, out TriggerMatch match) + { + match = null; + + if (string.IsNullOrEmpty(workspaceItemId) || statusCode <= 0) + { + return false; + } + + EnsureCurrent(); + + lock (_sync) + { + if (_snapshot == null) + { + return false; + } + + WorkspaceTriggers triggers; + if (!_snapshot.TryGetValue(workspaceItemId, out triggers) || triggers.Rows.Count == 0) + { + return false; + } + + foreach (var row in triggers.Rows) + { + if (row.Matches(url, method, statusCode)) + { + match = new TriggerMatch + { + WorkspaceItemId = workspaceItemId, + WorkspaceName = triggers.Name, + RowDescription = ProxyTriggerPolicy.Describe(row), + }; + return true; + } + } + } + + return false; + } + + /// + /// Force one resolve at composition time (the Event Server plugin's Init): bad rows are + /// audited in the log at startup rather than at an arbitrary first fetch, and the row + /// counts below show what the configuration actually loaded. + /// + internal void Prime() + { + lock (_sync) + { + _nextRefreshUtc = DateTime.MinValue; + } + + EnsureCurrent(); + + lock (_sync) + { + var rows = 0; + var workspaces = 0; + if (_snapshot != null) + { + foreach (var pair in _snapshot) + { + if (pair.Value.Rows.Count > 0) + { + workspaces++; + rows += pair.Value.Rows.Count; + } + } + } + + _log($"Workspace rows refreshed: {rows} row(s) across {workspaces} workspace(s)."); + } + } + + /// Snapshot for diagnostics/tests: workspace item ids with rows loaded right now. + internal IReadOnlyList WorkspaceIdsWithRows() + { + EnsureCurrent(); + + lock (_sync) + { + var ids = new List(); + if (_snapshot != null) + { + foreach (var pair in _snapshot) + { + if (pair.Value.Rows.Count > 0) + { + ids.Add(pair.Key); + } + } + } + + return ids; + } + } + + private void EnsureCurrent() + { + var now = DateTime.UtcNow; + lock (_sync) + { + if (now < _nextRefreshUtc) + { + return; + } + + _nextRefreshUtc = now.Add(DefaultRefreshInterval); + } + + Dictionary resolved; + try + { + resolved = BuildSnapshot(_snapshotResolver()); + } + catch (Exception exception) + { + // Transient config-service failure: keep serving the last known rows. Never + // resolved at all just stays closed (no events). + _log($"Proxy trigger row refresh failed: {exception.Message}"); + return; + } + + lock (_sync) + { + _snapshot = resolved; + } + } + + private Dictionary BuildSnapshot(IEnumerable workspaces) + { + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (workspaces == null) + { + return snapshot; + } + + foreach (var workspace in workspaces) + { + if (workspace == null || string.IsNullOrEmpty(workspace.ItemId)) + { + continue; + } + + // Tolerant read: no WorkspaceConfig blob, no WebContent item, or no proxyTriggers + // section (every pre-#65 definition) is simply "no rows". + var settings = WorkspaceConfiguration.GetFirstWebContentSettings(workspace.Properties); + var triggers = settings == null ? null : settings.ProxyTriggers; + if (triggers == null || triggers.Count == 0) + { + continue; + } + + var entry = GetOrAdd(snapshot, workspace); + foreach (var trigger in triggers) + { + ProxyTriggerPolicy.TriggerRow row; + string error; + if (ProxyTriggerPolicy.TryBuildRow(trigger, out row, out error)) + { + entry.Rows.Add(row); + } + else + { + // One audit line per refused row per refresh - actionable by workspace and + // raw text, mirroring how the #81 MC grid refuses saves by name. + _log($"Proxy trigger row refused for workspace '{workspace.Name}' ({workspace.ItemId}): {error}. Raw: url='{trigger.Url}' method='{trigger.Method}' status='{trigger.Status}'."); + } + } + } + + return snapshot; + } + + private static WorkspaceTriggers GetOrAdd(Dictionary snapshot, ProxyWorkspaceTriggerSource workspace) + { + WorkspaceTriggers entry; + if (!snapshot.TryGetValue(workspace.ItemId, out entry)) + { + entry = new WorkspaceTriggers { Name = workspace.Name ?? workspace.ItemId }; + snapshot[workspace.ItemId] = entry; + } + + return entry; + } + } +} diff --git a/src/WebContent/ProxyTriggerPolicy.cs b/src/WebContent/ProxyTriggerPolicy.cs new file mode 100644 index 0000000..3cb43da --- /dev/null +++ b/src/WebContent/ProxyTriggerPolicy.cs @@ -0,0 +1,560 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// Ticket #65: the matcher half of the "XProtect rule fires on a client HTTP request" feature. + /// An admin-defined trigger row says: a proxied fetch whose FULL url matches this glob, whose + /// method matches, and whose RESPONSE status matches, raises one plugin custom event on the + /// Event Server. This is the trigger grammar - deliberately a SEPARATE grammar from the proxy + /// allowlist (#73): the allowlist gates origins (scheme+host+port, never paths), while a + /// trigger pattern names full URLs including path wildcards, and any matching host may be + /// whatever the allowlist already admitted. Nothing here grants access; it only decides which + /// already-served fetches become events. + /// + /// The stored shape (a row) is written by the admin + /// config story (#78); the Event Server reads and normalizes it here. An invalid row never + /// crashes the store - the matcher reports it, the caller logs it once per refresh, and the + /// row simply never matches. + /// + internal static class ProxyTriggerPolicy + { + /// + /// The single fixed event type name this plugin raises (and only ever raises): it rides + /// EventHeader.Message, which is the field the Event Server matches admin-created + /// event/alarm definitions against. One event type for every trigger row - per-workspace + /// and per-row granularity lives in this matcher, not in a growing zoo of event types. + /// + internal const string EventType = "WebContent proxy request"; + + internal const string HostWildcard = "*"; + internal const string LabelWildcardPrefix = "*."; + + /// + /// One parsed, normalized trigger row: url glob + method filter + status filter, each + /// already validated. is the whole row's verdict for one served + /// fetch; the raw members ride along for audit lines. + /// + internal sealed class TriggerRow + { + internal string RawUrl; + internal string RawMethod; + internal string RawStatus; + + // Url parts: scheme is "http"/"https"/"*"; host is exact-lower, "*.tail", or "*"; + // port 0 means "the url's own scheme default"; pathGlob null means "any path". + internal string Scheme; + internal string HostPattern; + internal int Port; + internal string PathGlob; + internal string NormalizedUrl; + + /// Normalized uppercase method, or null for "any method". + internal string Method; + + /// Exact status code, or 0 when no exact filter is set. + internal int StatusExact; + + /// Leading digit of a class filter ("2xx" → 2), or 0 when no class filter is set. + internal int StatusClass; + + /// + /// The row's verdict for one served fetch. Only fetch-path traffic with an actual + /// response status reaches this: a transport failure (status 0) never matches, and a + /// CONNECT-tunnelled connection never produces a status here at all. + /// + internal bool Matches(Uri url, string method, int statusCode) + { + if (url == null || statusCode <= 0 || !MatchesStatus(statusCode)) + { + return false; + } + + if (Method != null && !string.Equals((method ?? "GET").Trim().ToUpperInvariant(), Method, StringComparison.Ordinal)) + { + return false; + } + + return MatchesUrl(url); + } + + internal bool MatchesUrl(Uri url) + { + if (url == null || !ProxyPolicy.IsHttpUri(url)) + { + return false; + } + + if (Scheme != HostWildcard && + !string.Equals(url.Scheme, Scheme, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!MatchesHost(url)) + { + return false; + } + + // Port 0 = "the url's own scheme default": an explicit non-default port in the + // fetched url (https on 8443) only matches a pattern that names that port. + if (Port != 0) + { + if (url.Port != Port) + { + return false; + } + } + else if (!url.IsDefaultPort) + { + return false; + } + + if (PathGlob == null) + { + return true; + } + + return GlobMatches(PathGlob, url.AbsolutePath ?? "/"); + } + + private bool MatchesHost(Uri url) + { + var host = url.Host.ToLowerInvariant(); + + if (HostPattern == HostWildcard) + { + return true; + } + + if (HostPattern.StartsWith(LabelWildcardPrefix, StringComparison.Ordinal)) + { + var tail = HostPattern.Substring(LabelWildcardPrefix.Length); + + // Same apex rule as the #73 allowlist matcher: a leftmost-label wildcard + // covers one-or-more labels below the tail and never the tail itself. + return host.Length > tail.Length + 1 && + host.EndsWith("." + tail, StringComparison.OrdinalIgnoreCase) && + host.IndexOf('.') == host.Length - tail.Length - 1; + } + + return string.Equals(host, HostPattern, StringComparison.OrdinalIgnoreCase); + } + + internal bool MatchesStatus(int statusCode) + { + if (StatusExact != 0) + { + return statusCode == StatusExact; + } + + if (StatusClass != 0) + { + return statusCode / 100 == StatusClass; + } + + return true; + } + } + + /// + /// Parses and normalizes one trigger url glob. Grammar: scheme://host[:port][/path-glob] + /// where scheme is http/https/*; host is an exact host, *.tail (leftmost label, + /// the tail itself never matches), or a lone *; port is an explicit 1-65535 or absent + /// (the url's own scheme default); the path glob is literal text with * matching any + /// run (including /) and ? matching exactly one character, or absent (any path). + /// Query/fragment are never part of the grammar - the fetched url is matched on scheme, + /// host, port, and path only. Scheme and host normalize to lower case; the path glob stays + /// case-sensitive, matching web convention. + /// + internal static bool TryNormalizeUrlPattern(string urlGlob, out TriggerRow row, out string error) + { + row = null; + error = null; + + var text = (urlGlob ?? string.Empty).Trim(); + if (text.Length == 0) + { + error = "the trigger url pattern is empty"; + return false; + } + + if (text.IndexOf('#') >= 0) + { + error = "the trigger url pattern carries a fragment; patterns match scheme, host, port, and path only"; + return false; + } + + // The lone "*" is the documented any-scheme, any-host, any-path hatch; everything + // else must be written absolute. + if (text == HostWildcard) + { + row = new TriggerRow + { + RawUrl = urlGlob, + Scheme = HostWildcard, + HostPattern = HostWildcard, + Port = 0, + PathGlob = null, + NormalizedUrl = HostWildcard, + }; + return true; + } + + var schemeCut = text.IndexOf("://", StringComparison.Ordinal); + if (schemeCut < 0) + { + error = "the trigger url pattern must be absolute (scheme://host...) or the lone '*' hatch"; + return false; + } + + var scheme = text.Substring(0, schemeCut).Trim().ToLowerInvariant(); + if (scheme != HostWildcard && scheme != Uri.UriSchemeHttp && scheme != Uri.UriSchemeHttps) + { + error = $"the trigger url scheme '{scheme}' is not http, https, or the '*' wildcard"; + return false; + } + + var rest = text.Substring(schemeCut + 3); + var pathCut = rest.IndexOf('/'); + var authority = pathCut >= 0 ? rest.Substring(0, pathCut) : rest; + var pathPart = pathCut >= 0 ? rest.Substring(pathCut) : null; + + if (authority.Length == 0 || authority.IndexOf('@') >= 0) + { + error = "the trigger url host is empty or carries credentials"; + return false; + } + + var hostPart = authority; + var port = 0; + var lastColon = authority.LastIndexOf(':'); + if (lastColon > 0) + { + var portPart = authority.Substring(lastColon + 1); + if (IsAllDigits(portPart)) + { + if (!int.TryParse(portPart, out port) || port < 1 || port > 65535) + { + error = $"the trigger url port '{portPart}' is outside 1-65535"; + return false; + } + + hostPart = authority.Substring(0, lastColon); + } + } + + hostPart = hostPart.Trim().ToLowerInvariant(); + if (hostPart.Length == 0) + { + error = "the trigger url host is empty"; + return false; + } + + if (hostPart.IndexOf('*') >= 0) + { + if (!IsValidWildcardHostPattern(hostPart)) + { + error = $"the trigger url host '{hostPart}' is outside the host wildcard grammar (exact, '*.tail', or a lone '*')"; + return false; + } + } + else + { + if (hostPart.IndexOf('?') >= 0 || hostPart.IndexOf(' ') >= 0) + { + error = $"the trigger url host '{hostPart}' carries wildcard or invalid characters"; + return false; + } + + if (hostPart[0] == '.' || hostPart[hostPart.Length - 1] == '.' || hostPart.IndexOf("..") >= 0) + { + error = $"the trigger url host '{hostPart}' carries an empty label"; + return false; + } + } + + string pathGlob = null; + if (!string.IsNullOrEmpty(pathPart)) + { + // A lone "/" means exactly the root path - distinct from the absent path ("any"). + pathGlob = pathPart; + } + + row = new TriggerRow + { + RawUrl = urlGlob, + Scheme = scheme, + HostPattern = hostPart, + Port = port, + PathGlob = pathGlob, + NormalizedUrl = FormatPattern(scheme, hostPart, port, pathGlob), + }; + return true; + } + + /// + /// Normalizes a method filter: null/blank/"*" means any method; otherwise one uppercase + /// verb token. A row naming a verb the proxy never relays simply never matches - the + /// fetch path itself already refuses verbs outside the relayed set. + /// + internal static bool TryNormalizeMethodFilter(string method, out string normalized, out string error) + { + normalized = null; + error = null; + + var text = (method ?? string.Empty).Trim(); + if (text.Length == 0 || text == HostWildcard) + { + return true; + } + + var upper = text.ToUpperInvariant(); + foreach (var c in upper) + { + if (c < 'A' || c > 'Z') + { + error = $"the trigger method filter '{method}' is not a single verb token"; + return false; + } + } + + normalized = upper; + return true; + } + + /// + /// Normalizes a status filter into (exact, class): null/blank means any status; "2xx" + /// (any case) is a class; a plain 100-599 number is exact. Anything else is refused. + /// + internal static bool TryNormalizeStatusFilter(string status, out int statusExact, out int statusClass, out string error) + { + statusExact = 0; + statusClass = 0; + error = null; + + var text = (status ?? string.Empty).Trim(); + if (text.Length == 0) + { + return true; + } + + if (text.Length == 3 && (text[1] == 'x' || text[1] == 'X') && (text[2] == 'x' || text[2] == 'X')) + { + var digit = text[0]; + if (digit >= '1' && digit <= '5') + { + statusClass = digit - '0'; + return true; + } + + error = $"the trigger status class '{text}' is not one of 1xx..5xx"; + return false; + } + + int code; + if (int.TryParse(text, out code) && code >= 100 && code <= 599) + { + statusExact = code; + return true; + } + + error = $"the trigger status filter '{status}' is neither a class (1xx..5xx) nor an exact code"; + return false; + } + + /// + /// Validates one stored row into a matchable ; the parts normalize + /// through the single grammar path and any part's refusal is reported with its reason. + /// + internal static bool TryBuildRow(ProxyTriggerConfiguration trigger, out TriggerRow row, out string error) + { + row = null; + error = null; + + if (trigger == null) + { + error = "the trigger row is missing"; + return false; + } + + TriggerRow parsed; + string urlError; + if (!TryNormalizeUrlPattern(trigger.Url, out parsed, out urlError)) + { + error = urlError; + return false; + } + + string method; + string methodError; + if (!TryNormalizeMethodFilter(trigger.Method, out method, out methodError)) + { + error = methodError; + return false; + } + + int statusExact; + int statusClass; + string statusError; + if (!TryNormalizeStatusFilter(trigger.Status, out statusExact, out statusClass, out statusError)) + { + error = statusError; + return false; + } + + parsed.RawMethod = trigger.Method; + parsed.RawStatus = trigger.Status; + parsed.Method = method; + parsed.StatusExact = statusExact; + parsed.StatusClass = statusClass; + row = parsed; + return true; + } + + // The wildcard host grammar mirrors the #73 allowlist shape on purpose - the SAME host + // wildcard means the same thing everywhere an admin types it - while the URL/path grammar + // around it is this matcher's own. + private static bool IsValidWildcardHostPattern(string host) + { + if (host == HostWildcard) + { + return true; + } + + if (!host.StartsWith(LabelWildcardPrefix, StringComparison.Ordinal) || host.Length == LabelWildcardPrefix.Length) + { + return false; + } + + return IsValidDomainLabels(host.Substring(LabelWildcardPrefix.Length)); + } + + // Domain label validation for wildcard tails: non-empty labels of letters, digits, and + // hyphens (hyphens never leading or trailing); no '*' - the only wildcard position is the + // leftmost label already checked by the caller. + private static bool IsValidDomainLabels(string host) + { + if (string.IsNullOrEmpty(host) || host[0] == '.' || host[host.Length - 1] == '.' || host.IndexOf("..") >= 0) + { + return false; + } + + foreach (var label in host.Split('.')) + { + if (label.Length == 0 || label[0] == '-' || label[label.Length - 1] == '-') + { + return false; + } + + foreach (var c in label) + { + if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '-') + { + return false; + } + } + } + + return true; + } + + private static bool IsAllDigits(string value) + { + if (value.Length == 0) + { + return false; + } + + foreach (var c in value) + { + if (c < '0' || c > '9') + { + return false; + } + } + + return true; + } + + /// + /// Anchored path glob: '*' matches any run of characters including '/', '?' matches + /// exactly one. Iterative two-pointer match, so a storm of fetches never turns into a + /// regex storm inside the Event Server process. + /// + internal static bool GlobMatches(string pattern, string value) + { + if (pattern == null) + { + return true; + } + + value = value ?? string.Empty; + + var patternIndex = 0; + var valueIndex = 0; + var starIndex = -1; + var starMatchIndex = 0; + + while (valueIndex < value.Length) + { + var patternChar = patternIndex < pattern.Length ? pattern[patternIndex] : '\0'; + + if (patternChar == '*') + { + starIndex = patternIndex; + starMatchIndex = valueIndex; + patternIndex++; + continue; + } + + if (patternIndex < pattern.Length && + (patternChar == '?' || patternChar == value[valueIndex])) + { + patternIndex++; + valueIndex++; + continue; + } + + if (starIndex >= 0) + { + patternIndex = starIndex + 1; + starMatchIndex++; + valueIndex = starMatchIndex; + continue; + } + + return false; + } + + while (patternIndex < pattern.Length && pattern[patternIndex] == '*') + { + patternIndex++; + } + + return patternIndex == pattern.Length; + } + + private static string FormatPattern(string scheme, string host, int port, string pathGlob) + { + var portPart = port == 0 ? string.Empty : $":{port}"; + return $"{scheme}://{host}{portPart}{pathGlob ?? string.Empty}"; + } + + /// Audit/one-line rendering of a matched row for the Event Server log. + internal static string Describe(TriggerRow row) + { + if (row == null) + { + return "(no trigger row)"; + } + + var method = row.Method ?? "any"; + var status = row.StatusExact != 0 + ? row.StatusExact.ToString() + : (row.StatusClass != 0 ? row.StatusClass + "xx" : "any"); + return $"url={row.NormalizedUrl} method={method} status={status}"; + } + } +} diff --git a/src/WebContent/ProxyTunnelCorrelation.cs b/src/WebContent/ProxyTunnelCorrelation.cs new file mode 100644 index 0000000..b2effc2 --- /dev/null +++ b/src/WebContent/ProxyTunnelCorrelation.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// Ticket #57: the client-side registry of live websocket tunnels. The messaging receive + /// thread feeds it (open acks, target data, target closes); the browser connection pumps + /// drain it (ordered data, close sentinels). It owns no transport - ProxyFetchClient turns + /// these events into MIP messages - so the whole lifetime model (half-open deadlines, idle + /// reclaim, ordered pumping, orphan-ack cleanup) is unit-testable on its own. + /// + internal sealed class ProxyTunnelCorrelation + { + private sealed class Session + { + internal readonly string Id; + internal DateTime LastActivityUtc; + internal TaskCompletionSource OpenWaiter; + internal readonly Queue Data = new Queue(); + internal TaskCompletionSource DataWaiter; + internal bool Closed; + + internal Session(string id) + { + Id = id; + LastActivityUtc = DateTime.UtcNow; + OpenWaiter = new TaskCompletionSource(); + } + } + + private readonly object _sync = new object(); + private readonly Dictionary _sessions = new Dictionary(StringComparer.Ordinal); + + internal int Count + { + get + { + lock (_sync) + { + return _sessions.Count; + } + } + } + + /// Reserve a tunnel id; false when it is already live (a client-side duplicate). + internal bool TryRegister(string id) + { + lock (_sync) + { + if (_sessions.ContainsKey(id)) + { + return false; + } + + _sessions[id] = new Session(id); + return true; + } + } + + /// + /// Await the Event Server's open answer for a registered id; null when the id is not (or + /// no longer) live. Completion arrives via , a failure path, or + /// any close (which completes it as an abandoned-open failure so waiters never hang). + /// + internal Task AwaitOpenAsync(string id) + { + lock (_sync) + { + Session session; + if (!_sessions.TryGetValue(id, out session) || session.Closed || session.OpenWaiter == null) + { + return Task.FromResult(null); + } + + return session.OpenWaiter.Task; + } + } + + /// + /// Land the open response. A success keeps the session live for pumping; a failure closes + /// and removes it. An ack for an unknown id is an orphan: the client already abandoned + /// this open (deadline/sweep) while the Event Server was still connecting, so the handle + /// it came with is closed immediately rather than left orphaned on the target. + /// + internal void CompleteOpen(string id, ProxyTunnelOpenOutcome outcome) + { + if (outcome == null) + { + return; + } + + lock (_sync) + { + Session session; + if (!_sessions.TryGetValue(id, out session) || session.Closed) + { + if (outcome.Handle != null) + { + outcome.Handle.Close("the Smart Client stopped waiting for this tunnel before it opened"); + } + + return; + } + + session.LastActivityUtc = DateTime.UtcNow; + var waiter = session.OpenWaiter; + session.OpenWaiter = null; + + if (!outcome.Success) + { + session.Closed = true; + _sessions.Remove(id); + var dataWaiter = session.DataWaiter; + session.DataWaiter = null; + dataWaiter?.TrySetResult(null); + } + + waiter?.TrySetResult(outcome); + } + } + + /// Relay target bytes toward the browser, in arrival order. Unknown/closed ids drop. + internal void OnTargetData(string id, byte[] bytes) + { + if (bytes == null || bytes.Length == 0) + { + return; + } + + lock (_sync) + { + Session session; + if (!_sessions.TryGetValue(id, out session) || session.Closed) + { + return; + } + + session.LastActivityUtc = DateTime.UtcNow; + var waiter = session.DataWaiter; + session.DataWaiter = null; + if (waiter != null) + { + waiter.TrySetResult(bytes); + } + else + { + session.Data.Enqueue(bytes); + } + } + } + + /// + /// The browser pump's read: the next target bytes, or null once the tunnel is closed + /// (either side). A closed/unknown id answers null immediately, so a pump racing with a + /// reclaim unwinds instead of parking. + /// + internal Task NextDataAsync(string id) + { + lock (_sync) + { + Session session; + if (!_sessions.TryGetValue(id, out session) || session.Closed) + { + return Task.FromResult(null); + } + + if (session.Data.Count > 0) + { + return Task.FromResult(session.Data.Dequeue()); + } + + // One pump per tunnel by construction; a replaced waiter would only mean a + // second concurrent reader, which the flow never creates. + session.DataWaiter = new TaskCompletionSource(); + return session.DataWaiter.Task; + } + } + + /// The Event Server ended the tunnel (target closed or reclaimed): end the pump. + internal void OnTargetClose(string id, string reason) + { + lock (_sync) + { + CloseLocked(id, reason ?? "the Event Server closed the tunnel"); + } + } + + /// + /// The browser side ended the tunnel. True when a live session was removed (the caller + /// owes the Event Server a close notice); false for unknown/already-closed ids. + /// + internal bool EndLocal(string id) + { + lock (_sync) + { + return _sessions.Remove(id); + } + } + + /// Browser-to-target traffic keeps the tunnel off the idle deadline. + internal void Touch(string id) + { + lock (_sync) + { + Session session; + if (_sessions.TryGetValue(id, out session) && !session.Closed) + { + session.LastActivityUtc = DateTime.UtcNow; + } + } + } + + internal bool IsClosed(string id) + { + lock (_sync) + { + Session session; + return !_sessions.TryGetValue(id, out session) || session.Closed; + } + } + + /// + /// Reclaim tunnels with no traffic in either direction for — + /// including half-open ones that never got their open ack. Returns the reclaimed ids so + /// the caller can notify the Event Server to release its side. + /// + internal IReadOnlyList ReclaimIdle(TimeSpan idle) + { + var cutoff = DateTime.UtcNow - idle; + List reclaimed = null; + lock (_sync) + { + List stale = null; + foreach (var pair in _sessions) + { + if (pair.Value.LastActivityUtc < cutoff) + { + if (stale == null) + { + stale = new List(); + } + + stale.Add(pair.Key); + } + } + + if (stale != null) + { + reclaimed = new List(); + foreach (var id in stale) + { + CloseLocked(id, "the Smart Client reclaimed an idle websocket tunnel"); + reclaimed.Add(id); + } + } + } + + return reclaimed ?? (IReadOnlyList)new string[0]; + } + + /// Shut every tunnel down (client teardown): waiters unwind, registry empties. + internal IReadOnlyList CloseAll(string reason) + { + lock (_sync) + { + List ids = null; + foreach (var pair in _sessions) + { + if (ids == null) + { + ids = new List(); + } + + ids.Add(pair.Key); + } + + if (ids == null) + { + return new string[0]; + } + + foreach (var id in ids) + { + CloseLocked(id, reason); + } + + return ids; + } + } + + private void CloseLocked(string id, string reason) + { + Session session; + if (!_sessions.TryGetValue(id, out session)) + { + return; + } + + session.Closed = true; + _sessions.Remove(id); + + var openWaiter = session.OpenWaiter; + session.OpenWaiter = null; + openWaiter?.TrySetResult(new ProxyTunnelOpenOutcome + { + Success = false, + ErrorCode = ProxyFetchErrorCodes.Timeout, + ErrorMessage = "the websocket tunnel closed before it opened: " + reason, + }); + + var dataWaiter = session.DataWaiter; + session.DataWaiter = null; + dataWaiter?.TrySetResult(null); + } + + internal void DebugSetLastActivity(string id, DateTime when) + { + lock (_sync) + { + Session session; + if (_sessions.TryGetValue(id, out session)) + { + session.LastActivityUtc = when; + } + } + } + } +} diff --git a/src/WebContent/ProxyTunnelServer.cs b/src/WebContent/ProxyTunnelServer.cs new file mode 100644 index 0000000..09e26b9 --- /dev/null +++ b/src/WebContent/ProxyTunnelServer.cs @@ -0,0 +1,636 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace WebContent +{ + /// + /// Ticket #57: the Event Server half of the websocket tunnel. Given the client's tunnel-open + /// (already allowlist-gated twice — client decision table and the ES allowlist union), it + /// replays the rebuilt origin-form handshake to the target, admits the tunnel only when the + /// target answers 101, and then pumps raw bytes in both directions until either side closes. + /// Tunnel bytes never enter the HTTP fetch/chunking path: each direction rides its own + /// directional message, base64 at the JSON edge only. + /// + /// Ticket #60 widened the target schemes: a plain ws:// upgrade tunnels to an http-scheme + /// target over a raw socket; a wss:// upgrade (an upgrade inside a terminated https tunnel, + /// or an absolute wss GET) gets a TLS leg — the handshake rides an SslStream the dialer + /// validates with FULL chain validation unless the target's own allowlist entry carries the + /// per-target accept-untrusted override (#61, wired through + /// — scoped to this tunnel's SslStream, never global), and the pumps then carry decrypted + /// websocket frames. + /// + /// Event-Server protection: a hard concurrency cap (a tunnel is a parked socket plus two + /// pumps), a per-message byte cap, and idle reclamation that also catches half-open tunnels + /// (one side gone without a clean close). The idle reaper disposes the socket, which breaks + /// the parked read and unwinds the pumps. + /// + internal sealed class ProxyTunnelServer + { + private sealed class Session + { + internal string Id; + + /// #69: the browser-view session this tunnel belongs to (from the open + /// payload); traffic is attributed to that session's live status. Null on pre-#69 + /// clients, where the traffic callback simply skips. + internal string SessionId; + + internal TcpClient Socket; + internal Stream Stream; + internal Func ToClient; + internal DateTime LastActivityUtc; + internal bool Closed; + + // Client->target writes are serialized through this queue by one writer pump: two + // overlapping WriteAsync calls could interleave and corrupt the byte stream. + internal readonly ConcurrentQueue Outbound = new ConcurrentQueue(); + internal TaskCompletionSource OutboundWaiter; + } + + private readonly ProxyTunnelLimits _limits; + private readonly Action _log; + + /// + /// Per-target TLS validation override for the https tunnel leg (#61). The Event Server + /// wires this to the allowlist's per-target accept-untrusted flag: a flagged target gets + /// the accept-untrusted posture on THIS tunnel's own SslStream, anything else resolves to + /// null = SslStream's default FULL chain validation. Never a process-wide bypass. + /// + private readonly Func _tlsValidationResolver; + + /// + /// #69: per-chunk traffic seam - (sessionId, browser→target bytes, target→browser bytes). + /// The Event Server plugin wires this to the diagnostics aggregator; the server itself + /// keeps no diagnostics state. Null (tests, unattributed clients) simply means no accounting. + /// + private readonly Action _traffic; + + private readonly object _sync = new object(); + private readonly Dictionary _sessions = new Dictionary(StringComparer.Ordinal); + private Timer _reaper; + + internal ProxyTunnelServer( + ProxyTunnelLimits limits, + Action log, + Func tlsValidationResolver = null, + Action traffic = null) + { + _limits = limits ?? ProxyTunnelLimits.Default(); + _log = log ?? (message => { }); + _tlsValidationResolver = tlsValidationResolver; + _traffic = traffic; + var period = TimeSpan.FromMilliseconds(Math.Max(1000, Math.Min(30_000, _limits.IdleTimeoutMs / 2))); + _reaper = new Timer(_ => ReapSafely(), null, period, period); + } + + internal int ActiveTunnels + { + get + { + lock (_sync) + { + return _sessions.Count; + } + } + } + + /// + /// Open one tunnel: connect, replay the handshake, and on 101 register the session and + /// start both pumps. is the caller-bound sender for this + /// tunnel's requester endpoint (unicast; a broadcast would leak another device's frames). + /// Never throws — every outcome is reported in the returned result. + /// + internal async Task OpenAsync(ProxyTunnelPayload open, Func toClient) + { + if (open == null || string.IsNullOrEmpty(open.CorrelationId) || toClient == null) + { + return OpenFailure(ProxyFetchErrorCodes.BadRequest, "The tunnel open carried no tunnel id or answer channel."); + } + + Uri target; + if (!Uri.TryCreate(open.Url, UriKind.Absolute, out target) || !ProxyPolicy.IsHttpUri(target)) + { + return OpenFailure( + ProxyFetchErrorCodes.BadRequest, + $"The tunnel target '{open.Url}' is not an absolute http(s) url; plain ws:// origins tunnel over http, wss:// origins over https."); + } + + lock (_sync) + { + if (_sessions.ContainsKey(open.CorrelationId)) + { + return OpenFailure(ProxyFetchErrorCodes.Error, "A tunnel with this id is already active."); + } + + if (_sessions.Count >= _limits.EsMaxTunnels) + { + return OpenFailure( + ProxyFetchErrorCodes.Error, + $"The Event Server is at its websocket tunnel limit ({_limits.EsMaxTunnels} concurrent tunnels); close some proxied pages and retry."); + } + } + + TcpClient socket = null; + try + { + socket = new TcpClient(); + var connectTask = socket.ConnectAsync(target.IdnHost, target.Port); + if (await Task.WhenAny(connectTask, Task.Delay(_limits.ConnectTimeoutMs)) != connectTask) + { + CloseSocket(socket); + return OpenFailure(ProxyFetchErrorCodes.Unreachable, $"{OriginOf(target)}: no tunnel connection within {_limits.ConnectTimeoutMs} ms."); + } + + await connectTask; + Stream stream = socket.GetStream(); + + // #60/#61: an https-scheme tunnel target (a wss upgrade) gets a TLS leg with the + // same per-target posture as the https fetcher: full-chain validation unless THIS + // target's allowlist entry carries the accept-untrusted override. The rebuilt + // handshake and every frame byte ride this SslStream; the browser's frames reach + // it already decrypted by the loopback tunnel termination, so this is the + // target's real wss leg - exactly the leg ADR-0002 keeps the security decision on. + if (target.Scheme == Uri.UriSchemeHttps) + { + var ssl = new SslStream( + socket.GetStream(), + false, + _tlsValidationResolver == null ? null : _tlsValidationResolver(target)); + stream = ssl; + + var authenticateTask = ssl.AuthenticateAsClientAsync(target.IdnHost); + if (await Task.WhenAny(authenticateTask, Task.Delay(_limits.HandshakeTimeoutMs)) != authenticateTask) + { + CloseSocket(socket); + return OpenFailure( + ProxyFetchErrorCodes.Timeout, + $"{OriginOf(target)}: the TLS handshake for the websocket tunnel did not complete within {_limits.HandshakeTimeoutMs} ms."); + } + + await authenticateTask; + } + + var handshake = ProxyWebSocketHandshake.BuildTargetHandshake(target, open.Headers); + var writeTask = stream.WriteAsync(handshake, 0, handshake.Length); + if (await Task.WhenAny(writeTask, Task.Delay(_limits.ConnectTimeoutMs)) != writeTask) + { + CloseSocket(socket); + return OpenFailure(ProxyFetchErrorCodes.Timeout, $"{OriginOf(target)}: the handshake request was not accepted within {_limits.ConnectTimeoutMs} ms."); + } + + await writeTask; + + var buffer = new byte[_limits.HandshakeMaxBytes]; + var received = 0; + int headLength; + while (!ProxyWebSocketHandshake.TryFindHandshakeEnd(buffer, received, out headLength)) + { + var readTask = stream.ReadAsync(buffer, received, buffer.Length - received); + if (await Task.WhenAny(readTask, Task.Delay(_limits.HandshakeTimeoutMs)) != readTask) + { + CloseSocket(socket); + return OpenFailure(ProxyFetchErrorCodes.Timeout, $"{OriginOf(target)}: no websocket handshake answer within {_limits.HandshakeTimeoutMs} ms."); + } + + var read = await readTask; + if (read <= 0) + { + CloseSocket(socket); + return OpenFailure(ProxyFetchErrorCodes.HandshakeRefused, $"{OriginOf(target)}: the target closed the connection without answering the websocket handshake."); + } + + received += read; + if (received >= buffer.Length) + { + CloseSocket(socket); + return OpenFailure(ProxyFetchErrorCodes.HandshakeRefused, $"{OriginOf(target)}: the websocket handshake head is past the {_limits.HandshakeMaxBytes} byte cap."); + } + } + + if (!ProxyWebSocketHandshake.IsUpgradeAccepted(buffer, headLength)) + { + var status = ReadStatusCode(buffer, headLength); + CloseSocket(socket); + return OpenFailure( + ProxyFetchErrorCodes.HandshakeRefused, + $"{OriginOf(target)}: the target answered the websocket upgrade with {status}, not 101.", + status); + } + + var session = new Session + { + Id = open.CorrelationId, + SessionId = open.SessionId, + Socket = socket, + Stream = stream, + ToClient = toClient, + LastActivityUtc = DateTime.UtcNow, + }; + + lock (_sync) + { + _sessions[open.CorrelationId] = session; + } + + _ = Task.Run(() => PumpClientToTargetAsync(session)); + _ = Task.Run(() => PumpTargetToClientAsync(session)); + + // The handshake read may already have buffered target frame bytes past the head: + // they are the tunnel's first data, relayed before the open outcome is even sent. + if (received > headLength) + { + var initial = new byte[received - headLength]; + Buffer.BlockCopy(buffer, headLength, initial, 0, initial.Length); + await SendDataAsync(session, initial); + } + + var handshakeBytes = new byte[headLength]; + Buffer.BlockCopy(buffer, 0, handshakeBytes, 0, headLength); + return new ProxyTunnelOpenOutcome + { + Success = true, + StatusCode = 101, + HandshakeBytes = handshakeBytes, + }; + } + catch (Exception exception) + { + CloseSocket(socket); + return ClassifyOpenFailure(target, exception); + } + } + + /// + /// Turns a target-connect or handshake failure into an explicit outcome. A chain refusal + /// is TlsValidation whether Schannel throws the AuthenticationException directly or wraps + /// it (live fail-closed runs showed an IOException wrapping), and the message quotes the + /// AuthenticationException itself so the wrapper never hides the real cause. A socket + /// failure anywhere in the chain is Unreachable; anything else is the generic Error. + /// + internal static ProxyTunnelOpenOutcome ClassifyOpenFailure(Uri target, Exception exception) + { + for (var inner = exception; inner != null; inner = inner.InnerException) + { + if (inner is AuthenticationException authenticationException) + { + return TlsValidationFailure(target, authenticationException); + } + + if (inner is SocketException) + { + return OpenFailure(ProxyFetchErrorCodes.Unreachable, $"{OriginOf(target)}: {exception.Message}"); + } + } + + return OpenFailure(ProxyFetchErrorCodes.Error, $"{OriginOf(target)}: {exception.Message}"); + } + + /// Relay client bytes toward the target. Unknown ids (closed/timed-out tunnels) are logged, not thrown. + internal void OnClientData(ProxyTunnelPayload frame) + { + var session = Find(frame?.CorrelationId); + if (session == null) + { + return; + } + + byte[] bytes; + try + { + bytes = string.IsNullOrEmpty(frame.DataBase64) ? new byte[0] : Convert.FromBase64String(frame.DataBase64); + } + catch (FormatException) + { + _log($"Tunnel {session.Id} data frame was not valid base64; the tunnel is closed."); + CloseSession(session, "the tunnel data frame was malformed", notifyClient: true); + return; + } + + if (bytes.Length > _limits.FrameMaxBytes) + { + _log($"Tunnel {session.Id} data frame ({bytes.Length} bytes) is past the {_limits.FrameMaxBytes} byte tunnel-frame cap; the tunnel is closed."); + CloseSession(session, "a tunnel data frame exceeded the frame cap", notifyClient: true); + return; + } + + if (bytes.Length == 0) + { + return; + } + + session.LastActivityUtc = DateTime.UtcNow; + _traffic?.Invoke(session.SessionId, bytes.Length, 0); + lock (_sync) + { + if (session.Closed) + { + return; + } + + session.Outbound.Enqueue(bytes); + var waiter = session.OutboundWaiter; + session.OutboundWaiter = null; + waiter?.TrySetResult(bytes); + } + } + + /// The client side ended the tunnel: drop it without echoing a close back. + internal void OnClientClose(ProxyTunnelPayload frame) + { + var session = Find(frame?.CorrelationId); + if (session != null) + { + CloseSession(session, frame.ErrorMessage ?? "the browser side closed the websocket", notifyClient: false); + } + } + + private void ReapSafely() + { + // An escaping exception on a Timer thread is an unhandled exception in the + // Event Server process - the sweep must swallow whatever a dying tunnel throws. + try + { + ReclaimIdle(); + } + catch (SocketException) { } + catch (ObjectDisposedException) { } + catch (Exception ex) + { + _log("ProxyTunnelServer: idle sweep faulted: " + ex.Message); + } + } + + /// Reclaim tunnels with no traffic in either direction for IdleTimeoutMs. + internal void ReclaimIdle() + { + List stale = null; + var cutoff = DateTime.UtcNow.AddMilliseconds(-_limits.IdleTimeoutMs); + lock (_sync) + { + if (_sessions.Count == 0) + { + return; + } + + foreach (var session in _sessions.Values) + { + if (!session.Closed && session.LastActivityUtc <= cutoff) + { + if (stale == null) + { + stale = new List(); + } + + stale.Add(session); + } + } + } + + foreach (var session in stale ?? Enumerable.Empty()) + { + _log($"Tunnel {session.Id} idle past {_limits.IdleTimeoutMs} ms; reclaimed."); + CloseSession(session, "the tunnel was idle past the proxy's reclaim timeout", notifyClient: true); + } + } + + /// Drop every tunnel (plugin teardown). The messaging session dies alongside, so no close frames are sent. + internal void CloseAll() + { + List all; + lock (_sync) + { + all = new List(_sessions.Values); + } + + foreach (var session in all) + { + CloseSession(session, "the proxy fetcher stopped", notifyClient: false); + } + } + + internal void Dispose() + { + var reaper = _reaper; + _reaper = null; + reaper?.Dispose(); + CloseAll(); + } + + private async Task PumpClientToTargetAsync(Session session) + { + try + { + while (true) + { + byte[] bytes; + lock (_sync) + { + if (session.Closed) + { + return; + } + + if (!session.Outbound.TryDequeue(out bytes)) + { + session.OutboundWaiter = new TaskCompletionSource(); + } + } + + if (bytes == null) + { + bytes = await session.OutboundWaiter.Task; + if (bytes == null) + { + return; + } + + lock (_sync) + { + if (!session.Outbound.TryDequeue(out bytes)) + { + continue; + } + } + } + + session.LastActivityUtc = DateTime.UtcNow; + await session.Stream.WriteAsync(bytes, 0, bytes.Length); + } + } + catch (Exception exception) + { + if (!SessionIsClosed(session)) + { + _log($"Tunnel {session.Id} target write failed: {exception.Message}"); + CloseSession(session, "the target connection failed", notifyClient: true); + } + } + } + + private async Task PumpTargetToClientAsync(Session session) + { + var buffer = new byte[_limits.FrameMaxBytes]; + try + { + while (true) + { + var read = await session.Stream.ReadAsync(buffer, 0, buffer.Length); + if (read <= 0) + { + if (!SessionIsClosed(session)) + { + _log($"Tunnel {session.Id}: the target closed the websocket."); + CloseSession(session, "the target closed the websocket", notifyClient: true); + } + + return; + } + + session.LastActivityUtc = DateTime.UtcNow; + var bytes = new byte[read]; + Buffer.BlockCopy(buffer, 0, bytes, 0, read); + await SendDataAsync(session, bytes); + } + } + catch (Exception exception) + { + // Socket disposal (idle reclaim, client close, teardown) is the designed exit of + // a parked read; anything else is a real target-side fault. + if (!SessionIsClosed(session)) + { + _log($"Tunnel {session.Id} target read failed: {exception.Message}"); + CloseSession(session, "the target connection failed", notifyClient: true); + } + } + } + + private async Task SendDataAsync(Session session, byte[] bytes) + { + _traffic?.Invoke(session.SessionId, 0, bytes.Length); + await session.ToClient(new ProxyTunnelPayload + { + CorrelationId = session.Id, + Kind = ProxyTunnelFrameKinds.Data, + DataBase64 = Convert.ToBase64String(bytes), + }); + } + + private void CloseSession(Session session, string reason, bool notifyClient) + { + lock (_sync) + { + if (session.Closed) + { + return; + } + + session.Closed = true; + _sessions.Remove(session.Id); + + var waiter = session.OutboundWaiter; + session.OutboundWaiter = null; + waiter?.TrySetResult(null); + } + + // Dispose the stream first: for a TLS leg that sends the close_notify and unwinds the + // pumps parked on it; then the socket, which is the caller's to own on the plain leg. + try { session.Stream?.Dispose(); } catch (Exception) { } + CloseSocket(session.Socket); + + if (notifyClient) + { + _ = SendCloseAsync(session, reason); + } + } + + private async Task SendCloseAsync(Session session, string reason) + { + try + { + await session.ToClient(new ProxyTunnelPayload + { + CorrelationId = session.Id, + Kind = ProxyTunnelFrameKinds.Close, + ErrorMessage = reason, + }); + } + catch (Exception exception) + { + _log($"Tunnel {session.Id} close notice not sent: {exception.Message}"); + } + } + + private Session Find(string tunnelId) + { + if (string.IsNullOrEmpty(tunnelId)) + { + return null; + } + + lock (_sync) + { + Session session; + return _sessions.TryGetValue(tunnelId, out session) && !session.Closed ? session : null; + } + } + + private bool SessionIsClosed(Session session) + { + lock (_sync) + { + return session.Closed; + } + } + + private static void CloseSocket(TcpClient socket) + { + try + { + socket?.Close(); + } + catch (Exception) + { + } + } + + private static int ReadStatusCode(byte[] buffer, int headLength) + { + var statusLine = Encoding.UTF8.GetString(buffer, 0, headLength).Split('\n')[0].Trim(); + int code; + return int.TryParse(statusLine.Split(' ').ElementAtOrDefault(1), out code) ? code : 0; + } + + private static string OriginOf(Uri target) => ProxyPolicy.CanonicalOrigin(target); + + /// + /// #60 AC: an unvalidatable TLS target fails with an explicit TLS-validation error + /// naming the target origin and the failing side (the Event Server's leg). Quoting the + /// authentication cause keeps the Schannel-wrapped case as informative as the raw one. + /// + private static ProxyTunnelOpenOutcome TlsValidationFailure(Uri target, Exception cause) => + OpenFailure( + ProxyFetchErrorCodes.TlsValidation, + $"{OriginOf(target)}: the Event Server refused the target's TLS certificate on the websocket tunnel leg: {cause.Message}"); + + private static ProxyTunnelOpenOutcome OpenFailure(string errorCode, string message, int statusCode = 0) + { + return new ProxyTunnelOpenOutcome + { + Success = false, + StatusCode = statusCode, + ErrorCode = errorCode, + ErrorMessage = message, + }; + } + } +} diff --git a/src/WebContent/ProxyViewRefusal.cs b/src/WebContent/ProxyViewRefusal.cs new file mode 100644 index 0000000..12ca9ba --- /dev/null +++ b/src/WebContent/ProxyViewRefusal.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// Ticket #88: the view-layer refusal for https requests an off-allowlist origin would carry. + /// + /// Root cause this answers: Chromium transports every https target to the loopback listener + /// as a CONNECT, and Chromium treats any non-2xx CONNECT answer as a tunnel refusal and drops + /// its body outright. The forwarder's 403 + friendly page (which it still writes for a refused + /// CONNECT, pinned by ProxyForwardConnectionTests) therefore never reaches the screen - the + /// browser renders its native ERR_TUNNEL_CONNECTION_FAILED page instead. Only absolute-form + /// plain-http requests can ride an HTTP response, so #58/#66 pages were reachable for http + /// refusals only. + /// + /// The fix refuses at the browser layer instead of styling the CONNECT answer: a proxied + /// WebView2 consults this gate through its WebResourceRequested event, BEFORE Chromium opens + /// any CONNECT. A refused https request gets the same friendly 403 page as the http posture, + /// committed as the document at the requested URL - history, address bar, and status code all + /// match the http row. The decision uses the ONE shared matcher with + /// the same entries and the same VMS floor the listener gates on (pinned by + /// ProxyAllowlistGateAgreementTests), so the view can never disagree with the proxy. + /// + /// What deliberately stays on the old path: plain http (absolute-form - the proxy's own 403 + /// page already reaches the screen) and ws:// / wss:// upgrades (WebResourceRequested does not + /// surface websocket requests, so those refusals keep the CONNECT posture and the native page + /// - documented in README + verification matrix). Fail-closed is untouched: this class only + /// answers refusals locally; it never widens what may load, and the CONNECT pinning still + /// forbids any direct route. + /// + internal static class ProxyViewRefusal + { + /// + /// True when this https request's origin is outside the workspace allowlist - the view + /// must then answer the refusal itself. Non-https uris (http rides the proxy's + /// absolute-form path) and allowlisted origins answer false and reach the network unchanged. + /// On refusal, carries the verbatim technical sentence and + /// the #66 friendly 403 page (NotAllowed cause chip, the + /// exact origin, and the canonical https entry that would admit it). + /// + internal static bool TryRefuseHttpsRequest( + IEnumerable canonicalTargets, + IEnumerable vmsFloorHosts, + Uri requestUri, + string method, + out string rejectReason, + out string refusalHtml) + { + rejectReason = null; + refusalHtml = null; + + // The CONNECT posture only exists for https; every other scheme already reaches a + // proxy-rendered page (or never enters a proxied browser at all). + if (requestUri == null || !string.Equals(requestUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (ProxyPolicy.IsRequestAllowed(canonicalTargets, requestUri, vmsFloorHosts)) + { + return false; + } + + // The SAME sentence shape the forward gate writes for an http refusal, so the + // diagnostics read identically whichever layer decided the refusal. + rejectReason = $"The request origin '{ProxyPolicy.CanonicalOrigin(requestUri)}' is not in the workspace proxy allowlist."; + + var details = new ProxyErrorPage.Details + { + Method = string.IsNullOrWhiteSpace(method) ? null : method, + RequestUri = requestUri, + RawTarget = requestUri.AbsoluteUri, + TechnicalMessage = rejectReason + " This https request cannot ride the loopback proxy's 403 page (Chromium tunnels https as CONNECT and discards a refused CONNECT's body), so the browser view answered the refusal at the view layer before any CONNECT was opened.", + DecidedBy = ProxyErrorPage.DecidedByLocalListener, + }.WithSuggestedAllowlistEntry(); + + refusalHtml = ProxyErrorPage.RenderHtml(403, "Forbidden", ProxyErrorPage.CategoryNotAllowed, details); + return true; + } + } +} diff --git a/src/WebContent/ProxyWebSocketHandshake.cs b/src/WebContent/ProxyWebSocketHandshake.cs new file mode 100644 index 0000000..83e6bd6 --- /dev/null +++ b/src/WebContent/ProxyWebSocketHandshake.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text; + +namespace WebContent +{ + /// + /// Pure websocket-handshake seam for the #57 CONNECT-style tunnel. A plain ws:// request + /// reaches the loopback forwarding proxy as an absolute-form GET carrying the RFC 6455 + /// upgrade headers; this class recognizes that request shape, validates the mandatory + /// client-handshake fields, rebuilds the origin-form handshake the Event Server replays at + /// the target, and validates the target's 101 response. Nothing here touches a socket — the + /// byte plumbing lives in (client side) and + /// (Event Server side). + /// + internal static class ProxyWebSocketHandshake + { + /// The only RFC 6455 client version browsers send and the only one relayed. + internal const string SupportedVersion = "13"; + + /// + /// True when the parsed request is a websocket protocol upgrade: a GET whose Connection + /// header lists the upgrade token and whose Upgrade header names websocket. Other upgrade + /// protocols (h2c and friends) are not websockets and stay on the ordinary forward path. + /// + internal static bool IsWebSocketUpgrade(ProxyForwardedRequest request) + { + if (request == null || request.Method != "GET") + { + return false; + } + + string connection; + request.Headers.TryGetValue("Connection", out connection); + if (!ConnectionCarriesUpgrade(connection)) + { + return false; + } + + string upgrade; + request.Headers.TryGetValue("Upgrade", out upgrade); + return string.Equals((upgrade ?? string.Empty).Trim(), "websocket", StringComparison.OrdinalIgnoreCase); + } + + /// + /// A tunnel-replayable upgrade needs the mandatory RFC 6455 client fields: a non-blank + /// Sec-WebSocket-Key and Sec-WebSocket-Version 13. Anything else is a malformed request, + /// refused with 400 rather than half-tunnelled. + /// + internal static bool HasValidClientHandshake(ProxyForwardedRequest request, out string rejectReason) + { + rejectReason = null; + + string key; + request.Headers.TryGetValue("Sec-WebSocket-Key", out key); + if (string.IsNullOrWhiteSpace(key)) + { + rejectReason = "The websocket upgrade carries no Sec-WebSocket-Key; a relayable upgrade needs the full RFC 6455 client handshake."; + return false; + } + + string version; + request.Headers.TryGetValue("Sec-WebSocket-Version", out version); + if (!string.Equals((version ?? string.Empty).Trim(), SupportedVersion, StringComparison.Ordinal)) + { + rejectReason = $"The websocket upgrade declares Sec-WebSocket-Version '{version}'; only version {SupportedVersion} is relayed."; + return false; + } + + return true; + } + + /// + /// Request headers the tunnel handshake must NOT carry to the target: per-hop proxy + /// headers and Host (restated from the target uri). Unlike the HTTP forward path, Upgrade + /// and Connection ride along — the whole point of the tunnel is that the target answers + /// the upgrade — and Sec-WebSocket-Key passes through byte-identical so the target's + /// Sec-WebSocket-Accept matches what the browser will verify. + /// + private static readonly HashSet HandshakeBlockedHeaders = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Host", + // The rebuilt handshake states Connection/Upgrade itself (explicit and canonical); + // relaying the browser's pair too would send two Connection lines and a target + // could reject the malformed upgrade. + "Connection", + "Upgrade", + "Proxy-Connection", + "Proxy-Authenticate", + "Proxy-Authorization", + "Content-Length", + "Transfer-Encoding", + "TE", + "Trailer", + "Keep-Alive", + }; + + /// + /// Rebuild the origin-form handshake request the Event Server writes onto its raw socket + /// to the target: request line against the target's path+query, a Host derived from the + /// uri (never from the client), the relayed headers, and an explicit "Connection: Upgrade" + /// / "Upgrade: websocket" pair regardless of how the browser spelled its Connection list. + /// + internal static byte[] BuildTargetHandshake(Uri target, List relayHeaders) + { + var pathAndQuery = string.IsNullOrEmpty(target.PathAndQuery) ? "/" : target.PathAndQuery; + var head = new StringBuilder(); + head.Append($"GET {pathAndQuery} HTTP/1.1\r\n"); + head.Append($"Host: {GetHostHeader(target)}\r\n"); + + if (relayHeaders != null) + { + foreach (var header in relayHeaders) + { + if (header == null || string.IsNullOrEmpty(header.Name) || header.Value == null) + { + continue; + } + + // A wire-supplied value with line breaks could inject headers at the target; + // the gate lives here, same as the fetcher's own header replay. + if (header.Value.IndexOf('\r') >= 0 || header.Value.IndexOf('\n') >= 0 || + header.Name.IndexOf(':') >= 0 || HandshakeBlockedHeaders.Contains(header.Name)) + { + continue; + } + + head.Append($"{header.Name}: {header.Value}\r\n"); + } + } + + head.Append("Connection: Upgrade\r\n"); + head.Append("Upgrade: websocket\r\n"); + head.Append("\r\n"); + return Encoding.UTF8.GetBytes(head.ToString()); + } + + /// + /// The headers to carry on the tunnel-open wire message: the browser's parsed headers + /// minus the hop-by-hop/proxy set. Casing and values are untouched. + /// + internal static List ToTunnelWireHeaders(ProxyForwardedRequest request) + { + var outbound = new List(); + foreach (var header in request.Headers) + { + if (HandshakeBlockedHeaders.Contains(header.Key)) + { + continue; + } + + outbound.Add(new ProxyFetchHeader { Name = header.Key, Value = header.Value }); + } + + return outbound; + } + + /// + /// The verdict on the target's handshake response bytes: complete (with status), still + /// incomplete, or overrun. The head terminator is CRLFCRLF (bare-LF tolerated), matching + /// the request-head reader's rules. + /// + internal static bool TryFindHandshakeEnd(byte[] buffer, int received, out int headLength) + { + headLength = -1; + for (var i = 0; i + 1 < received; i++) + { + if (buffer[i] == '\n' && buffer[i + 1] == '\n') + { + headLength = i + 2; + return true; + } + + if (i + 3 < received && + buffer[i] == '\r' && buffer[i + 1] == '\n' && buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + { + headLength = i + 4; + return true; + } + } + + return false; + } + + /// + /// True when the handshake response status line is HTTP/1.1 (or HTTP/1.0) 101 — the only + /// answer that admits the tunnel. Any other status (a plain 200 on the ws path, a vendor's + /// 401) means the target refused the upgrade. + /// + internal static bool IsUpgradeAccepted(byte[] headBytes, int headLength) + { + var head = Encoding.UTF8.GetString(headBytes, 0, Math.Min(headLength, headBytes.Length)); + var firstLine = head.Split('\n')[0].Trim(); + var parts = firstLine.Split(' '); + return parts.Length >= 2 && + parts[0].StartsWith("HTTP/1", StringComparison.Ordinal) && + parts[1] == "101"; + } + + private static bool ConnectionCarriesUpgrade(string connection) + { + if (string.IsNullOrEmpty(connection)) + { + return false; + } + + // Connection is a comma list ("keep-alive, Upgrade" is what Chromium actually sends + // for a websocket request through a proxy). + foreach (var token in connection.Split(',')) + { + if (string.Equals(token.Trim(), "upgrade", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string GetHostHeader(Uri target) + { + // Host header rules: omit the port only for the scheme default. Uri.IsDefaultPort + // already speaks http/https, which is all the tunnel relays. + return target.IsDefaultPort ? target.IdnHost : $"{target.IdnHost}:{target.Port}"; + } + } +} diff --git a/src/WebContent/ProxyWorkspaceProfile.cs b/src/WebContent/ProxyWorkspaceProfile.cs new file mode 100644 index 0000000..8f55cfd --- /dev/null +++ b/src/WebContent/ProxyWorkspaceProfile.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; + +namespace WebContent +{ + /// + /// Per-view-item proxy state for browser-profile creation (#54): the admin-owned ProxyEnabled + /// toggle plus the canonical ProxyTargets property the #52 seeding path publishes to the view + /// item. An enabled proxy always produces a profile — with no usable targets the listener + /// still fronts the browser and refuses everything, which is the "never direct" posture. + /// #73: the canonical entries may be wildcard patterns, and the profile carries the VMS's own + /// host names so the listener's matcher applies the wildcard SSRF floor identically to the + /// Event Server's gate. + /// + internal sealed class ProxyWorkspaceProfile + { + /// Canonical origins/patterns the loopback listener may forward to; may be empty. + internal IReadOnlyList CanonicalTargets { get; private set; } + + /// #73: VMS host names the wildcard SSRF floor is applied against; may be empty. + internal IReadOnlyList VmsFloorHosts { get; private set; } + + /// The workspace view item this profile was resolved for (#69 diagnostics + /// attribution); empty for designer/standalone paths that have no workspace item. + internal string WorkspaceItemId { get; private set; } + + /// Fingerprint of the admin definition that seeded the view (#69 freshness + /// context on diagnostics payloads); empty for standalone items. + internal string WorkspaceFingerprint { get; private set; } + + internal static bool TryCreate(bool proxyEnabled, string canonicalTargetsProperty, string workspaceItemId, string workspaceFingerprint, out ProxyWorkspaceProfile profile) + { + return TryCreate(proxyEnabled, canonicalTargetsProperty, workspaceItemId, workspaceFingerprint, out profile, null); + } + + /// #73: the VMS host names the host code resolved from + /// EnvironmentManager; null/empty just means the matcher's syntax floor (IPs, localhost). + internal static bool TryCreate( + bool proxyEnabled, + string canonicalTargetsProperty, + string workspaceItemId, + string workspaceFingerprint, + out ProxyWorkspaceProfile profile, + IEnumerable vmsFloorHosts) + { + profile = null; + if (!proxyEnabled) + { + return false; + } + + var origins = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in (canonicalTargetsProperty ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + // #73: TryNormalizeTarget accepts both the exact-origin shape and wildcard + // patterns, so a seeded list carrying "https://*.tail" survives seeding intact. + if (ProxyPolicy.TryNormalizeTarget(entry, out var normalized) && seen.Add(normalized)) + { + origins.Add(normalized); + } + } + + var floorHosts = new List(); + var seenFloor = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var host in vmsFloorHosts ?? new string[0]) + { + if (!string.IsNullOrWhiteSpace(host) && seenFloor.Add(host.Trim().ToLowerInvariant())) + { + floorHosts.Add(host.Trim().ToLowerInvariant()); + } + } + + // An enabled proxy always fronts the browser: an empty usable list refuses everything, + // which keeps the "never direct" promise for corrupt or half-configured definitions. + profile = new ProxyWorkspaceProfile + { + CanonicalTargets = origins, + VmsFloorHosts = floorHosts, + WorkspaceItemId = workspaceItemId ?? string.Empty, + WorkspaceFingerprint = workspaceFingerprint ?? string.Empty, + }; + return true; + } + } +} diff --git a/src/WebContent/Strings.Designer.cs b/src/WebContent/Strings.Designer.cs index 1c7b043..4bbb2ce 100644 --- a/src/WebContent/Strings.Designer.cs +++ b/src/WebContent/Strings.Designer.cs @@ -392,5 +392,806 @@ internal static string WorkspaceConfigErrorMissingBrowserConfig { return ResourceManager.GetString("WorkspaceConfigErrorMissingBrowserConfig", resourceCulture); } } + + /// + /// Looks up a localized string similar to Route web traffic through the VMS server proxy. + /// + internal static string ProxyEnabledLabel { + get { + return ResourceManager.GetString("ProxyEnabledLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Proxy targets. + /// + internal static string ProxyTargetsLabel { + get { + return ResourceManager.GetString("ProxyTargetsLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to One row per allowlisted target: pick the scheme, enter the host, and add a port only when the target is not on the scheme default. Wildcards: *.foo.bar.com allows that domain's subdomains (never the apex itself), and a lone * allows any host of that scheme except literal IP addresses, localhost and this VMS's own hosts - list those explicitly. Only allowlisted targets can be reached through the proxy.. + /// + internal static string ProxyTargetsHint { + get { + return ResourceManager.GetString("ProxyTargetsHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add target. + /// + internal static string ProxyAddTargetButton { + get { + return ResourceManager.GetString("ProxyAddTargetButton", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scheme. + /// + internal static string ProxyColumnScheme { + get { + return ResourceManager.GetString("ProxyColumnScheme", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Host. + /// + internal static string ProxyColumnHost { + get { + return ResourceManager.GetString("ProxyColumnHost", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Port. + /// + internal static string ProxyColumnPort { + get { + return ResourceManager.GetString("ProxyColumnPort", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Accept untrusted. + /// + internal static string ProxyColumnAcceptUntrusted { + get { + return ResourceManager.GetString("ProxyColumnAcceptUntrusted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Problem. + /// + internal static string ProxyColumnProblem { + get { + return ResourceManager.GetString("ProxyColumnProblem", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} row(s) in the allowlist. + /// + internal static string ProxyRowCounterFormat { + get { + return ResourceManager.GetString("ProxyRowCounterFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The allowlist contains the full-host wildcard (*): the Event Server will fetch ANY host on that scheme - every host except literal IP addresses, localhost and this VMS's own hosts. Use *.domain.com to bound a wildcard to one domain.. + /// + internal static string ProxyWildcardWarning { + get { + return ResourceManager.GetString("ProxyWildcardWarning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A host or wildcard pattern is required.. + /// + internal static string ProxyRowErrorHostRequired { + get { + return ResourceManager.GetString("ProxyRowErrorHostRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Port must be a number between 0 and 65535; leave it empty for the scheme default.. + /// + internal static string ProxyRowErrorPortInvalid { + get { + return ResourceManager.GetString("ProxyRowErrorPortInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a valid host or wildcard pattern.. + /// + internal static string ProxyRowErrorHostInvalid { + get { + return ResourceManager.GetString("ProxyRowErrorHostInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} is not a valid proxy target. Fix the highlighted row or remove it before saving.. + /// + internal static string InvalidProxyTargetMessage { + get { + return ResourceManager.GetString("InvalidProxyTargetMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The proxy is enabled but no target is allowlisted. Add at least one proxy target or disable the proxy.. + /// + internal static string MissingProxyTargetsMessage { + get { + return ResourceManager.GetString("MissingProxyTargetsMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Max proxied response size (MB). + /// + internal static string ProxyCapLabel { + get { + return ResourceManager.GetString("ProxyCapLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Bigger responses are refused with a "response too large" page. Default 5 MB, maximum 20 MB; leave empty for the default. Applies to proxied fetch traffic only - streamed traffic like websockets is not size-capped.. + /// + internal static string ProxyCapHint { + get { + return ResourceManager.GetString("ProxyCapHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a valid proxy response size. Enter a whole number of megabytes between 1 and 20, or leave the field empty for the default 5 MB.. + /// + internal static string InvalidProxyCapMessage { + get { + return ResourceManager.GetString("InvalidProxyCapMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Advanced: headers. + /// + internal static string ProxyHeadersSectionLabel { + get { + return ResourceManager.GetString("ProxyHeadersSectionLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add, override or strip headers on each leg of the proxied traffic: the request list rewrites what the VMS Event Server sends to the target, the response list rewrites what comes back. Connection-level headers (Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, any Proxy- header, Host, Content-Length) are always stripped by the proxy and can never be configured here. Nothing is added by default - the proxy adds no Via or X-Forwarded-For of its own, so the VMS topology stays invisible to the target unless you choose to disclose it.. + /// + internal static string ProxyHeadersHint { + get { + return ResourceManager.GetString("ProxyHeadersHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Request headers. + /// + internal static string ProxyRequestHeadersLabel { + get { + return ResourceManager.GetString("ProxyRequestHeadersLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Response headers. + /// + internal static string ProxyResponseHeadersLabel { + get { + return ResourceManager.GetString("ProxyResponseHeadersLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add request header. + /// + internal static string ProxyHeaderAddRequestButton { + get { + return ResourceManager.GetString("ProxyHeaderAddRequestButton", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add response header. + /// + internal static string ProxyHeaderAddResponseButton { + get { + return ResourceManager.GetString("ProxyHeaderAddResponseButton", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Header. + /// + internal static string ProxyColumnHeaderName { + get { + return ResourceManager.GetString("ProxyColumnHeaderName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Value. + /// + internal static string ProxyColumnHeaderValue { + get { + return ResourceManager.GetString("ProxyColumnHeaderValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Strip. + /// + internal static string ProxyColumnHeaderStrip { + get { + return ResourceManager.GetString("ProxyColumnHeaderStrip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} rule(s). + /// + internal static string ProxyHeaderRowCounterFormat { + get { + return ResourceManager.GetString("ProxyHeaderRowCounterFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A header name is required.. + /// + internal static string ProxyHeaderRowErrorNameRequired { + get { + return ResourceManager.GetString("ProxyHeaderRowErrorNameRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a valid header name (letters, digits and the characters ! # $ % & ' * + - . ^ _ ` | ~ are allowed).. + /// + internal static string ProxyHeaderRowErrorNameInvalid { + get { + return ResourceManager.GetString("ProxyHeaderRowErrorNameInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' is reserved for the proxy connection and cannot be configured.. + /// + internal static string ProxyHeaderRowErrorForbidden { + get { + return ResourceManager.GetString("ProxyHeaderRowErrorForbidden", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter a value for '{0}', or tick Strip to remove the header instead.. + /// + internal static string ProxyHeaderRowErrorValueRequired { + get { + return ResourceManager.GetString("ProxyHeaderRowErrorValueRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The header value must not contain a line break.. + /// + internal static string ProxyHeaderRowErrorValueLineBreak { + get { + return ResourceManager.GetString("ProxyHeaderRowErrorValueLineBreak", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The proxy header policy has invalid row(s): {0}. Fix the highlighted rows or remove them before saving.. + /// + internal static string InvalidProxyHeaderMessage { + get { + return ResourceManager.GetString("InvalidProxyHeaderMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rule triggers. + /// + internal static string ProxyTriggersSectionLabel { + get { + return ResourceManager.GetString("ProxyTriggersSectionLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to One row per trigger: when proxied traffic matches the URL pattern, the method, and the response status, the VMS Event Server raises the XProtect event type shown in the Event raised column ("WebContent proxy request").. + /// + internal static string ProxyTriggersHint { + get { + return ResourceManager.GetString("ProxyTriggersHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to URL pattern. + /// + internal static string ProxyColumnTriggerUrl { + get { + return ResourceManager.GetString("ProxyColumnTriggerUrl", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Method. + /// + internal static string ProxyColumnTriggerMethod { + get { + return ResourceManager.GetString("ProxyColumnTriggerMethod", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Status. + /// + internal static string ProxyColumnTriggerStatus { + get { + return ResourceManager.GetString("ProxyColumnTriggerStatus", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Event raised. + /// + internal static string ProxyColumnTriggerEvent { + get { + return ResourceManager.GetString("ProxyColumnTriggerEvent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add trigger. + /// + internal static string ProxyTriggerAddButton { + get { + return ResourceManager.GetString("ProxyTriggerAddButton", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} trigger(s). + /// + internal static string ProxyTriggerRowCounterFormat { + get { + return ResourceManager.GetString("ProxyTriggerRowCounterFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A trigger URL pattern is required.. + /// + internal static string ProxyTriggerRowErrorUrlRequired { + get { + return ResourceManager.GetString("ProxyTriggerRowErrorUrlRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a valid trigger URL pattern (scheme://host[:port][/path-glob], or the lone *).. + /// + internal static string ProxyTriggerRowErrorUrlInvalid { + get { + return ResourceManager.GetString("ProxyTriggerRowErrorUrlInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a method filter (one verb, or * for any method).. + /// + internal static string ProxyTriggerRowErrorMethodInvalid { + get { + return ResourceManager.GetString("ProxyTriggerRowErrorMethodInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' is not a status filter (blank for any, a class like 4xx, or an exact code 100-599).. + /// + internal static string ProxyTriggerRowErrorStatusInvalid { + get { + return ResourceManager.GetString("ProxyTriggerRowErrorStatusInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The proxy trigger list has invalid row(s): {0}. Fix the highlighted rows or remove them before saving.. + /// + internal static string InvalidProxyTriggerMessage { + get { + return ResourceManager.GetString("InvalidProxyTriggerMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Proxy:. + /// + internal static string ProxyLabel { + get { + return ResourceManager.GetString("ProxyLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Managed by Administrator. + /// + internal static string ManagedByAdministratorLabel { + get { + return ResourceManager.GetString("ManagedByAdministratorLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Disabled - browser connects directly. + /// + internal static string ProxyDisplayDisabled { + get { + return ResourceManager.GetString("ProxyDisplayDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enabled - no targets allowlisted. + /// + internal static string ProxyDisplayEnabledNoTargets { + get { + return ResourceManager.GetString("ProxyDisplayEnabledNoTargets", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enabled - allowlisted targets:. + /// + internal static string ProxyDisplayEnabledPrefix { + get { + return ResourceManager.GetString("ProxyDisplayEnabledPrefix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Test proxy fetch. + /// + internal static string ProxyTestFetchLabel { + get { + return ResourceManager.GetString("ProxyTestFetchLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends a proxy-fetch request from this Management Client across MIP messaging to the Event Server and reports what came back. Requires the Web Content plugin loaded on the Event Server (restart the Event Server service after deployment).. + /// + internal static string ProxyTestFetchHint { + get { + return ResourceManager.GetString("ProxyTestFetchHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fetching through the Event Server.... + /// + internal static string ProxyTestFetchRunning { + get { + return ResourceManager.GetString("ProxyTestFetchRunning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The proxy fetch harness is not connected. Check the Event Server log (MIP.log) and restart the Management Client after a plugin deployment.. + /// + internal static string ProxyTestFetchNotConnected { + get { + return ResourceManager.GetString("ProxyTestFetchNotConnected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to HTTP {0} - {1} bytes fetched in {2:0.0}s. + /// + internal static string ProxyTestFetchSuccessFormat { + get { + return ResourceManager.GetString("ProxyTestFetchSuccessFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Proxy fetch {0} after {1:0.0}s - {2}. + /// + internal static string ProxyTestFetchFailureFormat { + get { + return ResourceManager.GetString("ProxyTestFetchFailureFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} live proxy session(s) - {1:0.0} requests/min, sent {2}/s, received {3}/s. + /// + internal static string StatusSummaryFormat { + get { + return ResourceManager.GetString("StatusSummaryFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No live proxy sessions on this workspace right now. Open a Web Content workspace tab in a Smart Client to see it here. + /// + internal static string StatusNoSessions { + get { + return ResourceManager.GetString("StatusNoSessions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No status from the Event Server: this Management Client has no proxy messaging channel to it.. + /// + internal static string StatusNotConnected { + get { + return ResourceManager.GetString("StatusNotConnected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Event Server does not know this workspace any more, so it has no proxy sessions to report.. + /// + internal static string StatusWorkspaceGone { + get { + return ResourceManager.GetString("StatusWorkspaceGone", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Proxy status query failed ({0}) - {1}. + /// + internal static string StatusQueryFailedFormat { + get { + return ResourceManager.GetString("StatusQueryFailedFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No workspace item context: this page cannot ask the Event Server for a status snapshot.. + /// + internal static string StatusNoItemContext { + get { + return ResourceManager.GetString("StatusNoItemContext", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} blocked proxy origin(s) reported for this workspace since the Event Server started tracking. Newest refusal first. The list lives in the Event Server's memory only - it resets when the Event Server service restarts.. + /// + internal static string DiagnosticsSummaryFormat { + get { + return ResourceManager.GetString("DiagnosticsSummaryFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nothing has been blocked on this workspace since the Event Server started tracking. As Smart Clients open proxied views and the proxy refuses their requests, every refused origin appears here with its cause and hit count. The list lives in the Event Server's memory only - it resets when the Event Server service restarts.. + /// + internal static string DiagnosticsEmpty { + get { + return ResourceManager.GetString("DiagnosticsEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to on allowlist. + /// + internal static string DiagnosticsStatusAllowlisted { + get { + return ResourceManager.GetString("DiagnosticsStatusAllowlisted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to certificate trust, not the allowlist. + /// + internal static string DiagnosticsStatusCertTrust { + get { + return ResourceManager.GetString("DiagnosticsStatusCertTrust", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not add '{0}' to the allowlist: it is not an exact proxy origin the allowlist can name.. + /// + internal static string DiagnosticsPromotionRefusedFormat { + get { + return ResourceManager.GetString("DiagnosticsPromotionRefusedFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} client machine(s) report that the proxy certificate is NOT trusted on their PC. Export it here and distribute it to those machines - their users see a warning banner, and proxied HTTPS pages show a certificate error, until the certificate sits in those machines' trusted roots.. + /// + internal static string CaUntrustedBannerFormat { + get { + return ResourceManager.GetString("CaUntrustedBannerFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Machines: {0}. + /// + internal static string CaUntrustedMachinesFormat { + get { + return ResourceManager.GetString("CaUntrustedMachinesFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} ({1} report(s)). + /// + internal static string CaUntrustedMachineFormat { + get { + return ResourceManager.GetString("CaUntrustedMachineFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 1 resource blocked. + /// + internal static string BlockedResourcesIndicatorOne { + get { + return ResourceManager.GetString("BlockedResourcesIndicatorOne", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} resources blocked. + /// + internal static string BlockedResourcesIndicatorMany { + get { + return ResourceManager.GetString("BlockedResourcesIndicatorMany", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The proxy refused page resources during this page load. Click to list them.. + /// + internal static string BlockedResourcesToolTip { + get { + return ResourceManager.GetString("BlockedResourcesToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Blocked page resources (this page load):. + /// + internal static string BlockedResourcesHeader { + get { + return ResourceManager.GetString("BlockedResourcesHeader", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Asking the Event Server for its proxy authority certificate…. + /// + internal static string ProxyCaExportRunning { + get { + return ResourceManager.GetString("ProxyCaExportRunning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No answer from the Event Server: this Management Client has no proxy messaging channel to it. Check the Event Server log (MIP.log) and restart the Management Client after a plugin deployment.. + /// + internal static string ProxyCaExportNotConnected { + get { + return ResourceManager.GetString("ProxyCaExportNotConnected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The proxy authority certificate does not exist yet on the Event Server. It is created the first time a proxied HTTPS target is used, or you can provision it once on the Event Server machine - see the plugin README.. + /// + internal static string ProxyCaExportMissing { + get { + return ResourceManager.GetString("ProxyCaExportMissing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Proxy certificate export failed ({0}) - {1}. + /// + internal static string ProxyCaExportFailedFormat { + get { + return ResourceManager.GetString("ProxyCaExportFailedFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Save the proxy authority certificate. + /// + internal static string ProxyCaExportSaveTitle { + get { + return ResourceManager.GetString("ProxyCaExportSaveTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to X.509 certificate (DER)|*.cer. + /// + internal static string ProxyCaExportSaveFilter { + get { + return ResourceManager.GetString("ProxyCaExportSaveFilter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Export cancelled - nothing was written.. + /// + internal static string ProxyCaExportCancelled { + get { + return ResourceManager.GetString("ProxyCaExportCancelled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Saved {0} (thumbprint {1}). Distribute it to Smart Client machines' LocalMachine\Root trusted-root store - the recommended path is Active Directory Group Policy; see the plugin README.. + /// + internal static string ProxyCaExportSavedFormat { + get { + return ResourceManager.GetString("ProxyCaExportSavedFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The proxy certificate is not trusted on this PC - contact your administrator.. + /// + internal static string ProxyCaBannerText { + get { + return ResourceManager.GetString("ProxyCaBannerText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Fix certificate trust. + /// + internal static string ProxyCaFixButtonLabel { + get { + return ResourceManager.GetString("ProxyCaFixButtonLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Starts the bundled elevated helper to import this proxy's certificate authority into this machine's Trusted Root store. Windows will show its elevation prompt and its own root-store warning - import this certificate only if you trust the VMS administrator who deployed the proxy.. + /// + internal static string ProxyCaFixButtonToolTip { + get { + return ResourceManager.GetString("ProxyCaFixButtonToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dismiss this notice for the rest of this Smart Client session. It reappears in the next session while the certificate is still not trusted.. + /// + internal static string ProxyCaDismissButtonToolTip { + get { + return ResourceManager.GetString("ProxyCaDismissButtonToolTip", resourceCulture); + } + } } } diff --git a/src/WebContent/Strings.resx b/src/WebContent/Strings.resx index 9ed1c1b..23fd8d0 100644 --- a/src/WebContent/Strings.resx +++ b/src/WebContent/Strings.resx @@ -231,4 +231,271 @@ Workspace + + Route web traffic through the VMS server proxy + + + Proxy targets + + + One row per allowlisted target: pick the scheme, enter the host, and add a port only when the target is not on the scheme default. Wildcards: *.foo.bar.com allows that domain's subdomains (never the apex itself), and a lone * allows any host of that scheme except literal IP addresses, localhost and this VMS's own hosts - list those explicitly. Only allowlisted targets can be reached through the proxy. + + + Add target + + + Scheme + + + Host + + + Port + + + Accept untrusted + + + Problem + + + {0} row(s) in the allowlist + + + The allowlist contains the full-host wildcard (*): the Event Server will fetch ANY host on that scheme - every host except literal IP addresses, localhost and this VMS's own hosts. Use *.domain.com to bound a wildcard to one domain. + + + A host or wildcard pattern is required. + + + Port must be a number between 0 and 65535; leave it empty for the scheme default. + + + '{0}' is not a valid host or wildcard pattern. + + + {0} is not a valid proxy target. Fix the highlighted row or remove it before saving. + + + The proxy is enabled but no target is allowlisted. Add at least one proxy target or disable the proxy. + + + Max proxied response size (MB) + + + Bigger responses are refused with a "response too large" page. Default 5 MB, maximum 20 MB; leave empty for the default. Applies to proxied fetch traffic only - streamed traffic like websockets is not size-capped. + + + '{0}' is not a valid proxy response size. Enter a whole number of megabytes between 1 and 20, or leave the field empty for the default 5 MB. + + + Advanced: headers + + + Add, override or strip headers on each leg of the proxied traffic: the request list rewrites what the VMS Event Server sends to the target, the response list rewrites what comes back. Connection-level headers (Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, Upgrade, any Proxy- header, Host, Content-Length) are always stripped by the proxy and can never be configured here. Nothing is added by default - the proxy adds no Via or X-Forwarded-For of its own, so the VMS topology stays invisible to the target unless you choose to disclose it. + + + Request headers + + + Response headers + + + Add request header + + + Add response header + + + Header + + + Value + + + Strip + + + {0} rule(s) + + + A header name is required. + + + '{0}' is not a valid header name (letters, digits and the characters ! # $ % & ' * + - . ^ _ ` | ~ are allowed). + + + '{0}' is reserved for the proxy connection and cannot be configured. + + + Enter a value for '{0}', or tick Strip to remove the header instead. + + + The header value must not contain a line break. + + + The proxy header policy has invalid row(s): {0}. Fix the highlighted rows or remove them before saving. + + + Rule triggers + + + One row per trigger: when proxied traffic matches the URL pattern, the method, and the response status, the VMS Event Server raises the XProtect event type shown in the Event raised column ("WebContent proxy request"). Find that event type in the XProtect rule builder to react to it. URL pattern: scheme://host[:port][/path-glob] - * matches any run of characters (including /), ? exactly one; *.foo.bar.com covers that domain's subdomains (never the apex), and a lone * matches any host. Method * means any method; the status filter is blank (any), a class like 4xx, or an exact code. Triggers never widen access - they only decorate traffic the allowlist already admits - and rows are evaluated top-down: one request raises at most the first matching row's event. + + + URL pattern + + + Method + + + Status + + + Event raised + + + Add trigger + + + {0} trigger(s) + + + A trigger URL pattern is required. + + + '{0}' is not a valid trigger URL pattern (scheme://host[:port][/path-glob], or the lone *). + + + '{0}' is not a method filter (one verb, or * for any method). + + + '{0}' is not a status filter (blank for any, a class like 4xx, or an exact code 100-599). + + + The proxy trigger list has invalid row(s): {0}. Fix the highlighted rows or remove them before saving. + + + Proxy: + + + Managed by Administrator + + + Disabled - browser connects directly + + + Enabled - no targets allowlisted + + + Enabled - allowlisted targets: + + + Test proxy fetch + + + Sends a proxy-fetch request from this Management Client across MIP messaging to the Event Server and reports what came back. Requires the Web Content plugin loaded on the Event Server (restart the Event Server service after deployment). + + + Fetching through the Event Server... + + + The proxy fetch harness is not connected. Check the Event Server log (MIP.log) and restart the Management Client after a plugin deployment. + + + HTTP {0} - {1} bytes fetched in {2:0.0}s + + + Proxy fetch {0} after {1:0.0}s - {2} + + + {0} live proxy session(s) - {1:0.0} requests/min, sent {2}/s, received {3}/s + + + No live proxy sessions on this workspace right now. Open a Web Content workspace tab in a Smart Client to see it here. + + + No status from the Event Server: this Management Client has no proxy messaging channel to it. Check the Event Server log (MIP.log) and restart the Management Client after a plugin deployment. + + + The Event Server does not know this workspace any more, so it has no proxy sessions to report. + + + Proxy status query failed ({0}) - {1} + + + No workspace item context: this page cannot ask the Event Server for a status snapshot. + + + {0} blocked proxy origin(s) reported for this workspace since the Event Server started tracking. Newest refusal first. The list lives in the Event Server's memory only - it resets when the Event Server service restarts. + + + Nothing has been blocked on this workspace since the Event Server started tracking. As Smart Clients open proxied views and the proxy refuses their requests, every refused origin appears here with its cause and hit count. The list lives in the Event Server's memory only - it resets when the Event Server service restarts. + + + on allowlist + + + certificate trust, not the allowlist + + + Could not add '{0}' to the allowlist: it is not an exact proxy origin the allowlist can name. + + + {0} client machine(s) report that the proxy certificate is NOT trusted on their PC. Export it here and distribute it to those machines - their users see a warning banner, and proxied HTTPS pages show a certificate error, until the certificate sits in those machines' trusted roots. + + + Machines: {0} + + + {0} ({1} report(s)) + + + 1 resource blocked + + + {0} resources blocked + + + The proxy refused page resources during this page load. Click to list them. + + + Blocked page resources (this page load): + + + Asking the Event Server for its proxy authority certificate… + + + No answer from the Event Server: this Management Client has no proxy messaging channel to it. Check the Event Server log (MIP.log) and restart the Management Client after a plugin deployment. + + + The proxy authority certificate does not exist yet on the Event Server. It is created the first time a proxied HTTPS target is used, or you can provision it once on the Event Server machine - see the plugin README. + + + Proxy certificate export failed ({0}) - {1} + + + Save the proxy authority certificate + + + X.509 certificate (DER)|*.cer + + + Export cancelled - nothing was written. + + + Saved {0} (thumbprint {1}). Distribute it to Smart Client machines' LocalMachine\Root trusted-root store - the recommended path is Active Directory Group Policy; see the plugin README. + + + The proxy certificate is not trusted on this PC - contact your administrator. + + + Fix certificate trust + + + Starts the bundled elevated helper to import this proxy's certificate authority into this machine's Trusted Root store. Windows will show its elevation prompt and its own root-store warning - import this certificate only if you trust the VMS administrator who deployed the proxy. + + + Dismiss this notice for the rest of this Smart Client session. It reappears in the next session while the certificate is still not trusted. + \ No newline at end of file diff --git a/src/WebContent/WebContent.csproj b/src/WebContent/WebContent.csproj index 99e2234..1acd1de 100644 --- a/src/WebContent/WebContent.csproj +++ b/src/WebContent/WebContent.csproj @@ -1,173 +1,56 @@ - - - + + + - Debug - x64 - {01DD540B-2AF6-4F94-A4EE-CF4E32598513} + net472 Library - Properties WebContent WebContent - v4.7.2 - 512 + {01DD540B-2AF6-4F94-A4EE-CF4E32598513} + false true - - + x64 + true + true + + false + Resources\icon.ico 0.1.1 - + true full false ..\..\output\WebContent\ - DEBUG;TRACE - prompt - 4 - x64 + DEBUG;TRACE;NETFRAMEWORK - + pdbonly true ..\..\output\WebContent\ - TRACE - prompt - 4 - x64 - - - Resources\icon.ico + TRACE;NETFRAMEWORK + + - - - - - - - - - - - - + - - - - - - UserControl - - - RootNodeUserControl.cs - - - WorkspaceConfigPage.xaml - - - - - WorkspaceOverviewPage.xaml - - - - UserControl - - - - - - - - - - - - - - True - True - Strings.resx - - - - WebContentPropertiesUserControl.xaml - - - True - True - Resources.resx - - - - - - - - - WebContentUserControl.xaml - - - - - - - - - - - - - - - - - - Always - - - PreserveNewest - - - - - RootNodeUserControl.cs - - - ResXFileCodeGenerator - Strings.Designer.cs - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - - - Designer - MSBuild:Compile - + + 1.0.4314.35 @@ -181,24 +64,74 @@ all - + + + + + false + + + + + + Always + + + PreserveNewest + + + - - - - dotnet tool restore - - - - - del /s /q $(OutputPath)VideoOS* - for /R /D %%1 in ($(OutputPath)*) do rd "%%1" - dotnet tool run sbom-tool generate -b $(OutputPath) -bc "$(MSBuildProjectDirectory)" -pn CascadiaWebContentPlugin -pv $(BuildVersion) -ps "Cascadia Technology" -nsb https://cascadia.tech/joshooaj -D true -V Warning - - + + + + + $([System.IO.Path]::GetFullPath($(OutDir))) + + + + + + + + + + + + + + + + + + + + + $([System.IO.Path]::GetFullPath($(OutDir)).TrimEnd('\')) + + + + diff --git a/src/WebContent/WebContentConfiguration.cs b/src/WebContent/WebContentConfiguration.cs index 4ee03f7..6f0d254 100644 --- a/src/WebContent/WebContentConfiguration.cs +++ b/src/WebContent/WebContentConfiguration.cs @@ -10,6 +10,13 @@ internal static class WebContentConfiguration internal const double MinZoomLevel = 25.0; internal const double MaxZoomLevel = 500.0; + /// #85: the Proxy tab's response-cap field works in whole megabytes; the byte + /// bounds these correspond to are pinned to by a unit + /// test, so the admin-facing range and the server-side clamp cannot drift apart. + internal const int DefaultProxyResponseCapMegabytes = 5; + internal const int MinProxyResponseCapMegabytes = 1; + internal const int MaxProxyResponseCapMegabytes = 20; + internal static string GetString(Func valueProvider, string defaultValue, params string[] keys) { if (valueProvider == null || keys == null) @@ -298,6 +305,73 @@ internal static double ParseZoomLevel(string value, double defaultValue) return ClampZoomLevel(parsedValue); } + /// + /// #85: validates the Proxy tab's whole-megabytes response-cap field. Blank means "the + /// default" and is accepted as 0 (the stored member stays omitted); anything else must be + /// a plain integer inside the admin range - unlike zoom, an out-of-range value is NOT + /// silently clamped here, it refuses the save by name. The Event Server clamps stored + /// values server-side regardless (see ), so a refused + /// or bypassed UI can never smuggle a cap past the ceiling. + /// + internal static bool TryParseProxyResponseCapMegabytes(string value, out int megabytes, out string rejectedText) + { + rejectedText = null; + if (string.IsNullOrWhiteSpace(value)) + { + megabytes = 0; + return true; + } + + var text = value.Trim(); + if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out megabytes) || + megabytes < MinProxyResponseCapMegabytes || + megabytes > MaxProxyResponseCapMegabytes) + { + megabytes = 0; + rejectedText = text; + return false; + } + + return true; + } + + /// + /// #85: the stored wire value for an admin-entered megabyte count. 0 (the default) stores + /// 0 so the optional member is omitted from the blob - pre-#85 stored shapes never grow + /// a redundant section just because a newer plugin saved them. + /// + internal static int ProxyResponseCapBytes(int megabytes) + { + if (megabytes <= 0 || megabytes == DefaultProxyResponseCapMegabytes) + { + return 0; + } + + return megabytes * 1024 * 1024; + } + + /// + /// #85: the megabyte number the Proxy tab displays for a stored wire value. Absent/0 is + /// the default; a hand-edited value past the ceiling displays the ceiling; a hand-edited + /// sub-megabyte value displays the minimum (saving the page normalizes it to whole MB - + /// the display is honest about the range, the ES clamp is what governs until then). + /// + internal static int ResolveProxyResponseCapMegabytes(int storedBytes) + { + if (storedBytes <= 0) + { + return DefaultProxyResponseCapMegabytes; + } + + if (storedBytes >= ProxyFetchLimits.MaxAllowedResponseBytes) + { + return MaxProxyResponseCapMegabytes; + } + + var megabytes = storedBytes / (1024 * 1024); + return megabytes < MinProxyResponseCapMegabytes ? MinProxyResponseCapMegabytes : megabytes; + } + private static string TryGetPropertyValue(IDictionary properties, string key) { if (properties.TryGetValue(key, out var value)) diff --git a/src/WebContent/WebContentPluginDefinition.cs b/src/WebContent/WebContentPluginDefinition.cs index 4dfc6de..d1e7a28 100644 --- a/src/WebContent/WebContentPluginDefinition.cs +++ b/src/WebContent/WebContentPluginDefinition.cs @@ -2,12 +2,16 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.IO; using System.Threading; using System.Threading.Tasks; + using VideoOS.Platform; using VideoOS.Platform.Admin; +using VideoOS.Platform.Background; using VideoOS.Platform.Client; using VideoOS.Platform.Messaging; + using WebContent.Admin; using WebContent.Client; @@ -25,6 +29,7 @@ public class WebContentPluginDefinition : PluginDefinition private readonly List _workspaceMessageRegistrations = new List(); private readonly List _workSpacePlugins = new List(); private readonly WorkspacePluginFactory _workspacePluginFactory = new WorkspacePluginFactory(); + private readonly List _backgroundPlugins = new List(); private DateTime _nextWorkspaceRetryUtc = DateTime.MinValue; private DateTime _lastWorkspaceFailureLogUtc = DateTime.MinValue; @@ -32,6 +37,8 @@ public class WebContentPluginDefinition : PluginDefinition private int _emptyWorkspaceLoadCount; private bool _workspaceLoadSucceeded; private Timer _workspaceRetryTimer; + private DateTime _nextProxyChannelRetryUtc = DateTime.MinValue; + private Timer _profileSweepTimer; public override Guid Id => PluginId; @@ -59,6 +66,42 @@ public override string VersionString public override List ViewItemPlugins { get; } = new List(); + /// + /// Server-side proxy fetcher (#53). The host instantiates background plugins for the + /// environments declared in TargetEnvironments, so this reaches the Event Server only; + /// Smart Client and Management Client hosts never run it. + /// + public override List BackgroundPlugins => _backgroundPlugins; + + /// + /// The classic MIP messaging ids the proxy-fetch exchange uses (#53), registered in the + /// message id vocabulary so tooling sees them as plugin-owned traffic. + /// + public override List PluginDefinedMessageIds { get; } = new List + { + ProxyMessageIds.FetchRequestId, + ProxyMessageIds.FetchResponseId, + ProxyMessageIds.LeafRequestId, + ProxyMessageIds.LeafResponseId, + ProxyMessageIds.TunnelOpenRequestId, + ProxyMessageIds.TunnelOpenResponseId, + ProxyMessageIds.TunnelDataToTargetId, + ProxyMessageIds.TunnelDataFromTargetId, + ProxyMessageIds.TunnelCloseToTargetId, + ProxyMessageIds.TunnelCloseFromTargetId, + + // #69: diagnostics substrate - one-way block reports and session events, plus the + // workspace status query/answer pair. + ProxyMessageIds.BlockReportId, + ProxyMessageIds.SessionEventId, + ProxyMessageIds.StatusRequestId, + ProxyMessageIds.StatusResponseId, + + // #75: deployment CA public-certificate export (Management Client root page). + ProxyMessageIds.CaExportRequestId, + ProxyMessageIds.CaExportResponseId, + }; + public override List WorkSpacePlugins { get @@ -114,16 +157,31 @@ public override void Init() switch (EnvironmentManager.Instance.EnvironmentType) { case EnvironmentType.Administration: + // Management Client harness for the proxy-fetch exchange (#53): lets the admin + // config page fire a test fetch from the MC across to the Event Server. + ProvisionProxyFetchClient(); break; case EnvironmentType.SmartClient: + // Ticket #54: the Smart Client is the requester half of the proxy-fetch + // exchange - the loopback forwarding proxy in each proxied workspace's browser + // profile pushes allowlisted traffic to the Event Server fetcher through it. + ProvisionProxyFetchClient(); ViewItemPlugins.Add(new WebContentViewItemPlugin()); RegisterWorkspaceRecoveryReceivers(); StartWorkspaceRetryTimer(); EnsureWorkspacesLoaded(force: false, reason: "Smart Client Init"); + // #94 (ADR-0004 consequence): reap orphaned proxied profile folders and stale + // listener claims off the UI thread; never fatal to the view. + ScheduleProxiedProfileSweep(); break; case EnvironmentType.Standalone: - case EnvironmentType.Service: case EnvironmentType.ManagementServer: + break; + case EnvironmentType.Service: + // Event Server: host the proxy-fetch background plugin. Nothing Smart Client- + // or view-specific may run here - the WebView2 view stack is never loaded. + _backgroundPlugins.Add(new ProxyFetchBackgroundPlugin()); + break; default: break; } @@ -132,9 +190,19 @@ public override void Init() public override async void Close() { StopWorkspaceRetryTimer(); + StopProxiedProfileSweepTimer(); UnregisterWorkspaceRecoveryReceivers(); + BrowserManager.StopAllProxyListeners(); + + var proxyHarness = ProxyFetchClient.Active; + if (proxyHarness != null) + { + ProxyFetchClient.Active = null; + proxyHarness.Close(); + } ViewItemPlugins.Clear(); + _backgroundPlugins.Clear(); lock (_workspaceSync) { _workSpacePlugins.Clear(); @@ -157,6 +225,143 @@ public override async void Close() } } + private static void ProvisionProxyFetchClient() + { + // Replace-then-close: the fetch seam never sees a null Active window, and the + // previous client's response filter is detached instead of leaking. + var previous = ProxyFetchClient.Active; + var client = new ProxyFetchClient(); + ProxyFetchClient.Active = client; + client.Setup(EnvironmentManager.Instance.MasterSite == null ? null : EnvironmentManager.Instance.MasterSite.ServerId); + if (previous != null && !ReferenceEquals(previous, client)) + { + previous.Close(); + } + } + + /// + /// Smart Client (#54): the proxy-fetch channel may not be establishable during Init while + /// the master site is still connecting. Re-provision it, throttled, once workspaces load. + /// + private void EnsureProxyFetchChannelReady() + { + if (EnvironmentManager.Instance.EnvironmentType != EnvironmentType.SmartClient) + { + return; + } + + var active = ProxyFetchClient.Active; + if (active != null && active.IsReady) + { + return; + } + + var now = DateTime.UtcNow; + if (now < _nextProxyChannelRetryUtc) + { + return; + } + + _nextProxyChannelRetryUtc = now.AddSeconds(30); + ProvisionProxyFetchClient(); + } + + /// + /// #94 (ADR-0004: the proxied profile folders need an owner for disk cleanup, which + /// nothing did today): a bounded startup sweep reaps orphaned proxied-* profile + /// folders past the retention window and stale listener-claim files under this Windows + /// user's browser data root. One-shot timer, ten seconds in: the disk walk NEVER runs on + /// the startup UI thread, and every failure path is journalled - a throwing sweep can + /// never break a view. A folder a live browser holds is skipped, never forced. + /// + private void ScheduleProxiedProfileSweep() + { + if (_profileSweepTimer != null) + { + return; + } + + _profileSweepTimer = new Timer(ProfileSweepCallback, null, TimeSpan.FromSeconds(10), Timeout.InfiniteTimeSpan); + } + + private void StopProxiedProfileSweepTimer() + { + var timer = _profileSweepTimer; + if (timer == null) + { + return; + } + + _profileSweepTimer = null; + timer.Dispose(); + } + + private void ProfileSweepCallback(object state) + { + try + { + var dataRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + BrowserProfileKey.DataFolderRootName); + + var processWitness = new BrowserProcessLockWitness(); + var sweep = new ProfileSweep(browserLockHeld: folder => + ProfileSweep.BrowserLockFileHeld(folder) || processWitness.NamesFolder(folder)); + var report = sweep.Run(dataRoot); + + if (processWitness.ProbeError != null) + { + EnvironmentManager.Instance.Log( + true, + nameof(ProfileSweep), + "The browser-process lock witness could not read process command lines (" + processWitness.ProbeError + + "); the sweep ran on the lockfile probe and the never-force delete failure alone."); + } + + foreach (var line in report.LogLines) + { + EnvironmentManager.Instance.Log(false, nameof(ProfileSweep), line); + } + + EnvironmentManager.Instance.Log( + false, + nameof(ProfileSweep), + "Startup proxied-profile sweep: " + report.ProxiedFoldersSeen + " proxied folder(s) scanned, " + + report.ProxiedFoldersReaped + " reaped, " + report.ProxiedFoldersSkippedLocked + " skipped locked, " + + report.ClaimFilesReaped + " claim file(s) and " + report.ClaimTempFilesReaped + " claim temp file(s) reaped " + + "(retention " + ProfileSweep.DefaultFolderRetention.TotalDays + " day(s), data root '" + dataRoot + "')."); + } + catch (Exception ex) + { + // Never fatal to any view: the sweep is disk hygiene, not view plumbing. + try + { + EnvironmentManager.Instance.Log( + true, + nameof(ProfileSweep), + "Startup proxied-profile sweep failed (ignored; it reruns at next startup): " + ex); + } + catch (Exception) + { + } + } + finally + { + var timer = _profileSweepTimer; + _profileSweepTimer = null; + if (timer != null) + { + try + { + timer.Dispose(); + } + catch (Exception) + { + } + } + } + } + private void RegisterWorkspaceRecoveryReceivers() { _workspaceMessageRegistrations.Add(EnvironmentManager.Instance.RegisterReceiver( @@ -276,6 +481,10 @@ private void EnsureWorkspacesLoaded(bool force, string reason) _workspaceLoadSucceeded = true; _nextWorkspaceRetryUtc = DateTime.MinValue; } + + // Workspaces loaded: give the Smart Client's proxy-fetch channel (which Init may not + // have been able to establish before login completed) another chance (#54). + EnsureProxyFetchChannelReady(); } private void LogWorkspaceLoadFailure(string reason, Exception error, TimeSpan retryDelay) diff --git a/src/WebContent/WorkspaceConfiguration.cs b/src/WebContent/WorkspaceConfiguration.cs index 973991a..9c528f3 100644 --- a/src/WebContent/WorkspaceConfiguration.cs +++ b/src/WebContent/WorkspaceConfiguration.cs @@ -217,7 +217,6 @@ internal class WorkspaceItemSettings { [DataMember(Name = "homeAddress")] public string HomeAddress { get; set; } - [DataMember(Name = "hideToolbar")] public bool HideToolbar { get; set; } @@ -247,5 +246,143 @@ internal class WorkspaceItemSettings [DataMember(Name = "enableDevTools")] public bool EnableDevTools { get; set; } + + /// + /// Ticket #52: server-side proxy toggle for this workspace. Absent from definitions saved + /// before the proxy existed, which deserializes to false - proxy off. + /// + [DataMember(Name = "proxyEnabled")] + public bool ProxyEnabled { get; set; } + + /// + /// Ticket #52: targets the proxy is permitted to reach (scheme, host, port). Null for + /// definitions that predate the proxy or where the admin added none. + /// + [DataMember(Name = "proxyTargets")] + public List ProxyTargets { get; set; } + + /// + /// Ticket #65: admin-defined rule-trigger rows for this workspace. Null for definitions + /// saved before #65 (readers are tolerant of the section's absence) or where the admin + /// added none — no rows means the workspace's proxy traffic never raises events. + /// + [DataMember(Name = "proxyTriggers", EmitDefaultValue = false)] + public List ProxyTriggers { get; set; } + + /// + /// Ticket #85: the workspace's maximum proxied fetch-mode response size in bytes. Absent + /// from every definition saved before #85 and from saves that kept the default, which + /// deserialize to 0 — the Event Server then applies + /// . Whatever the stored value + /// claims, readers clamp it to ; + /// this raw member must never decide a cap on its own. + /// + [DataMember(Name = "proxyMaxResponseBytes", EmitDefaultValue = false)] + public int ProxyMaxResponseBytes { get; set; } + + /// + /// Ticket #86: header add/override/remove rows applied to the request the Event Server + /// sends upstream. Null for definitions saved before #86 (readers are tolerant of the + /// section's absence) or where the admin added none — no rows means plain pass-through + /// besides the unconditional hop-by-hop stripping. Raw, unvalidated text on the wire: + /// is the grammar authority on every read. + /// + [DataMember(Name = "proxyRequestHeaders", EmitDefaultValue = false)] + public List ProxyRequestHeaders { get; set; } + + /// + /// Ticket #86: header add/remove rows applied to the fetch-mode response returned to the + /// plugin client. Same wire shape and same guardrail as . + /// + [DataMember(Name = "proxyResponseHeaders", EmitDefaultValue = false)] + public List ProxyResponseHeaders { get; set; } + } + + /// + /// Ticket #86: one admin-defined proxy header policy row stored in the workspace's + /// proxyRequestHeaders/proxyResponseHeaders sections. Raw, unvalidated text on + /// the wire — is the single grammar authority on every read + /// (RFC 9110 field-name token, forbidden-header guardrail, CR/LF rejection), and the MC grid + /// validates against the same policy before saving. A reader must never decide header handling + /// from this shape alone. + /// + [DataContract] + internal class ProxyHeaderRuleConfiguration + { + /// Header name as configured (validated on read, never trusted as-is). + [DataMember(Name = "name")] + public string Name { get; set; } + + /// Header value for add/override rows; ignored for removal rows. + [DataMember(Name = "value", EmitDefaultValue = false)] + public string Value { get; set; } + + /// True = strip this header instead of adding/overriding it. + [DataMember(Name = "remove", EmitDefaultValue = false)] + public bool Remove { get; set; } + } + + [DataContract] + internal class ProxyTargetConfiguration + { + [DataMember(Name = "scheme")] + public string Scheme { get; set; } + + /// + /// Legacy (#52) exact-host member. Since #73 writers emit instead, + /// but readers accept this shape forever - stored workspaces are never migrated + /// write-back, and mixed versions must keep loading. + /// + [DataMember(Name = "host", EmitDefaultValue = false)] + public string Host { get; set; } + + /// + /// #73: the structured host pattern - an exact lower-case host, a leftmost-label wildcard + /// ("*.foo.bar.com"), or the lone full-host hatch ("*"). The new stored shape; readers + /// prefer it over when both are somehow present. + /// + [DataMember(Name = "pattern", EmitDefaultValue = false)] + public string Pattern { get; set; } + + [DataMember(Name = "port")] + public int Port { get; set; } + + /// + /// Ticket #56: the admin's per-target "accept untrusted certificate" opt-in. Absent from + /// definitions saved before #56, which deserializes to false - the Event Server fetcher + /// validates that target's TLS chain normally. + /// + [DataMember(Name = "acceptUntrusted")] + public bool AcceptUntrusted { get; set; } + + /// + /// The host part to canonicalize: the #73 pattern when the record carries one, else the + /// legacy exact host. This is the one reader seam every gate and seeder goes through, + /// which is what keeps old and new stored shapes equivalent. + /// + internal string EffectivePattern => !string.IsNullOrWhiteSpace(Pattern) ? Pattern : Host; + } + + /// + /// Ticket #65: one admin-defined proxy rule-trigger row stored in the workspace's + /// proxyTriggers section. Raw, unvalidated text on the wire — the Event Server's + /// is the single grammar authority on every read, and the + /// #78 MC grid must validate against the same policy before saving. A reader must never + /// decide match semantics from this shape alone. + /// + [DataContract] + internal class ProxyTriggerConfiguration + { + /// Full-URL glob: scheme://host[:port][/path-glob], wildcards per grammar. + [DataMember(Name = "url")] + public string Url { get; set; } + + /// Single uppercase verb, or absent/"*" for any method. + [DataMember(Name = "method", EmitDefaultValue = false)] + public string Method { get; set; } + + /// "1xx".."5xx" class or an exact code, or absent for any response status. + [DataMember(Name = "status", EmitDefaultValue = false)] + public string Status { get; set; } } } diff --git a/src/WebContent/license.rtf b/src/WebContent/license.rtf index f05d137..ce8883f 100644 --- a/src/WebContent/license.rtf +++ b/src/WebContent/license.rtf @@ -2,7 +2,7 @@ \viewstart \pard\ql \f0\fs20\b MIT License\b0\par \par -Copyright (c) 2024 Cascadia Technology\par +Copyright (c) 2026 Cascadia Technology\par \par Permission is hereby granted, free of charge, to any person obtaining a copy\par of this software and associated documentation files (the "Software"), to deal\par diff --git a/src/WebContent/plugin.def b/src/WebContent/plugin.def index db26ee9..22abe19 100644 --- a/src/WebContent/plugin.def +++ b/src/WebContent/plugin.def @@ -1,4 +1,4 @@  - + diff --git a/src/WebContentProxyCa/Program.cs b/src/WebContentProxyCa/Program.cs new file mode 100644 index 0000000..49fe6cd --- /dev/null +++ b/src/WebContentProxyCa/Program.cs @@ -0,0 +1,259 @@ +using System; +using System.IO; +using System.Reflection; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; + +using Microsoft.Win32; + +namespace WebContent.ProxyCa +{ + /// + /// Ticket #77 entry point: run ProxyCaCore against the real SCM, the installed + /// plugin assembly, and the real key storage. The MSI streams this exe as a deferred + /// custom action, so it already runs as SYSTEM with machine-store privilege; it never + /// elevates or self-checks (same posture as the #82 service helper). + /// + internal static class Program + { + private static int Main(string[] args) + { + try + { + return ProxyCaCore.Run( + args, + new ServiceControlManagerAccountReader(), + new PluginAuthorityGateway(), + new MachineKeyReadGrant(), + Console.WriteLine); + } + catch (Exception ex) + { + // Anything that escapes the seams (registry hiccups, store errors) must not + // silently look like success; Return="check" turns the exit into a rollback. + Console.Error.WriteLine("deployment proxy CA provisioning failed: " + ex.Message); + return ProxyCaCore.ExitProvisionFailed; + } + } + } + + /// + /// The SCM registry view of a service's logon account (ObjectName). The 64-bit view + /// is explicit: the service key lives under the real SYSTEM hive regardless of process bitness. + /// + internal sealed class ServiceControlManagerAccountReader : IServiceAccountReader + { + public string ReadObjectName(string serviceName) + { + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)) + using (var serviceKey = baseKey.OpenSubKey( + @"SYSTEM\CurrentControlSet\Services\" + serviceName, writable: false)) + { + if (serviceKey == null) + { + return null; + } + + return serviceKey.GetValue("ObjectName") as string; + } + } + } + + /// + /// Drives the plugin's own ADR-0003 load-or-create through reflection into the just + /// installed WebContent.dll - the identical posture the legacy provision script uses, + /// which is deliberate: the CA the MSI creates is produced by the exact code path the + /// Event Server plugin itself would use (fixed container, fixed CN, DER minting), so + /// there is no second certificate recipe to drift. LoadFrom, never an assembly + /// reference: the MIP SDK types WebContent.dll also references are never enumerated + /// or linked, only the crypto types are touched. + /// + internal sealed class PluginAuthorityGateway : IAuthorityGateway + { + public void Provision(string pluginDllPath, Action log) + { + if (!File.Exists(pluginDllPath)) + { + throw new FileNotFoundException( + "plugin assembly not found - the CA action runs after InstallFiles, a missing drop means the install is already broken: " + pluginDllPath, + pluginDllPath); + } + + var assembly = Assembly.LoadFrom(pluginDllPath); + + // The plugin's minting backend is internal; the script reached it the same way. + var authorityType = assembly.GetType("WebContent.Authority"); + if (authorityType == null) + { + throw new InvalidOperationException( + "WebContent.Authority is missing from " + pluginDllPath + " - the CA helper and the plugin moved apart; update src/WebContentProxyCa together with the plugin."); + } + + var loadOrCreate = authorityType.GetMethod( + "LoadOrCreateDeploymentAuthority", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + if (loadOrCreate == null) + { + throw new InvalidOperationException( + "LoadOrCreateDeploymentAuthority is missing from WebContent.Authority - the CA helper and the plugin moved apart; update src/WebContentProxyCa together with the plugin."); + } + + var authority = loadOrCreate.Invoke(null, new object[] { log }); + if (authority == null) + { + throw new InvalidOperationException("LoadOrCreateDeploymentAuthority returned null - refusing to claim success."); + } + + try + { + var certificateProperty = authorityType.GetProperty( + "Certificate", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var certificate = certificateProperty == null + ? null + : certificateProperty.GetValue(authority) as X509Certificate2; + if (certificate != null) + { + log("authority certificate: " + certificate.Subject + " (thumbprint " + certificate.Thumbprint + ")"); + } + } + finally + { + // Releases this process's key handles; the container itself persists + // (PersistKeyInCsp), so the Event Server loads the same CA on first mint. + var disposable = authority as IDisposable; + if (disposable != null) + { + disposable.Dispose(); + } + } + } + } + + /// + /// Grants (idempotently) the Event Server's logon account Read on the CA private key. + /// The key file is located through the certificate's own CAPI container handle + /// (CspKeyContainerInfo.UniqueKeyContainerName is the MachineKeys file name) - the + /// hash-of-container-name guess the legacy script carried is exactly why that script + /// needed a creation-time-window fallback; the handle is deterministic instead. + /// + internal sealed class MachineKeyReadGrant : IPrivateKeyAccessGrant + { + public bool EnsureServiceAccountCanReadKey(string account, Action log) + { + using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); + + // Same find posture as the plugin's loader: fixed CN, unverified, newest + // valid copy whose private key this minting path can use (CAPI RSA). + X509Certificate2 chosen = null; + RSACryptoServiceProvider chosenKey = null; + var now = DateTime.Now; + foreach (var certificate in store.Certificates.Find( + X509FindType.FindBySubjectName, ProxyCaCore.AuthorityCommonName, false)) + { + if (certificate.NotAfter <= now || !certificate.HasPrivateKey) + { + certificate.Dispose(); + continue; + } + + RSACryptoServiceProvider key; + try + { + key = certificate.PrivateKey as RSACryptoServiceProvider; + } + catch (CryptographicException) + { + certificate.Dispose(); + continue; + } + + if (key == null) + { + certificate.Dispose(); + continue; + } + + if (chosen == null || certificate.NotAfter > chosen.NotAfter) + { + if (chosen != null) + { + chosen.Dispose(); + } + + chosen = certificate; + chosenKey = key; + continue; + } + + certificate.Dispose(); + } + + if (chosen == null) + { + log("no valid deployment proxy CA with a usable private key is present in LocalMachine\\Personal - " + + "the load-or-create step should have made one; refusing to claim the grant succeeded."); + return false; + } + + using (chosen) + using (chosenKey) + { + string containerName; + try + { + containerName = chosenKey.CspKeyContainerInfo.UniqueKeyContainerName; + } + catch (CryptographicException exception) + { + log("the CA private key's container could not be opened: " + exception.Message); + return false; + } + + var keyDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + @"Microsoft\Crypto\RSA\MachineKeys"); + var keyFilePath = Path.Combine(keyDirectory, containerName); + if (!File.Exists(keyFilePath)) + { + log("the CA key file '" + containerName + "' is not under " + keyDirectory + " - cannot grant access."); + return false; + } + + var security = File.GetAccessControl(keyFilePath); + var accountSid = (SecurityIdentifier)new NTAccount(account).Translate(typeof(SecurityIdentifier)); + var alreadyGranted = false; + foreach (var entry in security.GetAccessRules(true, true, typeof(SecurityIdentifier))) + { + var rule = entry as FileSystemAccessRule; + if (rule != null && + rule.AccessControlType == AccessControlType.Allow && + rule.IdentityReference.Equals(accountSid) && + (rule.FileSystemRights & FileSystemRights.ReadData) == FileSystemRights.ReadData) + { + alreadyGranted = true; + break; + } + } + + if (alreadyGranted) + { + log("service account " + account + " already holds read access to the CA private key " + + "(key file " + containerName + ") - DACL untouched."); + return true; + } + + security.AddAccessRule(new FileSystemAccessRule( + accountSid, FileSystemRights.Read, InheritanceFlags.None, PropagationFlags.None, AccessControlType.Allow)); + File.SetAccessControl(keyFilePath, security); + log("granted Read on the CA private key (key file " + containerName + ") to " + account + "."); + return true; + } + } + } + } +} diff --git a/src/WebContentProxyCa/ProxyCaCore.cs b/src/WebContentProxyCa/ProxyCaCore.cs new file mode 100644 index 0000000..ed77233 --- /dev/null +++ b/src/WebContentProxyCa/ProxyCaCore.cs @@ -0,0 +1,221 @@ +using System; + +namespace WebContent.ProxyCa +{ + /// + /// Ticket #77: the auditable decision core of the MSI's deployment-CA provisioning + /// helper (ADR-0003 as amended by #64/#77). The MSI schedules this exe as a deferred + /// custom action (runs as SYSTEM) when the Event Server service exists on the machine + /// AND the admin ticked the proxy opt-in on the install options page. The core then, + /// in order: + /// + /// 1. drives the plugin's OWN load-or-create path (via the injected gateway) so the + /// CA written into LocalMachine\Personal is byte-for-byte what the Event Server + /// plugin later loads by - fixed common name, fixed machine key container, reuse + /// of an existing valid CA untouched; and + /// 2. grants the Event Server service's own logon account read access to the CA + /// private key (same grant the legacy provision script makes), because a CA + /// created under SYSTEM is otherwise unreadable by the restricted service account + /// and every leaf mint would die with Access is denied. + /// + /// Exit codes (the custom action is Return="check", so non-zero fails the install): + /// 0 = CA loaded-or-created and the service-account key grant verified/applied, + /// 1 = usage error (nothing was touched), + /// 2 = provisioning or the key grant failed - loud by design: an opted-in proxy + /// without a usable CA is the epic's "failure is loud, explained, fixable" + /// posture, so the install rolls back rather than shipping a half-set-up + /// deployment. + /// + public static class ProxyCaCore + { + public const int ExitDone = 0; + public const int ExitUsage = 1; + public const int ExitProvisionFailed = 2; + + /// + /// Fixed drop location the MSI installs to (INSTALLFOLDER is pinned by the setup + /// harvest, AGENTS.md) - and the reflection target for the plugin's load-or-create. + /// A deferred exe custom action cannot read installer properties, so the constant + /// is deliberate, exactly like the legacy script's default parameter. + /// + public const string DefaultPluginDllPath = + @"C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin\WebContent.dll"; + + /// + /// The deployment authority CN. Kept equal to ProxyLoopbackTls.AuthorityCommonName + /// (pinned by InstallerProxyCaGuardTests); used only to FIND the CA for the key + /// grant - creation itself runs through the plugin's own code, which owns the name. + /// + public const string AuthorityCommonName = "XProtect WebContent Plugin Loopback Proxy"; + + public const string Usage = + "usage: WebContentProxyCa provision [plugin-dll-path] " + + "(loads or creates the deployment proxy CA through the plugin's own code path, " + + "then grants the service's logon account read access to its private key)"; + + /// + /// Validates the argument vector before anything is touched: the "provision" verb, + /// a non-blank service name with no registry-path characters, and an optional + /// non-blank plugin path override (the MSI always runs without the override, using + /// the fixed drop; manual/legacy runs may pass one). + /// + public static bool TryParse(string[] args, out string serviceName, out string pluginDllPath) + { + serviceName = null; + pluginDllPath = null; + if (args == null || (args.Length != 2 && args.Length != 3)) + { + return false; + } + + if (!string.Equals((args[0] ?? string.Empty).Trim(), "provision", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + serviceName = (args[1] ?? string.Empty).Trim(); + if (serviceName.Length == 0 || + serviceName.IndexOfAny(new[] { '\\', '/', '=', ':' }) >= 0 || + serviceName.IndexOfAny(new[] { '\0' }) >= 0) + { + // The name flows into a registry subkey path; refuse anything that could + // escape it. The MSI's authored argument always passes. + return false; + } + + if (args.Length == 3) + { + pluginDllPath = (args[2] ?? string.Empty).Trim(); + if (pluginDllPath.Length == 0) + { + return false; + } + } + else + { + pluginDllPath = DefaultPluginDllPath; + } + + return true; + } + + /// + /// Maps the service's SCM ObjectName registry value to the account principal that + /// must be able to read the CA private key. Missing value or "LocalSystem" is the + /// SCM's built-in SYSTEM account; the two restricted built-ins are normalized to + /// their qualified names; anything else (a managed service account, a domain user) + /// flows through verbatim. + /// + public static string MapServiceAccount(string objectName) + { + var name = (objectName ?? string.Empty).Trim(); + if (name.Length == 0 || name.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase)) + { + return @"NT AUTHORITY\SYSTEM"; + } + + if (name.EndsWith("NetworkService", StringComparison.OrdinalIgnoreCase)) + { + return @"NT AUTHORITY\NetworkService"; + } + + if (name.EndsWith("LocalService", StringComparison.OrdinalIgnoreCase)) + { + return @"NT AUTHORITY\LocalService"; + } + + return name; + } + + /// + /// Executes the provisioning flow against the injected seams. Every refusal is + /// logged; the exit code is the only channel the MSI Return="check" sees. + /// + public static int Run( + string[] args, + IServiceAccountReader serviceAccounts, + IAuthorityGateway authorityGateway, + IPrivateKeyAccessGrant keyGrant, + Action log) + { + if (serviceAccounts == null) + { + throw new ArgumentNullException(nameof(serviceAccounts)); + } + + if (authorityGateway == null) + { + throw new ArgumentNullException(nameof(authorityGateway)); + } + + if (keyGrant == null) + { + throw new ArgumentNullException(nameof(keyGrant)); + } + + if (log == null) + { + log = _ => { }; + } + + string serviceName; + string pluginDllPath; + if (!TryParse(args, out serviceName, out pluginDllPath)) + { + log(Usage); + return ExitUsage; + } + + var account = MapServiceAccount(serviceAccounts.ReadObjectName(serviceName)); + log("Event Server service '" + serviceName + "' runs as " + account); + + try + { + authorityGateway.Provision(pluginDllPath, log); + } + catch (Exception exception) + { + log("deployment proxy CA provisioning FAILED: " + exception.Message); + return ExitProvisionFailed; + } + + if (!keyGrant.EnsureServiceAccountCanReadKey(account, log)) + { + log("deployment proxy CA private-key access for " + account + " could not be confirmed - " + + "leaf minting would fail at runtime, failing the install instead."); + return ExitProvisionFailed; + } + + log("deployment proxy CA is ready: " + AuthorityCommonName); + return ExitDone; + } + } + + /// Reads a service's SCM ObjectName (its logon account); null when absent. + public interface IServiceAccountReader + { + string ReadObjectName(string serviceName); + } + + /// + /// Loads or creates the deployment CA through the plugin's own code (the real + /// implementation reflects into the installed WebContent.dll). Throwing means the + /// CA half failed: the core maps that to the install-failing exit. + /// + public interface IAuthorityGateway + { + void Provision(string pluginDllPath, Action log); + } + + /// + /// Verifies (and when needed applies) the service account's Read access to the CA + /// private key. Reuse of an existing CA is untouched certificate-wise: the grant is + /// idempotent (add only when absent), the same posture the provision script always + /// had, so an upgrade re-run leaves both the certificate AND an already-correct DACL + /// byte-identical. + /// + public interface IPrivateKeyAccessGrant + { + bool EnsureServiceAccountCanReadKey(string account, Action log); + } +} diff --git a/src/WebContentProxyCa/WebContentProxyCa.csproj b/src/WebContentProxyCa/WebContentProxyCa.csproj new file mode 100644 index 0000000..cdb497b --- /dev/null +++ b/src/WebContentProxyCa/WebContentProxyCa.csproj @@ -0,0 +1,29 @@ + + + + + net472 + Exe + WebContent.ProxyCa + WebContentProxyCa + true + x64 + + false + bin\$(Configuration)\ + + + diff --git a/src/WebContentServiceCtl/Program.cs b/src/WebContentServiceCtl/Program.cs new file mode 100644 index 0000000..5f89e77 --- /dev/null +++ b/src/WebContentServiceCtl/Program.cs @@ -0,0 +1,134 @@ +using System; +using System.ServiceProcess; + +namespace WebContent.ServiceCtl +{ + /// + /// Ticket #82 entry point: parse, then run ServiceCtlCore against the real service + /// control manager. The MSI streams this exe as a deferred custom action, so it already + /// runs as SYSTEM with service-control privilege; it never elevates or self-checks. + /// + internal static class Program + { + private static int Main(string[] args) + { + ServiceCtlCommand command; + string serviceName; + if (!ServiceCtlCore.TryParse(args, out command, out serviceName)) + { + Console.Error.WriteLine(ServiceCtlCore.Usage); + return ServiceCtlCore.ExitUsage; + } + + try + { + return ServiceCtlCore.Run(command, serviceName, new WindowsServiceAccess(), Console.WriteLine); + } + catch (Exception ex) + { + // Same posture as the missing-service case: an SCM hiccup must not silently + // look like success, and the MSI's Return="check" turns non-zero into a + // rollback with the message above in the (manual-run) console. + Console.Error.WriteLine("service control failed: " + ex.Message); + return ServiceCtlCore.ExitWaitTimeout; + } + } + + private sealed class WindowsServiceAccess : IServiceAccess + { + public bool Exists(string serviceName) + { + try + { + using (var controller = new ServiceController(serviceName)) + { + var ignored = controller.Status; + } + + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + + public ServiceCtlState GetState(string serviceName) + { + using (var controller = new ServiceController(serviceName)) + { + return Map(controller.Status); + } + } + + public void Stop(string serviceName) + { + using (var controller = new ServiceController(serviceName)) + { + if (controller.Status == ServiceControllerStatus.Running || + controller.Status == ServiceControllerStatus.Paused) + { + controller.Stop(); + } + } + } + + public void Start(string serviceName) + { + using (var controller = new ServiceController(serviceName)) + { + if (controller.Status == ServiceControllerStatus.Stopped) + { + controller.Start(); + } + } + } + + public bool WaitForState(string serviceName, ServiceCtlState state, TimeSpan timeout) + { + using (var controller = new ServiceController(serviceName)) + { + try + { + controller.WaitForStatus(MapTarget(state), timeout); + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + } + + private static ServiceControllerStatus MapTarget(ServiceCtlState state) + { + switch (state) + { + case ServiceCtlState.Stopped: + return ServiceControllerStatus.Stopped; + default: + return ServiceControllerStatus.Running; + } + } + + private static ServiceCtlState Map(ServiceControllerStatus status) + { + switch (status) + { + case ServiceControllerStatus.Stopped: + return ServiceCtlState.Stopped; + case ServiceControllerStatus.Running: + return ServiceCtlState.Running; + case ServiceControllerStatus.Paused: + return ServiceCtlState.Paused; + case ServiceControllerStatus.StopPending: + return ServiceCtlState.StopPending; + case ServiceControllerStatus.StartPending: + return ServiceCtlState.StartPending; + default: + return ServiceCtlState.Other; + } + } + } + } +} diff --git a/src/WebContentServiceCtl/ServiceCtlCore.cs b/src/WebContentServiceCtl/ServiceCtlCore.cs new file mode 100644 index 0000000..123fdf8 --- /dev/null +++ b/src/WebContentServiceCtl/ServiceCtlCore.cs @@ -0,0 +1,187 @@ +using System; + +namespace WebContent.ServiceCtl +{ + /// + /// Ticket #82: the auditable decision core of the MSI's Event Server service helper. + /// The MSI schedules this exe as a deferred custom action (runs as SYSTEM) to stop the + /// Event Server service BEFORE the plugin files are overwritten (a still-stopping service + /// keeps the drop DLLs locked), and - when the admin's finish-page choice says so - to + /// bring the service back after InstallFiles. Every wait here polls the service + /// control manager for the real Stopped/Running transition; there is no fixed sleep + /// anywhere, so a slow service stop is tolerated, not raced. + /// Exit codes (the custom action is Return="check", so non-zero fails the install): + /// 0 = done, including "service not installed" (benign no-op; the MSI condition already + /// gates on presence, a mid-session removal must not fail the install), + /// 1 = usage error (never reaches the SCM), + /// 2 = the service did not reach the requested state within its timeout. + /// + public static class ServiceCtlCore + { + public const int ExitDone = 0; + public const int ExitUsage = 1; + public const int ExitWaitTimeout = 2; + + // The Event Server's stop is genuinely slow (its plugins dispose on shutdown); the + // psake deploy flow already allows a minute for the same transition, so the wait + // budgets start above that. + public static readonly TimeSpan StopWaitTimeout = TimeSpan.FromMinutes(3); + public static readonly TimeSpan StartWaitTimeout = TimeSpan.FromMinutes(2); + + public const string Usage = + "usage: WebContentServiceCtl " + + "(stop: stop a running service and wait for Stopped; " + + "restart: stop-if-running, then start and wait for Running)"; + + public static bool TryParseCommand(string arg, out ServiceCtlCommand command) + { + switch ((arg ?? string.Empty).Trim().ToLowerInvariant()) + { + case "stop": + command = ServiceCtlCommand.Stop; + return true; + case "restart": + command = ServiceCtlCommand.Restart; + return true; + default: + command = default(ServiceCtlCommand); + return false; + } + } + + /// + /// Validates the full argument vector: exactly one command and one non-blank service + /// name. Every refusal happens before the SCM is touched. + /// + public static bool TryParse(string[] args, out ServiceCtlCommand command, out string serviceName) + { + command = default(ServiceCtlCommand); + serviceName = null; + if (args == null || args.Length != 2) + { + return false; + } + + if (!TryParseCommand(args[0], out command)) + { + return false; + } + + serviceName = (args[1] ?? string.Empty).Trim(); + return serviceName.Length > 0; + } + + /// + /// Executes the command against the injected service access. The wait semantics are + /// the whole point of the ticket: Stop only returns success once the SCM reports + /// Stopped, Restart only returns success once it reports Running, and a transitional + /// state (Stop/Start pending) is waited out rather than assumed. + /// + public static int Run(ServiceCtlCommand command, string serviceName, IServiceAccess access, Action log) + { + if (access == null) + { + throw new ArgumentNullException(nameof(access)); + } + + if (log == null) + { + log = _ => { }; + } + + if (!access.Exists(serviceName)) + { + log("service '" + serviceName + "' is not installed - nothing to do"); + return ExitDone; + } + + var state = access.GetState(serviceName); + + if (command == ServiceCtlCommand.Stop) + { + return StopAndEnsure(serviceName, state, access, log) + ? ExitDone + : ExitWaitTimeout; + } + + // Restart: get to Stopped first (if not already there), then start and wait. + if (state != ServiceCtlState.Stopped && !StopAndEnsure(serviceName, state, access, log)) + { + return ExitWaitTimeout; + } + + log("starting service '" + serviceName + "'"); + access.Start(serviceName); + if (!access.WaitForState(serviceName, ServiceCtlState.Running, StartWaitTimeout)) + { + log("service '" + serviceName + "' did not reach Running within " + StartWaitTimeout); + return ExitWaitTimeout; + } + + log("service '" + serviceName + "' is Running"); + return ExitDone; + } + + // Drives the service to Stopped: issues Stop only when a stop is actually issuable + // (Running/Paused), lets a pending transition run, and always waits for the real + // Stopped verdict. Returns false only on wait timeout. + private static bool StopAndEnsure(string serviceName, ServiceCtlState state, IServiceAccess access, Action log) + { + switch (state) + { + case ServiceCtlState.Stopped: + log("service '" + serviceName + "' is already Stopped"); + return true; + case ServiceCtlState.StopPending: + log("service '" + serviceName + "' is StopPending - waiting for Stopped"); + break; + default: + log("stopping service '" + serviceName + "' (state " + state + ")"); + access.Stop(serviceName); + break; + } + + if (!access.WaitForState(serviceName, ServiceCtlState.Stopped, StopWaitTimeout)) + { + log("service '" + serviceName + "' did not reach Stopped within " + StopWaitTimeout); + return false; + } + + log("service '" + serviceName + "' is Stopped"); + return true; + } + } + + public enum ServiceCtlCommand + { + Stop, + Restart + } + + /// + /// The SCM states the core reasons about, mapped from ServiceControllerStatus by the + /// entry point so the decision table stays unit-testable without a real service. + /// + public enum ServiceCtlState + { + Stopped, + Running, + Paused, + StopPending, + StartPending, + Other + } + + public interface IServiceAccess + { + bool Exists(string serviceName); + + ServiceCtlState GetState(string serviceName); + + void Stop(string serviceName); + + void Start(string serviceName); + + bool WaitForState(string serviceName, ServiceCtlState state, TimeSpan timeout); + } +} diff --git a/src/WebContentServiceCtl/WebContentServiceCtl.csproj b/src/WebContentServiceCtl/WebContentServiceCtl.csproj new file mode 100644 index 0000000..b979492 --- /dev/null +++ b/src/WebContentServiceCtl/WebContentServiceCtl.csproj @@ -0,0 +1,34 @@ + + + + + net472 + Exe + WebContent.ServiceCtl + WebContentServiceCtl + true + x64 + + false + bin\$(Configuration)\ + + + + + + + + diff --git a/src/WebContentTrustHelper/Program.cs b/src/WebContentTrustHelper/Program.cs new file mode 100644 index 0000000..54a4666 --- /dev/null +++ b/src/WebContentTrustHelper/Program.cs @@ -0,0 +1,84 @@ +using System; +using System.Security.Cryptography.X509Certificates; + +namespace WebContent.TrustHelper +{ + /// + /// Ticket #72: the single entry point of the bundled elevated trust helper + /// (WebContentTrustHelper.exe). The Smart Client plugin starts this ONLY through the runas + /// verb, so its process is already elevated (UAC consent shown) and crypt32's own modal + /// trusted-root warning rides the interactive store.Add. Both dialogs are deliberate + /// and unsuppressed (the ticket's accepted UX cost); this process never suppresses either, + /// never reads the "yes" checkbox itself, and imports exactly one thumbprint-verified DER. + /// + /// It is a separate exe precisely because the Smart Client host is an ordinary user process: + /// a non-elevated plugin must not (and crypt32 will not silently) write trust stores. This + /// file is NOT compiled into the test project; the auditable decisions live in + /// , which the tests link and pin. + /// + internal static class Program + { + private static int Main(string[] args) + { + if (!TrustHelperCore.TryParseArguments(args, out var derFilePath, out var expectedThumbprint, out var error)) + { + Console.Error.WriteLine("WebContent proxy CA trust helper: " + error); + return TrustHelperCore.ExitUsage; + } + + Console.WriteLine("WebContent proxy CA trust helper: importing the deployment CA into LocalMachine\\Root (elevated, crypt32's warning will prompt)."); + try + { + return TrustHelperCore.Run(derFilePath, expectedThumbprint, ImportIntoLocalMachineRoot, Console.Out); + } + catch (Exception exception) + { + // Belt-and-braces: an unexpected fault must still exit loudly, never partially + // trust anything. The plugin re-checks the root stores itself regardless. + Console.Error.WriteLine("WebContent proxy CA trust helper: unexpected failure: " + exception.Message); + return TrustHelperCore.ExitImportFailed; + } + } + + /// + /// The one store write this program can perform. A non-elevated process fails on the + /// ReadWrite open (), which is the + /// negative-path shape the live probe pins unattended; the interactive success path is a + /// human step (two OS dialogs, no unattended automation). + /// + private static int ImportIntoLocalMachineRoot(X509Certificate2 certificate) + { + var opened = false; + try + { + using (var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.ReadWrite); + opened = true; + var already = store.Certificates.Find(X509FindType.FindByThumbprint, certificate.Thumbprint, false); + if (already.Count > 0) + { + Console.WriteLine("already trusted: the certificate is present in LocalMachine\\Root; nothing changed."); + return TrustHelperCore.ExitOk; + } + + store.Add(certificate); + Console.WriteLine("imported: certificate " + certificate.Thumbprint + " is now in LocalMachine\\Root."); + return TrustHelperCore.ExitOk; + } + } + catch (Exception exception) + { + // Access denied without elevation -> the store was never even openable + // (ExitStoreUnavailable, the negative-path shape the live probe pins unattended); + // a failure after opening means crypt32's warning was declined or the write itself + // failed (ExitImportFailed). Either way: loud, non-zero, and the plugin's own + // re-check confirms the store state regardless of what this process claims. + Console.Error.WriteLine(opened + ? "trusted-root write failed (was the crypt32 root-store warning declined?): " + exception.Message + : "cannot open LocalMachine\\Root for write (not elevated?): " + exception.Message); + return opened ? TrustHelperCore.ExitImportFailed : TrustHelperCore.ExitStoreUnavailable; + } + } + } +} diff --git a/src/WebContentTrustHelper/TrustHelperCore.cs b/src/WebContentTrustHelper/TrustHelperCore.cs new file mode 100644 index 0000000..e8fc3dd --- /dev/null +++ b/src/WebContentTrustHelper/TrustHelperCore.cs @@ -0,0 +1,154 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography.X509Certificates; + +namespace WebContent.TrustHelper +{ + /// + /// Ticket #72: the auditable core of the elevated trust helper. Split from Main so the + /// test project links it and pins every refusal - the whole point of the helper is that it is + /// too small and too strict to do anything but import THIS thumbprint's DER, so that shape + /// must be unit-provable without touching a trust store. + /// + /// Posture: exactly one certificate, supplied as a DER file path argument plus the expected + /// thumbprint; the file is size-capped before parsing; the parsed certificate's OWN + /// thumbprint must match the expected value before any store opens; the store operation is + /// injected so tests never write machine trust state. Exit codes are the caller's only + /// result channel (the plugin re-verifies the root stores itself - the helper's success is + /// never trusted blindly). + /// + internal static class TrustHelperCore + { + /// Same ceiling the CA export contract enforces (#75): a deployment CA DER is + /// ~1 KB; anything implausibly large is refused before parsing. + internal const int MaxAuthorityDerBytes = 8 * 1024; + + internal const int ExitOk = 0; + internal const int ExitUsage = 1; + internal const int ExitCertificateRejected = 2; + internal const int ExitFileUnreadable = 3; + internal const int ExitStoreUnavailable = 4; + internal const int ExitImportFailed = 5; + + /// + /// Two positional arguments: the CA DER file and the expected SHA-1 thumbprint (40 hex + /// chars, the shape X509Certificate2.Thumbprint prints). Nothing else is accepted - no + /// store selector, no "add anyway", no wildcard. + /// + internal static bool TryParseArguments(string[] args, out string derFilePath, out string expectedThumbprint, out string error) + { + derFilePath = null; + expectedThumbprint = null; + error = null; + + if (args == null || args.Length != 2) + { + error = "usage: WebContentTrustHelper.exe "; + return false; + } + + if (string.IsNullOrWhiteSpace(args[0])) + { + error = "the certificate DER file path must be non-empty."; + return false; + } + + var thumbprint = (args[1] ?? string.Empty).Trim(); + if (thumbprint.Length != 40 || !IsHexString(thumbprint)) + { + error = "the expected thumbprint must be exactly 40 hex characters (the SHA-1 thumbprint of the certificate to import)."; + return false; + } + + derFilePath = args[0]; + expectedThumbprint = thumbprint.ToUpperInvariant(); + return true; + } + + /// + /// Validate first, touch a store second, import exactly one certificate. Returns a + /// process exit code; is called ONLY after the + /// parsed certificate's own thumbprint equals the expected one, and its return value is + /// the process result (tests inject a fake; production injects the real store write). + /// + internal static int Run(string derFilePath, string expectedThumbprint, Func importToRootStore, TextWriter output) + { + if (importToRootStore == null) throw new ArgumentNullException(nameof(importToRootStore)); + + byte[] der; + try + { + der = File.ReadAllBytes(derFilePath); + } + catch (Exception exception) + { + output.WriteLine($"cannot read the certificate file: {exception.Message}"); + return ExitFileUnreadable; + } + + if (der.Length == 0 || der.Length > MaxAuthorityDerBytes) + { + output.WriteLine($"refused: the file is {der.Length} DER bytes; a deployment CA must be between 1 and {MaxAuthorityDerBytes} bytes."); + return ExitCertificateRejected; + } + + X509Certificate2 certificate; + try + { + certificate = LoadCertificate(der); + } + catch (Exception exception) + { + output.WriteLine($"refused: the file is not a parsable X.509 certificate: {exception.Message}"); + return ExitCertificateRejected; + } + + using (certificate) + { + if (!string.Equals(certificate.Thumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase)) + { + output.WriteLine( + "refused: the certificate's thumbprint " + certificate.Thumbprint + + " does not match the expected " + expectedThumbprint + + " (subject '" + certificate.Subject + "'); nothing was imported."); + return ExitCertificateRejected; + } + + output.WriteLine(string.Format( + CultureInfo.InvariantCulture, + "certificate verified: subject '{0}', thumbprint {1}, valid to {2:u}.", + certificate.Subject, + certificate.Thumbprint, + certificate.NotAfter)); + + return importToRootStore(certificate); + } + } + + private static X509Certificate2 LoadCertificate(byte[] der) + { +#if NETFRAMEWORK + return new X509Certificate2(der); +#else + return X509CertificateLoader.LoadCertificate(der); +#endif + } + + private static bool IsHexString(string value) + { + foreach (var character in value) + { + var isHex = (character >= '0' && character <= '9') + || (character >= 'a' && character <= 'f') + || (character >= 'A' && character <= 'F'); + if (!isHex) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/WebContentTrustHelper/WebContentTrustHelper.csproj b/src/WebContentTrustHelper/WebContentTrustHelper.csproj new file mode 100644 index 0000000..737a11d --- /dev/null +++ b/src/WebContentTrustHelper/WebContentTrustHelper.csproj @@ -0,0 +1,49 @@ + + + + + net472 + Exe + WebContent.TrustHelper + WebContentTrustHelper + true + + false + + + true + full + false + ..\..\output\WebContent\ + DEBUG;TRACE;NETFRAMEWORK + + + pdbonly + true + ..\..\output\WebContent\ + TRACE;NETFRAMEWORK + + + + + + 3.10.94 + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/tests/ux/WebContent.UxTests/Harness/AllowlistGridUi.cs b/tests/ux/WebContent.UxTests/Harness/AllowlistGridUi.cs new file mode 100644 index 0000000..94f6406 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/AllowlistGridUi.cs @@ -0,0 +1,1092 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #81: shared Management Client phase for driving and reading the structured Proxy-tab +/// allowlist grid (dgProxyTargets). The free-text txtProxyTargets box is gone, so every probe +/// that provisioned or read back an allowlist now goes through rows: scheme dropdown, host edit, +/// port edit, accept-untrusted checkbox, Problem cell, and a per-row Remove button. +/// +/// The per-row controls all carry the SAME AutomationId (proxyRowHost, proxyRowPort, ...) - one +/// per grid row - so rows are addressed by DOCUMENT ORDER: the i-th match of any per-row id is +/// the same row across ids (document order is display order, corroborated by ascending +/// BoundingRectangle.Y). Virtualization is off in the grid's XAML (pinned by the shell guard) +/// so every row stays realized and the ordering can never drift. +/// +/// Every edit verifies by observable change (read the row back), escalating the route on each +/// failed attempt - the harness rules learned in #52/#56/#74 still apply: never type into an +/// unfocused control, and never trust a pattern invoke that can silently no-op. +/// +public static class AllowlistGridUi +{ + public const string GridId = "dgProxyTargets"; + public const string SchemeCellId = "proxyRowScheme"; + public const string HostCellId = "proxyRowHost"; + public const string PortCellId = "proxyRowPort"; + public const string UntrustedCellId = "proxyRowUntrusted"; + public const string ProblemCellId = "proxyRowProblem"; + public const string RemoveCellId = "proxyRowRemove"; + public const string AddButtonId = "btnProxyTargetAdd"; + public const string WarningBannerId = "proxyWildcardWarning"; + + /// The banner Border's inner TextBlock. A plain WPF Border has no UIA peer, so the + /// Border itself is never in the tree - but a collapsed subtree removes its TextBlock too, + /// which makes the TextBlock's presence/visibility the honest UIA probe for banner state. + public const string WarningBannerTextId = "txtProxyWildcardWarning"; + public const string TargetsHintId = "lblProxyTargetsHint"; + public const string ProxyTabId = "tabProxy"; + public const string SettingsTabId = "tabSettings"; + public const string NameFieldId = "txtName"; + public const string ProxyEnabledId = "chkProxyEnabled"; + + /// #85: the Proxy tab's whole-megabytes response-cap field (plain TextBox, not a + /// grid cell - one instance on the page, so AutomationId addressing is unambiguous). The + /// field sets an EXPLICIT AutomationProperties.AutomationId, so UIA must address it by + /// that id, not by its x:Name. + public const string ResponseCapFieldId = "proxyResponseCapMB"; + + /// One row as UIA reads it. Entry renders in the pre-#81 probe-record shape + /// ("scheme://host[:port] accept-untrusted") so existing assertions transfer. + public sealed class RowReading + { + public int Index; + public string Scheme = string.Empty; + public string Host = string.Empty; + public string Port = string.Empty; + public bool Untrusted; + public string Problem = string.Empty; + + public string Entry + { + get + { + var origin = $"{Scheme}://{Host}{(string.IsNullOrWhiteSpace(Port) ? string.Empty : ":" + Port.Trim())}"; + return Untrusted ? origin + " accept-untrusted" : origin; + } + } + } + + public static void OpenProxyTab(Window mcMain) + { + SelectTab(mcMain, ProxyTabId); + + // The tab is only "open" once its content is realized. + ManagementClientUi.ExpectControl(mcMain, GridId, 15); + } + + /// Selects the Settings tab (Name / home address live there) and waits for its + /// first control to realize. WPF unloads a non-selected tab's content, so every harness + /// phase that reads or writes a control has to be on the tab that owns it. + public static void OpenSettingsTab(Window mcMain) + { + SelectTab(mcMain, SettingsTabId); + ManagementClientUi.ExpectControl(mcMain, NameFieldId, 15); + } + + private static void SelectTab(Window mcMain, string tabId) + { + var tab = ManagementClientUi.ExpectControl(mcMain, tabId, 15); + try + { + if (tab.Patterns.SelectionItem.IsSupported) + { + tab.Patterns.SelectionItem.Pattern.Select(); + Thread.Sleep(1200); + return; + } + } + catch { } + + tab.Click(); + Thread.Sleep(1200); + } + + /// The Settings tab's Name field - the guard every Save phase re-checks before + /// letting the shell write (one bad run saved a corrupted Name and renamed the item). + public static string ReadPaneName(Window mcMain) + { + OpenSettingsTab(mcMain); + return ReadValue(ManagementClientUi.ExpectControl(mcMain, NameFieldId, 10)); + } + + /// + /// Re-selects the workspace item so the detail pane is rebuilt FROM THE STORED ITEM. Every + /// "it saved" claim in these probes goes through this: a readback of the pane the harness + /// just edited proves nothing, a readback after a reload proves the record round-tripped. + /// + public static void ReloadPane(Window mcMain, string workspacesNodeName, string itemName) + { + ManagementClientUi.SelectNode(mcMain, workspacesNodeName); + Thread.Sleep(900); + ManagementClientUi.SelectTreeItem(mcMain, itemName); + Thread.Sleep(1800); + } + + /// Where the grid driver's step notes go (which selection route ran, what the + /// dropdown reported). Probes point this at their run log so the route history is on disk. + public static Action Trace { get; set; } = _ => { }; + + /// + /// The full allowlist save discipline, grid edition: routing toggle on, rows replaced with + /// the wanted entries, dirty-chain guards, the pre-Save Name check, Save with modal sweep, + /// then a readback from a RELOADED pane. Throws if any step cannot be verified by observable + /// change. This is what every proxy probe's "make the workspace allowlist look like X" phase + /// uses, so the grid's edit quirks are handled in exactly one place. + /// + public static void SetEntriesAndSave(ClientSession mc, Window mcMain, string workspacesNodeName, string workspaceName, IEnumerable entries, StringBuilder log) + { + Trace = message => log.AppendLine(message); + var wanted = (entries ?? Enumerable.Empty()).ToList(); + + OpenProxyTab(mcMain); + ManagementClientUi.EnsureCheckBox(mcMain, ProxyEnabledId, true); + SetEntries(mcMain, wanted); + log.AppendLine($"grid now shows: {string.Join(" | ", ReadEntries(mcMain))}"); + + SaveAndVerify(mc, mcMain, workspacesNodeName, workspaceName, wanted, log); + } + + /// The cap field as the pane currently displays it (whole-MB text). + public static string ReadResponseCapText(Window mcMain) + { + OpenProxyTab(mcMain); + return ReadValue(ManagementClientUi.ExpectControl(mcMain, ResponseCapFieldId, 10)).Trim(); + } + + /// Types the response-cap field and verifies it by readback (Value pattern first, + /// then the focus-gated clipboard route - same escalation ladder as the grid cells). + public static void SetResponseCapText(Window mcMain, string text) + { + OpenProxyTab(mcMain); + var wanted = (text ?? string.Empty).Trim(); + for (var attempt = 0; attempt < 4; attempt++) + { + var box = ManagementClientUi.ExpectControl(mcMain, ResponseCapFieldId, 10); + try + { + if (box.Patterns.Value.IsSupported) + box.Patterns.Value.Pattern.SetValue(wanted); + } + catch { } + + Thread.Sleep(300); + if (string.Equals(ReadValue(ManagementClientUi.ExpectControl(mcMain, ResponseCapFieldId, 10)).Trim(), wanted, StringComparison.Ordinal)) + return; + + box = ManagementClientUi.ExpectControl(mcMain, ResponseCapFieldId, 10); + if (ManagementClientUi.FocusVerified(mcMain, box, ResponseCapFieldId) && LoginFlow.TryClipboardSetText(wanted)) + { + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A); + Thread.Sleep(150); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_V); + Thread.Sleep(400); + if (string.Equals(ReadValue(ManagementClientUi.ExpectControl(mcMain, ResponseCapFieldId, 10)).Trim(), wanted, StringComparison.Ordinal)) + return; + } + + Thread.Sleep(300); + } + + throw new InvalidOperationException( + $"'{ResponseCapFieldId}' never read back as '{wanted}' (reads '{ReadValue(ManagementClientUi.ExpectControl(mcMain, ResponseCapFieldId, 10))}')."); + } + + /// + /// #85: set the response-cap field and store it, verified through the same two independent + /// channels as the allowlist save: the plugin's "Stored proxy response cap ..." audit line + /// in the MC MIP.log (naming the effective MB value, "(default)" for the 5 MB default) and + /// a readback from a RELOADED pane. + /// + public static void SetCapAndSave(ClientSession mc, Window mcMain, string workspacesNodeName, string workspaceName, string capText, StringBuilder log) + { + Trace = message => log.AppendLine(message); + var wanted = (capText ?? string.Empty).Trim(); + var display = string.IsNullOrEmpty(wanted) ? "5" : wanted; + // Keyed on the audit line's own ': N MB' segment so a stored '18 MB' could never satisfy + // a wait for '8 MB'. + var auditToken = string.IsNullOrEmpty(wanted) || wanted == "5" ? ": 5 MB (default)" : $": {wanted} MB"; + + OpenProxyTab(mcMain); + SetResponseCapText(mcMain, capText); + + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the cap edit; flipping the routing checkbox twice to dirty the pane"); + ManagementClientUi.DirtyPaneViaCheckbox(mcMain, ProxyEnabledId); + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the checkbox flip; keyboard-nudging the cap field"); + ManagementClientUi.NudgeControlWithKeystroke(mcMain, ResponseCapFieldId); + log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}"); + } + } + + var paneName = ReadPaneName(mcMain); + if (!string.Equals(paneName, workspaceName, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Refusing to Save: the Name field reads '{paneName}', expected '{workspaceName}'."); + + OpenProxyTab(mcMain); + + var auditMarker = $"Stored proxy response cap for workspace '{workspaceName}'"; + var auditBefore = McLog.CountMatches(auditMarker, auditToken); + + if (!ManagementClientUi.Save(mc, mcMain, 20)) + throw new InvalidOperationException($"Save never became enabled for cap '{wanted}'."); + + var auditLine = McLog.WaitForNewLine(auditBefore, 20, auditMarker, auditToken); + if (auditLine == null) + throw new InvalidOperationException( + $"the save wrote no 'Stored proxy response cap' audit line carrying '{auditToken}' " + + $"({auditBefore} -> {McLog.CountMatches(auditMarker, auditToken)})."); + log.AppendLine($"stored cap audit line: {auditLine}"); + + Thread.Sleep(1500); + ReloadPane(mcMain, workspacesNodeName, workspaceName); + var stored = ReadResponseCapText(mcMain); + if (!string.Equals(stored, display, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"After Save + reload the cap field reads '{stored}' - expected '{display}'."); + log.AppendLine($"stored cap '{display}' MB verified from the audit line AND a reloaded pane"); + } + + /// Save discipline for a grid the caller has already edited: dirty-chain guards, + /// the pre-Save Name check, Save with modal sweep, then the stored allowlist read back from + /// TWO independent channels - the plugin's own storage audit line in the MC MIP.log, and a + /// RELOADED pane. Either channel alone can lie: the grid's dropdowns only echo their own + /// selection through UIA, and the log line is the only view of what the save wrote. + public static void SaveAndVerify(ClientSession mc, Window mcMain, string workspacesNodeName, string workspaceName, IEnumerable expected, StringBuilder log) + { + Trace = message => log.AppendLine(message); + var wanted = (expected ?? Enumerable.Empty()).ToList(); + + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the grid edit; flipping the routing checkbox twice to dirty the pane"); + ManagementClientUi.DirtyPaneViaCheckbox(mcMain, ProxyEnabledId); + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the checkbox flip; keyboard-nudging a host cell"); + ManagementClientUi.NudgeControlWithKeystroke(mcMain, HostCellId); + log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}"); + } + } + + var paneName = ReadPaneName(mcMain); + if (!string.Equals(paneName, workspaceName, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Refusing to Save: the Name field reads '{paneName}', expected '{workspaceName}'."); + + OpenProxyTab(mcMain); + + // The audit line the save writes, keyed on this call's exact entry set, so an older save + // of the same allowlist cannot satisfy the delta. + var auditMarker = $"Stored proxy targets for workspace '{workspaceName}'"; + var origins = wanted.Select(WantedOrigin).ToArray(); + var auditBefore = McLog.CountMatches(auditMarker, origins); + + if (!ManagementClientUi.Save(mc, mcMain, 20)) + throw new InvalidOperationException($"Save never became enabled for allowlist '{string.Join(" | ", wanted)}'."); + + var auditLine = McLog.WaitForNewLine(auditBefore, 20, auditMarker, origins); + if (auditLine == null) + throw new InvalidOperationException( + $"the save wrote no storage audit line naming every wanted entry ({auditBefore} -> " + + $"{McLog.CountMatches(auditMarker, origins)} for '{string.Join(", ", origins)}'). " + + "The save either never ran or stored something other than the grid showed."); + log.AppendLine($"stored proxy targets (MC MIP.log): {AuditTail(auditLine)}"); + + var stored = EntriesInAuditLine(auditLine); + if (!SameOrigins(wanted, stored)) + throw new InvalidOperationException( + $"the plugin stored '{string.Join(" | ", stored)}' - expected '{string.Join(" | ", wanted)}'."); + + // The flag is part of the stored record, and the audit line is the one place it is stated + // independently of the checkbox's own UIA state. + foreach (var entry in wanted.Where(e => e.IndexOf("accept-untrusted", StringComparison.OrdinalIgnoreCase) >= 0)) + { + var flagged = $"{WantedOrigin(entry)} accept-untrusted"; + if (!auditLine.Contains(flagged, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"'{entry}' was saved without its accept-untrusted flag: the audit line says '{AuditTail(auditLine)}'."); + } + + Thread.Sleep(1500); + ReloadPane(mcMain, workspacesNodeName, workspaceName); + OpenProxyTab(mcMain); + var after = ReadEntries(mcMain); + if (!SameOrigins(wanted, after)) + throw new InvalidOperationException( + $"After Save + reload the allowlist reads '{string.Join(" | ", after)}' — expected '{string.Join(" | ", wanted)}'."); + log.AppendLine($"saved allowlist '{string.Join(" | ", wanted)}' (verified from the audit line AND a reloaded pane)"); + } + + /// The origin part of a probe entry ("scheme://host[:port]", flag token stripped). + public static string WantedOrigin(string entry) + { + ParseEntry(entry, out var scheme, out var host, out var port, out _); + return $"{scheme}://{host}" + (string.IsNullOrWhiteSpace(port) ? string.Empty : $":{port}"); + } + + /// The entry list out of the plugin's "Stored proxy targets ..." log line. + private static List EntriesInAuditLine(string line) + { + var tail = AuditTail(line); + return tail.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(e => e.Trim()) + .Where(e => e.Length > 0) + .ToList(); + } + + private static string AuditTail(string line) + { + var cut = line.IndexOf("routing ", StringComparison.Ordinal); + if (cut < 0) return line; + var rest = line.Substring(cut); + var semi = rest.IndexOf(';'); + var tail = semi < 0 ? rest : rest.Substring(semi + 1).Trim(); + // The audit line is a sentence: it ends in '.' after the last entry, not part of any entry. + return tail.EndsWith(".", StringComparison.Ordinal) ? tail.Substring(0, tail.Length - 1).Trim() : tail; + } + + /// Order-insensitive origin comparison: the save deduplicates and canonicalizes, so + /// the stored set is what matters, not the row order the harness happened to type. + public static bool SameOrigins(IEnumerable expected, IEnumerable actual) => + Origins(expected).OrderBy(o => o, StringComparer.Ordinal) + .SequenceEqual(Origins(actual).OrderBy(o => o, StringComparer.Ordinal)); + + private static string[] Origins(IEnumerable entries) => + (entries ?? Enumerable.Empty()) + .Select(e => (e ?? string.Empty).Trim()) + .Where(e => e.Length > 0) + .Select(WantedOrigin) + .ToArray(); + + /// + /// Clicks Save and captures the shell notice the plugin raises when a save is REFUSED (a + /// WinForms MessageBox the plugin's own save path shows), dismissing it on the way out. The + /// shell's own "Save changes?" confirmation is accepted first, because the plugin's refusal + /// only runs after it. Returns the captured notice text, or null when no such notice appeared + /// (i.e. the save went through). Invoke runs on a side thread: a UIA Invoke blocks for the + /// whole lifetime of the modal it caused, which is how the #56 row hung. + /// + /// The notice is read through WIN32, not UIA. Live on #81: the refusal dialog was demonstrably + /// up (the Save Invoke stayed blocked on it, and the MIP.log carried the matching rejection + /// line), yet the app's UIA window list reported nothing but the main window - so a UIA-only + /// capture cannot see a plugin-raised MessageBox at all. EnumWindows + GetWindowText + BM_CLICK + /// sees it and answers it. + /// + public static string SaveAndCaptureNotice(ClientSession mc, Window mcMain, string noticeFragment, StringBuilder log, int timeoutSeconds = 30) + { + var saveButton = mcMain.FindAllDescendants(cf => cf.ByName("Save")) + .FirstOrDefault(b => b.ControlType == ControlType.Button && b.IsEnabled); + if (saveButton == null) + throw new InvalidOperationException("no enabled Save button to press - the grid edit never dirtied the pane."); + + var mainHandle = IntPtr.Zero; + try { mainHandle = mcMain.Properties.NativeWindowHandle.Value; } catch { } + + System.Threading.Tasks.Task invoke; + try + { + invoke = System.Threading.Tasks.Task.Run(() => saveButton.Patterns.Invoke.Pattern.Invoke()); + } + catch (Exception ex) + { + log.AppendLine($"Save invoke threw on the way in: {ex.GetType().Name}: {ex.Message}"); + return null; + } + + var captured = (string)null; + var fallback = (string)null; + var seen = new HashSet(); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + var pid = mc.Process.Id; + + while (DateTime.UtcNow < deadline && captured == null) + { + // ---- Win32 route: the plugin's own notice dialogs -------------------------------- + foreach (var hwnd in TopLevelWindows(pid)) + { + try + { + if (hwnd == mainHandle) continue; + + var cls = WindowClass(hwnd); + var title = WindowTitle(hwnd); + var body = WindowBodyText(hwnd); + var text = $"{title} {body}"; + + // One diagnostic line per distinct window shape: which dialogs the shell and + // the plugin actually put on the desktop and what each of them says. Without + // this a missed notice is indistinguishable from a notice that never showed. + if (seen.Add($"w:{cls}|{title}")) + log.AppendLine($"window: class='{cls}' title='{Collapse(title)}' text='{Collapse(body)}'"); + + if (text.Contains(noticeFragment, StringComparison.OrdinalIgnoreCase)) + { + captured = Collapse(text); + if (!ClickNativeButton(hwnd, "OK")) ClickNativeButton(hwnd, "Close"); + break; + } + + // A notice dialog whose wording could not be matched: keep it as the fallback + // answer to the assertion and dismiss it so the blocked UI thread can continue. + if (cls.Contains("#32770")) + { + if (!string.IsNullOrWhiteSpace(Collapse(text))) fallback = fallback ?? Collapse(text); + if (!ClickNativeButton(hwnd, "OK")) ClickNativeButton(hwnd, "Close"); + } + } + catch { } + } + + if (captured != null) break; + + // ---- UIA route: the shell's own confirmation, which UIA does report --------------- + foreach (var window in mc.Windows()) + { + try + { + if (string.Equals(window.AutomationId ?? string.Empty, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)) + continue; + + var buttons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)).ToList(); + if (buttons.Count == 0) continue; + + var cls = SafeText(() => window.ClassName); + var title = SafeText(() => window.Name); + if (cls.Contains("#32770")) continue; // already handled by the Win32 route + + if (seen.Add($"u:{cls}|{title}")) + log.AppendLine($"uia window: class='{cls}' title='{title}' buttons=[{string.Join(",", buttons.Select(b => SafeText(() => b.Name)))}]"); + + var text = NoticeText(window); + if (text.Contains(noticeFragment, StringComparison.OrdinalIgnoreCase)) + { + captured = Collapse(text); + if (!ClickButton(buttons, "OK")) ClickButton(buttons, "Close"); + break; + } + + // The shell's save confirmation: answer it so the plugin's save path runs. + if (!ClickButton(buttons, "Yes")) + { + if (!ClickButton(buttons, "Save")) ClickButton(buttons, "OK"); + } + } + catch { } + } + + if (captured == null) Thread.Sleep(400); + } + + if (seen.Count == 0) + log.AppendLine($"no window other than the main one appeared within {timeoutSeconds}s of the Save press"); + + try { invoke.Wait(TimeSpan.FromSeconds(20)); } catch { } + ManagementClientUi.AcceptPendingModal(mc, 3); + return captured ?? fallback; + } + + /// Everything a notice window says: title plus every text-ish descendant, plus the + /// plain Win32 window text of the dialog and its children. The Win32 route is not decoration: + /// a MessageBox raised while the host's UI thread sits in its own modal loop can hand UIA an + /// empty element tree, and the HWND text is the only thing still readable (learned live on + /// #81, where the plugin's refusal dialog was on screen but UIA reported nothing). + private static string NoticeText(Window window) + { + var parts = new List(); + try { parts.Add(window.Name ?? string.Empty); } catch { } + try + { + foreach (var element in window.FindAllDescendants(cf => + cf.ByControlType(ControlType.Text) + .Or(cf.ByControlType(ControlType.Edit)) + .Or(cf.ByControlType(ControlType.Document)))) + { + try { parts.Add(element.Name ?? string.Empty); } catch { } + } + } + catch { } + + try + { + var hwnd = IntPtr.Zero; + try { hwnd = window.Properties.NativeWindowHandle.Value; } catch { } + if (hwnd != IntPtr.Zero) parts.Add(WindowBodyText(hwnd)); + } + catch { } + + return string.Join(" ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); + } + + // ---- Win32 dialog plumbing: the only route that sees a plugin-raised MessageBox ---------- + + private static List TopLevelWindows(int processId) + { + var found = new List(); + _ = EnumWindows((hwnd, _) => + { + GetWindowThreadProcessId(hwnd, out var pid); + if (pid == processId) found.Add(hwnd); + return true; + }, IntPtr.Zero); + return found; + } + + private static string WindowClass(IntPtr hwnd) + { + var buffer = new System.Text.StringBuilder(256); + _ = GetClassName(hwnd, buffer, buffer.Capacity); + return buffer.ToString().Trim(); + } + + private static string WindowTitle(IntPtr hwnd) + { + var buffer = new System.Text.StringBuilder(1024); + _ = GetWindowText(hwnd, buffer, buffer.Capacity); + return buffer.ToString().Trim(); + } + + /// The dialog's own text plus every child control's text - a #32770 notice keeps its + /// message in a static child and its answers in Button children. + private static string WindowBodyText(IntPtr hwnd) + { + var parts = new List(); + _ = EnumChildWindows(hwnd, (child, _) => + { + var text = WindowTitle(child); + if (!string.IsNullOrWhiteSpace(text)) parts.Add(text); + return true; + }, IntPtr.Zero); + return string.Join(" ", parts); + } + + private static bool ClickNativeButton(IntPtr parent, string name) + { + var hit = IntPtr.Zero; + _ = EnumChildWindows(parent, (child, _) => + { + if (hit != IntPtr.Zero) return true; + if (WindowClass(child) != "Button") return true; + if (!string.Equals(WindowTitle(child), name, StringComparison.OrdinalIgnoreCase)) return true; + hit = child; + return false; + }, IntPtr.Zero); + + if (hit == IntPtr.Zero) return false; + _ = PostMessage(hit, BM_CLICK, IntPtr.Zero, IntPtr.Zero); + return true; + } + + private const uint BM_CLICK = 0x00F5; + + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.I1)] + private static extern bool EnumChildWindows(IntPtr hWndParent, EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount); + + [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern int GetClassName(IntPtr hWnd, System.Text.StringBuilder lpClassName, int nMaxCount); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + private static string Collapse(string text) => + text == null ? string.Empty : System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim(); + + private static string SafeText(Func read) + { + try { return (read() ?? string.Empty).Trim(); } catch { return string.Empty; } + } + + private static bool ClickButton(System.Collections.Generic.IEnumerable buttons, string name) + { + var hit = buttons.FirstOrDefault(b => string.Equals((b.Name ?? string.Empty).Trim(), name, StringComparison.OrdinalIgnoreCase)); + if (hit == null) return false; + try { hit.Patterns.Invoke.Pattern.Invoke(); } catch { try { hit.Click(); } catch { } } + return true; + } + + public static bool SameEntries(IEnumerable expected, IEnumerable actual) => + Norm(expected).SequenceEqual(Norm(actual)); + + private static string[] Norm(IEnumerable entries) => + (entries ?? Enumerable.Empty()).Select(e => (e ?? string.Empty).Trim()).Where(e => e.Length > 0).ToArray(); + + + public static List ReadRows(Window mcMain) + { + var hosts = CellElements(mcMain, HostCellId); + var schemes = CellElements(mcMain, SchemeCellId); + var ports = CellElements(mcMain, PortCellId); + var untrusted = CellElements(mcMain, UntrustedCellId); + var problems = CellElements(mcMain, ProblemCellId); + + var rows = new List(); + for (var i = 0; i < hosts.Count; i++) + { + rows.Add(new RowReading + { + Index = i, + Scheme = i < schemes.Count ? ReadComboValue(schemes[i]) : string.Empty, + Host = ReadValue(hosts[i]), + Port = i < ports.Count ? ReadValue(ports[i]) : string.Empty, + Untrusted = i < untrusted.Count && ReadToggleState(untrusted[i]), + Problem = i < problems.Count ? ReadText(problems[i]) : string.Empty, + }); + } + + return rows; + } + + public static List ReadEntries(Window mcMain) => ReadRows(mcMain).Select(r => r.Entry).ToList(); + + public static int RowCount(Window mcMain) => CellElements(mcMain, HostCellId).Count; + + /// Press the grid's "Add target" button and wait for a new realized row. + public static void AddRow(Window mcMain) + { + var before = RowCount(mcMain); + var button = ManagementClientUi.ExpectControl(mcMain, AddButtonId, 10); + for (var attempt = 0; attempt < 5 && RowCount(mcMain) == before; attempt++) + { + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + Thread.Sleep(600); + } + + if (RowCount(mcMain) != before + 1) + throw new InvalidOperationException($"'{AddButtonId}' did not append a grid row ({before} -> {RowCount(mcMain)})."); + } + + /// Fill the LAST grid row (the one AddRow just appended) cell by cell, verifying + /// each cell by readback. scheme "http"/"https"; port may be null/empty for the default. + public static void SetLastRow(Window mcMain, string scheme, string host, string port, bool acceptUntrusted) + { + var index = RowCount(mcMain) - 1; + if (index < 0) throw new InvalidOperationException("no grid row to set."); + + SetSchemeCell(mcMain, index, scheme); + SetTextCell(mcMain, HostCellId, index, host); + SetTextCell(mcMain, PortCellId, index, port ?? string.Empty); + SetToggleCell(mcMain, UntrustedCellId, index, acceptUntrusted); + + var row = ReadRows(mcMain)[index]; + Trace($"grid row {index} typed as {scheme}://{host}{(string.IsNullOrWhiteSpace(port) ? string.Empty : ":" + port)} and reads '{row.Entry}' (Problem: '{row.Problem}')"); + if (!string.Equals(row.Host, host, StringComparison.Ordinal) || + !string.Equals(row.Port?.Trim() ?? string.Empty, (port ?? string.Empty).Trim(), StringComparison.Ordinal) || + !string.Equals(row.Scheme, scheme, StringComparison.OrdinalIgnoreCase) || + row.Untrusted != acceptUntrusted) + { + throw new InvalidOperationException( + $"grid row {index} never read back as {scheme}://{host}{(string.IsNullOrWhiteSpace(port) ? string.Empty : ":" + port)}{(acceptUntrusted ? " accept-untrusted" : string.Empty)} (reads '{row.Entry}')."); + } + } + + /// Add one entry as a fresh row: button, then fill the new last row. + public static void AddEntry(Window mcMain, string scheme, string host, string port, bool acceptUntrusted) + { + AddRow(mcMain); + SetLastRow(mcMain, scheme, host, port, acceptUntrusted); + } + + /// Remove one row by index, waiting for the row count to drop. + public static void RemoveRowAt(Window mcMain, int index) + { + var before = RowCount(mcMain); + if (index < 0 || index >= before) + throw new InvalidOperationException($"no grid row {index} to remove (rows={before})."); + + for (var attempt = 0; attempt < 5 && RowCount(mcMain) == before; attempt++) + { + var button = CellElement(mcMain, RemoveCellId, index); + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + Thread.Sleep(600); + } + + if (RowCount(mcMain) != before - 1) + throw new InvalidOperationException($"remove on grid row {index} never dropped a row ({before} -> {RowCount(mcMain)})."); + } + + public static void RemoveAllRows(Window mcMain) + { + for (var guard = 0; RowCount(mcMain) > 0 && guard < 50; guard++) + { + RemoveRowAt(mcMain, 0); + } + } + + /// Replace the whole grid with the given entries (probe-record shape "scheme://host[:port] + /// [accept-untrusted]"): remove every row, add one row per entry, verified row by row. + public static void SetEntries(Window mcMain, IEnumerable entries) + { + RemoveAllRows(mcMain); + foreach (var entry in entries) + { + ParseEntry(entry, out var scheme, out var host, out var port, out var untrusted); + AddEntry(mcMain, scheme, host, port, untrusted); + } + } + + public static void ParseEntry(string entry, out string scheme, out string host, out string port, out bool untrusted) + { + untrusted = false; + var tokens = (entry ?? string.Empty).Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + var origin = tokens.FirstOrDefault(t => t.Contains("://")) ?? string.Empty; + untrusted = tokens.Any(t => string.Equals(t, "accept-untrusted", StringComparison.OrdinalIgnoreCase)); + + var rest = origin.Substring(origin.IndexOf("://", StringComparison.Ordinal) + 3); + scheme = origin.Substring(0, origin.IndexOf("://", StringComparison.Ordinal)).ToLowerInvariant(); + var lastColon = rest.LastIndexOf(':'); + if (lastColon > 0 && rest.Substring(lastColon + 1).All(char.IsDigit)) + { + host = rest.Substring(0, lastColon); + port = rest.Substring(lastColon + 1); + } + else + { + host = rest; + port = string.Empty; + } + } + + /// True while the full-host-wildcard warning banner is on screen. + public static bool WarningBannerVisible(Window mcMain) + { + var banner = mcMain.FindFirstDescendant(cf => cf.ByAutomationId(WarningBannerTextId)); + if (banner == null) return false; + try { return !banner.IsOffscreen; } + catch { return false; } + } + + // ---- cell primitives: read/verify, escalate the route on failure ------------------------- + + private static void SetTextCell(Window mcMain, string automationId, int index, string value) + { + for (var attempt = 0; attempt < 4; attempt++) + { + var cell = CellElement(mcMain, automationId, index); + try + { + if (cell.Patterns.Value.IsSupported) + cell.Patterns.Value.Pattern.SetValue(value); + } + catch { } + + Thread.Sleep(400); + if (string.Equals(ReadValue(CellElement(mcMain, automationId, index)), value, StringComparison.Ordinal)) + return; + + // Keyboard route, focus-gated like the harness rules demand: verified focus on THIS + // cell (its id matches every row's, so the focus gate must also re-verify by the + // cell handle), select-all, clipboard paste. + cell = CellElement(mcMain, automationId, index); + if (ManagementClientUi.FocusVerified(mcMain, cell, automationId) && LoginFlow.TryClipboardSetText(value)) + { + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A); + Thread.Sleep(150); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_V); + Thread.Sleep(400); + if (string.Equals(ReadValue(CellElement(mcMain, automationId, index)), value, StringComparison.Ordinal)) + return; + } + + Thread.Sleep(400); + } + + throw new InvalidOperationException( + $"grid cell '{automationId}' row {index} never read back as '{value}' (reads '{ReadValue(CellElement(mcMain, automationId, index))}')."); + } + + private static void SetSchemeCell(Window mcMain, int index, string scheme) + { + // Three routes, always run in the order an admin would take them. Whether the row model + // actually followed is NOT decided here: the dropdown's own IsSelected echo has already + // been caught claiming a scheme the row never stored (live on #81: every row driven + // through the UIA selection routes displayed http and stored https). The authority is what + // the save writes - the refusal dialog's row text and the "Stored proxy targets ..." audit + // line in the MC MIP.log. + var combo = ResolveCombo(CellElement(mcMain, SchemeCellId, index)); + SelectSchemeByClick(combo, scheme); + SelectSchemeByKeyboard(mcMain, combo, index, scheme); + if (!string.Equals(ReadComboValue(ResolveCombo(CellElement(mcMain, SchemeCellId, index))), scheme, StringComparison.OrdinalIgnoreCase)) + SelectSchemeByPattern(ResolveCombo(CellElement(mcMain, SchemeCellId, index)), scheme); + + var final = ReadComboValue(ResolveCombo(CellElement(mcMain, SchemeCellId, index))); + if (!string.Equals(final, scheme, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"scheme combo row {index} never read back as '{scheme}' (reads '{final}')."); + } + + private static void SelectSchemeByClick(AutomationElement combo, string scheme) + { + var expanded = false; + try { combo.Patterns.ExpandCollapse.Pattern.Expand(); expanded = true; } + catch { try { combo.Click(); expanded = true; } catch { } } + Thread.Sleep(450); + + var items = combo.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)); + Trace($"scheme row: click route (Expand threw: {!expanded}), popup items=[{string.Join(",", items.Select(i => SafeText(() => i.Name)))}]"); + + var item = items.FirstOrDefault(i => string.Equals((i.Name ?? string.Empty).Trim(), scheme, StringComparison.OrdinalIgnoreCase)); + if (item != null) + { + try + { + item.Click(); + Trace($"scheme row: clicked the '{scheme}' item at {item.BoundingRectangle}"); + } + catch (Exception ex) + { + Trace($"scheme row: item click threw {ex.GetType().Name}, falling back to SelectionItem"); + try { item.Patterns.SelectionItem.Pattern.Select(); } catch { } + } + } + + Thread.Sleep(400); + try { combo.Patterns.ExpandCollapse.Pattern.Collapse(); } catch { } + Thread.Sleep(250); + } + + /// Open the dropdown with a real mouse click and TYPE the scheme: the route a + /// keyboard-driven admin takes, committing through the same input path the binding watches + /// rather than through an accessibility pattern. Typing only starts once the dropdown is + /// demonstrably open, so keystrokes can never land in another cell. + private static void SelectSchemeByKeyboard(Window mcMain, AutomationElement combo, int index, string scheme) + { + try { combo.Click(); } + catch { try { combo.Patterns.ExpandCollapse.Pattern.Expand(); } catch { } } + Thread.Sleep(500); + + var open = IsComboOpen(combo); + if (!open) + { + try { combo.Patterns.ExpandCollapse.Pattern.Expand(); Thread.Sleep(400); open = IsComboOpen(combo); } + catch { } + } + + if (!open) + { + Trace($"scheme row {index}: no dropdown open before typing - keyboard route skipped"); + return; + } + + FlaUI.Core.Input.Keyboard.Type(scheme); + Thread.Sleep(350); + FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.RETURN); + FlaUI.Core.Input.Keyboard.Release(FlaUI.Core.WindowsAPI.VirtualKeyShort.RETURN); + Trace($"scheme row {index}: typed '{scheme}' + Enter into an open dropdown"); + Thread.Sleep(400); + try { combo.Patterns.ExpandCollapse.Pattern.Collapse(); } catch { } + Thread.Sleep(250); + } + + private static bool IsComboOpen(AutomationElement combo) + { + try { return combo.Patterns.ExpandCollapse.IsSupported && combo.Patterns.ExpandCollapse.Pattern.ExpandCollapseState.Value == ExpandCollapseState.Expanded; } + catch { return false; } + } + + private static void SelectSchemeByPattern(AutomationElement combo, string scheme) + { + var item = combo.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) + .FirstOrDefault(i => string.Equals((i.Name ?? string.Empty).Trim(), scheme, StringComparison.OrdinalIgnoreCase)); + if (item == null) + { + try + { + combo.Patterns.ExpandCollapse.Pattern.Expand(); + Thread.Sleep(400); + item = combo.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) + .FirstOrDefault(i => string.Equals((i.Name ?? string.Empty).Trim(), scheme, StringComparison.OrdinalIgnoreCase)); + } + catch { } + } + + try { item?.Patterns.SelectionItem.Pattern.Select(); } catch { } + Thread.Sleep(400); + try { combo.Patterns.ExpandCollapse.Pattern.Collapse(); } catch { } + Thread.Sleep(250); + } + + private static void SetToggleCell(Window mcMain, string automationId, int index, bool on) + { + var desired = on ? ToggleState.On : ToggleState.Off; + for (var attempt = 0; attempt < 5; attempt++) + { + var cell = CellElement(mcMain, automationId, index); + if (!cell.Patterns.Toggle.IsSupported) { cell.Click(); Thread.Sleep(500); continue; } + if (cell.Patterns.Toggle.Pattern.ToggleState.Value == desired) return; + try { cell.Patterns.Toggle.Pattern.Toggle(); } + catch { cell.Click(); } + Thread.Sleep(500); + } + + var state = CellElement(mcMain, automationId, index).Patterns.Toggle.Pattern.ToggleState.Value; + if (state != desired) + throw new InvalidOperationException($"toggle cell '{automationId}' row {index} never reached {(on ? "On" : "Off")} (state {state})."); + } + + // ---- UIA readers ------------------------------------------------------------------------- + + /// The single grid-cell funnel. A raw FindAll can transiently throw a UIA + /// COMException (E_UNEXPECTED 0x8000FFFF mid-row-remove, also seen as ELEMENTNOTAVAILABLE + /// 0x80040201 on a stale element) while the WPF DataGrid re-virtualizes underneath the + /// tree walk (#61 live run tripped exactly this inside RemoveAllRows). Bounded retry with + /// backoff, then an honest re-throw - never a silent empty list that would let a + /// remove/add loop proceed against the wrong row count. + private static List CellElements(Window mcMain, string automationId) + { + for (var attempt = 0; ; attempt++) + { + try + { + return mcMain.FindAllDescendants(cf => cf.ByAutomationId(automationId)).ToList(); + } + catch (System.Runtime.InteropServices.COMException) when (attempt < 4) + { + Thread.Sleep(500 * (attempt + 1)); + } + } + } + + /// Cell for row {index}, WAITING for that row's cell of this column to be realized: + /// a row's host cell can exist while a sibling cell (scheme combo, remove button) is still + /// virtualized away, and a typing step that indexes straight into the short list crashed with + /// ArgumentOutOfRangeException on the #61 live run. Honest timeout once the wait expires. + private static AutomationElement CellElement(Window mcMain, string automationId, int index) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (true) + { + var cells = CellElements(mcMain, automationId); + if (index >= 0 && index < cells.Count) + { + return cells[index]; + } + + if (DateTime.UtcNow >= deadline) + { + throw new InvalidOperationException( + $"grid cell '{automationId}' row {index} never realized ({cells.Count} realized in that column)."); + } + + Thread.Sleep(400); + } + } + + private static string ReadValue(AutomationElement element) + { + try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value ?? string.Empty : element.Name ?? string.Empty; } + catch { return element.Name ?? string.Empty; } + } + + private static string ReadText(AutomationElement element) + { + try { return element.Name ?? string.Empty; } + catch { return string.Empty; } + } + + /// The ComboBox inside a grid cell (the column's AutomationId may sit on the cell). + private static AutomationElement ResolveCombo(AutomationElement target) + { + try + { + if (target.ControlType != ControlType.ComboBox) + return target.FindFirstDescendant(cf => cf.ByControlType(ControlType.ComboBox)) ?? target; + } + catch { } + + return target; + } + + private static string ReadComboValue(AutomationElement target) + { + // A WPF ComboBox only carries the Value pattern when it is editable, and a CLOSED combo + // realizes no ListItem peers - so a closed, non-editable combo reads empty through either + // single-shot route (learned live: every stored row's Scheme column came back blank). + // The grid column's AutomationId can also land on the cell rather than the combo, so the + // combo is resolved by control type first. Then escalate deliberately: Name, then any + // realized item peer, then open the popup, read the selected peer, and close it again. + var combo = ResolveCombo(target); + + try + { + if (combo.Patterns.Value.IsSupported) + { + var value = combo.Patterns.Value.Pattern.Value.Value; + if (!string.IsNullOrWhiteSpace(value)) return value.Trim(); + } + } + catch { } + + try + { + if (!string.IsNullOrWhiteSpace(combo.Name)) return combo.Name.Trim(); + } + catch { } + + var selected = SelectedItemName(combo); + if (!string.IsNullOrWhiteSpace(selected)) return selected; + + try + { + combo.Patterns.ExpandCollapse.Pattern.Expand(); + Thread.Sleep(350); + selected = SelectedItemName(combo); + combo.Patterns.ExpandCollapse.Pattern.Collapse(); + Thread.Sleep(200); + return selected ?? string.Empty; + } + catch + { + try { combo.Patterns.ExpandCollapse.Pattern.Collapse(); } catch { } + return string.Empty; + } + } + + private static string SelectedItemName(AutomationElement combo) + { + try + { + var items = combo.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)); + var selected = items.FirstOrDefault(i => + { + try { return i.Patterns.SelectionItem.IsSupported && i.Patterns.SelectionItem.Pattern.IsSelected.Value; } + catch { return false; } + }); + return (selected?.Name ?? string.Empty).Trim(); + } + catch { return string.Empty; } + } + + private static bool ReadToggleState(AutomationElement element) + { + try { return element.Patterns.Toggle.IsSupported && element.Patterns.Toggle.Pattern.ToggleState.Value == ToggleState.On; } + catch { return false; } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/Artifacts.cs b/tests/ux/WebContent.UxTests/Harness/Artifacts.cs index 1b805c5..c11a260 100644 --- a/tests/ux/WebContent.UxTests/Harness/Artifacts.cs +++ b/tests/ux/WebContent.UxTests/Harness/Artifacts.cs @@ -1,6 +1,7 @@ +using System.Runtime.InteropServices; + using FlaUI.Core.AutomationElements; using FlaUI.Core.Capturing; -using System.Runtime.InteropServices; namespace WebContent.UxTests.Harness; @@ -29,6 +30,54 @@ public static string Window(Window window, string name) return file; } + /// + /// DWM-buffer capture (PrintWindow, PW_RENDERFULLCONTENT) instead of CopyFromScreen: a + /// disconnected desktop session has no screen surface, and CopyFromScreen HANGS there + /// (observed live during the #83 probe run). The per-window redirection bitmap still + /// exists, so PrintWindow renders. Runs on a background thread with a join timeout - + /// if the target process never answers the print message, the capture is abandoned + /// (null returned, note file written) instead of freezing the test. + /// + public static string WindowPrint(Window window, string name, int timeoutMilliseconds = 8000) + { + var file = PathOf(name + ".png"); + System.Drawing.Bitmap bmp = null; + Exception error = null; + var hwnd = window.Properties.NativeWindowHandle.Value; + var thread = new Thread(() => + { + try + { + GetWindowRect(hwnd, out var r); + var width = r.Right - r.Left; + var height = r.Bottom - r.Top; + if (width <= 0 || height <= 0) { error = new InvalidOperationException("window has no rect"); return; } + bmp = new System.Drawing.Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format24bppRgb); + using var g = System.Drawing.Graphics.FromImage(bmp); + var hdc = g.GetHdc(); + bool printed; + try { printed = PrintWindow(hwnd, hdc, 2); } finally { g.ReleaseHdc(); } + if (!printed) { error = new InvalidOperationException("PrintWindow returned false"); bmp.Dispose(); bmp = null; } + } + catch (Exception ex) { error = ex; } + }) + { IsBackground = true }; + thread.Start(); + if (!thread.Join(timeoutMilliseconds)) + { + File.WriteAllText(PathOf(name + "-capture-note.txt"), $"PrintWindow capture for '{name}' timed out after {timeoutMilliseconds} ms (no screen surface on a disconnected session)."); + return null; + } + if (error != null || bmp == null) + { + File.WriteAllText(PathOf(name + "-capture-note.txt"), $"PrintWindow capture for '{name}' failed: {error?.GetType().Name}: {error?.Message}"); + return null; + } + bmp.Save(file, System.Drawing.Imaging.ImageFormat.Png); + bmp.Dispose(); + return file; + } + public static string Element(AutomationElement element, string name) { var file = PathOf(name + ".png"); @@ -116,6 +165,9 @@ private static void EnsureDpiAware() [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] + private static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags); + [StructLayout(LayoutKind.Sequential)] private struct RECT { diff --git a/tests/ux/WebContent.UxTests/Harness/BlockedResourcePageServer.cs b/tests/ux/WebContent.UxTests/Harness/BlockedResourcePageServer.cs new file mode 100644 index 0000000..42d01ea --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/BlockedResourcePageServer.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace WebContent.UxTests.Harness; + +/// +/// Deterministic page pair for the #83 blocked-resources indicator probe (SC-7): a page that +/// loads FINE through the proxy while its subresources point at an origin the workspace does +/// NOT allowlist (two images plus a script), and a clean twin with no subresource references. +/// The blocked-origin port deliberately serves nothing - the loopback listener refuses those +/// requests client-side (allowlist 403) before the Event Server is ever asked, which is +/// exactly the half-broken-page posture the indicator exists to surface. +/// +/// Raw TcpListener on IPAddress.Any (like ): HttpListener needs a +/// urlacl reservation to bind a LAN address unelevated, and the Event Server - not the local +/// browser - is the machine that must be able to fetch the page origin. +/// +public sealed class BlockedResourcePageServer : IDisposable +{ + private const string BlockedPage = """ + UX Probe BLOCKEDPAGE + + +
UX-PROBE-BLOCKEDPAGE
+ one + two + + + """; + + private const string CleanPage = """ + UX Probe CLEANPAGE + +
UX-PROBE-CLEANPAGE
+ """; + + private readonly TcpListener _listener; + + /// The origin the page is served from - the allowlist entry the probe provisions. + public string PageOrigin { get; } + + /// The origin every subresource points at - deliberately NOT allowlisted. + public string BlockedOrigin { get; } + + public BlockedResourcePageServer(string bindHost, int pagePort, int blockedOriginPort) + { + PageOrigin = $"http://{bindHost}:{pagePort}"; + BlockedOrigin = $"http://{bindHost}:{blockedOriginPort}"; + + _listener = new TcpListener(IPAddress.Any, pagePort); + _listener.Start(); + _ = Task.Run(AcceptLoopAsync); + } + + public void Dispose() => _listener.Stop(); + + private async Task AcceptLoopAsync() + { + while (true) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(); + } + catch + { + return; + } + + _ = Task.Run(() => ServeAsync(client)); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + try + { + client.NoDelay = true; + var stream = client.GetStream(); + + // Read request headers; the body (if any) is irrelevant for these GETs. + var reader = new StreamReader(stream, Encoding.ASCII); + string requestLine = null; + while (true) + { + var line = reader.ReadLine(); + if (line == null) return; + if (line.Length == 0) break; + requestLine ??= line; + } + + var path = requestLine != null && requestLine.Split(' ').Length > 1 ? requestLine.Split(' ')[1] : "/"; + var body = path.Contains("clean", System.StringComparison.OrdinalIgnoreCase) + ? string.Format(CleanPage) + : BlockedPage.Replace("{blocked}", BlockedOrigin); + var bytes = Encoding.UTF8.GetBytes(body); + + var head = $"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(head)); + await stream.WriteAsync(bytes); + await stream.FlushAsync(); + } + catch + { + // throwaway probe server: a broken client connection is not a test failure + } + } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/BrowserProcessArgs.cs b/tests/ux/WebContent.UxTests/Harness/BrowserProcessArgs.cs new file mode 100644 index 0000000..2fe29e2 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/BrowserProcessArgs.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management; +using System.Text.RegularExpressions; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #67 (SC-8): reads the live command lines of the WebView2 browser processes the +/// plugin hosts, so a probe can prove what routing each browser was LAUNCHED with - the +/// --proxy-server argument is per browser process, and "which proxy does this browser obey" +/// is decided there, not in any plugin-side log. Processes are scoped by --user-data-dir +/// (same marker ProcessKiller uses) so other WebView2 apps on the workstation never appear. +/// +public static class BrowserProcessArgs +{ + /// Full command line of every msedgewebview2.exe referencing the given data-root marker. + public static List CommandLines(string dataRootMarker) + { + using var searcher = new ManagementObjectSearcher( + "SELECT CommandLine FROM Win32_Process WHERE Name='msedgewebview2.exe'"); + var found = new List(); + foreach (ManagementObject proc in searcher.Get()) + { + var commandLine = proc["CommandLine"] as string ?? string.Empty; + if (commandLine.Contains(dataRootMarker ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + found.Add(commandLine); + } + + return found; + } + + /// One compact line per process: user-data-dir, and every proxy-shaping switch + /// actually present (--proxy-server, --proxy-bypass-list, --no-proxy-server). Absent + /// switches are simply not named, so "no proxy switch" is visible as silence. + public static string Summarize(string commandLine) + { + var userDataDir = MatchSwitch(commandLine, "--user-data-dir"); + var proxyServer = MatchSwitch(commandLine, "--proxy-server"); + var bypass = MatchSwitch(commandLine, "--proxy-bypass-list"); + var flags = new List(); + if (commandLine.Contains("--no-proxy-server", StringComparison.OrdinalIgnoreCase)) flags.Add("--no-proxy-server"); + if (commandLine.Contains(" --browser", StringComparison.OrdinalIgnoreCase) || + commandLine.EndsWith("--browser", StringComparison.OrdinalIgnoreCase)) flags.Add("--browser"); + return $"user-data-dir='{userDataDir}' proxy-server='{proxyServer}' proxy-bypass-list='{bypass}' flags=[{string.Join(",", flags)}]"; + } + + private static string MatchSwitch(string commandLine, string switchName) + { + var match = Regex.Match(commandLine ?? string.Empty, + Regex.Escape(switchName) + "=(\"(?[^\"]*)\"|(?[^\\s]*))"); + if (!match.Success) return string.Empty; + return match.Groups["q"].Success ? match.Groups["q"].Value : match.Groups["b"].Value; + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/ClientSession.cs b/tests/ux/WebContent.UxTests/Harness/ClientSession.cs index 00cc8da..c711303 100644 --- a/tests/ux/WebContent.UxTests/Harness/ClientSession.cs +++ b/tests/ux/WebContent.UxTests/Harness/ClientSession.cs @@ -1,7 +1,8 @@ +using System.Diagnostics; + using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.UIA3; -using System.Diagnostics; namespace WebContent.UxTests.Harness; @@ -154,7 +155,7 @@ public Window FindWindow(Func predicate) => public void Dispose() { - Automation.Dispose(); - Application.Dispose(); + try { Automation.Dispose(); } catch { } + try { Application.Dispose(); } catch { } } } diff --git a/tests/ux/WebContent.UxTests/Harness/EventServerAuthority.cs b/tests/ux/WebContent.UxTests/Harness/EventServerAuthority.cs new file mode 100644 index 0000000..8c7c83a --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/EventServerAuthority.cs @@ -0,0 +1,118 @@ +using System; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #75 (MC-15): read the deployment proxy CA that lives on the Event Server machine, so a +/// live probe can compare what the Management Client exported against what the server actually +/// holds. Strictly a READ: the remote block opens LocalMachine\Personal ReadOnly|OpenExistingOnly +/// (the same posture the export responder uses) and returns public DER only - no store write, no +/// private key. Remoting follows the convention: jhadmin +/// credentials travel through the child process's environment, never the command line. +/// +public static class EventServerAuthority +{ + /// The fixed deployment authority CN (ProxyLoopbackTls.AuthorityCommonName). + public const string AuthorityCommonName = "XProtect WebContent Plugin Loopback Proxy"; + + /// + /// The Event Server machine's LocalMachine\Personal copy of the deployment CA, or null when the + /// machine has none yet (the posture whose export refusal the #75 AC demands). + /// + public static X509Certificate2 ReadDeploymentAuthority(UxConfig config, StringBuilder log) + { + var address = Environment.GetEnvironmentVariable("UX_ES_HOST") ?? config.VmsAddress; + var host = address.Contains("://", StringComparison.Ordinal) && Uri.TryCreate(address, UriKind.Absolute, out var addressUri) + ? addressUri.Host + : address; + log.AppendLine($"reading LocalMachine\\Personal CN='{AuthorityCommonName}' on {host} (read-only)..."); + + var script = $@" +$ErrorActionPreference = 'Stop' +$password = ConvertTo-SecureString $env:WEBCONTENT_ES_PASSWORD -AsPlainText -Force +$credential = New-Object System.Management.Automation.PSCredential($env:WEBCONTENT_ES_USER, $password) +$session = New-PSSession -ComputerName '{host}' -Credential $credential +try {{ + Invoke-Command -Session $session -ArgumentList '{AuthorityCommonName}' -ScriptBlock {{ + param($commonName) + $ErrorActionPreference = 'Stop' + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('My', 'LocalMachine') + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly -bor [System.Security.Cryptography.X509Certificates.OpenFlags]::OpenExistingOnly) + try {{ + $found = @($store.Certificates.Find([System.Security.Cryptography.X509Certificates.X509FindType]::FindBySubjectName, $commonName, $false)) + Write-OUTPUT ('COUNT|' + $found.Count) + foreach ($certificate in $found) {{ + Write-OUTPUT ('CERT|' + $certificate.Thumbprint + '|' + $certificate.NotAfter.ToString('o') + '|' + [Convert]::ToBase64String($certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))) + }} + }} finally {{ + $store.Close() + }} + }} +}} finally {{ + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +}} +"; + + var startInfo = new ProcessStartInfo + { + FileName = ResolvePowerShell(), + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.Environment["WEBCONTENT_ES_USER"] = config.VmsUsername; + startInfo.Environment["WEBCONTENT_ES_PASSWORD"] = config.VmsPassword; + + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + log.AppendLine($" es-authority read FAILED (exit {process.ExitCode}): {stderr.Trim()}"); + throw new InvalidOperationException($"Reading the deployment authority on {host} failed: {stderr.Trim()}"); + } + + X509Certificate2 chosen = null; + var chosenNotAfter = DateTime.MinValue; + foreach (var line in stdout.Split('\n')) + { + var text = line.Trim(); + if (text.StartsWith("COUNT|", StringComparison.Ordinal)) + { + log.AppendLine($" es-authority: {text}"); + continue; + } + + if (!text.StartsWith("CERT|", StringComparison.Ordinal)) continue; + var parts = text.Substring("CERT|".Length).Split('|', 3); + if (parts.Length != 3) continue; + var der = Convert.FromBase64String(parts[2]); + var certificate = X509CertificateLoader.LoadCertificate(der); + log.AppendLine($" es-authority: thumbprint {parts[0]}, notAfter {parts[1]}, {der.Length} DER bytes, private key here={certificate.HasPrivateKey}"); + if (certificate.NotAfter > chosenNotAfter) + { + chosen = certificate; + chosenNotAfter = certificate.NotAfter; + } + } + + return chosen; + } + + private static string ResolvePowerShell() + { + // pwsh (PowerShell 7) is the repo's shell; Windows PowerShell 5.1 is the fallback. + var programFiles = Environment.GetEnvironmentVariable("ProgramFiles") ?? @"C:\Program Files"; + var pwsh = Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"); + if (File.Exists(pwsh)) return pwsh; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/EventServerControl.cs b/tests/ux/WebContent.UxTests/Harness/EventServerControl.cs new file mode 100644 index 0000000..ee2d606 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/EventServerControl.cs @@ -0,0 +1,115 @@ +using System; +using System.Diagnostics; +using System.Text; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #58 (SC-5 demo): stop/start the XProtect Event Server service on the VMS machine over +/// PowerShell remoting, so a live Smart Client can be shown degrading to the proxy error page +/// while the server-side proxy component is down, and recovering afterwards. Follows the deploy +/// conventions in AGENTS.md: jhadmin credentials from the repo-root .env, the service's stop is +/// slow so the state is POLLED to Stopped/Running before the caller continues, and Start is +/// always safe to call (the probe's finally must be able to restore the VMS even on failure). +/// Credentials travel through the child process's environment, never the command line or a +/// script file. +/// +public static class EventServerControl +{ + public const string ServiceName = "MilestoneEventServerService"; + + /// The outage row only runs when the operator explicitly opts in. + public static bool OutageRowEnabled => + Environment.GetEnvironmentVariable("WEBCONTENT_ES_STOP") == "1"; + + public static void Stop(UxConfig config, StringBuilder log, int waitSeconds = 300) => + Run(config, "Stop", log, waitSeconds); + + public static void Start(UxConfig config, StringBuilder log, int waitSeconds = 300) => + Run(config, "Start", log, waitSeconds); + + private static void Run(UxConfig config, string action, StringBuilder log, int waitSeconds) + { + var host = Environment.GetEnvironmentVariable("UX_ES_HOST") ?? config.VmsAddress; + log.AppendLine($"remote {action} of {ServiceName} on {host}..."); + + var script = $@" +$ErrorActionPreference = 'Stop' +$password = ConvertTo-SecureString $env:WEBCONTENT_ES_PASSWORD -AsPlainText -Force +$credential = New-Object System.Management.Automation.PSCredential($env:WEBCONTENT_ES_USER, $password) +$session = New-PSSession -ComputerName '{host}' -Credential $credential +try {{ + Invoke-Command -Session $session -ArgumentList $env:WEBCONTENT_ES_ACTION, $env:WEBCONTENT_ES_WAIT_SECONDS, '{ServiceName}' -ScriptBlock {{ + param($action, $waitSeconds, $serviceName) + $ErrorActionPreference = 'Stop' + $deadline = (Get-Date).AddSeconds($waitSeconds) + $service = Get-Service -Name $serviceName + if ($action -eq 'Stop') {{ + if ($service.Status -ne 'Stopped') {{ + Write-Output ('stopping ' + $serviceName + ' (was ' + $service.Status + ')') + Stop-Service -Name $serviceName -Force + $target = 'Stopped' + }} else {{ $target = 'Stopped' }} + }} else {{ + if ($service.Status -ne 'Running') {{ + Write-Output ('starting ' + $serviceName + ' (was ' + $service.Status + ')') + Start-Service -Name $serviceName + $target = 'Running' + }} else {{ $target = 'Running' }} + }} + while ((Get-Service -Name $serviceName).Status -ne $target -and (Get-Date) -lt $deadline) {{ + Start-Sleep -Seconds 2 + }} + $final = (Get-Service -Name $serviceName).Status + Write-Output ($serviceName + ' final status: ' + $final) + if ($final -ne $target) {{ + throw ('service ' + $serviceName + ' did not reach ' + $target + ' within ' + $waitSeconds + 's (now ' + $final + ')') + }} + }} +}} finally {{ + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +}} +"; + + var startInfo = new ProcessStartInfo + { + FileName = ResolvePowerShell(), + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.Environment["WEBCONTENT_ES_USER"] = config.VmsUsername; + startInfo.Environment["WEBCONTENT_ES_PASSWORD"] = config.VmsPassword; + startInfo.Environment["WEBCONTENT_ES_ACTION"] = action; + startInfo.Environment["WEBCONTENT_ES_WAIT_SECONDS"] = waitSeconds.ToString(); + + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + foreach (var line in stdout.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) log.AppendLine(" es: " + line.Trim()); + } + + if (process.ExitCode != 0) + { + log.AppendLine($" es {action} FAILED (exit {process.ExitCode}): {stderr.Trim()}"); + throw new InvalidOperationException($"Remote {action} of {ServiceName} on {host} failed: {stderr.Trim()}"); + } + + log.AppendLine($" {ServiceName} {action.ToLowerInvariant()} confirmed"); + } + + private static string ResolvePowerShell() + { + // pwsh (PowerShell 7) is the repo's shell; Windows PowerShell 5.1 is the fallback. + var programFiles = Environment.GetEnvironmentVariable("ProgramFiles") ?? @"C:\Program Files"; + var pwsh = Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"); + if (File.Exists(pwsh)) return pwsh; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/HeaderGridUi.cs b/tests/ux/WebContent.UxTests/Harness/HeaderGridUi.cs new file mode 100644 index 0000000..1204d48 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/HeaderGridUi.cs @@ -0,0 +1,397 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #86: shared Management Client phases for driving and reading the Proxy tab's two +/// "Advanced: headers" grids — the request-leg grid (dgProxyRequestHeaders) and the response-leg +/// grid (dgProxyResponseHeaders). Like the allowlist grid above them, every per-row control +/// carries the SAME AutomationId (proxyHeaderRowName, proxyHeaderRowValue, proxyHeaderRowStrip, +/// proxyHeaderRowProblem, proxyHeaderRowRemove) — but this time the id appears in TWO grids on +/// the same tab, so a window-wide FindAllByAutomationId would interleave the two policies' rows. +/// Everything here is therefore scoped to the grid element first (its explicit AutomationId, +/// proxyRequestHeadersGrid / proxyResponseHeadersGrid), and within one grid rows are addressed by +/// document order like the allowlist harness does. Virtualization is off in both grids' XAML, so +/// every row stays realized and the ordering cannot drift. +/// +/// Same discipline as the rest of the harness: every edit verifies by observable readback, +/// escalating the route (Value pattern first, then focus-gated clipboard paste) on failure, and +/// every "it saved" claim goes through two independent channels — the plugin's own +/// "Stored proxy header policy ..." audit line in the MC MIP.log, and a readback from a +/// RELOADED pane. +/// +public static class HeaderGridUi +{ + public const string RequestGridId = "proxyRequestHeadersGrid"; + public const string ResponseGridId = "proxyResponseHeadersGrid"; + public const string NameCellId = "proxyHeaderRowName"; + public const string ValueCellId = "proxyHeaderRowValue"; + public const string StripCellId = "proxyHeaderRowStrip"; + public const string ProblemCellId = "proxyHeaderRowProblem"; + public const string RemoveCellId = "proxyHeaderRowRemove"; + public const string AddRequestButtonId = "btnProxyHeaderAddRequest"; + public const string AddResponseButtonId = "btnProxyHeaderAddResponse"; + + /// Where the grid driver's step notes go (same convention as AllowlistGridUi.Trace). + public static Action Trace { get; set; } = _ => { }; + + /// One header-policy row as UIA reads it. Describe renders the stored-rule shape the + /// plugin's audit line uses: "Name=Value" for add/override rows, "-Name" for strip rows. + public sealed class RowReading + { + public int Index; + public string Name = string.Empty; + public string Value = string.Empty; + public bool Strip; + public string Problem = string.Empty; + + public string Describe() => Strip ? "-" + Name : Name + "=" + Value; + } + + /// The wanted-row shape callers pass in: (name, value, strip). + public sealed record Row(string Name, string Value, bool Strip) + { + public string Describe() => Strip ? "-" + Name : Name + "=" + Value; + } + + private static AutomationElement GridFor(Window mcMain, string gridId) => + ManagementClientUi.ExpectControl(mcMain, gridId, 15); + + private static string AddButtonFor(string gridId) => + gridId == ResponseGridId ? AddResponseButtonId : AddRequestButtonId; + + /// The grid-scoped cell funnel. Same COMException retry as AllowlistGridUi.CellElements: + /// a mid-edit UIA tree walk can transiently throw while the DataGrid re-realizes underneath. + private static List CellElements(AutomationElement grid, string cellId) + { + for (var attempt = 0; ; attempt++) + { + try + { + return grid.FindAllDescendants(cf => cf.ByAutomationId(cellId)).ToList(); + } + catch (System.Runtime.InteropServices.COMException) when (attempt < 4) + { + Thread.Sleep(500 * (attempt + 1)); + } + } + } + + public static int RowCount(Window mcMain, string gridId) => + CellElements(GridFor(mcMain, gridId), NameCellId).Count; + + public static List ReadRows(Window mcMain, string gridId) + { + var grid = GridFor(mcMain, gridId); + var names = CellElements(grid, NameCellId); + var values = CellElements(grid, ValueCellId); + var strips = CellElements(grid, StripCellId); + var problems = CellElements(grid, ProblemCellId); + + var rows = new List(); + for (var i = 0; i < names.Count; i++) + { + rows.Add(new RowReading + { + Index = i, + Name = ReadValue(names[i]), + Value = i < values.Count ? ReadValue(values[i]) : string.Empty, + Strip = i < strips.Count && ReadToggleState(strips[i]), + Problem = i < problems.Count ? ReadText(problems[i]) : string.Empty, + }); + } + + return rows; + } + + public static string ReadProblem(Window mcMain, string gridId, int index) + { + var problems = CellElements(GridFor(mcMain, gridId), ProblemCellId); + if (index < 0 || index >= problems.Count) + throw new InvalidOperationException($"no Problem cell at row {index} of '{gridId}' ({problems.Count} realized)."); + return ReadText(problems[index]); + } + + /// Press the grid's Add button and wait for a new realized row. + public static void AddRow(Window mcMain, string gridId) + { + var before = RowCount(mcMain, gridId); + var button = ManagementClientUi.ExpectControl(mcMain, AddButtonFor(gridId), 10); + for (var attempt = 0; attempt < 5 && RowCount(mcMain, gridId) == before; attempt++) + { + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + Thread.Sleep(600); + } + + if (RowCount(mcMain, gridId) != before + 1) + throw new InvalidOperationException($"'{AddButtonFor(gridId)}' did not append a row to '{gridId}' ({before} -> {RowCount(mcMain, gridId)})."); + } + + /// Remove one row by index, waiting for the row count to drop. + public static void RemoveRowAt(Window mcMain, string gridId, int index) + { + var before = RowCount(mcMain, gridId); + if (index < 0 || index >= before) + throw new InvalidOperationException($"no row {index} in '{gridId}' to remove (rows={before})."); + + var grid = GridFor(mcMain, gridId); + for (var attempt = 0; attempt < 5 && RowCount(mcMain, gridId) == before; attempt++) + { + var cells = CellElements(grid, RemoveCellId); + if (index >= cells.Count) { Thread.Sleep(400); continue; } + var button = cells[index]; + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + Thread.Sleep(600); + } + + if (RowCount(mcMain, gridId) != before - 1) + throw new InvalidOperationException($"remove on row {index} of '{gridId}' never dropped a row ({before} -> {RowCount(mcMain, gridId)})."); + } + + public static void RemoveAllRows(Window mcMain, string gridId) + { + for (var guard = 0; RowCount(mcMain, gridId) > 0 && guard < 50; guard++) + { + RemoveRowAt(mcMain, gridId, 0); + } + } + + /// Replace the whole grid with the wanted rows: remove every row, add one row per + /// entry, fill and verify row by row (order preserved — the plugin stores configuration + /// order, and for one header name the LAST rule wins). + public static void SetRows(Window mcMain, string gridId, IEnumerable rows) + { + RemoveAllRows(mcMain, gridId); + foreach (var row in rows ?? Enumerable.Empty()) + { + AddRow(mcMain, gridId); + SetLastRow(mcMain, gridId, row); + } + } + + /// Fill the LAST row of the grid and verify it by readback. + public static void SetLastRow(Window mcMain, string gridId, Row row) + { + var index = RowCount(mcMain, gridId) - 1; + if (index < 0) throw new InvalidOperationException($"no row to set in '{gridId}'."); + + var grid = GridFor(mcMain, gridId); + SetTextCell(mcMain, grid, NameCellId, index, row.Name); + SetTextCell(mcMain, grid, ValueCellId, index, row.Strip ? string.Empty : row.Value ?? string.Empty); + SetToggleCell(mcMain, grid, StripCellId, index, row.Strip); + + var read = ReadRows(mcMain, gridId)[index]; + Trace($"'{gridId}' row {index}: '{row.Name}' (strip={row.Strip}) reads Name='{read.Name}' Value='{read.Value}' Strip={read.Strip} Problem='{read.Problem}'"); + if (!string.Equals(read.Name, row.Name, StringComparison.Ordinal) + || !string.Equals(read.Strip ? string.Empty : read.Value, row.Strip ? string.Empty : row.Value ?? string.Empty, StringComparison.Ordinal) + || read.Strip != row.Strip) + { + throw new InvalidOperationException( + $"'{gridId}' row {index} never read back as '{row.Describe()}' (reads '{read.Describe()}')."); + } + } + + /// + /// The header-policy save discipline, mirroring AllowlistGridUi.SaveAndVerify: dirty-chain + /// guards, the pre-Save Name check, Save with modal sweep, then the stored policy read back + /// from TWO independent channels — the plugin's own "Stored proxy header policy for workspace + /// '...' : request N rule(s) [...]; response M rule(s) [...]" audit line in the MC MIP.log, + /// and both grids re-read from a RELOADED pane, ORDER-SENSITIVELY (configuration order is + /// part of the policy: last rule for one name wins, here and on the Event Server). + /// + public static void SavePoliciesAndVerify( + ClientSession mc, + Window mcMain, + string workspacesNodeName, + string workspaceName, + IEnumerable expectedRequest, + IEnumerable expectedResponse, + StringBuilder log) + { + Trace = message => log.AppendLine(message); + var request = (expectedRequest ?? Enumerable.Empty()).ToList(); + var response = (expectedResponse ?? Enumerable.Empty()).ToList(); + + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the header-grid edit; flipping the routing checkbox twice to dirty the pane"); + ManagementClientUi.DirtyPaneViaCheckbox(mcMain, AllowlistGridUi.ProxyEnabledId); + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the checkbox flip; keyboard-nudging the first request-grid name cell"); + try { ManagementClientUi.NudgeControlWithKeystroke(mcMain, NameCellId); } + catch (Exception ex) { log.AppendLine($"nudge skipped: {ex.GetType().Name}"); } + log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}"); + } + } + + var paneName = AllowlistGridUi.ReadPaneName(mcMain); + if (!string.Equals(paneName, workspaceName, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Refusing to Save: the Name field reads '{paneName}', expected '{workspaceName}'."); + + AllowlistGridUi.OpenProxyTab(mcMain); + + // The audit line this save writes, keyed on this call's exact leg counts AND exact rule + // renderings, so an older save of a different policy cannot satisfy the delta. + var auditMarker = $"Stored proxy header policy for workspace '{workspaceName}'"; + var tokens = AuditTokens(request, response); + var auditBefore = McLog.CountMatches(auditMarker, tokens); + + if (!ManagementClientUi.Save(mc, mcMain, 20)) + throw new InvalidOperationException( + $"Save never became enabled for policy request=[{string.Join("; ", request.Select(r => r.Describe()))}] response=[{string.Join("; ", response.Select(r => r.Describe()))}]."); + + var auditLine = McLog.WaitForNewLine(auditBefore, 20, auditMarker, tokens); + if (auditLine == null) + throw new InvalidOperationException( + $"the save wrote no 'Stored proxy header policy' audit line carrying [{string.Join(" | ", tokens)}] " + + $"({auditBefore} -> {McLog.CountMatches(auditMarker, tokens)}). " + + "The save either never ran or stored something other than the grids showed."); + log.AppendLine($"stored header policy audit line: {auditLine}"); + + Thread.Sleep(1500); + AllowlistGridUi.ReloadPane(mcMain, workspacesNodeName, workspaceName); + AllowlistGridUi.OpenProxyTab(mcMain); + var storedRequest = ReadRows(mcMain, RequestGridId); + var storedResponse = ReadRows(mcMain, ResponseGridId); + if (!SameRows(request, storedRequest)) + throw new InvalidOperationException( + $"After Save + reload the request grid reads '{string.Join(" | ", storedRequest.Select(r => r.Describe()))}' — expected '{string.Join(" | ", request.Select(r => r.Describe()))}'."); + if (!SameRows(response, storedResponse)) + throw new InvalidOperationException( + $"After Save + reload the response grid reads '{string.Join(" | ", storedResponse.Select(r => r.Describe()))}' — expected '{string.Join(" | ", response.Select(r => r.Describe()))}'."); + log.AppendLine( + $"saved header policy request=[{string.Join("; ", request.Select(r => r.Describe()))}] response=[{string.Join("; ", response.Select(r => r.Describe()))}] (verified from the audit line AND reloaded panes, order-sensitive)"); + } + + /// The audit line fragments for one wanted policy: each leg's count AND its exact + /// rendering in brackets. ProxyHeaderEditor.DescribeStoredRules renders add rows as + /// "Name=Value" and strip rows as "-Name", joined by "; " — mirrored exactly here. + private static string[] AuditTokens(IEnumerable request, IEnumerable response) + { + var requestRules = request.ToList(); + var responseRules = response.ToList(); + var reqDesc = string.Join("; ", requestRules.Select(r => r.Describe())); + var rspDesc = string.Join("; ", responseRules.Select(r => r.Describe())); + return new[] + { + $"request {requestRules.Count} rule(s)", + $"[{reqDesc}]", + $"response {responseRules.Count} rule(s)", + $"[{rspDesc}]", + }; + } + + /// Order-SENSITIVE row comparison: configuration order is part of the policy. + public static bool SameRows(IEnumerable expected, IEnumerable actual) + { + var e = (expected ?? Enumerable.Empty()).Select(r => r.Describe()).ToList(); + var a = (actual ?? Enumerable.Empty()).Select(r => r.Describe()).ToList(); + return e.SequenceEqual(a, StringComparer.Ordinal); + } + + // ---- cell primitives: read/verify, escalate the route on failure (same ladder as the + // allowlist grid, but every lookup is scoped to this caller's grid) ------------------ + + private static void SetTextCell(Window mcMain, AutomationElement grid, string cellId, int index, string value) + { + for (var attempt = 0; attempt < 4; attempt++) + { + var cell = CellElement(grid, cellId, index); + try + { + if (cell.Patterns.Value.IsSupported) + cell.Patterns.Value.Pattern.SetValue(value); + } + catch { } + + Thread.Sleep(400); + if (string.Equals(ReadValue(CellElement(grid, cellId, index)), value, StringComparison.Ordinal)) + return; + + // Keyboard route, focus-gated: the id is shared by BOTH grids' rows, so the focus + // gate only proves SOMETHING with this id holds focus — the row readback below is + // what proves the write landed in THIS grid's row. + cell = CellElement(grid, cellId, index); + if (ManagementClientUi.FocusVerified(mcMain, cell, cellId) && LoginFlow.TryClipboardSetText(value)) + { + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A); + Thread.Sleep(150); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_V); + Thread.Sleep(400); + if (string.Equals(ReadValue(CellElement(grid, cellId, index)), value, StringComparison.Ordinal)) + return; + } + + Thread.Sleep(400); + } + + throw new InvalidOperationException( + $"'{cellId}' row {index} never read back as '{value}' (reads '{ReadValue(CellElement(grid, cellId, index))}')."); + } + + private static AutomationElement CellElement(AutomationElement grid, string cellId, int index) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (true) + { + var cells = CellElements(grid, cellId); + if (index >= 0 && index < cells.Count) + { + return cells[index]; + } + + if (DateTime.UtcNow >= deadline) + { + throw new InvalidOperationException( + $"grid cell '{cellId}' row {index} never realized ({cells.Count} realized in that column)."); + } + + Thread.Sleep(400); + } + } + + private static void SetToggleCell(Window mcMain, AutomationElement grid, string cellId, int index, bool on) + { + var desired = on ? ToggleState.On : ToggleState.Off; + for (var attempt = 0; attempt < 5; attempt++) + { + var cell = CellElement(grid, cellId, index); + if (!cell.Patterns.Toggle.IsSupported) { cell.Click(); Thread.Sleep(500); continue; } + if (cell.Patterns.Toggle.Pattern.ToggleState.Value == desired) return; + try { cell.Patterns.Toggle.Pattern.Toggle(); } + catch { cell.Click(); } + Thread.Sleep(500); + } + + var state = CellElement(grid, cellId, index).Patterns.Toggle.Pattern.ToggleState.Value; + if (state != desired) + throw new InvalidOperationException($"toggle cell '{cellId}' row {index} never reached {(on ? "On" : "Off")} (state {state})."); + } + + private static string ReadValue(AutomationElement element) + { + try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value ?? string.Empty : element.Name ?? string.Empty; } + catch { return element.Name ?? string.Empty; } + } + + private static string ReadText(AutomationElement element) + { + try { return element.Name ?? string.Empty; } + catch { return string.Empty; } + } + + private static bool ReadToggleState(AutomationElement element) + { + try { return element.Patterns.Toggle.IsSupported && element.Patterns.Toggle.Pattern.ToggleState.Value == ToggleState.On; } + catch { return false; } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs index 6af65a4..1d36217 100644 --- a/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs +++ b/tests/ux/WebContent.UxTests/Harness/LoginFlow.cs @@ -1,6 +1,12 @@ +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +using FlaUI.Core; using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; namespace WebContent.UxTests.Harness; @@ -10,38 +16,172 @@ namespace WebContent.UxTests.Harness; /// public static class LoginFlow { + /// + /// Waits for the login dialog. Returns NULL only when the Smart Client main window (whose + /// _mainApplicationWindow AutomationId no other window carries) is up while NO window of the + /// process wears login controls - i.e. Auto-login connected on its own. That shortcut is + /// deliberately NOT extended to the Management Client: MC's main shell window exists from + /// launch, BEFORE login (login is a separate window), so its absence/presence proves nothing. + /// public static Window WaitForLoginWindow(ClientSession session, int timeoutSeconds = 60) - => session.WaitForWindow( - w => w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null || - w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null, - timeoutSeconds, "login dialog"); + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var window = session.FindWindow(HasLoginControls); + if (window != null) return window; + if (session.Windows().Any(w => + string.Equals(w.AutomationId, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase) + && !HasLoginControls(w))) + { + LogEntry("Smart Client main window up with no login dialog anywhere (auto-login); no entry needed"); + return null; + } + Thread.Sleep(300); + } + throw new TimeoutException($"Timed out waiting for login dialog (process {session.Process.ProcessName}, pid {session.Process.Id})."); + } public static void Login(ClientSession session, Window loginWindow, UxConfig config) { - var serverBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox")); - if (serverBox != null && !string.IsNullOrWhiteSpace(config.VmsAddress)) + if (loginWindow == null || HasMainSessionWindow(session)) { - serverBox.Focus(); - SetValueOrType(serverBox, config.VmsAddress); + LogEntry("main window already up (client auto-login with remembered credentials); nothing to enter"); + return; } - var userCombo = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_userNameComboBox")); - var userBox = userCombo?.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox")); - if (userBox != null) + // The dialog may vanish mid-entry because the client's own auto-login just connected + // (stale element: UIA throws InvalidOperationException / ElementNotAvailable). That is a + // SUCCESS outcome, not a broken login - recognize it instead of faulting the probe. + try { - userBox.Focus(); - SetValueOrType(userBox, config.VmsUsername); + // A pre-filled dialog with a DEAD Connect button is the client mid-auto-login: the + // fields hold the remembered credentials and Connect is disabled for the whole attempt. + // Typing into that dialog (select-all, paste) has raced and cancelled the attempt - + // leave it untouched and let the session window arrive. + var earlyConnect = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) + ?? loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")); + if (earlyConnect != null && !TryIsEnabled(earlyConnect) && RememberedUserPresent(loginWindow, config)) + { + LogEntry("dialog pre-filled with remembered user, Connect disabled - auto-login in progress; hands off 30 s"); + if (WaitForConnected(session, 30)) + { + LogEntry("auto-login completed while hands-off waiting; nothing to enter"); + return; + } + } + + // Focus is a hint, not a contract: on a locked/idle desktop UIA SetFocus throws + // "Operation is not valid due to the current state of the object" - never let it + // abort the login flow when SetValueOrType carries its own entry routes. + var serverBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox")); + if (serverBox != null && !string.IsNullOrWhiteSpace(config.VmsAddress)) + { + try { serverBox.Focus(); } catch { } + SetValueOrType(serverBox, config.VmsAddress); + } + + var userCombo = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_userNameComboBox")); + var userBox = userCombo?.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox")); + if (userBox != null) + { + try { userBox.Focus(); } catch { } + SetValueOrType(userBox, config.VmsUsername); + } + + var passwordBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_passwordBox")); + if (passwordBox == null) throw new InvalidOperationException("Login dialog has no _passwordBox."); + FocusAndType(session, loginWindow, passwordBox, config.VmsPassword); + + var connect = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) + ?? loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")); + if (connect == null) throw new InvalidOperationException("Login dialog has no connect button."); + + // The client may ALREADY be auto-connecting (Auto-login was remembered from an + // interactive session): the dialog shows filled remembered fields, Connect is disabled + // for the duration of the attempt, and on success the main window simply appears. + // Never fight that dialog - give the auto-login room, then fall back to entering. + if (!WaitUntilEnabled(connect, 3)) + { + if (WaitForConnected(session, 20)) + { + LogEntry("auto-login completed while waiting; leaving the dialog alone"); + return; + } + LogEntry($"connect disabled; re-entering password (loginWindow aid='{loginWindow.AutomationId}')"); + Artifacts.Window(loginWindow, "login-filled-disabled"); + DumpLoginTree(loginWindow, "login-tree-disabled.txt"); + FocusAndType(session, loginWindow, passwordBox, config.VmsPassword); + if (!WaitUntilEnabled(connect, 3) && !WaitForConnected(session, 10)) + { + DumpLoginTree(loginWindow, "login-tree-disabled2.txt"); + throw new InvalidOperationException( + "Connect stayed disabled after two password entries and no session window appeared " + + "(see login-entry-log.txt + login-tree-disabled*.txt artifacts)."); + } + if (HasMainSessionWindow(session)) return; + } + connect.Patterns.Invoke.Pattern.Invoke(); } + catch (Exception ex) when (ex is InvalidOperationException + || ex is FlaUI.Core.Exceptions.ElementNotAvailableException + || ex is FlaUI.Core.Exceptions.NoClickablePointException + || ex is FlaUI.Core.Exceptions.ElementNotEnabledException) + { + // Stale mid-entry: the dialog closed because the client's own auto-login connected. + if (HasMainSessionWindow(session) || WaitForConnected(session, 15)) + { + LogEntry($"login dialog vanished mid-entry ({ex.GetType().Name}) but the session window is up - auto-login won; treating as signed in"); + return; + } + throw; + } + } - var passwordBox = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_passwordBox")); - if (passwordBox == null) throw new InvalidOperationException("Login dialog has no _passwordBox."); - passwordBox.Focus(); - Keyboard.Type(config.VmsPassword); + private static bool WaitForConnected(ClientSession session, int seconds) + { + var deadline = DateTime.UtcNow.AddSeconds(seconds); + while (DateTime.UtcNow < deadline) + { + if (HasMainSessionWindow(session)) return true; + Thread.Sleep(400); + } + return false; + } - var connect = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) - ?? loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")); - if (connect == null) throw new InvalidOperationException("Login dialog has no connect button."); - connect.Patterns.Invoke.Pattern.Invoke(); + private static bool TryIsEnabled(AutomationElement element) + { + try { return element.IsEnabled; } + catch { return false; } + } + + /// + /// True when the login dialog's user combo already carries the configured username - + /// the signature of the client's remembered-credentials auto-login attempt. + /// + private static bool RememberedUserPresent(Window loginWindow, UxConfig config) + { + try + { + var userCombo = loginWindow.FindFirstDescendant(cf => cf.ByAutomationId("_userNameComboBox")); + var userBox = userCombo?.FindFirstDescendant(cf => cf.ByAutomationId("PART_EditableTextBox")); + var shown = userBox == null + ? (userCombo?.Name ?? string.Empty) + : (ReadBoxValue(userBox) ?? userBox.Name ?? string.Empty); + return !string.IsNullOrWhiteSpace(config.VmsUsername) + && shown.Contains(config.VmsUsername, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } + + private static string ReadBoxValue(AutomationElement box) + { + try + { + if (box.Patterns.Value.IsSupported) return box.Patterns.Value.Pattern.Value; + } + catch { } + return null; } private static readonly string[] PreferredTrustButtonIds = @@ -52,6 +192,7 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con "_btnInstall", "_btnOK", "_btnYes", + "_btnConnect", // the trust prompt's accept button is 'Connect' (paired with 'Close'/Cancel) }; private static readonly string[] PreferredTrustButtonNames = @@ -63,6 +204,7 @@ public static void Login(ClientSession session, Window loginWindow, UxConfig con "add", "ok", "yes", + "connect", }; /// @@ -123,9 +265,51 @@ public static void AcceptCertificateTrust(ClientSession session, int timeoutSeco return; } } + else if (HasMainSessionWindow(session)) + { + // Login completed with NO dialog pending (the cert is already in the local store + // from an earlier session): polling to the full timeout here was 30 s of dead + // time between login and every tree navigation. + return; + } - Thread.Sleep(250); + Thread.Sleep(150); + } + } + + /// + /// True when the process shows a session window AND no login dialog anywhere: + /// Smart Client's _mainApplicationWindow or a >800px Management Client shell, with no + /// window carrying _btnConnect/_connectButton. (MC's login window is separate from its + /// main shell, so "no dialog" must be checked process-wide, not window-by-window.) + /// + private static bool HasMainSessionWindow(ClientSession session) + { + var windows = session.Windows().ToList(); + if (windows.Any(HasLoginControls)) return false; + return windows.Any(w => + { + try + { + if (string.Equals(w.AutomationId, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)) return true; + var name = w.Name ?? string.Empty; + var cls = w.ClassName ?? string.Empty; + return name.Contains("Management Client", StringComparison.OrdinalIgnoreCase) + && cls.StartsWith("WindowsForms", StringComparison.Ordinal) + && w.BoundingRectangle.Width > 800; + } + catch { return false; } + }); + } + + private static bool HasLoginControls(Window w) + { + try + { + return w.FindFirstDescendant(cf => cf.ByAutomationId("_btnConnect")) != null + || w.FindFirstDescendant(cf => cf.ByAutomationId("_connectButton")) != null; } + catch { return false; } } private static bool IsNegativeButton(string name) => @@ -137,7 +321,8 @@ public static Window WaitForSmartClientMain(ClientSession session, int timeoutSe => session.WaitForWindow(w => w.AutomationId == "_mainApplicationWindow", timeoutSeconds, "Smart Client main window"); public static Window WaitForAdminMain(ClientSession session, int timeoutSeconds = 120) - => session.WaitForWindow( + { + var window = session.WaitForWindow( w => { var cls = w.ClassName; @@ -149,6 +334,19 @@ public static Window WaitForAdminMain(ClientSession session, int timeoutSeconds }, timeoutSeconds, "Management Client main window"); + // MC restores its window position from the last interactive session - it can come back + // hanging off the monitor, where UIA still READS every tree row but mouse clicks land on + // nothing (collapse strikes become silent no-ops). Normalize before any geometry work. + try + { + if (window.Patterns.Window.IsSupported) + window.Patterns.Window.Pattern.SetWindowVisualState(FlaUI.Core.Definitions.WindowVisualState.Maximized); + Thread.Sleep(600); + } + catch { } + return window; + } + private static void SetValueOrType(AutomationElement element, string value) { try @@ -165,4 +363,211 @@ private static void SetValueOrType(AutomationElement element, string value) } Keyboard.Type(value); } + + /// + /// Keyboard input (both typed keys and Ctrl+V) is routed by Windows to the FOREGROUND + /// window, while UIA focus is a different fact - a plain Focus() can report success while + /// another app still owns the foreground, so the password lands nowhere (empty box, disabled + /// Connect, or a stale corrupted value). Verify BOTH that _passwordBox holds UIA focus and + /// that its window is the foreground window before entering text. + /// + private static void FocusAndType(ClientSession session, Window window, AutomationElement element, string text) + { + // The Value pattern needs neither UIA focus nor the foreground window, so it is the + // cheapest route; XProtect's login edits answer it even while the desktop has no focus + // to give. Keyboard entry is the fallback because a PasswordBox can refuse SetValue. + try + { + if (element.Patterns.Value.IsSupported) + { + element.Patterns.Value.Pattern.SetValue(text); + LogEntry("entry via Value pattern"); + return; + } + } + catch (Exception ex) { LogEntry($"Value pattern entry threw {ex.GetType().Name}: {ex.Message}"); } + + for (var attempt = 0; attempt < 6; attempt++) + { + try { window.Focus(); } catch { } + try { element.Focus(); } catch { } + Thread.Sleep(150); + try + { + // NativeWindowHandle is unsupported on these dialog windows; route checks by + // process instead: keystrokes go to the foreground window's process, and UIA + // focus + AutomationId must name _passwordBox within the SAME process (MC and + // SC show identically-id'd login dialogs simultaneously). + var pid = session.Process.Id; + var fgHwnd = GetForegroundWindow(); + GetWindowThreadProcessId(fgHwnd, out var fgPid); + var focused = session.Automation.FocusedElement(); + int focusedPid = -1; + try { focusedPid = focused == null ? -1 : focused.Properties.ProcessId.Value; } catch { } + var focusedAid = SafeAutomationId(focused); + var focusOk = focused != null + && string.Equals(focusedAid, SafeAutomationId(element), StringComparison.OrdinalIgnoreCase) + && focusedPid == pid; + var fgOk = fgPid == pid; + if (focusOk && fgOk) + { + EnterText(text); + return; + } + LogEntry($"focus attempt {attempt}: focusOk={focusOk} (focusedAid='{focusedAid}' focusedPid={focusedPid}) fgOk={fgOk} (fgPid={fgPid} want={pid})"); + } + catch (Exception ex) + { + LogEntry($"focus attempt {attempt} threw {ex.GetType().Name}: {ex.Message}"); + } + Thread.Sleep(250); + } + + // Focus never verified: mouse-click the box (click forces foreground AND focus together), + // then enter the text anyway - the Connect-disabled check upstream catches a miss. A box + // that is off-screen has no clickable point; then keyboard input to the foreground window + // is all that is left and the Connect guard decides whether it landed. + try { element.Click(); } + catch (Exception ex) { LogEntry($"click-fallback threw {ex.GetType().Name}: {ex.Message} (rect={SafeRect(element)})"); } + Thread.Sleep(150); + EnterText(text); + LogEntry("entry via click-fallback (focus unverified)"); + } + + /// AutomationId is not implemented by every provider (a terminal or shell window can + /// hold focus while this runs), and the property read throws rather than returning null. + private static string SafeAutomationId(AutomationElement element) + { + try { return element?.AutomationId; } catch { return null; } + } + + private static string SafeRect(AutomationElement element) + { + try { return element == null ? "" : element.BoundingRectangle.ToString(); } catch { return ""; } + } + + private static bool WaitUntilEnabled(AutomationElement element, int seconds) + { + var deadline = DateTime.UtcNow.AddSeconds(seconds); + while (DateTime.UtcNow < deadline) + { + try { if (element.IsEnabled) return true; } catch { } + Thread.Sleep(250); + } + try { return element.IsEnabled; } catch { return false; } + } + + /// Flat UIA census of the login dialog: what the client itself thinks each control + /// (especially Connect) looks like after entry - the arbiter when Connect stays disabled. + private static void DumpLoginTree(Window window, string fileName) + { + try + { + var sb = new StringBuilder(); + foreach (var el in window.FindAllDescendants()) + { + try + { + var val = string.Empty; + try + { + if (el.Patterns.Value.IsSupported) + val = $" value='{el.Patterns.Value.Pattern.Value}'"; + } + catch { } + sb.AppendLine($"{el.ControlType} aid='{el.AutomationId}' name='{el.Name}' enabled={el.IsEnabled}{val}"); + } + catch { } + } + Artifacts.WriteText(fileName, sb.ToString()); + } + catch (Exception ex) + { + LogEntry($"login tree dump threw {ex.GetType().Name}: {ex.Message}"); + } + } + + private static void LogEntry(string line) + { + try + { + File.AppendAllText(Artifacts.PathOf("login-entry-log.txt"), + DateTime.Now.ToString("HH:mm:ss") + " " + line + Environment.NewLine); + } + catch { } + } + + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + /// + /// Enter text as clipboard paste (Ctrl+V) when possible: the VMS password carries characters + /// ($ & * @) whose shifted virtual-key mapping is layout-dependent, and Keyboard.Type had + /// been sending silently CORRUPTED passwords. CF_UNICODETEXT pastes exact UTF-16 regardless + /// of keyboard layout; Keyboard.Type remains the fallback if the clipboard is unavailable. + /// + private static void EnterText(string text) + { + if (TryClipboardSetText(text)) + { + LogEntry("entry-via=paste (Ctrl+V)"); + Keyboard.TypeSimultaneously(VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V); + return; + } + LogEntry("entry-via=typed-keys (clipboard unavailable - password may corrupt)"); + Keyboard.Type(text); + } + + [DllImport("user32.dll", SetLastError = true)] private static extern bool OpenClipboard(IntPtr newOwner); + [DllImport("user32.dll")] private static extern bool CloseClipboard(); + [DllImport("user32.dll")] private static extern bool EmptyClipboard(); + [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetClipboardData(uint format, IntPtr data); + [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GlobalAlloc(uint flags, UIntPtr bytes); + [DllImport("kernel32.dll")] private static extern IntPtr GlobalLock(IntPtr handle); + [DllImport("kernel32.dll")] private static extern bool GlobalUnlock(IntPtr handle); + [DllImport("kernel32.dll")] private static extern IntPtr GlobalFree(IntPtr handle); + + private const uint CF_UNICODETEXT = 13; + private const uint GHND = 0x42; // GMEM_MOVEABLE | GMEM_ZEROINIT + + internal static bool TryClipboardSetText(string text) + { + var handle = IntPtr.Zero; + try + { + var bytes = (uint)((text.Length + 1) * 2); + handle = GlobalAlloc(GHND, (UIntPtr)bytes); + if (handle == IntPtr.Zero) return false; + var block = GlobalLock(handle); + if (block == IntPtr.Zero) return false; + Marshal.Copy(text.ToCharArray(), 0, block, text.Length); // +2 zero bytes = UTF-16 NUL terminator + GlobalUnlock(handle); + + if (!OpenClipboard(IntPtr.Zero)) return false; + try + { + EmptyClipboard(); + // On success the CLIPBOARD owns the handle - do not GlobalFree it. + if (SetClipboardData(CF_UNICODETEXT, handle) == IntPtr.Zero) return false; + handle = IntPtr.Zero; + return true; + } + finally + { + CloseClipboard(); + } + } + catch + { + return false; + } + finally + { + if (handle != IntPtr.Zero) + { + try { GlobalUnlock(handle); } catch { } + try { GlobalFree(handle); } catch { } // clipboard never took ownership + } + } + } } diff --git a/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs new file mode 100644 index 0000000..3817e40 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/LoginTestServer.cs @@ -0,0 +1,404 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WebContent.UxTests.Harness; + +/// +/// Throwaway vendor-style login site for the #55 proxy login-flow probe (SC-2). A raw +/// TcpListener rather than HttpListener: HttpListener needs a urlacl reservation to bind a +/// LAN address unelevated, and the raw socket gives the probe exact control over multi-value +/// Set-Cookie framing — the same control the proxy's own listener relies on. +/// +/// It plays the camera-portal role the ticket names: a login form (POST), a session cookie +/// issued with a 302, a dashboard that only answers when the cookie comes back, and an XHR +/// endpoint the dashboard polls in the background. Every request is journaled so probe +/// artifacts show what actually crossed the proxy. +/// +/// #56 adds a TLS mode: the same site behind a self-signed certificate minted in-process, so +/// the Event Server's target TLS validation (and the per-target accept-untrusted override) +/// has a genuinely untrusted chain to decide on. +/// +public sealed class LoginTestServer : IDisposable +{ + public const string Username = "acmeadmin"; + public const string Password = "camera1"; + + private readonly TcpListener _listener; + private readonly X509Certificate2 _tlsCertificate; + private readonly HashSet _issuedSessions = new(); + private readonly List _journal = new(); + + /// The SID the last successful login issued (asserted against the browser's echo). + public string LastIssuedSessionId { get; private set; } + + public int Port { get; } + + public IReadOnlyList Journal { get { lock (_journal) return _journal.ToList(); } } + + public LoginTestServer(int port) : this(port, useTls: false) + { + } + + /// Serve over HTTPS with an in-process self-signed certificate — + /// untrusted by design, which is exactly what the #56 fail-closed / override probes need. + /// The certificate carries the bind host as SAN so the failure is chain-trust, not name mismatch. + public LoginTestServer(int port, bool useTls) + { + Port = port; + if (useTls) + { + _tlsCertificate = CreateSelfSignedCertificate(); + } + + _listener = new TcpListener(IPAddress.Any, port); + _listener.Start(); + _ = Task.Run(AcceptLoopAsync); + } + + public void Dispose() + { + _listener.Stop(); + _tlsCertificate?.Dispose(); + } + + /// + /// RSA self-signed certificate valid for the addresses the probe binds (the VMS-network + /// address the allowlist names, plus loopback for local poking). KeyUsage/EKU mirror a real + /// camera portal's server certificate; only the chain is untrusted. + /// + private static X509Certificate2 CreateSelfSignedCertificate() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest( + "CN=ACME IPCStore (self-signed test)", + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, + false)); + + var san = new SubjectAlternativeNameBuilder(); + san.AddIpAddress(IPAddress.Loopback); + san.AddIpAddress(IPAddress.Parse("172.16.128.210")); + san.AddDnsName(Environment.MachineName.ToLowerInvariant()); + request.CertificateExtensions.Add(san.Build()); + + using var created = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + // PFX roundtrip lands the key in a persisted form Schannel accepts server-side. + var password = Guid.NewGuid().ToString("N"); + var portable = created.Export(X509ContentType.Pkcs12, password); + return X509CertificateLoader.LoadPkcs12(portable, password); + } + + private async Task AcceptLoopAsync() + { + while (true) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(); + } + catch (Exception) + { + return; + } + + _ = Task.Run(() => ServeAsync(client)); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + client.NoDelay = true; + Stream stream = client.GetStream(); + try + { + if (_tlsCertificate != null) + { + var tls = new System.Net.Security.SslStream(stream, false); + stream = tls; + try + { + await tls.AuthenticateAsServerAsync( + _tlsCertificate, false, SslProtocols.Tls12, false); + } + catch (Exception handshake) + { + // A failed client handshake is itself probe data (e.g. the fetcher + // refusing the chain mid-TLS is a clean close, not a request). + lock (_journal) + { + _journal.Add($"TLS handshake failed: {handshake.GetType().Name}: {handshake.Message}"); + } + + return; + } + } + + await HandleAsync(stream); + } + catch (Exception exception) + { + lock (_journal) + { + _journal.Add($"request aborted: {exception.GetType().Name}: {exception.Message}"); + } + } + finally + { + stream.Dispose(); + } + } + } + + private async Task HandleAsync(Stream stream) + { + var head = await ReadHeadAsync(stream); + if (head == null) + { + return; + } + + var lines = head.Replace("\n", "\r\n").Split(new[] { "\r\n" }, StringSplitOptions.None); + var requestLine = lines[0].Split(' '); + if (requestLine.Length != 3) + { + return; + } + + var method = requestLine[0].ToUpperInvariant(); + var path = requestLine[1]; + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var line = 1; line < lines.Length && lines[line].Length > 0; line++) + { + var colon = lines[line].IndexOf(':'); + if (colon > 0) + { + headers[lines[line].Substring(0, colon).Trim()] = lines[line].Substring(colon + 1).Trim(); + } + } + + string body = null; + if (headers.TryGetValue("Content-Length", out var lengthText) && + int.TryParse(lengthText, out var length) && length > 0) + { + var buffer = new byte[length]; + var readTotal = 0; + while (readTotal < length) + { + var read = await stream.ReadAsync(buffer, readTotal, length - readTotal); + if (read <= 0) + { + break; + } + + readTotal += read; + } + + body = Encoding.UTF8.GetString(buffer, 0, readTotal); + } + + headers.TryGetValue("Cookie", out var cookieHeader); + lock (_journal) + { + _journal.Add($"{method} {path} body={body?.Length ?? 0}B cookie='{Truncate(cookieHeader, 120)}'"); + } + + switch (method, path) + { + case ("GET", "/"): + await WriteAsync(stream, Page(200, "ACME IPCStore - Login", LoginForm, null)); + return; + + case ("POST", "/login"): + var form = body ?? string.Empty; + if (form.Contains($"username={Username}") && form.Contains($"password={Password}")) + { + var session = Guid.NewGuid().ToString("N"); + lock (_issuedSessions) + { + _issuedSessions.Add(session); + } + + LastIssuedSessionId = session; + await WriteAsync(stream, Page( + 302, + "Found", + "redirecting...", + $"SID={session}; Path=/; HttpOnly", + "/dashboard")); + return; + } + + await WriteAsync(stream, Page(401, "ACME IPCStore - Login failed", + "

ACME IPCStore: bad credentials

" + LoginForm, null)); + return; + + case ("GET", "/dashboard"): + if (!HasValidSession(cookieHeader)) + { + await WriteAsync(stream, Page(403, "ACME IPCStore - Not signed in", + "
NOT SIGNED IN
", null)); + return; + } + + await WriteAsync(stream, Page(200, "ACME Portal - Dashboard", + """ +

ACME Camera Portal

+
SIGNED IN
+ +
XHR-PENDING
+ + """.Replace("{0}", HtmlEscape(cookieHeader)), null)); + return; + + case ("GET", "/api/status"): + if (!HasValidSession(cookieHeader)) + { + await WriteAsync(stream, Plain(403, "XHR-DENIED")); + return; + } + + await WriteAsync(stream, Plain(200, $"XHR-OK status=online cookie='{Truncate(cookieHeader, 60)}'")); + return; + + default: + await WriteAsync(stream, Plain(404, "not found")); + return; + } + } + + private bool HasValidSession(string cookieHeader) + { + if (string.IsNullOrEmpty(cookieHeader)) + { + return false; + } + + foreach (var part in cookieHeader.Split(';')) + { + var pair = part.Trim(); + if (!pair.StartsWith("SID=", StringComparison.Ordinal)) + { + continue; + } + + var value = pair.Substring(4); + lock (_issuedSessions) + { + if (_issuedSessions.Contains(value)) + { + return true; + } + } + } + + return false; + } + + private const string LoginForm = """ +
+ + + +
+ """; + + private static byte[] Page( + int status, + string title, + string bodyHtml, + string setCookie, + string redirectLocation = null, + string contentType = "text/html; charset=utf-8") + { + var body = Encoding.UTF8.GetBytes( + $"{title}{bodyHtml}"); + var head = new StringBuilder(); + head.Append($"HTTP/1.1 {status} {(status == 200 ? "OK" : status == 302 ? "Found" : "Status")}\r\n"); + head.Append($"Content-Type: {contentType}\r\n"); + if (setCookie != null) + { + head.Append($"Set-Cookie: {setCookie}\r\n"); + } + + if (redirectLocation != null) + { + head.Append($"Location: {redirectLocation}\r\n"); + } + + head.Append($"Content-Length: {body.Length}\r\n"); + head.Append("Connection: close\r\n\r\n"); + return Encoding.UTF8.GetBytes(head.ToString()).Concat(body).ToArray(); + } + + /// + /// Raw text/plain response (no HTML document shell): an XHR endpoint's body IS its payload, + /// so the dashboard can assert the exact text the fetcher carried back. + /// + private static byte[] Plain(int status, string text) + { + var body = Encoding.UTF8.GetBytes(text); + var head = new StringBuilder(); + head.Append($"HTTP/1.1 {status} {(status == 200 ? "OK" : status == 403 ? "Forbidden" : "Not Found")}\r\n"); + head.Append("Content-Type: text/plain; charset=utf-8\r\n"); + head.Append($"Content-Length: {body.Length}\r\n"); + head.Append("Connection: close\r\n\r\n"); + return Encoding.UTF8.GetBytes(head.ToString()).Concat(body).ToArray(); + } + + private static async Task ReadHeadAsync(Stream stream) + { + var received = new MemoryStream(); + var buffer = new byte[4096]; + while (received.Length < 64 * 1024) + { + var read = await stream.ReadAsync(buffer, 0, buffer.Length); + if (read <= 0) + { + return null; + } + + received.Write(buffer, 0, read); + var bytes = received.GetBuffer(); + for (var i = 0; i + 3 < received.Length; i++) + { + if (bytes[i] == '\r' && bytes[i + 1] == '\n' && bytes[i + 2] == '\r' && bytes[i + 3] == '\n') + { + return Encoding.UTF8.GetString(bytes, 0, i); + } + } + } + + return null; + } + + private static async Task WriteAsync(Stream stream, byte[] response) + { + await stream.WriteAsync(response, 0, response.Length); + await stream.FlushAsync(); + } + + private static string Truncate(string value, int max) => + value == null ? string.Empty : (value.Length <= max ? value : value.Substring(0, max) + "..."); + + private static string HtmlEscape(string value) => + (value ?? string.Empty).Replace("&", "&").Replace("<", "<").Replace(">", ">"); +} diff --git a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs index 55d4fad..866ccb2 100644 --- a/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs +++ b/tests/ux/WebContent.UxTests/Harness/ManagementClientUi.cs @@ -1,3 +1,6 @@ +using System.Runtime.InteropServices; +using System.Text; + using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; @@ -31,23 +34,6 @@ public static AutomationElement WaitForTreeItem(Window mainWindow, string name, throw new TimeoutException($"Tree item '{name}' never appeared."); } - /// - /// Finds a node under the currently selected tree node by walking DOWN with the keyboard. - /// The WinForms tree only realizes visible rows, so a newly expanded node's children - /// below the fold are invisible to UIA until the selection scrolls them into view. - /// - public static AutomationElement FindChildByWalkingDown(Window mainWindow, string name, int maxSteps = 40) - { - for (var step = 0; step < maxSteps; step++) - { - var item = FindTreeItem(mainWindow, name); - if (item != null) return item; - FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.DOWN); - Thread.Sleep(50); - } - return FindTreeItem(mainWindow, name); - } - /// Finds a tree node whose name contains the fragment (the Home root carries the version suffix). public static AutomationElement WaitForTreeItemContaining(Window mainWindow, string fragment, int timeoutSeconds = 15) { @@ -153,20 +139,20 @@ public static void SelectNode(Window mainWindow, string name) var item = FindTreeItem(mainWindow, name) ?? throw new InvalidOperationException($"Tree item '{name}' not found."); try { if (item.Patterns.ScrollItem.IsSupported) item.Patterns.ScrollItem.Pattern.ScrollIntoView(); } catch { } - Thread.Sleep(300); + Thread.Sleep(150); try { if (item.Patterns.SelectionItem.IsSupported) item.Patterns.SelectionItem.Pattern.Select(); } catch { } - Thread.Sleep(400); + Thread.Sleep(200); ClickLabel(mainWindow, item); // MC's tree has LabelEdit active: clicking the label of an already-selected node arms // the inline rename editor. ESC on the freshly focused tree cancels that arming; without // this, the captured node renders in edit mode and rename commits can even raise // "ItemName.set ...: Not valid to set dirty state at the line!!!" error dialogs. FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.ESCAPE); - Thread.Sleep(300); + Thread.Sleep(150); } /// Closes any leftover login/connection dialog that would steal keyboard focus from the tree. @@ -277,58 +263,141 @@ public static bool ClickMenuItem(ClientSession session, string nameContains) } /// - /// Ensures the named tree node is expanded. MC tree items expose NO ExpandCollapse pattern, - /// but LegacyIAccessible.DoDefaultAction() (and a real double-click on the label) toggle the - /// node, and collapsing un-realizes the subtree in UIA — so expansion state is verified by - /// the expected children being realized, toggling the node whenever they are missing. + /// Ensures the named tree node is expanded. Navigation is enumerate-and-click: the realized + /// rows are listed, the target's label is double-clicked, and the wheel scrolls when the node + /// or its children sit below the fold (MC tree rows are only realized while visible). + /// Expansion state is read from geometry — the MC tree exposes NO ExpandCollapse pattern, but + /// a node is expanded exactly when the next realized row is indented deeper, the same trick + /// CollapseTreeToRoot uses. The old keyboard walk (O(index) DOWN presses per attempt, re-walked + /// on every retry, plus fixed 800–1500 ms sleeps) was the source of the 10+ second stalls. /// public static void EnsureExpanded(Window mainWindow, string name, params string[] expectedChildNames) { - for (var attempt = 0; attempt < 6; attempt++) + var deadline = DateTime.UtcNow.AddSeconds(45); + var strategy = 0; + var scrollPass = 0; + var ancestorsTouched = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (DateTime.UtcNow < deadline) { - if (ChildrenAppeared(mainWindow, expectedChildNames)) return; - var items = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.TreeItem)).ToList(); + if (expectedChildNames.Length > 0 && ChildrenAppeared(mainWindow, expectedChildNames)) return; + + var items = RealizedTreeItems(mainWindow); var node = items.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)); + if (node == null) { - // Possibly the whole tree (or Home) is collapsed: open Home once and retry. - var home = items.FirstOrDefault(t => (t.Name ?? string.Empty).StartsWith("Home", StringComparison.OrdinalIgnoreCase)); - if (home != null && items.Count <= 2) + // Target not realized: wheel-scroll the whole list looking for it, then open + // collapsed level-1 ancestors one at a time until the subtree materializes. + if (scrollPass < MaxTreeScrollPasses) { - ClickLabel(mainWindow, home); - Thread.Sleep(800); - FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.RIGHT); - Thread.Sleep(1500); + WheelTree(mainWindow, +4); + scrollPass++; continue; } - throw new InvalidOperationException($"Tree node '{name}' not found (attempt {attempt})."); + WheelTree(mainWindow, -30); + scrollPass = 0; + if (!ExpandOneCollapsedLevel1(mainWindow, ancestorsTouched)) break; + continue; } - if (attempt < 3) + var expanded = IsExpandedIn(items, node); + if (expanded && expectedChildNames.Length == 0) return; + + if (!expanded) { - // Keyboard route: walk DOWN to the node's visible index, press RIGHT. This is what - // plugin-injected nodes honour (DoDefaultAction / double-click just open a page). - var home = items.FirstOrDefault(t => (t.Name ?? string.Empty).StartsWith("Home", StringComparison.OrdinalIgnoreCase)); - if (home != null) + if (items.IndexOf(node) + 1 >= items.Count && scrollPass < MaxTreeScrollPasses) { - var idx = items.IndexOf(node); - KeyboardWalkTo(mainWindow, home, idx); - FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.RIGHT); - Thread.Sleep(1200); + // Last realized row: its children may simply sit unrealized below the fold. + WheelTree(mainWindow, +4); + scrollPass++; continue; } + ToggleNode(node, strategy++ % 3); + Thread.Sleep(250); + continue; + } + + // Expanded but the expected children never showed up in any scan: below the fold. + if (scrollPass < MaxTreeScrollPasses) + { + WheelTree(mainWindow, +4); + scrollPass++; + continue; } - ToggleNode(node, Math.Min(attempt, 2)); - Thread.Sleep(1200); + ToggleNode(node, strategy++ % 3); + Thread.Sleep(250); } - if (expectedChildNames.Length > 0) + + if (expectedChildNames.Length > 0 && !ChildrenAppeared(mainWindow, expectedChildNames)) throw new InvalidOperationException($"Could not expand '{name}': none of [{string.Join(", ", expectedChildNames)}] appeared."); + if (expectedChildNames.Length == 0) + throw new InvalidOperationException($"Could not expand '{name}': geometry never showed a deeper-indented row under it."); + } + + /// Tree rows the WinForms tree currently realizes, in visual (top-to-bottom) order. + private static List RealizedTreeItems(Window mainWindow) + { + var treeNav = mainWindow.FindFirstDescendant(cf => cf.ByAutomationId("TreeNavigation")); + var items = (treeNav ?? (AutomationElement)mainWindow) + .FindAllDescendants(cf => cf.ByControlType(ControlType.TreeItem)) + .Where(t => { try { return t.BoundingRectangle.Height > 0; } catch { return false; } }) + .ToList(); + items.Sort((x, y) => x.BoundingRectangle.Y.CompareTo(y.BoundingRectangle.Y)); + return items; + } + + /// True exactly when the next realized row is indented deeper than the node. + private static bool IsExpandedIn(List items, AutomationElement node) + { + var idx = items.IndexOf(node); + if (idx < 0 || idx + 1 >= items.Count) return false; + var a = items[idx].BoundingRectangle; + var b = items[idx + 1].BoundingRectangle; + if (a.Width <= 0 || b.Width <= 0) return false; + return b.X > a.X + 5; + } + + private const int MaxTreeScrollPasses = 12; + + /// + /// Double-clicks the first realized, collapsed level-1 row (Home and already-tried rows are + /// skipped) so the subtree that should contain the target becomes realized. One ancestor per + /// call keeps the search verifiable; the touched-set stops the next round re-collapsing it. + /// + private static bool ExpandOneCollapsedLevel1(Window mainWindow, HashSet touched) + { + var items = RealizedTreeItems(mainWindow); + var home = items.FirstOrDefault(t => (t.Name ?? string.Empty).StartsWith("Home", StringComparison.OrdinalIgnoreCase)); + if (home == null) return false; + if (items.Count == 1) + { + ToggleNode(home, 0); // MC persists tree state: the whole tree can start collapsed at Home. + Thread.Sleep(300); + return true; + } + var homeIdx = items.IndexOf(home); + if (homeIdx + 1 >= items.Count) return false; + var level1X = items[homeIdx + 1].BoundingRectangle.X; + for (var i = homeIdx + 1; i < items.Count; i++) + { + var node = items[i]; + if (Math.Abs(node.BoundingRectangle.X - level1X) > 1) continue; + if (i + 1 < items.Count && items[i + 1].BoundingRectangle.X > node.BoundingRectangle.X + 5) continue; + if (!touched.Add(node.Name ?? string.Empty)) continue; + ToggleNode(node, 1); + Thread.Sleep(300); + return true; + } + return false; } /// /// Toggles expand/collapse of a node. Strategy 0 uses LegacyIAccessible.DoDefaultAction /// (works for most nodes), strategy 1 does a real double-click on the label (proven for nodes /// where DoDefaultAction is a silent no-op), strategy 2 selects the node first, then double-clicks. + /// EnsureExpanded verifies the toggle by geometry and escalates strategies, so no keyboard + /// fallback is needed here. /// private static void ToggleNode(AutomationElement node, int strategy = 0) { @@ -519,6 +588,73 @@ private static bool IsNodeSelected(AutomationElement el) return false; } + /// + /// Raises the Management Client window over the rest of the desktop. Every tree route in this + /// harness is mouse input at absolute screen points (the tree ignores keyboard scrolling and its + /// selection state is often unreadable), so a click lands on whatever window overlaps the tree - + /// and a login performed through the Value pattern never raises the window by itself. + /// + public static void BringToFront(Window mainWindow) + { + try + { + // Windows refuses SetForegroundWindow for a background process unless it just received + // input; automation is exactly that case. Two routes, because a Management Client that + // is not the topmost window silently swallows every mouse route in this harness: the + // click lands on whatever overlaps the tree. + // 1. attach to the foreground thread and set the foreground window, and + // 2. make the window topmost, which is enough for absolute mouse input regardless of + // which window the shell considers foreground. + var hwnd = new IntPtr(mainWindow.Properties.NativeWindowHandle.Value); + if (hwnd == IntPtr.Zero) return; + ShowWindow(hwnd, SW_MAXIMIZE); + + var fgThread = GetWindowThreadProcessId(GetForegroundWindow(), out _); + var ownThread = GetCurrentThreadId(); + if (fgThread != 0 && fgThread != ownThread) + { + AttachThreadInput(ownThread, fgThread, true); + SetForegroundWindow(hwnd); + AttachThreadInput(ownThread, fgThread, false); + } + else + { + SetForegroundWindow(hwnd); + } + SetWindowLong(hwnd, GWL_EXSTYLE, GetWindowLong(hwnd, GWL_EXSTYLE) | WS_EX_TOPMOST); + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + Artifacts.WriteText("mc-front.txt", $"hwnd={hwnd:x} foregroundNow={ForegroundDebugInfo()} want pid {mainWindow.Properties.ProcessId.Value}"); + } + catch { } + Thread.Sleep(400); + } + + public static string ForegroundDebugInfo() + { + try + { + var fg = GetForegroundWindow(); + GetWindowThreadProcessId(fg, out var pid); + return $"hwnd={fg:x} pid={pid}"; + } + catch (Exception ex) { return "ERR:" + ex.GetType().Name; } + } + + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + [DllImport("user32.dll", EntryPoint = "GetWindowLongW")] private static extern int GetWindowLong(IntPtr hWnd, int nIndex); + [DllImport("user32.dll", EntryPoint = "SetWindowLongW")] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); + [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + private const int GWL_EXSTYLE = -20; + private const int WS_EX_TOPMOST = 0x0008; + private const uint SWP_NOMOVE = 0x0002, SWP_NOSIZE = 0x0001; + private const int SW_MAXIMIZE = 3; + /// /// Polls until the navigation tree exists and its first TreeItem (the Home root) is realized. /// MC realizes the tree some time AFTER the main window appears, so this replaces fixed @@ -526,6 +662,7 @@ private static bool IsNodeSelected(AutomationElement el) /// public static bool WaitForTreeReady(Window mainWindow, int timeoutSeconds = 30) { + BringToFront(mainWindow); var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); while (DateTime.UtcNow < deadline) { @@ -653,6 +790,217 @@ public static void SetValue(Window mainWindow, string automationId, string value } } + /// + /// Replace the full contents of a detail-pane Edit through the keyboard route the login + /// harness proved reliable: focus (VERIFIED), select-all, CF_UNICODETEXT clipboard paste. + /// The verification is not decoration - an ungated run pasted the proxy-target list into + /// whatever field held focus (txtName), and the next Save renamed the workspace item to + /// 'http://httpforever.com' (MIP.log 19:12:31). Returns false when focus could not be + /// proven or the clipboard was unavailable; it NEVER types into an unproven control. + /// + public static bool PasteIntoControl(Window mainWindow, string automationId, string value) + { + var box = ExpectControl(mainWindow, automationId); + if (!FocusVerified(mainWindow, box, automationId)) return false; + if (!LoginFlow.TryClipboardSetText(value)) return false; + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A); + System.Threading.Thread.Sleep(150); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_V); + System.Threading.Thread.Sleep(300); + return true; + } + + /// + /// Focus an element and prove it took: UIA FocusedElement must carry the expected + /// AutomationId before any keystroke is sent. Retries the focus call once. + /// + public static bool FocusVerified(Window mainWindow, AutomationElement element, string expectedAutomationId) + { + for (var attempt = 0; attempt < 3; attempt++) + { + try { element.Focus(); } catch { } + System.Threading.Thread.Sleep(300); + try + { + var focused = mainWindow.Automation.FocusedElement(); + if (focused != null && string.Equals(focused.AutomationId, expectedAutomationId, StringComparison.OrdinalIgnoreCase)) + return true; + } + catch { } + } + return false; + } + + /// + /// Dirty the detail pane through the checkbox's Checked/Unchecked handlers: two Toggle + /// pattern flips leave the state as it was but fire the page's change events twice. + /// Programmatic TextBox writes proved not to raise the shell's dirty chain in a freshly + /// logged-in MC, while Toggle-driven changes do. + /// + public static bool DirtyPaneViaCheckbox(Window mainWindow, string automationId) + { + try + { + var box = ExpectControl(mainWindow, automationId, 10); + if (!box.Patterns.Toggle.IsSupported) return false; + var toggle = box.Patterns.Toggle.Pattern; + var wasOn = toggle.ToggleState == FlaUI.Core.Definitions.ToggleState.On; + toggle.Toggle(); + System.Threading.Thread.Sleep(300); + toggle.Toggle(); + System.Threading.Thread.Sleep(300); + return toggle.ToggleState == (wasOn ? FlaUI.Core.Definitions.ToggleState.On : FlaUI.Core.Definitions.ToggleState.Off); + } + catch { return false; } + } + + /// True once the shell exposes an enabled, invokable "Save". + public static bool WaitForSaveEnabled(Window mainWindow, int seconds) + { + var deadline = DateTime.UtcNow.AddSeconds(seconds); + while (DateTime.UtcNow < deadline) + { + try { if (FindSaveButton(mainWindow, -1) != null) return true; } + catch { } + Thread.Sleep(400); + } + return false; + } + + /// + /// Focus the Edit (verified) and press END, SPACE, BACK: a real keyboard round-trip through + /// WPF input that fires TextChanged even when a programmatic write left the shell's dirty + /// state cold. The box contents end up unchanged; nothing is typed without the focus gate. + /// + public static void NudgeControlWithKeystroke(Window mainWindow, string automationId) + { + var box = ExpectControl(mainWindow, automationId, 10); + if (!FocusVerified(mainWindow, box, automationId)) return; + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.END); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.SPACE); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.BACK); + System.Threading.Thread.Sleep(300); + } + + /// + /// Name of the single workspace item under the workspaces node when the expected name is + /// gone - an item renamed by a stray paste still shows up in the tree under its bad name. + /// Null when the node has no deeper-indented child at all. + /// + public static string OrphanWorkspaceName(Window mainWindow, string workspacesNodeName) + { + var items = RealizedTreeItems(mainWindow); + var node = items.FirstOrDefault(t => string.Equals(t.Name, workspacesNodeName, StringComparison.OrdinalIgnoreCase)); + if (node == null) return null; + var idx = items.IndexOf(node); + for (var i = idx + 1; i < items.Count; i++) + { + try + { + if (items[i].BoundingRectangle.X > node.BoundingRectangle.X) return items[i].Name; + break; + } + catch { break; } + } + return null; + } + + /// Every realized tree row (name, indent X, Y) - so a "no children appeared" + /// failure says what the tree ACTUALLY shows. + public static void DumpWorkspaceChildren(Window mainWindow, StringBuilder log) + { + try + { + var items = RealizedTreeItems(mainWindow); + var idx = items.FindIndex(t => string.Equals(t.Name, WorkspacesNodeName, StringComparison.OrdinalIgnoreCase)); + log.AppendLine($"tree rows={items.Count}, workspaces-node idx={(idx < 0 ? "ABSENT" : idx.ToString())}"); + foreach (var t in items) + { + var r = t.BoundingRectangle; + log.AppendLine($" '{t.Name}' x={r.X} y={r.Y}"); + } + } + catch (Exception ex) { log.AppendLine("tree dump threw " + ex.GetType().Name); } + } + + /// + /// Create a new item of the kind via the shell's 'Add New ...' menu entry (the tree + /// right-click opens no usable context menu on this shell - the menu bar census proved + /// 'Add New ...' is the real affordance). Every window/dialog that shows up afterwards is + /// logged so the follow-up dialog's shape is recorded, not guessed. + /// + public static bool NewItemUnderNode(Window mainWindow, ClientSession session, string nodeName, StringBuilder log) + { + var addNew = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.MenuItem)) + .FirstOrDefault(mi => (mi.Name ?? string.Empty).IndexOf("add new", StringComparison.OrdinalIgnoreCase) >= 0); + if (addNew == null) + { + log.AppendLine("no 'Add New ...' menu-bar entry found"); + return false; + } + try { addNew.Patterns.Invoke.Pattern.Invoke(); } + catch (Exception ex) { log.AppendLine("Add New invoke threw " + ex.GetType().Name); try { addNew.Click(); } catch { } } + Thread.Sleep(2500); + + // What did it open? Log windows, their edits/buttons, and any menu items left over. + try + { + foreach (var w in session.Windows()) + { + static string Safex(Func f) { try { return f() ?? ""; } catch { return ""; } } + log.AppendLine($"after AddNew window aid='{Safex(() => w.AutomationId)}' name='{Safex(() => w.Name)}' class='{Safex(() => w.ClassName)}'"); + try + { + foreach (var b in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)).Take(10)) + log.AppendLine($" edit aid='{Safex(() => b.AutomationId)}' name='{Safex(() => b.Name)}'"); + foreach (var b in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)).Take(15)) + log.AppendLine($" button aid='{Safex(() => b.AutomationId)}' name='{Safex(() => b.Name)}'"); + foreach (var b in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)).Take(15)) + log.AppendLine($" text name='{Safex(() => b.Name)}'"); + } + catch { } + } + } + catch (Exception ex) { log.AppendLine("AddNew aftermath dump threw " + ex.GetType().Name); } + + // If the shell opened a kind-picker, select our kind before accepting the dialog. + try + { + foreach (var w in session.Windows()) + { + foreach (var kind in w.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)) + .Concat(w.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem))) + .Where(e => (e.Name ?? string.Empty).IndexOf("Web Content", StringComparison.OrdinalIgnoreCase) >= 0)) + { + log.AppendLine($"AddNew picker: choosing '{kind.Name}'"); + try { kind.Patterns.Invoke.Pattern.Invoke(); } catch { try { kind.Click(); } catch { } } + Thread.Sleep(1500); + break; + } + } + } + catch { } + return true; + } + + /// + /// Re-type the pane's Name field (focus-verified keyboard only): select-all, then plain + /// ASCII typing. Throws when focus cannot be proven - the field is never typed blind. + /// + public static void RenameWorkspacePane(Window mainWindow, string newName) + { + var box = ExpectControl(mainWindow, "txtName", 15); + if (!FocusVerified(mainWindow, box, "txtName")) + throw new InvalidOperationException("Cannot verify focus on txtName; refusing to type the workspace rename."); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A); + System.Threading.Thread.Sleep(150); + FlaUI.Core.Input.Keyboard.Type(newName); + System.Threading.Thread.Sleep(300); + } + /// /// Drives a checkbox in the detail pane (WPF hosted control: AutomationId is the x:Name) /// to the requested state using the UIA Toggle pattern. @@ -705,6 +1053,260 @@ public static bool Save(Window mainWindow, int timeoutSeconds = 20) return false; } + /// + /// Session-aware Save: clicking Save on the proxy-target page raises a shell confirmation + /// modal ("Save changes?"). UIA Invoke BLOCKS for the whole modal lifetime — a harness + /// thread inside Invoke cannot clear the modal it is waiting on, which is exactly how the + /// #56 row hung until a human clicked Yes. So the click runs on a side thread while this + /// thread sweeps modals; a modal that is already up before the click is swept first. + /// + public static bool Save(ClientSession session, Window mainWindow, int timeoutSeconds = 20) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + var attempt = 0; + while (DateTime.UtcNow < deadline) + { + AcceptPendingModal(session, 1); + AutomationElement button = null; + try + { + button = FindSaveButton(mainWindow, attempt); + } + catch (Exception ex) + { + LogSaveAttempt(attempt, "locator threw " + ex.GetType().Name + ": " + ex.Message); + } + if (button != null) + { + try + { + var invoke = System.Threading.Tasks.Task.Run(() => button.Patterns.Invoke.Pattern.Invoke()); + if (!invoke.Wait(3000)) + { + // Still blocked: a modal owns the click. Accept it, then the click returns. + AcceptPendingModal(session, 10); + invoke.Wait(TimeSpan.FromSeconds(20)); + } + else + { + AcceptPendingModal(session, 4); + } + } + catch (AggregateException agg) when (IsStaleButton(agg)) + { + // MC re-renders the pane after an edit and disables Save for a moment; + // IsEnabled raced the Invoke. Re-find the button and retry inside the deadline. + Thread.Sleep(500); + continue; + } + + Thread.Sleep(2500); + AcceptPendingModal(session, 2); + return true; + } + attempt++; + Thread.Sleep(500); + } + DumpSaveCandidates(mainWindow); + return false; + } + + /// + /// The shell's enabled "Save" affordance. Name match is any control type: the MC toolbar + /// renders Save as a button, but a toolbar element surfacing through the WinForms/WPF + /// bridge can report as Text or Custom, and insisting on ControlType.Button hid a live, + /// enabled Save from the harness. Each poll records what every name-matching candidate + /// looked like, because "never became enabled" without the toolbar's actual state was a + /// guess-and-recheck loop across several runs. + /// + private static AutomationElement FindSaveButton(Window mainWindow, int attempt) + { + var candidates = mainWindow.FindAllDescendants(cf => cf.ByName("Save")); + var described = new List(); + AutomationElement chosen = null; + foreach (var b in candidates) + { + var ct = TryGet(() => b.ControlType.ToString()); + var enabled = TryGet(() => b.IsEnabled.ToString()); + var invoke = TryGet(() => b.Patterns.Invoke.IsSupported.ToString()); + described.Add($"ct={ct} enabled={enabled} invoke={invoke}"); + if (enabled != "True" || invoke != "True") continue; + // Prefer the VISIBLE toolbar affordance; an application-menu 'Save' MenuItem + // (zero rect) enumerates first but is the riskier click target. + var visible = TryGet(() => b.BoundingRectangle.Width.ToString()) != "0"; + var chosenVisible = chosen != null && TryGet(() => chosen.BoundingRectangle.Width.ToString()) != "0"; + if (chosen == null || (visible && !chosenVisible)) chosen = b; + } + if (attempt >= 0 && attempt < 40) + { + LogSaveAttempt(attempt, described.Count == 0 ? "no ByName('Save') candidates" : string.Join(" | ", described)); + } + return chosen; + } + + private static string TryGet(Func read) + { + try { return Convert.ToString(read()); } + catch (Exception ex) { return ""; } + } + + private static void LogSaveAttempt(int attempt, string line) + { + try + { + File.AppendAllText(Artifacts.PathOf("mc-save-attempts.txt"), + $"{DateTime.Now:HH:mm:ss} attempt={attempt} {line}{Environment.NewLine}"); + } + catch { } + } + + /// + /// Failure evidence for "Save never became enabled": every element in the shell whose name + /// mentions save/discard/revert, with control type and enabled state - so the next run + /// says WHAT the toolbar actually exposes instead of guessing. + /// + private static void DumpSaveCandidates(Window mainWindow) + { + try + { + var all = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Custom) + .Or(cf.ByControlType(ControlType.Button)) + .Or(cf.ByControlType(ControlType.Text)) + .Or(cf.ByControlType(ControlType.MenuItem)) + .Or(cf.ByControlType(ControlType.Hyperlink))); + var hits = all.Where(e => + { + var n = e.Name ?? string.Empty; + return n.IndexOf("save", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("discard", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("revert", StringComparison.OrdinalIgnoreCase) >= 0; + }); + var sb = new StringBuilder(); + sb.AppendLine($"save-census {DateTime.Now:HH:mm:ss} candidates={hits.Count()}"); + foreach (var e in hits) + { + sb.AppendLine($" ct={TryGet(() => e.ControlType.ToString())} aid={TryGet(() => e.AutomationId)} name={TryGet(() => e.Name)} enabled={TryGet(() => e.IsEnabled)} invoke={TryGet(() => e.Patterns.Invoke.IsSupported)}"); + } + File.AppendAllText(Artifacts.PathOf("mc-save-census.txt"), sb + Environment.NewLine); + } + catch { } + } + + /// True when the aggregate carries only "element not enabled" UIA failures (0x80040200). + private static bool IsStaleButton(AggregateException aggregate) + { + foreach (var inner in aggregate.Flatten().InnerExceptions) + { + var code = (inner as System.Runtime.InteropServices.COMException)?.ErrorCode; + var stale = inner.GetType().Name == "ElementNotEnabledException" || code == unchecked((int)0x80040200); + if (!stale) + { + return false; + } + } + + return aggregate.InnerExceptions.Count > 0; + } + + /// + /// Clicks the affirmative button of any pending MC modal dialog (the "Save changes?" + /// shell confirmation, a #32770 MessageBox, or a similar notice) that is not the main + /// window. Returns true when a modal was accepted. Button preference: Yes, Save, OK. + /// + public static bool AcceptPendingModal(ClientSession session, int timeoutSeconds = 5) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + foreach (var window in session.Windows()) + { + try + { + // The save-changes confirmation carries the SAME title as the main window + // ("Milestone XProtect Management Client "), so titles cannot tell + // them apart; structure can: the main window has the application AutomationId, + // and a confirmation is any other window pairing an affirmative button with a + // negative one (Yes/No/Cancel) or wearing the dialog window class. + if (string.Equals(window.AutomationId ?? string.Empty, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var cls = window.ClassName ?? string.Empty; + var buttons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)).ToList(); + if (buttons.Count == 0) continue; + + var hasNegative = buttons.Any(b => + string.Equals((b.Name ?? string.Empty).Trim(), "No", StringComparison.OrdinalIgnoreCase) + || string.Equals((b.Name ?? string.Empty).Trim(), "Cancel", StringComparison.OrdinalIgnoreCase)); + if (!cls.Contains("#32770") && !hasNegative) continue; + + foreach (var wanted in new[] { "Yes", "Save", "OK" }) + { + var hit = buttons.FirstOrDefault(b => + string.Equals((b.Name ?? string.Empty).Trim(), wanted, StringComparison.OrdinalIgnoreCase)); + if (hit != null) + { + hit.Patterns.Invoke.Pattern.Invoke(); + return true; + } + } + } + catch { } + } + Thread.Sleep(400); + } + return false; + } + + /// + /// Answer any pending MC modal NEGATIVELY: the "Save changes?" shell confirmation gets No/Cancel + /// (a probe that died mid-edit must never persist its unsaved pane state on teardown - live on + /// #84, where AcceptPendingModal at close accepted exactly such a leftover promotion), while a + /// plain #32770 notice still gets its dismiss button (OK/Close) so the process can exit. + /// Returns true when a modal was answered. + /// + public static bool RejectPendingModal(ClientSession session, int timeoutSeconds = 5) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + foreach (var window in session.Windows()) + { + try + { + if (string.Equals(window.AutomationId ?? string.Empty, "_mainApplicationWindow", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var cls = window.ClassName ?? string.Empty; + var buttons = window.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)).ToList(); + if (buttons.Count == 0) continue; + + var hasNegative = buttons.Any(b => + string.Equals((b.Name ?? string.Empty).Trim(), "No", StringComparison.OrdinalIgnoreCase) + || string.Equals((b.Name ?? string.Empty).Trim(), "Cancel", StringComparison.OrdinalIgnoreCase)); + if (!cls.Contains("#32770") && !hasNegative) continue; + + foreach (var wanted in new[] { "No", "Cancel", "OK", "Close" }) + { + var hit = buttons.FirstOrDefault(b => + string.Equals((b.Name ?? string.Empty).Trim(), wanted, StringComparison.OrdinalIgnoreCase)); + if (hit != null) + { + hit.Patterns.Invoke.Pattern.Invoke(); + return true; + } + } + } + catch { } + } + Thread.Sleep(400); + } + return false; + } + /// Handles the yes/no confirmation MessageBox WinForms pops for deletes. public static void AcceptConfirmation(ClientSession session, string buttonName = "Yes", int timeoutSeconds = 10) { diff --git a/tests/ux/WebContent.UxTests/Harness/McLog.cs b/tests/ux/WebContent.UxTests/Harness/McLog.cs new file mode 100644 index 0000000..05b7ef4 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/McLog.cs @@ -0,0 +1,73 @@ +namespace WebContent.UxTests.Harness; + +/// +/// Reads the Management Client's MIP.log (plus its rolling archives) as a verification channel. +/// The log is the one view of plugin behaviour that does not depend on how a WPF control happens +/// to surface through accessibility, so it is what settles arguments about what the shell really +/// did - the proxy allowlist's stored shape (#81) being the case that made it necessary. +/// +public static class McLog +{ + private static string Root => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Management Client", "Logs"); + + /// Every log line that contains ALL of the given fragments (case-sensitive: these are + /// the plugin's own marker phrases and the exact entry strings it prints). + public static List MatchingLines(string mustContain, params string[] andAlso) + { + var found = new List(); + foreach (var file in LogFiles()) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, true); + string line; + while ((line = reader.ReadLine()) != null) + { + if (!line.Contains(mustContain, StringComparison.Ordinal)) continue; + if (andAlso.Any(f => !line.Contains(f, StringComparison.Ordinal))) continue; + found.Add(line); + } + } + catch { /* locked mid-write or unreadable: try the next file */ } + } + + return found; + } + + public static int CountMatches(string mustContain, params string[] andAlso) => + MatchingLines(mustContain, andAlso).Count; + + /// Waits until the log carries more matching lines than and + /// returns the newest of them, or null when the delta never arrived. + public static string WaitForNewLine(int before, int timeoutSeconds, string mustContain, params string[] andAlso) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var lines = MatchingLines(mustContain, andAlso); + if (lines.Count > before) return lines[lines.Count - 1]; + Thread.Sleep(1000); + } + + return null; + } + + private static IEnumerable LogFiles() + { + var candidates = new List(); + try + { + var top = Path.Combine(Root, "MIP.log"); + if (File.Exists(top)) candidates.Add(top); + + var archive = Path.Combine(Root, "Archive"); + if (Directory.Exists(archive)) candidates.AddRange(Directory.EnumerateFiles(archive, "MIP*.log")); + } + catch { /* no MC log on this machine */ } + + return candidates; + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/ProxyTargetProvisioning.cs b/tests/ux/WebContent.UxTests/Harness/ProxyTargetProvisioning.cs new file mode 100644 index 0000000..1c97394 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/ProxyTargetProvisioning.cs @@ -0,0 +1,127 @@ +using System; +using System.Linq; +using System.Text; + +using FlaUI.Core.AutomationElements; + +namespace WebContent.UxTests.Harness; + +/// +/// Shared Management Client phase for every proxied-target probe: make sure the workspace +/// allowlist carries exactly one row for the probe origin - the entry as given, replacing any +/// older row for the same origin so a previous run's flag state never leaks into this row's +/// verdict. Since #81 the allowlist is the structured grid on the Proxy tab (no free-text box), +/// so the whole edit/save/readback discipline is . +/// Runs before the Smart Client so the fetched configuration carries the new allowlist. Includes +/// the self-repair discovered the hard way: adopting a mis-named item, recreating a destroyed +/// one (Add New), the pre-Save Name guard, and a readback taken from a reloaded pane. +/// +public static class ProxyTargetProvisioning +{ + public static void Ensure(UxConfig config, string entry, StringBuilder log) + { + ProcessKiller.KillClients("VideoOS.Administration"); + using var mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + var mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + // Canonical starting state (fast double-click collapse, no keyboard), then walk the + // known chain top-down (enumerate rows + click + wheel scroll): each step realizes the + // rows the next one needs, so no blind walking or long sleeps are involved. + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + + // A previous bad Save once renamed the item (txtName corrupted by an ungated paste - + // MIP.log 'ItemName.get ... http://httpforever.com'), and later evidence showed the + // item gone entirely. Adopt a mis-named lone child, or recreate via the shell New + // affordance, before touching anything else. + var workspaceItem = config.McWorkspaceName; + var paneReady = false; + try + { + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + } + catch (InvalidOperationException) + { + ManagementClientUi.DumpWorkspaceChildren(mcMain, log); + // The last toggle strategy may have left the node collapsed: one more expansion + // pass with no child-name assertion, then look for the lone child. + try { ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, new string[0]); } + catch (InvalidOperationException) { } + ManagementClientUi.DumpWorkspaceChildren(mcMain, log); + var orphan = ManagementClientUi.OrphanWorkspaceName(mcMain, ManagementClientUi.WorkspacesNodeName); + if (orphan != null) + { + log.AppendLine($"workspace item is named '{orphan}', not '{config.McWorkspaceName}' - adopting and renaming"); + workspaceItem = orphan; + } + else + { + log.AppendLine("workspaces node has NO children - recreating the item via the shell New menu"); + if (!ManagementClientUi.NewItemUnderNode(mcMain, mc, ManagementClientUi.WorkspacesNodeName, log)) + throw new InvalidOperationException( + $"Workspace '{config.McWorkspaceName}' is gone from the config and the shell New menu was unavailable."); + ManagementClientUi.AcceptPendingModal(mc, 5); // a name prompt, if the shell shows one + paneReady = true; // the New flow leaves the pane showing the fresh item + log.AppendLine("created new workspace item; filling the pane"); + } + } + if (!paneReady) ManagementClientUi.SelectTreeItem(mcMain, workspaceItem); + + var nameBox = ManagementClientUi.ExpectControl(mcMain, "txtName", 15); + var paneName = ReadValue(nameBox); + if (!string.Equals(paneName, config.McWorkspaceName, StringComparison.Ordinal)) + { + log.AppendLine($"pane Name field reads '{paneName}'; retyping '{config.McWorkspaceName}'"); + ManagementClientUi.RenameWorkspacePane(mcMain, config.McWorkspaceName); + } + + // #81: the allowlist is a structured grid on the Proxy tab now - one entry per row, the + // accept-untrusted flag a checkbox instead of a line token. Rows are read back in the + // same probe-record shape the old text box wrote, so the entry strings here are unchanged. + AllowlistGridUi.OpenProxyTab(mcMain); + var current = AllowlistGridUi.ReadEntries(mcMain); + log.AppendLine($"proxy targets before: '{string.Join(" | ", current)}'"); + + if (current.Any(line => string.Equals(line, entry, StringComparison.OrdinalIgnoreCase))) + { + log.AppendLine($"entry '{entry}' already allowlisted; no Management Client edit needed"); + Retire(mc, mcMain); + return; + } + + // Same origin with a different flag state is a contradictory entry: exactly one row + // decides the origin's TLS posture. + var originToken = entry.Split(' ')[0]; + var lines = current + .Where(line => !line.Split(' ')[0].Equals(originToken, StringComparison.OrdinalIgnoreCase)) + .ToList(); + lines.Add(entry); + + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, lines, log); + log.AppendLine($"allowlisted '{entry}' and saved"); + Retire(mc, mcMain); + } + + /// + /// Closes Management Client after its phase: an MC left open can hold a pending + /// "Save changes?" modal that steals focus and blocks UIA for the Smart Client phase + /// (the #56 hang). Close politely, sweep any confirmation it raises, then guarantee exit. + /// + private static void Retire(ClientSession mc, Window mcMain) + { + try { mcMain.Close(); } catch { } + ManagementClientUi.AcceptPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + + private static string ReadValue(AutomationElement element) + { + try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; } + catch { return element.Name; } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs b/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs index d5f431d..db053e8 100644 --- a/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs +++ b/tests/ux/WebContent.UxTests/Harness/SmartClientUi.cs @@ -1,3 +1,6 @@ +using System.IO; +using System.Text; + using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; @@ -70,6 +73,25 @@ public static Window SelectWorkspace(Window mainWindow, ClientSession session, s } Thread.Sleep(1000); } + Artifacts.Window(mainWindow, "sc-select-workspace-fail"); + try + { + var dump = new StringBuilder(); + dump.AppendLine($"select-workspace-fail for '{workspaceName}' {DateTime.Now:HH:mm:ss}"); + foreach (var w in session.Windows()) + { + static string Safex(Func f) { try { return f() ?? ""; } catch { return ""; } } + dump.AppendLine($" window aid='{Safex(() => w.AutomationId)}' class='{Safex(() => w.ClassName)}' name='{Safex(() => w.Name)}'"); + } + var blocks = mainWindow.FindAllDescendants(cf => cf.ByAutomationId("_textTextBlock")) + .Select(t => "'" + t.Name + "'"); + var texts = mainWindow.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Take(40).Select(t => "'" + t.Name + "'"); + dump.AppendLine(" _textTextBlock strip: " + string.Join(", ", blocks)); + dump.AppendLine(" Text elements: " + string.Join(", ", texts)); + File.AppendAllText(Artifacts.PathOf("sc-select-workspace-fail.txt"), dump + Environment.NewLine); + } + catch { } throw new TimeoutException($"Workspace '{workspaceName}' is neither a strip tab nor an overflow entry. Overflow saw: {string.Join(", ", seen)}"); } @@ -143,6 +165,42 @@ public static void NavigateAddress(AutomationElement addressBar, string url) FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.RETURN); } + /// + /// Selects the named workspace tab WITHOUT input injection (no mouse, no keyboard): a + /// disconnected desktop session rejects SendInput with ERROR_ACCESS_DENIED while every + /// UIA pattern keeps working. Walks up from the tab's text block and tries Invoke, + /// SelectionItem.Select, then legacy select on the first ancestor that supports one. + /// Returns whether a pattern fired. + /// + public static bool TrySelectWorkspaceWithoutInput(Window mainWindow, ClientSession session, string workspaceName) + { + mainWindow = session.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow") ?? mainWindow; + var label = mainWindow.FindAllDescendants(cf => cf.ByAutomationId("_textTextBlock")) + .FirstOrDefault(t => string.Equals(t.Name, workspaceName, StringComparison.OrdinalIgnoreCase)); + if (label == null) return false; + AutomationElement current = label; + for (var hop = 0; hop < 4 && current != null; hop++) + { + try + { + if (current.Patterns.Invoke.IsSupported) { current.Patterns.Invoke.Pattern.Invoke(); Thread.Sleep(2500); return true; } + } + catch { } + try + { + if (current.Patterns.SelectionItem.IsSupported) { current.Patterns.SelectionItem.Pattern.Select(); Thread.Sleep(2500); return true; } + } + catch { } + try + { + if (current.Patterns.LegacyIAccessible.IsSupported) { current.Patterns.LegacyIAccessible.Pattern.Select(9); Thread.Sleep(2500); return true; } + } + catch { } + current = current.Parent; + } + return false; + } + public static AutomationElement WaitForMipViewItem(Window mainWindow, int timeoutSeconds = 60) { var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); diff --git a/tests/ux/WebContent.UxTests/Harness/TreeDump.cs b/tests/ux/WebContent.UxTests/Harness/TreeDump.cs index 810087c..c3eaf93 100644 --- a/tests/ux/WebContent.UxTests/Harness/TreeDump.cs +++ b/tests/ux/WebContent.UxTests/Harness/TreeDump.cs @@ -1,6 +1,7 @@ -using FlaUI.Core.AutomationElements; using System.Text; +using FlaUI.Core.AutomationElements; + namespace WebContent.UxTests.Harness; public static class TreeDump diff --git a/tests/ux/WebContent.UxTests/Harness/TriggerGridUi.cs b/tests/ux/WebContent.UxTests/Harness/TriggerGridUi.cs new file mode 100644 index 0000000..15fa77f --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/TriggerGridUi.cs @@ -0,0 +1,510 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #78: shared Management Client phases for driving and reading the Proxy tab's +/// "Rule triggers" grid (proxyTriggersGrid). Same shape as HeaderGridUi: every per-row control +/// carries ONE shared AutomationId (proxyTriggerRowUrl, proxyTriggerRowMethod, +/// proxyTriggerRowStatus, proxyTriggerRowEvent, proxyTriggerRowProblem, proxyTriggerRowRemove), +/// so every lookup is scoped to the grid element first and rows are addressed by document order. +/// Virtualization is off in the grid's XAML, so the ordering cannot drift. +/// +/// The Method cell is a non-editable WPF ComboBox, not a TextBox: writes go through +/// ExpandCollapse + SelectionItem.Select on the matching item, escalating to focus-gated arrow +/// keys (a closed WPF combo changes SelectedItem with Up/Down), and the readback is the combo's +/// UIA Name, which WPF keeps equal to the selected item's text. The read-only Event column is a +/// TextBlock read by Name like the Problem cell. +/// +/// Same discipline as the rest of the harness: every edit verifies by observable readback, and +/// every "it saved" claim goes through two independent channels — the plugin's own "Stored proxy +/// triggers ..." audit line in the MC MIP.log, and the grid re-read from a RELOADED pane, +/// order-sensitively (the Event Server evaluates rows top-down; first match wins). +/// +public static class TriggerGridUi +{ + public const string GridId = "proxyTriggersGrid"; + public const string UrlCellId = "proxyTriggerRowUrl"; + public const string MethodCellId = "proxyTriggerRowMethod"; + public const string StatusCellId = "proxyTriggerRowStatus"; + public const string EventCellId = "proxyTriggerRowEvent"; + public const string ProblemCellId = "proxyTriggerRowProblem"; + public const string RemoveCellId = "proxyTriggerRowRemove"; + public const string AddButtonId = "btnProxyTriggerAdd"; + + /// The dropdown's verb set in XAML order — mirrors ProxyTriggerEditor.ProxyTriggerMethods + /// (index 0 is the any-method "*"). The arrow-key escalation counts steps on this list. + private static readonly string[] MethodOrder = { "*", "GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH" }; + + /// Where the grid driver's step notes go (same convention as AllowlistGridUi.Trace). + public static Action Trace { get; set; } = _ => { }; + + /// One trigger row as UIA reads it. Describe renders the stored-row shape the plugin's + /// audit line uses: "url=<url> method=<verb|any> status=<filter|any>". + public sealed class RowReading + { + public int Index; + public string Url = string.Empty; + public string Method = string.Empty; + public string Status = string.Empty; + public string Event = string.Empty; + public string Problem = string.Empty; + + public string Describe() => + $"url={Url} method={(Method == "*" ? "any" : Method)} status={(Status.Length == 0 ? "any" : Status)}"; + } + + /// The wanted-row shape callers pass in: (url, method, status); method "*" = any. + public sealed record Row(string Url, string Method, string Status) + { + public string Describe() => + $"url={Url} method={(Method == "*" ? "any" : Method)} status={(string.IsNullOrEmpty(Status) ? "any" : Status)}"; + } + + private static AutomationElement GridFor(Window mcMain) => + ManagementClientUi.ExpectControl(mcMain, GridId, 15); + + /// The grid-scoped cell funnel. Same COMException retry as HeaderGridUi: a mid-edit + /// UIA tree walk can transiently throw while the DataGrid re-realizes underneath. + private static List CellElements(AutomationElement grid, string cellId) + { + for (var attempt = 0; ; attempt++) + { + try + { + return grid.FindAllDescendants(cf => cf.ByAutomationId(cellId)).ToList(); + } + catch (System.Runtime.InteropServices.COMException) when (attempt < 4) + { + Thread.Sleep(500 * (attempt + 1)); + } + } + } + + public static int RowCount(Window mcMain) => CellElements(GridFor(mcMain), UrlCellId).Count; + + public static List ReadRows(Window mcMain) + { + var grid = GridFor(mcMain); + var urls = CellElements(grid, UrlCellId); + var methods = CellElements(grid, MethodCellId); + var statuses = CellElements(grid, StatusCellId); + var events = CellElements(grid, EventCellId); + var problems = CellElements(grid, ProblemCellId); + + var rows = new List(); + for (var i = 0; i < urls.Count; i++) + { + rows.Add(new RowReading + { + Index = i, + Url = ReadValue(urls[i]), + Method = i < methods.Count ? ReadComboSelection(methods[i]) : string.Empty, + Status = i < statuses.Count ? ReadValue(statuses[i]) : string.Empty, + Event = i < events.Count ? ReadText(events[i]) : string.Empty, + Problem = i < problems.Count ? ReadText(problems[i]) : string.Empty, + }); + } + + return rows; + } + + public static string ReadProblem(Window mcMain, int index) + { + var problems = CellElements(GridFor(mcMain), ProblemCellId); + if (index < 0 || index >= problems.Count) + throw new InvalidOperationException($"no Problem cell at row {index} of '{GridId}' ({problems.Count} realized)."); + return ReadText(problems[index]); + } + + /// Press the grid's Add button and wait for a new realized row. + public static void AddRow(Window mcMain) + { + var before = RowCount(mcMain); + var button = ManagementClientUi.ExpectControl(mcMain, AddButtonId, 10); + for (var attempt = 0; attempt < 5 && RowCount(mcMain) == before; attempt++) + { + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + Thread.Sleep(600); + } + + if (RowCount(mcMain) != before + 1) + throw new InvalidOperationException($"'{AddButtonId}' did not append a row to '{GridId}' ({before} -> {RowCount(mcMain)})."); + } + + /// Remove one row by index, waiting for the row count to drop. + public static void RemoveRowAt(Window mcMain, int index) + { + var before = RowCount(mcMain); + if (index < 0 || index >= before) + throw new InvalidOperationException($"no row {index} in '{GridId}' to remove (rows={before})."); + + var grid = GridFor(mcMain); + for (var attempt = 0; attempt < 5 && RowCount(mcMain) == before; attempt++) + { + var cells = CellElements(grid, RemoveCellId); + if (index >= cells.Count) { Thread.Sleep(400); continue; } + var button = cells[index]; + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + Thread.Sleep(600); + } + + if (RowCount(mcMain) != before - 1) + throw new InvalidOperationException($"remove on row {index} of '{GridId}' never dropped a row ({before} -> {RowCount(mcMain)})."); + } + + public static void RemoveAllRows(Window mcMain) + { + for (var guard = 0; RowCount(mcMain) > 0 && guard < 50; guard++) + { + RemoveRowAt(mcMain, 0); + } + } + + /// Replace the whole grid with the wanted rows: remove every row, add one row per + /// entry, fill and verify row by row (order preserved — the matcher evaluates top-down and + /// raises at most the FIRST matching row's event). + public static void SetRows(Window mcMain, IEnumerable rows) + { + RemoveAllRows(mcMain); + foreach (var row in rows ?? Enumerable.Empty()) + { + AddRow(mcMain); + SetLastRow(mcMain, row); + } + } + + /// Fill the LAST row of the grid and verify it by readback, including the read-only + /// Event column the row renders. + public static void SetLastRow(Window mcMain, Row row) + { + var index = RowCount(mcMain) - 1; + if (index < 0) throw new InvalidOperationException($"no row to set in '{GridId}'."); + + var grid = GridFor(mcMain); + SetTextCell(mcMain, grid, UrlCellId, index, row.Url ?? string.Empty); + SetComboCell(mcMain, grid, MethodCellId, index, string.IsNullOrEmpty(row.Method) ? "*" : row.Method); + SetTextCell(mcMain, grid, StatusCellId, index, row.Status ?? string.Empty); + + var read = ReadRows(mcMain)[index]; + Trace($"'{GridId}' row {index}: '{row.Describe()}' reads url='{read.Url}' method='{read.Method}' status='{read.Status}' event='{read.Event}' problem='{read.Problem}'"); + if (!string.Equals(read.Url, row.Url ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(read.Method, string.IsNullOrEmpty(row.Method) ? "*" : row.Method, StringComparison.Ordinal) + || !string.Equals(read.Status, row.Status ?? string.Empty, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"'{GridId}' row {index} never read back as '{row.Describe()}' (reads '{read.Describe()}')."); + } + } + + /// + /// The trigger-save discipline, mirroring HeaderGridUi.SavePoliciesAndVerify: dirty-chain + /// guards, the pre-Save Name check, Save with modal sweep, then the stored list read back + /// from TWO independent channels — the plugin's own "Stored proxy triggers for workspace + /// '...' : N row(s) [...]" audit line in the MC MIP.log, and the grid re-read from a + /// RELOADED pane, ORDER-SENSITIVELY (configuration order is part of the policy: the first + /// matching row wins, here and on the Event Server). + /// + public static void SaveTriggersAndVerify( + ClientSession mc, + Window mcMain, + string workspacesNodeName, + string workspaceName, + IEnumerable expected, + StringBuilder log) + { + Trace = message => log.AppendLine(message); + var rows = (expected ?? Enumerable.Empty()).ToList(); + + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the trigger-grid edit; flipping the routing checkbox twice to dirty the pane"); + ManagementClientUi.DirtyPaneViaCheckbox(mcMain, AllowlistGridUi.ProxyEnabledId); + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the checkbox flip; keyboard-nudging the first trigger-grid url cell"); + try { ManagementClientUi.NudgeControlWithKeystroke(mcMain, UrlCellId); } + catch (Exception ex) { log.AppendLine($"nudge skipped: {ex.GetType().Name}"); } + log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}"); + } + } + + var paneName = AllowlistGridUi.ReadPaneName(mcMain); + if (!string.Equals(paneName, workspaceName, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Refusing to Save: the Name field reads '{paneName}', expected '{workspaceName}'."); + + AllowlistGridUi.OpenProxyTab(mcMain); + + // The audit line this save writes, keyed on this call's exact row count AND exact row + // renderings, so an older save of a different list cannot satisfy the delta. + var auditMarker = $"Stored proxy triggers for workspace '{workspaceName}'"; + var tokens = AuditTokens(rows); + var auditBefore = McLog.CountMatches(auditMarker, tokens); + + if (!ManagementClientUi.Save(mc, mcMain, 20)) + throw new InvalidOperationException( + $"Save never became enabled for triggers [{string.Join("; ", rows.Select(r => r.Describe()))}]."); + + var auditLine = McLog.WaitForNewLine(auditBefore, 20, auditMarker, tokens); + if (auditLine == null) + throw new InvalidOperationException( + $"the save wrote no 'Stored proxy triggers' audit line carrying [{string.Join(" | ", tokens)}] " + + $"({auditBefore} -> {McLog.CountMatches(auditMarker, tokens)}). " + + "The save either never ran or stored something other than the grid showed."); + log.AppendLine($"stored triggers audit line: {auditLine}"); + + Thread.Sleep(1500); + AllowlistGridUi.ReloadPane(mcMain, workspacesNodeName, workspaceName); + AllowlistGridUi.OpenProxyTab(mcMain); + var stored = ReadRows(mcMain); + if (!SameRows(rows, stored)) + { + Artifacts.Window(mcMain, "mc-triggers-reload-mismatch"); + throw new InvalidOperationException( + $"After Save + reload the trigger grid reads '{string.Join(" | ", stored.Select(r => r.Describe()))}' — expected '{string.Join(" | ", rows.Select(r => r.Describe()))}'."); + } + log.AppendLine( + $"saved triggers [{string.Join("; ", rows.Select(r => r.Describe()))}] (verified from the audit line AND the reloaded grid, order-sensitive)"); + } + + /// The audit line fragments for one wanted list: the row count AND the exact + /// rendering in brackets. ProxyTriggerEditor.DescribeStoredTriggers renders each row as + /// "url=... method=...|any status=...|any", joined by "; " — mirrored exactly here. + private static string[] AuditTokens(IEnumerable rows) + { + var list = rows.ToList(); + return new[] + { + $"{list.Count} row(s)", + $"[{string.Join("; ", list.Select(r => r.Describe()))}]", + }; + } + + /// Order-SENSITIVE row comparison: configuration order is part of the policy. + public static bool SameRows(IEnumerable expected, IEnumerable actual) + { + var e = (expected ?? Enumerable.Empty()).Select(r => r.Describe()).ToList(); + var a = (actual ?? Enumerable.Empty()).Select(r => r.Describe()).ToList(); + return e.SequenceEqual(a, StringComparer.Ordinal); + } + + // ---- cell primitives: read/verify, escalate the route on failure (same ladder as the + // header grids, but every lookup is scoped to the trigger grid) ----------------------- + + private static void SetTextCell(Window mcMain, AutomationElement grid, string cellId, int index, string value) + { + for (var attempt = 0; attempt < 4; attempt++) + { + var cell = CellElement(grid, cellId, index); + try + { + if (cell.Patterns.Value.IsSupported) + cell.Patterns.Value.Pattern.SetValue(value); + } + catch { } + + Thread.Sleep(400); + if (string.Equals(ReadValue(CellElement(grid, cellId, index)), value, StringComparison.Ordinal)) + return; + + // Keyboard route, focus-gated: the id is shared by every row of this grid, so the + // focus gate only proves SOMETHING with this id holds focus — the row readback below + // is what proves the write landed in THIS row. + cell = CellElement(grid, cellId, index); + if (ManagementClientUi.FocusVerified(mcMain, cell, cellId) && LoginFlow.TryClipboardSetText(value)) + { + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A); + Thread.Sleep(150); + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_V); + Thread.Sleep(400); + if (string.Equals(ReadValue(CellElement(grid, cellId, index)), value, StringComparison.Ordinal)) + return; + } + + Thread.Sleep(400); + } + + throw new InvalidOperationException( + $"'{cellId}' row {index} never read back as '{value}' (reads '{ReadValue(CellElement(grid, cellId, index))}')."); + } + + /// Drive the Method dropdown to the wanted verb. Pattern route first (expand, then + /// SelectionItem.Select on the item whose Name is the verb); escalation is focus-gated arrow + /// keys — a closed WPF combo moves SelectedItem with Up/Down, and the dropdown's order is + /// fixed, so counting steps from the readback lands on the verb. + private static void SetComboCell(Window mcMain, AutomationElement grid, string cellId, int index, string verb) + { + for (var attempt = 0; attempt < 4; attempt++) + { + var cell = CellElement(grid, cellId, index); + try + { + var expandable = cell.Patterns.ExpandCollapse.IsSupported; + if (expandable) + cell.Patterns.ExpandCollapse.Pattern.Expand(); + Thread.Sleep(400); + var items = cell.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)).ToList(); + var scope = "combo-scoped"; + if (items.Count == 0) + { + // WPF dropdowns live in a popup that can attach OUTSIDE the combo's subtree - + // fall back to a window-wide search, restricted to the known verb set so no + // other list on the pane can be mistaken for this dropdown. + items = mcMain.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)) + .Where(i => MethodOrder.Contains((i.Name ?? string.Empty).Trim())) + .ToList(); + scope = "window-wide"; + } + + var match = items.FirstOrDefault(i => string.Equals((i.Name ?? string.Empty).Trim(), verb, StringComparison.Ordinal)); + Trace($"combo row {index} '{verb}': expand={expandable} items={items.Count} ({scope}) match={(match == null ? "none" : $"'{match.Name}' selectionItem={match.Patterns.SelectionItem.IsSupported}")}"); + if (match != null && match.Patterns.SelectionItem.IsSupported) + match.Patterns.SelectionItem.Pattern.Select(); + Thread.Sleep(300); + if (cell.Patterns.ExpandCollapse.IsSupported) + cell.Patterns.ExpandCollapse.Pattern.Collapse(); + } + catch (Exception ex) + { + Trace($"combo row {index} '{verb}' pattern route threw {ex.GetType().Name}: {ex.Message}"); + } + + Thread.Sleep(400); + if (string.Equals(ReadComboSelection(CellElement(grid, cellId, index)), verb, StringComparison.Ordinal)) + return; + + // Keyboard route, focus-gated. + cell = CellElement(grid, cellId, index); + var current = Array.IndexOf(MethodOrder, ReadComboSelection(cell)); + var target = Array.IndexOf(MethodOrder, verb); + var focusOk = ManagementClientUi.FocusVerified(mcMain, cell, cellId); + Trace($"combo row {index} '{verb}': keyboard route focus={focusOk} current={current} target={target}"); + if (focusOk && current >= 0 && target >= 0) + { + var key = target >= current + ? FlaUI.Core.WindowsAPI.VirtualKeyShort.DOWN + : FlaUI.Core.WindowsAPI.VirtualKeyShort.UP; + for (var step = 0; step < Math.Abs(target - current); step++) + { + FlaUI.Core.Input.Keyboard.Press(key); + Thread.Sleep(150); + } + + Thread.Sleep(400); + if (string.Equals(ReadComboSelection(CellElement(grid, cellId, index)), verb, StringComparison.Ordinal)) + return; + } + + Thread.Sleep(400); + } + + throw new InvalidOperationException( + $"'{cellId}' row {index} never read back method '{verb}' (reads '{ReadComboSelection(CellElement(grid, cellId, index))}')."); + } + + private static AutomationElement CellElement(AutomationElement grid, string cellId, int index) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (true) + { + var cells = CellElements(grid, cellId); + if (index >= 0 && index < cells.Count) + { + return cells[index]; + } + + if (DateTime.UtcNow >= deadline) + { + throw new InvalidOperationException( + $"grid cell '{cellId}' row {index} never realized ({cells.Count} realized in that column)."); + } + + Thread.Sleep(400); + } + } + + private static string ReadValue(AutomationElement element) + { + try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value ?? string.Empty : element.Name ?? string.Empty; } + catch { return element.Name ?? string.Empty; } + } + + /// Read the combo's selection. A WPF ComboBox peer exposes its dropdown items as + /// children only once the dropdown has been opened at least time (a freshly loaded closed + /// combo shows ZERO item peers), and the popup stays in the subtree after a collapse, so naive + /// Text scans hit the popup's first row ("*") instead of the selection. The selection is read + /// from the item peers' IsSelected flags; a combo whose peers are not realized yet is expanded + /// once to realize them, read there, and collapsed again. + private static string ReadComboSelection(AutomationElement element) + { + try + { + var direct = (element.Name ?? string.Empty).Trim(); + if (MethodOrder.Contains(direct)) + return direct; + + var selected = FirstSelected(element.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)).ToList()); + if (selected != null) + return (selected.Name ?? string.Empty).Trim(); + + var probe = new List(); + try + { + if (element.Patterns.ExpandCollapse.IsSupported) + { + element.Patterns.ExpandCollapse.Pattern.Expand(); + Thread.Sleep(400); + var realized = element.FindAllDescendants(cf => cf.ByControlType(ControlType.ListItem)).ToList(); + foreach (var i in realized) + { + try { probe.Add($"'{i.Name}'={i.Patterns.SelectionItem.IsSupported}:{i.Patterns.SelectionItem.Pattern.IsSelected}"); } + catch (Exception ex) { probe.Add($"'{i.Name}'=threw:{ex.GetType().Name}"); } + } + + selected = FirstSelected(realized); + element.Patterns.ExpandCollapse.Pattern.Collapse(); + } + } + catch { } + + if (selected == null) + Trace($"combo readback: realized=[{string.Join(" ", probe)}] selected=none"); + if (selected != null) + return (selected.Name ?? string.Empty).Trim(); + if (probe.Count == 0) + { + // No item peers even after expanding: fall back to the selection-box Text child. + foreach (var text in element.FindAllDescendants(cf => cf.ByControlType(ControlType.Text))) + { + var name = (text.Name ?? string.Empty).Trim(); + if (MethodOrder.Contains(name)) + return name; + } + } + + return direct; + } + catch { return string.Empty; } + } + + private static AutomationElement FirstSelected(List items) => + items.FirstOrDefault(i => + { + try { return i.Patterns.SelectionItem.IsSupported && i.Patterns.SelectionItem.Pattern.IsSelected; } + catch { return false; } + }); + + private static string ReadText(AutomationElement element) + { + try { return element.Name ?? string.Empty; } + catch { return string.Empty; } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/WebSocketEchoServer.cs b/tests/ux/WebContent.UxTests/Harness/WebSocketEchoServer.cs new file mode 100644 index 0000000..3465bd3 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/WebSocketEchoServer.cs @@ -0,0 +1,431 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #57 acceptance target: a vendor-style page that opens a WEBSOCKET, served from this +/// machine on one port. Plain HTTP GETs serve the probe page; a GET carrying the RFC 6455 +/// upgrade headers is answered with a real 101 handshake and the connection then echoes every +/// client frame back verbatim. The probe page shows "WS-OK:<token>" only when its token +/// survived the full browser -> loopback proxy -> MIP messaging -> Event Server raw-socket +/// round trip, and the journal records the handshake and every echoed frame for server-side +/// evidence. No external websocket library: the harness speaks the raw RFC 6455 frames itself, +/// exactly like the tunnel it is testing. +/// +/// #60 adds a TLS mode: every accepted connection first runs a server-side SslStream under an +/// in-process self-signed certificate (journal: TLS-CONNECT/TLS-HANDSHAKE/TLS-FAULT), so the +/// same echo target can sit behind an https origin and exercise the Event Server's wss +/// handshake leg. probePageHtml replaces the served page (the wss probe dials a different +/// origin from the page it loads on). +/// +public sealed class WebSocketEchoServer : IDisposable +{ + private const string WsGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + private readonly TcpListener _listener; + private readonly List _journal = new(); + private readonly X509Certificate2 _tlsCertificate; + private readonly string _probePageHtml; + private volatile bool _stopping; + + /// The in-process TLS certificate when useTls was requested (null otherwise). The + /// wss probe exports this and has the Event Server trust it for the validated-target row. + public X509Certificate2 TlsCertificate => _tlsCertificate; + + public WebSocketEchoServer(int port) : this(port, useTls: false, tlsHost: null, probePageHtml: null) + { + } + + /// Accept connections under an in-process self-signed certificate + /// (server-auth EKU, SAN for tlsHost + loopback), which is exactly what the #60 wss rows + /// need: untrusted for the fail-closed row, imported into the Event Server's trust store + /// for the validated row. + /// Host the https origin names; becomes a certificate SAN so the + /// failure on the fail-closed row is chain trust, never name mismatch. + /// Replaces the default ws page for GETs (the wss probe's page + /// lives on a plain http origin and dials the https origin by absolute url). + public WebSocketEchoServer(int port, bool useTls, string tlsHost = null, string probePageHtml = null) + { + if (useTls) + { + _tlsCertificate = CreateSelfSignedCertificate(tlsHost); + } + _probePageHtml = probePageHtml; + _listener = new TcpListener(IPAddress.Any, port); + _listener.Start(); + _ = Task.Run(AcceptLoopAsync); + } + + public IReadOnlyList Journal + { + get + { + lock (_journal) + { + return _journal.ToList(); + } + } + } + + private void Note(string entry) + { + lock (_journal) + { + _journal.Add(entry); + } + } + + private async Task AcceptLoopAsync() + { + while (!_stopping) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(); + } + catch + { + return; + } + + _ = Task.Run(() => ServeAsync(client)); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + var stream = client.GetStream(); + try + { + Stream requestStream = stream; + if (_tlsCertificate != null) + { + Note("TLS-CONNECT peer"); + var tls = new SslStream(stream, false); + try + { + await tls.AuthenticateAsServerAsync(_tlsCertificate, false, SslProtocols.Tls12, false); + Note("TLS-HANDSHAKE ok"); + } + catch (Exception tlsFault) + { + // A client that refuses the chain mid-TLS is a clean close, not a + // request: the journal entry is the evidence the leg was dialed. + Note($"TLS-FAULT {tlsFault.GetType().Name}: {tlsFault.Message}"); + tls.Dispose(); + return; + } + requestStream = tls; + } + + var head = await ReadHeadAsync(requestStream); + if (head == null) + { + return; + } + + var lines = head.Split(new[] { "\r\n" }, StringSplitOptions.None); + var requestLine = lines.Length > 0 ? lines[0] : ""; + var parts = requestLine.Split(' '); + var path = parts.Length > 1 ? parts[1] : ""; + var upgraded = head.Contains("upgrade: websocket", StringComparison.OrdinalIgnoreCase) || + head.Contains("Upgrade: websocket", StringComparison.Ordinal); + + if (upgraded && path.StartsWith("/echo", StringComparison.Ordinal)) + { + await EchoWebSocketAsync(requestStream, head, path); + return; + } + + Note($"GET {path}"); + var body = BuildProbePage(); + var bytes = Encoding.UTF8.GetBytes(body); + var response = + "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + $"Content-Length: {bytes.Length}\r\n" + + "Connection: close\r\n" + + "\r\n"; + await requestStream.WriteAsync(Encoding.UTF8.GetBytes(response)); + await requestStream.WriteAsync(bytes); + } + catch (Exception exception) + { + Note($"connection fault: {exception.GetType().Name}: {exception.Message}"); + } + } + } + + private async Task EchoWebSocketAsync(Stream stream, string head, string path) + { + string key = null; + foreach (var line in head.Split(new[] { "\r\n" }, StringSplitOptions.None)) + { + if (line.StartsWith("Sec-WebSocket-Key:", StringComparison.OrdinalIgnoreCase)) + { + key = line.Substring(line.IndexOf(':') + 1).Trim(); + } + } + + if (string.IsNullOrEmpty(key)) + { + Note($"WS-REFUSED {path}: no Sec-WebSocket-Key"); + var refusal = Encoding.UTF8.GetBytes("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + await stream.WriteAsync(refusal); + return; + } + + using var sha1 = SHA1.Create(); + var accept = Convert.ToBase64String(sha1.ComputeHash(Encoding.ASCII.GetBytes(key + WsGuid))); + var handshake = + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + $"Sec-WebSocket-Accept: {accept}\r\n" + + "\r\n"; + await stream.WriteAsync(Encoding.UTF8.GetBytes(handshake)); + Note($"WS-HANDSHAKE {path}"); + + while (true) + { + var frame = await ReadFrameAsync(stream); + if (frame == null) + { + Note("WS-CLOSE peer"); + return; + } + + var (opcode, payload) = frame.Value; + if (opcode == 8) + { + Note("WS-CLOSE client"); + return; + } + + Note($"WS-ECHO {payload.Length} bytes"); + await WriteFrameAsync(stream, opcode, payload); + } + } + + private static async Task<(byte opcode, byte[] payload)?> ReadFrameAsync(Stream stream) + { + var first = await ReadExactlyAsync(stream, 2); + if (first == null) + { + return null; + } + + var opcode = (byte)(first[0] & 0x0F); + var masked = (first[1] & 0x80) != 0; + long length = (byte)(first[1] & 0x7F); + if (length == 126) + { + var extra = await ReadExactlyAsync(stream, 2); + if (extra == null) + { + return null; + } + + length = (extra[0] << 8) | extra[1]; + } + else if (length == 127) + { + var extra = await ReadExactlyAsync(stream, 8); + if (extra == null) + { + return null; + } + + length = 0; + foreach (var b in extra) + { + length = (length << 8) | b; + } + } + + byte[] mask = null; + if (masked) + { + mask = await ReadExactlyAsync(stream, 4); + if (mask == null) + { + return null; + } + } + + var payload = await ReadExactlyAsync(stream, (int)length); + if (payload == null) + { + return null; + } + + if (mask != null) + { + for (var i = 0; i < payload.Length; i++) + { + payload[i] ^= mask[i % 4]; + } + } + + return (opcode, payload); + } + + private static async Task WriteFrameAsync(Stream stream, byte opcode, byte[] payload) + { + var head = new List { (byte)(0x80 | opcode) }; + if (payload.Length < 126) + { + head.Add((byte)payload.Length); + } + else if (payload.Length <= 0xFFFF) + { + head.Add(126); + head.Add((byte)(payload.Length >> 8)); + head.Add((byte)payload.Length); + } + else + { + head.Add(127); + for (var shift = 56; shift >= 0; shift -= 8) + { + head.Add((byte)(((long)payload.Length >> shift) & 0xFF)); + } + } + + await stream.WriteAsync(head.ToArray()); + await stream.WriteAsync(payload); + } + + private static async Task ReadExactlyAsync(Stream stream, int count) + { + var buffer = new byte[count]; + var offset = 0; + while (offset < count) + { + var read = await stream.ReadAsync(buffer, offset, count - offset); + if (read <= 0) + { + return null; + } + + offset += read; + } + + return buffer; + } + + private static async Task ReadHeadAsync(Stream stream) + { + var buffer = new byte[8192]; + var received = 0; + while (received < buffer.Length) + { + var read = await stream.ReadAsync(buffer, received, buffer.Length - received); + if (read <= 0) + { + return null; + } + + received += read; + var text = Encoding.UTF8.GetString(buffer, 0, received); + var end = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (end >= 0) + { + return text.Substring(0, end); + } + } + + return null; + } + + private string BuildProbePage() => _probePageHtml ?? DefaultWsProbePage; + + private static string DefaultWsProbePage => + "ws-echo-probe" + + "

websocket probe

" + + "
WS-PENDING
" + + ""; + + /// + /// RSA self-signed certificate valid for the https origin the probe names (SAN: tlsHost, + /// loopback, machine name). KeyUsage/EKU mirror a real camera portal's server certificate, + /// so the #60 fail-closed row fails on chain trust, never on name mismatch. The PFX + /// roundtrip lands the key in a persisted form Schannel accepts server-side. + /// + private static X509Certificate2 CreateSelfSignedCertificate(string tlsHost) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={WssTargetTrust.ProbeCertificateCommonName}", + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, + false)); + + var san = new SubjectAlternativeNameBuilder(); + san.AddIpAddress(IPAddress.Loopback); + if (!string.IsNullOrWhiteSpace(tlsHost)) + { + if (IPAddress.TryParse(tlsHost, out var address)) + { + san.AddIpAddress(address); + } + else + { + san.AddDnsName(tlsHost); + } + } + san.AddDnsName(Environment.MachineName.ToLowerInvariant()); + request.CertificateExtensions.Add(san.Build()); + + using var created = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30)); + + var password = Guid.NewGuid().ToString("N"); + var portable = created.Export(X509ContentType.Pkcs12, password); + return X509CertificateLoader.LoadPkcs12(portable, password); + } + + public void Dispose() + { + _stopping = true; + try + { + _listener.Stop(); + } + catch + { + } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/WinInetProxyControl.cs b/tests/ux/WebContent.UxTests/Harness/WinInetProxyControl.cs new file mode 100644 index 0000000..58594fd --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/WinInetProxyControl.cs @@ -0,0 +1,104 @@ +using System; + +using Microsoft.Win32; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #67 (SC-8): the probe-side control of the OS-configured (WinINET) proxy - the exact +/// per-user HKCU Internet Settings that "Internet Options > LAN settings" edits and that the +/// WebView2 system-proxy resolver follows for browsers launched WITHOUT --proxy-server. The +/// snapshot/restore pair keeps the workstation exactly as found: whatever ProxyEnable value, +/// ProxyServer string (this lab's is a socks= entry), ProxyOverride, or AutoConfigURL was +/// present is written back verbatim, and InternetSetOption broadcasts the change so the +/// settings are live for processes started afterwards without any sign-out. +/// +public static class WinInetProxyControl +{ + private const string InternetSettingsKey = + @"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; + + /// The proxy settings as HKCU held them, with missing values as null. + public sealed class Snapshot + { + public bool ProxyEnable; + public string ProxyServer; + public string ProxyOverride; + public string AutoConfigUrl; + } + + public static Snapshot Capture() + { + using var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: false) + ?? throw new InvalidOperationException($"HKCU\\{InternetSettingsKey} is not readable."); + return new Snapshot + { + ProxyEnable = ReadValue(key, "ProxyEnable") is int enabled && enabled != 0, + ProxyServer = ReadValue(key, "ProxyServer") as string, + ProxyOverride = ReadValue(key, "ProxyOverride") as string, + AutoConfigUrl = ReadValue(key, "AutoConfigURL") as string, + }; + } + + /// + /// Points the per-user system proxy at 127.0.0.1:port with "" as the bypass list + /// (loopback targets like the CDP endpoint stay direct), clears any PAC URL, and + /// broadcasts INTERNET_OPTION_SETTINGS_CHANGED + REFRESH. + /// + public static void UseLoopbackProxy(int port) + { + using (var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: true) + ?? throw new InvalidOperationException($"HKCU\\{InternetSettingsKey} is not writable.")) + { + key.SetValue("ProxyEnable", 1, RegistryValueKind.DWord); + key.SetValue("ProxyServer", $"127.0.0.1:{port}", RegistryValueKind.String); + key.SetValue("ProxyOverride", "", RegistryValueKind.String); + key.SetValue("AutoConfigURL", string.Empty, RegistryValueKind.String); + } + + BroadcastChange(); + } + + /// Writes the captured settings back verbatim (missing values deleted), then broadcasts. + public static void Restore(Snapshot snapshot) + { + if (snapshot == null) return; + using (var key = Registry.CurrentUser.OpenSubKey(InternetSettingsKey, writable: true) + ?? throw new InvalidOperationException($"HKCU\\{InternetSettingsKey} is not writable.")) + { + WriteValue(key, "ProxyEnable", snapshot.ProxyEnable ? 1 : 0, RegistryValueKind.DWord); + WriteValue(key, "ProxyServer", snapshot.ProxyServer, RegistryValueKind.String); + WriteValue(key, "ProxyOverride", snapshot.ProxyOverride, RegistryValueKind.String); + WriteValue(key, "AutoConfigURL", snapshot.AutoConfigUrl, RegistryValueKind.String); + } + + BroadcastChange(); + } + + private static object ReadValue(RegistryKey key, string name) + { + try { return key.GetValue(name); } catch { return null; } + } + + private static void WriteValue(RegistryKey key, string name, object value, RegistryValueKind kind) + { + try + { + if (value == null) key.DeleteValue(name, throwOnMissingValue: false); + else key.SetValue(name, value, kind); + } + catch { /* best-effort restore; the probe log records the snapshot either way */ } + } + + // wininet InternetSetOptionW: SETTINGS_CHANGED (39) tells every WinINET-aware process the + // per-user settings moved; REFRESH (37) re-reads them. The exact pair the Control Panel + // fires when OK closes the LAN-settings dialog. + [System.Runtime.InteropServices.DllImport("wininet.dll", SetLastError = true)] + private static extern bool InternetSetOptionW(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength); + + private static void BroadcastChange() + { + try { InternetSetOptionW(IntPtr.Zero, 39, IntPtr.Zero, 0); } catch { } + try { InternetSetOptionW(IntPtr.Zero, 37, IntPtr.Zero, 0); } catch { } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/WinInetProxyHarness.cs b/tests/ux/WebContent.UxTests/Harness/WinInetProxyHarness.cs new file mode 100644 index 0000000..6b0179f --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/WinInetProxyHarness.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #67 (SC-8): a minimal plain-HTTP forward proxy whose ONLY job is to be the OS +/// (WinINET) proxy the WebView2 system-proxy resolver discovers. It answers every +/// absolute-form http request with a synthetic marker page and journals each request head, +/// so "the marker page rendered" IS the proof the browser routed through the OS-configured +/// proxy: the requested host sits on the .invalid TLD (RFC 2606), which can never resolve, +/// so a direct route physically cannot serve it. CONNECT is journaled and refused (403) - +/// nothing is ever tunnelled - and origin-form junk gets a plain 400. The harness never +/// makes an upstream connection of its own. +/// +/// One request per connection (Connection: close): the probe only needs the journal, and a +/// short-lived connection keeps the parser honest about where a request head ends. +/// +public sealed class WinInetProxyHarness : IDisposable +{ + /// Title of the synthetic page the harness serves; the probe asserts it. + public const string PageTitle = "WebContent OS-proxy harness"; + + /// Token only this harness puts in a body; its presence proves who served the page. + public const string BodyMarker = "WININET-OS-PROXY-ROUTE-CONFIRMED"; + + /// One journaled request head, exactly as the proxy socket saw it. + public sealed record JournalEntry(DateTime SeenAtUtc, string Method, string RequestTarget, string UserAgent); + + private readonly TcpListener _listener; + private readonly List _journal = new(); + private readonly object _journalGate = new(); + private volatile bool _stopping; + + public int Port { get; } + + public WinInetProxyHarness() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = Task.Run(AcceptLoopAsync); + } + + /// Journal entries whose request target contains the given fragment. + public List RequestsFor(string targetFragment) + { + lock (_journalGate) + { + return _journal + .Where(e => e.RequestTarget.IndexOf(targetFragment ?? string.Empty, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + } + } + + public List Journal() + { + lock (_journalGate) + { + return _journal.ToList(); + } + } + + public void Dispose() + { + _stopping = true; + try { _listener.Stop(); } catch { } + } + + private async Task AcceptLoopAsync() + { + while (!_stopping) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch + { + return; // listener stopped (Dispose) + } + + _ = Task.Run(() => HandleClientAsync(client)); + } + } + + private async Task HandleClientAsync(TcpClient client) + { + using (client) + { + try + { + using var stream = client.GetStream(); + var head = await ReadRequestHeadAsync(stream).ConfigureAwait(false); + if (head == null) return; + + var lines = head.Split(new[] { "\r\n" }, StringSplitOptions.None); + var requestLine = (lines[0] ?? string.Empty).Split(new[] { ' ' }, 3); + if (requestLine.Length < 2) + { + await WriteRawAsync(stream, "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").ConfigureAwait(false); + return; + } + + var method = requestLine[0].ToUpperInvariant(); + var target = requestLine[1]; + var userAgent = lines.Skip(1) + .FirstOrDefault(l => l.StartsWith("User-Agent:", StringComparison.OrdinalIgnoreCase)) + ?.Substring("User-Agent:".Length).Trim() ?? string.Empty; + + lock (_journalGate) + { + _journal.Add(new JournalEntry(DateTime.UtcNow, method, target, userAgent)); + } + + if (method == "CONNECT") + { + // Journaled and refused: the probe only exercises plain http, and a harness + // that tunnelled would blur what "served by the OS proxy" means. + await WriteRawAsync(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").ConfigureAwait(false); + return; + } + + if ((method == "GET" || method == "HEAD") && + target.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + var body = BuildPage(target); + var response = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n\r\n"; + await WriteRawAsync(stream, response).ConfigureAwait(false); + if (method == "GET") + await WriteRawAsync(stream, body).ConfigureAwait(false); + return; + } + + await WriteRawAsync(stream, "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").ConfigureAwait(false); + } + catch + { + // A dead client mid-exchange is data, not an error: the journal already holds + // whatever head arrived. + } + } + } + + private static async Task ReadRequestHeadAsync(NetworkStream stream) + { + var buffer = new byte[8192]; + var collected = new StringBuilder(); + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline && collected.Length < 65536) + { + var read = await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (read <= 0) break; + collected.Append(Encoding.UTF8.GetString(buffer, 0, read)); + if (collected.ToString().Contains("\r\n\r\n")) break; + } + + var head = collected.ToString(); + var end = head.IndexOf("\r\n\r\n", StringComparison.Ordinal); + return end < 0 ? (head.Length == 0 ? null : head) : head.Substring(0, end); + } + + private static string BuildPage(string requestedTarget) => + "" + PageTitle + "" + + $"

{BodyMarker}

" + + $"

Served by the OS-configured (WinINET) proxy harness for: {WebUtility.HtmlEncode(requestedTarget)}

" + + $"

{DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)}

" + + ""; + + private static async Task WriteRawAsync(NetworkStream stream, string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + await stream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/WorkspaceHomeAddress.cs b/tests/ux/WebContent.UxTests/Harness/WorkspaceHomeAddress.cs new file mode 100644 index 0000000..ce241e8 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/WorkspaceHomeAddress.cs @@ -0,0 +1,91 @@ +using System; +using System.Text; +using System.Threading; + +using FlaUI.Core.AutomationElements; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #58 (SC-5 start-URL row): set the Demo Workspace's home (start) address through the +/// Management Client pane and return the previous value so the probe can restore it. Same +/// discipline as ProxyTargetProvisioning - edit via paste/Value fallback, verified readback, +/// the pre-Save Name guard, and a Save that must actually stick. Assumes the workspace item +/// exists (call after ProxyTargetProvisioning.Ensure, which provisions/recreates it). +/// +public static class WorkspaceHomeAddress +{ + /// Sets the home address; returns the previous value (null when already equal). + public static string Ensure(UxConfig config, string homeAddress, StringBuilder log) + { + ProcessKiller.KillClients("VideoOS.Administration"); + using var mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + var mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + var homeBox = ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 15); + var current = ReadValue(homeBox); + log.AppendLine($"workspace home address before: '{current}'"); + if (string.Equals(current?.Trim(), homeAddress, StringComparison.OrdinalIgnoreCase)) + { + log.AppendLine($"home already '{homeAddress}'; no Management Client edit needed"); + Retire(mc, mcMain); + return null; + } + + var pasted = ManagementClientUi.PasteIntoControl(mcMain, "txtHomeAddress", homeAddress); + var edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 5)); + if (!string.Equals(edited?.Trim(), homeAddress, StringComparison.OrdinalIgnoreCase)) + { + log.AppendLine($"paste route {(pasted ? "" : "(clipboard unavailable) ")}did not land (readback '{edited}'); ValuePattern fallback"); + ManagementClientUi.SetValue(mcMain, "txtHomeAddress", homeAddress); + edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 5)); + } + + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after edit; keyboard-nudging the home box"); + ManagementClientUi.NudgeControlWithKeystroke(mcMain, "txtHomeAddress"); + log.AppendLine($"Save enabled after nudge: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}"); + } + + var paneName = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtName", 5)); + if (!string.Equals(paneName, config.McWorkspaceName, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Refusing to Save: the Name field reads '{paneName}', expected '{config.McWorkspaceName}'."); + + if (!ManagementClientUi.Save(mc, mcMain, 20)) + throw new InvalidOperationException($"Save never completed after setting the home address to '{homeAddress}'."); + + Thread.Sleep(1500); + var after = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 15)); + if (!string.Equals(after?.Trim(), homeAddress, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"After Save, the home address reads '{after}' — '{homeAddress}' did not stick."); + + log.AppendLine($"home address set to '{homeAddress}' (verified readback)"); + Retire(mc, mcMain); + return current; + } + + private static void Retire(ClientSession mc, Window mcMain) + { + try { mcMain.Close(); } catch { } + ManagementClientUi.AcceptPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + + private static string ReadValue(AutomationElement element) + { + try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; } + catch { return element.Name; } + } +} diff --git a/tests/ux/WebContent.UxTests/Harness/WssTargetTrust.cs b/tests/ux/WebContent.UxTests/Harness/WssTargetTrust.cs new file mode 100644 index 0000000..8904d70 --- /dev/null +++ b/tests/ux/WebContent.UxTests/Harness/WssTargetTrust.cs @@ -0,0 +1,117 @@ +using System; +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace WebContent.UxTests.Harness; + +/// +/// Ticket #60 (SC-6 demo): make the Event Server trust the wss probe target's certificate. +/// The tunnel TLS leg keeps FULL chain validation on the Event Server (the accept-untrusted +/// override is a deliberate follow-on ticket), so the success row needs the probe's self-signed +/// certificate to sit in the Event Server machine's LocalMachine\Root store. Import follows the +/// same conventions as : jhadmin credentials from the repo-root +/// .env, PowerShell remoting, credentials through the child process's environment only. The +/// remote block is idempotent and self-cleaning: certificates previously imported under the +/// probe CN are removed before the fresh one is added, so repeat runs never stack roots. +/// Set UX_WSS_TRUST_IMPORT=0 when the target certificate is trusted by other means. +/// +public static class WssTargetTrust +{ + /// Subject CN marking every probe-minted certificate; the import purges stale + /// siblings of this CN before adding the new one. + public const string ProbeCertificateCommonName = "WebContent wss tunnel probe (test only)"; + + /// False when the operator opted out (UX_WSS_TRUST_IMPORT=0) because the target + /// certificate is already trusted on the Event Server by other means. + public static bool ImportEnabled => + Environment.GetEnvironmentVariable("UX_WSS_TRUST_IMPORT") != "0"; + + public static void ImportOnEventServer(UxConfig config, X509Certificate2 certificate, StringBuilder log) + { + // The same host sources as EventServerControl (UX_ES_HOST or VMS_ADDRESS), but a + // tolerant parse: the lab .env VMS_ADDRESS is a bare hostname, so this only takes a + // uri host when the value actually carries a scheme. + var address = Environment.GetEnvironmentVariable("UX_ES_HOST") ?? config.VmsAddress; + var host = address.Contains("://", StringComparison.Ordinal) && Uri.TryCreate(address, UriKind.Absolute, out var addressUri) + ? addressUri.Host + : address; + log.AppendLine($"importing the probe certificate (thumbprint {certificate.Thumbprint}) into LocalMachine\\Root on {host}..."); + + // Public DER only: the trust store needs the certificate, never the private key. + var publicDer = Convert.ToBase64String(certificate.Export(X509ContentType.Cert)); + + var script = $@" +$ErrorActionPreference = 'Stop' +$password = ConvertTo-SecureString $env:WEBCONTENT_ES_PASSWORD -AsPlainText -Force +$credential = New-Object System.Management.Automation.PSCredential($env:WEBCONTENT_ES_USER, $password) +$session = New-PSSession -ComputerName '{host}' -Credential $credential +try {{ + Invoke-Command -Session $session -ArgumentList ([Convert]::FromBase64String($env:WEBCONTENT_WSS_CERT_DER)), '{ProbeCertificateCommonName}' -ScriptBlock {{ + param($certificateBytes, $commonName) + $ErrorActionPreference = 'Stop' + $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certificateBytes) + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'LocalMachine') + $store.Open('ReadWrite') + try {{ + $stale = @($store.Certificates | Where-Object {{ $_.Subject -eq ('CN=' + $commonName) -and $_.Thumbprint -ne $certificate.Thumbprint }}) + foreach ($old in $stale) {{ + $store.Remove($old) + Write-Output ('removed stale probe certificate ' + $old.Thumbprint) + }} + if (-not ($store.Certificates | Where-Object {{ $_.Thumbprint -eq $certificate.Thumbprint }})) {{ + $store.Add($certificate) + Write-Output ('imported probe certificate ' + $certificate.Thumbprint) + }} else {{ + Write-Output ('probe certificate ' + $certificate.Thumbprint + ' already present') + }} + }} finally {{ + $store.Close() + }} + }} +}} finally {{ + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +}} +"; + + var startInfo = new ProcessStartInfo + { + FileName = ResolvePowerShell(), + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.Environment["WEBCONTENT_ES_USER"] = config.VmsUsername; + startInfo.Environment["WEBCONTENT_ES_PASSWORD"] = config.VmsPassword; + startInfo.Environment["WEBCONTENT_WSS_CERT_DER"] = publicDer; + + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + foreach (var line in stdout.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) log.AppendLine(" es-trust: " + line.Trim()); + } + + if (process.ExitCode != 0) + { + log.AppendLine($" certificate import FAILED (exit {process.ExitCode}): {stderr.Trim()}"); + throw new InvalidOperationException($"Importing the wss probe certificate into LocalMachine\\Root on {host} failed: {stderr.Trim()}"); + } + + log.AppendLine($" probe certificate trusted on {host}"); + } + + private static string ResolvePowerShell() + { + // pwsh (PowerShell 7) is the repo's shell; Windows PowerShell 5.1 is the fallback. + var programFiles = Environment.GetEnvironmentVariable("ProgramFiles") ?? @"C:\Program Files"; + var pwsh = Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"); + if (File.Exists(pwsh)) return pwsh; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + } +} diff --git a/tests/ux/WebContent.UxTests/McAllowlistGridProbeTests.cs b/tests/ux/WebContent.UxTests/McAllowlistGridProbeTests.cs new file mode 100644 index 0000000..be8f641 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McAllowlistGridProbeTests.cs @@ -0,0 +1,226 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #81 probe: the Management Client's structured allowlist grid on the Proxy tab, driven +/// through a live MC against the live VMS. Four things get pinned, none of them by narration: +/// +/// 1. The free-text one-line-per-entry box is GONE (AC: the accept-untrusted token is no longer +/// user-facing anywhere in the MC UI) - no txtProxyTargets control exists in the pane, and the +/// admin-facing hint no longer teaches a typed token. +/// 2. A refused entry is flagged ON ITS ROW (inline Problem cell) and the whole save is refused +/// BY NAME - the plugin's own notice names both bad rows, the MC MIP.log gains the matching +/// 'invalid proxy target row(s)' marker, and the rows are NOT silently dropped from the grid. +/// The follow-up save proves nothing partial reached storage: the stored set afterwards is +/// exactly the fixed grid, with no trace of the refused rows. +/// 3. scheme / host / port / accept-untrusted round-trip through the STORED record: the readback +/// is taken from a RELOADED pane (re-selected item), so the flag reading On is the stored +/// record speaking, not the harness's own keystrokes. +/// 4. The full-host-wildcard warning banner is up exactly while a '*' row is in the grid, and the +/// per-row Remove button drops both the row and the banner. +/// +/// The probe workspace's original allowlist is restored and verified before the probe exits. +/// Nothing here dials a probe origin: 'gridprobe.invalid' is a reserved, unroutable TLD, and the +/// probe never fetches it - it only proves what the admin page accepts, refuses and stores. +/// +public class McAllowlistGridProbeTests +{ + private const string ProbeHost = "gridprobe.invalid"; + private const string ProbeEntry = "https://gridprobe.invalid:8443"; + private const string ProbeEntryFlagged = "https://gridprobe.invalid:8443 accept-untrusted"; + private const string BadPatternHost = "foo.*.bar.com"; + private const string BadPortHost = "ok.gridprobe.invalid"; + private const string BadPort = "99999"; + private const string HatchEntry = "http://*"; + + /// The two refused rows exactly as the ROW MODEL describes them (scheme and all). The + /// refusal dialog and the refusal log marker both print that shape, so asserting these strings + /// asserts the model's own scheme, not what the dropdown appeared to show. + private const string BadPatternRow = "https://foo.*.bar.com"; + private const string BadPortRowEntry = "http://ok.gridprobe.invalid:99999"; + + /// Substring of Strings.InvalidProxyTargetMessage, the notice the plugin's save path + /// shows for a refused row. + private const string RefusalNotice = "is not a valid proxy target"; + + /// WorkspaceItemManager's own log marker for the same refusal. + private const string RefusalLogMarker = "invalid proxy target row(s)"; + + private readonly ITestOutputHelper _output; + + public McAllowlistGridProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeStructuredAllowlistGridEditsRefusesAndStores() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-allowlist-grid-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + AllowlistGridUi.Trace = message => log.AppendLine(message); + Window mcMain = null; + ClientSession mc = null; + try + { + ProcessKiller.KillClients("VideoOS.Administration"); + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + // ---- Phase 0: the free-text editor is gone; rows render from the stored records ---- + var legacyBox = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("txtProxyTargets")); + Assert.True(legacyBox == null, + "a txtProxyTargets control exists in the live pane again - the free-text allowlist editor is back (#81 AC1)."); + + AllowlistGridUi.OpenProxyTab(mcMain); + var rendered = AllowlistGridUi.ReadEntries(mcMain); + log.AppendLine($"stored allowlist rendered as {rendered.Count} row(s): {string.Join(" | ", rendered)}"); + Assert.True(rendered.Count > 0, "the probe workspace renders no allowlist rows at all - nothing to round-trip."); + Assert.All(rendered, entry => Assert.Contains("://", entry, StringComparison.Ordinal)); + + // A probe host row (or a full-host-wildcard hatch row) left over from an interrupted + // earlier run would double up when the probe adds its own, and the storage de-dupes - + // which reads as a save mismatch. The probe's baseline is the workspace's allowlist + // minus anything it owns. + var original = rendered + .Where(e => !e.Split(' ')[0].Contains(ProbeHost, StringComparison.OrdinalIgnoreCase) + && !e.Split(' ')[0].EndsWith("://*", StringComparison.Ordinal)) + .ToList(); + if (original.Count < rendered.Count) + log.AppendLine($"baseline excludes {rendered.Count - original.Count} leftover probe-host row(s): {string.Join(" | ", original)}"); + Assert.True(original.Count > 0, "once its own leftovers are excluded, the probe workspace renders no allowlist rows at all."); + + var hint = mcMain.FindFirstDescendant(cf => cf.ByAutomationId(AllowlistGridUi.TargetsHintId)); + Assert.NotNull(hint); + Assert.DoesNotContain("accept-untrusted", hint.Name ?? string.Empty, StringComparison.OrdinalIgnoreCase); + log.AppendLine("hint text teaches no accept-untrusted token (the flag is a column now)"); + + var renderedHasHatch = rendered.Any(e => e.Split(' ')[0].EndsWith("://*", StringComparison.Ordinal)); + Assert.Equal(renderedHasHatch, AllowlistGridUi.WarningBannerVisible(mcMain)); + log.AppendLine($"baseline warning banner visible={AllowlistGridUi.WarningBannerVisible(mcMain)} (matches a hatch row being present={renderedHasHatch})"); + + // ---- Phase 1: a refused row is flagged inline and refuses the save BY NAME ------- + // ---- Phase 1: a refused entry is flagged inline and refuses the save BY NAME ------- + // The bad rows deliberately straddle both schemes: the refusal dialog quotes every row + // as the ROW MODEL describes it, so "http://...:99999" in that text is the model + // speaking its own scheme - the one pre-save proof that the scheme dropdown really + // reached the row (a dropdown that only LOOKED selected stores https and gets caught here). + AllowlistGridUi.AddRow(mcMain); + AllowlistGridUi.SetLastRow(mcMain, "https", BadPatternHost, null, false); + AllowlistGridUi.AddRow(mcMain); + AllowlistGridUi.SetLastRow(mcMain, "http", BadPortHost, BadPort, false); + + var rows = AllowlistGridUi.ReadRows(mcMain); + Assert.Equal(rendered.Count + 2, rows.Count); + var midLabelRow = SingleRowWithHost(rows, BadPatternHost); + var badPortRow = SingleRowWithHost(rows, BadPortHost); + Assert.False(string.IsNullOrWhiteSpace(midLabelRow.Problem), + $"the mid-label wildcard row carries no inline Problem text: '{midLabelRow.Entry}'"); + Assert.False(string.IsNullOrWhiteSpace(badPortRow.Problem), + $"the out-of-range port row carries no inline Problem text: '{badPortRow.Entry}'"); + log.AppendLine($"inline problems: '{midLabelRow.Problem}' / '{badPortRow.Problem}'"); + Artifacts.Window(mcMain, "mc-grid-refused-rows"); + + var markersBefore = McLog.CountMatches(RefusalLogMarker, BadPatternHost); + var notice = AllowlistGridUi.SaveAndCaptureNotice(mc, mcMain, RefusalNotice, log); + Assert.True(notice != null, + "saving two invalid rows raised no refusal notice - the save went through, or the rows vanished silently."); + Assert.Contains(BadPatternRow, notice, StringComparison.Ordinal); + Assert.Contains(BadPortRowEntry, notice, StringComparison.Ordinal); + log.AppendLine($"refusal notice (each row as the row model describes it): {notice}"); + + // Never silently dropped: the refused rows are still in the grid, still flagged. + var afterRefusalRows = AllowlistGridUi.ReadRows(mcMain); + Assert.Equal(rendered.Count + 2, afterRefusalRows.Count); + Assert.False(string.IsNullOrWhiteSpace(SingleRowWithHost(afterRefusalRows, BadPatternHost).Problem)); + Assert.False(string.IsNullOrWhiteSpace(SingleRowWithHost(afterRefusalRows, BadPortHost).Problem)); + + var markerDelta = 0; + for (var attempt = 0; attempt < 10 && markerDelta == 0; attempt++) + { + Thread.Sleep(1000); + markerDelta = McLog.CountMatches(RefusalLogMarker, BadPatternHost) - markersBefore; + } + + Assert.True(markerDelta > 0, + $"the Management Client MIP.log gained no '{RefusalLogMarker}' marker for the refused save ({markersBefore} -> {McLog.CountMatches(RefusalLogMarker, BadPatternHost)})."); + log.AppendLine($"MIP.log refusal markers: {markersBefore} -> {McLog.CountMatches(RefusalLogMarker, BadPatternHost)} (the log marker names the refused rows)"); + + // ---- Phase 2: fixing the rows saves; nothing partial reached storage -------------- + var fixedLines = new List(original) { ProbeEntry }; + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, fixedLines, log); + var storedAfterFix = AllowlistGridUi.ReadEntries(mcMain); + Assert.Contains(ProbeEntry, storedAfterFix, StringComparer.Ordinal); + Assert.All(storedAfterFix, entry => + { + Assert.DoesNotContain(BadPatternHost, entry, StringComparison.Ordinal); + Assert.DoesNotContain(BadPortHost, entry, StringComparison.Ordinal); + }); + log.AppendLine("the refused rows never reached storage: the reloaded grid carries only the fixed entry"); + + // ---- Phase 3: scheme/host/port/accept-untrusted round-trip through the STORED record + var flaggedLines = new List(original) { ProbeEntryFlagged }; + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, flaggedLines, log); + var probeRow = SingleRowWithHost(AllowlistGridUi.ReadRows(mcMain), ProbeHost); + Assert.Equal("https", probeRow.Scheme); + Assert.Equal("8443", probeRow.Port.Trim()); + Assert.True(probeRow.Untrusted, + $"the accept-untrusted flag never came back from the stored record (row reads '{probeRow.Entry}')."); + log.AppendLine($"stored row reads back: {probeRow.Entry} (flag survived save + reload)"); + + // ---- Phase 4: the full-host-wildcard banner + the per-row Remove button ----------- + var hatchLines = new List(original) { HatchEntry }; + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, hatchLines, log); + Assert.True(AllowlistGridUi.WarningBannerVisible(mcMain), + $"with '{HatchEntry}' in the grid the full-host-wildcard warning banner never appeared."); + + var hatchIndex = AllowlistGridUi.ReadRows(mcMain).FindIndex(r => r.Host == "*"); + Assert.True(hatchIndex >= 0, $"the '{HatchEntry}' row never rendered into the grid."); + AllowlistGridUi.RemoveRowAt(mcMain, hatchIndex); + Assert.False(AllowlistGridUi.WarningBannerVisible(mcMain), + "the warning banner stayed up after the hatch row's Remove button dropped the row."); + + // ---- Restore: the workspace's own allowlist, verified from a reloaded pane -------- + AllowlistGridUi.SaveAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, original, log); + Assert.False(AllowlistGridUi.WarningBannerVisible(mcMain), "the warning banner is up on a restored allowlist with no hatch row."); + Assert.All(AllowlistGridUi.ReadEntries(mcMain), entry => Assert.DoesNotContain(ProbeHost, entry, StringComparison.Ordinal)); + log.AppendLine("allowlist restored (probe rows gone, verified from a reloaded pane)"); + + Artifacts.Window(mcMain, "mc-grid-restored"); + } + finally + { + Artifacts.WriteText("mc-allowlist-grid-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + if (mc != null) ManagementClientUi.AcceptPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + } + + private static AllowlistGridUi.RowReading SingleRowWithHost(List rows, string host) + { + var hit = rows.FirstOrDefault(r => string.Equals(r.Host.Trim(), host, StringComparison.OrdinalIgnoreCase)); + Assert.True(hit != null, $"no grid row has host '{host}' (rows: {string.Join(" | ", rows.Select(r => r.Entry))})."); + return hit; + } +} diff --git a/tests/ux/WebContent.UxTests/McCaExportProbeTests.cs b/tests/ux/WebContent.UxTests/McCaExportProbeTests.cs new file mode 100644 index 0000000..b7c39c3 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McCaExportProbeTests.cs @@ -0,0 +1,418 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #75 probe (MC-15): the Management Client's "Export proxy certificate" button on the Web +/// Content Workspaces overview asks the Event Server over MIP messaging and writes what it gets as a +/// Save As *.cer (DER). Proves live: the button realizes on the overview page; the server answers +/// (the MC's own MIP.log carries the answered marker with the thumbprint and DER size); the bytes on +/// disk are byte-identical to the deployment CA in the Event Server machine's LocalMachine\Personal +/// store - a CA this build did NOT create, so the export works whatever provisioned it (the ticket's +/// provenance AC); the Management Client logs the answer and the save; and the pane reports the saved +/// path plus thumbprint instead of an empty file. Read-only: no configuration is saved, no certificate +/// store is written, and the only file produced is inside the artifacts folder. +/// +public class McCaExportProbeTests +{ + private const string DefaultFileName = "WebContent-Proxy-Authority.cer"; + + private readonly ITestOutputHelper _output; + + public McCaExportProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeProxyCaExportSavesTheEventServerAuthorityAsDer() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-ca-export-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + + // Server side FIRST: an export that matches a stale view of the store proves nothing, so the + // store copy is read before the button is pressed. + var serverAuthority = EventServerAuthority.ReadDeploymentAuthority(config, log); + Assert.NotNull(serverAuthority); + log.AppendLine($"event server authority before the export: thumbprint {serverAuthority.Thumbprint}, " + + $"{serverAuthority.RawData.Length} DER bytes, subject {serverAuthority.Subject}"); + + ProcessKiller.KillClients("VideoOS.Administration"); + var session = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(session); + LoginFlow.Login(session, mcLogin, config); + LoginFlow.AcceptCertificateTrust(session); + var mcMain = LoginFlow.WaitForAdminMain(session); + ManagementClientUi.CloseStrayLoginDialogs(session); + ManagementClientUi.WaitForTreeReady(mcMain); + + var savedPath = Path.Combine(Artifacts.RunDirectory, "exported-authority.cer"); + try + { + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.SelectTreeItem(mcMain, ManagementClientUi.WorkspacesNodeName); + + // 1. The overview page of the plugin's workspaces node carries the export button. + var button = ManagementClientUi.ExpectControl(mcMain, "proxyCaExportButton", 30); + log.AppendLine($"overview page shows the export button (name '{button.Name}')"); + Artifacts.WindowPrint(mcMain, "mc-ca-export-overview"); + + var answeredBefore = McLog.CountMatches("Proxy authority certificate answered:"); + + // 2. Press it. The page disables the button and shows the "asking" line while the + // messaging exchange runs, then opens the Save As dialog with the default name. + button.Patterns.Invoke.Pattern.Invoke(); + log.AppendLine("invoked proxyCaExportButton"); + + // 3. Either the Save As dialog opens (the server answered) or the pane's failure banner + // does. Both are asserted outcomes; a silent timeout is the failure, and the journal + // below says which one it was. + var dialog = WaitForSaveDialog(session, mcMain, log, 90); + // PrintWindow capture, not CopyFromScreen: this machine's desktop session can be + // disconnected, where the screen-surface capture hangs (the #83 run learned this). + Artifacts.WindowPrint(dialog.AsWindow(), "mc-ca-export-save-dialog"); + log.AppendLine($"save dialog open: name '{dialog.Name}', class '{dialog.ClassName}', pid {dialog.Properties.ProcessId.Value}"); + + // 4. Name the file inside the artifacts folder and commit the dialog. + var fileNameEdit = SetSaveDialogFileName(dialog, savedPath, log); + CommitSaveDialog(dialog, fileNameEdit, savedPath, log); + + var deadline = DateTime.UtcNow.AddSeconds(20); + while (DateTime.UtcNow < deadline && !File.Exists(savedPath)) Thread.Sleep(500); + Assert.True(File.Exists(savedPath), $"the Save As dialog committed but no file appeared at '{savedPath}'."); + + // 5. What landed on disk is the server's authority certificate: same DER, and a real + // X.509 the platform can parse (the whole point of the .cer is that a trust import - + // a GPO among them - can consume it without the plugin in the picture). + var savedDer = File.ReadAllBytes(savedPath); + using var saved = X509CertificateLoader.LoadCertificate(savedDer); + log.AppendLine($"saved file: {savedDer.Length} bytes, thumbprint {saved.Thumbprint}, subject {saved.Subject}, " + + $"valid {saved.NotBefore:u} .. {saved.NotAfter:u}"); + Assert.Equal(serverAuthority.RawData, savedDer); + Assert.Equal(serverAuthority.Thumbprint, saved.Thumbprint); + Assert.Contains(EventServerAuthority.AuthorityCommonName, saved.Subject); + Assert.Equal("30", savedDer[0].ToString("x2")); // DER, not PEM/text + + // 6. The Management Client's own audit trail: an answer line and a save line. The path + // assertion doubles as proof the line belongs to THIS run (the artifacts folder name + // carries the run timestamp), so an older run's identical marker can never pass. + var answered = McLog.WaitForNewLine(answeredBefore, 20, "Proxy authority certificate answered:"); + Assert.NotNull(answered); + log.AppendLine("mc log: " + answered); + var exportedLine = McLog.MatchingLines("Proxy authority certificate exported to") + .LastOrDefault(line => line.Contains(savedPath, StringComparison.Ordinal)); + Assert.NotNull(exportedLine); + log.AppendLine("mc log: " + exportedLine); + Assert.Contains(saved.Thumbprint, exportedLine); + + // 7. The pane tells the admin where it wrote the file (and the trust-distribution + // follow-up) instead of leaving an empty file as the only signal. + var result = ManagementClientUi.ExpectControl(mcMain, "txtProxyCaExportResult", 10); + var resultText = result.Name ?? string.Empty; + log.AppendLine($"pane result text: {resultText}"); + Assert.Contains(savedPath, resultText); + Assert.Contains(saved.Thumbprint, resultText); + Artifacts.WindowPrint(mcMain, "mc-ca-export-result"); + } + finally + { + Artifacts.WriteText("mc-ca-export-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain.Close(); } catch { } + ManagementClientUi.AcceptPendingModal(session, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + } + + /// + /// Waits for the Save As dialog, escalating to a hard failure if the pane's failure banner appears + /// instead - the failure half of the AC is then visible in the probe record with its sentence. The + /// page rides an ElementHost inside a WinForms shell, so while waiting the probe journals what the + /// pane says and which windows the shell process owns: a timeout with no journal is only a guess + /// about where the dialog went. + /// + private static AutomationElement WaitForSaveDialog(ClientSession session, Window mcMain, StringBuilder log, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + var poll = 0; + while (DateTime.UtcNow < deadline) + { + var dialog = FindSaveDialog(session, log, record: poll % 6 == 0); + if (dialog != null) return dialog; + + var resultText = ReadVisiblePaneText(mcMain, "txtProxyCaExportResult"); + var bannerText = ReadVisiblePaneText(mcMain, "txtProxyCaExportError"); + var button = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaExportButton")); + log.AppendLine($"poll {poll}: button={(button == null ? "gone" : (button.IsEnabled ? "enabled" : "disabled"))} " + + $"result='{resultText ?? "(none)"}' error='{bannerText ?? "(none)"}'"); + + if (bannerText != null) + { + foreach (var line in McLog.MatchingLines("Proxy authority certificate")) + log.AppendLine("mc log: " + line); + Artifacts.WindowPrint(mcMain, "mc-ca-export-failure"); + throw new InvalidOperationException($"the export pane refused instead of answering: {bannerText}"); + } + + if (poll > 4 && resultText != null && resultText.Contains("cancelled", StringComparison.OrdinalIgnoreCase)) + { + DumpProcessWindows(session, mcMain, log, "cancelled"); + throw new InvalidOperationException($"the Save As dialog closed itself: '{resultText}'"); + } + + poll++; + Thread.Sleep(1000); + } + + FindSaveDialog(session, log, record: true); + DumpProcessWindows(session, mcMain, log, "final"); + foreach (var line in McLog.MatchingLines("Proxy authority certificate")) + log.AppendLine("mc log: " + line); + Artifacts.WindowPrint(mcMain, "mc-ca-export-no-dialog"); + throw new TimeoutException($"no Save As dialog and no failure banner within {timeoutSeconds}s."); + } + + /// + /// The Save As dialog. A Win32 common dialog (#32770) owned by a page inside a WinForms + /// ElementHost can appear as a top-level window, nested under the shell window, or as a desktop + /// child, so all three places are scanned; the dialog's own title is the tell and its class the + /// fallback. When recording, every desktop window UIA sees is journalled - a timeout answer + /// "the shell never opened it" versus "it is there under another parent" only comes from that. + /// + private static AutomationElement FindSaveDialog(ClientSession session, StringBuilder log, bool record) + { + var candidates = new List(); + try + { + candidates.AddRange(session.Automation.GetDesktop().FindAllChildren(cf => cf.ByControlType(ControlType.Window))); + } + catch (Exception exception) + { + log.AppendLine($"desktop window scan failed: {exception.GetType().Name}: {exception.Message}"); + } + + candidates.AddRange(session.Windows()); + foreach (var window in session.Windows()) + { + try { candidates.AddRange(window.FindAllDescendants(cf => cf.ByControlType(ControlType.Window))); } catch { } + } + + AutomationElement titledMatch = null; + AutomationElement anyDialog = null; + foreach (var candidate in candidates) + { + string name = null; + string className = null; + var processId = -1; + var offscreen = false; + try { name = candidate.Name; } catch { } + try { className = candidate.ClassName; } catch { } + try { processId = candidate.Properties.ProcessId.Value; } catch { } + try { offscreen = candidate.Properties.IsOffscreen.Value; } catch { } + + if (record) + { + string rect; + try { rect = candidate.BoundingRectangle.ToString(); } catch { rect = "(unreadable)"; } + log.AppendLine($" window: name='{name}' class='{className}' pid={processId} offscreen={offscreen} rect={rect}"); + } + + var titled = (name ?? string.Empty).IndexOf("proxy authority certificate", StringComparison.OrdinalIgnoreCase) >= 0; + var win32Dialog = (className ?? string.Empty).Contains("#32770") && processId == session.Process.Id; + if (!titled && !win32Dialog) continue; + + if (titled) titledMatch ??= candidate; + anyDialog ??= candidate; + } + + return titledMatch ?? anyDialog; + } + + /// Every window UIA attributes to the client process, plus the shell's own descendant + /// windows (an owned common dialog can ride the main window's UIA subtree, not the desktop's). + private static void DumpProcessWindows(ClientSession session, Window mcMain, StringBuilder log, string stage) + { + log.AppendLine($"--- {stage}: top-level windows of pid {session.Process.Id} ---"); + foreach (var window in session.Windows()) + { + try + { + log.AppendLine($" window name='{window.Name}' aid='{window.AutomationId}' class='{window.ClassName}' " + + $"rect={window.BoundingRectangle} visible={(!window.Properties.IsOffscreen.Value)} enabled={window.IsEnabled}"); + } + catch (Exception exception) + { + log.AppendLine($" window unreadable: {exception.GetType().Name}: {exception.Message}"); + } + } + + log.AppendLine($"--- {stage}: descendant windows under the main window ---"); + try + { + foreach (var child in mcMain.FindAllDescendants(cf => cf.ByControlType(ControlType.Window))) + log.AppendLine($" child window name='{child.Name}' aid='{child.AutomationId}' class='{child.ClassName}' rect={child.BoundingRectangle}"); + } + catch (Exception exception) + { + log.AppendLine($" descendant scan failed: {exception.GetType().Name}: {exception.Message}"); + } + } + + /// + /// Types the destination path into the common dialog's File name edit and returns that edit. The + /// Win32 dialog pre-fills the default name, so the default value is the most reliable tell. + /// + /// The path must arrive through KEYSTROKES, not ValuePattern.SetValue: live runs proved that a + /// SetValue-committed edit is honoured only for the file NAME while the dialog saves into its own + /// current folder (the file landed in Documents\Custom Office Templates instead of the requested + /// path). Keyboard input drives the dialog's own path parsing, the same route a human uses. + /// + private static AutomationElement SetSaveDialogFileName(AutomationElement dialog, string path, StringBuilder log) + { + var edits = dialog.FindAllDescendants(cf => cf.ByControlType(ControlType.Edit)).ToList(); + var interesting = edits.Where(e => + { + try + { + var value = ReadEditText(e); + return string.Equals(value, DefaultFileName, StringComparison.OrdinalIgnoreCase) + || string.Equals(e.AutomationId, "1001", StringComparison.OrdinalIgnoreCase) + || (e.Name ?? string.Empty).IndexOf("file name", StringComparison.OrdinalIgnoreCase) >= 0; + } + catch { return false; } + }).ToList(); + foreach (var edit in interesting) + { + try { log.AppendLine($"dialog file-name edit: aid='{edit.AutomationId}' name='{edit.Name}' value='{ReadEditText(edit)}' rect={edit.BoundingRectangle}"); } + catch (Exception exception) { log.AppendLine($"dialog file-name edit unreadable: {exception.GetType().Name}: {exception.Message}"); } + } + + // The Win32 dialog's OWN File name edit is aid 1001 - that is the edit whose typed text the + // dialog's commit parser reads. The shell file-list item field (aid 'System.ItemNameDisplay') + // also echoes the default name, but its UIA provider can die mid-read (0x80040201 "an event + // was unable to invoke any of the subscribers", proved live 2026-11-03) and typing into it + // is not what the dialog parses - so prefer 1001 outright and keep the old tells as fallbacks. + var fileNameEdit = interesting.FirstOrDefault(e => string.Equals(e.AutomationId, "1001", StringComparison.OrdinalIgnoreCase)) + ?? interesting.FirstOrDefault(e => string.Equals(ReadEditText(e), DefaultFileName, StringComparison.OrdinalIgnoreCase)) + ?? interesting.FirstOrDefault(); + Assert.NotNull(fileNameEdit); + + fileNameEdit.Click(); + Thread.Sleep(300); + try { fileNameEdit.Focus(); } catch { } + FlaUI.Core.Input.Keyboard.TypeSimultaneously(FlaUI.Core.WindowsAPI.VirtualKeyShort.CONTROL, + FlaUI.Core.WindowsAPI.VirtualKeyShort.KEY_A); + FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.BACK); + FlaUI.Core.Input.Keyboard.Release(FlaUI.Core.WindowsAPI.VirtualKeyShort.BACK); + FlaUI.Core.Input.Keyboard.Type(path); + Thread.Sleep(500); + + var readback = ReadEditText(fileNameEdit); + log.AppendLine($"file name edit now reads: '{readback}'"); + Assert.Equal(path, readback); + return fileNameEdit; + } + + /// + /// Commits the dialog and proves it by the file appearing. ENTER in the File name edit comes + /// first: it runs through the dialog's own commit handler, so the typed path is parsed as typed. + /// The UIA routes (IDOK, the Save button) are kept as fallbacks and are each judged only by the + /// file appearing where it was asked for. + /// + private static void CommitSaveDialog(AutomationElement dialog, AutomationElement fileNameEdit, string path, StringBuilder log) + { + foreach (var button in dialog.FindAllDescendants(cf => cf.ByControlType(ControlType.Button))) + { + string name; + string automationId; + try { name = button.Name; automationId = button.AutomationId; } catch { continue; } + if ((name ?? string.Empty).IndexOf("save", StringComparison.OrdinalIgnoreCase) >= 0 + || string.Equals(automationId, "1", StringComparison.OrdinalIgnoreCase)) + { + log.AppendLine($"dialog commit candidate: name='{name}' aid='{automationId}'"); + } + } + + var routes = new Action[] + { + () => + { + fileNameEdit.Click(); + Thread.Sleep(250); + FlaUI.Core.Input.Keyboard.Press(FlaUI.Core.WindowsAPI.VirtualKeyShort.RETURN); + FlaUI.Core.Input.Keyboard.Release(FlaUI.Core.WindowsAPI.VirtualKeyShort.RETURN); + log.AppendLine("committed with ENTER in the File name edit"); + }, + () => + { + var ok = dialog.FindFirstDescendant(cf => cf.ByAutomationId("1")); + if (ok == null) throw new InvalidOperationException("no IDOK control in the dialog"); + try { ok.Patterns.Invoke.Pattern.Invoke(); } catch { ok.Click(); } + log.AppendLine("committed through the dialog's IDOK control"); + }, + () => + { + var named = dialog.FindAllDescendants(cf => cf.ByName("Save")) + .Concat(dialog.FindAllDescendants(cf => cf.ByName("&Save"))) + .FirstOrDefault(); + if (named == null) throw new InvalidOperationException("no button named Save"); + try { named.Patterns.Invoke.Pattern.Invoke(); } catch { named.Click(); } + log.AppendLine("committed through the Save button"); + }, + }; + + foreach (var route in routes) + { + try { route(); } + catch (Exception exception) + { + log.AppendLine($"commit route failed: {exception.GetType().Name}: {exception.Message}"); + continue; + } + + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline) + { + if (File.Exists(path)) return; + Thread.Sleep(500); + } + + log.AppendLine("the file did not appear after that commit route"); + } + } + + private static string ReadEditText(AutomationElement edit) + { + try { return edit.Patterns.Value.IsSupported ? edit.Patterns.Value.Pattern.Value.Value : edit.Name; } + catch { try { return edit.Name; } catch { return null; } } + } + + /// + /// Pane text that is actually on screen. The failure banner and the result line are Collapsed + /// when unused, and a collapsed WPF element still answers UIA with an empty Name - so offscreen + /// is the signal that distinguishes "not shown" from "shown and empty". + /// + private static string ReadVisiblePaneText(Window mcMain, string automationId) + { + try + { + var element = mcMain.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + if (element == null) return null; + if (element.Properties.IsOffscreen.Value) return null; + return string.IsNullOrWhiteSpace(element.Name) ? "(shown, no text)" : element.Name; + } + catch { return null; } + } +} diff --git a/tests/ux/WebContent.UxTests/McCaUntrustedAggregateProbeTests.cs b/tests/ux/WebContent.UxTests/McCaUntrustedAggregateProbeTests.cs new file mode 100644 index 0000000..a5c2443 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McCaUntrustedAggregateProbeTests.cs @@ -0,0 +1,709 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests +{ + /// + /// LIVE (#80) probes for the untrusted-CA machine aggregate on the Management Client's + /// Diagnostics tab: clients that hit "the loopback proxy's certificate is not trusted on + /// THIS machine" report it once per trust flip to the Event Server, which keeps a per- + /// workspace table of MACHINES (ten views on one workstation count once), and the Diagnostics + /// tab surfaces it as a banner naming the machines with the #75 deployment-CA export button + /// inline - the operator exports once and distributes to the fleet. + /// + /// Row 1 (runs in every suite run, non-mutating): with the lab's healthy trust state the + /// banner is NOT shown - no aggregate text, the export button off-screen - while the rest of + /// the Diagnostics tab renders. A CA-banner false positive on a healthy machine is exactly + /// what an admin would drown in, so this half runs unguarded. + /// + /// Row 2 (guarded: WEBCONTENT_CA_UNTRUST_LIVE=1, same gate as the #72 SC-10 live row): the + /// FULL cross-machine chain on the real lab. The deployment CA is pulled from BOTH root + /// stores of this workstation, a Smart Client proxied view loads (the listener's posture + /// flips and the ONE CertUntrusted report files), and the Management Client's Diagnostics + /// banner must then name THIS machine - then the inline export writes the deployment CA file + /// an operator would distribute. The CA stores are restored afterwards; the banner itself + /// stays until an Event Server restart by design (the machine table is in-memory aggregate + /// state, and re-flips dedup to one row per machine anyway). The CurrentUser\Root half is + /// store-protected and Access-Denies for an unattended shell (verified live, same lesson as + /// SC-10): the row then restores whatever it touched, records SKIPPED with the exact reason, + /// and passes without asserting - the elevated human-step procedure lives in + /// docs/verification-matrix.md. Nothing is left mutated on the skip route. + /// + public class McCaUntrustedAggregateProbeTests + { + private const string McCaBannerAutomationId = "caUntrustedBanner"; + private const string McCaBannerTextAutomationId = "caUntrustedBannerText"; + + // The #75 Save-As dialog's default file name (ProxyCaExportViewBuilder pins it). + private const string ExportDialogFile = "Milestone XProtect WebContent Proxy CA.cer"; + + // The Diagnostics banner's export button AutomationId (pinned by the #80 guard tests). + private const string McCaExportAutomationId = "proxyCaExportFromDiagnostics"; + + private const string EsMarkerCaReport = "Proxy CA trust report recorded"; + private const string DeploymentCaSubject = "Milestone XProtect WebContent Proxy CA"; + + private static bool UntrustedRowEnabled => + Environment.GetEnvironmentVariable("WEBCONTENT_CA_UNTRUST_LIVE") == "1"; + + private readonly ITestOutputHelper _output; + + public McCaUntrustedAggregateProbeTests(ITestOutputHelper output) => _output = output; + + // ===================================================================================== + // Row 1: trusted lab state -> the Diagnostics tab shows NO CA banner. + // ===================================================================================== + + [Fact] + [Trait("Category", "probe")] + public void ProbeDiagnosticsTabShowsNoCaBannerWhileTheCaIsTrusted() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-ca-aggregate-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + Window mcMain = null; + ClientSession mc = null; + try + { + mc = ClientSession.Launch(config.AdminClientExe); + var login = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, login, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + SmartClientUi.Maximize(mcMain); + Thread.Sleep(2500); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + Thread.Sleep(1500); + SelectTabByAutomationId(mcMain, "tabDiagnostics"); + ManagementClientUi.ExpectControl(mcMain, "dgDiagnosticsBlocked", 15); + + // The banner peers EXIST (WPF collapsed elements still answer UIA in the MC pane - + // the MC-16 quirk), so 'not shown' = the banner reports off-screen and no visible + // pane text carries the banner's wording. ReadVisiblePaneText discipline. + Assert.True(WaitUntil(() => !CaBannerVisible(mcMain), 10), + "the untrusted-CA banner is VISIBLE while the deployment CA is trusted - a healthy lab must never show the aggregate warning"); + log.AppendLine("banner not shown while the CA is trusted (off-screen banner + no visible aggregate text)"); + + var export = Find(mcMain, McCaExportAutomationId); + Assert.NotNull(export); + Assert.True(SafeOffscreen(export), + "the Diagnostics CA export button is on-screen while no machine has reported - it belongs inside the collapsed banner"); + log.AppendLine("the inline export button is parked inside the collapsed banner while healthy"); + + // The tab itself still works (the banner must not displace the blocked ring). + ManagementClientUi.ExpectControl(mcMain, "txtDiagnosticsSummary", 5); + log.AppendLine("Diagnostics tab renders normally with the banner collapsed"); + Artifacts.Window(mcMain, "mc-ca-healthy"); + } + finally + { + Artifacts.WriteText("mc-ca-healthy-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + if (mc != null) ManagementClientUi.RejectPendingModal(mc, 5); + mc?.Dispose(); + } + } + + // ===================================================================================== + // Row 2 (guarded): full untrusted-report -> machine aggregate -> inline export chain. + // ===================================================================================== + + [Fact] + [Trait("Category", "probe")] + public void ProbeUntrustedCaReportAggregatesIntoTheDiagnosticsBanner() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-ca-aggregate-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + if (!UntrustedRowEnabled) + { + _output.WriteLine("SKIPPED (opt-in row): set WEBCONTENT_CA_UNTRUST_LIVE=1 to mutate this machine's root stores for the #80 aggregate live row."); + return; + } + + var log = new StringBuilder(); + ClientSession sc = null; + Window mcMain = null; + ClientSession mc = null; + byte[] authorityDer = null; + string authorityThumbprint = null; + var userRemoved = false; + var lmRemoved = false; + string skippedReason = null; + var failures = new List(); + try + { + // ---- 0. Locate the deployment CA (the plugin's minting authority, #56). ---- + using (var cu = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + cu.Open(OpenFlags.ReadWrite); + var found = cu.Certificates + .Find(X509FindType.FindBySubjectName, DeploymentCaSubject, false) + .OfType() + .FirstOrDefault(); + if (found == null) + { + skippedReason = $"no '{DeploymentCaSubject}' certificate in CurrentUser\\Root - this machine is not in the CA'd lab posture"; + return; + } + + authorityDer = found.RawData; + authorityThumbprint = found.Thumbprint; + log.AppendLine($"deployment CA {authorityThumbprint} found in CurrentUser\\Root"); + } + + // The elevated restore re-adds by thumbprint from a DER copy kept in the + // artifacts folder (the trust decision only needs the public certificate). + var derPath = Artifacts.PathOf("deployment-authority.der"); + File.WriteAllBytes(derPath, authorityDer); + + // ---- 1. Untrusted state: pull the CA from BOTH root stores. ---- + try + { + using (var cu = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + cu.Open(OpenFlags.ReadWrite); + foreach (var c in cu.Certificates.Find(X509FindType.FindByThumbprint, authorityThumbprint, false).OfType()) + { + cu.Remove(c); + } + } + + userRemoved = true; + log.AppendLine("deployment CA removed from CurrentUser\\Root"); + } + catch (Exception ex) + { + // Same live-verified lesson as SC-10: the CA entry is store-protected, an + // unattended shell Access-Denies. Stores untouched -> honest skip. + skippedReason = $"CurrentUser\\Root removal refused unattended ({ex.Message}) - run this row from an elevated joshooaj shell for the #80 live aggregate"; + return; + } + + var lm = (ExitCode: -1, Output: string.Empty); + try + { + using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadWrite); + foreach (var c in store.Certificates.Find(X509FindType.FindByThumbprint, authorityThumbprint, false).OfType()) + { + store.Remove(c); + } + + lm = (0, "direct elevated removal"); + } + catch (Exception direct) + { + log.AppendLine($"direct LocalMachine\\Root removal unavailable ({direct.Message}); trying the elevated localhost remote channel..."); + lm = RunElevatedLocalhostStoreOperation(config, authorityDer, authorityThumbprint, remove: true, log); + } + + if (lm.ExitCode != 0) + { + RestoreUserStoreAuthority(authorityDer, log); + userRemoved = false; + skippedReason = $"LocalMachine\\Root removal needs elevation and the unattended channel failed (exit {lm.ExitCode}): {Flatten(lm.Output)}"; + return; + } + + lmRemoved = true; + log.AppendLine("deployment CA removed from LocalMachine\\Root - the machine is now in the untrusted posture"); + + // ---- 2. The client: a proxied view loading while untrusted must file exactly + // ONE CertUntrusted report (the flip report). ---- + var esBefore = EsLogCount(config, EsMarkerCaReport, config.McWorkspaceName); + sc = ClientSession.Launch(config.SmartClientExe); + var scLogin = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, scLogin, config); + LoginFlow.AcceptCertificateTrust(sc); + var scMain = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(scMain); + Thread.Sleep(1500); + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + AutomationElement item = null; + try { item = SmartClientUi.WaitForMipViewItem(scMain, 20); } catch (TimeoutException) { } + if (item == null) + { + if (SmartClientUi.TrySelectWorkspaceWithoutInput(scMain, sc, config.WorkspaceName)) + { + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + } + + item = SmartClientUi.WaitForMipViewItem(scMain); + } + + Assert.NotNull(item); + log.AppendLine("proxied workspace view is up while the CA is NOT trusted (flip report due)"); + + var reportLine = WaitForEsLine(config, EsMarkerCaReport, esBefore, 90, config.McWorkspaceName); + Assert.NotNull(reportLine); + log.AppendLine($"ES marker: {Flatten(reportLine)}"); + Assert.Contains(Environment.MachineName, reportLine, StringComparison.OrdinalIgnoreCase); + log.AppendLine($"the ES machine table names this workstation: {Environment.MachineName}"); + + // ---- 3. The Management Client: the Diagnostics banner names the machine. ---- + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + SmartClientUi.Maximize(mcMain); + Thread.Sleep(2500); + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + Thread.Sleep(1500); + SelectTabByAutomationId(mcMain, "tabDiagnostics"); + ManagementClientUi.ExpectControl(mcMain, "dgDiagnosticsBlocked", 15); + + var bannerText = WaitValue(() => CaBannerVisible(mcMain) ? PaneBannerText(mcMain) : null, 30); + Assert.NotNull(bannerText); + log.AppendLine($"banner text: {bannerText}"); + Assert.Contains("client machine", bannerText, StringComparison.OrdinalIgnoreCase); + Assert.Contains(Environment.MachineName, bannerText, StringComparison.OrdinalIgnoreCase); + log.AppendLine("the banner names the aggregate as machine reports and names THIS machine"); + Artifacts.Window(mcMain, "mc-ca-banner"); + + // ---- 4. The inline export: one click writes the distributable CA file. ---- + var export = IfVisible(Find(mcMain, McCaExportAutomationId)); + Assert.NotNull(export); + var tempDir = Path.Combine(Path.GetTempPath(), "webcontent-ca-export"); + Directory.CreateDirectory(tempDir); + var certPath = Path.Combine(tempDir, ExportDialogFile); + File.Delete(certPath); + + InvokeButton(export); + var saved = AcceptSaveAsDialog(mc, certPath, 20); + Assert.True(saved, "the Diagnostics banner's export button never produced the #75 Save-As dialog"); + Assert.True(WaitUntil(() => File.Exists(certPath), 10), $"the export never wrote '{certPath}'"); + + var exported = new X509Certificate2(File.ReadAllBytes(certPath)); + Assert.Equal(authorityThumbprint, exported.Thumbprint); + log.AppendLine($"the banner's export wrote the deployment CA ({exported.Thumbprint}) - the distributable file"); + try { File.Delete(certPath); } catch { } + Artifacts.Window(mcMain, "mc-ca-export"); + + // Dedup honesty: the banner listed THIS machine exactly once even though the + // Smart Client's view (and any older flip) share this workstation. + Assert.Equal(1, CountOccurrences(bannerText, Environment.MachineName)); + log.AppendLine("one machine row for this workstation regardless of session count"); + } + catch (Exception ex) + { + failures.Add($"{ex.GetType().Name}: {ex.Message}"); + log.AppendLine($"probe failed: {ex}"); + } + finally + { + try { mcMain?.Close(); } catch { } + if (mc != null) + { + ManagementClientUi.RejectPendingModal(mc, 5); + mc.Dispose(); + } + + try { sc?.Dispose(); } catch { } + ProcessKiller.KillClients("Client", "VideoOS.Administration"); + + // ---- Restore the stores (CA back in both) even on a mid-row failure. ---- + if (userRemoved) + { + RestoreUserStoreAuthority(authorityDer, log); + } + + if (lmRemoved) + { + var restore = RunElevatedLocalhostStoreOperation(config, authorityDer, authorityThumbprint, remove: false, log); + if (restore.ExitCode != 0) + { + log.AppendLine($"!!! LocalMachine\\Root restore via the elevated channel FAILED (exit {restore.ExitCode}): {Flatten(restore.Output)} - import the deployment CA manually !!!"); + } + else + { + log.AppendLine("deployment CA restored to LocalMachine\\Root"); + } + } + + Artifacts.WriteText("mc-ca-aggregate-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + + if (skippedReason != null) + { + _output.WriteLine($"SKIPPED: {skippedReason}"); + } + else + { + Assert.True(failures.Count == 0, + $"#80 aggregate chain failures:{Environment.NewLine}{string.Join(Environment.NewLine, failures)}{Environment.NewLine}{log}"); + } + } + } + + // ===================================================================================== + // Helpers + // ===================================================================================== + + private static bool CaBannerVisible(Window mcMain) + { + var banner = Find(mcMain, McCaBannerAutomationId); + if (banner == null) return false; + try { if (banner.IsOffscreen) return false; } catch { } + var rect = SafeRect(banner); + return !rect.IsEmpty && rect.Width > 0 && rect.Height > 0; + } + + private static string PaneBannerText(Window mcMain) + { + var banner = Find(mcMain, McCaBannerAutomationId); + if (banner == null) return null; + var texts = banner.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(t => t.Name) + .Where(n => !string.IsNullOrWhiteSpace(n)); + var joined = string.Join(" ", texts); + if (!string.IsNullOrWhiteSpace(joined)) return joined; + + // A collapsed WPF TextBlock answers UIA with an EMPTY Name even though its peer + // exists (live MC-pane lesson); when the pane is expanded the summary TextBlock's + // own Name carries the banner sentence - take it too. + var summary = Find(mcMain, McCaBannerTextAutomationId); + var name = summary?.Name; + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + private static int CountOccurrences(string haystack, string needle) + { + var count = 0; + var at = 0; + while ((at = haystack.IndexOf(needle, at, StringComparison.OrdinalIgnoreCase)) >= 0) + { + count++; + at += needle.Length; + } + + return count; + } + + private static void InvokeButton(AutomationElement button) + { + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + } + + private static void RestoreUserStoreAuthority(byte[] der, StringBuilder log) + { + try + { + using var cu = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + cu.Open(OpenFlags.ReadWrite); + cu.Add(new X509Certificate2(der)); + log.AppendLine("deployment CA restored to CurrentUser\\Root"); + } + catch (Exception ex) + { + log.AppendLine($"!!! CurrentUser\\Root restore FAILED: {ex.Message} !!!"); + } + } + + /// The SC-10 elevated-localhost convention (live-verified): WinRM sessions for + /// the configured VMS admin are already elevated, so LocalMachine\Root surgery runs + /// unattended with no UAC and no crypt32 prompt. No channel can substitute THIS user's + /// CurrentUser\Root - that half stays a human step. + private static (int ExitCode, string Output) RunElevatedLocalhostStoreOperation( + UxConfig config, byte[] der, string thumbprint, bool remove, StringBuilder log) + { + var derBase64 = Convert.ToBase64String(der); + var flagLiteral = remove ? "$true" : "$false"; + var script = $@" +$ErrorActionPreference = 'Stop' +$password = ConvertTo-SecureString $env:WEBCONTENT_ES_PASSWORD -AsPlainText -Force +$credential = New-Object System.Management.Automation.PSCredential($env:WEBCONTENT_ES_USER, $password) +$session = New-PSSession -ComputerName localhost -Credential $credential +try {{ + Invoke-Command -Session $session -ArgumentList ([Convert]::FromBase64String($env:WEBCONTENT_CA_DER)), '{thumbprint}', {flagLiteral} -ScriptBlock {{ + param($certificateBytes, $expectedThumbprint, $removeIt) + $ErrorActionPreference = 'Stop' + $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certificateBytes) + if ($certificate.Thumbprint -ne $expectedThumbprint) {{ throw ('DER/thumbprint drift: ' + $certificate.Thumbprint) }} + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'LocalMachine') + $store.Open('ReadWrite') + try {{ + if ($removeIt) {{ + $found = $store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false) + foreach ($c in $found) {{ $store.Remove($c) }} + Write-Output ('removed ' + $found.Count + ' copy/copies from LocalMachine\Root (elevated)') + if (($store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false)).Count -ne 0) {{ + throw 'post-condition failed: still present after removal' + }} + }} else {{ + if (-not ($store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false)).Count) {{ + $store.Add($certificate) + Write-Output 're-imported into LocalMachine\Root (elevated)' + }} else {{ Write-Output 'already present' }} + if (($store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false)).Count -eq 0) {{ + throw 'post-condition failed: absent after add' + }} + }} + }} finally {{ $store.Close() }} + }} +}} finally {{ + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +}} +"; + var startInfo = new ProcessStartInfo + { + FileName = "powershell", + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.Environment["WEBCONTENT_ES_USER"] = config.VmsUsername; + startInfo.Environment["WEBCONTENT_ES_PASSWORD"] = config.VmsPassword; + startInfo.Environment["WEBCONTENT_CA_DER"] = derBase64; + + try + { + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + if (!process.WaitForExit(120000)) + { + try { process.Kill(); } catch { } + return (-1, "timeout"); + } + + return (process.ExitCode, stdout + " " + stderr); + } + catch (Exception ex) + { + return (-1, ex.Message); + } + } + + private static int EsLogCount(UxConfig config, params string[] fragments) => + EsLogLines(config, fragments).Count; + + private static string WaitForEsLine(UxConfig config, string marker, int before, int timeoutSeconds, params string[] andAlso) + { + var fragments = new List { marker }; + fragments.AddRange(andAlso); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var lines = EsLogLines(config, fragments.ToArray()); + if (lines.Count > before) return lines[lines.Count - 1]; + Thread.Sleep(1500); + } + + return null; + } + + /// The Event Server's MIP.log read over the WinRM admin channel (the machine + /// table lives only in ES memory; its log is the observable channel). + private static List EsLogLines(UxConfig config, string[] fragments) + { + var where = string.Join(" -and ", fragments.Select(f => $"$_ -like '*{EscapeLike(f)}*'")); + var script = @" +$ErrorActionPreference = 'Stop' +$password = ConvertTo-SecureString $env:WEBCONTENT_ES_PASSWORD -AsPlainText -Force +$credential = New-Object System.Management.Automation.PSCredential($env:WEBCONTENT_ES_USER, $password) +$session = New-PSSession -ComputerName localhost -Credential $credential +try { + Invoke-Command -Session $session -ScriptBlock { + $lines = Get-Content ""$env:ProgramData\Milestone\XProtect Event Server\logs\MIP.log"" -ErrorAction SilentlyContinue + $lines | Where-Object { " + where + @" } + } +} finally { + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +} +"; + var startInfo = new ProcessStartInfo + { + FileName = "powershell", + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.Environment["WEBCONTENT_ES_USER"] = config.VmsUsername; + startInfo.Environment["WEBCONTENT_ES_PASSWORD"] = config.VmsPassword; + + try + { + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(60000); + return stdout + .Replace("\r", string.Empty) + .Split('\n') + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToList(); + } + catch + { + return new List(); + } + } + + private static string EscapeLike(string fragment) => + fragment.Replace("'", "''").Replace("[", "[[]").Replace("*", "[*]").Replace("?", "[?]"); + + private static bool AcceptSaveAsDialog(ClientSession session, string targetPath, int timeoutSeconds) + { + // The #75 export's SaveFileDialog: a Win32 dialog of the MC process. Type the full + // path into the File name edit and press Save. + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + foreach (var window in session.Windows()) + { + try + { + var title = window.Title ?? string.Empty; + if (!title.Contains("Save", StringComparison.OrdinalIgnoreCase) && + !title.Contains("export", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var edit = window.FindFirstDescendant(cf => cf.ByControlType(ControlType.Edit)); + var save = window.FindFirstDescendant(cf => cf.ByName("Save")); + if (edit == null || save == null) continue; + edit.Patterns.Value.Pattern.SetValue(targetPath); + InvokeButton(save); + return true; + } + catch { } + } + + Thread.Sleep(500); + } + + return false; + } + + private static void SelectTabByAutomationId(Window window, string tabAutomationId) + { + var tab = Find(window, tabAutomationId); + if (tab == null) throw new InvalidOperationException($"tab '{tabAutomationId}' not found"); + InvokeButton(tab); + } + + private static AutomationElement Find(Window window, string automationId) => + window.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + + private static AutomationElement IfVisible(AutomationElement element) + { + if (element == null) return null; + var rect = SafeRect(element); + if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) return null; + try { if (element.IsOffscreen) return null; } catch { } + return element; + } + + private static System.Drawing.Rectangle SafeRect(AutomationElement element) + { + try + { + var r = element.BoundingRectangle; + return r == null ? System.Drawing.Rectangle.Empty : new System.Drawing.Rectangle(r.X, r.Y, r.Width, r.Height); + } + catch { return System.Drawing.Rectangle.Empty; } + } + + private static bool SafeOffscreen(AutomationElement element) + { + try { return element.IsOffscreen; } + catch + { + var rect = SafeRect(element); + return rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0; + } + } + + private static string Flatten(string text) => + (text ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim(); + + private static T WaitValue(Func read, int timeoutSeconds) where T : class + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var value = read(); + if (value != null) return value; + Thread.Sleep(1000); + } + + return null; + } + + private static bool WaitUntil(Func condition, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + try { if (condition()) return true; } catch { } + Thread.Sleep(1000); + } + + return false; + } + + private static (int ExitCode, string Output) Run(string file, string args, int timeoutSeconds) + { + try + { + var psi = new ProcessStartInfo(file, args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var p = Process.Start(psi); + var output = p.StandardOutput.ReadToEnd(); + var error = p.StandardError.ReadToEnd(); + if (!p.WaitForExit(timeoutSeconds * 1000)) + { + try { p.Kill(); } catch { } + return (-1, $"timeout: {output} {error}"); + } + + return (p.ExitCode, output + " " + error); + } + catch (Exception ex) + { + return (-1, ex.Message); + } + } + } +} diff --git a/tests/ux/WebContent.UxTests/McDiagnosticsTabProbeTests.cs b/tests/ux/WebContent.UxTests/McDiagnosticsTabProbeTests.cs new file mode 100644 index 0000000..bbd0027 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McDiagnosticsTabProbeTests.cs @@ -0,0 +1,650 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; + +using Microsoft.Playwright; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #84 probe: the Management Client's Diagnostics tab end to end against the live Event +/// Server. A Smart Client browser view loads (its origin is allowlisted) while its subresources +/// point at an origin the workspace does NOT allowlist - the Smart Client's loopback listener +/// refuses them and forwards every refusal into the Event Server's in-memory blocked ring +/// (#69/#83 plumbing). The contract pinned here: +/// 1. the Diagnostics tab renders the blocked origin with its "not allowed" reason, and the +/// tab drives the SAME status-query pair as the Status tab ONLY while it is open (the MC +/// MIP.log marker cadence is the polling trace, #76's discipline); +/// 2. the row's one-click "Add to allowlist" lands the exact origin as a row in the Proxy +/// tab's structured grid (visible there UNSAVED), annotates the Diagnostics row +/// "on allowlist", hides its own button, and arms the page's normal Save; +/// 3. the promotion saves through the ordinary allowlist save discipline - the audit line +/// names the promoted origin and a RELOADED pane re-renders it (the "equivalent to the +/// allowlist editor's save, survives a workspace re-read" acceptance criterion); +/// 4. after the reload the Diagnostics row re-annotates itself "on allowlist" from the SAVED +/// grid - and a CertUntrusted-style refusal never offers promotion by construction (the +/// builder hides the button; pinned in the unit layer, not reproducible against this ES). +/// Ordering is load-bearing: the allowlist cleanup (strip any earlier run's probe line) happens +/// BEFORE the Smart Client page load - live on the second MC-16 run, a leftover probe line made +/// the SC phase FORWARD the subresources (Event Server fetched them and timed out) instead of the +/// listener refusing them, and the ring row arrived as "target timed out", not "not allowed". +/// The probe mutates the workspace allowlist briefly and restores it verbatim (the #73 probe's +/// WithoutProbeLines discipline), so it leaves no residue; teardown answers the shell's +/// "Save changes?" prompt with NO (RejectPendingModal) - live on the first MC-16 run, an +/// AcceptPendingModal at close silently persisted the mid-run promotion into the stored item. +/// Run alone. +/// +public class McDiagnosticsTabProbeTests +{ + /// Same page pair the #83 probe drives: the page origin has been carried by the + /// workspace allowlist since the #56/#57 probes; the subresource origin never is. + private static string BindHost => Environment.GetEnvironmentVariable("UX_BLOCKED_HOST") ?? "172.16.128.210"; + private static int PagePort => ReadPort("UX_BLOCKED_PAGE_PORT", 18125); + private static int BlockedOriginPort => ReadPort("UX_BLOCKED_ASSET_PORT", 18131); + + /// Marker prefixes logged by WorkspaceConfigPage.RefreshStatus - the same pair the + /// Status tab probe counts (#76), because both tabs ride one poll loop (#84). + private const string SnapshotMarker = "Proxy status snapshot for workspace"; + private const string QueryFailMarker = "Proxy status query for workspace"; + + private const string PromotionMarker = "Proxy allowlist promotion for workspace"; + + /// The shared blocked-row reason label for a NotAllowed refusal - the reason fragment + /// that identifies OUR row inside a ring that can hold older entries for the same origin + /// (a stale "target timed out" block from a mis-ordered run must not satisfy the assertions). + private const string NotAllowedReason = "not allowed"; + + private readonly ITestOutputHelper _output; + + public McDiagnosticsTabProbeTests(ITestOutputHelper output) => _output = output; + + private static int ReadPort(string name, int fallback) => + int.TryParse(Environment.GetEnvironmentVariable(name), out var port) ? port : fallback; + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeDiagnosticsTabPromotesLiveBlockedOriginIntoTheSavedAllowlist() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-diagnostics-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + Window mcMain = null; + ClientSession mc = null; + ClientSession sc = null; + BlockedResourcePageServer server = null; + try + { + server = new BlockedResourcePageServer(BindHost, PagePort, BlockedOriginPort); + var pageOrigin = server.PageOrigin; + var blockedOrigin = server.BlockedOrigin; + log.AppendLine($"page origin (allowlisted): {pageOrigin}; blocked subresource origin: {blockedOrigin}"); + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client", "VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + // ---- Management Client phase 1: clean the allowlist BEFORE the SC page load. ---- + // The cleanup must precede the Smart Client session: with an earlier run's probe line + // still stored, the listener would FORWARD the subresources instead of refusing them, + // and the ring row would arrive as the Event Server's own fetch failure, not as a + // client-side "not allowed" block. + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + AllowlistGridUi.OpenProxyTab(mcMain); + var originalLines = AllowlistGridUi.ReadEntries(mcMain).ToList(); + log.AppendLine($"original allowlist: '{string.Join(" | ", originalLines)}'"); + + Assert.True(originalLines.Any(l => string.Equals(WantedOriginOf(l), pageOrigin, StringComparison.OrdinalIgnoreCase)), + $"the workspace allowlist lacks the page origin '{pageOrigin}' - the SC phase below could not load its page through the proxy"); + + // The blocked origin must NOT be covered: any leftover probe line goes first. + var startLines = WithoutProbeLines(originalLines); + if (startLines.Count != originalLines.Count) + { + log.AppendLine("an earlier probe run's line is still allowlisted - clearing it BEFORE the Smart Client phase"); + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, startLines, log); + } + + // ---- Smart Client phase: put a fresh NOT-ALLOWED blocked origin into the ES ring ---- + sc = ClientSession.Launch(config.SmartClientExe); + var scLogin = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, scLogin, config); + LoginFlow.AcceptCertificateTrust(sc); + var scMain = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(scMain); + Thread.Sleep(1500); + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + AutomationElement item = null; + try { item = SmartClientUi.WaitForMipViewItem(scMain, 20); } catch (TimeoutException) { } + if (item == null) + { + log.AppendLine("plugin view not on the opening workspace; pattern-selecting the tab (no input injection)"); + if (SmartClientUi.TrySelectWorkspaceWithoutInput(scMain, sc, config.WorkspaceName)) + { + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + } + item = SmartClientUi.WaitForMipViewItem(scMain); + } + Assert.NotNull(item); + log.AppendLine("proxied workspace view is up (runtime mode)"); + + // Filter-registration head start, same as the other proxy probes. + Thread.Sleep(6000); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await WaitForBrowserPageAsync(browser, 90); + await page.GotoAsync($"{pageOrigin}/blocked-page/", new PageGotoOptions { WaitUntil = WaitUntilState.Load, Timeout = 60000 }); + await page.WaitForSelectorAsync("#probe-marker", new PageWaitForSelectorOptions { Timeout = 30000 }); + var pageTitle = await page.TitleAsync(); + log.AppendLine($"blocked-subresource page (Event Server proxied) -> title='{pageTitle}'"); + Assert.False(pageTitle.StartsWith("Web Content proxy:"), + $"the top-level document itself was refused (is '{pageOrigin}' still in the workspace allowlist?): '{pageTitle}'"); + + // Give Chromium its subresource requests; every refusal forwards to the ES ring. + Thread.Sleep(5000); + log.AppendLine("subresource refusals tallied client-side and forwarded to the Event Server"); + + // ---- Management Client phase 2: the SAME workspace's Diagnostics tab. ---- + // 1. The Diagnostics tab renders, and its tab-gated polling is the marker trace. + SelectTabByAutomationId(mcMain, "tabDiagnostics"); + ManagementClientUi.ExpectControl(mcMain, "dgDiagnosticsBlocked", 15); + ManagementClientUi.ExpectControl(mcMain, "txtDiagnosticsSummary", 5); + ManagementClientUi.ExpectControl(mcMain, "btnDiagnosticsRefresh", 5); + log.AppendLine("diagnostics tab selected: dgDiagnosticsBlocked + summary + refresh rendered"); + + var opened = CountStatusMarkers(); + Assert.True(WaitUntil(() => CountStatusMarkers() > opened, 12), + "no 'Proxy status snapshot/query ... workspace' marker appeared in the Management Client MIP.log after opening the Diagnostics tab - the tab does not drive the status query pair (#84 polling acceptance)"); + log.AppendLine($"polling trace: markers after opening the tab: {CountStatusMarkers() - opened}"); + + // 2. The live blocked row: origin + "not allowed" reason, within the cadence. The + // reason fragment identifies THIS run's row inside a ring that can still carry + // older same-origin entries. + var rowText = WaitValue(() => DiagnosticsRowText(mcMain, blockedOrigin, NotAllowedReason), 45); + Assert.NotNull(rowText); + log.AppendLine($"diagnostics row: {rowText}"); + Assert.Contains("not allowed", rowText, StringComparison.OrdinalIgnoreCase); + Artifacts.Window(mcMain, "mc-diagnostics-row"); + + // 3. One click: the exact origin lands in the Proxy grid UNSAVED and the pane arms Save. + // The baseline MUST carry the same fragments as the wait: an older promotion line for + // a DIFFERENT origin otherwise outranks the origin-filtered count forever (live lesson: + // one stale vms.joshooaj.com promotion line made a landed click look dead). The row + // re-renders on every poll and a promote peer grabbed just before a re-render can be + // stale by click time (the MC-15 lesson - re-find the target immediately before each + // row-local command), so retry the whole find-and-invoke cycle; a button that has + // already flipped to 'covered' means SOME earlier click landed, and every attempt + // re-checks the marker anyway. + var beforePromotion = McLog.CountMatches(PromotionMarker, blockedOrigin); + string promotionLine = null; + for (var attempt = 1; attempt <= 6 && promotionLine == null; attempt++) + { + var promoteButton = DiagnosticsVisiblePromoteButton(mcMain, blockedOrigin, NotAllowedReason); + if (promoteButton != null) + { + try { promoteButton.Patterns.Invoke.Pattern.Invoke(); } + catch { promoteButton.Click(); } + } + else + { + log.AppendLine($"promote attempt {attempt}: no visible promote button (re-checking the marker anyway)"); + } + + promotionLine = McLog.WaitForNewLine(beforePromotion, 5, PromotionMarker, blockedOrigin); + } + + Assert.NotNull(promotionLine); + log.AppendLine($"promotion marker: {promotionLine}"); + Assert.Contains("added to the allowlist grid", promotionLine, StringComparison.Ordinal); + + var annotated = WaitValue(() => + { + var text = DiagnosticsRowText(mcMain, blockedOrigin, NotAllowedReason); + return text != null && text.Contains("on allowlist", StringComparison.OrdinalIgnoreCase) ? text : null; + }, 30); + Assert.NotNull(annotated); + log.AppendLine($"annotated row: {annotated}"); + log.AppendLine(DumpDiagnosticsGrid(mcMain, "after promotion")); + Artifacts.Window(mcMain, "mc-diagnostics-after-promote"); + Assert.True(WaitUntil(() => DiagnosticsVisiblePromoteButton(mcMain, blockedOrigin, NotAllowedReason) == null, 15), + "the promoted row still offers a VISIBLE Add-to-allowlist button - a second click could stack duplicates"); + + // The promotion is a GRID EDIT: the Proxy tab shows the exact row before any save. + AllowlistGridUi.OpenProxyTab(mcMain); + var unsavedGrid = AllowlistGridUi.ReadEntries(mcMain); + Assert.Contains(unsavedGrid, l => string.Equals(WantedOriginOf(l), blockedOrigin, StringComparison.OrdinalIgnoreCase)); + log.AppendLine($"unsaved grid carries the promoted origin: '{string.Join(" | ", unsavedGrid)}'"); + + // 4. The page's normal save path + the ordinary readback discipline: audit line names + // the promoted origin, and a RELOADED pane re-renders it. This is "a config write + // equivalent to the allowlist editor's save, surviving a workspace re-read". + var savedLines = WithoutProbeLines(originalLines); + savedLines.Add(blockedOrigin); + AllowlistGridUi.SaveAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, savedLines, log); + + // 5. After the reload the Diagnostics tab re-annotates from the SAVED grid - the + // promotion is visible to the next admin who opens the tab, from stored config. + SelectTabByAutomationId(mcMain, "tabDiagnostics"); + ManagementClientUi.ExpectControl(mcMain, "dgDiagnosticsBlocked", 15); + var reread = WaitValue(() => + { + var text = DiagnosticsRowText(mcMain, blockedOrigin, NotAllowedReason); + return text != null && text.Contains("on allowlist", StringComparison.OrdinalIgnoreCase) ? text : null; + }, 45); + Assert.NotNull(reread); + log.AppendLine($"re-read annotation: {reread}"); + Artifacts.Window(mcMain, "mc-diagnostics-annotated"); + + // 6. Restore the workspace's original allowlist verbatim (minus probe lines). + var restoreLines = WithoutProbeLines(originalLines); + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, restoreLines, log); + log.AppendLine("allowlist restored (probe line removed, verified from the audit line AND a reloaded pane)"); + } + finally + { + Artifacts.WriteText("mc-diagnostics-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + // NO at the "Save changes?" prompt: a run that died mid-promotion must never persist + // its unsaved pane (the first MC-16 run's AcceptPendingModal at close did exactly + // that, and the residue poisoned the next run's Smart Client phase). + if (mc != null) ManagementClientUi.RejectPendingModal(mc, 5); + ProcessKiller.KillClients("Client", "VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + mc?.Dispose(); + server?.Dispose(); + } + } + + /// Any earlier probe run's blocked-origin line never stacks into the starting or + /// restored allowlist (the #73 probe's discipline). + private static List WithoutProbeLines(List lines) => + lines + .Where(l => !string.Equals(WantedOriginOf(l), $"http://{BindHost}:{BlockedOriginPort}", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + private static string WantedOriginOf(string entry) => AllowlistGridUi.WantedOrigin(entry); + + // ================= the Diagnostics surface ================= + // + // Row addressing is STRUCTURAL, never a descendant-name join. Live on the MC-16 runs, + // FindAllDescendants over a DataGrid row surfaced SIBLING rows' promote buttons (UIA tree + // containment lies mid-virtualization), so "the promoted row's button is gone" cannot be + // decided by a row-scoped Name/AutomationId search. The authority is the UIA Grid pattern - + // IGridProvider.GetItem(row, column) is the structured table API, and a Collapsed WPF button + // has no peer in its template cell at all, which makes "button gone" an honest absence. + // The geometry fallback (button centre inside the matched row's rect) covers a host that + // refuses the Grid pattern; the raw descendant join is the last resort, trusted only for the + // LOG line, never for an assertion. + + /// The grid's column order as declared in WorkspaceConfigPage.xaml (#84): Origin, + /// Reason, Hits, Last seen, Status, then the promote-button template column. + private const int OriginColumn = 0; + private const int ReasonColumn = 1; + private const int LastColumn = 4; + private const int PromoteColumn = 5; + + /// The flattened text of the blocked-origin row (origin | reason | hits | seen | + /// status | button), or null while no row carries the origin. The optional reason fragment + /// pins THIS refusal category when the ring also holds older same-origin entries. + private static string DiagnosticsRowText(Window mcMain, string origin, string reasonFragment = null) + { + var grid = DiagnosticsGrid(mcMain); + if (grid == null) return null; + + var cells = GridRowCells(grid, origin, reasonFragment); + if (cells != null) + { + var button = IfVisible(GridPromoteButton(grid, origin, reasonFragment)); + return string.Join(" | ", cells.Concat(button == null ? new string[0] : new[] { button.Name ?? "Add to allowlist" })); + } + + // Fallback shape (log only): the raw descendant join, known to bleed sibling buttons. + var row = DiagnosticsRowElement(grid, origin, reasonFragment); + if (row == null) return null; + var texts = row.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(t => t.Name) + .Where(n => !string.IsNullOrWhiteSpace(n)); + return string.Join(" | ", texts); + } + + private static AutomationElement DiagnosticsGrid(Window mcMain) => + mcMain.FindFirstDescendant(cf => cf.ByAutomationId("dgDiagnosticsBlocked")); + + /// The row CONTAINER carrying the origin (only for the log fallback and the + /// geometry-fallback button scan - Grid-pattern items are the addressing authority). + private static AutomationElement DiagnosticsRowElement(AutomationElement grid, string origin, string reasonFragment = null) + { + foreach (var row in grid.FindAllDescendants(cf => cf.ByControlType(ControlType.DataItem))) + { + var text = string.Join(" ", row.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(t => t.Name) + .Where(n => !string.IsNullOrWhiteSpace(n))); + if (text.IndexOf(origin, StringComparison.OrdinalIgnoreCase) < 0) continue; + if (reasonFragment != null && text.IndexOf(reasonFragment, StringComparison.OrdinalIgnoreCase) < 0) continue; + return row; + } + + return null; + } + + /// The row's promotion button (AutomationId pinned by the #84 guard tests), or null + /// when the row carries none (an allowlisted row must HIDE it, not disable it). Grid pattern + /// first; the descendant scan only counts a button whose centre sits inside the matched + /// row's own rectangle - the containment lesson. + private static AutomationElement DiagnosticsPromoteButton(Window mcMain, string origin, string reasonFragment = null) + { + var grid = DiagnosticsGrid(mcMain); + if (grid == null) return null; + + var rowIndex = GridRowIndex(grid, origin, reasonFragment); + if (rowIndex >= 0) + { + return GridPromoteButton(grid, origin, reasonFragment, rowIndex); + } + + var row = DiagnosticsRowElement(grid, origin, reasonFragment); + if (row == null) return null; + + var rowRect = SafeRect(row); + foreach (var button in row.FindAllDescendants(cf => cf.ByAutomationId("diagnosticsRowPromote"))) + { + var rect = SafeRect(button); + if (rowRect.IsEmpty || rect.IsEmpty) return button; + var center = new System.Drawing.Point((rect.Left + rect.Right) / 2, (rect.Top + rect.Bottom) / 2); + if (rowRect.Contains(center)) return button; + } + + return null; + } + + /// A promote button counts as OFFERED only when the admin can actually see it: the + /// page hides a covered row's button with Visibility=Collapsed, and UIA can still hand back a + /// peer for a cell whose content never made it on screen (zero rect / IsOffscreen) - an + /// off-screen peer is not a second click waiting to happen, so it is not an affordance. + private static AutomationElement DiagnosticsVisiblePromoteButton(Window mcMain, string origin, string reasonFragment = null) + => IfVisible(DiagnosticsPromoteButton(mcMain, origin, reasonFragment)); + + /// Filters a promote peer down to one that is really on screen (see the note on + /// ). + private static AutomationElement IfVisible(AutomationElement button) + { + if (button == null) return null; + + var rect = SafeRect(button); + if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0) return null; + try { if (button.IsOffscreen) return null; } + catch { /* unreadable state: the rect already said it is on screen */ } + + return button; + } + + /// What the Diagnostics grid actually holds, cell by cell, when an assertion about a + /// row is about to fail - the shape of the grid (rows/columns), and every promote peer UIA + /// can see with its row, rect and off-screen state. Turns a "the button is still there" failure + /// into a readable statement about which cell carries a visible button. + private static string DumpDiagnosticsGrid(Window mcMain, string label) + { + var grid = DiagnosticsGrid(mcMain); + if (grid == null) return $"grid dump [{label}]: no dgDiagnosticsBlocked peer"; + + var sb = new StringBuilder($"grid dump [{label}]:"); + int rows = -1; + int cols = -1; + try + { + if (grid.Patterns.Grid.IsSupported) + { + rows = grid.Patterns.Grid.Pattern.RowCount; + cols = grid.Patterns.Grid.Pattern.ColumnCount; + } + } + catch { } + + sb.AppendLine($" grid pattern: supported={rows >= 0} rows={rows} cols={cols}"); + + for (var r = 0; r < rows; r++) + { + for (var c = 0; c < cols; c++) + { + var cell = GridItem(grid, r, c); + if (cell == null) + { + sb.AppendLine($" [{r},{c}] "); + continue; + } + + var promote = cell.FindFirstDescendant(cf => cf.ByAutomationId("diagnosticsRowPromote")); + if (promote == null) + { + sb.AppendLine($" [{r},{c}] id='{Safe(c == PromoteColumn ? cell.AutomationId : null)}' text='{CellText(cell)}'"); + continue; + } + + var rect = SafeRect(promote); + var offscreen = "?"; + try { offscreen = promote.IsOffscreen ? "offscreen" : "on-screen"; } + catch { } + + sb.AppendLine($" [{r},{c}] PROMOTE PEER name='{Safe(promote.Name)}' {offscreen} rect={rect}"); + } + } + + var loose = 0; + try + { + foreach (var button in grid.FindAllDescendants(cf => cf.ByAutomationId("diagnosticsRowPromote"))) + { + loose++; + var rect = SafeRect(button); + var offscreen = "?"; + try { offscreen = button.IsOffscreen ? "offscreen" : "on-screen"; } + catch { } + + sb.AppendLine($" descendant promote #{loose}: name='{Safe(button.Name)}' {offscreen} rect={rect}"); + } + } + catch { } + + sb.AppendLine($" descendant promote peers total: {loose}"); + return sb.ToString(); + } + + private static string Safe(string value) => value ?? string.Empty; + + // ---- Grid-pattern plumbing ---- + + /// The Grid-pattern row index whose Origin cell names the origin (and whose Reason + /// cell, when a fragment is given, carries it), or -1 when the pattern is unsupported or no + /// row matches. The cell's text is read through its TextBlock peer when the cell peer itself + /// is nameless. + private static int GridRowIndex(AutomationElement grid, string origin, string reasonFragment = null) + { + try + { + if (!grid.Patterns.Grid.IsSupported) return -1; + var rows = grid.Patterns.Grid.Pattern.RowCount; + for (var r = 0; r < rows; r++) + { + var originCell = GridItem(grid, r, OriginColumn); + if (originCell == null) continue; + if (CellText(originCell).IndexOf(origin, StringComparison.OrdinalIgnoreCase) < 0) continue; + if (reasonFragment != null) + { + var reasonCell = CellText(GridItem(grid, r, ReasonColumn)); + if (reasonCell.IndexOf(reasonFragment, StringComparison.OrdinalIgnoreCase) < 0) continue; + } + + return r; + } + } + catch { } + + return -1; + } + + private static AutomationElement GridItem(AutomationElement grid, int row, int column) + { + try { return grid.Patterns.Grid.Pattern.GetItem(row, column); } + catch { return null; } + } + + private static AutomationElement GridPromoteButton(AutomationElement grid, string origin, string reasonFragment = null, int? knownRow = null) + { + var rowIndex = knownRow ?? GridRowIndex(grid, origin, reasonFragment); + if (rowIndex < 0) return null; + + var cell = GridItem(grid, rowIndex, PromoteColumn); + if (cell == null) return null; + if (string.Equals(cell.AutomationId, "diagnosticsRowPromote", StringComparison.Ordinal)) return cell; + return cell.FindFirstDescendant(cf => cf.ByAutomationId("diagnosticsRowPromote")); + } + + /// The five read-only cell texts of the origin's row, in column order (a Grid-pattern + /// miss returns null so the caller falls back). + private static string[] GridRowCells(AutomationElement grid, string origin, string reasonFragment = null) + { + var rowIndex = GridRowIndex(grid, origin, reasonFragment); + if (rowIndex < 0) return null; + + var texts = new List(); + for (var c = OriginColumn; c <= LastColumn; c++) + { + var cell = GridItem(grid, rowIndex, c); + var text = cell == null ? string.Empty : CellText(cell); + if (!string.IsNullOrWhiteSpace(text)) texts.Add(text.Trim()); + } + + return texts.Count == 0 ? null : texts.ToArray(); + } + + /// A cell's text. Empty WPF DataGridCells answer a SYNTHESIZED UIA Name + /// ("Item: , Column Display Index: N") - live on the MC-16 re-run, the blank Status + /// cell leaked exactly that string into the row text - so the TextBlock peers decide first, + /// and a synthesized fallback name is read as empty. + private static string CellText(AutomationElement cell) + { + if (cell == null) return string.Empty; + + try + { + var texts = string.Join(" ", cell.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(t => t.Name) + .Where(n => !string.IsNullOrWhiteSpace(n))); + if (!string.IsNullOrWhiteSpace(texts)) return texts; + } + catch { } + + try + { + var name = cell.Name ?? string.Empty; + if (name.StartsWith("Item:", StringComparison.Ordinal) && name.Contains("Column Display Index", StringComparison.Ordinal)) + return string.Empty; + return name; + } + catch { return string.Empty; } + } + + private static System.Drawing.Rectangle SafeRect(AutomationElement element) + { + try { return element.BoundingRectangle; } + catch { return System.Drawing.Rectangle.Empty; } + } + + private static void SelectTabByAutomationId(Window mcMain, string automationId) + { + var tab = ManagementClientUi.ExpectControl(mcMain, automationId, 15); + try + { + if (tab.Patterns.SelectionItem.IsSupported) + { + tab.Patterns.SelectionItem.Pattern.Select(); + Thread.Sleep(1200); + return; + } + } + catch { } + + tab.Click(); + Thread.Sleep(1200); + } + + // ================= ES-ring / polling trace ================= + + /// Counts the #76 pull markers in the MC's MIP.log (plus archives): every poll of + /// the ONE status query pair leaves exactly one line, whether Status or Diagnostics armed + /// the timer. + private static int CountStatusMarkers() => + McLog.CountMatches(SnapshotMarker) + McLog.CountMatches(QueryFailMarker); + + // ================= waiting helpers (the status probe's discipline) ================= + + private static bool WaitUntil(Func condition, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + try { if (condition()) return true; } catch { } + Thread.Sleep(1000); + } + + return false; + } + + private static T WaitValue(Func probe, int timeoutSeconds) where T : class + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + try + { + var value = probe(); + if (value != null) return value; + } + catch { /* UIA mid-render: poll again */ } + + Thread.Sleep(1000); + } + + return null; + } + + /// First CDP page whose url is an http(s) page of the plugin browser. + private static async Task WaitForBrowserPageAsync(IBrowser browser, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var page = browser.Contexts + .SelectMany(c => c.Pages) + .FirstOrDefault(p => (p.Url ?? string.Empty).StartsWith("http", StringComparison.OrdinalIgnoreCase)); + if (page != null) return page; + await Task.Delay(1000); + } + + throw new TimeoutException("The proxied browser never exposed an http page over CDP. Seen: " + + string.Join(", ", browser.Contexts.SelectMany(c => c.Pages).Select(p => p.Url))); + } +} diff --git a/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs new file mode 100644 index 0000000..3076ec0 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McProxyFetchProbeTests.cs @@ -0,0 +1,106 @@ +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Diagnostic probe (#54): launches and logs in a Management Client, opens the workspace +/// definition page's "Test proxy fetch" row, and captures the actual proxy-fetch outcomes +/// (results land in the artifacts directory, proxy-probe-*.txt / .png). Case 1 exercises the +/// server-side allowlist refusal round-trip, case 2 the allowlisted chunked success round-trip. +/// +public class McProxyFetchProbeTests +{ + private const string WorkspaceName = "Demo Workspace"; + + private readonly ITestOutputHelper _output; + + public McProxyFetchProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeProxyTestFetchOutcomes() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + ProcessKiller.KillClients("VideoOS.Administration"); + var session = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(session); + LoginFlow.Login(session, mcLogin, config); + LoginFlow.AcceptCertificateTrust(session); + var mcMain = LoginFlow.WaitForAdminMain(session); + ManagementClientUi.CloseStrayLoginDialogs(session); + + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, WorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, WorkspaceName); + + // #81 moved the whole proxy section - routing toggle, allowlist grid, fetch harness - + // onto the Proxy tab, so this probe selects the tab before reaching for any proxy control. + AllowlistGridUi.OpenProxyTab(mcMain); + var urlBox = ManagementClientUi.ExpectControl(mcMain, "txtProxyTestUrl", 30); + Artifacts.Window(mcMain, "proxy-probe-page"); + + var chk = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("chkProxyEnabled")); + string proxyToggle = chk != null && chk.Patterns.Toggle.IsSupported + ? chk.Patterns.Toggle.Pattern.ToggleState.Value.ToString() + : "unknown"; + var targetsText = string.Join(" | ", AllowlistGridUi.ReadEntries(mcMain)); + + var log = new System.Text.StringBuilder(); + log.AppendLine($"workspace='{WorkspaceName}' proxyEnabledToggle={proxyToggle} targets={targetsText}"); + + // 1) Origin no proxied workspace allowlists: proves the request reaches the Event Server + // fetcher, the server-side allowlist gate answers, and the answer travels back (a + // NotAllowed failure frame, not a client-side timeout). + log.AppendLine($"non-allowlisted http://10.255.255.1/ -> {RunFetch(mcMain, urlBox, "http://10.255.255.1/")}"); + + // 2) Same url the Smart Client browser just tried: discriminates an Event Server-side + // problem from a Smart Client-side one when SC's fetch times out but this succeeds. + // (http, not https: the workspace allowlist is scheme-exact and proxied browsers + // refuse https targets by design, so this mirrors the live SC-1 traffic. ES-side + // https fetching stays covered by MC-10 manual step 3's NotAllowed probe only.) + log.AppendLine($"allowlisted http://httpforever.com/ -> {RunFetch(mcMain, urlBox, "http://httpforever.com/")}"); + + Artifacts.WriteText("proxy-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + + var result = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("lblProxyTestResult")); + if (result != null) + { + Artifacts.WindowCrop(mcMain, "proxy-probe-result", urlBox.BoundingRectangle, result.BoundingRectangle); + } + } + + private string RunFetch(Window mcMain, AutomationElement urlBox, string url) + { + ManagementClientUi.SetValue(mcMain, "txtProxyTestUrl", url); + var button = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("btnProxyTestFetch")) + ?? throw new InvalidOperationException("btnProxyTestFetch not found."); + + var before = ResultText(mcMain); + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + + var deadline = DateTime.UtcNow.AddSeconds(45); + while (DateTime.UtcNow < deadline) + { + var text = ResultText(mcMain); + if (!string.IsNullOrEmpty(text) + && text != before + && !text.StartsWith("Fetching through", StringComparison.OrdinalIgnoreCase)) + return text; + Thread.Sleep(500); + } + + return $"(timeout waiting for result; last text: '{ResultText(mcMain)}')"; + } + + private static string ResultText(Window mcMain) => + mcMain.FindFirstDescendant(cf => cf.ByAutomationId("lblProxyTestResult"))?.Name ?? string.Empty; +} diff --git a/tests/ux/WebContent.UxTests/McProxyHeaderPolicyProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyHeaderPolicyProbeTests.cs new file mode 100644 index 0000000..6a40ba8 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McProxyHeaderPolicyProbeTests.cs @@ -0,0 +1,238 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #86 probe: the per-workspace proxy header policy, driven end to end through a live +/// Management Client against the live VMS. Four things get pinned by observation, not narration: +/// +/// 1. STORE - a valid policy on BOTH legs (add/override row + strip row per grid) saves and is +/// read back from the plugin's "Stored proxy header policy ..." audit line AND from both grids +/// in a RELOADED pane, order-sensitively: configuration order is part of the policy (for one +/// header name the LAST rule wins, here and on the Event Server). +/// 2. FORBIDDEN GUARDRAIL - rows naming connection-level headers (Host on the request leg, +/// Proxy-Connection on the response leg) refuse the WHOLE save: the Problem cell names the +/// header while the row is typed, the plugin's notice names the refused row verbatim +/// ("Host=evil.example.com" / "-Host" for the strip variant), the MC MIP.log gains the +/// matching 'invalid proxy (request|response) header row(s)' marker - and NOTHING is stored +/// (no new audit line). Ticking Strip does NOT unlock a forbidden name: the guardrail is on +/// the name, not on the direction. Both legs, both variants. +/// 3. UNTOUCHED STORAGE - the refusals change nothing: the grids still display the stored policy +/// and the audit line count never moves while the refused rows sit in the pane. +/// 4. RESTORE - an emptied grid pair stores the EMPTY policy ("request 0 rule(s) []; response +/// 0 rule(s) []") the same verified way, leaving the workspace in the pre-#86 posture. +/// +/// The live FETCH effect of the policy (headers actually added/stripped against a real target) +/// is pinned by the unit suite (ProxyHeaderPolicyTests + the ServeFetchAsync wiring guards), not +/// here: the Proxy-tab fetch result label reports status/bytes/elapsed only - it cannot show +/// headers through UIA - so this probe pins the UI, the guardrail, and the stored shape, and the +/// Event Server's own refresh-audit line was verified live at deploy time. +/// +public class McProxyHeaderPolicyProbeTests +{ + /// The valid policy this probe stores on both legs (add/override + strip per grid). + private static HeaderGridUi.Row RequestAdd => new("X-Probe-Request", "probe-request-value", false); + private static HeaderGridUi.Row RequestStrip => new("X-Probe-Strip-Request", string.Empty, true); + private static HeaderGridUi.Row ResponseAdd => new("X-Probe-Response", "probe-response-value", false); + private static HeaderGridUi.Row ResponseStrip => new("X-Probe-Strip-Response", string.Empty, true); + + /// Forbidden names, one per leg: Host (request side) and a Proxy-* prefix header + /// (response side) - together they pin both the exact-name set and the Proxy-* rule. + private const string ForbiddenRequestHeader = "Host"; + private const string ForbiddenRequestValue = "evil.example.com"; + private const string ForbiddenResponseHeader = "Proxy-Connection"; + private const string ForbiddenResponseValue = "close"; + + /// Substring of Strings.ProxyHeaderRowErrorForbidden, the Problem-column text. + private const string ForbiddenProblemFragment = "reserved for the proxy connection"; + + /// Substring of Strings.InvalidProxyHeaderMessage, the whole-save refusal notice. + private const string RefusalNotice = "invalid row(s)"; + + /// WorkspaceItemManager's own log markers for the same refusals. + private const string RequestRefusalMarker = "invalid proxy request header row(s)"; + private const string ResponseRefusalMarker = "invalid proxy response header row(s)"; + + private readonly ITestOutputHelper _output; + + public McProxyHeaderPolicyProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbePerWorkspaceHeaderPolicyStoreRefusalAndRestore() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-header-policy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + HeaderGridUi.Trace = message => log.AppendLine(message); + Window mcMain = null; + ClientSession mc = null; + try + { + ProcessKiller.KillClients("VideoOS.Administration"); + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + var storedMarker = $"Stored proxy header policy for workspace '{config.McWorkspaceName}'"; + + // ---- Phase 0: baseline from the STORED record (both grids exist and read it) ----- + AllowlistGridUi.OpenProxyTab(mcMain); + var baselineRequest = HeaderGridUi.ReadRows(mcMain, HeaderGridUi.RequestGridId); + var baselineResponse = HeaderGridUi.ReadRows(mcMain, HeaderGridUi.ResponseGridId); + log.AppendLine($"baseline grids: request=[{string.Join(" | ", baselineRequest.Select(r => r.Describe()))}] response=[{string.Join(" | ", baselineResponse.Select(r => r.Describe()))}]"); + + // Leftover rows from an interrupted earlier run would silently change what "empty + // policy" means later (the #17 cap-normalization lesson): normalize to the pre-#86 + // posture first so the run is idempotent. + if (baselineRequest.Count > 0 || baselineResponse.Count > 0) + { + log.AppendLine("baseline grids carry rows - normalizing to the empty policy for an idempotent run"); + HeaderGridUi.RemoveAllRows(mcMain, HeaderGridUi.RequestGridId); + HeaderGridUi.RemoveAllRows(mcMain, HeaderGridUi.ResponseGridId); + HeaderGridUi.SavePoliciesAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, + Enumerable.Empty(), Enumerable.Empty(), log); + } + + // ---- Phase 1: a valid policy on BOTH legs stores and reloads ---------------------- + AllowlistGridUi.OpenProxyTab(mcMain); + HeaderGridUi.SetRows(mcMain, HeaderGridUi.RequestGridId, new[] { RequestAdd, RequestStrip }); + HeaderGridUi.SetRows(mcMain, HeaderGridUi.ResponseGridId, new[] { ResponseAdd, ResponseStrip }); + HeaderGridUi.SavePoliciesAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, + new[] { RequestAdd, RequestStrip }, new[] { ResponseAdd, ResponseStrip }, log); + Artifacts.Window(mcMain, "mc-headers-stored"); + + var auditAfterStore = McLog.CountMatches(storedMarker); + + // ---- Phase 2: request-leg forbidden name refuses the whole save, by name ---------- + RefuseForbiddenRow(mc, mcMain, config, log, storedMarker, + HeaderGridUi.RequestGridId, RequestRefusalMarker, + new HeaderGridUi.Row(ForbiddenRequestHeader, ForbiddenRequestValue, false), + $"{ForbiddenRequestHeader}={ForbiddenRequestValue}"); + + // ---- Phase 3: strip does NOT unlock a forbidden name (request leg) ---------------- + RefuseForbiddenRow(mc, mcMain, config, log, storedMarker, + HeaderGridUi.RequestGridId, RequestRefusalMarker, + new HeaderGridUi.Row(ForbiddenRequestHeader, string.Empty, true), + $"-{ForbiddenRequestHeader}"); + + // ---- Phase 4: response-leg forbidden name (Proxy-* prefix) refuses too ------------- + RefuseForbiddenRow(mc, mcMain, config, log, storedMarker, + HeaderGridUi.ResponseGridId, ResponseRefusalMarker, + new HeaderGridUi.Row(ForbiddenResponseHeader, ForbiddenResponseValue, false), + $"{ForbiddenResponseHeader}={ForbiddenResponseValue}"); + + // ---- Phase 5: strip does NOT unlock on the response leg either --------------------- + RefuseForbiddenRow(mc, mcMain, config, log, storedMarker, + HeaderGridUi.ResponseGridId, ResponseRefusalMarker, + new HeaderGridUi.Row(ForbiddenResponseHeader, string.Empty, true), + $"-{ForbiddenResponseHeader}"); + + // ---- Phase 6: storage untouched through every refusal ------------------------------ + // The refused rows came back out of the grids; what remains displayed is exactly the + // stored policy, and no refusal wrote a thing. + AllowlistGridUi.OpenProxyTab(mcMain); + var untouchedRequest = HeaderGridUi.ReadRows(mcMain, HeaderGridUi.RequestGridId); + var untouchedResponse = HeaderGridUi.ReadRows(mcMain, HeaderGridUi.ResponseGridId); + Assert.True(HeaderGridUi.SameRows(new[] { RequestAdd, RequestStrip }, untouchedRequest), + $"after the refusals the request grid reads '{string.Join(" | ", untouchedRequest.Select(r => r.Describe()))}' — the stored policy should be untouched."); + Assert.True(HeaderGridUi.SameRows(new[] { ResponseAdd, ResponseStrip }, untouchedResponse), + $"after the refusals the response grid reads '{string.Join(" | ", untouchedResponse.Select(r => r.Describe()))}' — the stored policy should be untouched."); + var auditAfterRefusals = McLog.CountMatches(storedMarker); + Assert.Equal(auditAfterStore, auditAfterRefusals); + log.AppendLine($"storage untouched through all four refusals: audit lines {auditAfterStore} -> {auditAfterRefusals}"); + Artifacts.Window(mcMain, "mc-headers-untouched"); + + // ---- Phase 7: empty grids store the EMPTY policy (pre-#86 posture) ----------------- + HeaderGridUi.SetRows(mcMain, HeaderGridUi.RequestGridId, Enumerable.Empty()); + HeaderGridUi.SetRows(mcMain, HeaderGridUi.ResponseGridId, Enumerable.Empty()); + HeaderGridUi.SavePoliciesAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, + Enumerable.Empty(), Enumerable.Empty(), log); + Artifacts.Window(mcMain, "mc-headers-restored"); + } + finally + { + Artifacts.WriteText("mc-header-policy-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + // MC-16 lesson: NEVER AcceptPendingModal at teardown - it would persist a mid-run + // edit. Answer the shell's save prompt with No so the workspace keeps exactly what + // the probe's own verified restores stored. + if (mc != null) ManagementClientUi.RejectPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + } + + /// + /// One forbidden-row cycle: append the row, pin the live Problem-cell explanation, press + /// Save and capture the plugin's refusal notice NAMING the row verbatim, pin the matching + /// MIP.log marker, pin that NOTHING was stored (no new audit line), then take the refused + /// row back out of the grid. + /// + private static void RefuseForbiddenRow( + ClientSession mc, + Window mcMain, + UxConfig config, + StringBuilder log, + string storedMarker, + string gridId, + string refusalMarker, + HeaderGridUi.Row forbidden, + string expectedNamedRow) + { + AllowlistGridUi.OpenProxyTab(mcMain); + HeaderGridUi.AddRow(mcMain, gridId); + var index = HeaderGridUi.RowCount(mcMain, gridId) - 1; + HeaderGridUi.SetLastRow(mcMain, gridId, forbidden); + + var problem = HeaderGridUi.ReadProblem(mcMain, gridId, index); + Assert.Contains(ForbiddenProblemFragment, problem, StringComparison.OrdinalIgnoreCase); + log.AppendLine($"{gridId} forbidden row '{forbidden.Describe()}' shows Problem: '{problem}'"); + + var markersBefore = McLog.CountMatches(refusalMarker, $"'{config.McWorkspaceName}'"); + var auditBefore = McLog.CountMatches(storedMarker); + + var notice = AllowlistGridUi.SaveAndCaptureNotice(mc, mcMain, RefusalNotice, log); + Assert.True(notice != null, + $"saving the forbidden row '{forbidden.Describe()}' raised no refusal notice - the save went through or the row vanished silently."); + Assert.Contains(expectedNamedRow, notice, StringComparison.Ordinal); + log.AppendLine($"refusal notice (names the row verbatim): {notice}"); + + var markerDelta = 0; + for (var attempt = 0; attempt < 10 && markerDelta == 0; attempt++) + { + Thread.Sleep(1000); + markerDelta = McLog.CountMatches(refusalMarker, $"'{config.McWorkspaceName}'") - markersBefore; + } + + Assert.True(markerDelta > 0, + $"the MC MIP.log gained no '{refusalMarker}' marker for the refused row '{forbidden.Describe()}' ({markersBefore} -> {McLog.CountMatches(refusalMarker, $"'{config.McWorkspaceName}'")})."); + + Thread.Sleep(2000); + var auditAfter = McLog.CountMatches(storedMarker); + Assert.Equal(auditBefore, auditAfter); + log.AppendLine($"nothing stored by the refusal: '{storedMarker}' lines {auditBefore} -> {auditAfter}"); + + HeaderGridUi.RemoveRowAt(mcMain, gridId, index); + log.AppendLine($"forbidden row '{forbidden.Describe()}' removed from {gridId}"); + Artifacts.Window(mcMain, $"mc-headers-refusal-{gridId}-{expectedNamedRow.TrimStart('-')}"); + } +} diff --git a/tests/ux/WebContent.UxTests/McProxyResponseCapProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyResponseCapProbeTests.cs new file mode 100644 index 0000000..9a27c50 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McProxyResponseCapProbeTests.cs @@ -0,0 +1,280 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #85 probe: the per-workspace max-response-bytes cap, driven end to end through a live +/// Management Client against the live VMS. Five things get pinned by observation, not narration: +/// +/// 1. DEFAULT POSTURE - with no stored cap the Event Server fetcher stops the same 10 MB download +/// at the 5 MB default and the MC harness shows the TooLarge frame NAMING the 5242880-byte +/// cap the refusal came from. +/// 2. RAISED CAP ENFORCED - saving 12 MB on the Proxy tab (audit line + reloaded-pane readback) +/// lets the SAME download through within the store's 60 s refresh window: the result flips +/// from TooLarge to "10485760 bytes fetched". That round trip also proves the client receive +/// guard was raised to the 20 MB protocol ceiling - a client still guarding at the old 5 MB +/// default would reject the 10 MB answer on the way back in. +/// 3. REFUSAL BY NAME - "99999" in the cap field refuses the WHOLE save: the plugin's notice +/// names the rejected text, the MC MIP.log gains the matching 'invalid proxy response cap' +/// marker, and storage stays untouched - no new cap audit line, and the Event Server keeps +/// serving the last stored cap while the pane still displays the refused edit. +/// 4. LOWERING RE-ARMS - restoring the default cap brings TooLarge back at 5242880 within one +/// refresh window; the cap is live in both directions. +/// 5. STORAGE SHAPE - every accepted save writes the plugin's "Stored proxy response cap ..." +/// audit line with the effective MB value, "(default)" for 5 MB. +/// +/// The probe fetches for real (proof.ovh.net's fixed-size 10 MB file), so it TEMPORARILY allowlists +/// that origin on the probe workspace and restores the workspace's own allowlist and cap before it +/// exits - verified from reloaded panes, and the MC-14 teardown discipline (RejectPendingModal) +/// keeps a crashed mid-run edit from persisting. +/// +public class McProxyResponseCapProbeTests +{ + private const string ProbeHost = "proof.ovh.net"; + + /// https, not http: proof.ovh.net answers 301 on the plain-http URL (learned live on + /// the first MC-17 run - 'HTTP 301 - 162 bytes fetched' instead of the body), and the fetch + /// harness does not follow cross-scheme redirects. ES-side https fetching is first-class + /// (#56 TLS modes), and this host carries a properly chained certificate, so the plain + /// ValidateNormally path succeeds. 10Mb.dat, not 6Mb.dat: the 6 MB file is gone (404 on the + /// second live run) while 10Mb.dat is the host's fixed-size classic. + private const string ProbeEntry = "https://proof.ovh.net"; + private const string ProbeFileUrl = "https://proof.ovh.net/files/10Mb.dat"; + + /// The file's exact size and the default cap the refusal must name. + private const string ProbeBodyBytes = "10485760"; + private const string DefaultCapBytes = "5242880"; + + /// Comfortably above the 10 MB file, comfortably below the 20 MB ceiling. + private const string RaisedCapMegabytes = "12"; + + /// Substring of Strings.InvalidProxyCapMessage, the notice for a refused cap. + private const string RefusalNotice = "is not a valid proxy response size"; + + /// WorkspaceItemManager's own log marker for the same refusal. + private const string RefusalLogMarker = "invalid proxy response cap"; + + private readonly ITestOutputHelper _output; + + public McProxyResponseCapProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbePerWorkspaceResponseCapDefaultRaiseRefusalAndRestore() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-response-cap-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + AllowlistGridUi.Trace = message => log.AppendLine(message); + Window mcMain = null; + ClientSession mc = null; + try + { + ProcessKiller.KillClients("VideoOS.Administration"); + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + // ---- Phase 0: baseline from the STORED record (cap field exists and reads it) ----- + AllowlistGridUi.OpenProxyTab(mcMain); + var baselineCap = AllowlistGridUi.ReadResponseCapText(mcMain); + log.AppendLine($"stored cap field baseline reads '{baselineCap}'"); + + // A raised cap equal to THIS probe's raise value is leftover from an interrupted + // earlier run (the same self-healing the allowlist does for probe-host rows): the + // pristine baseline is the default. + if (string.Equals(baselineCap, RaisedCapMegabytes, StringComparison.Ordinal)) + { + log.AppendLine("baseline cap equals this probe's raise value - treating it as leftover and normalizing to the default on restore"); + baselineCap = "5"; + } + + var rendered = AllowlistGridUi.ReadEntries(mcMain); + var original = rendered + .Where(e => !e.Split(' ')[0].Contains(ProbeHost, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (original.Count < rendered.Count) + log.AppendLine($"baseline excludes {rendered.Count - original.Count} leftover probe-host row(s)"); + log.AppendLine($"stored allowlist baseline: {string.Join(" | ", original)}"); + + // ---- Phase 1: allowlist the probe origin AND normalize the cap to the default ------ + // One save writes both halves of the pane, and normalizing the cap here makes the run + // idempotent: an earlier interrupted run may have LEFT a raised stored cap (learned + // live - the refusal-phase crash left 12 stored), which would silently invalidate + // Phase 2's default-posture expectation. + var probeLines = new List(original) { ProbeEntry }; + AllowlistGridUi.OpenProxyTab(mcMain); + AllowlistGridUi.SetEntries(mcMain, probeLines); + AllowlistGridUi.SetResponseCapText(mcMain, "5"); + AllowlistGridUi.SaveAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, probeLines, log); + var provisionedCap = AllowlistGridUi.ReadResponseCapText(mcMain); + Assert.Equal("5", provisionedCap); + log.AppendLine($"provisioned: allowlist + normalized cap '{provisionedCap}' (one save, verified from the audit line AND a reloaded pane)"); + + // ---- Phase 2: default posture - 10 MB refused at the 5242880 default cap ---------- + var defaultRun = RunFetch(mcMain, ProbeFileUrl, log, 60); + log.AppendLine($"default-cap fetch -> {defaultRun}"); + Assert.Contains("TooLarge", defaultRun, StringComparison.OrdinalIgnoreCase); + Assert.Contains(DefaultCapBytes, defaultRun, StringComparison.Ordinal); + Artifacts.Window(mcMain, "mc-cap-default-toolarge"); + + // ---- Phase 3: raise the cap to 12 MB (audit line + reloaded-pane readback) -------- + AllowlistGridUi.SetCapAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, RaisedCapMegabytes, log); + + // ---- Phase 4: same download inside the 60 s refresh window now succeeds ------------ + // Polling the fetch itself is the proof: the Event Server store re-resolves workspace + // caps at most every 60 s, and only the answer's SIZE can show which cap governed. + var raisedDeadline = DateTime.UtcNow.AddSeconds(120); + string raisedRun = null; + while (DateTime.UtcNow < raisedDeadline) + { + raisedRun = RunFetch(mcMain, ProbeFileUrl, log, 60); + if (raisedRun.Contains("bytes fetched", StringComparison.OrdinalIgnoreCase)) + break; + log.AppendLine($"raised-cap fetch still -> {raisedRun} (Event Server cap store refreshes within 60 s; re-polling)"); + Thread.Sleep(5000); + } + + Assert.True(raisedRun != null && raisedRun.Contains("bytes fetched", StringComparison.OrdinalIgnoreCase), + $"with a {RaisedCapMegabytes} MB stored cap the {ProbeBodyBytes}-byte download never succeeded within the refresh window (last: '{raisedRun}')."); + Assert.Contains(ProbeBodyBytes, raisedRun, StringComparison.Ordinal); + log.AppendLine($"raised-cap fetch -> {raisedRun} (client guard raised to the protocol ceiling: the 10 MB answer reassembled)"); + Artifacts.Window(mcMain, "mc-cap-raised-success"); + + // ---- Phase 5: an out-of-range cap REFUSES the whole save, by name ----------------- + AllowlistGridUi.OpenProxyTab(mcMain); + AllowlistGridUi.SetResponseCapText(mcMain, "99999"); + var markersBefore = McLog.CountMatches(RefusalLogMarker); + var notice = AllowlistGridUi.SaveAndCaptureNotice(mc, mcMain, RefusalNotice, log); + Assert.True(notice != null, + "saving cap '99999' raised no refusal notice - the save went through or the field vanished silently."); + Assert.Contains("99999", notice, StringComparison.Ordinal); + log.AppendLine($"refusal notice: {notice}"); + + var markerDelta = 0; + for (var attempt = 0; attempt < 10 && markerDelta == 0; attempt++) + { + Thread.Sleep(1000); + markerDelta = McLog.CountMatches(RefusalLogMarker) - markersBefore; + } + + Assert.True(markerDelta > 0, + $"the MC MIP.log gained no '{RefusalLogMarker}' marker for the refused save ({markersBefore} -> {McLog.CountMatches(RefusalLogMarker)})."); + + // Nothing partial reached storage, proven server-side rather than from the pane: + // (a) the save audit line gained no new "Stored proxy response cap" entry at all, and + // (b) the Event Server keeps SERVING the last stored cap - the 10 MB download still + // succeeds while the pane still displays the refused 99999 edit. (Reading the pane + // instead would be a lie here: MC's reselect cycle runs the platform fill through a + // state where its dirty chain throws 'Not valid to set dirty state at this time', and + // a TextBox interrupted mid-fill keeps the typed text - MC-17 live lesson.) + var capAuditMarker = $"Stored proxy response cap for workspace '{config.McWorkspaceName}'"; + var capAuditBefore = McLog.CountMatches(capAuditMarker); + var serverRun = RunFetch(mcMain, ProbeFileUrl, log, 60); + var capAuditAfter = McLog.CountMatches(capAuditMarker); + log.AppendLine($"cap audit lines after the refused save: {capAuditBefore} -> {capAuditAfter}"); + Assert.Equal(capAuditBefore, capAuditAfter); + Assert.Contains("bytes fetched", serverRun, StringComparison.OrdinalIgnoreCase); + log.AppendLine($"post-refusal fetch -> {serverRun} (the stored cap still governs: nothing partial stored)"); + Artifacts.Window(mcMain, "mc-cap-refusal"); + + // ---- Phase 6: lower back to the default - TooLarge re-arms within a refresh window + AllowlistGridUi.SetCapAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, "5", log); + var restoreDeadline = DateTime.UtcNow.AddSeconds(120); + string restoredRun = null; + while (DateTime.UtcNow < restoreDeadline) + { + restoredRun = RunFetch(mcMain, ProbeFileUrl, log, 60); + if (restoredRun.Contains("TooLarge", StringComparison.OrdinalIgnoreCase) + && restoredRun.Contains(DefaultCapBytes, StringComparison.Ordinal)) + break; + log.AppendLine($"restore-cap fetch still -> {restoredRun} (waiting for the lowered cap to take effect)"); + Thread.Sleep(5000); + } + + Assert.True(restoredRun != null + && restoredRun.Contains("TooLarge", StringComparison.OrdinalIgnoreCase) + && restoredRun.Contains(DefaultCapBytes, StringComparison.Ordinal), + $"after restoring the default cap the {ProbeBodyBytes}-byte download never met TooLarge at {DefaultCapBytes} again (last: '{restoredRun}')."); + log.AppendLine($"restored-cap fetch -> {restoredRun} (cap live in both directions)"); + + // ---- Restore: the workspace's own allowlist, verified from a reloaded pane -------- + // SaveAndVerify stores what the grid SHOWS and verifies it against the expected set, + // so the probe's own row has to come out of the grid first. + AllowlistGridUi.OpenProxyTab(mcMain); + AllowlistGridUi.SetEntries(mcMain, original); + AllowlistGridUi.SaveAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, original, log); + AllowlistGridUi.OpenProxyTab(mcMain); + Assert.DoesNotContain(ProbeHost, string.Join(" | ", AllowlistGridUi.ReadEntries(mcMain)), StringComparison.OrdinalIgnoreCase); + + // A workspace whose own cap is NOT the default gets its own value back the same + // verified way (today's probe workspaces all sit at the default, so this is a no-op + // save-skip in practice). + if (!string.Equals(AllowlistGridUi.ReadResponseCapText(mcMain), baselineCap, StringComparison.Ordinal)) + AllowlistGridUi.SetCapAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, baselineCap, log); + var finalCap = AllowlistGridUi.ReadResponseCapText(mcMain); + Assert.Equal(baselineCap, finalCap); + log.AppendLine($"allowlist and cap ('{finalCap}') restored (verified from reloaded panes)"); + Artifacts.Window(mcMain, "mc-cap-restored"); + } + finally + { + Artifacts.WriteText("mc-response-cap-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + // MC-16 lesson: NEVER AcceptPendingModal at teardown - it would persist a mid-run + // cap edit. Answer the shell's save prompt with No so the workspace keeps exactly + // what the probe's own verified restores stored. + if (mc != null) ManagementClientUi.RejectPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + } + + /// One Proxy-tab harness fetch: set the URL, invoke, wait for a settled result label + /// (the running placeholder is 'Fetching through ...'). Same reader discipline as McProxyFetchProbeTests. + private static string RunFetch(Window mcMain, string url, StringBuilder log, int timeoutSeconds) + { + AllowlistGridUi.OpenProxyTab(mcMain); + ManagementClientUi.SetValue(mcMain, "txtProxyTestUrl", url); + var button = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("btnProxyTestFetch")) + ?? throw new InvalidOperationException("btnProxyTestFetch not found."); + + var before = ResultText(mcMain); + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var text = ResultText(mcMain); + if (!string.IsNullOrEmpty(text) + && text != before + && !text.StartsWith("Fetching through", StringComparison.OrdinalIgnoreCase)) + return text; + Thread.Sleep(500); + } + + return $"(timeout waiting for result; last text: '{ResultText(mcMain)}')"; + } + + private static string ResultText(Window mcMain) => + mcMain.FindFirstDescendant(cf => cf.ByAutomationId("lblProxyTestResult"))?.Name ?? string.Empty; +} diff --git a/tests/ux/WebContent.UxTests/McProxyStatusProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyStatusProbeTests.cs new file mode 100644 index 0000000..4703d43 --- /dev/null +++ b/tests/ux/WebContent.UxTests/McProxyStatusProbeTests.cs @@ -0,0 +1,234 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #76 probe: the Management Client Status tab pulls the Event Server's live proxy +/// sessions for the workspace and ONLY while the tab is selected. The polling cadence is proven +/// from the MC's own MIP.log messaging trace (every pull logs exactly one "Proxy status ..." +/// marker): opening the tab starts the pull loop, an open Smart Client proxied view shows up as +/// a session row (XProtect user + client machine) within the cadence, leaving the tab stops the +/// markers, and returning restarts them. Read-only throughout: never clicks Save, never mutates +/// workspace configuration. The role-gate denial half needs a second restricted user and stays +/// manual (MC-11/MC-12 in the verification matrix). +/// +public class McProxyStatusProbeTests +{ + /// Marker prefixes logged by WorkspaceConfigPage.RefreshStatus (#76). + private const string SnapshotMarker = "Proxy status snapshot for workspace"; + private const string QueryFailMarker = "Proxy status query for workspace"; + + private readonly ITestOutputHelper _output; + + public McProxyStatusProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeStatusTabPollsLiveSessionsAndStopsWhenLeft() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-status-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var machine = Environment.MachineName; + var log = new StringBuilder(); + Window mcMain = null; + ClientSession mc = null; + ClientSession sc = null; + try + { + ProcessKiller.KillClients("Client", "VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + // ---- Management Client phase: open the workspace and its Status tab ---- + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + // The tab exists because this user holds Manage (the #74 gate); selecting it starts + // the pull loop. + SelectTabByAutomationId(mcMain, "tabStatus"); + ManagementClientUi.ExpectControl(mcMain, "dgStatusSessions", 15); + ManagementClientUi.ExpectControl(mcMain, "txtStatusSummary", 5); + ManagementClientUi.ExpectControl(mcMain, "btnStatusRefresh", 5); + log.AppendLine("status tab selected: dgStatusSessions + summary + refresh rendered"); + + // 1. Polling proof from the messaging trace: within the ~3 s cadence plus one + // 2 s query deadline, the MC's MIP.log must gain a status snapshot marker. + var opened = CountStatusMarkers(); + Assert.True(WaitUntil(() => CountStatusMarkers() > opened, 12), + "no 'Proxy status snapshot/query ... workspace' marker appeared in the Management Client MIP.log after opening the Status tab - the pull loop never sent (or the proxy channel is dead)"); + log.AppendLine($"polling trace: markers after opening the tab: {CountStatusMarkers() - opened}"); + + // The tab answers honestly: a summary line or a failure banner, never blank silence. + var quiet = WaitUntil(() => !string.IsNullOrWhiteSpace(SummaryText(mcMain)), 10) + ? $"summary: '{SummaryText(mcMain)}'" + : "summary still empty (a failure banner path is pinned by the unit suite)"; + log.AppendLine(quiet); + Artifacts.Window(mcMain, "mc-status-quiet"); + + // 2. Live session row: a proxied Smart Client view on this machine opens an ES + // session (SessionEvent Open at listener start); the MC's next polls must render + // it - XProtect user from the platform identity, machine from the client report. + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + sc = ClientSession.Launch(config.SmartClientExe); + var scLogin = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, scLogin, config); + LoginFlow.AcceptCertificateTrust(sc); + var scMain = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(scMain); + Thread.Sleep(1500); + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + scMain = SmartClientUi.SelectWorkspace(scMain, sc, config.WorkspaceName); + Assert.NotNull(SmartClientUi.WaitForMipViewItem(scMain, 60)); + log.AppendLine($"smart client workspace '{config.WorkspaceName}' open - its proxied view should register an Event Server session"); + + var rowAppeared = WaitUntil(() => GridShowsMachine(mcMain, machine), 25); + var summary = SummaryText(mcMain) ?? string.Empty; + log.AppendLine($"session row visible: {rowAppeared}; machine '{machine}'; summary: '{summary}'"); + Artifacts.Window(mcMain, "mc-status-live"); + Assert.True(rowAppeared, + "the Status tab never showed a session row for this machine's proxied Smart Client view within 25 s - polling or session tracking is broken end to end"); + Assert.Contains("live proxy session(s)", summary, StringComparison.Ordinal); + + // 3. No polling once the tab is left: switch to Settings, then the marker count must + // stay FLAT across several full cadence windows. This is the acceptance criterion's + // messaging-trace half - the timer is tied to tab selection, not to the page living. + SelectTabByAutomationId(mcMain, "tabSettings"); + var baseline = CountStatusMarkers(); + Thread.Sleep(10000); + var afterAway = CountStatusMarkers(); + log.AppendLine($"away window (10 s, three cadences): markers before={baseline} after={afterAway}"); + Assert.Equal(baseline, afterAway); + + // 4. Returning re-arms the loop: selecting Status again must resume the markers. + SelectTabByAutomationId(mcMain, "tabStatus"); + var resumed = WaitUntil(() => CountStatusMarkers() > afterAway, 8); + log.AppendLine($"reopen resumes polling: {resumed}"); + Assert.True(resumed, "selecting the Status tab again never resumed the pull loop"); + + // 5. Read-only: none of this enabled the config pane's Save. + Assert.False(ManagementClientUi.WaitForSaveEnabled(mcMain, 4)); + log.AppendLine("Save stayed disabled through the whole status probe"); + } + finally + { + Artifacts.WriteText("mc-status-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + if (mc != null) ManagementClientUi.AcceptPendingModal(mc, 5); + ProcessKiller.KillClients("Client", "VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + } + + private static void SelectTabByAutomationId(Window mcMain, string automationId) + { + var tab = ManagementClientUi.ExpectControl(mcMain, automationId, 15); + try + { + if (tab.Patterns.SelectionItem.IsSupported) + { + tab.Patterns.SelectionItem.Pattern.Select(); + Thread.Sleep(1200); + return; + } + } + catch { } + + tab.Click(); + Thread.Sleep(1200); + } + + private static string SummaryText(Window mcMain) + { + try + { + var summary = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("txtStatusSummary")); + return summary?.Name; + } + catch { return null; } + } + + private static bool GridShowsMachine(Window mcMain, string machine) + { + try + { + var grid = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("dgStatusSessions")); + if (grid == null) return false; + return grid.FindAllDescendants(cf => cf.ByName(machine)).Length > 0; + } + catch { return false; } + } + + /// + /// Counts this machine's #76 pull markers in the Management Client MIP.log (plus its rolling + /// archives). Success and query-failure markers both count: every pull leaves exactly one + /// line, so the count IS the polling trace. + /// + private static int CountStatusMarkers() + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Management Client", "Logs"); + var candidates = new List(); + try + { + var top = Path.Combine(root, "MIP.log"); + if (File.Exists(top)) candidates.Add(top); + var archive = Path.Combine(root, "Archive"); + if (Directory.Exists(archive)) candidates.AddRange(Directory.EnumerateFiles(archive, "MIP*.log")); + } + catch { return 0; } + + var count = 0; + foreach (var file in candidates) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, true); + string line; + while ((line = reader.ReadLine()) != null) + { + if (line.Contains(SnapshotMarker, StringComparison.Ordinal) || + line.Contains(QueryFailMarker, StringComparison.Ordinal)) + { + count++; + } + } + } + catch { /* locked mid-write or unreadable: try the next file */ } + } + + return count; + } + + private static bool WaitUntil(Func condition, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + try { if (condition()) return true; } catch { } + Thread.Sleep(1000); + } + + return false; + } +} diff --git a/tests/ux/WebContent.UxTests/McProxyTriggersProbeTests.cs b/tests/ux/WebContent.UxTests/McProxyTriggersProbeTests.cs new file mode 100644 index 0000000..fb66adb --- /dev/null +++ b/tests/ux/WebContent.UxTests/McProxyTriggersProbeTests.cs @@ -0,0 +1,240 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #78 probe: the per-workspace proxy RULE TRIGGER grid, driven end to end through a live +/// Management Client against the live VMS. Four things get pinned by observation, not narration: +/// +/// 1. STORE - two valid rows save and are read back from the plugin's "Stored proxy triggers ..." +/// audit line AND from a RELOADED grid, order-sensitively: configuration order is part of the +/// policy (the Event Server's matcher evaluates top-down and raises at most the FIRST matching +/// row's event, here and on the server). The read-only Event raised column shows the fixed +/// event type ("WebContent proxy request") on EVERY row - the one event type the whole plugin +/// raises - including after a reload. +/// 2. GRAMMAR GUARDRAIL - a row outside the grammar refuses the WHOLE save: the Problem cell names +/// the failing member while the row is typed, the plugin's notice names the refused row +/// verbatim ("url=... method=... status=..."), and the MC MIP.log gains the matching 'invalid +/// proxy trigger row(s)' marker - and NOTHING is stored (no new audit line). Three variants: +/// a mid-host wildcard url (outside the *.tail grammar), a status outside 1xx-5xx/100-599, and +/// a method chosen with no url at all (a verb without a url is a refusal, not a blank row). +/// The Problem text and the refusal posture are the SAME grammar the Event Server's matcher +/// applies to stored rows (ProxyTriggerPolicy), pinned against drift by the unit suite. +/// 3. UNTOUCHED STORAGE - the refusals change nothing: the grid still displays the stored rows and +/// the audit line count never moves while the refused rows sit in the pane. +/// 4. RESTORE - an emptied grid stores NO trigger section ("0 row(s) []"), leaving the workspace +/// in the pre-#78 posture (no configured triggers). +/// +/// The live EVENT effect (a matching fetch actually raising the XProtect event) is pinned by the +/// unit suite (ProxyTriggerMatcherTests) and was verified on the Event Server at the #65 deploy +/// (self-test probe, ES-3); this probe pins the MC UI, the guardrail, and the stored shape. +/// +public class McProxyTriggersProbeTests +{ + /// The valid two-row policy this probe stores - deliberately different shapes so the + /// audit line pins path globs, an exact status, a host wildcard, a class status, and the + /// any-method dropdown state side by side. + private static TriggerGridUi.Row RowExact => new("http://probe.triggers.example.com/videos/*", "GET", "404"); + + private static TriggerGridUi.Row RowWildcard => new("https://*.probe.triggers.example.com/*", "*", string.Empty); + + /// Outside-grammar rows, one per failing member: a mid-host wildcard (the host + /// wildcard grammar is exact / *.tail / lone *), status 600 (no 6xx class, outside 100-599), + /// and a POST filter with no url at all. + private static TriggerGridUi.Row BadUrlRow => new("http://foo.*.bar.com/", "*", string.Empty); + + private static TriggerGridUi.Row BadStatusRow => new("http://probe.triggers.example.com/ok", "*", "600"); + + private static TriggerGridUi.Row NoUrlRow => new(string.Empty, "POST", string.Empty); + + /// Substrings of the Problem-column strings, per failing member. + private const string UrlProblemFragment = "is not a valid trigger URL pattern"; + private const string StatusProblemFragment = "is not a status filter"; + private const string UrlRequiredFragment = "URL pattern is required"; + + /// Substring of Strings.InvalidProxyTriggerMessage, the whole-save refusal notice. + private const string RefusalNotice = "invalid row(s)"; + + /// WorkspaceItemManager's own log marker for the refusals. + private const string RefusalMarker = "invalid proxy trigger row(s)"; + + /// ProxyTriggerPolicy.EventType - the fixed event type the read-only Event column shows. + private const string EventType = "WebContent proxy request"; + + private readonly ITestOutputHelper _output; + + public McProxyTriggersProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeProxyRuleTriggersStoreRefusalAndRestore() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-proxy-triggers-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + TriggerGridUi.Trace = message => log.AppendLine(message); + Window mcMain = null; + ClientSession mc = null; + try + { + ProcessKiller.KillClients("VideoOS.Administration"); + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + var storedMarker = $"Stored proxy triggers for workspace '{config.McWorkspaceName}'"; + + // ---- Phase 0: baseline from the STORED record (the grid exists and reads it) -------- + AllowlistGridUi.OpenProxyTab(mcMain); + var baseline = TriggerGridUi.ReadRows(mcMain); + log.AppendLine($"baseline grid: [{string.Join(" | ", baseline.Select(r => r.Describe()))}]"); + + // Leftover rows from an interrupted earlier run would silently change what "no + // triggers" means later (the #17 cap-normalization lesson): normalize to the empty + // posture first so the run is idempotent. + if (baseline.Count > 0) + { + log.AppendLine("baseline grid carries rows - normalizing to no triggers for an idempotent run"); + TriggerGridUi.RemoveAllRows(mcMain); + TriggerGridUi.SaveTriggersAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, + Enumerable.Empty(), log); + } + + // ---- Phase 1: two valid rows store and reload, order-sensitive, Event column shown -- + AllowlistGridUi.OpenProxyTab(mcMain); + TriggerGridUi.SetRows(mcMain, new[] { RowExact, RowWildcard }); + var typed = TriggerGridUi.ReadRows(mcMain); + foreach (var row in typed) + { + Assert.Equal(EventType, row.Event); + } + + log.AppendLine($"every typed row shows the read-only Event column: '{EventType}'"); + TriggerGridUi.SaveTriggersAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, + new[] { RowExact, RowWildcard }, log); + var reloaded = TriggerGridUi.ReadRows(mcMain); + foreach (var row in reloaded) + { + Assert.Equal(EventType, row.Event); + } + + log.AppendLine($"Event column survives the reload: every row reads '{EventType}'"); + Artifacts.Window(mcMain, "mc-triggers-stored"); + + var auditAfterStore = McLog.CountMatches(storedMarker); + + // ---- Phase 2: mid-host wildcard url refuses the whole save, by name ----------------- + RefuseInvalidRow(mc, mcMain, config, log, storedMarker, BadUrlRow, UrlProblemFragment, + "url=http://foo.*.bar.com/ method=any status=any"); + + // ---- Phase 3: status 600 (outside the class/exact grammar) refuses too -------------- + RefuseInvalidRow(mc, mcMain, config, log, storedMarker, BadStatusRow, StatusProblemFragment, + "url=http://probe.triggers.example.com/ok method=any status=600"); + + // ---- Phase 4: a method with no url is a refusal, not a blank row -------------------- + RefuseInvalidRow(mc, mcMain, config, log, storedMarker, NoUrlRow, UrlRequiredFragment, + "method=POST"); + + // ---- Phase 5: storage untouched through every refusal -------------------------------- + // The refused rows came back out of the grid; what remains displayed is exactly the + // stored policy, and no refusal wrote a thing. + AllowlistGridUi.OpenProxyTab(mcMain); + var untouched = TriggerGridUi.ReadRows(mcMain); + Assert.True(TriggerGridUi.SameRows(new[] { RowExact, RowWildcard }, untouched), + $"after the refusals the trigger grid reads '{string.Join(" | ", untouched.Select(r => r.Describe()))}' — the stored policy should be untouched."); + var auditAfterRefusals = McLog.CountMatches(storedMarker); + Assert.Equal(auditAfterStore, auditAfterRefusals); + log.AppendLine($"storage untouched through all three refusals: audit lines {auditAfterStore} -> {auditAfterRefusals}"); + Artifacts.Window(mcMain, "mc-triggers-untouched"); + + // ---- Phase 6: empty grid stores NO trigger section (pre-#78 posture) ----------------- + TriggerGridUi.SetRows(mcMain, Enumerable.Empty()); + TriggerGridUi.SaveTriggersAndVerify(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, + Enumerable.Empty(), log); + Artifacts.Window(mcMain, "mc-triggers-restored"); + } + finally + { + Artifacts.WriteText("mc-proxy-triggers-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + // MC-16 lesson: NEVER AcceptPendingModal at teardown - it would persist a mid-run + // edit. Answer the shell's save prompt with No so the workspace keeps exactly what + // the probe's own verified restores stored. + if (mc != null) ManagementClientUi.RejectPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + } + + /// + /// One invalid-row cycle: append the row, pin the live Problem-cell explanation NAMING the + /// failing member, press Save and capture the plugin's refusal notice NAMING the row verbatim, + /// pin the matching MIP.log marker, pin that NOTHING was stored (no new audit line), then take + /// the refused row back out of the grid. + /// + private static void RefuseInvalidRow( + ClientSession mc, + Window mcMain, + UxConfig config, + StringBuilder log, + string storedMarker, + TriggerGridUi.Row invalid, + string problemFragment, + string expectedNamedFragment) + { + AllowlistGridUi.OpenProxyTab(mcMain); + TriggerGridUi.AddRow(mcMain); + var index = TriggerGridUi.RowCount(mcMain) - 1; + TriggerGridUi.SetLastRow(mcMain, invalid); + + var problem = TriggerGridUi.ReadProblem(mcMain, index); + Assert.Contains(problemFragment, problem, StringComparison.OrdinalIgnoreCase); + log.AppendLine($"invalid row '{invalid.Describe()}' shows Problem: '{problem}'"); + + var markersBefore = McLog.CountMatches(RefusalMarker, $"'{config.McWorkspaceName}'"); + var auditBefore = McLog.CountMatches(storedMarker); + + var notice = AllowlistGridUi.SaveAndCaptureNotice(mc, mcMain, RefusalNotice, log); + Assert.True(notice != null, + $"saving the invalid row '{invalid.Describe()}' raised no refusal notice - the save went through or the row vanished silently."); + Assert.Contains(expectedNamedFragment, notice, StringComparison.Ordinal); + log.AppendLine($"refusal notice (names the row verbatim): {notice}"); + + var markerDelta = 0; + for (var attempt = 0; attempt < 10 && markerDelta == 0; attempt++) + { + Thread.Sleep(1000); + markerDelta = McLog.CountMatches(RefusalMarker, $"'{config.McWorkspaceName}'") - markersBefore; + } + + Assert.True(markerDelta > 0, + $"the MC MIP.log gained no '{RefusalMarker}' marker for the refused row '{invalid.Describe()}' ({markersBefore} -> {McLog.CountMatches(RefusalMarker, $"'{config.McWorkspaceName}'")})."); + + Thread.Sleep(2000); + var auditAfter = McLog.CountMatches(storedMarker); + Assert.Equal(auditBefore, auditAfter); + log.AppendLine($"nothing stored by the refusal: '{storedMarker}' lines {auditBefore} -> {auditAfter}"); + + TriggerGridUi.RemoveRowAt(mcMain, index); + log.AppendLine($"invalid row '{invalid.Describe()}' removed from the trigger grid"); + Artifacts.Window(mcMain, $"mc-triggers-refusal-{index}-{problemFragment.Split(' ').First()}"); + } +} diff --git a/tests/ux/WebContent.UxTests/McToSmartClientWorkspaceTests.cs b/tests/ux/WebContent.UxTests/McToSmartClientWorkspaceTests.cs index eda9e35..2ae113e 100644 --- a/tests/ux/WebContent.UxTests/McToSmartClientWorkspaceTests.cs +++ b/tests/ux/WebContent.UxTests/McToSmartClientWorkspaceTests.cs @@ -1,4 +1,5 @@ using WebContent.UxTests.Harness; + using Xunit; using Xunit.Abstractions; diff --git a/tests/ux/WebContent.UxTests/McWildcardAllowlistProbeTests.cs b/tests/ux/WebContent.UxTests/McWildcardAllowlistProbeTests.cs new file mode 100644 index 0000000..bce368a --- /dev/null +++ b/tests/ux/WebContent.UxTests/McWildcardAllowlistProbeTests.cs @@ -0,0 +1,252 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #73 probe: the Event Server fetch gate speaks the wildcard grammar end to end through +/// a REAL Management Client save. Phase A lets the workspace carry "http://*.neverssl.com": a +/// subdomain (http://online.neverssl.com/) fetches through the Event Server while the apex +/// (http://neverssl.com/) is refused NotAllowed - apex-never-matched live, not just pinned. +/// Phase B swaps in the full-host hatch "http://*": a normal host still fetches while the +/// literal-IP target (http://10.20.30.40/) refuses NotAllowed INSTANTLY - the SSRF floor refusing +/// before any socket opens (a missing floor would answer Unreachable after seconds). The grid +/// readback of each wildcard line after every save (taken from a reloaded pane, #81's harness) +/// proves the structured 'pattern' storage shape round-trips through the live admin page, and +/// phase B doubles as the live row for #81's full-host-wildcard warning banner. Every phase +/// restores the workspace's original allowlist verbatim before the probe exits. +/// +public class McWildcardAllowlistProbeTests +{ + private const string WildcardLine = "http://*.neverssl.com"; + private const string HatchLine = "http://*"; + private const string SubdomainUrl = "http://online.neverssl.com/"; + private const string ApexUrl = "http://neverssl.com/"; + private const string FlooredIpUrl = "http://10.20.30.40/"; + + /// The Event Server allowlist snapshot re-resolves at most once per 60 s window + /// (ProxyServerAllowlist.DefaultRefreshInterval); admissions wait past that window. + private const int GateRefreshSeconds = 65; + + private readonly ITestOutputHelper _output; + + public McWildcardAllowlistProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeWildcardAndHatchAtTheLiveEventServerGate() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-wildcard-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + Window mcMain = null; + ClientSession mc = null; + var originalLines = new List(); + try + { + ProcessKiller.KillClients("VideoOS.Administration"); + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + try + { + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + } + catch (InvalidOperationException first) + { + // Diagnostics before giving up (the #74 walk passed this morning - find out + // whether the child is renamed, gone, or just unrealized). + ManagementClientUi.DumpWorkspaceChildren(mcMain, log); + log.AppendLine($"first expansion pass failed ({first.Message}); one retry"); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + } + + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + // #81: the allowlist is the structured grid on the Proxy tab - rows read back in the + // same "scheme://host[:port] [accept-untrusted]" shape the old text box wrote. + AllowlistGridUi.OpenProxyTab(mcMain); + originalLines.AddRange(AllowlistGridUi.ReadEntries(mcMain)); + + log.AppendLine($"original allowlist: '{string.Join(" | ", originalLines)}'"); + + // ---- Phase A: the leftmost-label wildcard ------------------------------ + var phaseALines = WithoutProbeLines(originalLines); + phaseALines.Add(WildcardLine); + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, phaseALines, log); + + // Readback: the grid re-renders the wildcard from the STORED structured record + // (SetEntriesAndSave reads the pane back after re-selecting the item) - the 'pattern' + // shape round-tripping through the live shell. + var readbackA = AllowlistGridUi.ReadEntries(mcMain); + Assert.True(readbackA.Any(l => l.Trim() == WildcardLine), + $"after the phase A save the admin page never re-rendered '{WildcardLine}' from the stored record: '{string.Join(" | ", readbackA)}'"); + log.AppendLine($"phase A readback carries '{WildcardLine}' verbatim (structured pattern shape round-tripped)"); + + // The gate snapshot is up to 60 s stale after the save - wait for admission. + // Admission is pinned at MATCHER level: the answer must no longer be NotAllowed - + // a Timeout/Unreachable answer NAMES the dialled target, which itself proves the + // wildcard admitted the fetch (a 200 through this exact path is banked in the + // earlier run's artifact; the ES's 5 s header timeout to the public target is + // transport, not policy). + var subResult = FetchUntil(mcMain, SubdomainUrl, + text => (text.StartsWith("HTTP ", StringComparison.OrdinalIgnoreCase) || + (text.StartsWith("Proxy fetch ", StringComparison.OrdinalIgnoreCase) && !text.Contains("NotAllowed", StringComparison.Ordinal))), + log, "phase A subdomain"); + log.AppendLine($"phase A: GET {SubdomainUrl} -> {subResult}"); + Assert.False(subResult.Contains("NotAllowed", StringComparison.Ordinal), + $"'{WildcardLine}' never admitted '{SubdomainUrl}' at the live Event Server gate within the refresh windows: {subResult}"); + + // Same snapshot window proves the apex posture: the wildcard covers SUBDOMAINS only. + var apexResult = Fetch(mcMain, ApexUrl); + log.AppendLine($"phase A: GET {ApexUrl} -> {apexResult}"); + Assert.Contains("NotAllowed", apexResult, StringComparison.Ordinal); + Assert.Contains("not in the proxy allowlist", apexResult, StringComparison.Ordinal); + + // ---- Phase B: the full-host hatch with the SSRF floor ------------------- + var phaseBLines = WithoutProbeLines(originalLines); + phaseBLines.Add(HatchLine); + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, phaseBLines, log); + + var readbackB = AllowlistGridUi.ReadEntries(mcMain); + Assert.True(readbackB.Any(l => l.Trim() == HatchLine), + $"after the phase B save the admin page never re-rendered '{HatchLine}' from the stored record: '{string.Join(" | ", readbackB)}'"); + log.AppendLine($"phase B readback carries '{HatchLine}' verbatim"); + + var hatchResult = FetchUntil(mcMain, ApexUrl, + text => text.StartsWith("HTTP ", StringComparison.OrdinalIgnoreCase) || + (text.StartsWith("Proxy fetch ", StringComparison.OrdinalIgnoreCase) && !text.Contains("NotAllowed", StringComparison.Ordinal)), + log, "phase B hatch admission"); + log.AppendLine($"phase B: GET {ApexUrl} -> {hatchResult}"); + Assert.False(hatchResult.Contains("NotAllowed", StringComparison.Ordinal), + $"'{HatchLine}' still refused the apex '{ApexUrl}' at the live Event Server gate: {hatchResult}"); + // (A Timeout/Unreachable answer here still proves ADMISSION - the gate let the + // fetch dial; only the matcher could have produced NotAllowed. Phase A already + // banked a 200 through the wildcard path.) + + // The syntax floor: the hatch covers every host EXCEPT literal IPs/localhost/VMS + // hosts. A floored refusal comes back NotAllowed in well under the gate refresh + // window - if the floor were missing, this would instead dial the socket and + // answer Unreachable after seconds. Both are refusals; only the floor refuses + // BEFORE the socket, and the NotAllowed code is what pins that. + var floorResult = Fetch(mcMain, FlooredIpUrl); + log.AppendLine($"phase B: GET {FlooredIpUrl} -> {floorResult}"); + Assert.Contains("NotAllowed", floorResult, StringComparison.Ordinal); + Assert.DoesNotContain("Unreachable", floorResult, StringComparison.Ordinal); + + // The EnvironmentManager half of the floor: the VMS's own host must be floored at + // the Event Server too (ResolveVmsFloorHosts). Without that wiring the fetch would + // dial the Management Server (a connection answer); with it, NotAllowed instantly. + var vmsHost = config.VmsAddress.Contains("://") ? new Uri(config.VmsAddress).Host : config.VmsAddress; + var vmsFloorResult = Fetch(mcMain, $"http://{vmsHost}/"); + log.AppendLine($"phase B: GET http://{vmsHost}/ -> {vmsFloorResult}"); + Assert.Contains("NotAllowed", vmsFloorResult, StringComparison.Ordinal); + Assert.DoesNotContain("Unreachable", vmsFloorResult, StringComparison.Ordinal); + + // #81 rides along: the hatch row is the full-host escape hatch, so the Proxy tab's + // warning banner must be up while it is in the grid. + Assert.True(AllowlistGridUi.WarningBannerVisible(mcMain), + $"phase B: with '{HatchLine}' saved in the grid, the full-host-wildcard warning banner never appeared."); + + try { Artifacts.Window(mcMain, "mc-wildcard-phase-b"); } + catch (Exception screenshotFailure) { log.AppendLine($"phase B screenshot skipped: {screenshotFailure.Message}"); } + + // ---- Restore: the workspace gets its allowlist back WITHOUT any probe line ---- + // (self-healing: a dead earlier run's leftover wildcard/hatch/neverssl lines are + // cleaned here, so "original" never re-bakes a stale probe entry.) + var restoreLines = WithoutProbeLines(originalLines); + AllowlistGridUi.SetEntriesAndSave(mc, mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName, restoreLines, log); + var restored = AllowlistGridUi.ReadEntries(mcMain); + Assert.True(AllowlistGridUi.SameEntries(restoreLines, restored), + $"restore failed: allowlist reads '{string.Join(" | ", restored)}', expected '{string.Join(" | ", restoreLines)}'"); + Assert.False(AllowlistGridUi.WarningBannerVisible(mcMain), + "the full-host-wildcard warning banner stayed up after the hatch row was removed."); + log.AppendLine("allowlist restored (probe lines removed, verified readback, banner down)"); + } + finally + { + Artifacts.WriteText("mc-wildcard-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + if (mc != null) ManagementClientUi.AcceptPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + } + + /// Any earlier probe run's wildcard/hatch/neverssl lines never stack into a + /// contradictory allowlist; the probe owns exactly its own lines. + private static List WithoutProbeLines(List lines) => + lines + .Where(l => !l.Split(' ')[0].Equals(WildcardLine, StringComparison.OrdinalIgnoreCase) && + !l.Split(' ')[0].Equals(HatchLine, StringComparison.OrdinalIgnoreCase) && + !l.Split(' ')[0].Equals(ApexUrl.TrimEnd('/'), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + private string FetchUntil(Window mcMain, string url, Func accept, StringBuilder log, string label) + { + var text = string.Empty; + for (var attempt = 1; attempt <= 3; attempt++) + { + Thread.Sleep(attempt == 1 ? 15000 : GateRefreshSeconds * 1000); + text = Fetch(mcMain, url); + if (accept(text)) return text; + log.AppendLine($"{label}: attempt {attempt} -> '{text}' (gate snapshot may still be stale; waiting {GateRefreshSeconds}s)"); + } + + return text; + } + + private static string Fetch(Window mcMain, string url) + { + ManagementClientUi.SetValue(mcMain, "txtProxyTestUrl", url); + var button = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("btnProxyTestFetch")) + ?? throw new InvalidOperationException("btnProxyTestFetch not found."); + + var before = ResultText(mcMain); + try { button.Patterns.Invoke.Pattern.Invoke(); } + catch { button.Click(); } + + // The label flips through "Fetching through..." on every invoke; once that transient + // is seen, the NEXT non-transient text is this attempt's verdict - even when it is + // word-for-word the previous attempt's (the identical-verdict case ValuePattern-less + // labels cannot otherwise distinguish). + var sawRunning = false; + var deadline = DateTime.UtcNow.AddSeconds(45); + while (DateTime.UtcNow < deadline) + { + var text = ResultText(mcMain); + if (text.StartsWith("Fetching through", StringComparison.OrdinalIgnoreCase)) + { + sawRunning = true; + } + else if (!string.IsNullOrEmpty(text) && (sawRunning || text != before)) + { + return text; + } + + Thread.Sleep(500); + } + + return sawRunning + ? before + " (verdict unchanged from the previous attempt)" + : $"(timeout waiting for result; last text: '{ResultText(mcMain)}')"; + } + + private static string ResultText(Window mcMain) => + mcMain.FindFirstDescendant(cf => cf.ByAutomationId("lblProxyTestResult"))?.Name ?? string.Empty; +} diff --git a/tests/ux/WebContent.UxTests/McWorkspaceTabsProbeTests.cs b/tests/ux/WebContent.UxTests/McWorkspaceTabsProbeTests.cs new file mode 100644 index 0000000..ee0be3a --- /dev/null +++ b/tests/ux/WebContent.UxTests/McWorkspaceTabsProbeTests.cs @@ -0,0 +1,155 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #74 probe: the Management Client workspace config page renders as the four-tab shell +/// (Settings | Proxy | Diagnostics | Status), the Settings tab keeps every settings control on +/// the default tab (no navigation needed - the home-address probes must stay intact), and walking +/// the live Proxy grid (#81), the read-only Diagnostics grid (#84) and the read-only Status tab (#76) +/// never dirties the pane: Save must stay disabled through the whole walk ("no config writes +/// from the shell"). Since #81 the proxy section lives ONLY on the Proxy tab, which the probe +/// asserts by looking for a realized proxy control while Settings is selected. The Status tab is +/// expected visible because the probe user is a full administrator (holds Manage); the view-only +/// denial half of the role gate is pinned by MC-11 in the verification matrix. +/// Read-only: never clicks Save. +/// +public class McWorkspaceTabsProbeTests +{ + private readonly ITestOutputHelper _output; + + public McWorkspaceTabsProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeWorkspaceTabsShellRendersAndStaysReadOnly() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-mc-tabs-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + ProcessKiller.KillClients("VideoOS.Administration"); + var session = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(session); + LoginFlow.Login(session, mcLogin, config); + LoginFlow.AcceptCertificateTrust(session); + var mcMain = LoginFlow.WaitForAdminMain(session); + ManagementClientUi.CloseStrayLoginDialogs(session); + ManagementClientUi.WaitForTreeReady(mcMain); + + var log = new StringBuilder(); + try + { + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + // 1. Default tab is Settings and every existing control is reachable WITHOUT any + // tab navigation (the shape the home-address harness relies on). Since #81 the + // whole proxy section lives on the Proxy tab, so the Settings tab must NOT carry + // proxy controls any more - asserted, not just left unmentioned. + var nameBox = ManagementClientUi.ExpectControl(mcMain, "txtName", 30); + ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 5); + var nameBefore = ReadValue(nameBox); + log.AppendLine($"settings tab live: txtName reads '{nameBefore}'"); + + // 2. Four-tab shell rendered; Status present because this user is a full admin. + foreach (var aid in new[] { "tabSettings", "tabProxy", "tabDiagnostics", "tabStatus" }) + { + ManagementClientUi.ExpectControl(mcMain, aid, 15); + } + + log.AppendLine("tabs rendered: tabSettings tabProxy tabDiagnostics tabStatus"); + Artifacts.Window(mcMain, "mc-tabs-settings"); + + // 3. Each tab selects (UIA SelectionItem, no mouse needed) and renders its content; + // screenshot per tab. The Proxy tab is live since #81 (structured allowlist grid); + // Diagnostics went live with #84 (read-only blocked-origin grid - MC-16 owns its + // live-content assertions). + SelectTabByAutomationId(mcMain, "tabProxy"); + ManagementClientUi.ExpectControl(mcMain, "dgProxyTargets", 10); + ManagementClientUi.ExpectControl(mcMain, "btnProxyTargetAdd", 5); + log.AppendLine("proxy tab live surface rendered - structured allowlist grid present"); + Artifacts.Window(mcMain, "mc-tabs-proxy"); + + SelectTabByAutomationId(mcMain, "tabDiagnostics"); + ManagementClientUi.ExpectControl(mcMain, "dgDiagnosticsBlocked", 10); + ManagementClientUi.ExpectControl(mcMain, "txtDiagnosticsSummary", 5); + ManagementClientUi.ExpectControl(mcMain, "btnDiagnosticsRefresh", 5); + log.AppendLine("diagnostics tab live surface rendered - read-only blocked-origin grid present"); + Artifacts.Window(mcMain, "mc-tabs-diagnostics"); + + // Status is live since #76: no placeholder - the shell check here is that selecting it + // renders the read-only session surface (grid + summary) without dirtying the pane. + // The live-content assertions live in McProxyStatusProbeTests. + SelectTabByAutomationId(mcMain, "tabStatus"); + ManagementClientUi.ExpectControl(mcMain, "dgStatusSessions", 10); + ManagementClientUi.ExpectControl(mcMain, "txtStatusSummary", 5); + log.AppendLine("status tab live surface rendered - session grid present (admin holds Manage)"); + Artifacts.Window(mcMain, "mc-tabs-status"); + + // 4. No config writes from the shell walk: after selecting three tabs and returning, + // the shell's Save must still be disabled - selecting tabs fired no change events. + Assert.False(ManagementClientUi.WaitForSaveEnabled(mcMain, 4)); + log.AppendLine("Save stayed disabled through the whole tab walk"); + + // 5. Back on Settings, the fields still read the same values (fill untouched), and + // the Proxy tab's grid still renders the same rows after the walk. The elements + // are RE-FOUND: a WPF TabControl unloads the non-selected tab's content, so the + // pre-walk element handles are stale by now. + SelectTabByAutomationId(mcMain, "tabSettings"); + var nameAfter = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtName", 10)); + Assert.Equal(nameBefore, nameAfter); + + var strayProxyControl = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("chkProxyEnabled")); + Assert.True(strayProxyControl == null, + "the Settings tab realized a proxy control - the proxy section lives on the Proxy tab (#81)."); + + AllowlistGridUi.OpenProxyTab(mcMain); + var entriesAfter = AllowlistGridUi.ReadEntries(mcMain); + log.AppendLine($"round-trip intact: txtName '{nameAfter}', allowlist rows '{string.Join(" | ", entriesAfter)}'"); + + Artifacts.Window(mcMain, "mc-tabs-final"); + } + finally + { + Artifacts.WriteText("mc-tabs-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain.Close(); } catch { } + ManagementClientUi.AcceptPendingModal(session, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + } + + private static void SelectTabByAutomationId(Window mcMain, string automationId) + { + var tab = ManagementClientUi.ExpectControl(mcMain, automationId, 15); + try + { + if (tab.Patterns.SelectionItem.IsSupported) + { + tab.Patterns.SelectionItem.Pattern.Select(); + Thread.Sleep(1200); + return; + } + } + catch { } + + tab.Click(); + Thread.Sleep(1200); + } + + private static string ReadValue(AutomationElement element) + { + try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; } + catch { return element.Name; } + } +} diff --git a/tests/ux/WebContent.UxTests/ReadmeScreenshotTests.cs b/tests/ux/WebContent.UxTests/ReadmeScreenshotTests.cs index aeea2cc..25d24d6 100644 --- a/tests/ux/WebContent.UxTests/ReadmeScreenshotTests.cs +++ b/tests/ux/WebContent.UxTests/ReadmeScreenshotTests.cs @@ -1,8 +1,11 @@ +using System.Diagnostics; + using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; using FlaUI.Core.Input; -using System.Diagnostics; + using WebContent.UxTests.Harness; + using Xunit; using Xunit.Abstractions; diff --git a/tests/ux/WebContent.UxTests/ReconTests.cs b/tests/ux/WebContent.UxTests/ReconTests.cs index ae0eb4d..12f23c3 100644 --- a/tests/ux/WebContent.UxTests/ReconTests.cs +++ b/tests/ux/WebContent.UxTests/ReconTests.cs @@ -1,6 +1,10 @@ +using System.Diagnostics; + using FlaUI.Core.AutomationElements; using FlaUI.Core.Definitions; + using WebContent.UxTests.Harness; + using Xunit; using Xunit.Abstractions; @@ -184,8 +188,11 @@ public void Probe_ManagementClientTreeInteraction() catch (Exception ex) { Log("tree patterns ERR:" + ex.GetType().Name); } var treeParent = tree.Parent; if (treeParent != null) - try { Log("tree parent: " + treeParent.ControlType + " cls=" + treeParent.ClassName + " patterns=" + - string.Join(",", treeParent.GetSupportedPatterns().Select(p => p.ToString()))); } + try + { + Log("tree parent: " + treeParent.ControlType + " cls=" + treeParent.ClassName + " patterns=" + + string.Join(",", treeParent.GetSupportedPatterns().Select(p => p.ToString()))); + } catch (Exception ex) { Log("tree parent ERR:" + ex.GetType().Name); } } @@ -329,7 +336,7 @@ public async Task Recon_SmartClient_And_ManagementClient() ProcessKiller.KillClients("Client", "VideoOS.Administration"); ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); - using var sc = ClientSession.Launch(config.SmartClientExe); + using var sc = ClientSession.Launch(config.SmartClientExe); var scLogin = LoginFlow.WaitForLoginWindow(sc); Artifacts.Window(scLogin, "sc-00-login"); Artifacts.WriteText("sc-00-login-tree.txt", TreeDump.Of(scLogin, 12)); @@ -393,4 +400,196 @@ public async Task Recon_SmartClient_And_ManagementClient() ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); _output.WriteLine($"Done. Artifacts: {Artifacts.RunDirectory}"); } + + [Fact] + [Trait("Category", "ux-recon")] + public void Probe_SmartClientStartupOnDisconnectedDesktop() + { + // #83 live probe run: Smart Client never reached the login dialog (#60 s) while MC + // worked fine on the same disconnected desktop session. Poll the process and its + // top-level windows for two minutes to see where the startup actually stalls. + var config = UxConfig.Load(); + Artifacts.Init(config.ArtifactsRoot); + var path = Path.Combine(Artifacts.RunDirectory, "sc-startup-diag.txt"); + void Step(string s) { try { File.AppendAllText(path, DateTime.Now.ToString("HH:mm:ss ") + s + Environment.NewLine); } catch { } } + + Step("launch SC"); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + Thread.Sleep(1500); + using var sc = ClientSession.Launch(config.SmartClientExe); + Step($"attached pid={sc.Process.Id} name={sc.Process.ProcessName}"); + + var deadline = DateTime.UtcNow.AddSeconds(120); + var seen = new HashSet(); + while (DateTime.UtcNow < deadline) + { + try + { + var exited = sc.Process.HasExited; + var kids = Process.GetProcesses().Where(p => + { + try { return p.ProcessName.StartsWith("SmartClient.WebContentData", StringComparison.OrdinalIgnoreCase) || p.ProcessName == "VideoOS.OAuth2TokenGenerator"; } + catch { return false; } + }).Select(p => $"{p.ProcessName}:{p.Id}"); + var wins = sc.Windows().Select(w => + { + string Safex(Func f) { try { return f() ?? ""; } catch { return ""; } } + return $"aid='{Safex(() => w.AutomationId)}' cls='{Safex(() => w.ClassName)}' name='{Safex(() => w.Name)}'"; + }); + foreach (var w in wins) seen.Add(w); + Step($"exited={exited} windows=[{string.Join(" | ", wins)}] kids=[{string.Join(",", kids)}]"); + if (wins.Any(w => w.Contains("_mainApplicationWindow"))) { Step("MAIN WINDOW UP"); break; } + } + catch (Exception ex) { Step($"poll ERR {ex.GetType().Name}: {ex.Message}"); } + Thread.Sleep(5000); + } + Step("seen windows: " + string.Join(" ;; ", seen)); + + // Whatever blocked startup, dump the message box text (title + body + buttons) so the + // blocker is on the record, then dismiss it. + try + { + var box = sc.FindWindow(w => string.Equals(w.AutomationId, "_messageBoxWindow", StringComparison.OrdinalIgnoreCase)); + if (box != null) + { + var texts = box.FindAllDescendants(cf => cf.ByControlType(ControlType.Text)) + .Select(t => "'" + t.Name + "'"); + var buttons = box.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)) + .Select(b => "'" + b.Name + "'"); + Step("message box texts: " + string.Join(" ", texts)); + Step("message box buttons: " + string.Join(" ", buttons)); + foreach (var b in box.FindAllDescendants(cf => cf.ByControlType(ControlType.Button))) + { + try { b.Patterns.Invoke.Pattern.Invoke(); break; } catch { } + } + } + else Step("no message box window found at exit"); + } + catch (Exception ex) { Step("message box dump ERR " + ex.GetType().Name + ": " + ex.Message); } + + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + [Fact] + [Trait("Category", "ux-recon")] + public void Probe_ManagementClientTreeSelectionWithoutInput() + { + // #83 live run hit FlaUI Mouse.SendInput Access Denied (disconnected desktop session): + // recon whether the Management Client phase can run on patterns only - + // LegacyIAccessible DoDefaultAction to expand nodes, SelectionItem.Select to pick the + // workspace, and the pane read to prove the selection landed. + var config = UxConfig.Load(); + Artifacts.Init(config.ArtifactsRoot); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var stepPath = Path.Combine(Artifacts.RunDirectory, "mc-tree-selection-without-input.txt"); + void Step(string s) { try { File.AppendAllText(stepPath, DateTime.Now.ToString("HH:mm:ss ") + s + Environment.NewLine); } catch { } } + + Step("launch MC"); + ProcessKiller.KillClients("VideoOS.Administration"); + using var mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + Step("login window found; logging in"); + LoginFlow.Login(mc, mcLogin, config); + Step("login sent"); + LoginFlow.AcceptCertificateTrust(mc); + var mcMain = LoginFlow.WaitForAdminMain(mc); + Step("admin main up"); + Thread.Sleep(3000); + mcMain = mc.Windows().First(w => (w.Name ?? string.Empty).Contains("Management Client")); + Step("window handle re-fetched"); + ManagementClientUi.CloseStrayLoginDialogs(mc); + Step("stray dialogs swept"); + + var sb = new System.Text.StringBuilder(); + var progressPath = Artifacts.PathOf("mc-tree-selection-without-input.txt"); + void Log(string s) + { + sb.AppendLine(s); + _output.WriteLine(s); + try { File.AppendAllText(progressPath, DateTime.Now.ToString("HH:mm:ss ") + s + Environment.NewLine); } catch { } + } + List Rows() => mcMain.FindAllDescendants(cf => cf.ByControlType(ControlType.TreeItem)).ToList(); + AutomationElement Row(string name) => Rows().FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)); + + string PatternsOf(AutomationElement el) + { + var parts = new List(); + try { if (el.Patterns.ExpandCollapse.IsSupported) parts.Add("ExpandCollapse:" + el.Patterns.ExpandCollapse.Pattern.ExpandCollapseState.Value); } catch { parts.Add("ExpandCollapse:ERR"); } + try { if (el.Patterns.SelectionItem.IsSupported) parts.Add("SelectionItem"); } catch { } + try + { + if (el.Patterns.LegacyIAccessible.IsSupported) + { + var action = "?"; + try { action = el.Patterns.LegacyIAccessible.Pattern.DefaultAction.Value; } catch { } + parts.Add("Legacy(action=" + action + ")"); + } + } + catch { } + return string.Join(" ", parts); + } + + void TryDefaultAction(string name, string context) + { + var row = Row(name); + if (row == null) { Log($" {context}: row '{name}' NOT realized"); return; } + Log($" {context}: row '{name}' {PatternsOf(row)}"); + try + { + row.Patterns.LegacyIAccessible.Pattern.DoDefaultAction(); + Thread.Sleep(1500); + Log($" {context}: DoDefaultAction done; now {PatternsOf(row)}; rows now: " + + string.Join(",", Rows().Select(r => "'" + r.Name + "'"))); + } + catch (Exception ex) { Log($" {context}: DoDefaultAction ERR {ex.GetType().Name}: {ex.Message}"); } + } + + Step("initial rows read"); + Log("initial rows: " + string.Join(",", Rows().Select(r => "'" + r.Name + "'"))); + + Step("TryDefaultAction plugin-node"); + TryDefaultAction(config.PluginNodeName, "plugin-node"); + Step("TryDefaultAction workspaces-node"); + TryDefaultAction(ManagementClientUi.WorkspacesNodeName, "workspaces-node"); + Step("nodes expanded"); + + var ws = Row(config.McWorkspaceName); + if (ws == null) + { + Log($"workspace row '{config.McWorkspaceName}' NOT realized - rows now: " + + string.Join(",", Rows().Select(r => "'" + r.Name + "'"))); + } + else + { + Log($"workspace row: {PatternsOf(ws)}"); + try + { + ws.Patterns.SelectionItem.Pattern.Select(); + Thread.Sleep(1500); + Log("SelectionItem.Select ok"); + } + catch (Exception ex) + { + Log($"Select ERR {ex.GetType().Name}: {ex.Message}"); + try { ws.Patterns.LegacyIAccessible.Pattern.Select(9); Thread.Sleep(1500); Log("legacy Select ok"); } + catch (Exception ex2) { Log($"legacy Select ERR {ex2.GetType().Name}"); } + } + + var nameBox = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("txtName")); + Log($"pane txtName: present={nameBox != null} value='{(nameBox != null && nameBox.Patterns.Value.IsSupported ? nameBox.Patterns.Value.Pattern.Value.Value : "?")}'"); + var proxyTab = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("tabProxy")); + Log($"pane tabProxy: present={proxyTab != null}"); + var grid = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("dgProxyTargets")); + Log($"pane dgProxyTargets: present={grid != null} (the #81 grid is Proxy-tab content, so it is unrealized until that tab is selected)"); + } + + // WindowPrint (PrintWindow), not Window: CopyFromScreen throws "handle is invalid" / + // outright hangs on the disconnected session this recon targets. + Artifacts.WindowPrint(mcMain, "mc-tree-selection-without-input"); + Artifacts.WriteText("mc-tree-selection-without-input.txt", sb.ToString()); + ProcessKiller.KillClients("VideoOS.Administration"); + } } diff --git a/tests/ux/WebContent.UxTests/ScListenerHandoffProbeTests.cs b/tests/ux/WebContent.UxTests/ScListenerHandoffProbeTests.cs new file mode 100644 index 0000000..9a43fcb --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScListenerHandoffProbeTests.cs @@ -0,0 +1,514 @@ +using System.Management; +using System.Text.RegularExpressions; + +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #96 live probes (SC-15): when the boundary's listener OWNER dies mid-session - clean +/// close or hard kill - the surviving joined Smart Client heals itself WITHOUT user action: the +/// claim watchdog notices (dead pid or a released slot), takes the handoff through the claim +/// protocol (new generation: epoch bumps, fresh port), and forces the proxied view's re-creation +/// so the browser process is recycled with the new --proxy-server port baked in. +/// +/// The witnesses: +/// - the survivor's proxied page re-RENDERS (CDP page count on the host recovers) within a +/// bounded window and with no interaction; +/// - the claim slot ends at exactly one v2 claim naming the SURVIVOR pid, a port different from +/// the dead owner's, epoch >= owner epoch + 1; +/// - the WMI process witness shows exactly ONE loopback --proxy-server port in the system and it +/// equals the NEW claim port (the recycled browser process carries the handoff's arguments); +/// - last-view-out-the-door: closing the survivor (now the owner) leaves NO claim file behind - +/// no dead-owner claim sitting on disk for hours. +/// +/// Log-line caveat (measured since #87): while one Smart Client lives the other may journal +/// NOTHING to the machine-wide MIPLog.txt, so the survivor's handoff lines are only asserted when +/// the run proves the survivor journalled at all; the claim + process + render witnesses are the +/// authoritative evidence either way. +/// +public sealed class ScListenerHandoffProbeTests +{ + private readonly ITestOutputHelper _output; + + public ScListenerHandoffProbeTests(ITestOutputHelper output) => _output = output; + + /// #96 acceptance row 1: the owner closes CLEANLY while a joined client sits open. + [Fact] + [Trait("Category", "probe")] + public async Task ProbeOwnerCleanCloseHealsJoinedView() + { + await RunHandoffProbeAsync(hardKill: false); + } + + /// #96 acceptance row 2: the owner is HARD-KILLED while a joined client sits open. + [Fact] + [Trait("Category", "probe")] + public async Task ProbeOwnerHardKillHealsJoinedView() + { + await RunHandoffProbeAsync(hardKill: true); + } + + private async Task RunHandoffProbeAsync(bool hardKill) + { + var config = UxConfig.Load(); + var shape = hardKill ? "hard-kill" : "clean-close"; + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-handoff-" + shape + "-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + ClientSession owner = null; + ClientSession survivor = null; + + log.AppendLine($"=== SC-15: owner {shape} while a joined client is open -> automatic handoff + view re-creation (#96) ==="); + try + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + Thread.Sleep(2000); + + // ---- Owner client: claims the slot, binds the listener, renders. ---- + owner = LaunchIntoWorkspace(config, log, "owner"); + var ownerMain = owner.WaitForWindow(w => w.AutomationId == "_mainApplicationWindow", 60, "owner main window"); + SmartClientUi.WaitForMipViewItem(ownerMain); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot, 120); + var homeHost = await WaitForRenderedPageHostAsync(browser, 120); + log.AppendLine($"owner proxied page rendered (page host '{homeHost}')"); + + var firstClaim = WaitForClaim(config, claim => claim.OwnerPid == owner.Process.Id, 60, log); + if (firstClaim == null) + { + throw new InvalidOperationException("the owner never published a listener claim - nothing to hand off"); + } + + var deadOwnerPid = owner.Process.Id; + var deadOwnerPort = firstClaim.Port; + var firstEpoch = firstClaim.Epoch; + log.AppendLine($"owner claim generation 1: pid={deadOwnerPid}, port={deadOwnerPort}, epoch={firstEpoch}"); + if (firstEpoch < 1) + { + throw new InvalidOperationException($"the owner's claim carries epoch {firstEpoch} - the v2 schema's generation field is not in use live"); + } + + // ---- Survivor client: joins the boundary; both pages render on the shared browser process. ---- + survivor = LaunchIntoWorkspace(config, log, "survivor"); + var survivorMain = survivor.WaitForWindow(w => w.AutomationId == "_mainApplicationWindow", 60, "survivor main window"); + var survivorItem = SmartClientUi.WaitForMipViewItem(survivorMain); + + var joinDeadline = DateTime.Now.AddSeconds(150); + int pages = 0; + while (DateTime.Now < joinDeadline) + { + pages = CountPagesOnHost(browser, homeHost); + if (pages >= 2) break; + Thread.Sleep(2000); + } + + if (pages < 2) + { + Artifacts.Window(survivorMain, "sc-handoff-prejoin-both-pages-missing"); + throw new InvalidOperationException("precondition failed: the survivor never joined the boundary (pages on the shared browser process: " + pages + ")"); + } + + log.AppendLine("both clients render on the shared browser process - removing the owner from the picture..."); + Artifacts.Window(survivorMain, "sc-handoff-before-owner-exit"); + + // ---- The owner leaves: clean close, or hard kill (ProcessKiller). ---- + if (hardKill) + { + // NOT ProcessKiller.KillClients: that would take the SURVIVOR down too. This row + // hard-kills exactly the owner process. + owner.Process.Kill(); + var exited = WaitForExit(owner, 30); + if (!exited) throw new InvalidOperationException("the hard-killed owner process never disappeared"); + } + else + { + ownerMain.Close(); + var exited = WaitForExit(owner, 90); + if (!exited) + { + Artifacts.Window(survivorMain, "sc-handoff-owner-close-did-not-exit"); + throw new InvalidOperationException("the owner's clean close never exited the process within 90 s - the clean-exit premise of this row did not run"); + } + } + + // ---- Recovery WITHOUT user action, bounded window. Watchdog period is 5 s; the + // re-creation then rides the ~13 s browser-process recycle measured in #90. ---- + var survivorPid = survivor.Process.Id; + var deadline = DateTime.Now.AddSeconds(180); + ClaimSnapshot healed = null; + while (DateTime.Now < deadline) + { + var claim = ReadOnlyClaim(config); + if (claim != null && claim.OwnerPid == survivorPid && claim.Port != deadOwnerPort && claim.Epoch >= firstEpoch + 1) + { + healed = claim; + break; + } + + Thread.Sleep(3000); + } + + var problems = new List(); + if (healed == null) + { + var last = ReadOnlyClaim(config); + problems.Add("within 180 s the claim never became the survivor's own fresh generation (last saw: " + + (last == null ? "no single claim" : $"pid={last.OwnerPid} port={last.Port} epoch={last.Epoch}") + + "; expected pid=" + survivorPid + ", port!=" + deadOwnerPort + ", epoch>=" + (firstEpoch + 1) + ")"); + } + else + { + log.AppendLine($"handoff claim: pid={healed.OwnerPid}, port={healed.Port}, epoch={healed.Epoch} - the survivor owns the boundary at generation {healed.Epoch}"); + } + + // ---- Render witness: the survivor's proxied page comes BACK on a CDP endpoint. ---- + string recoveredHost = null; + await using var cdp2 = new CdpConnector(); + try + { + var browser2 = await cdp2.ConnectFirstAvailableAsync(config.WebView2DataRoot, 120); + var renderDeadline = DateTime.Now.AddSeconds(90); + while (DateTime.Now < renderDeadline) + { + var rendered = CountPagesOnHost(browser2, homeHost); + if (rendered >= 1) + { + recoveredHost = homeHost; + break; + } + + await Task.Delay(3000); + } + } + catch (Exception ex) + { + log.AppendLine("CDP reconnect after the handoff failed: " + ex.Message); + } + + if (recoveredHost == null) + { + Artifacts.Window(survivorMain, "sc-handoff-view-never-re-rendered"); + problems.Add("the survivor's proxied view never re-rendered after the handoff (no CDP page on host '" + homeHost + "')"); + } + else + { + log.AppendLine($"survivor's proxied page re-rendered on the new generation (host '{recoveredHost}')"); + } + + // ---- Process witness: ONE loopback --proxy-server port, and it is the NEW claim port. ---- + var proxyPorts = WitnessProxyPorts(); + log.AppendLine($"distinct loopback --proxy-server ports after the handoff: [{string.Join(", ", proxyPorts)}]"); + if (proxyPorts.Count != 1) + { + problems.Add($"{proxyPorts.Count} distinct loopback --proxy-server ports exist ({string.Join(", ", proxyPorts)}) - the recycled browser process did not settle on exactly one"); + } + else if (healed != null && proxyPorts[0] != healed.Port.ToString(System.Globalization.CultureInfo.InvariantCulture)) + { + problems.Add($"the live browser process carries --proxy-server port {proxyPorts[0]}, not the handoff claim port {healed.Port}"); + } + + // ---- The survivor lives; the dead owner's pid names nothing live. ---- + if (survivor.Process.HasExited) + { + problems.Add("the surviving Smart Client died during the handoff"); + } + + // ---- MIP log (survivor-side lines conditionally enforced; measured #87 caveat). ---- + var watchLines = FindLogLinesSince("Boundary claim", runStarted: null); + var handoffLines = watchLines.Where(l => l.Contains("handoff re-creation", StringComparison.Ordinal)).ToList(); + var takeoverLines = watchLines.Where(l => + l.Contains("no longer running", StringComparison.Ordinal) || + l.Contains("the owner left the boundary", StringComparison.Ordinal)).ToList(); + if (handoffLines.Count > 0) + { + log.AppendLine($"watcher journalled {takeoverLines.Count} notice line(s) and {handoffLines.Count} re-creation line(s) (sample: {handoffLines[0].Trim()})"); + if (takeoverLines.Count < 1) + { + problems.Add("the re-creation was journalled but the watch's owner-death/stale-claim notice line is missing"); + } + } + else + { + log.AppendLine("NOTE (measured environment fact since #87): the surviving client may journal NOTHING to the machine-wide MIPLog.txt while another client lived - the handoff lines are unenforceable in that shape; the claim + process + render witnesses above are the authoritative evidence."); + } + + Artifacts.Window(survivorMain, "sc-handoff-after-recovery"); + + if (problems.Count > 0) + { + throw new InvalidOperationException(problems.Count + " handoff problem(s): " + string.Join("; ", problems)); + } + + log.AppendLine($"VERIFIED ({shape}): the owner exited, and within the bounded window the survivor took the boundary's claim at a new generation (epoch {firstEpoch} -> {healed.Epoch}, port {deadOwnerPort} -> {healed.Port}), its proxied view re-rendered WITHOUT user action, and exactly one --proxy-server port (the new claim port) lives in the system."); + + // ---- Last-view-out-the-door: closing the survivor (now the owner) must leave NO claim. ---- + log.AppendLine("closing the last proxied view (the survivor) - its teardown must release its own claim..."); + survivorMain.Close(); + if (!WaitForExit(survivor, 90)) + { + throw new InvalidOperationException("the survivor's clean close never exited the process within 90 s - the last-view-out premise did not run"); + } + + var gone = false; + var claimGoneDeadline = DateTime.Now.AddSeconds(120); + while (DateTime.Now < claimGoneDeadline) + { + var files = Directory.EnumerateFiles(config.WebView2DataRoot, "proxied-*.claim", SearchOption.AllDirectories).ToList(); + if (files.Count == 0) + { + gone = true; + break; + } + + Thread.Sleep(3000); + } + + if (!gone) + { + var leftovers = Directory.EnumerateFiles(config.WebView2DataRoot, "proxied-*.claim", SearchOption.AllDirectories).ToList(); + throw new InvalidOperationException("the last proxied view closed cleanly, yet " + leftovers.Count + " claim file(s) remain: " + string.Join("; ", leftovers) + " - a dead-owner claim would sit on disk until the 30-day sweep"); + } + + log.AppendLine("VERIFIED: the last view out the door left no claim file behind."); + } + catch (Exception ex) + { + failure = ex.GetType().Name + ": " + ex.Message; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + Artifacts.WriteText($"sc-handoff-{shape}-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { survivor?.Dispose(); } catch { } + try { owner?.Dispose(); } catch { } + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + private ClientSession LaunchIntoWorkspace(UxConfig config, System.Text.StringBuilder log, string role) + { + var session = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(session); + LoginFlow.Login(session, login, config); + LoginFlow.AcceptCertificateTrust(session); + var main = LoginFlow.WaitForSmartClientMain(session); + try + { + SmartClientUi.Maximize(main); + } + catch (Exception) + { + // The SC main window can be re-styled mid-login; Maximize is cosmetic and the + // window is re-queried fresh right after the settle sleep anyway. + Thread.Sleep(1500); + main = session.WaitForWindow(w => w.AutomationId == "_mainApplicationWindow", 60, "main window (post-maximize retry)"); + try { SmartClientUi.Maximize(main); } catch { } + } + + Thread.Sleep(1500); + main = session.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + if (role == "owner" || !SmartClientUi.TrySelectWorkspaceWithoutInput(main, session, config.WorkspaceName)) + { + main = SmartClientUi.SelectWorkspace(main, session, config.WorkspaceName); + } + + log.AppendLine($"{role} client logged in, workspace selected"); + return session; + } + + private sealed class ClaimSnapshot + { + public int OwnerPid; + public int Port; + public long Epoch; + } + + private static ClaimSnapshot ParseClaim(string path) + { + var snapshot = new ClaimSnapshot(); + foreach (var line in File.ReadAllLines(path)) + { + var split = line.IndexOf('='); + if (split <= 0) continue; + var key = line.Substring(0, split).Trim(); + var value = line.Substring(split + 1).Trim(); + if (key == "port" && int.TryParse(value, out var port)) snapshot.Port = port; + if (key == "ownerPid" && int.TryParse(value, out var pid)) snapshot.OwnerPid = pid; + if (key == "epoch" && long.TryParse(value, out var epoch)) snapshot.Epoch = epoch; + } + + return snapshot; + } + + /// Waits for exactly one claim file satisfying ; null on timeout. + private static ClaimSnapshot WaitForClaim(UxConfig config, Func accept, int timeoutSeconds, System.Text.StringBuilder log) + { + var deadline = DateTime.Now.AddSeconds(timeoutSeconds); + while (DateTime.Now < deadline) + { + var claim = ReadOnlyClaim(config); + if (claim != null && accept(claim)) return claim; + Thread.Sleep(2000); + } + + return null; + } + + /// The single proxied-*.claim under the plugin data root (null when absent or multiple). + private static ClaimSnapshot ReadOnlyClaim(UxConfig config) + { + try + { + var files = Directory.EnumerateFiles(config.WebView2DataRoot, "proxied-*.claim", SearchOption.AllDirectories).ToList(); + if (files.Count == 1) return ParseClaim(files[0]); + if (files.Count > 1) return ParseClaim(files[0]); // the acceptance rows demand ONE; report the first + } + catch (IOException) + { + } + + return null; + } + + private static bool WaitForExit(ClientSession session, int timeoutSeconds) + { + var deadline = DateTime.Now.AddSeconds(timeoutSeconds); + while (DateTime.Now < deadline) + { + try + { + if (session.Process.HasExited) return true; + } + catch (InvalidOperationException) + { + return true; + } + + Thread.Sleep(1000); + } + + return false; + } + + /// The distinct loopback --proxy-server ports across the plugin's browser processes. + private static List WitnessProxyPorts() + { + var ports = new List(); + using var searcher = new ManagementObjectSearcher( + "SELECT ProcessId, ParentProcessId, CommandLine FROM Win32_Process WHERE Name='msedgewebview2.exe'"); + foreach (ManagementObject mo in searcher.Get()) + { + var command = (mo["CommandLine"] as string) ?? string.Empty; + if (!command.Contains("SmartClient.WebContentData", StringComparison.OrdinalIgnoreCase)) continue; + + var portMatch = Regex.Match(command, @"--proxy-server=\S*?127\.0\.0\.1:(\d+)"); + if (portMatch.Success) ports.Add(portMatch.Groups[1].Value); + } + + return ports.Distinct().ToList(); + } + + private static async Task WaitForRenderedPageHostAsync(Microsoft.Playwright.IBrowser browser, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + string seen = string.Empty; + while (DateTime.UtcNow < deadline) + { + var page = browser.Contexts.SelectMany(c => c.Pages) + .FirstOrDefault(p => !string.IsNullOrEmpty(p.Url) && + (p.Url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || p.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))); + if (page != null) + { + if (Uri.TryCreate(page.Url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host)) return uri.Host; + seen = page.Url; + } + + await Task.Delay(1000); + } + + throw new TimeoutException("no rendered (http/https) CDP page appeared on the proxied browser process. Last seen: " + seen); + } + + private static int CountPagesOnHost(Microsoft.Playwright.IBrowser browser, string host) + { + return browser.Contexts.SelectMany(c => c.Pages) + .Count(p => !string.IsNullOrEmpty(p.Url) && p.Url.Contains(host, StringComparison.OrdinalIgnoreCase)); + } + + private static List FindLogLinesSince(string needle, DateTime? runStarted) + { + return ReadScLogLines() + .Where(l => l.Contains(needle, StringComparison.Ordinal) && (runStarted == null || LineStamp(l) >= runStarted.Value)) + .Take(40) + .ToList(); + } + + /// MIP-log lines begin "2026-11-04 01:18:31.261-08:00"; unparseable lines count as old. + private static DateTime LineStamp(string line) + { + var head = line.Length >= 23 ? line.Substring(0, 23) : line; + return DateTime.TryParse(head, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var stamp) + ? stamp + : DateTime.MinValue; + } + + private static List ReadScLogLines() + { + var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Smart Client"); + var candidates = new List(); + try + { + var main = Path.Combine(dir, "MIPLog.txt"); + if (File.Exists(main)) + { + candidates.Add(main); + } + + var rolling = Path.Combine(dir, "MIPLogs"); + if (Directory.Exists(rolling)) + { + candidates.AddRange(Directory.GetFiles(rolling, "MIPLog.*.txt") + .OrderByDescending(f => new FileInfo(f).LastWriteTime) + .Take(3)); + } + } + catch (Exception) + { + return new List(); + } + + var lines = new List(); + foreach (var file in candidates) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + lines.AddRange(reader.ReadToEnd().Split('\n').Select(l => l.TrimEnd('\r'))); + } + catch (Exception) + { + } + } + + return lines; + } +} diff --git a/tests/ux/WebContent.UxTests/ScListenerJoinProbeTests.cs b/tests/ux/WebContent.UxTests/ScListenerJoinProbeTests.cs new file mode 100644 index 0000000..c0cf58a --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScListenerJoinProbeTests.cs @@ -0,0 +1,733 @@ +using System.Management; +using System.Text.RegularExpressions; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #95 live probes (SC-14): a SECOND Smart Client on the same machine joins the boundary +/// owner's claimed loopback listener instead of colliding on the proxied browser profile. +/// +/// The witnesses this ticket demanded: +/// - BOTH clients' proxied views render (the #87 shape was a blank second view with an +/// ownership notice); +/// - exactly ONE browser-process shape: one msedgewebview2 root for the proxied profile, +/// parented to the claim OWNER only — no msedgewebview2 parented to the second client, no +/// --parent-pid naming the second client, and exactly one loopback --proxy-server port in the +/// system (the joiner is a core client of the shared browser process, exactly like the second +/// core of its own normal-profile browser sharing that process); +/// - exactly ONE claim file beside the profile folder, naming the owner pid + listener port; +/// - no profile-ownership notice on either view (that #87 fallback stays only for the +/// browser-held case; the second-Smart-Client case now joins); +/// - the join attempted WHILE the owner's proxied browser is mid-navigation still lands; +/// - the MC Status tab (#69/#76) shows exactly ONE live proxy session with both clients +/// rendering - the joiner creates no second session/forwarder, its traffic shares the owner's. +/// +/// Log-line caveat (measured since #87): while one Smart Client lives, the second concurrent one +/// may write NOTHING to the machine-wide MIPLog.txt, so the joiner-side join line is only +/// asserted when this run proves the joiner journalled at all; the rendered second page and the +/// process witnesses above are the authoritative evidence either way. +/// +public sealed class ScListenerJoinProbeTests +{ + private readonly ITestOutputHelper _output; + + public ScListenerJoinProbeTests(ITestOutputHelper output) => _output = output; + + /// The core shape: second client joins; one claim, one listener, one browser process, both views render. + [Fact] + [Trait("Category", "probe")] + public async Task ProbeSecondClientJoinsOneProxiedBrowserProcess() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-listener-join-probe")); + var runStarted = DateTime.Now; + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + ClientSession owner = null; + ClientSession joiner = null; + + log.AppendLine("=== SC-14: second Smart Client joins the proxied browser process via the listener claim (#95) ==="); + try + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + Thread.Sleep(2000); + + // ---- Owner client: opens the proxied view, claims the slot, binds the listener, renders. ---- + owner = ClientSession.Launch(config.SmartClientExe); + var ownerLogin = LoginFlow.WaitForLoginWindow(owner); + LoginFlow.Login(owner, ownerLogin, config); + LoginFlow.AcceptCertificateTrust(owner); + var ownerMain = LoginFlow.WaitForSmartClientMain(owner); + SmartClientUi.Maximize(ownerMain); + Thread.Sleep(1500); + ownerMain = owner.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + ownerMain = SmartClientUi.SelectWorkspace(ownerMain, owner, config.WorkspaceName); + var ownerItem = SmartClientUi.WaitForMipViewItem(ownerMain); + log.AppendLine("owner view item open; waiting for the owner's proxied page to render..."); + + // CDP is the render witness: ONE browser process answers it, and BOTH clients' pages + // must show up on that single endpoint. + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot, 120); + var homeHost = await WaitForRenderedPageHostAsync(browser, 120); + log.AppendLine($"owner proxied page rendered (page host '{homeHost}')"); + + var ownerPid = owner.Process.Id; + + // ---- Claim witness: exactly one proxied-.claim naming the owner pid + a live port. ---- + string claimPath = null; + int claimPort = 0; + int claimOwnerPid = 0; + var claimDeadline = DateTime.Now.AddSeconds(60); + while (claimPath == null && DateTime.Now < claimDeadline) + { + var files = Directory.EnumerateFiles(config.WebView2DataRoot, "proxied-*.claim", SearchOption.AllDirectories).ToList(); + if (files.Count > 0) + { + claimPath = files[0]; + foreach (var line in File.ReadAllLines(claimPath)) + { + var split = line.IndexOf('='); + if (split <= 0) continue; + var key = line.Substring(0, split).Trim(); + var value = line.Substring(split + 1).Trim(); + if (key == "port" && int.TryParse(value, out var port)) claimPort = port; + if (key == "ownerPid" && int.TryParse(value, out var pid)) claimOwnerPid = pid; + } + } + else Thread.Sleep(2000); + } + + var problems = new List(); + if (claimPath == null) + { + problems.Add("no listener claim file (proxied-*.claim) ever appeared under the plugin data root - the owner never claimed the boundary slot"); + } + else + { + log.AppendLine($"claim file: {claimPath} -> ownerPid={claimOwnerPid}, port={claimPort}"); + if (claimOwnerPid != ownerPid) + { + problems.Add($"the claim names owner pid {claimOwnerPid}, but the live owner client is pid {ownerPid}"); + } + + if (claimPort <= 0) + { + problems.Add("the claim names no usable listener port"); + } + + var siblings = Directory.EnumerateFiles(config.WebView2DataRoot, "proxied-*.claim", SearchOption.AllDirectories).ToList(); + if (siblings.Count != 1) + { + problems.Add($"{siblings.Count} claim files exist, expected exactly one: {string.Join("; ", siblings)}"); + } + } + + // ---- Second client: same machine, same workspace, the SAME proxied boundary profile. ---- + joiner = ClientSession.Launch(config.SmartClientExe); + var joinerLogin = LoginFlow.WaitForLoginWindow(joiner); + LoginFlow.Login(joiner, joinerLogin, config); + LoginFlow.AcceptCertificateTrust(joiner); + var joinerMain = LoginFlow.WaitForSmartClientMain(joiner); + SmartClientUi.Maximize(joinerMain); + Thread.Sleep(1500); + joinerMain = joiner.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + if (!SmartClientUi.TrySelectWorkspaceWithoutInput(joinerMain, joiner, config.WorkspaceName)) + { + joinerMain = SmartClientUi.SelectWorkspace(joinerMain, joiner, config.WorkspaceName); + } + + var joinerItem = SmartClientUi.WaitForMipViewItem(joinerMain); + log.AppendLine("second client's view item open; waiting for the SECOND proxied page on the shared browser process..."); + + // Before #95 the second client's browser never came up at all - exactly one page + // existed forever. Two page targets on the ONE CDP endpoint is the join witness. + var joinDeadline = DateTime.Now.AddSeconds(150); + int pages = 0; + while (DateTime.Now < joinDeadline) + { + pages = CountPagesOnHost(browser, homeHost); + if (pages >= 2) break; + Thread.Sleep(2000); + } + + log.AppendLine($"proxied pages on the browser process: {pages}"); + if (pages < 2) + { + Artifacts.Window(joinerMain, "sc-join-second-page-missing"); + throw new InvalidOperationException("the second client's proxied view never rendered - the join contract did not take effect (pages: " + pages + ")"); + } + + Artifacts.Window(ownerMain, "sc-join-owner-view"); + Artifacts.Window(joinerMain, "sc-join-joiner-view"); + + // ---- No ownership notice anywhere (this contract replaces the #87 notice for SC+SC). ---- + if (FindDescendant(joinerItem, "profileOwnershipHeadline") != null) + { + problems.Add("the second client's view shows the #87 profile-ownership notice - it was refused instead of joining"); + } + + var ownerItem2 = SmartClientUi.FindMipViewItem(ownerMain) ?? ownerItem; + if (FindDescendant(ownerItem2, "profileOwnershipHeadline") != null) + { + problems.Add("the owner's view shows a profile-ownership notice - the contract leaked onto the healthy session"); + } + + // ---- Process witnesses (WMI): ONE proxied browser-process shape. ---- + var joinerPid = joiner.Process.Id; + var (total, parentedByJoiner, parentedByOwner, joinerParentIdReferences, proxyPorts) = WitnessBrowserProcessShape(ownerPid, joinerPid); + log.AppendLine($"msedgewebview2 processes under the data root: {total}; parented to the joiner: {parentedByJoiner}; parented to the owner: {parentedByOwner}; --parent-pid naming the joiner: {joinerParentIdReferences}; distinct loopback --proxy-server ports: [{string.Join(", ", proxyPorts)}]"); + if (parentedByJoiner != 0) + { + problems.Add($"{parentedByJoiner} msedgewebview2 process(es) are parented to the SECOND client - it launched its own browser process instead of joining the shared one"); + } + + if (joinerParentIdReferences != 0) + { + problems.Add($"{joinerParentIdReferences} msedgewebview2 process(es) carry --parent-pid={joinerPid} - the second client launched browser helper processes"); + } + + if (parentedByOwner != 1) + { + problems.Add($"{parentedByOwner} msedgewebview2 processes are parented to the owner (expected exactly its one proxied browser root)"); + } + + if (proxyPorts.Count != 1) + { + problems.Add($"{proxyPorts.Count} distinct loopback --proxy-server ports exist ({string.Join(", ", proxyPorts)}), expected exactly the owner's one listener"); + } + else if (claimPort > 0 && proxyPorts[0] != claimPort.ToString(System.Globalization.CultureInfo.InvariantCulture)) + { + problems.Add($"the browser's --proxy-server port {proxyPorts[0]} is not the claimed listener port {claimPort}"); + } + + // ---- MIP log (joiner lines conditionally enforced; measured caveat since #87). ---- + var claimOwnerLines = FindLogLinesSince("loopback listener on 127.0.0.1:", runStarted) + .Where(l => l.Contains("(claim owner)", StringComparison.Ordinal)).ToList(); + var joinLines = FindLogLinesSince("joined the boundary owner's loopback listener", runStarted); + var pluginLoads = CountLogLinesSince("Web Content, loaded", runStarted); + var joinerLogged = pluginLoads >= 2; + log.AppendLine($"'Web Content' plugin load lines since run start: {pluginLoads} (second client demonstrably logged: {joinerLogged}); owner claim lines: {claimOwnerLines.Count}, join lines: {joinLines.Count}"); + if (claimOwnerLines.Count < 1) + { + problems.Add("the owner's 'loopback listener on 127.0.0.1: (claim owner)' creation line is missing from the MIP log"); + } + + if (joinerLogged && joinLines.Count < 1) + { + problems.Add("the second client demonstrably journalled to MIPLog.txt, but its 'joined the boundary owner's loopback listener' line is missing"); + } + else if (!joinerLogged) + { + log.AppendLine("NOTE (measured environment fact since #87): while one Smart Client lives, the second concurrent one may write NOTHING to the machine-wide MIPLog.txt - its join line is unenforceable in that shape, and the rendered second page + the process witnesses above are the authoritative evidence."); + } + + if (problems.Count > 0) + { + throw new InvalidOperationException(problems.Count + " join-contract problem(s): " + string.Join("; ", problems)); + } + + log.AppendLine("VERIFIED: both Smart Clients render the proxied view; exactly one claim file (owner pid + port) and one loopback listener port; one msedgewebview2 process shape (root parented to the owner, nothing parented to the second client); no ownership notice on either view."); + } + catch (Exception ex) + { + failure = ex.GetType().Name + ": " + ex.Message; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + Artifacts.WriteText("sc-listener-join-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { joiner?.Dispose(); } catch { } + try { owner?.Dispose(); } catch { } + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + /// The brief's timing row: the join runs while the owner's proxied browser is mid-navigation. + [Fact] + [Trait("Category", "probe")] + public async Task ProbeJoinWhileOwnerIsNavigating() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-listener-join-navigating-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + ClientSession owner = null; + ClientSession joiner = null; + + log.AppendLine("=== SC-14b: join attempted while the owner's proxied browser is mid-navigation ==="); + try + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + Thread.Sleep(2000); + + owner = ClientSession.Launch(config.SmartClientExe); + var ownerLogin = LoginFlow.WaitForLoginWindow(owner); + LoginFlow.Login(owner, ownerLogin, config); + LoginFlow.AcceptCertificateTrust(owner); + var ownerMain = LoginFlow.WaitForSmartClientMain(owner); + SmartClientUi.Maximize(ownerMain); + Thread.Sleep(1500); + ownerMain = owner.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + ownerMain = SmartClientUi.SelectWorkspace(ownerMain, owner, config.WorkspaceName); + var ownerItem = SmartClientUi.WaitForMipViewItem(ownerMain); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot, 120); + var homeHost = await WaitForRenderedPageHostAsync(browser, 120); + log.AppendLine($"owner proxied page rendered (page host '{homeHost}')"); + + joiner = ClientSession.Launch(config.SmartClientExe); + var joinerLogin = LoginFlow.WaitForLoginWindow(joiner); + LoginFlow.Login(joiner, joinerLogin, config); + LoginFlow.AcceptCertificateTrust(joiner); + var joinerMain = LoginFlow.WaitForSmartClientMain(joiner); + SmartClientUi.Maximize(joinerMain); + Thread.Sleep(1500); + joinerMain = joiner.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + if (!SmartClientUi.TrySelectWorkspaceWithoutInput(joinerMain, joiner, config.WorkspaceName)) + { + joinerMain = SmartClientUi.SelectWorkspace(joinerMain, joiner, config.WorkspaceName); + } + + var joinerItem = SmartClientUi.WaitForMipViewItem(joinerMain); + log.AppendLine("second client's view item open (its proxied browser creation runs NOW) - re-navigating the OWNER mid-join..."); + + // The overlap window: the second client's CoreWebView2 creation (consult -> join) runs + // while the owner's browser is busy re-navigating its proxied page. A consult/claim + // race would break the owner's rendering or refuse the joiner. + var ownerItemNow = SmartClientUi.FindMipViewItem(ownerMain) ?? ownerItem; + var ownerRefresh = FindDescendant(ownerItemNow, "NavReload"); + var navigated = false; + if (ownerRefresh != null) + { + try { ownerRefresh.Patterns.Invoke.Pattern.Invoke(); navigated = true; } + catch { ownerRefresh.Click(); navigated = true; } + + log.AppendLine("owner view re-navigated (NavReload) mid-join"); + } + else + { + log.AppendLine("NOTE: the owner view's NavReload button was not reachable via UIA in this run - the mid-navigation overlap was not exercised."); + } + + var joinDeadline = DateTime.Now.AddSeconds(150); + int pages = 0; + while (DateTime.Now < joinDeadline) + { + pages = CountPagesOnHost(browser, homeHost); + if (pages >= 2) break; + Thread.Sleep(2000); + } + + var problems = new List(); + if (pages < 2) + { + Artifacts.Window(joinerMain, "sc-join-navigating-second-page-missing"); + problems.Add("the second client's proxied view never rendered while the owner navigated (pages: " + pages + ")"); + } + + if (FindDescendant(joinerItem, "profileOwnershipHeadline") != null) + { + problems.Add("the second client's view shows the profile-ownership notice - the mid-navigation join was refused"); + } + + var ownerItemAfter = SmartClientUi.FindMipViewItem(ownerMain) ?? ownerItem; + if (FindDescendant(ownerItemAfter, "profileOwnershipHeadline") != null) + { + problems.Add("the owner's view shows an ownership notice - the mid-navigation join broke the healthy session"); + } + + if (owner.Process.HasExited) + { + problems.Add("the OWNER Smart Client died during the mid-navigation join"); + } + + if (joiner.Process.HasExited) + { + problems.Add("the joining Smart Client died during the mid-navigation join"); + } + + Artifacts.Window(joinerMain, "sc-join-navigating-joiner-view"); + Artifacts.Window(ownerMain, "sc-join-navigating-owner-view"); + + if (problems.Count > 0) + { + throw new InvalidOperationException(problems.Count + " mid-navigation join problem(s): " + string.Join("; ", problems)); + } + + log.AppendLine($"VERIFIED: the join landed while the owner's browser was mid-navigation{(!navigated ? " (navigation overlap unexercised - see NOTE)" : string.Empty)} - both proxied views render, no ownership notice anywhere, both clients alive."); + } + catch (Exception ex) + { + failure = ex.GetType().Name + ": " + ex.Message; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + Artifacts.WriteText("sc-listener-join-navigating-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { joiner?.Dispose(); } catch { } + try { owner?.Dispose(); } catch { } + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + /// + /// The #69 status row under #95: with BOTH clients rendering proxied views, the MC Status tab + /// shows exactly ONE live proxy session - the joiner opens no second session/forwarder, its + /// traffic shares the owner's session. + /// + [Fact] + [Trait("Category", "probe")] + public async Task ProbeStatusTabShowsSingleSharedSession() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-listener-join-status-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + ClientSession owner = null; + ClientSession joiner = null; + ClientSession mc = null; + Window mcMain = null; + + log.AppendLine("=== SC-14c: MC Status tab shows ONE live proxy session with two proxied Smart Clients ==="); + try + { + ProcessKiller.KillClients("Client", "VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + Thread.Sleep(2000); + + // ---- MC FIRST: the Status tab must prove a ZERO-session baseline before this row's + // clients launch. Rows that kill Smart Clients leak their ES sessions until the + // 3-minute idle backstop (30 s sweep) reclaims them, and a dead session is + // indistinguishable from a live second one in this grid. ---- + mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + var tab = ManagementClientUi.ExpectControl(mcMain, "tabStatus", 15); + try { tab.Patterns.SelectionItem.Pattern.Select(); } catch { tab.Click(); } + ManagementClientUi.ExpectControl(mcMain, "dgStatusSessions", 15); + ManagementClientUi.ExpectControl(mcMain, "txtStatusSummary", 5); + Thread.Sleep(3000); + + var baselineDeadline = DateTime.Now.AddSeconds(300); + var baselineSummary = string.Empty; + while (DateTime.Now < baselineDeadline) + { + try + { + var summary = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("txtStatusSummary")); + baselineSummary = summary?.Name ?? string.Empty; + } + catch { } + + if (baselineSummary.Contains("No live proxy sessions", StringComparison.Ordinal)) break; + Thread.Sleep(5000); + } + + if (!baselineSummary.Contains("No live proxy sessions", StringComparison.Ordinal)) + { + throw new InvalidOperationException("the Status tab baseline never drained to zero live sessions within 300 s (last: '" + baselineSummary + "') - leaked sessions make the single-shared-session witness unmeasurable; retry after the Event Server's idle backstop"); + } + + log.AppendLine($"status baseline proven zero: '{baselineSummary}'"); + log.AppendLine("launching both proxied Smart Clients..."); + + owner = ClientSession.Launch(config.SmartClientExe); + var ownerLogin = LoginFlow.WaitForLoginWindow(owner); + LoginFlow.Login(owner, ownerLogin, config); + LoginFlow.AcceptCertificateTrust(owner); + var ownerMain = LoginFlow.WaitForSmartClientMain(owner); + SmartClientUi.Maximize(ownerMain); + Thread.Sleep(1500); + ownerMain = owner.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + ownerMain = SmartClientUi.SelectWorkspace(ownerMain, owner, config.WorkspaceName); + SmartClientUi.WaitForMipViewItem(ownerMain); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot, 120); + var homeHost = await WaitForRenderedPageHostAsync(browser, 120); + log.AppendLine($"owner proxied page rendered (page host '{homeHost}')"); + + joiner = ClientSession.Launch(config.SmartClientExe); + var joinerLogin = LoginFlow.WaitForLoginWindow(joiner); + LoginFlow.Login(joiner, joinerLogin, config); + LoginFlow.AcceptCertificateTrust(joiner); + var joinerMain = LoginFlow.WaitForSmartClientMain(joiner); + SmartClientUi.Maximize(joinerMain); + Thread.Sleep(1500); + joinerMain = joiner.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + if (!SmartClientUi.TrySelectWorkspaceWithoutInput(joinerMain, joiner, config.WorkspaceName)) + { + joinerMain = SmartClientUi.SelectWorkspace(joinerMain, joiner, config.WorkspaceName); + } + + SmartClientUi.WaitForMipViewItem(joinerMain); + + var joinDeadline = DateTime.Now.AddSeconds(150); + int pages = 0; + while (DateTime.Now < joinDeadline) + { + pages = CountPagesOnHost(browser, homeHost); + if (pages >= 2) break; + Thread.Sleep(2000); + } + + if (pages < 2) + { + throw new InvalidOperationException("the second client's proxied view never rendered - this row needs BOTH proxied views up (pages: " + pages + ")"); + } + + log.AppendLine("both proxied views render; watching the status summary..."); + + // The status pull cadence is ~3 s plus heartbeats; from the proven-zero baseline the + // ONLY session that can appear is the owner's one. Regex the count so "1 live proxy + // session(s)" is never substring-satisfied by "11 ..." and the two-session shape + // (the naive non-shared fix) fails fast. + var deadline = DateTime.Now.AddSeconds(180); + var lastCount = 0; + var lastSummary = baselineSummary; + while (DateTime.Now < deadline) + { + try + { + var summary = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("txtStatusSummary")); + lastSummary = summary?.Name ?? string.Empty; + } + catch { } + + var match = Regex.Match(lastSummary, @"(\d+)\s+live proxy session"); + lastCount = match.Success ? int.Parse(match.Groups[1].Value) : 0; + if (lastCount >= 1) break; + Thread.Sleep(5000); + } + + log.AppendLine($"status summary: '{lastSummary}' (session count: {lastCount})"); + Artifacts.Window(mcMain, "sc-join-status-single-session"); + + if (lastCount >= 2) + { + throw new InvalidOperationException($"the Status tab shows {lastCount} live proxy sessions with two proxied clients - the second client registered its own #69 session instead of sharing the owner's"); + } + + if (lastCount != 1) + { + throw new InvalidOperationException("the Status tab never showed exactly '1 live proxy session(s)' with both proxied clients up; last summary: '" + lastSummary + "'"); + } + + var grid = mcMain.FindFirstDescendant(cf => cf.ByAutomationId("dgStatusSessions")); + if (grid != null) + { + var rows = grid.FindAllDescendants(cf => cf.ByControlType(ControlType.DataItem)).Length; + log.AppendLine($"status session grid rows: {rows}"); + if (rows > 1) + { + throw new InvalidOperationException($"the status session grid lists {rows} sessions for one proxied profile - expected the owner's one"); + } + } + + log.AppendLine("VERIFIED: from a proven-zero baseline, with both Smart Clients rendering the proxied view, the MC Status tab shows exactly ONE live proxy session - the joiner shares the owner's session and traffic."); + } + catch (Exception ex) + { + failure = ex.GetType().Name + ": " + ex.Message; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + Artifacts.WriteText("sc-listener-join-status-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + try { mcMain?.Close(); } catch { } + if (mc != null) ManagementClientUi.AcceptPendingModal(mc, 5); + try { joiner?.Dispose(); } catch { } + try { owner?.Dispose(); } catch { } + ProcessKiller.KillClients("Client", "VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + /// + /// WMI witness of the proxied browser-process shape under the plugin data root: total + /// msedgewebview2 processes, how many are parented to each client, how many carry + /// --parent-pid naming the second client, and the distinct loopback --proxy-server ports. + /// + private static (int Total, int ParentedByJoiner, int ParentedByOwner, int JoinerParentIdReferences, List ProxyPorts) + WitnessBrowserProcessShape(int ownerPid, int joinerPid) + { + var total = 0; + var parentedByJoiner = 0; + var parentedByOwner = 0; + var joinerParentIdReferences = 0; + var ports = new List(); + using var searcher = new ManagementObjectSearcher( + "SELECT ProcessId, ParentProcessId, CommandLine FROM Win32_Process WHERE Name='msedgewebview2.exe'"); + foreach (ManagementObject mo in searcher.Get()) + { + var command = (mo["CommandLine"] as string) ?? string.Empty; + + // Scope to the plugin's own browser processes (same marker ProcessKiller uses) so + // unrelated WebView2 apps on the workstation never count. + if (!command.Contains("SmartClient.WebContentData", StringComparison.OrdinalIgnoreCase)) continue; + total++; + + var parent = Convert.ToInt32(mo["ParentProcessId"]); + if (parent == joinerPid) parentedByJoiner++; + if (parent == ownerPid) parentedByOwner++; + if (command.Contains("--parent-pid=" + joinerPid.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)) joinerParentIdReferences++; + + var portMatch = Regex.Match(command, @"--proxy-server=\S*?127\.0\.0\.1:(\d+)"); + if (portMatch.Success) ports.Add(portMatch.Groups[1].Value); + } + + return (total, parentedByJoiner, parentedByOwner, joinerParentIdReferences, ports.Distinct().ToList()); + } + + /// + /// Waits for the owner's proxied page to actually render on the browser process and returns + /// its URL host - the fragment both clients' pages must then share. Deliberately NOT + /// config.HomeAddressFragment: the lab workspace's home address is live config (the uxconfig + /// fragment goes stale), while the rendered page is the ground truth. + /// + private static async Task WaitForRenderedPageHostAsync(Microsoft.Playwright.IBrowser browser, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + string seen = string.Empty; + while (DateTime.UtcNow < deadline) + { + var page = browser.Contexts.SelectMany(c => c.Pages) + .FirstOrDefault(p => !string.IsNullOrEmpty(p.Url) && + (p.Url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || p.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))); + if (page != null) + { + if (Uri.TryCreate(page.Url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host)) return uri.Host; + seen = page.Url; + } + + await Task.Delay(1000); + } + + throw new TimeoutException("no rendered (http/https) CDP page appeared on the proxied browser process. Last seen: " + seen); + } + + private static int CountPagesOnHost(Microsoft.Playwright.IBrowser browser, string host) + { + return browser.Contexts.SelectMany(c => c.Pages) + .Count(p => !string.IsNullOrEmpty(p.Url) && p.Url.Contains(host, StringComparison.OrdinalIgnoreCase)); + } + + private static List FindLogLinesSince(string needle, DateTime since) + { + return ReadScLogLines().Where(l => l.Contains(needle, StringComparison.Ordinal) && LineStamp(l) >= since).Take(40).ToList(); + } + + private static int CountLogLinesSince(string needle, DateTime since) + { + return ReadScLogLines().Count(l => l.Contains(needle, StringComparison.Ordinal) && LineStamp(l) >= since); + } + + /// MIP-log lines begin "2026-11-03 23:16:56.261-08:00"; unparseable lines count as old. + private static DateTime LineStamp(string line) + { + var head = line.Length >= 23 ? line.Substring(0, 23) : line; + return DateTime.TryParse(head, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var stamp) + ? stamp + : DateTime.MinValue; + } + + private static List ReadScLogLines() + { + var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Smart Client"); + var candidates = new List(); + try + { + var main = Path.Combine(dir, "MIPLog.txt"); + if (File.Exists(main)) + { + candidates.Add(main); + } + + var rolling = Path.Combine(dir, "MIPLogs"); + if (Directory.Exists(rolling)) + { + candidates.AddRange(Directory.GetFiles(rolling, "MIPLog.*.txt") + .OrderByDescending(f => new FileInfo(f).LastWriteTime) + .Take(3)); + } + } + catch (Exception) + { + return new List(); + } + + var lines = new List(); + foreach (var file in candidates) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + lines.AddRange(reader.ReadToEnd().Split('\n').Select(l => l.TrimEnd('\r'))); + } + catch (Exception) + { + } + } + + return lines; + } + + private static AutomationElement FindDescendant(AutomationElement root, string automationId) + { + try + { + return root.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + } + catch + { + return null; + } + } +} diff --git a/tests/ux/WebContent.UxTests/ScManagedWorkspaceProbeTests.cs b/tests/ux/WebContent.UxTests/ScManagedWorkspaceProbeTests.cs new file mode 100644 index 0000000..2a48cb8 --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScManagedWorkspaceProbeTests.cs @@ -0,0 +1,245 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #71 live probe (SC-9): the Smart Client properties pane of the WebContent view item +/// inside the admin-defined (managed) workspace renders the security-relevant field — the +/// pinned home/target URL — DISABLED with a "Managed by Administrator" indicator (and the same +/// notice under the read-only Proxy posture), while the cosmetic checkboxes stay enabled and a +/// per-instance choice survives the workspace re-seed that runs whenever the view/properties is +/// regenerated. The toggle cycle proves both halves of the acceptance criteria: locked settings +/// never accept a user edit, unlocked ones save per instance even though the definition seeds +/// their initial value. +/// +public class ScManagedWorkspaceProbeTests +{ + private const string ManagedNoticeText = "Managed by Administrator"; + private const string LockCheckboxId = "chkClearBrowsingDataOnExit"; + + private readonly ITestOutputHelper _output; + + public ScManagedWorkspaceProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "ux")] + public void ProbeManagedWorkspaceLocksPropertiesAndKeepsPerInstanceChoices() + { + var config = UxConfig.Load(); + Artifacts.Init(config.ArtifactsRoot); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + using var sc = ClientSession.Launch(config.SmartClientExe); + var scLogin = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, scLogin, config); + LoginFlow.AcceptCertificateTrust(sc); + var scMain = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(scMain); + Thread.Sleep(2000); + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + var restoreToggleToOff = false; + try + { + scMain = SmartClientUi.SelectWorkspace(scMain, sc, config.WorkspaceName); + SmartClientUi.WaitForMipViewItem(scMain, 60); + + // ---- setup phase 1: managed posture ---- + scMain = EnterSetupAndSelectTile(sc, scMain); + var props = WaitForPropertiesControl(sc); + + var home = Require(props, "txtHomeAddress"); + Assert.False(home.IsEnabled, "the pinned Home address field must render disabled in a managed workspace"); + var homeValue = home.Patterns.Value.IsSupported ? home.Patterns.Value.Pattern.Value.Value : string.Empty; + Assert.False(string.IsNullOrWhiteSpace(homeValue), "the pinned home URL must still be DISPLAYED (read-only), not blanked out"); + _output.WriteLine($"home field disabled, pinned value shown: {homeValue}"); + + var homeNotice = Require(props, "managedHomeNotice"); + var proxyNotice = Require(props, "managedProxyNotice"); + Assert.Equal(ManagedNoticeText, homeNotice.Name); + Assert.Equal(ManagedNoticeText, proxyNotice.Name); + Artifacts.Window(scMain, "sc-managed-locked-home"); + + var lockCheck = Require(props, LockCheckboxId); + Assert.True(lockCheck.IsEnabled, "a cosmetic checkbox must stay enabled in a managed workspace (per-instance editable)"); + + // Per-instance override of an UNLOCKED setting: flip it on (Toggle pattern works even + // when the pane scrolled the row out of view; no mouse needed). + if (lockCheck.Patterns.Toggle.Pattern.ToggleState.Value != ToggleState.On) + { + lockCheck.Patterns.Toggle.Pattern.Toggle(); + restoreToggleToOff = true; + Thread.Sleep(1200); + } + Assert.Equal(ToggleState.On, lockCheck.Patterns.Toggle.Pattern.ToggleState.Value); + _output.WriteLine("per-instance override stored: ClearBrowsingDataOnExit = on"); + + // ---- setup phase 2: regenerate the pane through a full exit/re-enter setup cycle. + // Leaving and re-entering setup re-runs GeneratePropertiesWpfUserControl, which + // re-seeds from the admin definition (fingerprint unchanged) - the override must + // survive and the locks must stay locked. + SmartClientUi.ClickSetup(scMain); + Thread.Sleep(2500); + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + scMain = EnterSetupAndSelectTile(sc, scMain); + props = WaitForPropertiesControl(sc); + + var lockCheck2 = Require(props, LockCheckboxId); + Assert.Equal(ToggleState.On, lockCheck2.Patterns.Toggle.Pattern.ToggleState.Value); + _output.WriteLine("override survived the workspace re-seed (unlocked keys keep per-instance values)"); + + var home2 = Require(props, "txtHomeAddress"); + Assert.False(home2.IsEnabled, "the Home address field must stay disabled after re-seeding"); + Assert.Equal(ManagedNoticeText, Require(props, "managedHomeNotice").Name); + Assert.Equal(ManagedNoticeText, Require(props, "managedProxyNotice").Name); + Artifacts.Window(scMain, "sc-managed-after-reseed"); + + // The seeding pass logs its decision per view item; when the log is present the + // marker must exist (missing logs are no proof, per the repo's log caveats). + if (MipLogFilesPresent()) + { + Assert.True(MipLogContains("WebContent workspace seed:"), + "Smart Client MIPLog must carry the workspace seed decision line"); + _output.WriteLine("MIPLog carries the 'WebContent workspace seed:' line"); + } + else + { + _output.WriteLine("NOTE: no Smart Client MIPLog file readable this session; seed marker not asserted (missing logs are not evidence the plugin never ran)."); + } + + // ---- restore: put the per-instance override back to off. ---- + if (restoreToggleToOff) + { + lockCheck2.Patterns.Toggle.Pattern.Toggle(); + Thread.Sleep(1200); + var props3 = WaitForPropertiesControl(sc); + Assert.NotEqual(ToggleState.On, Require(props3, LockCheckboxId).Patterns.Toggle.Pattern.ToggleState.Value); + _output.WriteLine("per-instance override restored to off"); + } + } + catch + { + var fresh = sc.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow"); + if (fresh != null) + { + Artifacts.Window(fresh, "sc-managed-locks-failure-state"); + Artifacts.WriteText("sc-managed-locks-failure-tree.txt", TreeDump.Of(fresh, 12)); + } + throw; + } + finally + { + if (restoreToggleToOff) + { + try + { + var pane = sc.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow"); + var checkbox = pane?.FindFirstDescendant(cf => cf.ByAutomationId(LockCheckboxId)); + if (checkbox != null && checkbox.Patterns.Toggle.IsSupported && + checkbox.Patterns.Toggle.Pattern.ToggleState.Value == ToggleState.On) + { + checkbox.Patterns.Toggle.Pattern.Toggle(); + } + } + catch { } + } + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + } + + /// Enters setup mode, opens the Properties side panel when closed, and clicks the + /// top of the WebContent tile to select the gadget (same selection posture as the README + /// screenshot run: near the top so focus never goes deep into the embedded browser). + private Window EnterSetupAndSelectTile(ClientSession sc, Window scMain) + { + SmartClientUi.ClickSetup(scMain); + Thread.Sleep(3000); + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + if (!SmartClientUi.SidePanelsOpen(scMain)) + SmartClientUi.TryInvokeById(scMain, "PanesSetupProperties"); + Thread.Sleep(1500); + scMain = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + var tile = SmartClientUi.WaitForMipViewItem(scMain, 30); + var tr = tile.BoundingRectangle; + Mouse.Click(new System.Drawing.Point((int)(tr.X + tr.Width / 2), (int)(tr.Y + 15))); + Thread.Sleep(2500); + return sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + } + + /// Waits for the plugin's properties UserControl to render into the setup Properties + /// panel (it only exists once the gadget is selected) and returns it. + private AutomationElement WaitForPropertiesControl(ClientSession sc) + { + var deadline = DateTime.UtcNow.AddSeconds(45); + while (DateTime.UtcNow < deadline) + { + var main = sc.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow"); + var props = main?.FindFirstDescendant(cf => cf.ByClassName("WebContentPropertiesUserControl")); + if (props != null) return props; + Thread.Sleep(1000); + } + throw new TimeoutException("The plugin's WebContentPropertiesUserControl never rendered in the setup Properties panel."); + } + + private static AutomationElement Require(AutomationElement props, string automationId) + { + var element = props.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + if (element == null) + throw new InvalidOperationException($"properties-pane control '{automationId}' not found - the pane rendered without it."); + return element; + } + + private static bool MipLogFilesPresent() + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Smart Client"); + return File.Exists(Path.Combine(root, "MIPLog.txt")) || Directory.Exists(Path.Combine(root, "MIPLogs")); + } + + /// Searches the Smart Client MIP logs (MIPLog.txt plus rolling MIPLogs copies). + private static bool MipLogContains(string marker) + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Smart Client"); + var candidates = new List(); + try + { + var top = Path.Combine(root, "MIPLog.txt"); + if (File.Exists(top)) candidates.Add(top); + var rollDir = Path.Combine(root, "MIPLogs"); + if (Directory.Exists(rollDir)) candidates.AddRange(Directory.EnumerateFiles(rollDir, "MIPLog*.txt")); + } + catch { return false; } + + foreach (var file in candidates) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, true); + string line; + while ((line = reader.ReadLine()) != null) + { + if (line.Contains(marker, StringComparison.Ordinal)) return true; + } + } + catch { /* locked mid-write or unreadable: try the next file */ } + } + return false; + } +} diff --git a/tests/ux/WebContent.UxTests/ScOsProxyInheritanceProbeTests.cs b/tests/ux/WebContent.UxTests/ScOsProxyInheritanceProbeTests.cs new file mode 100644 index 0000000..03d8857 --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScOsProxyInheritanceProbeTests.cs @@ -0,0 +1,512 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +using FlaUI.Core.AutomationElements; + +using Microsoft.Playwright; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Diagnostic probe (#67, verification-matrix SC-8): the OS-configured (WinINET) proxy and the +/// workspace proxy, end to end on the real desktop. +/// +/// Row 1 (inheritance): with the per-user WinINET proxy pointed at a local logging forward +/// proxy harness, a NON-proxied view (routing off) of http://wininet-proxy.invalid/ renders +/// the harness's marker page. The host sits on the RFC 2606 .invalid TLD - it can never +/// resolve - so the ONLY route to any bytes is the OS proxy: the rendered page IS the proof. +/// The browser processes are additionally read from their live command lines: no +/// --proxy-server / --no-proxy-server switch anywhere (the plugin injects nothing; WebView2 +/// falls back to the system proxy on its own). +/// +/// Row 2 (deliberate contrast): the SAME OS proxy still pointed at the harness, the SAME +/// home url, routing ON: the view shows the plugin's own 403 allowlist page, the harness +/// journal stays empty for that origin (the browser never asked the OS proxy), and the +/// live browser process command lines carry the loopback pin - +/// --proxy-server=http=http://127.0.0.1:;... plus --proxy-bypass-list=<-loopback>. +/// +/// The rows mutate live state and restore it: the per-user WinINET proxy settings (registry +/// snapshot/restore, no elevation) and the Demo Workspace's routing toggle + home address +/// (Management Client phase, audit-line verified). Artifacts land in +/// webcontent-os-proxy-probe\<run> like the other proxy probes. +/// +public class ScOsProxyInheritanceProbeTests +{ + /// Home url for both rows: a host on the RFC 2606 .invalid TLD can never resolve, + /// so a served page can only have come through the configured OS proxy - and no sane + /// workspace allowlist lists it, keeping the row-2 refusal stable. + private const string OsProxyHome = "http://wininet-proxy.invalid/"; + + /// The standing lab baseline this probe restores to when its own reads failed. + private const string BaselineHome = "http://httpforever.com/"; + + private const string DataRootMarker = "SmartClient.WebContentData"; + + private readonly ITestOutputHelper _output; + + public ScOsProxyInheritanceProbeTests(ITestOutputHelper output) => _output = output; + + /// + /// Row 1: routing OFF + the WinINET system proxy pointed at the logging harness. The + /// non-proxied WebView2 must inherit the OS proxy: the harness's marker page renders at + /// the .invalid url (which can never resolve directly), the harness journals the + /// browser's absolute-form GET, and no browser process carries any proxy switch. + /// + [Fact] + [Trait("Category", "probe")] + public async Task ProbeNonProxiedViewRoutesThroughOsConfiguredProxy() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-os-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + string failure = null; + string title = null; + string bodyText = null; + string documentUrl = null; + WorkspaceState workspace = null; + WinInetProxyHarness harness = null; + WinInetProxyControl.Snapshot wininet = null; + var wininetApplied = false; + ClientSession sc = null; + List browserArgs = new(); + List harnessJournal = new(); + try + { + try + { + workspace = ProvisionWorkspace(config, home: OsProxyHome, routing: false, log); + } + catch (Exception ex) + { + // The edit may have stored before the verification threw: put the item back to + // the documented lab baseline (routing on, httpforever home) best-effort. + log.AppendLine($"provisioning threw ({ex.GetType().Name}: {ex.Message}); restoring the baseline workspace state"); + RestoreToBaseline(config, log); + throw; + } + + harness = new WinInetProxyHarness(); + wininet = WinInetProxyControl.Capture(); + WinInetProxyControl.UseLoopbackProxy(harness.Port); + wininetApplied = true; + log.AppendLine($"WinINET (HKCU) proxy set to 127.0.0.1:{harness.Port} " + + $"(was enable={wininet.ProxyEnable} server='{wininet.ProxyServer}' override='{wininet.ProxyOverride}'); " + + $"user-scope WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='{Environment.GetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", EnvironmentVariableTarget.User) ?? ""}'"); + + sc = LaunchSmartClient(config, log); + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, "wininet-proxy.invalid", timeoutSeconds: 90); + await page.WaitForSelectorAsync("body", new PageWaitForSelectorOptions { Timeout = 30000 }); + + documentUrl = page.Url; + title = await page.TitleAsync(); + var body = await page.QuerySelectorAsync("body"); + bodyText = body == null ? null : await body.TextContentAsync(); + log.AppendLine($"start document -> url='{documentUrl}' title='{title}'"); + log.AppendLine($"body: {Summarize(bodyText)}"); + await page.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("os-proxy-nonproxied-harness-page.png") }); + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + // Capture the live browser command lines BEFORE anything is killed: which switches + // the browser was LAUNCHED with is the routing evidence this row is about. + browserArgs = BrowserProcessArgs.CommandLines(DataRootMarker); + harnessJournal = harness?.Journal() ?? new List(); + + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers(DataRootMarker); + sc?.Dispose(); + if (wininetApplied) + { + try + { + WinInetProxyControl.Restore(wininet); + log.AppendLine("WinINET proxy settings restored to the captured snapshot"); + } + catch (Exception ex) + { + log.AppendLine($"!!! WinINET restore FAILED: {ex.Message}"); + } + } + + harness?.Dispose(); + RestoreWorkspace(config, workspace, log); + Artifacts.WriteText("os-proxy-nonproxied-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + + // The browser processes the plugin hosts carry NO proxy switch: the plugin injects + // nothing into a non-proxied browser, and the OS proxy is what routes it. + log.AppendLine("browser process command lines (from Win32_Process):"); + foreach (var commandLine in browserArgs) + log.AppendLine($" {BrowserProcessArgs.Summarize(commandLine)}"); + Assert.NotEmpty(browserArgs); + Assert.All(browserArgs, cl => + { + Assert.False(cl.Contains("--proxy-server", StringComparison.OrdinalIgnoreCase), + "a non-proxied view's browser process carries a --proxy-server argument"); + Assert.False(cl.Contains("--no-proxy-server", StringComparison.OrdinalIgnoreCase), + "a non-proxied view's browser process carries --no-proxy-server (which would defeat OS proxy inheritance)"); + }); + Artifacts.WriteText("os-proxy-nonproxied-browser-args.txt", string.Join(Environment.NewLine, browserArgs)); + + // The marker page rendered: the harness served it (the host can never resolve direct). + Assert.NotNull(documentUrl); + Assert.StartsWith(OsProxyHome.TrimEnd('/'), documentUrl, StringComparison.OrdinalIgnoreCase); + Assert.Equal(WinInetProxyHarness.PageTitle, title); + Assert.NotNull(bodyText); + Assert.Contains(WinInetProxyHarness.BodyMarker, bodyText); + Assert.Contains("wininet-proxy.invalid", bodyText); + + // The harness's journal shows HOW the browser asked: an absolute-form proxied request. + var entries = harnessJournal + .Where(e => e.RequestTarget.IndexOf("wininet-proxy.invalid", StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + log.AppendLine($"harness journal for wininet-proxy.invalid: {entries.Count} request(s) of {harnessJournal.Count} total"); + foreach (var entry in entries) + log.AppendLine($" {entry.SeenAtUtc:o} {entry.Method} {entry.RequestTarget} UA='{entry.UserAgent}'"); + var get = entries.FirstOrDefault(e => e.Method == "GET" && + e.RequestTarget.StartsWith("http://wininet-proxy.invalid", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(get); + Assert.Contains("Chrome", get.UserAgent, StringComparison.OrdinalIgnoreCase); // the WebView2/Chromium UA + Artifacts.WriteText("os-proxy-nonproxied-harness-journal.txt", + string.Join(Environment.NewLine, harnessJournal.Select(e => $"{e.SeenAtUtc:o} {e.Method} {e.RequestTarget} UA='{e.UserAgent}'"))); + } + + /// + /// Row 2: routing ON with the SAME WinINET system proxy still pointed at the logging + /// harness. The proxied browser's loopback pin must win: the plugin's 403 allowlist page + /// renders, the harness journals NOTHING for the origin (the OS proxy was never asked), + /// and the live browser command lines show the --proxy-server loopback pin plus the + /// disabled <-loopback> bypass. + /// + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedViewNeverUsesOsConfiguredProxy() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-os-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + string failure = null; + string title = null; + string bodyText = null; + string documentUrl = null; + WorkspaceState workspace = null; + WinInetProxyHarness harness = null; + WinInetProxyControl.Snapshot wininet = null; + var wininetApplied = false; + ClientSession sc = null; + List browserArgs = new(); + List harnessJournal = new(); + try + { + // The Management Client phase runs BEFORE the OS proxy is pointed at the harness: + // the MC's own login channel follows the per-user system proxy (found live on this + // row's first run - with the harness enabled first, MC never reached its main + // window and the harness journal filled with its refused requests). The OS proxy + // only needs to be live for the Smart Client phase, which is the phase under test. + try + { + workspace = ProvisionWorkspace(config, home: OsProxyHome, routing: true, log); + } + catch (Exception ex) + { + // The edit may have stored before the verification threw: put the item back to + // the documented lab baseline (routing on, httpforever home) best-effort. + log.AppendLine($"provisioning threw ({ex.GetType().Name}: {ex.Message}); restoring the baseline workspace state"); + RestoreToBaseline(config, log); + throw; + } + + harness = new WinInetProxyHarness(); + wininet = WinInetProxyControl.Capture(); + WinInetProxyControl.UseLoopbackProxy(harness.Port); + wininetApplied = true; + log.AppendLine($"WinINET (HKCU) proxy set to 127.0.0.1:{harness.Port} (was enable={wininet.ProxyEnable} server='{wininet.ProxyServer}')"); + + sc = LaunchSmartClient(config, log); + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, "wininet-proxy.invalid", timeoutSeconds: 90); + await page.WaitForSelectorAsync("body", new PageWaitForSelectorOptions { Timeout = 30000 }); + + documentUrl = page.Url; + title = await page.TitleAsync(); + var body = await page.QuerySelectorAsync("body"); + bodyText = body == null ? null : await body.TextContentAsync(); + log.AppendLine($"start document -> url='{documentUrl}' title='{title}'"); + log.AppendLine($"body: {Summarize(bodyText)}"); + await page.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("os-proxy-proxied-403-page.png") }); + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + // Capture the live browser command lines and the harness journal BEFORE anything is + // killed: the launch switches and the (non-)requests are this row's evidence. + browserArgs = BrowserProcessArgs.CommandLines(DataRootMarker); + log.AppendLine("browser process command lines (from Win32_Process):"); + foreach (var commandLine in browserArgs) + log.AppendLine($" {BrowserProcessArgs.Summarize(commandLine)}"); + Artifacts.WriteText("os-proxy-proxied-browser-args.txt", string.Join(Environment.NewLine, browserArgs)); + + harnessJournal = harness?.Journal() ?? new List(); + log.AppendLine($"harness journal (whole run): {harnessJournal.Count} request(s)"); + foreach (var entry in harnessJournal.Take(40)) + log.AppendLine($" {entry.SeenAtUtc:o} {entry.Method} {entry.RequestTarget} UA='{entry.UserAgent}'"); + if (harnessJournal.Count > 40) + log.AppendLine($" ... {harnessJournal.Count - 40} more request(s) suppressed in the log (full journal in the artifact)"); + Artifacts.WriteText("os-proxy-proxied-harness-journal.txt", + string.Join(Environment.NewLine, harnessJournal.Select(e => $"{e.SeenAtUtc:o} {e.Method} {e.RequestTarget} UA='{e.UserAgent}'"))); + + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers(DataRootMarker); + sc?.Dispose(); + if (wininetApplied) + { + try + { + WinInetProxyControl.Restore(wininet); + log.AppendLine("WinINET proxy settings restored to the captured snapshot"); + } + catch (Exception ex) + { + log.AppendLine($"!!! WinINET restore FAILED: {ex.Message}"); + } + } + + harness?.Dispose(); + RestoreWorkspace(config, workspace, log); + Artifacts.WriteText("os-proxy-proxied-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + + // The proxied view's OWN 403 page - if the OS proxy had won, the harness would have + // served the marker page and this title assertion fails. + Assert.NotNull(documentUrl); + Assert.StartsWith(OsProxyHome.TrimEnd('/'), documentUrl, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Web Content proxy: 403", title); + Assert.NotNull(bodyText); + Assert.Contains("not in the workspace proxy allowlist", bodyText); + Assert.Contains("Allowlist entry that would admit this origin: http://wininet-proxy.invalid", bodyText); + + // The loopback pin is on the live command lines: every scheme pinned to the loopback + // listener, implicit localhost bypass disabled - the system proxy cannot apply. + Assert.NotEmpty(browserArgs); + var pinned = browserArgs.Where(cl => + cl.Contains("--proxy-server=http=http://127.0.0.1:", StringComparison.OrdinalIgnoreCase) && + cl.Contains("--proxy-bypass-list=<-loopback>", StringComparison.OrdinalIgnoreCase)).ToList(); + Assert.NotEmpty(pinned); + + // The OS proxy was never asked for this origin: an empty journal for it IS the proof + // the proxied browser ignored the system proxy. + var askedOsProxy = harnessJournal + .Where(e => e.RequestTarget.IndexOf("wininet-proxy.invalid", StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + Assert.Empty(askedOsProxy); + } + + // ---- shared phases ------------------------------------------------------------------------ + + /// The workspace state a ProvisionWorkspace call found (and whether it edited). + private sealed record WorkspaceState(string Home, bool Routing, bool Changed); + + /// + /// The Management Client phase shared by both rows: set the home address and the routing + /// toggle in ONE MC session, save, verify the plugin's own storage audit line names the + /// routing state this call wanted, then read both back from a RELOADED pane. Returns the + /// pre-call state so the probe restores exactly what it found. + /// + private static WorkspaceState ProvisionWorkspace(UxConfig config, string home, bool routing, StringBuilder log) + { + ProcessKiller.KillClients("VideoOS.Administration"); + using var mc = ClientSession.Launch(config.AdminClientExe); + var mcLogin = LoginFlow.WaitForLoginWindow(mc); + LoginFlow.Login(mc, mcLogin, config); + LoginFlow.AcceptCertificateTrust(mc); + var mcMain = LoginFlow.WaitForAdminMain(mc); + ManagementClientUi.CloseStrayLoginDialogs(mc); + ManagementClientUi.WaitForTreeReady(mcMain); + + ManagementClientUi.CollapseTreeToRoot(mcMain, timeoutSeconds: 60, allowExpanded: new[] { config.PluginNodeName }); + ManagementClientUi.EnsureExpanded(mcMain, config.PluginNodeName, ManagementClientUi.WorkspacesNodeName); + ManagementClientUi.EnsureExpanded(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + ManagementClientUi.SelectTreeItem(mcMain, config.McWorkspaceName); + + // Settings tab: the home address. + AllowlistGridUi.OpenSettingsTab(mcMain); + var homeBox = ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 15); + var previousHome = ReadValue(homeBox)?.Trim(); + var homeChanged = !string.Equals(previousHome, home, StringComparison.OrdinalIgnoreCase); + if (homeChanged) + { + var pasted = ManagementClientUi.PasteIntoControl(mcMain, "txtHomeAddress", home); + var edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 5))?.Trim(); + if (!string.Equals(edited, home, StringComparison.OrdinalIgnoreCase)) + { + log.AppendLine($"home paste route {(pasted ? "" : "(clipboard unavailable) ")}did not land (readback '{edited}'); ValuePattern fallback"); + ManagementClientUi.SetValue(mcMain, "txtHomeAddress", home); + edited = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 5))?.Trim(); + } + + if (!string.Equals(edited, home, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"the home address never read back as '{home}' (reads '{edited}')."); + } + + // Proxy tab: the routing toggle. + AllowlistGridUi.OpenProxyTab(mcMain); + var toggle = ManagementClientUi.ExpectControl(mcMain, AllowlistGridUi.ProxyEnabledId, 10); + var previousRouting = toggle.Patterns.Toggle.IsSupported && + toggle.Patterns.Toggle.Pattern.ToggleState.Value == FlaUI.Core.Definitions.ToggleState.On; + log.AppendLine($"workspace before: home='{previousHome}' routing={(previousRouting ? "on" : "off")}"); + if (previousRouting != routing) + ManagementClientUi.EnsureCheckBox(mcMain, AllowlistGridUi.ProxyEnabledId, routing); + + if (!homeChanged && previousRouting == routing) + { + log.AppendLine($"workspace already home='{home}' routing={(routing ? "on" : "off")}; no Management Client edit needed"); + Retire(mc, mcMain); + return new WorkspaceState(previousHome, previousRouting, Changed: false); + } + + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the edits; keyboard-nudging the home box"); + ManagementClientUi.NudgeControlWithKeystroke(mcMain, "txtHomeAddress"); + if (!ManagementClientUi.WaitForSaveEnabled(mcMain, 6)) + { + log.AppendLine("Save still disabled after the nudge; flipping the routing checkbox twice"); + ManagementClientUi.DirtyPaneViaCheckbox(mcMain, AllowlistGridUi.ProxyEnabledId); + log.AppendLine($"Save enabled after the checkbox flip: {ManagementClientUi.WaitForSaveEnabled(mcMain, 6)}"); + } + } + + var paneName = AllowlistGridUi.ReadPaneName(mcMain); + if (!string.Equals(paneName, config.McWorkspaceName, StringComparison.Ordinal)) + throw new InvalidOperationException($"Refusing to Save: the Name field reads '{paneName}', expected '{config.McWorkspaceName}'."); + + AllowlistGridUi.OpenProxyTab(mcMain); + var auditMarker = $"Stored proxy targets for workspace '{config.McWorkspaceName}'"; + var routingToken = routing ? "routing on" : "routing off"; + var auditBefore = McLog.CountMatches(auditMarker, routingToken); + if (!ManagementClientUi.Save(mc, mcMain, 20)) + throw new InvalidOperationException($"Save never completed for home='{home}' routing={(routing ? "on" : "off")}."); + var auditLine = McLog.WaitForNewLine(auditBefore, 20, auditMarker, routingToken); + if (auditLine == null) + throw new InvalidOperationException($"the save wrote no 'Stored proxy targets ...' audit line carrying '{routingToken}'."); + log.AppendLine($"storage audit line: {auditLine}"); + + Thread.Sleep(1500); + AllowlistGridUi.ReloadPane(mcMain, ManagementClientUi.WorkspacesNodeName, config.McWorkspaceName); + AllowlistGridUi.OpenProxyTab(mcMain); + var reloadedToggle = ManagementClientUi.ExpectControl(mcMain, AllowlistGridUi.ProxyEnabledId, 10); + var reloadedRouting = reloadedToggle.Patterns.Toggle.IsSupported && + reloadedToggle.Patterns.Toggle.Pattern.ToggleState.Value == FlaUI.Core.Definitions.ToggleState.On; + if (reloadedRouting != routing) + throw new InvalidOperationException($"after Save + reload the routing toggle reads {(reloadedRouting ? "on" : "off")}, expected {(routing ? "on" : "off")}."); + AllowlistGridUi.OpenSettingsTab(mcMain); + var reloadedHome = ReadValue(ManagementClientUi.ExpectControl(mcMain, "txtHomeAddress", 10))?.Trim(); + if (!string.Equals(reloadedHome, home, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"after Save + reload the home address reads '{reloadedHome}', expected '{home}'."); + log.AppendLine($"workspace set to home='{home}' routing={(routing ? "on" : "off")} (verified from the audit line AND a reloaded pane)"); + + Retire(mc, mcMain); + return new WorkspaceState(previousHome, previousRouting, Changed: true); + } + + /// Restores whatever ProvisionWorkspace found; falls back to the standing baseline. + private static void RestoreWorkspace(UxConfig config, WorkspaceState state, StringBuilder log) + { + if (state == null || !state.Changed) return; + try + { + ProvisionWorkspace(config, state.Home ?? BaselineHome, state.Routing, log); + log.AppendLine($"workspace restored to home='{state.Home ?? BaselineHome}' routing={(state.Routing ? "on" : "off")}"); + } + catch (Exception ex) + { + log.AppendLine($"!!! workspace restore FAILED ('{state.Home ?? BaselineHome}', routing={(state.Routing ? "on" : "off")}): {ex.Message}"); + } + } + + /// The standing lab baseline (routing on, httpforever home), used when the probe's + /// own read of the pre-call state never completed. + private static void RestoreToBaseline(UxConfig config, StringBuilder log) + { + try + { + ProvisionWorkspace(config, BaselineHome, routing: true, log); + log.AppendLine($"workspace restored to the baseline home='{BaselineHome}' routing=on"); + } + catch (Exception ex) + { + log.AppendLine($"!!! baseline restore FAILED: {ex.Message}"); + } + } + + private static ClientSession LaunchSmartClient(UxConfig config, StringBuilder log) + { + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers(DataRootMarker); + + var sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + if (item == null) throw new InvalidOperationException("no MIP view item after workspace selection"); + return sc; + } + + private static void Retire(ClientSession mc, Window mcMain) + { + try { mcMain.Close(); } catch { } + ManagementClientUi.AcceptPendingModal(mc, 5); + ProcessKiller.KillClients("VideoOS.Administration"); + } + + private static string ReadValue(AutomationElement element) + { + try { return element.Patterns.Value.IsSupported ? element.Patterns.Value.Pattern.Value.Value : element.Name; } + catch { return element.Name; } + } + + private static string Summarize(string text) + { + if (string.IsNullOrEmpty(text)) return ""; + var flat = text.Replace('\r', ' ').Replace('\n', ' '); + return flat.Substring(0, Math.Min(220, flat.Length)); + } +} diff --git a/tests/ux/WebContent.UxTests/ScProfileSweepProbeTests.cs b/tests/ux/WebContent.UxTests/ScProfileSweepProbeTests.cs new file mode 100644 index 0000000..d1f175c --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProfileSweepProbeTests.cs @@ -0,0 +1,345 @@ +using System.Net; +using System.Net.Sockets; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Live acceptance probe for ADR-0004 / #94 (SC-12 in docs/verification-matrix.md): the Smart +/// Client startup sweep reaps orphaned proxied browser-profile folders and stale listener-claim +/// files under the plugin data root, off the UI thread, journalled, never fatal, and NEVER +/// touching in-retention folders, live-owner claims, or the non-proxied EBWebView tree. +/// The row plants a full decoy table under the REAL data root in a dedicated probe user-key +/// folder (old-shape stale, boundary-shape stale, fresh, lock-held, dead/live/corrupt/cross-id +/// claims, old/fresh claim-temp litter, an ancient EBWebView tree), launches the real Smart +/// Client (whose plugin schedules the one-shot sweep ten seconds after Init), and reads the +/// outcomes back from disk. It also journalled whatever the Smart Client MIP log recorded for +/// the sweep source (log absence is journalled, not failed - the AGENTS caveat). +/// Artifacts land in webcontent-sc-sweep-probe\<run>; the whole probe folder is torn down +/// in the finally block. +/// +public class ScProfileSweepProbeTests +{ + private const string ProbeUserKey = "probe-94-sweep-user"; + private const string ProbeServerDir = "server-probe.sweep-7"; + + private readonly ITestOutputHelper _output; + + public ScProfileSweepProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeProxiedProfileSweepStartup() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-sweep-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + + var probeRoot = Path.Combine(config.WebView2DataRoot.TrimEnd('\\'), ProbeUserKey); + TcpListener liveClaimListener = null; + FileStream heldLockFile = null; + + try + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + + // A clean slate for THIS run's probe folder only - real user state is never removed. + if (Directory.Exists(probeRoot)) + { + Directory.Delete(probeRoot, true); + } + + var stale = DateTime.Now.AddDays(-45); + var litter = DateTime.Now.AddDays(-3); + + // (a) old-shape stale folder: \proxied-, pre-#92 shape -> REAPED. + var oldShapeStale = CreateProfile(probeRoot, GuidA, stale); + + // (b) boundary-shape stale folder: \server-\proxied- -> REAPED. + var serverDir = Path.Combine(probeRoot, ProbeServerDir); + var boundaryStale = CreateProfile(serverDir, GuidB, stale); + + // (c) fresh folder in retention -> KEPT. + var fresh = CreateProfile(probeRoot, GuidC, DateTime.Now); + + // (d) the non-proxied EBWebView tree, however ancient -> NEVER touched. + var nonProxiedWeb = Path.Combine(probeRoot, "EBWebView"); + var deep = Path.Combine(nonProxiedWeb, "Default", "OldCache"); + Directory.CreateDirectory(deep); + var ancientFile = Path.Combine(deep, "ancient.txt"); + File.WriteAllText(ancientFile, "do not delete me"); + Stamp(deep, ancientFile, DateTime.Now.AddDays(-365)); + + // (e) dead-owner claim beside a FRESH folder: only the claim is stale -> claim REAPED, + // folder KEPT (proves the claim rule is independent of folder age). + var deadClaimFolder = CreateProfile(serverDir, GuidD, DateTime.Now); + var deadClaim = WriteClaim(serverDir, GuidD, 999999, 47811); + + // (f) live-owner claim: owner = this still-running probe process, port = a port this + // process is actually listening on -> claim KEPT. + liveClaimListener = new TcpListener(IPAddress.Loopback, 0); + liveClaimListener.Start(); + var livePort = ((IPEndPoint)liveClaimListener.LocalEndpoint).Port; + var liveClaimFolder = CreateProfile(serverDir, GuidE, DateTime.Now); + var liveClaim = WriteClaim(serverDir, GuidE, Environment.ProcessId, livePort); + + // (g) cross-id claim (names another browser id) -> REAPED as foreign litter. + var crossIdClaim = WriteClaim(serverDir, GuidF, Environment.ProcessId, livePort, namesBrowserId: Guid.Parse("99999999-9999-4999-8999-999999999999")); + CreateProfile(serverDir, GuidF, DateTime.Now); + + // (h) corrupt claim content -> never trusted -> REAPED. + CreateProfile(serverDir, GuidG, DateTime.Now); + var corruptClaim = Path.Combine(serverDir, "proxied-" + GuidG.ToString("D") + ".claim"); + File.WriteAllText(corruptClaim, "i am not a claim at all"); + + // (i) claim-temp litter older than the 24h litter retention -> REAPED. + var oldLitter = Path.Combine(serverDir, "proxied-" + GuidH.ToString("D") + ".claim.tmp-4242-11111111-2222-4333-8444-555555555555"); + File.WriteAllText(oldLitter, "aborted create"); + File.SetLastWriteTimeUtc(oldLitter, litter.ToUniversalTime()); + + // (j) fresh claim-temp litter (a create possibly mid-flight) -> KEPT. + var freshLitter = Path.Combine(serverDir, "proxied-" + GuidI.ToString("D") + ".claim.tmp-4243-11111111-2222-4333-8444-555555555556"); + File.WriteAllText(freshLitter, "possibly mid-flight"); + + // (k) stale folder whose lockfile this process holds exclusively -> skipped, KEPT. + // The lockfile is stamped to the SAME stale marker: freshly-written it would make the + // whole folder look in-retention, so the folder would be kept by the freshness rule + // and the lock witness would never be consulted. Stamped stale, the ONLY thing that + // can keep this folder is the lock probe - which is exactly what this row tests. + var lockedStale = CreateProfile(probeRoot, GuidL, stale); + var lockfilePath = Path.Combine(lockedStale, "EBWebView", "lockfile"); + File.WriteAllText(lockfilePath, string.Empty); + File.SetLastWriteTimeUtc(lockfilePath, stale.ToUniversalTime()); + Directory.SetLastWriteTimeUtc(Path.GetDirectoryName(lockfilePath), stale.ToUniversalTime()); + Directory.SetLastWriteTimeUtc(lockedStale, stale.ToUniversalTime()); + heldLockFile = new FileStream(lockfilePath, FileMode.Open, FileAccess.Read, FileShare.None); + + log.AppendLine("decoys planted under " + probeRoot); + foreach (var dir in Directory.GetDirectories(probeRoot, "*", SearchOption.AllDirectories)) + { + log.AppendLine(" decoy: " + dir.Substring(probeRoot.Length + 1)); + } + + // Launch the real Smart Client: plugin Init schedules the one-shot sweep 10 s in, + // on the threadpool, never on the UI thread. + using var sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + log.AppendLine(item != null ? "MIP view item opened (plugin live)." : "no MIP view item; the sweep still rides plugin Init."); + + // The sweep fires ~10 s after plugin Init; poll for the reap of (a) and (b). + var deadline = DateTime.Now.AddSeconds(120); + while ((Directory.Exists(oldShapeStale) || Directory.Exists(boundaryStale)) && DateTime.Now < deadline) + { + Thread.Sleep(2000); + } + + var problems = new List(); + if (Directory.Exists(oldShapeStale)) problems.Add("(a) old-shape stale folder survived: " + oldShapeStale); + if (Directory.Exists(boundaryStale)) problems.Add("(b) boundary-shape stale folder survived: " + boundaryStale); + if (!Directory.Exists(fresh)) problems.Add("(c) in-retention folder was reaped: " + fresh); + if (!File.Exists(ancientFile)) problems.Add("(d) non-proxied EBWebView tree was touched: " + ancientFile); + if (File.Exists(deadClaim)) problems.Add("(e) dead-owner claim survived: " + deadClaim); + if (!Directory.Exists(deadClaimFolder)) problems.Add("(e) fresh folder next to the dead claim was reaped: " + deadClaimFolder); + if (!File.Exists(liveClaim)) problems.Add("(f) live-owner claim was reaped: " + liveClaim); + if (!Directory.Exists(liveClaimFolder)) problems.Add("(f) folder next to the live claim was reaped: " + liveClaimFolder); + if (File.Exists(crossIdClaim)) problems.Add("(g) cross-id claim survived: " + crossIdClaim); + if (File.Exists(corruptClaim)) problems.Add("(h) corrupt claim survived: " + corruptClaim); + if (File.Exists(oldLitter)) problems.Add("(i) stale claim-temp litter survived: " + oldLitter); + if (!File.Exists(freshLitter)) problems.Add("(j) fresh claim-temp litter was reaped: " + freshLitter); + if (!Directory.Exists(lockedStale)) problems.Add("(k) lock-held stale folder was forced: " + lockedStale); + + foreach (var problem in problems) + { + log.AppendLine("OUTCOME MISMATCH: " + problem); + } + + // Journal whatever the Smart Client MIP log recorded for the sweep source. The log + // can legitimately be absent (client logging posture - the AGENTS caveat), so its + // absence is journalled, not failed; the disk outcomes above are authoritative. + // The plugin flushes its lines slightly after the deletes land, so re-read for a + // few seconds before concluding "none". + var sweepLines = FindSweepLogLines(); + var logDeadline = DateTime.Now.AddSeconds(15); + while (sweepLines.Count == 0 && DateTime.Now < logDeadline) + { + Thread.Sleep(2000); + sweepLines = FindSweepLogLines(); + } + + if (sweepLines.Count == 0) + { + log.AppendLine("MIP log: no ProfileSweep lines found (log absent or client logging disabled) - journalled only."); + } + else + { + foreach (var line in sweepLines) + { + log.AppendLine("MIP log: " + line); + } + } + + if (problems.Count > 0) + { + throw new InvalidOperationException(problems.Count + " decoy outcome(s) wrong: " + string.Join("; ", problems)); + } + + log.AppendLine($"VERIFIED: startup sweep reaped (a) old-shape stale, (b) boundary-shape stale, (e) dead-owner claim, (g) cross-id claim, (h) corrupt claim, (i) stale litter; kept (c) fresh, (d) non-proxied tree, (f) live-owner claim, (j) fresh litter, (k) lock-held folder."); + } + catch (Exception ex) + { + failure = ex.GetType().Name + ": " + ex.Message; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + try { heldLockFile?.Dispose(); } catch (Exception) { } + try { liveClaimListener?.Stop(); } catch (Exception) { } + + Artifacts.WriteText("sc-sweep-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + // Tear the whole probe folder down (surviving decoys are litter now). + var wipeDeadline = DateTime.Now.AddSeconds(30); + while (Directory.Exists(probeRoot) && DateTime.Now < wipeDeadline) + { + try + { + Directory.Delete(probeRoot, true); + } + catch (Exception) + { + Thread.Sleep(2000); + } + } + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + // Fixed lowercase-D guids make the decoys greppable in the MIP log. + private static readonly Guid GuidA = Guid.Parse("aaaaaaaa-0000-4000-8000-00000000000a"); + private static readonly Guid GuidB = Guid.Parse("aaaaaaaa-0000-4000-8000-00000000000b"); + private static readonly Guid GuidC = Guid.Parse("aaaaaaaa-0000-4000-8000-00000000000c"); + private static readonly Guid GuidD = Guid.Parse("aaaaaaaa-0000-4000-8000-00000000000d"); + private static readonly Guid GuidE = Guid.Parse("aaaaaaaa-0000-4000-8000-00000000000e"); + private static readonly Guid GuidF = Guid.Parse("aaaaaaaa-0000-4000-8000-00000000000f"); + private static readonly Guid GuidG = Guid.Parse("aaaaaaaa-0000-4000-8000-000000000010"); + private static readonly Guid GuidH = Guid.Parse("aaaaaaaa-0000-4000-8000-000000000011"); + private static readonly Guid GuidI = Guid.Parse("aaaaaaaa-0000-4000-8000-000000000012"); + private static readonly Guid GuidL = Guid.Parse("aaaaaaaa-0000-4000-8000-000000000013"); + + /// A minimal UDF-shaped folder (proxied-<guid>\EBWebView\Default\state) stamped leaf-first. + private static string CreateProfile(string parent, Guid id, DateTime markerLocal) + { + var folder = Path.Combine(parent, "proxied-" + id.ToString("D")); + var web = Path.Combine(folder, "EBWebView"); + var profile = Path.Combine(web, "Default"); + Directory.CreateDirectory(profile); + var file = Path.Combine(web, "state.txt"); + File.WriteAllText(file, "chatter"); + Stamp(profile, file, markerLocal); + Stamp(web, null, markerLocal); + Stamp(folder, null, markerLocal); + return folder; + } + + private static void Stamp(string dir, string file, DateTime markerLocal) + { + var utc = markerLocal.ToUniversalTime(); + if (file != null) + { + File.SetLastWriteTimeUtc(file, utc); + } + + Directory.SetLastWriteTimeUtc(dir, utc); + } + + /// + /// The #93 claim schema written by hand (the harness never links plugin internals): + /// header line + the five required fields. + /// + private static string WriteClaim(string serverDir, Guid folderStem, int ownerPid, int port, Guid? namesBrowserId = null) + { + var path = Path.Combine(serverDir, "proxied-" + folderStem.ToString("D") + ".claim"); + var content = + "webcontent-listener-claim/1\n" + + "browserId=" + (namesBrowserId ?? folderStem).ToString("D") + "\n" + + "port=" + port + "\n" + + "ownerPid=" + ownerPid + "\n" + + "configFingerprint=probe-94\n" + + "claimedAtUnixMs=" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "\n"; + File.WriteAllText(path, content); + return path; + } + + /// ProfileSweep lines in the Smart Client MIP log (top-level plus rolling archives). + private static List FindSweepLogLines() + { + var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Smart Client"); + var candidates = new List(); + try + { + var main = Path.Combine(dir, "MIPLog.txt"); + if (File.Exists(main)) + { + candidates.Add(main); + } + + var rolling = Path.Combine(dir, "MIPLogs"); + if (Directory.Exists(rolling)) + { + candidates.AddRange(Directory.GetFiles(rolling, "MIPLog.*.txt") + .OrderByDescending(f => new FileInfo(f).LastWriteTime) + .Take(3)); + } + } + catch (Exception) + { + return new List(); + } + + var lines = new List(); + foreach (var file in candidates) + { + // The running Smart Client's logger holds MIPLog.txt open - read it the way a + // bystander must: ReadWrite sharing, swallow whatever the host refuses. + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + var text = reader.ReadToEnd(); + lines.AddRange(text.Split('\n') + .Select(l => l.TrimEnd('\r')) + .Where(l => l.Contains("ProfileSweep", StringComparison.Ordinal) + && (l.Contains("Swept", StringComparison.Ordinal) || l.Contains("Startup proxied-profile sweep", StringComparison.Ordinal))) + .Take(40)); + } + catch (Exception) + { + } + } + + return lines; + } +} diff --git a/tests/ux/WebContent.UxTests/ScProxiedBoundaryProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxiedBoundaryProbeTests.cs new file mode 100644 index 0000000..fe63f60 --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxiedBoundaryProbeTests.cs @@ -0,0 +1,242 @@ +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Live acceptance probe for ADR-0004 / #92 (SC-11 in docs/verification-matrix.md): the proxied +/// browser profile must be keyed to the FULL proxied boundary - the on-disk (and live browser +/// command line) shape is <root>\<user key>\server-<server key>\proxied-<browser id>, +/// so two Smart Clients logged into different VMS servers can never share a proxied profile. +/// The row launches the real Smart Client, opens the proxied lab workspace, and reads the LIVE +/// msedgewebview2 command line (same witness the SC-8 row uses): the browser was launched with a +/// boundary-keyed --user-data-dir naming the configured VMS host in its server segment. Old-shape +/// folders (<root>\<user>\proxied-*) that exist from before the change are only +/// tolerated, never rewritten - #94 reaps them. Artifacts land in webcontent-sc-proxy-probe\<run>. +/// +public class ScProxiedBoundaryProbeTests +{ + private readonly ITestOutputHelper _output; + + public ScProxiedBoundaryProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public void ProbeProxiedBoundaryProfilePath() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + var startedAt = DateTime.Now; + + try + { + var baseline = SnapshotProxiedFolders(config.WebView2DataRoot); + log.AppendLine($"baseline proxied-* folders before the run ({baseline.Count}):"); + foreach (var folder in baseline) + { + log.AppendLine($" baseline: {folder}"); + } + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + using var sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + if (item == null) + { + throw new InvalidOperationException("No MIP view item opened; cannot observe a proxied browser."); + } + + // Precondition: the lab workspace must be proxy-enabled - wait for a browser process + // that carries the loopback --proxy-server switch (the #54 posture SC-1..SC-10 rely on). + string proxiedCommandLine = null; + var deadline = DateTime.Now.AddSeconds(90); + while (proxiedCommandLine == null && DateTime.Now < deadline) + { + proxiedCommandLine = BrowserProcessArgs.CommandLines("SmartClient.WebContentData") + .FirstOrDefault(line => line.Contains("--proxy-server=http=http://127.0.0.1", StringComparison.OrdinalIgnoreCase)); + if (proxiedCommandLine == null) + { + Thread.Sleep(2000); + } + } + + if (proxiedCommandLine == null) + { + throw new InvalidOperationException( + "precondition failed: no msedgewebview2 process on this data root carries a loopback --proxy-server switch - the lab workspace is not proxy-enabled right now"); + } + + log.AppendLine("proxied browser process: " + BrowserProcessArgs.Summarize(proxiedCommandLine)); + + var userDataDir = MatchUserDataDir(proxiedCommandLine); + if (string.IsNullOrEmpty(userDataDir)) + { + throw new InvalidOperationException("the proxied browser command line carries no --user-data-dir"); + } + + // Harness pitfall for the next reader: the WebView2 RUNTIME process command line + // carries the runtime's own data subfolder (\EBWebView), not the + // folder the plugin requested. The boundary key lives in the requested UDF. + if (userDataDir.EndsWith("\\EBWebView", StringComparison.OrdinalIgnoreCase)) + { + userDataDir = userDataDir.Substring(0, userDataDir.Length - "\\EBWebView".Length); + } + + log.AppendLine($"boundary profile path observed live: {userDataDir}"); + + // Shape: \\server-\proxied-. The root itself and + // the browser-id guid are known; the user leg is whatever the login resolved (identity + // guid, DOMAIN\user fallback, ...), so it is one opaque safe segment; the server leg + // must name the configured VMS host. + var rootPrefix = config.WebView2DataRoot.TrimEnd('\\'); + if (!userDataDir.StartsWith(rootPrefix + "\\", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"--user-data-dir '{userDataDir}' is not under the plugin data root '{rootPrefix}'"); + } + + var tail = userDataDir.Substring(rootPrefix.Length + 1); + var segments = tail.Split('\\'); + if (segments.Length != 3) + { + throw new InvalidOperationException( + $"boundary profile path '{tail}' is not the three-leg shape \\server-\\proxied-"); + } + + var guidPattern = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; + if (!System.Text.RegularExpressions.Regex.IsMatch(segments[2], "^proxied-" + guidPattern + "$")) + { + throw new InvalidOperationException($"leaf segment '{segments[2]}' is not proxied-"); + } + + if (!segments[1].StartsWith("server-", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"server segment '{segments[1]}' lacks the server- prefix - this is the old pre-#92 shape"); + } + + // The server leg must name the configured VMS host (sanitized lower-case host, then + // the port the Smart Client logged into). The login box carries exactly VMS_ADDRESS. + var expectedHost = SanitizeHost(config.VmsAddress); + if (string.IsNullOrEmpty(expectedHost)) + { + throw new InvalidOperationException("could not derive the expected host leg from VMS_ADDRESS"); + } + + var serverLeg = segments[1].Substring("server-".Length); + var hostMatches = serverLeg.Equals(expectedHost, StringComparison.OrdinalIgnoreCase) + || serverLeg.StartsWith(expectedHost + "-", StringComparison.OrdinalIgnoreCase); + if (!hostMatches) + { + throw new InvalidOperationException( + $"server segment '{serverLeg}' does not carry the configured VMS host leg '{expectedHost}'"); + } + + if (!Directory.Exists(userDataDir)) + { + throw new InvalidOperationException($"the observed boundary profile folder does not exist on disk: {userDataDir}"); + } + + // And nothing wrote an OLD-shape folder (proxied-* directly under a user key) during + // this run - the migration must be complete, old folders are only pre-existing litter. + var rewrites = SnapshotProxiedFolders(config.WebView2DataRoot) + .Where(f => IsOldShape(f, rootPrefix) + && Directory.GetLastWriteTimeUtc(f) >= startedAt.ToUniversalTime().AddSeconds(-5)) + .ToList(); + if (rewrites.Count > 0) + { + throw new InvalidOperationException( + "old-shape proxied folders were WRITTEN during this run: " + string.Join("; ", rewrites)); + } + + log.AppendLine($"VERIFIED: live proxied browser uses boundary-keyed profile '{tail}' (host leg '{expectedHost}' present); no old-shape writes."); + } + catch (Exception ex) + { + failure = ex.GetType().Name + ": " + ex.Message; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + foreach (var folder in SnapshotProxiedFolders(config.WebView2DataRoot)) + { + log.AppendLine($"after-run: {folder}"); + } + + Artifacts.WriteText("sc-boundary-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + /// All proxied-* folders under the data root, root-relative. + private static List SnapshotProxiedFolders(string root) + { + if (!Directory.Exists(root)) + { + return new List(); + } + + return Directory.GetDirectories(root, "proxied-*", SearchOption.AllDirectories) + .Select(d => d.Substring(root.TrimEnd('\\').Length + 1)) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool IsOldShape(string rootRelativeFolder, string rootPrefix) + { + // Old shape: exactly two levels under the root (user key, proxied leaf). New shape: three. + return rootRelativeFolder.Split('\\').Length == 2; + } + + private static string MatchUserDataDir(string commandLine) + { + var match = System.Text.RegularExpressions.Regex.Match(commandLine ?? string.Empty, + "--user-data-dir=(\"(?[^\"]*)\"|(?[^\\s]*))"); + if (!match.Success) + { + return string.Empty; + } + + return match.Groups["q"].Success ? match.Groups["q"].Value : match.Groups["b"].Value; + } + + /// + /// Mirrors BrowserProfileKey.DeriveServerKey's host leg (the harness never links plugin + /// internals): lower-case, letters/digits/./-/_ survive, everything else becomes _. + /// + private static string SanitizeHost(string address) + { + var host = address; + if (Uri.TryCreate(address, UriKind.Absolute, out var uri)) + { + host = uri.Host; + } + else if (Uri.TryCreate("http://" + address, UriKind.Absolute, out var withScheme)) + { + host = withScheme.Host; + } + + host = (host ?? string.Empty).Trim().ToLowerInvariant(); + return new string(host.Select(ch => char.IsLetterOrDigit(ch) || ch == '.' || ch == '-' || ch == '_' ? ch : '_').ToArray()); + } +} diff --git a/tests/ux/WebContent.UxTests/ScProxyBlockedIndicatorProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyBlockedIndicatorProbeTests.cs new file mode 100644 index 0000000..3730d04 --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxyBlockedIndicatorProbeTests.cs @@ -0,0 +1,395 @@ +using System.Text; + +using FlaUI.Core.AutomationElements; + +using Microsoft.Playwright; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Diagnostic probe (#83, verification-matrix SC-7): the setup-mode blocked-resources +/// indicator. A page loads FINE through the proxy while its subresources (two images + +/// a script) point at an origin the workspace does NOT allowlist - the loopback listener +/// refuses them client-side (403 NotAllowed) and tallies them per browser-view instance. +/// The contract: +/// 1. nothing renders in runtime mode for ordinary users (indicator absent/offscreen), +/// 2. entering setup mode shows "N resources blocked" near the address bar (the label is +/// its own toolbar element: the #72-style guard pins it beside the icon button, after +/// the first live run 2026-11-03 proved text Content inside VideoOSButtonTransparentSmall +/// renders nothing), +/// 3. clicking it lists origin (selectable), reason, and hit count, +/// 4. a top-level navigation resets the counters - the probe's blocked origin is GONE from +/// the list after the clean twin page. Origin-scoped, not "the whole indicator is gone": +/// the pinned browser proxies Edge's OWN service traffic (nav-edge.smartscreen..., +/// edge.microsoft.com - live MIPLog lines show them re-refused within seconds of any +/// navigation), and those refusals honestly re-tally for the new page load. The reset +/// contract is about THIS page's blocked resources, not about silencing the proxy's +/// fail-closed posture toward browser traffic, +/// 5. re-blocking in runtime mode stays invisible until setup mode shows it again. +/// +/// The page origin is http://172.16.128.210:18125 - an origin the workspace allowlist has +/// carried since the #56/#57 probes provisioned it, so this run drives NO Management Client +/// phase: the blocked page's title is itself the proof the entry is still in place (a missing +/// entry renders the "Web Content proxy:" error page instead, which the assertions call out). +/// Set UX_REQUIRE_MC_PROVISIONING=1 to run the guarded MC Ensure phase first. The blocked +/// subresource origin (port 18131) is deliberately never allowlisted and never served: the +/// refusal happens at the loopback listener before the Event Server is ever asked. +/// +/// The run drives the Smart Client on UIA patterns + CDP only (no SendInput): a disconnected +/// desktop session rejects mouse/keyboard injection with ERROR_ACCESS_DENIED, so navigation +/// goes through CDP, the workspace comes up as the persisted last one (with a pattern-select +/// fallback), and window evidence uses the hang-guarded PrintWindow capture. Artifacts land +/// in webcontent-sc-proxy-probe\<run>. +/// +public class ScProxyBlockedIndicatorProbeTests +{ + /// Page origin already carried by the workspace allowlist (provisioned by the #56/#57 probes). + private static string BindHost => Environment.GetEnvironmentVariable("UX_BLOCKED_HOST") ?? "172.16.128.210"; + private static int PagePort => ReadPort("UX_BLOCKED_PAGE_PORT", 18125); + private static int BlockedOriginPort => ReadPort("UX_BLOCKED_ASSET_PORT", 18131); + + private readonly ITestOutputHelper _output; + + public ScProxyBlockedIndicatorProbeTests(ITestOutputHelper output) => _output = output; + + private static int ReadPort(string name, int fallback) => + int.TryParse(Environment.GetEnvironmentVariable(name), out var port) ? port : fallback; + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeSetupModeBlockedResourcesIndicator() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + string failure = null; + string pageTitle = null; + bool runtimeIndicatorHidden = false; + bool indicatorVisibleInSetup = false; + string indicatorText = null; + bool originRowFound = false; + string originRowValue = null; + bool reasonRowFound = false; + string countRowValue = null; + bool probeOriginGoneAfterCleanNavigation = false; + bool runtimeIndicatorHiddenAfterReblock = false; + bool setupShowsReblockedTally = false; + + BlockedResourcePageServer server = null; + ClientSession sc = null; + try + { + var allowlistEntry = $"http://{BindHost}:{PagePort}"; + if (Environment.GetEnvironmentVariable("UX_REQUIRE_MC_PROVISIONING") == "1") + { + ProxyTargetProvisioning.Ensure(config, allowlistEntry, log); + } + else + { + log.AppendLine($"reusing the workspace allowlist entry '{allowlistEntry}' provisioned by the #56/#57 probes; no Management Client phase (the page title assertion proves it is still in place)"); + } + + server = new BlockedResourcePageServer(BindHost, PagePort, BlockedOriginPort); + log.AppendLine($"page origin (allowlisted): {server.PageOrigin}; blocked subresource origin: {server.BlockedOrigin}"); + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + // Smart Client reopens the workspace shown at the previous run; the proxied + // workspace is the last one every proxy probe used. Only fall back to tab + // selection (pattern-based, no mouse) when the plugin view did not come up. + AutomationElement item = null; + try { item = SmartClientUi.WaitForMipViewItem(main, 20); } catch (TimeoutException) { } + if (item == null) + { + log.AppendLine("plugin view not on the opening workspace; pattern-selecting the tab (no input injection)"); + if (SmartClientUi.TrySelectWorkspaceWithoutInput(main, sc, config.WorkspaceName)) + { + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + } + item = SmartClientUi.WaitForMipViewItem(main); + } + Assert.NotNull(item); + log.AppendLine("proxied workspace view is up (runtime mode)"); + + // Filter-registration head start (documented 3-4 s), same as the other probes. + Thread.Sleep(6000); + + // Runtime phase: the page loads through the proxy, its subresources are refused + // client-side; navigation goes through CDP (no keyboard, and the address bar + // would persist the probe url onto the workspace). + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await WaitForBrowserPageAsync(browser, 90); + await page.GotoAsync($"{server.PageOrigin}/blocked-page/", new PageGotoOptions { WaitUntil = WaitUntilState.Load, Timeout = 60000 }); + await page.WaitForSelectorAsync("#probe-marker", new PageWaitForSelectorOptions { Timeout = 30000 }); + pageTitle = await page.TitleAsync(); + log.AppendLine($"blocked-subresource page (Event Server proxied) -> title='{pageTitle}'"); + await page.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("sc-blocked-indicator-page.png") }); + + // Give Chromium a moment to issue the three subresource requests; their refusals + // reach the tally even though nothing renders in runtime mode. + Thread.Sleep(5000); + item = SmartClientUi.WaitForMipViewItem(main); + runtimeIndicatorHidden = !IndicatorRendered(item, log, "runtime"); + log.AppendLine($"runtime posture: indicator rendered = {!runtimeIndicatorHidden}"); + + // Setup mode: the tally surfaces as "N resources blocked". + log.AppendLine("entering setup mode..."); + SmartClientUi.ClickSetup(main); + Thread.Sleep(2000); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + SmartClientUi.CloseSidePanels(main); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline && !indicatorVisibleInSetup) + { + item = SmartClientUi.WaitForMipViewItem(main); + indicatorVisibleInSetup = IndicatorRendered(item, log, "setup"); + if (!indicatorVisibleInSetup) Thread.Sleep(1000); + } + log.AppendLine($"setup posture: indicator visible = {indicatorVisibleInSetup}"); + + if (indicatorVisibleInSetup) + { + var text = item.FindFirstDescendant(cf => cf.ByAutomationId("blockedIndicatorText")); + indicatorText = text?.Name; + log.AppendLine($"indicator text: '{indicatorText}'"); + + // Click to open the per-view list. Rows are ENUMERATED, not just the first + // descendant: the browser's own pinned service traffic (SmartScreen pings) + // can occupy any row position, and the probe origin must be found among them. + SmartClientUi.TryInvokeById(main, "blockedIndicator", 1500); + var rowsDeadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < rowsDeadline && !originRowFound) + { + item = SmartClientUi.WaitForMipViewItem(main); + originRowFound = TryReadProbeOriginRow(item, server.BlockedOrigin, out originRowValue, out reasonRowFound, out countRowValue); + if (!originRowFound) Thread.Sleep(1000); + } + log.AppendLine($"list row: origin='{originRowValue}' reason-row={reasonRowFound} count='{countRowValue}'"); + SafeWindowPrint(main, "sc-blocked-indicator-setup", log); + } + else + { + SafeWindowPrint(main, "sc-blocked-indicator-setup-missing", log); + } + + // Navigation resets the counters like the browser's own console. The proof is + // ORIGIN-SCOPED (first live run 2026-11-03): Edge's own service traffic rides the + // pinned loopback proxy and is re-refused within seconds of any navigation, so the + // indicator may honestly stay lit on the clean page with the browser's own origins + // in its list. What the reset contract promises is that THIS page load's blocked + // resources are gone - the probe's 18131 row must no longer be counting. + await page.GotoAsync($"{server.PageOrigin}/clean/", new PageGotoOptions { WaitUntil = WaitUntilState.Load, Timeout = 60000 }); + var cleanDeadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < cleanDeadline && await page.TitleAsync() != "UX Probe CLEANPAGE") await Task.Delay(500); + log.AppendLine($"clean twin page -> title='{await page.TitleAsync()}'"); + var hideDeadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < hideDeadline && !probeOriginGoneAfterCleanNavigation) + { + item = SmartClientUi.WaitForMipViewItem(main); + if (!IndicatorRendered(item, log, "after-clean")) + { + probeOriginGoneAfterCleanNavigation = true; + log.AppendLine("after clean navigation: indicator hidden outright"); + break; + } + + // Indicator still lit: open the list (unless already open) and look for the + // probe origin specifically. Any surviving rows are browser-service noise and + // get named in the log so the run states what stayed blocked. + var openCheck = item.FindFirstDescendant(cf => cf.ByAutomationId("blockedResourceOrigin")); + if (openCheck == null) SmartClientUi.TryInvokeById(main, "blockedIndicator", 1500); + string cleanOrigin, cleanCount; + bool cleanReason; + if (!TryReadProbeOriginRow(item, server.BlockedOrigin, out cleanOrigin, out cleanReason, out cleanCount)) + { + probeOriginGoneAfterCleanNavigation = true; + var remaining = item.FindAllDescendants(cf => cf.ByAutomationId("blockedResourceOrigin")); + log.AppendLine($"after clean navigation: probe origin gone; rows kept (browser's own pinned traffic): {string.Join(", ", remaining.Select(o => ReadElementValue(o) ?? o.Name))}"); + break; + } + + Thread.Sleep(1000); + } + log.AppendLine($"after clean navigation: probe origin gone = {probeOriginGoneAfterCleanNavigation}"); + + // Back to runtime mode and re-block: tallies keep counting invisibly, and setup + // mode shows them again - the reset-then-recount cycle end to end. + log.AppendLine("leaving setup mode and re-blocking in runtime..."); + SmartClientUi.ClickSetup(main); + Thread.Sleep(2000); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + await page.GotoAsync($"{server.PageOrigin}/blocked-page/", new PageGotoOptions { WaitUntil = WaitUntilState.Load, Timeout = 60000 }); + Thread.Sleep(6000); + item = SmartClientUi.WaitForMipViewItem(main); + runtimeIndicatorHiddenAfterReblock = !IndicatorRendered(item, log, "runtime-reblock"); + log.AppendLine($"runtime re-block posture: indicator rendered = {!runtimeIndicatorHiddenAfterReblock}"); + + log.AppendLine("re-entering setup mode to prove the runtime-phase refusals tallied..."); + SmartClientUi.ClickSetup(main); + Thread.Sleep(2000); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + SmartClientUi.CloseSidePanels(main); + var recountDeadline = DateTime.UtcNow.AddSeconds(20); + while (DateTime.UtcNow < recountDeadline && !setupShowsReblockedTally) + { + item = SmartClientUi.WaitForMipViewItem(main); + setupShowsReblockedTally = IndicatorRendered(item, log, "setup-recount"); + if (!setupShowsReblockedTally) Thread.Sleep(1000); + } + log.AppendLine($"setup re-count posture: indicator visible = {setupShowsReblockedTally}"); + SafeWindowPrint(main, "sc-blocked-indicator-recount", log); + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + server?.Dispose(); + Artifacts.WriteText("sc-blocked-indicator-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + Assert.NotNull(pageTitle); + Assert.False(pageTitle.StartsWith("Web Content proxy:"), $"the top-level document itself was refused (is 'http://{BindHost}:{PagePort}' still in the workspace allowlist?): '{pageTitle}'"); + Assert.True(runtimeIndicatorHidden, "the blocked-resources indicator rendered in runtime mode - ordinary users must see nothing"); + Assert.True(indicatorVisibleInSetup, "setup mode did not show the blocked-resources indicator despite client-side subresource refusals"); + Assert.NotNull(indicatorText); + Assert.Matches(@"\d+ resources? blocked", indicatorText); + Assert.True(originRowFound, $"the list did not carry the canonical blocked origin '{server.BlockedOrigin}' (read: '{originRowValue}')"); + Assert.True(reasonRowFound, "the list row did not name the not-allowed refusal category"); + Assert.True(probeOriginGoneAfterCleanNavigation, "the probe's blocked origin survived a top-level navigation - the per-page-load tally did not reset it"); + Assert.True(runtimeIndicatorHiddenAfterReblock, "the indicator rendered in runtime mode after fresh refusals were tallied"); + Assert.True(setupShowsReblockedTally, "setup mode did not re-show the indicator after runtime-mode re-blocking"); + } + + /// + /// True when the open per-view list carries a row for ; + /// when it does, the row's canonical origin text, a whether-its-reason-says-"not allowed" + /// flag, and its hit-count text come back. Rows are enumerated by AutomationId and paired + /// by tree order - the first-live-run lesson was that a single FindFirstDescendant can + /// land on a browser-service row and call the probe origin "missing". + /// + private static bool TryReadProbeOriginRow( + AutomationElement viewItem, + string blockedOrigin, + out string originValue, + out bool reasonIsNotAllowed, + out string countValue) + { + originValue = null; + reasonIsNotAllowed = false; + countValue = null; + + var origins = viewItem.FindAllDescendants(cf => cf.ByAutomationId("blockedResourceOrigin")); + if (origins == null || origins.Length == 0) return false; + var reasons = viewItem.FindAllDescendants(cf => cf.ByAutomationId("blockedResourceReason")); + var counts = viewItem.FindAllDescendants(cf => cf.ByAutomationId("blockedResourceCount")); + + for (var i = 0; i < origins.Length; i++) + { + var value = ReadElementValue(origins[i]) ?? origins[i].Name; + if (!string.Equals(value, blockedOrigin, StringComparison.OrdinalIgnoreCase)) continue; + + originValue = value; + reasonIsNotAllowed = i < reasons.Length && (reasons[i].Name ?? "").Contains("not allowed", StringComparison.OrdinalIgnoreCase); + countValue = i < counts.Length ? counts[i].Name : null; + return true; + } + + return false; + } + + /// First CDP page whose url is an http(s) page of the plugin browser. + private static async Task WaitForBrowserPageAsync(IBrowser browser, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var page = browser.Contexts + .SelectMany(c => c.Pages) + .FirstOrDefault(p => (p.Url ?? string.Empty).StartsWith("http", StringComparison.OrdinalIgnoreCase)); + if (page != null) return page; + await Task.Delay(1000); + } + throw new TimeoutException("The proxied browser never exposed an http page over CDP. Seen: " + + string.Join(", ", browser.Contexts.SelectMany(c => c.Pages).Select(p => p.Url))); + } + + private static void SafeWindowPrint(Window main, string name, StringBuilder log) + { + try + { + var file = Artifacts.WindowPrint(main, name); + log.AppendLine(file == null ? $"window capture '{name}' abandoned (no screen surface)" : $"window capture '{name}' -> {file}"); + } + catch (Exception ex) + { + log.AppendLine($"window capture '{name}' threw {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// True when the plugin view renders the indicator control (visible on screen, not just + /// present in the automation tree - Collapsed WPF chrome stays offscreen). + /// + private static bool IndicatorRendered(AutomationElement viewItem, StringBuilder log, string context) + { + var indicator = viewItem.FindFirstDescendant(cf => cf.ByAutomationId("blockedIndicator")); + if (indicator == null) + { + return false; + } + + try + { + if (indicator.IsOffscreen) + { + return false; + } + + var rect = indicator.BoundingRectangle; + return rect.Width > 0 && rect.Height > 0; + } + catch (Exception ex) + { + log.AppendLine($" indicator visibility read failed ({context}): {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + + private static string ReadElementValue(AutomationElement element) + { + try + { + if (element.Patterns.Value.IsSupported) return element.Patterns.Value.Pattern.Value.Value; + } + catch { /* fall back to Name below */ } + return element.Name; + } +} diff --git a/tests/ux/WebContent.UxTests/ScProxyErrorProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyErrorProbeTests.cs new file mode 100644 index 0000000..21e453c --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxyErrorProbeTests.cs @@ -0,0 +1,531 @@ +using FlaUI.Core.AutomationElements; + +using Microsoft.Playwright; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Diagnostic probe (#58, verification-matrix SC-5): the proxied Smart Client view must show an +/// EXPLICIT proxy error page - never a spinner, never a page fetched direct from the client +/// machine - for the two refusal families: +/// 1. a start (home) URL whose origin is not on the workspace proxy allowlist renders the 403 +/// page as the workspace's start document (http over the proxy's absolute-form path; +/// https - which Chromium only transports as CONNECT, #88 - at the view layer); +/// 2. the Event Server (host of the server-side proxy component) going down mid-session renders +/// the 503 page naming that component, and a fresh client session recovers once the service +/// is back. +/// The outage row mutates the live VMS (stops/starts MilestoneEventServerService over WinRM, +/// AGENTS.md conventions) and only runs with WEBCONTENT_ES_STOP=1. Artifacts land in +/// webcontent-sc-proxy-probe\<run> like the other proxy probes. +/// +public class ScProxyErrorProbeTests +{ + private const string FetchUrl = "http://httpforever.com/"; + private const string AllowlistEntry = "http://httpforever.com"; + + /// + /// A plain-HTTP origin deliberately NOT on the allowlist: as the workspace home it exercises + /// the start-URL refusal over the proxy's absolute-form path (the 403 + friendly page ride + /// the proxy response itself). + /// + private const string OffAllowlistHome = "http://neverssl.com/"; + + /// + /// #88: the SAME origin as an https home. Chromium transports https to the loopback listener + /// only as CONNECT, and a non-2xx CONNECT answer's body is dropped by Chromium (native + /// ERR_TUNNEL_CONNECTION_FAILED page), so the friendly refusal page for these refusals is + /// answered at the VIEW layer (ProxyViewRefusal via WebResourceRequested) - this row proves + /// the friendly page now renders there with the same facts, at the same committed URL. + /// + private const string OffAllowlistHttpsHome = "https://neverssl.com/"; + + /// + /// Split live-host run (SC-5): the Management Client phase and the Smart Client view may run on + /// different boxes against the same VMS, because the workspace state lives on the server. With this + /// control set the view machine trusts the state the other box prepared and only drives the browser; + /// whoever sets it owns restoring the workspace home address afterwards. + /// + private static bool SkipMcProvisioning => Environment.GetEnvironmentVariable("UX_SKIP_MC_PROVISIONING") == "1"; + + private readonly ITestOutputHelper _output; + + public ScProxyErrorProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedStartUrlOutsideAllowlistShows403Page() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + string title = null; + string bodyText = null; + bool? detailsOpen = null; + bool copyButtonPresent = false; + string previousHome = null; + ClientSession sc = null; + try + { + if (SkipMcProvisioning) + { + log.AppendLine("CONTROL: UX_SKIP_MC_PROVISIONING=1 - the workspace allowlist entry and the off-allowlist home " + + $"address were applied from another box against the same VMS ({config.VmsAddress}); this run only drives the view."); + } + else + { + ProxyTargetProvisioning.Ensure(config, AllowlistEntry, log); + previousHome = WorkspaceHomeAddress.Ensure(config, OffAllowlistHome, log); + } + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + // The fingerprint guard restarts the browser at the NEW home after a definition edit; + // the first document is the refusal page itself - no address-bar interaction. + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, "neverssl.com", timeoutSeconds: 90); + await page.WaitForSelectorAsync("body", new PageWaitForSelectorOptions { Timeout = 30000 }); + + title = await page.TitleAsync(); + var body = await page.QuerySelectorAsync("body"); + bodyText = body == null ? null : await body.TextContentAsync(); + + // #66 surfaces: the technical section is collapsed by default and the copy button + // exists; the friendly headline/cause sit above it. (TextContent includes the + // collapsed section's text - the diagnostics facts are asserted from bodyText.) + detailsOpen = await page.EvalOnSelectorAsync("details", "el => el.open"); + copyButtonPresent = await page.QuerySelectorAsync("#wdc-copy") != null; + + log.AppendLine($"start document -> title='{title}'"); + log.AppendLine($"body: {Summarize(bodyText)}"); + + await page.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("sc-error-probe-starturl403.png") }); + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + if (previousHome != null) + { + try + { + WorkspaceHomeAddress.Ensure(config, previousHome.Trim(), log); + log.AppendLine($"workspace home address restored to '{previousHome.Trim()}'"); + } + catch (Exception ex) + { + log.AppendLine($"!!! home restore FAILED ('{previousHome.Trim()}'): {ex.Message}"); + } + } + Artifacts.WriteText("sc-error-probe-starturl403-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + Assert.Equal("Web Content proxy: 403", title); + Assert.NotNull(bodyText); + + // #66: friendly layer up front - one cause category chip + plain headline. + Assert.Contains("Not allowed by the administrator", bodyText); + Assert.Contains("This web address is not allowed for this view", bodyText); + + // ...technical detail collapsed by default, copy affordance present... + Assert.NotNull(detailsOpen); + Assert.False(detailsOpen.Value, "the technical details section is expanded by default"); + Assert.True(copyButtonPresent, "no Copy diagnostics button on the refusal page"); + + // ...and it carries the actionable facts: the verbatim refusal naming the origin plus + // the exact canonical allowlist entry that would admit it. + Assert.Contains("not in the workspace proxy allowlist", bodyText); + Assert.Contains("Allowlist entry that would admit this origin: http://neverssl.com", bodyText); + } + + /// + /// #88: the https sibling of the start-URL row. Chromium routes https to the loopback + /// listener as CONNECT only, and Chromium DROPS the body of a non-2xx CONNECT answer - so + /// before the #88 view-layer gate this same workspace state rendered Chromium's native + /// "can't reach this page / ERR_TUNNEL_CONNECTION_FAILED" page (the pre-fix run of this row + /// is the live half of the root-cause repro). With the gate, the friendly 403 page renders + /// at the view, committed at the requested URL - the same posture as the http row. + /// + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedStartUrlHttpsOutsideAllowlistShows403Page() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + string title = null; + string bodyText = null; + string documentUrl = null; + bool? detailsOpen = null; + bool copyButtonPresent = false; + string previousHome = null; + ClientSession sc = null; + try + { + if (SkipMcProvisioning) + { + log.AppendLine("CONTROL: UX_SKIP_MC_PROVISIONING=1 - the off-allowlist https home " + + $"was applied from another box against the same VMS ({config.VmsAddress}); this run only drives the view."); + } + else + { + previousHome = WorkspaceHomeAddress.Ensure(config, OffAllowlistHttpsHome, log); + } + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + // The fingerprint guard restarts the browser at the NEW home; the first document is + // the refusal itself - no address-bar interaction, exactly like the http row. + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, "neverssl.com", timeoutSeconds: 90); + await page.WaitForSelectorAsync("body", new PageWaitForSelectorOptions { Timeout = 30000 }); + + title = await page.TitleAsync(); + documentUrl = page.Url; + var body = await page.QuerySelectorAsync("body"); + bodyText = body == null ? null : await body.TextContentAsync(); + + detailsOpen = await page.EvalOnSelectorAsync("details", "el => el.open"); + copyButtonPresent = await page.QuerySelectorAsync("#wdc-copy") != null; + + log.AppendLine($"start document -> url='{documentUrl}' title='{title}'"); + log.AppendLine($"body: {Summarize(bodyText)}"); + + await page.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("sc-error-probe-starturl403-https.png") }); + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + if (previousHome != null) + { + try + { + WorkspaceHomeAddress.Ensure(config, previousHome.Trim(), log); + log.AppendLine($"workspace home address restored to '{previousHome.Trim()}'"); + } + catch (Exception ex) + { + log.AppendLine($"!!! home restore FAILED ('{previousHome.Trim()}'): {ex.Message}"); + } + } + Artifacts.WriteText("sc-error-probe-starturl403-https-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + + // The refusal committed at the REQUESTED url - history/address-bar posture identical to + // the http row (no synthetic error-page url, no cancelled navigation). + Assert.NotNull(documentUrl); + Assert.StartsWith("https://neverssl.com", documentUrl); + + // Our page, not Chromium's native CONNECT-failure page. + Assert.Equal("Web Content proxy: 403", title); + Assert.NotNull(bodyText); + + // #66 friendly layer, same posture as the http row. + Assert.Contains("Not allowed by the administrator", bodyText); + Assert.Contains("This web address is not allowed for this view", bodyText); + + Assert.NotNull(detailsOpen); + Assert.False(detailsOpen.Value, "the technical details section is expanded by default"); + Assert.True(copyButtonPresent, "no Copy diagnostics button on the refusal page"); + + // The actionable facts: the verbatim refusal phrase plus the canonical https entry that + // would admit this origin (the scheme the user actually requested). + Assert.Contains("not in the workspace proxy allowlist", bodyText); + Assert.Contains("Allowlist entry that would admit this origin: https://neverssl.com", bodyText); + } + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeEventServerOutageShowsProxyErrorPage() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + if (!EventServerControl.OutageRowEnabled) + { + throw new InvalidOperationException( + "control INVALID: this row stops and starts the live Event Server service; run with WEBCONTENT_ES_STOP=1 to opt in"); + } + + var log = new System.Text.StringBuilder(); + string failure = null; + string healthyTitle = null; + string outageTitle = null; + string outageBody = null; + int outageStatus = -1; + string recoveredTitle = null; + string recoveredBody = null; + ClientSession sc = null; + ClientSession fresh = null; + var serviceStopped = false; + try + { + if (SkipMcProvisioning) + log.AppendLine($"CONTROL: UX_SKIP_MC_PROVISIONING=1 - the '{AllowlistEntry}' allowlist entry was applied from another box against {config.VmsAddress}."); + else + ProxyTargetProvisioning.Ensure(config, AllowlistEntry, log); + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + var addressBar = item.FindFirstDescendant(cf => cf.ByAutomationId("addressBar")); + if (addressBar == null) + { + throw new InvalidOperationException("No addressBar in the proxied workspace view; cannot drive the outage probe."); + } + + Thread.Sleep(6000); + SmartClientUi.NavigateAddress(addressBar, FetchUrl); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, "httpforever.com"); + await page.WaitForSelectorAsync("body", new PageWaitForSelectorOptions { Timeout = 45000 }); + healthyTitle = await page.TitleAsync(); + log.AppendLine($"baseline (Event Server UP) -> title='{healthyTitle}'"); + if (healthyTitle?.StartsWith("Web Content proxy:") == true) + { + throw new InvalidOperationException($"baseline is already a proxy error page: '{healthyTitle}'"); + } + await page.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("sc-error-probe-healthy.png") }); + + // The demo itself: the whole server-side proxy component disappears; the next reload + // must show the explicit proxy error page, not a spinner and not a direct hit. + EventServerControl.Stop(config, log); + serviceStopped = true; + log.AppendLine("Event Server stopped; reloading the proxied page..."); + + try + { + var response = await page.ReloadAsync(new PageReloadOptions { Timeout = 60000 }); + outageStatus = response?.Status ?? -1; + log.AppendLine($"reload response status: {outageStatus}"); + } + catch (PlaywrightException reloadError) + { + log.AppendLine($"reload surfaced '{reloadError.Message}' before committing; inspecting the committed document anyway"); + } + + var deadline = DateTime.UtcNow.AddSeconds(60); + do + { + outageTitle = await page.TitleAsync(); + if (outageTitle?.StartsWith("Web Content proxy:") == true) break; + await Task.Delay(1000); + } while (DateTime.UtcNow < deadline); + + var body = await page.QuerySelectorAsync("body"); + outageBody = body == null ? null : await body.TextContentAsync(); + log.AppendLine($"reload (Event Server DOWN) -> title='{outageTitle}'"); + log.AppendLine($"body: {Summarize(outageBody)}"); + await page.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("sc-error-probe-es-down.png") }); + + // AC4 evidence (soft): the Smart Client MIP log names the failing side. The file can + // be mid-rotation or logging disabled, so absence is recorded, not asserted. + log.AppendLine(MipLogContains("ServerUnavailable") + ? "MIPLog.txt marker found: Proxy fetch ... failed on the Smart Client side ... (code=ServerUnavailable)" + : "NOTE: no 'ServerUnavailable' marker found in the Smart Client MIPLog.txt snapshot (logging may be disabled or rotated)"); + + // Recovery half of the demo: the restart disposes every connected client's messaging + // channel (AGENTS.md), so recovery is a FRESH Smart Client session, not a reload of + // this one. The new session must get the real page back - no error page, no spinner. + EventServerControl.Start(config, log); + serviceStopped = false; + Thread.Sleep(10000); // the fetcher's plugin load happens during service start + + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc.Dispose(); + sc = null; + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + + fresh = ClientSession.Launch(config.SmartClientExe); + var freshLogin = LoginFlow.WaitForLoginWindow(fresh); + LoginFlow.Login(fresh, freshLogin, config); + LoginFlow.AcceptCertificateTrust(fresh); + var freshMain = LoginFlow.WaitForSmartClientMain(fresh); + SmartClientUi.Maximize(freshMain); + Thread.Sleep(1500); + freshMain = fresh.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + freshMain = SmartClientUi.SelectWorkspace(freshMain, fresh, config.WorkspaceName); + var freshItem = SmartClientUi.WaitForMipViewItem(freshMain); + Assert.NotNull(freshItem); + var freshAddressBar = freshItem.FindFirstDescendant(cf => cf.ByAutomationId("addressBar")); + if (freshAddressBar == null) + { + throw new InvalidOperationException("No addressBar in the recovered session's view; cannot prove recovery."); + } + + Thread.Sleep(6000); + SmartClientUi.NavigateAddress(freshAddressBar, FetchUrl); + await using (var recovered = new CdpConnector()) + { + var recoveredBrowser = await recovered.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var recoveredPage = await CdpConnector.WaitForPageAsync(recoveredBrowser, "httpforever.com"); + await recoveredPage.WaitForSelectorAsync("body", new PageWaitForSelectorOptions { Timeout = 45000 }); + recoveredTitle = await recoveredPage.TitleAsync(); + var recoveredBodyElement = await recoveredPage.QuerySelectorAsync("body"); + recoveredBody = recoveredBodyElement == null ? null : await recoveredBodyElement.TextContentAsync(); + log.AppendLine($"fresh session after the restart -> title='{recoveredTitle}'"); + log.AppendLine($"body: {Summarize(recoveredBody)}"); + await recoveredPage.ScreenshotAsync(new PageScreenshotOptions { Path = Artifacts.PathOf("sc-error-probe-recovered.png") }); + } + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + if (serviceStopped) + { + try + { + EventServerControl.Start(config, log); + // The fetcher needs its service-start plugin load; give it the documented head start. + Thread.Sleep(10000); + } + catch (Exception ex) + { + log.AppendLine($"!!! Event Server START FAILED - manual recovery required on {config.VmsAddress}: {ex.Message}"); + } + } + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + fresh?.Dispose(); + Artifacts.WriteText("sc-error-probe-es-outage-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + Assert.NotNull(outageTitle); + Assert.Equal("Web Content proxy: 503", outageTitle); + Assert.NotNull(outageBody); + Assert.Contains("VMS server proxy component is not available", outageBody); + // Recovery: a session started after the service is back gets the real document again. + Assert.NotNull(recoveredTitle); + Assert.False(recoveredTitle.StartsWith("Web Content proxy:"), + $"a fresh session after the restart still shows a proxy error page: '{recoveredTitle}'"); + Assert.Equal(healthyTitle, recoveredTitle); + Assert.DoesNotContain("Web Content proxy:", recoveredBody); + } + + private static string Summarize(string text) + { + if (string.IsNullOrEmpty(text)) return ""; + var flat = text.Replace('\r', ' ').Replace('\n', ' '); + return flat.Substring(0, Math.Min(220, flat.Length)); + } + + /// Searches the Smart Client MIP logs (MIPLog.txt plus rolling MIPLogs copies). + private static bool MipLogContains(string marker) + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Smart Client"); + var candidates = new List(); + try + { + var top = Path.Combine(root, "MIPLog.txt"); + if (File.Exists(top)) candidates.Add(top); + var rollDir = Path.Combine(root, "MIPLogs"); + if (Directory.Exists(rollDir)) candidates.AddRange(Directory.EnumerateFiles(rollDir, "MIPLog*.txt")); + } + catch { return false; } + + foreach (var file in candidates) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, true); + string line; + while ((line = reader.ReadLine()) != null) + { + if (line.Contains(marker, StringComparison.Ordinal)) return true; + } + } + catch { /* locked mid-write or unreadable: try the next file */ } + } + return false; + } +} diff --git a/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs new file mode 100644 index 0000000..377156d --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxyFetchProbeTests.cs @@ -0,0 +1,181 @@ +using FlaUI.Core; +using FlaUI.Core.AutomationElements; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Diagnostic probe (#54): launches and logs in a Smart Client, opens the proxied +/// "Demo Workspace", and navigates its browser view to the workspace's allowlisted +/// http target. The view's WebView2 is pinned to the plugin's loopback forwarding +/// proxy, so a rendered page proves the full browser -> loopback listener -> MIP +/// messaging -> Event Server fetcher round-trip. Artifacts land in +/// webcontent-sc-proxy-probe\<run> (sc-proxy-probe-*.png / .txt). +/// +public class ScProxyFetchProbeTests +{ + private const string FetchUrl = "http://httpforever.com/"; + + /// + /// The origin form of as the workspace allowlist stores it: no + /// trailing slash. The fetch probe depends on this entry; the login probes learned the + /// hard way that the entry must be (re)provisioned, not assumed, after the workspace + /// item was recreated. + /// + private const string AllowlistEntry = "http://httpforever.com"; + + private readonly ITestOutputHelper _output; + + public ScProxyFetchProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedWorkspaceBrowserFetch() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new System.Text.StringBuilder(); + string failure = null; + string title = null; + var bodyChars = -1; + var directReachedTarget = false; + var blockedControl = Environment.GetEnvironmentVariable("WEBCONTENT_SC1_BLOCKED") == "1"; + ClientSession sc = null; + try + { + // Phase 1 (Management Client): provision the allowlist entry this probe depends + // on before the Smart Client logs in. Assuming the workspace keeps the entry + // forever is what turned this row red (403) after the Demo Workspace item was + // recreated without it. + ProxyTargetProvisioning.Ensure(config, AllowlistEntry, log); + + // Phase 2: fresh Smart Client so it fetches the updated configuration. + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + var addressBar = item.FindFirstDescendant(cf => cf.ByAutomationId("addressBar")); + if (addressBar == null) + { + throw new InvalidOperationException("No addressBar in the proxied workspace view; cannot drive the fetch probe."); + } + + // Give the plugin's ProxyFetchClient filter registration its documented 3-4 s + // head start before the first fetch is attempted through the loopback listener. + Thread.Sleep(6000); + + // SC-1 negative control (docs/verification-matrix.md): when the client machine's own + // route to the target is deliberately broken (hosts entry pointing it at 127.0.0.1), + // a direct hit must fail while the proxied page still renders - proving the traffic + // originates on the Event Server machine. Establish the block BEFORE rendering, so a + // run whose block is missing goes red instead of reporting the gap post hoc. + if (blockedControl) + { + directReachedTarget = await DirectGetReachesTargetAsync(); + if (directReachedTarget) + { + throw new InvalidOperationException( + "control INVALID: the client machine still reaches the target directly; add the hosts entry (127.0.0.1 httpforever.com # webcontent-sc-1-block) before this run"); + } + log.AppendLine("control pre-check: direct client-side GET already fails (route is blocked)"); + } + + SmartClientUi.NavigateAddress(addressBar, FetchUrl); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, "httpforever.com"); + + // The loopback fetch has the MIP round-trip plus the Event Server's own GET to + // satisfy; give it well past the client-side 30 s deadline to be conclusive. + var body = await page.WaitForSelectorAsync("body", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 }); + var text = body == null ? null : await body.TextContentAsync(); + title = await page.TitleAsync(); + bodyChars = text?.Length ?? -1; + + await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions + { + Path = Artifacts.PathOf("sc-proxy-probe-page.png"), + }); + + log.AppendLine($"navigated {FetchUrl} -> title='{title}' bodyChars={bodyChars}"); + if (!string.IsNullOrEmpty(text)) + { + var snippet = text.Replace('\r', ' ').Replace('\n', ' '); + log.AppendLine($"body snippet: {snippet.Substring(0, Math.Min(200, snippet.Length))}"); + } + + if (blockedControl) + { + // Re-check after the render: the control must still hold at verdict time. + directReachedTarget = await DirectGetReachesTargetAsync(); + log.AppendLine(directReachedTarget + ? "direct client-side GET -> reached the target (control INVALID: direct path not broken)" + : "direct client-side GET -> FAILED as required while the proxied page rendered"); + } + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}"); + var fresh = sc?.Windows().FirstOrDefault(w => w.AutomationId == "_mainApplicationWindow"); + if (fresh != null) + { + Artifacts.Window(fresh, "sc-proxy-probe-failure"); + } + } + finally + { + Artifacts.WriteText("sc-proxy-probe-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + } + + // The probe goes RED: evidence in the artifacts file is for humans, but a proxy that + // never completes, an error page that renders instead of the target, or a control run + // whose direct route was never broken must fail the run, not merely record it. + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + Assert.NotNull(title); + Assert.False(title.StartsWith("Web Content proxy:"), $"browser showed the proxy error page instead of the target: '{title}'"); + Assert.True(bodyChars >= 500, $"target body too small to be the real page ({bodyChars} chars)"); + if (blockedControl) + { + Assert.False(directReachedTarget, "SC-1 control invalid: the client reached the target directly while the proxied page rendered"); + } + } + + private static async Task DirectGetReachesTargetAsync() + { + using var direct = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + try + { + await direct.GetAsync(FetchUrl); + return true; + } + catch (Exception) + { + return false; + } + } +} diff --git a/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs new file mode 100644 index 0000000..e45bd70 --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxyLoginProbeTests.cs @@ -0,0 +1,317 @@ +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Live acceptance probes for #55 (SC-2 in docs/verification-matrix.md): a vendor-style login +/// flow through the proxied workspace browser. A POST login form, a 302 + Set-Cookie session +/// round-trip, a cookie-gated dashboard, and a background XHR all cross the +/// browser -> loopback listener -> MIP messaging -> Event Server fetcher chain with their +/// method, headers, and body intact. The probe is self-setting: it ensures the login server's +/// origin sits in the workspace proxy allowlist (Management Client phase), serves the login +/// site from this machine via (the Event Server fetches it from +/// the server network), then drives the login in Smart Client over CDP. +/// Artifacts land in webcontent-sc-proxy-probe\<run> (sc-login-probe-*.png / .txt). +/// +/// #56 adds the two HTTPS rows: the same site behind a self-signed certificate, allowlisted +/// WITHOUT the per-target override (the Event Server must refuse the target chain and the +/// browser must show the proxy error page — fail-closed), and allowlisted WITH +/// "accept-untrusted" (the whole login flow must complete over the loopback-terminated tunnel). +/// Both rows presuppose the ADR-0003 deployment: the Event Server holds the deployment CA in +/// LocalMachine\Personal and this machine trusts it in LocalMachine\Root, so the browser's +/// tunnel TLS to the loopback listener validates cleanly and any visible failure is the +/// TARGET's certificate decision, not the loopback leaf's. +/// +public class ScProxyLoginProbeTests +{ + /// + /// Origin the login site is served from and the workspace must allowlist. Override with + /// UX_LOGIN_TARGET when this machine's server-network address differs; the port must match + /// the allowlisted entry because the fetcher is driven at exactly this origin. + /// + private const string DefaultLoginTarget = "http://172.16.128.210:18123"; + + /// + /// #56: the self-signed HTTPS origin for the two TLS rows (its own port, so its allowlist + /// entry never collides with the plain-HTTP login origin). + /// + private const string DefaultHttpsLoginTarget = "https://172.16.128.210:18124"; + + private readonly ITestOutputHelper _output; + + public ScProxyLoginProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedVendorLoginFlow() + { + var target = Environment.GetEnvironmentVariable("UX_LOGIN_TARGET") ?? DefaultLoginTarget; + + var outcome = await RunLoginProbeAsync( + allowlistEntry: target, + target: target, + useTls: false, + artifactPrefix: "sc-login-probe"); + + // The probe goes RED like SC-1: a login that loops back to the form, a proxy error page, + // or a background XHR that comes back denied all fail the run, not merely record it. + Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}"); + Assert.NotNull(outcome.Title); + Assert.False(titleIsProxyError(outcome.Title), $"browser showed the proxy error page instead of the vendor site: '{outcome.Title}'"); + Assert.Equal("SIGNED IN", outcome.SignedIn); + Assert.NotNull(outcome.XhrStatus); + Assert.StartsWith("XHR-OK", outcome.XhrStatus); + Assert.Contains("POST /login", outcome.Log); + } + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedHttpsTargetFailsClosedWithoutOverride() + { + var target = Environment.GetEnvironmentVariable("UX_HTTPS_LOGIN_TARGET") ?? DefaultHttpsLoginTarget; + + // Allowlisted WITHOUT the accept-untrusted flag: the Event Server fetcher must refuse + // the self-signed target chain (TlsValidation), and the browser must see the proxy + // error page - never the login form, never a silent pass-through. + var outcome = await RunLoginProbeAsync( + allowlistEntry: target, + target: target, + useTls: true, + artifactPrefix: "sc-login-probe-tls-closed"); + + Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}"); + Assert.True( + titleIsProxyError(outcome.Title), + $"fail-closed expected the proxy error page for the untrusted target, got title '{outcome.Title}'{Environment.NewLine}{outcome.Log}"); + Assert.Contains("accept untrusted", outcome.PageBody ?? string.Empty); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("TLS", StringComparison.OrdinalIgnoreCase)), + $"the login server never recorded the refused TLS attempt{Environment.NewLine}{outcome.Log}"); + } + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedHttpsLoginFlowWithAcceptUntrustedOverride() + { + var target = Environment.GetEnvironmentVariable("UX_HTTPS_LOGIN_TARGET") ?? DefaultHttpsLoginTarget; + + // The same target WITH the per-entry override: the fetcher accepts the untrusted chain + // for exactly this origin, and the full login/session/XHR flow completes through the + // loopback-terminated tunnel. + var entry = $"{target} {AcceptUntrustedToken}"; + var outcome = await RunLoginProbeAsync( + allowlistEntry: entry, + target: target, + useTls: true, + artifactPrefix: "sc-login-probe-tls-open"); + + Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}"); + Assert.NotNull(outcome.Title); + Assert.False(titleIsProxyError(outcome.Title), $"override did not open the tunnel for the untrusted target: '{outcome.Title}'"); + Assert.Equal("SIGNED IN", outcome.SignedIn); + Assert.NotNull(outcome.XhrStatus); + Assert.StartsWith("XHR-OK", outcome.XhrStatus); + Assert.Contains("POST /login", outcome.Log); + } + + /// ProxyPolicy.AcceptUntrustedFlag, inlined (the harness cannot see internals). + private const string AcceptUntrustedToken = "accept-untrusted"; + + private static bool titleIsProxyError(string title) => + title != null && title.StartsWith("Web Content proxy:", StringComparison.OrdinalIgnoreCase); + + private sealed class ProbeOutcome + { + public string Failure; + public string Title; + public string PageBody; + public string SignedIn; + public string XhrStatus; + public string CookieEcho; + public IReadOnlyList ServerJournal = new string[0]; + public string Log = string.Empty; + } + + /// + /// Shared probe run: MC allowlist phase (the entry replaces any older line for the same + /// origin, so the fail-closed and override rows cannot leave contradictory entries), then + /// Smart Client + CDP drive. Rows that expect the proxy error page pass no login + /// interaction beyond navigation: the form never appears there, so the run captures the + /// error document and returns. + /// + private async Task RunLoginProbeAsync( + string allowlistEntry, + string target, + bool useTls, + string artifactPrefix) + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var targetUri = new Uri(target); + var log = new System.Text.StringBuilder(); + var outcome = new ProbeOutcome(); + string failure = null; + LoginTestServer server = null; + + try + { + // Phase 1: the workspace proxy allowlist must carry the login origin before the + // Smart Client logs in — a target the fetcher refuses is a NotAllowed frame, not a + // login page. + ProxyTargetProvisioning.Ensure(config, allowlistEntry, log); + + // Phase 2: serve the vendor site on THIS machine; the Event Server proves server-side + // reachability by fetching it from the VMS network while the browser never goes direct. + server = new LoginTestServer(targetUri.Port, useTls); + log.AppendLine($"{(useTls ? "https" : "http")} login server bound to port {targetUri.Port}, origin {target}, allowlist entry '{allowlistEntry}'"); + + // Phase 3: drive the real login flow in the proxied Smart Client browser. + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + using var sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + var addressBar = item.FindFirstDescendant(cf => cf.ByAutomationId("addressBar")); + if (addressBar == null) + { + throw new InvalidOperationException("No addressBar in the proxied workspace view; cannot drive the login probe."); + } + + // Filter-registration head start (documented 3-4 s), same as the SC-1 probe. + Thread.Sleep(6000); + + SmartClientUi.NavigateAddress(addressBar, targetUri.AbsoluteUri); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, targetUri.Host); + + // Playwright THROWS on a selector timeout; for the fail-closed row the missing form + // is the expected outcome (the proxy error page renders instead), so translate the + // timeout into a null form and let the branch below classify the document. + Microsoft.Playwright.IElementHandle form; + try + { + form = await page.WaitForSelectorAsync("#signin", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 45000 }); + } + catch (TimeoutException) + { + form = null; + } + + outcome.Title = await page.TitleAsync(); + log.AppendLine($"first document at {target}: title='{outcome.Title}' form={form != null}"); + if (form == null) + { + // No login form: either a genuine failure or the expected fail-closed error page. + outcome.PageBody = await page.ContentAsync(); + await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions + { + Path = Artifacts.PathOf($"{artifactPrefix}-document.png"), + }); + + if (!titleIsProxyError(outcome.Title)) + { + throw new InvalidOperationException($"login form not rendered at {target} — page shows something else"); + } + + // Expected fail-closed document: capture and return without login interaction. + outcome.ServerJournal = Snapshot(server); + outcome.Log = log.ToString(); + return outcome; + } + + await page.FillAsync("#username", LoginTestServer.Username); + await page.FillAsync("#password", LoginTestServer.Password); + await page.ClickAsync("#signin"); + + // The POST -> 302+Set-Cookie -> GET /dashboard chain runs entirely through the proxy; + // only a session that round-tripped reaches the signed-in marker. + var marker = await page.WaitForSelectorAsync("#signed-in", new Microsoft.Playwright.PageWaitForSelectorOptions { Timeout = 60000 }); + if (marker == null) + { + throw new InvalidOperationException("#signed-in never appeared — cookie/session round-trip failed"); + } + + outcome.SignedIn = await marker.TextContentAsync(); + outcome.Title = await page.TitleAsync(); + outcome.CookieEcho = await (await page.QuerySelectorAsync("#cookie-echo"))?.TextContentAsync(); + + // The dashboard's background fetch resolves asynchronously — reading #xhr-status the + // instant the dashboard renders races it and catches the XHR-PENDING placeholder. + // Wait for the XHR to land (either verdict), THEN read it. + await page.WaitForFunctionAsync( + "() => { var el = document.getElementById('xhr-status'); return el && el.textContent && el.textContent !== 'XHR-PENDING'; }", + null, + new Microsoft.Playwright.PageWaitForFunctionOptions { Timeout = 30000 }); + outcome.XhrStatus = await (await page.QuerySelectorAsync("#xhr-status"))?.TextContentAsync(); + + await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions + { + Path = Artifacts.PathOf($"{artifactPrefix}-dashboard.png"), + }); + + log.AppendLine($"after login: title='{outcome.Title}' signedIn='{outcome.SignedIn}' xhr='{outcome.XhrStatus}' cookieEcho='{outcome.CookieEcho}'"); + } + catch (Exception ex) + { + var detail = ex.Message; + if (ex is AggregateException agg) + { + detail = string.Join(";", agg.Flatten().InnerExceptions.Select(i => i.GetType().Name + ": " + i.Message)); + } + failure = ex.GetType().Name + ": " + detail; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + outcome.Failure = failure; + outcome.ServerJournal = Snapshot(server); + foreach (var entry in outcome.ServerJournal) + { + log.AppendLine($"server: {entry}"); + } + + server?.Dispose(); + outcome.Log = log.ToString(); + Artifacts.WriteText($"{artifactPrefix}-results.txt", outcome.Log); + _output.WriteLine(outcome.Log); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + return outcome; + } + + private static IReadOnlyList Snapshot(LoginTestServer server) + { + if (server == null) + { + return new string[0]; + } + + lock (server) + { + return server.Journal; + } + } + +} diff --git a/tests/ux/WebContent.UxTests/ScProxyTrustProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyTrustProbeTests.cs new file mode 100644 index 0000000..5888963 --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxyTrustProbeTests.cs @@ -0,0 +1,892 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Capturing; + +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Ticket #72 live rows (verification-matrix SC-10): the untrusted-deployment-CA posture end to +/// end. Row 1 (unattended): with the CA trusted (the lab's normal state) a proxied view shows NO +/// CA banner at all - the ticket's "nothing worse than today" half. Row 2 (unattended, dialog +/// free): every negative path of the DEPLOYED WebContentTrustHelper.exe from the drop - usage, +/// unreadable, garbage, oversize, thumbprint mismatch, and the unprivileged store refusal - all +/// of which refuse BEFORE any UAC/crypt32 dialog could appear. Row 3 (unattended): the helper's +/// full success path executed ON the Event Server machine inside an elevated remote session +/// (like WssTargetTrust's import: remote admin sessions are already elevated, so no UAC and +/// crypt32's modal prompt has no interactive desktop to appear on): throwaway CN-scoped CA, +/// helper imports it, present-verified in LocalMachine\Root, removed, absent-verified. Row 4 +/// (guarded, WEBCONTENT_CA_UNTRUST_LIVE=1): CA removed from BOTH root stores, Smart Client +/// shows the banner on the proxied view for the ordinary user, dismiss hides it for the session, +/// setup mode additionally reveals the Fix button (never clicked unattended - the UAC consent +/// would hang on the secure desktop; the declined/confirmed outcomes are unit+guard pinned). +/// +public class ScProxyTrustProbeTests +{ + /// The plugin drop's install folder (AGENTS.md INSTALLFOLDER); row 2 runs the real + /// deployed exe from here, row 3 pins the same path on the Event Server machine. + private const string PluginInstallFolder = @"C:\Program Files\VideoOS\MIPPlugins\CascadiaWebContentPlugin"; + + private const string HelperExeName = "WebContentTrustHelper.exe"; + + /// CN marking every helper-probe certificate (row 3); import rows purge by this CN. + private const string ProbeCertificateCommonName = "WebContent trust-helper probe (test only)"; + + /// CN of the deployment CA the plugin minted (#56 authority); row 4 removes and + /// restores exactly this certificate. + private const string DeploymentCaCommonName = "XProtect WebContent Plugin Loopback Proxy"; + + /// The proxied https home of the lab workspace; a mint through the loopback listener + /// (which publishes the trust posture) requires routing on + this origin on the allowlist. + private const string ProxiedHttpsHome = "https://joshooaj.com/"; + + private static bool UntrustedBannerRowEnabled => + Environment.GetEnvironmentVariable("WEBCONTENT_CA_UNTRUST_LIVE") == "1"; + + private static bool HelperEndToEndRowEnabled => + Environment.GetEnvironmentVariable("UX_TRUST_HELPER_E2E") != "0"; + + private readonly ITestOutputHelper _output; + + public ScProxyTrustProbeTests(ITestOutputHelper output) => _output = output; + + // ----------------------------------------------------------------------------------------- + // Row 1: CA trusted (lab normal) -> a proxied view shows no CA chrome at all. + // ----------------------------------------------------------------------------------------- + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeTrustedCaProxiedViewShowsNoBanner() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + string failure = null; + string previousHome = null; + ClientSession sc = null; + try + { + Assert.True(RootStoreContainsCaCn(StoreLocation.LocalMachine) || RootStoreContainsCaCn(StoreLocation.CurrentUser), + "precondition: the deployment CA must be in a root store for this row (it is the lab's normal state)"); + + previousHome = WorkspaceHomeAddress.Ensure(config, ProxiedHttpsHome, log); + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + // Let the https home commit and the leaf mint run (the mint publishes the posture); + // then a collapsed WPF element must simply be ABSENT from the UIA tree. + Thread.Sleep(12000); + var banner = item.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaBanner")); + var fix = item.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaFixButton")); + log.AppendLine($"trusted CA -> proxyCaBanner present: {banner != null}, proxyCaFixButton present: {fix != null}"); + + var windowShot = FlaUI.Core.Capturing.Capture.Element(main); + windowShot.ToFile(Artifacts.PathOf("sc-trust-probe-trusted-no-banner.png")); + windowShot.Dispose(); + + Assert.True(banner == null, "the CA banner appeared even though the deployment CA is trusted"); + Assert.True(fix == null, "the fix button appeared even though the CA is trusted"); + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + if (previousHome != null) + { + try + { + WorkspaceHomeAddress.Ensure(config, previousHome.Trim(), log); + log.AppendLine($"workspace home address restored to '{previousHome.Trim()}'"); + } + catch (Exception ex) + { + log.AppendLine($"!!! home restore FAILED ('{previousHome.Trim()}'): {ex.Message}"); + } + } + Artifacts.WriteText("sc-trust-probe-trusted-no-banner-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + // ----------------------------------------------------------------------------------------- + // Row 2: negative paths of the DEPLOYED helper exe. Every case refuses before any store or + // dialog is reached, so the row is fully unattended on this (non-elevated) machine. + // ----------------------------------------------------------------------------------------- + + [Fact] + [Trait("Category", "probe")] + public void ProbeDeployedTrustHelperRefusesEveryNegativePath() + { + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var log = new StringBuilder(); + var failures = new List(); + try + { + var helper = Path.Combine(PluginInstallFolder, HelperExeName); + Assert.True(File.Exists(helper), $"the deployed {HelperExeName} is missing from {PluginInstallFolder} - deploy first"); + log.AppendLine($"helper under test: {helper}"); + LogVersion(helper, log); + + string tempDir = Path.Combine(Path.GetTempPath(), "wc-trust-helper-probe-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var garbagePath = Path.Combine(tempDir, "garbage.der"); + File.WriteAllText(garbagePath, "not a certificate at all"); + var oversizePath = Path.Combine(tempDir, "oversize.der"); + File.WriteAllBytes(oversizePath, new byte[9 * 1024]); + + var (realDer, realThumbprint) = CreateThrowawayCertificate(tempDir); + + // exit 1: usage family (no args, bad thumbprint shape). + ExpectExit(RunHelper(helper, Array.Empty()), 1, "no arguments -> usage", log, failures); + ExpectExit(RunHelper(helper, new[] { garbagePath, "not-a-thumbprint" }), 1, "non-hex thumbprint -> usage", log, failures); + + // exit 3: unreadable certificate file. + ExpectExit(RunHelper(helper, new[] { Path.Combine(tempDir, "missing.der"), new string('A', 40) }), 3, "missing file -> 3", log, failures); + + // exit 2: certificate refused before any store touch. + ExpectExit(RunHelper(helper, new[] { garbagePath, new string('A', 40) }), 2, "garbage bytes -> refused", log, failures); + ExpectExit(RunHelper(helper, new[] { oversizePath, new string('A', 40) }), 2, "oversize file -> refused", log, failures); + + var mismatch = RunHelper(helper, new[] { realDer, new string('A', 40) }); + ExpectExit(mismatch, 2, "thumbprint mismatch -> refused", log, failures); + if (!mismatch.Output.Contains("does not match the expected", StringComparison.Ordinal)) + { + failures.Add("thumbprint-mismatch run did not state the refusal reason"); + } + log.AppendLine("thumbprint-mismatch output: " + Flatten(mismatch.Output)); + + // exit 4: everything validated, but THIS process (non-elevated) cannot even open + // LocalMachine\Root for write - the negative shape the ticket's audit trail pins. + // No dialog appears on this path (verified: the open simply Access-Denies). + var unprivileged = RunHelper(helper, new[] { realDer, realThumbprint }); + ExpectExit(unprivileged, 4, "matched cert + non-elevated process -> store unavailable", log, failures); + log.AppendLine("non-elevated run output: " + Flatten(unprivileged.Output)); + } + finally + { + try { Directory.Delete(tempDir, true); } catch { } + } + } + catch (Exception ex) + { + failures.Add($"{ex.GetType().Name}: {ex.Message}"); + log.AppendLine($"probe failed: {ex}"); + } + finally + { + Artifacts.WriteText("sc-trust-probe-helper-negative-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failures.Count == 0, $"helper negative-path failures:{Environment.NewLine}" + string.Join(Environment.NewLine, failures) + Environment.NewLine + log); + } + + // ----------------------------------------------------------------------------------------- + // Row 3: the helper's SUCCESS path, end to end ON the Event Server machine inside an + // elevated remote session (already-elevated, non-interactive: no UAC, crypt32's prompt has + // no interactive desktop - same session shape as WssTargetTrust's import). CN-scoped CA, + // import, presence check, removal, absence check. Set UX_TRUST_HELPER_E2E=0 to opt out. + // ----------------------------------------------------------------------------------------- + + [Fact] + [Trait("Category", "probe")] + public void ProbeTrustHelperElevatedEndToEndOnEventServer() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + if (!HelperEndToEndRowEnabled) + { + _output.WriteLine("SKIPPED: UX_TRUST_HELPER_E2E=0 - helper end-to-end row opted out."); + return; + } + + var log = new StringBuilder(); + string failure = null; + try + { + var host = config.VmsAddress.Contains("://", StringComparison.Ordinal) + && Uri.TryCreate(config.VmsAddress, UriKind.Absolute, out var uri) ? uri.Host : config.VmsAddress; + log.AppendLine($"event server host: {host}"); + + var script = $@" +$ErrorActionPreference = 'Stop' +$password = ConvertTo-SecureString $env:WEBCONTENT_ES_PASSWORD -AsPlainText -Force +$credential = New-Object System.Management.Automation.PSCredential($env:WEBCONTENT_ES_USER, $password) +$session = New-PSSession -ComputerName '{host}' -Credential $credential +try {{ + Invoke-Command -Session $session -ScriptBlock {{ + $ErrorActionPreference = 'Stop' + $commonName = '{ProbeCertificateCommonName}' + $helper = Join-Path '{PluginInstallFolder}' '{HelperExeName}' + if (-not (Test-Path -LiteralPath $helper)) {{ throw ('deployed helper missing on this machine: ' + $helper) }} + Write-Output ('helper on server: ' + ((Get-Item $helper).VersionInfo.FileVersion + ' ' + (Get-FileHash $helper -Algorithm SHA256).Hash)) + + # throwaway CN-scoped certificate, generated on the server itself + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $request = New-Object System.Security.Cryptography.X509Certificates.CertificateRequest( + ('CN=' + $commonName), $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $cert = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddDays(-1), [DateTimeOffset]::UtcNow.AddDays(30)) + $derPath = Join-Path $env:TEMP ('wc-trust-helper-e2e-' + $cert.Thumbprint + '.der') + [System.IO.File]::WriteAllBytes($derPath, $cert.RawData) + Write-Output ('probe certificate: ' + $cert.Thumbprint) + + # the helper, run from the deployed drop, inside this elevated non-interactive session + # (EAP relaxed around the native call: PS 5.1 turns redirected stderr into terminating + # error records otherwise; the exit code is the real verdict) + $ErrorActionPreference = 'Continue' + $output = & $helper $derPath $cert.Thumbprint 2>&1 + $exit = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + Remove-Item -LiteralPath $derPath -Force -ErrorAction SilentlyContinue + Write-Output ('helper exit=' + $exit + ' output: ' + (($output | Out-String).Trim() -replace [char]13, ' ')) + if ($exit -ne 0) {{ throw ('helper exit ' + $exit) }} + + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'LocalMachine') + $store.Open('ReadWrite') + try {{ + if (($store.Certificates.Find('FindByThumbprint', $cert.Thumbprint, $false)).Count -eq 0) {{ + throw ('helper claimed success but ' + $cert.Thumbprint + ' is NOT in LocalMachine\Root') + }} + Write-Output ('verified present in LocalMachine\Root: ' + $cert.Thumbprint) + $stale = @($store.Certificates | Where-Object {{ $_.Subject -eq ('CN=' + $commonName) }}) + foreach ($old in $stale) {{ + $store.Remove($old) + Write-Output ('removed: ' + $old.Thumbprint) + }} + if (($store.Certificates.Find('FindByThumbprint', $cert.Thumbprint, $false)).Count -ne 0) {{ + throw ('cleanup failed: ' + $cert.Thumbprint + ' still in LocalMachine\Root') + }} + Write-Output ('verified absent after cleanup: ' + $cert.Thumbprint) + }} finally {{ $store.Close() }} + }} +}} finally {{ + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +}} +"; + + var startInfo = new ProcessStartInfo + { + FileName = ResolvePowerShell(), + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.Environment["WEBCONTENT_ES_USER"] = config.VmsUsername; + startInfo.Environment["WEBCONTENT_ES_PASSWORD"] = config.VmsPassword; + + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + foreach (var line in stdout.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) log.AppendLine(" es-helper: " + line.Trim()); + } + if (process.ExitCode != 0) + { + log.AppendLine($" remote phase FAILED (exit {process.ExitCode}): {stderr.Trim()}"); + } + + Assert.True(process.ExitCode == 0, $"Event Server helper end-to-end phase failed (exit {process.ExitCode}): {stderr.Trim()}"); + Assert.Contains("verified present in LocalMachine\\Root", stdout, StringComparison.Ordinal); + Assert.Contains("verified absent after cleanup", stdout, StringComparison.Ordinal); + } + catch (Exception ex) + { + failure = $"{ex.GetType().Name}: {ex.Message}"; + log.AppendLine($"probe failed: {failure}{Environment.NewLine}{ex}"); + } + finally + { + Artifacts.WriteText("sc-trust-probe-helper-endtoend-results.txt", log.ToString()); + _output.WriteLine(log.ToString()); + } + + Assert.True(failure == null, $"probe run failed: {failure}{Environment.NewLine}{log}"); + } + + // ----------------------------------------------------------------------------------------- + // Row 4: the untrusted state made real on THIS machine (both root stores), the banner live, + // dismiss per session, setup-mode-only fix button. Guarded: WEBCONTENT_CA_UNTRUST_LIVE=1. + /// + /// The machine-store half of the mutation needs elevation: the row attempts an elevated + /// localhost remote session (jhadmin credentials, same convention as the ES helpers). When + /// that channel is unavailable unattended, the row restores the user store, records SKIPPED + /// with the exact reason, and passes without asserting - the human-step procedure lands in + /// docs/verification-matrix.md instead. Nothing is left mutated on the skip route. + /// + // ----------------------------------------------------------------------------------------- + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeUntrustedCaBannerDismissAndSetupGate() + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + if (!UntrustedBannerRowEnabled) + { + _output.WriteLine("SKIPPED (opt-in row): set WEBCONTENT_CA_UNTRUST_LIVE=1 to mutate this machine's root stores for the untrusted-banner live row."); + return; + } + + var log = new StringBuilder(); + var failures = new List(); + string skippedReason = null; + string previousHome = null; + ClientSession sc = null; + byte[] authorityDer = null; + string authorityThumbprint = null; + var lmRemoved = false; + try + { + // Locate the deployment CA the plugin minted (#56 authority) via the user store, + // where this test's own token can read and remove without elevation. + using (var cu = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + cu.Open(OpenFlags.ReadWrite); + var found = cu.Certificates + .Find(X509FindType.FindBySubjectName, DeploymentCaCommonName, false) + .OfType() + .FirstOrDefault(c => c.Subject.Contains(DeploymentCaCommonName, StringComparison.Ordinal)); + if (found == null) + { + skippedReason = $"no '{DeploymentCaCommonName}' certificate in CurrentUser\\Root - this machine is not in the CA'd lab posture"; + return; + } + + authorityDer = found.RawData; + authorityThumbprint = found.Thumbprint; + log.AppendLine($"deployment CA: {authorityThumbprint} found in CurrentUser\\Root"); + + if (!LocalMachineRootContains(authorityThumbprint, log)) + { + skippedReason = "the deployment CA is not in LocalMachine\\Root either - there is no untrusted-state transition to probe"; + cu.Add(new X509Certificate2(authorityDer)); + return; + } + } + + // Untrusted = absent from BOTH stores. User half first (works when this test host is + // elevated; Access-Denied on a plain shell, verified live). Machine half through an + // elevated localhost remote session - if that channel is unavailable unattended, + // restore the user half and skip cleanly. + try + { + using (var cu = new X509Store(StoreName.Root, StoreLocation.CurrentUser)) + { + cu.Open(OpenFlags.ReadWrite); + foreach (var stale in cu.Certificates.Find(X509FindType.FindByThumbprint, authorityThumbprint, false).OfType()) + { + cu.Remove(stale); + } + } + log.AppendLine("deployment CA removed from CurrentUser\\Root"); + } + catch (Exception ex) + { + // A CA in CurrentUser\Root is store-protected: removing it needs an elevated + // token of THIS user, which no unattended remote channel can substitute (the + // WinRM session sees the remote-admin's user store, not this one). The live row + // runs from an elevated shell (human step); unattended it skips with the stores + // untouched. + skippedReason = $"CurrentUser\\Root removal refused unattended ({ex.Message}) - run this row from an elevated joshooaj shell for the live untrusted state"; + RestoreUserStoreAuthority(authorityDer, log); + return; + } + + var removal = (ExitCode: -1, Output: string.Empty); + try + { + using var lm = new X509Store(StoreName.Root, StoreLocation.LocalMachine); + lm.Open(OpenFlags.ReadWrite); + var found = lm.Certificates.Find(X509FindType.FindByThumbprint, authorityThumbprint, false); + foreach (var c in found.OfType()) + { + lm.Remove(c); + } + + removal = (0, $"direct elevated removal of {found.Count} copy/copies from LocalMachine\\Root"); + } + catch (Exception direct) + { + log.AppendLine($"direct LocalMachine\\Root removal unavailable ({direct.Message}); trying the elevated localhost remote channel..."); + removal = RunElevatedlocalhostStoreOperation(config, authorityDer, authorityThumbprint, remove: true, log); + } + if (removal.ExitCode != 0) + { + RestoreUserStoreAuthority(authorityDer, log); + skippedReason = $"LocalMachine\\Root removal needs elevation and the unattended channel failed (exit {removal.ExitCode}): {Flatten(removal.Output)}"; + return; + } + + lmRemoved = true; + if (LocalMachineRootContains(authorityThumbprint, log) || UserStoreContains(authorityThumbprint)) + { + failures.Add("the CA is still in a root store after removal - the untrusted state was never established"); + throw new InvalidOperationException(failures.Last()); + } + + log.AppendLine("untrusted state established (CA absent from BOTH root stores); launching Smart Client..."); + + previousHome = WorkspaceHomeAddress.Ensure(config, ProxiedHttpsHome, log); + + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + // The mint publishes the posture as soon as the first https tunnel terminates; the + // banner must appear for this ORDINARY user (no setup needed to see it). + var banner = WaitForElement(item, "proxyCaBanner", 60); + log.AppendLine(banner == null + ? "proxyCaBanner NEVER appeared in the UIA tree" + : $"proxyCaBanner appeared (IsOffscreen={banner.IsOffscreen})"); + if (banner == null) failures.Add("banner missing in the live untrusted state"); + + var bannerText = item.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaBannerText")); + var bannerMessage = bannerText?.Name; + log.AppendLine($"banner text: '{bannerMessage}'"); + if (bannerMessage == null || !bannerMessage.Contains("not trusted", StringComparison.OrdinalIgnoreCase)) + { + failures.Add("the banner text does not state the not-trusted condition"); + } + + // Browse mode: the fix affordance must be ABSENT for this user (it is setup-only). + var fixBrowse = item.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaFixButton")); + log.AppendLine($"browse mode -> proxyCaFixButton present: {fixBrowse != null}"); + if (fixBrowse != null) failures.Add("the setup-only fix button was visible outside setup mode"); + + Capture.Element(main).ToFile(Artifacts.PathOf("sc-trust-probe-untrusted-banner.png")); + Thread.Sleep(250); + + // Dismiss for this session. + var dismiss = item.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaDismissButton")); + if (dismiss == null) failures.Add("no dismiss affordance on the banner"); + else + { + Assert.True(SmartClientUi.TryInvokeById(main, "proxyCaDismissButton"), "dismiss button did not invoke"); + Thread.Sleep(2000); + var afterDismiss = item.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaBanner")); + log.AppendLine($"after dismiss -> proxyCaBanner present: {afterDismiss != null}"); + if (afterDismiss != null) failures.Add("the banner survived the dismiss for this session"); + } + + // Setup mode: the same banner condition now ALSO reveals the fix button. (NOT clicked: + // the UAC consent would block on the secure desktop unattended; declined/confirmed + // outcomes are unit+guard pinned - see docs/verification-matrix.md human step.) + SmartClientUi.ClickSetup(main); + Thread.Sleep(4000); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + var item2 = SmartClientUi.WaitForMipViewItem(main); + var fixSetup = item2?.FindFirstDescendant(cf => cf.ByAutomationId("proxyCaFixButton")); + log.AppendLine($"setup mode -> proxyCaFixButton present: {fixSetup != null}"); + if (fixSetup == null) failures.Add("the fix button did not appear in setup mode while the CA is untrusted"); + else + { + log.AppendLine($"fix button name: '{fixSetup.Name}'"); + } + + Capture.Element(main).ToFile(Artifacts.PathOf("sc-trust-probe-untrusted-setup-fix.png")); + + var markerFound = MipLogContains("not trusted on this machine") || MipLogContains("trust posture"); + log.AppendLine(markerFound + ? "MIPLog.txt carries the trust-posture marker" + : "NOTE: no trust-posture marker found in the Smart Client MIPLog.txt snapshot (logging may be disabled or rotated)"); + } + catch (Exception ex) + { + failures.Add($"{ex.GetType().Name}: {ex.Message}"); + log.AppendLine($"probe failed: {ex}"); + } + finally + { + ProcessKiller.KillClients("Client"); + ProcessKiller.KillClients("VideoOS.Administration"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + sc?.Dispose(); + if (previousHome != null) + { + try { WorkspaceHomeAddress.Ensure(config, previousHome.Trim(), log); } catch (Exception ex) { log.AppendLine($"!!! home restore FAILED: {ex.Message}"); } + } + + // Restore the machine to the trusted lab state BEFORE anything else: the CA must be + // back in both stores or every later https probe breaks. + if (authorityDer != null && authorityThumbprint != null) + { + try + { + RestoreUserStoreAuthority(authorityDer, log); + if (lmRemoved) + { + var restore = TryDirectRestore(authorityDer, authorityThumbprint, log); + if (restore.ExitCode != 0) + { + restore = RunElevatedlocalhostStoreOperation(config, authorityDer, authorityThumbprint, remove: false, log); + } + + if (restore.ExitCode != 0 || !LocalMachineRootContains(authorityThumbprint, log)) + { + log.AppendLine($"!!! LocalMachine\\Root restore FAILED - manual step required: import the CA DER (thumbprint {authorityThumbprint}) back into LocalMachine\\Root. exit={restore.ExitCode} {Flatten(restore.Output)}"); + failures.Add("LocalMachine\\Root restore failed - manual re-import required on this machine"); + } + else + { + log.AppendLine("deployment CA restored to LocalMachine\\Root and CurrentUser\\Root"); + } + } + else + { + log.AppendLine("machine store was never touched; user store restored"); + } + } + catch (Exception ex) + { + log.AppendLine($"!!! authority restore FAILED: {ex.Message}"); + } + } + + var result = new StringBuilder(); + if (skippedReason != null) + { + result.AppendLine($"SKIPPED: {skippedReason}"); + result.AppendLine("The untrusted-state live row needs an elevated removal from LocalMachine\\Root, which this unattended channel could not perform; the human-step procedure is documented in docs/verification-matrix.md (SC-10)."); + } + result.Append(log); + Artifacts.WriteText("sc-trust-probe-untrusted-banner-results.txt", result.ToString()); + _output.WriteLine(result.ToString()); + } + + if (skippedReason != null) + { + return; // opt-in row honestly skipped: no live mutation happened, nothing to assert. + } + + Assert.True(failures.Count == 0, $"untrusted-banner live failures:{Environment.NewLine}" + string.Join(Environment.NewLine, failures) + Environment.NewLine + log); + } + + // ----------------------------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------------------------- + + private static AutomationElement WaitForElement(AutomationElement scope, string automationId, int timeoutSeconds) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (DateTime.UtcNow < deadline) + { + var found = scope.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + if (found != null) return found; + Thread.Sleep(1000); + } + + return null; + } + + private static bool RootStoreContainsCaCn(StoreLocation location) + { + try + { + using var store = new X509Store(StoreName.Root, location); + store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); + return store.Certificates.Find(X509FindType.FindBySubjectName, DeploymentCaCommonName, false).Count > 0; + } + catch + { + return false; + } + } + + private static bool LocalMachineRootContains(string thumbprint, StringBuilder log) + { + try + { + using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); + return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false).Count > 0; + } + catch (Exception ex) + { + log.AppendLine($"LocalMachine\\Root read failed: {ex.Message}"); + return false; + } + } + + private static bool UserStoreContains(string thumbprint) + { + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly); + return store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false).Count > 0; + } + + private static void RestoreUserStoreAuthority(byte[] der, StringBuilder log) + { + try + { + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + if (store.Certificates.Find(X509FindType.FindByThumbprint, new X509Certificate2(der).Thumbprint, false).Count == 0) + { + store.Add(new X509Certificate2(der)); + log.AppendLine("deployment CA re-added to CurrentUser\\Root"); + } + } + catch (Exception ex) + { + log.AppendLine($"!!! CurrentUser\\Root restore FAILED: {ex.Message}"); + } + } + + private static (int ExitCode, string Output) TryDirectRestore(byte[] der, string thumbprint, StringBuilder log) + { + try + { + using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadWrite); + if (store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false).Count == 0) + { + store.Add(new X509Certificate2(der)); + log.AppendLine("deployment CA re-added directly to LocalMachine\\Root"); + } + + return (0, "direct restore"); + } + catch (Exception ex) + { + return (-1, $"direct LocalMachine\\Root restore unavailable: {ex.Message}"); + } + } + + /// Elevated localhost remote session (jhadmin from .env/SecretStore): WinRM sessions + /// for administrators are already elevated, so the store Remove/Add runs unattended with no + /// UAC and no crypt32 prompt (no interactive desktop) - the same mechanism WssTargetTrust + /// uses against the Event Server. + private static (int ExitCode, string Output) RunElevatedlocalhostStoreOperation( + UxConfig config, byte[] der, string thumbprint, bool remove, StringBuilder log) + { + var derBase64 = Convert.ToBase64String(der); + var flagLiteral = remove ? "$true" : "$false"; + var script = $@" +$ErrorActionPreference = 'Stop' +$password = ConvertTo-SecureString $env:WEBCONTENT_ES_PASSWORD -AsPlainText -Force +$credential = New-Object System.Management.Automation.PSCredential($env:WEBCONTENT_ES_USER, $password) +$session = New-PSSession -ComputerName localhost -Credential $credential +try {{ + Invoke-Command -Session $session -ArgumentList ([Convert]::FromBase64String($env:WEBCONTENT_CA_DER)), '{thumbprint}', {flagLiteral} -ScriptBlock {{ + param($certificateBytes, $expectedThumbprint, $removeIt) + $ErrorActionPreference = 'Stop' + $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certificateBytes) + if ($certificate.Thumbprint -ne $expectedThumbprint) {{ throw ('DER/thumbprint drift: ' + $certificate.Thumbprint) }} + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'LocalMachine') + $store.Open('ReadWrite') + try {{ + if ($removeIt) {{ + $found = $store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false) + foreach ($c in $found) {{ $store.Remove($c) }} + Write-Output ('removed ' + $found.Count + ' copy/copies from LocalMachine\Root (elevated)') + if (($store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false)).Count -ne 0) {{ + throw 'post-condition failed: still present after removal' + }} + }} else {{ + if (-not ($store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false)).Count) {{ + $store.Add($certificate) + Write-Output 're-imported into LocalMachine\Root (elevated)' + }} else {{ Write-Output 'already present' }} + if (($store.Certificates.Find('FindByThumbprint', $expectedThumbprint, $false)).Count -eq 0) {{ + throw 'post-condition failed: absent after add' + }} + }} + }} finally {{ $store.Close() }} + }} +}} finally {{ + Remove-PSSession -Session $session -ErrorAction SilentlyContinue +}} +"; + + var startInfo = new ProcessStartInfo + { + FileName = ResolvePowerShell(), + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.Environment["WEBCONTENT_ES_USER"] = config.VmsUsername; + startInfo.Environment["WEBCONTENT_ES_PASSWORD"] = config.VmsPassword; + startInfo.Environment["WEBCONTENT_CA_DER"] = derBase64; + + try + { + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + foreach (var line in stdout.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) log.AppendLine(" elevated-store: " + line.Trim()); + } + + return (process.ExitCode, string.IsNullOrEmpty(stderr.Trim()) ? stdout : stderr.Trim()); + } + catch (Exception ex) + { + return (-1, ex.Message); + } + } + + private static (int ExitCode, string Output) RunHelper(string helperPath, string[] arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = helperPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdout + stderr); + } + + private static void ExpectExit((int ExitCode, string Output) run, int expected, string label, StringBuilder log, List failures) + { + log.AppendLine($"{label}: exit={run.ExitCode} (expected {expected})"); + if (run.ExitCode != expected) + { + failures.Add($"{label}: exit {run.ExitCode}, expected {expected}; output: {Flatten(run.Output)}"); + } + } + + private static (string DerPath, string Thumbprint) CreateThrowawayCertificate(string directory) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=" + ProbeCertificateCommonName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + var path = Path.Combine(directory, "real.der"); + File.WriteAllBytes(path, certificate.RawData); + return (path, certificate.Thumbprint); + } + + private static void LogVersion(string executable, StringBuilder log) + { + try + { + var info = FileVersionInfo.GetVersionInfo(executable); + log.AppendLine($"helper file version: {info.FileVersion}, sha256: {Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(executable)))}"); + } + catch (Exception ex) + { + log.AppendLine($"(version read failed: {ex.Message})"); + } + } + + private static string Flatten(string text) + { + return (text ?? string.Empty).Replace('\r', ' ').Replace('\n', ' ').Trim(); + } + + private static string ResolvePowerShell() + { + var programFiles = Environment.GetEnvironmentVariable("ProgramFiles") ?? @"C:\Program Files"; + var pwsh = Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"); + if (File.Exists(pwsh)) return pwsh; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + } + + /// Searches the Smart Client MIP logs (MIPLog.txt plus rolling MIPLogs copies). + private static bool MipLogContains(string marker) + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Milestone", "XProtect Smart Client"); + var candidates = new List(); + try + { + var top = Path.Combine(root, "MIPLog.txt"); + if (File.Exists(top)) candidates.Add(top); + var rollDir = Path.Combine(root, "MIPLogs"); + if (Directory.Exists(rollDir)) candidates.AddRange(Directory.EnumerateFiles(rollDir, "MIPLog*.txt")); + } + catch { return false; } + + foreach (var file in candidates) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, true); + string line; + while ((line = reader.ReadLine()) != null) + { + if (line.Contains(marker, StringComparison.Ordinal)) return true; + } + } + catch { /* locked mid-write or unreadable: try the next file */ } + } + + return false; + } +} diff --git a/tests/ux/WebContent.UxTests/ScProxyWebSocketProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyWebSocketProbeTests.cs new file mode 100644 index 0000000..9f127ad --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxyWebSocketProbeTests.cs @@ -0,0 +1,172 @@ +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Live acceptance probe for #57 (SC-5 in docs/verification-matrix.md): a vendor page opened in +/// the proxied Smart Client browser establishes a WEBSOCKET, and the connection survives the +/// full chain — browser -> loopback listener (upgrade recognized, not fetched) -> MIP messaging +/// tunnel -> Event Server raw socket -> target 101 -> raw frames both directions. The page's +/// echoed token is client-side proof; the echo server's WS-HANDSHAKE/WS-ECHO journal is +/// server-side proof the upgrade really arrived as a websocket (not as a refused fetch). The +/// run is self-provisioning like the login probes: it ensures the ws origin (spelled as its +/// http twin) sits in the workspace proxy allowlist first. +/// Artifacts land in webcontent-sc-proxy-probe\<run> (sc-ws-probe-*.png / .txt). +/// +public class ScProxyWebSocketProbeTests +{ + /// + /// Origin the ws-echo page is served from and the workspace must allowlist (the ws:// + /// origin spelled as its http twin — the proxy gates the upgrade on the http origin). + /// Override with UX_WS_TARGET when this machine's server-network address differs. + /// + private const string DefaultWsTarget = "http://172.16.128.210:18125"; + + private readonly ITestOutputHelper _output; + + public ScProxyWebSocketProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedWebSocketEchoRoundTrip() + { + var target = Environment.GetEnvironmentVariable("UX_WS_TARGET") ?? DefaultWsTarget; + var outcome = await RunWebSocketProbeAsync(target); + + Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}"); + Assert.NotNull(outcome.Title); + Assert.False(titleIsProxyError(outcome.Title), $"browser showed the proxy error page instead of the ws page: '{outcome.Title}'"); + Assert.NotNull(outcome.WsStatus); + Assert.True( + outcome.WsStatus.StartsWith("WS-OK:", StringComparison.Ordinal), + $"the websocket round trip did not echo the token: '{outcome.WsStatus}'{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("WS-HANDSHAKE", StringComparison.Ordinal)), + $"the ws server never recorded the upgrade handshake{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("WS-ECHO", StringComparison.Ordinal)), + $"the ws server never echoed a frame back through the tunnel{Environment.NewLine}{outcome.Log}"); + } + + private static bool titleIsProxyError(string title) => + title != null && title.StartsWith("Web Content proxy:", StringComparison.OrdinalIgnoreCase); + + private sealed class ProbeOutcome + { + public string Failure; + public string Title; + public string WsStatus; + public IReadOnlyList ServerJournal = new string[0]; + public string Log = string.Empty; + } + + private async Task RunWebSocketProbeAsync(string target) + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var targetUri = new Uri(target); + var log = new System.Text.StringBuilder(); + var outcome = new ProbeOutcome(); + string failure = null; + WebSocketEchoServer server = null; + + try + { + // Phase 1: the allowlist entry for the ws origin (its http twin) must exist before + // the browser asks for the upgrade; an unlisted origin is a 403 page, not a tunnel. + ProxyTargetProvisioning.Ensure(config, target, log); + + // Phase 2: serve the ws-echo site on THIS machine; the Event Server proves server- + // network reachability by dialing it for the tunnel while the browser never goes direct. + server = new WebSocketEchoServer(targetUri.Port); + log.AppendLine($"ws-echo server bound to port {targetUri.Port}, origin {target}, allowlist entry '{target}'"); + + // Phase 3: drive the real page in the proxied Smart Client browser. + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + using var sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + var addressBar = item.FindFirstDescendant(cf => cf.ByAutomationId("addressBar")); + if (addressBar == null) + { + throw new InvalidOperationException("No addressBar in the proxied workspace view; cannot drive the ws probe."); + } + + // Filter-registration head start (documented 3-4 s), same as the other probes. + Thread.Sleep(6000); + + SmartClientUi.NavigateAddress(addressBar, targetUri.AbsoluteUri); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, targetUri.Host); + + // The page reports WS-PENDING until the echoed token lands (or a failure verdict). + await page.WaitForFunctionAsync( + "() => { var el = document.getElementById('ws-status'); return el && el.textContent && el.textContent !== 'WS-PENDING'; }", + null, + new Microsoft.Playwright.PageWaitForFunctionOptions { Timeout = 60000 }); + outcome.WsStatus = await (await page.QuerySelectorAsync("#ws-status"))?.TextContentAsync(); + outcome.Title = await page.TitleAsync(); + log.AppendLine($"ws verdict: status='{outcome.WsStatus}' title='{outcome.Title}'"); + + await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions + { + Path = Artifacts.PathOf("sc-ws-probe-page.png"), + }); + + // Hold the closed-loop evidence window open briefly so the server journal captures + // the close frames too. + await Task.Delay(1500); + } + catch (Exception ex) + { + var detail = ex.Message; + if (ex is AggregateException agg) + { + detail = string.Join(";", agg.Flatten().InnerExceptions.Select(i => i.GetType().Name + ": " + i.Message)); + } + failure = ex.GetType().Name + ": " + detail; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + outcome.Failure = failure; + outcome.ServerJournal = server == null + ? new string[0] + : (IReadOnlyList)server.Journal; + foreach (var entry in outcome.ServerJournal) + { + log.AppendLine($"server: {entry}"); + } + + server?.Dispose(); + outcome.Log = log.ToString(); + Artifacts.WriteText("sc-ws-probe-results.txt", outcome.Log); + _output.WriteLine(outcome.Log); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + return outcome; + } +} diff --git a/tests/ux/WebContent.UxTests/ScProxyWssProbeTests.cs b/tests/ux/WebContent.UxTests/ScProxyWssProbeTests.cs new file mode 100644 index 0000000..5e31be3 --- /dev/null +++ b/tests/ux/WebContent.UxTests/ScProxyWssProbeTests.cs @@ -0,0 +1,326 @@ +using WebContent.UxTests.Harness; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Live acceptance probes for #60 (SC-6 in docs/verification-matrix.md): a wss:// websocket +/// inside a PROXIED HTTPS page rides the same byte tunnel as plain ws — browser CONNECT -> +/// loopback listener terminates the tunnel TLS (ES-minted leaf) -> in-tunnel upgrade GET is +/// recognized as a websocket tunnel (NOT refused 501) -> MIP messaging tunnel -> Event Server +/// dials a fresh TLS leg to the target, replays the rebuilt handshake, admits only the 101, +/// then pumps decrypted frames both ways. The rows pin the TLS postures on the Event +/// Server's leg: with the probe certificate imported into the Event Server's LocalMachine\Root +/// store (WssTargetTrust) the round trip echoes the token; with an untrusted chain and no flag +/// the Event Server refuses before the upgrade ever reaches the target (the target journal +/// shows the TLS leg dialed but never the WS handshake) and the page reports a failed +/// websocket, never an echo. #61 adds the third posture: the SAME untrusted origin, but the +/// workspace allowlist entry carries the per-target accept-untrusted override - the tunnel TLS +/// leg then admits the self-signed chain and the round trip echoes, proving the flag reaches +/// the tunnel scoped to exactly its own origin. The allowlist posture is scheme-exact: the +/// entry is the https origin itself. +/// Artifacts land in webcontent-sc-proxy-probe\<run> (sc-wss-probe-*.png / .txt). +/// +public class ScProxyWssProbeTests +{ + /// Origin the wss-echo page is served from and allowlisted (plain http twin, same + /// shape as the ws probe). Override with UX_WSS_PAGE_TARGET when this machine's + /// server-network address differs. + private const string DefaultPageTarget = "http://172.16.128.210:18125"; + + /// #60 success row: the wss target origin (its own port, so its https allowlist + /// entry never collides with the page origin). Override with UX_WSS_TARGET. + private const string DefaultWssTarget = "https://172.16.128.210:18126"; + + /// #60 fail-closed row: a second TLS origin whose certificate is deliberately NOT + /// imported, so the Event Server's chain validation must refuse it. UX_WSS_UNTRUSTED_TARGET. + private const string DefaultUntrustedWssTarget = "https://172.16.128.210:18127"; + + private readonly ITestOutputHelper _output; + + public ScProxyWssProbeTests(ITestOutputHelper output) => _output = output; + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedWssEchoRoundTrip() + { + var page = Environment.GetEnvironmentVariable("UX_WSS_PAGE_TARGET") ?? DefaultPageTarget; + var target = Environment.GetEnvironmentVariable("UX_WSS_TARGET") ?? DefaultWssTarget; + + var outcome = await RunWssProbeAsync( + pageTarget: page, + wssTarget: target, + importProbeCertificate: true, + artifactPrefix: "sc-wss-probe"); + + Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}"); + Assert.NotNull(outcome.Title); + Assert.False(titleIsProxyError(outcome.Title), $"browser showed the proxy error page instead of the wss page: '{outcome.Title}'"); + Assert.NotNull(outcome.WsStatus); + Assert.True( + outcome.WsStatus.StartsWith("WS-OK:", StringComparison.Ordinal), + $"the wss round trip did not echo the token: '{outcome.WsStatus}'{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("TLS-HANDSHAKE", StringComparison.Ordinal)), + $"the wss server never completed a TLS handshake with the Event Server{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("WS-HANDSHAKE", StringComparison.Ordinal)), + $"the wss server never recorded the upgrade handshake{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("WS-ECHO", StringComparison.Ordinal)), + $"the wss server never echoed a frame back through the tunnel{Environment.NewLine}{outcome.Log}"); + } + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedWssTlsChainFailureRefusesBeforeTheUpgradeArrives() + { + var page = Environment.GetEnvironmentVariable("UX_WSS_PAGE_TARGET") ?? DefaultPageTarget; + var target = Environment.GetEnvironmentVariable("UX_WSS_UNTRUSTED_TARGET") ?? DefaultUntrustedWssTarget; + + // Same allowlist posture as the success row (the https origin, no flag), but the probe + // certificate is NOT trusted on the Event Server: the tunnel TLS leg must fail chain + // validation there, and only the failure side effects may appear. + var outcome = await RunWssProbeAsync( + pageTarget: page, + wssTarget: target, + importProbeCertificate: false, + artifactPrefix: "sc-wss-probe-tls-closed"); + + Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}"); + Assert.NotNull(outcome.Title); + Assert.False(titleIsProxyError(outcome.Title), $"the page origin is plain http: only the wss leg should fail, got '{outcome.Title}'"); + Assert.NotNull(outcome.WsStatus); + Assert.True( + outcome.WsStatus.StartsWith("WS-FAIL", StringComparison.Ordinal), + $"the untrusted wss target must fail the websocket, never echo: '{outcome.WsStatus}'{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("TLS-CONNECT", StringComparison.Ordinal)), + $"the wss server never saw the Event Server's TLS dial{Environment.NewLine}{outcome.Log}"); + Assert.True( + !outcome.ServerJournal.Any(entry => entry.Contains("WS-HANDSHAKE", StringComparison.Ordinal)), + $"the upgrade reached the target despite the Event Server refusing its TLS chain:{Environment.NewLine}{outcome.Log}"); + } + + [Fact] + [Trait("Category", "probe")] + public async Task ProbeProxiedWssAcceptUntrustedOverrideAdmitsSelfSignedTarget() + { + // #61 row: the SAME self-signed origin the fail-closed row names, certificate deliberately + // NOT imported anywhere - but the workspace allowlist entry now carries the per-target + // accept-untrusted override. The Event Server's tunnel TLS leg must take the flag (not + // chain trust) scoped to this origin, and the round trip echoes exactly like the trusted + // row. The fail-closed row above is the twin without the flag, and every row re-Ensures + // its own entry state, so the flag never leaks between rows. (The Event Server refreshes + // its allowlist union at most once per 60 s; the minutes between rows' Management Client + // phases comfortably exceed that window, and the page fetch itself re-triggers the + // refresh before the tunnel opens.) + var page = Environment.GetEnvironmentVariable("UX_WSS_PAGE_TARGET") ?? DefaultPageTarget; + var target = Environment.GetEnvironmentVariable("UX_WSS_UNTRUSTED_TARGET") ?? DefaultUntrustedWssTarget; + + var outcome = await RunWssProbeAsync( + pageTarget: page, + wssTarget: target, + allowlistEntry: target + " accept-untrusted", + importProbeCertificate: false, + artifactPrefix: "sc-wss-probe-accept-untrusted"); + + Assert.True(outcome.Failure == null, $"probe run failed: {outcome.Failure}{Environment.NewLine}{outcome.Log}"); + Assert.NotNull(outcome.Title); + Assert.False(titleIsProxyError(outcome.Title), $"browser showed the proxy error page instead of the wss page: '{outcome.Title}'"); + Assert.NotNull(outcome.WsStatus); + Assert.True( + outcome.WsStatus.StartsWith("WS-OK:", StringComparison.Ordinal), + $"the accept-untrusted override did not admit the self-signed wss target: '{outcome.WsStatus}'{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("TLS-HANDSHAKE", StringComparison.Ordinal)), + $"the wss server never completed a TLS handshake with the Event Server{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("WS-HANDSHAKE", StringComparison.Ordinal)), + $"the wss server never recorded the upgrade handshake{Environment.NewLine}{outcome.Log}"); + Assert.True( + outcome.ServerJournal.Any(entry => entry.Contains("WS-ECHO", StringComparison.Ordinal)), + $"the wss server never echoed a frame back through the tunnel{Environment.NewLine}{outcome.Log}"); + } + + private static bool titleIsProxyError(string title) => + title != null && title.StartsWith("Web Content proxy:", StringComparison.OrdinalIgnoreCase); + + private sealed class ProbeOutcome + { + public string Failure; + public string Title; + public string WsStatus; + public IReadOnlyList ServerJournal = new string[0]; + public string Log = string.Empty; + } + + private async Task RunWssProbeAsync( + string pageTarget, + string wssTarget, + bool importProbeCertificate, + string artifactPrefix, + string allowlistEntry = null) + { + var config = UxConfig.Load(); + Artifacts.Init(Path.Combine(Path.GetTempPath(), "webcontent-sc-proxy-probe")); + _output.WriteLine($"Artifacts: {Artifacts.RunDirectory}"); + + var pageUri = new Uri(pageTarget); + var targetUri = new Uri(wssTarget); + var log = new System.Text.StringBuilder(); + var outcome = new ProbeOutcome(); + string failure = null; + WebSocketEchoServer wssServer = null; + WebSocketEchoServer pageServer = null; + + try + { + // Phase 1: BOTH origins must sit in the workspace allowlist before the browser asks: + // the plain http origin for the page, the https origin itself for the wss posture + // (an http twin alone is a 403 for the upgrade; the entry scheme IS the posture). + // #61: the allowlist entry may carry the accept-untrusted flag suffix while the + // target uri stays bare - Ensure replaces any older row for the same origin, so a + // previous row's flag state never leaks into this row's verdict. + ProxyTargetProvisioning.Ensure(config, pageTarget, log); + ProxyTargetProvisioning.Ensure(config, allowlistEntry ?? wssTarget, log); + + // Phase 2: serve both sites on THIS machine; the Event Server proves server-network + // reachability by dialing the https origin for the tunnel TLS leg while the browser + // never goes direct. + wssServer = new WebSocketEchoServer(targetUri.Port, useTls: true, tlsHost: targetUri.Host); + log.AppendLine($"wss-echo server bound to port {targetUri.Port}, origin {wssTarget}, allowlist entry '{wssTarget}'"); + if (importProbeCertificate) + { + if (WssTargetTrust.ImportEnabled) + { + WssTargetTrust.ImportOnEventServer(config, wssServer.TlsCertificate, log); + } + else + { + log.AppendLine("UX_WSS_TRUST_IMPORT=0: assuming the target certificate is trusted on the Event Server by other means"); + } + } + else + { + log.AppendLine(allowlistEntry != null && allowlistEntry.EndsWith("accept-untrusted", StringComparison.Ordinal) + ? "override row: probe certificate deliberately NOT imported - only the entry's accept-untrusted flag may admit it" + : "fail-closed row: deliberately NOT importing the probe certificate (chain validation must refuse it)"); + } + + pageServer = new WebSocketEchoServer(pageUri.Port, useTls: false, probePageHtml: BuildWssProbePage(wssTarget)); + log.AppendLine($"wss-echo page served from {pageTarget}, allowlist entry '{pageTarget}', dialing wss target {wssTarget}"); + + // Phase 3: drive the real page in the proxied Smart Client browser. + CdpConnector.CleanStalePortFiles(config.WebView2DataRoot); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + + using var sc = ClientSession.Launch(config.SmartClientExe); + var login = LoginFlow.WaitForLoginWindow(sc); + LoginFlow.Login(sc, login, config); + LoginFlow.AcceptCertificateTrust(sc); + var main = LoginFlow.WaitForSmartClientMain(sc); + SmartClientUi.Maximize(main); + Thread.Sleep(1500); + main = sc.Windows().First(w => w.AutomationId == "_mainApplicationWindow"); + + main = SmartClientUi.SelectWorkspace(main, sc, config.WorkspaceName); + var item = SmartClientUi.WaitForMipViewItem(main); + Assert.NotNull(item); + + var addressBar = item.FindFirstDescendant(cf => cf.ByAutomationId("addressBar")); + if (addressBar == null) + { + throw new InvalidOperationException("No addressBar in the proxied workspace view; cannot drive the wss probe."); + } + + // Filter-registration head start (documented 3-4 s), same as the other probes. + Thread.Sleep(6000); + + SmartClientUi.NavigateAddress(addressBar, pageUri.AbsoluteUri); + + await using var cdp = new CdpConnector(); + var browser = await cdp.ConnectFirstAvailableAsync(config.WebView2DataRoot); + var page = await CdpConnector.WaitForPageAsync(browser, pageUri.Host); + + // The page reports WS-PENDING until the echoed token lands (or a failure verdict: + // a refused TLS leg on the Event Server surfaces to the browser as a failed + // websocket handshake, never as a fetchable error document). + await page.WaitForFunctionAsync( + "() => { var el = document.getElementById('ws-status'); return el && el.textContent && el.textContent !== 'WS-PENDING'; }", + null, + new Microsoft.Playwright.PageWaitForFunctionOptions { Timeout = 90000 }); + outcome.WsStatus = await (await page.QuerySelectorAsync("#ws-status"))?.TextContentAsync(); + outcome.Title = await page.TitleAsync(); + log.AppendLine($"wss verdict: status='{outcome.WsStatus}' title='{outcome.Title}'"); + + await page.ScreenshotAsync(new Microsoft.Playwright.PageScreenshotOptions + { + Path = Artifacts.PathOf($"{artifactPrefix}-page.png"), + }); + + // Hold the closed-loop evidence window open briefly so the server journal captures + // the close frames too. + await Task.Delay(1500); + } + catch (Exception ex) + { + var detail = ex.Message; + if (ex is AggregateException agg) + { + detail = string.Join(";", agg.Flatten().InnerExceptions.Select(i => i.GetType().Name + ": " + i.Message)); + } + failure = ex.GetType().Name + ": " + detail; + log.AppendLine("probe failed: " + failure); + log.AppendLine(ex.ToString()); + } + finally + { + outcome.Failure = failure; + outcome.ServerJournal = wssServer == null + ? new string[0] + : (IReadOnlyList)wssServer.Journal; + foreach (var entry in outcome.ServerJournal) + { + log.AppendLine($"wss server: {entry}"); + } + + wssServer?.Dispose(); + pageServer?.Dispose(); + outcome.Log = log.ToString(); + Artifacts.WriteText($"{artifactPrefix}-results.txt", outcome.Log); + _output.WriteLine(outcome.Log); + ProcessKiller.KillClients("Client"); + ProcessKiller.KillPluginWebView2Browsers("SmartClient.WebContentData"); + } + + return outcome; + } + + /// The page loads from the plain http origin and dials the https origin by + /// absolute url, so the fail-closed row can show a failed websocket while the page + /// itself renders fine. + private static string BuildWssProbePage(string wssUrl) => + "wss-echo-probe" + + "

wss tunnel probe

" + + "
WS-PENDING
" + + ""; +} diff --git a/tests/ux/WebContent.UxTests/SmartClientWorkspaceTests.cs b/tests/ux/WebContent.UxTests/SmartClientWorkspaceTests.cs index a6996cd..1f7c735 100644 --- a/tests/ux/WebContent.UxTests/SmartClientWorkspaceTests.cs +++ b/tests/ux/WebContent.UxTests/SmartClientWorkspaceTests.cs @@ -1,5 +1,7 @@ using FlaUI.Core.WindowsAPI; + using WebContent.UxTests.Harness; + using Xunit; using Xunit.Abstractions; diff --git a/tests/ux/WebContent.UxTests/ToolbarOptionsTests.cs b/tests/ux/WebContent.UxTests/ToolbarOptionsTests.cs index 1a4685b..95ff6bb 100644 --- a/tests/ux/WebContent.UxTests/ToolbarOptionsTests.cs +++ b/tests/ux/WebContent.UxTests/ToolbarOptionsTests.cs @@ -1,4 +1,5 @@ using WebContent.UxTests.Harness; + using Xunit; using Xunit.Abstractions; diff --git a/tests/ux/WebContent.UxTests/WebContent.UxTests.csproj b/tests/ux/WebContent.UxTests/WebContent.UxTests.csproj index 1ea4b79..10325a6 100644 --- a/tests/ux/WebContent.UxTests/WebContent.UxTests.csproj +++ b/tests/ux/WebContent.UxTests/WebContent.UxTests.csproj @@ -8,6 +8,11 @@ false + + + + + diff --git a/tests/ux/WebContent.UxTests/WebView2LifetimeProbeTests.cs b/tests/ux/WebContent.UxTests/WebView2LifetimeProbeTests.cs new file mode 100644 index 0000000..04f91d1 --- /dev/null +++ b/tests/ux/WebContent.UxTests/WebView2LifetimeProbeTests.cs @@ -0,0 +1,458 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Management; +using System.Threading; + +using Xunit; +using Xunit.Abstractions; + +namespace WebContent.UxTests; + +/// +/// Spike probe (#90): what happens to the shared WebView2 browser process when the host +/// process that CREATED it exits while another host process is still attached to the same +/// user data folder with identical environment options? +/// +/// This is the question the #87 enablement design (#91) hangs on: if the process collection +/// dies with the creator, the surviving process's views die too but a fresh init can +/// re-claim cleanly; if it survives, joined views keep rendering with a possibly-dead +/// proxy target and need a forced re-creation story. +/// +/// Rows drive the standalone two-role harness in spike\WebView2LifetimeProbe (no MIP/VMS +/// dependency): both roles get the SAME user data folder and the SAME additional browser +/// argument, so they MUST share one browser process; the roles journal their own WebView2 +/// lifecycle (INIT_OK / TICK / PROCESS_FAILED / SECOND_INIT) into per-role status files, +/// and the orchestrator reads the live msedgewebview2 process set (parent pids from the +/// live command lines, the same WMI channel the #67 probe uses) as the independent witness. +/// The rows assert harness health and evidence completeness, not the lifetime answer +/// itself: the answer is recorded from the evidence for the #90 findings comment. +/// +public class WebView2LifetimeProbeTests +{ + /// Identical for both roles on purpose: this is the argument whose per-process + /// mismatch is the #87 bug; shared here so the attachment under test can succeed. + private const string SharedBrowserArgument = "--disable-renderer-backgrounding"; + + private static readonly TimeSpan InitTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan TickTimeout = TimeSpan.FromSeconds(15); + + private readonly ITestOutputHelper _output; + + public WebView2LifetimeProbeTests(ITestOutputHelper output) => _output = output; + + /// + /// Row 1: creator starts (INIT_OK + ticking), joiner starts with identical options and + /// must ATTACH rather than spawn a second browser process: the joiner's init succeeds, + /// both roles tick, and the machine's msedgewebview2 set contains no process whose live + /// command line names the joiner as its parent host. + /// + [Fact] + public void ProbeLifetime_JoinerWithIdenticalOptionsAttachesToCreatorBrowserProcess() + { + var dir = PrepareArtifactDir("join-attaches"); + using var creator = ProbeHost.Start("creator", dir); + WaitForJournalLine(creator, "INIT_OK", InitTimeout); + WaitForJournalLine(creator, "TICK", TickTimeout); + var creatorBrowserPids = SnapshotWebView2Processes(); + Assert.NotEmpty(creatorBrowserPids); + + using var joiner = ProbeHost.Start("joiner", dir); + WaitForJournalLine(joiner, "INIT_OK", InitTimeout); + WaitForJournalLineCount(joiner, "TICK", 2, TickTimeout); + + var afterJoin = SnapshotWebView2Processes(); + AssertNoBrowserProcessParentedTo(joiner.Process.Id, afterJoin); + + DumpEvidence("join-attaches", creator, joiner); + Cleanup(dir, creator, joiner); + } + + /// + /// Row 2: creator HARD-KILLED (host process only, crash simulation) while the joiner is + /// attached. Evidence question: does WebView2 itself take the browser process down with + /// the creating host, and can the survivor re-init. The kill deliberately leaves the + /// msedgewebview2 children alone: only WebView2's own lifetime logic may end them. + /// + [Fact] + public void ProbeLifetime_CreatorHardKill_RecordsSurvivorEvidence() + { + var dir = PrepareArtifactDir("hard-kill"); + using var creator = ProbeHost.Start("creator", dir); + WaitForJournalLine(creator, "INIT_OK", InitTimeout); + + // second-webview attempt fires late enough that the kill below lands first + using var joiner = ProbeHost.Start("joiner", dir, spawnSecondAfterMs: 15_000); + WaitForJournalLine(joiner, "INIT_OK", InitTimeout); + WaitForJournalLineCount(joiner, "TICK", 2, TickTimeout); + + // crash simulation: kill ONLY the host process. tree-kill would murder the browser + // processes directly, which is the harness pulling the trigger, not WebView2 deciding + creator.Process.Kill(entireProcessTree: false); + creator.Process.WaitForExit(30_000); + + // the joiner's own SECOND_INIT line is the completion sentinel for this row + WaitForJournalLineMatching(joiner, l => l.Contains(" SECOND_INIT"), TimeSpan.FromSeconds(45)); + Thread.Sleep(3_000); // settle: let any dying TICK_FAIL / PROCESS_FAILED lines land + + var evidence = ReadJournalLines(joiner); + Assert.Contains(evidence, l => l.Contains(" SECOND_INIT")); + _output.WriteLine($"post-kill joiner evidence:{Environment.NewLine}{string.Join(Environment.NewLine, evidence)}"); + + DumpEvidence("hard-kill", creator, joiner); + Cleanup(dir, creator, joiner); + } + + /// + /// Row 3: creator exits CLEANLY (window close) while the joiner is attached. Same + /// evidence question as row 2 with the graceful half of host exit: does the shared + /// browser process go down with the creator here too, and can the survivor re-init. + /// + [Fact] + public void ProbeLifetime_CreatorCleanExit_RecordsSurvivorEvidence() + { + var dir = PrepareArtifactDir("clean-exit"); + using var creator = ProbeHost.Start("creator", dir); + WaitForJournalLine(creator, "INIT_OK", InitTimeout); + + using var joiner = ProbeHost.Start("joiner", dir, spawnSecondAfterMs: 15_000); + WaitForJournalLine(joiner, "INIT_OK", InitTimeout); + WaitForJournalLineCount(joiner, "TICK", 2, TickTimeout); + + Assert.True(creator.Process.CloseMainWindow(), "creator window close request failed"); + creator.Process.WaitForExit(30_000); + + WaitForJournalLineMatching(joiner, l => l.Contains(" SECOND_INIT"), TimeSpan.FromSeconds(45)); + Thread.Sleep(3_000); + + var evidence = ReadJournalLines(joiner); + Assert.Contains(evidence, l => l.Contains(" SECOND_INIT")); + _output.WriteLine($"post-exit joiner evidence:{Environment.NewLine}{string.Join(Environment.NewLine, evidence)}"); + + DumpEvidence("clean-exit", creator, joiner); + Cleanup(dir, creator, joiner); + } + + /// + /// Row 4 (control): the JOINER exits cleanly; the creator's view must keep ticking + /// with no ProcessFailed - the attached host leaving never disturbs the collection. + /// This one asserts the outcome, because a failed control row means the harness lied. + /// + [Fact] + public void ProbeLifetime_JoinerCleanExit_LeavesCreatorUnharmed() + { + var dir = PrepareArtifactDir("joiner-exits"); + using var creator = ProbeHost.Start("creator", dir); + WaitForJournalLine(creator, "INIT_OK", InitTimeout); + WaitForJournalLineCount(creator, "TICK", 2, TickTimeout); + + using var joiner = ProbeHost.Start("joiner", dir); + WaitForJournalLine(joiner, "INIT_OK", InitTimeout); + WaitForJournalLineCount(joiner, "TICK", 2, TickTimeout); + + var baselineTicks = CountLines(ReadJournalLines(creator), "TICK "); + joiner.Process.CloseMainWindow(); + joiner.Process.WaitForExit(30_000); + + WaitForJournalLineCount(creator, "TICK", baselineTicks + 3, TickTimeout); + var evidence = ReadJournalLines(creator); + Assert.DoesNotContain(evidence, l => l.Contains("PROCESS_FAILED")); + Assert.DoesNotContain(evidence, l => l.Contains("TICK_FAIL")); + + DumpEvidence("joiner-exits", creator, joiner); + Cleanup(dir, creator, joiner); + } + + /// + /// Row 5 (control): the JOINER is hard-killed (host only); the creator's view must keep + /// ticking with no ProcessFailed - a crashed attached host never disturbs the collection. + /// + [Fact] + public void ProbeLifetime_JoinerHardKill_LeavesCreatorUnharmed() + { + var dir = PrepareArtifactDir("joiner-hard-kill"); + using var creator = ProbeHost.Start("creator", dir); + WaitForJournalLine(creator, "INIT_OK", InitTimeout); + WaitForJournalLineCount(creator, "TICK", 2, TickTimeout); + + using var joiner = ProbeHost.Start("joiner", dir); + WaitForJournalLine(joiner, "INIT_OK", InitTimeout); + WaitForJournalLineCount(joiner, "TICK", 2, TickTimeout); + + var baselineTicks = CountLines(ReadJournalLines(creator), "TICK "); + joiner.Process.Kill(entireProcessTree: false); + joiner.Process.WaitForExit(30_000); + + WaitForJournalLineCount(creator, "TICK", baselineTicks + 3, TickTimeout); + var evidence = ReadJournalLines(creator); + Assert.DoesNotContain(evidence, l => l.Contains("PROCESS_FAILED")); + Assert.DoesNotContain(evidence, l => l.Contains("TICK_FAIL")); + + DumpEvidence("joiner-hard-kill", creator, joiner); + Cleanup(dir, creator, joiner); + } + + /// + /// Row 6 (re-claim branch): the browser process collection IS gone while a host survives. + /// The teardown here is deliberately the harness pulling the trigger - tree-kill of the + /// creator host takes its msedgewebview2 children directly - because rows 2/3 proved + /// WebView2 itself does not end the collection on creator exit. With the collection dead, + /// the surviving joiner's attached view must die (ProcessFailed BrowserProcessExited) and + /// a FRESH EnsureCoreWebView2Async in the survivor must respawn the collection. + /// This is the reproducible evidence for #91's re-claim path. + /// + [Fact] + public void ProbeLifetime_BrowserProcessGone_SurvivorRespawnsCollection() + { + var dir = PrepareArtifactDir("collection-gone"); + using var creator = ProbeHost.Start("creator", dir); + WaitForJournalLine(creator, "INIT_OK", InitTimeout); + + using var joiner = ProbeHost.Start("joiner", dir, spawnSecondAfterMs: 15_000); + WaitForJournalLine(joiner, "INIT_OK", InitTimeout); + WaitForJournalLineCount(joiner, "TICK", 2, TickTimeout); + + creator.Process.Kill(entireProcessTree: true); + creator.Process.WaitForExit(30_000); + + // the survivor's attached view dies honestly... + WaitForJournalLineMatching(joiner, l => l.Contains("PROCESS_FAILED"), TimeSpan.FromSeconds(15)); + // ...and its delayed fresh init respawns the collection + WaitForJournalLineMatching(joiner, l => l.Contains(" SECOND_INIT_OK"), TimeSpan.FromSeconds(45)); + + DumpEvidence("collection-gone", creator, joiner); + Cleanup(dir, creator, joiner); + } + + // ---- harness: probe host process + status journal ------------------------------- + + private sealed class ProbeHost : IDisposable + { + public string Role { get; } + public Process Process { get; } + public string JournalPath { get; } + + private ProbeHost(string role, Process process, string journalPath) + { + Role = role; + Process = process; + JournalPath = journalPath; + } + + public static ProbeHost Start(string role, string artifactDir, int? spawnSecondAfterMs = null) + { + var exe = ProbeExePath(); + var args = $"--role {role} --dir \"{artifactDir}\""; + if (spawnSecondAfterMs.HasValue) + { + args += $" --spawn-second-after {spawnSecondAfterMs.Value}"; + } + + var process = Process.Start(new ProcessStartInfo(exe, args) { UseShellExecute = false }); + return new ProbeHost(role, process, Path.Combine(artifactDir, $"status-{role}.log")); + } + + public void Dispose() + { + try + { + if (!Process.HasExited) + { + Process.Kill(entireProcessTree: true); + Process.WaitForExit(10_000); + } + } + catch (Exception) + { + // best effort; probes must never leave the box loaded with stragglers + } + } + } + + private static string ProbeExePath() + { + var dir = AppContext.BaseDirectory; + while (dir != null && !(Directory.Exists(Path.Combine(dir, "src")) && Directory.Exists(Path.Combine(dir, "tests")))) + { + dir = Path.GetDirectoryName(dir); + } + + var exe = Path.Combine(dir ?? throw new InvalidOperationException("repo root not found"), "spike", "WebView2LifetimeProbe", "bin", "Debug", "net10.0-windows", "WebView2LifetimeProbe.exe"); + if (!File.Exists(exe)) + { + throw new FileNotFoundException($"lifetime probe host not built: {exe}"); + } + + return exe; + } + + private static string PrepareArtifactDir(string runName) + { + var dir = Path.Combine( + Path.GetTempPath(), + "webview2-lifetime-probe", + $"{runName}-{DateTime.Now:yyyyMMdd-HHmmss}"); + Directory.CreateDirectory(dir); + return dir; + } + + private static void WaitForJournalLine(ProbeHost host, string linePrefix, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var lines = ReadJournalLines(host); + if (lines.Any(l => l.Contains(" " + linePrefix + " ") || l.EndsWith(linePrefix, StringComparison.Ordinal))) + { + return; + } + + if (host.Process.HasExited) + { + Assert.Fail($"{host.Role} probe exited before {linePrefix}; journal:{Environment.NewLine}{string.Join(Environment.NewLine, lines)}"); + } + + Thread.Sleep(250); + } + + Assert.Fail($"timeout waiting for {linePrefix} on {host.Role}; journal:{Environment.NewLine}{string.Join(Environment.NewLine, ReadJournalLines(host))}"); + } + + private static int CountLines(IEnumerable lines, string contains) => + lines.Count(l => l.Contains(contains, StringComparison.Ordinal)); + + private static void WaitForJournalLineMatching(ProbeHost host, Func match, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var lines = ReadJournalLines(host); + if (lines.Any(match)) + { + return; + } + + if (host.Process.HasExited) + { + Assert.Fail($"{host.Role} probe exited early; journal:{Environment.NewLine}{string.Join(Environment.NewLine, lines)}"); + } + + Thread.Sleep(250); + } + + Assert.Fail($"timeout waiting for a matching line on {host.Role}; journal:{Environment.NewLine}{string.Join(Environment.NewLine, ReadJournalLines(host))}"); + } + + private static void WaitForJournalLineCount(ProbeHost host, string linePrefix, int count, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var lines = ReadJournalLines(host); + if (lines.Count(l => l.Contains(" " + linePrefix + " ") || l.EndsWith(linePrefix, StringComparison.Ordinal)) >= count) + { + return; + } + + if (host.Process.HasExited) + { + Assert.Fail($"{host.Role} probe exited before {count}x {linePrefix}; journal:{Environment.NewLine}{string.Join(Environment.NewLine, lines)}"); + } + + Thread.Sleep(250); + } + + Assert.Fail($"timeout waiting for {count}x {linePrefix} on {host.Role}; journal:{Environment.NewLine}{string.Join(Environment.NewLine, ReadJournalLines(host))}"); + } + + private static List ReadJournalLines(ProbeHost host) + { + try + { + if (!File.Exists(host.JournalPath)) + { + return new List(); + } + + // shared-read so the probe can keep appending while we poll + using var stream = new FileStream(host.JournalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + var text = reader.ReadToEnd(); + return text.Split('\n').Where(l => l.Trim().Length > 0).ToList(); + } + catch (IOException) + { + return new List(); + } + } + + // ---- independent witness: live msedgewebview2 process set ------------------------- + + private sealed record WebView2Process(int ProcessId, int ParentProcessId, string CommandLine); + + private static List SnapshotWebView2Processes() + { + var results = new List(); + using var searcher = new ManagementObjectSearcher( + "SELECT ProcessId, ParentProcessId, CommandLine FROM Win32_Process WHERE Name='msedgewebview2.exe'"); + foreach (ManagementObject row in searcher.Get()) + { + results.Add(new WebView2Process( + Convert.ToInt32(row["ProcessId"]), + Convert.ToInt32(row["ParentProcessId"]), + row["CommandLine"] as string ?? string.Empty)); + } + + return results; + } + + private static void AssertNoBrowserProcessParentedTo(int hostPid, List snapshot) + { + var offenders = snapshot + .Where(p => p.ParentProcessId == hostPid || p.CommandLine.Contains($"--parent-pid={hostPid}", StringComparison.OrdinalIgnoreCase)) + .ToList(); + Assert.True( + offenders.Count == 0, + $"joiner spawned its own browser process collection:{Environment.NewLine}" + + string.Join(Environment.NewLine, offenders.Select(o => $"pid={o.ProcessId} parent={o.ParentProcessId} cmd={o.CommandLine}"))); + } + + // ---- evidence dump --------------------------------------------------------------- + + private void DumpEvidence(string runName, params ProbeHost[] hosts) + { + _output.WriteLine($"=== #90 evidence run '{runName}' ==="); + foreach (var host in hosts) + { + _output.WriteLine($"--- {host.Role} (pid {host.Process.Id}) journal ---"); + foreach (var line in ReadJournalLines(host)) + { + _output.WriteLine(line); + } + } + + foreach (var p in SnapshotWebView2Processes()) + { + _output.WriteLine($"msedgewebview2 pid={p.ProcessId} parent={p.ParentProcessId}"); + } + } + + private static void Cleanup(string dir, params ProbeHost[] hosts) + { + foreach (var host in hosts) + { + host.Dispose(); + } + + try + { + Directory.Delete(dir, recursive: true); + } + catch (IOException) + { + // UDF can outlive its browser processes by a moment; leave the artifacts for inspection + } + } +} diff --git a/updatesetupversion.ps1 b/updatesetupversion.ps1 index aee3790..749b354 100644 --- a/updatesetupversion.ps1 +++ b/updatesetupversion.ps1 @@ -1,4 +1,8 @@ -$projectRoot = if ([string]::IsNullOrEmpty($PSScriptRoot)) { $PWD } else { $PSScriptRoot } +$projectRoot = if ([string]::IsNullOrEmpty($PSScriptRoot)) { + $PWD +} else { + $PSScriptRoot +} $nbgv = dotnet nbgv get-version -f json | ConvertFrom-Json $vdprojPath = (Resolve-Path "$projectRoot\Setup\Setup.vdproj" -ErrorAction Stop).Path $vdproj = Get-Content $vdprojPath -Raw diff --git a/verify.ps1 b/verify.ps1 index ff59657..e335aaf 100644 --- a/verify.ps1 +++ b/verify.ps1 @@ -6,8 +6,9 @@ .DESCRIPTION Runs the checks that must pass before a release: the xunit regression suite (which includes the source-hygiene guards). With -IncludePluginBuild it additionally runs the - VS2022 MSBuild plugin build, since dotnet cannot build the plugin csproj. Exit code 0 - means every gate passed; non-zero means at least one failed. + dotnet plugin build (the csproj is SDK-style net472, so plain `dotnet build` suffices; + no full-MSBuild dependency). Exit code 0 means every gate passed; non-zero means at + least one failed. See docs/verification-matrix.md for the full verification contract. #> param( @@ -23,20 +24,10 @@ dotnet test (Join-Path $repoRoot 'src/WebContent.Tests') --nologo -v minimal $gates.Add([pscustomobject]@{ Name = 'Regression suite'; ExitCode = $LASTEXITCODE }) if ($IncludePluginBuild) { - Write-Host "`n== Gate 2: plugin build (MSBuild x64 Debug) ==" -ForegroundColor Cyan - $msbuild = (Get-Command msbuild -ErrorAction SilentlyContinue)?.Source - if (-not $msbuild) { - $msbuild = 'C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe' - } - if (-not (Test-Path -LiteralPath $msbuild)) { - Write-Host 'MSBuild not found (PATH or VS2022 Community default). Install VS2022 or add msbuild to PATH.' -ForegroundColor Red - $gates.Add([pscustomobject]@{ Name = 'Plugin build'; ExitCode = 1 }) - } - else { - & $msbuild (Join-Path $repoRoot 'src/WebContent/WebContent.csproj') /t:Build /p:Configuration=Debug /p:Platform=x64 /v:minimal /nologo - # PostBuildEvent sbom generation is warning-free and self-overwriting (-D true); exit code is authoritative. - $gates.Add([pscustomobject]@{ Name = 'Plugin build'; ExitCode = $LASTEXITCODE }) - } + Write-Host "`n== Gate 2: plugin build (dotnet Debug) ==" -ForegroundColor Cyan + dotnet build (Join-Path $repoRoot 'src/WebContent/WebContent.csproj') -c Debug -v minimal + # GenerateSbom target is self-overwriting (-D true); exit code is authoritative. + $gates.Add([pscustomobject]@{ Name = 'Plugin build'; ExitCode = $LASTEXITCODE }) } Write-Host "`n== Gate results ==" -ForegroundColor Cyan @@ -44,8 +35,7 @@ $failed = $false foreach ($gate in $gates) { if ($gate.ExitCode -eq 0) { Write-Host ("PASS {0} (exit 0)" -f $gate.Name) -ForegroundColor Green - } - else { + } else { Write-Host ("FAIL {0} (exit {1})" -f $gate.Name, $gate.ExitCode) -ForegroundColor Red $failed = $true }