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