mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-05 13:42:07 +03:00
05a083eaef
* fix(api-token): keep a token's scope when the CLI regenerates it RecreateByName deleted the named row and created a new one without a Scope, so the insert took the column default of admin. Since -tokenName lets the CLI regenerate any token, rotating a monitor or node-sync token silently turned it into a full-access one. The replacement now takes the scope of the row it replaces, and a new name still gets admin as before. A stored scope this build does not know, as after a downgrade, fails the rotation and leaves the row alone instead of guessing. Assisted-by: Claude Code:claude-opus-5-5 (mostly) * feat(cli): let -getApiToken choose the scope of the token it issues -tokenScope sets the scope on both branches of -getApiToken: the token minted on a fresh panel and the one regenerated on a populated panel. Without the flag a regenerated token keeps its scope and a new one gets admin, so every existing invocation, install.sh included, behaves as before. An unknown scope is refused before anything is deleted, so a typo cannot revoke the token it meant to rotate. Assisted-by: Claude Code:claude-opus-5-5 (mostly) * fix(api-token): keep a token's expiry when the CLI regenerates it RecreateByName built the replacement row with ExpiresAt 0, so running `x-ui setting -getApiToken -tokenName <name>` on a token issued through the API with a deadline handed back one that never expires, and said nothing about it - the same silent widening this branch fixed for scope. The replacement now carries the replaced row's ExpiresAt. A token whose deadline has already passed is refused instead of rotated, since keeping the deadline would mint a dead token and dropping it would revive an expired credential without limit; the expired row is left untouched. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
163 lines
5.0 KiB
Go
163 lines
5.0 KiB
Go
package main
|
|
|
|
// GetApiToken rotates a credential rather than displaying one, so these pin
|
|
// which token name it destroys — the whole point of the -tokenName flag.
|
|
|
|
import (
|
|
"flag"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
"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/web/service/panel"
|
|
)
|
|
|
|
func newTokenCLIEnv(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
}
|
|
|
|
func tokenNames(t *testing.T) []string {
|
|
t.Helper()
|
|
tokens, err := (&panel.ApiTokenService{}).List()
|
|
if err != nil {
|
|
t.Fatalf("list tokens: %v", err)
|
|
}
|
|
names := make([]string, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
names = append(names, token.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func tokenRow(t *testing.T, name string) model.ApiToken {
|
|
t.Helper()
|
|
var row model.ApiToken
|
|
if err := database.GetDB().Where("name = ?", name).First(&row).Error; err != nil {
|
|
t.Fatalf("load token %q: %v", name, err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
func hasName(names []string, want string) bool {
|
|
for _, name := range names {
|
|
if name == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// The bug: two callers sharing one hardcoded slot silently revoke each other.
|
|
// A named token must leave an differently-named one authenticating.
|
|
func TestGetApiTokenRotatesOnlyTheNamedToken(t *testing.T) {
|
|
newTokenCLIEnv(t)
|
|
|
|
svc := panel.ApiTokenService{}
|
|
weekly, err := svc.RecreateByName("weekly-report", "")
|
|
if err != nil {
|
|
t.Fatalf("seed weekly-report: %v", err)
|
|
}
|
|
|
|
GetApiToken(true, "ci-bot", "")
|
|
|
|
names := tokenNames(t)
|
|
if !hasName(names, "ci-bot") {
|
|
t.Fatalf("token names = %v, want ci-bot among them", names)
|
|
}
|
|
if !svc.Match(weekly.Token) {
|
|
t.Fatal("weekly-report was revoked by a call naming ci-bot")
|
|
}
|
|
}
|
|
|
|
// An explicit name has to win on both branches, or the same command would
|
|
// produce ci-bot on a populated panel and "install" on a fresh one.
|
|
func TestGetApiTokenUsesGivenNameOnEmptyDatabase(t *testing.T) {
|
|
newTokenCLIEnv(t)
|
|
|
|
GetApiToken(true, "ci-bot", "")
|
|
|
|
names := tokenNames(t)
|
|
if !hasName(names, "ci-bot") {
|
|
t.Fatalf("token names = %v, want ci-bot among them", names)
|
|
}
|
|
if hasName(names, installTokenName) {
|
|
t.Fatalf("token names = %v, want no %s when a name was given", names, installTokenName)
|
|
}
|
|
}
|
|
|
|
// -tokenScope has to reach both branches, or a fresh panel would mint an admin
|
|
// token for a caller that asked for monitor.
|
|
func TestGetApiTokenAppliesGivenScope(t *testing.T) {
|
|
newTokenCLIEnv(t)
|
|
|
|
GetApiToken(true, "ci-bot", model.ApiScopeMonitor)
|
|
if got := tokenRow(t, "ci-bot").Scope; got != model.ApiScopeMonitor {
|
|
t.Fatalf("minted scope = %q, want %q", got, model.ApiScopeMonitor)
|
|
}
|
|
|
|
GetApiToken(true, "ci-bot", model.ApiScopeNodeSync)
|
|
if got := tokenRow(t, "ci-bot").Scope; got != model.ApiScopeNodeSync {
|
|
t.Fatalf("regenerated scope = %q, want %q", got, model.ApiScopeNodeSync)
|
|
}
|
|
}
|
|
|
|
// install.sh records the token it gets on a fresh panel. A later bare
|
|
// -getApiToken must rotate the fallback slot and leave that record valid.
|
|
func TestGetApiTokenPreservesInstallTokenWhenRotating(t *testing.T) {
|
|
newTokenCLIEnv(t)
|
|
|
|
GetApiToken(true, "", "")
|
|
installed := tokenRow(t, installTokenName)
|
|
|
|
GetApiToken(true, "", "")
|
|
|
|
names := tokenNames(t)
|
|
if !hasName(names, cliFallbackTokenName) {
|
|
t.Fatalf("token names = %v, want %s among them", names, cliFallbackTokenName)
|
|
}
|
|
if got := tokenRow(t, installTokenName); got.Id != installed.Id {
|
|
t.Fatalf("%s row id = %d, want %d — the installer's token was replaced", installTokenName, got.Id, installed.Id)
|
|
}
|
|
if got := tokenRow(t, installTokenName); got.Token != installed.Token {
|
|
t.Fatalf("the %s token hash changed, so the recorded credential stopped working", installTokenName)
|
|
}
|
|
}
|
|
|
|
// `-getApiToken true -tokenName ci-bot` parses tokenName as "", because flag
|
|
// stops at the positional. The command must not then rotate the shared slot.
|
|
func TestGetApiTokenWarnsOnIgnoredPositionalArgs(t *testing.T) {
|
|
set := flag.NewFlagSet("setting", flag.ContinueOnError)
|
|
var getApiToken bool
|
|
var tokenName string
|
|
set.BoolVar(&getApiToken, "getApiToken", false, "")
|
|
set.StringVar(&tokenName, "tokenName", "", "")
|
|
|
|
if err := set.Parse([]string{"-getApiToken", "true", "-tokenName", "ci-bot"}); err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if tokenName != "" {
|
|
t.Fatalf("tokenName = %q; this test guards the case where flag drops it", tokenName)
|
|
}
|
|
if got := set.Args(); len(got) == 0 {
|
|
t.Fatal("leftover arguments must be visible so the CLI can warn instead of silently rotating cli-fallback")
|
|
}
|
|
}
|
|
|
|
func TestGetApiTokenTrimsName(t *testing.T) {
|
|
newTokenCLIEnv(t)
|
|
|
|
if _, err := (&panel.ApiTokenService{}).RecreateByName("seed", ""); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
GetApiToken(true, " ", "")
|
|
|
|
names := tokenNames(t)
|
|
if !hasName(names, cliFallbackTokenName) {
|
|
t.Fatalf("token names = %v, want a whitespace-only name to fall back to %s", names, cliFallbackTokenName)
|
|
}
|
|
}
|