Files
3x-ui/api_token_cli_test.go
T
ilyusha 05a083eaef fix(api-token): keep a token's scope when -getApiToken regenerates it, add -tokenScope (#6700)
* 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>
2026-10-02 19:05:27 +02:00

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)
}
}