Files
Dash/apps/backend/internal/api/api_test.go
T
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

859 lines
29 KiB
Go

package api
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"github.com/tdvorak/dash/internal/domain"
"go.uber.org/zap"
)
func testServer(t *testing.T) *httptest.Server {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
s := newServer(zap.NewNop(), d, t.TempDir())
// Stub the domain lookup — real RDAP/WHOIS/DNS would make tests slow and
// network-dependent.
s.lookup = func(_ context.Context, name string) *domain.Result {
exp := time.Now().Add(90 * 24 * time.Hour).UTC()
return &domain.Result{
Name: name,
RegistrarName: "Test Registrar",
ExpiryDate: &exp,
IPv4: []string{"93.184.216.34"},
NameServers: []string{"ns1.example.com"},
Headers: map[string]string{},
FaviconURL: "https://www.google.com/s2/favicons?domain=" + name,
}
}
return httptest.NewServer(s.routes())
}
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
t.Helper()
req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer res.Body.Close()
var out map[string]any
_ = json.NewDecoder(res.Body).Decode(&out)
return res, out
}
func TestSectionItemReorderFlow(t *testing.T) {
srv := testServer(t)
defer srv.Close()
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, b := do(t, "POST", srv.URL+"/api/sections", `{"name":"B"}`)
secA, secB := a["id"].(string), b["id"].(string)
// Move B before A: position should go below A's.
res, mv := do(t, "POST", srv.URL+"/api/sections/reorder", `{"id":"`+secB+`","afterId":"`+secA+`"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("reorder: %v", mv)
}
if mv["position"].(float64) >= a["position"].(float64) {
t.Fatalf("expected B position < A position, got %v vs %v", mv["position"], a["position"])
}
// Item with two urls into A.
_, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+secA+`","name":"Svc","urls":[{"url":"http://x.local","label":"local"},{"url":"https://x.dev","label":"external"}]}`)
if res := it["id"]; res == nil {
t.Fatalf("create item: %v", it)
}
// Move the item across sections.
res, mv = do(t, "POST", srv.URL+"/api/items/reorder", `{"id":"`+it["id"].(string)+`","sectionId":"`+secB+`"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("item reorder: %v", mv)
}
// Board reflects the move.
var secs []Section
resp, err := http.Get(srv.URL + "/api/sections")
if err != nil {
t.Fatalf("get sections: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
t.Fatalf("decode board: %v", err)
}
if len(secs) != 2 || secs[0].Name != "B" || secs[1].Name != "A" {
t.Fatalf("unexpected section order: %+v", secs)
}
if len(secs[0].Items) != 1 || secs[0].Items[0].Name != "Svc" {
t.Fatalf("item did not move to section B: %+v", secs[0].Items)
}
if len(secs[0].Items[0].URLs) != 2 {
t.Fatalf("expected 2 urls, got %+v", secs[0].Items[0].URLs)
}
}
func TestRejectsNonHTTPURLs(t *testing.T) {
srv := testServer(t)
defer srv.Close()
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
res, body := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Bad","urls":[{"url":"file:///etc/passwd","label":"x"}]}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for file:// url, got %d: %v", res.StatusCode, body)
}
}
func TestSettingsRoundTrip(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"theme":"light","renderer":"cards"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put settings: %d", res.StatusCode)
}
resp, err := http.Get(srv.URL + "/api/settings")
if err != nil {
t.Fatalf("get settings: %v", err)
}
defer resp.Body.Close()
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode settings: %v", err)
}
if got["theme"] != "light" || got["renderer"] != "cards" {
t.Fatalf("settings mismatch: %v", got)
}
}
func TestWidgetEndpoints(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Types registry serves all integrations incl. the local clock.
resp, err := http.Get(srv.URL + "/api/widgets/types")
if err != nil {
t.Fatalf("get types: %v", err)
}
var types []struct {
Type string `json:"type"`
Local bool `json:"local"`
}
if err := json.NewDecoder(resp.Body).Decode(&types); err != nil {
t.Fatalf("decode types: %v", err)
}
resp.Body.Close()
got := map[string]bool{}
for _, w := range types {
got[w.Type] = w.Local
}
for _, want := range []string{"clock", "pihole", "adguard", "immich"} {
if _, ok := got[want]; !ok {
t.Fatalf("missing widget type %s in %v", want, got)
}
}
if !got["clock"] {
t.Fatalf("clock must be local, got %v", got)
}
// Widget data on a service item -> 400; missing -> 404.
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Svc"}`)
res, _ := do(t, "GET", srv.URL+"/api/widgets/"+it["id"].(string)+"/data", "")
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("service item data: %d", res.StatusCode)
}
res, _ = do(t, "GET", srv.URL+"/api/widgets/i_missing/data", "")
if res.StatusCode != http.StatusNotFound {
t.Fatalf("missing item: %d", res.StatusCode)
}
// Widget item with dead upstream -> 502, then cached (second call fast).
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"PH","config":{"type":"pihole","endpoint":"http://127.0.0.1:1","token":"x"}}`)
res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
if res.StatusCode != http.StatusBadGateway {
t.Fatalf("dead upstream: %d", res.StatusCode)
}
start := time.Now()
res, _ = do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
if res.StatusCode != http.StatusBadGateway || time.Since(start) > time.Second {
t.Fatalf("expected cached 502, got %d in %v", res.StatusCode, time.Since(start))
}
}
func TestImportExternalHomepage(t *testing.T) {
srv := testServer(t)
defer srv.Close()
body := `- Media:
- Plex:
href: http://plex.local:32400
icon: plex
- NoLink:
description: widget-only entry
- Apps:
- Sonarr:
href: https://sonarr.example.com
icon: https://icons.example.com/sonarr.png
`
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
if res.StatusCode != http.StatusOK {
t.Fatalf("homepage import: %d", res.StatusCode)
}
var secs []Section
resp, err := http.Get(srv.URL + "/api/sections")
if err != nil {
t.Fatalf("get sections: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(secs) != 2 {
t.Fatalf("expected 2 sections, got %+v", secs)
}
media := secs[0]
if media.Name != "Media" || len(media.Items) != 1 {
t.Fatalf("bad Media section: %+v", media)
}
if media.Items[0].Name != "Plex" || len(media.Items[0].URLs) != 1 {
t.Fatalf("bad Plex item: %+v", media.Items[0])
}
if media.Items[0].Icon == "" {
t.Fatal("expected resolved CDN icon")
}
apps := secs[1]
if apps.Items[0].Icon != "https://icons.example.com/sonarr.png" {
t.Fatalf("icon passthrough failed: %+v", apps.Items[0])
}
}
func TestImportExternalDashy(t *testing.T) {
srv := testServer(t)
defer srv.Close()
body := `appConfig:
title: test
sections:
- name: Media
items:
- title: Plex
url: http://plex.local
icon: favicon
- title: Broken
url: "notaurl"
`
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
if res.StatusCode != http.StatusOK {
t.Fatalf("dashy import: %d", res.StatusCode)
}
var secs []Section
resp, err := http.Get(srv.URL + "/api/sections")
if err != nil {
t.Fatalf("get sections: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(secs) != 1 || len(secs[0].Items) != 1 {
t.Fatalf("bad sections: %+v", secs)
}
if secs[0].Items[0].Icon != "http://plex.local/favicon.ico" {
t.Fatalf("favicon resolution failed: %+v", secs[0].Items[0])
}
}
func TestImportExternalHomarr(t *testing.T) {
srv := testServer(t)
defer srv.Close()
body := `{"apps":[{"name":"Plex","url":"http://plex.local","icon":"hl-plex","categoryId":"c1"},{"name":"Sonarr","url":"http://sonarr.local","categoryId":"c1"}],"categories":[{"id":"c1","name":"Media"}]}`
res, _ := do(t, "POST", srv.URL+"/api/import/external", body)
if res.StatusCode != http.StatusOK {
t.Fatalf("homarr import: %d", res.StatusCode)
}
var secs []Section
resp, err := http.Get(srv.URL + "/api/sections")
if err != nil {
t.Fatalf("get sections: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(secs) != 1 || secs[0].Name != "Media" || len(secs[0].Items) != 2 {
t.Fatalf("bad sections: %+v", secs)
}
}
func TestImportExternalRejectsGarbage(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, body := do(t, "POST", srv.URL+"/api/import/external", `{"foo": 1}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %v", res.StatusCode, body)
}
}
func TestMonitorLifecycle(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Target that returns 200.
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer up.Close()
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Site","type":"http","url":"`+up.URL+`","intervalS":60}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", res.StatusCode, mv)
}
id := mv["id"].(string)
// check-now -> up, heartbeat recorded.
res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "")
if res.StatusCode != http.StatusOK || mv["status"] != "up" {
t.Fatalf("check-now: %d %v", res.StatusCode, mv)
}
resp, err := http.Get(srv.URL + "/api/monitors/" + id + "/heartbeats")
if err != nil {
t.Fatalf("heartbeats: %v", err)
}
var hbs []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&hbs)
resp.Body.Close()
if len(hbs) != 1 || hbs[0]["status"] != "up" {
t.Fatalf("expected 1 up heartbeat, got %v", hbs)
}
// pause -> paused + inactive
res, mv = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"active":false}`)
if res.StatusCode != http.StatusOK || mv["status"] != "paused" || mv["active"] != false {
t.Fatalf("pause: %v", mv)
}
// delete
res, _ = do(t, "DELETE", srv.URL+"/api/monitors/"+id, "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", res.StatusCode)
}
res, _ = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if res.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after delete, got %d", res.StatusCode)
}
}
func TestMonitorCheckDown(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Dead","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", mv)
}
id := mv["id"].(string)
res, mv = do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "")
if res.StatusCode != http.StatusOK || mv["status"] != "down" {
t.Fatalf("expected down, got %v", mv)
}
}
func TestPushMonitor(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Backup","type":"push","intervalS":60}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", mv)
}
token, _ := mv["pushToken"].(string)
if token == "" {
t.Fatal("no push token issued")
}
res, _ = do(t, "GET", srv.URL+"/api/push/"+token+"?msg=done", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("push: %d", res.StatusCode)
}
res, mv = do(t, "GET", srv.URL+"/api/monitors/"+mv["id"].(string), "")
if mv["status"] != "up" {
t.Fatalf("expected up after push, got %v", mv["status"])
}
res, _ = do(t, "GET", srv.URL+"/api/push/bogus", "")
if res.StatusCode != http.StatusNotFound {
t.Fatalf("bad token: %d", res.StatusCode)
}
}
func TestDomainLifecycle(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Garbage name -> 400 before any lookup happens.
res, body := do(t, "POST", srv.URL+"/api/domains", `{"name":"not a domain!!"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %v", res.StatusCode, body)
}
// Valid name creates even when lookups fail (unresolvable TLD).
res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid","alertDaysBefore":14}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", res.StatusCode, dv)
}
if dv["tld"] != "invalid" || dv["name"] != "nonexistent.invalid" {
t.Fatalf("bad domain row: %v", dv)
}
// Stubbed lookup populated registration data + computed countdown.
if dv["registrarName"] != "Test Registrar" || dv["daysUntilExpiry"] == nil {
t.Fatalf("lookup not applied: %v", dv)
}
id := dv["id"].(string)
// Duplicate name -> 409.
res, _ = do(t, "POST", srv.URL+"/api/domains", `{"name":"nonexistent.invalid"}`)
if res.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", res.StatusCode)
}
// Patch alert threshold round-trips.
res, dv = do(t, "PATCH", srv.URL+"/api/domains/"+id, `{"alertDaysBefore":7,"autoRenew":true}`)
if res.StatusCode != http.StatusOK || dv["alertDaysBefore"] != float64(7) || dv["autoRenew"] != true {
t.Fatalf("patch: %v", dv)
}
// Refresh endpoint answers (lookup result content is network-dependent).
res, dv = do(t, "POST", srv.URL+"/api/domains/"+id+"/refresh", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("refresh: %d %v", res.StatusCode, dv)
}
// Checks history endpoint responds with a list.
resp, err := http.Get(srv.URL + "/api/domains/" + id + "/checks")
if err != nil {
t.Fatalf("checks: %v", err)
}
var checks []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&checks)
resp.Body.Close()
res, _ = do(t, "DELETE", srv.URL+"/api/domains/"+id, "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", res.StatusCode)
}
res, _ = do(t, "GET", srv.URL+"/api/domains/"+id, "")
if res.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 after delete, got %d", res.StatusCode)
}
}
func TestDomainWidget(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, dv := do(t, "POST", srv.URL+"/api/domains", `{"name":"widget.invalid"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", res.StatusCode, dv)
}
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"D","config":{"type":"domain","domainId":"`+dv["id"].(string)+`"}}`)
res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
inner, _ := data["data"].(map[string]any)
if res.StatusCode != http.StatusOK || inner["name"] != "widget.invalid" {
t.Fatalf("domain widget: %d %v", res.StatusCode, data)
}
}
func TestNotifyWebhook(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Receiver captures the webhook payload.
got := make(chan map[string]any, 1)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
got <- body
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
// Unconfigured -> 400.
res, _ := do(t, "POST", srv.URL+"/api/notify/test", "")
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 without webhook, got %d", res.StatusCode)
}
// Configure + test.
if res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`); res.StatusCode != http.StatusOK {
t.Fatalf("put settings: %d", res.StatusCode)
}
res, _ = do(t, "POST", srv.URL+"/api/notify/test", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("notify test: %d", res.StatusCode)
}
select {
case body := <-got:
if body["event"] != "test" || body["text"] == "" {
t.Fatalf("bad payload: %v", body)
}
case <-time.After(3 * time.Second):
t.Fatal("webhook never received the test notification")
}
// Monitor transition fires monitor.down through the same webhook.
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Flip","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", mv)
}
id := mv["id"].(string)
// pending -> down (no fire), then up -> down needs an up first.
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer up.Close()
res, _ = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("patch: %d", res.StatusCode)
}
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1","timeoutS":1}`)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> down, fires webhook
select {
case body := <-got:
if body["event"] != "monitor.down" {
t.Fatalf("expected monitor.down, got %v", body)
}
case <-time.After(3 * time.Second):
t.Fatal("webhook never received monitor.down")
}
}
func TestSystemIngest(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// No token -> 401.
res, _ := do(t, "POST", srv.URL+"/api/systems/ingest", `{"cpu":1}`)
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", res.StatusCode)
}
res, sys := do(t, "POST", srv.URL+"/api/systems", `{"name":"nas"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", sys)
}
id, token := sys["id"].(string), sys["token"].(string)
if token == "" {
t.Fatal("no agent token issued")
}
// Bad token -> 401.
req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest",
strings.NewReader(`{"cpu":50}`))
req.Header.Set("Authorization", "Bearer wrong")
if r, err := http.DefaultClient.Do(req); err != nil || r.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad token: %v %v", r.StatusCode, err)
}
// Real push -> system goes up, latest + stats stored.
push := func() (*http.Response, map[string]any) {
t.Helper()
req, _ := http.NewRequest("POST", srv.URL+"/api/systems/ingest",
strings.NewReader(`{"host":"nas.local","os":"linux","arch":"amd64",
"cpuModel":"Test CPU","cores":4,"cpu":42.5,"memTotal":8000,"memUsed":4000,
"diskTotal":100000,"diskUsed":50000,"netRx":100,"netTx":50,"load1":1.5}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("push: %v", err)
}
defer res.Body.Close()
var out map[string]any
_ = json.NewDecoder(res.Body).Decode(&out)
return res, out
}
if res, out := push(); res.StatusCode != http.StatusOK {
t.Fatalf("ingest: %v", out)
}
res, sys = do(t, "GET", srv.URL+"/api/systems/"+id, "")
if res.StatusCode != http.StatusOK || sys["status"] != "up" {
t.Fatalf("get: %v", sys)
}
if sys["host"] != "nas.local" || sys["cores"].(float64) != 4 {
t.Fatalf("host fields not stored: %v", sys)
}
latest := sys["latest"].(map[string]any)
if latest["cpu"].(float64) != 42.5 {
t.Fatalf("latest not stored: %v", latest)
}
res, list := do(t, "GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("stats: %v", list)
}
// stats returns an array — decode separately.
req2, _ := http.NewRequest("GET", srv.URL+"/api/systems/"+id+"/stats?hours=1", nil)
r2, _ := http.DefaultClient.Do(req2)
var arr []map[string]any
_ = json.NewDecoder(r2.Body).Decode(&arr)
r2.Body.Close()
if len(arr) != 1 || arr[0]["cpu"].(float64) != 42.5 {
t.Fatalf("stats rows: %v", arr)
}
// Widget fetcher serves the latest sample.
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, w := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","kind":"widget","name":"S","config":{"type":"system","systemId":"`+id+`"}}`)
res, data := do(t, "GET", srv.URL+"/api/widgets/"+w["id"].(string)+"/data", "")
inner, _ := data["data"].(map[string]any)
if res.StatusCode != http.StatusOK || inner["name"] != "nas" || inner["cpu"].(float64) != 42.5 {
t.Fatalf("system widget: %d %v", res.StatusCode, data)
}
// Rename + token rotation.
res, sys = do(t, "PATCH", srv.URL+"/api/systems/"+id, `{"name":"nas2","rotateToken":true}`)
if sys["name"] != "nas2" || sys["token"] == token {
t.Fatalf("patch: %v", sys)
}
res, _ = do(t, "DELETE", srv.URL+"/api/systems/"+id, "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %d", res.StatusCode)
}
}
func TestOpsLayer(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Monitor that flips up->down should auto-open an incident, and
// recovery should auto-resolve it.
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer up.Close()
_, mv := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Flip","type":"http","url":"`+up.URL+`","timeoutS":1}`)
id := mv["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down -> incident
res, _ := do(t, "GET", srv.URL+"/api/incidents?status=open", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("incidents: %d", res.StatusCode)
}
req, _ := http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil)
r, _ := http.DefaultClient.Do(req)
var incs []map[string]any
_ = json.NewDecoder(r.Body).Decode(&incs)
r.Body.Close()
if len(incs) != 1 || incs[0]["monitorId"] != id {
t.Fatalf("auto incident: %v", incs)
}
incID := incs[0]["id"].(string)
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // up -> resolved
res, inc := do(t, "GET", srv.URL+"/api/incidents?status=open", "")
req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil)
r, _ = http.DefaultClient.Do(req)
incs = nil
_ = json.NewDecoder(r.Body).Decode(&incs)
r.Body.Close()
if len(incs) != 0 {
t.Fatalf("incident should auto-resolve, got %v", incs)
}
// Manual incident lifecycle: create -> update -> resolve.
res, inc = do(t, "POST", srv.URL+"/api/incidents",
`{"title":"disk full","severity":"critical","message":"nas raid degraded"}`)
if res.StatusCode != http.StatusCreated || inc["status"] != "open" {
t.Fatalf("create incident: %v", inc)
}
incID = inc["id"].(string)
res, inc = do(t, "PATCH", srv.URL+"/api/incidents/"+incID, `{"status":"resolved","message":"swapped drive"}`)
if inc["status"] != "resolved" {
t.Fatalf("resolve: %v", inc)
}
ups, _ := inc["updates"].([]any)
if len(ups) < 2 {
t.Fatalf("updates timeline: %v", inc["updates"])
}
// Maintenance window suppresses a transition's side-effects.
_, mw := do(t, "POST", srv.URL+"/api/maintenance",
`{"title":"upgrade","startsAt":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+
`","endsAt":"`+time.Now().Add(time.Hour).UTC().Format(time.RFC3339)+`"}`)
if mw["active"] != true {
t.Fatalf("window should be active: %v", mw)
}
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1"}`)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // down under maintenance
req, _ = http.NewRequest("GET", srv.URL+"/api/incidents?status=open", nil)
r, _ = http.DefaultClient.Do(req)
incs = nil
_ = json.NewDecoder(r.Body).Decode(&incs)
r.Body.Close()
if len(incs) != 0 {
t.Fatalf("maintenance must suppress auto-incidents: %v", incs)
}
// Status page + public view shows maintenance state.
res, sp := do(t, "POST", srv.URL+"/api/status-pages", `{"title":"Ops","slug":"ops"}`)
if res.StatusCode != http.StatusCreated || sp["slug"] != "ops" {
t.Fatalf("status page: %v", sp)
}
res, pub := do(t, "GET", srv.URL+"/api/status-pages/ops/public", "")
if res.StatusCode != http.StatusOK || pub["overall"] != "maintenance" {
t.Fatalf("public status: %v", pub)
}
mons, _ := pub["monitors"].([]any)
if len(mons) != 1 {
t.Fatalf("public monitors: %v", pub["monitors"])
}
if m0, _ := mons[0].(map[string]any); m0["status"] != "maintenance" {
t.Fatalf("monitor should read maintenance: %v", m0)
}
// Badge + metrics smoke.
res, _ = do(t, "GET", srv.URL+"/api/badge/monitor/"+id+".svg", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("badge: %d", res.StatusCode)
}
res, _ = do(t, "GET", srv.URL+"/api/metrics", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("metrics: %d", res.StatusCode)
}
req, _ = http.NewRequest("GET", srv.URL+"/api/metrics", nil)
r, _ = http.DefaultClient.Do(req)
body, _ := io.ReadAll(r.Body)
r.Body.Close()
if !strings.Contains(string(body), "dash_monitors_total") {
t.Fatalf("metrics body: %s", body)
}
// CSV import.
req, _ = http.NewRequest("POST", srv.URL+"/api/import/csv?kind=monitors",
strings.NewReader("name,type,url\nA,http,https://a.example\nB,tcp,https://b.example:9\n"))
req.Header.Set("Content-Type", "text/csv")
r, _ = http.DefaultClient.Do(req)
var out map[string]any
_ = json.NewDecoder(r.Body).Decode(&out)
r.Body.Close()
if out["created"].(float64) != 2 {
t.Fatalf("csv import: %v", out)
}
}
func TestMidpoint(t *testing.T) {
f := func(v float64) *float64 { return &v }
if got := midpoint(nil, nil); got != 1024 {
t.Fatalf("empty midpoint: %v", got)
}
if got := midpoint(nil, f(1024)); got != 0 {
t.Fatalf("before-only midpoint: %v", got)
}
if got := midpoint(f(1024), nil); got != 2048 {
t.Fatalf("after-only midpoint: %v", got)
}
if got := midpoint(f(0), f(1024)); got != 512 {
t.Fatalf("bounded midpoint: %v", got)
}
}
func TestValidURL(t *testing.T) {
for _, u := range []string{"http://a.b", "https://a.b:8443/x"} {
if !validURL(u) {
t.Fatalf("expected valid: %s", u)
}
}
for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "http://", "notaurl", ""} {
if validURL(u) {
t.Fatalf("expected invalid: %s", u)
}
}
}
func TestBoards(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Default board exists and owns new sections.
var boards []Board
resp, err := http.Get(srv.URL + "/api/boards")
if err != nil || json.NewDecoder(resp.Body).Decode(&boards) != nil {
t.Fatalf("list boards: %v", err)
}
resp.Body.Close()
if len(boards) != 1 || boards[0].ID != "b_home" {
t.Fatalf("expected seeded home board, got %+v", boards)
}
_, sec := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
if sec["boardId"] != "b_home" {
t.Fatalf("section should default to home board: %v", sec["boardId"])
}
// Create a second board; slug is derived and unique.
_, b2 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b2["slug"] != "lab-net" {
t.Fatalf("bad slug: %v", b2["slug"])
}
_, b3 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b3["slug"] == b2["slug"] {
t.Fatalf("slug collision: %v", b3["slug"])
}
// Sections scope to their board.
_, s2 := do(t, "POST", srv.URL+"/api/sections", `{"name":"B","boardId":"`+b2["id"].(string)+`"}`)
if s2["boardId"] != b2["id"] {
t.Fatalf("section not scoped to board: %v", s2["boardId"])
}
var home, lab []Section
r1, _ := http.Get(srv.URL + "/api/sections?board=b_home")
_ = json.NewDecoder(r1.Body).Decode(&home)
r1.Body.Close()
r2, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r2.Body).Decode(&lab)
r2.Body.Close()
if len(home) != 1 || home[0].Name != "A" || len(lab) != 1 || lab[0].Name != "B" {
t.Fatalf("board filter broken: home=%v lab=%v", home, lab)
}
// Rename keeps the slug; deleting cascades sections.
_, rb := do(t, "PATCH", srv.URL+"/api/boards/"+b3["id"].(string), `{"name":"Renamed"}`)
if rb["name"] != "Renamed" || rb["slug"] == "renamed" {
t.Fatalf("rename should not change slug: %+v", rb)
}
res, _ := do(t, "DELETE", srv.URL+"/api/boards/"+b2["id"].(string), "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete board: %d", res.StatusCode)
}
var labAfter []Section
r3, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r3.Body).Decode(&labAfter)
r3.Body.Close()
if len(labAfter) != 0 {
t.Fatalf("sections should cascade with board: %v", labAfter)
}
// Down to two boards — delete both and the last delete must refuse.
do(t, "DELETE", srv.URL+"/api/boards/"+b3["id"].(string), "")
res, _ = do(t, "DELETE", srv.URL+"/api/boards/b_home", "")
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("deleting last board should 400, got %d", res.StatusCode)
}
}