Ctrl K
ComunidadGuías…

tests/web_endpoints_test.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"io"
	"log"
	"maps"
	"net/http"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/moby/moby/api/types/container"
	"github.com/shellhub-io/shellhub/pkg/api/authorizer"
	"github.com/shellhub-io/shellhub/pkg/api/query"
	"github.com/shellhub-io/shellhub/pkg/api/requests"
	"github.com/shellhub-io/shellhub/pkg/api/responses"
	"github.com/shellhub-io/shellhub/pkg/models"
	"github.com/shellhub-io/shellhub/pkg/testimage"
	"github.com/shellhub-io/shellhub/tests/environment"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

const (
	deviceServiceHost      = "127.0.0.1"
	intruderUsername       = "intruder"
	intruderNamespaceName  = "intruderspace"
	intruderNamespace      = "00000000-0000-4000-0000-000000000022"
	deviceTLSDomain        = "device.example.com"
	webEndpointDomain      = "localhost"
	unknownAddress         = "0123456789abcdef0123456789abcdef"
	webEndpointNotFound    = "web endpoint not found"
	gatewayRootCertificate = "/data/pki/authorities/local/root.crt"
	finiteTTL              = 60
)

var (
	webEndpointAddress = regexp.MustCompile(`^[0-9a-f]{32}$`)
	unknownDeviceUID   = strings.Repeat("0", 64)
)

type deviceService struct {
	name     string
	httpPort int
	tlsPort  int
}

func (s deviceService) plain(uid string, ttl int) environment.WebEndpointCreate {
	return environment.WebEndpointCreate{UID: uid, Host: deviceServiceHost, Port: s.httpPort, TTL: ttl}
}

func (s deviceService) secure(uid string, verify bool) environment.WebEndpointCreate {
	return environment.WebEndpointCreate{
		UID:  uid,
		Host: deviceServiceHost,
		Port: s.tlsPort,
		TTL:  -1,
		TLS:  &environment.WebEndpointTLS{Enabled: true, Verify: verify, Domain: deviceTLSDomain},
	}
}

type deviceSeen struct {
	Name string `json:"name"`
	Host string `json:"host"`
	URI  string `json:"uri"`
	TLS  bool   `json:"tls"`
}

// TestEnterpriseWebEndpoints covers web endpoints on an enterprise instance that enables them: what
// the API refuses to create, the address and lifetime of what it creates, the gateway carrying a
// request sent to an endpoint's address through the agent into an HTTP or TLS service on the
// device, the deprecated tunnel API over the same endpoints, a device merge handing a device's
// endpoints to the device it merged into, the gateway refusing an endpoint whose device is no
// longer accepted and its deletion taking the endpoint with it, the list refusing a filter or sort
// it does not take, the roles that may not create or delete an endpoint, and a connector carrying a
// request for a container's loopback to the container's own address. The device's services listen
// in the agents' network namespace, or on another host of the stack's network, and answer with the
// name they were started with and what they received.
func TestEnterpriseWebEndpoints(t *testing.T) {
	t.Parallel()

	ctx := context.Background()

	compose := newEnterpriseEnvironment(t, ctx, environment.New(t, run).WithEnv("SHELLHUB_WEB_ENDPOINTS", "true"))
	_, device := startAcceptedAgent(t, ctx, compose)

	first := startDeviceService(t, compose, deviceService{name: "first", httpPort: 8001, tlsPort: 8443})
	second := startDeviceService(t, compose, deviceService{name: "second", httpPort: 8002, tlsPort: 8444})

	t.Run("creating", func(t *testing.T) { testWebEndpointCreation(t, compose, device, first.plain(device.UID, -1)) })
	t.Run("proxying", func(t *testing.T) { testWebEndpointProxy(t, compose, device, first, second) })
	t.Run("lifetime", func(t *testing.T) {
		testWebEndpointLifetime(t, compose, first.plain(device.UID, -1), second.plain(device.UID, -1))
	})
	t.Run("deprecated tunnel API", func(t *testing.T) { testTunnelAPI(t, compose, device, second) })
	t.Run("another namespace", func(t *testing.T) { testWebEndpointAnotherNamespace(t, ctx, compose, device, first) })
	t.Run("device merge", func(t *testing.T) { testWebEndpointDeviceMerge(t, ctx, compose, first) })
	t.Run("device leaving", func(t *testing.T) { testWebEndpointDeviceLeaving(t, ctx, compose, first) })
	t.Run("listing", func(t *testing.T) { testWebEndpointListing(t, compose) })
	t.Run("roles", func(t *testing.T) { testWebEndpointRoles(t, compose, device, first) })
	t.Run("connector", func(t *testing.T) { testWebEndpointConnector(t, ctx, compose) })
}

