tests/admin_test.go
Rama master · réplica de lectura del repositorio original.
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
}