Files
terdut-server/internal/api/auth_test.go
T
Niklas Ye a4dd60f6b8 Add service accounts and operator mode
Service accounts (SERVICE-ACCOUNTS.md) are a scoped, non-human credential:
not a users row, so they never touch OIDC sync, login or the is_admin flag.
Instance scope can create a team and mint a team-scoped account for it;
team scope is owner-equivalent for that one team and nothing else. This is
what unblocks terdut-operator's DESIGN.md §6 — no more impersonating a
human admin, and a real rotation story instead of the unworkable
delete-and-re-bootstrap /api/bootstrap can't actually do.

- migration 014: service_accounts + service_account_keys
- POST /api/service-accounts, POST/DELETE .../keys, GET ?name= self-lookup
- AuthMiddleware resolves a tdsa_-prefixed key to a distinct principal;
  a team-scoped account gets a synthetic single membership so
  requireTeamMember/requireTeamOwner work on it unmodified
- handleCreateTeam accepts an instance-scoped caller; the team it creates
  has no human owner, which is the expected shape for one an operator is
  about to hand a team-scoped credential to

Operator mode (TERDUT_OPERATOR_MODE / values.operatorMode) declares an
install gitops-managed: session and user-API-key writes to teams,
escalation policies, dead man's switches and integrations get 403
reason=operator_managed, while a service account's writes still go
through. Team membership/invites and the schedule are deliberately left
out — never gitops-managed by design, and still human day-to-day work.
/api/auth/config reports operator_mode so the web UI can grey these
sections out from the start rather than only after a write fails.

Also: GET /api/version (both terdut-tui and terdut-operator currently
detect server capability by route-probing; this gives them a real answer),
and a PUT for dead man's switches so a reconciler can update one in place
instead of deleting and recreating it.
2026-09-29 21:25:17 +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(), "test"))
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)
}
}