// TestEnterpriseWebEndpointsDisabled covers an enterprise instance that leaves web endpoints off,
// as it does by default: neither the API nor the gateway serves them.
func TestEnterpriseWebEndpointsDisabled(t *testing.T) {
	t.Parallel()

	compose := newEnterpriseEnvironment(t, context.Background(), environment.New(t, run))

	t.Run("the API serves no web endpoint route", func(t *testing.T) {
		requests := []struct {
			method string
			path   string
		}{
			{method: http.MethodGet, path: "/api/web-endpoints"},
			{method: http.MethodPost, path: "/api/web-endpoints"},
			{method: http.MethodDelete, path: "/api/web-endpoints/" + unknownAddress},
			{method: http.MethodGet, path: "/api/devices/" + unknownDeviceUID + "/tunnels"},
			{method: http.MethodPost, path: "/api/devices/" + unknownDeviceUID + "/tunnels"},
		}

		for _, req := range requests {
			resp, err := compose.R(t.Context()).SetBody(map[string]any{}).Execute(req.method, req.path)
			require.NoError(t, err)
			assert.Equal(t, http.StatusNotFound, resp.StatusCode(), "%s %s: %s", req.method, req.path, resp.String())
		}
	})

	t.Run("the gateway serves no site for an endpoint's address", func(t *testing.T) {
		resp := visitWebEndpoint(t, compose, unknownAddress+"."+webEndpointDomain, "/")

		assert.Equal(t, http.StatusNotFound, resp.StatusCode)
		assert.Empty(t, resp.Body)
	})
}

// TestEnterpriseWebEndpointsTLS covers the gateway serving web endpoints over HTTPS on a domain no
// public authority signs for: it presents a certificate its own authority issued for the address,
// and carries the request to the server.
func TestEnterpriseWebEndpointsTLS(t *testing.T) {
	t.Parallel()

	compose := environment.New(t, run).
		WithEdition(environment.EditionEnterprise).
		WithEnv("SHELLHUB_WEB_ENDPOINTS", "true").
		WithAutoSSL().
		Up(context.Background())
	t.Cleanup(compose.Down)

	roots := x509.NewCertPool()
	roots.AddCert(gatewayAuthority(t, compose))

	var resp *environment.WebEndpointResponse

	require.EventuallyWithT(t, func(tt *assert.CollectT) {
		var err error

		resp, err = compose.VisitWebEndpointOverTLS(t.Context(), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, unknownAddress+"."+webEndpointDomain, "/")
		assert.NoError(tt, err)
	}, 30*time.Second, time.Second)

	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
	assert.Contains(t, resp.Body, webEndpointNotFound)
}

// TestEnterpriseWebEndpointsDNS01 covers the gateway proving the web endpoints wildcard over DNS-01
// through the acme-dns provider: it writes the challenge to an acme-dns server that answers for the
// web endpoint domain, waits until that server serves it, and then presents the wildcard
// certificate the ACME CA issued for an endpoint's address, carrying the request to the server.
func TestEnterpriseWebEndpointsDNS01(t *testing.T) {
	t.Parallel()

	compose := environment.New(t, run).
		WithEdition(environment.EditionEnterprise).
		WithEnv("SHELLHUB_WEB_ENDPOINTS", "true").
		WithACMEDNS().
		Up(context.Background())
	t.Cleanup(compose.Down)

	host := unknownAddress + "." + environment.ACMEDNSWebEndpointsDomain
	config := &tls.Config{
		ServerName:         host,
		InsecureSkipVerify: true, //nolint:gosec // Pebble's root is generated when it starts; the issuer is what the case reads
		MinVersion:         tls.VersionTLS12,
	}

	var served *x509.Certificate

	require.EventuallyWithT(t, func(tt *assert.CollectT) {
		var err error

		served, err = servedCertificate(t.Context(), compose.HTTPSAddress(), config)
		if assert.NoError(tt, err, "the gateway holds no certificate for %s yet", host) {
			assert.True(tt, strings.HasPrefix(served.Issuer.CommonName, "Pebble Intermediate CA"), "issued by %q", served.Issuer.CommonName)
		}
	}, 3*time.Minute, 2*time.Second)

	assert.Equal(t, []string{"*." + environment.ACMEDNSWebEndpointsDomain}, served.DNSNames)
	require.NoError(t, served.VerifyHostname(host))

	resp, err := compose.VisitWebEndpointOverTLS(t.Context(), config, host, "/")
	require.NoError(t, err)
	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
	assert.Contains(t, resp.Body, webEndpointNotFound)
}

