Every tool now returns a Go struct (apitypes.go) instead of a map, and typeschema.go derives the OpenAPI response schemas from those types by reflection (json, doc and enum tags), so the document cannot drift from what is sent. Text results are documented as text/plain, settings in answers use the typed settings schema, and edits answer with EditResponse. TestResponsesMatchTheirSchemas checks real responses against the generated schemas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
427 lines
15 KiB
Go
427 lines
15 KiB
Go
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")})
|
|
}
|