feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation

Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-30 15:56:44 +00:00
co-authored by cloudcode
parent 029695344c
commit e8dabf5bc6
153 changed files with 16974 additions and 244 deletions
+597
View File
@@ -0,0 +1,597 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func setupAPITokenTest(t *testing.T) func() {
t.Helper()
originalDB := singleton.DB
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.APIToken{}, &model.Server{}))
singleton.DB = db
return func() {
singleton.DB = originalDB
}
}
func ctxAsUser(uid uint64, role model.Role) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/", nil)
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: role})
return c
}
func bindJSON(c *gin.Context, body any) {
b, _ := json.Marshal(body)
c.Request = httptest.NewRequest("POST", "/", bytes.NewReader(b))
c.Request.Header.Set("Content-Type", "application/json")
}
func TestCreateAPIToken_MemberCanCreateExplicitScope(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "claude",
Scopes: []string{model.ScopeServerRead},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.NotEmpty(t, res.Token)
require.True(t, strings.HasPrefix(res.Token, model.APITokenPrefix))
require.Greater(t, res.ID, uint64(0))
var stored model.APIToken
require.NoError(t, singleton.DB.First(&stored, res.ID).Error)
require.Equal(t, model.HashAPIToken(res.Token), stored.TokenHash)
require.Equal(t, uint64(10), stored.UserID)
}
func TestCreateAPIToken_MemberCannotIssueWildcard(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeNezhaAll},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "admin")
}
func TestCreateAPIToken_AdminCanIssueWildcard(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "ops-script",
Scopes: []string{model.ScopeNezhaAll},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.Contains(t, res.Scopes, model.ScopeNezhaAll)
}
func TestCreateAPIToken_RejectsUnknownScope(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{"mcp:hack:everything"},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown scope")
}
func TestCreateAPIToken_RejectsEmptyScopes(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{Name: "x", Scopes: []string{}})
_, err := createAPIToken(c)
require.Error(t, err)
}
func TestCreateAPIToken_RejectsTooManyServerIDs(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
ids := make([]uint64, 1001)
for i := range ids {
ids[i] = uint64(i + 1)
}
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeServerRead},
ServerIDs: ids,
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "too many server_ids")
}
func TestCreateAPIToken_RejectsExpirationOutOfRange(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeServerRead},
ExpiresInDays: -1,
})
_, err := createAPIToken(c)
require.Error(t, err)
bindJSON(c, model.APITokenCreateRequest{
Name: "x",
Scopes: []string{model.ScopeServerRead},
ExpiresInDays: 10000,
})
_, err = createAPIToken(c)
require.Error(t, err)
}
func TestDeleteAPIToken_OnlyOwnerOrAdmin(t *testing.T) {
defer setupAPITokenTest(t)()
tok := model.APIToken{UserID: 10, Name: "x", TokenHash: model.HashAPIToken("nzp_x")}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
c := ctxAsUser(11, model.RoleMember)
c.Params = gin.Params{{Key: "id", Value: itoa(tok.ID)}}
_, err := deleteAPIToken(c)
require.Error(t, err, "other member must not delete")
c = ctxAsUser(10, model.RoleMember)
c.Params = gin.Params{{Key: "id", Value: itoa(tok.ID)}}
_, err = deleteAPIToken(c)
require.NoError(t, err)
}
func TestDeleteAPIToken_AdminCanDeleteAny(t *testing.T) {
defer setupAPITokenTest(t)()
tok := model.APIToken{UserID: 10, Name: "x", TokenHash: model.HashAPIToken("nzp_y")}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
c := ctxAsUser(1, model.RoleAdmin)
c.Params = gin.Params{{Key: "id", Value: itoa(tok.ID)}}
_, err := deleteAPIToken(c)
require.NoError(t, err)
}
// installServerForAPIToken 在 ServerShared 里塞一台属于 ownerUID 的 server
// 仅用于 PAT 创建路径的 server_ids 权限校验测试。
func installServerForAPIToken(t *testing.T, serverID, ownerUID uint64) func() {
t.Helper()
original := singleton.ServerShared
sc := singleton.NewEmptyServerClassForTest()
srv := &model.Server{}
srv.ID = serverID
srv.SetUserID(ownerUID)
sc.InsertForTest(srv)
singleton.ServerShared = sc
return func() { singleton.ServerShared = original }
}
func TestCreateAPIToken_MemberCannotIncludeForeignServerID(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 42, 999)() // server 42 owned by user 999
c := ctxAsUser(10, model.RoleMember) // attacker is user 10
bindJSON(c, model.APITokenCreateRequest{
Name: "evil",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{42},
})
_, err := createAPIToken(c)
require.Error(t, err, "member must not be able to bind foreign server_id into a PAT")
require.Contains(t, err.Error(), "permission denied")
}
func TestCreateAPIToken_MemberCannotIncludeNonexistentServerID(t *testing.T) {
defer setupAPITokenTest(t)()
cleanup := installServerForAPIToken(t, 1, 10)
defer cleanup()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "evil2",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{9999}, // never-existed server
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "server not found")
}
func TestCreateAPIToken_AdminCanIncludeAnyServerID(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 77, 999)() // foreign server
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "ops-script",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{77},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.Equal(t, []uint64{77}, res.ServerIDs)
}
func TestCreateAPIToken_MemberOwnServerIDIsAccepted(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 55, 10)() // user 10 owns server 55
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "self",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{55},
})
res, err := createAPIToken(c)
require.NoError(t, err)
require.Equal(t, []uint64{55}, res.ServerIDs)
}
func TestAPITokenAuthMW_ExpiredTokenRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("e", 32)
past := time.Now().Add(-time.Hour)
tok := model.APIToken{
UserID: 10,
Name: "expired",
TokenHash: model.HashAPIToken(plain),
ExpiresAt: &past,
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "expired token must abort the request")
require.Equal(t, http.StatusUnauthorized, w.Code)
require.Contains(t, w.Body.String(), "expired")
}
func TestAPITokenAuthMW_OwnerDeletedRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("o", 32)
tok := model.APIToken{
UserID: 999,
Name: "orphan",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "owner-less PAT must abort")
require.Equal(t, http.StatusUnauthorized, w.Code)
require.Contains(t, w.Body.String(), "owner")
}
func TestAPITokenAuthMW_HappyPathSetsUserContext(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("g", 32)
tok := model.APIToken{
UserID: 10,
Name: "good",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}, Username: "alice"}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted())
user, ok := c.Get(model.CtxKeyAuthorizedUser)
require.True(t, ok)
require.Equal(t, uint64(10), user.(*model.User).ID)
got := APITokenFromContext(c)
require.NotNil(t, got)
require.Equal(t, "good", got.Name)
}
func TestAPITokenAuthMW_NonNZPBearerPassesThrough(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer some-jwt-here")
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted(), "non-nzp Bearer must pass through to JWT middleware")
require.Nil(t, APITokenFromContext(c))
}
func TestAPITokenAuthMW_EmptyNZPBodyRejected(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_")
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(),
"Bearer with empty nzp_ body must be rejected (would otherwise hash an empty string and look it up)")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAPITokenAuthMW_RevokedTokenRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("r", 32)
tok := model.APIToken{
UserID: 10,
Name: "to-revoke",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
require.NoError(t, singleton.DB.Delete(&model.APIToken{}, tok.ID).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain)
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "revoked PAT must abort")
require.Equal(t, http.StatusUnauthorized, w.Code)
require.Contains(t, w.Body.String(), "invalid api token")
}
func TestAPITokenAuthMW_OversizedTokenIsRejected(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_"+strings.Repeat("X", 100*1024))
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "huge nzp_ body must still abort (no DoS via lookup)")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAPITokenAuthMW_NonASCIITokenIsRejected(t *testing.T) {
defer setupAPITokenTest(t)()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_中文😀💀")
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "non-ASCII nzp_ body must be rejected by hash lookup")
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAPITokenAuthMW_SQLInjectionAttemptIsRejected(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("s", 32)
tok := model.APIToken{
UserID: 10,
Name: "real",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer nzp_'; DROP TABLE api_tokens; --")
apiTokenAuthMiddleware()(c)
require.True(t, c.IsAborted(), "SQL-injection-shaped token must just look up and miss")
require.Equal(t, http.StatusUnauthorized, w.Code)
var stored model.APIToken
require.NoError(t, singleton.DB.First(&stored, tok.ID).Error,
"real token row must survive — GORM uses prepared statements")
}
func TestAPITokenAuthMW_LowercaseBearerPassesThrough(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("l", 32)
tok := model.APIToken{UserID: 10, Name: "x", TokenHash: model.HashAPIToken(plain)}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "bearer "+plain)
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted(),
"lowercase 'bearer ' is not the canonical scheme; PAT mw must skip it (RFC 7235 says scheme is case-insensitive, "+
"but we deliberately match GitHub/AWS behaviour of strict 'Bearer ' to keep PAT/JWT lookup paths predictable)")
require.Nil(t, APITokenFromContext(c), "lowercase bearer must not register as PAT")
}
func TestAPITokenAuthMW_TrailingWhitespaceTolerated(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("w", 32)
tok := model.APIToken{UserID: 10, Name: "trim", TokenHash: model.HashAPIToken(plain)}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 10}}).Error)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/", nil)
c.Request.Header.Set("Authorization", "Bearer "+plain+" ")
apiTokenAuthMiddleware()(c)
require.False(t, c.IsAborted(),
"trailing/leading whitespace around PAT must be trimmed (curl users often paste with newlines)")
require.NotNil(t, APITokenFromContext(c))
}
func TestCreateAPIToken_NameTooLongRejected(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: strings.Repeat("X", 129),
Scopes: []string{model.ScopeServerRead},
})
_, err := createAPIToken(c)
require.Error(t, err, "name >128 chars must be rejected (binding tag max=128 or handler check)")
}
func TestCreateAPIToken_EmptyNameRejected(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: " ",
Scopes: []string{model.ScopeServerRead},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "name required")
}
func TestAPIToken_DuplicateHashViolatesUniqueIndex(t *testing.T) {
defer setupAPITokenTest(t)()
plain := "nzp_" + strings.Repeat("a", 32)
for _, uid := range []uint64{10, 11} {
tok := model.APIToken{
UserID: uid,
Name: "dup",
TokenHash: model.HashAPIToken(plain),
}
tok.SetScopes([]string{model.ScopeServerRead})
err := singleton.DB.Create(&tok).Error
if uid == 10 {
require.NoError(t, err, "first insert must succeed")
continue
}
require.Error(t, err, "duplicate hash must violate unique index (defense against forged tokens)")
require.Contains(t, err.Error(), "UNIQUE")
}
}
func TestListAPITokens_ReturnsOnlyOwn(t *testing.T) {
defer setupAPITokenTest(t)()
for i, uid := range []uint64{10, 10, 20} {
tok := model.APIToken{UserID: uid, Name: "n", TokenHash: model.HashAPIToken("nzp_unique_" + itoa(uint64(i)))}
tok.SetScopes([]string{model.ScopeServerRead})
require.NoError(t, singleton.DB.Create(&tok).Error)
}
c := ctxAsUser(10, model.RoleMember)
got, err := listAPITokens(c)
require.NoError(t, err)
require.Len(t, got, 2)
}
func itoa(v uint64) string {
return strings.TrimSpace(jsonNum(v))
}
func jsonNum(v uint64) string {
b, _ := json.Marshal(v)
return string(b)
}
// scope_doc.go and HasScope advertise nezha:<resource>:* as a first-class
// scope shape, and rest_scope_test.go pins runtime support for it. The
// create-API-token endpoint must accept those wildcards too — otherwise
// the documented surface is unreachable via the only endpoint that can
// issue PATs.
func TestCreateAPIToken_AcceptsResourceWildcardScopes(t *testing.T) {
defer setupAPITokenTest(t)()
cases := []string{
"nezha:server:*",
"nezha:service:*",
"nezha:cron:*",
"nezha:transfer:*",
}
for _, scope := range cases {
t.Run(scope, func(t *testing.T) {
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "wildcard-" + scope,
Scopes: []string{scope},
})
res, err := createAPIToken(c)
require.NoError(t, err, "resource wildcard %q must be issuable", scope)
require.Contains(t, res.Scopes, scope)
})
}
}
// nezha:admin:* is admin-only and already on AdminOnlyScopes; this test
// ensures the new wildcard acceptance does NOT widen admin-only scopes
// to members.
func TestCreateAPIToken_ResourceWildcardStillRejectsAdminOnly(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(10, model.RoleMember)
bindJSON(c, model.APITokenCreateRequest{
Name: "member-tries-admin-wildcard",
Scopes: []string{"nezha:admin:*"},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "admin")
}
// Unknown resources must still be rejected even with a wildcard verb so
// that nezha:bogus:* does not become a forward-compat blank cheque.
func TestCreateAPIToken_RejectsUnknownResourceWildcard(t *testing.T) {
defer setupAPITokenTest(t)()
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "bogus",
Scopes: []string{"nezha:bogus:*"},
})
_, err := createAPIToken(c)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown scope")
}