Преглед на файлове

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.
MHSanaei преди 5 часа
родител
ревизия
dae91276aa
променени са 4 файла, в които са добавени 21 реда и са изтрити 82 реда
  1. 4 2
      CLAUDE.md
  2. 16 1
      internal/nodee2e/harness_test.go
  3. 1 0
      internal/nodee2e/node_sync_test.go
  4. 0 79
      internal/web/controller/inbound_master_push_test.go

+ 4 - 2
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`.

+ 16 - 1
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)
 	}

+ 1 - 0
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)
 		})
 	}

+ 0 - 79
internal/web/controller/inbound_master_push_test.go

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