Scripts/Test-ConnectivitySuite.ps1

<#
.SYNOPSIS
    Runs the staged connectivity fault isolation sequence defined in
    networking/connectivity-fault-isolation.md, automated end to end.
 
.DESCRIPTION
    Executes the six-stage isolation sequence (device self-test, gateway,
    internal reachability, internet egress, DNS, and service port checks),
    stopping logically at the first confirmed failure and reporting a clear
    fault boundary. Also performs traceroute analysis and optional specific
    port/service reachability tests. Designed to produce a ready-to-attach
    escalation package matching the report format defined in
    networking/connectivity-fault-isolation.md.
 
    This script is read-only. It makes no configuration changes. It is safe
    to run on any Windows 10/11 workstation without administrative
    privileges.
 
.NOTES
    Author: it-support-ops repository
    Run as: Standard user (no elevation required)
    Compatibility: Windows 10, Windows 11, PowerShell 5.1+
    Output: Console output + log file in user's Documents folder
 
.EXAMPLE
    .\Test-ConnectivitySuite.ps1
 
    Runs the full six-stage connectivity test and saves output to:
    $env:USERPROFILE\Documents\IT-Diagnostics\ConnectivitySuite_<timestamp>.txt
 
.EXAMPLE
    .\Test-ConnectivitySuite.ps1 -InternalHost "fileserver.company.local" -ServicePort 445
 
    Runs the full sequence and additionally tests reachability of an
    internal host and a specific service port (e.g. SMB file sharing).
#>


