package api
import (
"errors"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
type memRemovals map[string]string
func (m memRemovals) RecordRemoval(repo, post, reason, actor string) error {
m[post] = reason
return nil
}
func (m memRemovals) Removals(string) (map[string]string, error) { return map[string]string(m), nil }
// Fossil's rules, narrowed: the author edits and deletes their own post;
// an admin removes someone else's with a reason, logged and told; nobody
// else may do either, and the post never reads as anyone but its author's.
func TestForumEditAndDelete(t *testing.T) {
ix, z, _ := attachStore(t)
db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z, Sync: zitSync(z, ix)}}),
Removals: memRemovals{}, Forum: db}
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
out, err := r.Invoke(as("dan", "3"), "forum.post", forumPostIn{Repo: "test", Title: "hello", Body: "first\n"})
if err != nil {
t.Fatal(err)
}
root := out.(forumPostOut).Hash
rout, err := r.Invoke(as("eve", "3"), "forum.reply", forumReplyIn{Repo: "test", Root: root, Body: "buy watches\n"})
if err != nil {
t.Fatal(err)
}
reply := rout.(forumReplyOut).Hash
posts := func() []forumRendered {
t.Helper()
g, err := r.Invoke(as("dan", "2"), "forum.get", forumGetIn{Repo: "test", Root: root})
if err != nil {
t.Fatal(err)
}
return g.(forumGetOut).Posts
}
if _, err := r.Invoke(as("eve", "3"), "forum.edit", forumEditIn{Repo: "test", Post: root, Body: "hijacked\n"}); !errors.Is(err, ops.ErrForbidden) {
t.Fatalf("FAIL: editing someone else's post: err = %v", err)
}
if _, err := r.Invoke(as("dan", "3"), "forum.edit", forumEditIn{Repo: "test", Post: root, Body: "first, fixed\n"}); err != nil {
t.Fatal(err)
}
ps := posts()
if len(ps) != 2 || !strings.Contains(ps[0].HTML, "first, fixed") || ps[0].User != "dan" || ps[0].Edits != 1 {
t.Fatalf("FAIL: after the author's edit: %+v", ps)
}
t.Log("ok: only the author edits; the thread shows the newest text under the author")
if _, err := r.Invoke(as("dan", "3"), "forum.delete", forumDeleteIn{Repo: "test", Post: reply}); !errors.Is(err, ops.ErrForbidden) {
t.Fatalf("FAIL: a non-admin deleting someone else's post: err = %v", err)
}
admin := as("mo", "a")
if _, err := r.Invoke(admin, "forum.delete", forumDeleteIn{Repo: "test", Post: reply}); !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("FAIL: an admin removal with no reason: err = %v", err)
}
if _, err := r.Invoke(admin, "forum.delete", forumDeleteIn{Repo: "test", Post: reply, Reason: "spam"}); err != nil {
t.Fatal(err)
}
ps = posts()
if len(ps) != 2 || !ps[1].Deleted || ps[1].Removed != "spam" || ps[1].User != "eve" || ps[1].EditedBy != "mo" {
t.Fatalf("FAIL: after the admin's removal: %+v", ps[1])
}
if l, _ := db.ModLog("test", 10); len(l) != 1 || l[0].Action != "remove" || l[0].Author != "eve" || l[0].Reason != "spam" {
t.Fatalf("FAIL: log = %+v", l)
}
if _, err := r.Invoke(admin, "forum.delete", forumDeleteIn{Repo: "test", Post: reply, Reason: "again"}); !errors.Is(err, ops.ErrInvalid) {
t.Fatalf("FAIL: deleting a deleted post: err = %v", err)
}
t.Log("ok: an admin removes with a reason: tombstone keeps the author, shows the reason, logs it")
if _, err := r.Invoke(as("dan", "3"), "forum.delete", forumDeleteIn{Repo: "test", Post: root}); err != nil {
t.Fatal(err)
}
ps = posts()
if l, _ := db.ModLog("test", 10); !ps[0].Deleted || ps[0].Removed != "" || len(l) != 1 {
t.Fatalf("FAIL: the author's own delete: %+v, log %+v", ps[0], l)
}
t.Log("ok: the author deletes their own with no reason and no log line")
}