Ctrl K
ComunidadGuías…

tests/api_key_test.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"net/http"
	"slices"
	"testing"
	"time"

	"github.com/go-resty/resty/v2"
	"github.com/shellhub-io/shellhub/pkg/api/authorizer"
	"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/uuid"
	"github.com/shellhub-io/shellhub/tests/environment"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestRoutesThatRefuseAPIKeys(t *testing.T) {
	t.Parallel()

	compose := environment.New(t, run).Up(t.Context())
	t.Cleanup(compose.Down)

	compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword)
	compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace)

	auth := compose.AuthUser(t, ShellHubUsername, ShellHubPassword)
	compose.JWT(auth.Token)

	key := compose.CreateAPIKey(t, &requests.CreateAPIKey{
		Name:      "automation",
		ExpiresAt: -1,
		OptRole:   authorizer.RoleAdministrator,
	})

	t.Run("the key authenticates on a namespace route", func(t *testing.T) {
		resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices")
		require.NoError(t, err)
		assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	})

	cases := []struct {
		description string
		method      string
		path        string
	}{
		{
			description: "fetching the user token",
			method:      http.MethodGet,
			path:        "/api/auth/user",
		},
		{
			description: "switching to a namespace token",
			method:      http.MethodGet,
			path:        "/api/auth/token/" + ShellHubNamespace,
		},
		{
			description: "listing the namespaces",
			method:      http.MethodGet,
			path:        "/api/namespaces",
		},
		{
			description: "updating the user",
			method:      http.MethodPatch,
			path:        "/api/users",
		},
		{
			description: "updating the user through the deprecated route",
			method:      http.MethodPatch,
			path:        "/api/users/" + auth.ID + "/data",
		},
		{
			description: "changing the password through the deprecated route",
			method:      http.MethodPatch,
			path:        "/api/users/" + auth.ID + "/password",
		},
		{
			description: "listing the API keys",
			method:      http.MethodGet,
			path:        "/api/namespaces/api-key",
		},
		{
			description: "creating an API key",
			method:      http.MethodPost,
			path:        "/api/namespaces/api-key",
		},
		{
			description: "upgrading the namespace",
			method:      http.MethodPost,
			path:        "/api/namespaces/" + ShellHubNamespace + "/upgrade",
		},
		{
			description: "creating a provisioning key",
			method:      http.MethodPost,
			path:        "/api/namespaces/provisioning-key",
		},
		{
			description: "listing the provisioning keys",
			method:      http.MethodGet,
			path:        "/api/namespaces/provisioning-key",
		},
		{
			description: "updating a provisioning key",
			method:      http.MethodPatch,
			path:        "/api/namespaces/provisioning-key/any",
		},
		{
			description: "revealing a provisioning key",
			method:      http.MethodGet,
			path:        "/api/namespaces/provisioning-key/any/reveal",
		},
		{
			description: "reading a provisioning key's history",
			method:      http.MethodGet,
			path:        "/api/namespaces/provisioning-key/any/history",
		},
		{
			description: "generating an invitation link",
			method:      http.MethodPost,
			path:        "/api/namespaces/" + ShellHubNamespace + "/invitations/links",
		},
		{
			description: "accepting an invitation",
			method:      http.MethodPatch,
			path:        "/api/namespaces/" + ShellHubNamespace + "/invitations/accept",
		},
		{
			description: "resolving a device login code",
			method:      http.MethodGet,
			path:        "/api/devices/login-code/any",
		},
		{
			description: "accepting a device pairing",
			method:      http.MethodPost,
			path:        "/api/devices/pairing/any/accept",
		},
		{
			description: "reading an SSH approval",
			method:      http.MethodGet,
			path:        "/api/ssh-approvals/any",
		},
		{
			description: "confirming an SSH approval",
			method:      http.MethodPost,
			path:        "/api/ssh-approvals/any/confirm",
		},
		{
			description: "rejecting an SSH approval",
			method:      http.MethodPost,
			path:        "/api/ssh-approvals/any/reject",
		},
		{
			description: "renaming an API key",
			method:      http.MethodPatch,
			path:        "/api/namespaces/api-key/" + key.Name,
		},
		{
			description: "deleting an API key",
			method:      http.MethodDelete,
			path:        "/api/namespaces/api-key/" + key.Name,
		},
		{
			description: "revoking the user's tokens",
			method:      http.MethodDelete,
			path:        "/api/users/tokens",
		},
		{
			description: "leaving the namespace",
			method:      http.MethodDelete,
			path:        "/api/namespaces/" + ShellHubNamespace + "/members",
		},
	}

	for _, tc := range cases {
		t.Run("refuses "+tc.description, func(t *testing.T) {
			resp, err := withAPIKey(t, compose, key.Key).Execute(tc.method, tc.path)
			require.NoError(t, err)
			assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())
		})
	}
}

