diff --git a/.gitignore b/.gitignore index 0034f66..540bf58 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ go.work.sum # .idea/ # .vscode/ +machineid .DS_Store build/ dist/ \ No newline at end of file diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..7d31083 --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,55 @@ +# (mandatory) +# Path to coverage profile file (output of `go test -coverprofile` command). +# +# For cases where there are many coverage profiles, such as when running +# unit tests and integration tests separately, you can combine all those +# profiles into one. In this case, the profile should have a comma-separated list +# of profile files, e.g., 'cover_unit.out,cover_integration.out'. +profile: build/coverage.txt + +# (optional; but recommended to set) +# When specified reported file paths will not contain local prefix in the output. +local-prefix: "github.com/slashdevops/machineid" + +# Holds coverage thresholds percentages, values should be in range [0-100]. +threshold: + # (optional; default 0) + # Minimum coverage percentage required for individual files. + file: 0 + + # (optional; default 0) + # Minimum coverage percentage required for each package. + package: 20 + + # (optional; default 0) + # Minimum overall project coverage percentage required. + total: 25 + +# Holds regexp rules which will override thresholds for matched files or packages +# using their paths. +# +# First rule from this list that matches file or package is going to apply +# new threshold to it. If project has multiple rules that match same path, +# override rules should be listed in order from specific to more general rules. +override: + # Increase coverage threshold to 100% for `foo` package + # (default is 80, as configured above in this example). + - path: ^pkg/lib/foo$ + threshold: 100 + +# Holds regexp rules which will exclude matched files or packages +# from coverage statistics. +exclude: + # Exclude files or packages matching their paths + paths: + - ^cmd/ # exclude package main + - ^mocks/ # exclude package mocks + +# File name of go-test-coverage breakdown file, which can be used to +# analyze coverage difference. +breakdown-file-name: "" + +diff: + # File name of go-test-coverage breakdown file which will be used to + # report coverage difference. + base-breakdown-file-name: "" diff --git a/README.md b/README.md index 06e6459..914a949 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A **zero-dependency** Go library that generates unique, deterministic machine id - **Thread-Safe** — safe for concurrent use after configuration - **Diagnostic API** — inspect which components succeeded or failed - **Optional Logging** — `*slog.Logger` support for observability with zero overhead when disabled +- **Structured Errors** — sentinel errors and typed errors for programmatic handling via `errors.Is` / `errors.As` - **Testable** — dependency-injectable command executor ## Installation @@ -262,6 +263,54 @@ The logger is compatible with `slog.Default()` which bridges to the standard `lo provider.WithLogger(slog.Default()) ``` +### Error Handling + +The package provides sentinel errors for `errors.Is` and typed errors for `errors.As`: + +```go +id, err := provider.ID(ctx) +if errors.Is(err, machineid.ErrNoIdentifiers) { + // No hardware identifiers were collected +} +``` + +#### Sentinel Errors + +| Error | Meaning | +|-----------------------|------------------------------------------------------------------| +| `ErrNoIdentifiers` | No hardware identifiers collected with current config | +| `ErrEmptyValue` | A component returned an empty value | +| `ErrNoValues` | A multi-value component (MAC, disk) returned no values | +| `ErrNotFound` | A value was not found in command output or system files | +| `ErrOEMPlaceholder` | A value matches a BIOS/UEFI placeholder ("To be filled...") | +| `ErrAllMethodsFailed` | All collection methods for a component were exhausted | + +#### Typed Errors + +Use `errors.As` to extract structured context from errors: + +```go +// Check if a system command failed +var cmdErr *machineid.CommandError +if errors.As(err, &cmdErr) { + fmt.Println("command:", cmdErr.Command) // e.g. "sysctl", "ioreg", "wmic" +} + +// Check if output parsing failed +var parseErr *machineid.ParseError +if errors.As(err, &parseErr) { + fmt.Println("source:", parseErr.Source) // e.g. "system_profiler JSON" +} + +// Inspect diagnostic errors per component +diag := provider.Diagnostics() +var compErr *machineid.ComponentError +if errors.As(diag.Errors["cpu"], &compErr) { + fmt.Println("component:", compErr.Component) + fmt.Println("cause:", compErr.Err) +} +``` + ## CLI Tool A ready-to-use command-line tool is included. @@ -286,6 +335,12 @@ machineid -cpu -uuid -json -diagnostics # Validate a previously stored ID machineid -cpu -uuid -validate "b5c42832542981af58c9dc3bc241219e780ff7d276cfad05fac222846edb84f7" +# Info-level logging (fallbacks, lifecycle events) +machineid -cpu -uuid -verbose + +# Debug-level logging (command details, raw values, timing) +machineid -all -debug + # Version information machineid -version machineid -version.long @@ -293,22 +348,24 @@ machineid -version.long ### All Flags -| Flag | Description | -|-----------------|------------------------------------------------------| -| `-cpu` | Include CPU identifier | -| `-motherboard` | Include motherboard serial number | -| `-uuid` | Include system UUID | -| `-mac` | Include network MAC addresses | -| `-disk` | Include disk serial numbers | -| `-all` | Include all hardware identifiers | -| `-vm` | VM-friendly mode (CPU + UUID only) | -| `-format N` | Output length: `32`, `64` (default), `128`, or `256` | -| `-salt STRING` | Custom salt for application-specific IDs | -| `-validate ID` | Validate an ID against the current machine | -| `-diagnostics` | Show collected/failed components | -| `-json` | Output as JSON | -| `-version` | Show version information | -| `-version.long` | Show detailed version information | +| Flag | Description | +|-----------------|-----------------------------------------------------------------| +| `-cpu` | Include CPU identifier | +| `-motherboard` | Include motherboard serial number | +| `-uuid` | Include system UUID | +| `-mac` | Include network MAC addresses | +| `-disk` | Include disk serial numbers | +| `-all` | Include all hardware identifiers | +| `-vm` | VM-friendly mode (CPU + UUID only) | +| `-format N` | Output length: `32`, `64` (default), `128`, or `256` | +| `-salt STRING` | Custom salt for application-specific IDs | +| `-validate ID` | Validate an ID against the current machine | +| `-diagnostics` | Show collected/failed components | +| `-json` | Output as JSON | +| `-verbose` | Enable info-level logging to stderr (fallbacks, lifecycle) | +| `-debug` | Enable debug-level logging to stderr (commands, values, timing) | +| `-version` | Show version information | +| `-version.long` | Show detailed version information | ## How It Works diff --git a/cmd/machineid/main_test.go b/cmd/machineid/main_test.go new file mode 100644 index 0000000..da0c83c --- /dev/null +++ b/cmd/machineid/main_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "github.com/slashdevops/machineid" +) + +func TestParseFormatMode(t *testing.T) { + tests := []struct { + input int + want machineid.FormatMode + wantErr bool + }{ + {32, machineid.Format32, false}, + {64, machineid.Format64, false}, + {128, machineid.Format128, false}, + {256, machineid.Format256, false}, + {0, 0, true}, + {16, 0, true}, + {512, 0, true}, + {-1, 0, true}, + } + + for _, tt := range tests { + got, err := parseFormatMode(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseFormatMode(%d) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("parseFormatMode(%d) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestFormatDiagnosticsNil(t *testing.T) { + provider := machineid.New() + // Before ID() call, Diagnostics() is nil + result := formatDiagnostics(provider) + if result != nil { + t.Error("Expected nil for provider without diagnostics") + } +} + +func TestFormatDiagnosticsWithData(t *testing.T) { + provider := machineid.New().WithCPU().WithSystemUUID() + // Generate ID to populate diagnostics + _, err := provider.ID(t.Context()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + result := formatDiagnostics(provider) + if result == nil { + t.Fatal("Expected non-nil diagnostics") + } + + if _, ok := result["collected"]; !ok { + t.Error("Expected 'collected' key in diagnostics") + } +} + +func TestPrintDiagnosticsNil(t *testing.T) { + provider := machineid.New() + // Should not panic + printDiagnostics(provider) +} + +func TestPrintDiagnosticsWithData(t *testing.T) { + provider := machineid.New().WithCPU().WithSystemUUID() + _, err := provider.ID(t.Context()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + // Should not panic + printDiagnostics(provider) +} + +func TestFormatDiagnosticsWithErrors(t *testing.T) { + provider := machineid.New().WithCPU().WithDisk() + _, _ = provider.ID(t.Context()) + + result := formatDiagnostics(provider) + if result == nil { + t.Fatal("Expected non-nil diagnostics") + } + + // At least collected should be present + if _, ok := result["collected"]; !ok { + t.Error("Expected 'collected' key in diagnostics") + } +} + +func TestPrintJSON(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + printJSON(map[string]any{"key": "value"}) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("printJSON output is not valid JSON: %v", err) + } + if result["key"] != "value" { + t.Errorf("Expected key=value, got %v", result["key"]) + } +} + +func TestHandleValidateValid(t *testing.T) { + provider := machineid.New().WithCPU().WithSystemUUID() + id, err := provider.ID(t.Context()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + handleValidate(t.Context(), provider, id, false) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + if !bytes.Contains(buf.Bytes(), []byte("valid: machine ID matches")) { + t.Errorf("Expected 'valid: machine ID matches', got %q", buf.String()) + } +} + +func TestHandleValidateValidJSON(t *testing.T) { + provider := machineid.New().WithCPU().WithSystemUUID() + id, err := provider.ID(t.Context()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + handleValidate(t.Context(), provider, id, true) + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("handleValidate JSON output is not valid JSON: %v", err) + } + if result["valid"] != true { + t.Errorf("Expected valid=true, got %v", result["valid"]) + } +} diff --git a/darwin_test.go b/darwin_test.go index d4a3b1b..b069bfe 100644 --- a/darwin_test.go +++ b/darwin_test.go @@ -3,8 +3,11 @@ package machineid import ( + "bytes" "context" + "errors" "fmt" + "log/slog" "strings" "testing" ) @@ -342,3 +345,379 @@ func TestMacOSDiskInfoSuccess(t *testing.T) { t.Errorf("Expected 1 disk entry, got %d", len(result)) } } + +// TestMacOSHardwareUUIDWithLogger tests UUID fallback with logger enabled. +func TestMacOSHardwareUUIDWithLogger(t *testing.T) { + t.Run("system_profiler parse error falls back to ioreg with logging", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("system_profiler", "not json") // Will cause parse error + mock.setOutput("ioreg", `"IOPlatformUUID" = "FALLBACK-UUID-123"`) + + result, err := macOSHardwareUUID(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "FALLBACK-UUID-123" { + t.Errorf("Expected 'FALLBACK-UUID-123', got %q", result) + } + if !bytes.Contains(buf.Bytes(), []byte("system_profiler UUID parsing failed")) { + t.Error("Expected 'system_profiler UUID parsing failed' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to ioreg for hardware UUID")) { + t.Error("Expected 'falling back to ioreg' in log output") + } + }) + + t.Run("system_profiler command error falls back with logging", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setError("system_profiler", fmt.Errorf("command failed")) + mock.setOutput("ioreg", `"IOPlatformUUID" = "FALLBACK-UUID-456"`) + + result, err := macOSHardwareUUID(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "FALLBACK-UUID-456" { + t.Errorf("Expected 'FALLBACK-UUID-456', got %q", result) + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to ioreg for hardware UUID")) { + t.Error("Expected fallback log message") + } + }) +} + +// TestMacOSSerialNumberWithLogger tests serial fallback with logger enabled. +func TestMacOSSerialNumberWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("system_profiler", "not json") // Will cause parse error + mock.setOutput("ioreg", `"IOPlatformSerialNumber" = "SERIAL-LOG"`) + + result, err := macOSSerialNumber(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "SERIAL-LOG" { + t.Errorf("Expected 'SERIAL-LOG', got %q", result) + } + if !bytes.Contains(buf.Bytes(), []byte("system_profiler serial parsing failed")) { + t.Error("Expected 'system_profiler serial parsing failed' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to ioreg for serial number")) { + t.Error("Expected 'falling back to ioreg' in log output") + } +} + +// TestMacOSHardwareUUIDViaIORegNotFoundWithLogger tests ioreg not-found with logger. +func TestMacOSHardwareUUIDViaIORegNotFoundWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("ioreg", "output without UUID pattern") + + _, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, logger) + if err == nil { + t.Error("Expected error") + } + if !bytes.Contains(buf.Bytes(), []byte("hardware UUID not found in ioreg output")) { + t.Error("Expected 'hardware UUID not found in ioreg output' in log") + } +} + +// TestMacOSSerialNumberViaIORegNotFoundWithLogger tests serial not-found with logger. +func TestMacOSSerialNumberViaIORegNotFoundWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("ioreg", "output without serial pattern") + + _, err := macOSSerialNumberViaIOReg(context.Background(), mock, logger) + if err == nil { + t.Error("Expected error") + } + if !bytes.Contains(buf.Bytes(), []byte("serial number not found in ioreg output")) { + t.Error("Expected 'serial number not found in ioreg output' in log") + } +} + +// TestMacOSCPUInfoEmptyBrand tests sysctl returning empty brand string. +func TestMacOSCPUInfoEmptyBrand(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "") // Empty brand string + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "Apple M2", + "machine_model": "Mac", + "platform_UUID": "UUID", + "serial_number": "SER" + }] + }`) + + result, err := macOSCPUInfo(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "Apple M2" { + t.Errorf("Expected 'Apple M2', got %q", result) + } +} + +// TestMacOSCPUInfoEmptyBrandWithLogger tests empty brand path with logger. +func TestMacOSCPUInfoEmptyBrandWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("sysctl", "") // Empty brand → falls to system_profiler + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "Apple M2 Pro", + "machine_model": "Mac", + "platform_UUID": "UUID", + "serial_number": "SER" + }] + }`) + + result, err := macOSCPUInfo(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "Apple M2 Pro" { + t.Errorf("Expected 'Apple M2 Pro', got %q", result) + } + if !bytes.Contains(buf.Bytes(), []byte("falling back to system_profiler for CPU info")) { + t.Error("Expected fallback log message") + } +} + +// TestMacOSCPUInfoProfilerJSONParseFailWithLogger tests JSON parse failure with logger. +func TestMacOSCPUInfoProfilerJSONParseFailWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setError("sysctl", fmt.Errorf("not found")) + mock.setOutput("system_profiler", "not valid json") + + _, err := macOSCPUInfo(context.Background(), mock, logger) + if err == nil { + t.Error("Expected error when all methods fail") + } + if !bytes.Contains(buf.Bytes(), []byte("system_profiler CPU JSON parsing failed")) { + t.Error("Expected 'system_profiler CPU JSON parsing failed' in log") + } + if !bytes.Contains(buf.Bytes(), []byte("all CPU info methods failed")) { + t.Error("Expected 'all CPU info methods failed' in log") + } +} + +// TestMacOSCPUInfoEmptyChipTypeWithLogger tests empty chip_type with logger. +func TestMacOSCPUInfoEmptyChipTypeWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setError("sysctl", fmt.Errorf("not found")) + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "", + "machine_model": "Mac", + "platform_UUID": "UUID", + "serial_number": "SER" + }] + }`) + + _, err := macOSCPUInfo(context.Background(), mock, logger) + if err == nil { + t.Error("Expected error when chip_type is empty") + } + if !bytes.Contains(buf.Bytes(), []byte("system_profiler returned empty chip_type")) { + t.Error("Expected 'system_profiler returned empty chip_type' in log") + } +} + +// TestMacOSCPUInfoSysctlBrandWithFeaturesFail tests brand success but features fail. +func TestMacOSCPUInfoSysctlBrandWithFeaturesFail(t *testing.T) { + mock := newMockExecutor() + // The mock returns same output for all "sysctl" calls. To test the + // features-fail path, we set sysctl to error on the second call. + // Since mockExecutor doesn't support per-arg differentiation, we test + // the code path where sysctl succeeds for brand but the features call + // also "succeeds" (same mock behavior). The brand-only path is tested + // by making sysctl return error after first call isn't straightforward, + // so we test that a non-empty brand with features returns formatted output. + mock.setOutput("sysctl", "Intel Core i7") + + result, err := macOSCPUInfo(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Both brand and features calls go to same mock → "Intel Core i7:Intel Core i7" + if !strings.Contains(result, "Intel Core i7") { + t.Errorf("Expected result to contain 'Intel Core i7', got %q", result) + } +} + +// TestParseStorageJSONEmptyDeviceName tests entries with empty device_name are skipped. +func TestParseStorageJSONEmptyDeviceName(t *testing.T) { + jsonOutput := `{ + "SPStorageDataType": [ + { + "_name": "Volume 1", + "physical_drive": { + "device_name": "", + "is_internal_disk": "yes" + } + }, + { + "_name": "Volume 2", + "physical_drive": { + "device_name": "APPLE SSD", + "is_internal_disk": "yes" + } + } + ] + }` + + result, err := parseStorageJSON(jsonOutput) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result) != 1 { + t.Errorf("Expected 1 disk entry (empty name skipped), got %d", len(result)) + } + if result[0] != "APPLE SSD" { + t.Errorf("Expected 'APPLE SSD', got %q", result[0]) + } +} + +// TestParseStorageJSONAllEmpty tests when all entries have empty device_name. +func TestParseStorageJSONAllEmpty(t *testing.T) { + jsonOutput := `{ + "SPStorageDataType": [ + { + "_name": "Volume 1", + "physical_drive": { + "device_name": "", + "is_internal_disk": "yes" + } + } + ] + }` + + _, err := parseStorageJSON(jsonOutput) + if err == nil { + t.Error("Expected error when all disk entries have empty device_name") + } +} + +// TestParseStorageJSONEmptyArray tests empty storage array. +func TestParseStorageJSONEmptyArray(t *testing.T) { + jsonOutput := `{"SPStorageDataType": []}` + _, err := parseStorageJSON(jsonOutput) + if err == nil { + t.Error("Expected error for empty storage array") + } +} + +// TestCollectMACAddressesWithLogger tests MAC collection with logger enabled. +func TestCollectMACAddressesWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + macs, err := collectMACAddresses(logger) + if err != nil { + t.Logf("collectMACAddresses error (may be expected): %v", err) + return + } + + // On most systems, some interfaces should produce log output + output := buf.String() + t.Logf("Found %d MACs, log output length: %d", len(macs), len(output)) + + // We can't assert specific log messages since they depend on system interfaces, + // but we can verify no panic occurred and logs were produced + if len(macs) > 0 && !bytes.Contains(buf.Bytes(), []byte("including interface")) { + t.Error("Expected 'including interface' log when MACs are found") + } +} + +// TestExtractHardwareFieldErrorTypes tests that extractHardwareField returns correct error types. +func TestExtractHardwareFieldErrorTypes(t *testing.T) { + t.Run("invalid JSON returns ParseError", func(t *testing.T) { + _, err := extractHardwareField("not json", func(e spHardwareEntry) string { + return e.PlatformUUID + }) + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Fatalf("Expected ParseError, got %T: %v", err, err) + } + if parseErr.Source != "system_profiler hardware JSON" { + t.Errorf("ParseError.Source = %q, want %q", parseErr.Source, "system_profiler hardware JSON") + } + }) + + t.Run("no data returns ParseError with ErrNotFound", func(t *testing.T) { + _, err := extractHardwareField(`{"SPHardwareDataType": []}`, func(e spHardwareEntry) string { + return e.PlatformUUID + }) + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Fatalf("Expected ParseError, got %T: %v", err, err) + } + if !errors.Is(err, ErrNotFound) { + t.Error("Expected ErrNotFound in error chain") + } + }) + + t.Run("empty field returns ParseError with ErrEmptyValue", func(t *testing.T) { + _, err := extractHardwareField(`{"SPHardwareDataType": [{"platform_UUID": ""}]}`, func(e spHardwareEntry) string { + return e.PlatformUUID + }) + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Fatalf("Expected ParseError, got %T: %v", err, err) + } + if !errors.Is(err, ErrEmptyValue) { + t.Error("Expected ErrEmptyValue in error chain") + } + }) +} + +// TestMacOSHardwareUUIDViaIORegErrorType tests error type from ioreg not-found. +func TestMacOSHardwareUUIDViaIORegErrorType(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("ioreg", "no UUID here") + + _, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, nil) + + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Fatalf("Expected ParseError, got %T: %v", err, err) + } + if !errors.Is(err, ErrNotFound) { + t.Error("Expected ErrNotFound in error chain") + } +} + +// TestMacOSCPUInfoAllFailErrorType tests error type when all CPU methods fail. +func TestMacOSCPUInfoAllFailErrorType(t *testing.T) { + mock := newMockExecutor() + mock.setError("sysctl", fmt.Errorf("not found")) + mock.setError("system_profiler", fmt.Errorf("not found")) + + _, err := macOSCPUInfo(context.Background(), mock, nil) + if !errors.Is(err, ErrAllMethodsFailed) { + t.Errorf("Expected ErrAllMethodsFailed, got %v", err) + } +} diff --git a/example_darwin_test.go b/example_darwin_test.go new file mode 100644 index 0000000..7461a5a --- /dev/null +++ b/example_darwin_test.go @@ -0,0 +1,64 @@ +//go:build darwin + +package machineid_test + +import ( + "context" + "fmt" + + "github.com/slashdevops/machineid" +) + +// ExampleProvider_Diagnostics demonstrates inspecting which hardware components +// were successfully collected. +func ExampleProvider_Diagnostics() { + provider := machineid.New(). + WithCPU(). + WithSystemUUID() + + _, _ = provider.ID(context.Background()) + + diag := provider.Diagnostics() + if diag == nil { + fmt.Println("no diagnostics") + return + } + + fmt.Printf("Components collected: %d\n", len(diag.Collected)) + fmt.Printf("Has collected data: %v\n", len(diag.Collected) > 0) + // Output: + // Components collected: 2 + // Has collected data: true +} + +// Example_integrity demonstrates that the format maintains integrity without collisions. +func Example_integrity() { + // Generate multiple IDs to show consistency and uniqueness + p1 := machineid.New().WithCPU().WithSystemUUID() + p2 := machineid.New().WithCPU().WithSystemUUID().WithMotherboard() + p3 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app1") + p4 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app2") + + id1, _ := p1.ID(context.Background()) + id2, _ := p2.ID(context.Background()) + id3, _ := p3.ID(context.Background()) + id4, _ := p4.ID(context.Background()) + + // Same configuration always produces same ID + id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background()) + fmt.Printf("Consistency: %v\n", id1 == id1Again) + + // Different configurations produce different IDs + fmt.Printf("Different hardware: %v\n", id1 != id2) + fmt.Printf("Different salts: %v\n", id3 != id4) + + // All IDs are 64 characters (power of 2) + fmt.Printf("All are 64 chars: %v\n", + len(id1) == 64 && len(id2) == 64 && len(id3) == 64 && len(id4) == 64) + + // Output: + // Consistency: true + // Different hardware: true + // Different salts: true + // All are 64 chars: true +} diff --git a/example_poweroftwo_test.go b/example_poweroftwo_test.go index 82a3c26..4f8c982 100644 --- a/example_poweroftwo_test.go +++ b/example_poweroftwo_test.go @@ -27,38 +27,6 @@ func Example_powerOfTwo() { // Format64 bits: 256 (2^256 possible values) } -// Example_integrity demonstrates that the format maintains integrity without collisions. -func Example_integrity() { - // Generate multiple IDs to show consistency and uniqueness - p1 := machineid.New().WithCPU().WithSystemUUID() - p2 := machineid.New().WithCPU().WithSystemUUID().WithMotherboard() - p3 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app1") - p4 := machineid.New().WithCPU().WithSystemUUID().WithSalt("app2") - - id1, _ := p1.ID(context.Background()) - id2, _ := p2.ID(context.Background()) - id3, _ := p3.ID(context.Background()) - id4, _ := p4.ID(context.Background()) - - // Same configuration always produces same ID - id1Again, _ := machineid.New().WithCPU().WithSystemUUID().ID(context.Background()) - fmt.Printf("Consistency: %v\n", id1 == id1Again) - - // Different configurations produce different IDs - fmt.Printf("Different hardware: %v\n", id1 != id2) - fmt.Printf("Different salts: %v\n", id3 != id4) - - // All IDs are 64 characters (power of 2) - fmt.Printf("All are 64 chars: %v\n", - len(id1) == 64 && len(id2) == 64 && len(id3) == 64 && len(id4) == 64) - - // Output: - // Consistency: true - // Different hardware: true - // Different salts: true - // All are 64 chars: true -} - // Example_collisionResistance demonstrates the collision resistance of different formats. func Example_collisionResistance() { // Calculate collision probability (simplified) diff --git a/example_test.go b/example_test.go index 63feb8e..9455fc5 100644 --- a/example_test.go +++ b/example_test.go @@ -94,28 +94,6 @@ func ExampleProvider_Validate() { // Wrong ID valid: false } -// ExampleProvider_Diagnostics demonstrates inspecting which hardware components -// were successfully collected. -func ExampleProvider_Diagnostics() { - provider := machineid.New(). - WithCPU(). - WithSystemUUID() - - _, _ = provider.ID(context.Background()) - - diag := provider.Diagnostics() - if diag == nil { - fmt.Println("no diagnostics") - return - } - - fmt.Printf("Components collected: %d\n", len(diag.Collected)) - fmt.Printf("Has collected data: %v\n", len(diag.Collected) > 0) - // Output: - // Components collected: 2 - // Has collected data: true -} - // ExampleProvider_VMFriendly_preset demonstrates the VM-friendly preset. func ExampleProvider_VMFriendly_preset() { id, err := machineid.New(). diff --git a/machineid_internal_darwin_test.go b/machineid_internal_darwin_test.go new file mode 100644 index 0000000..655387c --- /dev/null +++ b/machineid_internal_darwin_test.go @@ -0,0 +1,358 @@ +//go:build darwin + +package machineid + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" +) + +// TestProviderWithMockExecutor tests using a mock executor for deterministic testing. +func TestProviderWithMockExecutor(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU Brand String") + + g := New(). + WithExecutor(mock). + WithCPU() + + id, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("ID() with mock executor error = %v", err) + } + + if len(id) != 64 { + t.Errorf("ID() returned ID of length %d, expected 64", len(id)) + } + + // Verify the ID is consistent with the same mock + id2, err := g.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() call error = %v", err) + } + + if id != id2 { + t.Error("ID() returned different IDs with same mock executor") + } +} + +// TestProviderErrorHandling tests various error conditions. +func TestProviderErrorHandling(t *testing.T) { + tests := []struct { + name string + setupMock func(*mockExecutor) + configure func(*Provider) *Provider + expectError bool + wantErr error + }{ + { + name: "command execution fails but no fallback available", + setupMock: func(m *mockExecutor) { + m.setError("sysctl", fmt.Errorf("command not found")) + }, + configure: func(p *Provider) *Provider { + return p.WithCPU() + }, + expectError: true, + wantErr: ErrNoIdentifiers, + }, + { + name: "no identifiers collected", + setupMock: func(m *mockExecutor) { + // All commands fail + m.setError("sysctl", fmt.Errorf("failed")) + m.setError("ioreg", fmt.Errorf("failed")) + m.setError("system_profiler", fmt.Errorf("failed")) + }, + configure: func(p *Provider) *Provider { + return p.WithCPU().WithSystemUUID() + }, + expectError: true, + wantErr: ErrNoIdentifiers, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockExecutor() + if tt.setupMock != nil { + tt.setupMock(mock) + } + + p := New().WithExecutor(mock) + p = tt.configure(p) + + _, err := p.ID(context.Background()) + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if tt.expectError && err != nil && tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Errorf("got error %v, want %v", err, tt.wantErr) + } + } + }) + } +} + +// TestValidateError tests Validate method when ID generation fails. +func TestValidateError(t *testing.T) { + mock := newMockExecutor() + mock.setError("sysctl", fmt.Errorf("command failed")) + + p := New().WithExecutor(mock).WithCPU() + + valid, err := p.Validate(context.Background(), "some-id") + if err == nil { + t.Error("Expected error when ID generation fails") + } + if valid { + t.Error("Validation should fail when error occurs") + } +} + +// TestDiagnosticsAvailableAfterID tests that Diagnostics() returns data after ID(). +func TestDiagnosticsAvailableAfterID(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU") + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "Apple M1", + "machine_model": "Mac", + "platform_UUID": "UUID-123", + "serial_number": "SERIAL" + }] + }`) + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + + // Before ID(), Diagnostics should be nil + if p.Diagnostics() != nil { + t.Error("Diagnostics should be nil before ID() call") + } + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + diag := p.Diagnostics() + if diag == nil { + t.Fatal("Diagnostics should not be nil after ID() call") + } + + if len(diag.Collected) == 0 { + t.Error("Expected at least one collected component") + } +} + +// TestDiagnosticsRecordsFailures tests that failed components are recorded. +func TestDiagnosticsRecordsFailures(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU") + mock.setOutput("system_profiler", `{ + "SPHardwareDataType": [{ + "chip_type": "Apple M1", + "machine_model": "Mac", + "platform_UUID": "", + "serial_number": "" + }] + }`) + mock.setError("ioreg", fmt.Errorf("ioreg not available")) + + p := New().WithExecutor(mock).WithCPU().WithSystemUUID() + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + diag := p.Diagnostics() + if diag == nil { + t.Fatal("Diagnostics should not be nil") + } + + // CPU should succeed, UUID should fail (empty in JSON + ioreg fails) + if len(diag.Collected) == 0 { + t.Error("Expected at least one collected component") + } +} + +// TestProviderCachedIDNotModified tests that cached ID is not modified on subsequent calls. +func TestProviderCachedIDNotModified(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "CPU1") + + p := New().WithExecutor(mock).WithCPU() + + id1, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("First ID() call failed: %v", err) + } + + // Change the mock output + mock.setOutput("sysctl", "CPU2") + + // Should still return cached value + id2, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() call failed: %v", err) + } + + if id1 != id2 { + t.Error("Cached ID was modified on subsequent call") + } + + // Verify mock was only called once (due to caching) + if mock.callCount["sysctl"] > 2 { + t.Errorf("Expected sysctl to be called at most twice, got %d", mock.callCount["sysctl"]) + } +} + +// TestProviderAllIdentifiers tests using all identifier types. +func TestProviderAllIdentifiers(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Intel CPU") + mock.setOutput("system_profiler", `platform_UUID: "UUID123"`) + mock.setOutput("ioreg", "some data") + mock.setOutput("diskutil", `/dev/disk0`) + + p := New(). + WithExecutor(mock). + WithCPU(). + WithSystemUUID(). + WithMotherboard(). + WithMAC(). + WithDisk() + + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() with all identifiers failed: %v", err) + } + + if len(id) != 64 { + t.Errorf("Expected 64-character ID (default Format64), got %d", len(id)) + } +} + +// TestCollectIdentifiersError tests when collectIdentifiers returns an error. +func TestCollectIdentifiersError(t *testing.T) { + mock := newMockExecutor() + // Don't set any outputs, so all commands will fail with "not configured" + + p := New().WithExecutor(mock).WithCPU() + + _, err := p.ID(context.Background()) + if err == nil { + t.Error("Expected error when collectIdentifiers fails") + } +} + +// TestProviderValidateMismatch tests validation with mismatched ID. +func TestProviderValidateMismatch(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "CPU1") + + p := New().WithExecutor(mock).WithCPU() + + // Generate ID + id, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() failed: %v", err) + } + + // Validate with different ID + valid, err := p.Validate(context.Background(), id+"different") + if err != nil { + t.Errorf("Validate() error: %v", err) + } + + if valid { + t.Error("Expected validation to fail for different ID") + } +} + +// TestWithLoggerOutput verifies that log output appears when a logger is set. +func TestWithLoggerOutput(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU Brand") + + p := New(). + WithExecutor(mock). + WithLogger(logger). + WithCPU() + + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } + + output := buf.String() + if output == "" { + t.Error("Expected log output when logger is set, got empty string") + } + + // Check for key log messages + if !bytes.Contains(buf.Bytes(), []byte("generating machine ID")) { + t.Error("Expected 'generating machine ID' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("machine ID generated")) { + t.Error("Expected 'machine ID generated' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("component collected")) { + t.Error("Expected 'component collected' in log output") + } +} + +// TestWithoutLoggerNoOutput verifies that no logging occurs without a logger. +func TestWithoutLoggerNoOutput(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU Brand") + + p := New(). + WithExecutor(mock). + WithCPU() + + // Should not panic or produce any output + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("ID() error: %v", err) + } +} + +// TestProviderCachedIDWithLogger tests the cached ID debug log path. +func TestProviderCachedIDWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("sysctl", "Test CPU") + + p := New().WithExecutor(mock).WithLogger(logger).WithCPU() + + // First call generates ID + _, err := p.ID(context.Background()) + if err != nil { + t.Fatalf("First ID() error: %v", err) + } + + // Second call returns cached + _, err = p.ID(context.Background()) + if err != nil { + t.Fatalf("Second ID() error: %v", err) + } + + if !bytes.Contains(buf.Bytes(), []byte("returning cached machine ID")) { + t.Error("Expected 'returning cached machine ID' in log output") + } +} diff --git a/machineid_internal_test.go b/machineid_internal_test.go index ff07868..87f98d3 100644 --- a/machineid_internal_test.go +++ b/machineid_internal_test.go @@ -9,97 +9,6 @@ import ( "testing" ) -// TestProviderWithMockExecutor tests using a mock executor for deterministic testing. -func TestProviderWithMockExecutor(t *testing.T) { - mock := newMockExecutor() - mock.setOutput("sysctl", "Test CPU Brand String") - - g := New(). - WithExecutor(mock). - WithCPU() - - id, err := g.ID(context.Background()) - if err != nil { - t.Fatalf("ID() with mock executor error = %v", err) - } - - if len(id) != 64 { - t.Errorf("ID() returned ID of length %d, expected 64", len(id)) - } - - // Verify the ID is consistent with the same mock - id2, err := g.ID(context.Background()) - if err != nil { - t.Fatalf("Second ID() call error = %v", err) - } - - if id != id2 { - t.Error("ID() returned different IDs with same mock executor") - } -} - -// TestProviderErrorHandling tests various error conditions. -func TestProviderErrorHandling(t *testing.T) { - tests := []struct { - name string - setupMock func(*mockExecutor) - configure func(*Provider) *Provider - expectError bool - wantErr error - }{ - { - name: "command execution fails but no fallback available", - setupMock: func(m *mockExecutor) { - m.setError("sysctl", fmt.Errorf("command not found")) - }, - configure: func(p *Provider) *Provider { - return p.WithCPU() - }, - expectError: true, - wantErr: ErrNoIdentifiers, - }, - { - name: "no identifiers collected", - setupMock: func(m *mockExecutor) { - // All commands fail - m.setError("sysctl", fmt.Errorf("failed")) - m.setError("ioreg", fmt.Errorf("failed")) - m.setError("system_profiler", fmt.Errorf("failed")) - }, - configure: func(p *Provider) *Provider { - return p.WithCPU().WithSystemUUID() - }, - expectError: true, - wantErr: ErrNoIdentifiers, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mock := newMockExecutor() - if tt.setupMock != nil { - tt.setupMock(mock) - } - - p := New().WithExecutor(mock) - p = tt.configure(p) - - _, err := p.ID(context.Background()) - if tt.expectError && err == nil { - t.Error("Expected error but got none") - } - if !tt.expectError && err != nil { - t.Errorf("Unexpected error: %v", err) - } - if tt.expectError && err != nil && tt.wantErr != nil { - if !errors.Is(err, tt.wantErr) { - t.Errorf("got error %v, want %v", err, tt.wantErr) - } - } - }) - } -} - // TestHashIdentifiersEmpty tests hashing with empty identifiers. func TestHashIdentifiersEmpty(t *testing.T) { result := hashIdentifiers([]string{}, "", Format64) @@ -133,22 +42,6 @@ func TestHashIdentifiersWithoutSalt(t *testing.T) { } } -// TestValidateError tests Validate method when ID generation fails. -func TestValidateError(t *testing.T) { - mock := newMockExecutor() - mock.setError("sysctl", fmt.Errorf("command failed")) - - p := New().WithExecutor(mock).WithCPU() - - valid, err := p.Validate(context.Background(), "some-id") - if err == nil { - t.Error("Expected error when ID generation fails") - } - if valid { - t.Error("Validation should fail when error occurs") - } -} - // TestAppendIdentifierIfValidEmpty tests with empty value. func TestAppendIdentifierIfValidEmpty(t *testing.T) { diag := &DiagnosticInfo{Errors: make(map[string]error)} @@ -288,214 +181,315 @@ func TestAppendIdentifierNilDiag(t *testing.T) { } } -// TestDiagnosticsAvailableAfterID tests that Diagnostics() returns data after ID(). -func TestDiagnosticsAvailableAfterID(t *testing.T) { - mock := newMockExecutor() - mock.setOutput("sysctl", "Test CPU") - mock.setOutput("system_profiler", `{ - "SPHardwareDataType": [{ - "chip_type": "Apple M1", - "machine_model": "Mac", - "platform_UUID": "UUID-123", - "serial_number": "SERIAL" - }] - }`) +// TestAppendIdentifierIfValidWithLogger tests all logger paths in appendIdentifierIfValid. +func TestAppendIdentifierIfValidWithLogger(t *testing.T) { + t.Run("error with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + result := appendIdentifierIfValid(nil, func() (string, error) { + return "", fmt.Errorf("test error") + }, "prefix:", diag, "test-comp", logger) + + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if !bytes.Contains(buf.Bytes(), []byte("component failed")) { + t.Error("Expected 'component failed' in log output") + } + }) + + t.Run("empty value with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + result := appendIdentifierIfValid(nil, func() (string, error) { + return "", nil + }, "prefix:", diag, "test-comp", logger) + + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if !bytes.Contains(buf.Bytes(), []byte("component returned empty value")) { + t.Error("Expected 'component returned empty value' in log output") + } + }) + + t.Run("success with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + result := appendIdentifierIfValid(nil, func() (string, error) { + return "good-value", nil + }, "prefix:", diag, "test-comp", logger) + + if len(result) != 1 { + t.Errorf("Expected 1 identifier, got %d", len(result)) + } + if !bytes.Contains(buf.Bytes(), []byte("component collected")) { + t.Error("Expected 'component collected' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("component value")) { + t.Error("Expected 'component value' in log output") + } + }) +} - p := New().WithExecutor(mock).WithCPU().WithSystemUUID() +// TestAppendIdentifiersIfValidWithLogger tests all logger paths in appendIdentifiersIfValid. +func TestAppendIdentifiersIfValidWithLogger(t *testing.T) { + t.Run("error with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + result := appendIdentifiersIfValid(nil, func() ([]string, error) { + return nil, fmt.Errorf("test error") + }, "prefix:", diag, "test-comp", logger) + + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if !bytes.Contains(buf.Bytes(), []byte("component failed")) { + t.Error("Expected 'component failed' in log output") + } + }) + + t.Run("empty values with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + result := appendIdentifiersIfValid(nil, func() ([]string, error) { + return []string{}, nil + }, "prefix:", diag, "test-comp", logger) + + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) + } + if !bytes.Contains(buf.Bytes(), []byte("component returned no values")) { + t.Error("Expected 'component returned no values' in log output") + } + }) + + t.Run("success with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + diag := &DiagnosticInfo{Errors: make(map[string]error)} + + result := appendIdentifiersIfValid(nil, func() ([]string, error) { + return []string{"val1", "val2"}, nil + }, "prefix:", diag, "test-comp", logger) + + if len(result) != 2 { + t.Errorf("Expected 2 identifiers, got %d", len(result)) + } + if !bytes.Contains(buf.Bytes(), []byte("component collected")) { + t.Error("Expected 'component collected' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("component values")) { + t.Error("Expected 'component values' in log output") + } + }) +} - // Before ID(), Diagnostics should be nil - if p.Diagnostics() != nil { - t.Error("Diagnostics should be nil before ID() call") - } +// TestAppendIdentifiersIfValidEmptyWithDiag tests empty values record ErrNoValues in diagnostics. +func TestAppendIdentifiersIfValidEmptyWithDiag(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + result := appendIdentifiersIfValid(nil, func() ([]string, error) { + return []string{}, nil + }, "prefix:", diag, "test", nil) - _, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("ID() error: %v", err) + if len(result) != 0 { + t.Errorf("Expected 0 identifiers, got %d", len(result)) } - - diag := p.Diagnostics() - if diag == nil { - t.Fatal("Diagnostics should not be nil after ID() call") + diagErr, ok := diag.Errors["test"] + if !ok { + t.Fatal("Expected error recorded in diagnostics for empty values") } - - if len(diag.Collected) == 0 { - t.Error("Expected at least one collected component") + if !errors.Is(diagErr, ErrNoValues) { + t.Errorf("Expected ErrNoValues in diagnostic, got %v", diagErr) } -} - -// TestDiagnosticsRecordsFailures tests that failed components are recorded. -func TestDiagnosticsRecordsFailures(t *testing.T) { - mock := newMockExecutor() - mock.setOutput("sysctl", "Test CPU") - mock.setOutput("system_profiler", `{ - "SPHardwareDataType": [{ - "chip_type": "Apple M1", - "machine_model": "Mac", - "platform_UUID": "", - "serial_number": "" - }] - }`) - mock.setError("ioreg", fmt.Errorf("ioreg not available")) - - p := New().WithExecutor(mock).WithCPU().WithSystemUUID() - - _, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("ID() error: %v", err) + var compErr *ComponentError + if !errors.As(diagErr, &compErr) { + t.Fatal("Expected ComponentError in diagnostic") } - - diag := p.Diagnostics() - if diag == nil { - t.Fatal("Diagnostics should not be nil") + if compErr.Component != "test" { + t.Errorf("ComponentError.Component = %q, want %q", compErr.Component, "test") } +} - // CPU should succeed, UUID should fail (empty in JSON + ioreg fails) - if len(diag.Collected) == 0 { - t.Error("Expected at least one collected component") +// TestFormatHashInvalidLength tests formatHash with non-64-char input. +func TestFormatHashInvalidLength(t *testing.T) { + short := "abc123" + result := formatHash(short, Format64) + if result != short { + t.Errorf("Expected input returned unchanged for invalid length, got %q", result) } } -// TestProviderCachedIDNotModified tests that cached ID is not modified on subsequent calls. -func TestProviderCachedIDNotModified(t *testing.T) { - mock := newMockExecutor() - mock.setOutput("sysctl", "CPU1") - - p := New().WithExecutor(mock).WithCPU() - - id1, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("First ID() call failed: %v", err) +// TestFormatHashDefaultCase tests formatHash with an unknown FormatMode. +func TestFormatHashDefaultCase(t *testing.T) { + // Create a valid 64-char hex string + hash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + result := formatHash(hash, FormatMode(999)) + if result != hash { + t.Errorf("Expected input returned unchanged for unknown format mode, got %q", result) } +} - // Change the mock output - mock.setOutput("sysctl", "CPU2") +// TestFormatHashAllModes tests formatHash produces correct lengths for all modes. +func TestFormatHashAllModes(t *testing.T) { + hash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" - // Should still return cached value - id2, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("Second ID() call failed: %v", err) + tests := []struct { + mode FormatMode + wantLength int + }{ + {Format32, 32}, + {Format64, 64}, + {Format128, 128}, + {Format256, 256}, } - if id1 != id2 { - t.Error("Cached ID was modified on subsequent call") + for _, tt := range tests { + result := formatHash(hash, tt.mode) + if len(result) != tt.wantLength { + t.Errorf("formatHash(mode=%d) length = %d, want %d", tt.mode, len(result), tt.wantLength) + } } +} - // Verify mock was only called once (due to caching) - if mock.callCount["sysctl"] > 2 { - t.Errorf("Expected sysctl to be called at most twice, got %d", mock.callCount["sysctl"]) - } +// TestLogMethodsNilLogger tests that log methods don't panic with nil logger. +func TestLogMethodsNilLogger(t *testing.T) { + p := New() // no logger set + + // These should not panic + p.logDebug("test debug") + p.logInfo("test info") + p.logWarn("test warn") } -// TestProviderAllIdentifiers tests using all identifier types. -func TestProviderAllIdentifiers(t *testing.T) { - mock := newMockExecutor() - mock.setOutput("sysctl", "Intel CPU") - mock.setOutput("system_profiler", `platform_UUID: "UUID123"`) - mock.setOutput("ioreg", "some data") - mock.setOutput("diskutil", `/dev/disk0`) +// TestLogMethodsWithLogger tests that log methods produce output with logger set. +func TestLogMethodsWithLogger(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + p := New().WithLogger(logger) - p := New(). - WithExecutor(mock). - WithCPU(). - WithSystemUUID(). - WithMotherboard(). - WithMAC(). - WithDisk() + p.logDebug("test debug msg") + p.logInfo("test info msg") + p.logWarn("test warn msg") - id, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("ID() with all identifiers failed: %v", err) + output := buf.String() + if !bytes.Contains([]byte(output), []byte("test debug msg")) { + t.Error("Expected 'test debug msg' in log output") } - - if len(id) != 64 { - t.Errorf("Expected 64-character ID (default Format64), got %d", len(id)) + if !bytes.Contains([]byte(output), []byte("test info msg")) { + t.Error("Expected 'test info msg' in log output") + } + if !bytes.Contains([]byte(output), []byte("test warn msg")) { + t.Error("Expected 'test warn msg' in log output") } } -// TestCollectIdentifiersError tests when collectIdentifiers returns an error. -func TestCollectIdentifiersError(t *testing.T) { - mock := newMockExecutor() - // Don't set any outputs, so all commands will fail with "not configured" +// TestEnabledComponents tests that enabledComponents returns correct names. +func TestEnabledComponents(t *testing.T) { + p := New().WithCPU().WithSystemUUID().WithDisk() + components := p.enabledComponents() - p := New().WithExecutor(mock).WithCPU() + if len(components) != 3 { + t.Fatalf("Expected 3 components, got %d: %v", len(components), components) + } - _, err := p.ID(context.Background()) - if err == nil { - t.Error("Expected error when collectIdentifiers fails") + want := []string{ComponentCPU, ComponentSystemUUID, ComponentDisk} + for i, c := range components { + if c != want[i] { + t.Errorf("Component[%d] = %q, want %q", i, c, want[i]) + } } } -// TestProviderValidateMismatch tests validation with mismatched ID. -func TestProviderValidateMismatch(t *testing.T) { - mock := newMockExecutor() - mock.setOutput("sysctl", "CPU1") - - p := New().WithExecutor(mock).WithCPU() +// TestEnabledComponentsAll tests all components enabled. +func TestEnabledComponentsAll(t *testing.T) { + p := New().WithCPU().WithMotherboard().WithSystemUUID().WithMAC().WithDisk() + components := p.enabledComponents() - // Generate ID - id, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("ID() failed: %v", err) + if len(components) != 5 { + t.Fatalf("Expected 5 components, got %d: %v", len(components), components) } +} - // Validate with different ID - valid, err := p.Validate(context.Background(), id+"different") - if err != nil { - t.Errorf("Validate() error: %v", err) - } +// TestEnabledComponentsNone tests no components enabled. +func TestEnabledComponentsNone(t *testing.T) { + p := New() + components := p.enabledComponents() - if valid { - t.Error("Expected validation to fail for different ID") + if len(components) != 0 { + t.Errorf("Expected 0 components, got %d: %v", len(components), components) } } -// TestWithLoggerOutput verifies that log output appears when a logger is set. -func TestWithLoggerOutput(t *testing.T) { +// TestProviderWithLoggerNoIdentifiersWarning tests logWarn path when no identifiers collected. +func TestProviderWithLoggerNoIdentifiersWarning(t *testing.T) { var buf bytes.Buffer logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) - mock := newMockExecutor() - mock.setOutput("sysctl", "Test CPU Brand") - - p := New(). - WithExecutor(mock). - WithLogger(logger). - WithCPU() + p := New().WithLogger(logger) + // No components enabled → ErrNoIdentifiers _, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("ID() error: %v", err) + if !errors.Is(err, ErrNoIdentifiers) { + t.Errorf("Expected ErrNoIdentifiers, got %v", err) } - output := buf.String() - if output == "" { - t.Error("Expected log output when logger is set, got empty string") - } - - // Check for key log messages - if !bytes.Contains(buf.Bytes(), []byte("generating machine ID")) { - t.Error("Expected 'generating machine ID' in log output") - } - if !bytes.Contains(buf.Bytes(), []byte("machine ID generated")) { - t.Error("Expected 'machine ID generated' in log output") - } - if !bytes.Contains(buf.Bytes(), []byte("component collected")) { - t.Error("Expected 'component collected' in log output") + if !bytes.Contains(buf.Bytes(), []byte("no hardware identifiers collected")) { + t.Error("Expected 'no hardware identifiers collected' warning in log output") } } -// TestWithoutLoggerNoOutput verifies that no logging occurs without a logger. -func TestWithoutLoggerNoOutput(t *testing.T) { - mock := newMockExecutor() - mock.setOutput("sysctl", "Test CPU Brand") - - p := New(). - WithExecutor(mock). - WithCPU() - - // Should not panic or produce any output - _, err := p.ID(context.Background()) - if err != nil { - t.Fatalf("ID() error: %v", err) - } +// TestExecuteCommandWithLogger tests executeCommand logger output paths. +func TestExecuteCommandWithLogger(t *testing.T) { + t.Run("success with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("testcmd", "output") + + result, err := executeCommand(context.Background(), mock, logger, "testcmd", "arg1") + if err != nil { + t.Fatalf("executeCommand error: %v", err) + } + if result != "output" { + t.Errorf("Expected 'output', got %q", result) + } + if !bytes.Contains(buf.Bytes(), []byte("executing command")) { + t.Error("Expected 'executing command' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("command completed")) { + t.Error("Expected 'command completed' in log output") + } + }) + + t.Run("error with logger", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setError("testcmd", fmt.Errorf("mock failure")) + + _, err := executeCommand(context.Background(), mock, logger, "testcmd") + if err == nil { + t.Fatal("Expected error") + } + if !bytes.Contains(buf.Bytes(), []byte("executing command")) { + t.Error("Expected 'executing command' in log output") + } + if !bytes.Contains(buf.Bytes(), []byte("command failed")) { + t.Error("Expected 'command failed' in log output") + } + }) }