package main import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "regexp" "slices" "strings" "testing" "time" ) // rest sends a request to the REST API with a token. func rest(h http.Handler, token, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } for k, v := range headers { req.Header.Set(k, v) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestRESTAuthAndScopes(t *testing.T) { a, _ := newTestApp(t) h := a.routes() if rec := rest(h, "", "GET", "/api/v1/status", "", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("no token: %d", rec.Code) } _, read, _ := a.tokens.Create("reader", "read") _, change, _ := a.tokens.Create("changer", "change") if rec := rest(h, read, "GET", "/api/v1/status", "", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"uplink"`) { t.Fatalf("status: %d %s", rec.Code, rec.Body) } if rec := rest(h, read, "DELETE", "/api/v1/changes", "", nil); rec.Code != http.StatusForbidden { t.Fatalf("a read token discards: %d", rec.Code) } if rec := rest(h, read, "PATCH", "/api/v1/settings", `{}`, map[string]string{"If-Match": "x"}); rec.Code != http.StatusForbidden { t.Fatalf("a read token patches: %d", rec.Code) } if rec := rest(h, change, "DELETE", "/api/v1/changes", "", nil); rec.Code == http.StatusForbidden || rec.Code == http.StatusUnauthorized { t.Fatalf("discard: %d %s", rec.Code, rec.Body) } if rec := rest(h, read, "POST", "/api/v1/status", "", nil); rec.Code != http.StatusMethodNotAllowed { t.Fatalf("POST: %d", rec.Code) } req := httptest.NewRequest("GET", "/api/v1/status", nil) req.Header.Set("Authorization", "Bearer "+read) req.Header.Set("Origin", "https://evil.example") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("foreign origin: %d", rec.Code) } } func TestRESTSettings(t *testing.T) { a, sys := newTestApp(t) if data, err := os.ReadFile(sys.settingsPath()); err == nil { os.WriteFile(sys.confirmedPath(), data, 0o600) } h := a.routes() _, tok, _ := a.tokens.Create("changer", "change") rec := rest(h, tok, "GET", "/api/v1/settings", "", nil) if rec.Code != 200 { t.Fatalf("get: %d", rec.Code) } etag := rec.Header().Get("ETag") if etag == "" { t.Fatal("no ETag header") } patch := `{"system":{"hostName":"gw"}}` if rec := rest(h, tok, "PATCH", "/api/v1/settings", patch, nil); rec.Code != http.StatusBadRequest { t.Fatalf("without If-Match: %d", rec.Code) } if rec := rest(h, tok, "PATCH", "/api/v1/settings", patch, map[string]string{"If-Match": `"0000"`}); rec.Code != http.StatusConflict { t.Fatalf("stale ETag: %d %s", rec.Code, rec.Body) } if rec := rest(h, tok, "PATCH", "/api/v1/settings", `{"system":{"sshKeys":[]}}`, map[string]string{"If-Match": etag}); rec.Code != http.StatusBadRequest { t.Fatalf("refused patch: %d", rec.Code) } rec = rest(h, tok, "PATCH", "/api/v1/settings", patch, map[string]string{"If-Match": etag}) if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"saved": true`) || rec.Header().Get("ETag") == etag { t.Fatalf("patch: %d %s", rec.Code, rec.Body) } rec = rest(h, tok, "GET", "/api/v1/changes", "", nil) if !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") || !strings.Contains(rec.Body.String(), "gw") { t.Fatalf("changes: %s %s", rec.Header().Get("Content-Type"), rec.Body) } } func TestLogs(t *testing.T) { one := 0 cases := []struct { q logQuery want []string bad bool }{ {logQuery{Unit: "router-webui"}, []string{"--lines=100"}, false}, {logQuery{}, nil, true}, {logQuery{Unit: "sshd.service"}, []string{"--unit=sshd.service"}, false}, {logQuery{Unit: "systemd-journald"}, nil, true}, {logQuery{Unit: "getty@tty1"}, nil, true}, {logQuery{Unit: "router-pppoe", Lines: 5, Since: "1 hour ago", Priority: "err", Grep: "LCP", Boot: &one}, []string{"--lines=5", "--unit=router-pppoe", "--since=1 hour ago", "--priority=err", "--grep=LCP", "--boot=0"}, false}, {logQuery{Unit: "kernel"}, []string{"-k"}, false}, {logQuery{Unit: "--vacuum-time=1s"}, nil, true}, {logQuery{Unit: "a b"}, nil, true}, {logQuery{Unit: "router-webui", Lines: 5000}, nil, true}, {logQuery{Unit: "router-webui", Priority: "loud"}, nil, true}, {logQuery{Unit: "router-webui", Since: "$(reboot)"}, nil, true}, } for _, c := range cases { args, err := journalArgs(c.q) if (err != nil) != c.bad { t.Fatalf("%+v: error %v", c.q, err) } for _, w := range c.want { if !slices.Contains(args, w) { t.Errorf("%+v: %q missing from %q", c.q, w, args) } } } a, _ := newTestApp(t) h := a.routes() var got []string runJournal = func(name string, args ...string) (string, error) { got = append([]string{name}, args...) return "2026-10-31T22:16:08+0000 router pppd[1]: Connected", nil } t.Cleanup(func() { runJournal = command }) _, tok, _ := a.tokens.Create("reader", "read") rec := rest(h, tok, "GET", "/api/v1/logs?unit=router-pppoe&lines=3&boot=-1", "", nil) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Connected") || !slices.Contains(got, "--boot=-1") || !slices.Contains(got, "--lines=3") { t.Fatalf("logs: %d %s %q", rec.Code, rec.Body, got) } if rec := rest(h, tok, "GET", "/api/v1/logs?lines=abc", "", nil); rec.Code != http.StatusBadRequest { t.Fatalf("bad number: %d", rec.Code) } if rec := rest(h, tok, "GET", "/api/v1/logs?unit=-x", "", nil); rec.Code != http.StatusBadRequest { t.Fatalf("bad unit: %d", rec.Code) } text, failed := call(t, h, tok, "get_logs", map[string]any{"unit": "router-webui", "grep": "x"}) if failed || !strings.Contains(text, "Connected") || !slices.Contains(got, "--unit=router-webui") { t.Fatalf("mcp get_logs: %v %q", failed, text) } } func TestOpenAPI(t *testing.T) { a, _ := newTestApp(t) h := a.routes() rec := rest(h, "", "GET", "/api/openapi.json", "", nil) if rec.Code != 200 { t.Fatalf("spec: %d", rec.Code) } var spec struct { OpenAPI string `json:"openapi"` Paths map[string]map[string]map[string]any `json:"paths"` } if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil || spec.OpenAPI != "3.1.0" { t.Fatalf("spec: %v %s", err, spec.OpenAPI) } // Every tool has an operation (the two interfaces stay equal). ops := map[string]bool{} for _, methods := range spec.Paths { for _, op := range methods { ops[op["operationId"].(string)] = true } } for _, tool := range mcpTools { if !ops[tool.name] { t.Errorf("tool %s has no REST operation", tool.name) } } for _, rt := range apiRoutes { if spec.Paths[rt.path][strings.ToLower(rt.method)] == nil { t.Errorf("%s %s is not in the spec", rt.method, rt.path) } } } func TestTokenExpiry(t *testing.T) { a, _ := newTestApp(t) h := a.routes() tok, secret, err := a.tokens.CreateFor("short", "read", time.Hour) if err != nil || tok.Expires.IsZero() { t.Fatalf("create: %v %+v", err, tok) } if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != 200 { t.Fatalf("valid token: %d", rec.Code) } // Move its end into the past. toks := a.tokens.load() toks[0].Expires = time.Now().Add(-time.Minute) if err := a.tokens.save(toks); err != nil { t.Fatal(err) } if rec := rest(h, secret, "GET", "/api/v1/status", "", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("expired token: %d", rec.Code) } if rec, _ := rpc(h, secret, "ping", nil); rec.Code != http.StatusUnauthorized { t.Fatalf("expired token on MCP: %d", rec.Code) } _, forever, _ := a.tokens.Create("long", "read") if rec := rest(h, forever, "GET", "/api/v1/status", "", nil); rec.Code != 200 { t.Fatalf("token without expiry: %d", rec.Code) } } func TestTokenEditNote(t *testing.T) { a, sys := newTestApp(t) h := a.routes() data, _ := os.ReadFile(sys.settingsPath()) os.WriteFile(sys.confirmedPath(), data, 0o600) _, tok, _ := a.tokens.Create("assistant", "change") rec := rest(h, tok, "GET", "/api/v1/settings", "", nil) e := rec.Header().Get("ETag") if rec := rest(h, tok, "PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": e}); rec.Code != 200 { t.Fatalf("patch: %d %s", rec.Code, rec.Body) } cur, _ := os.ReadFile(sys.settingsPath()) if note := sys.tokenEditNote(cur); !strings.Contains(note, "assistant") { t.Fatalf("no note: %q", note) } // A later edit by a person makes the note stale. other := append([]byte(nil), cur...) other = append(other, '\n', ' ') if note := sys.tokenEditNote(other); note != "" { t.Fatalf("stale note: %q", note) } } func TestAPIReference(t *testing.T) { a, _ := newTestApp(t) h := a.routes() if rec := do(h, "GET", "/apireference", nil, nil); rec.Code != http.StatusSeeOther { t.Fatalf("without a session: %d", rec.Code) } cookie := login(t, h) rec := do(h, "GET", "/apireference", nil, cookie) body := rec.Body.String() if rec.Code != 200 { t.Fatalf("reference: %d", rec.Code) } csp := rec.Header().Get("Content-Security-Policy") m := regexp.MustCompile(`data-nonce="([^"]+)"`).FindStringSubmatch(body) if m == nil || !strings.Contains(csp, "'nonce-"+m[1]+"'") { t.Errorf("the page's nonce is not in its CSP: %q", csp) } if strings.Contains(csp, "unsafe-inline") || strings.Contains(csp, "unsafe-eval") { t.Errorf("CSP too loose: %q", csp) } if !strings.Contains(body, `data-spec="/api/openapi.json"`) || !strings.Contains(body, "/static/redoc.standalone.js") { t.Errorf("the page does not load Redoc with the spec:\n%s", body) } } func TestSpecExplainsETags(t *testing.T) { spec := openAPISpec("1.2.3", nil, "router.example") info := spec["info"].(map[string]any)["description"].(string) for _, want := range []string{"ETag", "If-Match", "409"} { if !strings.Contains(info, want) { t.Errorf("%q is not explained in the spec's description", want) } } patch := spec["paths"].(map[string]any)["/api/v1/settings"].(map[string]any)["patch"].(map[string]any) if strings.Contains(patch["description"].(string), "update_settings") || strings.Contains(patch["description"].(string), "check_settings") { t.Errorf("REST description names an MCP tool: %v", patch["description"]) } src := patch["x-codeSamples"].([]any)[0].(map[string]any)["source"].(string) if !strings.Contains(src, "https://router.example/api/v1/settings") || !strings.Contains(src, "If-Match") { t.Errorf("curl sample: %s", src) } get := spec["paths"].(map[string]any)["/api/v1/settings"].(map[string]any)["get"].(map[string]any) if _, ok := get["responses"].(map[string]any)["200"].(map[string]any)["headers"].(map[string]any)["ETag"]; !ok { t.Errorf("GET settings does not document its ETag header") } } // strs reads a string or a list of strings of a decoded schema. func strs(v any) []string { switch x := v.(type) { case string: return []string{x} case []any: var out []string for _, e := range x { out = append(out, fmt.Sprint(e)) } return out } return nil } // conforms checks a decoded JSON value against a schema of the OpenAPI // document (the subset typeschema.go emits), resolving $ref in spec. func conforms(v any, s map[string]any, spec map[string]any, path string) []string { if ref, ok := s["$ref"].(string); ok { name := strings.TrimPrefix(ref, "#/components/schemas/") def, _ := spec["components"].(map[string]any)["schemas"].(map[string]any)[name].(map[string]any) if def == nil { return []string{path + ": unknown schema " + name} } return conforms(v, def, spec, path) } if branches, ok := s["anyOf"].([]any); ok { for _, b := range branches { if len(conforms(v, b.(map[string]any), spec, path)) == 0 { return nil } } return []string{path + ": matches none of the alternatives"} } types := strs(s["type"]) if len(types) == 0 { return nil // any } kind := "null" switch v.(type) { case bool: kind = "boolean" case string: kind = "string" case float64: kind = "number" case []any: kind = "array" case map[string]any: kind = "object" } if f, ok := v.(float64); ok && f == float64(int64(f)) && slices.Contains(types, "integer") { kind = "integer" } if !slices.Contains(types, kind) { return []string{path + ": is " + kind + ", the schema says " + strings.Join(types, " or ")} } var errs []string switch x := v.(type) { case string: if s["format"] == "date-time" { if _, err := time.Parse(time.RFC3339Nano, x); err != nil { errs = append(errs, path+": not a date-time: "+x) } } if enum := strs(s["enum"]); enum != nil && !slices.Contains(enum, x) { errs = append(errs, path+": "+x+" is not in the enum") } case []any: items, _ := s["items"].(map[string]any) for i, e := range x { errs = append(errs, conforms(e, items, spec, fmt.Sprintf("%s[%d]", path, i))...) } case map[string]any: props, _ := s["properties"].(map[string]any) { for _, k := range strs(s["required"]) { if _, ok := x[k]; !ok { errs = append(errs, path+": "+k+" is missing") } } } for k, c := range x { if p, ok := props[k].(map[string]any); ok { errs = append(errs, conforms(c, p, spec, path+"/"+k)...) } else if add, ok := s["additionalProperties"].(map[string]any); ok { errs = append(errs, conforms(c, add, spec, path+"/"+k)...) } else if props != nil { errs = append(errs, path+": "+k+" is not in the schema") } } } return errs } // What the API answers is what the OpenAPI document says it answers. func TestResponsesMatchTheirSchemas(t *testing.T) { a, sys := newTestApp(t) if data, err := os.ReadFile(sys.settingsPath()); err == nil { os.WriteFile(sys.confirmedPath(), data, 0o600) } h := a.routes() _, tok, _ := a.tokens.Create("changer", "change") // Through JSON, as a client sees the document. var spec map[string]any data, _ := json.Marshal(openAPISpec("1.2.3", nil, "router.example")) json.Unmarshal(data, &spec) check := func(method, path, body string, headers map[string]string) { t.Helper() rec := rest(h, tok, method, path, body, headers) if rec.Code != 200 { t.Errorf("%s %s: %d %s", method, path, rec.Code, rec.Body) return } op := spec["paths"].(map[string]any)[path].(map[string]any)[strings.ToLower(method)].(map[string]any) content := op["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any) js, ok := content["application/json"].(map[string]any) if !ok { if _, text := content["text/plain"]; !text || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") { t.Errorf("%s %s: documented %v, sent %s", method, path, content, rec.Header().Get("Content-Type")) } return } var v any if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { t.Errorf("%s %s: %v", method, path, err) return } if errs := conforms(v, js["schema"].(map[string]any), spec, ""); len(errs) > 0 { t.Errorf("%s %s does not match its schema: %v\n%s", method, path, errs, rec.Body) } } for _, p := range []string{"/api/v1/status", "/api/v1/settings", "/api/v1/interfaces", "/api/v1/dhcp-leases", "/api/v1/update", "/api/v1/apply-log"} { check("GET", p, "", nil) } check("GET", "/api/v1/changes", "", nil) check("DELETE", "/api/v1/changes", "", nil) rec := rest(h, tok, "GET", "/api/v1/settings", "", nil) check("PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": rec.Header().Get("ETag")}) }