2026-06-18

fgtest-runner lab.sample.json

 {

  "FGT_HOST": "192.0.2.1",

  "FGT_SERIAL": "CHANGE_ME",

  "FGT_VDOM": "root",

  "CLIENT_IP": "192.0.2.10",

  "CLIENT_MASK": "255.255.255.255",

  "URLFILTER_ID": "1",

  "TEST_ADDR_NAME": "zo3_test_client",

  "CURL_TIMEOUT_SEC": "10"

}


fgtest-runner fgt_webfilter_curl.yaml

 SuiteID: fgt_webfilter_curl

Description: FortiGate WebFilter setting patterns with curl fact collection. No pass/fail judgment.


Targets:

  FGT:

    Type: FortiGate

    Host: %%FGT_HOST%%

    Vdom: %%FGT_VDOM%%

    ExpectedSerial: %%FGT_SERIAL%%

    TokenEnv: FGT_TOKEN

    SkipCertificateCheck: true


PreCleanup:

  - ID: delete_test_address_old

    Target: FGT

    Type: API

    Method: DELETE

    Endpoint: /api/v2/cmdb/firewall/address/%%TEST_ADDR_NAME%%


Setup:

  - ID: create_test_address

    Target: FGT

    Type: API

    Method: POST

    Endpoint: /api/v2/cmdb/firewall/address

    PayloadFormat: raw_json

    Payload: |

      {

        "name": "%%TEST_ADDR_NAME%%",

        "subnet": "%%CLIENT_IP%% %%CLIENT_MASK%%"

      }


# Settings are data rows. The runner applies each row to ApplySetting, runs all Requests, then cleans up.

Settings:

  - ID: wf_block_example_simple

    wf_url_pattern: "example.com/malware"

    wf_type: "simple"

    wf_action: "block"


  - ID: wf_allow_example_simple

    wf_url_pattern: "example.com/business"

    wf_type: "simple"

    wf_action: "allow"


  - ID: wf_monitor_example_wildcard

    wf_url_pattern: "*.example.org"

    wf_type: "wildcard"

    wf_action: "monitor"


ApplySetting:

  - ID: apply_urlfilter_entry

    Target: FGT

    Type: API

    Method: PUT

    Endpoint: /api/v2/cmdb/webfilter/urlfilter/%%URLFILTER_ID%%

    PayloadFormat: raw_json

    Payload: |

      {

        "entries": [

          {

            "id": 1,

            "url": "%%wf_url_pattern%%",

            "type": "%%wf_type%%",

            "action": "%%wf_action%%",

            "status": "enable"

          }

        ]

      }

    WaitAfterMs: 1000


Requests:

  - ID: curl_http_malware

    Target: LOCAL

    Type: Command

    Command: curl.exe

    TimeoutMs: 30000

    Args:

      - "-i"

      - "-L"

      - "--max-time"

      - "%%CURL_TIMEOUT_SEC%%"

      - "http://example.com/malware"


  - ID: curl_https_malware_insecure

    Target: LOCAL

    Type: Command

    Command: curl.exe

    TimeoutMs: 30000

    Args:

      - "-k"

      - "-i"

      - "-L"

      - "--max-time"

      - "%%CURL_TIMEOUT_SEC%%"

      - "https://example.com/malware"


  - ID: curl_http_business

    Target: LOCAL

    Type: Command

    Command: curl.exe

    TimeoutMs: 30000

    Args:

      - "-i"

      - "-L"

      - "--max-time"

      - "%%CURL_TIMEOUT_SEC%%"

      - "http://example.com/business"


  - ID: get_fgt_system_status_after_request

    Target: FGT

    Type: API

    Method: GET

    Endpoint: /api/v2/monitor/system/status


CleanupSetting:

  - ID: clear_urlfilter_entries

    Target: FGT

    Type: API

    Method: PUT

    Endpoint: /api/v2/cmdb/webfilter/urlfilter/%%URLFILTER_ID%%

    PayloadFormat: raw_json

    Payload: |

      {

        "entries": []

      }

    WaitAfterMs: 500


Teardown:

  - ID: delete_test_address

    Target: FGT

    Type: API

    Method: DELETE

    Endpoint: /api/v2/cmdb/firewall/address/%%TEST_ADDR_NAME%%


