Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
181 lines
4.9 KiB
Go
181 lines
4.9 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/store"
|
|
"github.com/v10networks/nmon/internal/ulid"
|
|
)
|
|
|
|
// device stores a network device and returns it with its generated slug.
|
|
func (e *env) device(t *testing.T, name, site, role string, mut ...func(*store.Device)) *store.Device {
|
|
t.Helper()
|
|
d := &store.Device{Name: name, Kind: store.KindNetwork, Site: site, Role: role, Enabled: true, Profile: "generic", MgmtIP: "192.0.2.1"}
|
|
for _, m := range mut {
|
|
m(d)
|
|
}
|
|
if err := e.st.CreateDevice(context.Background(), d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// iface stores one interface of d and returns it.
|
|
func (e *env) iface(t *testing.T, d *store.Device, idx int64, name, alias string, mut ...func(*store.Interface)) store.Interface {
|
|
t.Helper()
|
|
i := store.Interface{
|
|
ID: ulid.New(e.clk.Now()), DeviceID: d.ID, IfIndex: idx, IfName: name, IfDescr: name, IfAlias: alias,
|
|
IfSpeed: 1_000_000_000, IfHighSpeed: 1000, AdminStatus: 1, OperStatus: 1, HasHCCounters: true,
|
|
FirstSeen: e.clk.Now(), LastDiscovered: e.clk.Now(),
|
|
}
|
|
for _, m := range mut {
|
|
m(&i)
|
|
}
|
|
if err := e.st.UpsertInterfaces(context.Background(), d.ID, []store.Interface{i}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return i
|
|
}
|
|
|
|
// getJSON GETs path and decodes a 200 body into v.
|
|
func (e *env) getJSON(t *testing.T, path string, v any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := e.do("GET", path, "")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body)
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
|
t.Fatalf("GET %s: %v: %s", path, err, rec.Body)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// vector builds an instant-query response from rows of (labels, value).
|
|
type vrow struct {
|
|
labels map[string]string
|
|
v float64
|
|
}
|
|
|
|
func vector(rows ...vrow) []byte {
|
|
var b bytes.Buffer
|
|
b.WriteString(`{"status":"success","data":{"resultType":"vector","result":[`)
|
|
for i, r := range rows {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
lb, _ := json.Marshal(r.labels)
|
|
fmt.Fprintf(&b, `{"metric":%s,"value":[1,"%v"]}`, lb, r.v)
|
|
}
|
|
b.WriteString(`]}}`)
|
|
return b.Bytes()
|
|
}
|
|
|
|
// matrix builds a range-query response with one series per entry of rows:
|
|
// labels and a map of unix second to value.
|
|
type mrow struct {
|
|
labels map[string]string
|
|
vals map[int64]float64
|
|
}
|
|
|
|
func matrix(rows ...mrow) []byte {
|
|
var b bytes.Buffer
|
|
b.WriteString(`{"status":"success","data":{"resultType":"matrix","result":[`)
|
|
for i, r := range rows {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
lb, _ := json.Marshal(r.labels)
|
|
fmt.Fprintf(&b, `{"metric":%s,"values":[`, lb)
|
|
first := true
|
|
for _, ts := range sortedKeys(r.vals) {
|
|
if !first {
|
|
b.WriteByte(',')
|
|
}
|
|
first = false
|
|
fmt.Fprintf(&b, `[%d,"%v"]`, ts, r.vals[ts])
|
|
}
|
|
b.WriteString(`]}`)
|
|
}
|
|
b.WriteString(`]}}`)
|
|
return b.Bytes()
|
|
}
|
|
|
|
func sortedKeys(m map[int64]float64) []int64 {
|
|
out := make([]int64, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
for i := 1; i < len(out); i++ {
|
|
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
|
out[j], out[j-1] = out[j-1], out[j]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// emptyMatrix is a range response with no series.
|
|
var emptyMatrix = []byte(`{"status":"success","data":{"resultType":"matrix","result":[]}}`)
|
|
|
|
// walkJSON calls fn for every key/value of every object in the JSON document.
|
|
func walkJSON(t *testing.T, body []byte, fn func(path, key string, val any)) {
|
|
t.Helper()
|
|
var doc any
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
t.Fatalf("not JSON: %v: %s", err, body)
|
|
}
|
|
var rec func(path string, v any)
|
|
rec = func(path string, v any) {
|
|
switch x := v.(type) {
|
|
case map[string]any:
|
|
for k, c := range x {
|
|
fn(path, k, c)
|
|
rec(path+"."+k, c)
|
|
}
|
|
case []any:
|
|
for i, c := range x {
|
|
rec(fmt.Sprintf("%s[%d]", path, i), c)
|
|
}
|
|
}
|
|
}
|
|
rec("$", doc)
|
|
}
|
|
|
|
// assertNoLeaks fails when body has a numeric id, a credential field or a secret.
|
|
func assertNoLeaks(t *testing.T, what string, body []byte, secrets ...string) {
|
|
t.Helper()
|
|
walkJSON(t, body, func(path, key string, val any) {
|
|
lower := strings.ToLower(key)
|
|
if _, isNum := val.(float64); isNum && (lower == "id" || strings.HasSuffix(lower, "_id") || lower == "device_id") {
|
|
t.Errorf("%s: numeric id at %s.%s = %v", what, path, key, val)
|
|
}
|
|
for _, bad := range []string{"community", "password", "pass", "hash", "secret", "token", "credential"} {
|
|
if strings.Contains(lower, bad) {
|
|
t.Errorf("%s: credential-like key %s.%s", what, path, key)
|
|
}
|
|
}
|
|
})
|
|
for _, s := range secrets {
|
|
if bytes.Contains(body, []byte(s)) {
|
|
t.Errorf("%s: response contains secret %q", what, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
|
|
|
|
// doFrom is do with a chosen remote address, which the admission limits key on.
|
|
func (e *env) doFrom(method, path, remote string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.RemoteAddr = remote
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|