Files
3x-ui/internal/web/service/panel/api_token_scope_test.go
T
MHSanaei 12d51d7195 perf(tests): copy a migrated template DB instead of migrating per test
Most tests opened a throwaway panel DB with database.InitDB, which runs the
full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under
-race because GORM's reflection-heavy migration is what the detector slows
most. internal/web/service does this in ~550 of its 830 tests, so the CI race
job spent ~10 of its ~14.6 minutes re-migrating empty databases.

internal/database/dbtest.InitDB migrates once per test process, then hands
each test its own copy of that file (~130ms under -race) and registers the
CloseDB cleanup. The copy then goes through InitDB like a panel restart, so
every test still starts from the state a fresh install has. Tests that reopen
an existing file, migrate a hand-built legacy DB or target Postgres keep
calling database.InitDB.

Locally under -race: internal/web/service 626s (last CI run) -> 114s,
internal/sub 246s -> 35s.
2026-09-27 03:04:50 +02:00

139 lines
4.5 KiB
Go

package panel
import (
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
)
func setupAPITokenTestDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
}
func TestAPITokenScopeExpiryAndExpectedRevoke(t *testing.T) {
setupAPITokenTestDB(t)
svc := &ApiTokenService{}
future := time.Now().Add(time.Hour).UnixMilli()
created, err := svc.Create("node-1", model.ApiScopeNodeSync, future)
if err != nil {
t.Fatalf("create node-sync token: %v", err)
}
row, ok := svc.MatchToken(created.Token)
if !ok {
t.Fatal("fresh node-sync token did not match")
}
if row.Scope != model.ApiScopeNodeSync || row.ExpiresAt != future {
t.Fatalf("matched row scope/expiry = %s/%d, want node-sync/%d", row.Scope, row.ExpiresAt, future)
}
if _, err := svc.Create("bad-scope", "superuser", 0); err == nil {
t.Fatal("unknown scope must be rejected on create")
}
if _, err := svc.Create("past", model.ApiScopeAdmin, time.Now().Add(-time.Minute).UnixMilli()); err == nil {
t.Fatal("past expiry must be rejected on create")
}
const expiredPlain = "expired-token"
if err := database.GetDB().Create(&model.ApiToken{
Name: "expired",
Token: crypto.HashTokenSHA256(expiredPlain),
Enabled: true,
Scope: model.ApiScopeAdmin,
ExpiresAt: time.Now().Add(-time.Minute).UnixMilli(),
}).Error; err != nil {
t.Fatalf("seed expired token: %v", err)
}
if _, ok := svc.MatchToken(expiredPlain); ok {
t.Fatal("expired token must fail closed")
}
const unknownPlain = "unknown-scope-token"
if err := database.GetDB().Create(&model.ApiToken{
Name: "unknown",
Token: crypto.HashTokenSHA256(unknownPlain),
Enabled: true,
Scope: "root",
}).Error; err != nil {
t.Fatalf("seed unknown-scope token: %v", err)
}
if _, ok := svc.MatchToken(unknownPlain); ok {
t.Fatal("unknown token scope must fail closed")
}
if err := svc.DisableExpectedScope(created.Id, model.ApiScopeAdmin); err == nil {
t.Fatal("expected-scope revoke must refuse a node-sync token when admin was expected")
}
if _, ok := svc.MatchToken(created.Token); !ok {
t.Fatal("wrong expected-scope revoke disabled the token")
}
if err := svc.DisableExpectedScope(created.Id, model.ApiScopeNodeSync); err != nil {
t.Fatalf("disable expected node-sync token: %v", err)
}
if _, ok := svc.MatchToken(created.Token); ok {
t.Fatal("disabled token still matched")
}
}
func TestAPITokenDeleteAndEnableRequireExpectedScope(t *testing.T) {
setupAPITokenTestDB(t)
svc := &ApiTokenService{}
created, err := svc.Create("scoped", model.ApiScopeMonitor, 0)
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := svc.SetEnabledExpectedScope(created.Id, model.ApiScopeAdmin, false); err == nil {
t.Fatal("wrong expected scope changed token state")
}
if _, ok := svc.MatchToken(created.Token); !ok {
t.Fatal("wrong-scope update disabled token")
}
if err := svc.DeleteExpectedScope(created.Id, model.ApiScopeAdmin); err == nil {
t.Fatal("wrong expected scope deleted token")
}
if err := svc.DeleteExpectedScope(created.Id, model.ApiScopeMonitor); err != nil {
t.Fatalf("DeleteExpectedScope: %v", err)
}
}
func TestAPITokenEmptyExpectedScopeCannotTargetAdmin(t *testing.T) {
setupAPITokenTestDB(t)
svc := &ApiTokenService{}
created, err := svc.Create("admin-token", model.ApiScopeAdmin, 0)
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := svc.DisableExpectedScope(created.Id, ""); err == nil {
t.Fatal("empty expected scope defaulted to admin")
}
if _, ok := svc.MatchToken(created.Token); !ok {
t.Fatal("empty expected scope disabled the admin token")
}
}
func TestAPITokenAdditiveDefaultsPreserveLegacyAccess(t *testing.T) {
setupAPITokenTestDB(t)
const plaintext = "legacy-token"
if err := database.GetDB().Exec(
"INSERT INTO api_tokens (name, token, enabled, created_at) VALUES (?, ?, ?, ?)",
"legacy", crypto.HashTokenSHA256(plaintext), true, time.Now().Unix(),
).Error; err != nil {
t.Fatalf("insert legacy-shaped token: %v", err)
}
row, ok := (&ApiTokenService{}).MatchToken(plaintext)
if !ok {
t.Fatal("legacy-shaped token no longer authenticates")
}
if row.Scope != model.ApiScopeAdmin || row.ExpiresAt != 0 {
t.Fatalf("legacy defaults = scope %q expiry %d, want admin/0", row.Scope, row.ExpiresAt)
}
}