Files
terdut-server/internal/api/auth_test.go
T
Niklas Ye b0a02c010b
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m1s
Add an admin page, and move the behaviour settings into the database
Closes #5. Three of the server's tunables were environment variables,
which meant changing how long an incident waits before being paged again
required editing a chart, merging it and waiting for a reconcile. They
are behaviour rather than infrastructure, and the difference is who needs
to change them and how often.

The split is by who owns the value. What stays in the environment is
where the server is plugged in: the listen address, the DSN, the ntfy URL
and token, the public URL. Those are needed before the database is open
and two of them are credentials -- the settings endpoint reports that
ntfy is configured and that a token is set, and never what either is.

What moves is how it behaves: the notify repeat interval, the stale
window and the archive window. The environment variable becomes the seed
rather than the setting, written once on first start and never
overwritten, so a redeploy cannot put a chart's default back over an
administrator's edit -- the rule the per-team dead man's switches already
follow. The loops read the current value per tick, so a change at 02:00
is obeyed at 02:00.

Key/value rather than a column per knob: #6 and #7 will both add
settings, and a table shaped one-column-per-setting needs a migration for
each. The cost is that values are text and the accessor has to say what
type it wanted, which settings.go does in one place. Unknown keys are
refused rather than stored -- a typo that wrote notify_repeat_second
would otherwise sit in the table looking like configuration and doing
nothing -- and each value has bounds loose enough to catch a slipped
decimal point without having an opinion about anybody's rota.

Disabling an account is new, and is not deleting one. Deleting a user
nulls acknowledged_by and assigned_to, which quietly rewrites who did
what during an incident months after the fact. A disabled user cannot
authenticate by either credential, loses their sessions immediately, and
stays the name on every acknowledgement they made. The check is part of
the lookup in serveAs rather than a test afterwards, so there is no path
where the row is loaded and the flag is then forgotten.

The page itself is a fourth tab, shown only to an administrator and only
as a courtesy: every endpoint under it is refused with 403 regardless, so
somebody who types /admin gets an explanation rather than a blank screen.
It lists teams with their size and open-incident count, users with their
flags, and the settings with their bounds -- plus the environment half,
read-only, so somebody hunting for the ntfy URL learns where it lives
instead of concluding the server has none.

Delete is disabled rather than offered-and-refused for a team with open
incidents, and neither admin action is offered on your own account, since
the server refuses both.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 18:23:46 +02:00

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{}, testConfig()))
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)
}
}