Files
Kontrollverlust/endpoints_test.go
T
irrlicht bee1305a2b 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>
2026-06-01 11:16:02 +02:00

254 lines
6.8 KiB
Go

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)
}
}