func testWebEndpointCreation(t *testing.T, compose *environment.DockerCompose, device *models.Device, valid environment.WebEndpointCreate) {
	t.Helper()

	refusals := []struct {
		description string
		change      func(req *environment.WebEndpointCreate)
		status      int
	}{
		{description: "a host that is not an IP address", change: func(req *environment.WebEndpointCreate) { req.Host = "localhost" }, status: http.StatusBadRequest},
		{description: "port 0", change: func(req *environment.WebEndpointCreate) { req.Port = 0 }, status: http.StatusBadRequest},
		{description: "a TTL of 0", change: func(req *environment.WebEndpointCreate) { req.TTL = 0 }, status: http.StatusBadRequest},
		{description: "a TTL below -1", change: func(req *environment.WebEndpointCreate) { req.TTL = -2 }, status: http.StatusBadRequest},
		{description: "a device the namespace does not hold", change: func(req *environment.WebEndpointCreate) { req.UID = unknownDeviceUID }, status: http.StatusNotFound},
		{description: "TLS without a domain", change: func(req *environment.WebEndpointCreate) { req.TLS = &environment.WebEndpointTLS{Enabled: true} }, status: http.StatusBadRequest},
	}

	for _, tc := range refusals {
		t.Run("an endpoint for "+tc.description+" is refused", func(t *testing.T) {
			req := valid
			tc.change(&req)

			requireCreateRefused(t, compose, req, tc.status)
		})
	}

	t.Run("an endpoint for a device not yet accepted is refused", func(t *testing.T) {
		pending := enrollPendingDevice(t, compose, "web-endpoint-pending", "02:00:00:00:22:01")

		req := valid
		req.UID = pending.UID

		requireCreateRefused(t, compose, req, http.StatusForbidden)
	})

	t.Run("a token whose namespace was deleted cannot create an endpoint", func(t *testing.T) {
		const username, password = "orphan", "password"

		compose.NewUser(t, username, "orphan@shellhub.example.com", password)
		compose.NewNamespace(t, username, "orphanspace", "00000000-0000-4000-0000-000000002201")
		token := compose.AuthUser(t, username, password).Token

		compose.DeleteNamespace(t, "orphanspace")

		resp, err := compose.Anonymous(t.Context()).SetAuthToken(token).SetBody(valid).Post("/api/web-endpoints")
		require.NoError(t, err)
		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode(), resp.String())
	})

	t.Run("an endpoint answers under a 32 hex digit address of the web endpoint domain", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, valid)

		assert.Regexp(t, webEndpointAddress, endpoint.Address)
		assert.Equal(t, endpoint.Address+"."+webEndpointDomain, endpoint.FullAddress)
		assert.Equal(t, ShellHubNamespace, endpoint.Namespace)
		assert.Equal(t, device.UID, endpoint.DeviceUID)
		assert.Equal(t, -1, endpoint.TTL)
		assert.True(t, endpoint.ExpiresIn.IsZero(), "an endpoint with a TTL of -1 has no expiry")
	})

	t.Run("the same device, host and port keep their endpoint and get a new address once it is deleted", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, valid)

		assert.Equal(t, endpoint.Address, compose.CreateWebEndpoint(t, valid).Address)

		resp, err := compose.DeleteWebEndpoint(t.Context(), endpoint.Address)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		recreated := compose.CreateWebEndpoint(t, valid)
		assert.Regexp(t, webEndpointAddress, recreated.Address)
		assert.NotEqual(t, endpoint.Address, recreated.Address)
	})
}

func testWebEndpointProxy(t *testing.T, compose *environment.DockerCompose, device *models.Device, first, second deviceService) {
	t.Helper()

	t.Run("a request to an endpoint's address reaches the device's service with its path and the address as Host", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, first.plain(device.UID, -1))

		seen := requireDeviceAnswer(t, compose, endpoint.FullAddress, "/some/path?query=value")
		assert.Equal(t, first.name, seen.Name)
		assert.Equal(t, "/some/path?query=value", seen.URI)
		assert.Equal(t, endpoint.FullAddress, seen.Host)
		assert.False(t, seen.TLS)
	})

	t.Run("requests to two endpoints each reach their own service", func(t *testing.T) {
		one := compose.CreateWebEndpoint(t, first.plain(device.UID, -1))
		two := compose.CreateWebEndpoint(t, second.plain(device.UID, -1))

		for range 3 {
			assert.Equal(t, first.name, requireDeviceAnswer(t, compose, one.FullAddress, "/").Name)
			assert.Equal(t, second.name, requireDeviceAnswer(t, compose, two.FullAddress, "/").Name)
		}
	})

	t.Run("a request to an endpoint for another host on the device's network reaches that host", func(t *testing.T) {
		neighbour := deviceService{name: "neighbour", httpPort: 8003, tlsPort: 8445}
		host := startDeviceServiceOnStackNetwork(t, compose, neighbour, nil)

		endpoint := compose.CreateWebEndpoint(t, environment.WebEndpointCreate{UID: device.UID, Host: host, Port: neighbour.httpPort, TTL: -1})

		assert.Equal(t, neighbour.name, requireDeviceAnswer(t, compose, endpoint.FullAddress, "/").Name)
	})

	t.Run("a request to a deleted endpoint's address is refused", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, first.plain(device.UID, -1))
		requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")

		resp, err := compose.DeleteWebEndpoint(t.Context(), endpoint.Address)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		requireEndpointRefused(t, compose, endpoint.FullAddress)
	})

	t.Run("a request to an address no endpoint has is refused", func(t *testing.T) {
		requireEndpointRefused(t, compose, unknownAddress+"."+webEndpointDomain)
	})

	t.Run("an endpoint over TLS that does not verify reaches a self-signed service, naming its domain as Host", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, first.secure(device.UID, false))

		seen := requireDeviceAnswer(t, compose, endpoint.FullAddress, "/secure?query=value")
		assert.Equal(t, first.name, seen.Name)
		assert.True(t, seen.TLS)
		assert.Equal(t, "/secure?query=value", seen.URI)
		assert.Equal(t, deviceTLSDomain, seen.Host)
	})

	t.Run("an endpoint over TLS that verifies refuses a self-signed service", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, first.secure(device.UID, true))

		resp := visitWebEndpoint(t, compose, endpoint.FullAddress, "/")
		assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
		assert.Contains(t, resp.Body, "failed to connect to the port on device")
	})

	t.Run("a subdomain that is not an address is not served", func(t *testing.T) {
		for _, path := range []string{"/api/info", "/internal/metrics"} {
			resp := visitWebEndpoint(t, compose, "console."+webEndpointDomain, path)

			assert.Equal(t, http.StatusNotFound, resp.StatusCode, "%s: %s", path, resp.Body)
		}
	})

	t.Run("a request to the instance's own domain still reaches the API", func(t *testing.T) {
		resp := visitWebEndpoint(t, compose, webEndpointDomain, "/api/info")

		assert.Equal(t, http.StatusOK, resp.StatusCode, resp.Body)
	})
}

