Ctrl K
ComunidadGuías…

tests/admin_test.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"encoding/csv"
	"encoding/json"
	"net/http"
	"strconv"
	"strings"
	"testing"

	"github.com/go-resty/resty/v2"
	"github.com/shellhub-io/shellhub/pkg/api/authorizer"
	"github.com/shellhub-io/shellhub/pkg/api/jwttoken"
	"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"
)

// TestEnterpriseAdmin drives the enterprise admin API as an instance administrator and as the
// callers it must turn away: the instance API keys an administrator mints, when they stop
// authenticating and the namespace and key-management routes that refuse them, the guard that
// keeps every other user off /admin/api, the user and namespace exports, the instance-wide
// statistics, the token that signs an administrator in as another user, and the writes an
// administrator makes to users and namespaces. The cases share one stack; each creates the users,
// namespaces and devices it reads.
func TestEnterpriseAdmin(t *testing.T) {
	t.Parallel()

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

	t.Run("instance API keys", func(t *testing.T) { testInstanceAPIKeys(t, compose) })
	t.Run("access guard", func(t *testing.T) { testAdminAccessGuard(t, compose) })
	t.Run("exports", func(t *testing.T) { testAdminExports(t, compose) })
	t.Run("stats", func(t *testing.T) { testAdminStats(t, compose) })
	t.Run("auth token", func(t *testing.T) { testAdminAuthToken(t, compose) })
	t.Run("user writes", func(t *testing.T) { testAdminUserWrites(t, compose) })
	t.Run("namespace writes", func(t *testing.T) { testAdminNamespaceWrites(t, compose) })
}

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

	t.Run("an instance key authenticates on the admin routes", func(t *testing.T) {
		key := createInstanceAPIKey(t, compose.R(t.Context()), "automation")

		for _, path := range []string{"/admin/api/users", "/admin/api/namespaces", "/admin/api/devices"} {
			requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, path, http.StatusOK)
		}

		requireAnswer(t, withAPIKey(t, compose, models.InstanceAPIKeyPrefix+uuid.Generate()), http.MethodGet, "/admin/api/users", http.StatusUnauthorized)
	})

	t.Run("an instance key is refused on a namespace route", func(t *testing.T) {
		key := createInstanceAPIKey(t, compose.R(t.Context()), "admin-only")

		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, "/api/devices", http.StatusUnauthorized)
		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, "/admin/api/users", http.StatusOK)
	})

	t.Run("an instance key cannot manage instance keys", func(t *testing.T) {
		key := createInstanceAPIKey(t, compose.R(t.Context()), "no-minting")

		requireAnswer(t, withAPIKey(t, compose, key.ID).SetBody(map[string]any{"name": "minted", "expires_at": 30}), http.MethodPost, "/admin/api/instance-api-keys", http.StatusForbidden)
		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, "/admin/api/instance-api-keys", http.StatusForbidden)
		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodDelete, "/admin/api/instance-api-keys/"+key.Name, http.StatusForbidden)

		assert.NotContains(t, instanceAPIKeyNames(t, compose), "minted")
		assert.Contains(t, instanceAPIKeyNames(t, compose), key.Name)
	})

	t.Run("a user who is not an instance administrator cannot create an instance key", func(t *testing.T) {
		compose.NewUser(t, "plain", "plain@shellhub.io", ShellHubPassword)
		user := compose.AuthUser(t, "plain", ShellHubPassword)

		resp, err := asBearer(t, compose, user.Token).
			SetBody(map[string]any{"name": "unprivileged", "expires_at": 30}).
			Post("/admin/api/instance-api-keys")
		require.NoError(t, err)
		require.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())

		assert.NotContains(t, instanceAPIKeyNames(t, compose), "unprivileged")
	})

	t.Run("an instance key stops authenticating once its creator is no longer an administrator", func(t *testing.T) {
		compose.NewAdmin(t, "keeper", "keeper@shellhub.io", ShellHubPassword)
		keeper := compose.AuthUser(t, "keeper", ShellHubPassword)

		key := createInstanceAPIKey(t, asBearer(t, compose, keeper.Token), "keeper-automation")
		require.Equal(t, keeper.ID, key.CreatedBy)
		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, "/admin/api/users", http.StatusOK)

		resp, err := compose.R(t.Context()).
			SetBody(map[string]any{"admin": false}).
			Put("/admin/api/users/" + keeper.ID)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, "/admin/api/users", http.StatusUnauthorized)
	})

	t.Run("an expired instance key is refused", func(t *testing.T) {
		key := createInstanceAPIKey(t, compose.R(t.Context()), "expiring")
		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, "/admin/api/users", http.StatusOK)

		compose.ExpireInstanceAPIKey(t, key.Name)

		requireAnswer(t, withAPIKey(t, compose, key.ID), http.MethodGet, "/admin/api/users", http.StatusUnauthorized)
	})
}

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

	compose.NewUser(t, "outsider", "outsider@shellhub.io", ShellHubPassword)
	outsider := compose.AuthUser(t, "outsider", ShellHubPassword)

	t.Run("an administrator's token reaches the admin routes", func(t *testing.T) {
		requireAnswer(t, compose.R(t.Context()), http.MethodGet, "/admin/api/users", http.StatusOK)
	})

	t.Run("a token without the administrator flag is refused on the admin routes", func(t *testing.T) {
		for _, path := range []string{"/admin/api/users", "/admin/api/stats", "/admin/api/export/users"} {
			requireAnswer(t, asBearer(t, compose, outsider.Token), http.MethodGet, path, http.StatusForbidden)
		}
	})

	t.Run("a forged administrator header does not let a token without the flag through", func(t *testing.T) {
		requireAnswer(t, asBearer(t, compose, outsider.Token).SetHeader("X-Admin", "true"), http.MethodGet, "/admin/api/users", http.StatusForbidden)
	})
}

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

	compose.NewUser(t, "export-alpha", "alpha@export.shellhub.io", ShellHubPassword)
	compose.NewUser(t, "export-beta", "beta@export.shellhub.io", ShellHubPassword)

	tenant := uuid.Generate()
	compose.NewNamespace(t, "export-alpha", "export-space", tenant)

	alpha := adminUser(t, compose, "export-alpha")
	beta := adminUser(t, compose, "export-beta")

	t.Run("the user export lists every user with the namespaces each owns", func(t *testing.T) {
		header, rows := exportCSV(t, compose, "/admin/api/export/users", "users.csv", "")

		assert.Equal(t, []string{"Name", "email", "username", "ID", "namespaces"}, header)
		assert.ElementsMatch(t, usernames(adminUsers(t, compose)), column(rows, 2))
		assert.Contains(t, rows, []string{alpha.Name, alpha.Email, alpha.Username, alpha.ID, "1"})
		assert.Contains(t, rows, []string{beta.Name, beta.Email, beta.Username, beta.ID, "0"})
	})

	t.Run("a filter narrows the user export to the users it matches", func(t *testing.T) {
		header, rows := exportCSV(t, compose, "/admin/api/export/users", "users.csv", propertyFilter(t, "username", "contains", "export-"))

		assert.Equal(t, []string{"Name", "email", "username", "ID", "namespaces"}, header)
		assert.ElementsMatch(t, [][]string{
			{alpha.Name, alpha.Email, alpha.Username, alpha.ID, "1"},
			{beta.Name, beta.Email, beta.Username, beta.ID, "0"},
		}, rows)
	})

	t.Run("a user export no user matches answers 204 without a file", func(t *testing.T) {
		requireEmptyExport(t, compose, "/admin/api/export/users", propertyFilter(t, "username", "eq", "nobody"))
	})

	t.Run("the namespace export lists every namespace with its owner", func(t *testing.T) {
		namespace := adminNamespace(t, compose, tenant)

		header, rows := exportCSV(t, compose, "/admin/api/export/namespaces", "namespaces.csv", "")

		assert.Equal(t, []string{"Namespace", "Owner Name", "Owner Username", "Email", "tenantID", "Type"}, header)
		assert.ElementsMatch(t, tenants(adminNamespaces(t, compose)), column(rows, 4))
		assert.Contains(t, rows, []string{"export-space", alpha.Name, alpha.Username, alpha.Email, tenant, string(namespace.Type)})
	})

	t.Run("a namespace export no namespace matches answers 204 without a file", func(t *testing.T) {
		requireEmptyExport(t, compose, "/admin/api/export/namespaces", propertyFilter(t, "name", "eq", "nowhere"))
	})
}

