diff --git a/PiHoleShell/PiHoleShell.psm1 b/PiHoleShell/PiHoleShell.psm1 index aa25829..92b77e2 100644 --- a/PiHoleShell/PiHoleShell.psm1 +++ b/PiHoleShell/PiHoleShell.psm1 @@ -33,5 +33,7 @@ Export-ModuleMember -Function @( #ListManagement 'Get-PiHoleList', 'Search-PiHoleListDomain', 'Add-PiHoleList', 'Remove-PiHoleList', ` #FTLInformation - 'Get-PiHoleInfoMessage', 'Get-PiHoleInfoHost' + 'Get-PiHoleInfoMessage', 'Get-PiHoleInfoHost', ` + #History + 'Get-PiHoleHistory', 'Get-PiHoleHistoryDatabase', 'Get-PiHoleHistoryClient', 'Get-PiHoleHistoryDatabaseClient' ) \ No newline at end of file diff --git a/PiHoleShell/Public/GroupManagement/New-PiHoleGroup.ps1 b/PiHoleShell/Public/GroupManagement/New-PiHoleGroup.ps1 index 50458e0..696019f 100644 --- a/PiHoleShell/Public/GroupManagement/New-PiHoleGroup.ps1 +++ b/PiHoleShell/Public/GroupManagement/New-PiHoleGroup.ps1 @@ -1,8 +1,35 @@ function New-PiHoleGroup { <# .SYNOPSIS -Creates a new group in the groups object. +Creates a new group +.DESCRIPTION +Creates a new group in Pi-hole's groups object. Lists and clients can be assigned to groups +to apply blocking rules selectively rather than globally. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER GroupName +The name of the group to create + +.PARAMETER Comment +An optional comment to store alongside the group + +.PARAMETER Enabled +Whether the group is enabled immediately. Defaults to $true + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +New-PiHoleGroup -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -GroupName "Kids" #> [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#post-/groups')] [Diagnostics.CodeAnalysis.SuppressMessage("PSUseShouldProcessForStateChangingFunctions", "", Justification = "Ignoring for now")] @@ -52,24 +79,23 @@ Creates a new group in the groups object. } else { - $ObjectFinal = @() - $Object = [PSCustomObject]@{ - Name = $GroupName - Comment = $Comment - Enabled = $Enabled + $ObjectFinal = foreach ($Item in $Response.groups) { + [PSCustomObject]@{ + Name = $Item.name + Comment = $Item.comment + Enabled = $Item.enabled + Id = $Item.id + DateAdded = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_added).LocalTime + DateModified = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_modified).LocalTime + } } - Write-Verbose -Message "Name - $($Object.GroupName)" - Write-Verbose -Message "Comment - $($Object.Comment)" - Write-Verbose -Message "Enabled - $($Object.Enabled)" - $ObjectFinal = $Object + Write-Output $ObjectFinal } - Write-Output $ObjectFinal } } catch { Write-Error -Message $_.Exception.Message - break } finally { diff --git a/PiHoleShell/Public/GroupManagement/Remove-PiHoleGroup.ps1 b/PiHoleShell/Public/GroupManagement/Remove-PiHoleGroup.ps1 index 4af5500..6f85970 100644 --- a/PiHoleShell/Public/GroupManagement/Remove-PiHoleGroup.ps1 +++ b/PiHoleShell/Public/GroupManagement/Remove-PiHoleGroup.ps1 @@ -1,7 +1,10 @@ function Remove-PiHoleGroup { <# .SYNOPSIS -Delete group +Delete a group + +.DESCRIPTION +Deletes a group from Pi-hole. Any lists or clients assigned to it are unassigned, not deleted. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" @@ -9,14 +12,18 @@ The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "h .PARAMETER Password The API Password you generated from your PiHole server +.PARAMETER GroupName +The name of the group to delete + .PARAMETER IgnoreSsl Set to $true to skip SSL certificate validation .PARAMETER RawOutput This will dump the response instead of the formatted object +.EXAMPLE +Remove-PiHoleGroup -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -GroupName "Kids" #> - #Work In Progress [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#delete-/groups/-name-')] [Diagnostics.CodeAnalysis.SuppressMessage("PSUseShouldProcessForStateChangingFunctions", "", Justification = "Ignoring for now")] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] @@ -34,8 +41,10 @@ This will dump the response instead of the formatted object try { $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl - $Body = @{ - name = $GroupName + $GetGroupName = Get-PiHoleGroup -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl -GroupName $GroupName + + if (-not $GetGroupName) { + throw "Cannot find $GroupName on $PiHoleServer! Please use Get-PiHoleGroup to list all groups" } $Params = @{ @@ -44,32 +53,24 @@ This will dump the response instead of the formatted object Method = "Delete" SkipCertificateCheck = $IgnoreSsl ContentType = "application/json" - Body = $Body | ConvertTo-Json -Depth 10 } - $GetGroupName = Get-PiHoleGroup -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl -GroupName $GroupName - if ($GetGroupName) { - $Response = Invoke-RestMethod @Params + $Response = Invoke-RestMethod @Params - if ($RawOutput) { - Write-Output $Response - } - else { - $ObjectFinal = @() - $Object = [PSCustomObject]@{ - Name = $GroupName - Status = "Deleted" - } - $ObjectFinal = $Object + if ($RawOutput) { + Write-Output $Response + } + else { + $Object = [PSCustomObject]@{ + Name = $GroupName + Status = "Deleted" } - Write-Verbose -Message "Deleted $($Object.GroupName)" - Write-Output $ObjectFinal + Write-Output $Object } } catch { Write-Error -Message $_.Exception.Message - break } finally { diff --git a/PiHoleShell/Public/GroupManagement/Update-PiHoleGroup.ps1 b/PiHoleShell/Public/GroupManagement/Update-PiHoleGroup.ps1 index 0966520..41e5c11 100644 --- a/PiHoleShell/Public/GroupManagement/Update-PiHoleGroup.ps1 +++ b/PiHoleShell/Public/GroupManagement/Update-PiHoleGroup.ps1 @@ -1,7 +1,13 @@ function Update-PiHoleGroup { <# .SYNOPSIS -Items may be updated by replacing them. +Update a group + +.DESCRIPTION +Updates a group's Comment and/or Enabled state. The underlying Pi-hole API replaces the +entire group on update, so any property you don't pass here is preserved by first reading +the group's current value and resending it - Comment and Enabled are never silently cleared +just because you only meant to change the other one. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" @@ -9,12 +15,23 @@ The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "h .PARAMETER Password The API Password you generated from your PiHole server +.PARAMETER GroupName +The name of the group to update + +.PARAMETER Comment +The new comment for the group. Leave unset to keep the group's current comment + +.PARAMETER Enabled +Whether the group should be enabled. Leave unset to keep the group's current state + .PARAMETER IgnoreSsl Set to $true to skip SSL certificate validation .PARAMETER RawOutput This will dump the response instead of the formatted object +.EXAMPLE +Update-PiHoleGroup -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -GroupName "Kids" -Enabled $false #> [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] [Diagnostics.CodeAnalysis.SuppressMessage("PSUseShouldProcessForStateChangingFunctions", "", Justification = "Ignoring for now")] @@ -26,41 +43,31 @@ This will dump the response instead of the formatted object [string]$Password, [Parameter(Mandatory = $true)] [string]$GroupName, - [string]$Comment = $null, - [bool]$Enabled, + [string]$Comment, + [Nullable[bool]]$Enabled, [bool]$IgnoreSsl = $false, [bool]$RawOutput = $false ) - #Enabled is weird here.. look into it try { $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl - $Body = @{ - name = $GroupName - } - $GetGroupName = Get-PiHoleGroup -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl -GroupName $GroupName - if ($Comment -eq $null -and $Enabled -eq $null) { - Write-Warning -Message "failed" - throw -Message "To update $GroupName, you must either use the Comment and/or Enabled parameter" + if (-not $GetGroupName) { + throw "Cannot find $GroupName on $PiHoleServer! Please use Get-PiHoleGroup to list all groups" } - if ($Comment) { - $Body += @{ - comment = $Comment - } - } - if ($Enabled -ne $null) { - $Body += @{ - enabled = $Enabled - } + if (-not $PSBoundParameters.ContainsKey('Comment') -and -not $PSBoundParameters.ContainsKey('Enabled')) { + throw "To update $GroupName, you must specify the Comment and/or Enabled parameter" } - else { - $Body += @{ - enabled = $GetGroupName.Enabled - } + + # The API replaces the whole group on update, so any property not explicitly passed + # here is resent using the group's current value to avoid silently clearing it. + $Body = @{ + name = $GroupName + comment = if ($PSBoundParameters.ContainsKey('Comment')) { $Comment } else { $GetGroupName.Comment } + enabled = if ($PSBoundParameters.ContainsKey('Enabled')) { $Enabled } else { $GetGroupName.Enabled } } $Params = @{ @@ -72,31 +79,28 @@ This will dump the response instead of the formatted object Body = $Body | ConvertTo-Json -Depth 10 } - if ($GetGroupName) { - $Response = Invoke-RestMethod @Params - if ($RawOutput) { - Write-Output $Response - } - else { - $ObjectFinal = @() - $Object = [PSCustomObject]@{ - Name = $GroupName - Comment = $Comment - Enabled = $Enabled + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ObjectFinal = foreach ($Item in $Response.groups) { + [PSCustomObject]@{ + Name = $Item.name + Comment = $Item.comment + Enabled = $Item.enabled + Id = $Item.id + DateAdded = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_added).LocalTime + DateModified = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.date_modified).LocalTime } - Write-Verbose -Message "Name - $($Object.GroupName)" - Write-Verbose -Message "Comment - $($Object.Comment)" - Write-Verbose -Message "Enabled - $($Object.Enabled)" - $ObjectFinal = $Object } Write-Output $ObjectFinal - } } catch { Write-Error -Message $_.Exception.Message - break } finally { diff --git a/PiHoleShell/Public/History/Get-PiHoleHistory.ps1 b/PiHoleShell/Public/History/Get-PiHoleHistory.ps1 new file mode 100644 index 0000000..45d618c --- /dev/null +++ b/PiHoleShell/Public/History/Get-PiHoleHistory.ps1 @@ -0,0 +1,77 @@ +function Get-PiHoleHistory { + <# +.SYNOPSIS +Get activity graph data + +.DESCRIPTION +Request the data needed to generate the "total queries over time" graph, covering roughly +the last 24 hours. The sum of Cached/Blocked/Forwarded for a given entry may be smaller than +Total - the remainder are queries that don't fit into any of those categories (e.g. a busy +database, or an unknown query status). + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleHistory -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/history')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/history" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ObjectFinal = foreach ($Item in $Response.history) { + [PSCustomObject]@{ + Timestamp = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.timestamp).LocalTime + Total = $Item.total + Cached = $Item.cached + Blocked = $Item.blocked + Forwarded = $Item.forwarded + } + } + Write-Output $ObjectFinal + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/History/Get-PiHoleHistoryClient.ps1 b/PiHoleShell/Public/History/Get-PiHoleHistoryClient.ps1 new file mode 100644 index 0000000..9487f41 --- /dev/null +++ b/PiHoleShell/Public/History/Get-PiHoleHistoryClient.ps1 @@ -0,0 +1,93 @@ +function Get-PiHoleHistoryClient { + <# +.SYNOPSIS +Get per-client activity graph data + +.DESCRIPTION +Request the data needed to generate the "client activity over the last 24 hours" graph. This +returns the top N clients (by total query count within the last 24 hours); pass -MaxResult 0 +to return all clients. Client names are only available if the client's IP address can be +resolved to a hostname. Due to privacy settings, the returned data may be empty. + +The last client returned is always a special entry with the name "other clients" and IP +"0.0.0.0", representing the combined total of any clients outside the top N. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER MaxResult +The maximum number of clients to return. Set to 0 to return all clients. Defaults to 20 + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleHistoryClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/history/clients')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [int]$MaxResult = 20, + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/history/clients?N=$MaxResult" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ClientNames = @{} + foreach ($Property in $Response.clients.PSObject.Properties) { + $ClientNames[$Property.Name] = $Property.Value.name + } + + $ObjectFinal = foreach ($Item in $Response.history) { + $Clients = foreach ($Property in $Item.data.PSObject.Properties) { + [PSCustomObject]@{ + IP = $Property.Name + Name = $ClientNames[$Property.Name] + Count = $Property.Value + } + } + [PSCustomObject]@{ + Timestamp = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.timestamp).LocalTime + Clients = $Clients + } + } + Write-Output $ObjectFinal + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/History/Get-PiHoleHistoryDatabase.ps1 b/PiHoleShell/Public/History/Get-PiHoleHistoryDatabase.ps1 new file mode 100644 index 0000000..4a63e83 --- /dev/null +++ b/PiHoleShell/Public/History/Get-PiHoleHistoryDatabase.ps1 @@ -0,0 +1,91 @@ +function Get-PiHoleHistoryDatabase { + <# +.SYNOPSIS +Get activity graph data (long-term data) + +.DESCRIPTION +Request the long-term (on-disk database) data needed to generate the "total queries over +time" graph for a given time range. The sum of Cached/Blocked/Forwarded for a given entry +may be smaller than Total - the remainder are queries that don't fit into any of those +categories (e.g. a busy database, or an unknown query status). + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER From +Local date/time from when the data should be requested. Defaults to 8 hours ago. + +.PARAMETER Until +Local date/time until when the data should be requested. Defaults to now. + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleHistoryDatabase -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + +.EXAMPLE +Get-PiHoleHistoryDatabase -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -From (Get-Date).AddDays(-7) -Until (Get-Date) + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/history/database')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [datetime]$From = (Get-Date).AddHours(-8), + [datetime]$Until = (Get-Date), + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $FromUnixTime = (Convert-LocalTimeToPiHoleUnixTime -Date $From).UnixTime + $UntilUnixTime = (Convert-LocalTimeToPiHoleUnixTime -Date $Until).UnixTime + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/history/database?from=$FromUnixTime&until=$UntilUnixTime" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + $ObjectFinal = foreach ($Item in $Response.history) { + [PSCustomObject]@{ + Timestamp = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.timestamp).LocalTime + Total = $Item.total + Cached = $Item.cached + Blocked = $Item.blocked + Forwarded = $Item.forwarded + } + } + Write-Output $ObjectFinal + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/PiHoleShell/Public/History/Get-PiHoleHistoryDatabaseClient.ps1 b/PiHoleShell/Public/History/Get-PiHoleHistoryDatabaseClient.ps1 new file mode 100644 index 0000000..3272c43 --- /dev/null +++ b/PiHoleShell/Public/History/Get-PiHoleHistoryDatabaseClient.ps1 @@ -0,0 +1,101 @@ +function Get-PiHoleHistoryDatabaseClient { + <# +.SYNOPSIS +Get per-client activity graph data (long-term data) + +.DESCRIPTION +Request the long-term (on-disk database) data needed to generate the per-client activity +graph for a given time range. Due to privacy settings, the returned data may be empty. + +Unlike Get-PiHoleHistoryClient (the live/last-24-hours version), the Pi-hole API keys this +endpoint's per-timestamp client breakdown by an internal numeric client ID rather than by IP +address, while the separate client summary it also returns is keyed by IP - there's no shared +key to join the two on. Because of this, ClientId below cannot be reliably resolved to an IP +or hostname and is returned as-is; use -RawOutput to inspect the full response if you need to +investigate further. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER From +Local date/time from when the data should be requested. Defaults to 8 hours ago. + +.PARAMETER Until +Local date/time until when the data should be requested. Defaults to now. + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleHistoryDatabaseClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + +.EXAMPLE +Get-PiHoleHistoryDatabaseClient -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -From (Get-Date).AddDays(-7) -Until (Get-Date) + #> + [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/history/database/clients')] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] + param ( + [Parameter(Mandatory = $true)] + [System.URI]$PiHoleServer, + [Parameter(Mandatory = $true)] + [string]$Password, + [datetime]$From = (Get-Date).AddHours(-8), + [datetime]$Until = (Get-Date), + [bool]$IgnoreSsl = $false, + [bool]$RawOutput = $false + ) + + try { + $Sid = Request-PiHoleAuth -PiHoleServer $PiHoleServer -Password $Password -IgnoreSsl $IgnoreSsl + + $FromUnixTime = (Convert-LocalTimeToPiHoleUnixTime -Date $From).UnixTime + $UntilUnixTime = (Convert-LocalTimeToPiHoleUnixTime -Date $Until).UnixTime + + $Params = @{ + Headers = @{sid = $($Sid) } + Uri = "$($PiHoleServer.OriginalString)/api/history/database/clients?from=$FromUnixTime&until=$UntilUnixTime" + Method = "Get" + SkipCertificateCheck = $IgnoreSsl + ContentType = "application/json" + } + + $Response = Invoke-RestMethod @Params + + if ($RawOutput) { + Write-Output $Response + } + else { + # The `data` keys here are internal numeric client IDs, not IPs - they don't match + # the IP-keyed `clients` summary the API also returns, so names can't be resolved. + $ObjectFinal = foreach ($Item in $Response.history) { + $Clients = foreach ($Property in $Item.data.PSObject.Properties) { + [PSCustomObject]@{ + ClientId = $Property.Name + Count = $Property.Value + } + } + [PSCustomObject]@{ + Timestamp = (Convert-PiHoleUnixTimeToLocalTime -UnixTime $Item.timestamp).LocalTime + Clients = $Clients + } + } + Write-Output $ObjectFinal + } + } + + catch { + Write-Error -Message $_.Exception.Message + } + + finally { + if ($Sid) { + Remove-PiHoleCurrentAuthSession -PiHoleServer $PiHoleServer -Sid $Sid -IgnoreSsl $IgnoreSsl + } + } +} diff --git a/README.md b/README.md index 456bdfe..7180dec 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ Functions marked 🚧 are still under active development — signatures and outp | Function | Description | |---|---| | `Get-PiHoleGroup` | Get groups | -| `New-PiHoleGroup` | Creates a new group in the groups object. | -| `Remove-PiHoleGroup` 🚧 | Delete group | -| `Update-PiHoleGroup` | Items may be updated by replacing them. | +| `New-PiHoleGroup` | Creates a new group | +| `Remove-PiHoleGroup` | Delete a group | +| `Update-PiHoleGroup` | Update a group | ### List Management @@ -138,6 +138,10 @@ Functions marked 🚧 are still under active development — signatures and outp | Function | Description | |---|---| | `Get-PiHoleConfig` | Get current configuration of Pi-hole | +| `Get-PiHoleHistory` | Get activity graph data | +| `Get-PiHoleHistoryClient` | Get per-client activity graph data | +| `Get-PiHoleHistoryDatabase` | Get activity graph data (long-term data) | +| `Get-PiHoleHistoryDatabaseClient` | Get per-client activity graph data (long-term data) | | `Get-PiHoleInfoHost` | Get information about the host system | | `Get-PiHoleInfoMessage` | Get Pi-hole diagnosis messages Request Pi-hole diagnosis messages | | `Get-PiHolePadd` | Get summarized data for PADD | diff --git a/tests/Add-PiHoleList.Integration.Tests.ps1 b/tests/Add-PiHoleList.Integration.Tests.ps1 index ce60d01..da428c5 100644 --- a/tests/Add-PiHoleList.Integration.Tests.ps1 +++ b/tests/Add-PiHoleList.Integration.Tests.ps1 @@ -58,7 +58,7 @@ Describe 'Add-PiHoleList (Integration)' -Tag 'Integration' { Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block | Out-Null $result = Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -ErrorVariable errOut -ErrorAction SilentlyContinue - Write-Host "Error: [$errOut]" + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" $errOut | Should -Not -BeNullOrEmpty @@ -67,7 +67,7 @@ Describe 'Add-PiHoleList (Integration)' -Tag 'Integration' { It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { $result = Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -ErrorVariable errOut -ErrorAction SilentlyContinue - Write-Host "Error: [$errOut]" + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" $errOut | Should -Not -BeNullOrEmpty } diff --git a/tests/Get-PiHoleHistory.Integration.Tests.ps1 b/tests/Get-PiHoleHistory.Integration.Tests.ps1 new file mode 100644 index 0000000..f37a0ad --- /dev/null +++ b/tests/Get-PiHoleHistory.Integration.Tests.ps1 @@ -0,0 +1,45 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleHistory (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + + # Generates some real query traffic so live history isn't all zero/empty. + & (Join-Path $PSScriptRoot 'Initialize-PiHoleTestData.ps1') -DnsServer $PiHoleServer.Host + } + } + + It 'returns history as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistory -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Select-Object -First 5 | Format-Table | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result[0].Total | Should -BeGreaterOrEqual 0 + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistory -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.history | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistory -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleHistoryClient.Integration.Tests.ps1 b/tests/Get-PiHoleHistoryClient.Integration.Tests.ps1 new file mode 100644 index 0000000..ae95ddc --- /dev/null +++ b/tests/Get-PiHoleHistoryClient.Integration.Tests.ps1 @@ -0,0 +1,46 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleHistoryClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + + # Generates some real query traffic so live client history isn't all zero/empty. + & (Join-Path $PSScriptRoot 'Initialize-PiHoleTestData.ps1') -DnsServer $PiHoleServer.Host + } + } + + It 'returns per-client history as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Select-Object -First 3 | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result[0].Clients | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.clients | Should -Not -BeNullOrEmpty + $result.history | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryClient -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleHistoryDatabase.Integration.Tests.ps1 b/tests/Get-PiHoleHistoryDatabase.Integration.Tests.ps1 new file mode 100644 index 0000000..f036dbc --- /dev/null +++ b/tests/Get-PiHoleHistoryDatabase.Integration.Tests.ps1 @@ -0,0 +1,53 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleHistoryDatabase (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + + # The API rejects from=0 (epoch) with a 400, so use a recent, valid window instead. + $script:Until = Get-Date + $script:From = $script:Until.AddDays(-30) + } + + It 'returns history as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabase -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Select-Object -First 5 | Format-Table | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result[0].Total | Should -BeGreaterOrEqual 0 + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabase -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.history | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabase -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'defaults to the last 8 hours when From/Until are omitted' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabase -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-Table | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Get-PiHoleHistoryDatabaseClient.Integration.Tests.ps1 b/tests/Get-PiHoleHistoryDatabaseClient.Integration.Tests.ps1 new file mode 100644 index 0000000..d9f14e9 --- /dev/null +++ b/tests/Get-PiHoleHistoryDatabaseClient.Integration.Tests.ps1 @@ -0,0 +1,54 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Get-PiHoleHistoryDatabaseClient (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + } + + # The API rejects from=0 (epoch) with a 400, so use a recent, valid window instead. + $script:Until = Get-Date + $script:From = $script:Until.AddDays(-30) + } + + It 'returns per-client history as formatted objects' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabaseClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Select-Object -First 3 | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result[0].Clients | Should -Not -BeNullOrEmpty + $result[0].Clients[0].ClientId | Should -Not -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabaseClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result.history | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabaseClient -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -From $script:From -Until $script:Until -IgnoreSsl $script:PiHoleIgnoreSsl -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'defaults to the last 8 hours when From/Until are omitted' -Skip:(-not $script:ConfigAvailable) { + $result = Get-PiHoleHistoryDatabaseClient -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/New-PiHoleGroup.Integration.Tests.ps1 b/tests/New-PiHoleGroup.Integration.Tests.ps1 new file mode 100644 index 0000000..bd9740d --- /dev/null +++ b/tests/New-PiHoleGroup.Integration.Tests.ps1 @@ -0,0 +1,71 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'New-PiHoleGroup (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestGroupName = 'PesterGroup' + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + + # Defensive cleanup in case a previous failed run left the test group behind + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + } + + It 'creates a new group and returns a formatted object' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Comment 'Pester integration test group' + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:TestGroupName + $result.Comment | Should -Be 'Pester integration test group' + $result.Enabled | Should -BeTrue + + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.groups[0].name | Should -Be $script:TestGroupName + + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + + It 'warns and returns nothing when the group already exists' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + + $result = New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -WarningVariable warnOut -WarningAction SilentlyContinue + + $result | Should -BeNullOrEmpty + $warnOut | Should -Not -BeNullOrEmpty + + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + $result = New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Remove-PiHoleGroup.Integration.Tests.ps1 b/tests/Remove-PiHoleGroup.Integration.Tests.ps1 new file mode 100644 index 0000000..cacc416 --- /dev/null +++ b/tests/Remove-PiHoleGroup.Integration.Tests.ps1 @@ -0,0 +1,71 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Remove-PiHoleGroup (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestGroupName = 'PesterGroup' + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + + # Defensive cleanup in case a previous failed run left the test group behind + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + } + + It 'removes an existing group and returns a formatted object' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + + $result = Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Name | Should -Be $script:TestGroupName + $result.Status | Should -Be 'Deleted' + + $remaining = Get-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -WarningAction SilentlyContinue + $remaining | Should -BeNullOrEmpty + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + + # A successful delete is HTTP 204 No Content, so RawOutput is expected to be empty here - + # the group actually being gone afterward is the real signal of success. + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -RawOutput $true + + $remaining = Get-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -WarningAction SilentlyContinue + $remaining | Should -BeNullOrEmpty + } + + It 'errors when the group does not exist' -Skip:(-not $script:ConfigAvailable) { + $result = Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorVariable errOut -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + + $result = Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/Remove-PiHoleList.Integration.Tests.ps1 b/tests/Remove-PiHoleList.Integration.Tests.ps1 index ff9df8e..d49ea9e 100644 --- a/tests/Remove-PiHoleList.Integration.Tests.ps1 +++ b/tests/Remove-PiHoleList.Integration.Tests.ps1 @@ -60,7 +60,7 @@ Describe 'Remove-PiHoleList (Integration)' -Tag 'Integration' { Add-PiHoleList -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block | Out-Null $result = Remove-PiHoleList -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Address $script:TestListAddress -Type Block -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue - Write-Host "Error: [$errOut]" + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" $errOut | Should -Not -BeNullOrEmpty } diff --git a/tests/Update-PiHoleActionsGravity.Integration.Tests.ps1 b/tests/Update-PiHoleActionsGravity.Integration.Tests.ps1 index 9257a43..ca0aaa8 100644 --- a/tests/Update-PiHoleActionsGravity.Integration.Tests.ps1 +++ b/tests/Update-PiHoleActionsGravity.Integration.Tests.ps1 @@ -43,7 +43,7 @@ Describe 'Update-PiHoleActionsGravity (Integration)' -Tag 'Integration' { It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { $result = Update-PiHoleActionsGravity -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -Confirm:$false -ErrorVariable errOut -ErrorAction SilentlyContinue - Write-Host "Error: [$errOut]" + Write-Host "Error ($($errOut.Count) entries, showing last): [$($errOut[-1])]" $errOut | Should -Not -BeNullOrEmpty } diff --git a/tests/Update-PiHoleGroup.Integration.Tests.ps1 b/tests/Update-PiHoleGroup.Integration.Tests.ps1 new file mode 100644 index 0000000..a802039 --- /dev/null +++ b/tests/Update-PiHoleGroup.Integration.Tests.ps1 @@ -0,0 +1,94 @@ +# Requires -Module Pester +# +# Integration tests that call a REAL Pi-hole server. Configure tests/IntegrationConfig.local.ps1 +# (copy it from IntegrationConfig.example.ps1) before running. Tests are skipped automatically +# if that file is missing. + +$script:ConfigAvailable = Test-Path (Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1') + +Describe 'Update-PiHoleGroup (Integration)' -Tag 'Integration' { + BeforeAll { + Import-Module .\PiHoleShell\PiHoleShell.psm1 -Force + + $script:TestGroupName = 'PesterGroup' + + $configPath = Join-Path $PSScriptRoot 'IntegrationConfig.local.ps1' + if (Test-Path $configPath) { + . $configPath + $script:PiHoleServer = $PiHoleServer + $script:PiHoleToken = $PiHoleToken + $script:PiHoleIgnoreSsl = $PiHoleIgnoreSsl + + # Defensive cleanup in case a previous failed run left the test group behind + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + } + + AfterAll { + if ($script:PiHoleServer) { + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null + } + } + + It 'updates only the comment, preserving the current Enabled state' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Comment 'original comment' -Enabled $true | Out-Null + + $result = Update-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Comment 'updated comment' + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Comment | Should -Be 'updated comment' + $result.Enabled | Should -BeTrue + + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + + It 'updates only Enabled, preserving the current comment' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Comment 'keep this comment' -Enabled $true | Out-Null + + $result = Update-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Enabled $false + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.Comment | Should -Be 'keep this comment' + $result.Enabled | Should -BeFalse + + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + + It 'returns the raw API response when RawOutput is set' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + + $result = Update-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Comment 'raw output test' -RawOutput $true + $result | Format-List | Out-String | Write-Host + + $result | Should -Not -BeNullOrEmpty + $result.groups[0].comment | Should -Be 'raw output test' + + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + + It 'errors when neither Comment nor Enabled is specified' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + + $result = Update-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + + Remove-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + } + + It 'errors when the group does not exist' -Skip:(-not $script:ConfigAvailable) { + $result = Update-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Comment 'does not matter' -ErrorVariable errOut -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } + + It 'errors when given a bad password' -Skip:(-not $script:ConfigAvailable) { + New-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password $script:PiHoleToken -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName | Out-Null + + $result = Update-PiHoleGroup -PiHoleServer $script:PiHoleServer -Password 'definitely-not-the-real-token' -IgnoreSsl $script:PiHoleIgnoreSsl -GroupName $script:TestGroupName -Comment 'irrelevant' -ErrorVariable errOut -ErrorAction SilentlyContinue + + $errOut | Should -Not -BeNullOrEmpty + } +}