acdream/tools/test-campaign-la-session-status.ps1

346 lines
14 KiB
PowerShell

<#
.SYNOPSIS
Strict Campaign LA v1 session-status and terminal-process validator.
.DESCRIPTION
Validates exact JSONL property sets and property order, lifecycle order for
probe/guiSelect/gui/headless, terminal semantics, plugin expectations,
credential redaction, and absence of launcher child-process leaks. The
report contains hashes and event names only; it does not copy account,
character, command, plugin-error, or other payload text.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StatusFile,
[Parameter(Mandatory = $true)]
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
[string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld,
[switch]$AllowPluginFailure,
[switch]$AllowLoginCommandFailure,
[switch]$AllowLauncherChildren,
[string[]]$ForbiddenEnvironmentVariable = @(
'ACDREAM_TEST_PASS',
'ACDREAM_LA_GATE_SECRET'),
[string]$ReportPath,
[int]$ProcessExitWaitSeconds = 5
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw 'Campaign LA status validation requires PowerShell 7 or newer.'
}
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
}
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
}
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
throw "Status file does not exist: $StatusFile"
}
if ([string]::IsNullOrWhiteSpace($ReportPath)) {
$ReportPath = "$StatusFile.validation.json"
}
elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) {
$ReportPath = [IO.Path]::GetFullPath($ReportPath)
}
$exactFields = @{
started = @('v', 'e', 't', 'sessionId')
connected = @('v', 'e', 't', 'sessionId')
characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters')
enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName')
pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin')
pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error')
loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error')
disconnected = @('v', 'e', 't', 'sessionId', 'reason')
exited = @('v', 'e', 't', 'sessionId', 'code', 'reason')
}
$failures = [Collections.Generic.List[string]]::new()
$eventNames = [Collections.Generic.List[string]]::new()
$loadedPlugins = [Collections.Generic.List[string]]::new()
$sessionId = $null
$previousTimestamp = [DateTimeOffset]::MinValue
$terminalSeen = $false
function Get-Properties([Text.Json.JsonElement]$Element) {
$properties = [Collections.Generic.List[object]]::new()
foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) }
return @($properties)
}
function Assert-String(
[Text.Json.JsonElement]$Root,
[string]$Name,
[bool]$AllowEmpty = $false) {
$value = $Root.GetProperty($Name)
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) {
throw "field '$Name' is not a string"
}
$text = $value.GetString()
if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) {
throw "field '$Name' is empty"
}
return $text
}
function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) {
$value = $Root.GetProperty($Name)
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
throw "field '$Name' is not a number"
}
return $value.GetInt32()
}
function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) {
$value = $Root.GetProperty($Name)
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
throw "field '$Name' is not a number"
}
return $value.GetUInt32()
}
$lines = @(Get-Content -LiteralPath $StatusFile)
if ($lines.Count -eq 0) { $failures.Add('status stream is empty') }
for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
$lineNumber = $lineIndex + 1
$line = $lines[$lineIndex]
if ([string]::IsNullOrWhiteSpace($line)) {
$failures.Add("line $lineNumber is empty")
continue
}
foreach ($variable in $ForbiddenEnvironmentVariable) {
$secret = [Environment]::GetEnvironmentVariable($variable)
if (-not [string]::IsNullOrEmpty($secret) -and
$line.Contains($secret, [StringComparison]::Ordinal)) {
$failures.Add("line $lineNumber contains the value of forbidden environment variable $variable")
}
}
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
$failures.Add("line $lineNumber contains a credential-like JSON field")
}
$document = $null
try {
$document = [Text.Json.JsonDocument]::Parse($line)
$root = $document.RootElement
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'root is not an object'
}
$properties = @(Get-Properties $root)
$names = @($properties | ForEach-Object { $_.Name })
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
throw 'object contains duplicate fields'
}
$eventName = Assert-String $root 'e'
if (-not $exactFields.ContainsKey($eventName)) {
throw 'event name is not in the v1 vocabulary'
}
$expected = $exactFields[$eventName]
if ($names.Count -ne $expected.Count -or
[string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) {
throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'"
}
if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' }
$timestampText = Assert-String $root 't'
$timestamp = [DateTimeOffset]::MinValue
if (-not [DateTimeOffset]::TryParseExact(
$timestampText,
'O',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::RoundtripKind,
[ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) {
throw 'field t is not an exact UTC round-trip timestamp'
}
if ($timestamp -lt $previousTimestamp) {
throw 'timestamp order moved backwards'
}
$previousTimestamp = $timestamp
$lineSessionId = Assert-String $root 'sessionId'
if ($null -eq $sessionId) { $sessionId = $lineSessionId }
if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' }
if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
$lineSessionId -cne $ExpectedSessionId) {
throw 'sessionId does not match -ExpectedSessionId'
}
if ($terminalSeen) { throw 'an event appears after terminal exited' }
switch ($eventName) {
'characterList' {
$null = Assert-String $root 'accountName' $true
$slotCount = Assert-Int32 $root 'slotCount'
if ($slotCount -lt 0) { throw 'slotCount is negative' }
$characters = $root.GetProperty('characters')
if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) {
throw 'characters is not an array'
}
foreach ($character in $characters.EnumerateArray()) {
if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'a character is not an object'
}
$characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name })
$characterExpected = @('id', 'name', 'secondsGreyedOut')
if ([string]::Join("`n", $characterNames) -cne
[string]::Join("`n", $characterExpected)) {
throw 'a character fields/order is not id,name,secondsGreyedOut'
}
$null = Assert-UInt32 $character 'id'
$null = Assert-String $character 'name'
$null = Assert-UInt32 $character 'secondsGreyedOut'
}
}
'enteredWorld' {
$null = Assert-UInt32 $root 'characterId'
$null = Assert-String $root 'characterName'
}
'pluginLoaded' {
$loadedPlugins.Add((Assert-String $root 'plugin'))
}
'pluginFailed' {
$null = Assert-String $root 'plugin'
$null = Assert-String $root 'error'
if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' }
}
'loginCommandFailed' {
if ((Assert-Int32 $root 'commandIndex') -lt 0) {
throw 'commandIndex is negative'
}
$null = Assert-String $root 'command' $true
$null = Assert-String $root 'error'
if (-not $AllowLoginCommandFailure) {
throw 'loginCommandFailed is not allowed for this row'
}
}
'disconnected' { $null = Assert-String $root 'reason' }
'exited' {
$code = Assert-Int32 $root 'code'
$reason = Assert-String $root 'reason'
if ($code -ne 0) { throw "terminal exit code is $code, expected 0" }
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
if ($reason -cne $expectedReason) {
throw "terminal reason does not match mode '$Mode'"
}
$terminalSeen = $true
}
}
$eventNames.Add($eventName)
}
catch {
$failures.Add("line ${lineNumber}: $($_.Exception.Message)")
}
finally { if ($null -ne $document) { $document.Dispose() } }
}
function Require-Count([string]$EventName, [int]$Count) {
$actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count
if ($actual -ne $Count) {
$failures.Add("event '$EventName' count is $actual, expected $Count")
}
}
function First-Index([string]$EventName) {
for ($index = 0; $index -lt $eventNames.Count; $index++) {
if ($eventNames[$index] -ceq $EventName) { return $index }
}
return -1
}
Require-Count 'started' 1
Require-Count 'connected' 1
Require-Count 'characterList' 1
Require-Count 'disconnected' 1
Require-Count 'exited' 1
$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld
Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 })
if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') {
$failures.Add('started is not the first event')
}
if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') {
$failures.Add('exited is not the final event')
}
$orderedRequired = if (-not $expectEnteredWorld) {
@('started', 'connected', 'characterList', 'disconnected', 'exited')
} else {
@('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited')
}
$last = -1
foreach ($name in $orderedRequired) {
$next = First-Index $name
if ($next -ge 0 -and $next -le $last) {
$failures.Add("event '$name' is out of lifecycle order")
}
$last = $next
}
$connectedIndex = First-Index 'connected'
foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) {
if ($eventNames.Count -eq 0) { break }
if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and
($index -le 0 -or $index -ge $connectedIndex)) {
$failures.Add("plugin event at index $index is outside started-to-connected startup")
}
}
foreach ($plugin in $ExpectedPlugin) {
if (-not ($loadedPlugins -ccontains $plugin)) {
$failures.Add("expected plugin '$plugin' did not emit pluginLoaded")
}
}
$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique)
$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique)
if ($loadedPlugins.Count -ne $loadedPluginSet.Count) {
$failures.Add('a plugin emitted pluginLoaded more than once')
}
if ([string]::Join("`n", $loadedPluginSet) -cne
[string]::Join("`n", $expectedPluginSet)) {
$failures.Add(
"loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)")
}
$enteredWorldIndex = First-Index 'enteredWorld'
for ($index = 0; $index -lt $eventNames.Count; $index++) {
if ($eventNames[$index] -ceq 'loginCommandFailed' -and
($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) {
$failures.Add("loginCommandFailed at index $index did not follow enteredWorld")
}
}
if (-not $AllowLauncherChildren) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
do {
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue)
if ($children.Count -eq 0) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($children.Count -gt 0) {
$failures.Add(
"launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')")
}
}
$reportDirectory = Split-Path -Parent $ReportPath
if (-not [string]::IsNullOrEmpty($reportDirectory)) {
$null = New-Item -ItemType Directory -Force -Path $reportDirectory
}
$report = [ordered]@{
schemaVersion = 1
kind = 'campaign-la-session-status-validation'
success = ($failures.Count -eq 0)
mode = $Mode
enteredWorldExpected = $expectEnteredWorld
statusFile = [IO.Path]::GetFileName($StatusFile)
statusSize = (Get-Item -LiteralPath $StatusFile).Length
statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant()
lineCount = $lines.Count
eventNames = @($eventNames)
loadedPluginCount = $loadedPlugins.Count
terminalObserved = $terminalSeen
launcherChildrenAllowed = [bool]$AllowLauncherChildren
failures = @($failures)
validatedUtc = [DateTime]::UtcNow.ToString('O')
}
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
Write-Host "Campaign LA status validation report: $ReportPath"
if (-not $report.success) {
$failures | ForEach-Object { Write-Error $_ }
exit 1
}