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
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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", "<name>.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user