tests/session_lifecycle_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"context"
"encoding/json"
"net"
"net/url"
"strings"
"testing"
"time"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/tests/environment"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
)
const (
outsiderUsername = "outsider"
outsiderEmail = "outsider@ossystems.com.br"
outsiderPassword = "password"
outsiderNamespaceName = "outsiderspace"
outsiderNamespace = "00000000-0000-4000-0000-000000000003"
)
const (
webMessageKindError = 4
webMessageKindSession = 5
)
func awaitSession(t *testing.T, ctx context.Context, compose *environment.DockerCompose, uid string, timeout time.Duration, check func(tt *assert.CollectT, session models.Session)) models.Session {
t.Helper()
var last models.Session
require.EventuallyWithT(t, func(tt *assert.CollectT) {
session := models.Session{} //nolint:exhaustruct // filled from the API response
resp, err := compose.R(ctx).SetResult(&session).Get("/api/sessions/" + uid)
if !assert.NoError(tt, err) || !assert.Equal(tt, 200, resp.StatusCode(), "the strict validator names the mismatch: %s", resp.String()) {
return
}
last = session
check(tt, session)
}, timeout, 1*time.Second)
return last
}
func getSession(t *testing.T, ctx context.Context, compose *environment.DockerCompose, uid string) models.Session {
t.Helper()
return awaitSession(t, ctx, compose, uid, 30*time.Second, func(*assert.CollectT, models.Session) {})
}
func openSession(t *testing.T, ctx context.Context, compose *environment.DockerCompose, device *models.Device, signer ssh.Signer, command string) (*ssh.Client, *models.Session) {
t.Helper()
before := currentSessions(t, ctx, compose)
conn := dialDevice(t, ctx, compose, device, signer)
t.Cleanup(func() { _ = conn.Close() })
opened := sessionAfter(t, ctx, compose, before, func() {
sess, err := conn.NewSession()
require.NoError(t, err)
if command != "" {
_, err = sess.CombinedOutput(command)
require.NoError(t, err)
return
}
require.NoError(t, sess.RequestPty("xterm", 24, 80, ssh.TerminalModes{ssh.ECHO: 1}))
require.NoError(t, sess.Shell())
})
return conn, opened
}
func finishSession(t *testing.T, ctx context.Context, compose *environment.DockerCompose, device *models.Device, signer ssh.Signer, command string) string {
t.Helper()
conn, opened := openSession(t, ctx, compose, device, signer, command)
require.NoError(t, conn.Close())
requireSessionActive(t, ctx, compose, opened.UID, false)
return opened.UID
}
func openWebTerminal(t *testing.T, ctx context.Context, compose *environment.DockerCompose, device *models.Device) string {
t.Helper()
_, session := dialWebTerminal(t, ctx, compose, device)
return session
}
func dialWebTerminal(t *testing.T, ctx context.Context, compose *environment.DockerCompose, device *models.Device) (*websocket.Conn, string) {
t.Helper()
issued := struct {
Token string `json:"token"`
}{}
resp, err := compose.R(ctx).
SetBody(map[string]string{
"device": device.UID,
"username": ShellHubAgentUsername,
"password": ShellHubAgentPassword,
}).
SetResult(&issued).
Post("/ws/ssh/session")
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode(), resp.String())
query := url.Values{"token": {issued.Token}, "cols": {"80"}, "rows": {"24"}}
config, err := websocket.NewConfig(
strings.Replace(compose.BaseURL(), "http", "ws", 1)+"/ws/ssh?"+query.Encode(),
compose.BaseURL(),
)
require.NoError(t, err)
conn, err := config.DialContext(ctx)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, conn.SetReadDeadline(wallClock.Now().Add(30*time.Second)))
for {
var frame []byte
require.NoError(t, websocket.Message.Receive(conn, &frame), "the terminal closed before naming its session")
message := struct {
Kind int `json:"kind"`
Data string `json:"data"`
}{}
if json.Unmarshal(frame, &message) != nil {
continue
}
require.NotEqual(t, webMessageKindError, message.Kind, "the web terminal refused: %s", message.Data)
if message.Kind == webMessageKindSession {
return conn, message.Data
}
}
}
func TestSessionLifecycle(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, legacyNamespace)
_, device := startAcceptedAgent(t, ctx, compose)
signer := registerDeviceKey(t, ctx, compose)
t.Run("a session is active while its client is connected and inactive once it disconnects", func(t *testing.T) {
conn, opened := openSession(t, ctx, compose, device, signer, "")
assert.False(t, opened.Web, "a session opened over SSH did not come from the web terminal")
requireSessionActive(t, ctx, compose, opened.UID, true)
require.NoError(t, conn.Close())
requireSessionActive(t, ctx, compose, opened.UID, false)
})
t.Run("keep-alives advance last_seen while the session is open", func(t *testing.T) {
_, opened := openSession(t, ctx, compose, device, signer, "")
requireSessionActive(t, ctx, compose, opened.UID, true)
seen := getSession(t, ctx, compose, opened.UID).LastSeen
awaitSession(t, ctx, compose, opened.UID, 45*time.Second, func(tt *assert.CollectT, session models.Session) {
assert.True(tt, session.Active, "the session is still open")
assert.True(tt, session.LastSeen.After(seen), "last_seen stayed at %s", seen)
})
})
t.Run("a login the device accepts marks the session authenticated", func(t *testing.T) {
_, opened := openSession(t, ctx, compose, device, signer, "")
awaitSession(t, ctx, compose, opened.UID, 30*time.Second, func(tt *assert.CollectT, session models.Session) {
assert.True(tt, session.Authenticated)
})
})
t.Run("a password the device refuses leaves the session unauthenticated", func(t *testing.T) {
before := currentSessions(t, ctx, compose)
refused := sessionAfter(t, ctx, compose, before, func() {
dialer := net.Dialer{} //nolint:exhaustruct // the zero dialer is what ssh.Dial uses
conn, err := dialer.DialContext(ctx, "tcp", compose.SSHAddress())
require.NoError(t, err)
defer conn.Close() //nolint:errcheck // the handshake failed, so nothing is left to flush
_, _, _, err = ssh.NewClientConn(conn, compose.SSHAddress(), &ssh.ClientConfig{ //nolint:exhaustruct // the remaining fields keep their defaults
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{ssh.Password("not-the-password")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // the test stack's host key is ephemeral
})
require.Error(t, err)
})
session := getSession(t, ctx, compose, refused.UID)
assert.False(t, session.Authenticated, "the device never accepted the credential")
assert.False(t, session.Active, "a session the device refused never went live")
})
t.Run("a session opened from the web terminal is flagged web", func(t *testing.T) {
skipUnlessAgentAcceptsPasswords(t)
uid := openWebTerminal(t, ctx, compose, device)
session := getSession(t, ctx, compose, uid)
assert.True(t, session.Web)
assert.Equal(t, device.UID, string(session.DeviceUID))
})
t.Run("a session is visible only inside its own namespace", func(t *testing.T) {
compose.NewUser(t, outsiderUsername, outsiderEmail, outsiderPassword)
compose.NewNamespace(t, outsiderUsername, outsiderNamespaceName, outsiderNamespace)
outsider := compose.AuthUser(t, outsiderUsername, outsiderPassword)
require.NotNil(t, outsider.Tenant)
require.Equal(t, outsiderNamespace, *outsider.Tenant)
_, opened := openSession(t, ctx, compose, device, signer, "")
assert.Equal(t, ShellHubNamespace, getSession(t, ctx, compose, opened.UID).TenantID)
resp, err := compose.Anonymous(ctx).SetAuthToken(outsider.Token).Get("/api/sessions/" + opened.UID)
require.NoError(t, err)
assert.Equal(t, 404, resp.StatusCode(), resp.String())
listed := []models.Session{}
resp, err = compose.Anonymous(ctx).SetAuthToken(outsider.Token).SetResult(&listed).Get("/api/sessions")
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode(), resp.String())
assert.Empty(t, listed, "another namespace lists none of this one's sessions")
})
}