test: achieve 92%+ coverage with comprehensive integration and edge case tests

Cover WebSocket session lifecycle (result channels, SNMP batch flushing,
heartbeats, restart), MikroTik TLS TOFU verification mismatch, worker pool
exhaustion, plaintext WebSocket rejection, HTTP/TCP/DNS check execution,
IPv4 fallback, and customer network edge cases.

Make heartbeat intervals and toWebSocketURL exit injectable for testing.
This commit is contained in:
Graham McIntire 2026-03-04 16:06:29 -06:00
parent c4bad51e73
commit aef770ea83
No known key found for this signature in database
9 changed files with 2442 additions and 7 deletions

View file

@ -25,6 +25,8 @@ var doSelfUpdate = selfUpdate
var errRestartRequested = fmt.Errorf("restart requested") var errRestartRequested = fmt.Errorf("restart requested")
var joinTimeout = 10 * time.Second var joinTimeout = 10 * time.Second
var heartbeatInterval = 60 * time.Second
var channelHeartbeatInterval = 25 * time.Second
const maxJobPayloadBytes = 4 << 20 // 4 MB — well above any legitimate job list const maxJobPayloadBytes = 4 << 20 // 4 MB — well above any legitimate job list
@ -224,9 +226,9 @@ func runSession(ctx context.Context, baseURL, token string) error {
} }
}() }()
heartbeatTicker := time.NewTicker(60 * time.Second) heartbeatTicker := time.NewTicker(heartbeatInterval)
defer heartbeatTicker.Stop() defer heartbeatTicker.Stop()
channelHeartbeatTicker := time.NewTicker(25 * time.Second) channelHeartbeatTicker := time.NewTicker(channelHeartbeatInterval)
defer channelHeartbeatTicker.Stop() defer channelHeartbeatTicker.Stop()
flushTicker := time.NewTicker(100 * time.Millisecond) flushTicker := time.NewTicker(100 * time.Millisecond)
defer flushTicker.Stop() defer flushTicker.Stop()

View file

