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..interface": {"loc": ["router","lans","","interface"], "type": "string"}, "router.lans..internet": {"loc": ["router","lans","","internet"], "type": "boolean"}, "router.lans..vlan": {"loc": ["router","lans","","vlan"], "type": "null or integer between 1 and 4094 (both inclusive)"}, "router.lans..ipv4.address": {"loc": ["router","lans","","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) }