type adminStats struct {
	models.Stats
	RegisteredUsers int64 `json:"registered_users"`
}

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

	t.Run("the stats count users, devices and sessions across every namespace", func(t *testing.T) {
		ctx := t.Context()
		before := readAdminStats(t, compose)

		_, connected := startAcceptedAgent(t, ctx, compose)
		conn, session := openSession(t, ctx, compose, connected, registerDeviceKey(t, ctx, compose), "")
		requireSessionActive(t, ctx, compose, session.UID, true)

		compose.NewUser(t, "stats-owner", "stats-owner@shellhub.io", ShellHubPassword)
		tenant := uuid.Generate()
		compose.NewLegacyNamespace(t, "stats-owner", "stats-space", tenant)
		owner := compose.AuthUser(t, "stats-owner", ShellHubPassword)

		enrollPendingDevice(t, compose, "stats-pending", "02:00:00:00:24:01")
		enrollPendingDeviceAs(t, compose, owner, "stats-other-pending", "02:00:00:00:24:02")

		accepted := enrollPendingDeviceAs(t, compose, owner, "stats-accepted", "02:00:00:00:24:03")
		requireAnswer(t, asBearer(t, compose, owner.Token), http.MethodPatch, "/api/devices/"+accepted.UID+"/"+string(environment.DeviceActionAccept), http.StatusOK)

		rejected := enrollPendingDeviceAs(t, compose, owner, "stats-rejected", "02:00:00:00:24:04")
		requireAnswer(t, asBearer(t, compose, owner.Token), http.MethodPatch, "/api/devices/"+rejected.UID+"/"+string(environment.DeviceActionReject), http.StatusOK)

		after := readAdminStats(t, compose)

		assert.Equal(t, before.RegisteredUsers+1, after.RegisteredUsers, "registered users")
		assert.Equal(t, before.PendingDevices+2, after.PendingDevices, "pending devices")
		assert.Equal(t, before.RegisteredDevices+2, after.RegisteredDevices, "registered devices")
		assert.Equal(t, before.RejectedDevices+1, after.RejectedDevices, "rejected devices")
		assert.Equal(t, before.OnlineDevices+1, after.OnlineDevices, "online devices: only the agent holding a tunnel")
		assert.Equal(t, before.ActiveSessions+1, after.ActiveSessions, "active sessions")

		require.NoError(t, conn.Close())
		requireSessionActive(t, ctx, compose, session.UID, false)

		assert.Equal(t, before.ActiveSessions, readAdminStats(t, compose).ActiveSessions, "active sessions once the session ended")
	})
}

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

	t.Run("an administrator gets a token that signs in as the user", func(t *testing.T) {
		compose.NewUser(t, "impersonated", "impersonated@shellhub.io", ShellHubPassword)
		tenant := uuid.Generate()
		compose.NewNamespace(t, "impersonated", "impersonated-space", tenant)
		target := adminUser(t, compose, "impersonated")

		token := struct {
			Token string `json:"token"`
		}{}

		resp, err := compose.R(t.Context()).SetResult(&token).Get("/admin/api/auth/token/" + target.ID)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		claims, err := jwttoken.ClaimsFromBearerToken(compose.APIPublicKey(t), token.Token)
		require.NoError(t, err)
		userClaims, ok := claims.(*authorizer.UserClaims)
		require.True(t, ok, "the token carries %T, not user claims", claims)
		assert.Equal(t, target.ID, userClaims.ID)
		assert.Equal(t, tenant, userClaims.TenantID)

		signedIn := new(models.UserAuthResponse)

		resp, err = asBearer(t, compose, token.Token).SetResult(signedIn).Get("/api/auth/user")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Equal(t, target.ID, signedIn.ID)
		assert.Equal(t, "impersonated", signedIn.User)
		require.NotNil(t, signedIn.Tenant)
		assert.Equal(t, tenant, *signedIn.Tenant)
	})

	t.Run("a session signed in by an administrator is marked impersonated and cannot answer the onboarding survey", func(t *testing.T) {
		compose.NewUser(t, "impersonatedsurvey", "impersonatedsurvey@shellhub.io", ShellHubPassword)
		target := adminUser(t, compose, "impersonatedsurvey")

		token := struct {
			Token string `json:"token"`
		}{}

		resp, err := compose.R(t.Context()).SetResult(&token).Get("/admin/api/auth/token/" + target.ID)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		signedIn := new(models.UserAuthResponse)
		resp, err = asBearer(t, compose, token.Token).SetResult(signedIn).Get("/api/auth/user")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.True(t, signedIn.Impersonated, "the administrator's session")

		renewed := new(models.UserAuthResponse)
		resp, err = asBearer(t, compose, signedIn.Token).SetResult(renewed).Get("/api/auth/user")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.True(t, renewed.Impersonated, "a token renewed from the administrator's session")

		resp, err = recordOnboardingSurvey(asBearer(t, compose, token.Token), "skipped")
		require.NoError(t, err)
		assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())

		own := compose.AuthUser(t, "impersonatedsurvey", ShellHubPassword)
		assert.False(t, own.Impersonated, "the user's own sign-in")
		assert.Equal(t, models.UserOnboardingSurveyPending, own.OnboardingSurvey, "the administrator left the user's survey untouched")
	})

	t.Run("the token an impersonated session gets back on leaving its namespace stays impersonated", func(t *testing.T) {
		compose.NewUser(t, "impersonatedowner", "impersonatedowner@shellhub.io", ShellHubPassword)
		tenant := uuid.Generate()
		compose.NewNamespace(t, "impersonatedowner", "impersonated-leave", tenant)
		compose.NewUser(t, "impersonatedleaver", "impersonatedleaver@shellhub.io", ShellHubPassword)
		compose.NewMember(t, "impersonatedleaver", "impersonated-leave", string(authorizer.RoleObserver))
		target := adminUser(t, compose, "impersonatedleaver")

		token := struct {
			Token string `json:"token"`
		}{}

		resp, err := compose.R(t.Context()).SetResult(&token).Get("/admin/api/auth/token/" + target.ID)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

		left := new(models.UserAuthResponse)
		resp, err = asBearer(t, compose, token.Token).SetResult(left).Delete("/api/namespaces/" + tenant + "/members")
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.True(t, left.Impersonated, "the replacement token's session")

		resp, err = recordOnboardingSurvey(asBearer(t, compose, left.Token), "skipped")
		require.NoError(t, err)
		assert.Equal(t, http.StatusForbidden, resp.StatusCode(), resp.String())
	})

	t.Run("a token for a user that does not exist is refused as not found", func(t *testing.T) {
		requireAnswer(t, compose.R(t.Context()), http.MethodGet, "/admin/api/auth/token/"+uuid.Generate(), http.StatusNotFound)
	})
}

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

	t.Run("an administrator creates a user who can sign in", func(t *testing.T) {
		createUserAsAdmin(t, compose, "created")

		created := adminUser(t, compose, "created")
		assert.Equal(t, "created@admin.shellhub.io", created.Email)
		assert.Equal(t, models.UserStatusConfirmed, created.Status)

		signedIn := compose.AuthUser(t, "created", ShellHubPassword)
		assert.Equal(t, created.ID, signedIn.ID)
		assert.Equal(t, "created", signedIn.User)
	})

	t.Run("creating a user whose username or email is taken is refused", func(t *testing.T) {
		createUserAsAdmin(t, compose, "taken")

		sameUsername := newAdminUserBody("taken")
		sameUsername["email"] = "other-taken@admin.shellhub.io"

		sameEmail := newAdminUserBody("other-taken")
		sameEmail["email"] = "taken@admin.shellhub.io"

		for field, body := range map[string]map[string]any{"username": sameUsername, "email": sameEmail} {
			resp, err := compose.R(t.Context()).SetBody(body).Post("/admin/api/users")
			require.NoError(t, err)
			require.Equal(t, http.StatusConflict, resp.StatusCode(), resp.String())

			conflicts := []string{}
			require.NoError(t, json.Unmarshal(resp.Body(), &conflicts), resp.String())
			assert.Equal(t, []string{field}, conflicts)
		}

		assert.NotContains(t, usernames(adminUsers(t, compose)), "other-taken")
		assert.Equal(t, "taken@admin.shellhub.io", adminUser(t, compose, "taken").Email)
	})

	t.Run("an update that names only some fields keeps the others", func(t *testing.T) {
		createUserAsAdmin(t, compose, "partial")
		before := adminUser(t, compose, "partial")

		requireAnswer(t, compose.R(t.Context()).SetBody(map[string]any{"max_namespaces": 7}), http.MethodPut, "/admin/api/users/"+before.ID, http.StatusOK)

		after := adminUser(t, compose, "partial")
		assert.Equal(t, 7, after.MaxNamespaces)
		assert.Equal(t, before.UserData, after.UserData)
		assert.Equal(t, before.Status, after.Status)
		assert.Equal(t, before.Admin, after.Admin)
		assert.Equal(t, before.ID, compose.AuthUser(t, "partial", ShellHubPassword).ID)

		requireAnswer(t, compose.R(t.Context()).SetBody(map[string]any{"password": "partial-replaced"}), http.MethodPut, "/admin/api/users/"+before.ID, http.StatusOK)

		after = adminUser(t, compose, "partial")
		assert.Equal(t, before.UserData, after.UserData)
		assert.Equal(t, 7, after.MaxNamespaces)
		assert.Equal(t, before.ID, compose.AuthUser(t, "partial", "partial-replaced").ID)
	})

	t.Run("a password an administrator sets refuses the user's earlier tokens", func(t *testing.T) {
		createUserAsAdmin(t, compose, "reset")
		earlier := compose.AuthUser(t, "reset", ShellHubPassword)
		requireAnswer(t, asBearer(t, compose, earlier.Token), http.MethodGet, "/api/auth/user", http.StatusOK)

		requireAnswer(t, compose.R(t.Context()).SetBody(map[string]any{"password": "reset-by-admin"}), http.MethodPut, "/admin/api/users/"+earlier.ID, http.StatusOK)

		requireAnswer(t, asBearer(t, compose, earlier.Token), http.MethodGet, "/api/auth/user", http.StatusUnauthorized)
		requireAnswer(t, asBearer(t, compose, compose.AuthUser(t, "reset", "reset-by-admin").Token), http.MethodGet, "/api/auth/user", http.StatusOK)
	})

	t.Run("the generated password reset refuses a user who already has a password", func(t *testing.T) {
		createUserAsAdmin(t, compose, "has-password")
		user := compose.AuthUser(t, "has-password", ShellHubPassword)

		requireAnswer(t, compose.R(t.Context()), http.MethodPatch, "/admin/api/users/"+user.ID+"/password/reset", http.StatusBadRequest)

		requireAnswer(t, asBearer(t, compose, user.Token), http.MethodGet, "/api/auth/user", http.StatusOK)
		compose.AuthUser(t, "has-password", ShellHubPassword)
	})

	t.Run("deleting a user takes their owned namespaces and paired devices with them", func(t *testing.T) {
		createUserAsAdmin(t, compose, "doomed")

		tenant := uuid.Generate()
		compose.NewNamespace(t, "doomed", "doomed-space", tenant)
		owner := signIntoTenant(t, compose, "doomed", ShellHubPassword, tenant)

		req := newDeviceAuthRequest(t, "doomed-owned", "02:00:00:00:24:11")
		req.TenantID = tenant
		owned := authDevice(t, compose, req).UID
		requireAnswer(t, asBearer(t, compose, owner.Token), http.MethodPatch, "/api/devices/"+owned+"/"+string(environment.DeviceActionAccept), http.StatusOK)

		pairedTenant := uuid.Generate()
		compose.NewNamespace(t, ShellHubUsername, "doomed-pairs-here", pairedTenant)
		compose.NewMember(t, "doomed", "doomed-pairs-here", string(authorizer.RoleOperator))
		member := signIntoTenant(t, compose, "doomed", ShellHubPassword, pairedTenant)
		pairing := startPairing(t, compose, newPairingRequest(t, "doomed-paired", "02:00:00:00:24:12"))
		paired := acceptPairingInto(t, asBearer(t, compose, member.Token), pairedTenant, pairing.Code)
		require.Equal(t, owner.ID, paired.OwnerID)

		requireAnswer(t, compose.R(t.Context()), http.MethodDelete, "/admin/api/users/"+owner.ID, http.StatusOK)

		requireAnswer(t, compose.R(t.Context()), http.MethodGet, "/admin/api/users/"+owner.ID, http.StatusNotFound)
		requireAnswer(t, compose.R(t.Context()), http.MethodGet, "/admin/api/namespaces/"+tenant, http.StatusNotFound)
		requireAnswer(t, compose.R(t.Context()), http.MethodGet, "/admin/api/devices/"+owned, http.StatusNotFound)

		removed := new(models.Device)
		resp, err := compose.R(t.Context()).SetResult(removed).Get("/admin/api/devices/" + paired.UID)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
		assert.Equal(t, models.DeviceStatusRemoved, removed.Status, "a paired device stayed after its owner was deleted")
	})
}

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

	t.Run("a device limit an administrator sets holds on the next accept", func(t *testing.T) {
		compose.NewUser(t, "limited", "limited@admin.shellhub.io", ShellHubPassword)
		tenant := uuid.Generate()
		compose.NewNamespace(t, "limited", "limited-space", tenant)
		owner := compose.AuthUser(t, "limited", ShellHubPassword)

		held := enrollPendingDeviceAs(t, compose, owner, "limited-held", "02:00:00:00:24:21")
		requireAnswer(t, asBearer(t, compose, owner.Token), http.MethodPatch, "/api/devices/"+held.UID+"/"+string(environment.DeviceActionAccept), http.StatusOK)
		over := enrollPendingDeviceAs(t, compose, owner, "limited-over", "02:00:00:00:24:22")

		before := adminNamespace(t, compose, tenant)
		updateNamespaceAsAdmin(t, compose, before, 1)

		after := adminNamespace(t, compose, tenant)
		assert.Equal(t, 1, after.MaxDevices)
		assert.Equal(t, before.Name, after.Name)

		requireAnswer(t, asBearer(t, compose, owner.Token), http.MethodPatch, "/api/devices/"+over.UID+"/"+string(environment.DeviceActionAccept), http.StatusForbidden)

		updateNamespaceAsAdmin(t, compose, before, 2)

		requireAnswer(t, asBearer(t, compose, owner.Token), http.MethodPatch, "/api/devices/"+over.UID+"/"+string(environment.DeviceActionAccept), http.StatusOK)
	})
}

