tests/session_events_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
"github.com/bramvdbogaerde/go-scp"
"github.com/pkg/sftp"
"github.com/shellhub-io/shellhub/pkg/api/requests"
"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"
)
func listSessions(ctx context.Context, compose *environment.DockerCompose) ([]models.Session, error) {
sessions := []models.Session{}
resp, err := compose.R(ctx).SetResult(&sessions).Get("/api/sessions")
if err != nil {
return nil, err
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("the list answered %d, and the strict validator names the mismatch: %s", resp.StatusCode(), resp.String())
}
return sessions, nil
}
func currentSessions(t *testing.T, ctx context.Context, compose *environment.DockerCompose) []models.Session {
t.Helper()
var sessions []models.Session
require.EventuallyWithT(t, func(tt *assert.CollectT) {
var err error
sessions, err = listSessions(ctx, compose)
assert.NoError(tt, err)
}, 30*time.Second, 1*time.Second)
return sessions
}
func sessionAfter(t *testing.T, ctx context.Context, compose *environment.DockerCompose, before []models.Session, action func()) *models.Session {
t.Helper()
known := make(map[string]struct{}, len(before))
for _, session := range before {
known[session.UID] = struct{}{}
}
action()
opened := &models.Session{} //nolint:exhaustruct // filled from the API response
require.EventuallyWithT(t, func(tt *assert.CollectT) {
sessions, err := listSessions(ctx, compose)
if !assert.NoError(tt, err) {
return
}
for _, session := range sessions {
if _, seen := known[session.UID]; !seen {
*opened = session
return
}
}
assert.Fail(tt, "the action opened no session the list did not already carry")
}, 30*time.Second, 1*time.Second)
return opened
}
func sessionDetail(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(tt *assert.CollectT, session models.Session) {
assert.NotEmpty(tt, session.Events.Items, "the timeline is written after the channel opens")
})
}
func itemOfType(events []models.SessionEvent, eventType models.SessionEventType) *models.SessionEvent {
for i, event := range events {
if event.Type == eventType {
return &events[i]
}
}
return nil
}
func payload[T any](t *testing.T, event *models.SessionEvent, key string) T {
t.Helper()
require.NotNil(t, event, "no event of the wanted type is in the timeline")
data, ok := event.Data.(map[string]any)
require.True(t, ok, "the event payload is not an object: %T", event.Data)
value, ok := data[key].(T)
require.True(t, ok, "the payload carries no %T under %q: %v", value, key, data)
return value
}
func commandsBySeat(events []models.SessionEvent) map[int]string {
commands := make(map[int]string)
for _, event := range events {
if event.Type != models.SessionEventTypeExec {
continue
}
if data, ok := event.Data.(map[string]any); ok {
commands[event.Seat], _ = data["command"].(string)
}
}
return commands
}
func TestSessionDetailSaysWhatTheSessionDid(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, legacyNamespace)
_, device := startAcceptedAgent(t, ctx, compose)
signer := registerDeviceKey(t, ctx, compose)
cases := []struct {
name string
open func(t *testing.T, conn *ssh.Client)
expect func(t *testing.T, session models.Session)
}{
{
name: "an interactive shell names the terminal the client asked for",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
sess, err := conn.NewSession()
require.NoError(t, err)
require.NoError(t, sess.RequestPty("xterm-256color", 30, 141, ssh.TerminalModes{ssh.ECHO: 1}))
require.NoError(t, sess.Shell())
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
assert.Equal(t, models.SessionEventTypePtyRequest, session.Events.First)
assert.Equal(t, "xterm-256color",
payload[string](t, itemOfType(session.Events.Items, models.SessionEventTypePtyRequest), "term"))
},
},
{
name: "a single command carries the command it ran",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
sess, err := conn.NewSession()
require.NoError(t, err)
_, err = sess.CombinedOutput("uptime")
require.NoError(t, err)
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
assert.Equal(t, models.SessionEventTypeExec, session.Events.First)
assert.Equal(t, "uptime",
payload[string](t, itemOfType(session.Events.Items, models.SessionEventTypeExec), "command"))
},
},
{
name: "an SFTP transfer opens with the subsystem",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
client, err := sftp.NewClient(conn)
require.NoError(t, err)
file, err := client.OpenFile("/tmp/sent-by-sftp", os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
require.NoError(t, err)
_, err = file.Write([]byte("sent"))
require.NoError(t, err)
require.NoError(t, file.Close())
require.NoError(t, client.Close())
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
assert.Equal(t, models.SessionEventTypeSubsystem, session.Events.First)
},
},
{
name: "a port forward records the destination it reached",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
forward := struct {
DestAddr string
DestPort uint32
OriginAddr string
OriginPort uint32
}{DestAddr: "127.0.0.1", DestPort: 80, OriginAddr: "127.0.0.1", OriginPort: 40000}
ch, requests, err := conn.OpenChannel("direct-tcpip", ssh.Marshal(forward))
require.NoError(t, err)
go ssh.DiscardRequests(requests)
_, err = ch.Write([]byte("GET / HTTP/1.0\r\n\r\n"))
require.NoError(t, err)
status, err := bufio.NewReader(ch).ReadString('\n')
require.NoError(t, err)
require.True(t, strings.HasPrefix(status, "HTTP/1."), status)
_ = ch.Close()
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
forward := itemOfType(session.Events.Items, models.SessionEventTypeDirectTCPIP)
assert.Equal(t, "127.0.0.1", payload[string](t, forward, "dest_addr"))
assert.InDelta(t, 80, payload[float64](t, forward, "dest_port"), 0)
assert.Contains(t, session.Events.Types, string(models.SessionEventTypeDirectTCPIP))
assert.Empty(t, session.Events.First, "a forward opens no session channel")
},
},
{
name: "a legacy scp is told from a plain command by its own payload",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
client, err := scp.NewClientBySSH(conn)
require.NoError(t, err)
file := bytes.NewBuffer(make([]byte, 64))
require.NoError(t, client.CopyFilePassThru(ctx, file, "/tmp/sent-by-scp", "0644", io.LimitReader))
client.Close()
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
assert.Equal(t, models.SessionEventTypeExec, session.Events.First)
assert.Contains(t,
payload[string](t, itemOfType(session.Events.Items, models.SessionEventTypeExec), "command"),
"scp",
"only the payload separates a legacy transfer from an ordinary command")
},
},
{
name: "a resized terminal records its new size",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
sess, err := conn.NewSession()
require.NoError(t, err)
require.NoError(t, sess.RequestPty("xterm", 24, 80, ssh.TerminalModes{ssh.ECHO: 0}))
startResizeReporter(t, sess)(50, 132)
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
resize := itemOfType(session.Events.Items, models.SessionEventTypeWindowChange)
assert.InDelta(t, 132, payload[float64](t, resize, "columns"), 0)
assert.InDelta(t, 50, payload[float64](t, resize, "rows"), 0)
assert.Contains(t, session.Events.Types, string(models.SessionEventTypeWindowChange))
},
},
{
name: "a command records the status it exited with",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
sess, err := conn.NewSession()
require.NoError(t, err)
var exit *ssh.ExitError
require.ErrorAs(t, sess.Run("exit 3"), &exit)
require.Equal(t, 3, exit.ExitStatus())
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
assert.InDelta(t, 3,
payload[float64](t, itemOfType(session.Events.Items, models.SessionEventTypeExitStatus), "status"), 0)
assert.Contains(t, session.Events.Types, string(models.SessionEventTypeExitStatus))
},
},
{
name: "channels on one connection take a seat each",
open: func(t *testing.T, conn *ssh.Client) {
t.Helper()
for _, command := range []string{"echo first", "echo second"} {
sess, err := conn.NewSession()
require.NoError(t, err)
_, err = sess.CombinedOutput(command)
require.NoError(t, err)
}
},
expect: func(t *testing.T, session models.Session) {
t.Helper()
assert.ElementsMatch(t, []int{0, 1}, session.Events.Seats)
assert.Equal(t, map[int]string{0: "echo first", 1: "echo second"}, commandsBySeat(session.Events.Items),
"each command is recorded against the seat its channel took")
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
before := currentSessions(t, ctx, compose)
conn := dialDevice(t, ctx, compose, device, signer)
t.Cleanup(func() { _ = conn.Close() })
opened := sessionAfter(t, ctx, compose, before, func() { tc.open(t, conn) })
assert.Empty(t, opened.Events.Items, "the list carries no timeline")
assert.Nil(t, opened.Principal, "a legacy namespace authorizes no principal")
require.NoError(t, conn.Close())
requireSessionActive(t, ctx, compose, opened.UID, false)
session := sessionDetail(t, ctx, compose, opened.UID)
tc.expect(t, session)
for i := 1; i < len(session.Events.Items); i++ {
assert.False(t, session.Events.Items[i].Timestamp.Before(session.Events.Items[i-1].Timestamp),
"the timeline is oldest first")
}
for _, item := range session.Events.Items {
assert.NotEqual(t, models.SessionEventTypePtyOutput, item.Type,
"terminal output is the recording, not a timeline entry")
}
})
}
}
func TestSessionPrincipalNamesWhoOpenedIt(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, newNamespace)
_, device := startAcceptedAgent(t, ctx, compose)
openedWith := func(t *testing.T, signer ssh.Signer) *models.Session {
t.Helper()
before := currentSessions(t, ctx, compose)
conn := dialClient(t, ctx, compose.SSHAddress(), &ssh.ClientConfig{ //nolint:exhaustruct // the remaining fields keep their defaults
User: deviceSSHID(device),
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // the test stack's host key is ephemeral
})
t.Cleanup(func() { _ = conn.Close() })
return sessionAfter(t, ctx, compose, before, func() {
sess, err := conn.NewSession()
require.NoError(t, err)
_, err = sess.CombinedOutput("uptime")
require.NoError(t, err)
})
}
t.Run("a person", func(t *testing.T) {
owner := compose.AuthUser(t, ShellHubUsername, ShellHubPassword)
signer, data := newSigner(t)
compose.EnrollIdentity(t, "person", data)
opened := openedWith(t, signer)
require.NotNil(t, opened.Principal, "a new namespace's session is opened by someone")
assert.Equal(t, models.Principal{Kind: models.PrincipalUser, ID: owner.ID}, *opened.Principal)
})
t.Run("an API key", func(t *testing.T) {
key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "automation", ExpiresAt: -1})
signer, data := newSigner(t)
resp, err := compose.R(ctx).
SetBody(map[string]any{"name": "automation", "data": data}).
Post("/api/namespaces/api-key/" + key.Name + "/ssh-identities")
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode(), resp.String())
compose.CreateAccessPolicy(t, &requests.AccessPolicyCreate{ //nolint:exhaustruct // the remaining fields keep their defaults
Name: "automation",
Subject: requests.AccessPolicySubject{Type: string(models.PolicySubjectAPIKey), Value: key.ID},
Filter: requests.AccessPolicyFilter{All: true},
Logins: []string{ShellHubAgentUsername},
Action: string(models.PolicyActionAllow),
})
opened := openedWith(t, signer)
require.NotNil(t, opened.Principal, "a new namespace's session is opened by someone")
assert.Equal(t, models.Principal{Kind: models.PrincipalAPIKey, ID: key.ID}, *opened.Principal)
})
}