tinyrelayfile
tinyrelay/internal/daemon/wiki_test.go/

internal/daemon/wiki_test.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/FelineStateMachine/tinyrelay/internal/event"
12 "github.com/FelineStateMachine/tinyrelay/internal/relay"
13)
14
15const (
16 wikiOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001"
17 wikiMemberSecret = "2222222222222222222222222222222222222222222222222222222222222222"
18 wikiOtherSecret = "3333333333333333333333333333333333333333333333333333333333333333"
19 wikiThirdSecret = "4444444444444444444444444444444444444444444444444444444444444444"
20)
21
22func wikiKey(t *testing.T, secret string) string {
23 t.Helper()
24 pk, err := event.PublicKey(secret)
25 if err != nil {
26 t.Fatal(err)
27 }
28 return pk
29}
30
31func wikiPublish(t *testing.T, tenant *Tenant, secret string, kind int, createdAt int64, content string, tags ...[]string) event.Event {
32 t.Helper()
33 e := event.Event{Kind: kind, CreatedAt: createdAt, Content: content, Tags: tags}
34 if err := event.Sign(&e, secret); err != nil {
35 t.Fatal(err)
36 }
37 if _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}, RelayURL: tenant.RelayURL()}); err != nil {
38 t.Fatalf("publish kind %d: %v", kind, err)
39 }
40 return e
41}
42
43func wikiArticle(t *testing.T, tenant *Tenant, secret, title string, createdAt int64, extra ...[]string) event.Event {
44 t.Helper()
45 tags := append([][]string{{"d", strings.ToLower(strings.ReplaceAll(title, " ", "-"))}, {"title", title}, {"summary", "Summary about " + title}}, extra...)
46 return wikiPublish(t, tenant, secret, kindWikiArticle, createdAt, "Content of "+title+" with a [[Wiki Link]].", tags...)
47}
48
49func wikiMember(t *testing.T, tenant *Tenant, pubkey string) {
50 t.Helper()
51 if _, err := tenant.community.Execute(context.Background(), tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(fmt.Sprintf("%q", pubkey)), json.RawMessage(fmt.Sprintf(`{"name":"member-%s"}`, pubkey[:8]))}); err != nil {
52 t.Fatal(err)
53 }
54}
55
56func wikiCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) map[string]any {
57 t.Helper()
58 raw, err := json.Marshal(params)
59 if err != nil {
60 t.Fatal(err)
61 }
62 value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw})
63 if err != nil {
64 t.Fatalf("%s %s: %v", method, raw, err)
65 }
66 encoded, err := json.Marshal(value)
67 if err != nil {
68 t.Fatal(err)
69 }
70 var result map[string]any
71 if err := json.Unmarshal(encoded, &result); err != nil {
72 t.Fatal(err)
73 }
74 return result
75}
76
77func wikiItems(result map[string]any, key string) []map[string]any {
78 var items []map[string]any
79 for _, item := range result[key].([]any) {
80 items = append(items, item.(map[string]any))
81 }
82 return items
83}
84
85func TestWikiPreferenceOrder(t *testing.T) {
86 _, tenant := testTenant(t)
87 owner := tenant.Policy().Owner
88 member, other, third := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret), wikiKey(t, wikiThirdSecret)
89 wikiMember(t, tenant, member)
90 wikiMember(t, tenant, third)
91 now := time.Now().Unix()
92
93 ownerBitcoin := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-300)
94 memberBitcoin := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-200)
95 otherBitcoin := wikiArticle(t, tenant, wikiOtherSecret, "Bitcoin", now-100)
96
97 memberLightning := wikiArticle(t, tenant, wikiMemberSecret, "Lightning", now-300)
98 otherLightning := wikiArticle(t, tenant, wikiOtherSecret, "Lightning", now-200)
99 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-150, "+", []string{"e", memberLightning.ID})
100 wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-140, "", []string{"a", "30818:" + member + ":lightning"})
101 wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-130, "-", []string{"e", memberLightning.ID})
102 // Non-members do not count, and one member counts once per version.
103 wikiPublish(t, tenant, wikiOtherSecret, kindReaction, now-120, "+", []string{"e", otherLightning.ID})
104 wikiPublish(t, tenant, wikiThirdSecret, kindReaction, now-110, "+", []string{"e", memberLightning.ID})
105
106 wikiArticle(t, tenant, wikiMemberSecret, "Nostr", now-300)
107 otherNostr := wikiArticle(t, tenant, wikiOtherSecret, "Nostr", now-200)
108
109 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
110 version := page["version"].(map[string]any)
111 if version["author"] != owner || page["preferred_by"] != "owner" || version["id"] != ownerBitcoin.ID {
112 t.Fatalf("owner preference %v", page)
113 }
114 if version["content"] == "" || fmt.Sprint(version["links"]) != "[wiki-link]" {
115 t.Fatalf("preferred version content %v", version)
116 }
117 versions := wikiItems(page, "versions")
118 if len(versions) != 3 || versions[0]["id"] != otherBitcoin.ID || versions[1]["id"] != memberBitcoin.ID || versions[2]["id"] != ownerBitcoin.ID {
119 t.Fatalf("versions newest first %v", versions)
120 }
121 if _, ok := versions[0]["content"]; ok {
122 t.Fatalf("version list carries content %v", versions[0])
123 }
124 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "Bitcoin", "author": member})
125 if page["version"].(map[string]any)["id"] != memberBitcoin.ID || page["preferred_by"] != "author" {
126 t.Fatalf("author preference %v", page)
127 }
128 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin", "version": otherBitcoin.ID})
129 if page["version"].(map[string]any)["id"] != otherBitcoin.ID || page["preferred_by"] != "version" {
130 t.Fatalf("version selection %v", page)
131 }
132 if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"bitcoin","version":"` + strings.Repeat("f", 64) + `"}`)}); err == nil {
133 t.Fatal("unknown version selected")
134 }
135
136 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "lightning"})
137 version = page["version"].(map[string]any)
138 if version["id"] != memberLightning.ID || page["preferred_by"] != "reactions" || version["likes"].(float64) != 2 {
139 t.Fatalf("reaction preference %v", page)
140 }
141 for _, v := range wikiItems(page, "versions") {
142 if v["id"] == otherLightning.ID && v["likes"].(float64) != 0 {
143 t.Fatalf("non-member like counted %v", v)
144 }
145 }
146
147 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "nostr"})
148 if page["version"].(map[string]any)["id"] != otherNostr.ID || page["preferred_by"] != "newest" {
149 t.Fatalf("newest preference %v", page)
150 }
151
152 list := wikiCall(t, tenant, "", "browsewiki", nil)
153 items := wikiItems(list, "items")
154 if len(items) != 3 || items[0]["d"] != "bitcoin" || items[1]["d"] != "lightning" || items[2]["d"] != "nostr" || list["next_cursor"] != "" {
155 t.Fatalf("page list %v", list)
156 }
157 if items[0]["versions"].(float64) != 3 || items[0]["version"].(map[string]any)["author"] != owner || items[1]["version"].(map[string]any)["id"] != memberLightning.ID {
158 t.Fatalf("page list preference %v", items)
159 }
160 list = wikiCall(t, tenant, "", "browsewiki", map[string]any{"author": other})
161 if wikiItems(list, "items")[0]["version"].(map[string]any)["author"] != other {
162 t.Fatalf("author preference in list %v", list)
163 }
164}
165
166func TestWikiSearchAndPagination(t *testing.T) {
167 _, tenant := testTenant(t)
168 now := time.Now().Unix()
169 names := []string{"Alpha", "Beta", "Delta", "Epsilon", "Gamma"}
170 for i, name := range names {
171 wikiArticle(t, tenant, wikiOwnerSecret, name, now-int64(i))
172 }
173 var got []string
174 cursor := ""
175 for page := 0; page < 5; page++ {
176 result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"limit": 2, "cursor": cursor})
177 for _, item := range wikiItems(result, "items") {
178 got = append(got, item["d"].(string))
179 }
180 cursor = result["next_cursor"].(string)
181 if cursor == "" {
182 break
183 }
184 }
185 if fmt.Sprint(got) != "[alpha beta delta epsilon gamma]" {
186 t.Fatalf("paginated pages = %v", got)
187 }
188 result := wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "about BETA"})
189 if items := wikiItems(result, "items"); len(items) != 1 || items[0]["d"] != "beta" {
190 t.Fatalf("summary search %v", result)
191 }
192 result = wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "gamm"})
193 if items := wikiItems(result, "items"); len(items) != 1 || items[0]["title"] != "Gamma" {
194 t.Fatalf("title search %v", result)
195 }
196 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", map[string]any{"q": "zeta"}), "items"); len(items) != 0 {
197 t.Fatalf("unmatched search %v", items)
198 }
199}
200
201func TestWikiMergeRequestStatusFollowsDestinationReactions(t *testing.T) {
202 _, tenant := testTenant(t)
203 ctx := context.Background()
204 owner := tenant.Policy().Owner
205 member := wikiKey(t, wikiMemberSecret)
206 wikiMember(t, tenant, member)
207 now := time.Now().Unix()
208 original := wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-500)
209 coordinate := "30818:" + owner + ":bitcoin"
210 fork := wikiArticle(t, tenant, wikiMemberSecret, "Bitcoin", now-400, []string{"a", coordinate, "", "fork"}, []string{"e", original.ID, "", "fork"})
211 merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-300, "Added the block size limit", []string{"a", coordinate}, []string{"e", original.ID}, []string{"p", owner}, []string{"e", fork.ID, "", "source"})
212
213 detail := wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"id": merge.ID})
214 m := detail["merge"].(map[string]any)
215 if m["status"] != "open" || m["source"] != fork.ID || m["base"] != original.ID || m["destination"] != owner || m["target_d"] != "bitcoin" {
216 t.Fatalf("merge detail %v", m)
217 }
218 if detail["proposed"].(map[string]any)["id"] != fork.ID || detail["target"].(map[string]any)["id"] != original.ID {
219 t.Fatalf("merge versions %v", detail)
220 }
221 if proposed := detail["proposed"].(map[string]any); proposed["fork"].(map[string]any)["e"] != original.ID || proposed["fork"].(map[string]any)["a"] != coordinate {
222 t.Fatalf("fork reference %v", proposed)
223 }
224 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
225 if merges := wikiItems(page, "merges"); len(merges) != 1 || merges[0]["id"] != merge.ID || merges[0]["status"] != "open" {
226 t.Fatalf("page merges %v", page)
227 }
228 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 1 {
229 t.Fatalf("open merge count %v", items)
230 }
231
232 // Only the destination author's reaction answers the request.
233 wikiPublish(t, tenant, wikiMemberSecret, kindReaction, now-250, "+", []string{"e", merge.ID}, []string{"p", owner})
234 if answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner); err != nil || answer.Status != wikiMergeOpen {
235 t.Fatalf("foreign reaction %+v %v", answer, err)
236 }
237 accept := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", merge.ID}, []string{"p", member})
238 answer, err := tenant.wikiMergeStatus(ctx, merge.ID, owner)
239 if err != nil || answer.Status != wikiMergeAccepted || answer.AnsweredAt != now-200 || answer.Reaction != accept.ID {
240 t.Fatalf("accepted %+v %v", answer, err)
241 }
242 reject := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-100, "-", []string{"e", merge.ID})
243 answer, err = tenant.wikiMergeStatus(ctx, merge.ID, owner)
244 if err != nil || answer.Status != wikiMergeRejected || answer.Reaction != reject.ID {
245 t.Fatalf("rejected %+v %v", answer, err)
246 }
247 // Other reaction content does not answer.
248 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "🤔", []string{"e", merge.ID})
249 if answer, _ := tenant.wikiMergeStatus(ctx, merge.ID, owner); answer.Status != wikiMergeRejected {
250 t.Fatalf("emoji reaction changed the answer %+v", answer)
251 }
252 detail = wikiCall(t, tenant, "", "browsewikimerge", map[string]any{"event": merge.ID})
253 if detail["merge"].(map[string]any)["status"] != "rejected" {
254 t.Fatalf("merge detail status %v", detail)
255 }
256 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); items[0]["open_merges"].(float64) != 0 {
257 t.Fatalf("answered merge still open %v", items)
258 }
259 if _, err := tenant.Execute(ctx, "", "browsewikimerge", []json.RawMessage{json.RawMessage(`{"id":"` + original.ID + `"}`)}); err == nil {
260 t.Fatal("article served as a merge request")
261 }
262}
263
264func TestWikiRedirects(t *testing.T) {
265 _, tenant := testTenant(t)
266 owner := tenant.Policy().Owner
267 now := time.Now().Unix()
268 wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", now-100)
269 redirect := wikiPublish(t, tenant, wikiMemberSecret, kindWikiRedirect, now-50, "", []string{"d", "btc"}, []string{"a", "30818:" + owner + ":bitcoin"})
270 page := wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "bitcoin"})
271 if to := wikiItems(page, "redirects_to"); len(to) != 1 || to[0]["d"] != "btc" || to[0]["id"] != redirect.ID || len(wikiItems(page, "redirects_from")) != 0 {
272 t.Fatalf("redirects to %v", page)
273 }
274 page = wikiCall(t, tenant, "", "browsewikipage", map[string]any{"d": "BTC"})
275 if from := wikiItems(page, "redirects_from"); page["version"] != nil || len(from) != 1 || from[0]["target_d"] != "bitcoin" || from[0]["target_author"] != owner {
276 t.Fatalf("redirects from %v", page)
277 }
278 if _, err := tenant.Execute(context.Background(), "", "browsewikipage", []json.RawMessage{json.RawMessage(`{"d":"missing"}`)}); err == nil || !strings.Contains(err.Error(), "not found") {
279 t.Fatalf("missing page %v", err)
280 }
281}
282
283func TestWikiMembersOnlyHidesPagesFromGuests(t *testing.T) {
284 _, tenant := testTenant(t)
285 ctx := context.Background()
286 member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)
287 wikiMember(t, tenant, member)
288 wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin", time.Now().Unix()-10)
289 if items := wikiItems(wikiCall(t, tenant, "", "browsewiki", nil), "items"); len(items) != 1 {
290 t.Fatalf("open relay list %v", items)
291 }
292 p := tenant.Policy()
293 p.Reads = "members"
294 if err := tenant.applyPolicy(ctx, p); err != nil {
295 t.Fatal(err)
296 }
297 for _, actor := range []string{"", other} {
298 for _, method := range []string{"browsewiki", "browsewikipage", "browsewikimerge"} {
299 if _, err := tenant.Execute(ctx, actor, method, []json.RawMessage{json.RawMessage(`{"d":"bitcoin","id":"` + strings.Repeat("a", 64) + `"}`)}); err == nil {
300 t.Fatalf("%s exposed to %q", method, actor)
301 }
302 }
303 }
304 if items := wikiItems(wikiCall(t, tenant, member, "browsewiki", nil), "items"); len(items) != 1 {
305 t.Fatalf("member list %v", items)
306 }
307 if page := wikiCall(t, tenant, tenant.Policy().Owner, "browsewikipage", map[string]any{"d": "bitcoin"}); page["version"] == nil {
308 t.Fatalf("owner page %v", page)
309 }
310}
311
312func TestWikiMergeRequestWakesDestinationDevices(t *testing.T) {
313 _, tenant := testTenant(t)
314 ctx := context.Background()
315 owner := tenant.Policy().Owner
316 member, other := wikiKey(t, wikiMemberSecret), wikiKey(t, wikiOtherSecret)
317 wikiMember(t, tenant, member)
318 now := time.Now().Unix()
319 for _, pubkey := range []string{owner, other} {
320 if _, err := tenant.store.DB().ExecContext(ctx, "INSERT INTO web_push(endpoint,pubkey,subscription,created_at) VALUES(?,?,?,?)", "https://push.example/"+pubkey[:8], pubkey, "{}", now); err != nil {
321 t.Fatal(err)
322 }
323 }
324 wikiArticle(t, tenant, wikiOwnerSecret, "Bitcoin Basics", now-100)
325 // A request aimed at someone who is not a member, and one the owner
326 // sends to themselves, wake nobody.
327 wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-90, "", []string{"a", "30818:" + other + ":bitcoin-basics"}, []string{"p", other}, []string{"e", strings.Repeat("a", 64), "", "source"})
328 wikiPublish(t, tenant, wikiOwnerSecret, kindWikiMerge, now-80, "", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("b", 64), "", "source"})
329 if got := countIntents(t, tenant, notificationPush); got != 0 {
330 t.Fatalf("unexpected notifications %d", got)
331 }
332 merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-70, "Please merge", []string{"a", "30818:" + owner + ":bitcoin-basics"}, []string{"p", owner}, []string{"e", strings.Repeat("c", 64), "", "source"})
333 var payload string
334 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, owner).Scan(&payload); err != nil {
335 t.Fatalf("notification intent: %v", err)
336 }
337 var notice pushPayload
338 if err := json.Unmarshal([]byte(payload), &notice); err != nil {
339 t.Fatal(err)
340 }
341 if notice.Kind != pushReplies || notice.Text != "Merge request for Bitcoin Basics" || notice.URL != "http://relay.test/wiki/bitcoin-basics?merge="+merge.ID {
342 t.Fatalf("notice %+v", notice)
343 }
344}
345
346func TestWikiMergeFromReadsEveryTagShape(t *testing.T) {
347 owner := strings.Repeat("a", 64)
348 base, source := strings.Repeat("b", 64), strings.Repeat("c", 64)
349 coordinate := "30818:" + owner + ":bitcoin"
350 cases := []struct {
351 name string
352 tags [][]string
353 base, source string
354 }{
355 {"source marker", [][]string{{"a", coordinate}, {"e", base}, {"p", owner}, {"e", source, "", "source"}}, base, source},
356 {"source tag", [][]string{{"a", coordinate}, {"e", base}, {"source", source}}, base, source},
357 {"single e", [][]string{{"a", coordinate}, {"e", source}}, "", source},
358 {"two plain e", [][]string{{"a", coordinate}, {"e", base}, {"e", source}}, base, source},
359 }
360 for _, tc := range cases {
361 m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: tc.tags})
362 if m.Base != tc.base || m.Source != tc.source || m.Destination != owner || m.TargetD != "bitcoin" || m.Status != wikiMergeOpen {
363 t.Errorf("%s: %+v", tc.name, m)
364 }
365 }
366 if m := wikiMergeFrom(event.Event{Kind: kindWikiMerge, Tags: [][]string{{"a", "30617:" + owner + ":repo"}}}); m.Destination != "" || m.TargetD != "" {
367 t.Errorf("foreign coordinate accepted %+v", m)
368 }
369}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs