package service import ( "errors" "fmt" "testing" "gorm.io/gorm" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // Delete tombstones up front and keeps the record when an inbound fails. A // surviving tombstone lets the next node merge finish the refused deletion. func TestFailedDeleteWithdrawsTombstone(t *testing.T) { setupBulkDB(t) svc := &ClientService{} inboundSvc := &InboundService{} db := database.GetDB() const email = "retry@x" broken := mkInbound(t, 30401, model.VLESS, `{"clients": [ THIS IS NOT JSON`) rec := &model.ClientRecord{Email: email, Enable: true, UUID: "33333333-3333-3333-3333-333333333333"} if err := db.Create(rec).Error; err != nil { t.Fatalf("create client record: %v", err) } if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: broken.Id}).Error; err != nil { t.Fatalf("attach client: %v", err) } t.Cleanup(func() { withdrawClientTombstones(email) }) if _, err := svc.Delete(inboundSvc, rec.Id, false); err == nil { t.Fatal("setup: delete was expected to fail on the unparseable inbound settings") } var surviving int64 if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil { t.Fatalf("count clients: %v", err) } if surviving != 1 { t.Fatalf("failed delete must keep the record for a retry, got %d rows", surviving) } if isClientEmailTombstoned(email) { t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused") } } // A client re-created under a just-deleted email is a live identity again. If // the tombstone outlives it, the node merge prunes the new client's link. func TestRecreatedClientSurvivesNodeMerge(t *testing.T) { db := initTrafficTestDB(t) svc := &ClientService{} inboundSvc := &InboundService{} const email = "reborn@x" seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) createNodeInboundWithClient(t, db, 1, "n1-in", 41501, email) nodeSettings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) syncNodeWithSettings(t, inboundSvc, 1, "n1-in", nodeSettings, xray.ClientTraffic{Email: email, Enable: true}) var ib model.Inbound if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { t.Fatalf("load inbound: %v", err) } rec := &model.ClientRecord{} if err := db.Where("email = ?", email).First(rec).Error; err != nil { t.Fatalf("load adopted client record: %v", err) } t.Cleanup(func() { withdrawClientTombstones(email) }) if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil { t.Fatalf("delete client: %v", err) } if _, err := svc.Create(inboundSvc, &ClientCreatePayload{ Client: model.Client{Email: email, Enable: true, ID: "44444444-4444-4444-4444-444444444444"}, InboundIds: []int{ib.Id}, }); err != nil { t.Fatalf("re-create client: %v", err) } // The create marks the node config-dirty, which parks the client merge; the // reconcile clears it a tick later, well inside the 90s tombstone window. if err := db.Model(&model.Node{}).Where("id = ?", 1).Update("config_dirty", false).Error; err != nil { t.Fatalf("clear config_dirty: %v", err) } snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ Tag: "n1-in", Protocol: model.VLESS, Settings: nodeSettings, ClientStats: []xray.ClientTraffic{{Email: email, Enable: true}}, }}} if _, err := inboundSvc.setRemoteTrafficLocked(1, snap, false, false); err != nil { t.Fatalf("node merge: %v", err) } var links int64 if err := db.Model(&model.ClientInbound{}). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Where("clients.email = ? AND client_inbounds.inbound_id = ?", email, ib.Id). Count(&links).Error; err != nil { t.Fatalf("count client links: %v", err) } if links != 1 { t.Fatalf("re-created client has %d inbound links after the node merge, want 1: the delete tombstone outlived the email and the merge pruned it", links) } } // Re-adding a node inbound with a just-deleted email creates that client again // through AddInbound, so the next merge must not prune it as the deleted one. func TestReaddedNodeInboundKeepsRecreatedClient(t *testing.T) { db := initTrafficTestDB(t) inboundSvc := &InboundService{} clientSvc := &ClientService{} const email = "tunnel-81@x" seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, InboundSyncMode: "all"}) recreated := fmt.Sprintf(`{"id": "55555555-5555-4555-8555-555555555555", "email": %q, "enable": true}`, email) nodeInbound := func(clients string) *model.Inbound { nodeID := 1 return &model.Inbound{ UserId: 1, NodeID: &nodeID, Enable: true, Listen: "127.0.0.1", Port: 18453, Protocol: model.VLESS, Settings: `{"clients": [` + clients + `], "decryption": "none"}`, StreamSettings: `{"network": "tcp", "security": "none"}`, } } first, _, err := inboundSvc.AddInbound(nodeInbound(recreated)) if err != nil { t.Fatalf("add node inbound: %v", err) } if _, err := inboundSvc.DelInbound(first.Id); err != nil { t.Fatalf("delete node inbound: %v", err) } t.Cleanup(func() { withdrawClientTombstones(email) }) // inbounds/del only detaches; the record must go too or the re-add is a duplicate. if _, err := clientSvc.DeleteByEmail(inboundSvc, email, false); err != nil { t.Fatalf("delete detached client: %v", err) } // A second client keeps the tombstone-filtered snapshot non-empty, past the #6734 guard. readded, _, err := inboundSvc.AddInbound(nodeInbound(recreated + `, {"id": "66666666-6666-4666-8666-666666666666", "email": "neighbour@x", "enable": true}`)) if err != nil { t.Fatalf("re-add node inbound: %v", err) } // The next tick reconciles the push, clears dirty and merges a snapshot that has it. if err := db.Model(&model.Node{}).Where("id = ?", 1).Update("config_dirty", false).Error; err != nil { t.Fatalf("clear config_dirty: %v", err) } snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ Tag: readded.Tag, Enable: true, Listen: readded.Listen, Port: readded.Port, Protocol: model.VLESS, Settings: readded.Settings, StreamSettings: readded.StreamSettings, ClientStats: []xray.ClientTraffic{{Email: email, Enable: true}, {Email: "neighbour@x", Enable: true}}, }}} if _, err := inboundSvc.setRemoteTrafficLocked(1, snap, false, true); err != nil { t.Fatalf("node merge: %v", err) } var rec model.ClientRecord if err := db.Where("email = ?", email).First(&rec).Error; err != nil { t.Fatalf("load re-created client: %v", err) } var links int64 if err := db.Model(&model.ClientInbound{}). Where("client_id = ? AND inbound_id = ?", rec.Id, readded.Id). Count(&links).Error; err != nil { t.Fatalf("count client links: %v", err) } if links != 1 || rec.SyncOrphanedAt != 0 { t.Fatalf("after the merge the re-added client has %d links to its inbound and sync_orphaned_at=%d, want 1 and 0: the delete tombstone outlived the re-add and the merge pruned the client for ReapSyncOrphans", links, rec.SyncOrphanedAt) } } // The lift waits for the commit: a create that rolls back must leave the tombstone // standing, or a snapshot taken before the delete resurrects the client. func TestRolledBackCreateKeepsTombstone(t *testing.T) { setupBulkDB(t) const email = "rolled-back@x" ib := mkInbound(t, 30451, model.VLESS, `{"clients": []}`) tombstoneClientEmail(email) t.Cleanup(func() { withdrawClientTombstones(email) }) errAbort := errors.New("abort after the client was created") err := runSerializedTx(func(tx *gorm.DB) error { created := []model.Client{{Email: email, ID: "77777777-7777-4777-8777-777777777777", Enable: true}} if err := (&ClientService{}).SyncInbound(tx, ib.Id, created); err != nil { return err } return errAbort }) if !errors.Is(err, errAbort) { t.Fatalf("runSerializedTx error = %v, want the injected abort", err) } if !isClientEmailTombstoned(email) { t.Fatal("a create that rolled back withdrew the delete tombstone, so a stale node snapshot can resurrect the deleted client") } }