package api_test import ( "bytes" "net/http" "strings" "testing" "time" ) // listSources reads a team's alert sources as the Sources page does. func listSources(t *testing.T, tm teamFixture) []map[string]any { t.Helper() return list(t, tm.call(http.MethodGet, "/api/teams/"+id64(tm.id)+"/integrations", nil)) } // addSource mints a second source in a team and returns its key. func addSource(t *testing.T, tm teamFixture, name string) string { t.Helper() var out struct { Key string `json:"key"` } decode(t, tm.call(http.MethodPost, "/api/teams/"+id64(tm.id)+"/integrations", map[string]string{"name": name}), &out) return out.Key } // A source that has never posted is "never", with nothing to say about alerts. func TestSources_NeverUsedIsBlank(t *testing.T) { s := newTS(t) tm := newTeam(t, s, "red") got := listSources(t, tm) if len(got) != 1 { t.Fatalf("expected 1 source, got %d", len(got)) } src := got[0] if src["status"] != "never" || src["last_used_at"] != nil || src["last_alert_at"] != nil { t.Errorf("a source nobody has posted on should be blank, got %v", src) } if src["alerts_24h"].(float64) != 0 { t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"]) } } // Each source is credited with what arrived on its own key, and only that. func TestSources_AlertsAreAttributedToTheirSource(t *testing.T) { s := newTS(t) tm := newTeam(t, s, "red") second := addSource(t, tm, "staging") postToIntegration(t, s, tm.key, "fp-1", "DiskFull") postToIntegration(t, s, tm.key, "fp-2", "CPUHot") got := listSources(t, tm) first, other := got[0], got[1] if first["status"] != "active" || first["last_used_at"] == nil || first["last_alert_at"] == nil { t.Errorf("the source that posted should be active with timestamps, got %v", first) } if first["alerts_24h"].(float64) != 2 { t.Errorf("alerts_24h = %v, want 2", first["alerts_24h"]) } if other["status"] != "never" || other["alerts_24h"].(float64) != 0 { t.Errorf("the other source should be untouched, got %v", other) } // Re-sending the same alert on the other key moves it: last sender wins. postToIntegration(t, s, second, "fp-1", "DiskFull") got = listSources(t, tm) if got[0]["alerts_24h"].(float64) != 1 || got[1]["alerts_24h"].(float64) != 1 { t.Errorf("fp-1 should have moved to the second source, got %v and %v", got[0]["alerts_24h"], got[1]["alerts_24h"]) } } // A payload with no alerts in it is a webhook, not an alert: the source was // heard from, and nothing arrived. func TestSources_EmptyPayloadStampsUseButNotAlert(t *testing.T) { s := newTS(t) tm := newTeam(t, s, "red") resp, err := http.Post(s.URL+"/api/integrations/"+tm.key+"/alertmanager", "application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`))) if err != nil { t.Fatalf("post: %v", err) } resp.Body.Close() src := listSources(t, tm)[0] if src["status"] != "active" || src["last_alert_at"] != nil { t.Errorf("want active with no alert yet, got %v", src) } } // Quiet is "has posted, not lately"; the alert counter forgets after a day but // the last alert's timestamp is kept. func TestSources_QuietAfterADay(t *testing.T) { s := newTS(t) tm := newTeam(t, s, "red") postToIntegration(t, s, tm.key, "fp-1", "DiskFull") old := time.Now().Add(-48 * time.Hour).Unix() s.exec(t, "UPDATE integrations SET last_used_at = $1", old) s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'fp-1'", old) src := listSources(t, tm)[0] if src["status"] != "quiet" { t.Errorf("status = %v, want quiet", src["status"]) } if src["alerts_24h"].(float64) != 0 { t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"]) } if src["last_alert_at"] == nil { t.Error("last_alert_at should survive the day") } } // Revoking a source does not take its alerts with it. func TestSources_RevokeKeepsTheAlerts(t *testing.T) { s := newTS(t) tm := newTeam(t, s, "red") postToIntegration(t, s, tm.key, "fp-1", "DiskFull") id := int64(listSources(t, tm)[0]["id"].(float64)) resp := tm.call(http.MethodDelete, "/api/teams/"+id64(tm.id)+"/integrations/"+id64(id), nil) resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("revoke: %d", resp.StatusCode) } if got := len(list(t, tm.call(http.MethodGet, "/api/alerts", nil))); got != 1 { t.Errorf("the alert should outlive its source, got %d alerts", got) } } // Renaming is an owner's, scoped to the team, and does not touch the key. func TestSources_Rename(t *testing.T) { s := newTS(t) tm := newTeam(t, s, "red") other := newTeam(t, s, "blue") id := int64(listSources(t, tm)[0]["id"].(float64)) path := "/api/teams/" + id64(tm.id) + "/integrations/" + id64(id) resp := tm.call(http.MethodPatch, path, map[string]string{"name": " prod "}) resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("rename: %d", resp.StatusCode) } if name := listSources(t, tm)[0]["name"]; name != "prod" { t.Errorf("name = %q, want it trimmed to prod", name) } postToIntegration(t, s, tm.key, "fp-1", "DiskFull") // the old key still works for name, body := range map[string]map[string]string{ "empty": {"name": " "}, "too long": {"name": strings.Repeat("x", 101)}, } { resp := tm.call(http.MethodPatch, path, body) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("%s name: expected 400, got %d", name, resp.StatusCode) } } // Another team's owner cannot reach it. resp = other.call(http.MethodPatch, "/api/teams/"+id64(other.id)+"/integrations/"+id64(id), map[string]string{"name": "mine now"}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("renaming another team's source: expected 404, got %d", resp.StatusCode) } }