Commit Diff


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)
+	}
 }