1package tinyclient23import (4 "context"5 "encoding/json"6 "errors"7 "net/http"8 "net/http/httptest"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/tinyrelay/internal/policy"13)1415const (16 wikiOwner = "1111111111111111111111111111111111111111111111111111111111111111"17 wikiOther = "2222222222222222222222222222222222222222222222222222222222222222"18 wikiMerge = "3333333333333333333333333333333333333333333333333333333333333333"19 wikiOwnerV = "4444444444444444444444444444444444444444444444444444444444444444"20 wikiOtherV = "5555555555555555555555555555555555555555555555555555555555555555"21 // wikiOwnerOld and wikiOtherOld are archived revisions that the22 // current versions replaced.23 wikiOwnerOld = "7777777777777777777777777777777777777777777777777777777777777777"24 wikiOtherOld = "8888888888888888888888888888888888888888888888888888888888888888"25)2627// wikiBackend answers the three wiki queries with one page that has two28// versions and an open merge request from the second author to the owner.29type wikiBackend struct {30 fakeBackend31 calls []string32 params map[string]map[string]any33 private bool34 // proposal makes the second author's version a proposal in this state35 // (pending, approved or rejected), decided by the owner when it is not36 // pending, with an approved earlier revision behind it in the history.37 // approver marks the caller as allowed to decide, and shown makes the38 // proposal the page's shown version.39 proposal string40 approver bool41 shown bool42 // archived gives the owner's version an earlier revision, which a43 // request naming it by version opens.44 archived bool45}4647func (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]any50 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] = object57 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), wikiOwner70 }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 = other75 }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}, nil88 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}, nil93 case "browsewikimerge":94 return map[string]any{"merge": merge, "proposed": other, "target": owner}, nil95 }96 return nil, errors.New("unsupported: " + method)97}9899func 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 app106}107108func 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 body123}124125func 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}133134func 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}142143func 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 <b></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&q=release"`,154 `<b>demo</b> » <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}160161func 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 <b> | wiki | demo</title>`, `<h1>Release notes 1.4 <b></h1>`,170 `<merge-request><nostr-name pubkey="`+wikiOther+`"`, `proposes a new version of this page`, `href="/wiki/release-notes-1-4/proposals/`+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>`, `<script>alert(1)</script>`,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> » <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 forks182 // 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}187188func TestWikiPageCompareEditorAndHistory(t *testing.T) {189 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}190 path := "/wiki/release-notes-1-4/proposals/" + wikiMerge191 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>`)196197 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 <b>"`, `value="release-notes-1-4"`, `value="What changed"`, `<script>alert(1)</script></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")205206 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}212213func 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 = false225 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}229230func TestWikiArchivedRevisionShowsANote(t *testing.T) {231 b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, archived: true}232 path := "/wiki/release-notes-1-4?version=" + wikiOwnerOld233 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}247248func 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}260261func 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">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}274275const wikiMember = "6666666666666666666666666666666666666666666666666666666666666666"276277func 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 approved286 // revision while the edit is rejected, and the edit itself once it is287 // approved.288 for _, state := range []string{"approved", "rejected"} {289 b.proposal = state290 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 the300 // panel and the history, with no bar above the article.301 b.proposal, b.shown = "pending", false302 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}308309func 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 <b></a> <small>proposed | pending</small></td>`)317}318319func 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}327328func 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 <b></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}