commit b717ce9bd51371aefd55ccd6ddf623f83aed8e29 from: Oliver Lowe date: Fri Feb 04 02:57:40 2022 UTC Test against a fake in-processs icinga server A lot of the testing doesn't really flex what a real icinga2 server does. We can implement a simple fake server with about 250 lines of Go, then we get all the benefits of really quick feedback and no dependency on an icinga2 server (which isn't super easy to set up) for most tests. More complicated tests are still performed against a real server listening on loopback (if available). commit - 49e200e1bf5862fe2a396ba4b3fa1646aa09e3ee commit + b717ce9bd51371aefd55ccd6ddf623f83aed8e29 blob - aee29003cd9406fed861d0fc0ecdff2202a5763d blob + bf68dac0cd9179bf79782212e1b34e1c2207bcca --- README.md +++ README.md @@ -32,18 +32,24 @@ For those unfamiliar with this workflow, see [git-send ### Tests +Some tests use a fake, in-process Icinga server. Not all features of +the API are implemented, but on any unsupported request it should +report an error. The fake server uses an in-memory map to store +Icinga2 objects, which maps object's path in the API request (e.g. +"objects/hosts/text.example.com") to the object's attributes (e.g. +`check_command` and `display_name`). + Some tests dial an instance of Icinga2 running on the loopback address -and the standard Icinga2 port 5665 ("127.0.0.1:5665"). If this fails, -those tests are skipped. To run these tests, create the following API -user: +and the standard Icinga2 port 5665 (`::1:5665`). If this fails, those +tests are skipped. To run these tests, create the following API user: - object ApiUser "root" { - password = "icinga" + object ApiUser "icinga" { + password = name permissions = [ "*" ] } -Getting data from 127.0.0.1:5665 to an Icinga server is left as an -exercise to the reader! +Getting data from the loopback interface to an Icinga server is left +as an exercise to the reader! Personally, I run an Alpine Linux virtual machine using qemu. You could also use the [official Icinga2 container image][image]. blob - 35cc262e6e5be08aa939f6a08886d9d7c1f82d26 blob + 1f48b98148f9c7834c9e516cdfa9238b4c297c99 --- icinga_test.go +++ icinga_test.go @@ -2,11 +2,18 @@ package icinga_test import ( "crypto/tls" + "encoding/json" "errors" + "fmt" + "io" "math/rand" "net/http" + "net/http/httptest" + "os" + "path" "reflect" "sort" + "strings" "testing" "time" @@ -26,33 +33,35 @@ func randomHostname(suffix string) string { return string(b) + suffix } -func createTestHosts(c *icinga.Client) ([]icinga.Host, error) { - hostgroup := icinga.HostGroup{Name: "test", DisplayName: "Test Group"} - if err := c.CreateHostGroup(hostgroup); err != nil && !errors.Is(err, icinga.ErrExist) { - return nil, err - } +func randomTestAddr() string { return fmt.Sprintf("192.0.2.%d", rand.Intn(254)) } +func randomHosts(n int, suffix string) []icinga.Host { var hosts []icinga.Host - for i := 0; i < 5; i++ { + for i := 0; i < n; i++ { h := icinga.Host{ - Name: randomHostname(".example.org"), + Name: randomHostname(suffix), CheckCommand: "random", - Groups: []string{hostgroup.Name}, + Groups: []string{"example"}, + Address: randomTestAddr(), } hosts = append(hosts, h) - if err := c.CreateHost(h); err != nil && !errors.Is(err, icinga.ErrExist) { - return nil, err - } } - return hosts, nil + return hosts } -func newTestClient() (*icinga.Client, error) { +func newTestClient(t *testing.T) *icinga.Client { tp := http.DefaultTransport.(*http.Transport) tp.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - c := http.DefaultClient - c.Transport = tp - return icinga.Dial("127.0.0.1:5665", "root", "icinga", c) + c := &http.Client{Transport: tp} + if client, err := icinga.Dial("::1:5665", "icinga", "icinga", c); err == nil { + return client + } + client, err := icinga.Dial("127.0.0.1:5665", "icinga", "icinga", c) + if err == nil { + return client + } + t.Skipf("cannot dial local icinga: %v", err) + return nil } func compareStringSlice(a, b []string) bool { @@ -68,46 +77,42 @@ func compareStringSlice(a, b []string) bool { } func TestFilter(t *testing.T) { - client, err := newTestClient() - if err != nil { - t.Skipf("no local test icinga? got: %v", err) - } + client := newTestClient(t) + var want, got []string // host names - var want, got []string - hosts, err := createTestHosts(client) - if err != nil { - t.Fatal(err) + hosts := randomHosts(10, "example.org") + for i := range hosts { + if err := client.CreateHost(hosts[i]); err != nil { + t.Fatal(err) + } + want = append(want, hosts[i].Name) } - for _, h := range hosts { - want = append(want, h.Name) - } - defer func() { - for _, h := range hosts { - if err := client.DeleteHost(h.Name, true); err != nil { - t.Log(err) + t.Cleanup(func() { + for i := range hosts { + if err := client.DeleteHost(hosts[i].Name, true); err != nil { + t.Error(err) } } - }() - hosts, err = client.Hosts("match(\"*example.org\", host.name)") + }) + + filter := `match("*example.org", host.name)` + hosts, err := client.Hosts(filter) if err != nil { t.Fatal(err) } - for _, h := range hosts { - got = append(got, h.Name) + for i := range hosts { + got = append(got, hosts[i].Name) } + sort.Strings(want) sort.Strings(got) if !compareStringSlice(want, got) { - t.Fail() + t.Error("want", want, "got", got) } - t.Logf("want %+v got %+v", want, got) } func TestUserRoundTrip(t *testing.T) { - client, err := newTestClient() - if err != nil { - t.Skipf("no local test icinga? got: %v", err) - } + client := newTestClient(t) want := icinga.User{Name: "olly", Email: "olly@example.com", Groups: []string{}} if err := client.CreateUser(want); err != nil && !errors.Is(err, icinga.ErrExist) { t.Fatal(err) @@ -127,98 +132,234 @@ func TestUserRoundTrip(t *testing.T) { } func TestChecker(t *testing.T) { - client, err := newTestClient() - if err != nil { - t.Skipf("no local test icinga? got: %v", err) - } - - h := icinga.Host{ - Name: randomHostname(".checker.example.com"), - CheckCommand: "hostalive", - } + client := newTestClient(t) + h := randomHosts(1, ".checker.example")[0] if err := client.CreateHost(h); err != nil { t.Fatal(err) } - - s := icinga.Service{ + defer client.DeleteHost(h.Name, true) + svc := icinga.Service{ Name: h.Name + "!http", CheckCommand: "http", } - if err := client.CreateService(s); err != nil { - t.Fatal(err) + if err := svc.Check(client); err == nil { + t.Error("nil error checking non-existent service") } - if err := s.Check(client); err != nil { + if err := client.CreateService(svc); err != nil { t.Fatal(err) } - s, err = client.LookupService(h.Name + "!http") - if err != nil { - t.Fatal(err) + if err := svc.Check(client); err != nil { + t.Error(err) } - t.Logf("%+v\n", s) + if err := client.DeleteService(svc.Name, false); err != nil { + t.Error(err) + } } func TestCheckHostGroup(t *testing.T) { - client, err := newTestClient() - if err != nil { - t.Skipf("no local test icinga? got: %v", err) + client := newTestClient(t) + hostgroup := icinga.HostGroup{Name: "test", DisplayName: "Test Group"} + if err := client.CreateHostGroup(hostgroup); err != nil && !errors.Is(err, icinga.ErrExist) { + t.Fatal(err) } - hosts, err := createTestHosts(client) + defer client.DeleteHostGroup(hostgroup.Name, false) + hostgroup, err := client.LookupHostGroup(hostgroup.Name) if err != nil { t.Fatal(err) } - defer func() { - for _, h := range hosts { - if err := client.DeleteHost(h.Name, true); err != nil { - t.Error(err) - } + hosts := randomHosts(10, "example.org") + for _, h := range hosts { + h.Groups = []string{hostgroup.Name} + if err := client.CreateHost(h); err != nil { + t.Fatal(err) } - }() - hostgroup, err := client.LookupHostGroup("test") - if err != nil { - t.Fatal(err) + defer client.DeleteHost(h.Name, false) } if err := hostgroup.Check(client); err != nil { t.Fatal(err) } } -func TestCreateService(t *testing.T) { - client, err := newTestClient() - if err != nil { - t.Skipf("no local test icinga? got: %v", err) +func TestNonExistentService(t *testing.T) { + client := newTestClient(t) + filter := `match("blablabla", service.name)` + service, err := client.Services(filter) + if err == nil { + t.Error("non-nil error TODO") + t.Log(service) } +} - h := icinga.Host{ - Name: "example.com", - Address: "example.com", - CheckCommand: "dummy", - DisplayName: "RFC 2606 example host", +type fakeServer struct { + objects map[string]attributes +} + +func newFakeServer() *httptest.Server { + return httptest.NewTLSServer(&fakeServer{objects: make(map[string]attributes)}) +} + +// Returns an error message in the same format as returned by the Icinga2 API. +func jsonError(err error) string { + return fmt.Sprintf("{ %q: %q }", "status", err.Error()) +} + +var notFoundResponse string = `{ + "error": 404, + "status": "No objects found." +}` + +var alreadyExistsResponse string = ` +{ + "results": [ + { + "code": 500, + "errors": [ + "Object already exists." + ], + "status": "Object could not be created." + } + ] +}` + +func (srv *fakeServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if req.URL.RawQuery != "" { + http.Error(w, jsonError(errors.New("query parameters unimplemented")), http.StatusBadRequest) + return } - if err := client.CreateHost(h); err != nil { - t.Error(err) + + switch { + case path.Base(req.URL.Path) == "v1": + srv.Permissions(w) + return + case strings.HasPrefix(req.URL.Path, "/v1/objects"): + srv.ObjectsHandler(w, req) + return } - defer client.DeleteHost(h.Name, true) - s := icinga.Service{ - Name: h.Name + "!http", - CheckCommand: "http", - DisplayName: "RFC 2606 example website", + http.Error(w, jsonError(errors.New(req.URL.Path+" unimplemented")), http.StatusNotFound) +} + +func (f *fakeServer) Permissions(w http.ResponseWriter) { + file, err := os.Open("testdata/permissions.json") + if err != nil { + panic(err) } - if err := client.CreateService(s); err != nil { - t.Error(err) + defer file.Close() + io.Copy(w, file) +} + +type apiResponse struct { + Results []apiResult `json:"results"` + Status string `json:"status,omitempty"` +} + +type apiResult struct { + Name string `json:"name"` + Type string `json:"type"` + Attrs attributes `json:"attrs"` +} + +// attributes represent configuration object attributes +type attributes map[string]interface{} + +// objType returns the icinga2 object type name from an API request path. +// For example from "objects/services/test" the type name is "Service". +func objType(path string) string { + var t string + a := strings.Split(path, "/") + for i := range a { + if a[i] == "objects" { + t = a[i+1] // services + } } + return strings.TrimSuffix(strings.Title(t), "s") // Services to Service } -func TestNonExistentService(t *testing.T) { - client, err := newTestClient() +func (srv *fakeServer) ObjectsHandler(w http.ResponseWriter, req *http.Request) { + name := strings.TrimPrefix(req.URL.Path, "/v1/") + switch req.Method { + case http.MethodPut: + if _, ok := srv.objects[name]; ok { + http.Error(w, alreadyExistsResponse, http.StatusInternalServerError) + return + } + srv.CreateObject(w, req) + case http.MethodGet: + srv.GetObject(w, req) + case http.MethodDelete: + if _, ok := srv.objects[name]; !ok { + http.Error(w, notFoundResponse, http.StatusNotFound) + return + } + delete(srv.objects, name) + default: + err := fmt.Errorf("%s unimplemented", req.Method) + http.Error(w, jsonError(err), http.StatusMethodNotAllowed) + } +} + +func (srv *fakeServer) GetObject(w http.ResponseWriter, req *http.Request) { + name := strings.TrimPrefix(req.URL.Path, "/v1/") + attrs, ok := srv.objects[name] + if !ok { + http.Error(w, notFoundResponse, http.StatusNotFound) + return + } + resp := apiResponse{ + Results: []apiResult{ + apiResult{ + Name: path.Base(req.URL.Path), + Type: objType(req.URL.Path), + Attrs: attrs, + }, + }, + } + json.NewEncoder(w).Encode(&resp) +} + +func (srv *fakeServer) CreateObject(w http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + m := make(map[string]attributes) + if err := json.NewDecoder(req.Body).Decode(&m); err != nil { + panic(err) + } + name := strings.TrimPrefix(req.URL.Path, "/v1/") + srv.objects[name] = m["attrs"] +} + +func TestDuplicateCreateDelete(t *testing.T) { + srv := newFakeServer() + defer srv.Close() + client, err := icinga.Dial(srv.Listener.Addr().String(), "root", "icinga", srv.Client()) if err != nil { - t.Skipf("no local test icinga? got: %v", err) + t.Fatal(err) } - filter := `match("blablabla", service.name)` - service, err := client.Services(filter) - if err == nil { - t.Fail() + host := randomHosts(1, ".example.org")[0] + if err := client.CreateHost(host); err != nil { + t.Fatal(err) } - t.Log(err) - t.Logf("%+v", service) + if err := client.CreateHost(host); !errors.Is(err, icinga.ErrExist) { + t.Errorf("want %s got %v", icinga.ErrExist, err) + } + host, err = client.LookupHost(host.Name) + if err != nil { + t.Error(err) + } + if err := client.DeleteHost(host.Name, false); err != nil { + t.Error(err) + } + if err := client.DeleteHost(host.Name, false); !errors.Is(err, icinga.ErrNotExist) { + t.Errorf("want icinga.ErrNotExist got %s", err) + } + _, err = client.LookupHost(host.Name) + if !errors.Is(err, icinga.ErrNotExist) { + t.Errorf("want icinga.ErrNotExist got %s", err) + } + if err := client.CreateHost(host); err != nil { + t.Error(err) + } + host, err = client.LookupHost(host.Name) + if err != nil { + t.Error(err) + } }