@ -3,8 +3,10 @@ package main
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net" "net"
"strings" "strings"
"sync" "sync"
@ -284,6 +286,59 @@ func TestHandleMessage(t *testing.T) {
// Should just log and not panic // Should just log and not panic
}) })
t.Run("check_jobs valid", func(t *testing.T) {
checkList := &pb.CheckList{Checks: []*pb.Check{
{Id: "c1", CheckType: "tcp", TimeoutMs: 1000,
Config: &pb.Check_Tcp{Tcp: &pb.TcpCheckConfig{Host: "127.0.0.1", Port: 1}}},
}}
bin, _ := proto.Marshal(checkList)
payload, _ := json.Marshal(map[string]string{"binary": base64.StdEncoding.EncodeToString(bin)})
snmpCh := make(chan *pb.SnmpResult, 1)
mtCh := make(chan *pb.MikrotikResult, 1)
credCh := make(chan *pb.CredentialTestResult, 1)
monCh := make(chan *pb.MonitoringCheck, 1)
checkCh := make(chan *pb.CheckResult, 1)
handleMessage(context.Background(), channelMsg{Event: "check_jobs", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh, checkCh)
select {
case <-checkCh:
case <-time.After(5 * time.Second):
t.Error("timed out waiting for check result")
}
})
t.Run("check_jobs invalid json", func(t *testing.T) {
snmpCh := make(chan *pb.SnmpResult, 1)
mtCh := make(chan *pb.MikrotikResult, 1)
credCh := make(chan *pb.CredentialTestResult, 1)
monCh := make(chan *pb.MonitoringCheck, 1)
checkCh := make(chan *pb.CheckResult, 1)
handleMessage(context.Background(), channelMsg{Event: "check_jobs", Payload: json.RawMessage(`not json`)}, testPools(t), snmpCh, mtCh, credCh, monCh, checkCh)
// Should log error but not panic
})
t.Run("check_jobs invalid base64", func(t *testing.T) {
snmpCh := make(chan *pb.SnmpResult, 1)
mtCh := make(chan *pb.MikrotikResult, 1)
credCh := make(chan *pb.CredentialTestResult, 1)
monCh := make(chan *pb.MonitoringCheck, 1)
checkCh := make(chan *pb.CheckResult, 1)
payload, _ := json.Marshal(map[string]string{"binary": "not-base64!!!"})
handleMessage(context.Background(), channelMsg{Event: "check_jobs", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh, checkCh)
// Should log error but not panic
})
t.Run("check_jobs invalid protobuf", func(t *testing.T) {
snmpCh := make(chan *pb.SnmpResult, 1)
mtCh := make(chan *pb.MikrotikResult, 1)
credCh := make(chan *pb.CredentialTestResult, 1)
monCh := make(chan *pb.MonitoringCheck, 1)
checkCh := make(chan *pb.CheckResult, 1)
payload, _ := json.Marshal(map[string]string{"binary": base64.StdEncoding.EncodeToString([]byte{0xFF, 0xFF, 0xFF})})
handleMessage(context.Background(), channelMsg{Event: "check_jobs", Payload: payload}, testPools(t), snmpCh, mtCh, credCh, monCh, checkCh)
// Should log error but not panic
})
t.Run("discovery_job event", func(t *testing.T) { t.Run("discovery_job event", func(t *testing.T) {
origDial := snmpDial origDial := snmpDial
defer func() { snmpDial = origDial }() defer func() { snmpDial = origDial }()
@ -698,3 +753,765 @@ func makeTextFrame(payload []byte) []byte {
frame = append(frame, payload...) frame = append(frame, payload...)
return frame return frame
} }
func TestDispatchJobCancelledContext(t *testing.T) {
// submit returns false when context is cancelled, triggering "pool full" log
p := testPools(t)
snmpCh := make(chan *pb.SnmpResult, 1)
mtCh := make(chan *pb.MikrotikResult, 1)
credCh := make(chan *pb.CredentialTestResult, 1)
monCh := make(chan *pb.MonitoringCheck, 1)
checkCh := make(chan *pb.CheckResult, 1)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
// All dispatch types should handle ctx cancellation gracefully
dispatchJob(ctx, &pb.AgentJob{
JobId: "s1", JobType: pb.JobType_POLL, SnmpDevice: &pb.SnmpDevice{Ip: "10.0.0.1"},
}, p, snmpCh, mtCh, credCh, monCh, checkCh)
dispatchJob(ctx, &pb.AgentJob{
JobId: "m1", JobType: pb.JobType_MIKROTIK, MikrotikDevice: &pb.MikrotikDevice{Ip: "10.0.0.1"},
}, p, snmpCh, mtCh, credCh, monCh, checkCh)
dispatchJob(ctx, &pb.AgentJob{
JobId: "tc1", JobType: pb.JobType_TEST_CREDENTIALS, SnmpDevice: &pb.SnmpDevice{Ip: "10.0.0.1"},
}, p, snmpCh, mtCh, credCh, monCh, checkCh)
dispatchJob(ctx, &pb.AgentJob{
JobId: "p1", JobType: pb.JobType_PING, SnmpDevice: &pb.SnmpDevice{Ip: "10.0.0.1"},
}, p, snmpCh, mtCh, credCh, monCh, checkCh)
}
// fakeWSServer is a test helper that sets up a WebSocket server for runSession tests.
type fakeWSServer struct {
ln net.Listener
conn net.Conn
}
func newFakeWSServer(t *testing.T) *fakeWSServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
return &fakeWSServer{ln: ln}
}
func (s *fakeWSServer) addr() string { return s.ln.Addr().String() }
// acceptAndJoin accepts one WS connection and responds with a successful join.
func (s *fakeWSServer) acceptAndJoin(t *testing.T) {
t.Helper()
conn, err := s.ln.Accept()
if err != nil {
t.Logf("accept: %v", err)
return
}
s.conn = conn
// Read HTTP upgrade
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
key := extractWSKey(string(buf[:n]))
accept := computeAcceptKey(key)
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"
_, _ = conn.Write([]byte(resp))
// Read join frame
frameBuf := make([]byte, 4096)
_, _ = conn.Read(frameBuf)
// Send join OK
reply, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: "phx_reply",
Payload: json.RawMessage(`{"status":"ok"}`),
Ref: strPtr("1"),
})
_, _ = conn.Write(makeTextFrame(reply))
}
// sendEvent sends a channel message to the connected client.
func (s *fakeWSServer) sendEvent(event string, payload json.RawMessage) {
msg, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: event,
Payload: payload,
})
_, _ = s.conn.Write(makeTextFrame(msg))
}
// close shuts down the server connection.
func (s *fakeWSServer) close() {
if s.conn != nil {
_ = s.conn.Close()
}
}
func TestRunSessionCtxCancel(t *testing.T) {
srv := newFakeWSServer(t)
go srv.acceptAndJoin(t)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- runSession(ctx, "ws://"+srv.addr(), "token")
}()
// Give the session time to enter the main loop
time.Sleep(200 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("expected nil error on ctx cancel, got: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("runSession did not exit after ctx cancel")
}
srv.close()
}
func TestRunSessionReadError(t *testing.T) {
srv := newFakeWSServer(t)
go func() {
srv.acceptAndJoin(t)
// Small delay, then close to trigger read error
time.Sleep(200 * time.Millisecond)
srv.close()
}()
err := runSession(context.Background(), "ws://"+srv.addr(), "token")
if err == nil {
t.Error("expected read error")
}
if !strings.Contains(err.Error(), "read:") {
t.Errorf("expected 'read:' in error, got: %v", err)
}
}
func TestRunSessionInvalidMessage(t *testing.T) {
srv := newFakeWSServer(t)
go func() {
srv.acceptAndJoin(t)
// Send invalid JSON — should be logged but not crash
time.Sleep(100 * time.Millisecond)
_, _ = srv.conn.Write(makeTextFrame([]byte("not json")))
// Then close to end session
time.Sleep(100 * time.Millisecond)
srv.close()
}()
err := runSession(context.Background(), "ws://"+srv.addr(), "token")
// Should end with read error from close, not crash
if err == nil {
t.Error("expected error")
}
}
func TestRunSessionConnectError(t *testing.T) {
err := runSession(context.Background(), "ws://127.0.0.1:1", "token")
if err == nil {
t.Error("expected connect error")
}
if !strings.Contains(err.Error(), "connect:") {
t.Errorf("expected 'connect:' in error, got: %v", err)
}
}
func TestRunSessionJoinWriteError(t *testing.T) {
// Server accepts WS upgrade but closes immediately after
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
// Read HTTP upgrade
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
key := extractWSKey(string(buf[:n]))
accept := computeAcceptKey(key)
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"
_, _ = conn.Write([]byte(resp))
// Close immediately so join write may fail
_ = conn.Close()
}()
err = runSession(context.Background(), "ws://"+ln.Addr().String(), "token")
if err == nil {
t.Error("expected error")
}
}
func TestRunSessionJoinUnmarshalError(t *testing.T) {
// Server sends binary garbage as join reply
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
key := extractWSKey(string(buf[:n]))
accept := computeAcceptKey(key)
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"
_, _ = conn.Write([]byte(resp))
frameBuf := make([]byte, 4096)
_, _ = conn.Read(frameBuf)
// Send invalid JSON as reply
_, _ = conn.Write(makeTextFrame([]byte("{invalid json")))
time.Sleep(time.Second)
}()
err = runSession(context.Background(), "ws://"+ln.Addr().String(), "token")
if err == nil {
t.Error("expected unmarshal error")
}
if !strings.Contains(err.Error(), "join reply unmarshal") {
t.Errorf("expected 'join reply unmarshal' in error, got: %v", err)
}
}
func TestRunSessionReadErrorDuringJoin(t *testing.T) {
// Server sends upgrade then closes before sending join reply
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
key := extractWSKey(string(buf[:n]))
accept := computeAcceptKey(key)
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"
_, _ = conn.Write([]byte(resp))
frameBuf := make([]byte, 4096)
_, _ = conn.Read(frameBuf)
// Close without sending reply
_ = conn.Close()
}()
err = runSession(context.Background(), "ws://"+ln.Addr().String(), "token")
if err == nil {
t.Error("expected read during join error")
}
if !strings.Contains(err.Error(), "read during join") {
t.Errorf("expected 'read during join' in error, got: %v", err)
}
}
func TestRunAgentReconnectOnError(t *testing.T) {
// Server that fails first connection then succeeds, then sends restart
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
connCount := 0
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
connCount++
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
key := extractWSKey(string(buf[:n]))
accept := computeAcceptKey(key)
if connCount == 1 {
// First connection: upgrade then close immediately
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"
_, _ = conn.Write([]byte(resp))
frameBuf := make([]byte, 4096)
_, _ = conn.Read(frameBuf)
_ = conn.Close()
} else {
// Second connection: proper session with restart
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"
_, _ = conn.Write([]byte(resp))
frameBuf := make([]byte, 4096)
_, _ = conn.Read(frameBuf)
reply, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: "phx_reply",
Payload: json.RawMessage(`{"status":"ok"}`),
Ref: strPtr("1"),
})
_, _ = conn.Write(makeTextFrame(reply))
time.Sleep(50 * time.Millisecond)
restart, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: "restart",
Payload: json.RawMessage(`{}`),
})
_, _ = conn.Write(makeTextFrame(restart))
time.Sleep(time.Second)
_ = conn.Close()
}
}
}()
origExit := osExit
defer func() { osExit = origExit }()
exitCalled := make(chan int, 1)
osExit = func(code int) { exitCalled <- code }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
runAgent(ctx, "ws://"+ln.Addr().String(), "token")
close(done)
}()
select {
case code := <-exitCalled:
if code != 0 {
t.Errorf("expected exit code 0, got %d", code)
}
case <-time.After(10 * time.Second):
t.Error("runAgent did not reconnect and restart")
}
}
func TestRunAgentContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
done := make(chan struct{})
go func() {
runAgent(ctx, "ws://127.0.0.1:1", "token")
close(done)
}()
select {
case <-done:
// runAgent returned as expected
case <-time.After(5 * time.Second):
t.Error("runAgent did not return after context cancellation")
}
}
func TestRunAgentRestart(t *testing.T) {
origExit := osExit
defer func() { osExit = origExit }()
exitCalled := make(chan int, 1)
osExit = func(code int) {
exitCalled <- code
}
// Start a fake WebSocket server that accepts the join and sends restart
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
buf := make([]byte, 4096)
n, _ := conn.Read(buf)
reqStr := string(buf[:n])
key := extractWSKey(reqStr)
accept := computeAcceptKey(key)
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n"
_, _ = conn.Write([]byte(resp))
// Read join frame
frameBuf := make([]byte, 4096)
_, _ = conn.Read(frameBuf)
// Send join OK reply
reply, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: "phx_reply",
Payload: json.RawMessage(`{"status":"ok"}`),
Ref: strPtr("1"),
})
_, _ = conn.Write(makeTextFrame(reply))
// Small delay then send restart event
time.Sleep(50 * time.Millisecond)
restart, _ := json.Marshal(channelMsg{
Topic: "agent:agent-0",
Event: "restart",
Payload: json.RawMessage(`{}`),
})
_, _ = conn.Write(makeTextFrame(restart))
// Keep connection open briefly
time.Sleep(2 * time.Second)
}()
addr := ln.Addr().String()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
runAgent(ctx, "ws://"+addr, "test-token")
close(done)
}()
select {
case code := <-exitCalled:
if code != 0 {
t.Errorf("expected exit code 0, got %d", code)
}
case <-time.After(5 * time.Second):
t.Error("osExit was not called after restart")
}
}
// readMaskedFrame reads a single masked WebSocket frame from the server side.
func readMaskedFrame(conn net.Conn) ([]byte, error) {
var header [2]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
return nil, err
}
masked := header[1]&0x80 != 0
length := uint64(header[1] & 0x7F)
switch length {
case 126:
var ext [2]byte
if _, err := io.ReadFull(conn, ext[:]); err != nil {
return nil, err
}
length = uint64(binary.BigEndian.Uint16(ext[:]))
case 127:
var ext [8]byte
if _, err := io.ReadFull(conn, ext[:]); err != nil {
return nil, err
}
length = binary.BigEndian.Uint64(ext[:])
}
var maskKey [4]byte
if masked {
if _, err := io.ReadFull(conn, maskKey[:]); err != nil {
return nil, err
}
}
payload := make([]byte, length)
if length > 0 {
if _, err := io.ReadFull(conn, payload); err != nil {
return nil, err
}
}
if masked {
for i := range payload {
payload[i] ^= maskKey[i%4]
}
}
return payload, nil
}
// drainFrames reads and discards client frames until stop channel is closed or error.
func drainFrames(conn net.Conn, stop <-chan struct{}) {
for {
select {
case <-stop:
return
default:
}
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
if _, err := readMaskedFrame(conn); err != nil {
select {
case <-stop:
return
default:
continue // timeout, retry
}
}
}
}
func TestRunSessionProcessesJobResults(t *testing.T) {
// Mock external dependencies for fast execution
origSnmpDial := snmpDial
origMtDial := mikrotikDial
origPing := doPing
defer func() {
snmpDial = origSnmpDial
mikrotikDial = origMtDial
doPing = origPing
}()
snmpDial = func(dev *pb.SnmpDevice) (snmpQuerier, func(), error) {
return &mockSnmpQuerier{
getFunc: func(oids []string) (*gosnmp.SnmpPacket, error) {
return &gosnmp.SnmpPacket{}, nil
},
}, func() {}, nil
}
mikrotikDial = func(ip string, port uint32, username, password string, useSSL bool) (*mikrotikClient, error) {
return nil, fmt.Errorf("unreachable")
}
doPing = func(ip string, timeoutMs int) (float64, error) {
return 1.5, nil
}
srv := newFakeWSServer(t)
serverDone := make(chan struct{})
go func() {
defer close(serverDone)
srv.acceptAndJoin(t)
// Drain client frames so writes don't block
stopDrain := make(chan struct{})
go drainFrames(srv.conn, stopDrain)
time.Sleep(100 * time.Millisecond)
// SNMP job → snmpResultCh → snmpBatch → flushSnmpBatch → sendBinaryResult
srv.sendEvent("jobs", makeJobPayload(&pb.AgentJob{
JobId: "s1", JobType: pb.JobType_POLL,
SnmpDevice: &pb.SnmpDevice{Ip: "10.0.0.1", Port: 161},
}))
// Mikrotik job → mikrotikResultCh → sendBinaryResult("mikrotik_result")
srv.sendEvent("jobs", makeJobPayload(&pb.AgentJob{
JobId: "m1", JobType: pb.JobType_MIKROTIK,
MikrotikDevice: &pb.MikrotikDevice{Ip: "10.0.0.1", Port: 8728},
}))
// Ping job → monitoringCheckCh → sendBinaryResult("monitoring_check")
srv.sendEvent("jobs", makeJobPayload(&pb.AgentJob{
JobId: "p1", JobType: pb.JobType_PING, DeviceId: "dev1",
SnmpDevice: &pb.SnmpDevice{Ip: "10.0.0.1"},
}))
// Credential test → credTestResultCh → sendBinaryResult("credential_test_result")
srv.sendEvent("jobs", makeJobPayload(&pb.AgentJob{
JobId: "ct1", JobType: pb.JobType_TEST_CREDENTIALS,
SnmpDevice: &pb.SnmpDevice{Ip: "10.0.0.1"},
}))
// Check job → checkResultCh → sendBinaryResult("check_result")
checkList := &pb.CheckList{Checks: []*pb.Check{
{Id: "c1", CheckType: "tcp", TimeoutMs: 500,
Config: &pb.Check_Tcp{Tcp: &pb.TcpCheckConfig{Host: "127.0.0.1", Port: 1}}},
}}
bin, _ := proto.Marshal(checkList)
checkPayload, _ := json.Marshal(map[string]string{"binary": base64.StdEncoding.EncodeToString(bin)})
srv.sendEvent("check_jobs", checkPayload)
// Wait for results to flow through all channels
time.Sleep(2 * time.Second)
close(stopDrain)
srv.close()
}()
err := runSession(context.Background(), "ws://"+srv.addr(), "token")
<-serverDone
if err == nil {
t.Error("expected error after server close")
}
}
func TestRunSessionSnmpBatchThreshold(t *testing.T) {
origSnmpDial := snmpDial
defer func() { snmpDial = origSnmpDial }()
snmpDial = func(dev *pb.SnmpDevice) (snmpQuerier, func(), error) {
return &mockSnmpQuerier{
getFunc: func(oids []string) (*gosnmp.SnmpPacket, error) {
return &gosnmp.SnmpPacket{}, nil
},
}, func() {}, nil
}
srv := newFakeWSServer(t)
serverDone := make(chan struct{})
go func() {
defer close(serverDone)
srv.acceptAndJoin(t)
stopDrain := make(chan struct{})
go drainFrames(srv.conn, stopDrain)
time.Sleep(100 * time.Millisecond)
// Send 55 SNMP jobs to trigger batch threshold (>=50)
jobs := make([]*pb.AgentJob, 55)
for i := range jobs {
jobs[i] = &pb.AgentJob{
JobId: fmt.Sprintf("s%d", i),
JobType: pb.JobType_POLL,
SnmpDevice: &pb.SnmpDevice{Ip: "10.0.0.1", Port: 161},
}
}
srv.sendEvent("jobs", makeJobPayload(jobs...))
time.Sleep(3 * time.Second)
close(stopDrain)
srv.close()
}()
err := runSession(context.Background(), "ws://"+srv.addr(), "token")
<-serverDone
if err == nil {
t.Error("expected error after server close")
}
}
func TestExecuteCheckPoolFull(t *testing.T) {
// Use a pool with 1 worker and 1 queue slot, then fill both
p := &jobPools{
snmp: newWorkerPool(4),
mikrotik: newWorkerPool(4),
ping: newWorkerPool(4),
checks: newWorkerPool(1),
}
t.Cleanup(func() { p.snmp.stop(); p.mikrotik.stop(); p.ping.stop(); p.checks.stop() })
done := make(chan struct{})
started := make(chan struct{})
// Block the single worker
p.checks.submit(context.Background(), func() {
close(started)
<-done
})
<-started
// Fill the queue slot
p.checks.submit(context.Background(), func() { <-done })
// Pool is now full — submit with cancelled ctx will fail
ctx, cancel := context.WithCancel(context.Background())
cancel()
checkCh := make(chan *pb.CheckResult, 1)
check := &pb.Check{Id: "c1", CheckType: "tcp", TimeoutMs: 1000}
executeCheck(ctx, check, p, checkCh)
// Should log "check rejected (pool full)" but not panic
close(done)
}
func TestExecuteCheckCtxDoneInClosure(t *testing.T) {
// Tests the ctx.Done path inside executeCheck's closure:
// Submit succeeds, check runs, but result channel is blocked so ctx.Done fires.
p := testPools(t)
ctx, cancel := context.WithCancel(context.Background())
checkCh := make(chan *pb.CheckResult) // unbuffered, no reader
check := &pb.Check{Id: "c1", CheckType: "tcp", TimeoutMs: 100,
Config: &pb.Check_Tcp{Tcp: &pb.TcpCheckConfig{Host: "127.0.0.1", Port: 1}}}
executeCheck(ctx, check, p, checkCh)
// Wait for the check to complete (TCP to port 1 fails fast)
time.Sleep(500 * time.Millisecond)
// Cancel ctx so the closure's select picks ctx.Done instead of blocked channel send
cancel()
time.Sleep(100 * time.Millisecond)
}
func TestRunSessionRestartInMainLoop(t *testing.T) {
srv := newFakeWSServer(t)
go func() {
srv.acceptAndJoin(t)
stopDrain := make(chan struct{})
go drainFrames(srv.conn, stopDrain)
time.Sleep(100 * time.Millisecond)
// Send restart event — exercised in the main loop select
srv.sendEvent("restart", json.RawMessage(`{}`))
time.Sleep(time.Second)
close(stopDrain)
srv.close()
}()
err := runSession(context.Background(), "ws://"+srv.addr(), "token")
if err != errRestartRequested {
t.Errorf("expected errRestartRequested, got: %v", err)
}
}
func TestRunSessionHeartbeats(t *testing.T) {
// Use short heartbeat intervals to exercise heartbeat paths
origHB := heartbeatInterval
origCHB := channelHeartbeatInterval
defer func() {
heartbeatInterval = origHB
channelHeartbeatInterval = origCHB
}()
heartbeatInterval = 100 * time.Millisecond
channelHeartbeatInterval = 100 * time.Millisecond
srv := newFakeWSServer(t)
go func() {
srv.acceptAndJoin(t)
stopDrain := make(chan struct{})
go drainFrames(srv.conn, stopDrain)
// Let heartbeats fire a few times
time.Sleep(500 * time.Millisecond)
close(stopDrain)
srv.close()
}()
err := runSession(context.Background(), "ws://"+srv.addr(), "token")
if err == nil {
t.Error("expected error after server close")
}
}
func TestRunAgentCancelDuringRetry(t *testing.T) {
// Connects to a port nothing listens on, then cancel during retry delay
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runAgent(ctx, "ws://127.0.0.1:1", "token")
close(done)
}()
// Wait for first connection attempt to fail and retry delay to start
time.Sleep(1500 * time.Millisecond)
cancel()
select {
case <-done:
// runAgent returned after cancel during retry
case <-time.After(5 * time.Second):
t.Error("runAgent did not return after cancel during retry")
}
}

