get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd, nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30 (default) or 90 days, or without limit. The overview and the Changes page say which token made the unapplied changes. options.json (router://options) no longer holds the options' source paths: they differ between builds of a release, so a router rebuilding itself offline had to build the file and failed (found by the update test). Web UI: panels flow into columns on wide screens and the width is no longer capped; on phones the navigation is a menu button; the Advanced page uses panels; VLAN dropdowns show their chosen names; the duplicate WireGuard heading is gone. basic: a subtest for wan -> local rules. SPECS.md is now the only document (decisions, how each part works, and what is not done yet); PLAN.md is gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
237 lines
8.5 KiB
Go
237 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|