test(ft546): verify read-only shadow operation
This commit is contained in:
254
tools/shadow/tests/Test-Ft546ShadowScripts.ps1
Normal file
254
tools/shadow/tests/Test-Ft546ShadowScripts.ps1
Normal file
@@ -0,0 +1,254 @@
|
||||
[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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user