[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool by design: report content goes through Write-ReportLine (pipe-safe); Write-Host is used only for colored status messages meant for a human reading the console.')]
[CmdletBinding()]
param(
    # Optional custom output path. Defaults to Documents\IT-Diagnostics.
    [Parameter(Mandatory = $false)]
    [string]$OutputPath,

    # Optional internal host to test at Stage 3 (Internal Network Reachability).
    # If not supplied, Stage 3 is noted as skipped rather than guessing an
    # internal address that may not exist in this environment.
    [Parameter(Mandatory = $false)]
    [string]$InternalHost,

    # Optional specific service hostname to test at Stage 6.
    [Parameter(Mandatory = $false)]
    [string]$ServiceHost,

    # Port to test against ServiceHost at Stage 6. Defaults to 443 (HTTPS)
    # as the most broadly applicable example if a host is supplied without
    # a specific port.
    [Parameter(Mandatory = $false)]
    [int]$ServicePort = 443,

    # Destination for the final traceroute. Defaults to a public, well-known
    # anycast address (Google Public DNS) so the traceroute stage always has
    # a target, but this can be pointed at an internal host instead.
    [Parameter(Mandatory = $false)]
    [string]$TraceTarget = "8.8.8.8"
)

# ---------------------------------------------------------------------------
# SETUP
# ---------------------------------------------------------------------------

$ErrorActionPreference = "Continue"
$timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss"

if (-not $OutputPath) {
    $diagnosticsFolder = Join-Path $env:USERPROFILE "Documents\IT-Diagnostics"
    if (-not (Test-Path $diagnosticsFolder)) {
        try {
            New-Item -ItemType Directory -Path $diagnosticsFolder -Force | Out-Null
        }
        catch {
            Write-Warning "Could not create diagnostics folder. Output will be console-only."
            $diagnosticsFolder = $null
        }
    }
    if ($diagnosticsFolder) {
        $OutputPath = Join-Path $diagnosticsFolder "ConnectivitySuite_$timestamp.txt"
    }
}

$reportLines = New-Object System.Collections.Generic.List[string]

function Write-ReportLine {
    param([string]$Text = "")
    Write-Host $Text
    $reportLines.Add($Text)
}

function Write-SectionHeader {
    param([string]$Title)
    Write-ReportLine ""
    Write-ReportLine "=" * 70
    Write-ReportLine " $Title"
    Write-ReportLine "=" * 70
}

# Stage results are tracked in a structured list so the final summary can
# print a clean PASS/FAIL table matching the escalation report format in
# networking/connectivity-fault-isolation.md exactly — this means a
# technician can copy the summary block directly into a ticket.
$script:stageResults = [ordered]@{
    "Stage 1 - Device Self-Test"      = "NOT RUN"
    "Stage 2 - Gateway Reachability"  = "NOT RUN"
    "Stage 3 - Internal Reachability" = "NOT RUN"
    "Stage 4 - Internet Egress"       = "NOT RUN"
    "Stage 5 - DNS Resolution"        = "NOT RUN"
    "Stage 6 - Service Port Test"     = "NOT RUN"
}
$script:faultBoundary = $null

# Note: PSScriptAnalyzer flags Set-StageResult under
# PSUseShouldProcessForStateChangingFunctions because of its "Set-" verb.
# This is a false positive: it only sets an internal script-scoped tracking
# variable, not real system state. Left unsuppressed (rather than adding a
# SuppressMessageAttribute) because that attribute is only valid on advanced
# functions ([CmdletBinding()]), and this is a plain helper function. The
# warning is non-blocking (Warning severity, not Error) and safe to ignore.
function Set-StageResult {
    param([string]$Stage, [string]$Result)
    $script:stageResults[$Stage] = $Result
    if ($Result -eq "FAIL" -and -not $script:faultBoundary) {
        $script:faultBoundary = $Stage
    }
}

# ---------------------------------------------------------------------------
# REPORT HEADER
# ---------------------------------------------------------------------------

Write-ReportLine "=" * 70
Write-ReportLine " IT SUPPORT - CONNECTIVITY FAULT ISOLATION REPORT"
Write-ReportLine " (Automated six-stage sequence per"
Write-ReportLine " networking/connectivity-fault-isolation.md)"
Write-ReportLine "=" * 70
Write-ReportLine "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-ReportLine "Computer Name: $env:COMPUTERNAME"
Write-ReportLine "Current User: $env:USERNAME"

# ---------------------------------------------------------------------------
# STAGE 1: DEVICE SELF-TEST
# ---------------------------------------------------------------------------

Write-SectionHeader "STAGE 1: DEVICE SELF-TEST"

$stage1Pass = $true
try {
    $loopback = Test-Connection -ComputerName "127.0.0.1" -Count 4 -ErrorAction Stop
    Write-ReportLine "Loopback (127.0.0.1): $($loopback.Count)/4 replies"
    if ($loopback.Count -eq 0) { $stage1Pass = $false }

    $adapter = Get-NetAdapter -ErrorAction Stop | Where-Object { $_.Status -eq "Up" } |
        Select-Object -First 1

    if ($adapter) {
        Write-ReportLine "Active Adapter: $($adapter.Name) - Status: $($adapter.Status) - Speed: $($adapter.LinkSpeed)"
    }
    else {
        Write-ReportLine "FLAG: No active network adapter found."
        $stage1Pass = $false
    }
}
catch {
    Write-ReportLine "ERROR during device self-test: $($_.Exception.Message)"
    $stage1Pass = $false
}

if ($stage1Pass) {
    Write-ReportLine ""
    Write-ReportLine "RESULT: PASS"
    Set-StageResult "Stage 1 - Device Self-Test" "PASS"
}
else {
    Write-ReportLine ""
    Write-ReportLine "RESULT: FAIL"
    Set-StageResult "Stage 1 - Device Self-Test" "FAIL"
}

# ---------------------------------------------------------------------------
# STAGE 2: GATEWAY REACHABILITY
# ---------------------------------------------------------------------------

Write-SectionHeader "STAGE 2: GATEWAY REACHABILITY"

$gateway = $null
if ($script:stageResults["Stage 1 - Device Self-Test"] -eq "PASS") {
    try {
        $gateway = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction Stop |
            Select-Object -First 1).NextHop

        if ($gateway) {
            $gwPing = Test-Connection -ComputerName $gateway -Count 10 -ErrorAction Stop
            $lossCount = 10 - $gwPing.Count
            $lossPct = [math]::Round(($lossCount / 10) * 100, 0)
            $avgLatency = if ($gwPing.Count -gt 0) {
                [math]::Round(($gwPing | Measure-Object ResponseTime -Average).Average, 1)
            } else { "N/A" }

            Write-ReportLine "Gateway: $gateway"
            Write-ReportLine "Replies Received: $($gwPing.Count)/10"
            Write-ReportLine "Packet Loss: $lossPct%"
            Write-ReportLine "Average Latency: $avgLatency ms"

            if ($gwPing.Count -eq 0) {
                Write-ReportLine ""
                Write-ReportLine "RESULT: FAIL"
                Set-StageResult "Stage 2 - Gateway Reachability" "FAIL"
            }
            else {
                Write-ReportLine ""
                Write-ReportLine "RESULT: PASS"
                Set-StageResult "Stage 2 - Gateway Reachability" "PASS"
                if ($lossPct -gt 0) {
                    Write-ReportLine "NOTE: Partial packet loss detected ($lossPct%) - may indicate"
                    Write-ReportLine " an unstable connection even though basic reachability passed."
                }
            }
        }
        else {
            Write-ReportLine "FLAG: No default gateway configured."
            Write-ReportLine ""
            Write-ReportLine "RESULT: FAIL"
            Set-StageResult "Stage 2 - Gateway Reachability" "FAIL"
        }
    }
    catch {
        Write-ReportLine "ERROR: $($_.Exception.Message)"
        Write-ReportLine ""
        Write-ReportLine "RESULT: FAIL"
        Set-StageResult "Stage 2 - Gateway Reachability" "FAIL"
    }
}
else {
    Write-ReportLine "SKIPPED - Stage 1 did not pass."
    Set-StageResult "Stage 2 - Gateway Reachability" "SKIPPED"
}

