1package tinyclient23import (4 "context"5 "encoding/json"6 "errors"7 "net/http"8 "net/http/httptest"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/tinyrelay/internal/policy"13)1415var (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)2526// roomsBackend answers the room browse calls for an open room and a27// members-only room. Guests see the open room only.28type roomsBackend struct {29 fakeBackend30 calls []string31}3233func 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}3637func 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}4041func 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}5758func (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": ""}, nil76 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, nil85 }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": ""}, nil91 }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"}, nil102}103104func 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, backend112}113114func 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 body130}131132func 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>`, `<status-badge kind="members">members only</status-badge> <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 the149 // 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}156157func 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 <status-badge kind="open">open</status-badge></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 + `"`, `<status-badge kind="agent"><nostr-name pubkey="` + roomOwner + `" title="` + roomOwner + `">` + shortID(roomOwner) + `</nostr-name>'s agent</status-badge>`,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}204205func TestMembersOnlyRoomIsHiddenFromGuests(t *testing.T) {206 guestApp, _ := roomsApp(t, "")207 body := roomsPage(t, guestApp, "/rooms/build")208 if !strings.Contains(body, `<p role="alert">This room is members-only. Sign in to read it.</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 <status-badge kind="members">members only</status-badge></h1>`) || !strings.Contains(body, `id="msg-`+roomHello) {217 t.Fatal("owner did not see the members-only room")218 }219}220221func 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}240241func 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}264265func 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}