func testWebEndpointLifetime(t *testing.T, compose *environment.DockerCompose, endless, elsewhere environment.WebEndpointCreate) {
	t.Helper()

	withTTL := func(ttl int) environment.WebEndpointCreate {
		req := endless
		req.TTL = ttl

		return req
	}

	t.Run("an endpoint never accessed expires its TTL after its creation", func(t *testing.T) {
		const ttl = 3600

		endpoint := listedWebEndpoint(t, compose, compose.CreateWebEndpoint(t, withTTL(ttl)).Address)

		assert.WithinDuration(t, endpoint.CreatedAt.Add(ttl*time.Second), endpoint.ExpiresIn, time.Millisecond)
	})

	t.Run("an accessed endpoint expires its TTL after its last access", func(t *testing.T) {
		const ttl = 3600

		endpoint := compose.CreateWebEndpoint(t, withTTL(ttl))
		compose.AgeWebEndpoint(t, endpoint.Address, 2*time.Second)
		created := listedWebEndpoint(t, compose, endpoint.Address)

		before := wallClock.Now()
		requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")
		after := wallClock.Now()

		accessed := listedWebEndpoint(t, compose, endpoint.Address)
		assert.True(t, accessed.ExpiresIn.After(created.ExpiresIn.Add(time.Second)), "the expiry %s did not move past %s", accessed.ExpiresIn, created.ExpiresIn)
		assert.WithinRange(t, accessed.ExpiresIn.Add(-ttl*time.Second), before.Add(-time.Second), after.Add(time.Second))
	})

	t.Run("an endpoint answers until its TTL runs out and is refused after it", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, withTTL(finiteTTL))

		requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")
		compose.MoveWebEndpointLastAccessBack(t, endpoint.Address, (finiteTTL+1)*time.Second)

		requireEndpointRefused(t, compose, endpoint.FullAddress)
	})

	t.Run("an endpoint accessed within its TTL keeps answering past it", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, withTTL(finiteTTL))

		requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")
		compose.AgeWebEndpoint(t, endpoint.Address, (finiteTTL+1)*time.Second)
		requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")

		compose.MoveWebEndpointLastAccessBack(t, endpoint.Address, (finiteTTL+1)*time.Second)
		requireEndpointRefused(t, compose, endpoint.FullAddress)
	})

	t.Run("an endpoint with a TTL of -1 keeps answering after one with a TTL has expired", func(t *testing.T) {
		finite := compose.CreateWebEndpoint(t, withTTL(finiteTTL))
		infinite := compose.CreateWebEndpoint(t, elsewhere)

		requireRefusedOnceCreatedPastTTL(t, compose, finite)

		requireDeviceAnswer(t, compose, infinite.FullAddress, "/")
		assert.True(t, listedWebEndpoint(t, compose, infinite.Address).ExpiresIn.IsZero())
	})

	t.Run("re-creating an expired endpoint restarts its TTL from the re-creation", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, withTTL(finiteTTL))
		requireRefusedOnceCreatedPastTTL(t, compose, endpoint)

		before := wallClock.Now()
		recreated := compose.CreateWebEndpoint(t, withTTL(finiteTTL))
		after := wallClock.Now()
		require.Equal(t, endpoint.Address, recreated.Address)

		listed := listedWebEndpoint(t, compose, recreated.Address)
		assert.WithinRange(t, listed.CreatedAt, before.Add(-time.Second), after.Add(time.Second))
		assert.WithinDuration(t, listed.CreatedAt.Add(finiteTTL*time.Second), listed.ExpiresIn, time.Millisecond)

		requireDeviceAnswer(t, compose, recreated.FullAddress, "/")
	})

	t.Run("re-creating an endpoint idle past its TTL answers again", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, withTTL(finiteTTL))
		requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")
		compose.MoveWebEndpointLastAccessBack(t, endpoint.Address, (finiteTTL+1)*time.Second)
		requireEndpointRefused(t, compose, endpoint.FullAddress)

		recreated := compose.CreateWebEndpoint(t, withTTL(finiteTTL))
		require.Equal(t, endpoint.Address, recreated.Address)

		requireDeviceAnswer(t, compose, recreated.FullAddress, "/")
	})

	t.Run("re-creating an endpoint takes the TTL it is re-created with", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, withTTL(finiteTTL))

		recreated := compose.CreateWebEndpoint(t, withTTL(-1))
		require.Equal(t, endpoint.Address, recreated.Address)
		assert.Equal(t, -1, recreated.TTL)
		assert.True(t, listedWebEndpoint(t, compose, recreated.Address).ExpiresIn.IsZero())

		compose.AgeWebEndpoint(t, recreated.Address, (finiteTTL+1)*time.Second)
		requireDeviceAnswer(t, compose, recreated.FullAddress, "/")
	})

	t.Run("an expired endpoint can be deleted", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, withTTL(finiteTTL))
		requireRefusedOnceCreatedPastTTL(t, compose, endpoint)

		resp, err := compose.DeleteWebEndpoint(t.Context(), endpoint.Address)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		for _, listed := range compose.ListWebEndpoints(t) {
			assert.NotEqual(t, endpoint.Address, listed.Address)
		}
	})
}

