package main import ( "database/sql" "encoding/json" "fmt" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "strings" "testing" "time" "unicode/utf8" ) // newTestServer richtet ein frisches, isoliertes Schema (eigenes je Test) in der // Datenbank aus KVER_TEST_DSN und einen httptest-Server mit dem echten Routing // ein. Ohne KVER_TEST_DSN werden die Tests übersprungen. func newTestServer(t *testing.T) *httptest.Server { t.Helper() base := os.Getenv("KVER_TEST_DSN") if base == "" { t.Skip("KVER_TEST_DSN nicht gesetzt") } admin, err := sql.Open("pgx", base) if err != nil { t.Fatal(err) } schema := fmt.Sprintf("test_%d", time.Now().UnixNano()) if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { t.Fatalf("schema anlegen: %v", err) } t.Cleanup(func() { admin.Exec(`DROP SCHEMA ` + schema + ` CASCADE`) admin.Close() }) sep := "?" if strings.Contains(base, "?") { sep = "&" } if err := initDB(base + sep + "search_path=" + schema); 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} } // postForm spricht die Endpunkte an wie das JS-Frontend (Accept: application/json). func postForm(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(form.Encode())) if err != nil { t.Fatalf("request %s: %v", urlStr, err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := c.Do(req) 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+"/api/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+"/api/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+"/api/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 TestRegisterLoginSession(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "alice") if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusOK { t.Fatalf("sessioninfo nach Login: erwartet 200, bekam %d", got) } // Anonymer Client hat keine Session. if got := getStatus(t, newClient(t), srv.URL+"/api/auth/sessioninfo"); got != http.StatusUnauthorized { t.Fatalf("sessioninfo anonym: erwartet 401, bekam %d", got) } } // getStatus macht einen GET und liefert nur den Statuscode. func getStatus(t *testing.T, c *http.Client, urlStr string) int { t.Helper() resp, err := c.Get(urlStr) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } func TestFeedEmptyThenPopulated(t *testing.T) { srv := newTestServer(t) resp, err := http.Get(srv.URL + "/api/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 + "/api/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+"/api/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/api/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/api/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 TestBump(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "bump me") bumpURL := fmt.Sprintf("%s/api/entry/%d/bump", srv.URL, pid) // Anonym: 401. resp := postForm(t, newClient(t), bumpURL, url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anon bump: erwartet 401, bekam %d", resp.StatusCode) } // Erster Bump (bump_count 0 -> kein Cooldown): 200, danach bump_count 1. resp = postForm(t, alice, bumpURL, url.Values{}) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("erster bump: erwartet 200, bekam %d", resp.StatusCode) } var out struct { BumpCount int64 `json:"bump_count"` RetryAfter int64 `json:"retry_after"` } json.NewDecoder(resp.Body).Decode(&out) if out.BumpCount != 1 || out.RetryAfter != 86400 { t.Fatalf("nach erstem bump: %+v", out) } // Cooldown gilt pro Beitrag (global): auch ein anderer Nutzer wird sofort // abgewiesen. resp = postForm(t, bob, bumpURL, url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusTooManyRequests { t.Fatalf("bump im cooldown: erwartet 429, bekam %d", resp.StatusCode) } // Gelöschter Beitrag lässt sich nicht bumpen. del := createEntry(t, alice, srv, "weg gleich") resp = postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, del), url.Values{}) resp.Body.Close() resp = postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/bump", srv.URL, del), url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode) } } // createReply erstellt eine Antwort auf parentPID und liefert deren pid. func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 { t.Helper() resp := postForm(t, c, srv.URL+"/api/entry/create", url.Values{ "content": {content}, "reply_to": {fmt.Sprint(parentPID)}, }) defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("create reply: status %d", resp.StatusCode) } var out struct { PID int64 `json:"pid"` } json.NewDecoder(resp.Body).Decode(&out) return out.PID } type threadEntry struct { PID int64 `json:"pid"` ReplyCount int64 `json:"reply_count"` Deleted int64 `json:"deleted"` Content string `json:"content"` Username string `json:"username"` Latest *struct { Entry threadEntry `json:"entry"` Skipped int64 `json:"skipped"` } `json:"latest"` } // threadJSON ist die Antwortform von /entry/{pid}/thread. Nicht zu verwechseln // mit threadView in feedview.go -- das ist das Modell der gerenderten Seite. type threadJSON struct { Entry threadEntry `json:"entry"` Ancestors []threadEntry `json:"ancestors"` Replies []threadEntry `json:"replies"` } func getThread(t *testing.T, srv *httptest.Server, pid int64) threadJSON { t.Helper() resp, err := http.Get(fmt.Sprintf("%s/api/entry/%d/thread", srv.URL, pid)) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("thread %d: status %d", pid, resp.StatusCode) } var tv threadJSON json.NewDecoder(resp.Body).Decode(&tv) return tv } func TestThreading(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") root := createEntry(t, alice, srv, "wurzel") mid := createReply(t, alice, srv, "antwort 1", root) createReply(t, alice, srv, "antwort 2", mid) // tiefere Antwort // reply_count bubblet bis zum Root: Root = 2 (ganzer Teilbaum), mid = 1. if tv := getThread(t, srv, root); tv.Entry.ReplyCount != 2 { t.Fatalf("root reply_count: erwartet 2, bekam %d", tv.Entry.ReplyCount) } rootThread := getThread(t, srv, root) if len(rootThread.Replies) != 1 || rootThread.Replies[0].PID != mid { t.Fatalf("root replies: erwartet [%d], bekam %+v", mid, rootThread.Replies) } if len(rootThread.Ancestors) != 0 { t.Fatalf("root ancestors: erwartet leer, bekam %+v", rootThread.Ancestors) } midThread := getThread(t, srv, mid) if midThread.Entry.ReplyCount != 1 { t.Fatalf("mid reply_count: erwartet 1, bekam %d", midThread.Entry.ReplyCount) } if len(midThread.Ancestors) != 1 || midThread.Ancestors[0].PID != root { t.Fatalf("mid ancestors: erwartet [%d], bekam %+v", root, midThread.Ancestors) } // Hauptfeed: nur der Root, keine Antworten. resp, _ := http.Get(srv.URL + "/api/entry/feed/0") var feed []struct { PID int64 `json:"pid"` } json.NewDecoder(resp.Body).Decode(&feed) resp.Body.Close() if len(feed) != 1 || feed[0].PID != root { t.Fatalf("hauptfeed: erwartet nur Root %d, bekam %+v", root, feed) } // Profil-Feed: alle drei Beiträge (Root + Antworten). resp, _ = http.Get(srv.URL + "/api/u/alice/feed/0") var profile []struct { PID int64 `json:"pid"` } json.NewDecoder(resp.Body).Decode(&profile) resp.Body.Close() if len(profile) != 3 { t.Fatalf("profil-feed: erwartet 3 Beiträge, bekam %d", len(profile)) } } func TestEditEntry(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "original") editURL := fmt.Sprintf("%s/api/entry/%d/edit", srv.URL, pid) // Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar. resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("edit: erwartet 200, bekam %d", resp.StatusCode) } if tv := getThread(t, srv, pid); tv.Entry.Content != "korrigiert" { t.Fatalf("inhalt nach edit: %q", tv.Entry.Content) } // Leerer Inhalt -> 400. resp = postForm(t, alice, editURL, url.Values{"content": {" "}}) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("leerer edit: erwartet 400, bekam %d", resp.StatusCode) } // Fremder -> 403. resp = postForm(t, bob, editURL, url.Values{"content": {"hijack"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("fremd-edit: erwartet 403, bekam %d", resp.StatusCode) } // Unbekannte pid -> 404. resp = postForm(t, alice, srv.URL+"/api/entry/999999/edit", url.Values{"content": {"x"}}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode) } // Nach Soft-Delete ist der Beitrag nicht mehr bearbeitbar (uid=0) -> 403. postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close() resp = postForm(t, alice, editURL, url.Values{"content": {"wieder da"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("edit nach delete: erwartet 403, bekam %d", resp.StatusCode) } } // latestOf beschreibt Latest eines Beitrags knapp für Vergleiche und // Fehlermeldungen: "pid/skipped" oder "-". func latestOf(e threadEntry) string { if e.Latest == nil { return "-" } return fmt.Sprintf("%d/%d", e.Latest.Entry.PID, e.Latest.Skipped) } func TestLatestDescendant(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") // root ── a ── b ── c // └─ d (d vor b und c erstellt) root := createEntry(t, alice, srv, "wurzel") a := createReply(t, alice, srv, "a", root) d := createReply(t, alice, srv, "d", root) b := createReply(t, alice, srv, "b", a) c := createReply(t, alice, srv, "c", b) feed := func() []threadEntry { resp, err := http.Get(srv.URL + "/api/entry/feed/0") if err != nil { t.Fatal(err) } defer resp.Body.Close() var f []threadEntry json.NewDecoder(resp.Body).Decode(&f) return f } // Feed: Root mit c, dazwischen a und b. if f := feed(); len(f) != 1 || latestOf(f[0]) != fmt.Sprintf("%d/2", c) { t.Fatalf("feed latest: erwartet %d/2, bekam %s", c, latestOf(f[0])) } // Antworten auf Root: a mit c (b dazwischen), d ohne Nachfahren. want := map[int64]string{a: fmt.Sprintf("%d/1", c), d: "-"} for _, r := range getThread(t, srv, root).Replies { if got := latestOf(r); got != want[r.PID] { t.Fatalf("reply %d latest: erwartet %s, bekam %s", r.PID, want[r.PID], got) } } // Direkte Antwort: skipped 0. if r := getThread(t, srv, a).Replies; len(r) != 1 || latestOf(r[0]) != fmt.Sprintf("%d/0", c) { t.Fatalf("b latest: erwartet %d/0, bekam %+v", c, r) } // Gelöschter jüngster Beitrag wird übersprungen -> nächstjüngerer (b). resp := postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, c), url.Values{}) resp.Body.Close() if f := feed(); latestOf(f[0]) != fmt.Sprintf("%d/1", b) { t.Fatalf("feed latest nach delete: erwartet %d/1, bekam %s", b, latestOf(f[0])) } } func TestSoftDelete(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") root := createEntry(t, alice, srv, "wurzel") reply := createReply(t, alice, srv, "antwort", root) delURL := fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, root) // Fremder darf nicht löschen. resp := postForm(t, bob, delURL, url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("fremd-delete: erwartet 403, bekam %d", resp.StatusCode) } // Eigentümer löscht den Root weich. resp = postForm(t, alice, delURL, url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode) } // Root bleibt als [deleted]-Platzhalter; die Antwort verwaist nicht. tv := getThread(t, srv, root) if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" { t.Fatalf("deleted entry nicht anonymisiert: %+v", tv.Entry) } if len(tv.Replies) != 1 || tv.Replies[0].PID != reply { t.Fatalf("antwort verwaist: %+v", tv.Replies) } // Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar). feedResp, _ := http.Get(srv.URL + "/api/entry/feed/0") var feed []threadEntry json.NewDecoder(feedResp.Body).Decode(&feed) feedResp.Body.Close() if len(feed) != 1 || feed[0].PID != root || feed[0].Deleted != 1 { t.Fatalf("hauptfeed nach delete: %+v", feed) } // Nicht existierender Beitrag -> 404. resp = postForm(t, alice, srv.URL+"/api/entry/999999/delete", url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode) } } func TestRenameUser(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") registerAndLogin(t, srv, "bob") // Zu kurz -> 400. resp := postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"ab"}}) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode) } // Bereits vergeben -> 409. resp = postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"bob"}}) resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode) } // Gültig -> 200 + neuer Name. resp = postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"alice2"}}) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("rename: erwartet 200, bekam %d", resp.StatusCode) } var out struct { Username string `json:"username"` } json.NewDecoder(resp.Body).Decode(&out) if out.Username != "alice2" { t.Fatalf("erwarteter Name alice2, bekam %q", out.Username) } // Profil unter dem neuen Namen erreichbar, alter Name weg. r2, _ := http.Get(srv.URL + "/api/u/alice2/info") r2.Body.Close() if r2.StatusCode != http.StatusOK { t.Fatalf("/u/alice2/info: erwartet 200, bekam %d", r2.StatusCode) } r3, _ := http.Get(srv.URL + "/api/u/alice/info") r3.Body.Close() if r3.StatusCode != http.StatusNotFound { t.Fatalf("/u/alice/info: erwartet 404, bekam %d", r3.StatusCode) } } func TestDeleteUser(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") // alice schreibt einen Beitrag, bob antwortet darauf. root := createEntry(t, alice, srv, "alices beitrag") reply := createReply(t, bob, srv, "bobs antwort", root) // Falsches Passwort -> 403. resp := postForm(t, alice, srv.URL+"/api/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+"/api/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+"/api/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) } // alices Beitrag bleibt als [deleted]-Platzhalter; bobs Antwort verwaist nicht. tv := getThread(t, srv, root) if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" { t.Fatalf("beitrag nach konto-löschung nicht soft-deleted: %+v", tv.Entry) } if len(tv.Replies) != 1 || tv.Replies[0].PID != reply { t.Fatalf("bobs antwort verwaist: %+v", tv.Replies) } } // postFormFrom schickt wie postForm als API-Client (Accept: application/json), // zusätzlich mit gesetzter Client-IP. // postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP // überschreibt damit r.RemoteAddr, sodass blockTOR die simulierte Quell-IP sieht. func postFormFrom(t *testing.T, c *http.Client, urlStr, realIP string, form url.Values) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(form.Encode())) if err != nil { t.Fatalf("request %s: %v", urlStr, err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") req.Header.Set("X-Real-IP", realIP) resp, err := c.Do(req) if err != nil { t.Fatalf("POST %s: %v", urlStr, err) } return resp } // TestLogoutPost prüft, dass Logout als POST funktioniert (und als GET nicht // mehr existiert -- als GET wäre er per fremdem Link auslösbar, CSRF). func TestLogoutPost(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "alice") if got := getStatus(t, c, srv.URL+"/api/auth/logout"); got != http.StatusMethodNotAllowed { t.Fatalf("GET logout: erwartet 405, bekam %d", got) } if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusOK { t.Fatalf("GET logout darf nicht ausloggen, sessioninfo: %d", got) } resp := postForm(t, c, srv.URL+"/api/auth/logout", url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("POST logout: erwartet 200, bekam %d", resp.StatusCode) } if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusUnauthorized { t.Fatalf("nach Logout: erwartet 401, bekam %d", got) } } // TestAPIErrorsAreJSON prüft, dass auch Fehler außerhalb der Handler (unbekannter // Pfad, falsche Methode, Rate-Limit, Panic) als JSON mit passendem Status // hinausgehen. func TestAPIErrorsAreJSON(t *testing.T) { srv := newTestServer(t) check := func(name string, resp *http.Response, wantStatus int, wantCode string) { t.Helper() defer resp.Body.Close() if resp.StatusCode != wantStatus { t.Fatalf("%s: erwartet %d, bekam %d", name, wantStatus, resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "application/json" { t.Fatalf("%s: Content-Type %q", name, ct) } var body struct { Error apiErrorBody `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.Error.Code != wantCode { t.Fatalf("%s: erwartet code %q, bekam %+v (%v)", name, wantCode, body, err) } } resp, err := http.Get(srv.URL + "/api/gibtsnicht") if err != nil { t.Fatal(err) } check("unbekannter Pfad", resp, http.StatusNotFound, "api.not_found") resp, err = http.Get(srv.URL + "/api/entry/create") if err != nil { t.Fatal(err) } check("falsche Methode", resp, http.StatusMethodNotAllowed, "api.method_not_allowed") limited := limitByIP(1, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) for i, want := range []int{http.StatusOK, http.StatusTooManyRequests} { rec := httptest.NewRecorder() limited.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) if rec.Code != want { t.Fatalf("rate-limit Anfrage %d: erwartet %d, bekam %d", i+1, want, rec.Code) } if want == http.StatusTooManyRequests { check("rate-limit", rec.Result(), want, "rate.limited") } } rec := httptest.NewRecorder() recoverJSON(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("kaputt") })). ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) check("panic", rec.Result(), http.StatusInternalServerError, "internal") } // TestCrossOriginPostRejected prüft den CSRF-Schutz: POSTs mit fremdem // Origin-Header werden abgewiesen, same-origin und ohne Origin gehen durch. func TestCrossOriginPostRejected(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "alice") post := func(origin string) int { req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/entry/create", strings.NewReader(url.Values{"content": {"x"}}.Encode())) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") if origin != "" { req.Header.Set("Origin", origin) } resp, err := c.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } if got := post("https://boese-seite.example"); got != http.StatusForbidden { t.Fatalf("cross-origin POST: erwartet 403, bekam %d", got) } if got := post("null"); got != http.StatusForbidden { t.Fatalf("Origin null: erwartet 403, bekam %d", got) } if got := post(srv.URL); got != http.StatusCreated { t.Fatalf("same-origin POST: erwartet 201, bekam %d", got) } if got := post(""); got != http.StatusCreated { t.Fatalf("POST ohne Origin: erwartet 201, bekam %d", got) } } // TestContentTruncationRuneSafe prüft, dass überlanger Content runenweise (nicht // byteweise) gekürzt wird und gültiges UTF-8 bleibt. func TestContentTruncationRuneSafe(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") long := strings.Repeat("ä", 1005) // 2 Bytes je Rune: byteweises s[:1000] schnitte mittendrin pid := createEntry(t, alice, srv, long) tv := getThread(t, srv, pid) if !utf8.ValidString(tv.Entry.Content) { t.Fatal("gekürzter Content ist kein gültiges UTF-8") } if n := len([]rune(tv.Entry.Content)); n != 1000 { t.Fatalf("erwartete 1000 Runen, bekam %d", n) } } // TestBlockTOREntry prüft, dass Beiträge aus dem TOR-Netz (Default 10.89.0.0/16) // mit 403 abgewiesen werden, aus einem anderen Netz aber durchgehen. func TestBlockTOREntry(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "toruser") resp := postFormFrom(t, c, srv.URL+"/api/entry/create", "10.89.1.2", url.Values{"content": {"aus tor"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("TOR-Beitrag: erwartet 403, bekam %d", resp.StatusCode) } resp = postFormFrom(t, c, srv.URL+"/api/entry/create", "203.0.113.7", url.Values{"content": {"normal"}}) resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode) } }