Files
Dash/apps/backend/internal/api/alerts_test.go
Tomas DvorakandDevin f3143af351 feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/
gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules
(failsBeforeDown, latencyWarn, certDays, mute).

P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip
collectors, crt.sh subdomain discovery with daily sweep.

P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login
rate limit, public-route bypass) and multiple boards (boards table,
sections.board_id, /api/boards CRUD, slug routing).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 12:49:44 +02:00

325 lines
9.8 KiB
Go

package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tdvorak/dash/internal/domain"
)
// ---------- transports ----------
func TestNotifyTransports(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// One receiver records body + headers + query per request.
type hit struct {
body map[string]any
raw string
title string
path string
}
got := make(chan hit, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
buf, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(buf, &m)
got <- hit{body: m, raw: string(buf), title: r.Header.Get("Title"), path: r.URL.RequestURI()}
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
put := func(kv string) {
if res, _ := do(t, "PUT", srv.URL+"/api/settings", kv); res.StatusCode != http.StatusOK {
t.Fatalf("put %s: %d", kv, res.StatusCode)
}
}
// slack + discord + gotify + ntfy all configured; test fires each.
put(`{"notify_slack":{"url":"` + hook.URL + `/slack"}}`)
put(`{"notify_discord":{"url":"` + hook.URL + `/discord"}}`)
put(`{"notify_gotify":{"server":"` + hook.URL + `","token":"gtok"}}`)
put(`{"notify_ntfy":{"server":"` + hook.URL + `","topic":"alerts"}}`)
res, out := do(t, "POST", srv.URL+"/api/notify/test", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("notify test: %d", res.StatusCode)
}
results := out["results"].(map[string]any)
for _, name := range []string{"slack", "discord", "gotify", "ntfy"} {
if results[name] != "ok" {
t.Fatalf("%s: %v", name, results[name])
}
}
seen := map[string]hit{}
for i := 0; i < 4; i++ {
select {
case h := <-got:
seen[h.path] = h
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for transports")
}
}
if seen["/slack"].body["text"] == "" {
t.Fatalf("slack payload missing text: %v", seen["/slack"].body)
}
if seen["/discord"].body["content"] == "" {
t.Fatalf("discord payload missing content: %v", seen["/discord"].body)
}
if !strings.HasPrefix(seen["/message?token=gtok"].path, "/message?token=") {
t.Fatalf("gotify hit missing: %v", seen)
}
if h := seen["/alerts"]; h.raw != "Dash test notification" || h.title != "dash" {
t.Fatalf("ntfy payload wrong: %q title=%q", h.raw, h.title)
}
// Legacy string webhook still works alongside presets.
put(`{"notify_webhook":"` + hook.URL + `/legacy"}`)
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"webhook"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("webhook test: %d", res.StatusCode)
}
select {
case h := <-got:
if h.path != "/legacy" || h.body["event"] != "test" {
t.Fatalf("legacy webhook payload: %v", h.body)
}
case <-time.After(3 * time.Second):
t.Fatal("legacy webhook never fired")
}
// Named unconfigured transport -> 400.
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for unconfigured smtp, got %d", res.StatusCode)
}
}
// fakeSMTP is the smallest lawful SMTP server: EHLO/MAIL/RCPT/DATA/QUIT.
func fakeSMTP(t *testing.T) (addr string, gotMsg <-chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
msgs := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
fmt.Fprint(conn, "220 fake\r\n")
var data strings.Builder
inData := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
if inData {
if line == "." {
inData = false
fmt.Fprint(conn, "250 queued\r\n")
continue
}
data.WriteString(line + "\n")
continue
}
switch {
case strings.HasPrefix(line, "EHLO"):
fmt.Fprint(conn, "250-fake\r\n250 OK\r\n")
case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
fmt.Fprint(conn, "250 OK\r\n")
case strings.HasPrefix(line, "DATA"):
fmt.Fprint(conn, "354 go\r\n")
inData = true
case strings.HasPrefix(line, "QUIT"):
fmt.Fprint(conn, "221 bye\r\n")
msgs <- data.String()
return
default:
fmt.Fprint(conn, "250 OK\r\n")
}
}
}()
return ln.Addr().String(), msgs
}
func TestNotifySMTP(t *testing.T) {
srv := testServer(t)
defer srv.Close()
addr, gotMsg := fakeSMTP(t)
host, port, _ := net.SplitHostPort(addr)
res, _ := do(t, "PUT", srv.URL+"/api/settings",
`{"notify_smtp":{"host":"`+host+`","port":"`+port+`","from":"dash@local","to":"me@local"}}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put smtp: %d", res.StatusCode)
}
res, out := do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusOK || out["results"].(map[string]any)["smtp"] != "ok" {
t.Fatalf("smtp test: %d %v", res.StatusCode, out)
}
select {
case msg := <-gotMsg:
if !strings.Contains(msg, "Subject: [Dash] Dash test notification") {
t.Fatalf("smtp message missing subject: %q", msg)
}
case <-time.After(3 * time.Second):
t.Fatal("smtp server never received the message")
}
}
// ---------- alert rules ----------
func TestAlertRulesConsecutiveAndMute(t *testing.T) {
srv := testServer(t)
defer srv.Close()
got := make(chan map[string]any, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
_ = json.NewDecoder(r.Body).Decode(&m)
got <- m
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer up.Close()
down := `{"url":"http://127.0.0.1:1","timeoutS":1}`
// Monitor requiring 2 consecutive failures before going down.
res, m := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Flap","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"consecutiveFailures":2}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", m)
}
id := m["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
do(t, "PATCH", srv.URL+"/api/monitors/"+id, down)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 1st failure: hold
_, mv := do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "up" {
t.Fatalf("expected held status up after 1 failure, got %v", mv["status"])
}
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 2nd failure: down
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "down" {
t.Fatalf("expected down after 2 failures, got %v", mv["status"])
}
select {
case body := <-got:
if body["event"] != "monitor.down" {
t.Fatalf("expected monitor.down, got %v", body["event"])
}
case <-time.After(3 * time.Second):
t.Fatal("monitor.down never fired after threshold")
}
// Muted monitor: status still flips, no notification.
res, m2 := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Quiet","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"mute":true}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create muted: %v", m2)
}
id2 := m2["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
do(t, "PATCH", srv.URL+"/api/monitors/"+id2, down)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id2, "")
if mv["status"] != "down" {
t.Fatalf("muted monitor should still go down, got %v", mv["status"])
}
select {
case body := <-got:
if body["name"] == "Quiet" {
t.Fatal("muted monitor still notified")
}
case <-time.After(1500 * time.Millisecond):
}
}
func TestAlertRulesBadJSON(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"X","type":"http","url":"http://127.0.0.1:1","alerts":"nope"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid alerts, got %d", res.StatusCode)
}
}
// ---------- subdomains ----------
func TestSubdomainDiscovery(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Stub the network seams: CT returns candidates, resolver answers a few.
oldCT, oldRes := domain.CTNames, domain.ResolveIPs
domain.CTNames = func(_ context.Context, d string) ([]string, error) {
return []string{"api." + d + "\nwww." + d, "*." + d, "other.com"}, nil
}
domain.ResolveIPs = func(_ context.Context, name string) ([]string, error) {
if strings.HasPrefix(name, "api.") {
return []string{"10.0.0.1"}, nil
}
return []string{"93.184.216.34"}, nil
}
t.Cleanup(func() { domain.CTNames, domain.ResolveIPs = oldCT, oldRes })
res, d := do(t, "POST", srv.URL+"/api/domains", `{"name":"example.com"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create domain: %v", d)
}
id := d["id"].(string)
// First refresh ran discovery inline (sub_checked_at was NULL).
res, subs := do(t, "GET", srv.URL+"/api/domains/"+id+"/subdomains", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("list subdomains: %d", res.StatusCode)
}
list, _ := subs["subdomains"].([]any) // response is a bare array — check below
_ = list
// do() decodes objects only; re-fetch raw for the array.
raw, err := http.Get(srv.URL + "/api/domains/" + id + "/subdomains")
if err != nil {
t.Fatal(err)
}
var arr []map[string]any
_ = json.NewDecoder(raw.Body).Decode(&arr)
raw.Body.Close()
names := map[string][]string{}
for _, s := range arr {
ips := []string{}
for _, ip := range s["ips"].([]any) {
ips = append(ips, ip.(string))
}
names[s["name"].(string)] = ips
}
if len(names) != 3 || len(names["api.example.com"]) != 1 || names["api.example.com"][0] != "10.0.0.1" {
t.Fatalf("unexpected subdomains: %v", names)
}
if _, bad := names["other.com"]; bad {
t.Fatal("foreign name leaked into subdomains")
}
}