# ---------------------------------------------------------------------------
# STAGE 3: INTERNAL NETWORK REACHABILITY
# ---------------------------------------------------------------------------

Write-SectionHeader "STAGE 3: INTERNAL NETWORK REACHABILITY"

if ($script:stageResults["Stage 2 - Gateway Reachability"] -eq "PASS") {
    if ($InternalHost) {
        try {
            $internalPing = Test-Connection -ComputerName $InternalHost -Count 4 -ErrorAction Stop
            Write-ReportLine "Internal Host: $InternalHost"
            Write-ReportLine "Replies Received: $($internalPing.Count)/4"

            if ($internalPing.Count -eq 0) {
                Write-ReportLine ""
                Write-ReportLine "RESULT: FAIL"
                Set-StageResult "Stage 3 - Internal Reachability" "FAIL"
            }
            else {
                Write-ReportLine ""
                Write-ReportLine "RESULT: PASS"
                Set-StageResult "Stage 3 - Internal Reachability" "PASS"
            }
        }
        catch {
            Write-ReportLine "ERROR: $InternalHost unreachable - $($_.Exception.Message)"
            Write-ReportLine ""
            Write-ReportLine "RESULT: FAIL"
            Set-StageResult "Stage 3 - Internal Reachability" "FAIL"
        }
    }
    else {
        Write-ReportLine "SKIPPED - No -InternalHost parameter supplied."
        Write-ReportLine "Run with -InternalHost <hostname> to test a specific internal resource."
        Set-StageResult "Stage 3 - Internal Reachability" "SKIPPED"
    }
}
else {
    Write-ReportLine "SKIPPED - Stage 2 did not pass."
    Set-StageResult "Stage 3 - Internal Reachability" "SKIPPED"
}

# ---------------------------------------------------------------------------
# STAGE 4: INTERNET EGRESS
# ---------------------------------------------------------------------------

Write-SectionHeader "STAGE 4: INTERNET EGRESS"

