193 lines
7.3 KiB
Go
193 lines
7.3 KiB
Go
package netbox
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func sourceTestServer(t *testing.T, token, ip, comment string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Token "+token {
|
|
t.Errorf("credentials crossed sources: %q, want %q", got, token)
|
|
http.Error(w, "bad token", 401)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/api/dcim/devices/":
|
|
json.NewEncoder(w).Encode(deviceListResponse(netboxDevice{ID: 1, Name: "web", PrimaryIP4: &netboxIP{Address: ip + "/24"}}))
|
|
case "/api/virtualization/virtual-machines/":
|
|
json.NewEncoder(w).Encode(vmListResponse())
|
|
case "/api/ipam/ip-addresses/":
|
|
if r.URL.Query().Get("device_id") != "1" {
|
|
t.Errorf("wrong host ID: %s", r.URL.RawQuery)
|
|
}
|
|
json.NewEncoder(w).Encode(ipListResponse(ip + "/24"))
|
|
case "/api/dcim/devices/1/":
|
|
if r.Method == http.MethodPatch {
|
|
var data map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if data["comments"] != comment+" updated" {
|
|
t.Errorf("comment write crossed sources: %v", data)
|
|
}
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]string{"comments": comment})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestMultiSourceSearchAndRouting(t *testing.T) {
|
|
office := sourceTestServer(t, "office-token", "10.0.0.1", "office note")
|
|
lab := sourceTestServer(t, "lab-token", "10.0.0.2", "lab note")
|
|
c := NewMultiClient([]Source{{Name: "office", URL: office.URL, Token: "office-token"}, {Name: "lab", URL: lab.URL, Token: "lab-token"}})
|
|
ctx := context.Background()
|
|
entries, err := c.Search(ctx, "web", SearchOptions{})
|
|
if err != nil || len(entries) != 2 {
|
|
t.Fatalf("combined search: %+v, %v", entries, err)
|
|
}
|
|
if entries[0].Target() != "lab/web" || entries[1].Target() != "office/web" {
|
|
t.Fatalf("source lost: %+v", entries)
|
|
}
|
|
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
|
t.Fatal("ambiguous unqualified hostname accepted")
|
|
}
|
|
for _, tc := range []struct{ name, ip, note string }{{"office", "10.0.0.1", "office note"}, {"lab", "10.0.0.2", "lab note"}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
host, err := c.FindHost(ctx, tc.name+"/web", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if host.Source != tc.name || host.Name != "web" || host.ID != 1 {
|
|
t.Fatalf("wrong host identity: %+v", host)
|
|
}
|
|
ips, err := c.GetIPs(ctx, host)
|
|
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
|
t.Fatalf("wrong IP source: %v, %v", ips, err)
|
|
}
|
|
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0")
|
|
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
|
t.Fatalf("wrong interface source: %v, %v", ips, err)
|
|
}
|
|
text, _, err := c.GetComments(ctx, host)
|
|
if err != nil || text != tc.note {
|
|
t.Fatalf("wrong comment source: %q, %v", text, err)
|
|
}
|
|
// TUI cache items carry a qualified name rather than a separate source.
|
|
qualified := HostEntry{Name: tc.name + "/web", ID: 1, Kind: "device"}
|
|
if err := c.SetComments(ctx, qualified, tc.note+" updated", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|
|
if _, err := c.Search(ctx, "missing/web", SearchOptions{}); err == nil {
|
|
t.Fatal("unknown source accepted")
|
|
}
|
|
if _, _, err := c.HostScope(HostEntry{Name: "web", Kind: "device"}); err == nil {
|
|
t.Fatal("unqualified note namespace accepted")
|
|
}
|
|
if _, err := c.GetIPsWithFilter(ctx, "device_id=1"); err == nil {
|
|
t.Fatal("IP filter without instance accepted")
|
|
}
|
|
}
|
|
|
|
func TestMultiSourceSearchRunsConcurrently(t *testing.T) {
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
makeServer := func() *httptest.Server {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
wg.Done()
|
|
both := make(chan struct{})
|
|
go func() { wg.Wait(); close(both) }()
|
|
select {
|
|
case <-both:
|
|
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
|
case <-r.Context().Done():
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
a, b := makeServer(), makeServer()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
c := NewMultiClient([]Source{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}})
|
|
if _, err := c.Search(ctx, "web", SearchOptions{Kind: "device"}); err != nil {
|
|
t.Fatalf("sources did not run concurrently: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMultiSourcePartialFailure(t *testing.T) {
|
|
good := sourceTestServer(t, "good-token", "10.0.0.1", "note")
|
|
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", 503) }))
|
|
defer failed.Close()
|
|
c := NewMultiClient([]Source{{Name: "good", URL: good.URL, Token: "good-token"}, {Name: "offline", URL: failed.URL}})
|
|
ctx := context.Background()
|
|
entries, err := c.Search(ctx, "web", SearchOptions{})
|
|
if len(entries) != 1 || err == nil || !strings.Contains(err.Error(), "offline") {
|
|
t.Fatalf("partial search lost results or warning: %+v, %v", entries, err)
|
|
}
|
|
if _, err := c.SearchAll(ctx, "web", SearchOptions{}); err == nil {
|
|
t.Fatal("incomplete full search succeeded")
|
|
}
|
|
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
|
t.Fatal("incomplete search claimed unique host")
|
|
}
|
|
if host, err := c.FindHost(ctx, "good/web", ""); err != nil || host.Source != "good" {
|
|
t.Fatalf("qualified lookup blocked by unrelated outage: %+v, %v", host, err)
|
|
}
|
|
}
|
|
|
|
func TestMultiSourceKeepsIndependentDeviceAndVMInventories(t *testing.T) {
|
|
office := newTestServer(t, map[string]any{
|
|
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "office-router", PrimaryIP4: &netboxIP{Address: "10.1.0.1/24"}}),
|
|
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "office-vm", PrimaryIP4: &netboxIP{Address: "10.1.0.2/24"}}),
|
|
})
|
|
defer office.Close()
|
|
lab := newTestServer(t, map[string]any{
|
|
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "lab-switch", PrimaryIP4: &netboxIP{Address: "10.2.0.1/24"}}),
|
|
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "lab-vm", PrimaryIP4: &netboxIP{Address: "10.2.0.2/24"}}),
|
|
})
|
|
defer lab.Close()
|
|
client := NewMultiClient([]Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
|
entries, err := client.SearchAll(context.Background(), "", SearchOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]string{"office/office-router": "device", "office/office-vm": "vm", "lab/lab-switch": "device", "lab/lab-vm": "vm"}
|
|
if len(entries) != len(want) {
|
|
t.Fatalf("independent inventories missing: %+v", entries)
|
|
}
|
|
for _, entry := range entries {
|
|
if want[entry.Target()] != entry.Kind {
|
|
t.Fatalf("inventory crossed source or kind: %+v", entry)
|
|
}
|
|
}
|
|
vms, err := client.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
|
if err != nil || len(vms) != 2 {
|
|
t.Fatalf("VM filter across instances: %+v, %v", vms, err)
|
|
}
|
|
for _, host := range vms {
|
|
if host.Kind != "vm" {
|
|
t.Fatalf("device leaked into VM filter: %+v", host)
|
|
}
|
|
}
|
|
host, err := client.FindHost(context.Background(), "lab/lab-vm", "vm")
|
|
if err != nil || host.Source != "lab" || host.ID != 2 || host.PrimaryIP4 != "10.2.0.2" {
|
|
t.Fatalf("wrong VM selected: %+v, %v", host, err)
|
|
}
|
|
}
|