Replace the hand-built reference page with Redoc rendering the generated OpenAPI document. The bundle is fetched by package.nix (pinned hash) and served from the UI's own address; only that page gets a style nonce and worker-src blob: in its CSP. The spec now explains authentication, saved-not-applied, ETags (If-Match, 409) and errors, documents the ETag header, gives each operation a curl sample, and no longer names MCP tools. Area GETs no longer emit "parameters": null, which Redoc could not render. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
293 lines
12 KiB
Go
293 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// Viewers (Redoc) fail on a null where the OpenAPI document needs a list.
|
|
func TestSpecHasNoNulls(t *testing.T) {
|
|
a, _, _ := areaApp(t)
|
|
spec := openAPISpec("1.2.3", a.settingsSchema(), "router.example")
|
|
var walk func(path string, v any)
|
|
walk = func(path string, v any) {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
t.Errorf("null at %s", path)
|
|
case map[string]any:
|
|
for k, c := range x {
|
|
walk(path+"/"+k, c)
|
|
}
|
|
case []any:
|
|
for i, c := range x {
|
|
walk(fmt.Sprintf("%s/%d", path, i), c)
|
|
}
|
|
}
|
|
}
|
|
// Round trip, as the served document is.
|
|
data, _ := json.Marshal(spec)
|
|
var doc any
|
|
_ = json.Unmarshal(data, &doc)
|
|
walk("", doc)
|
|
}
|