Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
341 lines
12 KiB
Go
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)
|
|
}
|
|
}
|