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 "}); 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 ", 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 " || 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), "Acme <Status>") || 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, "Acme status") || !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, " & 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 & 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) } }