Files
nixos-router/pkgs/router-webui/api_test.go
T
nilsandClaude Sonnet 5.5 876080d3f5 Show the API reference with Redoc
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
2026-11-01 22:01:30 +01:00

285 lines
11 KiB
Go

package main
import (
"encoding/json"
"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")
}
}