| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 12 | ) |
| 13 | |
| 14 | type activityHTTPBackend struct { |
| 15 | roomsBackend |
| 16 | seenActor, seenRoom, seenRoot string |
| 17 | denied bool |
| 18 | } |
| 19 | |
| 20 | func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, root string) (any, error) { |
| 21 | b.seenActor, b.seenRoom, b.seenRoot = actor, room, root |
| 22 | if b.denied { |
| 23 | return nil, errors.New("private details that must stay hidden" ) |
| 24 | } |
| 25 | return map[string]any{"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" }}}, nil |
| 26 | } |
| 27 | |
| 28 | func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) { |
| 29 | b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}} |
| 30 | actor := roomOwner |
| 31 | app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }}) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | req := httptest.NewRequest(http.MethodGet, "/chat/activity?room=general&root=" +roomThread, nil) |
| 36 | req.RequestURI = "/r/team/chat/activity?room=general&root=" + roomThread |
| 37 | res := httptest.NewRecorder() |
| 38 | app.ServeHTTP(res, req) |
| 39 | body := res.Body.String() |
| 40 | if res.Code != 200 || res.Header().Get("Cache-Control" ) != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread { |
| 41 | t.Fatalf("request context: %d %v %#v" , res.Code, res.Header(), b) |
| 42 | } |
| 43 | for _, want := range []string{`<chat-decision` , `href="/r/team/approvals?id=` + roomReply, `Approve <script>bad()</script>` , `<noscript>` } { |
| 44 | if !strings.Contains(body, want) { |
| 45 | t.Errorf("missing %q in %s" , want, body) |
| 46 | } |
| 47 | } |
| 48 | if strings.Contains(body, "class=" ) || strings.Contains(body, "<script>bad()" ) { |
| 49 | t.Fatal("unsafe markup" ) |
| 50 | } |
| 51 | actor = roomGuest |
| 52 | res = httptest.NewRecorder() |
| 53 | app.ServeHTTP(res, req) |
| 54 | if strings.Contains(res.Body.String(), "chat-decision" ) || strings.Contains(res.Body.String(), "Review the preview" ) { |
| 55 | t.Fatal("approval shown to unaddressed actor" ) |
| 56 | } |
| 57 | b.denied = true |
| 58 | res = httptest.NewRecorder() |
| 59 | app.ServeHTTP(res, req) |
| 60 | if res.Code != http.StatusForbidden || strings.Contains(res.Body.String(), "private details" ) { |
| 61 | t.Fatalf("private error leaked: %d %s" , res.Code, res.Body.String()) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestChatActivityEndpointRejectsInvalidRoutesAndWrites(t *testing.T) { |
| 66 | app, _ := roomsApp(t, roomOwner) |
| 67 | for _, test := range []struct { |
| 68 | method, path string |
| 69 | status int |
| 70 | }{ |
| 71 | {http.MethodPost, "/chat/activity?room=general" , 405}, |
| 72 | {http.MethodGet, "/chat/activity?room=../private" , 400}, |
| 73 | {http.MethodGet, "/chat/activity?room=general&root=bad" , 400}, |
| 74 | } { |
| 75 | t.Run(test.method+test.path, func(t *testing.T) { |
| 76 | res := httptest.NewRecorder() |
| 77 | app.ServeHTTP(res, httptest.NewRequest(test.method, test.path, nil)) |
| 78 | if res.Code != test.status { |
| 79 | t.Fatalf("status %d" , res.Code) |
| 80 | } |
| 81 | }) |
| 82 | } |
| 83 | } |