if ($script:stageResults["Stage 2 - Gateway Reachability"] -eq "PASS") {
    $externalTargets = @("8.8.8.8", "1.1.1.1")
    $anyReachable = $false

    foreach ($target in $externalTargets) {
        try {
            $result = Test-Connection -ComputerName $target -Count 4 -ErrorAction Stop
            $latency = if ($result.Count -gt 0) {
                [math]::Round(($result | Measure-Object ResponseTime -Average).Average, 1)
            } else { "N/A" }
            Write-ReportLine "$target : $($result.Count)/4 replies, avg ${latency}ms"
            if ($result.Count -gt 0) { $anyReachable = $true }
        }
        catch {
            Write-ReportLine "$target : UNREACHABLE"
        }
    }

    Write-ReportLine ""
    if ($anyReachable) {
        Write-ReportLine "RESULT: PASS"
        Set-StageResult "Stage 4 - Internet Egress" "PASS"
    }
    else {
        Write-ReportLine "RESULT: FAIL"
        Set-StageResult "Stage 4 - Internet Egress" "FAIL"
    }
}
else {
    Write-ReportLine "SKIPPED - Stage 2 did not pass."
    Set-StageResult "Stage 4 - Internet Egress" "SKIPPED"
}

# ---------------------------------------------------------------------------
# STAGE 5: DNS RESOLUTION
# ---------------------------------------------------------------------------

Write-SectionHeader "STAGE 5: DNS RESOLUTION"

if ($script:stageResults["Stage 4 - Internet Egress"] -eq "PASS") {
    $dnsTargets = @("google.com", "microsoft.com")
    $dnsFailures = 0

    foreach ($target in $dnsTargets) {
        try {
            $dnsResult = Resolve-DnsName -Name $target -ErrorAction Stop |
                Where-Object { $_.Type -eq "A" } | Select-Object -First 1
            if ($dnsResult) {
                Write-ReportLine "$target : RESOLVED -> $($dnsResult.IPAddress)"
            }
            else {
                Write-ReportLine "$target : NO A RECORD"
                $dnsFailures++
            }
        }
        catch {
            Write-ReportLine "$target : RESOLUTION FAILED"
            $dnsFailures++
        }
    }

    Write-ReportLine ""
    if ($dnsFailures -eq 0) {
        Write-ReportLine "RESULT: PASS"
        Set-StageResult "Stage 5 - DNS Resolution" "PASS"
    }
    else {
        Write-ReportLine "RESULT: FAIL ($dnsFailures of $($dnsTargets.Count) lookups failed)"
        Set-StageResult "Stage 5 - DNS Resolution" "FAIL"
    }
}
else {
    Write-ReportLine "SKIPPED - Stage 4 did not pass."
    Set-StageResult "Stage 5 - DNS Resolution" "SKIPPED"
}

# ---------------------------------------------------------------------------
# STAGE 6: SERVICE PORT TEST
# ---------------------------------------------------------------------------

Write-SectionHeader "STAGE 6: SERVICE PORT TEST"

