package sub import ( "strings" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) // Per-client links (GET /panel/api/clients/links/:email, served by // LinksForClient) must apply an inbound's managed Host records with the // same precedence as subscriptions (GetSubs) and export-all // (inboundLinks): after the fallback projection the host endpoint wins. // Before this coverage existed the per-client endpoint skipped hosts // entirely, for traditional and agent inbounds alike. const linksHostStream = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}` func TestLinksForClient_AppliesHostRecords(t *testing.T) { seedSubDB(t) ib := seedSubInbound(t, "s1", "h", 4433, 1, linksHostStream) seedHost(t, &model.Host{InboundId: ib.Id, Remark: "CDN", Address: "cdn.example.com", Port: 8443, Security: "tls", Sni: "cdn.sni"}) links := NewLinkProvider().LinksForClient("req.example.com", ib, "h@e") if len(links) != 1 { t.Fatalf("links = %d, want 1: %v", len(links), links) } if !strings.Contains(links[0], "cdn.example.com:8443") || !strings.Contains(links[0], "sni=cdn.sni") { t.Fatalf("per-client link must carry the host endpoint: %s", links[0]) } if strings.Contains(links[0], "203.0.113.5:4433") { t.Fatalf("per-client link leaked the plain inbound endpoint despite a host record: %s", links[0]) } } func TestLinksForClient_MultipleHostsOrdered(t *testing.T) { seedSubDB(t) ib := seedSubInbound(t, "s1", "m", 4435, 1, linksHostStream) seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls"}) seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 2096, Security: "tls"}) links := NewLinkProvider().LinksForClient("req.example.com", ib, "m@e") if len(links) != 2 { t.Fatalf("links = %d, want 2 (one per host): %v", len(links), links) } if !strings.Contains(links[0], "a.cdn.com:2096") || !strings.Contains(links[1], "b.cdn.com:8443") { t.Fatalf("host links out of order or wrong: %v", links) } } func TestLinksForClient_NoHosts_Unchanged(t *testing.T) { seedSubDB(t) ib := seedSubInbound(t, "s1", "z", 4436, 1, linksHostStream) links := NewLinkProvider().LinksForClient("req.example.com", ib, "z@e") if len(links) != 1 { t.Fatalf("links = %d, want 1: %v", len(links), links) } if !strings.Contains(links[0], "203.0.113.5:4436") { t.Fatalf("zero-host per-client link must keep the legacy endpoint: %s", links[0]) } } // seedAgentInbound wires a node + agent binding (telemetry IPv4) and an // inbound hosted on that node, mirroring production agent rows. func seedAgentInbound(t *testing.T, tag string, port int) (*model.Inbound, *model.Node) { t.Helper() db := database.GetDB() node := &model.Node{Name: "agent-" + tag, Scheme: "https", Address: "agent", Port: 2053, Enable: true} if err := db.Create(node).Error; err != nil { t.Fatalf("create node: %v", err) } ag := &model.AgentNode{TokenHash: "hash-" + tag, Name: node.Name, NodeID: &node.Id, IPv4: "198.51.100.23", LastSeen: 2000} if err := db.Create(ag).Error; err != nil { t.Fatalf("create binding: %v", err) } ib := seedSubInbound(t, "s1", tag, port, 1, linksHostStream) if err := db.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("node_id", node.Id).Error; err != nil { t.Fatalf("attach inbound to node: %v", err) } ib.NodeID = &node.Id return ib, node } func TestLinksForClient_AgentInbound_HostBeatsTelemetry(t *testing.T) { seedSubDB(t) ib, _ := seedAgentInbound(t, "ah", 4437) seedHost(t, &model.Host{InboundId: ib.Id, Remark: "CDN", Address: "cdn.example.com", Port: 8443, Security: "tls"}) links := NewLinkProvider().LinksForClient("req.example.com", ib, "ah@e") if len(links) != 1 { t.Fatalf("links = %d, want 1: %v", len(links), links) } if !strings.Contains(links[0], "cdn.example.com:8443") { t.Fatalf("agent per-client link must carry the host endpoint: %s", links[0]) } if strings.Contains(links[0], "198.51.100.23") { t.Fatalf("host record must win over the agent telemetry address: %s", links[0]) } } func TestLinksForClient_AgentInbound_NoHost_TelemetryPreserved(t *testing.T) { seedSubDB(t) ib, _ := seedAgentInbound(t, "an", 4438) links := NewLinkProvider().LinksForClient("req.example.com", ib, "an@e") if len(links) != 1 { t.Fatalf("links = %d, want 1: %v", len(links), links) } if !strings.Contains(links[0], "198.51.100.23:4438") { t.Fatalf("agent per-client link without hosts must keep the telemetry endpoint: %s", links[0]) } if strings.Contains(links[0], "@agent:") { t.Fatalf("agent per-client link leaks the placeholder address: %s", links[0]) } }