Ctrl K
ComunidadGuías…

tests/device_pairing_test.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"regexp"
	"testing"
	"time"

	"github.com/go-resty/resty/v2"
	"github.com/shellhub-io/shellhub/pkg/api/requests"
	"github.com/shellhub-io/shellhub/pkg/models"
	"github.com/shellhub-io/shellhub/pkg/pairingcode"
	"github.com/shellhub-io/shellhub/tests/environment"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
)

var (
	agentPairingLink    = regexp.MustCompile(`accept-device\?code=([` + pairingcode.Alphabet + `]+)`)
	agentConnectedLine  = regexp.MustCompile(`msg="Server connection established".* tenant_id=(\S+)`)
	agentPairsAgainLine = regexp.MustCompile(`msg="(The device was removed from its namespace; pairing it again)"`)
)

// TestDevicePairing drives the codes an agent prints for a person to accept it from a browser with
// requests built by hand, accepting them as the namespace's owner. A pairing code stands for a
// tenant-less agent whose device does not exist yet; a login code stands for a pending device that
// already enrolled with a tenant. The cases share one stack, so every device carries a hostname and
// a MAC address no other case uses. What a running agent makes of a pairing is covered by
// [TestDevicePairingAgent].
func TestDevicePairing(t *testing.T) {
	t.Parallel()

	compose := newSSHEnvironment(t, t.Context(), newNamespace)

	t.Run("pairing", func(t *testing.T) { testPairingCode(t, compose) })
	t.Run("login code", func(t *testing.T) { testDeviceLoginCode(t, compose) })
	t.Run("code format", func(t *testing.T) { testDeviceCodeFormat(t, compose) })
	t.Run("device limit", func(t *testing.T) { testPairingDeviceLimit(t, compose) })
}

// TestDevicePairingAgent runs a real agent with no tenant, for what it does with a pairing: the code
// it asks for, the status it polls, and the tenant it joins once someone accepts the code.
func TestDevicePairingAgent(t *testing.T) {
	t.Parallel()

	compose := newSSHEnvironment(t, t.Context(), newNamespace)

	t.Run("an agent without a tenant starts a pairing and waits on its code", func(t *testing.T) {
		agent := startAgent(t, t.Context(), compose, NewAgentContainerUnpaired(), NewAgentContainerWithHostname("pairing-waits"))
		code := awaitAgentPairingCode(t, agent)

		status, resp, err := getPairingStatus(t.Context(), compose, code)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Equal(t, &models.DevicePairingStatus{Status: models.DeviceStatusPending, Name: "pairing-waits", ExpiresIn: 600}, status)

		assert.NotContains(t, deviceNames(compose.ListDevices(t, models.DeviceStatusEmpty)), "pairing-waits", "a pairing created a device before anyone accepted it")

		assert.Never(t, func() bool {
			logs, err := agentLogs(t.Context(), agent)

			return err != nil || agentConnectedLine.Match(logs)
		}, 5*time.Second, time.Second, "the agent connected before anyone accepted its code")
	})

	t.Run("the agent learns of the acceptance by polling its code, without a restart", func(t *testing.T) {
		agent := startAgent(t, t.Context(), compose, NewAgentContainerUnpaired(), NewAgentContainerWithHostname("pairing-polled"))
		code := awaitAgentPairingCode(t, agent)

		accepted := acceptPairing(t, compose.R(t.Context()), code)

		status, resp, err := getPairingStatus(t.Context(), compose, code)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Equal(t, &models.DevicePairingStatus{
			Status:   models.DeviceStatusAccepted,
			TenantID: ShellHubNamespace,
			UID:      accepted.UID,
			Name:     "pairing-polled",
		}, status)

		awaitAgentConnected(t, agent)

		logs, err := agentLogs(t.Context(), agent)
		require.NoError(t, err)
		assert.Equal(t, 1, bytes.Count(logs, []byte(`msg="Starting ShellHub"`)), "the agent restarted to learn of the acceptance")
	})

	t.Run("the agent keeps the tenant it receives and serves the namespace with it", func(t *testing.T) {
		agent := startAgent(t, t.Context(), compose, NewAgentContainerUnpaired())

		accepted := acceptPairing(t, compose.R(t.Context()), awaitAgentPairingCode(t, agent))

		assert.Equal(t, ShellHubNamespace, awaitAgentConnected(t, agent))
		assert.Equal(t, ShellHubNamespace, readAgentFile(t, agent, "/tmp/shellhub.key.tenant"), "the agent did not keep the tenant it learned")
		assert.Equal(t, ShellHubNamespace, compose.AwaitDeviceOnline(t, accepted.UID).TenantID)
	})

	t.Run("an agent whose device was removed while it was stopped pairs again", func(t *testing.T) {
		agent := startAgent(t, t.Context(), compose, NewAgentContainerUnpaired(), NewAgentContainerWithHostname("pairing-deleted"))

		accepted := acceptPairing(t, compose.R(t.Context()), awaitAgentPairingCode(t, agent))
		awaitAgentConnected(t, agent)

		require.NoError(t, agent.Stop(t.Context(), nil))
		compose.DeleteDevice(t, accepted.UID)
		require.NoError(t, agent.Start(t.Context()))

		awaitAgentLog(t, agent, agentPairsAgainLine, "the agent did not go back to pairing")
		assert.NotContains(t, execInAgent(t, agent, "ls", "/tmp"), "shellhub.key.tenant", "the agent kept the tenant of a removed device")
	})
}

func testPairingCode(t *testing.T, compose *environment.DockerCompose) {
	t.Helper()

	t.Run("pairing again with the same public key returns the pending code", func(t *testing.T) {
		req := newPairingRequest(t, "pairing-twice", "02:00:00:00:90:01")

		first := startPairing(t, compose, req)
		require.Equal(t, models.DeviceStatusPending, first.Status)
		require.NotEmpty(t, first.Code)

		again := startPairing(t, compose, req)
		assert.Equal(t, first.Code, again.Code)
		assert.Equal(t, models.DeviceStatusPending, again.Status)

		anotherKey := startPairing(t, compose, newPairingRequest(t, "pairing-twice", "02:00:00:00:90:01"))
		assert.NotEqual(t, first.Code, anotherKey.Code, "a different public key was handed the same code")
	})

	t.Run("a device already accepted is answered with its tenant instead of a code", func(t *testing.T) {
		req := newPairingRequest(t, "pairing-accepted", "02:00:00:00:90:02")

		acceptPairing(t, compose.R(t.Context()), startPairing(t, compose, req).Code)

		assert.Equal(t, &models.DevicePairing{Status: models.DeviceStatusAccepted, TenantID: ShellHubNamespace}, startPairing(t, compose, req))
	})

	t.Run("a code past its lifetime is not found", func(t *testing.T) {
		req := newPairingRequest(t, "pairing-expired", "02:00:00:00:90:03")
		pairing := startPairing(t, compose, req)

		status, resp, err := getPairingStatus(t.Context(), compose, pairing.Code)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), "the code was gone before its lifetime ran out: %s", resp.String())
		require.Equal(t, models.DeviceStatusPending, status.Status)

		compose.ExpireCacheEntryIn(t, pairingCodeCacheKey(pairing.Code), 0)

		_, resp, err = getPairingStatus(t.Context(), compose, pairing.Code)
		require.NoError(t, err)
		require.Equal(t, http.StatusNotFound, resp.StatusCode(), resp.String())

		_, resp, err = postPairingAccept(compose.R(t.Context()), pairing.Code)
		require.NoError(t, err)
		assert.Equal(t, http.StatusNotFound, resp.StatusCode(), resp.String())

		assert.NotContains(t, deviceNames(compose.ListDevices(t, models.DeviceStatusEmpty)), req.Hostname, "an expired code enrolled a device")
	})
}

func agentLogs(ctx context.Context, agent testcontainers.Container) ([]byte, error) {
	reader, err := agent.Logs(ctx)
	if err != nil {
		return nil, err
	}

	defer func() { _ = reader.Close() }()

	return io.ReadAll(reader)
}

func awaitAgentLog(t *testing.T, agent testcontainers.Container, line *regexp.Regexp, missing string) string {
	t.Helper()

	return awaitAgentLogWithin(t, agent, line, missing, 30*time.Second)
}

func awaitAgentLogWithin(t *testing.T, agent testcontainers.Container, line *regexp.Regexp, missing string, within time.Duration) string {
	t.Helper()

	return awaitNthAgentLogWithin(t, agent, line, 1, missing, within)
}

func awaitNthAgentLogWithin(t *testing.T, agent testcontainers.Container, line *regexp.Regexp, n int, missing string, within time.Duration) string {
	t.Helper()

	var captured string

	require.EventuallyWithT(t, func(tt *assert.CollectT) {
		logs, err := agentLogs(t.Context(), agent)
		if !assert.NoError(tt, err) {
			return
		}

		matches := line.FindAllSubmatch(logs, -1)
		if assert.GreaterOrEqual(tt, len(matches), n, missing) {
			captured = string(matches[n-1][1])
		}
	}, within, time.Second)

	return captured
}

func awaitAgentPairingCode(t *testing.T, agent testcontainers.Container) string {
	t.Helper()

	return awaitAgentLog(t, agent, agentPairingLink, "the agent has not printed a pairing code")
}

func awaitAgentConnected(t *testing.T, agent testcontainers.Container) string {
	t.Helper()

	return awaitAgentLog(t, agent, agentConnectedLine, "the agent has not connected to the server")
}

func readAgentFile(t *testing.T, agent testcontainers.Container, path string) string {
	t.Helper()

	return execInAgent(t, agent, "cat", path)
}

func pairingCodeCacheKey(code string) string {
	return "pairing_code/" + code
}

func newPairingRequest(t *testing.T, hostname, mac string) requests.DevicePairingCreate {
	t.Helper()

	auth := newDeviceAuthRequest(t, hostname, mac)

	return requests.DevicePairingCreate{
		Info:      auth.Info,
		Hostname:  auth.Hostname,
		Identity:  auth.Identity,
		PublicKey: auth.PublicKey,
	}
}

func startPairing(t *testing.T, compose *environment.DockerCompose, req requests.DevicePairingCreate) *models.DevicePairing {
	t.Helper()

	pairing := new(models.DevicePairing)

	resp, err := compose.Anonymous(t.Context()).SetBody(req).SetResult(pairing).Post("/api/devices/pairing")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	return pairing
}

func getPairingStatus(ctx context.Context, compose *environment.DockerCompose, code string) (*models.DevicePairingStatus, *resty.Response, error) {
	status := new(models.DevicePairingStatus)

	resp, err := compose.Anonymous(ctx).SetResult(status).Get("/api/devices/pairing/" + code + "/status")

	return status, resp, err
}

func postPairingAccept(req *resty.Request, code string) (*models.DevicePairingAccepted, *resty.Response, error) {
	return postPairingAcceptInto(req, ShellHubNamespace, code)
}

func postPairingAcceptInto(req *resty.Request, tenant, code string) (*models.DevicePairingAccepted, *resty.Response, error) {
	accepted := new(models.DevicePairingAccepted)

	resp, err := req.
		SetBody(map[string]string{"tenant_id": tenant}).
		SetResult(accepted).
		Post("/api/devices/pairing/" + code + "/accept")

	return accepted, resp, err
}

func acceptPairing(t *testing.T, req *resty.Request, code string) *models.DevicePairingAccepted {
	t.Helper()

	return acceptPairingInto(t, req, ShellHubNamespace, code)
}

func acceptPairingInto(t *testing.T, req *resty.Request, tenant, code string) *models.DevicePairingAccepted {
	t.Helper()

	accepted, resp, err := postPairingAcceptInto(req, tenant, code)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	require.NotEmpty(t, accepted.UID)

	return accepted
}