tinyrelayfile
tinyrelay/tinyclient/rooms_test.go/

tinyclient/rooms_test.go raw
1package tinyclient
2
3import (
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
15var (
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.
28type roomsBackend struct {
29 fakeBackend
30 calls []string
31}
32
33func 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
37func 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
41func 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
58func (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
104func 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
114func 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
132func 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="/">&larr; 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
157func 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 `&lt;script&gt;alert(1)&lt;/script&gt;`,
172 `<span>to <nostr-name pubkey="` + roomAgent + `"`,
173 `<span data-reaction="&#43;1">&#43;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 `&raquo; <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
205func 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
221func 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 `&raquo; <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
241func 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
265func TestRoomContentLinksAndEscapes(t *testing.T) {
266 got := string(roomContent(`<b>hi</b> https://a.example/x?y=1) then nostr:note1abc, done`))
267 want := `&lt;b&gt;hi&lt;/b&gt; <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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs