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", DedupRatio: 1.42, 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", Dedup: "on"}, {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, "") || 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", `action="/zfs/dedup"`, "Dedup for tmp_pool", "ratio 1.42x"} { 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"}}, {"pool": {"tank"}, "name": {"x"}, "dedup": {"verify"}}, } 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(), "") { t.Errorf("%v: template leak", form) } } rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "dedup": {"on"}, "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 dedup=on -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 TestZfsDedup(t *testing.T) { srv, _, _ := newTestServer(t) f := &fakeRunner{} srv.admin = zfsadm.New(f.run) bad := []url.Values{ {"pool": {"nope"}, "dedup": {"on"}}, // unknown pool {"pool": {"tank/data"}, "dedup": {"on"}}, // not a pool {"pool": {"tank"}, "dedup": {"verify"}}, // unsupported value {"pool": {"tank"}}, // missing value } for _, form := range bad { f.calls = nil rec := postForm(srv, "/zfs/dedup", form) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Dedup not changed") { 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) } } rec := postForm(srv, "/zfs/dedup", url.Values{"pool": {"tmp_pool"}, "dedup": {"on"}}) if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "#tmp_pool") { t.Fatalf("status %d location %q", rec.Code, rec.Header().Get("Location")) } if len(f.calls) != 1 || f.calls[0] != "zfs set dedup=on tmp_pool" { t.Errorf("calls = %v", f.calls) } f = &fakeRunner{fail: "zfs set"} srv.admin = zfsadm.New(f.run) rec = postForm(srv, "/zfs/dedup", url.Values{"pool": {"tank"}, "dedup": {"off"}}) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") { t.Errorf("failure not reported: %d", rec.Code) } } 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) } }