// TestEditionRoutesThatRefuseAPIKeys covers the routes the enterprise and cloud editions add that
// refuse a namespace API key whatever its role: creating a namespace, MFA, the vault,
// re-authenticating a web terminal, and each edition's own account routes, SAML re-authentication
// on enterprise and email confirmation, password recovery and account deletion on cloud. Each
// edition runs its own stack. The community routes are covered by [TestRoutesThatRefuseAPIKeys].
func TestEditionRoutesThatRefuseAPIKeys(t *testing.T) {
	t.Parallel()

	type route struct{ method, path string }

	shared := []route{
		{http.MethodPost, "/api/namespaces"},
		{http.MethodGet, "/api/user/mfa/generate"},
		{http.MethodPost, "/api/user/mfa/auth"},
		{http.MethodPut, "/api/user/mfa/enable"},
		{http.MethodPut, "/api/user/mfa/disable"},
		{http.MethodPut, "/api/user/mfa/recovery/disable"},
		{http.MethodPost, "/api/user/mfa/recover"},
		{http.MethodPost, "/api/user/mfa/reset"},
		{http.MethodPut, "/api/user/mfa/reset/any"},
		{http.MethodPost, "/api/web-terminal/reauth"},
		{http.MethodGet, "/api/vault"},
		{http.MethodPut, "/api/vault/meta"},
		{http.MethodPut, "/api/vault/data"},
		{http.MethodPut, "/api/vault/settings"},
		{http.MethodDelete, "/api/vault"},
	}

	editions := []struct {
		edition environment.Edition
		own     []route
	}{
		{
			edition: environment.EditionEnterprise,
			own:     []route{{http.MethodGet, "/api/user/saml/reauth"}},
		},
		{
			edition: environment.EditionCloud,
			own: []route{
				{http.MethodPost, "/api/user/resend_email"},
				{http.MethodPost, "/api/user/confirm_account"},
				{http.MethodPost, "/api/user/recover_password"},
				{http.MethodPost, "/api/user/any/update_password"},
				{http.MethodDelete, "/api/user"},
			},
		},
	}

	for _, tc := range editions {
		t.Run(string(tc.edition), func(t *testing.T) {
			t.Parallel()

			compose := environment.New(t, run).WithEdition(tc.edition).Up(t.Context())
			t.Cleanup(compose.Down)

			compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword)
			compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace)
			compose.JWT(compose.AuthUser(t, ShellHubUsername, ShellHubPassword).Token)

			key := compose.CreateAPIKey(t, &requests.CreateAPIKey{
				Name:      "automation",
				ExpiresAt: -1,
				OptRole:   authorizer.RoleAdministrator,
			})

			requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/devices", http.StatusOK)

			for _, r := range append(slices.Clone(shared), tc.own...) {
				t.Run("refuses "+r.method+" "+r.path, func(t *testing.T) {
					requireAnswer(t, withAPIKey(t, compose, key.Key), r.method, r.path, http.StatusForbidden)
				})
			}
		})
	}
}

