package discovery import ( "net/url" "testing" ) // cannedDescriptionXML is a realistic UPnP device description that includes // a root device with one icon, a ContentDirectory service, and one sub-device // (mimicking the FRITZ!Box nesting pattern). Used to exercise parseDescription // and FindService without any network I/O. const cannedDescriptionXML = ` 10 http://192.0.2.1:49000 urn:schemas-upnp-org:device:Basic:1 FRITZ!Box 7590 AVM FRITZ!Box 7590 SN-001 uuid:root-device-0001 image/png 48 48 /icons/root.png urn:schemas-upnp-org:service:Layer3Forwarding:1 /ctl/L3Fwd /evt/L3Fwd /L3Fwd.xml urn:schemas-upnp-org:device:MediaServer:1 FRITZ!Box NAS AVM FRITZ!NAS SN-002 uuid:media-server-0001 image/png 32 32 /icons/nas.png urn:schemas-upnp-org:service:ContentDirectory:1 /ctl/ContentDir /evt/ContentDir /ContentDir.xml ` // TestParseDescription_Fields checks that parseDescription populates the // root-device fields correctly from the canned XML. func TestParseDescription_Fields(t *testing.T) { location := "http://192.0.2.1:49000/rootDesc.xml" desc, err := parseDescription([]byte(cannedDescriptionXML), location) if err != nil { t.Fatalf("parseDescription: %v", err) } if desc.URLBase != "http://192.0.2.1:49000" { t.Errorf("URLBase = %q, want %q", desc.URLBase, "http://192.0.2.1:49000") } root := desc.Root if root.FriendlyName != "FRITZ!Box 7590" { t.Errorf("FriendlyName = %q, want %q", root.FriendlyName, "FRITZ!Box 7590") } if root.UDN != "uuid:root-device-0001" { t.Errorf("UDN = %q, want %q", root.UDN, "uuid:root-device-0001") } if root.Manufacturer != "AVM" { t.Errorf("Manufacturer = %q, want %q", root.Manufacturer, "AVM") } if root.ModelName != "FRITZ!Box 7590" { t.Errorf("ModelName = %q, want %q", root.ModelName, "FRITZ!Box 7590") } if len(root.Devices) != 1 { t.Fatalf("root sub-devices = %d, want 1", len(root.Devices)) } sub := root.Devices[0] if sub.FriendlyName != "FRITZ!Box NAS" { t.Errorf("sub FriendlyName = %q, want %q", sub.FriendlyName, "FRITZ!Box NAS") } } // TestParseDescription_FindService confirms that FindService recurses into // sub-devices and resolves the controlURL to an absolute form using URLBase. func TestParseDescription_FindService(t *testing.T) { location := "http://192.0.2.1:49000/rootDesc.xml" desc, err := parseDescription([]byte(cannedDescriptionXML), location) if err != nil { t.Fatalf("parseDescription: %v", err) } // ContentDirectory is in the sub-device, not the root. svc, ok := desc.FindService("urn:schemas-upnp-org:service:ContentDirectory:1") if !ok { t.Fatal("FindService(ContentDirectory:1): not found") } // URLBase is http://192.0.2.1:49000, controlURL is /ctl/ContentDir. wantControlURL := "http://192.0.2.1:49000/ctl/ContentDir" if svc.ControlURL != wantControlURL { t.Errorf("ControlURL = %q, want %q", svc.ControlURL, wantControlURL) } // Root-only service should also be found. l3, ok := desc.FindService("urn:schemas-upnp-org:service:Layer3Forwarding:1") if !ok { t.Fatal("FindService(Layer3Forwarding:1): not found") } if l3.ControlURL != "http://192.0.2.1:49000/ctl/L3Fwd" { t.Errorf("Layer3Forwarding ControlURL = %q", l3.ControlURL) } } // TestParseDescription_FindService_Missing ensures false is returned when the // service does not exist in the tree. func TestParseDescription_FindService_Missing(t *testing.T) { location := "http://192.0.2.1:49000/rootDesc.xml" desc, err := parseDescription([]byte(cannedDescriptionXML), location) if err != nil { t.Fatalf("parseDescription: %v", err) } _, ok := desc.FindService("urn:schemas-upnp-org:service:DoesNotExist:1") if ok { t.Error("FindService(DoesNotExist:1) returned ok=true, want false") } } // TestParseDescription_FirstIcon checks that icon URLs are resolved to // absolute form and that FirstIcon returns the root-device icon. func TestParseDescription_FirstIcon(t *testing.T) { location := "http://192.0.2.1:49000/rootDesc.xml" desc, err := parseDescription([]byte(cannedDescriptionXML), location) if err != nil { t.Fatalf("parseDescription: %v", err) } ic, ok := desc.FirstIcon() if !ok { t.Fatal("FirstIcon: not found") } wantURL := "http://192.0.2.1:49000/icons/root.png" if ic.URL != wantURL { t.Errorf("icon URL = %q, want %q", ic.URL, wantURL) } if ic.Width != 48 { t.Errorf("icon Width = %d, want 48", ic.Width) } if ic.MimeType != "image/png" { t.Errorf("icon MimeType = %q, want %q", ic.MimeType, "image/png") } } // TestParseDescription_RelativeURLResolution tests URL resolution without // URLBase (falls back to the location URL). func TestParseDescription_RelativeURLResolution(t *testing.T) { const xmlNoURLBase = ` urn:schemas-upnp-org:device:MediaServer:1 Mini NAS uuid:mini-001 urn:schemas-upnp-org:service:ContentDirectory:1 /ctl/CDS ` location := "http://198.51.100.5:8200/rootDesc.xml" desc, err := parseDescription([]byte(xmlNoURLBase), location) if err != nil { t.Fatalf("parseDescription: %v", err) } svc, ok := desc.FindService("urn:schemas-upnp-org:service:ContentDirectory:1") if !ok { t.Fatal("FindService: not found") } // Without URLBase, base URL comes from location. want := "http://198.51.100.5:8200/ctl/CDS" if svc.ControlURL != want { t.Errorf("ControlURL = %q, want %q", svc.ControlURL, want) } } // TestAbsURL_Variants exercises the absURL helper with several input // combinations. func TestAbsURL_Variants(t *testing.T) { cases := []struct { base string ref string want string }{ // Relative path resolved against explicit-port base. {"http://192.0.2.1:49000/desc.xml", "/ctl/CDS", "http://192.0.2.1:49000/ctl/CDS"}, // Already absolute: returned unchanged. {"http://192.0.2.1:49000/", "http://198.51.100.5:8200/ctl/CDS", "http://198.51.100.5:8200/ctl/CDS"}, // Empty ref: returned as-is. {"http://192.0.2.1:49000/", "", ""}, } for _, tc := range cases { base, err := url.Parse(tc.base) if err != nil { t.Fatalf("url.Parse(%q): %v", tc.base, err) } got := absURL(base, tc.ref) if got != tc.want { t.Errorf("absURL(%q, %q) = %q, want %q", tc.base, tc.ref, got, tc.want) } } }