new-api/controller/muse_internal_test.go
zizi 769a700b5c Move Muse mirror-user provisioning into new-api control plane
Muse needs a trusted path to create and disable per-user long-lived relay tokens so billing, routing, and downgrade logic can stay inside new-api instead of leaking back into Muse.

This adds a small internal route group guarded by a shared secret, provisions a mirror user plus a dedicated long-lived token, and exposes disable/status/revoke operations for the control plane. The sync path stores the Muse user id in remark as a temporary lookup anchor until Muse persists the returned new-api ids.

Constraint: Must reuse new-api billing/token model behavior without adding schema changes in phase 1
Constraint: Must avoid CreateUser side effects such as signup quota grants for mirrored internal users
Rejected: Reusing existing self-service token controllers | they only manage the authenticated owner and cannot return control-plane ids/keys
Rejected: Keying mirror users by username/email alone | unstable and collision-prone for external identity sync
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Treat remark-based muse_user_id lookup as a phase-1 bridge; prefer persisted new_api_user_id from Muse binding records for later operations
Tested: go test ./controller ./service ./middleware -run MuseInternal -count=1
Tested: go test ./controller ./service ./middleware -count=1
Not-tested: Full relay/data-plane integration against a live Muse control-plane caller
2026-04-16 17:07:06 +08:00

152 lines
5.1 KiB
Go

package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
museDTO "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type museInternalAPIResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data museDTO.SyncMuseUserResponse `json:"data"`
}
func setupMuseInternalControllerTestDB(t *testing.T) *gorm.DB {
t.Helper()
gin.SetMode(gin.TestMode)
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
common.GlobalApiRateLimitEnable = false
t.Setenv("MUSE_INTERNAL_SECRET", "test-secret")
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
model.DB = db
model.LOG_DB = db
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Token{}))
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func decodeMuseInternalResponse(t *testing.T, recorder *httptest.ResponseRecorder) museInternalAPIResponse {
t.Helper()
var response museInternalAPIResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
return response
}
func newMuseInternalRouter() *gin.Engine {
engine := gin.New()
internal := engine.Group("/api/internal/muse")
internal.Use(middleware.MuseInternalAuth())
{
internal.POST("/users/sync", SyncMuseUser)
internal.POST("/users/:id/disable", DisableMuseUser)
internal.POST("/tokens/:id/revoke", RevokeMuseToken)
internal.GET("/users/:id/status", GetMuseUserStatus)
}
return engine
}
func TestMuseInternalSyncUserCreatesMirrorUserAndToken(t *testing.T) {
setupMuseInternalControllerTestDB(t)
req := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-1","username":"alice","display_name":"Alice","status":"active"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Muse-Service-Secret", "test-secret")
rr := httptest.NewRecorder()
newMuseInternalRouter().ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
response := decodeMuseInternalResponse(t, rr)
require.True(t, response.Success)
require.Equal(t, "active", response.Data.Status)
require.NotZero(t, response.Data.NewAPIUserID)
require.NotZero(t, response.Data.TokenID)
require.NotEmpty(t, response.Data.TokenKey)
user, err := model.GetUserById(response.Data.NewAPIUserID, false)
require.NoError(t, err)
require.Equal(t, common.UserStatusEnabled, user.Status)
require.Equal(t, "alice", user.Username)
require.Equal(t, "muse_user_id:u-1", user.Remark)
token, err := model.GetTokenById(response.Data.TokenID)
require.NoError(t, err)
require.Equal(t, user.Id, token.UserId)
require.Equal(t, common.TokenStatusEnabled, token.Status)
require.True(t, token.UnlimitedQuota)
}
func TestMuseInternalSyncUserRejectsInvalidSecret(t *testing.T) {
setupMuseInternalControllerTestDB(t)
req := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-1","username":"alice","display_name":"Alice","status":"active"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Muse-Service-Secret", "wrong-secret")
rr := httptest.NewRecorder()
newMuseInternalRouter().ServeHTTP(rr, req)
require.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestMuseInternalDisableUserDisablesUserAndToken(t *testing.T) {
setupMuseInternalControllerTestDB(t)
syncReq := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-2","username":"bob","display_name":"Bob","status":"active"}`))
syncReq.Header.Set("Content-Type", "application/json")
syncReq.Header.Set("X-Muse-Service-Secret", "test-secret")
syncRR := httptest.NewRecorder()
engine := newMuseInternalRouter()
engine.ServeHTTP(syncRR, syncReq)
syncResponse := decodeMuseInternalResponse(t, syncRR)
require.True(t, syncResponse.Success)
disableReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/internal/muse/users/%d/disable", syncResponse.Data.NewAPIUserID), nil)
disableReq.Header.Set("X-Muse-Service-Secret", "test-secret")
disableRR := httptest.NewRecorder()
engine.ServeHTTP(disableRR, disableReq)
require.Equal(t, http.StatusOK, disableRR.Code)
disableResponse := decodeMuseInternalResponse(t, disableRR)
require.True(t, disableResponse.Success)
require.Equal(t, "disabled", disableResponse.Data.Status)
require.Equal(t, syncResponse.Data.NewAPIUserID, disableResponse.Data.NewAPIUserID)
user, err := model.GetUserById(syncResponse.Data.NewAPIUserID, false)
require.NoError(t, err)
require.Equal(t, common.UserStatusDisabled, user.Status)
token, err := model.GetTokenById(syncResponse.Data.TokenID)
require.NoError(t, err)
require.Equal(t, common.TokenStatusDisabled, token.Status)
}