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

188 lines
7.3 KiB
Go

package api_test
import (
"context"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/auth"
"github.com/v10networks/nmon/internal/auth/authtest"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/ingest"
"github.com/v10networks/nmon/internal/metrics"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/testutil"
)
// authedEnv is the API as production composes it: the auth middleware over a mux of the API, push ingest and
// the /auth/ handlers (internal/app builds the same shape in topHandler).
type authedEnv struct {
*env
auth *auth.Auth
top http.Handler
}
func newAuthedEnv(t *testing.T, mutate func(*api.Deps)) *authedEnv {
t.Helper()
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
st := testutil.OpenStore(t, clk)
a, err := auth.New(context.Background(), auth.Options{
Store: st, Clock: clk, IngestPrefixes: []string{"/write", "/api/v1/write"}, Policy: auth.NewPolicy(),
BreakGlass: auth.BreakGlassConfig{},
})
if err != nil {
t.Fatal(err)
}
api.DeclarePolicy(a.Policy())
a.Policy().Public("GET /healthz")
e := &env{clk: clk, st: st, q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)},
ref: &refresher{}, reg: selfmon.New()}
pushTokens := ingest.NewTokens(st, clk)
d := api.Deps{Store: st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, DiscoveryTimeout: time.Second,
PollerStatus: func() []poller.DeviceStatus { return nil }, Tokens: pushTokens, APITokens: a.Tokens,
}
if mutate != nil {
mutate(&d)
}
e.h = api.New(d)
g, err := ingest.New(ingest.Config{
Tokens: pushTokens, Clock: clk, Registry: selfmon.New(),
Writer: metrics.WriterFunc(func(context.Context, []metrics.Sample) error { return nil }),
})
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(http.ResponseWriter, *http.Request) {})
mux.Handle("/auth/", a.Handler())
g.Mount(mux)
mux.Handle("/", e.h)
return &authedEnv{env: e, auth: a, top: a.Middleware(mux)}
}
var pathParam = regexp.MustCompile(`\{[^}]+\}`)
// concrete turns a route pattern into a path a request can use.
func concrete(path string) string { return pathParam.ReplaceAllString(path, "fuzz") }
// TestViewerCannotMutate is the M6 gate's core: every state-changing route of the real router refuses a viewer
// session (with a valid CSRF token) and a viewer API token with 403, refuses a cookie request without the CSRF
// token, and still works for an admin so the check cannot pass by refusing everything.
func TestViewerCannotMutate(t *testing.T) {
e := newAuthedEnv(t, nil)
viewerSess, viewerTok := authtest.Mint(t, e.auth, auth.RoleViewer)
adminSess, _ := authtest.Mint(t, e.auth, auth.RoleAdmin)
readOnly := map[string]bool{}
for _, p := range api.ReadOnlyPosts() {
readOnly[p] = true
}
var routes []authtest.Route
for _, r := range api.Routes() {
routes = append(routes, authtest.Route{Method: r.Method, Path: concrete(r.Path), ReadOnly: readOnly[r.Pattern]})
}
mutating := 0
for _, r := range routes {
if !auth.IsSafeMethod(r.Method) && !r.ReadOnly {
mutating++
}
}
if mutating < 25 {
t.Fatalf("only %d state-changing routes were enumerated from the huma API; the enumeration is broken", mutating)
}
authtest.CheckViewerCannotMutate(t, e.top, routes, viewerSess, viewerTok, &adminSess)
}
// TestViewerAndPushCredentialsOnIngestAndAuthRoutes covers the routes outside huma: push ingest accepts only a
// push token (a viewer session or an API token is refused, 401 or 403), and the /auth/ endpoints that change
// state either need no credentials by design (login) or only act on the caller's own session (logout).
func TestViewerAndPushCredentialsOnIngestAndAuthRoutes(t *testing.T) {
e := newAuthedEnv(t, nil)
viewerSess, viewerTok := authtest.Mint(t, e.auth, auth.RoleViewer)
for _, path := range []string{"/write", "/api/v1/write?format=influx"} {
for name, c := range map[string]authtest.Creds{"viewer session": viewerSess, "viewer token": viewerTok} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("m v=1\n"))
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
if c.Cookie != nil {
req.AddCookie(c.Cookie)
req.Header.Set(auth.CSRFHeader, c.CSRF)
}
rec := httptest.NewRecorder()
e.top.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
t.Errorf("POST %s with a %s: status %d, want 401 or 403", path, name, rec.Code)
}
}
}
// Login endpoints are public by design; with nothing configured they say so instead of logging anyone in.
for _, path := range []string{"/auth/breakglass", "/auth/login"} {
method := http.MethodPost
if path == "/auth/login" {
method = http.MethodGet
}
req := httptest.NewRequest(method, path, strings.NewReader(`{"username":"x","password":"y"}`))
rec := httptest.NewRecorder()
e.top.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("%s %s with no provider configured: status %d, want 404", method, path, rec.Code)
}
}
}
// TestEveryRouteIsEitherPublicOrNeedsACredential: no declared API route answers an anonymous caller with data.
func TestEveryRouteIsEitherPublicOrNeedsACredential(t *testing.T) {
e := newAuthedEnv(t, nil)
for _, r := range api.Routes() {
if r.Pattern == "GET /api/v1/setup" {
continue // public on purpose: the UI asks it before anyone can sign in (TestSetupStateIsPublicAndCarriesNoSecrets)
}
req := httptest.NewRequest(r.Method, concrete(r.Path), strings.NewReader("{}"))
rec := httptest.NewRecorder()
e.top.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s without credentials: status %d, want 401", r.Pattern, rec.Code)
}
}
}
func TestPageLoadsRedirectToSignInAndAPICallsGet401(t *testing.T) {
e := newAuthedEnv(t, nil)
get := func(path string, hdr ...string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for i := 0; i+1 < len(hdr); i += 2 {
req.Header.Set(hdr[i], hdr[i+1])
}
rec := httptest.NewRecorder()
e.top.ServeHTTP(rec, req)
return rec
}
rec := get("/devices/sw1?tab=ifaces")
// No IdP is configured in this environment, so the redirect goes to the break-glass prompt.
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/auth/breakglass?return=%2Fdevices%2Fsw1%3Ftab%3Difaces" {
t.Fatalf("page load: %d Location %q", rec.Code, rec.Header().Get("Location"))
}
for _, p := range []string{"/api/v1/devices", "/metrics", "/openapi.json"} {
if rec := get(p); rec.Code != http.StatusUnauthorized {
t.Errorf("%s without credentials: %d, want 401 (no redirect)", p, rec.Code)
}
}
if rec := get("/", "Authorization", "Bearer nma_1_nope"); rec.Code != http.StatusUnauthorized {
t.Errorf("a request with a bad bearer token is an API client: %d, want 401", rec.Code)
}
if rec := get("/auth/signedout"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Signed out") {
t.Errorf("/auth/signedout: %d", rec.Code)
}
rec = get("/auth/breakglass")
if rec.Code != http.StatusNotFound && rec.Code != http.StatusUnauthorized {
t.Errorf("/auth/breakglass with no break-glass configured: %d", rec.Code)
}
}