| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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 | |
| 15 | var ( |
| 16 | roomOwner = strings.Repeat("a" , 64) |
| 17 | roomAgent = strings.Repeat("b" , 64) |
| 18 | roomGuest = strings.Repeat("c" , 64) |
| 19 | roomHello = strings.Repeat("1" , 64) |
| 20 | roomChat = strings.Repeat("2" , 64) |
| 21 | roomThread = strings.Repeat("3" , 64) |
| 22 | roomReply = strings.Repeat("4" , 64) |
| 23 | roomLike = strings.Repeat("5" , 64) |
| 24 | ) |
| 25 | |
| 26 | // roomsBackend answers the room browse calls for an open room and a |
| 27 | // members-only room. Guests see the open room only. |
| 28 | type roomsBackend struct { |
| 29 | fakeBackend |
| 30 | calls []string |
| 31 | } |
| 32 | |
| 33 | func roomEvent(id, pubkey string, kind int, createdAt int64, content string, tags ...[]string) map[string]any { |
| 34 | return map[string]any{"id" : id, "pubkey" : pubkey, "kind" : kind, "created_at" : createdAt, "content" : content, "tags" : tags, "sig" : "" } |
| 35 | } |
| 36 | |
| 37 | func roomRecord(id, name, access, role string) map[string]any { |
| 38 | return map[string]any{"id" : id, "name" : name, "about" : "Where " + name + " happens." , "picture" : "" , "access" : access, "created_by" : roomOwner, "created_at" : 1757200000, "event_id" : "" , "members" : 2, "last_message_at" : 1757203600, "role" : role} |
| 39 | } |
| 40 | |
| 41 | func TestRoomInteractionSummariesSurvivePagination(t *testing.T) { |
| 42 | root := RoomMessage{ID: roomThread, PubKey: roomAgent, Kind: 11, Content: "Root" } |
| 43 | page := RoomPage{ |
| 44 | Room: RoomSummary{ID: "general" }, Messages: []RoomMessage{root}, Root: &root, |
| 45 | ReplyCounts: map[string]int{roomThread: 105}, |
| 46 | ReactionCounts: map[string]map[string]int{roomThread: {"+1" : 2, "wave" : 1}}, |
| 47 | } |
| 48 | value := roomPageValueFromContract(page) |
| 49 | items := roomItems(value, "messages" ) |
| 50 | if len(items) != 1 || items[0].Replies != 105 || len(items[0].Reactions) != 2 || items[0].Reactions[0] != (reaction{Content: "+1" , Count: 2}) { |
| 51 | t.Fatalf("folded summaries missing: %#v" , items) |
| 52 | } |
| 53 | if item := roomRoot(value); item.Replies != 105 { |
| 54 | t.Fatalf("thread total counts only the current page: %d" , item.Replies) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func (b *roomsBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 59 | b.calls = append(b.calls, method+":" +actor) |
| 60 | q := map[string]any{} |
| 61 | if len(params) > 0 { |
| 62 | _ = json.Unmarshal(params[0], &q) |
| 63 | } |
| 64 | role := "" |
| 65 | if actor != "" { |
| 66 | role = "owner" |
| 67 | } |
| 68 | id, _ := q["id" ].(string) |
| 69 | switch method { |
| 70 | case "browserooms" : |
| 71 | items := []any{roomRecord("general" , "General" , "open" , role)} |
| 72 | if actor != "" { |
| 73 | items = append(items, roomRecord("build" , "Build" , "members" , role)) |
| 74 | } |
| 75 | return map[string]any{"items" : items, "next_cursor" : "" }, nil |
| 76 | case "browseroom" , "browsethread" : |
| 77 | if id == "build" && actor == "" { |
| 78 | return nil, errors.New("auth-required: this room is members-only" ) |
| 79 | } |
| 80 | if id != "general" && id != "build" { |
| 81 | return nil, errors.New("not found: room" ) |
| 82 | } |
| 83 | default: |
| 84 | return nil, nil |
| 85 | } |
| 86 | members := []any{map[string]any{"pubkey" : roomOwner, "role" : "owner" , "added_at" : 1757200000}, map[string]any{"pubkey" : roomAgent, "role" : "member" , "added_at" : 1757200100, "agent" : true, "operator" : roomOwner}} |
| 87 | root := roomEvent(roomThread, roomAgent, 11, 1757203000, "Release notes draft" , []string{"h" , id}) |
| 88 | reply := roomEvent(roomReply, roomOwner, 12, 1757203300, "Looks good" , []string{"h" , id}, []string{"e" , roomThread}, []string{"p" , roomAgent}) |
| 89 | if method == "browsethread" { |
| 90 | return map[string]any{"room" : roomRecord(id, "General" , "open" , "" ), "root" : root, "replies" : []any{reply}, "next_cursor" : "" }, nil |
| 91 | } |
| 92 | messages := []any{ |
| 93 | roomEvent(strings.Repeat("a" , 64), roomAgent, 40003, 1757203650, "hello again, see https://example.com/docs. and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2" , []string{"h" , id}, []string{"e" , roomHello}), |
| 94 | roomEvent(strings.Repeat("b" , 64), roomOwner, 40003, 1757203660, "not my message" , []string{"h" , id}, []string{"e" , roomHello}), |
| 95 | roomEvent(roomLike, roomOwner, 7, 1757203600, "+" , []string{"h" , id}, []string{"e" , roomChat}), |
| 96 | reply, |
| 97 | root, |
| 98 | roomEvent(roomChat, roomOwner, 9, 1757202000, "@agent take a look <script>alert(1)</script>" , []string{"h" , id}, []string{"p" , roomAgent}, []string{"q" , roomThread, "" , roomAgent}), |
| 99 | roomEvent(roomHello, roomAgent, 9, 1757201000, "hello, see https://example.com/docs. and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2" , []string{"h" , id}), |
| 100 | } |
| 101 | return map[string]any{"room" : roomRecord(id, strings.ToUpper(id[:1])+id[1:], map[string]string{"general" : "open" , "build" : "members" }[id], role), "members" : members, "messages" : messages, "next_cursor" : "cursor-1" }, nil |
| 102 | } |
| 103 | |
| 104 | func roomsApp(t *testing.T, actor string) (*App, *roomsBackend) { |
| 105 | t.Helper() |
| 106 | backend := &roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}} |
| 107 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return actor, nil }}) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | return app, backend |
| 112 | } |
| 113 | |
| 114 | func roomsPage(t *testing.T, app *App, path string) string { |
| 115 | t.Helper() |
| 116 | recorder := httptest.NewRecorder() |
| 117 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) |
| 118 | if recorder.Code != http.StatusOK { |
| 119 | t.Fatalf("%s: status=%d" , path, recorder.Code) |
| 120 | } |
| 121 | body := recorder.Body.String() |
| 122 | if strings.Contains(body, "class=" ) { |
| 123 | t.Errorf("%s carries a class attribute" , path) |
| 124 | } |
| 125 | content := body[strings.Index(body, `id="content"` ):strings.Index(body, `id="aside"` )] |
| 126 | if sections := strings.Count(content, "<section" ); sections != 1 { |
| 127 | t.Errorf("%s renders %d sections in the content column, want 1" , path, sections) |
| 128 | } |
| 129 | return body |
| 130 | } |
| 131 | |
| 132 | func TestRoomsListRendersRoomsRailAndCreateForm(t *testing.T) { |
| 133 | app, backend := roomsApp(t, roomOwner) |
| 134 | body := roomsPage(t, app, "/chat" ) |
| 135 | for _, want := range []string{`<table id="rows">` , `</room-avatar>General</a>` , `</room-avatar>Build</a>` , `members only <small>Where Build happens.</small>` , `<room-create>` , `<a href="/chat"><b>chat</b></a>` , `<nav id="room-list"><a href="/rooms/general"><room-avatar` , `<a href="/chat#new-room">+ new room</a>` , `<p id="rail-tools"><a href="/">← relay</a>` , `<h4>Rooms</h4>` , `<td>2</td>` } { |
| 136 | if !strings.Contains(body, want) { |
| 137 | t.Errorf("rooms list missing %q" , want) |
| 138 | } |
| 139 | } |
| 140 | if backend.calls[0] != "browserooms:" +roomOwner { |
| 141 | t.Fatalf("calls = %v" , backend.calls) |
| 142 | } |
| 143 | guestApp, _ := roomsApp(t, "" ) |
| 144 | body = roomsPage(t, guestApp, "/chat" ) |
| 145 | if strings.Contains(body, "<room-create>" ) || !strings.Contains(body, `<a href="/signin?next=%2Fchat">Sign in</a> to create a room.` ) || strings.Contains(body, `href="/rooms/build"` ) { |
| 146 | t.Fatalf("guest rooms list: %s" , body[strings.Index(body, `id="content"` ):]) |
| 147 | } |
| 148 | // The relay rail lists chat in the conversation group and leaves the |
| 149 | // reliability event views out of the primary navigation. |
| 150 | home := roomsPage(t, app, "/" ) |
| 151 | chat, search := strings.Index(home, `href="/chat"` ), strings.Index(home, `href="/search"` ) |
| 152 | if chat < 0 || search < 0 || search > chat || strings.Contains(home, `href="/inbox"` ) || strings.Contains(home, `href="/outbox"` ) { |
| 153 | t.Fatalf("relay rail order or hidden reliability views: search=%d chat=%d" , search, chat) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) { |
| 158 | app, backend := roomsApp(t, roomOwner) |
| 159 | body := roomsPage(t, app, "/rooms/general" ) |
| 160 | for _, want := range []string{ |
| 161 | `</room-avatar>General <small>open room</small></h1>` , `<p>Where General happens.</p>` , |
| 162 | `<a href="/rooms/general?cursor=cursor-1">load earlier</a>` , |
| 163 | `<room-message id="msg-` + roomHello + `" data-id="` + roomHello + `" data-kind="9" data-updated-at="1757203650" data-pubkey="` + roomAgent + `" data-agent data-edited>` , |
| 164 | `<nostr-avatar pubkey="` + roomAgent + `" aria-hidden="true"><img src="` + avatarURL(roomAgent) + `" alt="" width="32" height="32" loading="lazy"></nostr-avatar>` , |
| 165 | `<room-message id="msg-` + roomChat + `" data-id="` + roomChat + `" data-kind="9" data-updated-at="1757202000" data-pubkey="` + roomOwner + `" data-own><nostr-avatar pubkey="` + roomOwner + `" aria-hidden="true"><img src="` + avatarURL(roomOwner) + `" alt="" width="32" height="32" loading="lazy"></nostr-avatar>` , |
| 166 | `</b><span data-own-label>you</span>` , |
| 167 | `<section id="room" data-viewer="` + roomOwner + `">` , |
| 168 | `<a href="https://example.com/docs" rel="noopener">https://example.com/docs</a>.` , |
| 169 | `<a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1` , |
| 170 | `<blockquote data-quote><a href="/open?target=nostr%3A` + roomThread + `">quoted event ` + shortID(roomThread) + `</a></blockquote>` , |
| 171 | `<script>alert(1)</script>` , |
| 172 | `<span>to <nostr-name pubkey="` + roomAgent + `"` , |
| 173 | `<span data-reaction="+1">+1 1</span>` , |
| 174 | `<div><p>hello again, see <a href="https://example.com/docs" rel="noopener">` , `<span data-edited>edited</span>` , |
| 175 | `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>` , |
| 176 | `<room-live room="general"></room-live>` , |
| 177 | `<room-compose room="general" pubkey="` + roomOwner + `">` , |
| 178 | `<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>` , |
| 179 | `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"` , `<li data-agent data-operator="` + roomOwner + `"><nostr-name pubkey="` + roomAgent + `"` , `<small><nostr-name pubkey="` + roomOwner + `" title="` + roomOwner + `">` + shortID(roomOwner) + `</nostr-name>'s agent</small>` , |
| 180 | `<room-action room="general" kind="9000">` , `<room-action room="general" kind="9002">` , `<room-action room="general" kind="9022">` , |
| 181 | `<nav id="room-list"><a href="/rooms/general" aria-current="page"><room-avatar` , |
| 182 | `» <page-link url="http://relay.example/rooms/general" title="Copy the page address">rooms/general</page-link></span>` , |
| 183 | } { |
| 184 | if !strings.Contains(body, want) { |
| 185 | t.Errorf("room page missing %q" , want) |
| 186 | } |
| 187 | } |
| 188 | hello, chat, thread, reply := strings.Index(body, `id="msg-` +roomHello), strings.Index(body, `id="msg-` +roomChat), strings.Index(body, `id="msg-` +roomThread), strings.Index(body, `id="msg-` +roomReply) |
| 189 | if !(hello < chat && chat < thread && reply == -1) { |
| 190 | t.Fatalf("messages are not oldest first: %d %d %d %d" , hello, chat, thread, reply) |
| 191 | } |
| 192 | if strings.Contains(body, `data-kind="7"` ) || strings.Contains(body, "<script>alert" ) { |
| 193 | t.Fatal("reactions rendered as messages or content unescaped" ) |
| 194 | } |
| 195 | if len(backend.calls) != 2 || backend.calls[0] != "browseroom:" +roomOwner || backend.calls[1] != "browserooms:" +roomOwner { |
| 196 | t.Fatalf("calls = %v" , backend.calls) |
| 197 | } |
| 198 | guestApp, _ := roomsApp(t, "" ) |
| 199 | body = roomsPage(t, guestApp, "/rooms/general" ) |
| 200 | if strings.Contains(body, "<room-compose" ) || strings.Contains(body, "<room-action" ) || !strings.Contains(body, `<a href="/signin?next=%2Frooms%2Fgeneral">Sign in</a> to send messages.` ) || !strings.Contains(body, `id="msg-` +roomHello) { |
| 201 | t.Fatalf("guest room page: %s" , body[strings.Index(body, `id="content"` ):]) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) { |
| 206 | guestApp, _ := roomsApp(t, "" ) |
| 207 | body := roomsPage(t, guestApp, "/rooms/build" ) |
| 208 | if !strings.Contains(body, `<p role="alert">auth-required: this room is members-only</p>` ) || strings.Contains(body, "<room-message" ) || strings.Contains(body, "<room-compose" ) || !strings.Contains(body, `<a href="/signin?next=%2Frooms%2Fbuild">Sign in</a> to read this room.` ) { |
| 209 | t.Fatalf("guest saw a members-only room: %s" , body[strings.Index(body, `id="content"` ):]) |
| 210 | } |
| 211 | if !strings.Contains(body, `<h1>#build</h1>` ) || strings.Contains(body, "Where Build happens" ) { |
| 212 | t.Fatal("guest error page leaked room details" ) |
| 213 | } |
| 214 | app, _ := roomsApp(t, roomOwner) |
| 215 | body = roomsPage(t, app, "/rooms/build" ) |
| 216 | if !strings.Contains(body, `</room-avatar>Build <small>members only</small></h1>` ) || !strings.Contains(body, `id="msg-` +roomHello) { |
| 217 | t.Fatal("owner did not see the members-only room" ) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestThreadPageRendersRootRepliesAndReplyCompose(t *testing.T) { |
| 222 | app, _ := roomsApp(t, roomOwner) |
| 223 | body := roomsPage(t, app, "/rooms/general/thread/" +roomThread) |
| 224 | for _, want := range []string{ |
| 225 | `<p id="crumbs"><a href="/rooms/general"><room-avatar` , `</room-avatar>General</a></p>` , `<h1>Thread <small>1 reply</small></h1>` , |
| 226 | `<div id="root"><room-message id="msg-` + roomThread + `"` , `<div id="messages">` , `id="msg-` + roomReply + `"` , |
| 227 | `data-viewer="` + roomOwner + `"` , `<span data-own-label>you</span>` , |
| 228 | `<room-live room="general" root="` + roomThread + `"></room-live>` , |
| 229 | `<room-compose room="general" pubkey="` + roomOwner + `" kind="12" root="` + roomThread + `" root-pubkey="` + roomAgent + `">` , |
| 230 | `» <page-link url="http://relay.example/rooms/general/thread/` + roomThread + `" title="Copy the page address">rooms/general/thread/` + shortID(roomThread) + `</page-link></span>` , |
| 231 | } { |
| 232 | if !strings.Contains(body, want) { |
| 233 | t.Errorf("thread page missing %q" , want) |
| 234 | } |
| 235 | } |
| 236 | if strings.Contains(body, `">thread</a>` ) || strings.Contains(body, ">in thread</a>" ) { |
| 237 | t.Fatal("thread page links back to itself" ) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestRoomRoutesRejectMalformedPaths(t *testing.T) { |
| 242 | for path, want := range map[string]roomPath{ |
| 243 | "/rooms" : {tab: "rooms" }, |
| 244 | "/rooms/general" : {tab: "room" , id: "general" }, |
| 245 | "/rooms/General" : {}, |
| 246 | "/rooms/a/b" : {}, |
| 247 | "/rooms/a/thread/xyz" : {}, |
| 248 | "/rooms/a/thread/" + roomThread: {tab: "thread" , id: "a" , event: roomThread}, |
| 249 | "/rooms/a/stream" : {}, |
| 250 | } { |
| 251 | if got := roomRoute(path); got != want { |
| 252 | t.Errorf("roomRoute(%q) = %+v, want %+v" , path, got, want) |
| 253 | } |
| 254 | } |
| 255 | app, backend := roomsApp(t, roomOwner) |
| 256 | recorder := httptest.NewRecorder() |
| 257 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/rooms/Not%20A%20Room" , nil)) |
| 258 | for _, call := range backend.calls { |
| 259 | if strings.HasPrefix(call, "browseroom" ) { |
| 260 | t.Fatalf("malformed room path reached the backend: %v" , backend.calls) |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | func TestRoomContentLinksAndEscapes(t *testing.T) { |
| 266 | got := string(roomContent(`<b>hi</b> https://a.example/x?y=1) then nostr:note1abc, done` )) |
| 267 | want := `<b>hi</b> <a href="https://a.example/x?y=1" rel="noopener">https://a.example/x?y=1</a>) then <a href="/open?target=nostr%3Anote1abc">nostr:note1abc</a>, done` |
| 268 | if got != want { |
| 269 | t.Fatalf("roomContent:\n got %s\nwant %s" , got, want) |
| 270 | } |
| 271 | } |