build / build (push) Successful in 1m4s
Pool cards show the dedup ratio and can set dedup=on|off on the pool root dataset (inherited by children); the new-dataset form gets a dedup option. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
457 lines
19 KiB
Go
457 lines
19 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", 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, "<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", `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(), "<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"}, "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)
|
|
}
|
|
}
|