Pulse/pkg/proxmox/client_test.go
2026-03-28 13:33:48 +00:00

1638 lines
42 KiB
Go

package proxmox
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
func TestDiskUnmarshalWearout(t *testing.T) {
tests := []struct {
name string
wearout json.RawMessage
expected int
}{
{
name: "numeric",
wearout: json.RawMessage(`81`),
expected: 81,
},
{
name: "numeric string",
wearout: json.RawMessage(`"81"`),
expected: 81,
},
{
name: "escaped numeric string",
wearout: json.RawMessage(`"\"81\""`),
expected: 81,
},
{
name: "percentage string",
wearout: json.RawMessage(`"81%"`),
expected: 81,
},
{
name: "percentage with spaces",
wearout: json.RawMessage(`" 82 % "`),
expected: 82,
},
{
name: "not applicable string",
wearout: json.RawMessage(`"N/A"`),
expected: wearoutUnknown,
},
{
name: "empty string",
wearout: json.RawMessage(`""`),
expected: wearoutUnknown,
},
{
name: "null value",
wearout: json.RawMessage(`null`),
expected: wearoutUnknown,
},
{
name: "unknown string",
wearout: json.RawMessage(`"Unknown"`),
expected: wearoutUnknown,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
payload := fmt.Sprintf(`{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":%s,"size":1000,"rpm":7200,"used":"LVM","vendor":"Example","wwn":"example"}`, tc.wearout)
var disk Disk
if err := json.Unmarshal([]byte(payload), &disk); err != nil {
t.Fatalf("unexpected error unmarshalling disk: %v", err)
}
if disk.Wearout != tc.expected {
t.Fatalf("wearout: got %d, want %d", disk.Wearout, tc.expected)
}
})
}
}
func TestDiskUnmarshalRPM(t *testing.T) {
tests := []struct {
name string
rpm json.RawMessage
expected int
}{
{
name: "numeric",
rpm: json.RawMessage(`7200`),
expected: 7200,
},
{
name: "numeric string",
rpm: json.RawMessage(`"5400"`),
expected: 5400,
},
{
name: "ssd string",
rpm: json.RawMessage(`"SSD"`),
expected: 0,
},
{
name: "ssd lowercase",
rpm: json.RawMessage(`"ssd"`),
expected: 0,
},
{
name: "na string",
rpm: json.RawMessage(`"N/A"`),
expected: 0,
},
{
name: "empty string",
rpm: json.RawMessage(`""`),
expected: 0,
},
{
name: "null value",
rpm: json.RawMessage(`null`),
expected: 0,
},
{
name: "invalid string",
rpm: json.RawMessage(`"unknown-value"`),
expected: 0,
},
{
name: "string with spaces",
rpm: json.RawMessage(`" 7200 "`),
expected: 7200,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
payload := fmt.Sprintf(`{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":50,"size":1000,"rpm":%s,"used":"LVM","vendor":"Example","wwn":"example"}`, tc.rpm)
var disk Disk
if err := json.Unmarshal([]byte(payload), &disk); err != nil {
t.Fatalf("unexpected error unmarshalling disk: %v", err)
}
if disk.RPM != tc.expected {
t.Fatalf("rpm: got %d, want %d", disk.RPM, tc.expected)
}
})
}
}
func TestParseTagColorMap(t *testing.T) {
tests := []struct {
name string
tagStyle string
expected map[string]string
}{
{
name: "parses documented proxmox background and text color format",
tagStyle: "color-map=Production:000000:FFFFFF;staging:ffaa00:101010,ordering=config",
expected: map[string]string{
"production": "#000000",
"staging": "#ffaa00",
},
},
{
name: "parses legacy single-color entries with leading hash",
tagStyle: "ordering=config,color-map=backup:#ABCDEF;ops:123456",
expected: map[string]string{
"backup": "#abcdef",
"ops": "#123456",
},
},
{
name: "ignores invalid color tokens",
tagStyle: "color-map=good:00ff00;bad:zzzzzz;also-bad:12345",
expected: map[string]string{
"good": "#00ff00",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := ParseTagColorMap(tc.tagStyle); !reflect.DeepEqual(got, tc.expected) {
t.Fatalf("ParseTagColorMap() = %#v, want %#v", got, tc.expected)
}
})
}
}
func TestDiskUnmarshalJSON_InvalidJSON(t *testing.T) {
var disk Disk
err := json.Unmarshal([]byte(`{invalid json`), &disk)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestDiskUnmarshalJSON_UnexpectedWearoutType(t *testing.T) {
// Test with boolean wearout value (unexpected type)
payload := `{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":true,"size":1000,"rpm":7200}`
var disk Disk
if err := json.Unmarshal([]byte(payload), &disk); err != nil {
t.Fatalf("unexpected error unmarshalling disk: %v", err)
}
// Unexpected type should normalize to unknown
if disk.Wearout != wearoutUnknown {
t.Fatalf("wearout: got %d, want %d (unknown)", disk.Wearout, wearoutUnknown)
}
}
func TestDiskUnmarshalJSON_UnexpectedRPMType(t *testing.T) {
// Test with boolean rpm value (unexpected type)
payload := `{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":50,"size":1000,"rpm":true}`
var disk Disk
if err := json.Unmarshal([]byte(payload), &disk); err != nil {
t.Fatalf("unexpected error unmarshalling disk: %v", err)
}
// Unexpected type should normalize to 0
if disk.RPM != 0 {
t.Fatalf("rpm: got %d, want 0", disk.RPM)
}
}
func TestVMFileSystemUnmarshalFlexibleNumbers(t *testing.T) {
t.Run("accepts numeric values", func(t *testing.T) {
payload := `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":8589934592,"used-bytes":3221225472,"disk":[{"dev":"/dev/vtbd0p2"}]}`
var fs VMFileSystem
if err := json.Unmarshal([]byte(payload), &fs); err != nil {
t.Fatalf("unexpected error unmarshalling numeric fields: %v", err)
}
if fs.TotalBytes != 8589934592 || fs.UsedBytes != 3221225472 {
t.Fatalf("unexpected numeric values: got total=%d used=%d", fs.TotalBytes, fs.UsedBytes)
}
})
t.Run("accepts numeric strings", func(t *testing.T) {
payload := `{"name":"rootfs","type":"ufs","mountpoint":"/","total-bytes":"5368709120","used-bytes":"2147483648","disk":[{"dev":"/dev/vtbd0p3"}]}`
var fs VMFileSystem
if err := json.Unmarshal([]byte(payload), &fs); err != nil {
t.Fatalf("unexpected error unmarshalling string fields: %v", err)
}
if fs.TotalBytes != 5368709120 || fs.UsedBytes != 2147483648 {
t.Fatalf("unexpected string values: got total=%d used=%d", fs.TotalBytes, fs.UsedBytes)
}
})
t.Run("accepts float-like strings", func(t *testing.T) {
payload := `{"name":"rootfs","type":"ufs","mountpoint":"/","total-bytes":"1073741824.0","used-bytes":"536870912.0","disk":[{"dev":"/dev/vtbd0p4"}]}`
var fs VMFileSystem
if err := json.Unmarshal([]byte(payload), &fs); err != nil {
t.Fatalf("unexpected error unmarshalling float-like strings: %v", err)
}
if fs.TotalBytes != 1073741824 || fs.UsedBytes != 536870912 {
t.Fatalf("unexpected float string values: got total=%d used=%d", fs.TotalBytes, fs.UsedBytes)
}
})
t.Run("falls back to total-bytes-privileged when total-bytes is missing", func(t *testing.T) {
payload := `{"name":"windows","type":"ntfs","mountpoint":"C:\\\\","total-bytes-privileged":536870912000,"used-bytes":214748364800}`
var fs VMFileSystem
if err := json.Unmarshal([]byte(payload), &fs); err != nil {
t.Fatalf("unexpected error unmarshalling privileged total-bytes: %v", err)
}
if fs.TotalBytes != 536870912000 || fs.TotalBytesPrivileged != 536870912000 || fs.UsedBytes != 214748364800 {
t.Fatalf("unexpected privileged values: got total=%d privileged=%d used=%d", fs.TotalBytes, fs.TotalBytesPrivileged, fs.UsedBytes)
}
})
t.Run("uses windows name as mountpoint fallback when mountpoint is empty", func(t *testing.T) {
payload := `{"name":"C:\\Windows","type":"ntfs","mountpoint":"","total-bytes":536870912000,"used-bytes":214748364800}`
var fs VMFileSystem
if err := json.Unmarshal([]byte(payload), &fs); err != nil {
t.Fatalf("unexpected error unmarshalling windows name fallback: %v", err)
}
if fs.Mountpoint != "C:\\Windows" {
t.Fatalf("expected windows mountpoint fallback from name, got %q", fs.Mountpoint)
}
})
t.Run("accepts disk metadata as an object", func(t *testing.T) {
payload := `{"name":"rootfs","type":"ext4","mountpoint":"/","total-bytes":1000,"used-bytes":200,"disk":{"dev":"/dev/sda"}}`
var fs VMFileSystem
if err := json.Unmarshal([]byte(payload), &fs); err != nil {
t.Fatalf("unexpected error unmarshalling disk object: %v", err)
}
if len(fs.DiskRaw) != 1 {
t.Fatalf("expected disk metadata object to normalize to one entry, got %d", len(fs.DiskRaw))
}
if diskMap, ok := fs.DiskRaw[0].(map[string]interface{}); !ok || diskMap["dev"] != "/dev/sda" {
t.Fatalf("expected normalized disk metadata to preserve dev, got %#v", fs.DiskRaw[0])
}
})
}
func TestVMFileSystemUnmarshalJSON_InvalidValues(t *testing.T) {
tests := []struct {
name string
payload string
wantErr bool
}{
{
name: "invalid JSON",
payload: `{invalid json}`,
wantErr: true,
},
{
name: "invalid total-bytes field type",
payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":true,"used-bytes":1000}`,
wantErr: true,
},
{
name: "invalid used-bytes field type",
payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":1000,"used-bytes":[1,2,3]}`,
wantErr: true,
},
{
name: "invalid string value for total-bytes",
payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":"not-a-number","used-bytes":1000}`,
wantErr: true,
},
{
name: "invalid string value for used-bytes",
payload: `{"name":"rootfs","type":"zfs","mountpoint":"/","total-bytes":1000,"used-bytes":"invalid"}`,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var fs VMFileSystem
err := json.Unmarshal([]byte(tc.payload), &fs)
if tc.wantErr && err == nil {
t.Errorf("expected error for %s, got nil", tc.name)
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error for %s: %v", tc.name, err)
}
})
}
}
func TestMemoryStatusEffectiveAvailable(t *testing.T) {
t.Run("nil receiver returns zero", func(t *testing.T) {
var status *MemoryStatus
if status.EffectiveAvailable() != 0 {
t.Fatalf("expected nil receiver to return 0")
}
})
tests := []struct {
name string
status MemoryStatus
want uint64
}{
{
name: "uses available field when set",
status: MemoryStatus{Total: 16 * 1024, Available: 6 * 1024},
want: 6 * 1024,
},
{
name: "uses avail field fallback",
status: MemoryStatus{Total: 16 * 1024, Avail: 5 * 1024},
want: 5 * 1024,
},
{
name: "derives from free buffers cached",
status: MemoryStatus{Total: 32 * 1024, Free: 4 * 1024, Buffers: 2 * 1024, Cached: 6 * 1024},
want: 12 * 1024,
},
{
name: "available zero but buffers and cache present",
status: MemoryStatus{
Total: 64 * 1024,
Used: 40 * 1024,
Free: 6 * 1024,
Buffers: 8 * 1024,
Cached: 10 * 1024,
},
want: 24 * 1024,
},
{
name: "caps derived value at total",
status: MemoryStatus{Total: 8 * 1024, Free: 4 * 1024, Buffers: 4 * 1024, Cached: 4 * 1024},
want: 8 * 1024,
},
{
name: "returns zero when no data",
status: MemoryStatus{Total: 24 * 1024},
want: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.status.EffectiveAvailable(); got != tc.want {
t.Fatalf("EffectiveAvailable: got %d, want %d", got, tc.want)
}
})
}
}
func TestMemoryStatusUnmarshalFlexibleValues(t *testing.T) {
tests := []struct {
name string
payload string
want MemoryStatus
}{
{
name: "numeric strings",
payload: `{"total":"16549875712","used":"6050492416","free":"2467389440","available":"10499383296","buffers":"0","cached":"0","shared":"0"}`,
want: MemoryStatus{
Total: 16549875712,
Used: 6050492416,
Free: 2467389440,
Available: 10499383296,
Buffers: 0,
Cached: 0,
Shared: 0,
},
},
{
name: "scientific notation",
payload: `{"total":1.6549875712e+10,"used":6.050492416e+09,"free":2.46738944e+09,"available":1.0499383296e+10,"buffers":0,"cached":0,"shared":0}`,
want: MemoryStatus{
Total: 16549875712,
Used: 6050492416,
Free: 2467389440,
Available: 10499383296,
},
},
{
name: "float-like strings with spaces",
payload: `{"total":" 8589934592.0 ","used":"3221225472.0","free":"536870912.0","avail":"4831838208.0","buffers":"67108864","cached":"4026531840","shared":"0"}`,
want: MemoryStatus{
Total: 8589934592,
Used: 3221225472,
Free: 536870912,
Avail: 4831838208,
Buffers: 67108864,
Cached: 4026531840,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var status MemoryStatus
if err := json.Unmarshal([]byte(tc.payload), &status); err != nil {
t.Fatalf("unexpected error unmarshalling %s payload: %v", tc.name, err)
}
if status.Total != tc.want.Total {
t.Fatalf("total: got %d, want %d", status.Total, tc.want.Total)
}
if status.Used != tc.want.Used {
t.Fatalf("used: got %d, want %d", status.Used, tc.want.Used)
}
if status.Free != tc.want.Free {
t.Fatalf("free: got %d, want %d", status.Free, tc.want.Free)
}
if status.Available != tc.want.Available {
t.Fatalf("available: got %d, want %d", status.Available, tc.want.Available)
}
if status.Avail != tc.want.Avail {
t.Fatalf("avail: got %d, want %d", status.Avail, tc.want.Avail)
}
if status.Buffers != tc.want.Buffers {
t.Fatalf("buffers: got %d, want %d", status.Buffers, tc.want.Buffers)
}
if status.Cached != tc.want.Cached {
t.Fatalf("cached: got %d, want %d", status.Cached, tc.want.Cached)
}
if status.Shared != tc.want.Shared {
t.Fatalf("shared: got %d, want %d", status.Shared, tc.want.Shared)
}
})
}
}
func TestMemoryStatusUnmarshalJSON_InvalidValues(t *testing.T) {
tests := []struct {
name string
payload string
wantErr bool
}{
{
name: "invalid JSON",
payload: `{invalid json}`,
wantErr: true,
},
{
name: "invalid total field type",
payload: `{"total":true,"used":1000,"free":500}`,
wantErr: true,
},
{
name: "invalid used field type",
payload: `{"total":1000,"used":[1,2,3],"free":500}`,
wantErr: true,
},
{
name: "invalid free field type",
payload: `{"total":1000,"used":500,"free":{"nested":"object"}}`,
wantErr: true,
},
{
name: "invalid available field type",
payload: `{"total":1000,"used":500,"free":300,"available":true}`,
wantErr: true,
},
{
name: "invalid string value for total",
payload: `{"total":"not-a-number","used":500}`,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var status MemoryStatus
err := json.Unmarshal([]byte(tc.payload), &status)
if tc.wantErr && err == nil {
t.Errorf("expected error for %s, got nil", tc.name)
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error for %s: %v", tc.name, err)
}
})
}
}
func TestFlexIntUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want FlexInt
wantErr bool
}{
{
name: "integer",
input: "42",
want: FlexInt(42),
},
{
name: "zero",
input: "0",
want: FlexInt(0),
},
{
name: "negative integer",
input: "-10",
want: FlexInt(-10),
},
{
name: "float truncates",
input: "1.5",
want: FlexInt(1),
},
{
name: "float 2.9 truncates",
input: "2.9",
want: FlexInt(2),
},
{
name: "string integer",
input: `"123"`,
want: FlexInt(123),
},
{
name: "string float",
input: `"1.5"`,
want: FlexInt(1),
},
{
name: "string float 3.7",
input: `"3.7"`,
want: FlexInt(3),
},
{
name: "large integer",
input: "1000000",
want: FlexInt(1000000),
},
{
name: "float overflow",
input: "1e309",
wantErr: true,
},
{
name: "string float overflow",
input: `"1e309"`,
wantErr: true,
},
{
name: "invalid string",
input: `"not a number"`,
wantErr: true,
},
{
name: "empty string",
input: `""`,
wantErr: true,
},
{
name: "invalid json",
input: `{invalid}`,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var f FlexInt
err := f.UnmarshalJSON([]byte(tc.input))
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f != tc.want {
t.Fatalf("got %d, want %d", f, tc.want)
}
})
}
}
func TestVMIpAddressUnmarshalJSONCapsPrefix(t *testing.T) {
var addr VMIpAddress
if err := addr.UnmarshalJSON([]byte(`{"ip-address":"2001:db8::1","prefix":"18446744073709551615"}`)); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if addr.Address != "2001:db8::1" {
t.Fatalf("Address = %q", addr.Address)
}
if addr.Prefix != 128 {
t.Fatalf("Prefix = %d, want 128", addr.Prefix)
}
}
func TestParseUint64Flexible(t *testing.T) {
tests := []struct {
name string
value interface{}
want uint64
wantErr bool
}{
// nil
{name: "nil", value: nil, want: 0},
// uint64
{name: "uint64", value: uint64(42), want: 42},
{name: "uint64 max", value: uint64(18446744073709551615), want: 18446744073709551615},
// int
{name: "int positive", value: int(100), want: 100},
{name: "int zero", value: int(0), want: 0},
{name: "int negative", value: int(-5), want: 0},
// int64
{name: "int64 positive", value: int64(200), want: 200},
{name: "int64 negative", value: int64(-10), want: 0},
// float64
{name: "float64 positive", value: float64(3.7), want: 3},
{name: "float64 truncates down", value: float64(9.9), want: 9},
{name: "float64 negative", value: float64(-1.5), want: 0},
{name: "float64 zero", value: float64(0.0), want: 0},
// json.Number
{name: "json.Number integer", value: json.Number("99"), want: 99},
{name: "json.Number float", value: json.Number("3.14"), want: 3},
{name: "json.Number invalid", value: json.Number("abc"), wantErr: true},
// string - empty/whitespace
{name: "string empty", value: "", want: 0},
{name: "string whitespace", value: " ", want: 0},
// string - decimal
{name: "string decimal", value: "12345", want: 12345},
{name: "string with whitespace", value: " 678 ", want: 678},
{name: "string invalid decimal", value: "abc", wantErr: true},
{name: "string negative decimal", value: "-100", wantErr: true},
// string - hex
{name: "string hex lowercase", value: "0x10", want: 16},
{name: "string hex uppercase", value: "0X1F", want: 31},
{name: "string hex invalid", value: "0xGG", wantErr: true},
// string - float notation
{name: "string float", value: "3.14", want: 3},
{name: "string scientific", value: "1e3", want: 1000},
{name: "string scientific uppercase", value: "1.5E2", want: 150},
{name: "string negative float", value: "-2.5", want: 0},
{name: "string invalid float", value: "1.2.3", wantErr: true},
// unsupported type
{name: "unsupported bool", value: true, wantErr: true},
{name: "unsupported slice", value: []int{1, 2}, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseUint64Flexible(tc.value)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil with value %d", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %d, want %d", got, tc.want)
}
})
}
}
func TestCoerceUint64(t *testing.T) {
tests := []struct {
name string
field string
value interface{}
want uint64
wantErr bool
}{
// nil handling
{
name: "nil returns zero",
field: "test",
value: nil,
want: 0,
},
// float64 handling
{
name: "float64 positive",
field: "test",
value: float64(100.0),
want: 100,
},
{
name: "float64 rounds",
field: "test",
value: float64(100.6),
want: 101,
},
{
name: "float64 negative returns zero",
field: "test",
value: float64(-10),
want: 0,
},
{
name: "float64 zero",
field: "test",
value: float64(0),
want: 0,
},
{
name: "float64 NaN returns error",
field: "test",
value: math.NaN(),
wantErr: true,
},
{
name: "float64 positive infinity returns error",
field: "test",
value: math.Inf(1),
wantErr: true,
},
// int handling
{
name: "int positive",
field: "test",
value: int(42),
want: 42,
},
{
name: "int negative returns zero",
field: "test",
value: int(-5),
want: 0,
},
{
name: "int zero",
field: "test",
value: int(0),
want: 0,
},
// int64 handling
{
name: "int64 positive",
field: "test",
value: int64(1000000000000),
want: 1000000000000,
},
{
name: "int64 negative returns zero",
field: "test",
value: int64(-100),
want: 0,
},
// int32 handling
{
name: "int32 positive",
field: "test",
value: int32(12345),
want: 12345,
},
{
name: "int32 negative returns zero",
field: "test",
value: int32(-1),
want: 0,
},
// uint32 handling
{
name: "uint32",
field: "test",
value: uint32(4294967295),
want: 4294967295,
},
// uint64 handling
{
name: "uint64",
field: "test",
value: uint64(18446744073709551615),
want: 18446744073709551615,
},
// json.Number handling
{
name: "json.Number integer",
field: "test",
value: json.Number("12345"),
want: 12345,
},
{
name: "json.Number float",
field: "test",
value: json.Number("123.45"),
want: 123,
},
// string handling
{
name: "string integer",
field: "test",
value: "12345",
want: 12345,
},
{
name: "string with whitespace",
field: "test",
value: " 12345 ",
want: 12345,
},
{
name: "string empty",
field: "test",
value: "",
want: 0,
},
{
name: "string null",
field: "test",
value: "null",
want: 0,
},
{
name: "string NULL uppercase",
field: "test",
value: "NULL",
want: 0,
},
{
name: "string with quotes",
field: "test",
value: `"12345"`,
want: 12345,
},
{
name: "string with single quotes",
field: "test",
value: `'12345'`,
want: 12345,
},
{
name: "string with commas",
field: "test",
value: "1,000,000",
want: 1000000,
},
{
name: "string float notation",
field: "test",
value: "123.45",
want: 123,
},
{
name: "string scientific notation",
field: "test",
value: "1e6",
want: 1000000,
},
{
name: "string scientific notation uppercase",
field: "test",
value: "1E6",
want: 1000000,
},
{
name: "string invalid",
field: "test",
value: "not a number",
wantErr: true,
},
{
name: "string invalid float in scientific notation",
field: "test",
value: "1.2.3e4",
wantErr: true,
},
// String without .eE that fails ParseUint (covers the ParseUint error branch)
{
name: "string invalid no decimal chars",
field: "test",
value: "abc",
wantErr: true,
},
{
name: "string quoted null",
field: "test",
value: `"null"`,
want: 0,
},
{
name: "string quoted empty",
field: "test",
value: `""`,
want: 0,
},
{
name: "string single quoted empty",
field: "test",
value: `''`,
want: 0,
},
{
name: "float64 at MaxUint64 boundary",
field: "test",
value: float64(math.MaxUint64),
want: math.MaxUint64,
},
{
name: "float64 exceeding MaxUint64",
field: "test",
value: float64(math.MaxUint64) * 2,
want: math.MaxUint64,
},
// unsupported type
{
name: "unsupported type bool",
field: "test",
value: true,
wantErr: true,
},
{
name: "unsupported type slice",
field: "test",
value: []int{1, 2, 3},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := coerceUint64(tc.field, tc.value)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %d, want %d", got, tc.want)
}
})
}
}
func TestParseWearoutValue(t *testing.T) {
tests := []struct {
name string
raw string
want int
}{
// Empty and whitespace
{name: "empty string", raw: "", want: wearoutUnknown},
{name: "whitespace only", raw: " ", want: wearoutUnknown},
{name: "tab whitespace", raw: "\t\n", want: wearoutUnknown},
// Simple numeric strings
{name: "zero", raw: "0", want: 0},
{name: "simple integer", raw: "81", want: 81},
{name: "integer with leading space", raw: " 75", want: 75},
{name: "integer with trailing space", raw: "90 ", want: 90},
{name: "integer with surrounding space", raw: " 42 ", want: 42},
{name: "100 percent", raw: "100", want: 100},
// Quoted values (API sometimes wraps values in quotes)
{name: "double quoted", raw: `"81"`, want: 81},
{name: "single quoted", raw: `'75'`, want: 75},
{name: "escaped quotes", raw: `\"81\"`, want: 81},
{name: "double escaped quotes", raw: `"\"90\""`, want: 90},
{name: "mixed quote styles", raw: `"'50'"`, want: 50},
// Percentage symbols
{name: "percentage symbol", raw: "81%", want: 81},
{name: "percentage with space before", raw: "82 %", want: 82},
{name: "percentage with space after", raw: "83% ", want: 83},
{name: "quoted percentage", raw: `"75%"`, want: 75},
// N/A and similar
{name: "N/A uppercase", raw: "N/A", want: wearoutUnknown},
{name: "n/a lowercase", raw: "n/a", want: wearoutUnknown},
{name: "NA no slash", raw: "NA", want: wearoutUnknown},
{name: "na lowercase no slash", raw: "na", want: wearoutUnknown},
{name: "none", raw: "none", want: wearoutUnknown},
{name: "None capitalized", raw: "None", want: wearoutUnknown},
{name: "NONE uppercase", raw: "NONE", want: wearoutUnknown},
{name: "unknown", raw: "unknown", want: wearoutUnknown},
{name: "Unknown capitalized", raw: "Unknown", want: wearoutUnknown},
{name: "UNKNOWN uppercase", raw: "UNKNOWN", want: wearoutUnknown},
{name: "quoted N/A", raw: `"N/A"`, want: wearoutUnknown},
// Float values
{name: "float value truncated", raw: "81.5", want: 81},
{name: "float zero decimal", raw: "90.0", want: 90},
{name: "float high precision", raw: "75.999", want: 75},
{name: "negative float", raw: "-5.5", want: -5},
{name: "float zero exactly", raw: "0.0", want: 0},
{name: "float negative zero", raw: "-0.0", want: 0},
// Quoted values that become empty after trimming
{name: "only escaped quotes", raw: `\"\"`, want: wearoutUnknown},
{name: "quoted whitespace", raw: `" "`, want: wearoutUnknown},
// Digit extraction fallback (messy SMART data)
{name: "percentage text mixed", raw: "about 50 percent", want: 50},
{name: "text with digits", raw: "wear level 25 remaining", want: 25},
{name: "complex messy string", raw: "SSD: 15% endurance", want: 15},
// Edge cases
{name: "negative value", raw: "-1", want: -1},
{name: "large value", raw: "999", want: 999},
// Non-parseable strings (no digits at all)
{name: "no digits text", raw: "not available", want: wearoutUnknown},
{name: "symbols only", raw: "---", want: wearoutUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := parseWearoutValue(tc.raw)
if got != tc.want {
t.Errorf("parseWearoutValue(%q) = %d, want %d", tc.raw, got, tc.want)
}
})
}
}
func TestClampWearoutConsumed(t *testing.T) {
tests := []struct {
name string
val int
want int
}{
// Unknown value passthrough
{name: "unknown passthrough", val: wearoutUnknown, want: wearoutUnknown},
// Normal range
{name: "zero", val: 0, want: 0},
{name: "middle value", val: 50, want: 50},
{name: "max valid", val: 100, want: 100},
// Clamping negative
{name: "negative clamped to zero", val: -5, want: 0},
{name: "large negative clamped", val: -100, want: 0},
// Clamping over 100
{name: "over 100 clamped", val: 105, want: 100},
{name: "way over 100", val: 999, want: 100},
// Edge cases
{name: "just under unknown", val: -2, want: 0},
{name: "one", val: 1, want: 1},
{name: "ninety-nine", val: 99, want: 99},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := clampWearoutConsumed(tc.val)
if got != tc.want {
t.Errorf("clampWearoutConsumed(%d) = %d, want %d", tc.val, got, tc.want)
}
})
}
}
// TestMemoryStatusEffectiveAvailable_RegressionIssue435 tests the specific scenarios
// reported in GitHub issue #435 where memory calculations incorrectly included cache/buffers
func TestMemoryStatusEffectiveAvailable_RegressionIssue435(t *testing.T) {
tests := []struct {
name string
status MemoryStatus
wantAvailable uint64
wantUsedPct float64 // Expected usage percentage when using EffectiveAvailable
description string
}{
{
name: "real proxmox 8.x node with available field",
status: MemoryStatus{
Total: 16466186240, // 16GB
Used: 13981696000, // ~14GB (includes cache)
Free: 1558323200, // ~1.5GB
Available: 7422545920, // ~7GB (cache-aware - this is what we should use!)
Buffers: 23592960, // ~23MB
Cached: 5478891520, // ~5.4GB
},
wantAvailable: 7422545920,
wantUsedPct: 54.92, // Should be ~55%, not ~85%!
description: "Proxmox 8.x returns 'available' field - most accurate",
},
{
name: "older proxmox with avail field",
status: MemoryStatus{
Total: 8589934592, // 8GB
Used: 6871947674, // ~6.4GB (includes cache)
Free: 805306368, // ~768MB
Avail: 3221225472, // ~3GB (cache-aware)
},
wantAvailable: 3221225472,
wantUsedPct: 62.5, // Should be ~62.5%, not ~80%
description: "Older Proxmox uses 'avail' field as fallback",
},
{
name: "proxmox without available/avail - derive from components",
status: MemoryStatus{
Total: 16777216000, // ~16GB
Used: 13421772800, // ~12.5GB (includes cache)
Free: 2147483648, // 2GB
Buffers: 536870912, // 512MB
Cached: 4294967296, // 4GB
},
wantAvailable: 6979321856, // Free + Buffers + Cached = ~6.5GB
wantUsedPct: 58.4, // Should be ~58%, not ~80%
description: "When available/avail missing, derive from free+buffers+cached",
},
{
name: "proxmox 8.4 hides cache fields - derive from total-minus-used gap",
status: MemoryStatus{
Total: 134794743808, // ~125.6GB
Used: 107351023616, // ~100GB actual usage
Free: 6471057408, // ~6GB bare free reported
Buffers: 0,
Cached: 0,
},
wantAvailable: 27443720192, // total - used => ~25.6GB reclaimable (free + cache)
wantUsedPct: 79.6, // Matches Proxmox node dashboard
description: "Proxmox 8.4 stops reporting buffers/cached; use total-used gap to recover cache-aware metric",
},
{
name: "issue #435 specific case - 86% vs 42% real usage",
status: MemoryStatus{
Total: 33554432000, // ~32GB
Used: 28857589760, // ~27GB (includes cache - WRONG!)
Free: 1073741824, // 1GB
Available: 19327352832, // ~18GB (cache-aware - CORRECT!)
},
wantAvailable: 19327352832,
wantUsedPct: 42.4, // Should be ~42%, not 86%!
description: "Real user report: 86% shown when actual usage is 42%",
},
{
name: "missing available but buffers/cached still present (issue 553 guard)",
status: MemoryStatus{
Total: 34359738368, // 32GB
Used: 27917287424, // ~26GB reported used (includes cache)
Free: 2147483648, // 2GB
Buffers: 3221225472, // 3GB
Cached: 8053063680, // 7.5GB
},
wantAvailable: 13421772800, // Free + Buffers + Cached
wantUsedPct: 60.94,
description: "When available=0 but buffers/cached exist, derive reclaimable memory instead of alerting",
},
{
name: "missing all cache-aware fields - fallback to zero",
status: MemoryStatus{
Total: 8589934592,
Used: 6871947674,
Free: 0, // All fields missing
},
wantAvailable: 1717986918, // Derived from total - used
wantUsedPct: 80.0, // Still aligns with cache-inclusive calculation when nothing else reported
description: "When all cache fields missing, fall back to total-used gap instead of zero",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.status.EffectiveAvailable()
if got != tc.wantAvailable {
t.Errorf("EffectiveAvailable() = %d, want %d\nScenario: %s",
got, tc.wantAvailable, tc.description)
}
// Calculate what the usage percentage would be with cache-aware calculation
if tc.wantAvailable > 0 && tc.wantAvailable <= tc.status.Total {
actualUsed := tc.status.Total - tc.wantAvailable
usagePct := (float64(actualUsed) / float64(tc.status.Total)) * 100
// Allow 0.5% tolerance for floating point differences
if usagePct < tc.wantUsedPct-0.5 || usagePct > tc.wantUsedPct+0.5 {
t.Errorf("Calculated usage = %.2f%%, want ~%.2f%%\nScenario: %s",
usagePct, tc.wantUsedPct, tc.description)
}
}
})
}
}
func TestAuthHTTPError_Error(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err *authHTTPError
contains string
}{
{
name: "unauthorized status includes status code",
err: &authHTTPError{status: 401, body: "invalid credentials"},
contains: "status 401",
},
{
name: "forbidden status includes status code",
err: &authHTTPError{status: 403, body: "access denied"},
contains: "status 403",
},
{
name: "unauthorized includes body",
err: &authHTTPError{status: 401, body: "bad user/pass"},
contains: "bad user/pass",
},
{
name: "forbidden includes body",
err: &authHTTPError{status: 403, body: "permission denied"},
contains: "permission denied",
},
{
name: "other status omits status code",
err: &authHTTPError{status: 500, body: "server error"},
contains: "authentication failed: server error",
},
{
name: "bad request omits status code",
err: &authHTTPError{status: 400, body: "bad request"},
contains: "authentication failed: bad request",
},
{
name: "zero status omits status code",
err: &authHTTPError{status: 0, body: "unknown error"},
contains: "authentication failed: unknown error",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
msg := tc.err.Error()
if !containsSubstring(msg, tc.contains) {
t.Errorf("Error() = %q, want to contain %q", msg, tc.contains)
}
})
}
}
func TestShouldFallbackToForm(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{
name: "nil error",
err: nil,
want: false,
},
{
name: "non-authHTTPError",
err: fmt.Errorf("some other error"),
want: false,
},
{
name: "bad request triggers fallback",
err: &authHTTPError{status: 400, body: "bad request"},
want: true,
},
{
name: "unsupported media type triggers fallback",
err: &authHTTPError{status: 415, body: "unsupported media type"},
want: true,
},
{
name: "unauthorized does not trigger fallback",
err: &authHTTPError{status: 401, body: "unauthorized"},
want: false,
},
{
name: "forbidden does not trigger fallback",
err: &authHTTPError{status: 403, body: "forbidden"},
want: false,
},
{
name: "server error does not trigger fallback",
err: &authHTTPError{status: 500, body: "internal error"},
want: false,
},
{
name: "not found does not trigger fallback",
err: &authHTTPError{status: 404, body: "not found"},
want: false,
},
{
name: "zero status does not trigger fallback",
err: &authHTTPError{status: 0, body: ""},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := shouldFallbackToForm(tc.err)
if got != tc.want {
t.Errorf("shouldFallbackToForm(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// containsSubstring checks if s contains substr (helper for error message checks)
func containsSubstring(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestVMAgentFieldUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{
name: "integer format enabled",
input: `1`,
want: 1,
},
{
name: "integer format zero",
input: `0`,
want: 0,
},
{
name: "object format with available > 0",
input: `{"enabled":1,"available":1}`,
want: 1,
},
{
name: "object format with enabled > 0 but available=0",
input: `{"enabled":1,"available":0}`,
want: 1,
},
{
name: "object format with both 0",
input: `{"enabled":0,"available":0}`,
want: 0,
},
{
name: "object format available takes priority",
input: `{"enabled":0,"available":1}`,
want: 1,
},
{
name: "invalid JSON defaults to 0",
input: `{invalid}`,
want: 0,
},
{
name: "empty string defaults to 0",
input: ``,
want: 0,
},
{
name: "null value defaults to 0",
input: `null`,
want: 0,
},
{
name: "string value defaults to 0",
input: `"enabled"`,
want: 0,
},
{
name: "array value defaults to 0",
input: `[1,2,3]`,
want: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var agent VMAgentField
// UnmarshalJSON always returns nil, so we just check the value
_ = agent.UnmarshalJSON([]byte(tc.input))
if agent.Value != tc.want {
t.Errorf("VMAgentField.UnmarshalJSON(%q) = %d, want %d", tc.input, agent.Value, tc.want)
}
})
}
}
func TestVMAgentFieldUnmarshalJSON_InVMStatus(t *testing.T) {
tests := []struct {
name string
payload string
want int
}{
{
name: "VMStatus with integer agent field",
payload: `{"status":"running","cpu":0.5,"cpus":4,"mem":1073741824,"maxmem":4294967296,"agent":1}`,
want: 1,
},
{
name: "VMStatus with object agent field",
payload: `{"status":"running","cpu":0.1,"cpus":2,"mem":536870912,"maxmem":2147483648,"agent":{"enabled":1,"available":1}}`,
want: 1,
},
{
name: "VMStatus with object agent enabled but not available",
payload: `{"status":"stopped","cpu":0,"cpus":1,"mem":0,"maxmem":1073741824,"agent":{"enabled":1,"available":0}}`,
want: 1,
},
{
name: "VMStatus with object agent both zero",
payload: `{"status":"stopped","cpu":0,"cpus":1,"mem":0,"maxmem":1073741824,"agent":{"enabled":0,"available":0}}`,
want: 0,
},
{
name: "VMStatus without agent field",
payload: `{"status":"running","cpu":0.2,"cpus":2,"mem":268435456,"maxmem":1073741824}`,
want: 0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var status VMStatus
if err := json.Unmarshal([]byte(tc.payload), &status); err != nil {
t.Fatalf("unexpected error unmarshalling VMStatus: %v", err)
}
if status.Agent.Value != tc.want {
t.Errorf("VMStatus.Agent.Value = %d, want %d", status.Agent.Value, tc.want)
}
})
}
}
func TestGetMHzString(t *testing.T) {
tests := []struct {
name string
cpu CPUInfo
want string
}{
{
name: "nil MHz returns empty string",
cpu: CPUInfo{MHz: nil},
want: "",
},
{
name: "string MHz returned as-is",
cpu: CPUInfo{MHz: "3600.000"},
want: "3600.000",
},
{
name: "empty string MHz",
cpu: CPUInfo{MHz: ""},
want: "",
},
{
name: "float64 MHz formatted without decimals",
cpu: CPUInfo{MHz: float64(3600.123)},
want: "3600",
},
{
name: "float64 MHz zero",
cpu: CPUInfo{MHz: float64(0)},
want: "0",
},
{
name: "float64 MHz large value",
cpu: CPUInfo{MHz: float64(5000.999)},
want: "5001",
},
{
name: "int MHz formatted",
cpu: CPUInfo{MHz: int(2400)},
want: "2400",
},
{
name: "int MHz zero",
cpu: CPUInfo{MHz: int(0)},
want: "0",
},
{
name: "other type uses default formatting",
cpu: CPUInfo{MHz: int64(3200)},
want: "3200",
},
{
name: "boolean type uses default formatting",
cpu: CPUInfo{MHz: true},
want: "true",
},
{
name: "slice type uses default formatting",
cpu: CPUInfo{MHz: []int{1, 2, 3}},
want: "[1 2 3]",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.cpu.GetMHzString()
if got != tc.want {
t.Errorf("GetMHzString() = %q, want %q", got, tc.want)
}
})
}
}
func TestGetNodes_InvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Return invalid JSON that will cause decode error
fmt.Fprint(w, `{"data": [{"node": "test" invalid json}`)
}))
defer server.Close()
cfg := ClientConfig{
Host: server.URL,
TokenName: "test@pve!token",
TokenValue: "secret",
VerifySSL: false,
Timeout: 2 * time.Second,
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient failed: %v", err)
}
ctx := context.Background()
_, err = client.GetNodes(ctx)
if err == nil {
t.Fatal("expected JSON decode error, got nil")
}
}
func TestNewClient_InvalidUserFormat(t *testing.T) {
tests := []struct {
name string
user string
wantErr bool
errContains string
}{
{
name: "missing realm separator",
user: "userwithoutrealm",
wantErr: true,
errContains: "invalid user format",
},
{
name: "empty user",
user: "",
wantErr: true,
errContains: "invalid user format",
},
{
name: "multiple @ symbols",
user: "user@realm@extra",
wantErr: true,
errContains: "invalid user format",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := ClientConfig{
Host: "https://localhost:8006",
User: tc.user,
Password: "testpass",
}
_, err := NewClient(cfg)
if tc.wantErr {
if err == nil {
t.Errorf("expected error for user %q, got nil", tc.user)
} else if !strings.Contains(err.Error(), tc.errContains) {
t.Errorf("expected error containing %q, got: %v", tc.errContains, err)
}
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error for user %q: %v", tc.user, err)
}
})
}
}