package main import ( "bytes" "encoding/json" "fmt" "image" "image/png" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "unicode/utf8" ) // 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() 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} } // postForm spricht die Endpunkte als API-Client an (Accept: application/json, // wie das JS-Frontend). Ohne den Header liefern Endpunkte mit HTML-Zweig -- // derzeit /auth/login und /auth/newuser -- einen Redirect, dem der Client // folgt; der Test würde dann den Status der Zielseite prüfen statt den des // Endpunkts. Für den noscript-Weg gibt es postFormHTML. 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+"/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 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/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/entry/%d/delete", srv.URL, del), url.Values{}) resp.Body.Close() resp = postForm(t, alice, fmt.Sprintf("%s/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) } } // TestRepostOhneJavaScript deckt den Repost-Weg ohne JS ab: der Knopf muss im // Feed stehen, der Klick per 303 an den Beitrag zurückführen, danach gesperrt // sein -- und ein erneuter Klick im Cooldown eine HTML-Seite liefern, nicht die // JSON-Zeile, die ein Browser roh anzeigen würde. func TestRepostOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") pid := createEntry(t, alice, srv, "hebt euch") // Angemeldet: Repost-Formular mit Rücksprungziel, Knopf noch frei. _, page := getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) { t.Fatalf("kein repost-formular im feed:\n%s", excerpt(page, "interactions")) } if !strings.Contains(page, "Repost (0)") { t.Errorf("repost-zähler falsch:\n%s", excerpt(page, "bump")) } if strings.Contains(page, `class="bump" type="submit" disabled`) { t.Errorf("repost-knopf ohne bisherigen repost gesperrt:\n%s", excerpt(page, "bump")) } // Reposten -> 303 zurück an den Beitrag. resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{"return_to": {fmt.Sprintf("/feed#e%d", pid)}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("repost: erwartet 303, bekam %d", resp.StatusCode) } if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { t.Fatalf("repost: Location = %q, erwartet %q", got, want) } // Jetzt im Cooldown: Zähler hoch, Knopf gesperrt, Wartezeit sichtbar. _, page = getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, "Repost (1)") { t.Errorf("repost-zähler nicht erhöht:\n%s", excerpt(page, "bump")) } if !strings.Contains(page, `class="bump" type="submit" disabled`) { t.Errorf("repost-knopf im cooldown nicht gesperrt:\n%s", excerpt(page, "bump")) } // Erneuter Repost im Cooldown -> HTML-Fehlerseite, nicht rohes JSON. resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) cooldown := body(t, resp) if resp.StatusCode != http.StatusTooManyRequests { t.Fatalf("repost im cooldown: erwartet 429, bekam %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("repost im cooldown: erwartet HTML, bekam %q", ct) } if !strings.Contains(cooldown, "erst kürzlich repostet") { t.Errorf("keine verständliche meldung:\n%s", cooldown) } // Der JSON-Client bekommt weiterhin JSON mit Nutzdaten. resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) jsonBody := body(t, resp) if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Fatalf("API-Client: erwartet JSON, bekam %q", ct) } if !strings.Contains(jsonBody, `"retry_after"`) { t.Errorf("JSON-Antwort ohne retry_after:\n%s", jsonBody) } // Nicht angemeldet: kein Formular im Feed, POST -> 401 als HTML. _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") if strings.Contains(anonPage, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) { t.Error("repost-formular für nicht angemeldete besucher") } resp = postFormHTML(t, newClient(t), fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{}) page = body(t, resp) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("anon repost: erwartet 401, bekam %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("anon repost: erwartet HTML, bekam %q", ct) } } // 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+"/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"` } // 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/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 + "/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 + "/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/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+"/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/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) } } 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/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 + "/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+"/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+"/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+"/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+"/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 + "/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 + "/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+"/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) } // 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") resp, err := c.Get(srv.URL + "/auth/logout") if err != nil { t.Fatal(err) } resp.Body.Close() // GET fällt in den FileServer-Catch-All -> 404. Hauptsache: kein Logout. if resp.StatusCode != http.StatusNotFound { t.Fatalf("GET logout: erwartet 404, bekam %d", resp.StatusCode) } resp = postForm(t, c, srv.URL+"/auth/logout", url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("POST logout: erwartet 200, bekam %d", resp.StatusCode) } 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("nach Logout sollte loggedin=false sein") } } // 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+"/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+"/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+"/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) } } // postFormHTML schickt ein Formular wie ein Browser ohne JavaScript: kein // Accept: application/json, und Redirects werden nicht verfolgt, damit der // 303 selbst prüfbar bleibt. func postFormHTML(t *testing.T, c *http.Client, urlStr string, form url.Values) *http.Response { t.Helper() prev := c.CheckRedirect c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } defer func() { c.CheckRedirect = prev }() resp, err := c.PostForm(urlStr, form) if err != nil { t.Fatalf("POST %s: %v", urlStr, err) } return resp } func body(t *testing.T, resp *http.Response) string { t.Helper() b, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { t.Fatalf("body lesen: %v", err) } return string(b) } // TestLoginOhneJavaScript deckt den noscript-Weg ab: das Formular muss ohne JS // abrufbar und abschickbar sein, Fehler müssen in der HTML-Antwort stehen (nicht // als JSON, das ein Browser roh anzeigen würde), und der Erfolgsfall muss per // Redirect enden (Post/Redirect/Get). func TestLoginOhneJavaScript(t *testing.T) { srv := newTestServer(t) registerAndLogin(t, srv, "alice") c := newClient(t) resp, err := c.Get(srv.URL + "/auth/login") if err != nil { t.Fatalf("GET /auth/login: %v", err) } page := body(t, resp) if resp.StatusCode != http.StatusOK { t.Fatalf("login-seite: erwartet 200, bekam %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("login-seite: erwartet HTML, bekam %q", ct) } // Ohne echtes form/action wäre die Seite ohne JS nicht bedienbar. if !strings.Contains(page, `method="post"`) || !strings.Contains(page, `action="/auth/login"`) { t.Fatalf("login-seite ohne abschickbares Formular:\n%s", page) } // Falsches Passwort: Formular neu, mit Meldung und erhaltenem Nutzernamen. resp = postFormHTML(t, newClient(t), srv.URL+"/auth/login", url.Values{"user": {"alice"}, "pass": {"falsch"}}) page = body(t, resp) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("falsches passwort: erwartet 401, bekam %d", resp.StatusCode) } if !strings.Contains(page, "Nutzername oder Passwort ist falsch.") { t.Fatalf("fehlermeldung fehlt in der HTML-Antwort:\n%s", page) } if !strings.Contains(page, `value="alice"`) { t.Fatalf("nutzername nicht ins formular zurückgeschrieben:\n%s", page) } // Das Passwort darf nie zurück ins Formular. if strings.Contains(page, "falsch\"") { t.Fatalf("passwort landet im HTML:\n%s", page) } // Richtiges Passwort: 303 auf die Startseite, Session-Cookie gesetzt. anon := newClient(t) resp = postFormHTML(t, anon, srv.URL+"/auth/login", url.Values{"user": {"alice"}, "pass": {"supersecret1"}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("login ok: erwartet 303, bekam %d", resp.StatusCode) } if loc := resp.Header.Get("Location"); loc != "/" { t.Fatalf("login ok: erwartet Location /, bekam %q", loc) } var got bool for _, ck := range resp.Cookies() { if ck.Name == "session" && ck.Value != "" { got = true } } if !got { t.Fatal("login ok: kein session-cookie gesetzt") } } // TestRegistrierungOhneJavaScript spiegelt TestLoginOhneJavaScript für die // Registrierung. Zusätzlich geprüft: alle Feldfehler erscheinen auf einmal // (nicht Feld für Feld), und der Erfolgsfall loggt direkt ein -- sonst bräuchte // es ohne JS zwei Formulare hintereinander. func TestRegistrierungOhneJavaScript(t *testing.T) { srv := newTestServer(t) c := newClient(t) resp, err := c.Get(srv.URL + "/auth/register") if err != nil { t.Fatalf("GET /auth/register: %v", err) } pageHTML := body(t, resp) if resp.StatusCode != http.StatusOK { t.Fatalf("register-seite: erwartet 200, bekam %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("register-seite: erwartet HTML, bekam %q", ct) } if !strings.Contains(pageHTML, `method="post"`) || !strings.Contains(pageHTML, `action="/auth/newuser"`) { t.Fatalf("register-seite ohne abschickbares Formular:\n%s", pageHTML) } // Mehrere Fehler gleichzeitig: zu kurzer Name, zu kurzes Passwort, // abweichende Wiederholung. Alle drei müssen in der Antwort stehen. resp = postFormHTML(t, newClient(t), srv.URL+"/auth/newuser", url.Values{"user": {"ab"}, "pass1": {"kurz"}, "pass2": {"anders"}}) pageHTML = body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("ungültige registrierung: erwartet 400, bekam %d", resp.StatusCode) } for _, want := range []string{ "Der Nutzername ist zu kurz.", "Das Passwort ist kürzer als 10 Zeichen.", "Passwort und Passwortbestätigung sind ungleich.", } { if !strings.Contains(pageHTML, want) { t.Fatalf("feldfehler %q fehlt in der HTML-Antwort:\n%s", want, pageHTML) } } if !strings.Contains(pageHTML, `value="ab"`) { t.Fatalf("nutzername nicht ins formular zurückgeschrieben:\n%s", pageHTML) } for _, leak := range []string{"kurz", "anders"} { if strings.Contains(pageHTML, `value="`+leak+`"`) { t.Fatalf("passwort %q landet im HTML:\n%s", leak, pageHTML) } } // Erfolg: 303 auf die Startseite, Session-Cookie direkt gesetzt. anon := newClient(t) resp = postFormHTML(t, anon, srv.URL+"/auth/newuser", url.Values{"user": {"carol"}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("registrierung ok: erwartet 303, bekam %d", resp.StatusCode) } if loc := resp.Header.Get("Location"); loc != "/" { t.Fatalf("registrierung ok: erwartet Location /, bekam %q", loc) } var sessionSet bool for _, ck := range resp.Cookies() { if ck.Name == "session" && ck.Value != "" { sessionSet = true } } if !sessionSet { t.Fatal("registrierung ok: kein session-cookie -- ohne JS wäre ein zweites Formular nötig") } // Der Cookie muss auch wirklich tragen: der Client ist jetzt eingeloggt. resp, err = anon.Get(srv.URL + "/auth/headerbar") if err != nil { t.Fatal(err) } var h struct { LoggedIn bool `json:"loggedin"` } json.NewDecoder(resp.Body).Decode(&h) resp.Body.Close() if !h.LoggedIn { t.Fatal("nach registrierung ohne JS nicht eingeloggt") } // Bereits vergebener Name -> Formular neu mit Meldung, kein 500. resp = postFormHTML(t, newClient(t), srv.URL+"/auth/newuser", url.Values{"user": {"carol"}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"}}) pageHTML = body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("doppelter name: erwartet 400, bekam %d", resp.StatusCode) } if !strings.Contains(pageHTML, "Der Nutzername wird bereits verwendet.") { t.Fatalf("meldung zum vergebenen namen fehlt:\n%s", pageHTML) } } // getHTML holt eine Seite als Browser ohne JavaScript. func getHTML(t *testing.T, c *http.Client, urlStr string) (int, string) { t.Helper() resp, err := c.Get(urlStr) if err != nil { t.Fatalf("GET %s: %v", urlStr, err) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { resp.Body.Close() t.Fatalf("GET %s: erwartet HTML, bekam %q", urlStr, ct) } return resp.StatusCode, body(t, resp) } // TestFeedOhneJavaScript deckt den serverseitig gerenderten Feed ab: Beiträge // müssen ohne JS sichtbar sein, geblättert werden können und Nutzerinhalt darf // kein Markup einschleusen. func TestFeedOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") // Mehr als eine Seite füllen. for i := 0; i < feedPageSize+5; i++ { createEntry(t, alice, srv, fmt.Sprintf("beitrag %d", i)) } anon := newClient(t) status, page1 := getHTML(t, anon, srv.URL+"/feed") if status != http.StatusOK { t.Fatalf("/feed: erwartet 200, bekam %d", status) } if !strings.Contains(page1, "Seite 1 von 2") { t.Errorf("seitenanzeige fehlt oder falsch:\n%s", excerpt(page1, "pager")) } // Ohne Weiter-Link ist der Rest des Feeds ohne JS unerreichbar. if !strings.Contains(page1, `href="/feed?page=1"`) { t.Errorf("kein Link auf die nächste Seite:\n%s", excerpt(page1, "pager")) } if strings.Contains(page1, "← Neuere") { t.Error("Zurück-Link auf der ersten Seite") } // Jeder Beitrag braucht einen Anker, damit Aktionen später dorthin // zurückspringen können. if !strings.Contains(page1, `class="card" id="e`) { t.Errorf("beiträge ohne id-anker:\n%s", excerpt(page1, "card")) } status, page2 := getHTML(t, anon, srv.URL+"/feed?page=1") if status != http.StatusOK { t.Fatalf("/feed?page=1: erwartet 200, bekam %d", status) } if !strings.Contains(page2, "Seite 2 von 2") { t.Errorf("zweite seite falsch gezählt:\n%s", excerpt(page2, "pager")) } if !strings.Contains(page2, `href="/feed"`) { t.Error("kein Link zurück auf die erste Seite") } if strings.Contains(page2, "Ältere →") { t.Error("Weiter-Link auf der letzten Seite") } // Seite jenseits des Endes fällt auf die letzte zurück, statt leer zu sein. if _, beyond := getHTML(t, anon, srv.URL+"/feed?page=99"); !strings.Contains(beyond, "Seite 2 von 2") { t.Errorf("seite jenseits des endes:\n%s", excerpt(beyond, "pager")) } // Nutzerfeed mit demselben Aufbau. status, userPage := getHTML(t, anon, srv.URL+"/u/alice/beitraege") if status != http.StatusOK { t.Fatalf("/u/alice/beitraege: erwartet 200, bekam %d", status) } if !strings.Contains(userPage, "Beiträge von alice") { t.Error("nutzerfeed ohne Überschrift") } if !strings.Contains(userPage, `href="/u/alice/beitraege?page=1"`) { t.Error("nutzerfeed: Blätter-Link verliert den Pfad") } if status, _ := getHTML(t, anon, srv.URL+"/u/gibtsnicht/beitraege"); status != http.StatusNotFound { t.Errorf("unbekannter nutzer: erwartet 404, bekam %d", status) } } // TestFeedEscapesUserContent stellt sicher, dass Beitragstext kein Markup // einschleusen kann. Der JS-Pfad hat dafür escapeHtml (feed.js); der // SSR-Pfad braucht denselben Schutz eigenständig. func TestFeedEscapesUserContent(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") createEntry(t, alice, srv, ` & "zitat" 'apos' https://example.com/x?a=1&b=2`) _, page := getHTML(t, newClient(t), srv.URL+"/feed") for _, bad := range []string{"", `alert("xss")`} { if strings.Contains(page, bad) { t.Errorf("unescaptes %q im HTML:\n%s", bad, excerpt(page, "content")) } } if !strings.Contains(page, "<script>") { t.Errorf("inhalt fehlt oder anders escaped:\n%s", excerpt(page, "content")) } // Links werden erzeugt -- aber nur mit http/https-Schema. if !strings.Contains(page, ``) { t.Errorf("link nicht oder falsch erzeugt:\n%s", excerpt(page, "content")) } } // excerpt schneidet einen Ausschnitt um das erste Vorkommen von marker heraus, // damit Fehlermeldungen nicht die ganze Seite ausgeben. func excerpt(s, marker string) string { i := strings.Index(s, marker) if i < 0 { return s } start := max(0, i-120) end := min(len(s), i+400) return "..." + s[start:end] + "..." } // TestVoteOhneJavaScript prüft den Abstimmungs-Weg ohne JS: das Formular muss // im Feed stehen, der Klick per 303 an genau den Beitrag zurückführen, und der // neue Stand danach in der Seite stehen. func TestVoteOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") pid := createEntry(t, alice, srv, "stimm ab") // Angemeldet: Formular mit beiden Knöpfen und Rücksprungziel. _, page := getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/vote"`, pid)) { t.Fatalf("kein vote-formular im feed:\n%s", excerpt(page, "interactions")) } if !strings.Contains(page, fmt.Sprintf(`name="return_to" value="/feed#e%d"`, pid)) { t.Errorf("return_to fehlt oder zeigt woanders hin:\n%s", excerpt(page, "vote")) } if !strings.Contains(page, `name="mode" value="left"`) || !strings.Contains(page, `name="mode" value="right"`) { t.Errorf("vote-knöpfe unvollständig:\n%s", excerpt(page, "vote")) } // Abstimmen -> 303 zurück an den Beitrag. resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), url.Values{"mode": {"left"}, "return_to": {fmt.Sprintf("/feed#e%d", pid)}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("vote: erwartet 303, bekam %d", resp.StatusCode) } if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { t.Fatalf("vote: Location = %q, erwartet %q", got, want) } // Der Stand steht jetzt in der Seite, die Stimme ist markiert. _, page = getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, "1 / 0") { t.Errorf("zähler nicht aktualisiert:\n%s", excerpt(page, "interactions")) } if !strings.Contains(page, `class="ghost selected"`) { t.Errorf("eigene stimme nicht markiert:\n%s", excerpt(page, "vote")) } // Ein manipuliertes Ziel darf nicht im Location landen. resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), url.Values{"mode": {"right"}, "return_to": {"//evil.example"}}) resp.Body.Close() if got := resp.Header.Get("Location"); got != "/feed" { t.Errorf("manipuliertes return_to: Location = %q, erwartet /feed", got) } // Ohne Anmeldung: kein Formular, aber der Stand ist sichtbar. _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") if strings.Contains(anonPage, fmt.Sprintf(`action="/entry/%d/vote"`, pid)) { t.Error("vote-formular für nicht angemeldete besucher") } if !strings.Contains(anonPage, "anmelden zum Abstimmen") { t.Errorf("kein hinweis auf die anmeldung:\n%s", excerpt(anonPage, "interactions")) } } // TestReportOhneJavaScript prüft den Meldeweg ohne JS. Der Grund bleibt // optional (nur die Möglichkeit muss bestehen), und die Bestätigung ist eine // eigene Seite -- ein stiller Rücksprung wäre von "nichts passiert" nicht zu // unterscheiden. func TestReportOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") pid := createEntry(t, alice, srv, "melde mich") anon := newClient(t) _, page := getHTML(t, anon, srv.URL+"/feed") if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/report"`, pid)) { t.Fatalf("kein melde-formular im feed:\n%s", excerpt(page, "report")) } //
klappt ohne JavaScript auf -- ohne das wäre das Feld unerreichbar. if !strings.Contains(page, `
`) { t.Errorf("melde-formular nicht aufklappbar:\n%s", excerpt(page, "report")) } // Die Grund-Eingabe muss angeboten werden, darf aber nicht erzwungen sein. if !strings.Contains(page, `name="reason"`) { t.Errorf("kein freitextfeld für den grund:\n%s", excerpt(page, "report")) } if strings.Contains(excerpt(page, `name="reason"`), "required") { t.Errorf("grund ist pflicht, soll aber optional sein:\n%s", excerpt(page, "report")) } // Melden mit Grund -> 303 auf die Bestätigungsseite. back := fmt.Sprintf("/feed#e%d", pid) resp := postFormHTML(t, anon, fmt.Sprintf("%s/entry/%d/report", srv.URL, pid), url.Values{"reason": {"Spam & \"Werbung\""}, "return_to": {back}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("report: erwartet 303, bekam %d", resp.StatusCode) } loc := resp.Header.Get("Location") if !strings.HasPrefix(loc, fmt.Sprintf("/entry/%d/gemeldet?", pid)) { t.Fatalf("report: Location = %q, erwartet Bestätigungsseite", loc) } status, done := getHTML(t, anon, srv.URL+loc) if status != http.StatusOK { t.Fatalf("bestätigungsseite: erwartet 200, bekam %d", status) } if !strings.Contains(done, "Meldung eingegangen") { t.Errorf("keine bestätigung:\n%s", done) } // Der Rückweg ist ein Link, kein automatischer Sprung. if !strings.Contains(done, fmt.Sprintf(`href="/feed#e%d"`, pid)) { t.Errorf("kein rückweg zum beitrag:\n%s", excerpt(done, "report-done")) } // Der Grund wird angezeigt und dabei escaped. if !strings.Contains(done, "Spam & "Werbung"") { t.Errorf("grund fehlt oder ist nicht escaped:\n%s", excerpt(done, "report-done")) } // Melden ohne Grund bleibt möglich. resp = postFormHTML(t, anon, fmt.Sprintf("%s/entry/%d/report", srv.URL, pid), url.Values{"return_to": {back}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("report ohne grund: erwartet 303, bekam %d", resp.StatusCode) } _, done = getHTML(t, anon, srv.URL+resp.Header.Get("Location")) if !strings.Contains(done, "keinen Grund angegeben") { t.Errorf("fehlender grund nicht benannt:\n%s", excerpt(done, "report-done")) } } // postMultipartHTML schickt ein Formular mit Datei wie ein Browser ohne // JavaScript. Ohne enctype=multipart/form-data käme beim Server nur der // Dateiname an -- deshalb prüft der Test den echten Weg. func postMultipartHTML(t *testing.T, c *http.Client, urlStr string, fields map[string]string, fileField, fileName string, fileData []byte) *http.Response { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) for k, v := range fields { if err := mw.WriteField(k, v); err != nil { t.Fatal(err) } } if fileField != "" { part, err := mw.CreateFormFile(fileField, fileName) if err != nil { t.Fatal(err) } if _, err := part.Write(fileData); err != nil { t.Fatal(err) } } if err := mw.Close(); err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, urlStr, &buf) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", mw.FormDataContentType()) prev := c.CheckRedirect c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } defer func() { c.CheckRedirect = prev }() resp, err := c.Do(req) if err != nil { t.Fatalf("POST %s: %v", urlStr, err) } return resp } // pngBytes baut ein winziges gültiges PNG (1x1), damit storeImage etwas zu // dekodieren hat. func pngBytes(t *testing.T) []byte { t.Helper() var buf bytes.Buffer img := image.NewRGBA(image.Rect(0, 0, 1, 1)) if err := png.Encode(&buf, img); err != nil { t.Fatal(err) } return buf.Bytes() } // TestBeitragOhneJavaScript deckt das Schreiben ohne JS ab: Formular im Feed, // Text und Bild per multipart, Rücksprung zum neuen Beitrag, und im Fehlerfall // der Feed mit erhaltenem Text. func TestBeitragOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") // Angemeldet: Formular ist da und kann Dateien schicken. _, page := getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, `action="/entry/create"`) { t.Fatalf("kein beitragsformular im feed:\n%s", excerpt(page, "compose")) } if !strings.Contains(page, `enctype="multipart/form-data"`) { t.Errorf("formular ohne enctype -- upload käme nie an:\n%s", excerpt(page, "compose")) } if !strings.Contains(page, `type="file"`) { t.Errorf("kein dateifeld:\n%s", excerpt(page, "compose")) } // Das Formular trägt den Rücksprungort mit, damit ein abgelehnter Post ihn // nicht verliert und die neu gerenderten Knöpfe auf "/feed" zeigen statt "/". if !strings.Contains(page, `name="return_to" value="/feed"`) { t.Errorf("beitragsformular ohne return_to:\n%s", excerpt(page, "compose")) } // Nicht angemeldet: kein Formular. _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") if strings.Contains(anonPage, `action="/entry/create"`) { t.Error("beitragsformular für nicht angemeldete besucher") } // Beitrag mit Text und Bild -> 303 auf den neuen Beitrag. resp := postMultipartHTML(t, alice, srv.URL+"/entry/create", map[string]string{"content": "mein erster ssr-beitrag"}, "file", "bild.png", pngBytes(t)) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("beitrag anlegen: erwartet 303, bekam %d", resp.StatusCode) } loc := resp.Header.Get("Location") if !strings.HasPrefix(loc, "/feed#e") { t.Fatalf("Location = %q, erwartet /feed#e", loc) } // Der Beitrag steht im Feed, mit Bild und unter dem Anker aus dem Redirect. _, page = getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, "mein erster ssr-beitrag") { t.Errorf("neuer beitrag fehlt im feed:\n%s", excerpt(page, "content")) } anchor := strings.TrimPrefix(loc, "/feed#") if !strings.Contains(page, `id="`+anchor+`"`) { t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor) } if !strings.Contains(page, ` Feed mit Meldung und erhaltenem Text, kein Redirect. // return_to wie vom Formular mitgeschickt. resp = postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {" "}, "return_to": {"/feed"}, "page": {"0"}}) failPage := body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("leerer beitrag: erwartet 400, bekam %d", resp.StatusCode) } if !strings.Contains(failPage, "Leerer Beitrag!") { t.Errorf("fehlermeldung fehlt:\n%s", excerpt(failPage, "compose")) } // Der Feed muss weiterhin dastehen -- nicht nur eine nackte Fehlerseite. if !strings.Contains(failPage, "mein erster ssr-beitrag") { t.Errorf("feed nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "feed")) } // Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Feed, nicht // auf "/" (die noch per JS bediente Startseite). if strings.Contains(failPage, `name="return_to" value="/#e`) { t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote")) } if !strings.Contains(failPage, `name="return_to" value="/feed#e`) { t.Errorf("interaktions-knöpfe ohne feed-rücksprung:\n%s", excerpt(failPage, "vote")) } // Getippter Text überlebt einen abgelehnten Upload. resp = postMultipartHTML(t, alice, srv.URL+"/entry/create", map[string]string{"content": "text der nicht verloren gehen darf"}, "file", "kaputt.png", []byte("das ist kein bild")) failPage = body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("kaputtes bild: erwartet 400, bekam %d", resp.StatusCode) } if !strings.Contains(failPage, "Bild konnte nicht verarbeitet werden.") { t.Errorf("meldung zum bild fehlt:\n%s", excerpt(failPage, "compose")) } if !strings.Contains(failPage, "text der nicht verloren gehen darf") { t.Errorf("getippter text ging verloren:\n%s", excerpt(failPage, "compose")) } // Dateifelder lassen sich nicht vorbelegen -- darauf muss hingewiesen werden. if !strings.Contains(failPage, "erneut auswählen") { t.Errorf("kein hinweis zum erneuten auswählen des bildes:\n%s", excerpt(failPage, "compose")) } } // mediaPathFrom liest den Pfad des ersten eingebetteten Uploads aus einer // gerenderten Seite, damit der Test seine Datei wieder entfernen kann. func mediaPathFrom(page string) string { const marker = ``) { t.Error("feed über TOR nicht sichtbar") } } // TestThreadOhneJavaScript deckt die Thread-Ansicht ab: Ahnenkette, der // Beitrag selbst, Antworten und das Antwortformular müssen ohne JS erreichbar // sein -- ohne diese Seite ist Antworten ohne JavaScript unmöglich. func TestThreadOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") root := createEntry(t, alice, srv, "wurzelbeitrag") mitte := replyTo(t, alice, srv, root, "mittlere antwort") anon := newClient(t) status, page := getHTML(t, anon, fmt.Sprintf("%s/beitrag/%d", srv.URL, mitte)) if status != http.StatusOK { t.Fatalf("/beitrag/%d: erwartet 200, bekam %d", mitte, status) } // Ahnenkette: der Wurzelbeitrag steht als Kontext über dem fokussierten. if !strings.Contains(page, "wurzelbeitrag") { t.Errorf("ahnenkette fehlt:\n%s", excerpt(page, "ancestors")) } if !strings.Contains(page, "mittlere antwort") { t.Errorf("fokussierter beitrag fehlt:\n%s", excerpt(page, "focus")) } // Der Weg nach oben muss ein echter Link sein. if !strings.Contains(page, fmt.Sprintf(`href="/beitrag/%d"`, root)) { t.Errorf("kein link zum elternbeitrag:\n%s", excerpt(page, "ancestors")) } // Antwortformular: nur angemeldet, mit reply_to auf den fokussierten Beitrag. if strings.Contains(page, `action="/entry/create"`) { t.Error("antwortformular für nicht angemeldete besucher") } _, page = getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, mitte)) if !strings.Contains(page, fmt.Sprintf(`name="reply_to" value="%d"`, mitte)) { t.Fatalf("antwortformular ohne reply_to:\n%s", excerpt(page, "compose")) } // Das Antwortformular trägt den Rücksprung in den Thread mit. if !strings.Contains(page, fmt.Sprintf(`name="return_to" value="/beitrag/%d"`, mitte)) { t.Errorf("antwortformular ohne return_to:\n%s", excerpt(page, "compose")) } // Antworten ohne JS -> 303 in den Thread des Elternbeitrags. resp := postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {"meine antwort"}, "reply_to": {fmt.Sprint(mitte)}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("antwort: erwartet 303, bekam %d", resp.StatusCode) } loc := resp.Header.Get("Location") if !strings.HasPrefix(loc, fmt.Sprintf("/beitrag/%d#e", mitte)) { t.Fatalf("antwort: Location = %q, erwartet /beitrag/%d#e", loc, mitte) } // Die Antwort steht im Thread, unter dem Anker aus dem Redirect. _, page = getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, mitte)) if !strings.Contains(page, "meine antwort") { t.Errorf("antwort fehlt im thread:\n%s", excerpt(page, "replies")) } anchor := loc[strings.Index(loc, "#")+1:] if !strings.Contains(page, `id="`+anchor+`"`) { t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor) } // Fehlerfall: leere Antwort -> Thread neu, nicht der Feed. return_to wie // vom Formular mitgeschickt. resp = postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)}, "return_to": {fmt.Sprintf("/beitrag/%d", mitte)}}) failPage := body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("leere antwort: erwartet 400, bekam %d", resp.StatusCode) } if !strings.Contains(failPage, "Leerer Beitrag!") { t.Errorf("fehlermeldung fehlt:\n%s", excerpt(failPage, "compose")) } if !strings.Contains(failPage, "mittlere antwort") { t.Errorf("thread nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "focus")) } // Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Thread, // nicht auf "/". if strings.Contains(failPage, `name="return_to" value="/#e`) { t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote")) } if !strings.Contains(failPage, fmt.Sprintf(`name="return_to" value="/beitrag/%d#e`, mitte)) { t.Errorf("interaktions-knöpfe ohne thread-rücksprung:\n%s", excerpt(failPage, "vote")) } // Unbekannter Beitrag -> 404, keine leere Seite. if status, _ := getHTML(t, anon, srv.URL+"/beitrag/999999"); status != http.StatusNotFound { t.Errorf("unbekannter beitrag: erwartet 404, bekam %d", status) } } // TestThreadGeloeschterBeitrag prüft, dass ein soft-gelöschter Beitrag den // Thread zusammenhält, aber kein Antwortformular mehr anbietet. func TestThreadGeloeschterBeitrag(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") root := createEntry(t, alice, srv, "wird geloescht") replyTo(t, alice, srv, root, "haengt daran") resp := postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, root), url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("löschen: status %d", resp.StatusCode) } status, page := getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, root)) if status != http.StatusOK { t.Fatalf("gelöschter beitrag: erwartet 200, bekam %d", status) } if !strings.Contains(page, "[deleted]") { t.Errorf("kein platzhalter für den gelöschten beitrag:\n%s", excerpt(page, "focus")) } if !strings.Contains(page, "haengt daran") { t.Errorf("antworten am gelöschten beitrag nicht mehr erreichbar:\n%s", excerpt(page, "replies")) } if strings.Contains(page, `action="/entry/create"`) { t.Error("antwortformular an einem gelöschten beitrag") } } // replyTo erstellt eine Antwort und liefert deren pid. func replyTo(t *testing.T, c *http.Client, srv *httptest.Server, parent int64, content string) int64 { t.Helper() resp := postForm(t, c, srv.URL+"/entry/create", url.Values{"content": {content}, "reply_to": {fmt.Sprint(parent)}}) defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("reply: status %d", resp.StatusCode) } var out struct { PID int64 `json:"pid"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode reply: %v", err) } return out.PID } // TestBearbeitenOhneJavaScript deckt das Bearbeiten ohne JS ab. Anders als beim // Erstellen ist der Ausgangszustand nicht leer: das Formular muss den // bisherigen Text tragen, und der Rückweg muss dorthin führen, wo der // Bearbeiten-Link stand -- nicht auf die Bearbeitungsseite selbst. func TestBearbeitenOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "urspruenglicher text") // Der Bearbeiten-Link steht nur am eigenen Beitrag. _, page := getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, fmt.Sprintf("/beitrag/%d/bearbeiten", pid)) { t.Fatalf("kein bearbeiten-link am eigenen beitrag:\n%s", excerpt(page, "interactions")) } _, bobPage := getHTML(t, bob, srv.URL+"/feed") if strings.Contains(bobPage, "/bearbeiten") { t.Error("bearbeiten-link an fremdem beitrag") } _, anonPage := getHTML(t, newClient(t), srv.URL+"/feed") if strings.Contains(anonPage, "/bearbeiten") { t.Error("bearbeiten-link für nicht angemeldete besucher") } // Das Formular trägt den bisherigen Text und einen Rückweg in den Feed. editPage := fmt.Sprintf("%s/beitrag/%d/bearbeiten?return_to=%s", srv.URL, pid, url.QueryEscape(fmt.Sprintf("/feed#e%d", pid))) status, form := getHTML(t, alice, editPage) if status != http.StatusOK { t.Fatalf("bearbeitungsseite: erwartet 200, bekam %d", status) } if !strings.Contains(form, "urspruenglicher text") { t.Errorf("bisheriger text fehlt im formular:\n%s", excerpt(form, "textarea")) } if !strings.Contains(form, fmt.Sprintf(`value="/feed#e%d"`, pid)) { t.Errorf("rückweg nicht übernommen:\n%s", excerpt(form, "return_to")) } // Ohne Abbrechen-Weg wäre die Seite ohne JS eine Sackgasse. if !strings.Contains(form, "Abbrechen") { t.Errorf("kein abbrechen-link:\n%s", excerpt(form, "compose")) } // Fremder Beitrag -> 403 als HTML-Seite, nicht als JSON. status, denied := getHTML(t, bob, fmt.Sprintf("%s/beitrag/%d/bearbeiten", srv.URL, pid)) if status != http.StatusForbidden { t.Fatalf("fremde bearbeitungsseite: erwartet 403, bekam %d", status) } if !strings.Contains(denied, "Das ist nicht dein Beitrag.") { t.Errorf("keine verständliche meldung:\n%s", denied) } // Speichern -> 303 auf den mitgegebenen Rückweg. resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid), url.Values{"content": {"geaenderter text"}, "return_to": {fmt.Sprintf("/feed#e%d", pid)}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("speichern: erwartet 303, bekam %d", resp.StatusCode) } if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { t.Fatalf("speichern: Location = %q, erwartet %q", got, want) } _, page = getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, "geaenderter text") || strings.Contains(page, "urspruenglicher text") { t.Errorf("änderung nicht übernommen:\n%s", excerpt(page, "content")) } // Leerer Text -> Formular neu, mit der abgelehnten Fassung statt des // gespeicherten Stands. resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid), url.Values{"content": {" "}, "return_to": {fmt.Sprintf("/feed#e%d", pid)}}) failPage := body(t, resp) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("leerer text: erwartet 400, bekam %d", resp.StatusCode) } if !strings.Contains(failPage, "Inhalt darf nicht leer sein.") { t.Errorf("fehlermeldung fehlt:\n%s", excerpt(failPage, "compose")) } if !strings.Contains(failPage, fmt.Sprintf(`action="/entry/%d/edit"`, pid)) { t.Errorf("kein bearbeitungsformular nach dem fehler:\n%s", excerpt(failPage, "compose")) } if !strings.Contains(failPage, fmt.Sprintf(`value="/feed#e%d"`, pid)) { t.Errorf("rückweg nach dem fehler verloren:\n%s", excerpt(failPage, "return_to")) } } // TestAbgelaufeneSessionZeigtHTML deckt den Fehler ab, den der Bearbeiten-Weg // ans Licht gebracht hat: requireAuth antwortete immer mit JSON. Ohne // JavaScript bekäme ein Nutzer mit abgelaufener Session eine rohe JSON-Zeile // im Browser zu sehen. func TestAbgelaufeneSessionZeigtHTML(t *testing.T) { srv := newTestServer(t) anon := newClient(t) resp := postFormHTML(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}}) page := body(t, resp) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("erwartet 401, bekam %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("erwartet HTML, bekam %q -- rohes JSON im Browser", ct) } if !strings.Contains(page, "Nicht angemeldet") { t.Errorf("keine verständliche meldung:\n%s", page) } if !strings.Contains(page, `href="/feed"`) { t.Errorf("kein weg zurück von der fehlerseite:\n%s", page) } // Der JSON-Client bekommt weiterhin JSON. resp = postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}}) defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Fatalf("API-Client: erwartet JSON, bekam %q", ct) } } // TestLoeschenOhneJavaScript deckt das Löschen ohne JS ab. Kernpunkt: der Link // im Feed darf nichts löschen, sondern nur fragen -- ohne JavaScript gibt es // kein confirm(), und einem GET-Link folgen auch Linkvorschauen und Crawler. func TestLoeschenOhneJavaScript(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "wird gleich geloescht") replyTo(t, alice, srv, pid, "antwort bleibt") // Der Löschen-Link steht nur am eigenen Beitrag. _, page := getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, fmt.Sprintf("/beitrag/%d/loeschen", pid)) { t.Fatalf("kein löschen-link am eigenen beitrag:\n%s", excerpt(page, "interactions")) } _, bobPage := getHTML(t, bob, srv.URL+"/feed") if strings.Contains(bobPage, "/loeschen") { t.Error("löschen-link an fremdem beitrag") } // Der Link führt auf eine Frage, nicht auf die Tat. confirmURL := fmt.Sprintf("%s/beitrag/%d/loeschen?return_to=%s", srv.URL, pid, url.QueryEscape(fmt.Sprintf("/feed#e%d", pid))) status, confirm := getHTML(t, alice, confirmURL) if status != http.StatusOK { t.Fatalf("bestätigungsseite: erwartet 200, bekam %d", status) } if !strings.Contains(confirm, "wird gleich geloescht") { t.Errorf("beitrag wird nicht gezeigt:\n%s", excerpt(confirm, "confirm-delete")) } if !strings.Contains(confirm, "nicht rückgängig") { t.Errorf("kein hinweis auf die endgültigkeit:\n%s", excerpt(confirm, "confirm-delete")) } if !strings.Contains(confirm, `method="post"`) { t.Errorf("löschen nicht als POST:\n%s", excerpt(confirm, "confirm-delete")) } if !strings.Contains(confirm, "Abbrechen") { t.Errorf("kein abbrechen-weg:\n%s", excerpt(confirm, "confirm-delete")) } // Entscheidend: das Aufrufen der Bestätigungsseite hat nichts gelöscht. _, page = getHTML(t, alice, srv.URL+"/feed") if !strings.Contains(page, "wird gleich geloescht") { t.Fatal("der bestätigungs-GET hat den beitrag bereits gelöscht") } // Fremder Beitrag -> 403 als HTML. if status, _ := getHTML(t, bob, fmt.Sprintf("%s/beitrag/%d/loeschen", srv.URL, pid)); status != http.StatusForbidden { t.Errorf("fremde bestätigungsseite: erwartet 403, bekam %d", status) } // Bestätigen -> 303 auf den Rückweg. resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, pid), url.Values{"return_to": {fmt.Sprintf("/feed#e%d", pid)}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("löschen: erwartet 303, bekam %d", resp.StatusCode) } if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want { t.Fatalf("löschen: Location = %q, erwartet %q", got, want) } // Platzhalter statt Inhalt, Antwort bleibt erreichbar, keine Aktionen mehr. _, page = getHTML(t, alice, srv.URL+"/feed") if strings.Contains(page, "wird gleich geloescht") { t.Errorf("inhalt nach dem löschen noch sichtbar:\n%s", excerpt(page, "content")) } if !strings.Contains(page, "[deleted]") { t.Errorf("kein platzhalter:\n%s", excerpt(page, "card")) } if strings.Contains(page, fmt.Sprintf("/beitrag/%d/loeschen", pid)) { t.Error("löschen-link an einem bereits gelöschten beitrag") } _, thread := getHTML(t, alice, fmt.Sprintf("%s/beitrag/%d", srv.URL, pid)) if !strings.Contains(thread, "antwort bleibt") { t.Errorf("antworten nach dem löschen nicht mehr erreichbar:\n%s", excerpt(thread, "replies")) } }