// TestNamespaceAPIKeyAuthentication covers what a namespace API key can do once minted: manage the
// namespace's tags, act only within its role, keep working when it never expires, and stop working
// once its expiry passes. A role change and a deletion take effect on the key's next request, past
// the cached copy authentication keeps, and no two keys of a namespace share a name. The routes that
// refuse a key whatever its role are covered by [TestRoutesThatRefuseAPIKeys], and an instance key
// refused on a namespace route by [TestEnterpriseAdmin].
func TestNamespaceAPIKeyAuthentication(t *testing.T) {
	t.Parallel()

	compose := environment.New(t, run).Up(t.Context())
	t.Cleanup(compose.Down)

	compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword)
	compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace)

	compose.JWT(compose.AuthUser(t, ShellHubUsername, ShellHubPassword).Token)
	useManualProvisioningKey(t, compose)

	_, device := startAcceptedAgent(t, t.Context(), compose)

	deviceTags := func(t *testing.T) []string {
		t.Helper()

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

		return tagNames(current.Tags)
	}

	namespaceTags := func(t *testing.T, req *resty.Request) []string {
		t.Helper()

		tags := []models.Tag{}
		resp, err := req.SetResult(&tags).Get("/api/tags")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		return tagNames(tags)
	}

	t.Run("a key creates, lists, attaches, renames, detaches and deletes tags", func(t *testing.T) {
		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "tagger", ExpiresAt: -1, OptRole: authorizer.RoleOperator})

		resp, err := withAPIKey(t, compose, key.Key).SetBody(map[string]string{"name": "staging"}).Post("/api/tags")
		require.NoError(t, err)
		require.Equal(t, http.StatusCreated, resp.StatusCode(), resp.String())

		assert.Contains(t, namespaceTags(t, withAPIKey(t, compose, key.Key)), "staging")

		resp, err = withAPIKey(t, compose, key.Key).Post("/api/devices/" + device.UID + "/tags/staging")
		require.NoError(t, err)
		require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String())
		assert.Equal(t, []string{"staging"}, deviceTags(t))

		renamed := new(models.Tag)
		resp, err = withAPIKey(t, compose, key.Key).
			SetBody(map[string]string{"name": "production"}).
			SetResult(renamed).
			Patch("/api/tags/staging")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Equal(t, "production", renamed.Name)
		assert.Equal(t, ShellHubNamespace, renamed.TenantID)
		assert.True(t, renamed.UpdatedAt.After(renamed.CreatedAt))

		tagsAfterRename := namespaceTags(t, withAPIKey(t, compose, key.Key))
		assert.Contains(t, tagsAfterRename, "production")
		assert.NotContains(t, tagsAfterRename, "staging")
		assert.Equal(t, []string{"production"}, deviceTags(t))

		resp, err = withAPIKey(t, compose, key.Key).Delete("/api/devices/" + device.UID + "/tags/production")
		require.NoError(t, err)
		require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String())
		assert.Empty(t, deviceTags(t))

		resp, err = withAPIKey(t, compose, key.Key).Delete("/api/tags/production")
		require.NoError(t, err)
		require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String())

		assert.NotContains(t, namespaceTags(t, compose.R(t.Context())), "production")
	})

	t.Run("a key acts only within its role", func(t *testing.T) {
		listDevices := func(req *resty.Request) (*resty.Response, error) { return req.Get("/api/devices") }
		createTag := func(req *resty.Request) (*resty.Response, error) {
			return req.SetBody(map[string]string{"name": "role" + uuid.Generate()[:8]}).Post("/api/tags")
		}
		listAccessPolicies := func(req *resty.Request) (*resty.Response, error) { return req.Get("/api/access-policies") }

		cases := []struct {
			role                    authorizer.Role
			devices, tags, policies int
		}{
			{role: authorizer.RoleObserver, devices: http.StatusOK, tags: http.StatusForbidden, policies: http.StatusForbidden},
			{role: authorizer.RoleOperator, devices: http.StatusOK, tags: http.StatusCreated, policies: http.StatusForbidden},
			{role: authorizer.RoleAdministrator, devices: http.StatusOK, tags: http.StatusCreated, policies: http.StatusOK},
		}

		for _, tc := range cases {
			t.Run(tc.role.String(), func(t *testing.T) {
				key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: tc.role.String(), ExpiresAt: -1, OptRole: tc.role})
				require.Equal(t, tc.role, key.Role)

				for _, check := range []struct {
					request func(*resty.Request) (*resty.Response, error)
					want    int
				}{
					{request: listDevices, want: tc.devices},
					{request: createTag, want: tc.tags},
					{request: listAccessPolicies, want: tc.policies},
				} {
					resp, err := check.request(withAPIKey(t, compose, key.Key))
					require.NoError(t, err)
					assert.Equal(t, check.want, resp.StatusCode(), "%s %s: %s", resp.Request.Method, resp.Request.URL, resp.String())
				}
			})
		}
	})

	t.Run("a key that never expires is stored without an expiry and authenticates", func(t *testing.T) {
		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "forever", ExpiresAt: -1})
		require.Equal(t, int64(-1), key.ExpiresIn)

		assert.Equal(t, int64(-1), listedAPIKey(t, compose, "forever").ExpiresIn,
			"-1 is the marker for a key that never expires, not a time")

		resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices")
		require.NoError(t, err)
		assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	})

	t.Run("a deleted key stops authenticating at once and leaves the cache", func(t *testing.T) {
		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "deleted", ExpiresAt: -1})

		requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/devices", http.StatusOK)
		require.True(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "authentication did not cache the key")

		requireAnswer(t, compose.R(t.Context()), http.MethodDelete, "/api/namespaces/api-key/"+key.Name, http.StatusOK)

		assert.False(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "the deletion left the key cached")
		requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/devices", http.StatusUnauthorized)
	})

	t.Run("a role change takes effect on the key's next request", func(t *testing.T) {
		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "promoted", ExpiresAt: -1, OptRole: authorizer.RoleObserver})

		changeRole := func(t *testing.T, role authorizer.Role) {
			t.Helper()

			resp, err := compose.R(t.Context()).
				SetBody(map[string]string{"role": role.String()}).
				Patch("/api/namespaces/api-key/" + key.Name)
			require.NoError(t, err)
			require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
			assert.False(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "the role change left the key cached")
		}

		requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/access-policies", http.StatusForbidden)
		require.True(t, compose.HoldsCacheEntry(t, apiKeyCacheKey(key.Key)), "authentication did not cache the key")

		changeRole(t, authorizer.RoleAdministrator)
		requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/access-policies", http.StatusOK)

		changeRole(t, authorizer.RoleObserver)
		requireAnswer(t, withAPIKey(t, compose, key.Key), http.MethodGet, "/api/access-policies", http.StatusForbidden)
		assert.Equal(t, authorizer.RoleObserver, listedAPIKey(t, compose, key.Name).Role)
	})

	t.Run("a name another key holds is refused on create and on rename", func(t *testing.T) {
		compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "taken", ExpiresAt: -1})
		other := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "other", ExpiresAt: -1})

		resp, err := compose.R(t.Context()).
			SetBody(&requests.CreateAPIKey{Name: "taken", ExpiresAt: -1}).
			Post("/api/namespaces/api-key")
		require.NoError(t, err)
		assert.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String())

		resp, err = compose.R(t.Context()).
			SetBody(map[string]string{"name": "taken"}).
			Patch("/api/namespaces/api-key/" + other.Name)
		require.NoError(t, err)
		assert.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String())

		listedAPIKey(t, compose, other.Name)
		requireAnswer(t, withAPIKey(t, compose, other.Key), http.MethodGet, "/api/devices", http.StatusOK)
	})

	t.Run("a key is refused once its expiry passes", func(t *testing.T) {
		const ttl = 3 * time.Second

		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "expiring", ExpiresAt: 30})
		compose.ExpireAPIKeyIn(t, key.Name, ttl)

		resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		require.EventuallyWithT(t, func(tt *assert.CollectT) {
			resp, err := withAPIKey(t, compose, key.Key).Get("/api/devices")
			if !assert.NoError(tt, err) {
				return
			}

			assert.Equal(tt, http.StatusUnauthorized, resp.StatusCode(), resp.String())
		}, 3*ttl, 250*time.Millisecond)
	})
}

