Files
nmon/internal/api/settings_form_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

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
}