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
96 lines
3.6 KiB
Go
96 lines
3.6 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/auth"
|
|
)
|
|
|
|
type fakeSetup struct {
|
|
install bool
|
|
done bool
|
|
}
|
|
|
|
func (f *fakeSetup) InstallMode() bool { return f.install }
|
|
func (f *fakeSetup) SetupCompleted(context.Context) (bool, error) { return f.done, nil }
|
|
func (f *fakeSetup) SignIn(context.Context, string, api.SetupSignInBody) (api.SetupSignIn, error) {
|
|
return api.SetupSignIn{}, api.ErrSetupClosed
|
|
}
|
|
func (f *fakeSetup) Complete(context.Context, string) (api.SetupComplete, error) {
|
|
return api.SetupComplete{}, api.ErrSetupNoSignIn
|
|
}
|
|
|
|
func TestSetupStateIsPublicAndCarriesNoSecrets(t *testing.T) {
|
|
fs := &fakeSetup{install: true}
|
|
e := newAuthedEnv(t, func(d *api.Deps) { d.Setup = fs })
|
|
rec := httptest.NewRecorder()
|
|
e.top.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/setup", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /setup without credentials = %d %s", rec.Code, rec.Body)
|
|
}
|
|
var s api.SetupState
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil || !s.InstallMode || s.Defaults.BreakGlassUser != "admin" ||
|
|
s.Defaults.MinSignInLength != auth.MinBreakGlassPassword || s.Defaults.PollInterval != "5m" {
|
|
t.Fatalf("state = %s (%v)", rec.Body, err)
|
|
}
|
|
if strings.Contains(strings.ToLower(rec.Body.String()), "password\":") {
|
|
t.Fatalf("state leaks a password field: %s", rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestSetupPostsAnswer409WhenSetupIsClosed(t *testing.T) {
|
|
e := newEnvDeps(t, 0, func(d *api.Deps) { d.Setup = &fakeSetup{done: true}; d.AlertPolicy = &fakePolicyHost{} })
|
|
isProblem(t, e.do("POST", "/api/v1/setup/signin", `{"username":"admin","password":"a long enough one"}`), http.StatusConflict)
|
|
isProblem(t, e.do("POST", "/api/v1/setup/recommended-policies", `{}`), http.StatusConflict)
|
|
isProblem(t, e.do("POST", "/api/v1/setup/complete", ``), http.StatusConflict)
|
|
}
|
|
|
|
// allRulesHost knows every rule the recommended set binds.
|
|
type allRulesHost struct{ fakePolicyHost }
|
|
|
|
func (allRulesHost) Rules() []api.AlertRuleInfo {
|
|
var out []api.AlertRuleInfo
|
|
for name, level := range map[string]string{"device_down": "device", "device_warning": "device", "interface_errors": "interface",
|
|
"cpu_high": "device", "memory_high": "device", "disk_full": "component"} {
|
|
out = append(out, api.AlertRuleInfo{Name: name, Level: level, Severity: "warning"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestRecommendedPoliciesAreCreatedOnceAndIdempotently(t *testing.T) {
|
|
host := &allRulesHost{}
|
|
e := newEnvDeps(t, 0, func(d *api.Deps) { d.Setup = &fakeSetup{install: true}; d.AlertPolicy = host })
|
|
var first, second api.SetupPolicies
|
|
rec := e.do("POST", "/api/v1/setup/recommended-policies", `{}`)
|
|
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &first) != nil || len(first.Created) != 6 {
|
|
t.Fatalf("first run: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec = e.do("POST", "/api/v1/setup/recommended-policies", `{}`)
|
|
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &second) != nil || len(second.Created) != 0 || len(second.Existing) != 6 {
|
|
t.Fatalf("second run must create nothing: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var list api.AlertPolicyList
|
|
e.getJSON(t, "/api/v1/alert-policies", &list)
|
|
if len(list.Items) != 6 || !list.Items[0].Scope.All || !list.Items[0].Enabled {
|
|
t.Fatalf("policies = %+v", list)
|
|
}
|
|
}
|
|
|
|
func TestInstallRoutesAreRealRoutes(t *testing.T) {
|
|
have := map[string]bool{}
|
|
for _, r := range api.Routes() {
|
|
have[r.Pattern] = true
|
|
}
|
|
for _, pat := range api.InstallRoutes() {
|
|
if !have[pat] {
|
|
t.Errorf("install mode allows %q, which is not an API route", pat)
|
|
}
|
|
}
|
|
}
|