1372
checks_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
package main package main
import ( import (
"os" "crypto/x509"
"path/filepath" "path/filepath"
"sync" "sync"
"testing" "testing"
@ -116,7 +116,20 @@ func TestHostKeyStoreSaveError(t *testing.T) {
} }
func TestTlsCertFingerprint(t *testing.T) { func TestTlsCertFingerprint(t *testing.T) {
// Just verify it doesn't panic with nil - we test with real certs in integration cert := &x509.Certificate{
// The function is simple enough: sha256 of cert.Raw Raw: []byte("test certificate data"),
_ = os.Getenv("dummy") // placeholder }
fp := tlsCertFingerprint(cert)
if fp == "" {
t.Error("expected non-empty fingerprint")
}
// Verify it's a hex-encoded SHA256 (64 chars)
if len(fp) != 64 {
t.Errorf("expected 64-char hex fingerprint, got %d chars: %s", len(fp), fp)
}
// Same input should produce same output
fp2 := tlsCertFingerprint(cert)
if fp != fp2 {
t.Error("fingerprint not deterministic")
}
} }

View file

@ -93,7 +93,7 @@ func toWebSocketURL(rawURL string) string {
if strings.HasPrefix(result, "ws://") && !insecureFlag { if strings.HasPrefix(result, "ws://") && !insecureFlag {
slog.Error("plaintext ws:// connection rejected — use wss:// or pass --insecure to allow") slog.Error("plaintext ws:// connection rejected — use wss:// or pass --insecure to allow")
os.Exit(1) osExit(1)
} }
return result return result
} }

