package main import ( "encoding/json" "net/http" "net/http/httptest" "os" "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) } for _, rt := range apiRoutes { if !strings.Contains(body, rt.path) { t.Errorf("%s is not on the page", rt.path) } } if !strings.Contains(body, "curl -sk") || !strings.Contains(body, "If-Match") || !strings.Contains(body, "unit=router-pppoe") { t.Errorf("examples missing") } }