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
455 lines
13 KiB
Go
455 lines
13 KiB
Go
package api_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
)
|
|
|
|
// The UI is built against web/openapi.contract.json. These tests hold the
|
|
// generated OpenAPI document to it: every path, method, parameter, request
|
|
// body and response field of the contract must exist in the generated spec
|
|
// with a compatible type. Additions in the generated spec are fine.
|
|
|
|
type obj = map[string]any
|
|
|
|
// knownDivergences are places where the generated spec deliberately differs
|
|
// from the hand-written contract. Each has a reason; anything else is a failure.
|
|
var knownDivergences = map[string]string{
|
|
// M1/M2 shipped these two as strings that also accept Unix seconds and "30s"; the routes are frozen once shipped
|
|
// and the UI does not call them.
|
|
"param GET /api/v1/query_range step: type": "frozen since M1: accepts 30s and plain seconds",
|
|
"param GET /api/v1/query_range from: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
|
|
"param GET /api/v1/query_range to: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
|
|
"param GET /api/v1/query time: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
|
|
// The contract left q optional; an empty expression is meaningless, so the API requires it.
|
|
"param GET /api/v1/query q: required": "q is required by the frozen M1 route",
|
|
"param GET /api/v1/query_range q: required": "q is required by the frozen M1 route",
|
|
"param GET /api/v1/query_range step: required": "step is required by the frozen M1 route",
|
|
}
|
|
|
|
type checker struct {
|
|
t *testing.T
|
|
gen obj
|
|
con obj
|
|
failed []string
|
|
}
|
|
|
|
func (c *checker) fail(key, format string, a ...any) {
|
|
if _, ok := knownDivergences[key]; ok {
|
|
return
|
|
}
|
|
c.failed = append(c.failed, fmt.Sprintf("%s: %s", key, fmt.Sprintf(format, a...)))
|
|
}
|
|
|
|
func load(t *testing.T) *checker {
|
|
t.Helper()
|
|
raw, err := os.ReadFile("../../web/openapi.contract.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var con obj
|
|
if err := json.Unmarshal(raw, &con); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
spec, err := api.Spec()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var gen obj
|
|
if err := json.Unmarshal(spec, &gen); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &checker{t: t, gen: gen, con: con}
|
|
}
|
|
|
|
func get(m any, keys ...string) any {
|
|
for _, k := range keys {
|
|
mm, ok := m.(obj)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m = mm[k]
|
|
}
|
|
return m
|
|
}
|
|
|
|
// resolve follows $ref within doc.
|
|
func resolve(doc obj, s any) obj {
|
|
m, _ := s.(obj)
|
|
for m != nil {
|
|
ref, ok := m["$ref"].(string)
|
|
if !ok {
|
|
return m
|
|
}
|
|
m, _ = get(doc, strings.Split(strings.TrimPrefix(ref, "#/"), "/")...).(obj)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// shape is a schema reduced to what the comparison needs.
|
|
type shape struct {
|
|
typ string
|
|
nullable bool
|
|
s obj
|
|
}
|
|
|
|
func shapeOf(doc obj, raw any) shape {
|
|
s := resolve(doc, raw)
|
|
sh := shape{s: s}
|
|
switch t := s["type"].(type) {
|
|
case string:
|
|
sh.typ = t
|
|
case []any:
|
|
for _, x := range t {
|
|
if x == "null" {
|
|
sh.nullable = true
|
|
} else {
|
|
sh.typ, _ = x.(string)
|
|
}
|
|
}
|
|
}
|
|
if alts, ok := s["anyOf"].([]any); ok {
|
|
for _, a := range alts {
|
|
am := resolve(doc, a)
|
|
if am["type"] == "null" {
|
|
sh.nullable = true
|
|
continue
|
|
}
|
|
sub := shapeOf(doc, am)
|
|
sh.typ, sh.s = sub.typ, sub.s
|
|
}
|
|
}
|
|
return sh
|
|
}
|
|
|
|
// compat checks that gen can stand where con is expected. request flips the
|
|
// direction of the required/nullable rules: a client sends what the contract
|
|
// allows, and receives what the contract promises.
|
|
func (c *checker) compat(key string, con, gen any, request bool) {
|
|
cs, gs := shapeOf(c.con, con), shapeOf(c.gen, gen)
|
|
if cs.typ != gs.typ && (cs.typ != "number" || gs.typ != "integer") {
|
|
c.fail(key+": type", "contract %q, generated %q", cs.typ, gs.typ)
|
|
return
|
|
}
|
|
if !request && cs.nullable && !gs.nullable {
|
|
c.fail(key+": nullable", "contract allows null, generated never sends it (fine) -- but UI types expect null; declare nullable")
|
|
}
|
|
if !request && gs.nullable && !cs.nullable {
|
|
c.fail(key+": nullable", "generated may send null, contract does not allow it")
|
|
}
|
|
for _, k := range []string{"format", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"} {
|
|
if cv, ok := cs.s[k]; ok && fmt.Sprint(cv) != fmt.Sprint(gs.s[k]) {
|
|
c.fail(key+": "+k, "contract %v, generated %v", cv, gs.s[k])
|
|
}
|
|
}
|
|
c.enums(key, cs.s, gs.s, request)
|
|
switch cs.typ {
|
|
case "object":
|
|
c.object(key, cs.s, gs.s, request)
|
|
case "array":
|
|
c.array(key, cs.s, gs.s, request)
|
|
}
|
|
}
|
|
|
|
func (c *checker) enums(key string, con, gen obj, request bool) {
|
|
ce, _ := con["enum"].([]any)
|
|
if len(ce) == 0 {
|
|
return
|
|
}
|
|
ge, _ := gen["enum"].([]any)
|
|
have := func(list []any, v any) bool { return slices.Contains(list, v) }
|
|
for _, v := range ge {
|
|
if !request && !have(ce, v) {
|
|
c.fail(key+": enum", "generated may send %v, not in the contract", v)
|
|
}
|
|
}
|
|
for _, v := range ce {
|
|
if (request || len(ge) == 0) && !have(ge, v) {
|
|
c.fail(key+": enum", "contract value %v missing from generated", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *checker) object(key string, con, gen obj, request bool) {
|
|
cp, _ := con["properties"].(obj)
|
|
gp, _ := gen["properties"].(obj)
|
|
names := make([]string, 0, len(cp))
|
|
for n := range cp {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
if _, ok := gp[n]; !ok {
|
|
c.fail(key+"."+n, "missing from generated")
|
|
continue
|
|
}
|
|
c.compat(key+"."+n, cp[n], gp[n], request)
|
|
}
|
|
creq, greq := strset(con["required"]), strset(gen["required"])
|
|
if request {
|
|
for n := range greq {
|
|
if !creq[n] {
|
|
c.fail(key+"."+n+": required", "generated requires a field the contract made optional")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
for n := range creq {
|
|
if !greq[n] {
|
|
c.fail(key+"."+n+": required", "contract promises it, generated may omit it")
|
|
}
|
|
}
|
|
}
|
|
|
|
func strset(v any) map[string]bool {
|
|
out := map[string]bool{}
|
|
l, _ := v.([]any)
|
|
for _, x := range l {
|
|
out[fmt.Sprint(x)] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *checker) array(key string, con, gen obj, request bool) {
|
|
if pc, ok := con["prefixItems"].([]any); ok {
|
|
pg, _ := gen["prefixItems"].([]any)
|
|
if len(pg) != len(pc) {
|
|
c.fail(key+": prefixItems", "contract has %d, generated %d", len(pc), len(pg))
|
|
return
|
|
}
|
|
for i := range pc {
|
|
c.compat(fmt.Sprintf("%s[%d]", key, i), pc[i], pg[i], request)
|
|
}
|
|
return
|
|
}
|
|
if con["items"] != nil {
|
|
if gen["items"] == nil {
|
|
c.fail(key+": items", "generated array has no items schema")
|
|
return
|
|
}
|
|
c.compat(key+"[]", con["items"], gen["items"], request)
|
|
}
|
|
}
|
|
|
|
// operations lists "METHOD path" for every operation of doc, with path-level parameters merged in.
|
|
func operations(doc obj) map[string]obj {
|
|
out := map[string]obj{}
|
|
paths, _ := doc["paths"].(obj)
|
|
for p, item := range paths {
|
|
im, _ := item.(obj)
|
|
for m, op := range im {
|
|
om, ok := op.(obj)
|
|
if !ok || m == "parameters" {
|
|
continue
|
|
}
|
|
merged := obj{}
|
|
for k, v := range om {
|
|
merged[k] = v
|
|
}
|
|
params, _ := im["parameters"].([]any)
|
|
merged["parameters"] = append(slices.Clone(params), asList(om["parameters"])...)
|
|
out[strings.ToUpper(m)+" "+p] = merged
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func asList(v any) []any { l, _ := v.([]any); return l }
|
|
|
|
func (c *checker) params(opKey string, con, gen obj) {
|
|
genByName := map[string]obj{}
|
|
for _, p := range asList(gen["parameters"]) {
|
|
pm := resolve(c.gen, p)
|
|
genByName[fmt.Sprint(pm["in"])+" "+fmt.Sprint(pm["name"])] = pm
|
|
}
|
|
conByName := map[string]bool{}
|
|
for _, p := range asList(con["parameters"]) {
|
|
pm := resolve(c.con, p)
|
|
id := fmt.Sprint(pm["in"]) + " " + fmt.Sprint(pm["name"])
|
|
conByName[id] = true
|
|
key := fmt.Sprintf("param %s %s", opKey, pm["name"])
|
|
g, ok := genByName[id]
|
|
if !ok {
|
|
c.fail(key, "missing from generated")
|
|
continue
|
|
}
|
|
if pm["required"] == true && g["required"] != true {
|
|
c.fail(key+": required", "contract requires it, generated does not")
|
|
}
|
|
if pm["required"] != true && g["required"] == true {
|
|
c.fail(key+": required", "generated requires a parameter the contract made optional")
|
|
}
|
|
c.compat(key, pm["schema"], g["schema"], true)
|
|
}
|
|
for id, g := range genByName {
|
|
if g["required"] == true && !conByName[id] {
|
|
c.fail(fmt.Sprintf("param %s %s", opKey, g["name"]), "new required parameter not in the contract")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *checker) body(opKey string, con, gen obj) {
|
|
cb := resolve(c.con, get(con, "requestBody"))
|
|
if cb == nil {
|
|
return
|
|
}
|
|
gb := resolve(c.gen, get(gen, "requestBody"))
|
|
cs, gs := get(cb, "content", "application/json", "schema"), get(gb, "content", "application/json", "schema")
|
|
if gs == nil {
|
|
c.fail("body "+opKey, "generated has no JSON request body")
|
|
return
|
|
}
|
|
c.compat("body "+opKey, cs, gs, true)
|
|
}
|
|
|
|
func (c *checker) responses(opKey string, con, gen obj) {
|
|
cr, _ := con["responses"].(obj)
|
|
gr, _ := gen["responses"].(obj)
|
|
for code, r := range cr {
|
|
g, ok := gr[code]
|
|
if !ok {
|
|
c.fail("response "+opKey+" "+code, "missing from generated")
|
|
continue
|
|
}
|
|
cm, _ := get(resolve(c.con, r), "content").(obj)
|
|
for ct, media := range cm {
|
|
gm := get(resolve(c.gen, g), "content", ct, "schema")
|
|
if gm == nil {
|
|
c.fail("response "+opKey+" "+code, "no %s content in generated", ct)
|
|
continue
|
|
}
|
|
c.compat(fmt.Sprintf("response %s %s", opKey, code), get(media, "schema"), gm, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGeneratedSpecMatchesUIContract(t *testing.T) {
|
|
c := load(t)
|
|
if c.gen["openapi"] != "3.1.0" {
|
|
t.Fatalf("generated openapi version %v, want 3.1.0", c.gen["openapi"])
|
|
}
|
|
conOps, genOps := operations(c.con), operations(c.gen)
|
|
for key, con := range conOps {
|
|
gen, ok := genOps[key]
|
|
if !ok {
|
|
c.fail("operation "+key, "missing from generated spec")
|
|
continue
|
|
}
|
|
c.params(key, con, gen)
|
|
c.body(key, con, gen)
|
|
c.responses(key, con, gen)
|
|
}
|
|
if len(c.failed) > 0 {
|
|
sort.Strings(c.failed)
|
|
t.Fatalf("generated spec breaks the UI contract (%d):\n %s", len(c.failed), strings.Join(c.failed, "\n "))
|
|
}
|
|
}
|
|
|
|
// TestContractDivergencesStayNeeded fails when a divergence is no longer needed, so the list shrinks.
|
|
func TestContractDivergencesStayNeeded(t *testing.T) {
|
|
c := load(t)
|
|
saved := knownDivergences
|
|
defer func() { knownDivergences = saved }()
|
|
knownDivergences = map[string]string{}
|
|
conOps, genOps := operations(c.con), operations(c.gen)
|
|
for key, con := range conOps {
|
|
if gen, ok := genOps[key]; ok {
|
|
c.params(key, con, gen)
|
|
}
|
|
}
|
|
hit := strings.Join(c.failed, "\n")
|
|
for k := range saved {
|
|
if !strings.Contains(hit, k) {
|
|
t.Errorf("divergence %q no longer happens; remove it", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResponseSchemasExposeNoIDsOrCredentials walks every schema reachable from a
|
|
// response in the generated spec: no integer id, and no credential-like property.
|
|
func TestResponseSchemasExposeNoIDsOrCredentials(t *testing.T) {
|
|
c := load(t)
|
|
seen := map[string]bool{}
|
|
var walk func(path string, raw any)
|
|
walk = func(path string, raw any) {
|
|
m, _ := raw.(obj)
|
|
if ref, ok := m["$ref"].(string); ok {
|
|
if seen[ref] {
|
|
return
|
|
}
|
|
seen[ref] = true
|
|
walk(path+"→"+ref, resolve(c.gen, m))
|
|
return
|
|
}
|
|
props, _ := m["properties"].(obj)
|
|
for name, p := range props {
|
|
lower := strings.ToLower(name)
|
|
if (lower == "id" || strings.HasSuffix(lower, "_id")) && shapeOf(c.gen, p).typ != "string" {
|
|
t.Errorf("%s.%s is not a string id", path, name)
|
|
}
|
|
for _, bad := range []string{"community", "password", "pass", "hash", "secret", "token", "credential", "key"} {
|
|
// TokenCreated.secret is the one-time reveal of a freshly minted push token; nothing else may carry one.
|
|
if strings.HasSuffix(path, "schemas/TokenCreated") && name == "secret" {
|
|
continue
|
|
}
|
|
if strings.Contains(lower, bad) && !namesAStoredSecret(path, name) {
|
|
t.Errorf("%s.%s looks like a credential", path, name)
|
|
}
|
|
}
|
|
walk(path+"."+name, p)
|
|
}
|
|
if items := m["items"]; items != nil {
|
|
walk(path+"[]", items)
|
|
}
|
|
for _, k := range []string{"anyOf", "oneOf", "allOf"} {
|
|
for _, a := range asList(m[k]) {
|
|
walk(path, a)
|
|
}
|
|
}
|
|
}
|
|
for key, op := range operations(c.gen) {
|
|
responses, _ := op["responses"].(obj)
|
|
for code, r := range responses {
|
|
walk(key+" "+code, get(resolve(c.gen, r), "content", "application/json", "schema"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCheckedInOpenAPIIsCurrent keeps openapi.json (the file the UI client is
|
|
// generated from) equal to what the handlers produce.
|
|
func TestCheckedInOpenAPIIsCurrent(t *testing.T) {
|
|
want, err := api.Spec()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := os.ReadFile("../../openapi.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.TrimSpace(string(got)) != strings.TrimSpace(string(want)) {
|
|
t.Fatal("openapi.json is stale: run `go run ./cmd/nmon openapi > openapi.json`")
|
|
}
|
|
}
|
|
|
|
// namesAStoredSecret exempts the settings-form fields that hold the NAME of a stored secret (or a Proxmox token
|
|
// identifier), never its value: the value is write-only through PUT /secrets/{name}.
|
|
func namesAStoredSecret(path, name string) bool {
|
|
for schema, fields := range map[string][]string{
|
|
"schemas/OIDCForm": {"client_secret_name"},
|
|
"schemas/ProxmoxClusterForm": {"secret_name", "token_id"},
|
|
"schemas/SetupDefaults": {"oidc_secret_name"},
|
|
} {
|
|
if strings.HasSuffix(path, schema) && slices.Contains(fields, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|