tinyrelayfile
tinyrelay/tinyclient/wiki_test.go/

tinyclient/wiki_test.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11
12 "github.com/FelineStateMachine/tinyrelay/internal/policy"
13)
14
15const (
16 wikiOwner = "1111111111111111111111111111111111111111111111111111111111111111"
17 wikiOther = "2222222222222222222222222222222222222222222222222222222222222222"
18 wikiMerge = "3333333333333333333333333333333333333333333333333333333333333333"
19 wikiOwnerV = "4444444444444444444444444444444444444444444444444444444444444444"
20 wikiOtherV = "5555555555555555555555555555555555555555555555555555555555555555"
21 // wikiOwnerOld and wikiOtherOld are archived revisions that the
22 // current versions replaced.
23 wikiOwnerOld = "7777777777777777777777777777777777777777777777777777777777777777"
24 wikiOtherOld = "8888888888888888888888888888888888888888888888888888888888888888"
25)
26
27// wikiBackend answers the three wiki queries with one page that has two
28// versions and an open merge request from the second author to the owner.
29type wikiBackend struct {
30 fakeBackend
31 calls []string
32 params map[string]map[string]any
33 private bool
34 // proposal makes the second author's version a proposal in this state
35 // (pending, approved or rejected), decided by the owner when it is not
36 // pending, with an approved earlier revision behind it in the history.
37 // approver marks the caller as allowed to decide, and shown makes the
38 // proposal the page's shown version.
39 proposal string
40 approver bool
41 shown bool
42 // archived gives the owner's version an earlier revision, which a
43 // request naming it by version opens.
44 archived bool
45}
46
47func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
48 b.calls = append(b.calls, method)
49 var object map[string]any
50 if len(params) == 1 {
51 _ = json.Unmarshal(params[0], &object)
52 }
53 if b.params == nil {
54 b.params = map[string]map[string]any{}
55 }
56 b.params[method] = object
57 if b.private && actor == "" {
58 return nil, errors.New("auth-required: sign in to read this relay")
59 }
60 owner := map[string]any{"id": wikiOwnerV, "author": wikiOwner, "created_at": float64(1788876000), "d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "summary": "What changed", "coordinate": "30818:" + wikiOwner + ":release-notes-1-4", "likes": float64(0), "revision": float64(1), "revisions": float64(1),
61 "content": "# Highlights\n\nPrivate tenants and _encrypted_ uploads. See [[Files and Private Repositories]].\n\n<script>alert(1)</script>", "links": []any{"files-and-private-repositories"}}
62 other := map[string]any{"id": wikiOtherV, "author": wikiOther, "created_at": float64(1788880000), "d": "release-notes-1-4", "title": "Release notes 1.4", "coordinate": "30818:" + wikiOther + ":release-notes-1-4", "likes": float64(0), "revision": float64(1), "revisions": float64(1), "fork": map[string]any{"a": owner["coordinate"], "e": wikiOwnerV}, "content": "# Highlights\n\nA proposed rewrite."}
63 merge := map[string]any{"id": wikiMerge, "author": wikiOther, "created_at": float64(1788881000), "content": "Please take my rewrite", "target": owner["coordinate"], "target_d": "release-notes-1-4", "destination": wikiOwner, "source": wikiOtherV, "status": "open"}
64 shown, preferredBy := owner, "owner"
65 history := []any{other, owner}
66 if b.proposal != "" {
67 other["proposal"], other["approval"], other["revision"], other["revisions"] = true, b.proposal, float64(2), float64(2)
68 if b.proposal != "pending" {
69 other["approval_event"], other["approval_at"], other["approval_by"] = wikiMerge, float64(1788882000), wikiOwner
70 }
71 old := map[string]any{"id": wikiOtherOld, "author": wikiOther, "created_at": float64(1788878000), "d": "release-notes-1-4", "title": "Release notes 1.4", "coordinate": other["coordinate"], "likes": float64(0), "revision": float64(1), "revisions": float64(2), "superseded_by": wikiOtherV, "proposal": true, "approval": "approved", "approval_event": wikiMerge, "approval_at": float64(1788879000), "approval_by": wikiOwner}
72 history = []any{other, old, owner}
73 if b.shown {
74 shown = other
75 }
76 }
77 if b.archived {
78 owner["revision"], owner["revisions"] = float64(2), float64(2)
79 old := map[string]any{"id": wikiOwnerOld, "author": wikiOwner, "created_at": float64(1788870000), "d": "release-notes-1-4", "title": "Release notes 1.3", "coordinate": owner["coordinate"], "likes": float64(1), "revision": float64(1), "revisions": float64(2), "superseded_by": wikiOwnerV, "content": "# Highlights\n\nThe first draft."}
80 history = append(history, old)
81 if object["version"] == wikiOwnerOld {
82 shown, preferredBy = old, "version"
83 }
84 }
85 switch method {
86 case "browsewiki":
87 return map[string]any{"items": []any{map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": shown, "versions": float64(2), "open_merges": float64(1)}}, "next_cursor": "release-notes-1-4", "can_approve": b.approver}, nil
88 case "browsewikipage":
89 if object["d"] != "release-notes-1-4" {
90 return nil, errors.New("not found: wiki page")
91 }
92 return map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": shown, "preferred_by": preferredBy, "versions": []any{other, owner}, "history": history, "merges": []any{merge}, "redirects_to": []any{map[string]any{"d": "changelog-1-4", "target_d": "release-notes-1-4"}}, "redirects_from": []any{}, "can_approve": b.approver}, nil
93 case "browsewikimerge":
94 return map[string]any{"merge": merge, "proposed": other, "target": owner}, nil
95 }
96 return nil, errors.New("unsupported: " + method)
97}
98
99func wikiApp(t *testing.T, b *wikiBackend, actor string) *App {
100 t.Helper()
101 app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})
102 if err != nil {
103 t.Fatal(err)
104 }
105 return app
106}
107
108func wikiGet(t *testing.T, app *App, path string) string {
109 t.Helper()
110 recorder := httptest.NewRecorder()
111 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
112 if recorder.Code != http.StatusOK {
113 t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())
114 }
115 body := recorder.Body.String()
116 if strings.Contains(body, "class=") {
117 t.Fatalf("%s carries a class attribute", path)
118 }
119 if strings.Contains(body, "\u00b7") {
120 t.Fatalf("%s carries a middle dot", path)
121 }
122 return body
123}
124
125func wantAll(t *testing.T, path, body string, wants ...string) {
126 t.Helper()
127 for _, want := range wants {
128 if !strings.Contains(body, want) {
129 t.Errorf("%s missing %q", path, want)
130 }
131 }
132}
133
134func wantNone(t *testing.T, path, body string, wants ...string) {
135 t.Helper()
136 for _, want := range wants {
137 if strings.Contains(body, want) {
138 t.Errorf("%s unexpectedly contains %q", path, want)
139 }
140 }
141}
142
143func TestWikiListRendersPagesAndSearch(t *testing.T) {
144 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
145 app := wikiApp(t, b, wikiOwner)
146 body := wikiGet(t, app, "/wiki?q=release")
147 if b.calls[0] != "browsewiki" || b.params["browsewiki"]["q"] != "release" {
148 t.Fatalf("calls=%v params=%v", b.calls, b.params)
149 }
150 wantAll(t, "/wiki", body,
151 `href="/wiki" aria-current="page"`, `id="wiki-search"`, `value="release"`,
152 `<a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a>`, `<nostr-name pubkey="`+wikiOwner+`"`,
153 `<td>2</td><td>1 open</td>`, `2026-09-08 14:00 UTC`, `href="/wiki?cursor=release-notes-1-4&amp;q=release"`,
154 `<b>demo</b> &raquo; <page-link url="http://relay.example/wiki`, `<a href="/wiki?new=1">new page</a>`)
155 wantNone(t, "/wiki", body, "<wiki-compose")
156 body = wikiGet(t, app, "/wiki?new=1")
157 wantAll(t, "/wiki?new=1", body, `<h2>New page</h2>`, `<wiki-compose name="" author="" coordinate="" event="">`, `name="title"`, `name="name"`, `name="summary"`, `name="content"`, `value="publish"`, `<noscript>`)
158 wantNone(t, "/wiki?new=1", body, "Propose to")
159}
160
161func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {
162 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
163 path := "/wiki/Release%20Notes%201-4"
164 body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
165 if b.params["browsewikipage"]["d"] != "release-notes-1-4" {
166 t.Fatalf("page name was not normalized: %v", b.params["browsewikipage"])
167 }
168 wantAll(t, path, body,
169 `<title>Release notes 1.4 &lt;b&gt; | wiki | demo</title>`, `<h1>Release notes 1.4 &lt;b&gt;</h1>`,
170 `<merge-request><nostr-name pubkey="`+wikiOther+`"`, `proposes a new version of this page`, `href="/wiki/release-notes-1-4?merge=`+wikiMerge+`">compare</a>`,
171 `<nostr-react event="`+wikiMerge+`" pubkey="`+wikiOther+`" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>`,
172 `<a href="/e/`+wikiMerge+`">Reply</a>`,
173 `<wiki-article><header>wiki | release-notes-1-4 | revision 1 by <nostr-name pubkey="`+wikiOwner+`"`, `version 1 of 2 | 1 fork</header>`,
174 `<h1>Highlights</h1>`, `<em>encrypted</em>`, `<a href="/wiki/files-and-private-repositories">Files and Private Repositories</a>`, `&lt;script&gt;alert(1)&lt;/script&gt;`,
175 `<h4>Page</h4>`, `<th>versions</th><td>2</td>`, `<th>merge requests</th><td>1 open</td>`, `<th>links</th><td>1</td>`,
176 `<h4>Other versions</h4>`, `href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork`,
177 `<h4>Also known as</h4>`, `<code>changelog-1-4</code>`,
178 `href="/wiki/release-notes-1-4?edit=1">edit</a>`, `href="/wiki/release-notes-1-4?history=1">history</a>`,
179 `<b>demo</b> &raquo; <page-link url="http://relay.example/wiki/release-notes-1-4" title="Copy the page address">wiki/release-notes-1-4</page-link>`)
180 wantNone(t, path, body, "<script>alert", ">fork</a>")
181 // The other author sees the request but cannot answer it, and forks
182 // instead of editing.
183 body = wikiGet(t, wikiApp(t, b, wikiOther), path)
184 wantAll(t, path, body, `<merge-request>`, `href="/wiki/release-notes-1-4?edit=1">fork</a>`)
185 wantNone(t, path, body, "<nostr-react", ">edit</a>")
186}
187
188func TestWikiPageCompareEditorAndHistory(t *testing.T) {
189 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
190 path := "/wiki/release-notes-1-4?merge=" + wikiMerge
191 body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
192 if b.params["browsewikimerge"]["id"] != wikiMerge {
193 t.Fatalf("merge lookup params=%v", b.params["browsewikimerge"])
194 }
195 wantAll(t, path, body, `<div id="wiki-compare">`, `| open | Please take my rewrite</p>`, `<h2>Proposed version</h2>`, `A proposed rewrite.`, `<h2>Current version</h2>`, `<em>encrypted</em>`, `href="/wiki/release-notes-1-4">back to the page</a>`)
196
197 path = "/wiki/release-notes-1-4?edit=1"
198 body = wikiGet(t, wikiApp(t, b, wikiOther), path)
199 wantAll(t, path, body, `<wiki-compose name="release-notes-1-4" author="`+wikiOwner+`" coordinate="30818:`+wikiOwner+`:release-notes-1-4" event="`+wikiOwnerV+`">`,
200 `value="Release notes 1.4 &lt;b&gt;"`, `value="release-notes-1-4"`, `value="What changed"`, `&lt;script&gt;alert(1)&lt;/script&gt;</textarea>`,
201 `<button name="action" value="propose">Propose to 111111111111</button>`, `JavaScript and a connected signer are required to publish.`)
202 wantNone(t, path, body, "<wiki-article>")
203 body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
204 wantNone(t, path, body, "Propose to")
205
206 path = "/wiki/release-notes-1-4?history=1"
207 body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
208 wantAll(t, path, body, `<th>author</th><th>revision</th><th>version</th>`, `<td>1 <small>current</small></td>`, `fork of 444444444444`, `href="/wiki/release-notes-1-4?version=`+wikiOwnerV+`">444444444444</a>`)
209 wantAll(t, path, body, `<th>revisions</th><td>2</td>`)
210 wantNone(t, path, body, "<small>replaced</small>")
211}
212
213func TestWikiHistoryListsRevisionsWithStates(t *testing.T) {
214 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "pending", approver: true, shown: true, archived: true}
215 path := "/wiki/release-notes-1-4?history=1"
216 body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
217 wantAll(t, path, body,
218 `<tr data-state="pending"><td><nostr-name pubkey="`+wikiOther+`"`, `<td>2 <small>current</small></td><td><a href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">555555555555</a></td><td>fork of 444444444444 | proposed pending</td>`,
219 `<tr data-state="approved"><td><nostr-name pubkey="`+wikiOther+`"`, `<td>1 <small>replaced</small></td><td><a href="/wiki/release-notes-1-4?version=`+wikiOtherOld+`">888888888888</a></td><td> | proposed approved</td>`,
220 `<td>2 <small>current</small></td><td><a href="/wiki/release-notes-1-4?version=`+wikiOwnerV+`">444444444444</a></td>`,
221 `<td>1 <small>replaced</small></td><td><a href="/wiki/release-notes-1-4?version=`+wikiOwnerOld+`">777777777777</a></td><td> | 1 likes</td>`,
222 `<th>revisions</th><td>4</td>`)
223 // A member sees the rows without their proposal states.
224 b.approver = false
225 body = wikiGet(t, wikiApp(t, b, wikiMember), path)
226 wantAll(t, path, body, `<td>1 <small>replaced</small></td><td><a href="/wiki/release-notes-1-4?version=`+wikiOtherOld+`">888888888888</a></td><td></td>`)
227 wantNone(t, path, body, "<tr data-state=", "proposed ")
228}
229
230func TestWikiArchivedRevisionShowsANote(t *testing.T) {
231 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, archived: true}
232 path := "/wiki/release-notes-1-4?version=" + wikiOwnerOld
233 body := wikiGet(t, wikiApp(t, b, wikiOther), path)
234 if b.params["browsewikipage"]["version"] != wikiOwnerOld {
235 t.Fatalf("version lookup params=%v", b.params["browsewikipage"])
236 }
237 wantAll(t, path, body,
238 `<wiki-article><header>wiki | release-notes-1-4 | revision 1 by <nostr-name pubkey="`+wikiOwner+`"`,
239 `<p id="wiki-revision-note">revision 1 of 2 by this author; a newer revision exists | <a href="/wiki/release-notes-1-4?author=`+wikiOwner+`">show the newest</a></p>`,
240 `The first draft.`)
241 // The author's current revision carries no note.
242 path = "/wiki/release-notes-1-4"
243 body = wikiGet(t, wikiApp(t, b, wikiOther), path)
244 wantAll(t, path, body, `revision 2 by <nostr-name pubkey="`+wikiOwner+`"`, `<em>encrypted</em>`)
245 wantNone(t, path, body, `id="wiki-revision-note"`, "The first draft.")
246}
247
248func TestWikiMissingPageOffersTheEditorToMembers(t *testing.T) {
249 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
250 path := "/wiki/Whats%20Up%3F"
251 body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
252 wantAll(t, path, body, `<h1>whats-up</h1>`, `There is no page named <code>whats-up</code> yet.`, `<wiki-compose name="whats-up"`, `<th>name</th><td><code>whats-up</code></td>`)
253 wantNone(t, path, body, `role="alert"`)
254 body = wikiGet(t, wikiApp(t, b, ""), path)
255 if !strings.Contains(body, `<a href="/signin?next=`) || !strings.Contains(body, `">Sign in</a> to write it.`) {
256 t.Fatalf("%s missing return-preserving sign-in link", path)
257 }
258 wantNone(t, path, body, "<wiki-compose")
259}
260
261func TestWikiGuestAccessFollowsTheReadsPolicy(t *testing.T) {
262 members := policy.Defaults(wikiOwner)
263 members.Reads = "members"
264 b := &wikiBackend{fakeBackend: fakeBackend{policy: members}, private: true}
265 for _, path := range []string{"/wiki", "/wiki/release-notes-1-4"} {
266 body := wikiGet(t, wikiApp(t, b, ""), path)
267 wantAll(t, path, body, `<p role="alert">auth-required: sign in to read this relay</p>`, `browsing as guest`)
268 wantNone(t, path, body, "<wiki-article>", "<merge-request>", "Release notes 1.4", "<wiki-compose")
269 body = wikiGet(t, wikiApp(t, b, wikiOther), path)
270 wantAll(t, path, body, "Release notes 1.4")
271 wantNone(t, path, body, `role="alert"`)
272 }
273}
274
275const wikiMember = "6666666666666666666666666666666666666666666666666666666666666666"
276
277func TestWikiProposalBarOffersTheDecisionToApprovers(t *testing.T) {
278 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "pending", approver: true, shown: true}
279 path := "/wiki/release-notes-1-4"
280 body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
281 wantAll(t, path, body,
282 `<wiki-proposal data-state="pending">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| pending<nostr-react event="`+wikiOtherV+`" pubkey="`+wikiOther+`" kind="30818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react><small>showing pending revision 2; readers see approved revision 1</small></wiki-proposal>`,
283 `<wiki-article><header>wiki | release-notes-1-4 | revision 2 by <nostr-name pubkey="`+wikiOther+`"`, `A proposed rewrite.`)
284 // A decided proposal names the decision, its time and the deciding key,
285 // and offers no buttons. Readers still see the earlier approved
286 // revision while the edit is rejected, and the edit itself once it is
287 // approved.
288 for _, state := range []string{"approved", "rejected"} {
289 b.proposal = state
290 body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
291 wantAll(t, path, body, `<wiki-proposal data-state="`+state+`">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| `+state+` 2026-09-08 15:40 UTC by <nostr-name pubkey="`+wikiOwner+`"`)
292 wantNone(t, path, body, "<nostr-react", ">Accept<")
293 if state == "rejected" {
294 wantAll(t, path, body, `<small>showing rejected revision 2; readers see approved revision 1</small></wiki-proposal>`)
295 } else {
296 wantNone(t, path, body, "readers see approved revision")
297 }
298 }
299 // A pending proposal that is not the shown version is marked in the
300 // panel and the history, with no bar above the article.
301 b.proposal, b.shown = "pending", false
302 body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
303 wantAll(t, path, body, `<h4>Other versions</h4><ul><li data-state="pending"><a href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork | proposed pending <small>`)
304 wantNone(t, path, body, "<wiki-proposal")
305 body = wikiGet(t, wikiApp(t, b, wikiOwner), path+"?history=1")
306 wantAll(t, path, body, `<tr data-state="pending"><td><nostr-name pubkey="`+wikiOther+`"`, `fork of 444444444444 | proposed pending</td>`)
307}
308
309func TestWikiProposalAuthorSeesTheStateWithoutButtons(t *testing.T) {
310 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "pending", shown: true}
311 path := "/wiki/release-notes-1-4"
312 body := wikiGet(t, wikiApp(t, b, wikiOther), path)
313 wantAll(t, path, body, `<wiki-proposal data-state="pending">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| pending<small>showing pending revision 2; readers see approved revision 1</small></wiki-proposal>`)
314 wantNone(t, path, body, "<nostr-react", ">Accept<", ">Reject<")
315 body = wikiGet(t, wikiApp(t, b, wikiOther), "/wiki")
316 wantAll(t, "/wiki", body, `<tr data-state="pending"><td><a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a> <small>proposed | pending</small></td>`)
317}
318
319func TestWikiProposalStateIsHiddenFromMembers(t *testing.T) {
320 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "approved", shown: true}
321 for _, path := range []string{"/wiki/release-notes-1-4", "/wiki/release-notes-1-4?history=1", "/wiki"} {
322 body := wikiGet(t, wikiApp(t, b, wikiMember), path)
323 wantAll(t, path, body, "Release notes 1.4")
324 wantNone(t, path, body, "<wiki-proposal", "<tr data-state=", "<li data-state=", "proposed | ", "| proposed ", "<nostr-react")
325 }
326}
327
328func TestWikiListMarksProposalRowsForApprovers(t *testing.T) {
329 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "rejected", approver: true, shown: true}
330 body := wikiGet(t, wikiApp(t, b, wikiOwner), "/wiki")
331 wantAll(t, "/wiki", body, `<tr data-state="rejected"><td><a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a> <small>proposed | rejected</small></td><td><nostr-name pubkey="`+wikiOther+`"`)
332 b.proposal = ""
333 body = wikiGet(t, wikiApp(t, b, wikiOwner), "/wiki")
334 wantNone(t, "/wiki", body, "<tr data-state=", "proposed | ")
335}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs