package clientapi_test import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/dariuszjaniak/warp/server/internal/httputil" ) // createRoom calls POST /createRoom and returns the room ID. func (ts *testServer) createRoom(acc account, body map[string]any) string { ts.t.Helper() rec := ts.do(request{ method: http.MethodPost, path: "/_matrix/client/v3/createRoom", body: body, token: acc.AccessToken, }) expectStatus(ts.t, rec, http.StatusOK) roomID, _ := jsonBody(ts.t, rec)["room_id"].(string) if roomID == "" { ts.t.Fatalf("createRoom returned no room ID: %s", rec.Body.String()) } return roomID } // put is a PUT with a body, which most of the room endpoints are. func (ts *testServer) put(path, token string, body any) *httptest.ResponseRecorder { ts.t.Helper() return ts.do(request{method: http.MethodPut, path: path, body: body, token: token}) } // post is a POST with a body. func (ts *testServer) post(path, token string, body any) *httptest.ResponseRecorder { ts.t.Helper() return ts.do(request{method: http.MethodPost, path: path, body: body, token: token}) } func TestCreateRoomEndpoint(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") rec := ts.do(request{ method: http.MethodPost, path: "/_matrix/client/v3/createRoom", body: map[string]any{ "name": "Test Room", "topic": "Testing", "preset": "public_chat", "visibility": "public", "room_alias_name": "test", }, token: alice.AccessToken, }) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) roomID, _ := body["room_id"].(string) if roomID == "" || roomID[0] != '!' { t.Fatalf("createRoom returned room_id %q", roomID) } if got, _ := body["room_alias"].(string); got != "#test:"+testServerName { t.Errorf("createRoom returned room_alias %q", got) } // A token is required: a room has to be created by somebody. rec = ts.do(request{ method: http.MethodPost, path: "/_matrix/client/v3/createRoom", body: map[string]any{}, }) expectError(t, rec, http.StatusUnauthorized, httputil.ErrCodeMissingToken) // An unknown room version is reported as such, so a client can offer // the user the versions that do exist. rec = ts.do(request{ method: http.MethodPost, path: "/_matrix/client/v3/createRoom", body: map[string]any{"room_version": "99"}, token: alice.AccessToken, }) expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnsupportedVer) // A taken alias is a conflict, not a bad request. rec = ts.do(request{ method: http.MethodPost, path: "/_matrix/client/v3/createRoom", body: map[string]any{"room_alias_name": "test"}, token: alice.AccessToken, }) expectError(t, rec, http.StatusConflict, httputil.ErrCodeRoomInUse) } func TestSendAndReadEvents(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) rec := ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/send/m.room.message/txn1", alice.AccessToken, map[string]any{"msgtype": "m.text", "body": "hello"}) expectStatus(t, rec, http.StatusOK) eventID, _ := jsonBody(t, rec)["event_id"].(string) if eventID == "" || eventID[0] != '$' { t.Fatalf("send returned event_id %q", eventID) } // A retry with the same transaction ID returns the first event. rec = ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/send/m.room.message/txn1", alice.AccessToken, map[string]any{"msgtype": "m.text", "body": "hello"}) expectStatus(t, rec, http.StatusOK) if again, _ := jsonBody(t, rec)["event_id"].(string); again != eventID { t.Errorf("a retried send returned %q, want %q", again, eventID) } rec = ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/event/"+ url.PathEscape(eventID), alice.AccessToken) expectStatus(t, rec, http.StatusOK) fetched := jsonBody(t, rec) if fetched["event_id"] != eventID { t.Errorf("the event came back as %v", fetched["event_id"]) } if fetched["room_id"] != roomID { t.Errorf("the event's room_id is %v, want %q", fetched["room_id"], roomID) } if fetched["sender"] != alice.UserID { t.Errorf("the event's sender is %v, want %q", fetched["sender"], alice.UserID) } // A client is given the ID explicitly and never the signatures: in // every room version since 3 the ID is a hash, not a property, and the // signatures are a server's business. for _, forbidden := range []string{"hashes", "signatures", "auth_events", "prev_events", "depth"} { if _, present := fetched[forbidden]; present { t.Errorf("the client event carries %q", forbidden) } } // An unknown event in a known room is a 404. rec = ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/event/%24nope", alice.AccessToken) expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound) // A malformed room ID is caught before anything is looked up. rec = ts.get("/_matrix/client/v3/rooms/not-a-room/state", alice.AccessToken) expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam) } func TestStateEndpoints(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat", "name": "Original"}) escaped := url.PathEscape(roomID) // The whole state comes back as an array. rec := ts.get("/_matrix/client/v3/rooms/"+escaped+"/state", alice.AccessToken) expectStatus(t, rec, http.StatusOK) state := decode[[]map[string]any](t, rec) if len(state) < 6 { t.Fatalf("the room state has %d events, want the full setup: %s", len(state), rec.Body.String()) } // One piece of state comes back as bare content, which is a quirk of // the Client-Server API that every client relies on. rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.name/", alice.AccessToken) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["name"].(string); got != "Original" { t.Errorf("the state content is %s", rec.Body.String()) } // Without a trailing slash the state key is the empty string too. rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.name", alice.AccessToken) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["name"].(string); got != "Original" { t.Errorf("the state content without a trailing slash is %s", rec.Body.String()) } // ?format=event asks for the whole event instead. rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.name?format=event", alice.AccessToken) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["type"].(string); got != "m.room.name" { t.Errorf("?format=event returned %s", rec.Body.String()) } rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.topic", alice.AccessToken, map[string]any{"topic": "Changed"}) expectStatus(t, rec, http.StatusOK) rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.topic", alice.AccessToken) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["topic"].(string); got != "Changed" { t.Errorf("the topic is %s", rec.Body.String()) } // State this room has never set is a 404. rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.avatar", alice.AccessToken) expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound) // A stranger cannot read the state of a room they are not in. bob := ts.register("bob", "correct-horse-battery-staple") rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state", bob.AccessToken) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) // Nor can they write it. rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.topic", bob.AccessToken, map[string]any{"topic": "Hijacked"}) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) } func TestMembershipEndpoints(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) escaped := url.PathEscape(roomID) rec := ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["room_id"].(string); got != roomID { t.Errorf("join returned room_id %q, want %q", got, roomID) } rec = ts.get("/_matrix/client/v3/joined_rooms", bob.AccessToken) expectStatus(t, rec, http.StatusOK) rooms := decode[struct { JoinedRooms []string `json:"joined_rooms"` }](t, rec) if len(rooms.JoinedRooms) != 1 || rooms.JoinedRooms[0] != roomID { t.Errorf("bob's joined rooms = %v", rooms.JoinedRooms) } rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/joined_members", bob.AccessToken) expectStatus(t, rec, http.StatusOK) joined := decode[struct { Joined map[string]struct { DisplayName string `json:"display_name"` } `json:"joined"` }](t, rec) if len(joined.Joined) != 2 { t.Errorf("joined_members returned %d members, want 2", len(joined.Joined)) } rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/members", bob.AccessToken) expectStatus(t, rec, http.StatusOK) members := decode[struct { Chunk []map[string]any `json:"chunk"` }](t, rec) if len(members.Chunk) != 2 { t.Errorf("members returned %d events, want 2", len(members.Chunk)) } // An invented membership filter is refused rather than ignored. rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/members?membership=lurking", bob.AccessToken) expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeBadJSON) // ?at= names a stream position to report the membership as of; the // zero token is the start of the room, which every member postdates. // TestMembersAtAnEarlierPosition covers what it actually returns. rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/members?at=s0_0", bob.AccessToken) expectStatus(t, rec, http.StatusOK) // Moderation. rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/kick", alice.AccessToken, map[string]any{"user_id": bob.UserID, "reason": "behave"}) expectStatus(t, rec, http.StatusOK) rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/ban", alice.AccessToken, map[string]any{"user_id": bob.UserID}) expectStatus(t, rec, http.StatusOK) // A banned user cannot come back. rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{}) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/unban", alice.AccessToken, map[string]any{"user_id": bob.UserID}) expectStatus(t, rec, http.StatusOK) rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/leave", bob.AccessToken, nil) expectStatus(t, rec, http.StatusOK) // user_id is required where it is the whole point of the call. rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/kick", alice.AccessToken, map[string]any{}) expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeMissingParam) // Moderation without the power is M_FORBIDDEN, decided by the room's // power levels rather than by this handler. carol := ts.register("carol", "correct-horse-battery-staple") rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", carol.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/kick", carol.AccessToken, map[string]any{"user_id": alice.UserID}) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) } func TestInviteEndpoint(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "private_chat"}) escaped := url.PathEscape(roomID) // An invite-only room cannot be walked into. rec := ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{}) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/invite", alice.AccessToken, map[string]any{"user_id": bob.UserID}) expectStatus(t, rec, http.StatusOK) rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) // Inviting a user of another server needs federation, and says so. rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/invite", alice.AccessToken, map[string]any{"user_id": "@eve:elsewhere.example"}) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) if msg, _ := jsonBody(t, rec)["error"].(string); msg == "" { t.Error("the federation refusal carried no explanation") } // A third-party invite goes through an identity server, which this // server does not talk to. rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/invite", alice.AccessToken, map[string]any{"id_server": "id.example", "medium": "email", "address": "a@b.example"}) expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnknown) } func TestRedactEndpoint(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) escaped := url.PathEscape(roomID) rec := ts.put("/_matrix/client/v3/rooms/"+escaped+"/send/m.room.message/t1", alice.AccessToken, map[string]any{"msgtype": "m.text", "body": "oops"}) expectStatus(t, rec, http.StatusOK) eventID, _ := jsonBody(t, rec)["event_id"].(string) rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/redact/"+ url.PathEscape(eventID)+"/r1", alice.AccessToken, map[string]any{"reason": "a mistake"}) expectStatus(t, rec, http.StatusOK) redaction, _ := jsonBody(t, rec)["event_id"].(string) if redaction == "" || redaction == eventID { t.Fatalf("redact returned event_id %q", redaction) } // The event is readable and empty, and says why. rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/event/"+url.PathEscape(eventID), alice.AccessToken) expectStatus(t, rec, http.StatusOK) fetched := jsonBody(t, rec) content, _ := fetched["content"].(map[string]any) if len(content) != 0 { t.Errorf("the redacted event still has content: %v", content) } unsigned, _ := fetched["unsigned"].(map[string]any) because, _ := unsigned["redacted_because"].(map[string]any) if because["event_id"] != redaction { t.Errorf("unsigned.redacted_because = %v, want the redaction %q", because, redaction) } // A malformed event ID in the path is caught before anything else. rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/redact/nope/r2", alice.AccessToken, map[string]any{}) expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam) } func TestDirectoryEndpoints(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) escaped := url.PathEscape(roomID) const alias = "#chat:" + testServerName escapedAlias := url.PathEscape(alias) rec := ts.put("/_matrix/client/v3/directory/room/"+escapedAlias, alice.AccessToken, map[string]any{"room_id": roomID}) expectStatus(t, rec, http.StatusOK) // Resolving an alias needs no token: a client handed a link has to be // able to follow it before it decides whether to log in. rec = ts.get("/_matrix/client/v3/directory/room/"+escapedAlias, "") expectStatus(t, rec, http.StatusOK) resolved := jsonBody(t, rec) if resolved["room_id"] != roomID { t.Errorf("the alias resolves to %v, want %q", resolved["room_id"], roomID) } rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/aliases", alice.AccessToken) expectStatus(t, rec, http.StatusOK) aliases := decode[struct { Aliases []string `json:"aliases"` }](t, rec) if len(aliases.Aliases) != 1 || aliases.Aliases[0] != alias { t.Errorf("the room's aliases = %v", aliases.Aliases) } // An unknown alias is a 404. rec = ts.get("/_matrix/client/v3/directory/room/"+url.PathEscape("#nope:"+testServerName), "") expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound) // The public room directory. rec = ts.get("/_matrix/client/v3/publicRooms", "") expectStatus(t, rec, http.StatusOK) if total, _ := jsonBody(t, rec)["total_room_count_estimate"].(float64); total != 0 { t.Errorf("an unpublished room is in the directory (total %v)", total) } rec = ts.put("/_matrix/client/v3/directory/list/room/"+escaped, alice.AccessToken, map[string]any{"visibility": "public"}) expectStatus(t, rec, http.StatusOK) rec = ts.get("/_matrix/client/v3/directory/list/room/"+escaped, "") expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["visibility"].(string); got != "public" { t.Errorf("visibility = %q, want public", got) } rec = ts.get("/_matrix/client/v3/publicRooms?limit=10", "") expectStatus(t, rec, http.StatusOK) directory := decode[struct { Chunk []map[string]any `json:"chunk"` Total int `json:"total_room_count_estimate"` }](t, rec) if directory.Total != 1 || len(directory.Chunk) != 1 { t.Fatalf("the directory has %d of %d rooms, want 1 of 1", len(directory.Chunk), directory.Total) } if directory.Chunk[0]["room_id"] != roomID { t.Errorf("the directory lists %v, want %q", directory.Chunk[0]["room_id"], roomID) } // The POST form takes a filter. rec = ts.post("/_matrix/client/v3/publicRooms", alice.AccessToken, map[string]any{ "filter": map[string]any{"generic_search_term": "nothing matches this"}, }) expectStatus(t, rec, http.StatusOK) filtered := decode[struct { Chunk []map[string]any `json:"chunk"` }](t, rec) if len(filtered.Chunk) != 0 { t.Errorf("a search that matches nothing returned %d rooms", len(filtered.Chunk)) } // An ordinary member cannot publish or unpublish the room. rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) rec = ts.put("/_matrix/client/v3/directory/list/room/"+escaped, bob.AccessToken, map[string]any{"visibility": "private"}) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) // Another server's directory needs federation. rec = ts.get("/_matrix/client/v3/publicRooms?server=other.example", "") expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) // A bad limit is a bad request rather than a default. rec = ts.get("/_matrix/client/v3/publicRooms?limit=lots", "") expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam) } // TestJoinByAlias is the endpoint a client uses when all it was given is a // link. func TestJoinByAlias(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{ "preset": "public_chat", "room_alias_name": "open", }) alias := "#open:" + testServerName rec := ts.post("/_matrix/client/v3/join/"+url.PathEscape(alias), bob.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["room_id"].(string); got != roomID { t.Errorf("joining by alias returned %q, want %q", got, roomID) } // The same endpoint takes a room ID. rec = ts.post("/_matrix/client/v3/join/"+url.PathEscape(roomID), bob.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) // Anything that is neither is a bad request. rec = ts.post("/_matrix/client/v3/join/nonsense", bob.AccessToken, map[string]any{}) expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam) // A room on another server needs federation. rec = ts.post("/_matrix/client/v3/join/"+url.PathEscape("!x:other.example"), bob.AccessToken, map[string]any{}) expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden) } // TestStateKeyWithSpecialCharacters is about the router, not the rooms: a // membership state key is a user ID, so it carries an '@' and a ':', and // the empty state key arrives in two spellings. All of them have to route. func TestStateKeyWithSpecialCharacters(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) escaped := url.PathEscape(roomID) rec := ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{}) expectStatus(t, rec, http.StatusOK) rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.member/"+ url.PathEscape(bob.UserID), alice.AccessToken) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["membership"].(string); got != "join" { t.Errorf("bob's membership content is %s", rec.Body.String()) } // And writing one back through the state endpoint. rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.member/"+ url.PathEscape(bob.UserID), alice.AccessToken, map[string]any{"membership": "ban"}) expectStatus(t, rec, http.StatusOK) rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.member/"+ url.PathEscape(bob.UserID), alice.AccessToken) expectStatus(t, rec, http.StatusOK) if got, _ := jsonBody(t, rec)["membership"].(string); got != "ban" { t.Errorf("bob's membership after the ban is %s", rec.Body.String()) } }