| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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 | |
| 17 | type siteBackend struct { |
| 18 | fakeBackend |
| 19 | filter map[string]any |
| 20 | } |
| 21 | |
| 22 | func (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 | |
| 30 | func 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> » <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 | |
| 55 | func 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 | |
| 100 | func 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 | |
| 154 | func 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 | |
| 163 | func 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 | |
| 172 | func 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 | |
| 186 | func 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 | |
| 203 | func 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 | |
| 216 | type portSiteBackend struct{ *siteBackend } |
| 217 | |
| 218 | func (*portSiteBackend) URL() string { return "http://relay.example:8787" } |
| 219 | |
| 220 | func 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 | |
| 238 | func 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 | |
| 263 | func 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 | |
| 275 | type imageBackend struct{ fakeBackend } |
| 276 | |
| 277 | func (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 | |
| 284 | func 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 | |
| 296 | func 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 | |
| 312 | type connectionsBackend struct { |
| 313 | fakeBackend |
| 314 | sharePresence bool |
| 315 | } |
| 316 | |
| 317 | func (b *connectionsBackend) SharePresence(context.Context, string) bool { return b.sharePresence } |
| 318 | |
| 319 | func (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 | |
| 326 | func 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 | } |