|
@@ -0,0 +1,121 @@
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "sync/atomic"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// dirtyProbeRuntime reads a node's config_dirty flag at the moment an inbound
|
|
|
|
|
+// pushes, i.e. while the edit's other inbounds are still unapplied.
|
|
|
|
|
+type dirtyProbeRuntime struct {
|
|
|
|
|
+ fakeNodeRuntime
|
|
|
|
|
+ watch int
|
|
|
|
|
+ sawSet atomic.Bool
|
|
|
|
|
+ probed atomic.Bool
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (d *dirtyProbeRuntime) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, c model.Client) error {
|
|
|
|
|
+ var node model.Node
|
|
|
|
|
+ if err := database.GetDB().Where("id = ?", d.watch).First(&node).Error; err == nil {
|
|
|
|
|
+ d.sawSet.Store(node.ConfigDirty)
|
|
|
|
|
+ d.probed.Store(true)
|
|
|
|
|
+ }
|
|
|
|
|
+ return d.fakeNodeRuntime.UpdateUser(ctx, ib, oldEmail, c)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestEditFlagsOtherNodesBeforeApplying pins the ordering: every node is flagged
|
|
|
|
|
+// BEFORE any inbound applies, so no merge can slot into the fanout gap.
|
|
|
|
|
+func TestEditFlagsOtherNodesBeforeApplying(t *testing.T) {
|
|
|
|
|
+ setupBulkDB(t)
|
|
|
|
|
+ startSerializedWriter(t)
|
|
|
|
|
+ mgr := useTestRuntimeManager(t)
|
|
|
|
|
+ db := database.GetDB()
|
|
|
|
|
+
|
|
|
|
|
+ const uuid = "eeeeeeee-1111-2222-3333-444444444444"
|
|
|
|
|
+ probe := &dirtyProbeRuntime{}
|
|
|
|
|
+ ids := fanoutNodeInbounds(t, mgr, probe, 2, 47200)
|
|
|
|
|
+
|
|
|
|
|
+ // The watched node is the one whose apply is made to fail, so nothing but
|
|
|
|
|
+ // the up-front marking can have flagged it when the other inbound pushes.
|
|
|
|
|
+ var victim model.Inbound
|
|
|
|
|
+ if err := db.Where("id = ?", ids[1]).First(&victim).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("read inbound %d: %v", ids[1], err)
|
|
|
|
|
+ }
|
|
|
|
|
+ probe.watch = *victim.NodeID
|
|
|
|
|
+
|
|
|
|
|
+ if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
|
|
|
|
|
+ Client: model.Client{Email: "carol", ID: uuid, SubID: "sub-carol", Enable: true},
|
|
|
|
|
+ InboundIds: ids,
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("seed Create: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Model(&model.Inbound{}).Where("id = ?", ids[1]).
|
|
|
|
|
+ Update("settings", `{"clients":`).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("corrupt inbound %d: %v", ids[1], err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Model(model.Node{}).Where("1 = 1").
|
|
|
|
|
+ Update("config_dirty", false).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("clear config_dirty: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ rec := lookupClientRecord(t, "carol")
|
|
|
|
|
+ if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, model.Client{
|
|
|
|
|
+ Email: "carol-renamed", ID: uuid, SubID: "sub-carol", Enable: true,
|
|
|
|
|
+ }, 0); err == nil {
|
|
|
|
|
+ t.Fatal("Update on a corrupted inbound should report the failure")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if !probe.probed.Load() {
|
|
|
|
|
+ t.Fatal("the healthy inbound never pushed, so the ordering was never observed")
|
|
|
|
|
+ }
|
|
|
|
|
+ if !probe.sawSet.Load() {
|
|
|
|
|
+ t.Fatal("a node was still clean while another inbound of the same edit was applying: a snapshot merge in that gap would resurrect the pre-edit email")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestEditFlagsNodesOutsideTheInboundFilter pins that the marking covers the
|
|
|
|
|
+// client's whole attachment set, not just the inbounds the filter applies.
|
|
|
|
|
+func TestEditFlagsNodesOutsideTheInboundFilter(t *testing.T) {
|
|
|
|
|
+ setupBulkDB(t)
|
|
|
|
|
+ startSerializedWriter(t)
|
|
|
|
|
+ mgr := useTestRuntimeManager(t)
|
|
|
|
|
+ db := database.GetDB()
|
|
|
|
|
+
|
|
|
|
|
+ const uuid = "ffffffff-1111-2222-3333-444444444444"
|
|
|
|
|
+ ids := fanoutNodeInbounds(t, mgr, &fakeNodeRuntime{}, 2, 47300)
|
|
|
|
|
+ if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
|
|
|
|
|
+ Client: model.Client{Email: "dave", ID: uuid, SubID: "sub-dave", Enable: true},
|
|
|
|
|
+ InboundIds: ids,
|
|
|
|
|
+ }); err != nil {
|
|
|
|
|
+ t.Fatalf("seed Create: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Model(model.Node{}).Where("1 = 1").
|
|
|
|
|
+ Update("config_dirty", false).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("clear config_dirty: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Edit scoped to the first inbound only; the second one's node still holds
|
|
|
|
|
+ // the old email once the shared record is renamed.
|
|
|
|
|
+ rec := lookupClientRecord(t, "dave")
|
|
|
|
|
+ if _, err := (&ClientService{}).Update(&InboundService{}, rec.Id, model.Client{
|
|
|
|
|
+ Email: "dave-renamed", ID: uuid, SubID: "sub-dave", Enable: true,
|
|
|
|
|
+ }, 0, ids[0]); err != nil {
|
|
|
|
|
+ t.Fatalf("filtered Update: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var excluded model.Inbound
|
|
|
|
|
+ if err := db.Where("id = ?", ids[1]).First(&excluded).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("read inbound %d: %v", ids[1], err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var node model.Node
|
|
|
|
|
+ if err := db.Where("id = ?", *excluded.NodeID).First(&node).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("read node %d: %v", *excluded.NodeID, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !node.ConfigDirty {
|
|
|
|
|
+ t.Fatal("a node left out of the inboundIds filter stayed clean after the shared record was renamed, so its stale snapshot would be merged")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|