func createUserAsAdmin(t *testing.T, compose *environment.DockerCompose, username string) {
	t.Helper()

	requireAnswer(t, compose.R(t.Context()).SetBody(newAdminUserBody(username)), http.MethodPost, "/admin/api/users", http.StatusOK)
}

func newAdminUserBody(username string) map[string]any {
	return map[string]any{
		"name":     username + " by admin",
		"username": username,
		"email":    username + "@admin.shellhub.io",
		"password": ShellHubPassword,
	}
}

func updateNamespaceAsAdmin(t *testing.T, compose *environment.DockerCompose, namespace models.Namespace, maxDevices int) {
	t.Helper()

	namespace.MaxDevices = maxDevices

	requireAnswer(t, compose.R(t.Context()).SetBody(namespace), http.MethodPut, "/admin/api/namespaces-update/"+namespace.TenantID, http.StatusOK)
}

func createInstanceAPIKey(t *testing.T, req *resty.Request, name string) *responses.CreateInstanceAPIKey {
	t.Helper()

	key := new(responses.CreateInstanceAPIKey)

	resp, err := req.
		SetBody(map[string]any{"name": name, "expires_at": 30}).
		SetResult(key).
		Post("/admin/api/instance-api-keys")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	require.True(t, strings.HasPrefix(key.ID, models.InstanceAPIKeyPrefix), "the plaintext key carries the instance key prefix")

	return key
}

