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 }
// The author edits and deletes their own post; an admin removes someone
// else's with a reason, logged and told; a member with neither role may
// do neither to another's post.
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")
}
// Edits beyond the author's own: the person answering for an agent edits
// the agent's posts, a forum moderator (5) edits anyone's. The post says
// who edited it, forum.get hands the source to exactly those who may
// edit, and a moderator's edit is in the moderation log.
func TestForumEditsByAnAgentsOwnerAndAModerator(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() })
for _, who := range []string{"dmestas", "eve"} {
if err := db.AddAccount(who, "a-long-enough-password", who == "dmestas"); err != nil {
t.Fatal(err)
}
}
if err := db.NewAgent("bot", "dmestas", -1); err != nil {
t.Fatal(err)
}
if err := db.NewForgeAgent("zeitgeist", "dmestas"); err != nil {
t.Fatal(err)
}
d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z, Sync: zitSync(z, ix)}}),
Removals: memRemovals{}, Forum: db, Accounts: acctAdapter{db}}
r := ops.NewRegistry()
if err := RegisterAll(r, d); err != nil {
t.Fatal(err)
}
post := func(who, body string) string {
t.Helper()
out, err := r.Invoke(as(who, "3"), "forum.post", forumPostIn{Repo: "test", Title: who, Body: body})
if err != nil {
t.Fatal(err)
}
return out.(forumPostOut).Hash
}
get := func(who, letters, root string) forumRendered {
t.Helper()
g, err := r.Invoke(as(who, letters), "forum.get", forumGetIn{Repo: "test", Root: root})
if err != nil {
t.Fatal(err)
}
return g.(forumGetOut).Posts[0]
}
edit := func(who, letters, p, body string) error {
_, err := r.Invoke(as(who, letters), "forum.edit", forumEditIn{Repo: "test", Post: p, Body: body})
return err
}
for _, agent := range []string{"bot", "zeitgeist"} {
p := post(agent, "an agent wrote this\n")
if get("dmestas", "3", p).Source == "" || get("eve", "3", p).Source != "" {
t.Errorf("FAIL: %s's source should go to the person answering for it and nobody else", agent)
}
if err := edit("eve", "3", p, "not yours\n"); !errors.Is(err, ops.ErrForbidden) {
t.Errorf("FAIL: eve edited %s's post: err = %v", agent, err)
}
if err := edit("dmestas", "3", p, "fixed by the person who answers for it\n"); err != nil {
t.Fatalf("FAIL: dmestas editing %s's post: %v", agent, err)
}
if g := get("eve", "3", p); g.User != agent || g.EditedBy != "dmestas" || !strings.Contains(g.HTML, "fixed by") {
t.Errorf("FAIL: after the edit %s's post reads %+v", agent, g)
}
}
t.Log("ok: whoever answers for an agent edits its posts, and the post says who did")
p := post("eve", "eve's words\n")
if get("mo", "5", p).Source == "" {
t.Error("FAIL: a moderator is not handed the source to edit")
}
if err := edit("mo", "5", p, "eve's words, moderated\n"); err != nil {
t.Fatalf("FAIL: a moderator's edit: %v", err)
}
if g := get("eve", "3", p); g.User != "eve" || g.EditedBy != "mo" {
t.Errorf("FAIL: after the moderator's edit: %+v", g)
}
l, _ := db.ModLog("test", 10)
if len(l) != 1 || l[0].Action != "edit" || l[0].Author != "eve" {
t.Errorf("FAIL: the moderation log reads %+v", l)
}
t.Log("ok: a moderator edits anyone's post, marked on the post and in the log")
}