fgtest-runner run.ps1

 #requires -Version 7.0

<#

.SYNOPSIS

  Minimal network test runner for lab use.


.DESCRIPTION

  YAML 1 file = 1 test suite.

  The runner changes target settings, executes requests, records facts, and cleans up.

  It does not judge pass/fail.


  Flow:

    PreCleanup (optional, reverse order)

    Setup

    For each Settings item:

      CleanupSetting (reverse order, pre-clean)

      ApplySetting

      Requests

      CleanupSetting (reverse order)

    Teardown (reverse order, always)


.NOTES

  Required module: powershell-yaml

  Install is attempted automatically for CurrentUser if missing.

#>


[CmdletBinding()]

param(

    [Parameter(Mandatory = $false)]

    [string]$Scenario = ".\tests\fgt_webfilter_curl.yaml",


    [Parameter(Mandatory = $false)]

    [string]$ScenarioDir,


    [Parameter(Mandatory = $false)]

    [string]$EnvFile = ".\env\lab.json",


    [Parameter(Mandatory = $false)]

    [string]$OutDir = ".\results",


    [Parameter(Mandatory = $false)]

    [switch]$SkipSerialCheck,


    [Parameter(Mandatory = $false)]

    [switch]$NoModuleInstall

)


Set-StrictMode -Version Latest

$ErrorActionPreference = "Stop"


function Ensure-YamlModule {

    if (Get-Module -ListAvailable -Name powershell-yaml) {

        Import-Module powershell-yaml -ErrorAction Stop

        return

    }


    if ($NoModuleInstall) {

        throw "Required module 'powershell-yaml' is not installed. Install it or rerun without -NoModuleInstall."

    }


    Write-Host "Installing powershell-yaml for CurrentUser..."

    Install-Module powershell-yaml -Scope CurrentUser -Force -AllowClobber

    Import-Module powershell-yaml -ErrorAction Stop

}


function ConvertTo-HashtableRecursive {

    param([Parameter(ValueFromPipeline = $true)]$InputObject)


    process {

        if ($null -eq $InputObject) { return $null }


        if ($InputObject -is [System.Collections.IDictionary]) {

            $h = [ordered]@{}

            foreach ($key in $InputObject.Keys) {

                $h[[string]$key] = ConvertTo-HashtableRecursive $InputObject[$key]

            }

            return $h

        }


        if ($InputObject -is [pscustomobject]) {

            $h = [ordered]@{}

            foreach ($p in $InputObject.PSObject.Properties) {

                $h[$p.Name] = ConvertTo-HashtableRecursive $p.Value

            }

            return $h

        }


        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {

            $arr = @()

            foreach ($item in $InputObject) {

                $arr += ,(ConvertTo-HashtableRecursive $item)

            }

            return $arr

        }


        return $InputObject

    }

}


function Load-JsonAsHashtable {

    param([Parameter(Mandatory = $true)][string]$Path)


    if (-not (Test-Path -LiteralPath $Path)) {

        throw "Env file not found: $Path"

    }


    $json = Get-Content -LiteralPath $Path -Raw -Encoding UTF8

    if ([string]::IsNullOrWhiteSpace($json)) { return [ordered]@{} }


    $obj = $json | ConvertFrom-Json -Depth 100

    return ConvertTo-HashtableRecursive $obj

}


function Get-VarValue {

    param(

        [Parameter(Mandatory = $true)][string]$Name,

        [Parameter(Mandatory = $true)][hashtable]$Vars,

        [Parameter(Mandatory = $false)]$DefaultValue = ""

    )


    if ($Name.StartsWith("ENV_")) {

        $envName = $Name.Substring(4)

        $envValue = [Environment]::GetEnvironmentVariable($envName)

        if ($null -ne $envValue) { return $envValue }

        return $DefaultValue

    }


    if ($Vars.ContainsKey($Name) -and $null -ne $Vars[$Name]) {

        return [string]$Vars[$Name]

    }


    return $DefaultValue

}


function Render-String {

    param(

        [AllowNull()][string]$Text,

        [Parameter(Mandatory = $true)][hashtable]$Vars

    )


    if ($null -eq $Text) { return $null }


    # %%?name%% means non-string JSON literal. If undefined, use literal null.

    $result = [regex]::Replace($Text, '%%\?([A-Za-z0-9_]+)%%', {

        param($m)

        $name = $m.Groups[1].Value

        return Get-VarValue -Name $name -Vars $Vars -DefaultValue "null"

    })


    # %%name%% means string replacement. If undefined, empty string.

    $result = [regex]::Replace($result, '%%([A-Za-z0-9_]+)%%', {

        param($m)

        $name = $m.Groups[1].Value

        return Get-VarValue -Name $name -Vars $Vars -DefaultValue ""

    })


    return $result

}


function Render-Object {

    param(

        [AllowNull()]$InputObject,

        [Parameter(Mandatory = $true)][hashtable]$Vars

    )


    if ($null -eq $InputObject) { return $null }


    if ($InputObject -is [string]) {

        return Render-String -Text $InputObject -Vars $Vars

    }


    if ($InputObject -is [System.Collections.IDictionary]) {

        $h = [ordered]@{}

        foreach ($key in $InputObject.Keys) {

            $h[$key] = Render-Object -InputObject $InputObject[$key] -Vars $Vars

        }

        return $h

    }


    if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {

        $arr = @()

        foreach ($item in $InputObject) {

            $arr += ,(Render-Object -InputObject $item -Vars $Vars)

        }

        return $arr

    }


    return $InputObject

}


function Merge-Vars {

    param(

        [Parameter(Mandatory = $true)][hashtable]$Base,

        [Parameter(Mandatory = $false)]$Extra

    )


    $merged = [ordered]@{}

    foreach ($key in $Base.Keys) { $merged[$key] = $Base[$key] }


    if ($null -ne $Extra) {

        $extraHash = ConvertTo-HashtableRecursive $Extra

        if ($extraHash -is [System.Collections.IDictionary]) {

            foreach ($key in $extraHash.Keys) { $merged[$key] = $extraHash[$key] }

        }

    }

    return $merged

}


function Get-StepArray {

    param(

        [Parameter(Mandatory = $true)]$Suite,

        [Parameter(Mandatory = $true)][string]$Name

    )


    if (-not $Suite.ContainsKey($Name) -or $null -eq $Suite[$Name]) { return @() }


    if ($Suite[$Name] -is [System.Collections.IEnumerable] -and $Suite[$Name] -isnot [string] -and $Suite[$Name] -isnot [System.Collections.IDictionary]) {

        return @($Suite[$Name])

    }


    return @($Suite[$Name])

}


function Get-ReversedArray {

    param([array]$Items)


    $copy = @($Items)

    [array]::Reverse($copy)

    return $copy

}


function Add-FortiGateVdomQuery {

    param(

        [Parameter(Mandatory = $true)][string]$Endpoint,

        [Parameter(Mandatory = $false)][string]$Vdom

    )


    if ([string]::IsNullOrWhiteSpace($Vdom)) { return $Endpoint }

    if ($Endpoint -match '(\?|&)vdom=') { return $Endpoint }


    $sep = '?'

    if ($Endpoint.Contains('?')) { $sep = '&' }

    return "$Endpoint${sep}vdom=$Vdom"

}


function Get-TargetBaseUri {

    param([Parameter(Mandatory = $true)]$Target)


    if (-not $Target.ContainsKey('Host')) { throw "Target requires Host." }

    $scheme = "https"

    if ($Target.ContainsKey('Scheme') -and -not [string]::IsNullOrWhiteSpace($Target.Scheme)) {

        $scheme = [string]$Target.Scheme

    }

    return "$scheme://$($Target.Host)"

}


function New-StepResultBase {

    param(

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)]$Step,

        [Parameter(Mandatory = $false)][string]$Phase,

        [Parameter(Mandatory = $false)][string]$TargetName,

        [Parameter(Mandatory = $false)][string]$TargetType

    )


    return [ordered]@{

        suiteId    = $SuiteId

        settingId  = $SettingId

        phase      = $Phase

        stepId     = if ($Step.ContainsKey('ID')) { $Step.ID } else { "" }

        target     = $TargetName

        targetType = $TargetType

        type       = if ($Step.ContainsKey('Type')) { $Step.Type } else { "" }

        startedAt  = (Get-Date).ToString("o")

        endedAt    = $null

        durationMs = $null

        ok         = $false

        error      = $null

    }

}


