From dae91276aa53428bfc4fd124a80a22e27c4e9012 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 5 Oct 2026 13:30:18 +0200 Subject: [PATCH] chore(nodes): run the master+node scopes in parallel and document both layers Each enrollment scope owns its panels, ports and temp dirs, so the two run side by side; the only shared state, the process-global database handle the harness borrows for setup and for simulating a lost inbound, is now behind a mutex. On Linux the suite drops from 188s to 95s. CLAUDE.md now names the fast contract layer (node_contract_test.go, in make test-go) next to the multi-tick nodee2e layer. --- CLAUDE.md | 6 +- internal/nodee2e/harness_test.go | 17 +++- internal/nodee2e/node_sync_test.go | 1 + .../controller/inbound_master_push_test.go | 79 ------------------- 4 files changed, 21 insertions(+), 82 deletions(-) delete mode 100644 internal/web/controller/inbound_master_push_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 00fc01287..fbdb4684e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,8 +195,10 @@ That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`, `make vulncheck`, a live-Postgres job (where a SKIP counts as a failure), `make node-e2e` (a real master and node panel, `internal/nodee2e/`) and a 30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when -you touch DB/dialect, node sync or parser code. A new node-sync behaviour gets -a cell in `internal/nodee2e/node_sync_test.go`. +you touch DB/dialect, node sync or parser code. Node sync has two layers: every +`runtime.Remote` call gets a cell in `internal/web/node_contract_test.go` (fast, +in `make test-go`; a method without one fails it), and a multi-tick flow (cron, +adopt, node down) gets one in `internal/nodee2e/node_sync_test.go`. Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend), `make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`. diff --git a/internal/nodee2e/harness_test.go b/internal/nodee2e/harness_test.go index 16244cc1f..2aa28ec36 100644 --- a/internal/nodee2e/harness_test.go +++ b/internal/nodee2e/harness_test.go @@ -15,6 +15,7 @@ import ( "regexp" "strconv" "strings" + "sync" "testing" "time" @@ -93,6 +94,17 @@ func (p *panel) mintToken(name, scope string) string { // newPanel prepares a panel's database: credentials, a private port, its own // sub-server port (two panels on one host would race for 2096) and an admin token. func newPanel(t *testing.T, bin, name string) *panel { + t.Helper() + p := preparePanel(t, bin, name) + p.token = p.mintToken("e2e-driver", "admin") + return p +} + +// sharedDBMu guards the process-global database handle the harness borrows +// while the scopes run in parallel. +var sharedDBMu sync.Mutex + +func preparePanel(t *testing.T, bin, name string) *panel { t.Helper() p := &panel{t: t, name: name, bin: bin, dir: t.TempDir(), port: freePort(t)} for _, d := range []string{"db", "log", "bin"} { @@ -101,6 +113,8 @@ func newPanel(t *testing.T, bin, name string) *panel { } } p.cli("setting", "-username", "e2e", "-password", "e2e-pass", "-port", strconv.Itoa(p.port), "-webBasePath", "/") + sharedDBMu.Lock() + defer sharedDBMu.Unlock() if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil { t.Fatalf("%s: open db: %v", name, err) } @@ -112,7 +126,6 @@ func newPanel(t *testing.T, bin, name string) *panel { if err := database.CloseDB(); err != nil { t.Fatalf("%s: close db: %v", name, err) } - p.token = p.mintToken("e2e-driver", "admin") t.Cleanup(p.stop) return p } @@ -169,6 +182,8 @@ func (p *panel) deleteInboundRow(id int) { if p.cmd != nil { p.t.Fatalf("%s: deleteInboundRow on a running panel", p.name) } + sharedDBMu.Lock() + defer sharedDBMu.Unlock() if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil { p.t.Fatalf("%s: open db: %v", p.name, err) } diff --git a/internal/nodee2e/node_sync_test.go b/internal/nodee2e/node_sync_test.go index c22628521..410b13ea1 100644 --- a/internal/nodee2e/node_sync_test.go +++ b/internal/nodee2e/node_sync_test.go @@ -16,6 +16,7 @@ func TestNodeSync(t *testing.T) { bin := panelBinary(t) for _, scope := range []string{"admin", "node-sync"} { t.Run("enrolled with "+scope+" token", func(t *testing.T) { + t.Parallel() runNodeSyncScenarios(t, bin, scope) }) } diff --git a/internal/web/controller/inbound_master_push_test.go b/internal/web/controller/inbound_master_push_test.go deleted file mode 100644 index 11a4ee51a..000000000 --- a/internal/web/controller/inbound_master_push_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package controller - -import ( - "context" - "net/http/httptest" - "net/url" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/gin-gonic/gin" - - "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" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -// A node enrolled with an admin-scope token (the -getApiToken default) must -// still store the clients its master pushes; it used to keep its own list. -func TestMasterPushWithAdminTokenAppliesClients(t *testing.T) { - gin.SetMode(gin.TestMode) - dbDir := t.TempDir() - t.Setenv("XUI_DB_FOLDER", dbDir) - dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db")) - prev := runtime.GetManager() - runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})) - t.Cleanup(func() { runtime.SetManager(prev) }) - - const token = "admin-node-token" - if err := database.GetDB().Create(&model.ApiToken{ - Name: "node", Token: crypto.HashTokenSHA256(token), Enabled: true, Scope: model.ApiScopeAdmin, - }).Error; err != nil { - t.Fatalf("seed token: %v", err) - } - var owner model.User - if err := database.GetDB().First(&owner).Error; err != nil { - t.Fatalf("load panel user: %v", err) - } - const stream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}` - stored := &model.Inbound{ - UserId: owner.Id, Tag: "in-46001", Protocol: model.VLESS, Port: 46001, Enable: true, - Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: stream, Sniffing: `{}`, - } - if err := database.GetDB().Create(stored).Error; err != nil { - t.Fatalf("seed node inbound: %v", err) - } - - engine := gin.New() - a := &APIController{} - api := engine.Group("/panel/api") - api.Use(a.checkAPIAuth, a.enforceTokenScope) - NewInboundController(api.Group("/inbounds")) - srv := httptest.NewServer(engine) - defer srv.Close() - - u, _ := url.Parse(srv.URL) - port, _ := strconv.Atoi(u.Port()) - master := runtime.NewRemote(&model.Node{ - Id: 1, Name: "n1", Scheme: "http", Address: u.Hostname(), Port: port, - BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true, - }, nil) - - pushed := *stored - pushed.Settings = `{"clients":[{"id":"7fa0b7d1-9b5f-47ad-bef2-6cb0c4a624be","email":"alice","enable":true,"subId":"s-alice"}],"decryption":"none"}` - if err := master.UpdateInbound(context.Background(), &pushed, &pushed); err != nil { - t.Fatalf("master push: %v", err) - } - - var got model.Inbound - if err := database.GetDB().First(&got, stored.Id).Error; err != nil { - t.Fatalf("reload node inbound: %v", err) - } - if !strings.Contains(got.Settings, `"alice"`) { - t.Fatalf("node kept its own client list after a master push: %s", got.Settings) - } -}