clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / tinyclient / shell_test.go
shell_test.go 13.7K
1package tinyclient23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "net/url"9 "strings"10 "testing"1112 "golang.org/x/net/html"1314 "github.com/FelineStateMachine/tinyrelay/internal/policy"15)1617type siteBackend struct {18 fakeBackend19 filter map[string]any20}2122func (b *siteBackend) Query(_ context.Context, method string, params []json.RawMessage, _ string) (any, error) {23 if method != "queryevents" {24 return nil, nil25 }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)}}}}, nil28}2930func 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}5455func 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, "/")7374 // The menu is part of the shell, so each viewer and rail context retains75 // 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 }8485 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}99100func 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.Node107 detailsCount, summaryCount, railCount := 0, 0, 0108 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 break115 }116 detailsCount++117 if details == nil {118 details = node119 }120 case "summary":121 if attr(node, "id") != "menu" {122 break123 }124 summaryCount++125 if summary == nil {126 summary = node127 }128 case "td":129 if attr(node, "id") == "rail" {130 railCount++131 rail = node132 }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}153154func attr(node *html.Node, name string) string {155 for _, attribute := range node.Attr {156 if attribute.Key == name {157 return attribute.Val158 }159 }160 return ""161}162163func hasAttr(node *html.Node, name string) bool {164 for _, attribute := range node.Attr {165 if attribute.Key == name {166 return true167 }168 }169 return false170}171172func 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}185186func 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}202203func 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}215216type portSiteBackend struct{ *siteBackend }217218func (*portSiteBackend) URL() string { return "http://relay.example:8787" }219220func 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}237238func 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}262263func 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}274275type imageBackend struct{ fakeBackend }276277func (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}, nil280 }281 return nil, nil282}283284func 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}295296func 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}311312type connectionsBackend struct {313 fakeBackend314 sharePresence bool315}316317func (b *connectionsBackend) SharePresence(context.Context, string) bool { return b.sharePresence }318319func (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}"}}}}, nil322 }323 return nil, nil324}325326func 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}