function Write-StepResult {

    param(

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $true)]$Result

    )


    $setting = if ([string]::IsNullOrWhiteSpace($Result.settingId)) { "_global" } else { $Result.settingId }

    $phase = if ([string]::IsNullOrWhiteSpace($Result.phase)) { "phase" } else { $Result.phase }

    $step = if ([string]::IsNullOrWhiteSpace($Result.stepId)) { "step" } else { $Result.stepId }

    $safeName = ($phase + "__" + $setting + "__" + $step) -replace '[^A-Za-z0-9_.-]', '_'


    $stepDir = Join-Path $RunDir "steps"

    New-Item -ItemType Directory -Force -Path $stepDir | Out-Null

    $path = Join-Path $stepDir ("{0:0000}__{1}.json" -f $script:StepCounter, $safeName)

    $script:StepCounter++


    $Result | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $path -Encoding UTF8

}


function Invoke-ApiStep {

    param(

        [Parameter(Mandatory = $true)]$Step,

        [Parameter(Mandatory = $true)]$Target,

        [Parameter(Mandatory = $true)][string]$TargetName,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $false)][switch]$Allow404

    )


    $targetType = if ($Target.ContainsKey('Type')) { [string]$Target.Type } else { "Generic" }

    $result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $Step -Phase $Phase -TargetName $TargetName -TargetType $targetType

    $sw = [System.Diagnostics.Stopwatch]::StartNew()


    try {

        if (-not $Step.ContainsKey('Method')) { throw "API step requires Method." }

        if (-not $Step.ContainsKey('Endpoint')) { throw "API step requires Endpoint." }


        $method = [string]$Step.Method

        $endpoint = [string]$Step.Endpoint

        if ($targetType -eq "FortiGate" -and $Target.ContainsKey('Vdom')) {

            $endpoint = Add-FortiGateVdomQuery -Endpoint $endpoint -Vdom ([string]$Target.Vdom)

        }

        $uri = (Get-TargetBaseUri -Target $Target) + $endpoint


        $headers = @{}

        if ($Target.ContainsKey('TokenEnv') -and -not [string]::IsNullOrWhiteSpace($Target.TokenEnv)) {

            $token = [Environment]::GetEnvironmentVariable([string]$Target.TokenEnv)

            if ([string]::IsNullOrWhiteSpace($token)) { throw "Environment variable '$($Target.TokenEnv)' is empty or undefined." }

            $headers["Authorization"] = "Bearer $token"

        }

        if ($Target.ContainsKey('Headers') -and $Target.Headers -is [System.Collections.IDictionary]) {

            foreach ($key in $Target.Headers.Keys) { $headers[$key] = [string]$Target.Headers[$key] }

        }


        $body = $null

        $contentType = "application/json"

        if ($Step.ContainsKey('PayloadFormat') -and $null -ne $Step.PayloadFormat) {

            switch ([string]$Step.PayloadFormat) {

                "native" {

                    $body = ($Step.Payload | ConvertTo-Json -Depth 100 -Compress)

                    $contentType = "application/json"

                }

                "raw_json" {

                    $body = [string]$Step.Payload

                    $null = $body | ConvertFrom-Json -Depth 100

                    $contentType = "application/json"

                }

                "raw_xml" {

                    $body = [string]$Step.Payload

                    $null = [xml]$body

                    $contentType = "application/xml"

                }

                "text_list" {

                    $body = ($Step.Payload -join "`n")

                    $contentType = "text/plain"

                }

                default { throw "Unsupported PayloadFormat: $($Step.PayloadFormat)" }

            }

        }


        if ($Step.ContainsKey('ContentType') -and -not [string]::IsNullOrWhiteSpace($Step.ContentType)) {

            $contentType = [string]$Step.ContentType

        }


        $params = @{

            Uri         = $uri

            Method      = $method

            Headers     = $headers

            TimeoutSec  = if ($Step.ContainsKey('TimeoutSec')) { [int]$Step.TimeoutSec } else { 60 }

            ErrorAction = 'Stop'

        }

        if ($null -ne $body) {

            $params['Body'] = $body

            $params['ContentType'] = $contentType

        }

        if ($Target.ContainsKey('SkipCertificateCheck') -and [bool]$Target.SkipCertificateCheck) {

            $params['SkipCertificateCheck'] = $true

        }


        $resp = Invoke-WebRequest @params


        $result.method = $method

        $result.endpoint = $endpoint

        $result.statusCode = [int]$resp.StatusCode

        $result.responseBody = [string]$resp.Content

        $result.ok = $true

    }

    catch {

        $statusCode = $null

        $responseBody = $null


        if ($_.Exception.Response) {

            try { $statusCode = [int]$_.Exception.Response.StatusCode } catch {}

            try {

                $stream = $_.Exception.Response.GetResponseStream()

                if ($null -ne $stream) {

                    $reader = [System.IO.StreamReader]::new($stream)

                    $responseBody = $reader.ReadToEnd()

                }

            } catch {}

            try {

                if ($_.Exception.Response.Content) {

                    $responseBody = $_.Exception.Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()

                }

            } catch {}

        }


        $result.statusCode = $statusCode

        $result.responseBody = $responseBody

        $result.error = $_.Exception.Message

        if ($Allow404 -and $statusCode -eq 404) {

            $result.ok = $true

            $result.ignored = "404 allowed"

        } else {

            $result.ok = $false

        }

    }

    finally {

        $sw.Stop()

        $result.endedAt = (Get-Date).ToString("o")

        $result.durationMs = [int64]$sw.ElapsedMilliseconds

        Write-StepResult -RunDir $RunDir -Result $result

    }


    if (-not $result.ok) {

        throw "API step failed: phase=$Phase setting=$SettingId step=$($result.stepId) status=$($result.statusCode) error=$($result.error)"

    }

}


