Improve test coverage for cmd/pulse-sensor-proxy

This commit is contained in:
rcourtman 2026-01-04 16:10:34 +00:00
parent f77025fb2f
commit 7a1e3e9b4e
4 changed files with 659 additions and 4 deletions

View file

@ -319,6 +319,12 @@ func TestHelperProcess(t *testing.T) {
output := os.Getenv("GO_HELPER_OUTPUT")
fmt.Fprint(os.Stdout, output)
if codeStr := os.Getenv("GO_HELPER_EXIT_CODE"); codeStr != "" {
if code, err := strconv.Atoi(codeStr); err == nil {
os.Exit(code)
}
}
os.Exit(0)
}

View file

@ -0,0 +1,336 @@
package main
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/rs/zerolog"
)
type mockKnownHosts struct{}
func (m *mockKnownHosts) Ensure(ctx context.Context, host string) error { return nil }
func (m *mockKnownHosts) EnsureWithPort(ctx context.Context, host string, port int) error { return nil }
func (m *mockKnownHosts) EnsureWithEntries(ctx context.Context, host string, port int, entries [][]byte) error {
return nil
}
func (m *mockKnownHosts) Path() string { return "/tmp/known_hosts" }
func TestHandleGetStatusV2(t *testing.T) {
tmpDir := t.TempDir()
pubKeyPath := filepath.Join(tmpDir, "id_ed25519.pub")
os.WriteFile(pubKeyPath, []byte("ssh-ed25519 AAA..."), 0644)
proxy := &Proxy{
sshKeyPath: tmpDir,
}
req := &RPCRequest{}
logger := zerolog.New(os.Stderr)
resp, err := proxy.handleGetStatusV2(context.Background(), req, logger)
if err != nil {
t.Fatalf("handleGetStatusV2 failed: %v", err)
}
m, ok := resp.(map[string]interface{})
if !ok {
t.Fatalf("expected map response, got %T", resp)
}
if m["version"] != Version {
t.Errorf("expected version %s, got %v", Version, m["version"])
}
if m["public_key"] != "ssh-ed25519 AAA..." {
t.Errorf("unexpected public key: %v", m["public_key"])
}
}
func TestHandleGetTemperatureV2(t *testing.T) {
// Mock execLookPath and execCommandFunc
origLookPath := execLookPath
origExec := execCommandFunc
origExecCtx := execCommandContextFunc
defer func() {
execLookPath = origLookPath
execCommandFunc = origExec
execCommandContextFunc = origExecCtx
}()
execLookPath = func(file string) (string, error) {
return "/usr/bin/" + file, nil
}
mockCmd := func(ctx context.Context, name string, arg ...string) *exec.Cmd {
args := strings.Join(arg, " ")
if name == "sh" && strings.Contains(args, "ssh") {
// Mock SSH output with sensor JSON
output := `{"cpu": {"temp1": {"temp1_input": 45.0}}}`
return mockExecCommand(output)
}
// For local node
if name == "sensors" {
output := `{"cpu": {"temp1": {"temp1_input": 45.0}}}`
return mockExecCommand(output)
}
return mockExecCommand("")
}
execCommandFunc = func(name string, arg ...string) *exec.Cmd {
return mockCmd(context.Background(), name, arg...)
}
execCommandContextFunc = func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return mockCmd(ctx, name, arg...)
}
proxy := &Proxy{
config: &Config{
AllowedNodes: []string{"node1"},
},
metrics: NewProxyMetrics("test"),
nodeGate: newNodeGate(),
knownHosts: &mockKnownHosts{},
}
logger := zerolog.New(os.Stderr)
// Success case
req := &RPCRequest{
Params: map[string]interface{}{
"node": "node1",
},
}
resp, err := proxy.handleGetTemperatureV2(context.Background(), req, logger)
if err != nil {
t.Fatalf("handleGetTemperatureV2 failed: %v", err)
}
m, ok := resp.(map[string]interface{})
if !ok {
t.Fatalf("expected map response, got %T", resp)
}
respStr, ok := m["temperature"].(string)
if !ok {
t.Errorf("expected string temperature, got %T", m["temperature"])
}
if !strings.Contains(respStr, "45.0") {
t.Errorf("expected 45.0 in response, got %s", respStr)
}
// Missing parameter
reqMissing := &RPCRequest{Params: map[string]interface{}{}}
_, err = proxy.handleGetTemperatureV2(context.Background(), reqMissing, logger)
if err == nil {
t.Error("expected error for missing node param")
}
// Invalid node (bad format)
reqInvalid := &RPCRequest{
Params: map[string]interface{}{
"node": "invalid node!", // Space should make it invalid
},
}
_, err = proxy.handleGetTemperatureV2(context.Background(), reqInvalid, logger)
if err == nil {
t.Error("expected error for invalid node")
}
}
func TestHandleRegisterNodesV2(t *testing.T) {
// Mock pvecm
origLookPath := execLookPath
origExec := execCommandFunc
origExecCtx := execCommandContextFunc
defer func() {
execLookPath = origLookPath
execCommandFunc = origExec
execCommandContextFunc = origExecCtx
}()
execLookPath = func(file string) (string, error) {
if file == "pvecm" {
return "/usr/sbin/pvecm", nil
}
return "", fmt.Errorf("not found")
}
mockCmd := func(ctx context.Context, name string, arg ...string) *exec.Cmd {
args := strings.Join(arg, " ")
if name == "pvecm" && args == "status" {
// Mock pvecm status output
output := "\nCluster information\n-------------------\nName: cluster\nConfig Version: 1\nTransport: knet\nSecure auth: on\n\nQuorum information\n------------------\nDate: Sun Jan 1 00:00:00 2023\nQuorum provider: corosync_votequorum\nNodes: 1\nNode ID: 0x00000001\nRing ID: 1.1\nQuorate: Yes\n\nVotequorum information\n----------------------\nExpected votes: 1\nHighest expected: 1\nTotal votes: 1\nQuorum: 1\nFlags: Quorate \n\nMembership information\n----------------------\n Nodeid Votes Name\n0x00000001 1 10.0.0.1 (local)\n"
return mockExecCommand(output)
}
// Mock SSH connection test
if name == "sh" && strings.Contains(args, "ssh") {
// Success
return mockExecCommand("")
}
return mockExecCommand("")
}
execCommandFunc = func(name string, arg ...string) *exec.Cmd {
return mockCmd(context.Background(), name, arg...)
}
execCommandContextFunc = func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return mockCmd(ctx, name, arg...)
}
tmpDir := t.TempDir()
proxy := &Proxy{
sshKeyPath: tmpDir,
metrics: NewProxyMetrics("test"),
knownHosts: &mockKnownHosts{},
config: &Config{},
}
logger := zerolog.New(os.Stderr)
req := &RPCRequest{}
resp, err := proxy.handleRegisterNodesV2(context.Background(), req, logger)
if err != nil {
t.Fatalf("handleRegisterNodesV2 failed: %v", err)
}
m, ok := resp.(map[string]interface{})
if !ok {
t.Fatalf("expected map response, got %T", resp)
}
nodes, ok := m["nodes"].([]map[string]interface{})
if !ok {
t.Fatalf("expected nodes list, got %T", m["nodes"])
}
if len(nodes) != 1 {
t.Errorf("expected 1 node, got %d", len(nodes))
}
if nodes[0]["name"] != "10.0.0.1" {
t.Errorf("expected node name 10.0.0.1, got %v", nodes[0]["name"])
}
if nodes[0]["ssh_ready"] != true {
t.Errorf("expected ssh_ready true, got %v, error: %v", nodes[0]["ssh_ready"], nodes[0]["error"])
}
}
func TestHandleConnection_InvalidJSON(t *testing.T) {
// Mock extractPeerCredentials
origExtract := extractPeerCredentials
defer func() { extractPeerCredentials = origExtract }()
extractPeerCredentials = func(conn net.Conn) (*peerCredentials, error) {
return &peerCredentials{uid: 1000, gid: 1000, pid: 123}, nil
}
metrics := NewProxyMetrics("test")
proxy := &Proxy{
metrics: metrics,
config: &Config{
AllowedPeerUIDs: []uint32{1000},
RateLimit: &RateLimitConfig{
PerPeerIntervalMs: 1,
PerPeerBurst: 10,
},
},
allowedPeerUIDs: map[uint32]struct{}{1000: {}},
peerCapabilities: map[uint32]Capability{
1000: 3, // LegacyAll
},
rateLimiter: newRateLimiter(metrics, &RateLimitConfig{
PerPeerIntervalMs: 1,
PerPeerBurst: 10,
}, nil, nil),
}
client, server := net.Pipe()
go func() {
defer client.Close()
client.Write([]byte("invalid json\n"))
}()
// handleConnection handles the connection and returns
proxy.handleConnection(server)
server.Close()
}
func TestHandleEnsureClusterKeysV2(t *testing.T) {
// Mock pvecm and ssh
origLookPath := execLookPath
origExec := execCommandFunc
defer func() {
execLookPath = origLookPath
execCommandFunc = origExec
}()
execLookPath = func(file string) (string, error) {
return "/usr/bin/" + file, nil
}
execCommandFunc = func(name string, arg ...string) *exec.Cmd {
args := strings.Join(arg, " ")
if name == "pvecm" && args == "status" {
output := "\nCluster information\n-------------------\nName: cluster\nConfig Version: 1\nTransport: knet\nSecure auth: on\n\nQuorum information\n------------------\nDate: Sun Jan 1 00:00:00 2023\nQuorum provider: corosync_votequorum\nNodes: 1\nNode ID: 0x00000001\nRing ID: 1.1\nQuorate: Yes\n\nVotequorum information\n----------------------\nExpected votes: 1\nHighest expected: 1\nTotal votes: 1\nQuorum: 1\nFlags: Quorate \n\nMembership information\n----------------------\n Nodeid Votes Name\n0x00000001 1 10.0.0.1 (local)\n"
return mockExecCommand(output)
}
if name == "sh" && strings.Contains(args, "ssh") {
// Check if key exists (grep) - return empty (not found) to trigger adding
if strings.Contains(args, "grep -F") {
return mockExecCommandWithExitCode("", 1) // 1 = not found
}
// ensureTempWrapper or key push
return mockExecCommand("")
}
return mockExecCommand("")
}
tmpDir := t.TempDir()
// Create SSH keys in tmpDir (for push)
os.WriteFile(filepath.Join(tmpDir, "id_ed25519.pub"), []byte("ssh-ed25519 KEY"), 0644)
proxy := &Proxy{
sshKeyPath: tmpDir,
metrics: NewProxyMetrics("test"),
knownHosts: &mockKnownHosts{},
config: &Config{
AllowedSourceSubnets: []string{"10.0.0.0/24"},
},
}
logger := zerolog.New(os.Stderr)
req := &RPCRequest{}
resp, err := proxy.handleEnsureClusterKeysV2(context.Background(), req, logger)
if err != nil {
t.Fatalf("handleEnsureClusterKeysV2 failed: %v", err)
}
m, ok := resp.(map[string]interface{})
if !ok {
t.Fatalf("expected map response, got %T", resp)
}
if m["success_count"] != 1 {
t.Errorf("expected 1 success, got %v", m["success_count"])
}
}
// mockExecCommandWithExitCode helper needed for grep fail
func mockExecCommandWithExitCode(output string, exitCode int) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", output}
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_HELPER_OUTPUT=" + output,
fmt.Sprintf("GO_HELPER_EXIT_CODE=%d", exitCode),
}
return cmd
}

