From dbe071cda2b7751a2e2665b119a11f557fd81cdf Mon Sep 17 00:00:00 2001 From: Asif Mohammad Mollah Date: Tue, 6 Oct 2026 11:20:59 +0000 Subject: [PATCH 1/2] test coverage added --- models/config_test.go | 100 ++++++++++++++++ services/config_parser_test.go | 101 ++++++++++++++++ services/connection_test.go | 26 +++++ services/server_lifecycle_test.go | 123 ++++++++++++++++++++ services/server_test.go | 24 ++++ services/ssh_client_test.go | 179 +++++++++++++++++++++++++++++ services/tunnel_test.go | 184 ++++++++++++++++++++++++++++++ services/validator_test.go | 32 ++++++ utils/encoding_test.go | 27 +++++ 9 files changed, 796 insertions(+) create mode 100644 models/config_test.go create mode 100644 services/config_parser_test.go create mode 100644 services/connection_test.go create mode 100644 services/server_lifecycle_test.go create mode 100644 services/server_test.go create mode 100644 services/ssh_client_test.go create mode 100644 services/tunnel_test.go create mode 100644 services/validator_test.go create mode 100644 utils/encoding_test.go diff --git a/models/config_test.go b/models/config_test.go new file mode 100644 index 0000000..ce737dd --- /dev/null +++ b/models/config_test.go @@ -0,0 +1,100 @@ +package models + +import ( + "testing" + "github.com/tech-thinker/telepath/constants" +) + +func TestServerValidateSuccessKey(t *testing.T) { + s := &Server{ + Host: "example.com", + Port: 22, + Username: "user", + AuthType: constants.CREDIENTIAL_KEY, + Key: "/tmp/key", + } + if err := s.Validate("test"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServerValidateSuccessPass(t *testing.T) { + s := &Server{ + Host: "example.com", + Port: 22, + Username: "user", + AuthType: constants.CREDIENTIAL_PASS, + Password: "cGFzc3dvcmQ=", // "password" base64 + } + if err := s.Validate("test"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestServerValidateMissingFields(t *testing.T) { + tests := []struct { + name string + server *Server + want string + }{ + {"missing host", &Server{Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, "`test.host` must be present."}, + {"invalid port", &Server{Host: "h", Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, "`test.port` is invalid."}, + {"missing username", &Server{Host: "h", Port: 22, AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, "`test.username` must be present."}, + {"invalid auth", &Server{Host: "h", Port: 22, Username: "u", AuthType: "XYZ", Password: "cGFz"}, "`test.authType` must be present."}, + {"key auth missing key", &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_KEY}, "`test.key` must be present for authType KEY."}, + {"pass auth missing password", &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS}, "`test.password` must be present for authType PASS."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.server.Validate("test") + if err == nil || err.Error() != tt.want { + t.Fatalf("expected error %q, got %v", tt.want, err) + } + }) + } +} + +func TestServerValidateJump(t *testing.T) { + // nested jump chain with valid inner server + inner := &Server{Host: "inner", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"} + top := &Server{Host: "top", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz", Jump: inner} + if err := top.Validate("test"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestConfigValidateSuccess(t *testing.T) { + cfg := Config{ + Name: "cfg1", + Type: "L", + LocalPort: 8080, + RemotePort: 80, + Server: &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestConfigValidateErrors(t *testing.T) { + baseServer := &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"} + tests := []struct { + name string + cfg Config + want string + }{ + {"missing name", Config{Type: "L", LocalPort: 1, RemotePort: 1, Server: baseServer}, "`name` must be present."}, + {"invalid type", Config{Name: "n", Type: "X", LocalPort: 1, RemotePort: 1, Server: baseServer}, "`type` must be L or R."}, + {"invalid localPort", Config{Name: "n", Type: "L", LocalPort: 0, RemotePort: 1, Server: baseServer}, "`localPort` is invalid."}, + {"invalid remotePort", Config{Name: "n", Type: "L", LocalPort: 1, RemotePort: 0, Server: baseServer}, "`remotePort` is invalid."}, + {"missing server", Config{Name: "n", Type: "L", LocalPort: 1, RemotePort: 1}, "`server` must be present."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if err == nil || err.Error() != tt.want { + t.Fatalf("expected %q, got %v", tt.want, err) + } + }) + } +} diff --git a/services/config_parser_test.go b/services/config_parser_test.go new file mode 100644 index 0000000..5510738 --- /dev/null +++ b/services/config_parser_test.go @@ -0,0 +1,101 @@ +package services + +import ( + "os" + "testing" +) + +func TestParseConfigComments(t *testing.T) { + raw := `[ + // single line comment + { + "name": "test", + "type": "L", + "localPort": 8080, + "localHost": "127.0.0.1", + "remotePort": 80, + "remoteHost": "example.com", + "server": { + "host": "ssh.example.com", + "port": 22, + "username": "user", + "authType": "PASS", + "password": "cGFzc3dvcmQ=" + } + } /* multi line comment */ + ]` + f, err := os.CreateTemp("", "config-*.json") + if err != nil { + t.Fatalf("temp file: %v", err) + } + defer os.Remove(f.Name()) + if _, err := f.Write([]byte(raw)); err != nil { + t.Fatalf("write temp: %v", err) + } + f.Close() + + cfgs, err := ParseConfig(f.Name()) + if err != nil { + t.Fatalf("ParseConfig error: %v", err) + } + if len(cfgs) != 1 { + t.Fatalf("expected 1 config, got %d", len(cfgs)) + } + if cfgs[0].Name != "test" { + t.Fatalf("unexpected config name: %s", cfgs[0].Name) + } + if cfgs[0].Server.AuthType != "PASS" { + t.Fatalf("unexpected authType: %s", cfgs[0].Server.AuthType) + } +} + +func TestParseConfigMissingFile(t *testing.T) { + _, err := ParseConfig("/tmp/nonexistent-config-xyz.json") + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestParseConfigInvalidJSON(t *testing.T) { + f, _ := os.CreateTemp("", "bad-json") + f.WriteString("{ invalid json }") + f.Close() + defer os.Remove(f.Name()) + + _, err := ParseConfig(f.Name()) + if err == nil { + t.Fatal("expected JSON parse error") + } +} + +func TestParseConfigValidWithComments(t *testing.T) { + f, _ := os.CreateTemp("", "config") + f.WriteString(`[ + // comment + { + "name": "test", + "type": "L", + "localPort": 8080, + "localHost": "127.0.0.1", + "remotePort": 80, + "remoteHost": "127.0.0.1", + "server": { + "host": "h", + "port": 22, + "username": "u", + "authType": "PASS", + "password": "p" + } + } /* comment */ + ]`) + f.Close() + defer os.Remove(f.Name()) + + cfgs, err := ParseConfig(f.Name()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfgs) != 1 { + t.Fatalf("expected 1 config, got %d", len(cfgs)) + } +} \ No newline at end of file diff --git a/services/connection_test.go b/services/connection_test.go new file mode 100644 index 0000000..34ab044 --- /dev/null +++ b/services/connection_test.go @@ -0,0 +1,26 @@ +package services + +import "testing" + +func TestLiveConnectionIsActive(t *testing.T) { + lc := &LiveConnection{} + if lc.IsActive() { + t.Fatalf("expected inactive when cancel is nil") + } + lc2 := NewLiveConnection(func() {}) + if !lc2.IsActive() { + t.Fatalf("expected active when cancel is set") + } +} + +func TestNewLiveConnection(t *testing.T) { + cancelCalled := false + lc := NewLiveConnection(func() { cancelCalled = true }) + if lc.cancel == nil { + t.Fatal("cancel should be set") + } + lc.cancel() + if !cancelCalled { + t.Fatal("cancel function not called") + } +} \ No newline at end of file diff --git a/services/server_lifecycle_test.go b/services/server_lifecycle_test.go new file mode 100644 index 0000000..860b0d8 --- /dev/null +++ b/services/server_lifecycle_test.go @@ -0,0 +1,123 @@ +package services + +import ( + "context" + "sync" + "testing" + "time" + "github.com/tech-thinker/telepath/models" +) + +func TestNewServerInitialization(t *testing.T) { + cfgs := []models.Config{{Name: "c1"}, {Name: "c2"}} + s := NewServer(cfgs) + if s == nil { + t.Fatal("NewServer returned nil") + } +} + +func TestStartWithNilServer(t *testing.T) { + s := &srv{cfg: []models.Config{{Name: "test"}}, liveConnections: make(map[string]*LiveConnection)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s.start(ctx, models.Config{Name: "test", Server: nil}) +} + +func TestStartTypeR(t *testing.T) { + s := &srv{cfg: []models.Config{}, liveConnections: make(map[string]*LiveConnection)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := models.Config{Name: "test", Type: "R", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1} + s.start(ctx, cfg) +} + +func TestStartTypeL(t *testing.T) { + s := &srv{cfg: []models.Config{}, liveConnections: make(map[string]*LiveConnection)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := models.Config{Name: "test", Type: "L", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1} + s.start(ctx, cfg) +} + +func TestBuildSSHClientNilServer(t *testing.T) { + s := &srv{} + c, err := s.buildSSHClient(models.Config{Server: nil}) + if c != nil || err != nil { + t.Fatalf("expected nil,nil got %v,%v", c, err) + } +} + +func TestStartAllMultiple(t *testing.T) { + cfgs := []models.Config{{Name: "c1", Server: nil}, {Name: "c2", Server: nil}} + s := &srv{cfg: cfgs, liveConnections: make(map[string]*LiveConnection)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var wg sync.WaitGroup + s.StartAll(ctx, &wg) + wg.Wait() +} + +func TestStopAllMultiple(t *testing.T) { + s := &srv{liveConnections: map[string]*LiveConnection{ + "c1": NewLiveConnection(func() {}), + "c2": NewLiveConnection(func() {}), + }} + s.StopAll() +} + +func TestStartWithServerErrorAfterSSH(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := models.Config{ + Name: "ssh-error", + Type: "L", + Server: &models.Server{Host: "invalid.host", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, + LocalPort: 8080, + RemotePort: 80, + } + s := &srv{liveConnections: make(map[string]*LiveConnection)} + s.start(ctx, cfg) + time.Sleep(10 * time.Millisecond) +} + +func TestStartWithAlreadyActiveConnection(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := models.Config{Name: "active", Server: nil} + s := &srv{liveConnections: make(map[string]*LiveConnection)} + active := NewLiveConnection(func() {}) + s.liveConnections[cfg.Name] = active + s.start(ctx, cfg) + if _, ok := s.liveConnections[cfg.Name]; !ok { + t.Fatal("expected active connection to remain") + } +} + +func TestStartWithTypeRLocalBoth(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := &srv{liveConnections: make(map[string]*LiveConnection)} + cfgR := models.Config{Name: "r", Type: "R", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1} + s.start(ctx, cfgR) + s2 := &srv{liveConnections: make(map[string]*LiveConnection)} + cfgL := models.Config{Name: "l", Type: "L", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1} + s2.start(ctx, cfgL) + s3 := &srv{liveConnections: make(map[string]*LiveConnection)} + cfgEmpty := models.Config{Name: "empty", Type: "", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1} + s3.start(ctx, cfgEmpty) + time.Sleep(10 * time.Millisecond) +} + +func TestStartAllWithMixedConfigs(t *testing.T) { + cfgs := []models.Config{ + {Name: "c1", Server: nil}, + {Name: "c2", Server: &models.Server{Host: "invalid", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}}, + {Name: "c3", Server: nil}, + } + s := &srv{cfg: cfgs, liveConnections: make(map[string]*LiveConnection)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var wg sync.WaitGroup + s.StartAll(ctx, &wg) + wg.Wait() +} \ No newline at end of file diff --git a/services/server_test.go b/services/server_test.go new file mode 100644 index 0000000..8eb86ab --- /dev/null +++ b/services/server_test.go @@ -0,0 +1,24 @@ +package services + +import ( + "testing" + "github.com/tech-thinker/telepath/models" +) + +func TestMakeHostChainOrder(t *testing.T) { + hop2 := &models.Server{Host: "hop2", Port: 2222} + hop1 := &models.Server{Host: "hop1", Port: 2221, Jump: hop2} + final := &models.Server{Host: "final", Port: 22, Jump: hop1} + + s := &srv{cfg: nil, liveConnections: nil} + chain := s.makeHostChain(final) + if len(chain) != 3 { + t.Fatalf("expected chain length 3, got %d", len(chain)) + } + expected := []string{"hop2", "hop1", "final"} + for i, srv := range chain { + if srv.Host != expected[i] { + t.Fatalf("at index %d expected host %s, got %s", i, expected[i], srv.Host) + } + } +} \ No newline at end of file diff --git a/services/ssh_client_test.go b/services/ssh_client_test.go new file mode 100644 index 0000000..5e2519d --- /dev/null +++ b/services/ssh_client_test.go @@ -0,0 +1,179 @@ +package services + +import ( + "os" + "testing" + "github.com/tech-thinker/telepath/models" + "github.com/tech-thinker/telepath/constants" +) + +func TestCreateSSHClientPasswordBranch(t *testing.T) { + srv := &srv{} + server := &models.Server{ + Host: "invalid", + Port: 22, + Username: "user", + AuthType: constants.CREDIENTIAL_PASS, + Password: "cGFzc3dvcmQ=", + } + _, err := srv.createSSHClient(server, nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestCreateSSHClientKeyMissing(t *testing.T) { + srv := &srv{} + server := &models.Server{ + Host: "invalid", + Port: 22, + Username: "user", + AuthType: constants.CREDIENTIAL_KEY, + Key: "/tmp/nonexistent-key-xyz", + } + _, err := srv.createSSHClient(server, nil) + if err == nil { + t.Fatal("expected error for missing key") + } +} + +func TestCreateSSHClientKeyInvalid(t *testing.T) { + f, _ := os.CreateTemp("", "bad-key") + f.WriteString("INVALID KEY") + f.Close() + defer os.Remove(f.Name()) + + srv := &srv{} + server := &models.Server{ + Host: "invalid", + Port: 22, + Username: "user", + AuthType: constants.CREDIENTIAL_KEY, + Key: f.Name(), + } + _, err := srv.createSSHClient(server, nil) + if err == nil { + t.Fatal("expected error for invalid key") + } +} + +func TestCreateSSHClientKeyAuthNoPassphrase(t *testing.T) { + f, _ := os.CreateTemp("", "key-no-pass") + keyData := `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0Z3VS5JJcds3s5uEsw9Z3vNaHxnhW9VV7iHZJoB6f8bC5v3u +rKz0dK7vLJnCKMhfJzV5GvKKHHMjxKaQUKWZB4YZvKQZq5J8pZ5sJ6L5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +QIDAQABAoIBAC5N2sVRVXZCaAf0wKH2rrVVl5XZE8rPl5YzqzqZJmUQR5pP5sV5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +-----END RSA PRIVATE KEY-----` + f.WriteString(keyData) + f.Close() + defer os.Remove(f.Name()) + + srv := &srv{} + server := &models.Server{ + Host: "invalid", + Port: 22, + Username: "user", + AuthType: constants.CREDIENTIAL_KEY, + Key: f.Name(), + } + _, err := srv.createSSHClient(server, nil) + if err == nil { + t.Fatal("expected error for invalid host") + } +} + +func TestCreateSSHClientWithPassphraseKey(t *testing.T) { + f, _ := os.CreateTemp("", "test-key-*.pem") + keyData := `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0Z3VS5JJcds3s5uEsw9Z3vNaHxnhW9VV7iHZJoB6f8bC5v3u +rKz0dK7vLJnCKMhfJzV5GvKKHHMjxKaQUKWZB4YZvKQZq5J8pZ5sJ6L5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +QIDAQABAoIBAC5N2sVRVXZCaAf0wKH2rrVVl5XZE8rPl5YzqzqZJmUQR5pP5sV5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5K5V5 +-----END RSA PRIVATE KEY-----` + f.WriteString(keyData) + f.Close() + defer os.Remove(f.Name()) + + srv := &srv{} + server := &models.Server{ + Host: "invalid", + Port: 22, + Username: "user", + AuthType: constants.CREDIENTIAL_KEY, + Key: f.Name(), + Passphrase: "cGFzc3BocmFzZQ==", + } + _, err := srv.createSSHClient(server, nil) + if err == nil { + t.Fatal("expected error for invalid host") + } +} + +func TestMakeHostChainNil(t *testing.T) { + s := &srv{} + chain := s.makeHostChain(nil) + if len(chain) != 0 { + t.Fatalf("expected 0, got %d", len(chain)) + } +} + +func TestMakeHostChainOrdering(t *testing.T) { + f := &models.Server{Host: "final"} + h := &models.Server{Host: "hop", Jump: f} + s := &srv{} + chain := s.makeHostChain(h) + if len(chain) != 2 || chain[0].Host != "final" || chain[1].Host != "hop" { + t.Fatal("chain order wrong") + } +} + +func TestMakeHostChainThreeLevels(t *testing.T) { + f := &models.Server{Host: "final"} + h2 := &models.Server{Host: "hop2", Jump: f} + h1 := &models.Server{Host: "hop1", Jump: h2} + s := &srv{} + chain := s.makeHostChain(h1) + if len(chain) != 3 { + t.Fatalf("expected 3, got %d", len(chain)) + } + if chain[0].Host != "final" || chain[1].Host != "hop2" || chain[2].Host != "hop1" { + t.Fatal("wrong order") + } +} + +func TestBuildSSHClientWithMultipleHops(t *testing.T) { + cfg := models.Config{ + Server: &models.Server{ + Host: "final", + Port: 22, + Username: "u", + AuthType: constants.CREDIENTIAL_PASS, + Password: "p", + Jump: &models.Server{ + Host: "jump", + Port: 22, + Username: "u", + AuthType: constants.CREDIENTIAL_PASS, + Password: "p", + }, + }, + } + s := &srv{} + _, err := s.buildSSHClient(cfg) + if err == nil { + t.Fatal("expected error for invalid host") + } +} \ No newline at end of file diff --git a/services/tunnel_test.go b/services/tunnel_test.go new file mode 100644 index 0000000..0b5da34 --- /dev/null +++ b/services/tunnel_test.go @@ -0,0 +1,184 @@ +package services + +import ( + "context" + "errors" + "net" + "sync" + "testing" + "time" + "github.com/tech-thinker/telepath/models" +) + +// mockListener implements net.Listener for testing acceptLoop. +type mockListener struct { + acceptCh chan net.Conn + closeCh chan struct{} + addr net.Addr + errOnce error +} + +func (m *mockListener) Accept() (net.Conn, error) { + if m.errOnce != nil { + err := m.errOnce + m.errOnce = nil + return nil, err + } + select { + case <-m.closeCh: + return nil, errors.New("closed") + case c := <-m.acceptCh: + return c, nil + } +} +func (m *mockListener) Close() error { + close(m.closeCh) + return nil +} +func (m *mockListener) Addr() net.Addr { return m.addr } + +func newMockListener() *mockListener { + return &mockListener{acceptCh: make(chan net.Conn, 1), closeCh: make(chan struct{}), addr: &net.TCPAddr{}} +} + +func TestStartLocalWithListenerError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := &srv{} + cfg := models.Config{Name: "bad", LocalPort: -1, LocalHost: "127.0.0.1", RemotePort: 80, RemoteHost: "127.0.0.1"} + s.startLocal(ctx, cfg, nil) +} + +func TestStartLocalWithValidListener(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := models.Config{ + Name: "valid-local", + LocalPort: 0, + LocalHost: "127.0.0.1", + RemotePort: 80, + RemoteHost: "127.0.0.1", + } + s := &srv{liveConnections: make(map[string]*LiveConnection)} + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + s.startLocal(ctx, cfg, nil) +} + +func TestStartRemoteWithNilServerSkipped(t *testing.T) { + t.Skip("startRemote requires real SSH client; cannot test with nil client without production code changes") +} + +func TestAcceptLoopContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + ml := newMockListener() + done := make(chan struct{}) + go func() { + s := &srv{} + s.acceptLoop(ctx, models.Config{Name: "test"}, ml, func(c net.Conn) { c.Close() }) + close(done) + }() + time.Sleep(10 * time.Millisecond) + cancel() + ml.Close() + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("acceptLoop did not exit after context cancel") + } +} + +func TestAcceptLoopErrorExits(t *testing.T) { + ctx := context.Background() + ml := newMockListener() + ml.errOnce = errors.New("forced error") + s := &srv{} + s.acceptLoop(ctx, models.Config{Name: "test"}, ml, func(c net.Conn) { c.Close() }) + ml.Close() +} + +func TestAcceptLoopMultipleAccepts(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + ml := newMockListener() + cfg := models.Config{Name: "multi-accept"} + s := &srv{} + var mu sync.Mutex + handlerCalls := 0 + handler := func(c net.Conn) { + mu.Lock() + handlerCalls++ + mu.Unlock() + c.Close() + } + done := make(chan struct{}) + go func() { + s.acceptLoop(ctx, cfg, ml, handler) + close(done) + }() + for i := 0; i < 3; i++ { + c1, c2 := net.Pipe() + ml.acceptCh <- c1 + c2.Close() + } + time.Sleep(50 * time.Millisecond) + cancel() + ml.Close() + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("acceptLoop didn't exit") + } + mu.Lock() + if handlerCalls != 3 { + t.Fatalf("expected 3 handler calls, got %d", handlerCalls) + } + mu.Unlock() +} + +func TestAcceptLoopAcceptErrorNonContext(t *testing.T) { + ctx := context.Background() + ml := newMockListener() + ml.errOnce = errors.New("non-context accept error") + cfg := models.Config{Name: "accept-error"} + s := &srv{} + s.acceptLoop(ctx, cfg, ml, func(c net.Conn) { c.Close() }) + ml.Close() +} + +func TestPipeCopiesBidirectional(t *testing.T) { + a, b := net.Pipe() + defer a.Close() + defer b.Close() + go pipe(a, b) + msg1 := []byte("hello from a") + msg2 := []byte("reply from b") + if _, err := a.Write(msg1); err != nil { + t.Fatalf("write a->b failed: %v", err) + } + buf := make([]byte, len(msg1)) + if _, err := b.Read(buf); err != nil { + t.Fatalf("read b failed: %v", err) + } + if string(buf) != string(msg1) { + t.Fatalf("expected %s, got %s", msg1, buf) + } + if _, err := b.Write(msg2); err != nil { + t.Fatalf("write b->a failed: %v", err) + } + buf2 := make([]byte, len(msg2)) + if _, err := a.Read(buf2); err != nil { + t.Fatalf("read a failed: %v", err) + } + if string(buf2) != string(msg2) { + t.Fatalf("expected %s, got %s", msg2, buf2) + } + time.Sleep(10 * time.Millisecond) +} + +func TestPipeWithImmediateClose(t *testing.T) { + a, b := net.Pipe() + b.Close() + pipe(a, b) +} \ No newline at end of file diff --git a/services/validator_test.go b/services/validator_test.go new file mode 100644 index 0000000..6e12d35 --- /dev/null +++ b/services/validator_test.go @@ -0,0 +1,32 @@ +package services + +import ( + "testing" + "github.com/tech-thinker/telepath/models" + "github.com/tech-thinker/telepath/constants" +) + +func TestValidateConfigSuccess(t *testing.T) { + cfg := models.Config{ + Name: "valid", + Type: "L", + LocalPort: 8080, + RemotePort: 80, + Server: &models.Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, + } + valid, err := ValidateConfig([]models.Config{cfg}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(valid) != 1 { + t.Fatalf("expected 1 valid config, got %d", len(valid)) + } +} + +func TestValidateConfigError(t *testing.T) { + invalid := models.Config{Type: "L", LocalPort: 8080, RemotePort: 80, Server: &models.Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}} + _, err := ValidateConfig([]models.Config{invalid}) + if err == nil { + t.Fatalf("expected error for invalid config, got nil") + } +} \ No newline at end of file diff --git a/utils/encoding_test.go b/utils/encoding_test.go new file mode 100644 index 0000000..b12a895 --- /dev/null +++ b/utils/encoding_test.go @@ -0,0 +1,27 @@ +package utils + +import ( + "encoding/base64" + "testing" +) + +func TestBase64EncodeDecode(t *testing.T) { + plain := "hello world" + encoded := Base64Encode(plain) + expected := base64.StdEncoding.EncodeToString([]byte(plain)) + if encoded != expected { + t.Fatalf("expected %s, got %s", expected, encoded) + } + + decoded := Base64Decode(encoded) + if decoded != plain { + t.Fatalf("expected decoded %s, got %s", plain, decoded) + } +} + +func TestBase64DecodeInvalid(t *testing.T) { + // non‑base64 string should return empty string + if got := Base64Decode("@@@invalid@@@"); got != "" { + t.Fatalf("expected empty string for invalid base64, got %s", got) + } +} From 793d2f85fc71be815d5326a8d34cda7c59f78d78 Mon Sep 17 00:00:00 2001 From: Asif Mohammad Mollah Date: Tue, 6 Oct 2026 11:30:18 +0000 Subject: [PATCH 2/2] test badge added --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b74189..3ccafd4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # telepath ![GitHub release (latest by date)](https://img.shields.io/github/v/release/tech-thinker/telepath) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/tech-thinker/telepath/release.yaml) +[![Tests](https://github.com/tech-thinker/telepath/actions/workflows/go-tests.yaml/badge.svg)](https://github.com/tech-thinker/telepath/actions/workflows/go-tests.yaml) ![GitHub](https://img.shields.io/github/license/tech-thinker/telepath) ![GitHub All Releases](https://img.shields.io/github/downloads/tech-thinker/telepath/total) ![GitHub last commit](https://img.shields.io/github/last-commit/tech-thinker/telepath) @@ -183,7 +184,7 @@ The configuration file is a JSON array of objects. Each object defines a tunnel. > **Note:** Jump hosts are optional and can be nested multiple times. ### Tunnel Type -- **L (Local)**: Forwards traffic from **remote → local** +- **L (Local)**: Forwards traffic from **remote → local** - **R (Remote)**: Forwards traffic from **local → remote** ### Example Topology Diagram