diff --git a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 new file mode 100644 index 0000000..ca74319 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 @@ -0,0 +1,346 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 integration tests for PSProxmoxVE. + + These tests are tagged 'Integration' and are SKIPPED by default. + They require a live, dedicated Proxmox VE test node and the following + environment variables to be set: + + PVETEST_HOST - Hostname or IP of the PVE test node + PVETEST_PORT - API port (usually 8006) + PVETEST_APITOKEN - API token in the format USER@REALM!TOKENID=UUID + PVETEST_NODE - PVE node name (e.g. pve-test1) + PVETEST_STORAGE - Storage pool name for disk/ISO operations (e.g. local) + PVETEST_ISO_PATH - Local filesystem path to a small .iso for upload tests + + WARNING: These tests CREATE and DESTROY real resources (VMs, snapshots, + ISO uploads, etc.) on the target node. Never run against a production cluster. + + To run: + Invoke-Pester -Path ./tests/PSProxmoxVE.Tests -Tag Integration + # or use the project's helper script: + ./Invoke-Tests.ps1 -Tier Integration +#> + +BeforeAll { + # ----------------------------------------------------------------------- + # Resolve and import module + # ----------------------------------------------------------------------- + $moduleRoot = Resolve-Path (Join-Path $PSScriptRoot '../../../src/PSProxmoxVE') + $dllCandidates = @( + Join-Path $moduleRoot 'bin/Debug/net8.0/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Release/net8.0/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Debug/net48/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Release/net48/PSProxmoxVE.dll' + ) + + $moduleDll = $dllCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + + if ($null -eq $moduleDll) { + throw "PSProxmoxVE.dll not found. Build the project before running Pester tests." + } + + Import-Module $moduleDll -Force -ErrorAction Stop + + # ----------------------------------------------------------------------- + # Check required environment variables + # ----------------------------------------------------------------------- + $requiredVars = @( + 'PVETEST_HOST', + 'PVETEST_PORT', + 'PVETEST_APITOKEN', + 'PVETEST_NODE', + 'PVETEST_STORAGE', + 'PVETEST_ISO_PATH' + ) + + $script:SkipReason = $null + + foreach ($var in $requiredVars) { + if (-not [System.Environment]::GetEnvironmentVariable($var)) { + $script:SkipReason = ( + "No live Proxmox VE target configured. " + + "Set environment variables: $($requiredVars -join ', ')" + ) + break + } + } + + # Convenience accessors (only meaningful when SkipReason is null) + $script:Host_ = [System.Environment]::GetEnvironmentVariable('PVETEST_HOST') + $script:Port = [int]([System.Environment]::GetEnvironmentVariable('PVETEST_PORT') ?? '8006') + $script:ApiToken = [System.Environment]::GetEnvironmentVariable('PVETEST_APITOKEN') + $script:Node = [System.Environment]::GetEnvironmentVariable('PVETEST_NODE') + $script:Storage = [System.Environment]::GetEnvironmentVariable('PVETEST_STORAGE') + $script:IsoPath = [System.Environment]::GetEnvironmentVariable('PVETEST_ISO_PATH') + + # Track resources created during the run so AfterAll can clean up. + $script:CreatedVmIds = [System.Collections.Generic.List[int]]::new() + $script:CreatedVmSnaps = [System.Collections.Generic.List[hashtable]]::new() +} + +AfterAll { + # Best-effort cleanup — remove any VMs created during the test run. + if ($null -eq $script:SkipReason -and $script:CreatedVmIds.Count -gt 0) { + foreach ($vmId in $script:CreatedVmIds) { + try { + Remove-PveVm -Node $script:Node -VmId $vmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } + catch { <# non-fatal #> } + } + } +} + +# =========================================================================== +# Integration Tests +# =========================================================================== +Describe 'Integration Tests' -Tag 'Integration' { + + # ----------------------------------------------------------------------- + Context 'Connection' { + It 'Should connect to PVE server' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $session = Connect-PveServer ` + -Server $script:Host_ ` + -Port $script:Port ` + -ApiToken $script:ApiToken ` + -SkipCertificateCheck ` + -PassThru + + $session | Should -Not -BeNullOrEmpty + $session.Hostname | Should -Be $script:Host_ + $session.AuthMode.ToString() | Should -Be 'ApiToken' + + Test-PveConnection | Should -BeTrue + } + + It 'Should detect server version' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $detail = Test-PveConnection -Detailed + $detail | Should -Not -BeNullOrEmpty + $detail.ServerVersion | Should -Not -BeNullOrEmpty + $detail.ServerVersion.Major | Should -BeIn @(8, 9) + } + } + + # ----------------------------------------------------------------------- + Context 'Nodes' { + It 'Should list nodes' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $nodes = Get-PveNode + $nodes | Should -Not -BeNullOrEmpty + } + + It 'Should get node status' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $status = Get-PveNodeStatus -Node $script:Node + $status | Should -Not -BeNullOrEmpty + $status.Node | Should -Be $script:Node + } + } + + # ----------------------------------------------------------------------- + Context 'VMs' { + It 'Should list VMs' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + # Does not assert count — the node may have zero VMs. + { Get-PveVm -Node $script:Node -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should create and remove a test VM' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $task = New-PveVm ` + -Node $script:Node ` + -Name 'pester-test-vm' ` + -Memory 512 ` + -Cores 1 ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + # Retrieve the new VM ID from the cluster. + $vm = Get-PveVm -Node $script:Node -Name 'pester-test-vm' | + Select-Object -First 1 + $vm | Should -Not -BeNullOrEmpty + + $script:CreatedVmIds.Add($vm.VmId) + + $removeTask = Remove-PveVm ` + -Node $script:Node ` + -VmId $vm.VmId ` + -Confirm:$false ` + -Wait + + $removeTask | Should -Not -BeNullOrEmpty + $script:CreatedVmIds.Remove($vm.VmId) | Out-Null + } + + It 'Should start and stop a VM' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + # Requires at least one VM on the test node; skip if none found. + $vm = Get-PveVm -Node $script:Node | Where-Object { $_.Status -eq 'stopped' } | + Select-Object -First 1 + + if ($null -eq $vm) { + Set-ItResult -Skipped -Because 'No stopped VM available on the test node' + return + } + + $startTask = Start-PveVm -Node $script:Node -VmId $vm.VmId -Wait + $startTask | Should -Not -BeNullOrEmpty + + $stopTask = Stop-PveVm -Node $script:Node -VmId $vm.VmId -Wait + $stopTask | Should -Not -BeNullOrEmpty + } + + It 'Should clone a VM' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $templateVm = Get-PveVm -Node $script:Node -TemplatesOnly | Select-Object -First 1 + + if ($null -eq $templateVm) { + Set-ItResult -Skipped -Because 'No template VM available on the test node' + return + } + + $task = Copy-PveVm ` + -SourceNode $script:Node ` + -VmId $templateVm.VmId ` + -NewName 'pester-clone-vm' ` + -Full ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + $cloned = Get-PveVm -Node $script:Node -Name 'pester-clone-vm' | + Select-Object -First 1 + $cloned | Should -Not -BeNullOrEmpty + + $script:CreatedVmIds.Add($cloned.VmId) + } + } + + # ----------------------------------------------------------------------- + Context 'Storage' { + It 'Should list storage' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $storage = Get-PveStorage -Node $script:Node + $storage | Should -Not -BeNullOrEmpty + } + + It 'Should upload an ISO' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + if (-not (Test-Path $script:IsoPath)) { + Set-ItResult -Skipped -Because "ISO file not found at PVETEST_ISO_PATH: $($script:IsoPath)" + return + } + + { + Send-PveIso ` + -Node $script:Node ` + -Storage $script:Storage ` + -Path $script:IsoPath ` + -ErrorAction Stop + } | Should -Not -Throw + } + } + + # ----------------------------------------------------------------------- + Context 'Snapshots' { + It 'Should create and remove a snapshot' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $vm = Get-PveVm -Node $script:Node | Where-Object { $_.Status -eq 'stopped' } | + Select-Object -First 1 + + if ($null -eq $vm) { + Set-ItResult -Skipped -Because 'No stopped VM available for snapshot test' + return + } + + $snapName = 'pester-snap' + + $createTask = New-PveSnapshot ` + -Node $script:Node ` + -VmId $vm.VmId ` + -Name $snapName ` + -Description 'Created by Pester integration test' ` + -Wait + + $createTask | Should -Not -BeNullOrEmpty + + $snapshots = Get-PveSnapshot -Node $script:Node -VmId $vm.VmId + $snapshots | Where-Object { $_.Name -eq $snapName } | Should -Not -BeNullOrEmpty + + $removeTask = Remove-PveSnapshot ` + -Node $script:Node ` + -VmId $vm.VmId ` + -Name $snapName ` + -Confirm:$false ` + -Wait + + $removeTask | Should -Not -BeNullOrEmpty + } + } + + # ----------------------------------------------------------------------- + Context 'Network' { + It 'Should list networks' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $networks = Get-PveNetwork -Node $script:Node + $networks | Should -Not -BeNullOrEmpty + } + } + + # ----------------------------------------------------------------------- + Context 'Users' { + It 'Should list users' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $users = Get-PveUser + $users | Should -Not -BeNullOrEmpty + # root@pam is always present + $users | Where-Object { $_.UserId -eq 'root@pam' } | Should -Not -BeNullOrEmpty + } + } + + # ----------------------------------------------------------------------- + Context 'Templates' { + It 'Should list templates' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + # Zero templates is acceptable; just verify the cmdlet does not throw. + { Get-PveTemplate -Node $script:Node -ErrorAction Stop } | Should -Not -Throw + } + } + + # ----------------------------------------------------------------------- + Context 'Cloud-Init' { + It 'Should get cloud-init config' { + if ($script:SkipReason) { Set-ItResult -Skipped -Because $script:SkipReason; return } + + $ciVm = Get-PveVm -Node $script:Node | Where-Object { $_.Status -eq 'stopped' } | + Select-Object -First 1 + + if ($null -eq $ciVm) { + Set-ItResult -Skipped -Because 'No stopped VM available for cloud-init test' + return + } + + # Get-PveCloudInitConfig should not throw even if the VM has no cloud-init drive. + { Get-PveCloudInitConfig -Node $script:Node -VmId $ciVm.VmId -ErrorAction Stop } | + Should -Not -Throw + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/README.md b/tests/PSProxmoxVE.Tests/Integration/README.md new file mode 100644 index 0000000..9c63c72 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/README.md @@ -0,0 +1,166 @@ +# Integration Tests + +This directory contains Pester 5 integration tests for PSProxmoxVE that exercise the module +against a **real, live Proxmox VE API endpoint**. They are skipped by default and must be +opted into explicitly. + +--- + +## Prerequisites + +### Dedicated test node — never production + +Integration tests **create and destroy real resources** (VMs, snapshots, ISO uploads, network +objects, user accounts). You must use a dedicated PVE test cluster or standalone node running +Proxmox VE **8.x or 9.x**. Never point these tests at a production cluster. + +Recommended minimum: +- Single-node cluster or standalone host +- At least one storage pool with `images`, `iso`, and `rootdir` content types enabled +- Network access from the machine running Pester to the PVE API port (default 8006) + +--- + +## Required API Token + +Create a dedicated API token on the test node with the permissions listed below. +Using a scoped token (rather than the root password) limits blast radius if credentials leak. + +``` +pveum user add pester@pve +pveum acl modify / --users pester@pve --roles PVEAdmin +pveum user token add pester@pve pester-ci +``` + +### Minimum permissions per domain + +| Test area | Required privilege(s) | +|----------------|----------------------------------------------------------------| +| Connection | `Sys.Audit` | +| Nodes | `Sys.Audit` | +| VMs (read) | `VM.Audit` | +| VMs (create) | `VM.Allocate`, `VM.Config.Disk`, `VM.Config.Memory`, `VM.Config.Network` | +| VMs (delete) | `VM.Allocate` | +| VMs (power) | `VM.PowerMgmt` | +| VMs (clone) | `VM.Clone` | +| Storage (read) | `Datastore.Audit` | +| Storage (ISO) | `Datastore.AllocateSpace`, `Datastore.AllocateTemplate` | +| Snapshots | `VM.Snapshot`, `VM.Snapshot.Rollback` | +| Network | `Sys.Modify` | +| Users | `User.Modify` | +| Templates | `VM.Allocate`, `VM.Clone` | +| Cloud-Init | `VM.Config.CloudInit` | + +--- + +## Environment Variables + +Set these before running the integration suite. All six are required; any missing variable +causes every integration test to be skipped with a clear reason message. + +| Variable | Description | Example value | +|---------------------|-----------------------------------------------------------------------|-----------------------------------------------------| +| `PVETEST_HOST` | Hostname or IP address of the test PVE node | `192.168.1.10` or `pve-test.internal` | +| `PVETEST_PORT` | PVE API port | `8006` | +| `PVETEST_APITOKEN` | API token in `USER@REALM!TOKENID=UUID` format | `pester@pve!pester-ci=xxxxxxxx-xxxx-xxxx-xxxx-xxxx` | +| `PVETEST_NODE` | Node name as it appears in `pvesh get /nodes` | `pve-test1` | +| `PVETEST_STORAGE` | Storage pool to use for disk and ISO operations | `local` | +| `PVETEST_ISO_PATH` | Local path to a small `.iso` file used for upload tests | `/tmp/tinycorelinux.iso` | + +### Setting variables (Bash / zsh) + +```bash +export PVETEST_HOST="192.168.1.10" +export PVETEST_PORT="8006" +export PVETEST_APITOKEN="pester@pve!pester-ci=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +export PVETEST_NODE="pve-test1" +export PVETEST_STORAGE="local" +export PVETEST_ISO_PATH="/tmp/tinycorelinux.iso" +``` + +### Setting variables (PowerShell) + +```powershell +$env:PVETEST_HOST = '192.168.1.10' +$env:PVETEST_PORT = '8006' +$env:PVETEST_APITOKEN = 'pester@pve!pester-ci=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +$env:PVETEST_NODE = 'pve-test1' +$env:PVETEST_STORAGE = 'local' +$env:PVETEST_ISO_PATH = '/tmp/tinycorelinux.iso' +``` + +### GitHub Actions / CI + +Add the variables as repository **Actions secrets** and expose them as environment variables +in your workflow: + +```yaml +env: + PVETEST_HOST: ${{ secrets.PVETEST_HOST }} + PVETEST_PORT: ${{ secrets.PVETEST_PORT }} + PVETEST_APITOKEN: ${{ secrets.PVETEST_APITOKEN }} + PVETEST_NODE: ${{ secrets.PVETEST_NODE }} + PVETEST_STORAGE: ${{ secrets.PVETEST_STORAGE }} + PVETEST_ISO_PATH: ${{ secrets.PVETEST_ISO_PATH }} +``` + +--- + +## How to Run + +The integration suite is tagged `Integration`. Use the `-Tag` filter so that the unit +tests and integration tests can be run independently. + +### Via the project helper script (recommended) + +```powershell +./Invoke-Tests.ps1 -Tier Integration +``` + +### Directly with Invoke-Pester + +```powershell +# Run integration tests only +Invoke-Pester -Path ./tests/PSProxmoxVE.Tests -Tag Integration -Output Detailed + +# Run everything (unit + integration) +Invoke-Pester -Path ./tests/PSProxmoxVE.Tests -Output Detailed + +# Run unit tests only (exclude integration) +Invoke-Pester -Path ./tests/PSProxmoxVE.Tests -ExcludeTag Integration -Output Detailed +``` + +--- + +## Warning — Real Resources Are Created and Destroyed + +The integration tests: + +- **Create VMs** (named `pester-test-vm`, `pester-clone-vm`) on `PVETEST_NODE` +- **Delete** those VMs after the test completes (via `AfterAll` cleanup) +- **Start and stop** an existing stopped VM if one is available +- **Create and delete a snapshot** on an existing stopped VM +- **Upload an ISO** to `PVETEST_STORAGE` + +The `AfterAll` block performs best-effort cleanup. If the test run is interrupted, leftover +VMs named `pester-*` may remain on the test node and should be removed manually. + +**Always confirm you are pointing at the correct, isolated test node before running.** + +--- + +## Planned Test Coverage + +| Domain | Current integration tests | Planned additions | +|----------------|--------------------------------------------------------------------------------|----------------------------------------------------------------| +| Connection | Connect via API token, detect server version | Connect via credential (ticket auth), session expiry handling | +| Nodes | List nodes, get node status | Node resource usage metrics | +| VMs | List, create, delete, start, stop, clone | Migrate, resize disk, Get/Set-PveVmConfig, Move-PveVm | +| Storage | List, upload ISO | Get-PveStorageContent, Invoke-PveStorageDownload, Remove ISO | +| Snapshots | Create, list, delete snapshot on stopped VM | Restore snapshot, snapshots on running VM (with vmstate) | +| Network | List node networks | Create bridge, Set-PveNetwork, Invoke-PveNetworkApply | +| SDN | _(none yet — requires SDN plugin enabled on test node)_ | Get/New/Remove zone and vnet | +| Users | List users, verify root@pam present | Create/remove user, assign role, set permission | +| Templates | List templates (count not asserted) | Convert VM to template, deploy VM from template | +| Cloud-Init | Get cloud-init config from a stopped VM (no-throw assertion) | Set cloud-init fields, verify propagation via VM config | +| Tasks | _(implicitly exercised via -Wait on lifecycle cmdlets)_ | Get-PveTask, Wait-PveTask with custom timeout |