Components can be put in groups and given public names. Public incidents carry posts written for customers (the internal timeline stays internal), the components they affect, and a page for each incident plus paged history. Scheduled maintenance has its own window, shows affected components as under maintenance, and is announced when scheduled, started, ended or canceled. Branding (title, logo, accent checked for contrast, contact link, footer) and a custom domain that serves only the status page. Visitors follow by RSS, by confirmed email, or by webhook that must answer a test request. Admin UI for all of it, tests, README and design notes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
701 lines
29 KiB
Go
701 lines
29 KiB
Go
package sandbar
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type sentMail struct{ to, subject, body string }
|
|
|
|
type mailBox struct {
|
|
mu sync.Mutex
|
|
sent []sentMail
|
|
}
|
|
|
|
func (m *mailBox) send(to, subject, body string) error {
|
|
m.mu.Lock()
|
|
m.sent = append(m.sent, sentMail{to, subject, body})
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (m *mailBox) all() []sentMail {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return append([]sentMail(nil), m.sent...)
|
|
}
|
|
|
|
func statusTestServer(t *testing.T) (*Server, *fakeClock, *mailBox, *httptest.Server) {
|
|
t.Helper()
|
|
s, clk, _ := newTestServer(t)
|
|
mb := &mailBox{}
|
|
s.opts.Mailer = mb.send
|
|
ts := httptest.NewServer(s.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return s, clk, mb, ts
|
|
}
|
|
|
|
func getJSON(t *testing.T, url string, v any) int {
|
|
t.Helper()
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if v != nil {
|
|
json.NewDecoder(resp.Body).Decode(v)
|
|
}
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func postJSON(t *testing.T, url string, body any) (int, map[string]string) {
|
|
t.Helper()
|
|
b, _ := json.Marshal(body)
|
|
resp, err := http.Post(url, "application/json", strings.NewReader(string(b)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out map[string]string
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
return resp.StatusCode, out
|
|
}
|
|
|
|
type statusDoc struct {
|
|
Settings StatusSettings `json:"settings"`
|
|
Overall string
|
|
Groups []StatusGroup
|
|
Monitors []pubComponent
|
|
Incidents []pubIncident
|
|
Maintenance []pubMaintenance
|
|
Subscribe map[string]bool
|
|
}
|
|
|
|
func TestStatusSettingsValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in StatusSettings
|
|
ok bool
|
|
}{
|
|
{"empty gets a title", StatusSettings{}, true},
|
|
{"domain", StatusSettings{Domain: "Status.Example.com"}, true},
|
|
{"domain with scheme", StatusSettings{Domain: "https://status.example.com"}, false},
|
|
{"domain with path", StatusSettings{Domain: "status.example.com/x"}, false},
|
|
{"single label", StatusSettings{Domain: "localhost"}, false},
|
|
{"own address", StatusSettings{Domain: "sandbar.test"}, false},
|
|
{"http logo", StatusSettings{LogoURL: "http://x.test/a.png"}, false},
|
|
{"https logo", StatusSettings{LogoURL: "https://x.test/a.png"}, true},
|
|
{"javascript support link", StatusSettings{SupportURL: "javascript:alert(1)"}, false},
|
|
{"mailto support link", StatusSettings{SupportURL: "mailto:help@example.com"}, true},
|
|
{"accent", StatusSettings{Accent: "#1F5C5C"}, true},
|
|
{"accent too light for white text", StatusSettings{Accent: "#ffee00"}, false},
|
|
{"accent not a hex color", StatusSettings{Accent: "teal"}, false},
|
|
{"title too long", StatusSettings{Title: strings.Repeat("x", 81)}, false},
|
|
}
|
|
for _, c := range cases {
|
|
err := validateStatusSettings(&c.in, "https://sandbar.test")
|
|
if (err == nil) != c.ok {
|
|
t.Errorf("%s: err = %v, want ok=%v", c.name, err, c.ok)
|
|
}
|
|
}
|
|
c := StatusSettings{Domain: "Status.Example.com", Accent: "#1F5C5C"}
|
|
validateStatusSettings(&c, "")
|
|
if c.Domain != "status.example.com" || c.Accent != "#1f5c5c" || c.Title != "Status" {
|
|
t.Fatalf("not normalised: %+v", c)
|
|
}
|
|
if r := contrastWithWhite("#000000"); r < 20.9 || r > 21.1 {
|
|
t.Fatalf("black on white is 21:1, got %.2f", r)
|
|
}
|
|
}
|
|
|
|
// Components sit in groups, take their state from monitors, incidents and maintenance, and the
|
|
// banner shows the worst of them.
|
|
func TestStatusPageComponentsAndOverall(t *testing.T) {
|
|
s, clk, _, ts := statusTestServer(t)
|
|
s.st.put("sgroup", "g2", &StatusGroup{ID: "g2", Name: "Apps", Order: 2})
|
|
s.st.put("sgroup", "g1", &StatusGroup{ID: "g1", Name: "Infrastructure", Order: 1})
|
|
s.st.put("monitor", "web", &Monitor{ID: "web", Name: "web-1", PublicName: "Website", Kind: "http", Target: "http://x", Enabled: true, Public: true, Group: "g2"})
|
|
s.st.put("monitor", "db", &Monitor{ID: "db", Name: "Database", Kind: "tcp", Target: "x:1", Enabled: true, Public: true, Group: "g1"})
|
|
s.st.put("monitor", "old", &Monitor{ID: "old", Name: "Orphan", Kind: "tcp", Target: "x:1", Enabled: true, Public: true, Group: "deleted"})
|
|
s.st.put("monitor", "hid", &Monitor{ID: "hid", Name: "Hidden", Kind: "tcp", Target: "x:1", Enabled: true, Group: "g1"})
|
|
for _, id := range []string{"web", "db", "old", "hid"} {
|
|
s.saveState(MonitorState{MonitorID: id, Status: "up", Since: clk.Now().Unix()})
|
|
}
|
|
|
|
var d statusDoc
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
if d.Overall != "operational" || len(d.Monitors) != 3 {
|
|
t.Fatalf("all up: %+v", d)
|
|
}
|
|
if got := []string{d.Monitors[0].ID, d.Monitors[1].ID, d.Monitors[2].ID}; got[0] != "old" && got[0] != "db" {
|
|
t.Fatalf("order %v", got)
|
|
}
|
|
// ungrouped (a deleted group counts as none) first, then groups by order; the public name wins
|
|
if d.Monitors[0].ID != "old" || d.Monitors[0].Group != "" || d.Monitors[1].ID != "db" || d.Monitors[2].Name != "Website" {
|
|
t.Fatalf("order or names wrong: %+v", d.Monitors)
|
|
}
|
|
if len(d.Groups) != 2 || d.Groups[0].ID != "g1" {
|
|
t.Fatalf("groups: %+v", d.Groups)
|
|
}
|
|
|
|
// A sev2 incident on one component makes only that one partially down.
|
|
s.st.put("incident", "i1", &Incident{ID: "i1", Title: "Slow pages", Severity: "sev2", Status: "identified", Public: true, Created: clk.Now().Unix(), Components: []string{"web"}})
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
states := map[string]string{}
|
|
for _, c := range d.Monitors {
|
|
states[c.ID] = c.State
|
|
}
|
|
if states["web"] != "partial_outage" || states["db"] != "operational" || d.Overall != "degraded" {
|
|
t.Fatalf("sev2: %v overall %s", states, d.Overall)
|
|
}
|
|
if len(d.Incidents) != 1 || len(d.Incidents[0].Components) != 1 || d.Incidents[0].Components[0] != "Website" {
|
|
t.Fatalf("incident components should be public names: %+v", d.Incidents)
|
|
}
|
|
|
|
// A failing monitor is a major outage.
|
|
s.saveState(MonitorState{MonitorID: "db", Status: "down", Since: clk.Now().Unix()})
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
if d.Overall != "outage" {
|
|
t.Fatalf("overall = %s", d.Overall)
|
|
}
|
|
|
|
// Maintenance in progress explains the failure instead of showing an outage.
|
|
start := clk.Now().Add(-time.Minute).Unix()
|
|
s.st.put("maintenance", "m1", &Maintenance{ID: "m1", Title: "Database upgrade", Start: start, End: start + 3600, Components: []string{"db"}, StartSent: true})
|
|
s.st.put("incident", "i1", &Incident{ID: "i1", Title: "Slow pages", Severity: "sev2", Status: "resolved", Resolved: clk.Now().Unix(), Public: true, Created: clk.Now().Unix()})
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
for _, c := range d.Monitors {
|
|
states[c.ID] = c.State
|
|
}
|
|
if states["db"] != "maintenance" || d.Overall != "maintenance" || len(d.Maintenance) != 1 || d.Maintenance[0].State != "in_progress" {
|
|
t.Fatalf("maintenance: %v overall %s %+v", states, d.Overall, d.Maintenance)
|
|
}
|
|
|
|
// Once the window is over, the failure shows again, and the window moves to the history.
|
|
clk.Advance(2 * time.Hour)
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
if d.Overall != "outage" || len(d.Maintenance) != 0 {
|
|
t.Fatalf("after maintenance: %s %+v", d.Overall, d.Maintenance)
|
|
}
|
|
}
|
|
|
|
// Customers see the posts written for them. Internal comments, names and private incidents stay out.
|
|
func TestIncidentPostsArePublicAndInternalNotesAreNot(t *testing.T) {
|
|
s, _, _, ts := statusTestServer(t)
|
|
u := mustUser(t, s, "Alice")
|
|
s.st.put("monitor", "web", &Monitor{ID: "web", Name: "Website", Kind: "http", Target: "http://x", Enabled: true, Public: true})
|
|
|
|
hidden, _ := s.DeclareIncident("Secret outage", "sev1", "internal only", u.ID, u.Name, nil)
|
|
inc, _ := s.DeclareIncident("Checkout errors", "sev2", "Some orders fail.", u.ID, u.Name, nil)
|
|
s.CommentIncident(inc.ID, u.Name, "ALICE-INTERNAL-COMMENT")
|
|
if code := getJSON(t, ts.URL+"/status/api/incidents/"+inc.ID, nil); code != 404 {
|
|
t.Fatalf("a private incident is public: %d", code)
|
|
}
|
|
|
|
yes, comps := true, []string{"web"}
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes, Components: &comps})
|
|
ident, mon, done := "identified", "monitoring", "resolved"
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &ident, Note: "A bad deploy is the cause."})
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &mon})
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Note: "Rolled back; watching error rates."})
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &done})
|
|
|
|
var pi pubIncident
|
|
if code := getJSON(t, ts.URL+"/status/api/incidents/"+inc.ID, &pi); code != 200 {
|
|
t.Fatalf("public incident: %d", code)
|
|
}
|
|
var texts, statuses []string
|
|
for _, p := range pi.Updates {
|
|
texts, statuses = append(texts, p.Text), append(statuses, p.Status)
|
|
}
|
|
// newest first
|
|
wantStatus := []string{"resolved", "monitoring", "monitoring", "identified", "investigating"}
|
|
if strings.Join(statuses, ",") != strings.Join(wantStatus, ",") {
|
|
t.Fatalf("statuses %v, want %v", statuses, wantStatus)
|
|
}
|
|
if texts[0] != defaultPost("resolved") || texts[1] != "Rolled back; watching error rates." || texts[3] != "A bad deploy is the cause." || texts[4] != "Some orders fail." {
|
|
t.Fatalf("texts: %q", texts)
|
|
}
|
|
raw, _ := json.Marshal(pi)
|
|
for _, leak := range []string{"ALICE-INTERNAL-COMMENT", "Alice", "alice@", "Secret outage", "bad deploy is the cause by"} {
|
|
if strings.Contains(string(raw), leak) {
|
|
t.Fatalf("public incident leaks %q: %s", leak, raw)
|
|
}
|
|
}
|
|
var h struct {
|
|
Entries []historyEntry
|
|
Next int64
|
|
}
|
|
getJSON(t, ts.URL+"/status/api/history", &h)
|
|
if len(h.Entries) != 1 || h.Entries[0].Incident.ID != inc.ID {
|
|
t.Fatalf("history should hold only the public incident, not %s: %+v", hidden.ID, h.Entries)
|
|
}
|
|
}
|
|
|
|
func TestHistoryPaging(t *testing.T) {
|
|
s, clk, _, ts := statusTestServer(t)
|
|
for i := 0; i < 5; i++ {
|
|
id := string(rune('a' + i))
|
|
s.st.put("incident", id, &Incident{ID: id, Title: "Incident " + id, Severity: "sev3", Status: "resolved", Public: true, Created: clk.Now().Unix() - int64(i)*100, Resolved: clk.Now().Unix()})
|
|
}
|
|
var h struct {
|
|
Entries []historyEntry
|
|
Next int64
|
|
}
|
|
getJSON(t, ts.URL+"/status/api/history?limit=2", &h)
|
|
if len(h.Entries) != 2 || h.Entries[0].Incident.ID != "a" || h.Next == 0 {
|
|
t.Fatalf("page 1: %+v", h)
|
|
}
|
|
var seen []string
|
|
for next := int64(1); next != 0; {
|
|
getJSON(t, ts.URL+"/status/api/history?limit=2&before="+itoa(h.Next), &h)
|
|
for _, e := range h.Entries {
|
|
seen = append(seen, e.Incident.ID)
|
|
}
|
|
next = h.Next
|
|
if len(seen) > 10 {
|
|
t.Fatal("paging does not end")
|
|
}
|
|
}
|
|
if strings.Join(seen, "") != "cde" {
|
|
t.Fatalf("later pages %v", seen)
|
|
}
|
|
}
|
|
|
|
func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
|
|
|
|
var linkRe = regexp.MustCompile(`https?://\S+`)
|
|
|
|
func TestEmailSubscriptionFlow(t *testing.T) {
|
|
s, _, mb, ts := statusTestServer(t)
|
|
u := mustUser(t, s, "Alice")
|
|
s.saveStatusSettings(StatusSettings{Title: "Acme status"})
|
|
|
|
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "not an address"}); code != 400 {
|
|
t.Fatalf("bad address: %d", code)
|
|
}
|
|
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "Bob <bob@example.com>"}); code != 400 {
|
|
t.Fatalf("display name accepted: %d", code)
|
|
}
|
|
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "bob@example.com"}); code != 200 {
|
|
t.Fatalf("subscribe: %d", code)
|
|
}
|
|
mails := mb.all()
|
|
if len(mails) != 1 || mails[0].to != "bob@example.com" || !strings.Contains(mails[0].subject, "Confirm") {
|
|
t.Fatalf("confirmation mail: %+v", mails)
|
|
}
|
|
confirm := linkRe.FindString(mails[0].body)
|
|
|
|
// Not confirmed yet: updates go nowhere.
|
|
yes := true
|
|
inc, _ := s.DeclareIncident("API errors", "sev2", "Elevated errors.", u.ID, u.Name, nil)
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
|
s.notifyWG.Wait()
|
|
if n := len(mb.all()); n != 1 {
|
|
t.Fatalf("an unconfirmed address got %d mails", n)
|
|
}
|
|
|
|
// A forged link does nothing; the real one confirms and sends the visitor to the page.
|
|
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
forged := confirm[:len(confirm)-1] + "z"
|
|
if resp, _ := noRedirect.Get(strings.Replace(forged, "http://sandbar.test", ts.URL, 1)); resp.Header.Get("Location") != "/status/?notice=invalid" {
|
|
t.Fatalf("forged link: %v", resp.Header.Get("Location"))
|
|
}
|
|
resp, _ := noRedirect.Get(strings.Replace(confirm, "http://sandbar.test", ts.URL, 1))
|
|
if resp.StatusCode != 303 || resp.Header.Get("Location") != "/status/?notice=confirmed" {
|
|
t.Fatalf("confirm: %d %s", resp.StatusCode, resp.Header.Get("Location"))
|
|
}
|
|
if !strings.HasPrefix(confirm, "http://sandbar.test/status/confirm?token=") {
|
|
t.Fatalf("confirm link: %s", confirm)
|
|
}
|
|
|
|
// Subscribing again says the same thing and sends nothing more.
|
|
postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "bob@example.com"})
|
|
if n := len(mb.all()); n != 1 {
|
|
t.Fatalf("re-subscribing a confirmed address sent mail: %d", n)
|
|
}
|
|
|
|
fixed := "resolved"
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &fixed, Note: "Fixed."})
|
|
s.notifyWG.Wait()
|
|
mails = mb.all()
|
|
if len(mails) != 2 || mails[1].subject != "[Acme status] Resolved: API errors" || !strings.Contains(mails[1].body, "Fixed.") ||
|
|
!strings.Contains(mails[1].body, "http://sandbar.test/status/incidents/"+inc.ID) {
|
|
t.Fatalf("update mail: %+v", mails)
|
|
}
|
|
|
|
// The unsubscribe link needs a click on the page; a GET alone changes nothing.
|
|
unsub := linkRe.FindString(mails[1].body[strings.Index(mails[1].body, "Unsubscribe:"):])
|
|
resp, _ = noRedirect.Get(strings.Replace(unsub, "http://sandbar.test", ts.URL, 1))
|
|
if resp.StatusCode != 303 || !strings.HasPrefix(resp.Header.Get("Location"), "/status/?unsubscribe=") {
|
|
t.Fatalf("unsubscribe redirect: %d %s", resp.StatusCode, resp.Header.Get("Location"))
|
|
}
|
|
token, _ := url.Parse(unsub)
|
|
if code, _ := postJSON(t, ts.URL+"/status/unsubscribe", map[string]string{"token": "x.y"}); code != 400 {
|
|
t.Fatalf("bad token: %d", code)
|
|
}
|
|
if code, _ := postJSON(t, ts.URL+"/status/unsubscribe", map[string]string{"token": token.Query().Get("token")}); code != 200 {
|
|
t.Fatalf("unsubscribe: %d", code)
|
|
}
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Note: "More detail."})
|
|
s.notifyWG.Wait()
|
|
if n := len(mb.all()); n != 2 {
|
|
t.Fatalf("an unsubscribed address got mail: %d", n)
|
|
}
|
|
}
|
|
|
|
func TestEmailSubscriptionsNeedMailAndAreRateLimited(t *testing.T) {
|
|
s, _, _, ts := statusTestServer(t)
|
|
s.opts.Mailer = nil // no SMTP configured
|
|
var d statusDoc
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
if d.Subscribe["email"] {
|
|
t.Fatal("email offered with no SMTP server")
|
|
}
|
|
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"email": "a@example.com"}); code != 400 {
|
|
t.Fatalf("email accepted without SMTP: %d", code)
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": "nonsense"})
|
|
}
|
|
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": "nonsense"}); code != 429 {
|
|
t.Fatalf("no rate limit: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestWebhookSubscription(t *testing.T) {
|
|
s, _, _, ts := statusTestServer(t)
|
|
u := mustUser(t, s, "Alice")
|
|
var mu sync.Mutex
|
|
var got []map[string]any
|
|
status := 200
|
|
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var m map[string]any
|
|
json.NewDecoder(r.Body).Decode(&m)
|
|
mu.Lock()
|
|
got = append(got, m)
|
|
w.WriteHeader(status)
|
|
mu.Unlock()
|
|
}))
|
|
defer hook.Close()
|
|
s.opts.HTTPClient = hook.Client() // tests stand in for the guarded client, which refuses loopback
|
|
|
|
status = 500
|
|
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": hook.URL}); code != 400 {
|
|
t.Fatalf("a webhook that fails its test was accepted: %d", code)
|
|
}
|
|
status = 200
|
|
if code, _ := postJSON(t, ts.URL+"/status/subscribe", map[string]string{"webhook": hook.URL}); code != 200 {
|
|
t.Fatalf("subscribe: %d", code)
|
|
}
|
|
yes := true
|
|
inc, _ := s.DeclareIncident("DNS trouble", "sev1", "Lookups fail.", u.ID, u.Name, nil)
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
|
s.notifyWG.Wait()
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
last := got[len(got)-1]
|
|
if last["event"] != "incident.created" || last["title"] != "DNS trouble" || last["status"] != "investigating" || last["message"] != "Lookups fail." ||
|
|
!strings.Contains(last["url"].(string), "/status/incidents/"+inc.ID) || !strings.Contains(last["unsubscribe"].(string), "/status/unsubscribe?token=") {
|
|
t.Fatalf("webhook payload: %v", last)
|
|
}
|
|
if got[len(got)-2]["event"] != "subscription.created" {
|
|
t.Fatalf("first request should be the test: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMaintenanceLifecycleNotifiesSubscribers(t *testing.T) {
|
|
s, clk, mb, _ := statusTestServer(t)
|
|
s.addSubscriber("email", "bob@example.com", true)
|
|
start := clk.Now().Add(2 * time.Hour)
|
|
m := Maintenance{ID: "m1", Title: "Network upgrade", Message: "Brief packet loss.", Start: start.Unix(), End: start.Add(time.Hour).Unix(), Created: clk.Now().Unix()}
|
|
s.st.put("maintenance", m.ID, m)
|
|
s.announceMaintenance("maintenance.scheduled", m)
|
|
s.notifyWG.Wait()
|
|
|
|
s.runMaintenance(clk.Now()) // before the window: nothing
|
|
clk.Advance(2*time.Hour + time.Minute)
|
|
s.runMaintenance(clk.Now())
|
|
s.runMaintenance(clk.Now()) // a second pass must not repeat itself
|
|
clk.Advance(time.Hour)
|
|
s.runMaintenance(clk.Now())
|
|
s.notifyWG.Wait()
|
|
|
|
var subjects []string
|
|
for _, ml := range mb.all() {
|
|
subjects = append(subjects, ml.subject)
|
|
}
|
|
want := []string{"[Status] Scheduled maintenance: Network upgrade", "[Status] Maintenance started: Network upgrade", "[Status] Maintenance complete: Network upgrade"}
|
|
if strings.Join(subjects, "|") != strings.Join(want, "|") {
|
|
t.Fatalf("mails %q, want %q", subjects, want)
|
|
}
|
|
if body := mb.all()[0].body; !strings.Contains(body, "Brief packet loss.") || !strings.Contains(body, "Jan 5, 2026, 11:00 to 12:00 UTC") {
|
|
t.Fatalf("scheduled mail: %s", body)
|
|
}
|
|
|
|
// A window that passes while Sandbar is stopped is not announced after the fact.
|
|
s.st.put("maintenance", "m2", &Maintenance{ID: "m2", Title: "Missed", Start: clk.Now().Add(-3 * time.Hour).Unix(), End: clk.Now().Add(-2 * time.Hour).Unix()})
|
|
before := len(mb.all())
|
|
s.runMaintenance(clk.Now())
|
|
s.notifyWG.Wait()
|
|
if len(mb.all()) != before {
|
|
t.Fatal("a missed window was announced")
|
|
}
|
|
}
|
|
|
|
func TestMaintenanceAdminAPI(t *testing.T) {
|
|
s, clk, _, ts := statusTestServer(t)
|
|
c := newAPIClient(t, ts)
|
|
c.setup()
|
|
_ = s
|
|
now := clk.Now()
|
|
body := map[string]any{"title": "Upgrade", "message": "m", "start": now.Add(time.Hour).Unix(), "end": now.Add(2 * time.Hour).Unix(), "components": []string{"a", "a", " "}}
|
|
var created maintView
|
|
if code := c.do("POST", "/api/v1/maintenance", body, &created); code != 200 || created.State != "scheduled" || len(created.Components) != 1 {
|
|
t.Fatalf("create: %d %+v", code, created)
|
|
}
|
|
if code := c.do("POST", "/api/v1/maintenance", map[string]any{"title": "x", "start": now.Unix() + 10, "end": now.Unix() + 5}, nil); code != 400 {
|
|
t.Fatalf("end before start: %d", code)
|
|
}
|
|
if code := c.do("POST", "/api/v1/maintenance", map[string]any{"title": "x", "start": now.Unix() - 100, "end": now.Unix() - 50}, nil); code != 400 {
|
|
t.Fatalf("window already over: %d", code)
|
|
}
|
|
body["title"] = "Upgrade, moved"
|
|
if code := c.do("PUT", "/api/v1/maintenance/"+created.ID, body, &created); code != 200 || created.Title != "Upgrade, moved" {
|
|
t.Fatalf("update: %d %+v", code, created)
|
|
}
|
|
if code := c.do("POST", "/api/v1/maintenance/"+created.ID+"/cancel", nil, &created); code != 200 || created.State != "canceled" {
|
|
t.Fatalf("cancel: %d %+v", code, created)
|
|
}
|
|
if code := c.do("POST", "/api/v1/maintenance/"+created.ID+"/cancel", nil, nil); code != 400 {
|
|
t.Fatalf("second cancel: %d", code)
|
|
}
|
|
var d statusDoc
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
if len(d.Maintenance) != 0 {
|
|
t.Fatalf("a canceled window is still listed: %+v", d.Maintenance)
|
|
}
|
|
if code := newAPIClient(t, ts).do("POST", "/api/v1/maintenance", body, nil); code != 401 {
|
|
t.Fatalf("anonymous create: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestStatusSettingsAPIAndBranding(t *testing.T) {
|
|
_, _, _, ts := statusTestServer(t)
|
|
c := newAPIClient(t, ts)
|
|
c.setup()
|
|
if code := c.do("PUT", "/api/v1/statuspage", StatusSettings{Title: "Acme", Accent: "#ffee00"}, nil); code != 400 {
|
|
t.Fatalf("low contrast accent: %d", code)
|
|
}
|
|
if code := c.do("PUT", "/api/v1/statuspage", StatusSettings{Title: "Acme <Status>", Description: "Live status", Accent: "#0b5394", LogoURL: "https://acme.test/logo.svg", Domain: "status.acme.test"}, nil); code != 200 {
|
|
t.Fatalf("save: %d", code)
|
|
}
|
|
var d statusDoc
|
|
getJSON(t, ts.URL+"/status.json", &d)
|
|
if d.Settings.Title != "Acme <Status>" || d.Settings.Accent != "#0b5394" || d.Settings.Domain != "" {
|
|
t.Fatalf("public settings: %+v", d.Settings)
|
|
}
|
|
resp, _ := http.Get(ts.URL + "/status")
|
|
page, _ := io.ReadAll(resp.Body)
|
|
if !strings.Contains(string(page), "<title>Acme <Status></title>") || strings.Contains(string(page), "__") {
|
|
t.Fatalf("page shell: %s", page[:300])
|
|
}
|
|
if csp := resp.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "img-src 'self' data: https:") {
|
|
t.Fatalf("a logo needs img-src https: on the status page: %s", csp)
|
|
}
|
|
resp, _ = http.Get(ts.URL + "/")
|
|
if csp := resp.Header.Get("Content-Security-Policy"); strings.Contains(csp, "https:") {
|
|
t.Fatalf("the admin app should not allow remote images: %s", csp)
|
|
}
|
|
}
|
|
|
|
// The custom domain serves the status page at its root and nothing else.
|
|
func TestCustomDomain(t *testing.T) {
|
|
s, _, mb, ts := statusTestServer(t)
|
|
s.saveStatusSettings(StatusSettings{Title: "Acme status", Domain: "status.acme.test"})
|
|
s.st.put("monitor", "web", &Monitor{ID: "web", Name: "Website", Kind: "http", Target: "http://x", Enabled: true, Public: true})
|
|
u := mustUser(t, s, "Alice")
|
|
yes := true
|
|
inc, _ := s.DeclareIncident("Outage", "sev2", "Down.", u.ID, u.Name, nil)
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
|
|
|
get := func(host, path string) (int, string) {
|
|
req, _ := http.NewRequest("GET", ts.URL+path, nil)
|
|
req.Host = host
|
|
resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(b)
|
|
}
|
|
host := "Status.Acme.Test:443"
|
|
if code, body := get(host, "/"); code != 200 || !strings.Contains(body, "<title>Acme status</title>") || !strings.Contains(body, "const BASE = '';") {
|
|
t.Fatalf("root on the custom domain: %d %.200s", code, body)
|
|
}
|
|
for _, path := range []string{"/history", "/incidents/" + inc.ID} {
|
|
if code, body := get(host, path); code != 200 || !strings.Contains(body, "<main") {
|
|
t.Fatalf("%s: %d", path, code)
|
|
}
|
|
}
|
|
if code, body := get(host, "/status.json"); code != 200 || !strings.Contains(body, "Website") {
|
|
t.Fatalf("status.json: %d", code)
|
|
}
|
|
if code, body := get(host, "/feed.rss"); code != 200 || !strings.Contains(body, "<rss") {
|
|
t.Fatalf("feed: %d", code)
|
|
}
|
|
for _, path := range []string{"/api/v1/users", "/healthz", "/metrics", "/alerts/sse", "/script.js", "/statusx"} {
|
|
if code, _ := get(host, path); code != 404 {
|
|
t.Fatalf("%s should not exist on the status domain, got %d", path, code)
|
|
}
|
|
}
|
|
// On Sandbar's own address the app is where it was, and the status page stays under /status.
|
|
if code, body := get("sandbar.test", "/"); code != 200 || !strings.Contains(body, "Sandbar") {
|
|
t.Fatalf("admin root: %d", code)
|
|
}
|
|
if code, body := get("sandbar.test", "/status"); code != 200 || !strings.Contains(body, "const BASE = '/status';") {
|
|
t.Fatalf("own address: %d", code)
|
|
}
|
|
|
|
// Subscriber links point at the custom domain; the redirect after confirming stays on it.
|
|
s.opts.URL = "https://sandbar.test"
|
|
s.addSubscriber("email", "bob@example.com", true)
|
|
s.notifyWG.Wait()
|
|
n := len(mb.all())
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Note: "Still working."})
|
|
s.notifyWG.Wait()
|
|
mails := mb.all()
|
|
if len(mails) != n+1 || !strings.Contains(mails[n].body, "https://status.acme.test/incidents/"+inc.ID) || !strings.Contains(mails[n].body, "https://status.acme.test/unsubscribe?token=") {
|
|
t.Fatalf("links in mail: %+v", mails[n:])
|
|
}
|
|
req, _ := http.NewRequest("POST", ts.URL+"/status/subscribe", strings.NewReader(`{"email":"carol@example.com"}`))
|
|
req.Host = "status.acme.test"
|
|
http.DefaultClient.Do(req)
|
|
conf := mb.all()[len(mb.all())-1]
|
|
if !strings.Contains(conf.body, "https://status.acme.test/confirm?token=") {
|
|
t.Fatalf("confirm link: %s", conf.body)
|
|
}
|
|
req, _ = http.NewRequest("GET", ts.URL+strings.TrimPrefix(linkRe.FindString(conf.body), "https://status.acme.test"), nil)
|
|
req.Host = "status.acme.test"
|
|
resp, _ := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
|
|
if resp.Header.Get("Location") != "/?notice=confirmed" {
|
|
t.Fatalf("confirming on the custom domain should stay on it, got %s", resp.Header.Get("Location"))
|
|
}
|
|
}
|
|
|
|
func TestFeedIsValidRSS(t *testing.T) {
|
|
s, clk, _, ts := statusTestServer(t)
|
|
u := mustUser(t, s, "Alice")
|
|
s.saveStatusSettings(StatusSettings{Title: "Acme status"})
|
|
yes := true
|
|
inc, _ := s.DeclareIncident("Login <broken> & slow", "sev2", "We are looking.", u.ID, u.Name, nil)
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Public: &yes})
|
|
clk.Advance(10 * time.Minute)
|
|
done := "resolved"
|
|
s.UpdateIncident(inc.ID, u.Name, IncidentPatch{Status: &done, Note: "All good."})
|
|
hidden, _ := s.DeclareIncident("Private thing", "sev3", "", u.ID, u.Name, nil)
|
|
_ = hidden
|
|
s.st.put("maintenance", "m", &Maintenance{ID: "m", Title: "Planned", Start: clk.Now().Add(time.Hour).Unix(), End: clk.Now().Add(2 * time.Hour).Unix(), Created: clk.Now().Unix()})
|
|
|
|
resp, err := http.Get(ts.URL + "/status/feed.rss")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") {
|
|
t.Fatalf("content type %s", ct)
|
|
}
|
|
var feed struct {
|
|
Channel struct {
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
Items []struct {
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
GUID string `xml:"guid"`
|
|
PubDate string `xml:"pubDate"`
|
|
Description string `xml:"description"`
|
|
} `xml:"item"`
|
|
} `xml:"channel"`
|
|
}
|
|
if err := xml.NewDecoder(resp.Body).Decode(&feed); err != nil {
|
|
t.Fatalf("not XML: %v", err)
|
|
}
|
|
c := feed.Channel
|
|
if c.Title != "Acme status" || c.Link != "http://sandbar.test/status" || len(c.Items) != 3 {
|
|
t.Fatalf("channel: %+v", c)
|
|
}
|
|
var resolved bool
|
|
for _, it := range c.Items {
|
|
if strings.Contains(it.Title, "Private") {
|
|
t.Fatal("private incident in feed")
|
|
}
|
|
if it.Title == "Resolved: Login <broken> & slow" && it.Description == "All good." && it.Link == "http://sandbar.test/status/incidents/"+inc.ID {
|
|
resolved = true
|
|
}
|
|
if _, err := time.Parse(time.RFC1123Z, it.PubDate); err != nil {
|
|
t.Fatalf("pubDate %q: %v", it.PubDate, err)
|
|
}
|
|
}
|
|
if !resolved {
|
|
t.Fatalf("resolved item missing: %+v", c.Items)
|
|
}
|
|
}
|
|
|
|
// Declaring with public set goes live and tells subscribers in one step; existing databases with
|
|
// events from before posts existed still load.
|
|
func TestDeclarePublicAndLegacyEvents(t *testing.T) {
|
|
s, _, mb, ts := statusTestServer(t)
|
|
c := newAPIClient(t, ts)
|
|
c.setup()
|
|
s.addSubscriber("email", "bob@example.com", true)
|
|
var inc Incident
|
|
if code := c.do("POST", "/api/v1/incidents", map[string]any{"title": "Slow", "severity": "sev3", "summary": "Pages are slow.", "public": true, "components": []string{"x"}}, &inc); code != 200 || !inc.Public {
|
|
t.Fatalf("declare: %d %+v", code, inc)
|
|
}
|
|
s.notifyWG.Wait()
|
|
if m := mb.all(); len(m) != 1 || !strings.Contains(m[0].subject, "New incident: Slow") {
|
|
t.Fatalf("mails: %+v", m)
|
|
}
|
|
// Old rows: an events table without the status column is upgraded in place and keeps its rows.
|
|
s.st.db.Exec(`DROP TABLE events`)
|
|
s.st.db.Exec(`CREATE TABLE events (id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL, kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL)`)
|
|
s.st.db.Exec(`INSERT INTO events(scope,ref,time,kind,actor,text) VALUES('incident','x',1,'update','a','t')`)
|
|
s.st.db.Exec(`ALTER TABLE events ADD COLUMN status TEXT NOT NULL DEFAULT ''`)
|
|
if ev := s.st.events("incident", "x"); len(ev) != 1 || ev[0].Status != "" {
|
|
t.Fatalf("events: %+v", ev)
|
|
}
|
|
}
|
|
|
|
func TestPruneForgetsStaleUnconfirmedSubscribers(t *testing.T) {
|
|
s, clk, _, _ := statusTestServer(t)
|
|
old, _, _ := s.addSubscriber("email", "old@example.com", false)
|
|
s.addSubscriber("email", "kept@example.com", true)
|
|
s.st.db.Exec(`UPDATE subscribers SET created=? WHERE id=?`, clk.Now().AddDate(0, 0, -8).Unix(), old.ID)
|
|
s.st.db.Exec(`UPDATE subscribers SET created=? WHERE kind='email' AND confirmed=1`, clk.Now().AddDate(0, 0, -300).Unix())
|
|
s.st.prune(clk.Now(), 30, 30, 365)
|
|
subs := s.confirmedSubscribers()
|
|
var n int
|
|
s.st.db.QueryRow(`SELECT COUNT(*) FROM subscribers`).Scan(&n)
|
|
if n != 1 || len(subs) != 1 || subs[0].Target != "kept@example.com" {
|
|
t.Fatalf("after prune: %d rows, confirmed %+v", n, subs)
|
|
}
|
|
// Addresses are sealed at rest.
|
|
var raw string
|
|
s.st.db.QueryRow(`SELECT target FROM subscribers`).Scan(&raw)
|
|
if strings.Contains(raw, "kept@") {
|
|
t.Fatalf("address stored in the clear: %s", raw)
|
|
}
|
|
}
|