func instanceAPIKeyNames(t *testing.T, compose *environment.DockerCompose) []string {
	t.Helper()

	keys := []models.InstanceAPIKey{}

	resp, err := compose.R(t.Context()).SetQueryParam("per_page", "100").SetResult(&keys).Get("/admin/api/instance-api-keys")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	total, err := strconv.Atoi(resp.Header().Get("X-Total-Count"))
	require.NoError(t, err)
	require.Len(t, keys, total, "the instance holds more instance API keys than one page lists")

	names := make([]string, 0, len(keys))
	for _, key := range keys {
		names = append(names, key.Name)
	}

	return names
}

func requireAnswer(t *testing.T, req *resty.Request, method, path string, status int) {
	t.Helper()

	resp, err := req.Execute(method, path)
	require.NoError(t, err)
	require.Equal(t, status, resp.StatusCode(), "%s %s: %s", method, path, resp.String())
}

func adminUsers(t *testing.T, compose *environment.DockerCompose) []models.User {
	t.Helper()

	users := []models.User{}

	resp, err := compose.R(t.Context()).SetQueryParam("per_page", "100").SetResult(&users).Get("/admin/api/users")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	total, err := strconv.Atoi(resp.Header().Get("X-Total-Count"))
	require.NoError(t, err)
	require.Len(t, users, total, "the instance holds more users than one page lists")

	return users
}

func adminUser(t *testing.T, compose *environment.DockerCompose, username string) models.User {
	t.Helper()

	for _, user := range adminUsers(t, compose) {
		if user.Username == username {
			return user
		}
	}

	require.FailNow(t, "the admin API lists no user "+username)

	return models.User{}
}

func adminNamespace(t *testing.T, compose *environment.DockerCompose, tenant string) models.Namespace {
	t.Helper()

	namespace := models.Namespace{}

	resp, err := compose.R(t.Context()).SetResult(&namespace).Get("/admin/api/namespaces/" + tenant)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	return namespace
}

func adminNamespaces(t *testing.T, compose *environment.DockerCompose) []models.Namespace {
	t.Helper()

	namespaces := []models.Namespace{}

	resp, err := compose.R(t.Context()).SetQueryParam("per_page", "100").SetResult(&namespaces).Get("/admin/api/namespaces")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	total, err := strconv.Atoi(resp.Header().Get("X-Total-Count"))
	require.NoError(t, err)
	require.Len(t, namespaces, total, "the instance holds more namespaces than one page lists")

	return namespaces
}

func tenants(namespaces []models.Namespace) []string {
	ids := make([]string, 0, len(namespaces))
	for _, namespace := range namespaces {
		ids = append(ids, namespace.TenantID)
	}

	return ids
}

func usernames(users []models.User) []string {
	names := make([]string, 0, len(users))
	for _, user := range users {
		names = append(names, user.Username)
	}

	return names
}

func column(rows [][]string, index int) []string {
	values := make([]string, 0, len(rows))
	for _, row := range rows {
		values = append(values, row[index])
	}

	return values
}

func exportCSV(t *testing.T, compose *environment.DockerCompose, path, filename, filter string) ([]string, [][]string) {
	t.Helper()

	req := compose.R(t.Context())
	if filter != "" {
		req.SetQueryParam("filter", filter)
	}

	resp, err := req.Get(path)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	assert.Equal(t, "attachment; filename="+filename, resp.Header().Get("Content-Disposition"))

	records, err := csv.NewReader(strings.NewReader(resp.String())).ReadAll()
	require.NoError(t, err)
	require.NotEmpty(t, records, "the export has no header row")

	return records[0], records[1:]
}

func requireEmptyExport(t *testing.T, compose *environment.DockerCompose, path, filter string) {
	t.Helper()

	resp, err := compose.R(t.Context()).SetQueryParam("filter", filter).Get(path)
	require.NoError(t, err)
	require.Equal(t, http.StatusNoContent, resp.StatusCode(), resp.String())
	assert.Empty(t, resp.Header().Get("Content-Disposition"))
	assert.Empty(t, resp.String())
}

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

	stats := adminStats{}

	resp, err := compose.R(t.Context()).SetResult(&stats).Get("/admin/api/stats")
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())

	return stats
}

func enrollPendingDeviceAs(t *testing.T, compose *environment.DockerCompose, owner *models.UserAuthResponse, hostname, mac string) models.Device {
	t.Helper()

	require.NotNil(t, owner.Tenant, "the owner's token carries no namespace")

	req := newDeviceAuthRequest(t, hostname, mac)
	req.TenantID = *owner.Tenant

	device := models.Device{}

	resp, err := asBearer(t, compose, owner.Token).SetResult(&device).Get("/api/devices/" + authDevice(t, compose, req).UID)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	require.Equal(t, models.DeviceStatusPending, device.Status)

	t.Cleanup(func() {
		resp, err := compose.Anonymous(context.WithoutCancel(t.Context())).SetAuthToken(owner.Token).Delete("/api/devices/" + device.UID)
		require.NoError(t, err)
		require.Equal(t, http.StatusOK, resp.StatusCode(), resp.String())
	})

	return device
}