From bee1305a2bebb31b7b5e49b525f462d3a19088ec Mon Sep 17 00:00:00 2001 From: irrlicht Date: Mon, 1 Jun 2026 11:16:02 +0200 Subject: [PATCH] Add endpoint tests with httptest Two small refactors make the handlers testable: - extract route registration into routes() http.Handler (was inline in main) - initDB(dsn) takes a DSN so tests use an isolated temp-file DB loginJitter var lets tests disable the anti-timing sleep for speed. endpoints_test.go covers the main flows via httptest + cookie jar: register/login/headerbar, empty-then-populated feed, create requires auth, voting (new/toggle/switch, per-user tallies), vote auth + invalid mode, and account deletion (wrong/correct password, login fails after). Co-Authored-By: Claude Opus 4.8 --- auth.go | 8 +- db.go | 4 +- endpoints_test.go | 253 ++++++++++++++++++++++++++++++++++++++++++++++ main.go | 14 ++- 4 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 endpoints_test.go diff --git a/auth.go b/auth.go index 7d87d77..d6161e9 100644 --- a/auth.go +++ b/auth.go @@ -27,6 +27,10 @@ var ( reSpace = regexp.MustCompile(`\s+`) ) +// loginJitter ist die Obergrenze (in ms) der zufälligen Login-Verzögerung gegen +// Timing-Angriffe. Tests setzen den Wert auf 0, um die Verzögerung abzuschalten. +var loginJitter = 2000 + // getSession liest den session-Cookie und liefert die zugehörige, noch gültige Session. func getSession(r *http.Request) (*Session, bool) { c, err := r.Cookie("session") @@ -68,7 +72,9 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash) // Gegen Timing-Angriffe: immer eine kleine, zufällige Verzögerung. - time.Sleep(time.Duration(rand.IntN(2000)) * time.Millisecond) + if loginJitter > 0 { + time.Sleep(time.Duration(rand.IntN(loginJitter)) * time.Millisecond) + } if err == sql.ErrNoRows { writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.") diff --git a/db.go b/db.go index 8aaae1d..3d379dd 100644 --- a/db.go +++ b/db.go @@ -45,9 +45,9 @@ CREATE TABLE IF NOT EXISTS vote ( ); ` -func initDB() error { +func initDB(dsn string) error { var err error - db, err = sql.Open("sqlite", "kver.db") + db, err = sql.Open("sqlite", dsn) if err != nil { return err } diff --git a/endpoints_test.go b/endpoints_test.go new file mode 100644 index 0000000..037abf7 --- /dev/null +++ b/endpoints_test.go @@ -0,0 +1,253 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" +) + +// newTestServer richtet eine frische, isolierte DB (eigene Datei je Test) und +// einen httptest-Server mit dem echten Routing ein. +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + + loginJitter = 0 // Login-Verzögerung in Tests abschalten + + dsn := filepath.Join(t.TempDir(), "test.db") + if err := initDB(dsn); err != nil { + t.Fatalf("initDB: %v", err) + } + t.Cleanup(func() { db.Close() }) + + srv := httptest.NewServer(routes()) + t.Cleanup(srv.Close) + return srv +} + +// newClient liefert einen HTTP-Client mit Cookie-Jar (hält Session-Cookies). +func newClient(t *testing.T) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return &http.Client{Jar: jar} +} + +func postForm(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response { + t.Helper() + resp, err := c.PostForm(urlStr, form) + if err != nil { + t.Fatalf("POST %s: %v", urlStr, err) + } + return resp +} + +// registerAndLogin legt einen Nutzer an, loggt ihn ein und gibt den Client +// (mit gesetztem Session-Cookie) zurück. +func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http.Client { + t.Helper() + c := newClient(t) + + resp := postForm(t, c, srv.URL+"/auth/newuser", url.Values{ + "user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"}, + }) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("register %s: status %d", username, resp.StatusCode) + } + + resp = postForm(t, c, srv.URL+"/auth/login", url.Values{ + "user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"}, + }) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("login %s: status %d", username, resp.StatusCode) + } + return c +} + +// createEntry erstellt einen Beitrag und liefert dessen pid. +func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 { + t.Helper() + resp := postForm(t, c, srv.URL+"/entry/create", url.Values{"content": {content}}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create entry: status %d", resp.StatusCode) + } + var out struct { + PID int64 `json:"pid"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode create: %v", err) + } + return out.PID +} + +func TestRegisterLoginHeaderbar(t *testing.T) { + srv := newTestServer(t) + c := registerAndLogin(t, srv, "alice") + + resp, err := c.Get(srv.URL + "/auth/headerbar") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var h struct { + LoggedIn bool `json:"loggedin"` + } + json.NewDecoder(resp.Body).Decode(&h) + if !h.LoggedIn { + t.Fatal("erwartete loggedin=true nach Login") + } + + // Anonymer Client ist nicht eingeloggt. + anon := newClient(t) + resp2, err := anon.Get(srv.URL + "/auth/headerbar") + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + var h2 struct { + LoggedIn bool `json:"loggedin"` + } + json.NewDecoder(resp2.Body).Decode(&h2) + if h2.LoggedIn { + t.Fatal("anonymer Client sollte nicht eingeloggt sein") + } +} + +func TestFeedEmptyThenPopulated(t *testing.T) { + srv := newTestServer(t) + + resp, err := http.Get(srv.URL + "/entry/feed/0") + if err != nil { + t.Fatal(err) + } + var empty []map[string]any + json.NewDecoder(resp.Body).Decode(&empty) + resp.Body.Close() + if len(empty) != 0 { + t.Fatalf("erwartete leeren Feed, bekam %d", len(empty)) + } + + alice := registerAndLogin(t, srv, "alice") + createEntry(t, alice, srv, "hallo welt") + + resp, err = http.Get(srv.URL + "/entry/feed/0") + if err != nil { + t.Fatal(err) + } + var posts []struct { + Content string `json:"content"` + Username string `json:"username"` + } + json.NewDecoder(resp.Body).Decode(&posts) + resp.Body.Close() + if len(posts) != 1 || posts[0].Content != "hallo welt" || posts[0].Username != "alice" { + t.Fatalf("unerwarteter Feed: %+v", posts) + } +} + +func TestCreateEntryRequiresAuth(t *testing.T) { + srv := newTestServer(t) + + anon := newClient(t) + resp := postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}}) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("create ohne Login: erwartet 401, bekam %d", resp.StatusCode) + } +} + +type tally struct { + Left int `json:"left"` + Right int `json:"right"` + Selected string `json:"selected"` +} + +func TestVoting(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + bob := registerAndLogin(t, srv, "bob") + pid := createEntry(t, alice, srv, "vote me") + + vote := func(c *http.Client, mode string) tally { + resp := postForm(t, c, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), url.Values{"mode": {mode}}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("vote %s: status %d", mode, resp.StatusCode) + } + var tl tally + json.NewDecoder(resp.Body).Decode(&tl) + return tl + } + + if got := vote(alice, "left"); got.Left != 1 || got.Selected != "left" { + t.Fatalf("alice left: %+v", got) + } + if got := vote(bob, "right"); got.Left != 1 || got.Right != 1 { + t.Fatalf("bob right: %+v", got) + } + if got := vote(alice, "left"); got.Left != 0 || got.Selected != "none" { + t.Fatalf("alice toggle off: %+v", got) + } + if got := vote(alice, "right"); got.Right != 2 || got.Selected != "right" { + t.Fatalf("alice switch: %+v", got) + } +} + +func TestVoteRequiresAuthAndValidMode(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + pid := createEntry(t, alice, srv, "x") + voteURL := fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid) + + anon := newClient(t) + resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}}) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("anon vote: erwartet 401, bekam %d", resp.StatusCode) + } + + resp = postForm(t, alice, voteURL, url.Values{"mode": {"up"}}) + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("ungültiger Modus: erwartet 400, bekam %d", resp.StatusCode) + } +} + +func TestDeleteUser(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + + // Falsches Passwort -> 403. + resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}}) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode) + } + + // Richtiges Passwort -> 200. + resp = postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"supersecret1"}}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode) + } + + // Login danach schlägt fehl. + fresh := newClient(t) + resp = postForm(t, fresh, srv.URL+"/auth/login", url.Values{ + "user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"}, + }) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("Login nach Löschen: erwartet 401, bekam %d", resp.StatusCode) + } +} diff --git a/main.go b/main.go index 202ecfe..7498861 100644 --- a/main.go +++ b/main.go @@ -10,11 +10,19 @@ import ( ) func main() { - if err := initDB(); err != nil { + if err := initDB("kver.db"); err != nil { log.Fatalf("db init: %v", err) } defer db.Close() + const addr = ":8080" + log.Printf("kver listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, routes())) +} + +// routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den +// Tests identisch verwendet. +func routes() http.Handler { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) @@ -48,9 +56,7 @@ func main() { // Neues JS-Frontend r.Handle("/*", http.FileServer(http.Dir("web"))) - const addr = ":8080" - log.Printf("kver listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, r)) + return r } func writeJSON(w http.ResponseWriter, status int, v any) {