Files
nmon/internal/api/contract_test.go
Vibe Coder f31fb3712a
Some checks failed
ci / lint (push) Successful in 4m19s
ci / openapi (push) Successful in 29s
ci / postgres (push) Successful in 4m55s
ci / test (push) Failing after 19m48s
Add settings forms, first-install wizard, webhook presets, help and poll tools
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
2026-10-25 06:31:12 +00:00

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
}