Make nmon easier to install and run without editing YAML by hand: - Settings: point-and-click forms over config.yaml (GET/PUT /api/v1/settings/form); the YAML editor moves to /settings/yaml. - First install: with no sign-in configured nmon starts in install mode, serves only the setup wizard (sign-in, basics, devices, alerting) and closes it for good once sign-in is saved. A self-signed certificate is generated when no TLS source is given. poller.interval becomes a config setting. - Reverse proxies: opt-in --http-listen plain-HTTP listener beside HTTPS; sign-in stays required. - Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets, template preview and test send. - Help: the operations, coverage and profile docs served at /help. - Polling: per-device "Poll now", a dry-run test poll returning an SNMP trace, and time-limited in-memory SNMP debug recording, in the UI and as `nmon device poll`. - Ansible: deploys into install mode when no sign-in is set, and no longer overwrites the config.yaml the wizard wrote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
124 lines
4.3 KiB
Go
124 lines
4.3 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/fixturegen"
|
|
"github.com/v10networks/nmon/internal/poller"
|
|
"github.com/v10networks/nmon/internal/simagent"
|
|
"github.com/v10networks/nmon/internal/testutil"
|
|
)
|
|
|
|
// fakeTools stands in for the poller.
|
|
type fakeTools struct {
|
|
mu sync.Mutex
|
|
polled bool
|
|
nowIDs []int64
|
|
testErr error
|
|
minutes int
|
|
}
|
|
|
|
func (f *fakeTools) PollNow(id int64) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.nowIDs = append(f.nowIDs, id)
|
|
return f.polled
|
|
}
|
|
|
|
func (f *fakeTools) TestPoll(_ context.Context, _ int64, group time.Duration) (*poller.Trace, error) {
|
|
if f.testErr != nil {
|
|
return nil, f.testErr
|
|
}
|
|
return &poller.Trace{Device: "x", Target: "[::1]:161", SNMPVersion: "2c", Requested: 3, Returned: 2, Missing: 1,
|
|
Groups: []poller.GroupTrace{{IntervalSeconds: int(group / time.Second), Requests: []poller.RequestTrace{}, Results: []poller.OIDResult{}}}}, nil
|
|
}
|
|
|
|
func (f *fakeTools) SetDebug(_ int64, minutes int) poller.DebugState {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.minutes = minutes
|
|
return poller.DebugState{Enabled: minutes > 0, MaxCycles: poller.DebugCycles, Cycles: []poller.GroupTrace{}}
|
|
}
|
|
|
|
func (f *fakeTools) DebugState(int64) poller.DebugState {
|
|
return poller.DebugState{MaxCycles: poller.DebugCycles, Cycles: []poller.GroupTrace{}}
|
|
}
|
|
|
|
func newToolsEnv(t *testing.T, f *fakeTools) (*env, string) {
|
|
a := testutil.StartAgent(t, simagent.Config{
|
|
Recording: fixturegen.Generic(fixturegen.Options{Ports: 2, SysName: "d1", HCCounters: true}), Community: community,
|
|
})
|
|
e := newEnvDeps(t, int(a.Addr().Port()), func(d *api.Deps) { d.PollTools = f })
|
|
body := fmt.Sprintf(`{"name":"D One","addresses":["::1"],"snmp":{"version":"v2c","community":%q}}`, community)
|
|
rec := e.do("POST", "/api/v1/devices", body)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("POST: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var dev api.DeviceView
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &dev)
|
|
return e, "/api/v1/devices/" + dev.Slug
|
|
}
|
|
|
|
func TestPollNowQueuesOrExplainsWhyNot(t *testing.T) {
|
|
f := &fakeTools{polled: true}
|
|
e, base := newToolsEnv(t, f)
|
|
if rec := e.do("POST", base+"/poll-now", ""); rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), `"queued":true`) {
|
|
t.Fatalf("poll-now: %d %s", rec.Code, rec.Body)
|
|
}
|
|
f.polled = false
|
|
isProblem(t, e.do("POST", base+"/poll-now", ""), http.StatusConflict)
|
|
isProblem(t, e.do("POST", "/api/v1/devices/nope/poll-now", ""), http.StatusNotFound)
|
|
}
|
|
|
|
func TestPollTestReturnsTraceAuditsAndMapsLimits(t *testing.T) {
|
|
f := &fakeTools{}
|
|
e, base := newToolsEnv(t, f)
|
|
rec := e.do("POST", base+"/poll-test?interval_seconds=300", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("poll-test: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var tr poller.Trace
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &tr); err != nil || tr.Requested != 3 || tr.Groups[0].IntervalSeconds != 300 {
|
|
t.Fatalf("trace %+v (%v)", tr, err)
|
|
}
|
|
if a := e.do("GET", "/api/v1/audit", ""); !strings.Contains(a.Body.String(), "device.poll_test") {
|
|
t.Errorf("no audit entry: %s", a.Body)
|
|
}
|
|
for err, status := range map[error]int{
|
|
poller.ErrTestBusy: http.StatusConflict,
|
|
poller.ErrTestLimit: http.StatusTooManyRequests,
|
|
poller.ErrNotPolled: http.StatusUnprocessableEntity,
|
|
poller.ErrNoSuchPoll: http.StatusUnprocessableEntity,
|
|
} {
|
|
f.testErr = err
|
|
isProblem(t, e.do("POST", base+"/poll-test", ""), status)
|
|
}
|
|
}
|
|
|
|
func TestSNMPDebugSwitchesAndIsBounded(t *testing.T) {
|
|
f := &fakeTools{}
|
|
e, base := newToolsEnv(t, f)
|
|
rec := e.do("PUT", base+"/snmp-debug", `{"minutes":15}`)
|
|
if rec.Code != http.StatusOK || f.minutes != 15 || !strings.Contains(rec.Body.String(), `"enabled":true`) {
|
|
t.Fatalf("PUT: %d %s (minutes %d)", rec.Code, rec.Body, f.minutes)
|
|
}
|
|
if rec := e.do("GET", base+"/snmp-debug", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"cycles":[]`) {
|
|
t.Fatalf("GET: %d %s", rec.Code, rec.Body)
|
|
}
|
|
isProblem(t, e.do("PUT", base+"/snmp-debug", `{"minutes":61}`), http.StatusUnprocessableEntity)
|
|
isProblem(t, e.do("PUT", base+"/snmp-debug", `{"minutes":-1}`), http.StatusUnprocessableEntity)
|
|
}
|
|
|
|
func TestPollToolsAnswer503WithoutAPoller(t *testing.T) {
|
|
e := newEnv(t, 161)
|
|
isProblem(t, e.do("POST", "/api/v1/devices/x/poll-now", ""), http.StatusServiceUnavailable)
|
|
}
|