2016 lines
85 KiB
PowerShell
2016 lines
85 KiB
PowerShell
$ErrorActionPreference = "Stop"
|
|
|
|
$root = Split-Path $PSScriptRoot -Parent
|
|
$scriptPath = Join-Path $root "Upgrade-MinerOpsAgentCredential.ps1"
|
|
$tokens = $null
|
|
$parseErrors = $null
|
|
|
|
[System.Management.Automation.Language.Parser]::ParseFile(
|
|
$scriptPath,
|
|
[ref]$tokens,
|
|
[ref]$parseErrors
|
|
) | Out-Null
|
|
|
|
if ($parseErrors.Count -gt 0) {
|
|
throw "Credential transition parser errors: $($parseErrors.Message -join '; ')"
|
|
}
|
|
|
|
$source = Get-Content -LiteralPath $scriptPath -Raw
|
|
|
|
function Assert-True {
|
|
param(
|
|
[Parameter(Mandatory = $true)][bool]$Condition,
|
|
[Parameter(Mandatory = $true)][string]$Message
|
|
)
|
|
if (-not $Condition) { throw $Message }
|
|
}
|
|
|
|
function Assert-SourceContains {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Pattern,
|
|
[Parameter(Mandatory = $true)][string]$Message
|
|
)
|
|
if ($source -notmatch $Pattern) { throw $Message }
|
|
}
|
|
|
|
function Assert-Throws {
|
|
param(
|
|
[Parameter(Mandatory = $true)][scriptblock]$Action,
|
|
[string]$Pattern = ""
|
|
)
|
|
try {
|
|
& $Action | Out-Null
|
|
}
|
|
catch {
|
|
if ($Pattern -and $_.Exception.Message -notmatch $Pattern) {
|
|
throw "Expected error '$Pattern', got '$($_.Exception.Message)'."
|
|
}
|
|
return
|
|
}
|
|
throw "Expected action to fail."
|
|
}
|
|
|
|
$script:Passed = 0
|
|
function Invoke-BehaviorTest {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Name,
|
|
[Parameter(Mandatory = $true)][scriptblock]$Action
|
|
)
|
|
|
|
& $Action
|
|
$script:Passed++
|
|
Write-Host "PASS: $Name"
|
|
}
|
|
|
|
# Dot-sourcing loads functions without executing the real transition.
|
|
. $scriptPath
|
|
|
|
function Get-SimPathKey {
|
|
param([Parameter(Mandatory = $true)][string]$Path)
|
|
return [IO.Path]::GetFullPath($Path)
|
|
}
|
|
|
|
function New-TransitionSimulation {
|
|
param([Parameter(Mandatory = $true)][bool]$OriginallyRunning)
|
|
|
|
$testRoot = Join-Path ([IO.Path]::GetTempPath()) (
|
|
"minerops-transition-test-" + [guid]::NewGuid().ToString("N")
|
|
)
|
|
$targetRoot = Join-Path $testRoot "agent"
|
|
$packageRoot = Join-Path $testRoot "package"
|
|
[void](New-Item -ItemType Directory -Path $targetRoot, $packageRoot)
|
|
|
|
foreach ($name in $RequiredFiles) {
|
|
[IO.File]::WriteAllText((Join-Path $packageRoot $name), "test`n")
|
|
}
|
|
|
|
$config = [ordered]@{
|
|
hub_url = "https://hub.example.test"
|
|
agent_id = "00000000-0000-4000-8000-000000000111"
|
|
site_id = "00000000-0000-4000-8000-000000000222"
|
|
agent_zone_id = "00000000-0000-4000-8000-000000000333"
|
|
site_name = "Test Site"
|
|
agent_secret = "legacy-shared-secret"
|
|
api3_account = "test-account"
|
|
api3_password = "test-password"
|
|
poll_seconds = 30
|
|
}
|
|
$configPath = Join-Path $targetRoot "agent-config.json"
|
|
[IO.File]::WriteAllText(
|
|
$configPath,
|
|
($config | ConvertTo-Json -Depth 20) + [Environment]::NewLine
|
|
)
|
|
$token = "transition-token-123456789012345678901234567890"
|
|
$enrollment = [ordered]@{
|
|
workflow = "existing_agent_credential_transition"
|
|
hub_url = "https://hub.example.test"
|
|
token = $token
|
|
}
|
|
[IO.File]::WriteAllText(
|
|
(Join-Path $packageRoot "enrollment.json"),
|
|
($enrollment | ConvertTo-Json) + [Environment]::NewLine
|
|
)
|
|
|
|
$script:Sim = @{
|
|
Root = $testRoot
|
|
Target = $targetRoot
|
|
Package = $packageRoot
|
|
Config = $configPath
|
|
Recovery = Join-Path $targetRoot "agent-credential-transition.recovery.json"
|
|
Pending = Join-Path $targetRoot "agent-config.json.pending"
|
|
Prior = Join-Path $targetRoot "agent-config.pre-credential-transition.json"
|
|
Enrollment = Join-Path $packageRoot "enrollment.json"
|
|
Token = $token
|
|
Secure = @{}
|
|
Events = [System.Collections.Generic.List[string]]::new()
|
|
Redemptions = [System.Collections.Generic.List[object]]::new()
|
|
TaskRunning = $OriginallyRunning
|
|
TaskVerificationFailureAfterCommit = $false
|
|
ResponseMode = "success"
|
|
SwapMode = "success"
|
|
FailAclLeaf = ""
|
|
FailTaskRestore = $false
|
|
InstallerFailure = $false
|
|
TaskDefinitionInvalid = $false
|
|
TaskStateOverride = $null
|
|
CleanupSafe = $false
|
|
SwapCalls = 0
|
|
DirectoryTrusted = $true
|
|
DirectoryTrustedAtDiscovery = $true
|
|
TrustEpochBlocked = $false
|
|
TrustResetAuthorized = $false
|
|
TrustResetRuntimePublished = $false
|
|
TrustResetOriginalTaskRunning = $null
|
|
BootChanged = $false
|
|
TrustBlocker = Join-Path $testRoot `
|
|
"agent.minerops-filesystem-trust.blocked-simulated.state"
|
|
DirectoryReparse = $false
|
|
AttemptSwap = $false
|
|
}
|
|
$script:Sim.Secure[(Get-SimPathKey -Path $configPath)] = $false
|
|
Set-TransitionSimulationMocks
|
|
return $script:Sim
|
|
}
|
|
|
|
function Remove-TransitionSimulation {
|
|
if ($null -ne $script:Sim -and (Test-Path -LiteralPath $script:Sim.Root)) {
|
|
Remove-Item -LiteralPath $script:Sim.Root -Recurse -Force
|
|
}
|
|
$script:Sim = $null
|
|
}
|
|
|
|
function Set-TransitionSimulationMocks {
|
|
function script:Initialize-MinerOpsCredentialDirectory {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[scriptblock]$CheckpointAction,
|
|
[switch]$ApproveFilesystemTrustResetAfterReboot
|
|
)
|
|
if ($script:Sim.DirectoryReparse) {
|
|
throw "Agent credential directory path contains a reparse point."
|
|
}
|
|
$wasTrusted = $script:Sim.DirectoryTrusted
|
|
$script:Sim.DirectoryTrustedAtDiscovery = $wasTrusted
|
|
if (-not $wasTrusted) {
|
|
if (-not $script:Sim.TrustEpochBlocked) {
|
|
[IO.File]::WriteAllText($script:Sim.TrustBlocker, "blocked")
|
|
$script:Sim.TrustEpochBlocked = $true
|
|
Invoke-MinerOpsTransitionCheckpoint `
|
|
-Name "directory-repair-blocker-written" `
|
|
-Action $CheckpointAction
|
|
}
|
|
$script:Sim.Events.Add("repair-directory")
|
|
$script:Sim.DirectoryTrusted = $true
|
|
Invoke-MinerOpsTransitionCheckpoint `
|
|
-Name "directory-trust-established" `
|
|
-Action $CheckpointAction
|
|
}
|
|
$newlyBlocked = -not $wasTrusted
|
|
$approved = $false
|
|
if ($script:Sim.TrustEpochBlocked -and -not $newlyBlocked) {
|
|
if (-not $ApproveFilesystemTrustResetAfterReboot) {
|
|
throw "filesystem trust epoch is blocked"
|
|
}
|
|
if (-not $script:Sim.BootChanged) {
|
|
throw "without a verified reboot"
|
|
}
|
|
$approved = $true
|
|
}
|
|
return [pscustomobject]@{
|
|
Path = [IO.Path]::GetFullPath($Path)
|
|
WasTrustedAtDiscovery = $wasTrusted
|
|
WasRepaired = -not $wasTrusted
|
|
TrustEpochBlocked = $script:Sim.TrustEpochBlocked
|
|
TrustEpochNewlyBlocked = $newlyBlocked
|
|
TrustResetApproved = $approved
|
|
TrustResetAuthorized = $script:Sim.TrustResetAuthorized
|
|
TrustResetRuntimePublished = `
|
|
$script:Sim.TrustResetRuntimePublished
|
|
TrustResetOriginalTaskRunning = `
|
|
$script:Sim.TrustResetOriginalTaskRunning
|
|
TrustBlockerPaths = @($script:Sim.TrustBlocker)
|
|
}
|
|
}
|
|
|
|
function script:Assert-MinerOpsCredentialDirectory {
|
|
param([Parameter(Mandatory = $true)][string]$Path)
|
|
if ($script:Sim.DirectoryReparse) {
|
|
throw "Agent credential directory path contains a reparse point."
|
|
}
|
|
if (-not $script:Sim.DirectoryTrusted) {
|
|
throw "Agent credential directory owner or DACL is not trusted."
|
|
}
|
|
$script:Sim.Events.Add("assert-directory")
|
|
if ($script:Sim.AttemptSwap) {
|
|
$script:Sim.Events.Add("swap-blocked-by-directory")
|
|
}
|
|
return [IO.Path]::GetFullPath($Path)
|
|
}
|
|
|
|
function script:Initialize-MinerOpsQuarantineDirectory {
|
|
param([Parameter(Mandatory = $true)][string]$TargetRoot)
|
|
$path = Join-Path $TargetRoot `
|
|
"minerops-credential-transition-quarantine"
|
|
if (-not (Test-Path -LiteralPath $path)) {
|
|
[void](New-Item -ItemType Directory -Path $path)
|
|
}
|
|
return [IO.Path]::GetFullPath($path)
|
|
}
|
|
|
|
function script:Protect-MinerOpsQuarantineEvidence {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$QuarantineRoot
|
|
)
|
|
$script:Sim.Secure[(Get-SimPathKey -Path $Path)] = $true
|
|
}
|
|
|
|
function script:Get-MinerOpsCredentialArtifactProvenance {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$ExpectedParent,
|
|
[Parameter(Mandatory = $true)][string]$Role,
|
|
[Parameter(Mandatory = $true)][bool]$DirectoryWasTrustedAtDiscovery,
|
|
[switch]$CreatedByCurrentTransition
|
|
)
|
|
if ($Role -eq "Active") { return "UnknownUntrusted" }
|
|
if ($Role -eq "Quarantine") {
|
|
return "ProtectedButUntrustedQuarantined"
|
|
}
|
|
$key = Get-SimPathKey -Path $Path
|
|
if (
|
|
$CreatedByCurrentTransition -or
|
|
($DirectoryWasTrustedAtDiscovery -and
|
|
$script:Sim.Secure.ContainsKey($key) -and
|
|
$script:Sim.Secure[$key])
|
|
) {
|
|
return "ProtectedTransitionCandidate"
|
|
}
|
|
return "UnknownUntrusted"
|
|
}
|
|
|
|
function script:Move-MinerOpsArtifactToQuarantine {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$ExpectedParent,
|
|
[Parameter(Mandatory = $true)][string]$Role,
|
|
[scriptblock]$CheckpointAction
|
|
)
|
|
if (-not (Test-Path -LiteralPath $Path)) { return $null }
|
|
$sourceKey = Get-SimPathKey -Path $Path
|
|
$script:Sim.Events.Add("contain-without-read:$([IO.Path]::GetFileName($Path))")
|
|
$quarantineRoot = Initialize-MinerOpsQuarantineDirectory `
|
|
-TargetRoot $ExpectedParent
|
|
$destination = Join-Path $quarantineRoot (
|
|
"minerops-quarantine-credential-transition-$Role-" +
|
|
[guid]::NewGuid().ToString("N") + ".evidence"
|
|
)
|
|
[IO.File]::Move($Path, $destination)
|
|
Invoke-MinerOpsTransitionCheckpoint `
|
|
-Name "artifact-moved-to-quarantine" `
|
|
-Action $CheckpointAction
|
|
$script:Sim.Secure.Remove($sourceKey)
|
|
$script:Sim.Secure[(Get-SimPathKey -Path $destination)] = $true
|
|
$script:Sim.Events.Add("quarantine:$([IO.Path]::GetFileName($Path))")
|
|
return $destination
|
|
}
|
|
|
|
function script:Read-MinerOpsProtectedCredentialText {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$ExpectedParent,
|
|
[switch]$AllowAclRepair
|
|
)
|
|
$key = Get-SimPathKey -Path $Path
|
|
if (-not $script:Sim.Secure.ContainsKey($key) -or
|
|
-not $script:Sim.Secure[$key]) {
|
|
$script:Sim.Events.Add("repair:$([IO.Path]::GetFileName($Path))")
|
|
$script:Sim.Secure[$key] = $true
|
|
if (-not $AllowAclRepair) {
|
|
throw "Credential transition artifact had an insecure owner or DACL."
|
|
}
|
|
}
|
|
$script:Sim.Events.Add("read:$([IO.Path]::GetFileName($Path))")
|
|
return [IO.File]::ReadAllText($Path)
|
|
}
|
|
|
|
function script:Write-MinerOpsProtectedCredentialJson {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$ExpectedParent,
|
|
[Parameter(Mandatory = $true)]$Value
|
|
)
|
|
$leaf = [IO.Path]::GetFileName($Path)
|
|
$key = Get-SimPathKey -Path $Path
|
|
if (Test-Path -LiteralPath $Path) {
|
|
throw "CreateNew refused existing artifact."
|
|
}
|
|
[IO.File]::Create($Path).Dispose()
|
|
$script:Sim.Events.Add("create-empty:$leaf")
|
|
$script:Sim.Events.Add("protect:$leaf")
|
|
if ($script:Sim.FailAclLeaf -eq $leaf) {
|
|
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
|
|
throw "simulated ACL establishment failure"
|
|
}
|
|
$script:Sim.Secure[$key] = $true
|
|
$script:Sim.Events.Add("verify-acl:$leaf")
|
|
[IO.File]::WriteAllText(
|
|
$Path,
|
|
($Value | ConvertTo-Json -Depth 50) + [Environment]::NewLine
|
|
)
|
|
$script:Sim.Events.Add("write-secret:$leaf")
|
|
}
|
|
|
|
function script:Open-MinerOpsProtectedCredentialFile {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$ExpectedParent,
|
|
[switch]$AllowAclRepair
|
|
)
|
|
$key = Get-SimPathKey -Path $Path
|
|
if (-not $script:Sim.Secure.ContainsKey($key) -or
|
|
-not $script:Sim.Secure[$key]) {
|
|
$script:Sim.Secure[$key] = $true
|
|
if (-not $AllowAclRepair) {
|
|
throw "Credential transition artifact had an insecure owner or DACL."
|
|
}
|
|
}
|
|
return [IO.File]::Open(
|
|
$Path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite,
|
|
[IO.FileShare]::None
|
|
)
|
|
}
|
|
|
|
function script:Remove-MinerOpsProtectedCredentialArtifact {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$ExpectedParent
|
|
)
|
|
if (-not (Test-Path -LiteralPath $Path)) { return }
|
|
$leaf = [IO.Path]::GetFileName($Path)
|
|
if ($leaf -eq "agent-credential-transition.recovery.json") {
|
|
$active = [IO.File]::ReadAllText($script:Sim.Config) |
|
|
ConvertFrom-Json
|
|
$script:Sim.CleanupSafe = (
|
|
[string]$active.agent_secret -ne "legacy-shared-secret" -and
|
|
$script:Sim.TaskRunning -eq
|
|
[bool](ConvertFrom-Json ([IO.File]::ReadAllText($Path))).original_task_running
|
|
)
|
|
}
|
|
Remove-Item -LiteralPath $Path -Force
|
|
$script:Sim.Secure.Remove((Get-SimPathKey -Path $Path))
|
|
$script:Sim.Events.Add("remove:$leaf")
|
|
}
|
|
|
|
function script:Invoke-MinerOpsRuntimeInstaller {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[switch]$ApproveFilesystemTrustResetAfterReboot
|
|
)
|
|
if ($ApproveFilesystemTrustResetAfterReboot) {
|
|
$script:Sim.Events.Add("trust-reset-runtime-publication")
|
|
if ($script:Sim.InstallerFailure) {
|
|
throw "simulated runtime publication failure"
|
|
}
|
|
$script:Sim.TaskRunning = $false
|
|
}
|
|
$script:Sim.Events.Add("install")
|
|
}
|
|
|
|
function script:Set-MinerOpsFilesystemTrustBlockerRuntimePublished {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$TargetRoot,
|
|
[Parameter(Mandatory = $true)][bool]$OriginalTaskRunning
|
|
)
|
|
if (-not (Test-Path -LiteralPath $Path) -or
|
|
-not $script:Sim.TrustResetAuthorized) {
|
|
throw "simulated blocker disappeared before publication state"
|
|
}
|
|
$script:Sim.TrustResetRuntimePublished = $true
|
|
$script:Sim.TrustResetOriginalTaskRunning = $OriginalTaskRunning
|
|
$script:Sim.Events.Add("trust-blocker:runtime-published")
|
|
}
|
|
|
|
function script:Set-MinerOpsFilesystemTrustBlockerResetAuthorized {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$TargetRoot,
|
|
[Parameter(Mandatory = $true)][bool]$OriginalTaskRunning
|
|
)
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
throw "simulated blocker disappeared before reset authorization"
|
|
}
|
|
$script:Sim.TrustResetAuthorized = $true
|
|
$script:Sim.TrustResetOriginalTaskRunning = $OriginalTaskRunning
|
|
$script:Sim.Events.Add("trust-blocker:reset-authorized")
|
|
}
|
|
|
|
function script:Remove-MinerOpsFilesystemTrustBlockerAfterConvergence {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Path,
|
|
[Parameter(Mandatory = $true)][string]$TargetRoot
|
|
)
|
|
if (-not $script:Sim.TrustResetRuntimePublished) {
|
|
throw "simulated blocker removed before runtime publication"
|
|
}
|
|
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
|
|
$script:Sim.TrustEpochBlocked = $false
|
|
$script:Sim.Events.Add("trust-blocker:removed-after-convergence")
|
|
}
|
|
|
|
function script:Get-MinerOpsVerifiedScheduledTask {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Name,
|
|
[Parameter(Mandatory = $true)][string]$TargetRoot
|
|
)
|
|
if ($script:Sim.TaskVerificationFailureAfterCommit -and
|
|
$script:Sim.Redemptions.Count -gt 0) {
|
|
throw "simulated strict task verification failure"
|
|
}
|
|
if ($script:Sim.TaskDefinitionInvalid) {
|
|
throw "simulated strict task definition failure"
|
|
}
|
|
$state = if ($null -ne $script:Sim.TaskStateOverride) {
|
|
[string]$script:Sim.TaskStateOverride
|
|
}
|
|
elseif ($script:Sim.TaskRunning) { "Running" } else { "Ready" }
|
|
return [pscustomobject]@{
|
|
Task = [pscustomobject]@{ TaskName = $Name }
|
|
State = $state
|
|
}
|
|
}
|
|
|
|
function script:Set-MinerOpsScheduledTaskState {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Name,
|
|
[Parameter(Mandatory = $true)][string]$TargetRoot,
|
|
[Parameter(Mandatory = $true)][bool]$ShouldRun,
|
|
[TimeSpan]$Timeout = [TimeSpan]::FromSeconds(30)
|
|
)
|
|
if ($script:Sim.FailTaskRestore -and $ShouldRun) {
|
|
throw "simulated task restart failure"
|
|
}
|
|
$script:Sim.TaskRunning = $ShouldRun
|
|
$script:Sim.Events.Add("task:$ShouldRun")
|
|
}
|
|
|
|
function script:Stop-MinerOpsAgentTask {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Name,
|
|
[Parameter(Mandatory = $true)][string]$TargetRoot
|
|
)
|
|
$script:Sim.TaskRunning = $false
|
|
$script:Sim.Events.Add("task:stopped-for-transition")
|
|
}
|
|
|
|
function script:Invoke-MinerOpsCredentialRedemption {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$HubUrl,
|
|
[Parameter(Mandatory = $true)][string]$Token,
|
|
[Parameter(Mandatory = $true)][string]$SecretHash
|
|
)
|
|
$script:Sim.Redemptions.Add([pscustomobject]@{
|
|
Token = $Token
|
|
SecretHash = $SecretHash
|
|
})
|
|
if ($script:Sim.ResponseMode -eq "lost-once" -and
|
|
$script:Sim.Redemptions.Count -eq 1) {
|
|
throw "simulated lost HTTP response after commit"
|
|
}
|
|
return [pscustomobject]@{
|
|
data = [pscustomobject]@{
|
|
agent_id = "00000000-0000-4000-8000-000000000111"
|
|
site_id = "00000000-0000-4000-8000-000000000222"
|
|
site_name = "Test Site"
|
|
agent_zone_id = "00000000-0000-4000-8000-000000000333"
|
|
agent_zone_name = "Test Zone"
|
|
}
|
|
}
|
|
}
|
|
|
|
function script:Invoke-MinerOpsRecoverableConfigInstall {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$PendingPath,
|
|
[Parameter(Mandatory = $true)][string]$ConfigPath,
|
|
[Parameter(Mandatory = $true)][string]$PriorPath,
|
|
[Parameter(Mandatory = $true)][string]$TargetRoot,
|
|
[scriptblock]$CheckpointAction
|
|
)
|
|
$script:Sim.SwapCalls++
|
|
if ($script:Sim.SwapMode -eq "fail-before-once" -and
|
|
$script:Sim.SwapCalls -eq 1) {
|
|
throw "simulated replacement failure"
|
|
}
|
|
if ($script:Sim.SwapMode -eq "active-missing-pending-once" -and
|
|
$script:Sim.SwapCalls -eq 1) {
|
|
Remove-Item -LiteralPath $ConfigPath -Force
|
|
$script:Sim.Secure.Remove((Get-SimPathKey -Path $ConfigPath))
|
|
throw "simulated ERROR_UNABLE_TO_MOVE_REPLACEMENT"
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $ConfigPath) {
|
|
[void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot)
|
|
$script:Sim.Events.Add("directory-verified-before-active-move")
|
|
[IO.File]::Move($ConfigPath, $PriorPath)
|
|
$script:Sim.Secure[(Get-SimPathKey -Path $PriorPath)] = `
|
|
$script:Sim.Secure[(Get-SimPathKey -Path $ConfigPath)]
|
|
$script:Sim.Secure.Remove((Get-SimPathKey -Path $ConfigPath))
|
|
Invoke-MinerOpsTransitionCheckpoint `
|
|
-Name "active-moved-to-prior" -Action $CheckpointAction
|
|
}
|
|
if ($script:Sim.SwapMode -eq "active-to-prior-fail-once" -and
|
|
$script:Sim.SwapCalls -eq 1) {
|
|
throw "simulated ERROR_UNABLE_TO_MOVE_REPLACEMENT_2"
|
|
}
|
|
|
|
[void](Assert-MinerOpsCredentialDirectory -Path $TargetRoot)
|
|
$script:Sim.Events.Add("directory-verified-before-pending-move")
|
|
[IO.File]::Move($PendingPath, $ConfigPath)
|
|
$script:Sim.Secure[(Get-SimPathKey -Path $ConfigPath)] = $true
|
|
$script:Sim.Secure.Remove((Get-SimPathKey -Path $PendingPath))
|
|
$script:Sim.Events.Add("recoverable-install")
|
|
Invoke-MinerOpsTransitionCheckpoint `
|
|
-Name "pending-moved-to-active" -Action $CheckpointAction
|
|
if ($script:Sim.SwapMode -eq "move-then-fail-once" -and
|
|
$script:Sim.SwapCalls -eq 1) {
|
|
throw "simulated ambiguous post-replacement failure"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-SimulatedTransition {
|
|
param(
|
|
[scriptblock]$Checkpoint,
|
|
[switch]$ApproveFilesystemTrustResetAfterReboot
|
|
)
|
|
|
|
Invoke-MinerOpsAgentCredentialTransition `
|
|
-TargetRoot $script:Sim.Target `
|
|
-PackageRoot $script:Sim.Package `
|
|
-ScheduledTaskName "MinerOpsAgent-Test" `
|
|
-CheckpointAction $Checkpoint `
|
|
-ApproveFilesystemTrustResetAfterReboot:$ApproveFilesystemTrustResetAfterReboot
|
|
}
|
|
|
|
function Get-SimRecovery {
|
|
return [IO.File]::ReadAllText($script:Sim.Recovery) | ConvertFrom-Json
|
|
}
|
|
|
|
function Set-SimPendingObject {
|
|
param([Parameter(Mandatory = $true)]$Value)
|
|
[IO.File]::WriteAllText(
|
|
$script:Sim.Pending,
|
|
($Value | ConvertTo-Json -Depth 50) + [Environment]::NewLine
|
|
)
|
|
}
|
|
|
|
# Source invariants complement the behavioral harness. In particular, JSON is
|
|
# not even serialized until after the empty file's DACL has been read back.
|
|
Assert-SourceContains 'existing_agent_credential_transition' `
|
|
"Transition package workflow is not validated."
|
|
Assert-SourceContains '/api/agent/credential-transition' `
|
|
"Dedicated credential transition endpoint is missing."
|
|
Assert-SourceContains 'FileMode\]::CreateNew' `
|
|
"Secret artifacts are not created without overwrite."
|
|
Assert-SourceContains 'Test-MinerOpsCredentialAcl' `
|
|
"Credential artifact DACL verification is missing."
|
|
Assert-SourceContains 'original_task_running' `
|
|
"Original task state is not durable."
|
|
Assert-SourceContains 'transition_token_hash' `
|
|
"Recovery does not bind the transition token."
|
|
Assert-SourceContains 'agent_secret_hash' `
|
|
"Recovery does not bind the generated credential."
|
|
Assert-SourceContains 'Assert-MinerOpsNoReparseDirectoryPath' `
|
|
"Full directory-path reparse validation is missing."
|
|
Assert-SourceContains 'Test-MinerOpsAgentDirectoryAcl' `
|
|
"Credential directory DACL verification is missing."
|
|
Assert-SourceContains 'TrustedCurrentTransitionState' `
|
|
"Trusted transition provenance is not explicit."
|
|
Assert-SourceContains 'TrustedActiveConfig' `
|
|
"Trusted active-config provenance is not explicit."
|
|
Assert-SourceContains 'ProtectedButUntrustedQuarantined' `
|
|
"Quarantined provenance is not explicit."
|
|
Assert-SourceContains 'UnknownUntrusted' `
|
|
"Unknown artifact provenance is not explicit."
|
|
Assert-SourceContains 'minerops-quarantine-credential-transition' `
|
|
"Non-authoritative quarantine naming is missing."
|
|
Assert-SourceContains 'minerops-credential-transition\.blocked' `
|
|
"Durable quarantine blocked-state naming is missing."
|
|
Assert-SourceContains 'minerops-credential-transition-quarantine' `
|
|
"Protected quarantine directory naming is missing."
|
|
Assert-SourceContains 'marker exists before ACL repair' `
|
|
"ACL-repair laundering protection is not documented in source."
|
|
Assert-SourceContains 'Invoke-MinerOpsRecoverableConfigInstall' `
|
|
"Recoverable config installation is missing."
|
|
Assert-SourceContains 'active-moved-to-prior' `
|
|
"Known active-to-prior intermediate state is missing."
|
|
Assert-SourceContains 'pending-moved-to-active' `
|
|
"Known pending-to-active intermediate state is missing."
|
|
Assert-SourceContains 'before-recovery-cleanup' `
|
|
"Success cleanup checkpoint is missing."
|
|
Assert-SourceContains 'Remove-MinerOpsFilesystemTrustBlockerAfterConvergence' `
|
|
"Parent-owned post-convergence trust-blocker removal is missing."
|
|
Assert-SourceContains 'windows-full-boot-event-v1:' `
|
|
"Canonical Windows full-kernel-boot representation is missing."
|
|
Assert-True ($source -notmatch 'LastBootUpTime') `
|
|
"Wall-clock boot timestamps must not authorize a trust reset."
|
|
|
|
foreach ($functionName in @(
|
|
"Assert-MinerOpsNoReparseDirectoryPath",
|
|
"Assert-MinerOpsTrustedAncestorNamespace"
|
|
)) {
|
|
$functionSource = [regex]::Match(
|
|
$source,
|
|
"(?s)function $functionName \{.*?\n\}"
|
|
).Value
|
|
Assert-True (-not [string]::IsNullOrWhiteSpace($functionSource)) `
|
|
"$functionName source was not found."
|
|
Assert-True ($functionSource -match '\$current\s*=\s*\$root') `
|
|
"$functionName does not preserve the filesystem root."
|
|
Assert-True ($functionSource -match `
|
|
'\$fullPath\.Equals\(\s*\$root') `
|
|
"$functionName does not protect a root-valued full path from trimming."
|
|
Assert-True ($functionSource -notmatch '\$root\.TrimEnd\(') `
|
|
"$functionName converts a root-qualified path to drive-relative form."
|
|
Assert-True ($functionSource -notmatch `
|
|
'GetFullPath\(\$Path\)\.TrimEnd\(') `
|
|
"$functionName trims the filesystem root before identifying it."
|
|
if ($functionName -eq "Assert-MinerOpsTrustedAncestorNamespace") {
|
|
Assert-True ($functionSource -match `
|
|
'\$parent\.Equals\(\s*\$root') `
|
|
"$functionName does not protect a root-valued parent from trimming."
|
|
}
|
|
}
|
|
|
|
$taskRestoreIndex = $source.LastIndexOf('-Name "task-restored"')
|
|
$nonRecoveryCleanupIndex = $source.LastIndexOf('-Path $pendingPath')
|
|
$blockerRemovalIndex = $source.LastIndexOf(
|
|
'Remove-MinerOpsFilesystemTrustBlockerAfterConvergence'
|
|
)
|
|
$recoveryCleanupIndex = $source.LastIndexOf('-Path $recoveryPath')
|
|
Assert-True (
|
|
$taskRestoreIndex -ge 0 -and
|
|
$nonRecoveryCleanupIndex -gt $taskRestoreIndex -and
|
|
$blockerRemovalIndex -gt $nonRecoveryCleanupIndex -and
|
|
$recoveryCleanupIndex -gt $blockerRemovalIndex
|
|
) "Blocker deletion must follow convergence and non-recovery cleanup while recovery remains last."
|
|
|
|
function New-TestTrustBlockerRecord {
|
|
return [pscustomobject][ordered]@{
|
|
version = 2
|
|
state = "blocked"
|
|
reason = "existing-agent-directory-was-untrusted"
|
|
target_path = [IO.Path]::GetFullPath($script:VerifierTarget)
|
|
detected_boot_id = "windows-full-boot-event-v1:000000000000002a"
|
|
reset_boot_id = $null
|
|
original_task_running = $null
|
|
created_at = "2026-09-19T00:00:00.0000000Z"
|
|
}
|
|
}
|
|
|
|
$script:VerifierTarget = Join-Path ([IO.Path]::GetTempPath()) `
|
|
"minerops-verifier-target"
|
|
|
|
Invoke-BehaviorTest "production blocker parser accepts canonical boot epoch" {
|
|
$json = (New-TestTrustBlockerRecord) | ConvertTo-Json -Depth 10
|
|
$record = ConvertFrom-MinerOpsJson `
|
|
-Json $json -Description "test filesystem trust blocker"
|
|
[void](Assert-MinerOpsFilesystemTrustBlockerRecord `
|
|
-Record $record -TargetRoot $script:VerifierTarget)
|
|
}
|
|
Invoke-BehaviorTest "malformed stored BootId fails closed" {
|
|
$record = New-TestTrustBlockerRecord
|
|
$record.detected_boot_id = "not-a-boot-id"
|
|
Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord `
|
|
-Record $record -TargetRoot $script:VerifierTarget } "canonical"
|
|
}
|
|
Invoke-BehaviorTest "missing stored BootId fails closed" {
|
|
$record = New-TestTrustBlockerRecord
|
|
$record.PSObject.Properties.Remove("detected_boot_id")
|
|
Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord `
|
|
-Record $record -TargetRoot $script:VerifierTarget } "invalid"
|
|
}
|
|
Invoke-BehaviorTest "empty stored BootId fails closed" {
|
|
$record = New-TestTrustBlockerRecord
|
|
$record.detected_boot_id = ""
|
|
Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord `
|
|
-Record $record -TargetRoot $script:VerifierTarget } "canonical"
|
|
}
|
|
Invoke-BehaviorTest "wrong-type stored BootId fails closed" {
|
|
$record = New-TestTrustBlockerRecord
|
|
$record.detected_boot_id = 42
|
|
Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord `
|
|
-Record $record -TargetRoot $script:VerifierTarget } "canonical"
|
|
}
|
|
Invoke-BehaviorTest "additional blocker representation fails closed" {
|
|
$record = New-TestTrustBlockerRecord
|
|
$record | Add-Member -NotePropertyName boot_timestamp `
|
|
-NotePropertyValue "2026-09-19T00:00:00Z"
|
|
Assert-Throws { Assert-MinerOpsFilesystemTrustBlockerRecord `
|
|
-Record $record -TargetRoot $script:VerifierTarget } "invalid"
|
|
}
|
|
Invoke-BehaviorTest "current boot-epoch query failure fails closed" {
|
|
Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction {
|
|
throw "simulated registry failure"
|
|
} } "could not be verified"
|
|
}
|
|
Invoke-BehaviorTest "malformed current boot-epoch query fails closed" {
|
|
Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction {
|
|
[pscustomobject]@{
|
|
RecordId = "42"
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = [int]0
|
|
}
|
|
} } "could not be verified"
|
|
}
|
|
Invoke-BehaviorTest "duplicated current full-boot identity fails closed" {
|
|
Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction {
|
|
1..2 | ForEach-Object {
|
|
[pscustomobject]@{
|
|
RecordId = [long]42
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = [int]0
|
|
}
|
|
}
|
|
} } "could not be verified"
|
|
}
|
|
Invoke-BehaviorTest "wrong-type current BootType fails closed" {
|
|
Assert-Throws { Get-MinerOpsBootIdentifier -QueryAction {
|
|
[pscustomobject]@{
|
|
RecordId = [long]42
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = "0"
|
|
}
|
|
} } "could not be verified"
|
|
}
|
|
Invoke-BehaviorTest "canonical full-kernel-boot event query succeeds" {
|
|
$bootId = Get-MinerOpsBootIdentifier -QueryAction {
|
|
[pscustomobject]@{
|
|
RecordId = [long]42
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = [int]0
|
|
}
|
|
}
|
|
Assert-True (
|
|
$bootId -ceq "windows-full-boot-event-v1:000000000000002a"
|
|
) "Full-kernel-boot event was not canonicalized."
|
|
}
|
|
Invoke-BehaviorTest "Fast Startup event does not advance full-boot epoch" {
|
|
$bootId = Get-MinerOpsBootIdentifier -QueryAction {
|
|
[pscustomobject]@{
|
|
RecordId = [long]43
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = [int]1
|
|
}
|
|
[pscustomobject]@{
|
|
RecordId = [long]42
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = [int]0
|
|
}
|
|
}
|
|
Assert-True (
|
|
$bootId -ceq "windows-full-boot-event-v1:000000000000002a"
|
|
) "Fast Startup advanced the full-kernel-boot epoch."
|
|
}
|
|
Invoke-BehaviorTest "hibernate event does not advance full-boot epoch" {
|
|
$bootId = Get-MinerOpsBootIdentifier -QueryAction {
|
|
[pscustomobject]@{
|
|
RecordId = [long]44
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = [int]2
|
|
}
|
|
[pscustomobject]@{
|
|
RecordId = [long]42
|
|
EventId = [int]27
|
|
ProviderName = "Microsoft-Windows-Kernel-Boot"
|
|
Version = [byte]1
|
|
BootType = [int]0
|
|
}
|
|
}
|
|
Assert-True (
|
|
$bootId -ceq "windows-full-boot-event-v1:000000000000002a"
|
|
) "Hibernate advanced the full-kernel-boot epoch."
|
|
}
|
|
Invoke-BehaviorTest "same canonical boot epoch denies reset" {
|
|
Assert-Throws { Assert-MinerOpsBootEpochChanged `
|
|
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
|
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002a" } `
|
|
"without a verified full Restart"
|
|
}
|
|
Invoke-BehaviorTest "different canonical boot epoch passes epoch check only" {
|
|
Assert-MinerOpsBootEpochChanged `
|
|
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
|
-CurrentBootIdentifier "windows-full-boot-event-v1:000000000000002b"
|
|
}
|
|
Invoke-BehaviorTest "older canonical boot epoch denies reset" {
|
|
Assert-Throws { Assert-MinerOpsBootEpochChanged `
|
|
-StoredBootIdentifier "windows-full-boot-event-v1:000000000000002a" `
|
|
-CurrentBootIdentifier "windows-full-boot-event-v1:0000000000000029" } `
|
|
"older than the"
|
|
}
|
|
$bootQueryFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function Get-MinerOpsBootIdentifier \{.*?\n\}'
|
|
).Value
|
|
Assert-True (
|
|
$bootQueryFunction -notmatch 'Get-Date|DateTime|LastBootUpTime' -and
|
|
$bootQueryFunction -match 'Get-WinEvent' -and
|
|
$bootQueryFunction -match 'BootType'
|
|
) "Boot epoch comparison must not derive from the wall clock."
|
|
|
|
$writeFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function Write-MinerOpsProtectedCredentialJson \{.*?\n\}'
|
|
).Value
|
|
$createIndex = $writeFunction.IndexOf(
|
|
"New-MinerOpsProtectedFileCreateNewStream"
|
|
)
|
|
$verifyIndex = $writeFunction.IndexOf("Test-MinerOpsCredentialAcl")
|
|
$jsonIndex = $writeFunction.IndexOf('$json =')
|
|
Assert-True (
|
|
$createIndex -ge 0 -and $verifyIndex -gt $createIndex -and
|
|
$jsonIndex -gt $verifyIndex -and
|
|
$writeFunction -notmatch 'Set-MinerOpsStreamAcl'
|
|
) "Secret serialization must follow protected creation and ACL verification."
|
|
|
|
$createStreamFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function New-MinerOpsProtectedFileCreateNewStream \{.*?\n\}'
|
|
).Value
|
|
foreach ($requiredCreateContract in @(
|
|
'\[IO\.FileStream\]::new',
|
|
'FileMode\]::CreateNew',
|
|
'FileSystemRights\]::WriteData',
|
|
'FileSystemRights\]::ReadPermissions',
|
|
'FileSystemRights\]::Synchronize',
|
|
'FileShare\]::None',
|
|
'\$Acl'
|
|
)) {
|
|
Assert-True ($createStreamFunction -match $requiredCreateContract) `
|
|
"Protected CreateNew stream contract is incomplete."
|
|
}
|
|
Assert-True (
|
|
$createStreamFunction -notmatch 'FullControl|SetAccessControl|Set-Acl'
|
|
) "Protected creation must use minimum rights and creation-time FileSecurity."
|
|
|
|
$openStreamFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function Open-MinerOpsFileStreamWithRights \{.*?\n\}'
|
|
).Value
|
|
Assert-True (
|
|
$openStreamFunction -match 'FileMode\]::Open' -and
|
|
$openStreamFunction -match 'FileShare\]::None' -and
|
|
$openStreamFunction -match '\$Rights'
|
|
) "Existing protected artifacts must use an exclusive explicit-rights handle."
|
|
|
|
$repairOpenFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function Open-MinerOpsProtectedCredentialFile \{.*?\n\}'
|
|
).Value
|
|
foreach ($requiredRepairRight in @(
|
|
'ReadData',
|
|
'ReadPermissions',
|
|
'ChangePermissions',
|
|
'TakeOwnership',
|
|
'Synchronize'
|
|
)) {
|
|
Assert-True ($repairOpenFunction -match $requiredRepairRight) `
|
|
"Existing-artifact repair is missing $requiredRepairRight."
|
|
}
|
|
Assert-True (
|
|
$repairOpenFunction -match 'Set-MinerOpsStreamAcl' -and
|
|
$repairOpenFunction -match 'will not be trusted' -and
|
|
$repairOpenFunction -notmatch 'Set-Acl'
|
|
) "Existing-artifact repair must remain handle-bound and fail closed."
|
|
|
|
$trustBlockerCreateFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function New-MinerOpsFilesystemTrustBlocker \{.*?\n\}'
|
|
).Value
|
|
$blockerCreateIndex = $trustBlockerCreateFunction.IndexOf(
|
|
'New-MinerOpsProtectedFileCreateNewStream'
|
|
)
|
|
$blockerVerifyIndex = $trustBlockerCreateFunction.IndexOf(
|
|
'Test-MinerOpsCredentialAcl'
|
|
)
|
|
$blockerContentIndex = $trustBlockerCreateFunction.IndexOf('$record =')
|
|
Assert-True (
|
|
$blockerCreateIndex -ge 0 -and
|
|
$blockerVerifyIndex -gt $blockerCreateIndex -and
|
|
$blockerContentIndex -gt $blockerVerifyIndex
|
|
) "Filesystem trust blockers must be protected and verified before content."
|
|
|
|
Assert-True ($source -notmatch '\[IO\.File\]::Open') `
|
|
"Credential transition stream security must use explicit FileSystemRights."
|
|
|
|
$quarantineFunction = [regex]::Match(
|
|
$source,
|
|
'(?s)function Move-MinerOpsArtifactToQuarantine \{.*?\n\}'
|
|
).Value
|
|
$quarantineMoveIndex = $quarantineFunction.IndexOf('[IO.File]::Move')
|
|
$quarantineAclIndex = $quarantineFunction.IndexOf('Set-Acl')
|
|
Assert-True (
|
|
$quarantineMoveIndex -ge 0 -and
|
|
$quarantineAclIndex -gt $quarantineMoveIndex -and
|
|
$quarantineFunction -notmatch 'Read(AllText|-MinerOpsProtectedCredentialText)'
|
|
) "Untrusted evidence must leave authority before ACL mutation and must never be read."
|
|
|
|
foreach ($sensitiveVariable in @(
|
|
'enrollmentToken', 'candidateSecret', 'transition_token', 'agent_secret'
|
|
)) {
|
|
Assert-True (
|
|
$source -notmatch (
|
|
'(?im)Write-(Host|Output|Verbose|Information).*?\$' +
|
|
$sensitiveVariable
|
|
)
|
|
) "$sensitiveVariable must not be written to output."
|
|
}
|
|
foreach ($productionValue in @('plexus-windows-agent', 'Plexus Mining Site')) {
|
|
Assert-True (
|
|
$source -notmatch [regex]::Escape($productionValue)
|
|
) "Production-specific value found in generic transition source."
|
|
}
|
|
|
|
Assert-True ($source -notmatch 'Get-ScheduledTask[^\r\n]*SilentlyContinue') `
|
|
"Scheduled Task queries must use terminating error behavior."
|
|
Assert-SourceContains 'function Get-MinerOpsVerifiedScheduledTask' `
|
|
"The strict Scheduled Task verifier is missing."
|
|
Assert-SourceContains 'Settings\.Enabled' `
|
|
"The strict verifier does not require an enabled task."
|
|
Assert-SourceContains 'Principal\.UserId' `
|
|
"The strict verifier does not validate the task principal."
|
|
Assert-SourceContains 'Actions' `
|
|
"The strict verifier does not validate the task action."
|
|
|
|
# Exercise the real strict verifier with deterministic Scheduled Task provider
|
|
# results before the transition simulation replaces it with a filesystem-free
|
|
# mock. These cases prove that only a valid expected task in exactly Running or
|
|
# Ready can satisfy durable intent.
|
|
$script:VerifierTasks = @()
|
|
$script:VerifierQueryFailure = ""
|
|
function script:Get-ScheduledTask {
|
|
param($ErrorAction)
|
|
if ($script:VerifierQueryFailure -eq "provider") {
|
|
throw "provider failure"
|
|
}
|
|
if ($script:VerifierQueryFailure -eq "access") {
|
|
throw (New-Object UnauthorizedAccessException("access denied"))
|
|
}
|
|
return $script:VerifierTasks
|
|
}
|
|
function script:New-VerifierTask {
|
|
param(
|
|
[string]$State = "Ready",
|
|
[string]$Execute = "",
|
|
[string]$Arguments = "",
|
|
[string]$UserId = "SYSTEM",
|
|
[bool]$Enabled = $true,
|
|
[switch]$Malformed,
|
|
[string]$Name = "MinerOpsAgent-Test"
|
|
)
|
|
if (-not $Execute) {
|
|
$Execute = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
|
}
|
|
if (-not $Arguments) {
|
|
$Arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f
|
|
(Join-Path $script:VerifierTarget "MinerOpsAgentService.ps1")
|
|
}
|
|
$task = [pscustomobject]@{
|
|
TaskName = $Name
|
|
TaskPath = "\"
|
|
State = $State
|
|
Settings = [pscustomobject]@{ Enabled = $Enabled }
|
|
Actions = @([pscustomobject]@{
|
|
Execute = $Execute
|
|
Arguments = $Arguments
|
|
})
|
|
Principal = [pscustomobject]@{
|
|
UserId = $UserId
|
|
LogonType = "ServiceAccount"
|
|
RunLevel = "Highest"
|
|
}
|
|
}
|
|
if ($Malformed) { $task.Actions = @() }
|
|
return $task
|
|
}
|
|
function script:Set-VerifierTask {
|
|
param([Parameter(Mandatory)]$Task)
|
|
$script:VerifierQueryFailure = ""
|
|
$script:VerifierTasks = @($Task)
|
|
}
|
|
function script:Start-ScheduledTask {
|
|
param([string]$TaskName, $ErrorAction)
|
|
$script:VerifierTasks[0].State = "Running"
|
|
}
|
|
function script:Stop-ScheduledTask {
|
|
param([string]$TaskName, $ErrorAction)
|
|
$script:VerifierTasks[0].State = "Ready"
|
|
}
|
|
function script:Start-Sleep { param([int]$Milliseconds) }
|
|
|
|
$strictName = "MinerOpsAgent-Test"
|
|
|
|
Invoke-BehaviorTest "running intent accepts exactly Running" {
|
|
Set-VerifierTask (New-VerifierTask -State "Running")
|
|
[void](Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true)
|
|
}
|
|
Invoke-BehaviorTest "running intent rejects Ready" {
|
|
Set-VerifierTask (New-VerifierTask -State "Ready")
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true }
|
|
}
|
|
Invoke-BehaviorTest "running intent rejects missing task" {
|
|
$script:VerifierTasks = @()
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true } "missing"
|
|
}
|
|
Invoke-BehaviorTest "running intent rejects query exception" {
|
|
$script:VerifierQueryFailure = "provider"
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $true } "query failed"
|
|
$script:VerifierQueryFailure = ""
|
|
}
|
|
Invoke-BehaviorTest "stopped intent accepts exactly Ready" {
|
|
Set-VerifierTask (New-VerifierTask -State "Ready")
|
|
[void](Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false)
|
|
}
|
|
Invoke-BehaviorTest "stopped intent stops Running then verifies Ready" {
|
|
Set-VerifierTask (New-VerifierTask -State "Running")
|
|
[void](Set-MinerOpsScheduledTaskState `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false)
|
|
[void](Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false)
|
|
}
|
|
Invoke-BehaviorTest "stopped intent rejects missing task" {
|
|
$script:VerifierTasks = @()
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "missing"
|
|
}
|
|
Invoke-BehaviorTest "stopped intent rejects provider exception" {
|
|
$script:VerifierQueryFailure = "provider"
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "query failed"
|
|
$script:VerifierQueryFailure = ""
|
|
}
|
|
Invoke-BehaviorTest "stopped intent rejects access denied" {
|
|
$script:VerifierQueryFailure = "access"
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "query failed"
|
|
$script:VerifierQueryFailure = ""
|
|
}
|
|
Invoke-BehaviorTest "stopped intent rejects empty state" {
|
|
Set-VerifierTask (New-VerifierTask -State "")
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "malformed"
|
|
}
|
|
Invoke-BehaviorTest "stopped intent rejects Disabled" {
|
|
Set-VerifierTask (New-VerifierTask -State "Disabled" -Enabled:$false)
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false } "disabled"
|
|
}
|
|
foreach ($invalidState in @("Unknown", "Queued")) {
|
|
Invoke-BehaviorTest "stopped intent rejects $invalidState" {
|
|
Set-VerifierTask (New-VerifierTask -State $invalidState)
|
|
Assert-Throws { Assert-MinerOpsScheduledTaskIntent `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget -ShouldRun $false }
|
|
}
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects malformed task object" {
|
|
Set-VerifierTask (New-VerifierTask -Malformed)
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "exactly one"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects multiple results" {
|
|
$script:VerifierTasks = @(
|
|
(New-VerifierTask),
|
|
(New-VerifierTask)
|
|
)
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "multiple"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects wrong executable" {
|
|
Set-VerifierTask (New-VerifierTask -Execute "C:\Windows\System32\cmd.exe")
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "executable"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects wrong script arguments" {
|
|
Set-VerifierTask (New-VerifierTask -Arguments '-NoProfile -File "C:\wrong.ps1"')
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "arguments"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects wrong principal" {
|
|
Set-VerifierTask (New-VerifierTask -UserId "BUILTIN\Users")
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "principal"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects missing TaskPath" {
|
|
$task = New-VerifierTask
|
|
$task.PSObject.Properties.Remove("TaskPath")
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "task path"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects null TaskPath" {
|
|
$task = New-VerifierTask
|
|
$task.TaskPath = $null
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "task path"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects empty TaskPath" {
|
|
$task = New-VerifierTask
|
|
$task.TaskPath = ""
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "task path"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects wrong-type TaskPath" {
|
|
$task = New-VerifierTask
|
|
$task.TaskPath = 1
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "task path"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects non-root TaskPath" {
|
|
$task = New-VerifierTask
|
|
$task.TaskPath = "\MinerOps\"
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "task path"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects missing Settings" {
|
|
$task = New-VerifierTask
|
|
$task.PSObject.Properties.Remove("Settings")
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "enabled"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects missing Enabled" {
|
|
$task = New-VerifierTask
|
|
$task.Settings.PSObject.Properties.Remove("Enabled")
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "enabled"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects Boolean false Enabled" {
|
|
Set-VerifierTask (New-VerifierTask -Enabled:$false)
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "enabled"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects string False Enabled" {
|
|
$task = New-VerifierTask
|
|
$task.Settings.Enabled = "False"
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "enabled"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier rejects numeric Enabled" {
|
|
$task = New-VerifierTask
|
|
$task.Settings.Enabled = 1
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget } "enabled"
|
|
}
|
|
Invoke-BehaviorTest "strict verifier accepts actual Boolean true Enabled" {
|
|
Set-VerifierTask (New-VerifierTask -Enabled:$true)
|
|
[void](Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget)
|
|
}
|
|
foreach ($malformation in @(
|
|
"numeric-state", "string-actions", "numeric-execute", "array-arguments",
|
|
"string-principal", "numeric-user", "numeric-logon", "numeric-runlevel"
|
|
)) {
|
|
Invoke-BehaviorTest "strict verifier rejects $malformation property" {
|
|
$task = New-VerifierTask
|
|
switch ($malformation) {
|
|
"numeric-state" { $task.State = 3 }
|
|
"string-actions" { $task.Actions = "powershell.exe" }
|
|
"numeric-execute" { $task.Actions[0].Execute = 1 }
|
|
"array-arguments" { $task.Actions[0].Arguments = @("arg1", "arg2") }
|
|
"string-principal" { $task.Principal = "SYSTEM" }
|
|
"numeric-user" { $task.Principal.UserId = 18 }
|
|
"numeric-logon" { $task.Principal.LogonType = 5 }
|
|
"numeric-runlevel" { $task.Principal.RunLevel = 1 }
|
|
}
|
|
Set-VerifierTask $task
|
|
Assert-Throws { Get-MinerOpsVerifiedScheduledTask `
|
|
-Name $strictName -TargetRoot $script:VerifierTarget }
|
|
}
|
|
}
|
|
|
|
try {
|
|
Invoke-BehaviorTest "normal successful transition" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Invoke-SimulatedTransition | Out-Null
|
|
$active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json
|
|
Assert-True ($active.agent_secret -ne "legacy-shared-secret") `
|
|
"Permanent secret was not installed."
|
|
Assert-True $sim.TaskRunning "Originally running task did not end running."
|
|
Assert-True (-not (Test-Path $sim.Recovery)) "Recovery was not cleaned."
|
|
Assert-True $sim.CleanupSafe "Cleanup occurred before success criteria."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "protected-before-secret writes" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $false
|
|
Invoke-SimulatedTransition | Out-Null
|
|
foreach ($leaf in @(
|
|
"agent-credential-transition.recovery.json",
|
|
"agent-config.json.pending"
|
|
)) {
|
|
$create = $sim.Events.IndexOf("create-empty:$leaf")
|
|
$protect = $sim.Events.IndexOf("protect:$leaf")
|
|
$verify = $sim.Events.IndexOf("verify-acl:$leaf")
|
|
$write = $sim.Events.IndexOf("write-secret:$leaf")
|
|
Assert-True (
|
|
$create -ge 0 -and $protect -gt $create -and
|
|
$verify -gt $protect -and $write -gt $verify
|
|
) "ACL ordering failed for $leaf."
|
|
}
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "ACL failure blocks redemption without leaked content" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.FailAclLeaf = "agent-credential-transition.recovery.json"
|
|
Assert-Throws { Invoke-SimulatedTransition } "ACL establishment failure"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Redemption proceeded after ACL failure."
|
|
Assert-True (-not (Test-Path $sim.Recovery)) `
|
|
"Insecure recovery artifact remained after handled ACL failure."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "insecure recovery is quarantined and blocked on two runs" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "recovery-written") { throw "stop" }
|
|
}
|
|
} "stop"
|
|
$malicious = Get-SimRecovery
|
|
$malicious.agent_secret = "attacker-provided-secret"
|
|
$malicious.agent_secret_hash = Get-MinerOpsSha256 `
|
|
-Value $malicious.agent_secret
|
|
$malicious.original_task_running = $false
|
|
[IO.File]::WriteAllText(
|
|
$sim.Recovery,
|
|
($malicious | ConvertTo-Json -Depth 50) + [Environment]::NewLine
|
|
)
|
|
$sim.Events.Clear()
|
|
$sim.Secure[(Get-SimPathKey $sim.Recovery)] = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined"
|
|
Assert-True (
|
|
$sim.Events -contains "quarantine:agent-credential-transition.recovery.json"
|
|
) "Insecure recovery was not moved out of authority."
|
|
Assert-True (
|
|
$sim.Events -notcontains "read:agent-credential-transition.recovery.json"
|
|
) "Insecure recovery contents were read."
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Recovery credential was used after an insecure read."
|
|
Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Second invocation used quarantined recovery authority."
|
|
Assert-True $sim.TaskRunning `
|
|
"Attacker-provided task intent changed the Scheduled Task."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "blocked recovery cannot be laundered by later ACL repair" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "recovery-written") { throw "stop" }
|
|
}
|
|
} "stop"
|
|
$malicious = Get-SimRecovery
|
|
$malicious.agent_secret = "attacker-laundering-secret"
|
|
$malicious.agent_secret_hash = Get-MinerOpsSha256 `
|
|
-Value $malicious.agent_secret
|
|
$malicious.original_task_running = $false
|
|
[IO.File]::WriteAllText(
|
|
$sim.Recovery,
|
|
($malicious | ConvertTo-Json -Depth 50) + [Environment]::NewLine
|
|
)
|
|
$sim.Secure[(Get-SimPathKey $sim.Recovery)] = $false
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "quarantine-blocker-written") {
|
|
throw "termination after quarantine blocker"
|
|
}
|
|
}
|
|
} "termination after quarantine blocker"
|
|
Assert-True (Test-Path -LiteralPath $sim.Recovery) `
|
|
"Injected interruption did not leave the authority pathname."
|
|
Assert-True (
|
|
@(Get-ChildItem -LiteralPath $sim.Target `
|
|
-Filter "minerops-credential-transition.blocked*.state").Count -gt 0
|
|
) "Durable blocker was not present before containment."
|
|
|
|
# Model an external/legacy ACL repair after interruption. ACL
|
|
# correctness must not override the already-durable blocked state.
|
|
$sim.Secure[(Get-SimPathKey $sim.Recovery)] = $true
|
|
|
|
Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined"
|
|
Assert-True (-not (Test-Path -LiteralPath $sim.Recovery)) `
|
|
"Second invocation retained repaired recovery authority."
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Repaired malicious recovery reached redemption on a later run."
|
|
Assert-True $sim.TaskRunning `
|
|
"Repaired malicious recovery changed Scheduled Task intent."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
foreach ($originallyRunning in @($true, $false)) {
|
|
$label = if ($originallyRunning) { "running" } else { "stopped" }
|
|
Invoke-BehaviorTest "lost response retry restores originally $label task" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $originallyRunning
|
|
$sim.ResponseMode = "lost-once"
|
|
Assert-Throws { Invoke-SimulatedTransition } "lost HTTP response"
|
|
Assert-True (-not $sim.TaskRunning) `
|
|
"Task should remain stopped after ambiguous redemption."
|
|
$recoverySecretHash = (Get-SimRecovery).agent_secret_hash
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True ($sim.TaskRunning -eq $originallyRunning) `
|
|
"Original task intent was not restored."
|
|
Assert-True ($sim.Redemptions.Count -eq 2) `
|
|
"Retry did not redeem twice."
|
|
Assert-True (
|
|
$sim.Redemptions[0].SecretHash -eq $recoverySecretHash -and
|
|
$sim.Redemptions[1].SecretHash -eq $recoverySecretHash
|
|
) "Retry changed the committed credential."
|
|
Remove-TransitionSimulation
|
|
}
|
|
}
|
|
|
|
Invoke-BehaviorTest "crash after server commit before pending converges" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "server-committed") { throw "crash-after-commit" }
|
|
}
|
|
} "crash-after-commit"
|
|
Assert-True (-not (Test-Path $sim.Pending)) `
|
|
"Pending unexpectedly existed at injected checkpoint."
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True $sim.TaskRunning "Retry did not restore task."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "valid protected pending resumes on rerun" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "pending-written") { throw "crash-after-pending" }
|
|
}
|
|
} "crash-after-pending"
|
|
$writesBefore = @($sim.Events | Where-Object {
|
|
$_ -eq "write-secret:agent-config.json.pending"
|
|
}).Count
|
|
Invoke-SimulatedTransition | Out-Null
|
|
$writesAfter = @($sim.Events | Where-Object {
|
|
$_ -eq "write-secret:agent-config.json.pending"
|
|
}).Count
|
|
Assert-True ($writesAfter -eq $writesBefore) `
|
|
"Valid pending config was overwritten instead of resumed."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "failure immediately before replacement converges" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "before-replace") { throw "before-replace" }
|
|
}
|
|
} "before-replace"
|
|
Assert-True (Test-Path $sim.Pending) "Pending config was lost."
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True $sim.TaskRunning "Retry did not finish replacement."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "pre-rename replacement failure converges" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.SwapMode = "fail-before-once"
|
|
Assert-Throws { Invoke-SimulatedTransition } "replacement failure"
|
|
Assert-True (Test-Path $sim.Pending) `
|
|
"Pending config was lost on replacement failure."
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True $sim.TaskRunning "Replacement retry did not restore task."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "post-replacement ambiguity converges" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.SwapMode = "move-then-fail-once"
|
|
Assert-Throws { Invoke-SimulatedTransition } "post-replacement"
|
|
Assert-True (-not (Test-Path $sim.Pending)) `
|
|
"Recoverable installation should consume pending config."
|
|
Assert-True (Test-Path $sim.Prior) `
|
|
"Known prior remnant was not retained."
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True $sim.TaskRunning `
|
|
"Already-replaced config was not recovered."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
foreach ($originallyRunning in @($true, $false)) {
|
|
$label = if ($originallyRunning) { "running" } else { "stopped" }
|
|
Invoke-BehaviorTest "active-missing pending recovery restores originally $label task" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $originallyRunning
|
|
$sim.SwapMode = "active-missing-pending-once"
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"ERROR_UNABLE_TO_MOVE_REPLACEMENT"
|
|
Assert-True (-not (Test-Path $sim.Config)) `
|
|
"Active config unexpectedly survived the injected partial state."
|
|
Assert-True (Test-Path $sim.Pending) `
|
|
"Authenticated pending config was lost."
|
|
$recovery = Get-SimRecovery
|
|
$first = $sim.Redemptions[0]
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True ($sim.Redemptions.Count -eq 2) `
|
|
"Active-missing recovery did not retry idempotent redemption."
|
|
Assert-True (
|
|
$sim.Redemptions[1].Token -eq $first.Token -and
|
|
$sim.Redemptions[1].SecretHash -eq $first.SecretHash -and
|
|
$sim.Redemptions[1].Token -eq $recovery.transition_token -and
|
|
$sim.Redemptions[1].SecretHash -eq $recovery.agent_secret_hash
|
|
) "Active-missing recovery changed token or permanent credential."
|
|
$active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json
|
|
Assert-True (
|
|
[string]$active.agent_id -eq
|
|
"00000000-0000-4000-8000-000000000111" -and
|
|
[string]$active.site_id -eq
|
|
"00000000-0000-4000-8000-000000000222" -and
|
|
[string]$active.agent_zone_id -eq
|
|
"00000000-0000-4000-8000-000000000333"
|
|
) "Active-missing recovery changed Agent routing."
|
|
Assert-True ($sim.TaskRunning -eq $originallyRunning) `
|
|
"Active-missing recovery lost original task intent."
|
|
Remove-TransitionSimulation
|
|
}
|
|
}
|
|
|
|
Invoke-BehaviorTest "known active-missing pending prior intermediate converges" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.SwapMode = "active-to-prior-fail-once"
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"ERROR_UNABLE_TO_MOVE_REPLACEMENT_2"
|
|
Assert-True (-not (Test-Path $sim.Config)) `
|
|
"Active config should be absent at the known rename midpoint."
|
|
Assert-True (Test-Path $sim.Pending) `
|
|
"Pending config should remain at the known rename midpoint."
|
|
Assert-True (Test-Path $sim.Prior) `
|
|
"Authenticated prior remnant should represent the moved active file."
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True $sim.TaskRunning `
|
|
"Known replacement midpoint did not converge."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "active-missing unknown pending fails closed" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Remove-Item -LiteralPath $sim.Config -Force
|
|
$sim.Secure.Remove((Get-SimPathKey -Path $sim.Config))
|
|
[IO.File]::WriteAllText(
|
|
$sim.Pending,
|
|
'{"agent_secret":"unknown-pending-secret"}'
|
|
)
|
|
$sim.Secure[(Get-SimPathKey -Path $sim.Pending)] = $false
|
|
$captured = & {
|
|
try { Invoke-SimulatedTransition } catch { $_.Exception.Message }
|
|
} *>&1 | Out-String
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Unknown active-missing pending state reached redemption."
|
|
Assert-True (-not $captured.Contains("unknown-pending-secret")) `
|
|
"Unknown active-missing pending secret reached output."
|
|
Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined"
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
foreach ($activeMissingMutation in @("malformed", "agent", "secret")) {
|
|
Invoke-BehaviorTest "active-missing $activeMissingMutation pending fails closed" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "pending-written") { throw "stop" }
|
|
}
|
|
} "stop"
|
|
Remove-Item -LiteralPath $sim.Config -Force
|
|
$sim.Secure.Remove((Get-SimPathKey -Path $sim.Config))
|
|
if ($activeMissingMutation -eq "malformed") {
|
|
[IO.File]::WriteAllText($sim.Pending, '{"agent_secret":')
|
|
}
|
|
else {
|
|
$pending = [IO.File]::ReadAllText($sim.Pending) |
|
|
ConvertFrom-Json
|
|
if ($activeMissingMutation -eq "agent") {
|
|
$pending.agent_id = "00000000-0000-4000-8000-000000009999"
|
|
}
|
|
else {
|
|
$pending.agent_secret = "mismatched-active-missing-secret"
|
|
}
|
|
Set-SimPendingObject -Value $pending
|
|
}
|
|
$calls = $sim.Redemptions.Count
|
|
Assert-Throws { Invoke-SimulatedTransition }
|
|
Assert-True ($sim.Redemptions.Count -eq $calls) `
|
|
"Invalid active-missing pending state reached redemption."
|
|
Remove-TransitionSimulation
|
|
}
|
|
}
|
|
|
|
Invoke-BehaviorTest "prior remnant alone cannot restore active authority" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "active-moved-to-prior") {
|
|
throw "stop-at-rename-midpoint"
|
|
}
|
|
}
|
|
} "stop-at-rename-midpoint"
|
|
Remove-Item -LiteralPath $sim.Pending -Force
|
|
$sim.Secure.Remove((Get-SimPathKey -Path $sim.Pending))
|
|
$calls = $sim.Redemptions.Count
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"no authenticated pending config"
|
|
Assert-True (-not (Test-Path -LiteralPath $sim.Config)) `
|
|
"Prior remnant was promoted into active authority."
|
|
Assert-True ($sim.Redemptions.Count -eq $calls) `
|
|
"Prior-only state reached redemption."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "directory junction fails before credential operations" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryReparse = $true
|
|
Assert-Throws { Invoke-SimulatedTransition } "reparse point"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Reparse directory reached redemption."
|
|
Assert-True (-not (Test-Path $sim.Recovery)) `
|
|
"Reparse directory received new secret state."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "directory repair quarantines authority and blocks three invocations" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "recovery-written") { throw "stop" }
|
|
}
|
|
} "stop"
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } "existing Agent directory was untrusted"
|
|
Assert-True ($sim.Events -contains "repair-directory") `
|
|
"Permissive target directory was not repaired."
|
|
Assert-True (
|
|
$sim.Events -contains "quarantine:agent-credential-transition.recovery.json"
|
|
) "Pre-repair recovery was promoted instead of quarantined."
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Directory repair promoted pre-existing recovery authority."
|
|
Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Later rerun promoted a pre-repair artifact."
|
|
Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Third invocation escaped the durable trust-epoch blocker."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "clean directory repair contains but never continues automatically" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"existing Agent directory was untrusted"
|
|
$repairIndex = $sim.Events.IndexOf("repair-directory")
|
|
$recoveryWriteIndex = $sim.Events.IndexOf(
|
|
"write-secret:agent-credential-transition.recovery.json"
|
|
)
|
|
Assert-True ($repairIndex -ge 0 -and $recoveryWriteIndex -lt 0) `
|
|
"Same-epoch repair created transition credential state."
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Same-epoch repair redeemed a transition token."
|
|
Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked"
|
|
Assert-Throws { Invoke-SimulatedTransition } "filesystem trust epoch is blocked"
|
|
Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) `
|
|
"Ordinary reruns removed the external trust blocker."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
foreach ($lateLeaf in @(
|
|
"agent-credential-transition.recovery.json",
|
|
"agent-config.json.pending"
|
|
)) {
|
|
Invoke-BehaviorTest "post-scan $lateLeaf stays non-authoritative" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "directory-containment-scan-complete") {
|
|
$latePath = Join-Path $sim.Target $lateLeaf
|
|
[IO.File]::WriteAllText(
|
|
$latePath,
|
|
'{"original_task_running":false,"agent_secret":"late"}'
|
|
)
|
|
$sim.Secure[(Get-SimPathKey $latePath)] = $true
|
|
}
|
|
}
|
|
} "existing Agent directory was untrusted"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Post-scan artifact reached redemption in the repair epoch."
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"filesystem trust epoch is blocked"
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"filesystem trust epoch is blocked"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Correct-ACL late artifact became authority on an ordinary rerun."
|
|
Assert-True $sim.TaskRunning `
|
|
"Late artifact changed durable Scheduled Task intent."
|
|
$active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json
|
|
Assert-True (
|
|
[string]$active.agent_secret -eq "legacy-shared-secret"
|
|
) "Late pending state was activated while the blocker existed."
|
|
Remove-TransitionSimulation
|
|
}
|
|
}
|
|
|
|
Invoke-BehaviorTest "explicit post-reboot trust reset republishes runtime then resumes" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"existing Agent directory was untrusted"
|
|
$sim.BootChanged = $true
|
|
$script:BlockerPresentBeforeRemoval = $false
|
|
Invoke-SimulatedTransition `
|
|
-ApproveFilesystemTrustResetAfterReboot `
|
|
-Checkpoint {
|
|
param($name)
|
|
if ($name -eq "before-trust-blocker-removal") {
|
|
$script:BlockerPresentBeforeRemoval = Test-Path `
|
|
-LiteralPath $sim.TrustBlocker
|
|
}
|
|
} | Out-Null
|
|
Assert-True $script:BlockerPresentBeforeRemoval `
|
|
"Parent removed the blocker before final convergence."
|
|
Assert-True (-not (Test-Path -LiteralPath $sim.TrustBlocker)) `
|
|
"Approved post-reboot reset did not remove the external blocker."
|
|
Assert-True (
|
|
$sim.Events -contains "trust-reset-runtime-publication"
|
|
) "Trust reset did not publish fresh runtime before continuing."
|
|
Assert-True ($sim.Redemptions.Count -eq 1) `
|
|
"Approved clean trust reset did not resume normal transition."
|
|
$publishIndex = $sim.Events.IndexOf("trust-reset-runtime-publication")
|
|
$restoreIndex = $sim.Events.IndexOf("task:True")
|
|
$removeIndex = $sim.Events.IndexOf(
|
|
"trust-blocker:removed-after-convergence"
|
|
)
|
|
Assert-True (
|
|
$publishIndex -ge 0 -and $restoreIndex -gt $publishIndex -and
|
|
$removeIndex -gt $restoreIndex
|
|
) "Trust blocker removal did not follow runtime and task convergence."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "installer failure leaves trust blocker present" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"existing Agent directory was untrusted"
|
|
$sim.BootChanged = $true
|
|
$sim.InstallerFailure = $true
|
|
Assert-Throws { Invoke-SimulatedTransition `
|
|
-ApproveFilesystemTrustResetAfterReboot } `
|
|
"runtime publication failure"
|
|
Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) `
|
|
"Installer failure removed the trust blocker."
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Installer failure reached credential redemption."
|
|
Assert-True (
|
|
$sim.TrustResetAuthorized -and
|
|
$sim.TrustResetOriginalTaskRunning
|
|
) "Installer failure lost the strictly validated task intent."
|
|
$sim.InstallerFailure = $false
|
|
Invoke-SimulatedTransition `
|
|
-ApproveFilesystemTrustResetAfterReboot | Out-Null
|
|
Assert-True $sim.TaskRunning `
|
|
"Installer-failure retry did not restore original running intent."
|
|
Assert-True (-not (Test-Path -LiteralPath $sim.TrustBlocker)) `
|
|
"Installer-failure retry did not converge blocker removal."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "published reset marker resumes without early deletion" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"existing Agent directory was untrusted"
|
|
$sim.BootChanged = $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition `
|
|
-ApproveFilesystemTrustResetAfterReboot `
|
|
-Checkpoint {
|
|
param($name)
|
|
if ($name -eq "recovery-written") {
|
|
throw "stop-after-reset-publication"
|
|
}
|
|
}
|
|
} "stop-after-reset-publication"
|
|
Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) `
|
|
"Interrupted parent removed the trust blocker."
|
|
Assert-True $sim.TrustResetRuntimePublished `
|
|
"Interrupted parent did not persist runtime publication state."
|
|
Invoke-SimulatedTransition `
|
|
-ApproveFilesystemTrustResetAfterReboot | Out-Null
|
|
Assert-True (-not (Test-Path -LiteralPath $sim.TrustBlocker)) `
|
|
"Resumed converged parent did not remove the blocker."
|
|
Assert-True ($sim.Redemptions.Count -eq 1) `
|
|
"Resumed trust reset did not complete exactly one redemption."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "wrong task definition cannot remove trust blocker" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"existing Agent directory was untrusted"
|
|
$sim.BootChanged = $true
|
|
$sim.TaskDefinitionInvalid = $true
|
|
Assert-Throws { Invoke-SimulatedTransition `
|
|
-ApproveFilesystemTrustResetAfterReboot } `
|
|
"strict task definition"
|
|
Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) `
|
|
"Wrong task definition indirectly removed the trust blocker."
|
|
Assert-True (
|
|
$sim.Events -notcontains "trust-reset-runtime-publication"
|
|
) "Wrong task definition reached runtime publication."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
foreach ($blockedTaskState in @("Disabled", "Unknown", "Queued")) {
|
|
Invoke-BehaviorTest "$blockedTaskState task cannot remove trust blocker" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.DirectoryTrusted = $false
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"existing Agent directory was untrusted"
|
|
$sim.BootChanged = $true
|
|
$sim.TaskStateOverride = $blockedTaskState
|
|
Assert-Throws { Invoke-SimulatedTransition `
|
|
-ApproveFilesystemTrustResetAfterReboot } `
|
|
"no valid pre-reset intent"
|
|
Assert-True (Test-Path -LiteralPath $sim.TrustBlocker) `
|
|
"$blockedTaskState task indirectly removed the trust blocker."
|
|
Remove-TransitionSimulation
|
|
}
|
|
}
|
|
|
|
Invoke-BehaviorTest "directory is revalidated and simulated swap is blocked" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.AttemptSwap = $true
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True (
|
|
$sim.Events -contains "directory-verified-before-active-move" -and
|
|
$sim.Events -contains "directory-verified-before-pending-move"
|
|
) "Directory invariant was not revalidated before both renames."
|
|
Assert-True (
|
|
$sim.Events -contains "swap-blocked-by-directory"
|
|
) "Simulated untrusted pathname swap was not blocked."
|
|
$active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json
|
|
Assert-True ([string]$active.agent_secret -ne "legacy-shared-secret") `
|
|
"Simulated swap prevented installation of the protected config."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "unknown replacement remnant is quarantined and blocks rerun" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$unknown = Join-Path $sim.Target "agent-config.json.unknown-replacement"
|
|
[IO.File]::WriteAllText($unknown, '{"agent_secret":"do-not-log-this"}')
|
|
$captured = & {
|
|
try { Invoke-SimulatedTransition } catch { $_.Exception.Message }
|
|
} *>&1 | Out-String
|
|
Assert-True (-not $captured.Contains("do-not-log-this")) `
|
|
"Unknown remnant contents reached output."
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Unknown replacement remnant reached redemption."
|
|
Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined"
|
|
Assert-True ($sim.Redemptions.Count -eq 0) `
|
|
"Quarantined unknown remnant was later trusted."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "wrong committed active config never converges" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "pending-moved-to-active") {
|
|
throw "stop-after-active-install"
|
|
}
|
|
}
|
|
} "stop-after-active-install"
|
|
$active = [IO.File]::ReadAllText($sim.Config) | ConvertFrom-Json
|
|
$active.agent_id = "00000000-0000-4000-8000-000000009999"
|
|
[IO.File]::WriteAllText(
|
|
$sim.Config,
|
|
($active | ConvertTo-Json -Depth 50) + [Environment]::NewLine
|
|
)
|
|
$calls = $sim.Redemptions.Count
|
|
Assert-Throws { Invoke-SimulatedTransition } "does not match this Agent"
|
|
Assert-True ($sim.Redemptions.Count -eq $calls) `
|
|
"Wrong active Agent identity reached redemption."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "malformed pending fails closed before redemption" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "pending-written") { throw "stop" }
|
|
}
|
|
} "stop"
|
|
[IO.File]::WriteAllText($sim.Pending, '{"agent_secret":')
|
|
$calls = $sim.Redemptions.Count
|
|
Assert-Throws { Invoke-SimulatedTransition } "malformed JSON"
|
|
Assert-True ($sim.Redemptions.Count -eq $calls) `
|
|
"Malformed pending state reached redemption."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
foreach ($mutation in @("agent", "secret")) {
|
|
Invoke-BehaviorTest "mismatched pending $mutation fails closed" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "pending-written") { throw "stop" }
|
|
}
|
|
} "stop"
|
|
$pending = [IO.File]::ReadAllText($sim.Pending) | ConvertFrom-Json
|
|
if ($mutation -eq "agent") {
|
|
$pending.agent_id = "00000000-0000-4000-8000-000000009999"
|
|
}
|
|
else {
|
|
$pending.agent_secret = "attacker-secret"
|
|
}
|
|
Set-SimPendingObject -Value $pending
|
|
$calls = $sim.Redemptions.Count
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"does not match protected recovery"
|
|
Assert-True ($sim.Redemptions.Count -eq $calls) `
|
|
"Mismatched pending state reached redemption."
|
|
Remove-TransitionSimulation
|
|
}
|
|
}
|
|
|
|
Invoke-BehaviorTest "insecure pending is quarantined and blocked on two runs" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "pending-written") { throw "stop" }
|
|
}
|
|
} "stop"
|
|
$sim.Events.Clear()
|
|
$sim.Secure[(Get-SimPathKey $sim.Pending)] = $false
|
|
$calls = $sim.Redemptions.Count
|
|
Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined"
|
|
Assert-True (
|
|
$sim.Events -notcontains "read:agent-config.json.pending"
|
|
) "Insecure pending contents were read."
|
|
Assert-True ($sim.Redemptions.Count -eq $calls) `
|
|
"Insecure pending state reached redemption."
|
|
Assert-Throws { Invoke-SimulatedTransition } "evidence is quarantined"
|
|
Assert-True ($sim.Redemptions.Count -eq $calls) `
|
|
"Second invocation used quarantined pending authority."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "task restart failure retains recovery and no success cleanup" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.FailTaskRestore = $true
|
|
Assert-Throws { Invoke-SimulatedTransition } "task restart failure"
|
|
Assert-True (Test-Path $sim.Recovery) `
|
|
"Recovery was deleted before task verification."
|
|
Assert-True (-not $sim.CleanupSafe) `
|
|
"Cleanup was marked safe after restart failure."
|
|
$sim.FailTaskRestore = $false
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True $sim.TaskRunning "Task recovery retry failed."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "strict task verification failure preserves committed recovery for retry" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $false
|
|
$sim.TaskVerificationFailureAfterCommit = $true
|
|
Assert-Throws { Invoke-SimulatedTransition } `
|
|
"strict task verification failure"
|
|
Assert-True (Test-Path -LiteralPath $sim.Recovery) `
|
|
"Recovery was deleted after strict final task verification failed."
|
|
Assert-True (-not $sim.CleanupSafe) `
|
|
"Task verification failure reached success cleanup."
|
|
$recovery = Get-SimRecovery
|
|
$first = $sim.Redemptions[0]
|
|
$sim.TaskVerificationFailureAfterCommit = $false
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True ($sim.Redemptions.Count -eq 2) `
|
|
"Task-repair retry did not repeat idempotent redemption."
|
|
Assert-True (
|
|
$sim.Redemptions[1].Token -eq $first.Token -and
|
|
$sim.Redemptions[1].SecretHash -eq $first.SecretHash -and
|
|
$sim.Redemptions[1].Token -eq $recovery.transition_token -and
|
|
$sim.Redemptions[1].SecretHash -eq $recovery.agent_secret_hash
|
|
) "Task-repair retry changed the committed credential."
|
|
Assert-True (-not (Test-Path -LiteralPath $sim.Recovery)) `
|
|
"Successful task-repair retry did not complete cleanup."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "cleanup interruption resumes from recovery token" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
Assert-Throws {
|
|
Invoke-SimulatedTransition -Checkpoint {
|
|
param($name)
|
|
if ($name -eq "before-recovery-cleanup") {
|
|
throw "cleanup-interrupted"
|
|
}
|
|
}
|
|
} "cleanup-interrupted"
|
|
Assert-True (-not (Test-Path $sim.Enrollment)) `
|
|
"Enrollment token file should already be removed at this checkpoint."
|
|
Assert-True (Test-Path $sim.Recovery) `
|
|
"Recovery must remain last during cleanup."
|
|
Invoke-SimulatedTransition | Out-Null
|
|
Assert-True (-not (Test-Path $sim.Recovery)) `
|
|
"Recovery-token retry did not finish cleanup."
|
|
Assert-True $sim.TaskRunning `
|
|
"Cleanup retry changed the intended task state."
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Invoke-BehaviorTest "ordinary output and errors omit token and secret" {
|
|
$sim = New-TransitionSimulation -OriginallyRunning $true
|
|
$sim.ResponseMode = "lost-once"
|
|
$captured = & {
|
|
try { Invoke-SimulatedTransition } catch { $_.Exception.Message }
|
|
} *>&1 | Out-String
|
|
$recovery = Get-SimRecovery
|
|
Assert-True (-not $captured.Contains([string]$recovery.agent_secret)) `
|
|
"Generated Agent secret appeared in output."
|
|
Assert-True (-not $captured.Contains([string]$recovery.transition_token)) `
|
|
"Transition token appeared in output."
|
|
Remove-TransitionSimulation
|
|
}
|
|
}
|
|
finally {
|
|
Remove-TransitionSimulation
|
|
}
|
|
|
|
Write-Host "$script:Passed credential-transition behavioral tests passed."
|