package api_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" "github.com/v10networks/nmon/internal/api" "github.com/v10networks/nmon/internal/clock" "github.com/v10networks/nmon/internal/fixturegen" "github.com/v10networks/nmon/internal/poller" "github.com/v10networks/nmon/internal/selfmon" "github.com/v10networks/nmon/internal/simagent" "github.com/v10networks/nmon/internal/store" "github.com/v10networks/nmon/internal/testutil" "github.com/v10networks/nmon/internal/ulid" "github.com/v10networks/nmon/internal/vmsup" ) // querier is the fake metric store. fn, when set, answers every query; // otherwise body does. Every query is recorded. type querier struct { mu sync.Mutex body []byte err error fn func(store, expr string, from, to time.Time, step time.Duration) ([]byte, error) q string ts, from, to time.Time step time.Duration instant, rangeCnt int calls []call } type call struct { store, expr string from, to time.Time step time.Duration } func (q *querier) Query(_ context.Context, st, expr string, ts time.Time) ([]byte, error) { q.mu.Lock() q.instant++ q.q, q.ts = expr, ts q.calls = append(q.calls, call{store: st, expr: expr, from: ts, to: ts}) fn, body, err := q.fn, q.body, q.err q.mu.Unlock() if fn != nil { return fn(st, expr, ts, ts, 0) } return body, err } func (q *querier) QueryRange(_ context.Context, st, expr string, from, to time.Time, step time.Duration) ([]byte, error) { q.mu.Lock() q.rangeCnt++ q.q, q.from, q.to, q.step = expr, from, to, step q.calls = append(q.calls, call{store: st, expr: expr, from: from, to: to, step: step}) fn, body, err := q.fn, q.body, q.err q.mu.Unlock() if fn != nil { return fn(st, expr, from, to, step) } return body, err } // queries returns a copy of the recorded queries. func (q *querier) queries() []call { q.mu.Lock() defer q.mu.Unlock() return append([]call(nil), q.calls...) } type refresher struct{ n atomic.Int32 } func (r *refresher) Refresh() { r.n.Add(1) } type env struct { clk *clock.Fake h http.Handler st store.Store q *querier ref *refresher reg *selfmon.Registry afterID atomic.Int64 status []poller.DeviceStatus } func newEnv(t *testing.T, port int) *env { return newEnvDeps(t, port, nil) } // newEnvDeps is newEnv with a chance to change the API's dependencies. func newEnvDeps(t *testing.T, port int, mutate func(*api.Deps)) *env { clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)) e := &env{clk: clk, st: testutil.OpenStore(t, clk), q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)}, ref: &refresher{}, reg: selfmon.New()} d := api.Deps{Store: e.st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, SNMPPort: port, DiscoveryTimeout: 3 * time.Second, PollerStatus: func() []poller.DeviceStatus { return e.status }, AfterDiscover: func(id int64) { e.afterID.Store(id) }} if mutate != nil { mutate(&d) } e.h = api.New(d) return e } func (e *env) do(method, path, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } func isProblem(t *testing.T, rec *httptest.ResponseRecorder, status int) { t.Helper() if rec.Code != status { t.Fatalf("status %d, want %d: %s", rec.Code, status, rec.Body) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/problem+json") { t.Fatalf("content type %q, want problem+json: %s", ct, rec.Body) } var p struct { Title string `json:"title"` Status int `json:"status"` } if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || p.Status != status || p.Title == "" { t.Fatalf("problem body %s (%v)", rec.Body, err) } } const community = "s3cr3t-comm-xyz" func TestAddAndGetDevice(t *testing.T) { a := testutil.StartAgent(t, simagent.Config{ Recording: fixturegen.Generic(fixturegen.Options{Ports: 8, SysName: "edge1", HCCounters: true}), Community: community, }) e := newEnv(t, int(a.Addr().Port())) body := fmt.Sprintf(`{"name":"Edge One","addresses":["::1"],"site":"yyz","role":"edge","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) } if e.ref.n.Load() != 1 { t.Error("poller not refreshed") } var created api.DeviceView if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { t.Fatal(err) } if created.Slug == "" || created.Name != "Edge One" || created.Site != "yyz" || created.SysName != "edge1" || created.MgmtIP != "::1" || created.SNMPVersion != "v2c" || len(created.Interfaces) != 8 { t.Fatalf("created: %+v", created) } get := e.do("GET", "/api/v1/devices/"+created.Slug, "") if get.Code != 200 { t.Fatalf("GET: %d %s", get.Code, get.Body) } for _, r := range []*httptest.ResponseRecorder{rec, get} { if strings.Contains(r.Body.String(), community) { t.Fatalf("response contains the community: %s", r.Body) } var m map[string]json.RawMessage if err := json.Unmarshal(r.Body.Bytes(), &m); err != nil { t.Fatal(err) } for _, k := range []string{"id", "snmp_credential_id", "community", "device_id"} { if _, ok := m[k]; ok { t.Errorf("device response has key %q", k) } } } var got api.DeviceView if err := json.Unmarshal(get.Body.Bytes(), &got); err != nil { t.Fatal(err) } for _, i := range got.Interfaces { if !ulid.Valid(i.ID) { t.Errorf("interface id %q", i.ID) } if i.FirstSeen.Location() != time.UTC { t.Errorf("timestamp not UTC: %v", i.FirstSeen) } } if !strings.Contains(get.Body.String(), `"created_at":"2026-10-22T12:00:00Z"`) { t.Errorf("created_at is not RFC 3339 UTC: %s", get.Body) } // the secret is in the store, encrypted, and decrypts for the poller d, _ := e.st.GetDeviceBySlug(context.Background(), created.Slug) c, err := e.st.GetCredential(context.Background(), *d.SNMPCredentialID) if err != nil || c.Community != community { t.Fatalf("stored credential: %+v %v", c, err) } } func TestAddDeviceRejections(t *testing.T) { e := newEnv(t, 1) // nothing listens on UDP 1 for name, tc := range map[string]struct { body string status int }{ "no snmp for network": {`{"name":"x","addresses":["::1"]}`, 422}, "bad address": {`{"name":"x","addresses":["nope"],"snmp":{"version":"v2c","community":"c"}}`, 422}, "no community": {`{"name":"x","addresses":["::1"],"snmp":{"version":"v2c"}}`, 422}, "bad version": {`{"name":"x","addresses":["::1"],"snmp":{"version":"v9","community":"c"}}`, 422}, "no addresses": {`{"name":"x","addresses":[],"snmp":{"version":"v2c","community":"c"}}`, 422}, "unreachable": {`{"name":"x","addresses":["::1"],"snmp":{"version":"v2c","community":"c"}}`, 502}, } { t.Run(name, func(t *testing.T) { isProblem(t, e.do("POST", "/api/v1/devices", tc.body), tc.status) }) } if _, n, err := e.st.ListDevices(context.Background(), store.DeviceFilter{}, store.Page{}); err != nil || n != 0 { t.Fatalf("rejected requests stored %d devices (%v)", n, err) } if e.ref.n.Load() != 0 { t.Error("poller refreshed for a rejected device") } // a device without SNMP is fine rec := e.do("POST", "/api/v1/devices", `{"name":"vm1","kind":"vm","addresses":["192.0.2.7"]}`) if rec.Code != 201 { t.Fatalf("vm: %d %s", rec.Code, rec.Body) } } func TestProblemDetails(t *testing.T) { e := newEnv(t, 0) isProblem(t, e.do("GET", "/api/v1/devices/no-such-device", ""), 404) isProblem(t, e.do("GET", "/api/v1/nothing", ""), 404) rec := e.do("DELETE", "/api/v1/query", "") isProblem(t, rec, 405) isProblem(t, e.do("GET", "/api/v1/query", ""), 422) // missing q } func TestOpenAPIDocsAndMetrics(t *testing.T) { e := newEnv(t, 0) e.reg.Counter("nmon_test_total", "device", "d1").Add(3) rec := e.do("GET", "/openapi.json", "") var spec struct { Paths map[string]map[string]any `json:"paths"` } if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &spec) != nil { t.Fatalf("openapi.json: %d %s", rec.Code, rec.Body) } for _, p := range []string{"/api/v1/devices", "/api/v1/devices/{slug}", "/api/v1/query", "/api/v1/query_range", "/metrics"} { if spec.Paths[p] == nil { t.Errorf("openapi.json lacks %s", p) } } if d := e.do("GET", "/docs", ""); d.Code != 200 || !strings.Contains(d.Header().Get("Content-Type"), "text/html") { t.Errorf("/docs: %d %s", d.Code, d.Header().Get("Content-Type")) } m := e.do("GET", "/metrics", "") if m.Code != 200 || !strings.Contains(m.Body.String(), `nmon_test_total{device="d1"} 3`) { t.Errorf("/metrics: %d %s", m.Code, m.Body) } } func seriesBody(n int) []byte { var b bytes.Buffer b.WriteString(`{"status":"success","data":{"resultType":"vector","result":[`) for i := 0; i < n; i++ { if i > 0 { b.WriteByte(',') } b.WriteString(`{"metric":{},"value":[1,"1"]}`) } b.WriteString(`]}}`) return b.Bytes() } func TestQueryProxy(t *testing.T) { e := newEnv(t, 0) e.q.body = seriesBody(3) rec := e.do("GET", "/api/v1/query?q=ifHCInOctets&time=2026-10-22T12:00:00Z", "") if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), e.q.body) || rec.Header().Get("Content-Type") != "application/json" { t.Fatalf("passthrough: %d %s", rec.Code, rec.Body) } if e.q.q != "ifHCInOctets" || !e.q.ts.Equal(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)) { t.Errorf("forwarded %q at %v", e.q.q, e.q.ts) } e.do("GET", "/api/v1/query?q=up&time=1700000000.5", "") if e.q.ts.UnixMilli() != 1700000000500 { t.Errorf("unix time parsed as %v", e.q.ts) } isProblem(t, e.do("GET", "/api/v1/query?q=up&time=yesterday", ""), 422) rec = e.do("GET", "/api/v1/query_range?q=up&from=1700000000&to=1700003600&step=30s", "") if rec.Code != 200 || e.q.step != 30*time.Second || e.q.to.Sub(e.q.from) != time.Hour { t.Fatalf("range: %d %s step=%v", rec.Code, rec.Body, e.q.step) } isProblem(t, e.do("GET", "/api/v1/query_range?q=up", ""), 422) // step required isProblem(t, e.do("GET", "/api/v1/query_range?q=up&step=0", ""), 422) e.q.body = seriesBody(api.MaxSeries) if rec := e.do("GET", "/api/v1/query?q=x", ""); rec.Code != 200 { t.Fatalf("exactly %d series must pass: %d", api.MaxSeries, rec.Code) } e.q.body = seriesBody(api.MaxSeries + 1) isProblem(t, e.do("GET", "/api/v1/query?q=x", ""), 422) isProblem(t, e.do("GET", "/api/v1/query_range?q=x&step=1m", ""), 422) e.q.err = fmt.Errorf("%w: hot", vmsup.ErrStoreDown) isProblem(t, e.do("GET", "/api/v1/query?q=x", ""), 503) e.q.err = errors.New("vmsup: hot store: 422 Unprocessable Entity: cannot parse") isProblem(t, e.do("GET", "/api/v1/query?q=x", ""), 422) e.q.err = errors.New("connection reset") isProblem(t, e.do("GET", "/api/v1/query?q=x", ""), 502) } func TestDocsAreSelfHosted(t *testing.T) { e := newEnv(t, 0) page := e.do("GET", "/docs", "") if page.Code != 200 || strings.Contains(page.Body.String(), "http") && strings.Contains(page.Body.String(), "unpkg") || !strings.Contains(page.Body.String(), "/docs/assets/swagger-ui-bundle.js") { t.Fatalf("/docs: %d %s", page.Code, page.Body) } for path, ct := range map[string]string{"/docs/assets/swagger-ui-bundle.js": "javascript", "/docs/assets/swagger-ui.css": "css", "/docs/assets/init.js": "javascript"} { rec := e.do("GET", path, "") if rec.Code != 200 || rec.Body.Len() < 50 || !strings.Contains(rec.Header().Get("Content-Type"), ct) { t.Errorf("%s: %d %s %d bytes", path, rec.Code, rec.Header().Get("Content-Type"), rec.Body.Len()) } } if !strings.Contains(e.do("GET", "/docs/assets/init.js", "").Body.String(), "/openapi.json") { t.Error("the page must load the spec from this server") } if rec := e.do("GET", "/openapi.json", ""); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"openapi":"3.1.0"`) { t.Errorf("openapi.json: %d", rec.Code) } }