Files
VeillaxandClaude Sonnet 5.5 cf4d5b3ce2 Turn the status page into a full status page generator
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>
2026-10-27 17:05:38 -06:00

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 &lt;Status&gt;</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)
}
}