func testTunnelAPI(t *testing.T, compose *environment.DockerCompose, device *models.Device, service deviceService) {
	t.Helper()

	resp, err := compose.PostTunnel(t.Context(), device.UID, environment.TunnelCreate{Host: deviceServiceHost, Port: service.httpPort, TTL: -1})
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	tunnels := compose.ListTunnels(t, device.UID)
	require.Len(t, tunnels, 1)

	tunnel := tunnels[0]
	t.Cleanup(func() { _, _ = compose.DeleteTunnel(context.WithoutCancel(t.Context()), device.UID, tunnel.Address) })

	assert.Regexp(t, webEndpointAddress, tunnel.Address)
	assert.Equal(t, tunnel.Address+"."+webEndpointDomain, tunnel.FullAddress)
	assert.Equal(t, device.UID, tunnel.Device)
	assert.Equal(t, deviceServiceHost, tunnel.Host)
	assert.Equal(t, service.httpPort, tunnel.Port)
	assert.Equal(t, -1, tunnel.TTL)

	assert.Equal(t, service.name, requireDeviceAnswer(t, compose, tunnel.FullAddress, "/").Name)

	resp, err = compose.DeleteTunnel(t.Context(), device.UID, tunnel.Address)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	assert.Empty(t, compose.ListTunnels(t, device.UID))
	requireEndpointRefused(t, compose, tunnel.FullAddress)
}

func testWebEndpointAnotherNamespace(t *testing.T, ctx context.Context, compose *environment.DockerCompose, device *models.Device, service deviceService) {
	t.Helper()

	endpoint := compose.CreateWebEndpoint(t, service.plain(device.UID, -1))
	requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")

	owner := compose.AuthUser(t, ShellHubUsername, ShellHubPassword)

	compose.NewUser(t, intruderUsername, "intruder@ossystems.com.br", ShellHubPassword)
	compose.NewNamespace(t, intruderUsername, intruderNamespaceName, intruderNamespace)
	compose.JWT(compose.AuthUser(t, intruderUsername, ShellHubPassword).Token)
	t.Cleanup(func() { compose.JWT(owner.Token) })

	intruderKey := compose.CreateProvisioningKey(t, &requests.CreateProvisioningKey{Name: "intruder", Mode: string(models.ProvisioningKeyModeManual)})

	_, foreign := startAcceptedAgent(t, ctx, compose,
		NewAgentContainerWithProvisioningKey(intruderKey.Key),
		NewAgentContainerWithIdentity("020000002203"),
		NewAgentContainerWithHostname("intruding"),
	)

	resp, err := compose.DeleteTunnel(t.Context(), foreign.UID, endpoint.Address)
	require.NoError(t, err)
	assert.Equal(t, http.StatusNotFound, resp.StatusCode(), "deleting through a device of its own: %s", resp.String())

	resp, err = compose.DeleteWebEndpoint(t.Context(), endpoint.Address)
	require.NoError(t, err)
	assert.Equal(t, http.StatusNotFound, resp.StatusCode(), "deleting by address: %s", resp.String())

	assert.Equal(t, service.name, requireDeviceAnswer(t, compose, endpoint.FullAddress, "/").Name)
}

