255 lines
11 KiB
PowerShell
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
|
|
}
|
|
}
|