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 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,10 @@ var (
|
|||||||
reSpace = regexp.MustCompile(`\s+`)
|
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.
|
// getSession liest den session-Cookie und liefert die zugehörige, noch gültige Session.
|
||||||
func getSession(r *http.Request) (*Session, bool) {
|
func getSession(r *http.Request) (*Session, bool) {
|
||||||
c, err := r.Cookie("session")
|
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)
|
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash)
|
||||||
|
|
||||||
// Gegen Timing-Angriffe: immer eine kleine, zufällige Verzögerung.
|
// 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 {
|
if err == sql.ErrNoRows {
|
||||||
writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.")
|
writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.")
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ CREATE TABLE IF NOT EXISTS vote (
|
|||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
func initDB() error {
|
func initDB(dsn string) error {
|
||||||
var err error
|
var err error
|
||||||
db, err = sql.Open("sqlite", "kver.db")
|
db, err = sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := initDB(); err != nil {
|
if err := initDB("kver.db"); err != nil {
|
||||||
log.Fatalf("db init: %v", err)
|
log.Fatalf("db init: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
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 := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
@@ -48,9 +56,7 @@ func main() {
|
|||||||
// Neues JS-Frontend
|
// Neues JS-Frontend
|
||||||
r.Handle("/*", http.FileServer(http.Dir("web")))
|
r.Handle("/*", http.FileServer(http.Dir("web")))
|
||||||
|
|
||||||
const addr = ":8080"
|
return r
|
||||||
log.Printf("kver listening on %s", addr)
|
|
||||||
log.Fatal(http.ListenAndServe(addr, r))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user