func testWebEndpointDeviceMerge(t *testing.T, ctx context.Context, compose *environment.DockerCompose, service deviceService) {
	t.Helper()

	same := []NewAgentContainerOption{NewAgentContainerWithIdentity("020000002202"), NewAgentContainerWithHostname("merging")}

	oldAgent, old := startAcceptedAgent(t, ctx, compose, same...)

	endpoint := compose.CreateWebEndpoint(t, service.plain(old.UID, -1))
	requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")

	require.NoError(t, oldAgent.Stop(ctx, nil))
	compose.AwaitDeviceOffline(t, old.UID)

	_, merged := startAcceptedAgent(t, ctx, compose, same...)
	require.NotEqual(t, old.UID, merged.UID)

	assert.Equal(t, merged.UID, listedWebEndpoint(t, compose, endpoint.Address).DeviceUID)
	assert.Equal(t, service.name, requireDeviceAnswer(t, compose, endpoint.FullAddress, "/").Name)
}

func testWebEndpointDeviceLeaving(t *testing.T, ctx context.Context, compose *environment.DockerCompose, service deviceService) {
	t.Helper()

	agent, device := startAcceptedAgent(t, ctx, compose, NewAgentContainerWithIdentity("020000002204"), NewAgentContainerWithHostname("leaving"))

	endpoint := compose.CreateWebEndpoint(t, service.plain(device.UID, -1))
	requireDeviceAnswer(t, compose, endpoint.FullAddress, "/")

	require.NoError(t, agent.Stop(ctx, nil))
	compose.AwaitDeviceOffline(t, device.UID)

	removed := t.Run("an endpoint whose device was removed stays listed and is refused", func(t *testing.T) {
		compose.DeleteDevice(t, device.UID)
		require.Equal(t, models.DeviceStatusRemoved, requireDevice(t, compose, device.UID).Status)

		listedWebEndpoint(t, compose, endpoint.Address)
		requireEndpointRefused(t, compose, endpoint.FullAddress)
	})
	if !removed {
		return
	}

	pending := t.Run("an endpoint whose removed device is back to pending is refused", func(t *testing.T) {
		require.NoError(t, agent.Start(ctx))
		awaitDeviceStatus(t, compose, device.UID, models.DeviceStatusPending)

		requireEndpointRefused(t, compose, endpoint.FullAddress)
	})
	if !pending {
		return
	}

	rejected := t.Run("an endpoint whose pending device was rejected is refused", func(t *testing.T) {
		compose.UpdateDeviceStatus(t, device.UID, environment.DeviceActionReject)
		require.Equal(t, models.DeviceStatusRejected, requireDevice(t, compose, device.UID).Status)

		requireEndpointRefused(t, compose, endpoint.FullAddress)
	})
	if !rejected {
		return
	}

	t.Run("deleting a rejected device deletes its endpoints", func(t *testing.T) {
		require.NoError(t, agent.Stop(ctx, nil))
		listedWebEndpoint(t, compose, endpoint.Address)
		compose.DeleteDevice(t, device.UID)

		_, resp, err := compose.GetDevice(t.Context(), device.UID)
		require.NoError(t, err)
		require.Equal(t, http.StatusNotFound, resp.StatusCode(), resp.String())

		for _, listed := range compose.ListWebEndpoints(t) {
			assert.NotEqual(t, endpoint.Address, listed.Address)
		}

		requireEndpointRefused(t, compose, endpoint.FullAddress)
	})
}

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

	refusals := []struct {
		description string
		param       string
		value       string
		reason      error
	}{
		{description: "a filter that is not encoded JSON", param: "filter", value: "not a filter", reason: query.ErrFilterInvalid},
		{description: "a filter on a field it does not take", param: "filter", value: propertyFilter(t, "port", "eq", 8001), reason: query.ErrFilterPropertyInvalid},
		{description: "a filter with an operator its field does not take", param: "filter", value: propertyFilter(t, "device_id", "contains", "0"), reason: query.ErrFilterPropertyInvalid},
		{description: "a sort on a field it does not take", param: "sort_by", value: "device_id", reason: query.ErrSorterFieldInvalid},
	}

	for _, tc := range refusals {
		t.Run("a list with "+tc.description+" is refused", func(t *testing.T) {
			var refusal responses.Error

			resp, err := compose.R(t.Context()).SetQueryParam(tc.param, tc.value).SetError(&refusal).Get("/api/web-endpoints")
			require.NoError(t, err)
			require.Equal(t, http.StatusBadRequest, resp.StatusCode(), resp.String())
			assert.Equal(t, map[string]string{tc.param: tc.reason.Error()}, refusal.Fields)
		})
	}
}

