fix(launcher): harden Campaign LA11 gate evidence
This commit is contained in:
parent
134edabed2
commit
accd01a008
16 changed files with 1820 additions and 210 deletions
296
tools/test-campaign-la-gate-helpers.ps1
Normal file
296
tools/test-campaign-la-gate-helpers.ps1
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Connection-free contract tests for Campaign LA gate evidence helpers.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||
[Parameter(Mandatory = $true)][string]$OutputDirectory
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
||||
throw 'Campaign LA helper tests require PowerShell 7 or newer.'
|
||||
}
|
||||
$Repository = [IO.Path]::GetFullPath($Repository)
|
||||
if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
||||
throw '-OutputDirectory must be absolute.'
|
||||
}
|
||||
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||
if (Test-Path -LiteralPath $OutputDirectory) {
|
||||
throw '-OutputDirectory must be fresh.'
|
||||
}
|
||||
$null = New-Item -ItemType Directory -Path $OutputDirectory
|
||||
$pwsh = [Environment]::ProcessPath
|
||||
if ([string]::IsNullOrWhiteSpace($pwsh)) {
|
||||
throw 'The PowerShell process path is unavailable.'
|
||||
}
|
||||
$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1'
|
||||
$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1'
|
||||
|
||||
function Write-Profile([string]$Path, [string]$Secret) {
|
||||
$document = [ordered]@{
|
||||
version = 1
|
||||
servers = @([ordered]@{
|
||||
name = 'fixture'
|
||||
host = '127.0.0.1'
|
||||
port = 9000
|
||||
accounts = @([ordered]@{
|
||||
account = 'fixture-account'
|
||||
password = $Secret
|
||||
characters = @()
|
||||
})
|
||||
})
|
||||
}
|
||||
[IO.File]::WriteAllText(
|
||||
$Path,
|
||||
($document | ConvertTo-Json -Depth 8),
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
if ($IsLinux) {
|
||||
[IO.File]::SetUnixFileMode(
|
||||
$Path,
|
||||
[IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite)
|
||||
}
|
||||
}
|
||||
|
||||
function New-GuiEvents {
|
||||
$begin = [DateTimeOffset]::ParseExact(
|
||||
'2026-08-15T10:00:00.0000000+00:00',
|
||||
'O',
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
$session = 'fixture-session'
|
||||
return @(
|
||||
[ordered]@{ v = 1; e = 'started'; t = $begin.ToString('O'); sessionId = $session },
|
||||
[ordered]@{ v = 1; e = 'pluginLoaded'; t = $begin.AddSeconds(1).ToString('O'); sessionId = $session; plugin = 'smoke' },
|
||||
[ordered]@{ v = 1; e = 'pluginFailed'; t = $begin.AddSeconds(2).ToString('O'); sessionId = $session; plugin = 'optional'; error = 'allowed fixture failure' },
|
||||
[ordered]@{ v = 1; e = 'connected'; t = $begin.AddSeconds(3).ToString('O'); sessionId = $session },
|
||||
[ordered]@{
|
||||
v = 1; e = 'characterList'; t = $begin.AddSeconds(4).ToString('O')
|
||||
sessionId = $session; accountName = 'fixture-account'; slotCount = 1
|
||||
characters = @([ordered]@{ id = 1342177290; name = 'Fixture'; secondsGreyedOut = 0 })
|
||||
},
|
||||
[ordered]@{ v = 1; e = 'enteredWorld'; t = $begin.AddSeconds(5).ToString('O'); sessionId = $session; characterId = 1342177290; characterName = 'Fixture' },
|
||||
[ordered]@{ v = 1; e = 'loginCommandFailed'; t = $begin.AddSeconds(6).ToString('O'); sessionId = $session; commandIndex = 0; command = '/fixture'; error = 'allowed fixture failure' },
|
||||
[ordered]@{ v = 1; e = 'disconnected'; t = $begin.AddSeconds(7).ToString('O'); sessionId = $session; reason = 'stopped' },
|
||||
[ordered]@{ v = 1; e = 'exited'; t = $begin.AddSeconds(8).ToString('O'); sessionId = $session; code = 0; reason = 'graceful' }
|
||||
)
|
||||
}
|
||||
|
||||
function Write-Events([string]$Path, [object[]]$Events) {
|
||||
$lines = @($Events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress })
|
||||
[IO.File]::WriteAllLines($Path, $lines, [Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
function Invoke-Validator(
|
||||
[string]$Status,
|
||||
[string]$Profile,
|
||||
[string]$Report,
|
||||
[int]$ExpectedProcessId,
|
||||
[bool]$ShouldPass,
|
||||
[string]$SessionConfig = '') {
|
||||
$arguments = [Collections.Generic.List[string]]::new()
|
||||
foreach ($value in @(
|
||||
'-NoProfile', '-File', $validator,
|
||||
'-StatusFile', $Status,
|
||||
'-Mode', 'gui',
|
||||
'-ExpectedProcessId', $ExpectedProcessId.ToString(
|
||||
[Globalization.CultureInfo]::InvariantCulture),
|
||||
'-CredentialProfilePath', $Profile,
|
||||
'-ExpectedPlugin', 'smoke',
|
||||
'-AllowPluginFailure',
|
||||
'-AllowLoginCommandFailure',
|
||||
'-ReportPath', $Report)) {
|
||||
$arguments.Add($value)
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($SessionConfig)) {
|
||||
$arguments.Add('-SessionConfigPath')
|
||||
$arguments.Add($SessionConfig)
|
||||
}
|
||||
$start = [Diagnostics.ProcessStartInfo]::new($pwsh)
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
$start.RedirectStandardOutput = $true
|
||||
$start.RedirectStandardError = $true
|
||||
foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) }
|
||||
$process = [Diagnostics.Process]::Start($start)
|
||||
if ($null -eq $process) { throw 'Could not start status validator.' }
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
$process.WaitForExit()
|
||||
$outText = $stdout.GetAwaiter().GetResult()
|
||||
$errorText = $stderr.GetAwaiter().GetResult()
|
||||
$exitCode = $process.ExitCode
|
||||
$process.Dispose()
|
||||
if (($exitCode -eq 0) -ne $ShouldPass) {
|
||||
throw "Validator result mismatch (exit $exitCode). $outText $errorText"
|
||||
}
|
||||
}
|
||||
|
||||
$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh)
|
||||
$quickInfo.UseShellExecute = $false
|
||||
$quickInfo.ArgumentList.Add('-NoProfile')
|
||||
$quickInfo.ArgumentList.Add('-Command')
|
||||
$quickInfo.ArgumentList.Add('exit 0')
|
||||
$quick = [Diagnostics.Process]::Start($quickInfo)
|
||||
if ($null -eq $quick) { throw 'Could not create an exited PID fixture.' }
|
||||
$goneProcessId = $quick.Id
|
||||
$quick.WaitForExit()
|
||||
$quick.Dispose()
|
||||
|
||||
$profile = Join-Path $OutputDirectory 'launcher-profiles.json'
|
||||
Write-Profile $profile 'la11-positive-secret-7E477A2D'
|
||||
$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl'
|
||||
Write-Events $positiveStatus (New-GuiEvents)
|
||||
Invoke-Validator `
|
||||
$positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') `
|
||||
$goneProcessId $true
|
||||
|
||||
foreach ($reason in @('transport', 'reconnect', 'other')) {
|
||||
$events = @(New-GuiEvents)
|
||||
$events[7].reason = $reason
|
||||
$path = Join-Path $OutputDirectory "reason-$reason.jsonl"
|
||||
$report = Join-Path $OutputDirectory "reason-$reason.validation.json"
|
||||
Write-Events $path $events
|
||||
Invoke-Validator $path $profile $report $goneProcessId $false
|
||||
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
|
||||
if (-not ($result.failures -match 'disconnected reason')) {
|
||||
throw "Disconnected reason '$reason' was not rejected by its exact assertion."
|
||||
}
|
||||
}
|
||||
|
||||
$secretCases = @(
|
||||
'eventName', 'timestamp', 'sessionId', 'accountName', 'characterName',
|
||||
'enteredCharacterName', 'loadedPlugin', 'failedPlugin', 'pluginError',
|
||||
'command', 'commandError', 'disconnectedReason', 'exitReason')
|
||||
foreach ($case in $secretCases) {
|
||||
$secret = "la11-secret-$case-5A7D"
|
||||
$caseProfile = Join-Path $OutputDirectory "secret-$case.profile.json"
|
||||
Write-Profile $caseProfile $secret
|
||||
$events = @(New-GuiEvents)
|
||||
switch ($case) {
|
||||
'eventName' { $events[0].e = $secret }
|
||||
'timestamp' { $events[0].t = $secret }
|
||||
'sessionId' { foreach ($event in $events) { $event.sessionId = $secret } }
|
||||
'accountName' { $events[4].accountName = $secret }
|
||||
'characterName' { $events[4].characters[0].name = $secret }
|
||||
'enteredCharacterName' { $events[5].characterName = $secret }
|
||||
'loadedPlugin' { $events[1].plugin = $secret }
|
||||
'failedPlugin' { $events[2].plugin = $secret }
|
||||
'pluginError' { $events[2].error = $secret }
|
||||
'command' { $events[6].command = $secret }
|
||||
'commandError' { $events[6].error = $secret }
|
||||
'disconnectedReason' { $events[7].reason = $secret }
|
||||
'exitReason' { $events[8].reason = $secret }
|
||||
}
|
||||
$path = Join-Path $OutputDirectory "secret-$case.jsonl"
|
||||
$report = Join-Path $OutputDirectory "secret-$case.validation.json"
|
||||
Write-Events $path $events
|
||||
Invoke-Validator $path $caseProfile $report $goneProcessId $false
|
||||
$result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json
|
||||
if (-not ($result.failures -match 'credential value')) {
|
||||
throw "Credential echo case '$case' was not rejected by recursive scanning."
|
||||
}
|
||||
}
|
||||
|
||||
$fixtureSource = Join-Path `
|
||||
$Repository 'tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/bin/Release/net10.0'
|
||||
$fixtureRoot = Join-Path $OutputDirectory 'process-fixture'
|
||||
Copy-Item -LiteralPath $fixtureSource -Destination $fixtureRoot -Recurse
|
||||
$sourceBase = 'AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder'
|
||||
$suffix = if ($IsWindows) { '.exe' } else { '' }
|
||||
$sourceHost = Join-Path $fixtureRoot "$sourceBase$suffix"
|
||||
$sameNameHost = Join-Path $fixtureRoot "acdream-headless$suffix"
|
||||
Copy-Item -LiteralPath $sourceHost -Destination $sameNameHost
|
||||
foreach ($extension in @('.runtimeconfig.json', '.deps.json')) {
|
||||
Copy-Item -LiteralPath (Join-Path $fixtureRoot "$sourceBase$extension") `
|
||||
-Destination (Join-Path $fixtureRoot "acdream-headless$extension")
|
||||
}
|
||||
if ($IsLinux) {
|
||||
[IO.File]::SetUnixFileMode(
|
||||
$sameNameHost,
|
||||
[IO.File]::GetUnixFileMode($sourceHost))
|
||||
}
|
||||
|
||||
$sessionConfig = Join-Path $OutputDirectory 'session.json'
|
||||
[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false))
|
||||
$targetReady = Join-Path $OutputDirectory 'target.ready'
|
||||
$targetRelease = Join-Path $OutputDirectory 'target.release'
|
||||
$unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready'
|
||||
$unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release'
|
||||
|
||||
function Start-Fixture([string[]]$Arguments) {
|
||||
$start = [Diagnostics.ProcessStartInfo]::new($sameNameHost)
|
||||
$start.UseShellExecute = $false
|
||||
$start.CreateNoWindow = $true
|
||||
foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
|
||||
return [Diagnostics.Process]::Start($start)
|
||||
}
|
||||
|
||||
$target = Start-Fixture @(
|
||||
'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease)
|
||||
$unrelated = Start-Fixture @(
|
||||
'hold-update-lease', 'session', (Join-Path $OutputDirectory 'unrelated-data'),
|
||||
$unrelatedReady, $unrelatedRelease)
|
||||
if ($null -eq $target -or $null -eq $unrelated) {
|
||||
throw 'Could not start process-correlation fixtures.'
|
||||
}
|
||||
try {
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(10)
|
||||
while ((-not (Test-Path -LiteralPath $targetReady) -or
|
||||
-not (Test-Path -LiteralPath $unrelatedReady)) -and
|
||||
[DateTime]::UtcNow -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $targetReady) -or
|
||||
-not (Test-Path -LiteralPath $unrelatedReady)) {
|
||||
throw 'Process-correlation fixtures did not become ready.'
|
||||
}
|
||||
|
||||
$captureReport = Join-Path $OutputDirectory 'process-capture.json'
|
||||
& $pwsh -NoProfile -File $capture `
|
||||
-SessionConfigPath $sessionConfig -ReportPath $captureReport
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' }
|
||||
$captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json
|
||||
if ([int]$captured.processId -ne $target.Id) {
|
||||
throw 'Process capture did not return the exact correlated PID.'
|
||||
}
|
||||
|
||||
$liveReport = Join-Path $OutputDirectory 'live-pid.validation.json'
|
||||
Invoke-Validator `
|
||||
$positiveStatus $profile $liveReport $target.Id $false $sessionConfig
|
||||
$liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json
|
||||
if (-not ($liveResult.failures -match 'remains alive')) {
|
||||
throw 'A live exact child PID was not rejected by the terminal validator.'
|
||||
}
|
||||
|
||||
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
|
||||
$target.WaitForExit()
|
||||
Invoke-Validator `
|
||||
$positiveStatus $profile `
|
||||
(Join-Path $OutputDirectory 'unrelated-same-name.validation.json') `
|
||||
$target.Id $true $sessionConfig
|
||||
}
|
||||
finally {
|
||||
Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline
|
||||
Set-Content -LiteralPath $unrelatedRelease -Value 'release' -NoNewline
|
||||
if (-not $target.HasExited) { $target.WaitForExit() }
|
||||
if (-not $unrelated.HasExited) { $unrelated.WaitForExit() }
|
||||
$target.Dispose()
|
||||
$unrelated.Dispose()
|
||||
}
|
||||
|
||||
$summary = [ordered]@{
|
||||
schemaVersion = 1
|
||||
kind = 'campaign-la-gate-helper-tests'
|
||||
success = $true
|
||||
disconnectedReasonNegatives = 3
|
||||
credentialStringFieldNegatives = $secretCases.Count
|
||||
exactPidCapture = $true
|
||||
livePidRejected = $true
|
||||
unrelatedSameNameIgnored = $true
|
||||
platform = if ($IsWindows) { 'windows' } else { 'linux' }
|
||||
}
|
||||
$summary | ConvertTo-Json -Depth 4 |
|
||||
Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM
|
||||
Write-Host "Campaign LA gate helper tests: $OutputDirectory"
|
||||
Loading…
Add table
Add a link
Reference in a new issue