Files
terdut-server/internal/api/auth_test.go
T
Niklas Ye dc39e3a5d3
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 2m5s
Move the database to Postgres, before teams need the schema
First step of #1, and it goes first for one reason: #4 adds a team_id to
nearly every table, and doing that twice -- once for SQLite, once for
Postgres -- is work nobody gets paid for. The teams migrations now only
have to be written against one database.

The ten SQLite migrations are replaced by a single Postgres baseline
rather than ported one by one. They were incremental in a way that has
no value on a fresh install: 004 adds columns 008 drops again, and 008's
backfill rewrites data a Postgres database never had. The history stays
in git; the schema they add up to is now 001_baseline.sql.

Timestamps stay BIGINT unix seconds and are NOT converted to timestamptz.
Everything in Go already speaks epochs, so converting would have been a
second, larger change riding along inside this one. It is worth doing on
its own. The JSON columns did move to jsonb, because #4 will want to
filter and index on labels.

Most of the port is mechanical -- 170 placeholders from ? to $1 -- but
four things needed more than a search and replace:

  * Dynamically built WHERE clauses cannot keep their numbering straight
    by hand, so they hand out placeholders through sqlArgs instead. A
    filter can now be added or reordered without renumbering anything.

  * SUM(resolved_at IS NULL) was SQLite counting a boolean as 0 or 1.
    Postgres has no sum(boolean), and this was breaking every dead man's
    switch -- silently, since the sweeper only logs. Now COUNT(*) FILTER.

  * unixepoch() became FLOOR(EXTRACT(EPOCH FROM now()))::bigint. The
    FLOOR is load-bearing: a bare cast rounds half up, so a row written
    at .6 of a second claimed a timestamp a second in the future and
    disagreed with the time.Now().Unix() the Go side stamps.

  * The unique-violation check matched SQLite's error text. It matches
    SQLSTATE 23505 now, so a renamed constraint cannot turn a 409 back
    into a 500.

Tests need a real Postgres, because there is no in-memory Postgres the
way there was an in-memory SQLite. Each test gets its own schema on a
shared server -- cheaper than a database each, and still isolated.
TERDUT_TEST_DSN says where it is; `make test-db` starts one locally and
ci.yaml runs one as a service container. An unset DSN fails the suite
rather than skipping it: a run that quietly tests nothing is worse than
one that does not run.

TestMigration_BackfillCarriesAckAndComments is deleted along with the
migrations it replayed. What it protected -- an upgrade not losing
acknowledgements and comments -- now belongs to scripts/sqlite-to-postgres.go,
which is build-tagged so the SQLite driver stays out of the server
binary. Both are meant to be deleted once this install has migrated.

The chart loses the PVC, the data volume and the python backup sidecar,
and requires database.dsnSecret.name: it provisions no database and
cannot guess where the credentials live, so a render without it is meant
to fail. Backups move to where Postgres actually runs. The other half of
that -- the postgresql CR, the k8up pg_dump annotation and the network
policy -- is a change to the wrapper chart in Ryuvia/charts and is not in
here.

Verified rather than assumed: the gate is green with -race against
Postgres 17, govulncheck and gitleaks are clean, and the migration script
was run end to end against a SQLite database built at the old schema and
seeded in every table. Ids survive, so incidents keep their numbers and
every foreign key still points where it did; the identity sequences are
moved past the copied ids, and a webhook after the migration opened
incident 12 rather than colliding at 1.
2026-09-20 10:44:12 +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.DeadmanConfig{}, 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{}, api.DeadmanConfig{}))
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)
}
}