func testWebEndpointRoles(t *testing.T, compose *environment.DockerCompose, device *models.Device, service deviceService) {
	t.Helper()

	endpoint := compose.CreateWebEndpoint(t, service.plain(device.UID, -1))
	unused := environment.WebEndpointCreate{UID: device.UID, Host: deviceServiceHost, Port: 9001, TTL: -1}

	requests := []struct {
		method string
		path   string
		body   any
	}{
		{method: http.MethodPost, path: "/api/web-endpoints", body: unused},
		{method: http.MethodPost, path: "/api/devices/" + device.UID + "/tunnels", body: environment.TunnelCreate{Host: unused.Host, Port: unused.Port, TTL: unused.TTL}},
		{method: http.MethodDelete, path: "/api/web-endpoints/" + endpoint.Address},
		{method: http.MethodDelete, path: "/api/devices/" + device.UID + "/tunnels/" + endpoint.Address},
	}

	for _, role := range []authorizer.Role{authorizer.RoleOperator, authorizer.RoleObserver} {
		t.Run("a member with the "+string(role)+" role can neither create nor delete an endpoint", func(t *testing.T) {
			member := newMember(t, compose, "web-"+string(role), role)
			listed := len(compose.ListWebEndpoints(t))

			for _, req := range requests {
				resp, err := compose.Anonymous(t.Context()).SetAuthToken(member.Token).SetBody(req.body).Execute(req.method, req.path)
				require.NoError(t, err)
				assert.Equal(t, http.StatusForbidden, resp.StatusCode(), "%s %s: %s", req.method, req.path, resp.String())
			}

			assert.Len(t, compose.ListWebEndpoints(t), listed)
			assert.Equal(t, service.name, requireDeviceAnswer(t, compose, endpoint.FullAddress, "/").Name)
		})
	}

	t.Run("a member without the device connect permission cannot read an endpoint's address", func(t *testing.T) {
		require.False(t, authorizer.RoleObserver.HasPermission(authorizer.DeviceConnect))

		member := newMember(t, compose, "web-reader", authorizer.RoleObserver)

		for _, path := range []string{"/api/web-endpoints", "/api/devices/" + device.UID + "/tunnels"} {
			resp, err := compose.Anonymous(t.Context()).SetAuthToken(member.Token).Get(path)
			require.NoError(t, err)
			assert.Equal(t, http.StatusForbidden, resp.StatusCode(), "GET %s: %s", path, resp.String())
		}
	})

	t.Run("a member with the device connect permission reads an endpoint's address", func(t *testing.T) {
		require.True(t, authorizer.RoleOperator.HasPermission(authorizer.DeviceConnect))

		member := newMember(t, compose, "web-connector", authorizer.RoleOperator)

		assert.True(t, slices.ContainsFunc(compose.ListWebEndpointsAs(t, member.Token), func(e environment.WebEndpoint) bool {
			return e.Address == endpoint.Address
		}))
		assert.True(t, slices.ContainsFunc(compose.ListTunnelsAs(t, device.UID, member.Token), func(e environment.Tunnel) bool {
			return e.Address == endpoint.Address
		}))
	})
}

func awaitDeviceStatus(t *testing.T, compose *environment.DockerCompose, uid string, status models.DeviceStatus) {
	t.Helper()

	require.EventuallyWithT(t, func(tt *assert.CollectT) {
		device, resp, err := compose.GetDevice(t.Context(), uid)
		if assert.NoError(tt, err) && assert.Equal(tt, http.StatusOK, resp.StatusCode()) {
			assert.Equal(tt, status, device.Status)
		}
	}, 30*time.Second, time.Second)
}

func testWebEndpointConnector(t *testing.T, ctx context.Context, compose *environment.DockerCompose) {
	t.Helper()

	label := "web-" + randomHex(t)
	containerService := deviceService{name: "container", httpPort: 8006, tlsPort: 8446}
	startDeviceServiceOnStackNetwork(t, compose, containerService, map[string]string{connectorLabel: label})
	startConnector(t, ctx, compose, connectorLabel+"="+label)

	device := awaitContainerWithStatus(t, compose, models.DeviceStatusPending)
	compose.UpdateDeviceStatus(t, device.UID, environment.DeviceActionAccept)
	compose.AwaitDeviceOnline(t, device.UID)

	t.Run("an endpoint for a container's loopback reaches the service on the container's address", func(t *testing.T) {
		endpoint := compose.CreateWebEndpoint(t, containerService.plain(device.UID, -1))

		assert.Equal(t, containerService.name, requireDeviceAnswer(t, compose, endpoint.FullAddress, "/").Name)
	})
}

func startDeviceService(t *testing.T, compose *environment.DockerCompose, service deviceService) deviceService {
	t.Helper()

	relay := compose.Service(environment.ServiceRelay).GetContainerID()

	startDeviceHTTP(t, service, deviceServiceHost, func(req *testcontainers.ContainerRequest) {
		req.HostConfigModifier = func(hc *container.HostConfig) {
			hc.NetworkMode = container.NetworkMode("container:" + relay)
		}
	})

	return service
}

