Moderation entfernt, JSON-API unter /api

- Moderationsseite und Melde-Funktion (report) komplett ausgebaut; folgt
  als eigenständiges Projekt
- alle API-Endpunkte unter /api, dort ausschließlich JSON: auch 404, 405,
  Rate-Limit (429) und Panics (500)
- Fehler nur über HTTP-Status; stille DB-Fehler in stats, logout und
  Vote-Zählern liefern jetzt 500 statt Nullen
- /auth/headerbar entfernt (Frontend nutzt /api/user/info)
- Frontend auf /api und statusbasierte Auswertung umgestellt
- notes/api.md neu als Referenz der aktuellen API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:10:02 +02:00
co-authored by Claude Opus 5.5
parent 2714fdb1a5
commit 030a5bd943
21 changed files with 430 additions and 726 deletions
+103 -73
View File
@@ -84,7 +84,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http
t.Helper()
c := newClient(t)
resp := postForm(t, c, srv.URL+"/auth/newuser", url.Values{
resp := postForm(t, c, srv.URL+"/api/auth/newuser", url.Values{
"user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"},
})
resp.Body.Close()
@@ -92,7 +92,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http
t.Fatalf("register %s: status %d", username, resp.StatusCode)
}
resp = postForm(t, c, srv.URL+"/auth/login", url.Values{
resp = postForm(t, c, srv.URL+"/api/auth/login", url.Values{
"user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"},
})
resp.Body.Close()
@@ -105,7 +105,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http
// 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}})
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)
@@ -119,44 +119,34 @@ func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content str
return out.PID
}
func TestRegisterLoginHeaderbar(t *testing.T) {
func TestRegisterLoginSession(t *testing.T) {
srv := newTestServer(t)
c := registerAndLogin(t, srv, "alice")
resp, err := c.Get(srv.URL + "/auth/headerbar")
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)
}
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")
}
resp.Body.Close()
return resp.StatusCode
}
func TestFeedEmptyThenPopulated(t *testing.T) {
srv := newTestServer(t)
resp, err := http.Get(srv.URL + "/entry/feed/0")
resp, err := http.Get(srv.URL + "/api/entry/feed/0")
if err != nil {
t.Fatal(err)
}
@@ -170,7 +160,7 @@ func TestFeedEmptyThenPopulated(t *testing.T) {
alice := registerAndLogin(t, srv, "alice")
createEntry(t, alice, srv, "hallo welt")
resp, err = http.Get(srv.URL + "/entry/feed/0")
resp, err = http.Get(srv.URL + "/api/entry/feed/0")
if err != nil {
t.Fatal(err)
}
@@ -189,7 +179,7 @@ func TestCreateEntryRequiresAuth(t *testing.T) {
srv := newTestServer(t)
anon := newClient(t)
resp := postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}})
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)
@@ -209,7 +199,7 @@ func TestVoting(t *testing.T) {
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}})
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)
@@ -237,7 +227,7 @@ 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)
voteURL := fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid)
anon := newClient(t)
resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}})
@@ -258,7 +248,7 @@ func TestBump(t *testing.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)
bumpURL := fmt.Sprintf("%s/api/entry/%d/bump", srv.URL, pid)
// Anonym: 401.
resp := postForm(t, newClient(t), bumpURL, url.Values{})
@@ -292,9 +282,9 @@ func TestBump(t *testing.T) {
// 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 = 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/entry/%d/bump", srv.URL, del), url.Values{})
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)
@@ -304,7 +294,7 @@ func TestBump(t *testing.T) {
// 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{
resp := postForm(t, c, srv.URL+"/api/entry/create", url.Values{
"content": {content}, "reply_to": {fmt.Sprint(parentPID)},
})
defer resp.Body.Close()
@@ -336,7 +326,7 @@ type threadJSON struct {
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))
resp, err := http.Get(fmt.Sprintf("%s/api/entry/%d/thread", srv.URL, pid))
if err != nil {
t.Fatal(err)
}
@@ -378,7 +368,7 @@ func TestThreading(t *testing.T) {
}
// Hauptfeed: nur der Root, keine Antworten.
resp, _ := http.Get(srv.URL + "/entry/feed/0")
resp, _ := http.Get(srv.URL + "/api/entry/feed/0")
var feed []struct {
PID int64 `json:"pid"`
}
@@ -389,7 +379,7 @@ func TestThreading(t *testing.T) {
}
// Profil-Feed: alle drei Beiträge (Root + Antworten).
resp, _ = http.Get(srv.URL + "/u/alice/feed/0")
resp, _ = http.Get(srv.URL + "/api/u/alice/feed/0")
var profile []struct {
PID int64 `json:"pid"`
}
@@ -406,7 +396,7 @@ func TestEditEntry(t *testing.T) {
bob := registerAndLogin(t, srv, "bob")
pid := createEntry(t, alice, srv, "original")
editURL := fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid)
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"}})
@@ -433,14 +423,14 @@ func TestEditEntry(t *testing.T) {
}
// Unbekannte pid -> 404.
resp = postForm(t, alice, srv.URL+"/entry/999999/edit", url.Values{"content": {"x"}})
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/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close()
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 {
@@ -456,7 +446,7 @@ func TestSoftDelete(t *testing.T) {
root := createEntry(t, alice, srv, "wurzel")
reply := createReply(t, alice, srv, "antwort", root)
delURL := fmt.Sprintf("%s/entry/%d/delete", srv.URL, root)
delURL := fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, root)
// Fremder darf nicht löschen.
resp := postForm(t, bob, delURL, url.Values{})
@@ -482,7 +472,7 @@ func TestSoftDelete(t *testing.T) {
}
// Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar).
feedResp, _ := http.Get(srv.URL + "/entry/feed/0")
feedResp, _ := http.Get(srv.URL + "/api/entry/feed/0")
var feed []threadEntry
json.NewDecoder(feedResp.Body).Decode(&feed)
feedResp.Body.Close()
@@ -491,7 +481,7 @@ func TestSoftDelete(t *testing.T) {
}
// Nicht existierender Beitrag -> 404.
resp = postForm(t, alice, srv.URL+"/entry/999999/delete", url.Values{})
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)
@@ -504,21 +494,21 @@ func TestRenameUser(t *testing.T) {
registerAndLogin(t, srv, "bob")
// Zu kurz -> 400.
resp := postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"ab"}})
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+"/user/rename", url.Values{"user": {"bob"}})
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+"/user/rename", url.Values{"user": {"alice2"}})
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)
@@ -532,12 +522,12 @@ func TestRenameUser(t *testing.T) {
}
// Profil unter dem neuen Namen erreichbar, alter Name weg.
r2, _ := http.Get(srv.URL + "/u/alice2/info")
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 + "/u/alice/info")
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)
@@ -554,14 +544,14 @@ func TestDeleteUser(t *testing.T) {
reply := createReply(t, bob, srv, "bobs antwort", root)
// Falsches Passwort -> 403.
resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}})
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+"/user/delete", url.Values{"pass1": {"supersecret1"}})
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)
@@ -569,7 +559,7 @@ func TestDeleteUser(t *testing.T) {
// Login danach schlägt fehl.
fresh := newClient(t)
resp = postForm(t, fresh, srv.URL+"/auth/login", url.Values{
resp = postForm(t, fresh, srv.URL+"/api/auth/login", url.Values{
"user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"},
})
resp.Body.Close()
@@ -613,34 +603,74 @@ 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)
if got := getStatus(t, c, srv.URL+"/api/auth/logout"); got != http.StatusMethodNotAllowed {
t.Fatalf("GET logout: erwartet 405, bekam %d", got)
}
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)
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+"/auth/logout", url.Values{})
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)
}
}
resp, err = c.Get(srv.URL + "/auth/headerbar")
// 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)
}
defer resp.Body.Close()
var h struct {
LoggedIn bool `json:"loggedin"`
check("unbekannter Pfad", resp, http.StatusNotFound, "api.not_found")
resp, err = http.Get(srv.URL + "/api/entry/create")
if err != nil {
t.Fatal(err)
}
json.NewDecoder(resp.Body).Decode(&h)
if h.LoggedIn {
t.Fatal("nach Logout sollte loggedin=false sein")
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
@@ -650,7 +680,7 @@ func TestCrossOriginPostRejected(t *testing.T) {
c := registerAndLogin(t, srv, "alice")
post := func(origin string) int {
req, err := http.NewRequest(http.MethodPost, srv.URL+"/entry/create",
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/entry/create",
strings.NewReader(url.Values{"content": {"x"}}.Encode()))
if err != nil {
t.Fatal(err)
@@ -706,13 +736,13 @@ 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 := 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+"/entry/create", "203.0.113.7", url.Values{"content": {"normal"}})
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)