Pulse/internal/smartctl/collector_test.go
rcourtman 2ddd222911
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Core E2E Tests / Playwright Core E2E (push) Waiting to run
Add smartctl text fallback parsing (#1254)
2026-03-28 00:13:37 +00:00

731 lines
18 KiB
Go

package smartctl
import (
"encoding/json"
"testing"
)
func TestDetectDiskType(t *testing.T) {
tests := []struct {
name string
data smartctlJSON
expected string
}{
{
name: "NVMe protocol",
data: smartctlJSON{
Device: struct {
Name string `json:"name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
}{
Protocol: "NVMe",
},
},
expected: "nvme",
},
{
name: "SAS protocol",
data: smartctlJSON{
Device: struct {
Name string `json:"name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
}{
Protocol: "SAS",
},
},
expected: "sas",
},
{
name: "SATA protocol",
data: smartctlJSON{
Device: struct {
Name string `json:"name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
}{
Protocol: "ATA",
},
},
expected: "sata",
},
{
name: "NVMe device type fallback",
data: smartctlJSON{
Device: struct {
Name string `json:"name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
}{
Type: "nvme",
Protocol: "",
},
},
expected: "nvme",
},
{
name: "Empty defaults to sata",
data: smartctlJSON{
Device: struct {
Name string `json:"name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
}{},
},
expected: "sata",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := detectDiskType(tt.data)
if result != tt.expected {
t.Errorf("detectDiskType() = %q, want %q", result, tt.expected)
}
})
}
}
func TestFormatWWN(t *testing.T) {
tests := []struct {
name string
naa uint64
oui uint64
id uint64
expected string
}{
{
name: "Standard WWN",
naa: 5,
oui: 0x000c50,
id: 0x123456789abc,
expected: "5-c50-123456789abc",
},
{
name: "Zero values",
naa: 0,
oui: 0,
id: 0,
expected: "0-0-0",
},
{
name: "Simple values",
naa: 5,
oui: 1234,
id: 5678,
expected: "5-4d2-162e",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatWWN(tt.naa, tt.oui, tt.id)
if result != tt.expected {
t.Errorf("formatWWN(%d, %d, %d) = %q, want %q", tt.naa, tt.oui, tt.id, result, tt.expected)
}
})
}
}
func TestDiskSMARTStruct(t *testing.T) {
// Verify struct can be properly created
disk := DiskSMART{
Device: "sda",
Model: "Samsung SSD 870 EVO 1TB",
Serial: "S123456789",
WWN: "5-abc-123",
Type: "sata",
Temperature: 35,
Health: "PASSED",
Standby: false,
}
if disk.Device != "sda" {
t.Errorf("unexpected Device: %s", disk.Device)
}
if disk.Temperature != 35 {
t.Errorf("unexpected Temperature: %d", disk.Temperature)
}
if disk.Health != "PASSED" {
t.Errorf("unexpected Health: %s", disk.Health)
}
}
func TestSmartctlJSONParsing(t *testing.T) {
// Test parsing the smartctlJSON struct
data := smartctlJSON{
ModelName: "Samsung SSD 870 EVO 1TB",
SerialNumber: "S123456789",
}
data.Device.Name = "/dev/sda"
data.Device.Protocol = "ATA"
data.SmartStatus = &struct {
Passed bool `json:"passed"`
}{Passed: true}
data.Temperature.Current = 35
if data.ModelName != "Samsung SSD 870 EVO 1TB" {
t.Errorf("unexpected ModelName: %s", data.ModelName)
}
if data.SmartStatus == nil || data.SmartStatus.Passed != true {
t.Errorf("expected Passed to be true")
}
if data.Temperature.Current != 35 {
t.Errorf("unexpected Temperature: %d", data.Temperature.Current)
}
}
func TestLinuxSMARTSkipReason(t *testing.T) {
tests := []struct {
name string
device lsblkDevice
skip bool
}{
{
name: "physical sata disk",
device: lsblkDevice{
Name: "sda",
Type: "disk",
Model: "Samsung SSD 870 EVO",
Vendor: "ATA",
},
skip: false,
},
{
name: "zfs zvol device",
device: lsblkDevice{
Name: "zd16",
Type: "disk",
},
skip: true,
},
{
name: "device mapper device",
device: lsblkDevice{
Name: "dm-0",
Type: "disk",
},
skip: true,
},
{
name: "virtio transport",
device: lsblkDevice{
Name: "vda",
Type: "disk",
Tran: "virtio",
},
skip: true,
},
{
name: "vmware virtual disk",
device: lsblkDevice{
Name: "sdb",
Type: "disk",
Model: "Virtual disk",
Vendor: "VMware",
},
skip: true,
},
{
name: "virtual subsystem",
device: lsblkDevice{
Name: "sdc",
Type: "disk",
Subsystems: "block:scsi:vmbus:pci",
},
skip: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reason := linuxSMARTSkipReason(tt.device)
if tt.skip && reason == "" {
t.Fatalf("expected device to be skipped")
}
if !tt.skip && reason != "" {
t.Fatalf("expected device to be collected, got skip reason %q", reason)
}
})
}
}
func TestNVMeTemperatureFallback(t *testing.T) {
// Test that NVMe temperature is extracted from the correct location
data := smartctlJSON{
Device: struct {
Name string `json:"name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
}{
Protocol: "NVMe",
},
}
data.NVMeSmartHealthInformationLog.Temperature = 42
// Verify the type detection
diskType := detectDiskType(data)
if diskType != "nvme" {
t.Errorf("expected nvme, got %s", diskType)
}
// Verify NVMe temp location is populated
if data.NVMeSmartHealthInformationLog.Temperature != 42 {
t.Errorf("NVMe temperature not set correctly")
}
}
func TestParseSMARTAttributes_SATA(t *testing.T) {
data := &smartctlJSON{}
data.ATASmartAttributes.Table = []struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
Worst int `json:"worst"`
Thresh int `json:"thresh"`
Raw struct {
Value int64 `json:"value"`
String string `json:"string"`
} `json:"raw"`
}{
{ID: 5, Name: "Reallocated_Sector_Ct", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 3}},
{ID: 9, Name: "Power_On_Hours", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 12345}},
{ID: 12, Name: "Power_Cycle_Count", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 42}},
{ID: 197, Name: "Current_Pending_Sector", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 0}},
{ID: 198, Name: "Offline_Uncorrectable", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 1}},
{ID: 199, Name: "UDMA_CRC_Error_Count", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 7}},
}
attrs := parseSMARTAttributes(data, "sata")
if attrs == nil {
t.Fatal("expected non-nil attributes for SATA disk with data")
}
if attrs.PowerOnHours == nil || *attrs.PowerOnHours != 12345 {
t.Errorf("PowerOnHours: got %v, want 12345", attrs.PowerOnHours)
}
if attrs.PowerCycles == nil || *attrs.PowerCycles != 42 {
t.Errorf("PowerCycles: got %v, want 42", attrs.PowerCycles)
}
if attrs.ReallocatedSectors == nil || *attrs.ReallocatedSectors != 3 {
t.Errorf("ReallocatedSectors: got %v, want 3", attrs.ReallocatedSectors)
}
if attrs.PendingSectors == nil || *attrs.PendingSectors != 0 {
t.Errorf("PendingSectors: got %v, want 0", attrs.PendingSectors)
}
if attrs.OfflineUncorrectable == nil || *attrs.OfflineUncorrectable != 1 {
t.Errorf("OfflineUncorrectable: got %v, want 1", attrs.OfflineUncorrectable)
}
if attrs.UDMACRCErrors == nil || *attrs.UDMACRCErrors != 7 {
t.Errorf("UDMACRCErrors: got %v, want 7", attrs.UDMACRCErrors)
}
// NVMe fields should be nil
if attrs.PercentageUsed != nil {
t.Errorf("PercentageUsed should be nil for SATA disk")
}
if attrs.AvailableSpare != nil {
t.Errorf("AvailableSpare should be nil for SATA disk")
}
}
func TestParseSMARTAttributes_NVMe(t *testing.T) {
data := &smartctlJSON{}
data.NVMeSmartHealthInformationLog.PowerOnHours = 5000
data.NVMeSmartHealthInformationLog.PowerCycles = 100
data.NVMeSmartHealthInformationLog.PercentageUsed = 5
data.NVMeSmartHealthInformationLog.AvailableSpare = 100
data.NVMeSmartHealthInformationLog.MediaErrors = 0
data.NVMeSmartHealthInformationLog.UnsafeShutdowns = 12
attrs := parseSMARTAttributes(data, "nvme")
if attrs == nil {
t.Fatal("expected non-nil attributes for NVMe disk")
}
if attrs.PowerOnHours == nil || *attrs.PowerOnHours != 5000 {
t.Errorf("PowerOnHours: got %v, want 5000", attrs.PowerOnHours)
}
if attrs.PowerCycles == nil || *attrs.PowerCycles != 100 {
t.Errorf("PowerCycles: got %v, want 100", attrs.PowerCycles)
}
if attrs.PercentageUsed == nil || *attrs.PercentageUsed != 5 {
t.Errorf("PercentageUsed: got %v, want 5", attrs.PercentageUsed)
}
if attrs.AvailableSpare == nil || *attrs.AvailableSpare != 100 {
t.Errorf("AvailableSpare: got %v, want 100", attrs.AvailableSpare)
}
if attrs.MediaErrors == nil || *attrs.MediaErrors != 0 {
t.Errorf("MediaErrors: got %v, want 0", attrs.MediaErrors)
}
if attrs.UnsafeShutdowns == nil || *attrs.UnsafeShutdowns != 12 {
t.Errorf("UnsafeShutdowns: got %v, want 12", attrs.UnsafeShutdowns)
}
// SATA fields should be nil
if attrs.ReallocatedSectors != nil {
t.Errorf("ReallocatedSectors should be nil for NVMe disk")
}
}
func TestParseSMARTAttributes_PartialData(t *testing.T) {
// SATA disk with only power-on hours — other attributes missing
data := &smartctlJSON{}
data.ATASmartAttributes.Table = []struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
Worst int `json:"worst"`
Thresh int `json:"thresh"`
Raw struct {
Value int64 `json:"value"`
String string `json:"string"`
} `json:"raw"`
}{
{ID: 9, Name: "Power_On_Hours", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 100}},
}
attrs := parseSMARTAttributes(data, "sata")
if attrs == nil {
t.Fatal("expected non-nil attributes")
}
if attrs.PowerOnHours == nil || *attrs.PowerOnHours != 100 {
t.Errorf("PowerOnHours: got %v, want 100", attrs.PowerOnHours)
}
if attrs.ReallocatedSectors != nil {
t.Errorf("ReallocatedSectors should be nil when not in table")
}
if attrs.PendingSectors != nil {
t.Errorf("PendingSectors should be nil when not in table")
}
}
func TestParseSMARTAttributes_Standby(t *testing.T) {
// No data at all — simulates standby or no SMART support
data := &smartctlJSON{}
attrs := parseSMARTAttributes(data, "sata")
if attrs != nil {
t.Errorf("expected nil attributes for empty data, got %+v", attrs)
}
attrs = parseSMARTAttributes(data, "nvme")
if attrs != nil {
t.Errorf("expected nil attributes for empty NVMe data, got %+v", attrs)
}
}
func TestParseSMARTOutputStandbyPowerMode(t *testing.T) {
payload := smartctlJSON{
ModelName: "WDC WD40EFRX",
SerialNumber: "WD-123",
PowerMode: "STANDBY",
}
payload.Device.Protocol = "ATA"
out, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
result, err := parseSMARTOutput(out, smartctlTarget{Path: "/dev/ada0"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result")
}
if !result.Standby {
t.Fatalf("expected standby result, got %#v", result)
}
if result.Model != "WDC WD40EFRX" || result.Serial != "WD-123" {
t.Fatalf("expected model/serial to be preserved, got %#v", result)
}
}
func TestParseSMARTOutputUsesATASCTTemperature(t *testing.T) {
payload := smartctlJSON{
ModelName: "WDC WD40EFRX",
SerialNumber: "WD-456",
}
payload.Device.Protocol = "ATA"
payload.SmartStatus = &struct {
Passed bool `json:"passed"`
}{Passed: true}
payload.ATASCTStatus.Current.Value = 41
out, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
result, err := parseSMARTOutput(out, smartctlTarget{Path: "/dev/ada0"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result")
}
if result.Temperature != 41 {
t.Fatalf("expected SCT temperature 41, got %#v", result)
}
}
func TestParseSMARTOutputFallsBackToOriginalTextTemperature(t *testing.T) {
payload := smartctlJSON{
ModelName: "WDC WD40EFRX",
SerialNumber: "WD-789",
}
payload.Device.Protocol = "ATA"
payload.SmartStatus = &struct {
Passed bool `json:"passed"`
}{Passed: true}
payload.Smartctl.Output = []string{
"=== START OF READ SMART DATA SECTION ===",
"194 Temperature_Celsius 0x0022 120 099 000 Old_age Always - 37 (Min/Max 20/45)",
}
out, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
result, err := parseSMARTOutput(out, smartctlTarget{Path: "/dev/ada0"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result")
}
if result.Temperature != 37 {
t.Fatalf("expected text-fallback temperature 37, got %#v", result)
}
if result.Model != "WDC WD40EFRX" || result.Serial != "WD-789" || result.Health != "PASSED" {
t.Fatalf("expected model/serial/health to be preserved, got %#v", result)
}
}
func TestParseSMARTOutputFallsBackToPlainTextOutput(t *testing.T) {
output := []byte(`
=== START OF INFORMATION SECTION ===
Device Model: WDC WD40EFRX
Serial Number: WD-123
SMART overall-health self-assessment test result: PASSED
Current Temperature: 39 C
`)
result, err := parseSMARTOutput(output, smartctlTarget{Path: "/dev/ada0"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result")
}
if result.Model != "WDC WD40EFRX" || result.Serial != "WD-123" {
t.Fatalf("expected text model/serial, got %#v", result)
}
if result.Health != "PASSED" || result.Temperature != 39 {
t.Fatalf("expected text-fallback health/temp, got %#v", result)
}
if result.Type != "sata" {
t.Fatalf("expected sata fallback type, got %#v", result)
}
}
func TestParseRawValue(t *testing.T) {
tests := []struct {
name string
str string
value int64
expected int64
}{
{"plain number", "16951", 150323855943, 16951},
{"seagate power-on hours", "16951 (223 173 0)", 150323855943, 16951},
{"simple value matches", "12345", 12345, 12345},
{"empty string fallback", "", 42, 42},
{"whitespace only fallback", " ", 42, 42},
{"zero", "0", 999, 0},
{"non-numeric fallback", "unknown", 77, 77},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseRawValue(tt.str, tt.value)
if got != tt.expected {
t.Errorf("parseRawValue(%q, %d) = %d, want %d", tt.str, tt.value, got, tt.expected)
}
})
}
}
func TestParseSMARTAttributes_SeagateRawValues(t *testing.T) {
// Seagate drives pack vendor-specific data in upper bytes of the 48-bit raw
// value, making raw.value huge. raw.string contains the correct interpretation.
data := &smartctlJSON{}
data.ATASmartAttributes.Table = []struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
Worst int `json:"worst"`
Thresh int `json:"thresh"`
Raw struct {
Value int64 `json:"value"`
String string `json:"string"`
} `json:"raw"`
}{
{ID: 9, Name: "Power_On_Hours", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 150323855943, String: "16951 (223 173 0)"}},
{ID: 12, Name: "Power_Cycle_Count", Raw: struct {
Value int64 `json:"value"`
String string `json:"string"`
}{Value: 94, String: "94"}},
}
attrs := parseSMARTAttributes(data, "sata")
if attrs == nil {
t.Fatal("expected non-nil attributes")
}
if attrs.PowerOnHours == nil || *attrs.PowerOnHours != 16951 {
t.Errorf("PowerOnHours: got %v, want 16951 (should parse from raw.string, not raw.value)", attrs.PowerOnHours)
}
if attrs.PowerCycles == nil || *attrs.PowerCycles != 94 {
t.Errorf("PowerCycles: got %v, want 94", attrs.PowerCycles)
}
}
func TestMatchesDeviceExclude(t *testing.T) {
tests := []struct {
name string
deviceName string
devicePath string
patterns []string
expected bool
}{
{
name: "exact match on name",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: []string{"sda"},
expected: true,
},
{
name: "exact match on full path",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: []string{"/dev/sda"},
expected: true,
},
{
name: "no match",
deviceName: "sdb",
devicePath: "/dev/sdb",
patterns: []string{"sda"},
expected: false,
},
{
name: "prefix pattern on name",
deviceName: "nvme0n1",
devicePath: "/dev/nvme0n1",
patterns: []string{"nvme*"},
expected: true,
},
{
name: "prefix pattern on path",
deviceName: "nvme0n1",
devicePath: "/dev/nvme0n1",
patterns: []string{"/dev/nvme*"},
expected: true,
},
{
name: "contains pattern",
deviceName: "sdcache1",
devicePath: "/dev/sdcache1",
patterns: []string{"*cache*"},
expected: true,
},
{
name: "contains pattern no match",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: []string{"*cache*"},
expected: false,
},
{
name: "empty patterns",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: []string{},
expected: false,
},
{
name: "nil patterns",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: nil,
expected: false,
},
{
name: "multiple patterns first matches",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: []string{"sda", "sdb"},
expected: true,
},
{
name: "multiple patterns second matches",
deviceName: "sdb",
devicePath: "/dev/sdb",
patterns: []string{"sda", "sdb"},
expected: true,
},
{
name: "whitespace trimmed",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: []string{" sda "},
expected: true,
},
{
name: "empty pattern ignored",
deviceName: "sda",
devicePath: "/dev/sda",
patterns: []string{"", " ", "sdb"},
expected: false,
},
{
name: "sd prefix pattern",
deviceName: "sdc",
devicePath: "/dev/sdc",
patterns: []string{"sd*"},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := matchesDeviceExclude(tt.deviceName, tt.devicePath, tt.patterns)
if result != tt.expected {
t.Errorf("matchesDeviceExclude(%q, %q, %v) = %t, want %t",
tt.deviceName, tt.devicePath, tt.patterns, result, tt.expected)
}
})
}
}