Files
nixos-router/pkgs/router-webui/dashboard_test.go
T
nilsandClaude Sonnet 5.5 586274af4e Add the appliance web UI look, interface names, MCP and REST API, local zone
Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes),
theme preference on the Advanced page, Changes page with the pending diff.

Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and
pppoe.vlanName; a rename is staged for the next reboot by router-apply.

Remote access: an MCP server (POST /mcp) and an OpenAPI REST API
(/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit
log; tools to read status, settings, leases, logs (get_logs), and to edit
and check the saved settings (never applied). Tokens on the API access page.

Firewall: a reserved `local` zone (the router itself) as the destination of
rules, so the web UI or SSH can be allowed from the WAN.

PLAN.md lists what is still left.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 16:56:59 +01:00

236 lines
7.3 KiB
Go

package main
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestTierAveragesAndGaps(t *testing.T) {
s := newStore()
base := time.Unix(1_800_000_000, 0) // a multiple of every step
for i := range 12 { // one minute of 5 s samples
s.Add("x", base.Add(time.Duration(i)*5*time.Second), float64(i))
}
// A gap of 10 minutes, then one more sample.
s.Add("x", base.Add(11*time.Minute), 100)
now := base.Add(11 * time.Minute)
start, step, vals := s.Read("x", 0, now)
if step != 5*time.Second || len(vals) != 720 {
t.Fatalf("step %v, %d values", step, len(vals))
}
at := func(ts time.Time) float64 { return vals[int(ts.Sub(start)/step)] }
if at(base) != 0 || at(base.Add(55*time.Second)) != 11 {
t.Fatalf("5 s values: %v %v", at(base), at(base.Add(55*time.Second)))
}
if !math.IsNaN(at(base.Add(5 * time.Minute))) {
t.Fatal("the gap is not empty")
}
if at(now) != 100 || !math.IsNaN(at(base.Add(-5*time.Second))) {
t.Fatal("latest bucket or before the first sample")
}
// The minute tier averages the first minute.
start, step, vals = s.Read("x", 1, now)
if v := vals[int(base.Sub(start)/step)]; v != 5.5 {
t.Fatalf("minute average %v", v)
}
// An hour later the 5 s ring has turned over: the old values are gone.
later := now.Add(2 * time.Hour)
s.Add("x", later, 1)
_, _, vals = s.Read("x", 0, later)
for i, v := range vals[:len(vals)-1] {
if !math.IsNaN(v) {
t.Fatalf("stale value %v at %d", v, i)
}
}
}
func TestStoreSurvivesSaving(t *testing.T) {
path := filepath.Join(t.TempDir(), "m.gob")
s := newStore()
now := time.Unix(1_800_000_000, 0)
s.Add("x", now, 42)
s.SetConnected(true, now, "198.51.100.7")
if err := s.Save(path); err != nil {
t.Fatal(err)
}
l, err := loadStore(path)
if err != nil {
t.Fatal(err)
}
_, _, vals := l.Read("x", 0, now)
connected, since, events := l.Uplink()
if vals[len(vals)-1] != 42 || !connected || !since.Equal(now) || len(events) != 1 {
t.Fatalf("loaded %v %v %v %v", vals[len(vals)-1], connected, since, events)
}
}
const leaseFile = `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
192.168.10.101,52:54:00:12:34:56,01:52,3600,%d,1,0,0,laptop,0,,0
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,old&#x2cname,0,,0
192.168.10.100,52:54:00:aa:bb:cc,01:52,3600,%d,1,0,0,phone,0,,0
192.168.10.102,52:54:00:dd:ee:ff,01:52,0,%d,1,0,0,gone,0,,0
192.168.10.103,52:54:00:dd:ee:00,01:52,3600,%d,1,0,0,expired,0,,0
`
func TestReadLeases(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
future, past := now.Unix()+1800, now.Unix()-10
path := filepath.Join(t.TempDir(), "dhcp4.leases")
os.WriteFile(path, []byte(fmt.Sprintf(leaseFile, future, future, future, future, past)), 0o600)
lans := []LANForm{{Name: "main", Address: "192.168.10.1"}}
got := readLeases(path, lans, now)
if len(got) != 2 || got[0].Address != "192.168.10.100" || got[0].Hostname != "phone" ||
got[1].Hostname != "laptop" || got[1].LAN != "main" {
t.Fatalf("leases: %+v", got)
}
}
func csrfFrom(t *testing.T, page string) string {
t.Helper()
m := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(page)
if m == nil {
t.Fatalf("no CSRF token in:\n%s", page)
}
return m[1]
}
func TestParsePingTargets(t *testing.T) {
got, err := parsePingTargets("gateway, 1.1.1.1\n2606:4700:4700::1111 1.1.1.1")
if err != nil || strings.Join(got, " ") != "gateway 1.1.1.1 2606:4700:4700::1111" {
t.Fatalf("%v %v", got, err)
}
if _, err := parsePingTargets("example.com"); err == nil {
t.Fatal("host names are not accepted")
}
if _, err := parsePingTargets("1.1.1.1 1.1.1.2 1.1.1.3 1.1.1.4 1.1.1.5 1.1.1.6"); err == nil {
t.Fatal("too many targets accepted")
}
}
func login(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
rec := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login: %d", rec.Code)
}
return rec.Result().Cookies()[0]
}
func TestDashboardRenders(t *testing.T) {
a, _ := newTestApp(t)
now := time.Now()
for i := range 20 {
at := now.Add(time.Duration(i-20) * 5 * time.Second)
a.metrics.store.Add("if:eth1:rx", at, 1e6)
a.metrics.store.Add("if:eth1:tx", at, 2e5)
}
h := a.routes()
cookie := login(t, h)
for _, path := range []string{"/", "/?range=7d", "/dashboard?range=24h", "/advanced"} {
rec := do(h, "GET", path, nil, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("%s: %d\n%s", path, rec.Code, rec.Body)
}
body := rec.Body.String()
if path == "/" && (!strings.Contains(body, "Internet (eth1)") || !strings.Contains(body, "8.0 Mbit/s") ||
!strings.Contains(body, "/static/dashboard.js")) {
t.Fatalf("overview lacks the traffic chart:\n%s", body)
}
if strings.HasPrefix(path, "/dashboard") && strings.Contains(body, "<html") {
t.Fatal("the fragment is a whole page")
}
}
}
func TestBackup(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := login(t, h)
page := do(h, "GET", "/advanced", nil, cookie).Body.String()
token := csrfFrom(t, page)
rec := do(h, "POST", "/backup", url.Values{"csrf": {token}}, cookie)
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), `attachment; filename="router-backup-`) {
t.Fatalf("backup: %d %v", rec.Code, rec.Header())
}
gz, err := gzip.NewReader(rec.Body)
if err != nil {
t.Fatal(err)
}
files := map[string]string{}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(tr)
files[hdr.Name] = string(data)
}
settings, _ := os.ReadFile(sys.settingsPath())
if files["router/config/settings.json"] != string(settings) || !strings.HasPrefix(files["router/webui-password"], "pbkdf2") {
t.Fatalf("backup content: %v", keys(files))
}
if _, ok := files["router/webui/metrics.gob"]; !ok {
t.Fatalf("no metric history in the backup: %v", keys(files))
}
if rec := do(h, "POST", "/backup", url.Values{}, cookie); rec.Code != http.StatusForbidden {
t.Fatalf("backup without CSRF token: %d", rec.Code)
}
}
func keys(m map[string]string) []string {
var out []string
for k := range m {
out = append(out, k)
}
return out
}
func TestTheme(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := login(t, h)
token := csrfFrom(t, do(h, "GET", "/advanced", nil, cookie).Body.String())
has := func(path, want string, ck *http.Cookie) bool {
return strings.Contains(do(h, "GET", path, nil, ck).Body.String(), want)
}
if has("/advanced", "data-theme", cookie) || !has("/login", "<body class=\"bare\"", nil) {
t.Fatal("default must follow the browser; the login page has no sidebar")
}
save := func(theme string) {
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "theme": {theme}}, cookie)
}
save("dark")
if sys.loadPrefs().Theme != "dark" || !has("/", `data-theme="dark"`, cookie) || !has("/login", `data-theme="dark"`, nil) {
t.Fatal("dark not applied")
}
save("neon")
if sys.loadPrefs().Theme != "dark" {
t.Fatal("an unknown theme was stored")
}
do(h, "POST", "/prefs", url.Values{"csrf": {token}, "pingTargets": {"1.1.1.1"}}, cookie)
if sys.loadPrefs().Theme != "dark" {
t.Fatal("saving the ping targets dropped the theme")
}
save("")
if has("/advanced", "data-theme", cookie) {
t.Fatal("automatic not restored")
}
}