74359c72ab
The rest of #4. Two halves that belong together because they are the same sentence from opposite ends: a team decides which of its alerts are heartbeats, and the UI has to be able to say which team it is talking about. Switches were three environment variables, which made them one setting for the whole install. That was the last piece of the alerting path a team could not control: it could take its own alerts on its own key and still not say which of them were heartbeats, or how long a silence had to last. They are a row per team now, edited by an owner through PUT /api/teams/{teamID}/deadman, and the sweeper runs each team against its own matchers, timeout and severity. The environment variables become the starting point rather than the setting. Every team without a configuration is seeded from them at startup, so an upgrade keeps watching exactly what it was watching, and SeedDeadmanConfigs never overwrites -- a redeploy must not put the environment's value back over an owner's edit. A team created later watches nothing until somebody says otherwise: inheriting an install-wide heartbeat would page a new team about a source it has never heard of, and a switch nobody chose is the kind that gets muted rather than fixed. A matcher string with no alertname in it is refused at the door instead of stored. Storing it would produce a switch that watches nothing silently, which is the exact failure the feature exists to prevent. NewRouter and Sweep lose their DeadmanConfig parameter -- there is no longer one answer to hand them. The type stays, because parsing a matcher string is still parsing a matcher string. The UI side: rows in the queue carry a team badge, the filter row gains a team chip per team, and "on call now" shows one card per team. All three appear only when the viewer is in more than one team -- otherwise they are the same word repeated down a list, which is noise rather than information, and the single-team install reads exactly as it did before teams existed. Verified against a live two-team server as well as in tests: the combined queue labelled by team, the team_id filter, a heartbeat that is a heartbeat in one team and an ordinary alert in another, and a new team's switches starting empty while the upgraded team keeps the environment's. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
354 lines
10 KiB
Go
354 lines
10 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
|
)
|
|
|
|
const adminPassword = "correct horse battery"
|
|
|
|
// browser is an HTTP client with its own cookie jar, standing in for one
|
|
// signed-in browser.
|
|
type browser struct {
|
|
*http.Client
|
|
base string
|
|
}
|
|
|
|
func newBrowser(t *testing.T, base string) *browser {
|
|
t.Helper()
|
|
jar, _ := cookiejar.New(nil)
|
|
return &browser{Client: &http.Client{Jar: jar}, base: base}
|
|
}
|
|
|
|
// do sends a request the way the web UI's own fetch would: same-origin, with
|
|
// the cookie from the jar.
|
|
func (b *browser) do(t *testing.T, method, path string, body any, header ...string) *http.Response {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
data, _ := json.Marshal(body)
|
|
r = bytes.NewReader(data)
|
|
}
|
|
req, _ := http.NewRequest(method, b.base+path, r)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
for i := 0; i+1 < len(header); i += 2 {
|
|
req.Header.Set(header[i], header[i+1])
|
|
}
|
|
resp, err := b.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (b *browser) login(t *testing.T, username, password string) *http.Response {
|
|
t.Helper()
|
|
return b.do(t, http.MethodPost, "/api/login", map[string]string{"username": username, "password": password})
|
|
}
|
|
|
|
// setAdminPassword gives the bootstrapped admin a password over its API key.
|
|
func setAdminPassword(t *testing.T, s *ts) {
|
|
t.Helper()
|
|
resp := s.req(t, http.MethodPut, "/api/users/1/password", map[string]string{"password": adminPassword})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("set password: %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func signedIn(t *testing.T, s *ts) *browser {
|
|
t.Helper()
|
|
setAdminPassword(t, s)
|
|
b := newBrowser(t, s.URL)
|
|
resp := b.login(t, "admin", adminPassword)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login: %d", resp.StatusCode)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func status(t *testing.T, resp *http.Response) int {
|
|
t.Helper()
|
|
resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func TestLogin_SetsSessionCookie(t *testing.T) {
|
|
s := newTS(t)
|
|
setAdminPassword(t, s)
|
|
b := newBrowser(t, s.URL)
|
|
|
|
resp := b.login(t, "admin", adminPassword)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("login: %d", resp.StatusCode)
|
|
}
|
|
var cookie *http.Cookie
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == "terdut_session" {
|
|
cookie = c
|
|
}
|
|
}
|
|
if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
|
|
t.Fatalf("expected an HttpOnly, SameSite=Lax session cookie, got %+v", cookie)
|
|
}
|
|
if cookie.Secure {
|
|
t.Error("cookie is Secure on a plain-HTTP server with no https public URL")
|
|
}
|
|
var me struct {
|
|
User struct {
|
|
Username string `json:"username"`
|
|
} `json:"user"`
|
|
HasPassword bool `json:"has_password"`
|
|
}
|
|
decode(t, resp, &me)
|
|
if me.User.Username != "admin" || !me.HasPassword {
|
|
t.Errorf("unexpected login response %+v", me)
|
|
}
|
|
}
|
|
|
|
func TestLogin_CookieAuthenticatesAPI(t *testing.T) {
|
|
s := newTS(t)
|
|
b := signedIn(t, s)
|
|
|
|
if code := status(t, b.do(t, http.MethodGet, "/api/incidents", nil)); code != http.StatusOK {
|
|
t.Errorf("GET /api/incidents with cookie: %d", code)
|
|
}
|
|
resp := b.do(t, http.MethodGet, "/api/me", nil)
|
|
var me struct {
|
|
User struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"user"`
|
|
}
|
|
decode(t, resp, &me)
|
|
if me.User.ID != 1 {
|
|
t.Errorf("/api/me returned user %d", me.User.ID)
|
|
}
|
|
}
|
|
|
|
func TestLogin_SecureCookieBehindHTTPSPublicURL(t *testing.T) {
|
|
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
|
setAdminPassword(t, s)
|
|
resp := newBrowser(t, s.URL).login(t, "admin", adminPassword)
|
|
resp.Body.Close()
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == "terdut_session" && !c.Secure {
|
|
t.Error("cookie should be Secure when the public URL is https")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogin_WrongPasswordAndUnknownUser(t *testing.T) {
|
|
s := newTS(t)
|
|
setAdminPassword(t, s)
|
|
b := newBrowser(t, s.URL)
|
|
|
|
if code := status(t, b.login(t, "admin", "not the password")); code != http.StatusUnauthorized {
|
|
t.Errorf("wrong password: %d", code)
|
|
}
|
|
if code := status(t, b.login(t, "nobody", adminPassword)); code != http.StatusUnauthorized {
|
|
t.Errorf("unknown user: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestLogin_UserWithoutPasswordCannotSignIn(t *testing.T) {
|
|
s := newTS(t)
|
|
b := newBrowser(t, s.URL)
|
|
// The empty password must not match a user that has none.
|
|
if code := status(t, b.login(t, "admin", "")); code != http.StatusUnauthorized {
|
|
t.Errorf("login without a password set: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestLogin_RateLimitedPerUsername(t *testing.T) {
|
|
s := newTS(t)
|
|
setAdminPassword(t, s)
|
|
b := newBrowser(t, s.URL)
|
|
|
|
for i := range 10 {
|
|
if code := status(t, b.login(t, "admin", "wrong")); code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: %d", i+1, code)
|
|
}
|
|
}
|
|
// Even the right password is refused once the limit is reached.
|
|
resp := b.login(t, "admin", adminPassword)
|
|
if resp.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429, got %d", resp.StatusCode)
|
|
}
|
|
if resp.Header.Get("Retry-After") == "" {
|
|
t.Error("429 without Retry-After")
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestSession_CrossOriginWriteRejected(t *testing.T) {
|
|
s := newTS(t)
|
|
b := signedIn(t, s)
|
|
|
|
code := status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil,
|
|
"Sec-Fetch-Site", "cross-site", "Origin", "https://evil.example"))
|
|
if code != http.StatusForbidden {
|
|
t.Errorf("cross-origin POST with cookie: %d, want 403", code)
|
|
}
|
|
|
|
// The same request from the page itself gets through to the handler.
|
|
code = status(t, b.do(t, http.MethodPost, "/api/incidents/999/acknowledge", nil))
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("same-origin POST with cookie: %d, want 404 from the handler", code)
|
|
}
|
|
}
|
|
|
|
func TestSession_BearerIgnoresOriginChecks(t *testing.T) {
|
|
s := newTS(t)
|
|
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/incidents/999/acknowledge", nil)
|
|
req.Header.Set("Authorization", "Bearer "+s.key)
|
|
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code := status(t, resp); code != http.StatusNotFound {
|
|
t.Errorf("Bearer request: %d, want 404 from the handler", code)
|
|
}
|
|
}
|
|
|
|
func TestLogout_EndsSession(t *testing.T) {
|
|
s := newTS(t)
|
|
b := signedIn(t, s)
|
|
|
|
if code := status(t, b.do(t, http.MethodPost, "/api/logout", nil)); code != http.StatusNoContent {
|
|
t.Fatalf("logout: %d", code)
|
|
}
|
|
if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
|
|
t.Errorf("after logout: %d", code)
|
|
}
|
|
var n int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n)
|
|
if n != 0 {
|
|
t.Errorf("%d session(s) left after logout", n)
|
|
}
|
|
}
|
|
|
|
func TestSession_ExpiredIsRejected(t *testing.T) {
|
|
s := newTS(t)
|
|
b := signedIn(t, s)
|
|
s.exec(t, "UPDATE sessions SET expires_at = 1")
|
|
|
|
if code := status(t, b.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
|
|
t.Errorf("expired session: %d", code)
|
|
}
|
|
api.Sweep(t.Context(), s.db, 0, 0, api.NotifyConfig{})
|
|
var n int
|
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&n)
|
|
if n != 0 {
|
|
t.Errorf("sweep left %d expired session(s)", n)
|
|
}
|
|
}
|
|
|
|
func TestSetPassword_OwnNeedsCurrent(t *testing.T) {
|
|
s := newTS(t)
|
|
b := signedIn(t, s)
|
|
|
|
code := status(t, b.do(t, http.MethodPut, "/api/users/1/password",
|
|
map[string]string{"password": "a brand new secret", "current_password": "wrong"}))
|
|
if code != http.StatusForbidden {
|
|
t.Errorf("wrong current password: %d", code)
|
|
}
|
|
code = status(t, b.do(t, http.MethodPut, "/api/users/1/password",
|
|
map[string]string{"password": "short", "current_password": adminPassword}))
|
|
if code != http.StatusBadRequest {
|
|
t.Errorf("too-short password: %d", code)
|
|
}
|
|
code = status(t, b.do(t, http.MethodPut, "/api/users/1/password",
|
|
map[string]string{"password": "a brand new secret", "current_password": adminPassword}))
|
|
if code != http.StatusNoContent {
|
|
t.Fatalf("change password: %d", code)
|
|
}
|
|
if code := status(t, newBrowser(t, s.URL).login(t, "admin", "a brand new secret")); code != http.StatusOK {
|
|
t.Errorf("login with the new password: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
|
|
s := newTS(t)
|
|
phone := signedIn(t, s)
|
|
laptop := newBrowser(t, s.URL)
|
|
status(t, laptop.login(t, "admin", adminPassword))
|
|
|
|
code := status(t, phone.do(t, http.MethodPut, "/api/users/1/password",
|
|
map[string]string{"password": "a brand new secret", "current_password": adminPassword}))
|
|
if code != http.StatusNoContent {
|
|
t.Fatalf("change password: %d", code)
|
|
}
|
|
if code := status(t, phone.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusOK {
|
|
t.Errorf("the session that changed the password: %d", code)
|
|
}
|
|
if code := status(t, laptop.do(t, http.MethodGet, "/api/me", nil)); code != http.StatusUnauthorized {
|
|
t.Errorf("the other session: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestBootstrap_WithPassword(t *testing.T) {
|
|
database := newTestDB(t)
|
|
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
|
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code := status(t, resp); code != http.StatusCreated {
|
|
t.Fatalf("bootstrap: %d", code)
|
|
}
|
|
if code := status(t, newBrowser(t, srv.URL).login(t, "admin", adminPassword)); code != http.StatusOK {
|
|
t.Errorf("login after bootstrap: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestRouter_UnknownAPIPathIsJSON404(t *testing.T) {
|
|
s := newTS(t)
|
|
resp, err := http.Get(s.URL + "/api/nope")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
|
t.Errorf("GET /api/nope: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
|
|
}
|
|
}
|
|
|
|
func TestRouter_DeepLinkServesWebUI(t *testing.T) {
|
|
s := newTS(t)
|
|
resp, err := http.Get(s.URL + "/incidents/1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
|
|
t.Errorf("GET /incidents/1: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
|
|
}
|
|
if resp.Header.Get("Content-Security-Policy") == "" {
|
|
t.Error("web UI served without a CSP")
|
|
}
|
|
|
|
resp2, err := http.Get(s.URL + "/js/missing.js")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code := status(t, resp2); code != http.StatusNotFound {
|
|
t.Errorf("missing asset: %d", code)
|
|
}
|
|
}
|