a4dd60f6b8
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.
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{}, 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)
|
|
}
|
|
}
|