function Invoke-CommandStep {

    param(

        [Parameter(Mandatory = $true)]$Step,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir

    )


    $result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $Step -Phase $Phase -TargetName "LOCAL" -TargetType "Local"

    $sw = [System.Diagnostics.Stopwatch]::StartNew()


    try {

        if (-not $Step.ContainsKey('Command')) { throw "Command step requires Command." }

        $command = [string]$Step.Command

        $args = @()

        if ($Step.ContainsKey('Args') -and $null -ne $Step.Args) { $args = @($Step.Args) }


        $psi = [System.Diagnostics.ProcessStartInfo]::new()

        $psi.FileName = $command

        foreach ($a in $args) { [void]$psi.ArgumentList.Add([string]$a) }

        $psi.RedirectStandardOutput = $true

        $psi.RedirectStandardError = $true

        $psi.UseShellExecute = $false

        $psi.CreateNoWindow = $true


        $proc = [System.Diagnostics.Process]::new()

        $proc.StartInfo = $psi

        [void]$proc.Start()


        $stdoutTask = $proc.StandardOutput.ReadToEndAsync()

        $stderrTask = $proc.StandardError.ReadToEndAsync()

        $timeoutMs = if ($Step.ContainsKey('TimeoutMs')) { [int]$Step.TimeoutMs } else { 60000 }

        $finished = $proc.WaitForExit($timeoutMs)

        if (-not $finished) {

            try { $proc.Kill($true) } catch {}

            $result.timedOut = $true

        }


        $stdout = $stdoutTask.GetAwaiter().GetResult()

        $stderr = $stderrTask.GetAwaiter().GetResult()


        $result.command = $command

        $result.args = $args

        $result.exitCode = if ($finished) { [int]$proc.ExitCode } else { $null }

        $result.stdout = $stdout

        $result.stderr = $stderr

        $result.ok = $finished


        # This runner records facts only. Non-zero exit code is not pass/fail.

        # Process timeout is treated as runner failure because stdout/stderr may be incomplete.

    }

    catch {

        $result.ok = $false

        $result.error = $_.Exception.Message

    }

    finally {

        $sw.Stop()

        $result.endedAt = (Get-Date).ToString("o")

        $result.durationMs = [int64]$sw.ElapsedMilliseconds

        Write-StepResult -RunDir $RunDir -Result $result

    }


    if (-not $result.ok) {

        throw "Command step failed or timed out: phase=$Phase setting=$SettingId step=$($result.stepId) error=$($result.error)"

    }

}


