Files
nmon/internal/api/poll_tools_test.go
Vibe Coder f31fb3712a
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Add settings forms, first-install wizard, webhook presets, help and poll tools
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
2026-10-25 06:31:12 +00:00

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