<# .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' . (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.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, [string]$ProcessCapture, [bool]$ShouldPass) { $arguments = [Collections.Generic.List[string]]::new() foreach ($value in @( '-NoProfile', '-File', $validator, '-StatusFile', $Status, '-Mode', 'gui', '-ProcessCapturePath', $ProcessCapture, '-CredentialProfilePath', $Profile, '-ExpectedPlugin', 'smoke', '-AllowPluginFailure', '-AllowLoginCommandFailure', '-ReportPath', $Report)) { $arguments.Add($value) } $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" } } function Write-ProcessCapture( [string]$Path, [int]$ProcessId, [string]$ProcessInstanceIdentity, [string]$SessionConfigPath, [string]$CommandFingerprint = ('a' * 64)) { $sessionId = [IO.Path]::GetFileName( [IO.Path]::GetDirectoryName($SessionConfigPath)) $document = [ordered]@{ schemaVersion = 2 kind = 'campaign-la-session-process-capture' processId = $ProcessId processInstanceIdentity = $ProcessInstanceIdentity sessionId = $sessionId sessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) commandLineFingerprintSha256 = $CommandFingerprint capturedUtc = [DateTime]::UtcNow.ToString('O') } [IO.File]::WriteAllText( $Path, ($document | ConvertTo-Json -Depth 4), [Text.UTF8Encoding]::new($false)) } $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() $sessionRoot = Join-Path $OutputDirectory 'fixture-session' $null = New-Item -ItemType Directory -Path $sessionRoot $sessionConfig = Join-Path $sessionRoot 'session.json' [IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) $syntheticIdentity = if ($IsWindows) { 'windows-creation-v1:638000000000000000' } else { 'linux-proc-start-v1:00000000-0000-0000-0000-000000000001:1' } $goneCapture = Join-Path $OutputDirectory 'gone-process.capture.json' Write-ProcessCapture ` $goneCapture $goneProcessId $syntheticIdentity $sessionConfig $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') ` $goneCapture $true $malformedCapture = Join-Path $OutputDirectory 'malformed-process.capture.json' [IO.File]::WriteAllText( $malformedCapture, '{"schemaVersion":2}', [Text.UTF8Encoding]::new($false)) Invoke-Validator ` $positiveStatus $profile (Join-Path $OutputDirectory 'malformed.validation.json') ` $malformedCapture $false 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 $goneCapture $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 $goneCapture $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)) } $targetReady = Join-Path $OutputDirectory 'target.ready' $targetRelease = Join-Path $OutputDirectory 'target.release' $unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready' $unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release' $unrelatedSessionRoot = Join-Path $OutputDirectory 'unrelated-session' $null = New-Item -ItemType Directory -Path $unrelatedSessionRoot $unrelatedConfig = Join-Path $unrelatedSessionRoot 'session.json' [IO.File]::WriteAllText($unrelatedConfig, '{}', [Text.UTF8Encoding]::new($false)) 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-campaign-la-process', '--config', $unrelatedConfig, $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 -or [string]$captured.sessionConfigPath -cne $sessionConfig -or [string]$captured.commandLineFingerprintSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'Process capture did not return exact sanitized instance evidence.' } $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json' Invoke-Validator ` $positiveStatus $profile $liveReport $captureReport $false $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json if (-not ($liveResult.failures -match 'process instance.*remains alive') -or $liveResult.capturedProcessInstanceExited) { throw 'A live exact child instance 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') ` $captureReport $true $reusedCapture = Join-Path $OutputDirectory 'reused-pid.capture.json' $capturedIdentity = [string]$captured.processInstanceIdentity $identitySeparator = $capturedIdentity.LastIndexOf(':') $capturedStartValue = [uint64]::Parse( $capturedIdentity.Substring($identitySeparator + 1), [Globalization.CultureInfo]::InvariantCulture) $reusedPriorIdentity = $capturedIdentity.Substring(0, $identitySeparator + 1) ` + ($capturedStartValue + 1).ToString( [Globalization.CultureInfo]::InvariantCulture) Write-ProcessCapture ` $reusedCapture ` $unrelated.Id ` $reusedPriorIdentity ` $sessionConfig ` ([string]$captured.commandLineFingerprintSha256) $reusedReport = Join-Path $OutputDirectory 'reused-pid.validation.json' Invoke-Validator $positiveStatus $profile $reusedReport $reusedCapture $true $reusedResult = Get-Content -LiteralPath $reusedReport -Raw | ConvertFrom-Json if (-not $reusedResult.capturedPidReused -or -not $reusedResult.capturedProcessInstanceExited -or -not $reusedResult.sessionConfigProcessExited) { throw 'A reused PID was not distinguished from the exited captured instance.' } } 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 stableProcessInstanceCapture = $true liveProcessInstanceRejected = $true malformedProcessCaptureRejected = $true injectedPidReuseIgnored = $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"