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
136 lines
5.8 KiB
Go
136 lines
5.8 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/auth"
|
|
"github.com/v10networks/nmon/internal/auth/authtest"
|
|
"github.com/v10networks/nmon/internal/config"
|
|
"github.com/v10networks/nmon/internal/notify"
|
|
)
|
|
|
|
// formSettings keeps a config in memory and validates it with config.Validate, as the app does.
|
|
type formSettings struct {
|
|
fakeSettings
|
|
cfg config.Config
|
|
mu2 sync.Mutex
|
|
}
|
|
|
|
func (f *formSettings) CurrentConfig() (config.Config, []string) {
|
|
f.mu2.Lock()
|
|
defer f.mu2.Unlock()
|
|
return f.cfg, nil
|
|
}
|
|
|
|
func (f *formSettings) UpdateConfig(_ context.Context, _ string, mutate func(*config.Config) error) (api.SettingsResult, error) {
|
|
f.mu2.Lock()
|
|
defer f.mu2.Unlock()
|
|
c := f.cfg
|
|
if err := mutate(&c); err != nil {
|
|
return api.SettingsResult{}, fmt.Errorf("%w: %w", api.ErrInvalidSettings, err)
|
|
}
|
|
if err := c.Validate(); err != nil {
|
|
return api.SettingsResult{}, fmt.Errorf("%w: %w", api.ErrInvalidSettings, err)
|
|
}
|
|
f.cfg = c
|
|
return api.SettingsResult{Applied: []string{"backup"}}, nil
|
|
}
|
|
|
|
func TestSettingsFormReadsTypedSectionsAndPatchesOnlyThePresentOnes(t *testing.T) {
|
|
fs := &formSettings{cfg: config.Default()}
|
|
e := newAuthedEnv(t, func(d *api.Deps) { d.Settings = fs })
|
|
admin, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
|
viewer, _ := authtest.Mint(t, e.auth, auth.RoleViewer)
|
|
|
|
if code := e.call(http.MethodGet, "/api/v1/settings/form", "", viewer).Code; code != http.StatusForbidden {
|
|
t.Fatalf("a viewer reading the form: %d, want 403", code)
|
|
}
|
|
var st api.SettingsFormState
|
|
rec := e.call(http.MethodGet, "/api/v1/settings/form", "", admin)
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil || st.Form.Retention.Hot != "30d" || st.Defaults.L2.Interval != "15m" {
|
|
t.Fatalf("form: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
rec = e.call(http.MethodPut, "/api/v1/settings/form", `{"backup":{"at":"03:00","dir":"","keep":5},"retention":{"hot":"45d","fiveminute":"120d","long":"2y"}}`, admin)
|
|
var out api.SettingsFormApplied
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || rec.Code != http.StatusOK || out.Form.Backup.At != "03:00" || out.Form.Retention.Hot != "45d" {
|
|
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if fs.cfg.Timezone != "UTC" || fs.cfg.Backup.Keep != 5 {
|
|
t.Fatalf("config = %+v", fs.cfg)
|
|
}
|
|
}
|
|
|
|
func TestSettingsFormRejectionsLocateTheField(t *testing.T) {
|
|
fs := &formSettings{cfg: config.Default()}
|
|
e := newAuthedEnv(t, func(d *api.Deps) { d.Settings = fs })
|
|
admin, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
|
for body, loc := range map[string]string{
|
|
`{"retention":{"hot":"12h","fiveminute":"120d","long":"2y"}}`: "body.retention.hot",
|
|
`{"retention":{"hot":"soon","fiveminute":"120d","long":"2y"}}`: "body.retention.hot",
|
|
`{"timezone":"Mars/Base"}`: "body.timezone",
|
|
`{"backup":{"at":"25:00","dir":"","keep":0}}`: "body.backup.at",
|
|
`{"sites":[{"name":"a","timezone":""},{"name":"a","timezone":""}]}`: "body.sites[1].name",
|
|
`{"disk":{"alert_used":0.95,"admit_used":0.9,"reserve_free":0}}`: "body.disk",
|
|
`{"l2":{"interval":"1m","keep":"","edge_max_macs":0,"concurrency":0,"device_timeout":""}}`: "body.l2.interval",
|
|
} {
|
|
rec := e.call(http.MethodPut, "/api/v1/settings/form", body, admin)
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"location":"`+loc) {
|
|
t.Errorf("%s: %d %s (want location %s)", body, rec.Code, rec.Body, loc)
|
|
}
|
|
}
|
|
if fs.cfg.Retention.Hot.D() != config.Default().Retention.Hot.D() {
|
|
t.Fatalf("a rejected form changed the configuration: %+v", fs.cfg)
|
|
}
|
|
}
|
|
|
|
func TestSettingsFormReceiverCredentialsAreWriteOnly(t *testing.T) {
|
|
fs := &formSettings{cfg: config.Default()}
|
|
e := newAuthedEnv(t, func(d *api.Deps) { d.Settings = fs })
|
|
admin, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
|
|
put := func(body string) string {
|
|
rec := e.call(http.MethodPut, "/api/v1/settings/form", body, admin)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT %s: %d %s", body, rec.Code, rec.Body)
|
|
}
|
|
return rec.Body.String()
|
|
}
|
|
const smtp = `{"name":"mail","kind":"smtp","url":"","headers":{},"template":"","smtp":{"host":"smtp.example.net","port":0,"from":"a@b.c","to":["x@y.z"],` +
|
|
`"username":"u","password":%q,"clear_password":%t,"starttls":true}}`
|
|
const hook = `{"name":"hook","kind":"webhook","url":"https://h.example.net/x","headers":{"Authorization":%q},"template":"","smtp":{"host":"","port":0,"from":"",` +
|
|
`"to":[],"username":"","password":"","clear_password":false,"starttls":false}}`
|
|
out := put(`{"receivers":[` + fmt.Sprintf(smtp, "hunter2", false) + `,` + fmt.Sprintf(hook, "Bearer abc") + `]}`)
|
|
if strings.Contains(out, "hunter2") || strings.Contains(out, "Bearer abc") || !strings.Contains(out, `"login_stored":true`) {
|
|
t.Fatalf("the response leaked or hid a credential wrongly: %s", out)
|
|
}
|
|
put(`{"receivers":[` + fmt.Sprintf(smtp, "", false) + `,` + fmt.Sprintf(hook, "") + `]}`)
|
|
rs := alertingReceivers(t, fs)
|
|
if rs[0].SMTP.Password != "hunter2" || rs[1].Headers["Authorization"] != "Bearer abc" {
|
|
t.Fatalf("blank credentials must keep the stored ones: %+v", rs)
|
|
}
|
|
put(`{"receivers":[` + fmt.Sprintf(smtp, "", true) + `]}`)
|
|
if rs = alertingReceivers(t, fs); rs[0].SMTP.Password != "" {
|
|
t.Fatalf("clear_password kept the password: %+v", rs)
|
|
}
|
|
}
|
|
|
|
func alertingReceivers(t *testing.T, fs *formSettings) []notify.Receiver {
|
|
t.Helper()
|
|
var out []notify.Receiver
|
|
for i := range fs.cfg.Alerting.Receivers {
|
|
var r notify.Receiver
|
|
if err := fs.cfg.Alerting.Receivers[i].Decode(&r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|