Ctrl K
ComunidadGuías…

tests/session_events_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
	})
}