Files
nixos-router/pkgs/router-webui/areas_test.go
T
nilsandClaude Sonnet 5.5 64e08fb485 Add typed settings areas to the REST API
/api/v1/settings/router/firewall, .../router/lans/<name>, .../system, down to
a single option: GET reads, PUT replaces, PATCH merges, DELETE removes, with
the settings' ETag/If-Match and the same limits as the whole-document edit
(editSettings is now shared). Values are checked against a JSON Schema made
from the option documentation (schema.go reads the types, which nixosOptionsDoc
describes in words); a wrong type, a value out of range or an unknown key is
refused with its place. The OpenAPI document has the area paths with their
schemas; the API reference page lists every area's options.

Tests: unit tests for the type reader, the validator and the paths; the real
option file and a router's own settings validate (ROUTER_OPTIONS_JSON,
LIVE_SETTINGS_JSON); a subtest in apply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 20:09:20 +01:00

278 lines
12 KiB
Go

package main
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// A few options in the format of nixosOptionsDoc.
const testOptions = `{
"router.wan.interface": {"loc": ["router","wan","interface"], "type": "string", "description": "The uplink."},
"router.lans": {"loc": ["router","lans"], "type": "attribute set of (submodule)", "description": "LANs.", "default": {"_type":"literalExpression","text":"{ }"}},
"router.lans.<name>.interface": {"loc": ["router","lans","<name>","interface"], "type": "string"},
"router.lans.<name>.internet": {"loc": ["router","lans","<name>","internet"], "type": "boolean"},
"router.lans.<name>.vlan": {"loc": ["router","lans","<name>","vlan"], "type": "null or integer between 1 and 4094 (both inclusive)"},
"router.lans.<name>.ipv4.address": {"loc": ["router","lans","<name>","ipv4","address"], "type": "string matching the pattern [0-9.]+"},
"router.firewall.rules": {"loc": ["router","firewall","rules"], "type": "list of (submodule)"},
"router.firewall.rules.*.from": {"loc": ["router","firewall","rules","*","from"], "type": "string"},
"router.firewall.rules.*.to": {"loc": ["router","firewall","rules","*","to"], "type": "string"},
"router.firewall.rules.*.protocol": {"loc": ["router","firewall","rules","*","protocol"], "type": "one of \"any\", \"tcp\", \"udp\", \"icmp\""},
"router.firewall.rules.*.ports": {"loc": ["router","firewall","rules","*","ports"], "type": "list of (16 bit unsigned integer; between 0 and 65535 (both inclusive) or string matching the pattern [0-9]+-[0-9]+)"},
"router.dns.domain": {"loc": ["router","dns","domain"], "type": "null or string"},
"router.dns.hosts": {"loc": ["router","dns","hosts"], "type": "attribute set of list of string"}
}`
func areaApp(t *testing.T) (*app, http.Handler, string) {
t.Helper()
a, sys := newTestApp(t)
opts := filepath.Join(t.TempDir(), "options.json")
os.WriteFile(opts, []byte(testOptions), 0o600)
a.optionsFile = opts
data, _ := os.ReadFile(sys.settingsPath())
os.WriteFile(sys.confirmedPath(), data, 0o600)
_, tok, _ := a.tokens.Create("area", "change")
return a, a.routes(), tok
}
func TestParseOptType(t *testing.T) {
sub := func() map[string]any { return map[string]any{"type": "object"} }
cases := map[string]string{
"boolean": `{"type":"boolean"}`,
"null or string": `{"anyOf":[{"type":"string"},{"type":"null"}]}`,
"list of string": `{"items":{"type":"string"},"type":"array"}`,
"attribute set of list of string": `{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object"}`,
`one of "dhcp", "pppoe"`: `{"enum":["dhcp","pppoe"]}`,
"integer between 8 and 30 (both inclusive)": `{"maximum":30,"minimum":8,"type":"integer"}`,
"16 bit unsigned integer; between 0 and 65535 (both inclusive)": `{"maximum":65535,"minimum":0,"type":"integer"}`,
"list of (submodule)": `{"items":{"type":"object"},"type":"array"}`,
"something nobody has thought of yet": `{}`,
}
for in, want := range cases {
got, _ := json.Marshal(parseOptType(in, sub))
if string(got) != want {
t.Errorf("%q: %s, want %s", in, got, want)
}
}
// Alternatives in parentheses.
got, _ := json.Marshal(parseOptType("list of (16 bit unsigned integer; between 0 and 65535 (both inclusive) or string matching the pattern [0-9]+-[0-9]+)", sub))
if !strings.Contains(string(got), `"anyOf"`) || !strings.Contains(string(got), `[0-9]+-[0-9]+`) {
t.Errorf("ports: %s", got)
}
}
func TestValidate(t *testing.T) {
a, _ := newTestApp(t)
opts := filepath.Join(t.TempDir(), "o.json")
os.WriteFile(opts, []byte(testOptions), 0o600)
a.optionsFile = opts
root := a.settingsSchema()
lans, err := resolveSchema(root, []string{"router", "lans"})
if err != nil {
t.Fatal(err)
}
ok := map[string]any{"lan": map[string]any{"interface": "eth1", "internet": true, "vlan": float64(20), "ipv4": map[string]any{"address": "10.0.0.1"}}}
if errs := validate(ok, lans, "/router/lans"); len(errs) != 0 {
t.Fatalf("valid: %v", errs)
}
for name, bad := range map[string]map[string]any{
"unknown option": {"lan": map[string]any{"interfce": "eth1"}},
"wrong type": {"lan": map[string]any{"internet": "yes"}},
"range": {"lan": map[string]any{"vlan": float64(5000)}},
"not whole": {"lan": map[string]any{"vlan": 1.5}},
"pattern": {"lan": map[string]any{"ipv4": map[string]any{"address": "ten"}}},
} {
if errs := validate(bad, lans, "/router/lans"); len(errs) == 0 {
t.Errorf("%s was accepted", name)
}
}
if _, err := resolveSchema(root, []string{"router", "firewall", "rules", "0"}); err == nil {
t.Error("list elements are addressable")
}
if _, err := resolveSchema(root, []string{"router", "nothing"}); err == nil {
t.Error("unknown option resolves")
}
}
func TestAreaPaths(t *testing.T) {
_, h, tok := areaApp(t)
if rec := rest(h, tok, "GET", "/api/v1/settings/router/lans/main", "", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"interface"`) {
t.Fatalf("get a LAN: %d %s", rec.Code, rec.Body)
}
etag := rest(h, tok, "GET", "/api/v1/settings/router/lans", "", nil).Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag")
}
for path, want := range map[string]int{
"/api/v1/settings/router/nothing": 404,
"/api/v1/settings/other": 404,
"/api/v1/settings/router/dns": 404, // not in the sample: defaults apply
"/api/v1/settings/router/firewall/rules/0": 404,
} {
if rec := rest(h, tok, "GET", path, "", nil); rec.Code != want {
t.Errorf("GET %s: %d, want %d", path, rec.Code, want)
}
}
hm := map[string]string{"If-Match": etag}
// Typos and wrong types are refused with the place.
rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": 5}`, hm)
if rec.Code != 400 || !strings.Contains(rec.Body.String(), "/router/dns/domain") {
t.Fatalf("wrong type: %d %s", rec.Code, rec.Body)
}
if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domian": "x"}`, hm); rec.Code != 400 || !strings.Contains(rec.Body.String(), "unknown option") {
t.Fatalf("typo: %d %s", rec.Code, rec.Body)
}
if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test"}`, nil); rec.Code != 400 {
t.Fatalf("without If-Match: %d", rec.Code)
}
if rec := rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test"}`, map[string]string{"If-Match": "0000"}); rec.Code != 409 {
t.Fatalf("stale: %d", rec.Code)
}
rec = rest(h, tok, "PUT", "/api/v1/settings/router/dns", `{"domain": "home.test", "hosts": {"nas": ["192.168.10.9"]}}`, hm)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"saved": true`) {
t.Fatalf("put: %d %s", rec.Code, rec.Body)
}
etag = rec.Header().Get("ETag")
hm = map[string]string{"If-Match": etag}
if rec := rest(h, tok, "GET", "/api/v1/settings/router/dns/hosts/nas", "", nil); !strings.Contains(rec.Body.String(), "192.168.10.9") {
t.Fatalf("read back: %d %s", rec.Code, rec.Body)
}
// PATCH merges: the other host stays.
rec = rest(h, tok, "PATCH", "/api/v1/settings/router/dns/hosts", `{"printer": ["192.168.10.8"]}`, hm)
if rec.Code != 200 {
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
}
hm = map[string]string{"If-Match": rec.Header().Get("ETag")}
body := rest(h, tok, "GET", "/api/v1/settings/router/dns", "", nil).Body.String()
if !strings.Contains(body, "nas") || !strings.Contains(body, "printer") || !strings.Contains(body, "home.test") {
t.Fatalf("after patch: %s", body)
}
// What the API may not change stays out of reach.
if rec := rest(h, tok, "PUT", "/api/v1/settings/system/sshKeys", `[]`, hm); rec.Code != 400 {
t.Fatalf("ssh keys: %d %s", rec.Code, rec.Body)
}
rec = rest(h, tok, "PUT", "/api/v1/settings/router/wan", `{"interface":"x"}`, hm)
if rec.Code != 200 {
t.Fatalf("wan: %d %s", rec.Code, rec.Body)
}
hm = map[string]string{"If-Match": rec.Header().Get("ETag")}
// DELETE removes; the diff and the edit note work like for the whole document.
rec = rest(h, tok, "DELETE", "/api/v1/settings/router/dns", "", hm)
if rec.Code != 200 {
t.Fatalf("delete: %d %s", rec.Code, rec.Body)
}
if rec := rest(h, tok, "GET", "/api/v1/settings/router/dns", "", nil); rec.Code != 404 {
t.Fatalf("after delete: %d", rec.Code)
}
// A read token reads but cannot write.
a, _, _ := areaApp(t)
_, read, _ := a.tokens.Create("reader", "read")
if rec := rest(a.routes(), read, "GET", "/api/v1/settings/router/lans", "", nil); rec.Code != 200 {
t.Fatalf("read token reads: %d", rec.Code)
}
if rec := rest(a.routes(), read, "PUT", "/api/v1/settings/router/dns", `{}`, map[string]string{"If-Match": "x"}); rec.Code != 403 {
t.Fatalf("read token wrote: %d", rec.Code)
}
}
func TestAreaOpenAPI(t *testing.T) {
_, h, _ := areaApp(t)
rec := rest(h, "", "GET", "/api/openapi.json", "", nil)
var spec struct {
Paths map[string]map[string]any `json:"paths"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil {
t.Fatal(err)
}
for _, p := range []string{"/api/v1/settings/router/lans", "/api/v1/settings/router/lans/{name}", "/api/v1/settings/router/dns/domain", "/api/v1/settings/system/hostName"} {
if spec.Paths[p] == nil {
t.Errorf("%s is not documented", p)
}
}
lan := spec.Paths["/api/v1/settings/router/lans/{name}"]
for _, m := range []string{"get", "put", "patch", "delete"} {
if lan[m] == nil {
t.Errorf("lans/{name} has no %s", m)
}
}
if _, ok := spec.Paths["/api/v1/settings/router/dns/domain"]["patch"]; ok {
t.Error("a string option has a PATCH")
}
keys := make([]string, 0)
for p := range spec.Paths {
keys = append(keys, p)
}
if !slices.Contains(keys, "/api/v1/settings") {
t.Error("the whole settings are gone from the spec")
}
}
// With ROUTER_OPTIONS_JSON set to the real options file, every option's type is
// understood (nothing falls back to "anything") and the sample settings of the
// tests validate.
func TestRealOptionsSchema(t *testing.T) {
path := os.Getenv("ROUTER_OPTIONS_JSON")
if path == "" {
t.Skip("ROUTER_OPTIONS_JSON not set")
}
entries, err := loadOptions(path)
if err != nil {
t.Fatal(err)
}
for k, e := range entries {
if !strings.HasPrefix(k, "router.") {
continue
}
s := parseOptType(e.Type, func() map[string]any { return map[string]any{"type": "object"} })
if len(s) == 0 {
t.Errorf("%s: type %q is not understood", k, e.Type)
}
}
root := settingsSchema(path)
if root == nil {
t.Fatal("no schema")
}
var doc map[string]any
if err := json.Unmarshal([]byte(sample), &doc); err != nil {
t.Fatal(err)
}
if errs := validate(doc, root, ""); len(errs) != 0 {
t.Errorf("the sample settings do not validate: %v", errs)
}
}
// LIVE_SETTINGS_JSON: a real router's settings must validate too.
func TestLiveSettingsValidate(t *testing.T) {
opts, live := os.Getenv("ROUTER_OPTIONS_JSON"), os.Getenv("LIVE_SETTINGS_JSON")
if opts == "" || live == "" {
t.Skip("ROUTER_OPTIONS_JSON and LIVE_SETTINGS_JSON not set")
}
data, err := os.ReadFile(live)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatal(err)
}
if errs := validate(doc, settingsSchema(opts), ""); len(errs) != 0 {
t.Errorf("%v", errs)
}
}
func TestAreasOnReferencePage(t *testing.T) {
a, h, _ := areaApp(t)
cookie := login(t, h)
_ = a
body := do(h, "GET", "/apireference", nil, cookie).Body.String()
for _, want := range []string{"/api/v1/settings/router/lans", "/api/v1/settings/router/firewall", "&lt;name&gt;.vlan", "null or integer between 1 and 4094", "Settings areas"} {
if !strings.Contains(body, want) {
t.Errorf("%q is missing from the reference page", want)
}
}
}