func TestAPIKeyRoleCap(t *testing.T) {
	t.Parallel()

	compose := environment.New(t, run).Up(t.Context())
	t.Cleanup(compose.Down)

	compose.NewUser(t, ShellHubUsername, ShellHubEmail, ShellHubPassword)
	compose.NewNamespace(t, ShellHubUsername, ShellHubNamespaceName, ShellHubNamespace)

	compose.JWT(compose.AuthUser(t, ShellHubUsername, ShellHubPassword).Token)

	requestKey := func(t *testing.T, req *resty.Request, name string, role authorizer.Role) *resty.Response {
		t.Helper()

		resp, err := req.SetBody(&requests.CreateAPIKey{Name: name, ExpiresAt: -1, OptRole: role}).Post("/api/namespaces/api-key")
		require.NoError(t, err)

		return resp
	}

	t.Run("an owner's key created without a role is an administrator key", func(t *testing.T) {
		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "defaulted", ExpiresAt: -1})
		assert.Equal(t, authorizer.RoleAdministrator, key.Role)
		assert.Equal(t, authorizer.RoleAdministrator, listedAPIKey(t, compose, key.Name).Role)

		resp, err := withAPIKey(t, compose, key.Key).Delete("/api/namespaces/" + ShellHubNamespace)
		require.NoError(t, err)
		assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())
	})

	t.Run("no key is created as owner", func(t *testing.T) {
		resp := requestKey(t, compose.R(t.Context()), "owner", authorizer.RoleOwner)
		assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())
	})

	t.Run("no key is raised to owner", func(t *testing.T) {
		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "raised", ExpiresAt: -1, OptRole: authorizer.RoleObserver})

		resp, err := compose.R(t.Context()).
			SetBody(map[string]string{"role": authorizer.RoleOwner.String()}).
			Patch("/api/namespaces/api-key/" + key.Name)
		require.NoError(t, err)
		assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())

		assert.Equal(t, authorizer.RoleObserver, listedAPIKey(t, compose, key.Name).Role)
	})

	t.Run("an administrator raises a key no higher than administrator", func(t *testing.T) {
		administrator := newMember(t, compose, "raiser", authorizer.RoleAdministrator)
		key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "raised-by-admin", ExpiresAt: -1, OptRole: authorizer.RoleObserver})

		raise := func(role authorizer.Role) *resty.Response {
			resp, err := asBearer(t, compose, administrator.Token).
				SetBody(map[string]string{"role": role.String()}).
				Patch("/api/namespaces/api-key/" + key.Name)
			require.NoError(t, err)

			return resp
		}

		resp := raise(authorizer.RoleOwner)
		assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())
		assert.Equal(t, authorizer.RoleObserver, listedAPIKey(t, compose, key.Name).Role)

		resp = raise(authorizer.RoleAdministrator)
		assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Equal(t, authorizer.RoleAdministrator, listedAPIKey(t, compose, key.Name).Role)
	})

	t.Run("an operator creates no key", func(t *testing.T) {
		operator := newMember(t, compose, "operator", authorizer.RoleOperator)

		resp := requestKey(t, asBearer(t, compose, operator.Token), "operator", "")
		assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())
	})

	t.Run("a key keeps its role after its creator is demoted", func(t *testing.T) {
		creator := newMember(t, compose, "demoted", authorizer.RoleAdministrator)

		key := new(responses.CreateAPIKey)
		resp := requestKey(t, asBearer(t, compose, creator.Token).SetResult(key), "demoted", "")
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		require.Equal(t, authorizer.RoleAdministrator, key.Role)

		resp, err := compose.R(t.Context()).
			SetBody(map[string]string{"role": authorizer.RoleOperator.String()}).
			Patch("/api/namespaces/" + ShellHubNamespace + "/members/" + creator.ID)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		assert.Equal(t, authorizer.RoleAdministrator, listedAPIKey(t, compose, key.Name).Role)

		resp, err = withAPIKey(t, compose, key.Key).Get("/api/access-policies")
		require.NoError(t, err)
		assert.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	})
}

func listedAPIKey(t *testing.T, compose *environment.DockerCompose, name string) models.APIKey {
	t.Helper()

	keys := []models.APIKey{}
	resp, err := compose.R(t.Context()).SetResult(&keys).Get("/api/namespaces/api-key")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	index := slices.IndexFunc(keys, func(k models.APIKey) bool { return k.Name == name })
	require.GreaterOrEqual(t, index, 0, "the key %s is not listed", name)

	return keys[index]
}

func apiKeyCacheKey(plaintext string) string {
	digest := sha256.Sum256([]byte(plaintext))

	return "api-key/unique-digest={" + hex.EncodeToString(digest[:]) + "}"
}

func withAPIKey(t *testing.T, compose *environment.DockerCompose, plaintext string) *resty.Request {
	t.Helper()

	return compose.Anonymous(t.Context()).SetHeader("X-API-Key", plaintext)
}