Files
nasmon/internal/web/web_test.go
T
a.gritsenkoandClaude Opus 5.5 b331bc736d
build / build (push) Successful in 1m7s
Shares: allow any folder, not only ZFS datasets
SMB shares and NFS exports may now point to any existing directory
(USB disks under /mnt or /media, /srv, home folders...) instead of only
ZFS dataset mountpoints. System trees (/etc, /usr, /var, /boot, /proc,
/root, ...) are refused, also after resolving symlinks. The path
suggestions list mounted data filesystems from /proc/self/mounts in
addition to ZFS mountpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:48:31 +03:00

418 lines
17 KiB
Go

package web
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"nasmon/internal/alert"
"nasmon/internal/collect"
"nasmon/internal/config"
"nasmon/internal/history"
"nasmon/internal/sched"
"nasmon/internal/shares"
"nasmon/internal/store"
"nasmon/internal/zfsadm"
)
func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
t.Helper()
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
ring := history.New(100)
for i := 0; i < 50; i++ {
ring.Add(history.Sample{T: time.Now().Add(-time.Duration(50-i) * time.Minute).Unix(), CPU: float64(i), MemUsed: 1 << 30, CPUTemp: 40 + float64(i%10)})
}
am := &alert.Manager{Store: st, Config: cfg}
sc := sched.New(cfg, st, ring, am, "test")
now := time.Now()
old := now.Add(-100 * 24 * time.Hour)
hdd := &collect.Smart{Device: "/dev/sda", Protocol: "ATA", Model: "WDC WD80EFZZ", Serial: "WD-AAA", Firmware: "81.00A81",
Supported: true, Healthy: true, HasTemp: true, TempC: 36, PowerOnHrs: 7541, PowerCycles: 21, CheckedAt: now,
Attrs: []collect.SmartAttr{{ID: 5, Name: "Reallocated_Sector_Ct", Value: 200, Worst: 200, Thresh: 140, Raw: 3, RawString: "3"},
{ID: 194, Name: "Temperature_Celsius", Value: 116, Raw: 36, RawString: "36"}},
LastTest: &collect.SelfTest{Type: "Extended offline", Status: "Completed without error", Passed: true, Hours: 7000}}
nvme := &collect.Smart{Device: "/dev/nvme0n1", Protocol: "NVMe", Model: "Samsung 980", Serial: "S64", Supported: true, Healthy: true,
HasTemp: true, TempC: 41, PowerOnHrs: 1234, CheckedAt: now,
NVMe: &collect.NVMeHealth{AvailableSpare: 100, SpareThreshold: 10, PercentageUsed: 3},
LastTest: &collect.SelfTest{InProgress: true, Remaining: 40, Passed: true}}
_ = st.AddSmart(*hdd)
_ = st.TouchDisk("WD-AAA", "WDC WD80EFZZ", "/dev/sda", now)
a, _ := st.OpenAlert("zfs:tank:health", alert.LevelCrit, "Pool tank is DEGRADED", "State: DEGRADED\nAction: replace", now)
sc.SetStatus(sched.Status{
UpdatedAt: now,
CPU: collect.CPU{UsagePct: 12.5, Cores: 4, Load: [3]float64{0.5, 0.4, 0.3}},
Mem: collect.Memory{Total: 16 << 30, Available: 8 << 30, Used: 8 << 30, ARCSize: 4 << 30, ARCMax: 8 << 30, SwapTotal: 1 << 30, SwapUsed: 100 << 20},
Temps: []collect.Temp{{Chip: "coretemp", Label: "Package id 0", C: 45}},
CPUTemp: 45, HasCPUTemp: true,
Disks: []sched.DiskView{
{Disk: collect.Disk{Name: "sda", Path: "/dev/sda", Model: "WDC WD80EFZZ", Serial: "WD-AAA", Size: 8 << 40, Rota: true, Tran: "sata"}, Smart: hdd},
{Disk: collect.Disk{Name: "nvme0n1", Path: "/dev/nvme0n1", Model: "Samsung 980", Serial: "S64", Size: 500 << 30, Tran: "nvme"}, Smart: nvme},
{Disk: collect.Disk{Name: "sdb", Path: "/dev/sdb", Model: "Sleepy", Serial: "ZZZ", Size: 1 << 40, Rota: true, Tran: "sata"}, Standby: true},
{Disk: collect.Disk{Name: "sdc", Path: "/dev/sdc", Model: "USB", Serial: "U1", Size: 1 << 40, Tran: "usb"}, Error: "open failed"},
},
Pools: []collect.Pool{
{Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old,
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors",
Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}},
{Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"},
},
Datasets: []collect.Dataset{
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"},
{Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"},
{Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"},
{Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"},
{Name: "tank/parent/child", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent/child"},
},
RootFS: collect.FSUsage{Path: "/", Total: 100 << 30, Free: 40 << 30},
Alerts: []store.Alert{a},
LastSmartCheck: now.Add(-10 * time.Minute), NextSmartCheck: now.Add(20 * time.Minute),
LastMailErr: "auth: 535",
})
shs, err := shares.Load(filepath.Join(dir, "shares.json"))
if err != nil {
t.Fatal(err)
}
srv := New(cfg, st, ring, sc, shs)
srv.dirExists = func(p string) bool {
return strings.HasPrefix(p, "/tank") || strings.HasPrefix(p, "/tmp_pool") || strings.HasPrefix(p, "/srv")
}
srv.realPath = func(p string) (string, error) {
if p == "/srv/evil" { // symlink into a system directory
return "/etc", nil
}
return p, nil
}
srv.readMounts = func() string {
return "proc /proc proc rw 0 0\n/dev/sda2 / ext4 rw 0 0\ntank/data /tank/data zfs rw 0 0\n/dev/sdc1 /mnt/usb ext4 rw 0 0\n"
}
return srv, sc, st
}
func TestPagesRender(t *testing.T) {
srv, _, _ := newTestServer(t)
for _, path := range []string{"/", "/?partial=1", "/zfs", "/alerts", "/settings", "/shares", "/shares?path=/tank/data", "/disks/WD-AAA", "/disks/S64", "/disks/ZZZ", "/disks/U1", "/api/status", "/api/history", "/api/disks/WD-AAA/history", "/static/app.css"} {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
if rec.Code != 200 {
t.Errorf("%s: status %d", path, rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "<no value>") || strings.Contains(body, "{{") {
t.Errorf("%s: template leak in output", path)
}
if len(body) < 100 {
t.Errorf("%s: suspiciously short body: %q", path, body)
}
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/disks/NOPE", nil))
if rec.Code != 404 {
t.Errorf("unknown disk: status %d", rec.Code)
}
}
func TestDashboardContent(t *testing.T) {
srv, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
body := rec.Body.String()
for _, want := range []string{"Pool tank is DEGRADED", "WD-AAA", "PASSED", "standby", "40% left", "DEGRADED", "scrubbing", "mail error", "8.0 TiB", "ARC"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
if rec.Header().Get("X-Alerts-Open") != "1" {
t.Errorf("X-Alerts-Open = %q", rec.Header().Get("X-Alerts-Open"))
}
}
func TestSettingsRoundTrip(t *testing.T) {
srv, _, _ := newTestServer(t)
var sent []string
srv.send = func(c config.SMTP, subject, body string) error {
sent = append(sent, subject+"|"+strings.Join(c.To, ","))
return nil
}
form := url.Values{
"smtp_host": {"smtp.example.com"}, "smtp_port": {"465"}, "smtp_security": {"tls"},
"smtp_user": {"u"}, "smtp_pass": {"secret"}, "smtp_from": {"nas@example.com"},
"smtp_to": {"a@example.com\nb@example.com"}, "smtp_prefix": {"[nas]"},
"alerts_enabled": {"1"}, "remind_hours": {"12"}, "smart_interval": {"15"},
"th_disk_temp": {"48"}, "st_enabled": {"1"}, "st_hour": {"4"},
"action": {"test"},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "test mail sent") {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String()[:200])
}
if len(sent) != 1 || !strings.Contains(sent[0], "a@example.com,b@example.com") {
t.Errorf("sent = %v", sent)
}
c := srv.cfg.Get()
if c.SMTP.Host != "smtp.example.com" || c.SMTP.Port != 465 || c.SMTP.Security != "tls" || c.SMTP.Pass != "secret" {
t.Errorf("smtp = %+v", c.SMTP)
}
if c.RemindHours != 12 || c.SmartIntervalMin != 15 || c.Thresholds.DiskTempC != 48 || !c.SelfTests.Enabled || c.SelfTests.Hour != 4 {
t.Errorf("settings = %+v", c)
}
if c.NotifyResolved || c.SmartSkipStandby {
t.Errorf("unchecked boxes must be false: %+v", c)
}
// password must not be echoed
if strings.Contains(rec.Body.String(), "secret") {
t.Errorf("password leaked into the page")
}
// second save without password keeps the old one
form.Del("smtp_pass")
form.Set("action", "save")
rec = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
if srv.cfg.Get().SMTP.Pass != "secret" {
t.Errorf("empty password field must keep the stored one")
}
// invalid value is rejected
form.Set("smtp_port", "99999")
rec = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "Not saved") {
t.Errorf("invalid port accepted")
}
}
func TestSmartRunRedirect(t *testing.T) {
srv, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("POST", "/smart/run", nil))
if rec.Code != http.StatusSeeOther {
t.Errorf("status %d", rec.Code)
}
}
// fakeRunner records host commands and answers "zfs get"/"zfs list" queries.
type fakeRunner struct {
calls []string
fail string // command prefix that fails
}
func (f *fakeRunner) run(_ context.Context, name string, args ...string) (string, error) {
c := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, c)
if f.fail != "" && strings.HasPrefix(c, f.fail) {
return "cannot do that", errors.New("cannot do that")
}
switch {
case strings.HasPrefix(c, "zfs get"):
return "/" + args[len(args)-1], nil
case strings.HasPrefix(c, "zfs list -Hp"):
return args[len(args)-1] + " 98304", nil
}
return "", nil
}
func postForm(srv *Server, path string, form url.Values) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
return rec
}
func TestZfsPageControls(t *testing.T) {
srv, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil))
body := rec.Body.String()
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} {
if !strings.Contains(body, want) {
t.Errorf("zfs page missing %q", want)
}
}
for _, no := range []string{"type tank to confirm", "type tank/data to confirm", "type tank/parent to confirm"} {
if strings.Contains(body, no) {
t.Errorf("zfs page offers destroy for a non-empty/parent/root dataset: %q", no)
}
}
if !strings.Contains(body, "type tank/parent/child to confirm") {
t.Errorf("leaf child should be destroyable")
}
}
func TestZfsCreate(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &fakeRunner{}
srv.admin = zfsadm.New(f.run)
// rejected input never reaches the runner (checked first: a successful
// create refreshes the status from the real host, which has no ZFS here)
bad := []url.Values{
{"pool": {"nope"}, "name": {"x"}}, // unknown pool
{"pool": {"tank"}, "name": {"../etc"}}, // bad name
{"pool": {"tank"}, "name": {"data"}}, // exists
{"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota
{"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner
{"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}},
}
for _, form := range bad {
f.calls = nil
rec := postForm(srv, "/zfs/create", form)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not created") {
t.Errorf("%v: status %d, no error shown", form, rec.Code)
}
if len(f.calls) != 0 {
t.Errorf("%v: commands ran: %v", form, f.calls)
}
if strings.Contains(rec.Body.String(), "<no value>") {
t.Errorf("%v: template leak", form)
}
}
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") {
t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video"
if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" {
t.Errorf("calls = %v", f.calls)
}
// zfs failure is shown and the form keeps its values
f = &fakeRunner{fail: "zfs create"}
srv.admin = zfsadm.New(f.run)
rec = postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"x"}, "quota": {"5G"}})
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") || !strings.Contains(rec.Body.String(), `value="5G"`) {
t.Errorf("failure not reported: %d %s", rec.Code, rec.Body.String()[:300])
}
}
func TestZfsDestroy(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &fakeRunner{}
srv.admin = zfsadm.New(f.run)
bad := []url.Values{
{"name": {"tank/empty"}, "confirm": {"tank/emp"}}, // wrong confirmation
{"name": {"tank/data"}, "confirm": {"tank/data"}}, // not empty
{"name": {"tank/parent"}, "confirm": {"tank/parent"}}, // has child
{"name": {"tank"}, "confirm": {"tank"}}, // pool root
{"name": {"tank/none"}, "confirm": {"tank/none"}}, // unknown
}
for _, form := range bad {
f.calls = nil
rec := postForm(srv, "/zfs/destroy", form)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not destroyed") {
t.Errorf("%v: status %d", form, rec.Code)
}
if len(f.calls) != 0 {
t.Errorf("%v: commands ran: %v", form, f.calls)
}
}
rec := postForm(srv, "/zfs/destroy", url.Values{"name": {"tank/empty"}, "confirm": {"tank/empty"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if len(f.calls) == 0 || f.calls[len(f.calls)-1] != "zfs destroy tank/empty" {
t.Errorf("calls = %v", f.calls)
}
}
func get(srv http.Handler, path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
return rec
}
func post(srv http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
srv.ServeHTTP(rec, req)
return rec
}
func TestAlertAckAndDelete(t *testing.T) {
srv, _, st := newTestServer(t)
open, _ := st.OpenAlerts()
id := open[0].ID
idS := strconv.FormatInt(id, 10)
// dashboard shows the open alert with an ack control
body := get(srv, "/").Body.String()
if !strings.Contains(body, "/alerts/"+idS+"/ack") {
t.Fatalf("dashboard lacks ack form")
}
// ack via fetch: 204, gone from dashboard and badge
rec := post(srv, "/alerts/"+idS+"/ack", map[string]string{"X-Requested-With": "fetch"})
if rec.Code != http.StatusNoContent {
t.Fatalf("ack status %d", rec.Code)
}
rec = get(srv, "/")
if strings.Contains(rec.Body.String(), "Pool tank is DEGRADED") || rec.Header().Get("X-Alerts-Open") != "0" {
t.Errorf("acked alert still on dashboard / badge=%q", rec.Header().Get("X-Alerts-Open"))
}
// /alerts shows it as acked with "show again"
body = get(srv, "/alerts").Body.String()
if !strings.Contains(body, ">acked<") || !strings.Contains(body, "/alerts/"+idS+"/unack") {
t.Errorf("alerts page missing acked state")
}
// delete refused for an open alert
if rec := post(srv, "/alerts/"+idS+"/delete", nil); rec.Code != http.StatusConflict {
t.Errorf("delete of open alert: %d", rec.Code)
}
// unack via a plain form post redirects back to the referer with a message
rec = post(srv, "/alerts/"+idS+"/unack", map[string]string{"Referer": "/alerts?all=1"})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "all=1") || !strings.Contains(rec.Header().Get("Location"), "msg=") {
t.Errorf("unack redirect: %d %s", rec.Code, rec.Header().Get("Location"))
}
if rec := get(srv, "/"); rec.Header().Get("X-Alerts-Open") != "1" {
t.Errorf("badge after unack = %q", rec.Header().Get("X-Alerts-Open"))
}
// resolved long ago: hidden by default, visible with ?all=1, deletable
oldA, _ := st.OpenAlert("k:old", "warn", "Ancient problem", "d", time.Now().Add(-40*24*time.Hour))
_ = st.ResolveAlert(oldA.ID, time.Now().Add(-39*24*time.Hour))
body = get(srv, "/alerts").Body.String()
if strings.Contains(body, "Ancient problem") || !strings.Contains(body, "Show all (1 older resolved hidden)") {
t.Errorf("old resolved alert not hidden by default")
}
body = get(srv, "/alerts?all=1").Body.String()
if !strings.Contains(body, "Ancient problem") || !strings.Contains(body, "/alerts/"+strconv.FormatInt(oldA.ID, 10)+"/delete") {
t.Errorf("old resolved alert missing from ?all=1")
}
if rec := post(srv, "/alerts/"+strconv.FormatInt(oldA.ID, 10)+"/delete", map[string]string{"X-Requested-With": "fetch"}); rec.Code != http.StatusNoContent {
t.Errorf("delete: %d", rec.Code)
}
if strings.Contains(get(srv, "/alerts?all=1").Body.String(), "Ancient problem") {
t.Errorf("deleted alert still listed")
}
if rec := post(srv, "/alerts/999/ack", nil); rec.Code != 404 {
t.Errorf("unknown alert: %d", rec.Code)
}
}