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)
}
}
// 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"))
}
// 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.
resp = postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {" "}})
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"))
}
// 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"))
}
// 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.
resp = postFormHTML(t, alice, srv.URL+"/entry/create",
url.Values{"content": {" "}, "reply_to": {fmt.Sprint(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"))
}
// 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"))
}
}