// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package portmapper
import (
	"context"
	"encoding/xml"
	"net/http"
	"net/url"
	"strings"
	"testing"
	"github.com/tailscale/goupnp"
	"github.com/tailscale/goupnp/dcps/internetgateway2"
)
// NOTE: this is in a distinct file because the various string constants are
// pretty verbose.
func TestSelectBestService(t *testing.T) {
	mustParseURL := func(ss string) *url.URL {
		u, err := url.Parse(ss)
		if err != nil {
			t.Fatalf("error parsing URL %q: %v", ss, err)
		}
		return u
	}
	// Run a fake IGD server to respond to UPnP requests.
	igd, err := NewTestIGD(t, TestIGDOptions{UPnP: true})
	if err != nil {
		t.Fatal(err)
	}
	defer igd.Close()
	testCases := []struct {
		name     string
		rootDesc string
		control  map[string]map[string]any
		want     string // controlURL field
	}{
		{
			name:     "single_device",
			rootDesc: testRootDesc,
			control: map[string]map[string]any{
				// Service that's up and should be selected.
				"/ctl/IPConn": {
					"GetExternalIPAddress": testGetExternalIPAddressResponse,
					"GetStatusInfo":        testGetStatusInfoResponse,
				},
			},
			want: "/ctl/IPConn",
		},
		{
			name:     "first_device_disconnected",
			rootDesc: testSelectRootDesc,
			control: map[string]map[string]any{
				// Service that's down; it's important that this is the
				// one that's down since it's ordered first in the XML
				// and we want to verify that our code properly queries
				// and then skips it.
				"/upnp/control/yomkmsnooi/wanipconn-1": {
					"GetStatusInfo": testGetStatusInfoResponseDisconnected,
					// NOTE: nothing else should be called
					// if GetStatusInfo returns a
					// disconnected result
				},
				// Service that's up and should be selected.
				"/upnp/control/xstnsgeuyh/wanipconn-7": {
					"GetExternalIPAddress": testGetExternalIPAddressResponse,
					"GetStatusInfo":        testGetStatusInfoResponse,
				},
			},
			want: "/upnp/control/xstnsgeuyh/wanipconn-7",
		},
		{
			name:     "prefer_public_external_IP",
			rootDesc: testSelectRootDesc,
			control: map[string]map[string]any{
				// Service with a private external IP; order matters as above.
				"/upnp/control/yomkmsnooi/wanipconn-1": {
					"GetStatusInfo":        testGetStatusInfoResponse,
					"GetExternalIPAddress": testGetExternalIPAddressResponsePrivate,
				},
				// Service that's up and should be selected.
				"/upnp/control/xstnsgeuyh/wanipconn-7": {
					"GetExternalIPAddress": testGetExternalIPAddressResponse,
					"GetStatusInfo":        testGetStatusInfoResponse,
				},
			},
			want: "/upnp/control/xstnsgeuyh/wanipconn-7",
		},
		{
			name:     "all_private_external_IPs",
			rootDesc: testSelectRootDesc,
			control: map[string]map[string]any{
				"/upnp/control/yomkmsnooi/wanipconn-1": {
					"GetStatusInfo":        testGetStatusInfoResponse,
					"GetExternalIPAddress": testGetExternalIPAddressResponsePrivate,
				},
				"/upnp/control/xstnsgeuyh/wanipconn-7": {
					"GetStatusInfo":        testGetStatusInfoResponse,
					"GetExternalIPAddress": testGetExternalIPAddressResponsePrivate,
				},
			},
			want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML
		},
		{
			name:     "nothing_connected",
			rootDesc: testSelectRootDesc,
			control: map[string]map[string]any{
				"/upnp/control/yomkmsnooi/wanipconn-1": {
					"GetStatusInfo": testGetStatusInfoResponseDisconnected,
				},
				"/upnp/control/xstnsgeuyh/wanipconn-7": {
					"GetStatusInfo": testGetStatusInfoResponseDisconnected,
				},
			},
			want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML
		},
		{
			name:     "GetStatusInfo_errors",
			rootDesc: testSelectRootDesc,
			control: map[string]map[string]any{
				"/upnp/control/yomkmsnooi/wanipconn-1": {
					"GetStatusInfo": func(_ string) (int, string) {
						return http.StatusInternalServerError, "internal error"
					},
				},
				"/upnp/control/xstnsgeuyh/wanipconn-7": {
					"GetStatusInfo": func(_ string) (int, string) {
						return http.StatusNotFound, "not found"
					},
				},
			},
			want: "/upnp/control/yomkmsnooi/wanipconn-1", // since this is first in the XML
		},
		{
			name:     "GetExternalIPAddress_bad_ip",
			rootDesc: testSelectRootDesc,
			control: map[string]map[string]any{
				"/upnp/control/yomkmsnooi/wanipconn-1": {
					"GetStatusInfo":        testGetStatusInfoResponse,
					"GetExternalIPAddress": testGetExternalIPAddressResponseInvalid,
				},
				"/upnp/control/xstnsgeuyh/wanipconn-7": {
					"GetStatusInfo":        testGetStatusInfoResponse,
					"GetExternalIPAddress": testGetExternalIPAddressResponse,
				},
			},
			want: "/upnp/control/xstnsgeuyh/wanipconn-7",
		},
	}
	for _, tt := range testCases {
		t.Run(tt.name, func(t *testing.T) {
			// Ensure that we're using our test IGD server for all requests.
			rootDesc := strings.ReplaceAll(tt.rootDesc, "@SERVERURL@", igd.ts.URL)
			igd.SetUPnPHandler(&upnpServer{
				t:       t,
				Desc:    rootDesc,
				Control: tt.control,
			})
			c := newTestClient(t, igd, nil)
			t.Logf("Listening on upnp=%v", c.testUPnPPort)
			// Ensure that we're using the HTTP client that talks to our test IGD server
			ctx := context.Background()
			ctx = goupnp.WithHTTPClient(ctx, c.upnpHTTPClientLocked())
			loc := mustParseURL(igd.ts.URL)
			rootDev := mustParseRootDev(t, rootDesc, loc)
			svc, err := selectBestService(ctx, t.Logf, rootDev, loc)
			if err != nil {
				t.Fatal(err)
			}
			var controlURL string
			switch v := svc.(type) {
			case *internetgateway2.WANIPConnection2:
				controlURL = v.ServiceClient.Service.ControlURL.Str
			case *internetgateway2.WANIPConnection1:
				controlURL = v.ServiceClient.Service.ControlURL.Str
			case *internetgateway2.WANPPPConnection1:
				controlURL = v.ServiceClient.Service.ControlURL.Str
			default:
				t.Fatalf("unknown client type: %T", v)
			}
			if controlURL != tt.want {
				t.Errorf("mismatched controlURL: got=%q want=%q", controlURL, tt.want)
			}
		})
	}
}
func mustParseRootDev(t *testing.T, devXML string, loc *url.URL) *goupnp.RootDevice {
	decoder := xml.NewDecoder(strings.NewReader(devXML))
	decoder.DefaultSpace = goupnp.DeviceXMLNamespace
	decoder.CharsetReader = goupnp.CharsetReaderDefault
	root := new(goupnp.RootDevice)
	if err := decoder.Decode(root); err != nil {
		t.Fatalf("error decoding device XML: %v", err)
	}
	// Ensure the URLBase is set properly; this is how DeviceByURL does it.
	var urlBaseStr string
	if root.URLBaseStr != "" {
		urlBaseStr = root.URLBaseStr
	} else {
		urlBaseStr = loc.String()
	}
	urlBase, err := url.Parse(urlBaseStr)
	if err != nil {
		t.Fatalf("error parsing URL %q: %v", urlBaseStr, err)
	}
	root.SetURLBase(urlBase)
	return root
}
// Note: adapted from mikrotikRootDescXML with addresses replaced with
// localhost, and unnecessary fields removed.
const testSelectRootDesc = `
  
    1
    0
  
  
    urn:schemas-upnp-org:device:InternetGatewayDevice:1
    MikroTik Router
    MikroTik
    https://www.mikrotik.com/
    Router OS
    uuid:UUID-MIKROTIK-INTERNET-GATEWAY-DEVICE-
    
      
        urn:schemas-microsoft-com:service:OSInfo:1
        urn:microsoft-com:serviceId:OSInfo1
        /osinfo.xml
        /upnp/control/oqjsxqshhz/osinfo
        /upnp/event/cwzcyndrjf/osinfo
      
    
    
      
        urn:schemas-upnp-org:device:WANDevice:1
        WAN Device
        MikroTik
        https://www.mikrotik.com/
        Router OS
        uuid:UUID-MIKROTIK-WAN-DEVICE--1
        
          
            urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1
            urn:upnp-org:serviceId:WANCommonIFC1
            /wancommonifc-1.xml
            /upnp/control/ivvmxhunyq/wancommonifc-1
            /upnp/event/mkjzdqvryf/wancommonifc-1
          
        
        
          
            urn:schemas-upnp-org:device:WANConnectionDevice:1
            WAN Connection Device
            MikroTik
            https://www.mikrotik.com/
            Router OS
            uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--1
            
              
                urn:schemas-upnp-org:service:WANIPConnection:1
                urn:upnp-org:serviceId:WANIPConn1
                /wanipconn-1.xml
                /upnp/control/yomkmsnooi/wanipconn-1
                /upnp/event/veeabhzzva/wanipconn-1
              
            
          
        
      
      
        urn:schemas-upnp-org:device:WANDevice:1
        WAN Device
        MikroTik
        https://www.mikrotik.com/
        Router OS
        uuid:UUID-MIKROTIK-WAN-DEVICE--7
        
          
            urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1
            urn:upnp-org:serviceId:WANCommonIFC1
            /wancommonifc-7.xml
            /upnp/control/vzcyyzzttz/wancommonifc-7
            /upnp/event/womwbqtbkq/wancommonifc-7
          
        
        
          
            urn:schemas-upnp-org:device:WANConnectionDevice:1
            WAN Connection Device
            MikroTik
            https://www.mikrotik.com/
            Router OS
            uuid:UUID-MIKROTIK-WAN-CONNECTION-DEVICE--7
            
              
                urn:schemas-upnp-org:service:WANIPConnection:1
                urn:upnp-org:serviceId:WANIPConn1
                /wanipconn-7.xml
                /upnp/control/xstnsgeuyh/wanipconn-7
                /upnp/event/rscixkusbs/wanipconn-7
              
            
          
        
      
    
    @SERVERURL@
  
  @SERVERURL@
`
const testGetStatusInfoResponseDisconnected = `
  
    
      Disconnected
      ERROR_NONE
      0
    
  
`
const testGetExternalIPAddressResponsePrivate = `
  
    
      10.9.8.7
    
  
`
const testGetExternalIPAddressResponseInvalid = `
  
    
      not-an-ip-addr
    
  
`