From cfef94870f331bc462e68f9861060fd54000f7d6 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 12 Sep 2026 16:36:07 +0300 Subject: [PATCH] test: cover IPMI, PSU, and SAT formatting --- audit/internal/app/app_format_test.go | 38 +++++ audit/internal/collector/board_test.go | 24 ++++ audit/internal/collector/ipmi_parsers_test.go | 131 ++++++++++++++++++ audit/internal/collector/ipmi_sel.go | 4 +- audit/internal/collector/psu_sdr_test.go | 43 ++++++ audit/internal/collector/summary_test.go | 71 ++++++++++ 6 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 audit/internal/collector/ipmi_parsers_test.go create mode 100644 audit/internal/collector/summary_test.go diff --git a/audit/internal/app/app_format_test.go b/audit/internal/app/app_format_test.go index 2bcf159..15a6097 100644 --- a/audit/internal/app/app_format_test.go +++ b/audit/internal/app/app_format_test.go @@ -30,3 +30,41 @@ func TestFormatGPULineIgnoresNonGPUSameVendorDevice(t *testing.T) { t.Fatalf("formatGPULine() = %q, want %q", got, want) } } + +func TestFormatSATDetail(t *testing.T) { + raw := `run_at_utc=2026-09-12T16:30:00Z +01-cpu_status=OK +storage_status=FAILED +tpm_status=UNSUPPORTED +overall_status=FAILED +job_ok=1 +job_failed=1` + + got := formatSATDetail(raw) + want := `Run: 2026-09-12T16:30:00Z + +PASS cpu +FAIL storage +SKIP tpm + +Overall: FAILED (ok=1 failed=1)` + if got != want { + t.Fatalf("formatSATDetail() = %q, want %q", got, want) + } +} + +func TestCleanSummaryKey(t *testing.T) { + for _, tt := range []struct { + input string + want string + }{ + {input: "01-cpu", want: "cpu"}, + {input: "cpu", want: "cpu"}, + {input: "cpu-01", want: "cpu-01"}, + {input: "-cpu", want: "-cpu"}, + } { + if got := cleanSummaryKey(tt.input); got != tt.want { + t.Errorf("cleanSummaryKey(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/audit/internal/collector/board_test.go b/audit/internal/collector/board_test.go index cfa538a..8636390 100644 --- a/audit/internal/collector/board_test.go +++ b/audit/internal/collector/board_test.go @@ -66,6 +66,30 @@ func TestParseBIOSFirmware_empty(t *testing.T) { } } +func TestParseBMCFirmwareRevision(t *testing.T) { + tests := []struct { + name string + out string + want string + }{ + { + name: "value with surrounding whitespace", + out: "Device ID : 32\nFirmware Revision : 2.14\n", + want: "2.14", + }, + {name: "field missing", out: "Device ID : 32\n", want: ""}, + {name: "malformed line", out: "Firmware Revision 2.14\n", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseBMCFirmwareRevision(tt.out); got != tt.want { + t.Fatalf("parseBMCFirmwareRevision() = %q, want %q", got, tt.want) + } + }) + } +} + func TestCleanDMIValue(t *testing.T) { tests := []struct { input string diff --git a/audit/internal/collector/ipmi_parsers_test.go b/audit/internal/collector/ipmi_parsers_test.go new file mode 100644 index 0000000..119afc9 --- /dev/null +++ b/audit/internal/collector/ipmi_parsers_test.go @@ -0,0 +1,131 @@ +package collector + +import ( + "bee/audit/internal/schema" + "testing" +) + +func TestParseIPMISELOutput(t *testing.T) { + entries := parseIPMISELOutput(` +1 | 06/18/2026 | 14:23:45 | Temperature #0x30 | Upper Critical going high | Asserted +2 | 06/18/2026 | 14:24:45 | Voltage #0x21 | Upper Non-critical going high | Deasserted +malformed +`) + if len(entries) != 2 { + t.Fatalf("entries = %d, want 2", len(entries)) + } + + first := entries[0] + if first.Source != "ipmi-sel" || first.MessageID == nil || *first.MessageID != "1" { + t.Fatalf("first entry identity = %#v", first) + } + if first.EventTime == nil || *first.EventTime != "06/18/2026 14:23:45" { + t.Fatalf("first event time = %v", first.EventTime) + } + if first.ComponentRef == nil || *first.ComponentRef != "Temperature #0x30" { + t.Fatalf("first component = %v", first.ComponentRef) + } + if first.Severity == nil || *first.Severity != statusCritical || first.IsActive == nil || !*first.IsActive { + t.Fatalf("first severity/activity = %#v", first) + } + + second := entries[1] + if second.Message != "Upper Non-critical going high (Deasserted)" { + t.Fatalf("second message = %q", second.Message) + } + if second.Severity == nil || *second.Severity != statusWarning || second.IsActive == nil || *second.IsActive { + t.Fatalf("second severity/activity = %#v", second) + } +} + +func TestIPMISELSeverity(t *testing.T) { + tests := []struct { + event string + want string + }{ + {event: "Upper Critical going high", want: statusCritical}, + {event: "Non-recoverable fault", want: statusCritical}, + {event: "Upper Non-critical going high", want: statusWarning}, + {event: "Voltage degraded", want: statusWarning}, + {event: "Device inserted", want: "info"}, + } + for _, tt := range tests { + if got := ipmiSELSeverity(tt.event); got != tt.want { + t.Errorf("ipmiSELSeverity(%q) = %q, want %q", tt.event, got, tt.want) + } + } +} + +func TestParseIPMISensorOutput(t *testing.T) { + sensors := parseIPMISensorOutput(` +FAN1 | 4200 | RPM | ok | na | na | na | na | na | na +CPU Temp | 92.5 | degrees C | ns | na | na | na | 80 | 90 | na +CPU Temp | 80 | degrees C | ok | na | na | na | 80 | 90 | na +PSU Input | 12.1 | Volts | ok | na | na | na | na | na | na +System Power | 650 | Watts | cr | na | na | na | na | na | na +Ambient | 44 | percent | nc | na | na | na | na | na | na +Missing | na | RPM | ok +Broken | not-a-number | RPM | ok +`) + if sensors == nil { + t.Fatal("expected sensors") + } + if len(sensors.Fans) != 1 || sensors.Fans[0].RPM == nil || *sensors.Fans[0].RPM != 4200 { + t.Fatalf("fans = %#v", sensors.Fans) + } + if len(sensors.Temperatures) != 1 { + t.Fatalf("temperatures = %#v", sensors.Temperatures) + } + temp := sensors.Temperatures[0] + if temp.ThresholdWarningCelsius == nil || *temp.ThresholdWarningCelsius != 80 || + temp.ThresholdCriticalCelsius == nil || *temp.ThresholdCriticalCelsius != 90 || + temp.Status == nil || *temp.Status != statusCritical { + t.Fatalf("temperature = %#v", temp) + } + if len(sensors.Power) != 2 || sensors.Power[0].VoltageV == nil || sensors.Power[1].PowerW == nil || + sensors.Power[1].Status == nil || *sensors.Power[1].Status != statusCritical { + t.Fatalf("power = %#v", sensors.Power) + } + if len(sensors.Other) != 1 || sensors.Other[0].Unit == nil || *sensors.Other[0].Unit != "percent" || + sensors.Other[0].Status == nil || *sensors.Other[0].Status != statusWarning { + t.Fatalf("other = %#v", sensors.Other) + } +} + +func TestParseIPMIThresholdAndStatus(t *testing.T) { + if got := parseIPMIThreshold(" 42.5 "); got == nil || *got != 42.5 { + t.Fatalf("threshold = %v, want 42.5", got) + } + for _, raw := range []string{"", "na", "N/A", "not-a-number"} { + if got := parseIPMIThreshold(raw); got != nil { + t.Errorf("parseIPMIThreshold(%q) = %v, want nil", raw, got) + } + } + for raw, want := range map[string]string{ + "ok": statusOK, "cr": statusCritical, "unc": statusWarning, "na": "", "other": "", + } { + if got := normalizeIPMISensorStatus(raw); got != want { + t.Errorf("normalizeIPMISensorStatus(%q) = %q, want %q", raw, got, want) + } + } +} + +func TestMergeIPMISensors(t *testing.T) { + existing := &schema.HardwareSensors{ + Fans: []schema.HardwareFanSensor{{Name: "FAN1"}}, + } + ipmi := &schema.HardwareSensors{ + Fans: []schema.HardwareFanSensor{{Name: "FAN1"}, {Name: "FAN2"}}, + Temperatures: []schema.HardwareTemperatureSensor{{Name: "CPU Temp"}}, + } + merged := mergeIPMISensors(existing, ipmi) + if merged != existing || len(merged.Fans) != 2 || len(merged.Temperatures) != 1 { + t.Fatalf("merged = %#v", merged) + } + if got := mergeIPMISensors(existing, nil); got != existing { + t.Fatal("nil IPMI sensors should preserve existing sensors") + } + if got := mergeIPMISensors(nil, ipmi); got != ipmi { + t.Fatal("nil existing sensors should return IPMI sensors") + } +} diff --git a/audit/internal/collector/ipmi_sel.go b/audit/internal/collector/ipmi_sel.go index 849c5b4..aff5aff 100644 --- a/audit/internal/collector/ipmi_sel.go +++ b/audit/internal/collector/ipmi_sel.go @@ -80,10 +80,10 @@ func parseIPMISELOutput(output string) []schema.HardwareEventLog { func ipmiSELSeverity(event string) string { lower := strings.ToLower(event) switch { - case strings.Contains(lower, "critical") || strings.Contains(lower, "non-recoverable"): - return statusCritical case strings.Contains(lower, "non-critical") || strings.Contains(lower, "warning") || strings.Contains(lower, "degraded"): return statusWarning + case strings.Contains(lower, "critical") || strings.Contains(lower, "non-recoverable"): + return statusCritical default: return "info" } diff --git a/audit/internal/collector/psu_sdr_test.go b/audit/internal/collector/psu_sdr_test.go index 52dd92e..aabc486 100644 --- a/audit/internal/collector/psu_sdr_test.go +++ b/audit/internal/collector/psu_sdr_test.go @@ -133,3 +133,46 @@ func TestMergePSUSDRAppendsPSUMissingFromFRU(t *testing.T) { t.Fatalf("PSUs=%#v, want FRU PSU0 plus synthesized PSU1", got) } } + +func TestParseFRUFiltersNonPSUAndNormalizesSlots(t *testing.T) { + psus := parseFRU(` +FRU Device Description : Builtin FRU Device (ID 0) + Board Mfg : Example Systems +FRU Device Description : PSU 2 (ID 12) + Board Product : Server PSU 1600W + Board Mfg : Acme Power + Board Serial : PS2-SERIAL + Board Part Number : ACME-1600 + Board Extra : 1.4 +FRU Device Description : Power Supply Bay 3 (ID 13) + Product Name : Redundant 800W + Product Manufacturer : Acme Power + Product Serial : PS3-SERIAL +`) + if len(psus) != 2 { + t.Fatalf("PSUs = %#v, want two PSU FRUs", psus) + } + if psus[0].Slot == nil || *psus[0].Slot != "1" || psus[0].Model == nil || *psus[0].Model != "Server PSU 1600W" || + psus[0].WattageW == nil || *psus[0].WattageW != 1600 || psus[0].Status == nil || *psus[0].Status != statusOK { + t.Fatalf("first PSU = %#v", psus[0]) + } + if psus[1].Slot == nil || *psus[1].Slot != "2" || psus[1].SerialNumber == nil || *psus[1].SerialNumber != "PS3-SERIAL" { + t.Fatalf("second PSU = %#v", psus[1]) + } +} + +func TestPSUNumericParsersRejectInvalidReadings(t *testing.T) { + for _, raw := range []string{"", "na", "0", "-1", "6001", "not-a-number"} { + if got := parseBoundedFloat(raw, 6000); got != nil { + t.Errorf("parseBoundedFloat(%q) = %v, want nil", raw, got) + } + } + if got := parseBoundedFloat("1200 Watts", 6000); got == nil || *got != 1200 { + t.Fatalf("parseBoundedFloat valid value = %v", got) + } + for raw, want := range map[string]int{"PSU 800W": 800, "1200W PLATINUM": 1200, "no wattage": 0, "6000W": 0} { + if got := parseWattage(raw); got != want { + t.Errorf("parseWattage(%q) = %d, want %d", raw, got, want) + } + } +} diff --git a/audit/internal/collector/summary_test.go b/audit/internal/collector/summary_test.go new file mode 100644 index 0000000..96c3035 --- /dev/null +++ b/audit/internal/collector/summary_test.go @@ -0,0 +1,71 @@ +package collector + +import ( + "bee/audit/internal/schema" + "testing" + "time" +) + +func TestBuildHealthSummary(t *testing.T) { + warning := statusWarning + critical := statusCritical + empty := statusEmpty + present := true + absent := false + diskModel := "NVMe A" + pcieSerial := "GPU-1" + psuSlot := "PSU1" + dimmSlot := "DIMM_A1" + + summary := BuildHealthSummary(schema.HardwareSnapshot{ + Memory: []schema.HardwareMemory{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &warning}, Slot: &dimmSlot}, + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &empty}}, + }, + Storage: []schema.HardwareStorage{{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &critical}, Model: &diskModel}}, + PCIeDevices: []schema.HardwarePCIeDevice{{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &warning}, SerialNumber: &pcieSerial}}, + PowerSupplies: []schema.HardwarePowerSupply{ + {HardwareComponentStatus: schema.HardwareComponentStatus{Status: &critical}, Slot: &psuSlot, Present: &present}, + {Present: &absent}, + }, + }) + + if summary.Status != statusCritical || summary.MemoryWarn != 1 || summary.EmptyDIMMs != 1 || + summary.StorageFail != 1 || summary.PCIeWarn != 1 || summary.PSUFail != 1 || summary.MissingPSUs != 1 { + t.Fatalf("summary = %#v", summary) + } + if len(summary.Warnings) != 2 || len(summary.Failures) != 2 { + t.Fatalf("warnings/failures = %#v", summary) + } + if _, err := time.Parse(time.RFC3339, summary.CollectedAt); err != nil { + t.Fatalf("collected_at = %q: %v", summary.CollectedAt, err) + } +} + +func TestBuildHealthSummaryEmptySnapshot(t *testing.T) { + summary := BuildHealthSummary(schema.HardwareSnapshot{}) + if summary.Status != statusOK || summary.Warnings != nil || summary.Failures != nil { + t.Fatalf("summary = %#v", summary) + } +} + +func TestPreferredName(t *testing.T) { + model, serial, slot := "model", "serial", "slot" + tests := []struct { + name string + model *string + serial *string + slot *string + want string + }{ + {name: "model", model: &model, serial: &serial, slot: &slot, want: "model"}, + {name: "serial", serial: &serial, slot: &slot, want: "serial"}, + {name: "slot", slot: &slot, want: "slot"}, + {name: "fallback", want: "unknown"}, + } + for _, tt := range tests { + if got := preferredName(tt.model, tt.serial, tt.slot); got != tt.want { + t.Errorf("%s: preferredName() = %q, want %q", tt.name, got, tt.want) + } + } +}