tinyrelayfile
tinyrelay/internal/daemon/wiki_revisions_test.go/

internal/daemon/wiki_revisions_test.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/FelineStateMachine/tinyrelay/internal/event"
12 "github.com/FelineStateMachine/tinyrelay/internal/replication"
13)
14
15// 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 e
27}
28
29func 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 ids
35}
36
37func TestWikiRevisionsAreKeptWhenAVersionIsReplaced(t *testing.T) {
38 _, tenant := testTenant(t)
39 ctx := context.Background()
40 owner := tenant.Policy().Owner
41 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})
49
50 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 float64
66 superseded any
67 likes float64
68 }{{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 }
77
78 // 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 }
91
92 // 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}
102
103func 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}
127
128func 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)
138
139 // 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 edit
157 // 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 }
182
183 // The MCP tools read through the same code. An empty secret signs as
184 // 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 value
197 }
198 for _, viewer := range []struct {
199 secret string
200 id string
201 }{{"", 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 }
212
213 // A rejected edit keeps the approved revision in view; approving the
214 // 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs