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 activityOpenTask = strings.Repeat("7", 64)1617type activityHTTPBackend struct {18 roomsBackend19 seenActor, seenRoom, seenRoot string20 denied bool21}2223func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, root string) (any, error) {24 b.seenActor, b.seenRoom, b.seenRoot = actor, room, root25 if b.denied {26 return nil, errors.New("private details that must stay hidden")27 }28 job := map[string]any{"id": roomThread, "requester": roomOwner, "assignees": []any{roomAgent}, "subject": "Build the preview", "content": "Build it with the dark palette.", "state": "done", "status": "done", "created_at": 1, "result": map[string]any{"id": roomReply, "provider": roomAgent, "content": "Preview is up.", "artifacts": []any{map[string]any{"type": "r", "value": "https://preview.example/site"}, map[string]any{"type": "e", "value": roomReply}}}}29 open := map[string]any{"id": activityOpenTask, "requester": roomOwner, "assignees": []any{roomAgent, roomGuest}, "subject": "Transcribe the call", "state": "open", "status": "running", "created_at": 2, "progress": map[string]any{"id": strings.Repeat("8", 64), "provider": roomAgent, "content": "Halfway through"}}30 return map[string]any{"jobs": []any{job, open}, "approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview", "type": "question", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}}}}, nil31}3233func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {34 b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}35 actor := roomOwner36 app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})37 if err != nil {38 t.Fatal(err)39 }40 req := httptest.NewRequest(http.MethodGet, "/chat/activity?room=general&root="+roomThread, nil)41 req.RequestURI = "/r/team/chat/activity?room=general&root=" + roomThread42 res := httptest.NewRecorder()43 app.ServeHTTP(res, req)44 body := res.Body.String()45 if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread {46 t.Fatalf("request context: %d %v %#v body=%s", res.Code, res.Header(), b, res.Body.String())47 }48 for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals/` + roomReply, `Approve <script>bad()</script>`, `<noscript>`, `data-activity="job"`, `data-state="done"`, `<strong>Build the preview</strong>`, `data-status="done"`, `<h4>Result</h4>`, `href="https://preview.example/site"`, `href="/r/team/e/` + roomReply + `">Event `, `href="/r/team/e/` + roomThread + `">Open task</a>`,49 `data-status="running">running</small>`, `<p data-progress>Halfway through</p>`, ` for <nostr-name pubkey="` + roomAgent + `"`, `<chat-cancel event="` + activityOpenTask + `" room="general" actor="` + roomOwner + `" assignees="` + roomAgent + ` ` + roomGuest + `"><button type="submit" data-danger>Cancel task</button></chat-cancel>`} {50 if !strings.Contains(body, want) {51 t.Errorf("missing %q in %s", want, body)52 }53 }54 if strings.Contains(body, "class=") || strings.Contains(body, "<script>bad()") {55 t.Fatal("unsafe markup")56 }57 if strings.Contains(body, "<chat-request") || strings.Contains(body, `id="chat-ask"`) {58 t.Fatal("refresh response carried the request form")59 }60 actor = roomGuest61 res = httptest.NewRecorder()62 app.ServeHTTP(res, req)63 if strings.Contains(res.Body.String(), "chat-decision") || strings.Contains(res.Body.String(), "Review the preview") {64 t.Fatal("approval shown to unaddressed actor")65 }66 if strings.Contains(res.Body.String(), "<chat-cancel") || !strings.Contains(res.Body.String(), `data-event="`+activityOpenTask+`"`) {67 t.Fatal("cancel offered to a viewer who did not request the task")68 }69 b.denied = true70 res = httptest.NewRecorder()71 app.ServeHTTP(res, req)72 if res.Code != http.StatusForbidden || strings.Contains(res.Body.String(), "private details") {73 t.Fatalf("private error leaked: %d %s", res.Code, res.Body.String())74 }75}7677func TestChatActivityEndpointRejectsInvalidRoutesAndWrites(t *testing.T) {78 app, _ := roomsApp(t, roomOwner)79 for _, test := range []struct {80 method, path string81 status int82 }{83 {http.MethodPost, "/chat/activity?room=general", 405},84 {http.MethodGet, "/chat/activity?room=../private", 400},85 {http.MethodGet, "/chat/activity?room=general&root=bad", 400},86 } {87 t.Run(test.method+test.path, func(t *testing.T) {88 res := httptest.NewRecorder()89 app.ServeHTTP(res, httptest.NewRequest(test.method, test.path, nil))90 if res.Code != test.status {91 t.Fatalf("status %d", res.Code)92 }93 })94 }95}9697func TestRoomPageRendersTheRequestFormAndCancelWithoutJavaScript(t *testing.T) {98 b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}99 actor := roomOwner100 app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})101 if err != nil {102 t.Fatal(err)103 }104 body := roomsPage(t, app, "/rooms/general")105 form := `<details id="chat-ask"><summary>Ask an agent</summary><chat-request room="general" actor="` + roomOwner + `"><label>Agent <select name="agent" required><option value="` + roomAgent + `">` + roomAgent[:12] + ` (` + roomOwner[:12] + `'s agent)</option></select></label>`106 for _, want := range []string{form, `<input name="subject" maxlength="200"`, `<textarea name="content" required maxlength="8000"`, `<button type="submit" data-primary>Ask</button></chat-request><noscript><p>Publishing needs JavaScript and a connected signer.</p></noscript></details><div id="chat-activity-cards">`, `<chat-cancel event="` + activityOpenTask + `"`} {107 if !strings.Contains(body, want) {108 t.Errorf("room page missing %q", want)109 }110 }111 if strings.Contains(body, `<input name="agent"`) {112 t.Error("room with agent members rendered the free key input")113 }114 thread := roomsPage(t, app, "/rooms/general/thread/"+roomThread)115 if !strings.Contains(thread, `<chat-request room="general" actor="`+roomOwner+`" root="`+roomThread+`">`) {116 t.Error("thread page request form is not scoped to the thread")117 }118 actor = ""119 guest := roomsPage(t, app, "/rooms/general")120 if strings.Contains(guest, `id="chat-ask"`) || strings.Contains(guest, "<chat-request") || strings.Contains(guest, "<chat-cancel") {121 t.Error("signed-out room page offered the form or cancel")122 }123}124125// A backend whose room read lists no agents falls back to a key input.126type activityNoAgentsBackend struct{ activityHTTPBackend }127128func (b *activityNoAgentsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {129 result, err := b.activityHTTPBackend.Query(ctx, method, params, actor)130 if page, ok := result.(map[string]any); ok && method == "browseroom" {131 page["members"] = []any{map[string]any{"pubkey": roomOwner, "role": "owner"}}132 }133 return result, err134}135136func TestRoomPageRequestFormAcceptsAKeyWhenNoAgentIsListed(t *testing.T) {137 b := &activityNoAgentsBackend{activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}}138 app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return roomOwner, nil }})139 if err != nil {140 t.Fatal(err)141 }142 body := roomsPage(t, app, "/rooms/general")143 if !strings.Contains(body, `<label>Agent <input name="agent" required pattern="[0-9a-f]{64}" maxlength="64"`) || strings.Contains(body, `<select name="agent"`) {144 t.Errorf("key input missing: %s", body[strings.Index(body, "chat-ask"):][:400])145 }146}