if ($ServiceHost) {
    try {
        $portResult = Test-NetConnection -ComputerName $ServiceHost -Port $ServicePort `
            -WarningAction SilentlyContinue -ErrorAction Stop

        Write-ReportLine "Target: ${ServiceHost}:${ServicePort}"
        Write-ReportLine "Reachable: $($portResult.TcpTestSucceeded)"

        if ($portResult.TcpTestSucceeded) {
            Write-ReportLine ""
            Write-ReportLine "RESULT: PASS"
            Set-StageResult "Stage 6 - Service Port Test" "PASS"
        }
        else {
            Write-ReportLine ""
            Write-ReportLine "RESULT: FAIL"
            Set-StageResult "Stage 6 - Service Port Test" "FAIL"
        }
    }
    catch {
        Write-ReportLine "ERROR: $($_.Exception.Message)"
        Write-ReportLine ""
        Write-ReportLine "RESULT: FAIL"
        Set-StageResult "Stage 6 - Service Port Test" "FAIL"
    }
}
else {
    Write-ReportLine "SKIPPED - No -ServiceHost parameter supplied."
    Write-ReportLine "Run with -ServiceHost <hostname> -ServicePort <port> to test a"
    Write-ReportLine "specific service (e.g. -ServiceHost mail.company.com -ServicePort 993)."
    Set-StageResult "Stage 6 - Service Port Test" "SKIPPED"
}

# ---------------------------------------------------------------------------
# TRACEROUTE (only run if a fault boundary was found beyond Stage 1)
# ---------------------------------------------------------------------------

Write-SectionHeader "TRACEROUTE TO $TraceTarget"

if ($script:stageResults["Stage 1 - Device Self-Test"] -eq "PASS") {
    try {
        Write-ReportLine "Running traceroute (this may take up to 30 seconds)..."
        Write-ReportLine ""
        $trace = Test-NetConnection -ComputerName $TraceTarget -TraceRoute -ErrorAction Stop
        $hopNumber = 1
        foreach ($hop in $trace.TraceRoute) {
            Write-ReportLine (" Hop {0,-3} {1}" -f $hopNumber, $hop)
            $hopNumber++
        }
    }
    catch {
        Write-ReportLine "Could not complete traceroute - $($_.Exception.Message)"
    }
}
else {
    Write-ReportLine "SKIPPED - Stage 1 did not pass; traceroute would not be informative."
}

# ---------------------------------------------------------------------------
# ESCALATION REPORT SUMMARY
# ---------------------------------------------------------------------------
# This block intentionally mirrors the escalation package format documented
# in networking/connectivity-fault-isolation.md so it can be copied directly
# into a ticket without reformatting.

Write-SectionHeader "CONNECTIVITY FAULT ISOLATION SUMMARY"

Write-ReportLine "Date/Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-ReportLine "Device: $env:COMPUTERNAME"
Write-ReportLine ""
Write-ReportLine "STAGE RESULTS:"
foreach ($stage in $script:stageResults.Keys) {
    Write-ReportLine (" {0,-35} {1}" -f $stage, $script:stageResults[$stage])
}

Write-ReportLine ""
if ($script:faultBoundary) {
    Write-ReportLine "FAULT BOUNDARY: $script:faultBoundary"
    Write-ReportLine ""
    Write-ReportLine "Refer to the corresponding stage in"
    Write-ReportLine "networking/connectivity-fault-isolation.md for next steps and"
    Write-ReportLine "escalation guidance."
}
else {
    $skippedOnly = ($script:stageResults.Values | Where-Object { $_ -eq "SKIPPED" }).Count
    $totalStages = $script:stageResults.Count
    if ($skippedOnly -eq 0) {
        Write-ReportLine "All stages passed. No connectivity fault detected by this automated"
        Write-ReportLine "sequence. If the user is still reporting an issue, the fault is"
        Write-ReportLine "likely application-specific, intermittent, or requires the optional"
        Write-ReportLine "-InternalHost / -ServiceHost parameters to test the specific"
        Write-ReportLine "resource the user is trying to reach."
    }
    else {
        Write-ReportLine "All executed stages passed; $skippedOnly of $totalStages stage(s) were"
        Write-ReportLine "skipped (optional parameters not supplied). Re-run with -InternalHost"
        Write-ReportLine "and/or -ServiceHost for complete coverage if the issue persists."
    }
}

Write-ReportLine ""
Write-ReportLine "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

# ---------------------------------------------------------------------------
# FILE OUTPUT
# ---------------------------------------------------------------------------

if ($OutputPath) {
    try {
        $reportLines | Out-File -FilePath $OutputPath -Encoding UTF8 -ErrorAction Stop
        Write-Host ""
        Write-Host "Report saved to: $OutputPath" -ForegroundColor Green
        Write-Host "Attach this file to the ticket per diagnostic-report-template.md" -ForegroundColor Green
    }
    catch {
        Write-Warning "Could not save report to file: $($_.Exception.Message)"
        Write-Warning "Report was displayed in console only — copy manually if needed."
    }
}
else {
    Write-Host ""
    Write-Host "No output file path available — report displayed in console only." -ForegroundColor Yellow
}