[CmdletBinding()] param() $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw "ASSERTION_FAILED: $Message" } } function Assert-Throws { param([scriptblock]$Action, [string]$Reason) try { & $Action } catch { if ($_.Exception.Message -like "*$Reason*") { return } throw "EXPECTED_$Reason, ACTUAL=$($_.Exception.Message)" } throw "EXPECTED_$Reason, BUT_COMMAND_SUCCEEDED" } $toolsDirectory = Split-Path -Parent $PSScriptRoot $capture = Join-Path $toolsDirectory 'Capture-StableWcsManifest.ps1' $preflight = Join-Path $toolsDirectory 'Test-Ft546ShadowPreflight.ps1' $start = Join-Path $toolsDirectory 'Start-Ft546Shadow.ps1' $stop = Join-Path $toolsDirectory 'Stop-Ft546Shadow.ps1' $export = Join-Path $toolsDirectory 'Export-Ft546ShadowEvidence.ps1' foreach ($script in @($capture, $preflight, $start, $stop, $export)) { Assert-True (Test-Path -LiteralPath $script -PathType Leaf) ` "missing script $script" } $testRoot = Join-Path ([IO.Path]::GetTempPath()) ` ("ft546-shadow-script-test-" + [guid]::NewGuid().ToString('N')) $deployment = Join-Path $testRoot 'shadow-deployment' $stableDirectory = Join-Path $testRoot 'stable-deployment' $snapshot = Join-Path $testRoot 'evidence-snapshot' $exportDirectory = Join-Path $testRoot 'evidence-export' $startedPid = $null try { New-Item -ItemType Directory -Path $deployment, $stableDirectory, $snapshot ` -Force | Out-Null $sourceFile = Join-Path $testRoot 'ShadowSleeper.java' @' public final class ShadowSleeper { public static void main(String[] args) throws Exception { Thread.sleep(300000L); } } '@ | Set-Content -LiteralPath $sourceFile -Encoding ASCII & javac -d $testRoot $sourceFile if ($LASTEXITCODE -ne 0) { throw 'JAVAC_FAILED' } $shadowArtifact = Join-Path $deployment 'ruoyi-wcs-shadow.jar' & jar cfe $shadowArtifact ShadowSleeper -C $testRoot 'ShadowSleeper.class' if ($LASTEXITCODE -ne 0) { throw 'JAR_FAILED' } $stableArtifact = Join-Path $stableDirectory 'ruoyi-admin.jar' Copy-Item -LiteralPath $shadowArtifact -Destination $stableArtifact $stableConfig = Join-Path $stableDirectory 'application.yml' 'spring.profiles.active: stable' | Set-Content -LiteralPath $stableConfig ` -Encoding ASCII $shadowConfig = Join-Path $deployment 'application-shadow-site.yml' @' server: address: 127.0.0.1 port: 18091 shadow: enabled: true source: jdbc-url: jdbc:sqlserver://source.example.invalid;databaseName=FT546-wcs username: shadow_reader evidence: jdbc-url: jdbc:sqlserver://evidence.example.invalid;databaseName=FT546-shadow username: shadow_writer '@ | Set-Content -LiteralPath $shadowConfig -Encoding ASCII $stableManifest = Join-Path $testRoot 'stable-manifest.json' & $capture -StableArtifact $stableArtifact -StablePid $PID ` -Configuration $stableConfig -OutputJson $stableManifest | Out-Null $stable = Get-Content -LiteralPath $stableManifest -Raw | ConvertFrom-Json Assert-True ($stable.status -eq 'PASS') 'stable manifest must pass' Assert-True ($stable.pid -eq $PID) 'stable PID must be captured exactly' $permissionReport = Join-Path $testRoot 'permission-report.json' [ordered]@{ generatedAtUtc = [DateTime]::UtcNow.ToString('o') status = 'PASS' source = [ordered]@{ server = 'source.example.invalid'; database = 'FT546-wcs' login = 'shadow_reader'; approvedViewsReadable = $true productionWritePermissionCount = 0 } evidence = [ordered]@{ server = 'evidence.example.invalid'; database = 'FT546-shadow' login = 'shadow_writer'; requiredWritesGranted = $true } } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $permissionReport ` -Encoding UTF8 $networkReport = Join-Path $testRoot 'network-deny-report.json' [ordered]@{ generatedAtUtc = [DateTime]::UtcNow.ToString('o') status = 'PASS'; plcEgressDenied = $true; wmsEgressDenied = $true } | ConvertTo-Json | Set-Content -LiteralPath $networkReport -Encoding UTF8 $wrongPermissionReport = Join-Path $testRoot 'wrong-permission-report.json' $wrongPermissions = Get-Content -LiteralPath $permissionReport -Raw | ` ConvertFrom-Json $wrongPermissions.source.database = 'WRONG_DATABASE' $wrongPermissions | ConvertTo-Json -Depth 5 | Set-Content ` -LiteralPath $wrongPermissionReport -Encoding UTF8 Assert-Throws { & $preflight -StableManifest $stableManifest ` -ShadowArtifact $shadowArtifact -Config $shadowConfig ` -DeploymentDirectory $deployment ` -PermissionReport $wrongPermissionReport ` -NetworkDenyReport $networkReport ` -StatusUri 'http://127.0.0.1:18091/shadow/status' ` -MinimumFreeBytes 0 ` -OutputJson (Join-Path $testRoot 'wrong-preflight.json') | Out-Null } 'CONFIG_SOURCE_IDENTITY_MATCHES_PERMISSION_REPORT' $stalePidFile = Join-Path $deployment 'shadow.pid.json' [ordered]@{ pid = 2147483000 } | ConvertTo-Json | Set-Content ` -LiteralPath $stalePidFile -Encoding UTF8 Assert-Throws { & $preflight -StableManifest $stableManifest ` -ShadowArtifact $shadowArtifact -Config $shadowConfig ` -DeploymentDirectory $deployment ` -PermissionReport $permissionReport ` -NetworkDenyReport $networkReport ` -StatusUri 'http://127.0.0.1:18091/shadow/status' ` -MinimumFreeBytes 0 ` -OutputJson (Join-Path $testRoot 'stale-pid-preflight.json') | Out-Null } 'NO_EXISTING_SHADOW_PID_FILE' Remove-Item -LiteralPath $stalePidFile $preflightJson = Join-Path $testRoot 'shadow-preflight.json' & $preflight -StableManifest $stableManifest ` -ShadowArtifact $shadowArtifact -Config $shadowConfig ` -DeploymentDirectory $deployment -PermissionReport $permissionReport ` -NetworkDenyReport $networkReport -StatusUri 'http://127.0.0.1:18091/shadow/status' ` -MinimumFreeBytes 0 -OutputJson $preflightJson | Out-Null $preflightResult = Get-Content -LiteralPath $preflightJson -Raw | ConvertFrom-Json Assert-True ([bool]$preflightResult.success) 'preflight must pass' Assert-True ($preflightResult.stable.pid -eq $PID) ` 'preflight must preserve stable PID' Assert-Throws { & $start -PreflightJson $preflightJson | Out-Null } 'CONFIRM_READ_ONLY_SHADOW_REQUIRED' $configBytes = [IO.File]::ReadAllBytes($shadowConfig) Add-Content -LiteralPath $shadowConfig -Value '# changed after preflight' ` -Encoding ASCII Assert-Throws { & $start -ConfirmReadOnlyShadow -PreflightJson $preflightJson | Out-Null } 'SHADOW_CONFIG_CHANGED_AFTER_PREFLIGHT' [IO.File]::WriteAllBytes($shadowConfig, $configBytes) $startResult = & $start -ConfirmReadOnlyShadow ` -PreflightJson $preflightJson $startedPid = [int]$startResult.pid Assert-True ($startedPid -ne $PID) 'shadow PID must differ from stable PID' Assert-True ($null -ne (Get-Process -Id $startedPid -ErrorAction Stop)) ` 'shadow process must be alive after start' Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) ` 'stable process must remain alive after shadow start' $shadowCommandLine = (Get-CimInstance Win32_Process ` -Filter "ProcessId=$startedPid").CommandLine Assert-True ($shadowCommandLine -like ` '*--shadow.evidence.run.artifact-sha256=*') ` 'start must pass the verified artifact hash' Assert-True ($shadowCommandLine -like ` '*--shadow.evidence.run.configuration-sha256=*') ` 'start must pass the verified configuration hash' Assert-True ($shadowCommandLine -like ` '*--shadow.evidence.run.source-database=FT546-wcs*') ` 'start must pass the verified source database' Assert-True ($shadowCommandLine -like '*--server.address=127.0.0.1*') ` 'start must force the preflight loopback address' Assert-True ($shadowCommandLine -like '*--server.port=18091*') ` 'start must force the preflight status port' $pidFile = Join-Path $deployment 'shadow.pid.json' & $stop -PidFile $pidFile | Out-Null Assert-True ($null -eq (Get-Process -Id $startedPid -ErrorAction SilentlyContinue)) ` 'recorded shadow PID must stop' $startedPid = $null Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) ` 'stable process must remain alive after shadow stop' $stableProcess = Get-Process -Id $PID [ordered]@{ pid = $PID processStartTimeUtc = $stableProcess.StartTime.ToUniversalTime().ToString('o') javaExecutable = $stableProcess.Path shadowArtifact = $shadowArtifact shadowArtifactSha256 = (Get-FileHash -LiteralPath $shadowArtifact -Algorithm SHA256).Hash deploymentDirectory = $deployment } | ConvertTo-Json | Set-Content -LiteralPath $pidFile -Encoding UTF8 Assert-Throws { & $stop -PidFile $pidFile | Out-Null } ` 'RECORDED_PROCESS_COMMAND_MISMATCH' Assert-True ($null -ne (Get-Process -Id $PID -ErrorAction Stop)) ` 'mismatched PID must never be stopped' $requiredEvidence = @( 'cycles.csv', 'cycles.json', 'protocol-differences.csv', 'algorithm-differences.csv', 'insufficient-facts.csv', 'startup-checks.json' ) foreach ($name in $requiredEvidence) { "fixture:$name" | Set-Content -LiteralPath (Join-Path $snapshot $name) ` -Encoding UTF8 } & $export -SnapshotDirectory $snapshot -OutputDirectory $exportDirectory ` -RunId ([guid]::NewGuid()) | Out-Null Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.json')) ` 'evidence manifest must exist' Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory 'manifest.sha256')) ` 'evidence manifest hash must exist' $expectedManifestHash = ((Get-Content -LiteralPath ` (Join-Path $exportDirectory 'manifest.sha256')) -split '\s+')[0] $actualManifestHash = (Get-FileHash -LiteralPath ` (Join-Path $exportDirectory 'manifest.json') -Algorithm SHA256).Hash Assert-True ($expectedManifestHash -eq $actualManifestHash.ToLowerInvariant()) ` 'evidence manifest hash must verify' foreach ($name in $requiredEvidence) { Assert-True (Test-Path -LiteralPath (Join-Path $exportDirectory $name)) ` "evidence export missing $name" } [pscustomobject]@{ status = 'PASS' stablePid = $PID preflight = $preflightJson exportManifest = (Join-Path $exportDirectory 'manifest.json') } } finally { if ($null -ne $startedPid) { Stop-Process -Id $startedPid -Force -ErrorAction SilentlyContinue } $resolvedTemp = [IO.Path]::GetFullPath($testRoot) $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) if ($resolvedTemp.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) ` -and (Test-Path -LiteralPath $resolvedTemp)) { Remove-Item -LiteralPath $resolvedTemp -Recurse -Force } }