func startDeviceServiceOnStackNetwork(t *testing.T, compose *environment.DockerCompose, service deviceService, labels map[string]string) string {
	t.Helper()

	network := stackNetwork(t, compose)

	c := startDeviceHTTP(t, service, "0.0.0.0", func(req *testcontainers.ContainerRequest) {
		req.Networks = []string{network}
		maps.Copy(req.Labels, labels)
	})

	ip, err := c.ContainerIP(t.Context())
	require.NoError(t, err)

	return ip
}

func startDeviceHTTP(t *testing.T, service deviceService, listenHost string, place func(*testcontainers.ContainerRequest)) testcontainers.Container {
	t.Helper()

	buildArgs, err := testimage.BuildArgs("..")
	require.NoError(t, err)

	req := testcontainers.ContainerRequest{
		FromDockerfile: testcontainers.FromDockerfile{
			Repo:      "device-http",
			Tag:       "test",
			Context:   "cmd/device-http",
			KeepImage: true,
			BuildArgs: buildArgs,
		},
		Env: map[string]string{
			"NAME":         service.name,
			"HTTP_ADDRESS": listenHost + ":" + strconv.Itoa(service.httpPort),
			"TLS_ADDRESS":  listenHost + ":" + strconv.Itoa(service.tlsPort),
			"TLS_DOMAIN":   deviceTLSDomain,
		},
		Labels:     run.Labels(),
		WaitingFor: wait.ForLog("listening"),
	}
	place(&req)

	c, err := testcontainers.GenericContainer(t.Context(), testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
		Logger:           log.New(io.Discard, "", log.LstdFlags),
	})
	require.NoError(t, err)

	t.Cleanup(func() { _ = c.Terminate(context.WithoutCancel(t.Context())) })

	return c
}

func gatewayAuthority(t *testing.T, compose *environment.DockerCompose) *x509.Certificate {
	t.Helper()

	var data []byte

	require.EventuallyWithT(t, func(tt *assert.CollectT) {
		reader, err := compose.Service(environment.ServiceServer).CopyFileFromContainer(t.Context(), gatewayRootCertificate)
		if !assert.NoError(tt, err) {
			return
		}

		defer reader.Close() //nolint:errcheck // a failed read is retried; the close changes nothing

		data, err = io.ReadAll(reader)
		assert.NoError(tt, err)
	}, 30*time.Second, time.Second)

	block, _ := pem.Decode(data)
	require.NotNil(t, block, "the gateway's root certificate is not PEM")

	certificate, err := x509.ParseCertificate(block.Bytes)
	require.NoError(t, err)

	return certificate
}

func listedWebEndpoint(t *testing.T, compose *environment.DockerCompose, address string) environment.WebEndpoint {
	t.Helper()

	for _, endpoint := range compose.ListWebEndpoints(t) {
		if endpoint.Address == address {
			return endpoint
		}
	}

	require.Failf(t, "the web endpoint is not listed", "address %s", address)

	return environment.WebEndpoint{}
}

func requireCreateRefused(t *testing.T, compose *environment.DockerCompose, req environment.WebEndpointCreate, status int) {
	t.Helper()

	listed := len(compose.ListWebEndpoints(t))

	resp, err := compose.PostWebEndpoint(t.Context(), req, nil)
	require.NoError(t, err)
	require.Equal(t, status, resp.StatusCode(), resp.String())
	assert.Len(t, compose.ListWebEndpoints(t), listed, "a refused create left an endpoint behind")
}

func visitWebEndpoint(t *testing.T, compose *environment.DockerCompose, host, uri string) *environment.WebEndpointResponse {
	t.Helper()

	resp, err := compose.VisitWebEndpoint(t.Context(), host, uri)
	require.NoError(t, err)

	return resp
}

func requireDeviceAnswer(t *testing.T, compose *environment.DockerCompose, host, uri string) deviceSeen {
	t.Helper()

	resp := visitWebEndpoint(t, compose, host, uri)
	require.Equal(t, http.StatusOK, resp.StatusCode, resp.Body)

	seen := deviceSeen{}
	require.NoError(t, json.Unmarshal([]byte(resp.Body), &seen), resp.Body)

	return seen
}

func requireEndpointRefused(t *testing.T, compose *environment.DockerCompose, host string) {
	t.Helper()

	resp := visitWebEndpoint(t, compose, host, "/")
	require.Equal(t, http.StatusForbidden, resp.StatusCode, resp.Body)
	assert.Contains(t, resp.Body, webEndpointNotFound)
}

func requireRefusedOnceCreatedPastTTL(t *testing.T, compose *environment.DockerCompose, endpoint environment.WebEndpoint) {
	t.Helper()

	compose.AgeWebEndpoint(t, endpoint.Address, time.Duration(endpoint.TTL+1)*time.Second)

	requireEndpointRefused(t, compose, endpoint.FullAddress)
}