Files
nmon/internal/api/api_test.go
jleung f85c3ef781
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
M3: Go API per UI contract, embedded UI, openapi compat check, nmon-devtools, live gate + screenshots; M4 WP1 reachability and dependency suppression
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 20:34:14 +00:00

341 lines
12 KiB
Go

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)
}
}