Files
FT546-WCS/tools/shadow/tests/Test-Ft546ShadowScripts.ps1

255 lines
11 KiB
PowerShell

[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
}
}