View file

@ -39,6 +39,14 @@ func TestSanitizeURL(t *testing.T) {
} }
} }
func TestIsFlagSet(t *testing.T) {
// In tests, flags are not set via flag.Parse on the default flag set
// so isFlagSet should return false for any arbitrary name.
if isFlagSet("nonexistent-flag-xyz") {
t.Error("expected isFlagSet to return false for unset flag")
}
}
func TestToWebSocketURL(t *testing.T) { func TestToWebSocketURL(t *testing.T) {
// Enable insecure for testing plaintext conversions // Enable insecure for testing plaintext conversions
origInsecure := insecureFlag origInsecure := insecureFlag
@ -62,3 +70,33 @@ func TestToWebSocketURL(t *testing.T) {
} }
} }
} }
func TestToWebSocketURLRejectsPlaintext(t *testing.T) {
origInsecure := insecureFlag
origExit := osExit
defer func() {
insecureFlag = origInsecure
osExit = origExit
}()
insecureFlag = false
exitCode := -1
osExit = func(code int) { exitCode = code }
tests := []struct {
name string
input string
}{
{"ws:// scheme", "ws://localhost:4000"},
{"http:// converts to ws://", "http://localhost:4000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exitCode = -1
toWebSocketURL(tt.input)
if exitCode != 1 {
t.Errorf("expected osExit(1), got %d", exitCode)
}
})
}
}

