tinyrelayfile
tinyrelay/tinyclient/shell_test.go/

tinyclient/shell_test.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "testing"
11
12 "golang.org/x/net/html"
13
14 "github.com/FelineStateMachine/tinyrelay/internal/policy"
15)
16
17type siteBackend struct {
18 fakeBackend
19 filter map[string]any
20}
21
22func (b *siteBackend) Query(_ context.Context, method string, params []json.RawMessage, _ string) (any, error) {
23 if method != "queryevents" {
24 return nil, nil
25 }
26 _ = json.Unmarshal(params[0], &b.filter)
27 return []any{map[string]any{"id": strings.Repeat("1", 64), "pubkey": "0629c45c1b2820eb649f44a0ab5dd39a16497208eebbe1210b641855c809dbd7", "kind": 15128, "created_at": float64(1757200000), "content": "", "tags": [][]string{{"path", "/index.html", strings.Repeat("c", 64)}}}}, nil
28}
29
30func TestShellRendersRailPanelAndPrompt(t *testing.T) {
31 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
32 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
33 if err != nil {
34 t.Fatal(err)
35 }
36 for path, wants := range map[string][]string{
37 "/": {`id="mark"`, `href="/search"`, `<b>demo</b> &raquo; <page-link url="http://relay.example" title="Copy the page address">home</page-link>`, `id="panel"`, `id="topbar"`, `rel="manifest"`},
38 "/manage/rules": {`<b>manage</b>`, `href="/manage/rules" aria-current="page"`, `manage/rules</page-link></span>`},
39 "/repo?owner=aa&repo=notes&view=history": {`/history</a>`, `repos/notes/history`},
40 } {
41 recorder := httptest.NewRecorder()
42 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
43 body := recorder.Body.String()
44 for _, want := range wants {
45 if !strings.Contains(body, want) {
46 t.Errorf("%s missing %q", path, want)
47 }
48 }
49 if strings.Contains(body, "class=") {
50 t.Errorf("%s carries a class attribute", path)
51 }
52 }
53}
54
55func TestShellNavigationIsProgressiveAndAccessible(t *testing.T) {
56 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
57 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
58 if err != nil {
59 t.Fatal(err)
60 }
61 recorder := httptest.NewRecorder()
62 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
63 body := recorder.Body.String()
64 for _, want := range []string{
65 `data-js="no"`, `id="content" tabindex="-1"`,
66 `id="navigation-status" role="status" aria-live="polite"`,
67 } {
68 if !strings.Contains(body, want) {
69 t.Errorf("shell missing progressive navigation marker %q", want)
70 }
71 }
72 assertSharedNativeMenu(t, body, "/")
73
74 // The menu is part of the shell, so each viewer and rail context retains
75 // one native control and one shared navigation container.
76 for _, path := range []string{"/search", "/social", "/files", "/repo?owner=alice&repo=notes&view=history", "/e/" + strings.Repeat("1", 64), "/manage/rules"} {
77 recorder := httptest.NewRecorder()
78 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
79 if recorder.Code != http.StatusOK {
80 t.Fatalf("%s: status=%d", path, recorder.Code)
81 }
82 assertSharedNativeMenu(t, recorder.Body.String(), path)
83 }
84
85 privatePolicy := policy.Defaults(strings.Repeat("b", 64))
86 privatePolicy.Reads = "members"
87 private := &privateFakeBackend{fakeBackend: &fakeBackend{policy: privatePolicy}, allowed: map[string]bool{}}
88 privateApp, err := New(private, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
89 if err != nil {
90 t.Fatal(err)
91 }
92 recorder = httptest.NewRecorder()
93 privateApp.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/identity", nil))
94 if recorder.Code != http.StatusOK {
95 t.Fatalf("private shell: status=%d", recorder.Code)
96 }
97 assertSharedNativeMenu(t, recorder.Body.String(), "private")
98}
99
100func assertSharedNativeMenu(t *testing.T, body, path string) {
101 t.Helper()
102 doc, err := html.Parse(strings.NewReader(body))
103 if err != nil {
104 t.Fatalf("%s: parse shell: %v", path, err)
105 }
106 var details, summary, rail *html.Node
107 detailsCount, summaryCount, railCount := 0, 0, 0
108 var walk func(*html.Node)
109 walk = func(node *html.Node) {
110 if node.Type == html.ElementNode {
111 switch node.Data {
112 case "details":
113 if attr(node, "id") != "nav-menu" {
114 break
115 }
116 detailsCount++
117 if details == nil {
118 details = node
119 }
120 case "summary":
121 if attr(node, "id") != "menu" {
122 break
123 }
124 summaryCount++
125 if summary == nil {
126 summary = node
127 }
128 case "td":
129 if attr(node, "id") == "rail" {
130 railCount++
131 rail = node
132 }
133 }
134 }
135 for child := node.FirstChild; child != nil; child = child.NextSibling {
136 walk(child)
137 }
138 }
139 walk(doc)
140 if detailsCount != 1 || summaryCount != 1 || railCount != 1 {
141 t.Fatalf("%s: expected one details, summary and rail; got %d, %d, %d", path, detailsCount, summaryCount, railCount)
142 }
143 if hasAttr(details, "open") {
144 t.Errorf("%s: menu details must be nav-menu without open", path)
145 }
146 if summary.Parent != details || attr(summary, "id") != "menu" || attr(summary, "aria-controls") != "rail" {
147 t.Errorf("%s: summary must be the menu details child controlling rail", path)
148 }
149 if attr(rail, "id") != "rail" {
150 t.Errorf("%s: menu target must be the rail", path)
151 }
152}
153
154func attr(node *html.Node, name string) string {
155 for _, attribute := range node.Attr {
156 if attribute.Key == name {
157 return attribute.Val
158 }
159 }
160 return ""
161}
162
163func hasAttr(node *html.Node, name string) bool {
164 for _, attribute := range node.Attr {
165 if attribute.Key == name {
166 return true
167 }
168 }
169 return false
170}
171
172func TestRepositoryHomeRendersReadmeAsMarkdown(t *testing.T) {
173 b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
174 app, err := New(b, Options{})
175 if err != nil {
176 t.Fatal(err)
177 }
178 recorder := httptest.NewRecorder()
179 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repo?owner=alice&repo=notes&view=home", nil))
180 body := recorder.Body.String()
181 if recorder.Code != http.StatusOK || !strings.Contains(body, `id="readme"`) || !strings.Contains(body, "<p>one two</p>") {
182 t.Fatalf("repository home did not render the README: %d %s", recorder.Code, body)
183 }
184}
185
186func TestSitesPageListsHostedSites(t *testing.T) {
187 backend := &siteBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
188 app, err := New(backend, Options{})
189 if err != nil {
190 t.Fatal(err)
191 }
192 recorder := httptest.NewRecorder()
193 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/sites", nil))
194 body := recorder.Body.String()
195 if recorder.Code != http.StatusOK || !strings.Contains(body, `href="http://npub1`) || !strings.Contains(body, ".relay.example") || !strings.Contains(body, "1 paths") {
196 t.Fatalf("sites page did not list the site: %d %s", recorder.Code, body)
197 }
198 if kinds, _ := backend.filter["kinds"].([]any); len(kinds) != 2 {
199 t.Fatalf("sites query kinds = %v", backend.filter["kinds"])
200 }
201}
202
203func TestSitesPagePreservesRelayPortInHostedURL(t *testing.T) {
204 backend := &portSiteBackend{siteBackend: &siteBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}}
205 app, err := New(backend, Options{})
206 if err != nil {
207 t.Fatal(err)
208 }
209 recorder := httptest.NewRecorder()
210 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/sites", nil))
211 if !strings.Contains(recorder.Body.String(), ".relay.example:8787") {
212 t.Fatalf("hosted URL lost relay port: %s", recorder.Body.String())
213 }
214}
215
216type portSiteBackend struct{ *siteBackend }
217
218func (*portSiteBackend) URL() string { return "http://relay.example:8787" }
219
220func TestFeedFiltersNarrowTheQuery(t *testing.T) {
221 filter := map[string]any{"kinds": []int{1, 30023}, "limit": 12}
222 applyFeedFilters(filter, url.Values{"kinds": {"7"}, "author": {strings.Repeat("b", 64)}, "since": {"2026-09-01"}})
223 if kinds, _ := filter["kinds"].([]int); len(kinds) != 1 || kinds[0] != 7 {
224 t.Fatalf("kinds = %v", filter["kinds"])
225 }
226 if authors, _ := filter["authors"].([]string); len(authors) != 1 {
227 t.Fatalf("authors = %v", filter["authors"])
228 }
229 if since, _ := filter["since"].(int64); since != 1788220800 {
230 t.Fatalf("since = %v", filter["since"])
231 }
232 applyFeedFilters(filter, url.Values{"kinds": {"all"}, "author": {"short"}})
233 if _, ok := filter["kinds"]; ok {
234 t.Fatal("kinds were not cleared by all")
235 }
236}
237
238func TestManifestCarriesTenantPrefixAndRelayName(t *testing.T) {
239 p := policy.Defaults(strings.Repeat("a", 64))
240 p.Name = "my relay"
241 backend := &fakeBackend{policy: p}
242 app, err := New(backend, Options{})
243 if err != nil {
244 t.Fatal(err)
245 }
246 recorder := httptest.NewRecorder()
247 request := httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil)
248 request.RequestURI = "/r/alice/manifest.webmanifest"
249 app.ServeHTTP(recorder, request)
250 body := recorder.Body.String()
251 if recorder.Code != http.StatusOK || !strings.Contains(recorder.Header().Get("content-type"), "manifest+json") || !strings.Contains(body, `"start_url": "/r/alice/"`) || !strings.Contains(body, `"name": "my relay"`) || !strings.Contains(body, `"purpose": "monochrome"`) {
252 t.Fatalf("manifest: %d %s", recorder.Code, body)
253 }
254 for _, path := range []string{"/sw.js", "/icon.svg", "/icon-mono.svg"} {
255 recorder := httptest.NewRecorder()
256 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
257 if recorder.Code != http.StatusOK {
258 t.Errorf("%s: %d", path, recorder.Code)
259 }
260 }
261}
262
263func TestPromptPathSpellsOutRepositoriesAndFiles(t *testing.T) {
264 if got := promptPath("/", nil); got != "home" {
265 t.Fatalf("home prompt = %q", got)
266 }
267 if got := promptPath("/repo", url.Values{"repo": {"notes"}, "view": {"file"}}); got != "repos/notes/file" {
268 t.Fatalf("repo prompt = %q", got)
269 }
270 if got := promptPath("/manage/people", nil); got != "manage/people" {
271 t.Fatalf("manage prompt = %q", got)
272 }
273}
274
275type imageBackend struct{ fakeBackend }
276
277func (b *imageBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {
278 if method == "browsefile" {
279 return map[string]any{"sha256": strings.Repeat("9", 64), "type": "image/png", "size": 12, "binary": true}, nil
280 }
281 return nil, nil
282}
283
284func TestFilePagePreviewsImages(t *testing.T) {
285 app, err := New(&imageBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}, Options{})
286 if err != nil {
287 t.Fatal(err)
288 }
289 recorder := httptest.NewRecorder()
290 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/file?sha="+strings.Repeat("9", 64), nil))
291 if body := recorder.Body.String(); !strings.Contains(body, `<img src="/files/raw?hash=`+strings.Repeat("9", 64)+`"`) {
292 t.Fatalf("no image preview: %s", body)
293 }
294}
295
296func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
297 backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}
298 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
299 if err != nil {
300 t.Fatal(err)
301 }
302 for _, path := range []string{"/manage/people", "/manage/agents", "/manage/owner", "/manage/health", "/manage/data"} {
303 recorder := httptest.NewRecorder()
304 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
305 body := recorder.Body.String()
306 if recorder.Code != http.StatusOK || !strings.Contains(body, "Sign in to manage this relay.") || strings.Contains(body, `<rpc-form method="setmember"`) || strings.Contains(body, `id="rail"><div id="railbox"><header><span><a href="/">`) {
307 t.Fatalf("%s exposed management content to a guest: %d %s", path, recorder.Code, body[:min(600, len(body))])
308 }
309 }
310}
311
312type connectionsBackend struct {
313 fakeBackend
314 sharePresence bool
315}
316
317func (b *connectionsBackend) SharePresence(context.Context, string) bool { return b.sharePresence }
318
319func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {
320 if method == "connections" {
321 return []any{map[string]any{"template": "notes", "title": "Notes", "about": "Everything posted here.", "app": "Jumble", "where": "web", "visibility": "public", "links": []any{map[string]any{"label": "Open", "href": "https://jumble.social/?r={relay:url|enc}"}}}}, nil
322 }
323 return nil, nil
324}
325
326func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) {
327 backend := &connectionsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, sharePresence: true}
328 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
329 if err != nil {
330 t.Fatal(err)
331 }
332 recorder := httptest.NewRecorder()
333 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/account", nil))
334 body := recorder.Body.String()
335 for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`} {
336 if !strings.Contains(body, want) {
337 t.Errorf("account page missing %q", want)
338 }
339 }
340 if !strings.Contains(body, `<input type="checkbox" name="share_presence" checked disabled>`) {
341 t.Error("account page did not show saved presence preference")
342 }
343 recorder = httptest.NewRecorder()
344 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))
345 if strings.Contains(recorder.Body.String(), "<relay-lists") {
346 t.Fatal("sign-in page still exposes account relay settings")
347 }
348 if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `customElements.define("relay-lists"`) {
349 t.Error("components script missing relay-lists")
350 }
351 recorder = httptest.NewRecorder()
352 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
353 body = recorder.Body.String()
354 if !strings.Contains(body, "<h2>Connect</h2>") || !strings.Contains(body, `href="https://jumble.social/?r=ws%3A%2F%2Frelay.example"`) || !strings.Contains(body, "<connect-card>") {
355 t.Fatalf("home page did not show connect cards: %s", body[:min(400, len(body))])
356 }
357}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs