Files
3x-ui/internal/web/service/node_client_expiry_sync_test.go
T
MHSanaei 8dd3b31ee8 fix(node): show the activated first-use deadline on the Clients page
With "start after first use" on a node inbound, the node activates the
absolute deadline and the master adopts it into client_traffics via the
sync CASE merge — but the client record (what the Clients page reads) was
only refreshed by SyncInbound from the snapshot's settings JSON. A node
whose JSON still carried the negative duration (stale conversion, older
node build, or a mixed local+node attachment) kept rewriting the record
back to "not started" even though the DB held the real deadline (#5714).

Lift the activated deadline from client_traffics onto still-negative
client records at the end of every node sync, after SyncInbound has run.
Intentional resets back to delayed start are unaffected: editing a client
also resets client_traffics to the negative duration, so the lift's
expiry_time > 0 guard never matches.

Closes #5714
2026-07-02 09:36:07 +02:00

184 lines
8.5 KiB
Go

package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// TestMergeActivationExpiry covers the pure reconciliation rule in isolation.
func TestMergeActivationExpiry(t *testing.T) {
const (
dur = int64(-2592000000) // 30 days as a "start after first connect" duration
early = int64(1000) // earliest absolute deadline (first connection)
late = int64(2000) // a later absolute deadline
)
cases := []struct {
name string
existing, node int64
want int64
}{
{"master unset takes node duration", 0, dur, dur},
{"master unset takes node activation", 0, early, early},
{"activation adopted over stored duration", dur, early, early},
{"node still un-activated does not reset deadline", early, dur, early},
{"node un-activated zero does not reset deadline", early, 0, early},
{"node renewal extends the deadline forward", early, late, late},
{"node positive adopted even if earlier", late, early, early},
{"both un-activated keep node value", dur, dur, dur},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := mergeActivationExpiry(c.existing, c.node); got != c.want {
t.Fatalf("mergeActivationExpiry(%d,%d) = %d, want %d", c.existing, c.node, got, c.want)
}
})
}
}
// TestNodeFirstConnectExpiry_NotClobbered reproduces the multi-node bug: a
// client is attached to inbounds on two nodes with a "start after first connect"
// expiry. The client connects only on node 1, which activates an absolute
// deadline; node 2 never sees a connection and keeps reporting the negative
// duration. The shared per-email client_traffics row must hold the activated
// deadline — a later node-2 sync must not reset it back to "not started".
func TestNodeFirstConnectExpiry_NotClobbered(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
createNodeInbound(t, db, 2, "n2-in", 41002)
svc := &InboundService{}
const email = "delayed"
const duration = int64(-2592000000) // 30 days, not yet started
// Both nodes start out reporting the un-activated negative duration.
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != duration {
t.Fatalf("before any connection: expiry = %d, want %d", got, duration)
}
// Client connects on node 1: it activates an absolute deadline.
const activated = int64(1893456000000) // some absolute ms timestamp
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
}
// Node 2 (no connection there) keeps reporting the negative duration. This
// must NOT reset the activated deadline.
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("node 2 clobbered the activated deadline: expiry = %d, want %d", got, activated)
}
// Subsequent node 1 syncs keep the same absolute deadline.
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("after further node 1 sync: expiry = %d, want %d", got, activated)
}
}
// TestNodeFirstConnectExpiry_NotClobbered_WithSettings exercises the full
// production sync path — snapshots carrying real settings JSON, which drives the
// GetClients/SyncInbound branch inside setRemoteTrafficLocked — to prove that
// branch does not re-derive the per-email client_traffics.expiry_time from the
// node's (still negative) settings and undo the merge guard.
func TestNodeFirstConnectExpiry_NotClobbered_WithSettings(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "delayed")
svc := &InboundService{}
const email = "delayed"
const duration = int64(-2592000000)
const activated = int64(1893456000000)
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
actSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":1893456000000}]}`
// Both nodes start un-activated.
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
// Node 1 activates (both its ClientStats and its settings now carry the
// absolute deadline, like a real node after adjustTraffics).
syncNodeWithSettings(t, svc, 1, "n1-in", actSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
}
// Node 2 still reports the negative duration in BOTH ClientStats and
// settings. Neither the merge nor SyncInbound may reset the deadline.
syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("node 2 settings-sync clobbered the deadline: expiry = %d, want %d", got, activated)
}
}
// TestNodeRenewExtendsExpiry guards against over-correcting: a node that renews
// a client (traffic reset / auto-renew) legitimately moves the deadline FORWARD
// to a later absolute timestamp, and that must still propagate to the master.
// The guard only rejects un-activated (<= 0) values, never a positive one.
func TestNodeRenewExtendsExpiry(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "renewing"
const first = int64(1893456000000)
const renewed = first + int64(2592000000) // +30 days after auto-renew
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 10, Down: 10, ExpiryTime: first, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != first {
t.Fatalf("after activation: expiry = %d, want %d", got, first)
}
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 20, Down: 20, ExpiryTime: renewed, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != renewed {
t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
}
}
// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates
// the deadline (positive ClientStats) while its settings JSON still carries the
// negative duration, so SyncInbound keeps writing the stale value into the
// client record and the Clients page shows "not started" forever.
func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
svc := &InboundService{}
const email = "delayed"
const duration = int64(-2592000000)
const activated = int64(1798448344010)
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil {
t.Fatalf("seed client record: %v", err)
}
readRecordExpiry := func() int64 {
t.Helper()
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("read client record: %v", err)
}
return rec.ExpiryTime
}
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
if got := readRecordExpiry(); got != duration {
t.Fatalf("before activation: record expiry = %d, want %d", got, duration)
}
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated)
}
if got := readRecordExpiry(); got != activated {
t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated)
}
}