clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / internal / daemon / wiki_revisions_test.go
wiki_revisions_test.go 11.6K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "strings"8 "testing"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12 "github.com/FelineStateMachine/tinyrelay/internal/replication"13)1415// wikiImport signs an article and takes it in through the import path.16func wikiImport(t *testing.T, tenant *Tenant, secret, title string, createdAt int64) event.Event {17 t.Helper()18 d := strings.ToLower(strings.ReplaceAll(title, " ", "-"))19 e := event.Event{Kind: kindWikiArticle, CreatedAt: createdAt, Content: "Imported " + title, Tags: [][]string{{"d", d}, {"title", title}}}20 if err := event.Sign(&e, secret); err != nil {21 t.Fatal(err)22 }23 if err := tenant.ingest(context.Background(), e, replication.OriginImport); err != nil {24 t.Fatalf("import: %v", err)25 }26 return e27}2829func wikiIDs(items []map[string]any) []string {30 ids := make([]string, 0, len(items))31 for _, item := range items {32 ids = append(ids, item["id"].(string))33 }34 return ids35}3637func TestWikiRevisionsAreKeptWhenAVersionIsReplaced(t *testing.T) {38 _, tenant := testTenant(t)39 ctx := context.Background()40 owner := tenant.Policy().Owner41 member := wikiKey(t, wikiMemberSecret)42 wikiMember(t, tenant, member)43 now := time.Now().Unix()44 first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)45 second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-400)46 third := wikiImport(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)47 // A reaction to an old revision id is an ordinary event.48 wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+", []string{"e", first.ID})4950 rows, err := tenant.store.WikiRevisions(ctx, owner, "bitcoin")51 if err != nil || len(rows) != 2 || rows[0].ID != second.ID || rows[0].SupersededBy != third.ID || rows[1].ID != first.ID || rows[1].SupersededBy != second.ID || rows[1].Event.Content != first.Content {52 t.Fatalf("stored revisions %+v %v", rows, err)53 }54 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})55 versions := wikiItems(page, "versions")56 version := page["version"].(map[string]any)57 if len(versions) != 1 || version["id"] != third.ID || version["revision"].(float64) != 3 || version["revisions"].(float64) != 3 || version["superseded_by"] != nil || version["content"] != "Imported Bitcoin" {58 t.Fatalf("current version %v", page)59 }60 history := wikiItems(page, "history")61 if got := wikiIDs(history); strings.Join(got, ",") != third.ID+","+second.ID+","+first.ID {62 t.Fatalf("history order %v", got)63 }64 for i, want := range []struct {65 revision float6466 superseded any67 likes float6468 }{{3, nil, 0}, {2, third.ID, 0}, {1, second.ID, 1}} {69 h := history[i]70 if h["revision"].(float64) != want.revision || h["revisions"].(float64) != 3 || h["superseded_by"] != want.superseded || h["author"] != owner || h["title"] != "Bitcoin" || h["likes"].(float64) != want.likes {71 t.Fatalf("history entry %d %v", i, h)72 }73 if _, ok := h["content"]; ok {74 t.Fatalf("history entry carries content %v", h)75 }76 }7778 // An archived revision opens by id with a note of what replaced it.79 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "version": first.ID})80 version = page["version"].(map[string]any)81 if version["id"] != first.ID || version["content"] != first.Content || version["superseded_by"] != second.ID || version["revision"].(float64) != 1 || version["likes"].(float64) != 1 || page["preferred_by"] != "version" {82 t.Fatalf("archived version %v", version)83 }84 if len(wikiItems(page, "versions")) != 1 {85 t.Fatalf("archived lookup changed the versions %v", page["versions"])86 }87 items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items")88 if len(items) != 1 || items[0]["version"].(map[string]any)["revision"].(float64) != 3 || items[0]["versions"].(float64) != 1 {89 t.Fatalf("list %v", items)90 }9192 // A member's version of the same page is numbered on its own.93 memberVersion := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-200)94 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "author": member})95 if v := page["version"].(map[string]any); v["id"] != memberVersion.ID || v["revision"].(float64) != 1 || v["revisions"].(float64) != 1 {96 t.Fatalf("member version %v", v)97 }98 if history := wikiItems(page, "history"); len(history) != 4 || history[0]["id"] != memberVersion.ID {99 t.Fatalf("history across authors %v", wikiIDs(history))100 }101}102103func TestWikiRevisionIsDroppedByTheAuthorsDeletion(t *testing.T) {104 _, tenant := testTenant(t)105 now := time.Now().Unix()106 first := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)107 second := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-400)108 wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)109 // Someone else's deletion changes nothing.110 wikiPublish(t, tenant, wikiMemberSecret, 5, now-250, "", []string{"e", first.ID})111 if history := wikiItems(wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"}), "history"); len(history) != 3 {112 t.Fatalf("foreign deletion dropped a revision %v", wikiIDs(history))113 }114 wikiPublish(t, tenant, wikiOwnerSecret, 5, now-200, "", []string{"e", first.ID})115 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})116 history := wikiItems(page, "history")117 if len(history) != 2 || history[1]["id"] != second.ID || history[1]["revision"].(float64) != 1 || history[0]["revisions"].(float64) != 2 {118 t.Fatalf("history after deletion %v", history)119 }120 if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + first.ID + `"}`)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {121 t.Fatalf("deleted revision still opens: %v", err)122 }123 if rows, err := tenant.store.WikiRevisions(context.Background(), "", ""); err != nil || len(rows) != 1 {124 t.Fatalf("stored revisions %+v %v", rows, err)125 }126}127128func TestWikiReadersFallBackToTheNewestApprovedRevision(t *testing.T) {129 app, tenant, owner, agent, moderator, member := wikiProposalTenant(t, "propose")130 ctx := context.Background()131 now := time.Now().Unix()132 first := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-500)133 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-490, "+", []string{"e", first.ID})134 second := wikiArticle(t, tenant, testAgentSecret, "Bitcoin", now-400)135 // A page whose revisions were never approved stays absent.136 wikiArticle(t, tenant, testAgentSecret, "Nostr", now-380)137 wikiArticle(t, tenant, testAgentSecret, "Nostr", now-370)138139 // The owner, a moderator and the agent see the pending edit in place,140 // with the approved revision behind it in the history.141 for _, actor := range []string{owner, moderator, agent} {142 page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})143 version := page["version"].(map[string]any)144 if version["id"] != second.ID || version["approval"] != "pending" || version["revision"].(float64) != 2 || version["revisions"].(float64) != 2 || version["superseded_by"] != nil {145 t.Fatalf("%s page %v", actor, version)146 }147 history := wikiItems(page, "history")148 if len(history) != 2 || history[0]["id"] != second.ID || history[0]["approval"] != "pending" || history[1]["id"] != first.ID || history[1]["approval"] != "approved" || history[1]["superseded_by"] != second.ID {149 t.Fatalf("%s history %v", actor, history)150 }151 items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items")152 if len(items) != 2 || items[0]["version"].(map[string]any)["id"] != second.ID || items[0]["versions"].(float64) != 1 {153 t.Fatalf("%s list %v", actor, items)154 }155 }156 // A member and a guest read the approved revision while the edit157 // waits, and the history shows only what they may see.158 for _, actor := range []string{member, ""} {159 page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})160 version := page["version"].(map[string]any)161 if version["id"] != first.ID || version["approval"] != "approved" || version["proposal"] != true || version["content"] != first.Content || version["superseded_by"] != second.ID || version["revision"].(float64) != 1 || version["revisions"].(float64) != 2 {162 t.Fatalf("%q page %v", actor, version)163 }164 if page["can_approve"] != false {165 t.Fatalf("%q page %v", actor, page)166 }167 versions := wikiItems(page, "versions")168 history := wikiItems(page, "history")169 if len(versions) != 1 || versions[0]["id"] != first.ID || len(history) != 1 || history[0]["id"] != first.ID {170 t.Fatalf("%q versions %v history %v", actor, wikiIDs(versions), wikiIDs(history))171 }172 items := wikiItems(wikiCall(t, tenant, actor, "browsewiki", nil), "items")173 if len(items) != 1 || items[0]["d"] != "bitcoin" || items[0]["version"].(map[string]any)["id"] != first.ID || items[0]["versions"].(float64) != 1 {174 t.Fatalf("%q list %v", actor, items)175 }176 for _, params := range []string{`{"d":"bitcoin","version":"` + second.ID + `"}`, `{"d":"nostr"}`} {177 if _, err := tenant.Execute(ctx, actor, "browsewikipage", []json.RawMessage{json.RawMessage(params)}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {178 t.Fatalf("%q read %s: %v", actor, params, err)179 }180 }181 }182183 // The MCP tools read through the same code. An empty secret signs as184 // the owner; the other key has no standing on the relay.185 call := func(name string, arguments map[string]any, secret string) map[string]any {186 t.Helper()187 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")188 if w.Code != http.StatusOK {189 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())190 }191 result, isError := mcpToolResult(t, response)192 if isError {193 t.Fatalf("%s as %q: %v", name, secret, result)194 }195 value, _ := result["structuredContent"].(map[string]any)196 return value197 }198 for _, viewer := range []struct {199 secret string200 id string201 }{{"", second.ID}, {testModSecret, second.ID}, {testAgentSecret, second.ID}, {wikiMemberSecret, first.ID}, {wikiOtherSecret, first.ID}} {202 page := call("read_wiki_page", map[string]any{"d": "bitcoin"}, viewer.secret)203 history, _ := page["history"].([]any)204 if page["version"].(map[string]any)["id"] != viewer.id || len(history) != map[bool]int{true: 2, false: 1}[viewer.id == second.ID] {205 t.Fatalf("read_wiki_page as %q: %v", viewer.secret, page)206 }207 items, _ := call("list_wiki", map[string]any{}, viewer.secret)["items"].([]any)208 if len(items) == 0 || items[0].(map[string]any)["version"].(map[string]any)["id"] != viewer.id {209 t.Fatalf("list_wiki as %q: %v", viewer.secret, items)210 }211 }212213 // A rejected edit keeps the approved revision in view; approving the214 // edit shows it to everyone with the full history.215 wikiPublish(t, tenant, testModSecret, kindReaction, now-390, "-", []string{"e", second.ID})216 if page := wikiCall(t, tenant, member, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"].(map[string]any)["id"] != first.ID {217 t.Fatalf("rejected edit shown %v", page["version"])218 }219 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-380, "+", []string{"e", second.ID})220 for _, actor := range []string{owner, member, ""} {221 page := wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin"})222 history := wikiItems(page, "history")223 if page["version"].(map[string]any)["id"] != second.ID || len(history) != 2 || history[1]["superseded_by"] != second.ID {224 t.Fatalf("%q after approval %v", actor, page)225 }226 // The approved older revision still opens from the history.227 page = wikiCall(t, tenant, actor, "browsewikipage", map[string]any{"d": "bitcoin", "version": first.ID})228 if v := page["version"].(map[string]any); v["id"] != first.ID || v["approval"] != "approved" || v["superseded_by"] != second.ID || v["content"] != first.Content {229 t.Fatalf("%q archived approved revision %v", actor, v)230 }231 }232}