mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 19:25:57 +00:00
bd6f77e093
Tighten v5-to-v6 upgrade safety, release installability, provider MSP mode handling, AI cost accounting, metrics flushing, and frontend guardrails for the v6.0.0 GA candidate.
1756 lines
46 KiB
Go
1756 lines
46 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func platformIntOverflowString() string {
|
|
if strconv.IntSize == 32 {
|
|
return "2147483648"
|
|
}
|
|
return "9223372036854775808"
|
|
}
|
|
|
|
func platformIntOverflowFloat() float64 {
|
|
return math.Exp2(float64(strconv.IntSize - 1))
|
|
}
|
|
|
|
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 TestDiskUnmarshalJSON_OutOfRangeNumericFieldsNormalize(t *testing.T) {
|
|
payload := fmt.Sprintf(`{"devpath":"/dev/sda","model":"Example","serial":"123","type":"hdd","health":"OK","wearout":%.0f,"size":1000,"rpm":%.0f}`, platformIntOverflowFloat(), platformIntOverflowFloat())
|
|
var disk Disk
|
|
if err := json.Unmarshal([]byte(payload), &disk); err != nil {
|
|
t.Fatalf("unexpected error unmarshalling disk: %v", err)
|
|
}
|
|
if disk.Wearout != wearoutUnknown {
|
|
t.Fatalf("wearout: got %d, want %d (unknown)", disk.Wearout, wearoutUnknown)
|
|
}
|
|
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("replaces windows volume GUID mountpoint with normalized name", func(t *testing.T) {
|
|
payload := `{"name":"C:\\","type":"ntfs","mountpoint":"\\\\?\\Volume{1234-5678}\\","total-bytes":536870912000,"used-bytes":214748364800}`
|
|
var fs VMFileSystem
|
|
if err := json.Unmarshal([]byte(payload), &fs); err != nil {
|
|
t.Fatalf("unexpected error unmarshalling volume GUID mountpoint: %v", err)
|
|
}
|
|
if fs.Mountpoint != "C:\\" {
|
|
t.Fatalf("expected windows drive mountpoint fallback, 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: "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 TestFlexIntUnmarshalJSONRejectsNonFiniteAndOverflow(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{name: "string NaN", input: `"NaN"`},
|
|
{name: "string positive infinity", input: `"Infinity"`},
|
|
{name: "string overflow", input: fmt.Sprintf(`"%s"`, platformIntOverflowString())},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var f FlexInt
|
|
if err := f.UnmarshalJSON([]byte(tc.input)); err == nil {
|
|
t.Fatalf("expected error for %s, got nil", tc.input)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVMIPAddressUnmarshalJSONCapsPrefixToCIDRRange(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
payload string
|
|
want int
|
|
}{
|
|
{
|
|
name: "keeps valid prefix",
|
|
payload: `{"ip-address":"10.0.0.10","prefix":24}`,
|
|
want: 24,
|
|
},
|
|
{
|
|
name: "caps overlong ipv6 prefix",
|
|
payload: `{"ip-address":"2001:db8::10","prefix":129}`,
|
|
want: 128,
|
|
},
|
|
{
|
|
name: "caps huge prefix before int conversion",
|
|
payload: `{"ip-address":"2001:db8::10","prefix":"18446744073709551615"}`,
|
|
want: 128,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var got VMIPAddress
|
|
if err := json.Unmarshal([]byte(tc.payload), &got); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.Prefix != tc.want {
|
|
t.Fatalf("prefix = %d, want %d", got.Prefix, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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},
|
|
{name: "float64 NaN", value: math.NaN(), wantErr: true},
|
|
{name: "float64 positive infinity", value: math.Inf(1), wantErr: true},
|
|
{name: "float64 out of range", value: math.Exp2(64), wantErr: true},
|
|
// 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: "float non-finite string", raw: "NaN", want: wearoutUnknown},
|
|
{name: "float overflow string", raw: "9.223372036854776e18", want: wearoutUnknown},
|
|
{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: 0,
|
|
},
|
|
{
|
|
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: 0,
|
|
},
|
|
{
|
|
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 TestVMAgentFieldTracksEnabledAndAvailableSeparately(t *testing.T) {
|
|
var agent VMAgentField
|
|
if err := agent.UnmarshalJSON([]byte(`{"enabled":1,"available":0}`)); err != nil {
|
|
t.Fatalf("UnmarshalJSON returned error: %v", err)
|
|
}
|
|
if !agent.IsEnabled() {
|
|
t.Fatal("expected IsEnabled to preserve VM config state")
|
|
}
|
|
if agent.IsAvailable() {
|
|
t.Fatal("expected IsAvailable to reflect current guest-agent availability")
|
|
}
|
|
}
|
|
|
|
func TestVMStatusUnmarshalJSON_BalloonInfo(t *testing.T) {
|
|
payload := []byte(`{
|
|
"status":"running",
|
|
"mem":8589934592,
|
|
"maxmem":8589934592,
|
|
"agent":1,
|
|
"ballooninfo":{
|
|
"actual":8589934592,
|
|
"free_mem":5368709120,
|
|
"max_mem":8589934592,
|
|
"total_mem":8200802304
|
|
}
|
|
}`)
|
|
|
|
var status VMStatus
|
|
if err := json.Unmarshal(payload, &status); err != nil {
|
|
t.Fatalf("unexpected error unmarshalling VMStatus: %v", err)
|
|
}
|
|
if status.BalloonInfo == nil {
|
|
t.Fatal("expected ballooninfo to be parsed")
|
|
}
|
|
if status.BalloonInfo.FreeMem != 5368709120 {
|
|
t.Fatalf("BalloonInfo.FreeMem = %d, want 5368709120", status.BalloonInfo.FreeMem)
|
|
}
|
|
if status.BalloonInfo.TotalMem != 8200802304 {
|
|
t.Fatalf("BalloonInfo.TotalMem = %d, want 8200802304", status.BalloonInfo.TotalMem)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|