|
|
|
|
| package agent
|
|
|
| import (
|
| "crypto/ed25519"
|
| "errors"
|
| "fmt"
|
| "net"
|
| "net/url"
|
| "testing"
|
| "time"
|
|
|
| "github.com/stretchr/testify/assert"
|
| "github.com/stretchr/testify/require"
|
| "golang.org/x/crypto/ssh"
|
| )
|
|
|
| func createTestAgent(t *testing.T) *Agent {
|
| dataDir := t.TempDir()
|
| agent, err := NewAgent(dataDir)
|
| require.NoError(t, err)
|
| return agent
|
| }
|
|
|
| func createTestServerOptions(t *testing.T) ServerOptions {
|
|
|
| _, privKey, err := ed25519.GenerateKey(nil)
|
| require.NoError(t, err)
|
| sshPubKey, err := ssh.NewPublicKey(privKey.Public().(ed25519.PublicKey))
|
| require.NoError(t, err)
|
|
|
|
|
| listener, err := net.Listen("tcp", "127.0.0.1:0")
|
| require.NoError(t, err)
|
| port := listener.Addr().(*net.TCPAddr).Port
|
| listener.Close()
|
|
|
| return ServerOptions{
|
| Network: "tcp",
|
| Addr: fmt.Sprintf("127.0.0.1:%d", port),
|
| Keys: []ssh.PublicKey{sshPubKey},
|
| }
|
| }
|
|
|
|
|
| func TestConnectionManager_NewConnectionManager(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := newConnectionManager(agent)
|
|
|
| assert.NotNil(t, cm, "Connection manager should not be nil")
|
| assert.Equal(t, agent, cm.agent, "Agent reference should be set")
|
| assert.Equal(t, Disconnected, cm.State, "Initial state should be Disconnected")
|
| assert.Nil(t, cm.eventChan, "Event channel should be nil initially")
|
| assert.Nil(t, cm.wsClient, "WebSocket client should be nil initially")
|
| assert.Nil(t, cm.wsTicker, "WebSocket ticker should be nil initially")
|
| assert.False(t, cm.isConnecting, "isConnecting should be false initially")
|
| }
|
|
|
|
|
| func TestConnectionManager_StateTransitions(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
| initialState := cm.State
|
| cm.wsClient = &WebSocketClient{
|
| hubURL: &url.URL{
|
| Host: "localhost:8080",
|
| },
|
| }
|
| assert.NotNil(t, cm, "Connection manager should not be nil")
|
| assert.Equal(t, Disconnected, initialState, "Initial state should be Disconnected")
|
|
|
|
|
| cm.handleStateChange(WebSocketConnected)
|
| assert.Equal(t, WebSocketConnected, cm.State, "State should change to WebSocketConnected")
|
|
|
| cm.handleStateChange(SSHConnected)
|
| assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected")
|
|
|
| cm.handleStateChange(Disconnected)
|
| assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
|
|
|
|
|
| cm.State = WebSocketConnected
|
| cm.handleStateChange(WebSocketConnected)
|
| assert.Equal(t, WebSocketConnected, cm.State, "Same state should not trigger change")
|
| }
|
|
|
|
|
| func TestConnectionManager_EventHandling(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
| cm.wsClient = &WebSocketClient{
|
| hubURL: &url.URL{
|
| Host: "localhost:8080",
|
| },
|
| }
|
|
|
| testCases := []struct {
|
| name string
|
| initialState ConnectionState
|
| event ConnectionEvent
|
| expectedState ConnectionState
|
| }{
|
| {
|
| name: "WebSocket connect from disconnected",
|
| initialState: Disconnected,
|
| event: WebSocketConnect,
|
| expectedState: WebSocketConnected,
|
| },
|
| {
|
| name: "SSH connect from disconnected",
|
| initialState: Disconnected,
|
| event: SSHConnect,
|
| expectedState: SSHConnected,
|
| },
|
| {
|
| name: "WebSocket disconnect from connected",
|
| initialState: WebSocketConnected,
|
| event: WebSocketDisconnect,
|
| expectedState: Disconnected,
|
| },
|
| {
|
| name: "SSH disconnect from connected",
|
| initialState: SSHConnected,
|
| event: SSHDisconnect,
|
| expectedState: Disconnected,
|
| },
|
| {
|
| name: "WebSocket disconnect from SSH connected (no change)",
|
| initialState: SSHConnected,
|
| event: WebSocketDisconnect,
|
| expectedState: SSHConnected,
|
| },
|
| {
|
| name: "SSH disconnect from WebSocket connected (no change)",
|
| initialState: WebSocketConnected,
|
| event: SSHDisconnect,
|
| expectedState: WebSocketConnected,
|
| },
|
| }
|
|
|
| for _, tc := range testCases {
|
| t.Run(tc.name, func(t *testing.T) {
|
| cm.State = tc.initialState
|
| cm.handleEvent(tc.event)
|
| assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
|
| })
|
| }
|
| }
|
|
|
|
|
| func TestConnectionManager_TickerManagement(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
|
|
|
|
| cm.startWsTicker()
|
| assert.NotNil(t, cm.wsTicker, "Ticker should be created")
|
|
|
|
|
| assert.NotPanics(t, func() {
|
| cm.stopWsTicker()
|
| }, "Stopping ticker should not panic")
|
|
|
|
|
| cm.wsTicker = nil
|
| assert.NotPanics(t, func() {
|
| cm.stopWsTicker()
|
| }, "Stopping nil ticker should not panic")
|
|
|
|
|
| cm.startWsTicker()
|
| assert.NotNil(t, cm.wsTicker, "Ticker should be recreated")
|
|
|
|
|
| firstTicker := cm.wsTicker
|
| cm.startWsTicker()
|
| assert.Equal(t, firstTicker, cm.wsTicker, "Same ticker instance should be reused")
|
|
|
| cm.stopWsTicker()
|
| }
|
|
|
|
|
| func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
|
|
|
|
| err := cm.startWebSocketConnection()
|
| assert.Error(t, err, "WebSocket connection should fail without proper environment")
|
| assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
|
|
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "1,33%")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
| _, err2 := newWebSocketClient(agent)
|
| assert.Error(t, err2, "WebSocket client creation should fail with invalid URL")
|
|
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "")
|
|
|
| _, err3 := newWebSocketClient(agent)
|
| assert.Error(t, err3, "WebSocket client creation should fail without token")
|
| }
|
|
|
|
|
| func TestConnectionManager_ReconnectionLogic(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
| cm.eventChan = make(chan ConnectionEvent, 1)
|
|
|
|
|
|
|
| cm.State = WebSocketConnected
|
| cm.isConnecting = false
|
|
|
|
|
| cm.handleStateChange(Disconnected)
|
| assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
|
| assert.True(t, cm.isConnecting, "Should set isConnecting flag")
|
| }
|
|
|
|
|
| func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
|
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
|
|
| wsClient, err := newWebSocketClient(agent)
|
| require.NoError(t, err)
|
| cm.wsClient = wsClient
|
|
|
|
|
| cm.wsClient.lastConnectAttempt = time.Now()
|
|
|
|
|
| err = cm.startWebSocketConnection()
|
| assert.Error(t, err, "Should error due to rate limiting")
|
| assert.Contains(t, err.Error(), "already connecting", "Error should indicate rate limiting")
|
|
|
|
|
| cm.wsClient.lastConnectAttempt = time.Now().Add(-10 * time.Second)
|
| err = cm.startWebSocketConnection()
|
|
|
| assert.Error(t, err, "Connection should fail but not due to rate limiting")
|
| assert.NotContains(t, err.Error(), "already connecting", "Error should not indicate rate limiting")
|
| }
|
|
|
|
|
| func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
| serverOptions := createTestServerOptions(t)
|
|
|
|
|
| cm.eventChan = make(chan ConnectionEvent, 5)
|
| err := cm.Start(serverOptions)
|
| assert.Error(t, err, "Should error when starting already started connection manager")
|
| }
|
|
|
|
|
| func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
|
|
|
|
| assert.NotPanics(t, func() {
|
| cm.closeWebSocket()
|
| }, "Should not panic when closing nil WebSocket client")
|
|
|
|
|
| t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
| t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
|
| wsClient, err := newWebSocketClient(agent)
|
| require.NoError(t, err)
|
| cm.wsClient = wsClient
|
|
|
|
|
| assert.NotPanics(t, func() {
|
| cm.closeWebSocket()
|
| }, "Should not panic when closing WebSocket client")
|
| }
|
|
|
|
|
| func TestConnectionManager_ConnectFlow(t *testing.T) {
|
| agent := createTestAgent(t)
|
| cm := agent.connectionManager
|
|
|
|
|
| assert.NotPanics(t, func() {
|
| cm.connect()
|
| }, "Connect should not panic without WebSocket client")
|
| }
|
|
|
| func TestShouldExitOnErr(t *testing.T) {
|
| createDialErr := func(msg string) error {
|
| return &net.OpError{
|
| Op: "dial",
|
| Net: "tcp",
|
| Err: errors.New(msg),
|
| }
|
| }
|
|
|
| tests := []struct {
|
| name string
|
| err error
|
| envValue string
|
| expected bool
|
| }{
|
| {
|
| name: "no env var",
|
| err: createDialErr("lookup lkahsdfasdf: no such host"),
|
| envValue: "",
|
| expected: false,
|
| },
|
| {
|
| name: "env var false",
|
| err: createDialErr("lookup lkahsdfasdf: no such host"),
|
| envValue: "false",
|
| expected: false,
|
| },
|
| {
|
| name: "env var true, matching error",
|
| err: createDialErr("lookup lkahsdfasdf: no such host"),
|
| envValue: "true",
|
| expected: true,
|
| },
|
| {
|
| name: "env var true, matching error with extra context",
|
| err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
|
| envValue: "true",
|
| expected: true,
|
| },
|
| {
|
| name: "env var true, non-matching error",
|
| err: errors.New("connection refused"),
|
| envValue: "true",
|
| expected: false,
|
| },
|
| {
|
| name: "env var true, dial but not lookup",
|
| err: createDialErr("connection timeout"),
|
| envValue: "true",
|
| expected: false,
|
| },
|
| }
|
|
|
| for _, tt := range tests {
|
| t.Run(tt.name, func(t *testing.T) {
|
| t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
|
| result := shouldExitOnErr(tt.err)
|
| assert.Equal(t, tt.expected, result)
|
| })
|
| }
|
| }
|
|
|