dd699b706f
Three ways a mavpoll process died or lied after start: - -interval 0 panicked time.NewTicker on the first tick. - -timeout 0 is 'no deadline' to http.Client, so one wedged source stalls every source behind it forever. - -wg-cmd '' indexed field 0 of an empty slice in pollWg. All three are now refused in run(), where the operator reads the message, and pollWg guards its own command as well. A body that hits maxBodyBytes was silently truncated. A cut kuma page parses cleanly up to the cut and every monitor past it looks deleted, so the poller would write 'unknown' over live services and the down-rule would go quiet. Read one byte past the cap and refuse. wg's stderr was dropped by Output(), leaving 'exit status 1' in the log where the real cause is a missing CAP_NET_ADMIN or a bad interface.
272 lines
8.6 KiB
Go
272 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/zenmoney"
|
|
)
|
|
|
|
func TestMaxSeverity(t *testing.T) {
|
|
parse := func(s string) netdataAlarms {
|
|
var a netdataAlarms
|
|
if err := json.Unmarshal([]byte(s), &a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a
|
|
}
|
|
cases := []struct{ body, want string }{
|
|
{`{"alarms":{}}`, "clear"},
|
|
{`{"alarms":{"a":{"status":"WARNING"}}}`, "warning"},
|
|
{`{"alarms":{"a":{"status":"WARNING"},"b":{"status":"CRITICAL"}}}`, "critical"},
|
|
{`{"alarms":{"a":{"status":"CLEAR"}}}`, "clear"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := maxSeverity(parse(c.body)); got != c.want {
|
|
t.Errorf("maxSeverity(%s) = %q, want %q", c.body, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want map[string]string
|
|
}{
|
|
{"empty body", "", map[string]string{}},
|
|
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
|
|
{
|
|
"one up one down",
|
|
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
|
|
map[string]string{"web": "up", "db": "down"},
|
|
},
|
|
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
|
|
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
|
|
{
|
|
"other labels do not hide the name",
|
|
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
|
|
map[string]string{"ci": "down"},
|
|
},
|
|
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := kumaMonitors([]byte(c.body))
|
|
if len(got) != len(c.want) {
|
|
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
|
|
}
|
|
for k, v := range c.want {
|
|
if got[k] != v {
|
|
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKumaFactKeyCarriesTheName(t *testing.T) {
|
|
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
|
|
t.Errorf("kumaFactKey = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestParseMaxHandshake(t *testing.T) {
|
|
cases := []struct {
|
|
out string
|
|
want int64
|
|
}{
|
|
{"", 0},
|
|
{"pubkeyAAA\t0\n", 0}, // never handshaked
|
|
{"pubkeyAAA\t1700000000\n", 1700000000},
|
|
{"pubkeyAAA\t1700000000\npubkeyBBB\t1700000500\n", 1700000500}, // max wins
|
|
{"wg0\tpubkeyAAA\t1700000000\nwg0\tpubkeyBBB\t0\n", 1700000000}, // 'all' 3-field form
|
|
}
|
|
for _, c := range cases {
|
|
if got := parseMaxHandshake(c.out); got != c.want {
|
|
t.Errorf("parseMaxHandshake(%q) = %d, want %d", c.out, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- zenmoney (Vikunja #125) ----------------------------------------------
|
|
|
|
// factCore records the facts the poller wrote and answers "no fact yet".
|
|
type factCore struct {
|
|
ipc.UnimplementedCoreAPI
|
|
|
|
written []ipc.WriteFactReq
|
|
prev map[string]string
|
|
}
|
|
|
|
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
|
if v, ok := c.prev[key+"|"+source]; ok {
|
|
return ipc.Fact{Key: key, Source: source, Value: v}, nil
|
|
}
|
|
return ipc.Fact{}, ipc.ErrNoFact
|
|
}
|
|
|
|
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
|
c.written = append(c.written, req)
|
|
return int64(len(c.written)), nil
|
|
}
|
|
|
|
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if status != http.StatusOK {
|
|
w.WriteHeader(status)
|
|
return
|
|
}
|
|
w.Write(body)
|
|
}))
|
|
}
|
|
|
|
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
|
|
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := zenFixtureServer(t, body, http.StatusOK)
|
|
defer srv.Close()
|
|
zen, err := zenmoney.New("tok", srv.URL, time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
core := &factCore{}
|
|
p := &poller{core: core, zen: zen}
|
|
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
|
|
if err := p.pollZenmoney(context.Background(), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(core.written) != 2 {
|
|
t.Fatalf("wrote %d facts, want today + month", len(core.written))
|
|
}
|
|
for _, f := range core.written {
|
|
if f.Kind != "env" || f.Source != zenmoney.Source {
|
|
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
|
|
}
|
|
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
|
|
t.Errorf("fact value %q does not decode: %v", f.Value, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A read that returns nothing for the window writes NOTHING. Silence, not a
|
|
// zero: an invented 0 would be recited back to him as fact.
|
|
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
|
|
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
|
|
defer srv.Close()
|
|
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
|
|
core := &factCore{}
|
|
p := &poller{core: core, zen: zen}
|
|
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(core.written) != 0 {
|
|
t.Errorf("wrote %+v, want no fact at all", core.written)
|
|
}
|
|
}
|
|
|
|
// An API failure must not overwrite the last good total either.
|
|
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
|
|
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
|
|
defer srv.Close()
|
|
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
|
|
core := &factCore{}
|
|
p := &poller{core: core, zen: zen}
|
|
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
|
|
t.Error("want the 401 reported")
|
|
}
|
|
if len(core.written) != 0 {
|
|
t.Errorf("wrote %+v on a failed read", core.written)
|
|
}
|
|
}
|
|
|
|
// Unchanged totals do not churn the facts table.
|
|
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
|
|
core := &factCore{prev: map[string]string{
|
|
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
|
|
}}
|
|
p := &poller{core: core}
|
|
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(core.written) != 0 {
|
|
t.Errorf("wrote %+v for an unchanged value", core.written)
|
|
}
|
|
}
|
|
|
|
// Money tracking is off unless configured: no token file, no zenmoney client,
|
|
// and the poller still refuses to start with nothing at all to poll.
|
|
func TestRunRequiresSomethingToPoll(t *testing.T) {
|
|
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
|
|
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
|
|
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
|
}
|
|
}
|
|
|
|
// A flag value that would kill the poller later is refused at start, before it
|
|
// dials core: a non-positive interval panics time.NewTicker on the first tick, a
|
|
// zero timeout means http.Client waits forever, and an empty wg command used to
|
|
// index field 0 of an empty slice.
|
|
func TestRunRefusesFlagsThatCrashLater(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{"zero interval", []string{"-interval", "0"}, "-interval must be positive"},
|
|
{"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"},
|
|
{"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"},
|
|
{"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...)
|
|
err := run(args)
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("err = %v, want %q", err, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// pollWg refuses an empty command rather than panicking on fields[0].
|
|
func TestPollWgEmptyCommand(t *testing.T) {
|
|
p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""}
|
|
if err := p.pollWg(context.Background()); err == nil {
|
|
t.Error("want an error, got a poll that ran something")
|
|
}
|
|
}
|
|
|
|
// A body at the cap is a truncated body, and a truncated kuma page reads as
|
|
// "every monitor past the cut was deleted". Refuse it instead of parsing it.
|
|
func TestGetRefusesTruncatedBody(t *testing.T) {
|
|
big := strings.Repeat("x", maxBodyBytes+64)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(w, big)
|
|
}))
|
|
defer srv.Close()
|
|
p := &poller{http: srv.Client()}
|
|
if _, err := p.get(context.Background(), srv.URL, ""); err == nil {
|
|
t.Error("want an over-size refusal, got a silently truncated body")
|
|
}
|
|
small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(w, "ok")
|
|
}))
|
|
defer small.Close()
|
|
body, err := p.get(context.Background(), small.URL, "")
|
|
if err != nil || string(body) != "ok" {
|
|
t.Errorf("get = %q, %v; want the whole small body", body, err)
|
|
}
|
|
}
|