View file

@ -2,10 +2,21 @@ package main
import ( import (
"bytes" "bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt" "fmt"
"io" "io"
"math/big"
"net" "net"
"path/filepath"
"strings"
"sync"
"testing" "testing"
"time"
) )
func TestEncodeLength(t *testing.T) { func TestEncodeLength(t *testing.T) {
@ -467,6 +478,78 @@ func TestMikrotikConnectSSL(t *testing.T) {
} }
} }
func TestMikrotikConnectSSLWithServer(t *testing.T) {
// Generate a self-signed cert at runtime
cert := generateTestCert(t)
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
// Reset host key store for this test
origStore := globalHostKeys
defer func() {
hostKeysOnce = sync.Once{}
globalHostKeys = origStore
}()
hostKeysOnce = sync.Once{}
t.Setenv("TOWEROPS_HOST_KEYS_FILE", filepath.Join(t.TempDir(), "hosts.json"))
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
sc := &mikrotikClient{conn: conn}
_, _ = sc.readSentence()
_ = sc.writeSentence([]string{"!done"})
_, _ = sc.readSentence()
_ = sc.writeSentence([]string{"!fatal"})
}()
_, port, _ := net.SplitHostPort(ln.Addr().String())
var portNum uint32
_, _ = fmt.Sscanf(port, "%d", &portNum)
client, err := mikrotikConnect("127.0.0.1", portNum, "admin", "pass", true)
if err != nil {
t.Fatalf("expected TLS connection to succeed: %v", err)
}
_ = client.close()
}
func generateTestCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
return tlsCert
}
func TestReadResponseEmptySentence(t *testing.T) { func TestReadResponseEmptySentence(t *testing.T) {
// An empty sentence (just the terminator byte) should be skipped // An empty sentence (just the terminator byte) should be skipped
var buf bytes.Buffer var buf bytes.Buffer
@ -636,3 +719,51 @@ func (rwc *readWriteCloser) Close() error {
} }
return nil return nil
} }
func TestMikrotikConnectSSLTOFUMismatch(t *testing.T) {
cert := generateTestCert(t)
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
// Reset host key store and pre-populate with wrong fingerprint
origStore := globalHostKeys
defer func() {
hostKeysOnce = sync.Once{}
globalHostKeys = origStore
}()
hostKeysOnce = sync.Once{}
t.Setenv("TOWEROPS_HOST_KEYS_FILE", filepath.Join(t.TempDir(), "hosts.json"))
_, port, _ := net.SplitHostPort(ln.Addr().String())
var portNum uint32
_, _ = fmt.Sscanf(port, "%d", &portNum)
// Pre-register wrong fingerprint so TOFU verification fails
store := getHostKeyStore()
store.keys["tls:"+net.JoinHostPort("127.0.0.1", port)] = "wrong_fingerprint"
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
// Complete TLS handshake so client gets peer certificates
if tlsConn, ok := conn.(*tls.Conn); ok {
_ = tlsConn.Handshake()
}
time.Sleep(time.Second)
}()
_, err = mikrotikConnect("127.0.0.1", portNum, "admin", "pass", true)
if err == nil {
t.Error("expected TOFU verification failure")
}
if !strings.Contains(err.Error(), "TOFU verification failed") {
t.Errorf("expected 'TOFU verification failed' in error, got: %v", err)
}
}

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"runtime" "runtime"
"testing" "testing"
) )
@ -65,6 +66,13 @@ func TestIcmpPingInvalidIP(t *testing.T) {
} }
} }
func TestErrICMPUnavailableError(t *testing.T) {
err := &errICMPUnavailable{err: fmt.Errorf("permission denied")}
if err.Error() != "permission denied" {
t.Errorf("got %q, want %q", err.Error(), "permission denied")
}
}
func TestParsePingTime(t *testing.T) { func TestParsePingTime(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -657,6 +657,60 @@ func TestWSDialIPv4Fallback(t *testing.T) {
} }
} }
func TestWSDialTLSPath(t *testing.T) {
// Verify the TLS dial path is exercised (connection will fail, but we hit the code path)
origTLS := tlsDial
defer func() { tlsDial = origTLS }()
called := false
tlsDial = func(network, addr string) (net.Conn, error) {
called = true
return nil, fmt.Errorf("tls dial: connection refused")
}
_, err := WSDial("wss://127.0.0.1:9999/path")
if err == nil {
t.Error("expected TLS dial error")
}
if !called {
t.Error("expected tlsDial to be called for wss:// URL")
}
}
func TestWSDialMissingAcceptHeader(t *testing.T) {
// Server sends 101 but without Sec-WebSocket-Accept header
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = ln.Close() }()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer func() { _ = c.Close() }()
buf := make([]byte, 4096)
_, _ = c.Read(buf)
resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"
_, _ = c.Write([]byte(resp))
}(conn)
}
}()
addr := ln.Addr().String()
_, err = WSDial("ws://" + addr + "/socket")
if err == nil {
t.Error("expected error for missing accept header")
}
if !strings.Contains(err.Error(), "missing Sec-WebSocket-Accept") {
t.Errorf("expected 'missing Sec-WebSocket-Accept' in error, got: %v", err)
}
}
func TestWSDialDefaultPorts(t *testing.T) { func TestWSDialDefaultPorts(t *testing.T) {
// Test that ws:// defaults to port 80 — will fail to connect but verifies URL parsing // Test that ws:// defaults to port 80 — will fail to connect but verifies URL parsing
_, err := WSDial("ws://127.0.0.1/path") _, err := WSDial("ws://127.0.0.1/path")