function Invoke-Step {

    param(

        [Parameter(Mandatory = $true)]$RawStep,

        [Parameter(Mandatory = $true)][hashtable]$Vars,

        [Parameter(Mandatory = $true)]$Targets,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $false)][switch]$Allow404

    )


    $step = Render-Object -InputObject $RawStep -Vars $Vars

    if (-not $step.ContainsKey('Type')) { throw "Step requires Type. Phase=$Phase" }


    switch ([string]$step.Type) {

        "API" {

            $targetName = if ($step.ContainsKey('Target') -and -not [string]::IsNullOrWhiteSpace($step.Target)) { [string]$step.Target } else { "FGT" }

            if (-not $Targets.ContainsKey($targetName)) { throw "Target '$targetName' not found." }

            Invoke-ApiStep -Step $step -Target $Targets[$targetName] -TargetName $targetName -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir -Allow404:$Allow404

        }

        "Command" {

            Invoke-CommandStep -Step $step -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir

        }

        "Wait" {

            $ms = if ($step.ContainsKey('Ms')) { [int]$step.Ms } elseif ($step.ContainsKey('WaitAfterMs')) { [int]$step.WaitAfterMs } else { 1000 }

            $result = New-StepResultBase -SuiteId $SuiteId -SettingId $SettingId -Step $step -Phase $Phase -TargetName "LOCAL" -TargetType "Local"

            Start-Sleep -Milliseconds $ms

            $result.ms = $ms

            $result.ok = $true

            $result.endedAt = (Get-Date).ToString("o")

            $result.durationMs = $ms

            Write-StepResult -RunDir $RunDir -Result $result

        }

        default { throw "Unsupported step Type: $($step.Type)" }

    }


    if ($step.ContainsKey('WaitAfterMs') -and [int]$step.WaitAfterMs -gt 0) {

        Start-Sleep -Milliseconds ([int]$step.WaitAfterMs)

    }

}


function Invoke-StepList {

    param(

        [Parameter(Mandatory = $true)][array]$Steps,

        [Parameter(Mandatory = $true)][hashtable]$Vars,

        [Parameter(Mandatory = $true)]$Targets,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $false)][string]$SettingId,

        [Parameter(Mandatory = $true)][string]$Phase,

        [Parameter(Mandatory = $true)][string]$RunDir,

        [Parameter(Mandatory = $false)][switch]$Reverse,

        [Parameter(Mandatory = $false)][switch]$Allow404

    )


    $items = @($Steps)

    if ($Reverse) { $items = Get-ReversedArray -Items $items }


    foreach ($s in $items) {

        Invoke-Step -RawStep $s -Vars $Vars -Targets $Targets -SuiteId $SuiteId -SettingId $SettingId -Phase $Phase -RunDir $RunDir -Allow404:$Allow404

    }

}


function Test-TargetSafety {

    param(

        [Parameter(Mandatory = $true)]$Targets,

        [Parameter(Mandatory = $true)][string]$SuiteId,

        [Parameter(Mandatory = $true)][string]$RunDir

    )


    foreach ($name in $Targets.Keys) {

        $t = $Targets[$name]

        if (-not $t.ContainsKey('ExpectedSerial') -or [string]::IsNullOrWhiteSpace($t.ExpectedSerial)) { continue }

        if ($SkipSerialCheck) { continue }

        if ($t.ExpectedSerial -eq "CHANGE_ME") { throw "Target '$name' ExpectedSerial is CHANGE_ME. Set the real serial or use -SkipSerialCheck deliberately." }


        if ($t.ContainsKey('Type') -and $t.Type -eq "FortiGate") {

            $step = [ordered]@{

                ID       = "safety_check_$name"

                Type     = "API"

                Target   = $name

                Method   = "GET"

                Endpoint = "/api/v2/monitor/system/status"

            }

            Invoke-ApiStep -Step $step -Target $t -TargetName $name -SuiteId $SuiteId -SettingId "" -Phase "SafetyCheck" -RunDir $RunDir


            $latest = Get-ChildItem -Path (Join-Path $RunDir "steps") -Filter "*.json" | Sort-Object Name -Descending | Select-Object -First 1

            $obj = Get-Content -LiteralPath $latest.FullName -Raw | ConvertFrom-Json -Depth 100

            if ([string]$obj.responseBody -notlike "*$($t.ExpectedSerial)*") {

                throw "Safety check failed. Target '$name' response did not contain ExpectedSerial '$($t.ExpectedSerial)'."

            }

        }

    }

}


