package telnet import ( "bufio" "errors" "net" "strings" "sync" "testing" "time" ) // scriptedServer is a minimal mock of the device's port-17000 shell. It // returns the supplied banner on connect, then for each line read it emits // the corresponding entry from responses (or "Command not found" if the line // is not in the map). type scriptedServer struct { t *testing.T listener net.Listener banner string responses map[string]string // hangAfter, if non-empty, names a command after which the server stops // responding (to exercise the read-timeout path). hangAfter string // closeAfter, if non-empty, names a command after which the server closes // the connection mid-stream. closeAfter string stop chan struct{} wg sync.WaitGroup } func newScriptedServer(t *testing.T, banner string, responses map[string]string) *scriptedServer { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } s := &scriptedServer{ t: t, listener: l, banner: banner, responses: responses, stop: make(chan struct{}), } s.wg.Add(1) go s.serve() return s } func (s *scriptedServer) addr() string { return s.listener.Addr().String() } func (s *scriptedServer) hostPort() (string, int) { host, portStr, err := net.SplitHostPort(s.addr()) if err != nil { s.t.Fatalf("split host/port: %v", err) } port := 0 if _, err := parseInt(portStr, &port); err != nil { s.t.Fatalf("parse port %q: %v", portStr, err) } return host, port } func (s *scriptedServer) close() { close(s.stop) _ = s.listener.Close() s.wg.Wait() } func (s *scriptedServer) serve() { defer s.wg.Done() conn, err := s.listener.Accept() if err != nil { return } defer func() { _ = conn.Close() }() if s.banner != "" { _, _ = conn.Write([]byte(s.banner)) } r := bufio.NewReader(conn) for { line, err := r.ReadString('\n') if err != nil { return } cmd := strings.TrimRight(line, "\r\n") if cmd == "" { continue } if cmd == s.closeAfter { return } resp, ok := s.responses[cmd] if !ok { resp = "Command not found\n" } _, _ = conn.Write([]byte(resp)) if cmd == s.hangAfter { // Block until the server is closed; the client's read deadline // must fire before then. <-s.stop return } } } // parseInt is a tiny strconv.Atoi wrapper so we don't drag strconv into this file. func parseInt(s string, out *int) (int, error) { n := 0 for _, ch := range s { if ch < '0' || ch > '9' { return 0, errors.New("not a number") } n = n*10 + int(ch-'0') } *out = n return n, nil } func newClientFor(t *testing.T, s *scriptedServer) *Client { t.Helper() host, port := s.hostPort() c := NewClient(host) c.Port = port // Tighten the timeouts so tests fail fast if the implementation regresses. c.DialTimeout = 500 * time.Millisecond c.ReadTimeout = 1500 * time.Millisecond c.WriteTimeout = 500 * time.Millisecond return c } func TestNewClient_Defaults(t *testing.T) { c := NewClient("192.0.2.10") if c.Host != "192.0.2.10" { t.Errorf("Host = %q, want 192.0.2.10", c.Host) } if c.Port != DefaultPort { t.Errorf("Port = %d, want %d", c.Port, DefaultPort) } if c.DialTimeout != DefaultDialTimeout { t.Errorf("DialTimeout = %v, want %v", c.DialTimeout, DefaultDialTimeout) } } func TestDial_Failure(t *testing.T) { // A reserved-for-test address that nothing should be listening on. c := NewClient("127.0.0.1") c.Port = 1 // privileged port, will not connect from a test c.DialTimeout = 200 * time.Millisecond if err := c.Dial(); err == nil { t.Error("expected dial failure, got nil") } } func TestProbe_ReturnsBanner(t *testing.T) { s := newScriptedServer(t, "BoseShell v1\n-> ", nil) defer s.close() c := newClientFor(t, s) if err := c.Dial(); err != nil { t.Fatalf("Dial: %v", err) } defer func() { _ = c.Close() }() got, err := c.Probe() if err != nil { t.Fatalf("Probe: %v", err) } if !strings.Contains(got, "BoseShell v1") { t.Errorf("Probe = %q, want to contain banner", got) } } func TestProbe_NoBannerIsOK(t *testing.T) { s := newScriptedServer(t, "", nil) defer s.close() c := newClientFor(t, s) if err := c.Dial(); err != nil { t.Fatalf("Dial: %v", err) } defer func() { _ = c.Close() }() got, err := c.Probe() if err != nil { t.Fatalf("Probe: %v", err) } if got != "" { t.Errorf("Probe = %q, want empty when no banner is sent", got) } } func TestSendCommand_HappyPath(t *testing.T) { s := newScriptedServer(t, "", map[string]string{ "sys configuration bmxRegistryUrl http://example:8000/bmx/registry/v1/services": "OK\n", "sys configuration margeServerUrl http://example:8000": "OK\n", "getpdo CurrentSystemConfiguration": "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n", }) defer s.close() c := newClientFor(t, s) if err := c.Dial(); err != nil { t.Fatalf("Dial: %v", err) } defer func() { _ = c.Close() }() resp, err := c.SendCommand("sys configuration margeServerUrl http://example:8000") if err != nil { t.Fatalf("SendCommand: %v", err) } if !strings.Contains(resp, "OK") { t.Errorf("response = %q, want to contain OK", resp) } resp, err = c.SendCommand("getpdo CurrentSystemConfiguration") if err != nil { t.Fatalf("SendCommand getpdo: %v", err) } if !strings.Contains(resp, "margeServerUrl=http://example:8000") { t.Errorf("getpdo response = %q, want to echo configured url", resp) } } func TestSendCommand_CommandNotFound(t *testing.T) { s := newScriptedServer(t, "", map[string]string{}) defer s.close() c := newClientFor(t, s) if err := c.Dial(); err != nil { t.Fatalf("Dial: %v", err) } defer func() { _ = c.Close() }() resp, err := c.SendCommand("definitely not a real command") if err != nil { t.Fatalf("SendCommand: %v", err) } if !strings.Contains(resp, "Command not found") { t.Errorf("response = %q, want to contain 'Command not found'", resp) } } func TestSendCommand_DeadlineFiresWhenDeviceHangs(t *testing.T) { s := newScriptedServer(t, "", map[string]string{ "first": "OK\n", "second": "", }) s.hangAfter = "second" defer s.close() c := newClientFor(t, s) c.ReadTimeout = 600 * time.Millisecond if err := c.Dial(); err != nil { t.Fatalf("Dial: %v", err) } defer func() { _ = c.Close() }() if _, err := c.SendCommand("first"); err != nil { t.Fatalf("first SendCommand: %v", err) } start := time.Now() _, err := c.SendCommand("second") if err == nil { t.Fatal("expected timeout error, got nil") } if !strings.Contains(err.Error(), "timed out") { t.Errorf("err = %v, want timed-out wording", err) } // The error must arrive within roughly the ReadTimeout, not after several // times that — guards against an accidental infinite read loop. if elapsed := time.Since(start); elapsed > 2*time.Second { t.Errorf("SendCommand returned after %v, want under 2s", elapsed) } } func TestSendCommand_ConnectionClosedMidStream(t *testing.T) { s := newScriptedServer(t, "", map[string]string{}) s.closeAfter = "trigger close" defer s.close() c := newClientFor(t, s) if err := c.Dial(); err != nil { t.Fatalf("Dial: %v", err) } defer func() { _ = c.Close() }() _, err := c.SendCommand("trigger close") if err == nil { t.Fatal("expected error after server closes mid-stream, got nil") } } func TestSendCommand_FailsWithoutDial(t *testing.T) { c := NewClient("127.0.0.1") if _, err := c.SendCommand("anything"); err == nil { t.Error("SendCommand without Dial should fail, got nil") } } func TestClose_IsIdempotent(t *testing.T) { s := newScriptedServer(t, "", nil) defer s.close() c := newClientFor(t, s) if err := c.Dial(); err != nil { t.Fatalf("Dial: %v", err) } if err := c.Close(); err != nil { t.Errorf("first Close: %v", err) } if err := c.Close(); err != nil { t.Errorf("second Close: %v", err) } }