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) } }