function Invoke-ScenarioFile {

    param([Parameter(Mandatory = $true)][string]$Path)


    if (-not (Test-Path -LiteralPath $Path)) { throw "Scenario file not found: $Path" }


    $envVars = Load-JsonAsHashtable -Path $EnvFile

    $rawYaml = Get-Content -LiteralPath $Path -Raw -Encoding UTF8

    $renderedYaml = Render-String -Text $rawYaml -Vars $envVars

    $suite = ConvertTo-HashtableRecursive (ConvertFrom-Yaml -Yaml $renderedYaml)


    if (-not $suite.ContainsKey('SuiteID')) { throw "Scenario requires SuiteID: $Path" }

    if (-not $suite.ContainsKey('Targets')) { throw "Scenario requires Targets: $Path" }


    $suiteId = [string]$suite.SuiteID

    $timestamp = Get-Date -Format "yyyyMMdd_HHmmss"

    $runDir = Join-Path (Join-Path $OutDir $suiteId) $timestamp

    New-Item -ItemType Directory -Force -Path $runDir | Out-Null

    $script:StepCounter = 1


    $targets = $suite.Targets

    $settings = Get-StepArray -Suite $suite -Name "Settings"

    if ($settings.Count -eq 0) { $settings = @([ordered]@{ ID = "default" }) }


    $summary = [ordered]@{

        suiteId     = $suiteId

        scenario    = (Resolve-Path -LiteralPath $Path).Path

        envFile     = (Resolve-Path -LiteralPath $EnvFile).Path

        startedAt   = (Get-Date).ToString("o")

        endedAt     = $null

        result      = "Running"

        settings    = @($settings | ForEach-Object { if ($_ -is [System.Collections.IDictionary] -and $_.ContainsKey('ID')) { $_.ID } else { "" } })

        runDir      = (Resolve-Path -LiteralPath $runDir).Path

        error       = $null

    }


    try {

        Test-TargetSafety -Targets $targets -SuiteId $suiteId -RunDir $runDir


        Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "PreCleanup") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "PreCleanup" -RunDir $runDir -Reverse -Allow404

        Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Setup") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "Setup" -RunDir $runDir


        foreach ($setting in $settings) {

            $settingId = if ($setting -is [System.Collections.IDictionary] -and $setting.ContainsKey('ID')) { [string]$setting.ID } else { "default" }

            $vars = Merge-Vars -Base $envVars -Extra $setting


            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "CleanupSetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "CleanupSettingBefore" -RunDir $runDir -Reverse -Allow404

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "ApplySetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "ApplySetting" -RunDir $runDir

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Requests") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "Requests" -RunDir $runDir

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "CleanupSetting") -Vars $vars -Targets $targets -SuiteId $suiteId -SettingId $settingId -Phase "CleanupSettingAfter" -RunDir $runDir -Reverse -Allow404

        }


        $summary.result = "Completed"

    }

    catch {

        $summary.result = "Failed"

        $summary.error = $_.Exception.Message

        throw

    }

    finally {

        try {

            Invoke-StepList -Steps (Get-StepArray -Suite $suite -Name "Teardown") -Vars $envVars -Targets $targets -SuiteId $suiteId -SettingId "" -Phase "Teardown" -RunDir $runDir -Reverse -Allow404

        }

        catch {

            $summary.result = "FailedDuringTeardown"

            $summary.error = if ($summary.error) { $summary.error + " / Teardown: " + $_.Exception.Message } else { "Teardown: " + $_.Exception.Message }

        }


        $summary.endedAt = (Get-Date).ToString("o")

        $summaryPath = Join-Path $runDir "summary.json"

        $summary | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $summaryPath -Encoding UTF8

        Write-Host "Result: $summaryPath"

    }

}


Ensure-YamlModule


if ($ScenarioDir) {

    $files = Get-ChildItem -LiteralPath $ScenarioDir -Filter "*.yaml" | Sort-Object Name

    foreach ($f in $files) { Invoke-ScenarioFile -Path $f.FullName }

} else {

    Invoke-ScenarioFile -Path $Scenario

}