Files
2026-07-20 04:32:16 +00:00

616 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.APIToken{}, &model.Server{}))
singleton.DB = db
return func() {
_ = sqlDB.Close()
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_AdminCannotIncludeNonexistentServerID(t *testing.T) {
defer setupAPITokenTest(t)()
defer installServerForAPIToken(t, 77, 999)() // only server 77 exists
c := ctxAsUser(1, model.RoleAdmin)
bindJSON(c, model.APITokenCreateRequest{
Name: "ops-future-bind",
Scopes: []string{model.ScopeServerRead},
ServerIDs: []uint64{4242}, // never-existed server id
})
_, err := createAPIToken(c)
require.Error(t, err, "admin must not bind a PAT to a nonexistent server_id; a later-created server with that id would auto-inherit the grant")
require.Contains(t, err.Error(), "server not found")
}
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")
}