1656 lines
58 KiB
Go
1656 lines
58 KiB
Go
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, `<script>alert("xss")</script> & "zitat" 'apos' https://example.com/x?a=1&b=2`)
|
|
|
|
_, page := getHTML(t, newClient(t), srv.URL+"/feed")
|
|
|
|
for _, bad := range []string{"<script>", "</script>", `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, `<a href="https://example.com/x?a=1&b=2" target="_blank" rel="noopener">`) {
|
|
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"))
|
|
}
|
|
// <details> klappt ohne JavaScript auf -- ohne das wäre das Feld unerreichbar.
|
|
if !strings.Contains(page, `<details class="report">`) {
|
|
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<pid>", 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, `<img src="/static/media/`) {
|
|
t.Errorf("bild nicht gespeichert oder nicht gerendert:\n%s", excerpt(page, "content"))
|
|
}
|
|
// storeImage schreibt in das echte static/media der Arbeitskopie -- ohne
|
|
// Aufräumen sammelt sich dort bei jedem Testlauf eine Datei an.
|
|
if p := mediaPathFrom(page); p != "" {
|
|
t.Cleanup(func() { os.Remove(p) })
|
|
} else {
|
|
t.Error("medienpfad nicht aus der seite lesbar -- upload bliebe liegen")
|
|
}
|
|
|
|
// Leerer Beitrag -> 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 = `<img src="/static/media/`
|
|
i := strings.Index(page, marker)
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
rest := page[i+len(`<img src="/`):]
|
|
j := strings.IndexByte(rest, '"')
|
|
if j < 0 {
|
|
return ""
|
|
}
|
|
return rest[:j]
|
|
}
|
|
|
|
// TestBeitragUeberTORZeigtHinweis prüft, dass über TOR gar kein Formular
|
|
// erscheint. blockTOR würde das Absenden ohnehin ablehnen; ein Formular, das
|
|
// immer scheitert, wäre schlechter als ein Hinweis.
|
|
func TestBeitragUeberTORZeigtHinweis(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
c := registerAndLogin(t, srv, "toruser")
|
|
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/feed", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("X-Real-IP", "10.89.1.2")
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
page := body(t, resp)
|
|
|
|
if strings.Contains(page, `action="/entry/create"`) {
|
|
t.Error("beitragsformular wird über TOR angeboten, obwohl blockTOR es ablehnt")
|
|
}
|
|
if !strings.Contains(page, "Über TOR ist das Schreiben") {
|
|
t.Errorf("kein hinweis auf die TOR-Sperre:\n%s", excerpt(page, "muted"))
|
|
}
|
|
// Lesen bleibt möglich.
|
|
if !strings.Contains(page, `<section id="feed">`) {
|
|
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<pid>", 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"))
|
|
}
|
|
}
|