View file

@ -27,7 +27,11 @@ var netInterfaces = net.Interfaces
var execLookPath = exec.LookPath
// Variable for testing to mock os.Hostname
var osHostname = os.Hostname
// Variables for testing
var (
osHostname = os.Hostname
osStat = os.Stat
)
// Variable for testing to mock exec.Command (for simple output)
var execCommandFunc = exec.Command
@ -382,7 +386,7 @@ func (p *Proxy) ensureHostKey(node string) error {
if err := p.ensureHostKeyFromProxmox(ctx, node); err == nil {
return nil
} else {
if p.config.RequireProxmoxHostkeys {
if p.config != nil && p.config.RequireProxmoxHostkeys {
return err
}
log.Warn().
@ -390,7 +394,7 @@ func (p *Proxy) ensureHostKey(node string) error {
Err(err).
Msg("Failed to load host key from Proxmox; falling back to ssh-keyscan")
}
} else if p.config.RequireProxmoxHostkeys {
} else if p.config != nil && p.config.RequireProxmoxHostkeys {
return fmt.Errorf("require_proxmox_hostkeys enabled but not running on Proxmox host")
}
@ -969,7 +973,8 @@ func isProxmoxHost() bool {
return true
}
// Check for /etc/pve directory
if info, err := os.Stat("/etc/pve"); err == nil && info.IsDir() {
// Check for /etc/pve directory
if info, err := osStat("/etc/pve"); err == nil && info.IsDir() {
return true
}
return false

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
@ -11,6 +12,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ssh/knownhosts"
)
func setupTempWrapper(t *testing.T) (scriptPath, thermalFile, binDir, baseDir string) {
@ -707,3 +710,308 @@ func TestDiscoverLocalHostAddresses_InterfaceEdgeCases(t *testing.T) {
// But we can check that it didn't crash.
t.Logf("Got addresses: %v", addrs)
}
func TestLoadProxmoxHostKeys(t *testing.T) {
tmpDir := t.TempDir()
hostsFile := filepath.Join(tmpDir, "known_hosts")
// Override path
origPath := proxmoxClusterKnownHostsPath
defer func() { proxmoxClusterKnownHostsPath = origPath }()
proxmoxClusterKnownHostsPath = hostsFile
// Create dummy file
content := `
# Comment
node1 ssh-ed25519 AAA...
node2 ssh-rsa BBB... comment
invalid line
node1 ssh-rsa CCC...
[node3]:2222 ssh-ed25519 DDD...
`
if err := os.WriteFile(hostsFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
// Test success
keys, err := loadProxmoxHostKeys("node1")
if err != nil {
t.Fatalf("loadProxmoxHostKeys failed: %v", err)
}
if len(keys) != 2 {
t.Errorf("expected 2 keys for node1, got %d", len(keys))
}
if !strings.Contains(string(keys[0]), "node1 ssh-ed25519 AAA...") {
t.Errorf("unexpected key content: %s", keys[0])
}
// Test node2 with comment
keys2, err := loadProxmoxHostKeys("node2")
if err != nil {
t.Fatal(err)
}
if len(keys2) != 1 {
t.Errorf("expected 1 key for node2")
}
// Normalization puts comment at end
if !strings.Contains(string(keys2[0]), "comment") {
t.Errorf("expected comment in key: %s", keys2[0])
}
// Test not found
_, err = loadProxmoxHostKeys("unknown")
if err == nil {
t.Error("expected error for unknown host")
}
// Test file missing
proxmoxClusterKnownHostsPath = filepath.Join(tmpDir, "missing")
_, err = loadProxmoxHostKeys("node1")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestBuildAuthorizedKey(t *testing.T) {
proxy := &Proxy{
config: &Config{
AllowedSourceSubnets: []string{"10.0.0.1/32", "192.168.1.0/24"},
},
}
pubKey := "ssh-ed25519 AAA..."
line, err := proxy.buildAuthorizedKey(pubKey)
if err != nil {
t.Fatalf("buildAuthorizedKey failed: %v", err)
}
expectedStart := `from="10.0.0.1/32,192.168.1.0/24",command="/usr/local/libexec/pulse-sensor-proxy/temp-wrapper.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAA... pulse-sensor-proxy`
if !strings.HasPrefix(line, expectedStart) && line != expectedStart {
t.Errorf("unexpected authorized_key line: %s", line)
}
// Test empty config
proxyEmpty := &Proxy{config: &Config{}}
_, err = proxyEmpty.buildAuthorizedKey(pubKey)
if err == nil {
t.Error("expected error for empty allowed subnets")
}
}
func TestGetPublicKeyFrom(t *testing.T) {
tmpDir := t.TempDir()
pubKeyPath := filepath.Join(tmpDir, "id_ed25519.pub")
content := "ssh-ed25519 AAA... comment"
if err := os.WriteFile(pubKeyPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
// Test getPublicKeyFrom
proxy := &Proxy{}
pub, err := proxy.getPublicKeyFrom(tmpDir)
if err != nil {
t.Fatalf("getPublicKeyFrom failed: %v", err)
}
expected := "ssh-ed25519 AAA... comment"
if pub != expected {
t.Errorf("expected %s, got %s", expected, pub)
}
// Test missing
_, err = proxy.getPublicKeyFrom(filepath.Join(tmpDir, "missing"))
if err == nil {
t.Error("expected error for missing key")
}
}
func TestHandleHostKeyEnsureError(t *testing.T) {
proxy := &Proxy{
metrics: NewProxyMetrics("test"),
}
// Normal error
err := errors.New("normal error")
res := proxy.handleHostKeyEnsureError("node1", err)
if res != err {
t.Errorf("expected original error, got %v", res)
}
// HostKeyChangeError
changeErr := &knownhosts.HostKeyChangeError{
Host: "node1",
Existing: "old",
Provided: "new",
}
res = proxy.handleHostKeyEnsureError("node1", changeErr)
if res != changeErr {
t.Errorf("expected original error, got %v", res)
}
// Verify metrics if possible, or just standard output log
}
func TestEnsureHostKeyFromProxmox(t *testing.T) {
// Mock isProxmoxHost
origLookPath := execLookPath
origStat := osStat
defer func() {
execLookPath = origLookPath
osStat = origStat
}()
// Case 1: Not Proxmox host
execLookPath = func(file string) (string, error) {
return "", fmt.Errorf("not found")
}
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
proxy := &Proxy{}
err := proxy.ensureHostKeyFromProxmox(context.Background(), "node1")
if err == nil || err.Error() != "not running on Proxmox host" {
t.Errorf("expected 'not running on Proxmox host' error, got %v", err)
}
// Case 2: Proxmox host, key found
execLookPath = func(file string) (string, error) {
if file == "pvecm" {
return "/bin/pvecm", nil
}
return "", fmt.Errorf("not found")
}
tmpDir := t.TempDir()
hostsFile := filepath.Join(tmpDir, "known_hosts")
origPath := proxmoxClusterKnownHostsPath
defer func() { proxmoxClusterKnownHostsPath = origPath }()
proxmoxClusterKnownHostsPath = hostsFile
os.WriteFile(hostsFile, []byte("node1 ssh-ed25519 AAA..."), 0644)
proxy = &Proxy{
knownHosts: &mockKnownHosts{},
config: &Config{RequireProxmoxHostkeys: true},
}
err = proxy.ensureHostKeyFromProxmox(context.Background(), "node1")
if err != nil {
t.Errorf("ensureHostKeyFromProxmox failed: %v", err)
}
// Case 3: Proxmox host, key NOT found
// knownHosts EnsureWithEntries (mock) returns nil, but loadProxmoxHostKeys will verify if key exists in file
// loadProxmoxHostKeys returns error if key not found
err = proxy.ensureHostKeyFromProxmox(context.Background(), "unknown")
if err == nil {
t.Error("expected error for unknown host")
}
}
func TestPushSSHKeyFrom(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "id_ed25519.pub"), []byte("ssh-ed25519 AAA..."), 0644)
proxy := &Proxy{
sshKeyPath: tmpDir,
metrics: NewProxyMetrics("test"),
knownHosts: &mockKnownHosts{},
config: &Config{AllowedSourceSubnets: []string{"10.0.0.0/24"}},
}
origExec := execCommandFunc
defer func() { execCommandFunc = origExec }()
// Case 1: Success (key already present)
execCommandFunc = func(name string, arg ...string) *exec.Cmd {
args := strings.Join(arg, " ")
if strings.Contains(args, "grep -F") {
return mockExecCommand("from=...,ssh-ed25519 AAA...") // Found
}
if strings.Contains(args, "stage temperature wrapper") {
return mockExecCommand("") // Wrapper install success
}
return mockExecCommand("")
}
if err := proxy.pushSSHKeyFrom("node1", tmpDir); err != nil {
t.Errorf("pushSSHKeyFrom failed (present): %v", err)
}
// Case 2: Success (key added)
execCommandFunc = func(name string, arg ...string) *exec.Cmd {
args := strings.Join(arg, " ")
if strings.Contains(args, "grep -F") {
return mockExecCommandWithExitCode("", 1) // Not found
}
return mockExecCommand("")
}
if err := proxy.pushSSHKeyFrom("node1", tmpDir); err != nil {
t.Errorf("pushSSHKeyFrom failed (added): %v", err)
}
// Case 3: Error key not found
if err := proxy.pushSSHKeyFrom("node1", filepath.Join(tmpDir, "missing")); err == nil {
t.Error("expected error for missing key dir")
}
// Case 4: ensureHostKey error (mock knownHosts.Ensure failure?)
// mockKnownHosts returns nil.
}
func TestSSHConnection(t *testing.T) {
tmpDir := t.TempDir()
proxy := &Proxy{
sshKeyPath: tmpDir,
metrics: NewProxyMetrics("test"),
knownHosts: &mockKnownHosts{},
maxSSHOutputBytes: 1024,
config: &Config{},
}
origExec := execCommandFunc
origExecCtx := execCommandContextFunc
defer func() {
execCommandFunc = origExec
execCommandContextFunc = origExecCtx
}()
mockCmd := func(name string, arg ...string) *exec.Cmd {
if name == "sh" { // execCommandWithLimits uses "sh -c"
return mockExecCommand("{}")
}
return mockExecCommand("")
}
execCommandFunc = mockCmd
execCommandContextFunc = func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return mockCmd(name, arg...)
}
if err := proxy.testSSHConnection("node1"); err != nil {
t.Errorf("testSSHConnection failed: %v", err)
}
// Failure
mockCmdFail := func(name string, arg ...string) *exec.Cmd {
if name == "sh" {
return mockExecCommandWithExitCode("failed", 1)
}
return mockExecCommand("")
}
execCommandContextFunc = func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return mockCmdFail(name, arg...)
}
execCommandFunc = mockCmdFail
if err := proxy.testSSHConnection("node1"); err == nil {
t.Error("expected error for connection failure")
}
}