complement/tests/knocking_test.go
Eric Eastwood 6dbf2bd09a
Update knock test utilities to be more generic/useful (#758)
They now match the style of the other utilities under `client`: `mustKnockOnRoomSynced`/`syncKnockedOn`

This is spawning from trying to write some knock tests that also stressed https://github.com/element-hq/synapse/pull/18075 but they didn't pan out. I thought this refactor and update was still useful to upstream in any case.
2025-01-27 10:15:40 -06:00

452 lines
17 KiB
Go

// Knocking is not yet implemented on Dendrite
//go:build !dendrite_blacklist
// +build !dendrite_blacklist
// This file contains Client-Server and Federation API tests for knocking
// https://spec.matrix.org/1.2/client-server-api/#knocking-on-rooms
package tests
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/matrix-org/complement"
"github.com/matrix-org/gomatrixserverlib"
"github.com/tidwall/gjson"
"github.com/matrix-org/complement/b"
"github.com/matrix-org/complement/client"
"github.com/matrix-org/complement/federation"
"github.com/matrix-org/complement/helpers"
"github.com/matrix-org/complement/match"
"github.com/matrix-org/complement/must"
)
// A reason to include in the request body when testing knock reason parameters
const testKnockReason string = "Let me in... LET ME IN!!!"
// TestKnocking tests sending knock membership events and transitioning from knock to other membership states.
// Knocking is currently an experimental feature and not in the matrix spec.
// This function tests knocking on local and remote room.
func TestKnocking(t *testing.T) {
// v7 is required for knocking support
doTestKnocking(t, "7", "knock")
}
func doTestKnocking(t *testing.T, roomVersion string, joinRule string) {
deployment := complement.Deploy(t, 2)
defer deployment.Destroy(t)
// Create a client for one local user
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{})
// Create a client for another local user
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{})
// Create a client for a remote user
charlie := deployment.Register(t, "hs2", helpers.RegistrationOpts{})
// Create a server to observe
inviteWaiter := helpers.NewWaiter()
srv := federation.NewServer(t, deployment,
federation.HandleKeyRequests(),
federation.HandleInviteRequests(func(ev gomatrixserverlib.PDU) {
inviteWaiter.Finish()
}),
federation.HandleTransactionRequests(nil, nil),
)
cancel := srv.Listen()
defer cancel()
srv.UnexpectedRequestsAreErrors = false
david := srv.UserID("david")
// Create a room for alice and bob to test knocking with
roomIDOne := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "private_chat", // Set to private in order to get an invite-only room
"room_version": roomVersion,
})
alice.MustInviteRoom(t, roomIDOne, david)
inviteWaiter.Wait(t, 5*time.Second)
serverRoomOne := srv.MustJoinRoom(t, deployment, "hs1", roomIDOne, david)
// Test knocking between two users on the same homeserver
knockingBetweenTwoUsersTest(t, roomIDOne, alice, bob, serverRoomOne, false, joinRule)
// Create a room for alice and charlie to test knocking with
roomIDTwo := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "private_chat", // Set to private in order to get an invite-only room
"room_version": roomVersion,
})
inviteWaiter = helpers.NewWaiter()
alice.MustInviteRoom(t, roomIDTwo, david)
inviteWaiter.Wait(t, 5*time.Second)
serverRoomTwo := srv.MustJoinRoom(t, deployment, "hs1", roomIDTwo, david)
// Test knocking between two users, each on a separate homeserver
knockingBetweenTwoUsersTest(t, roomIDTwo, alice, charlie, serverRoomTwo, true, joinRule)
}
func knockingBetweenTwoUsersTest(t *testing.T, roomID string, inRoomUser, knockingUser *client.CSAPI, serverRoom *federation.ServerRoom, testFederation bool, joinRule string) {
t.Run("Knocking on a room with a join rule other than 'knock' should fail", func(t *testing.T) {
knockOnRoomWithStatus(t, knockingUser, roomID, "Can I knock anyways?", []string{"hs1"}, 403)
})
t.Run("Change the join rule of a room from 'invite' to 'knock'", func(t *testing.T) {
emptyStateKey := ""
inRoomUser.SendEventSynced(t, roomID, b.Event{
Type: "m.room.join_rules",
Sender: inRoomUser.UserID,
StateKey: &emptyStateKey,
Content: map[string]interface{}{
"join_rule": joinRule,
},
})
})
t.Run("Attempting to join a room with join rule 'knock' without an invite should fail", func(t *testing.T) {
// Set server_name so we can find rooms via ID over federation
res := knockingUser.JoinRoom(t, roomID, []string{"hs1"})
must.MatchResponse(t, res, match.HTTPResponse{
StatusCode: 403,
})
})
t.Run("Knocking on a room with join rule 'knock' should succeed", func(t *testing.T) {
mustKnockOnRoomSynced(t, knockingUser, roomID, testKnockReason, []string{"hs1"})
})
t.Run("A user that has already knocked is allowed to knock again on the same room", func(t *testing.T) {
mustKnockOnRoomSynced(t, knockingUser, roomID, "I really like knock knock jokes", []string{"hs1"})
})
t.Run("Users in the room see a user's membership update when they knock", func(t *testing.T) {
// wait for the membership to arrive over federation
start := time.Now()
knockerState := serverRoom.CurrentState("m.room.member", knockingUser.UserID)
for knockerState == nil && time.Since(start) < 5*time.Second {
time.Sleep(100 * time.Millisecond)
knockerState = serverRoom.CurrentState("m.room.member", knockingUser.UserID)
}
// check the membership seen over the federation
if knockerState == nil {
t.Errorf("Did not get membership state for knocking user")
} else {
m, err := knockerState.Membership()
if err != nil {
t.Errorf("Unable to unpack membership state for knocking user: %v", err)
} else if m != "knock" {
t.Errorf("membership for knocking user: got %#v, want \"knock\"", m)
}
}
inRoomUser.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas(roomID, func(ev gjson.Result) bool {
if ev.Get("type").Str != "m.room.member" || ev.Get("sender").Str != knockingUser.UserID {
return false
}
must.Equal(t, ev.Get("content").Get("reason").Str, testKnockReason, "incorrect reason for knock")
must.Equal(t, ev.Get("content").Get("membership").Str, "knock", "incorrect membership for knocking user")
return true
}))
})
if !testFederation {
// Rescinding a knock over federation is currently not specced
t.Run("A user that has knocked on a local room can rescind their knock and then knock again", func(t *testing.T) {
// We need to carry out an incremental sync after knocking in order to get leave information
// Carry out an initial sync here and save the since token
_, since := knockingUser.MustSync(t, client.SyncReq{TimeoutMillis: "0"})
// Rescind knock
knockingUser.MustLeaveRoom(t, roomID)
// Use our sync token from earlier to carry out an incremental sync. Initial syncs may not contain room
// leave information for obvious reasons
knockingUser.MustSyncUntil(t, client.SyncReq{Since: since}, func(clientUserID string, topLevelSyncJSON gjson.Result) error {
events := topLevelSyncJSON.Get("rooms.leave." + client.GjsonEscape(roomID) + ".timeline.events")
if !events.Exists() {
return fmt.Errorf("no leave section for room %s", roomID)
}
for _, ev := range events.Array() {
if ev.Get("type").Str != "m.room.member" || ev.Get("sender").Str != knockingUser.UserID {
continue
}
must.Equal(t, ev.Get("content").Get("membership").Str, "leave", "expected leave membership after rescinding a knock")
return nil
}
return fmt.Errorf("leave timeline for %s doesn't have leave event for %s", roomID, knockingUser.UserID)
})
// Knock again to return us to the knocked state
mustKnockOnRoomSynced(t, knockingUser, roomID, "Let me in... again?", []string{"hs1"})
})
}
t.Run("A user in the room can reject a knock", func(t *testing.T) {
// Reject the knock. Note that the knocking homeserver will *not* receive the leave
// event over federation due to event validation complications.
//
// In the case of federation, this test will still check that a knock can be
// carried out after a previous knock is rejected.
inRoomUser.MustDo(
t,
"POST",
[]string{"_matrix", "client", "v3", "rooms", roomID, "kick"},
client.WithJSONBody(t, map[string]string{
"user_id": knockingUser.UserID,
"reason": "I don't think so",
}),
)
// Wait until the leave membership event has come down sync
inRoomUser.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas(roomID, func(ev gjson.Result) bool {
return ev.Get("type").Str != "m.room.member" ||
ev.Get("state_key").Str != knockingUser.UserID ||
ev.Get("content").Get("membership").Str != "leave"
}))
// Knock again
mustKnockOnRoomSynced(t, knockingUser, roomID, "Pleeease let me in?", []string{"hs1"})
})
t.Run("A user can knock on a room without a reason", func(t *testing.T) {
// Reject the knock
inRoomUser.MustDo(
t,
"POST",
[]string{"_matrix", "client", "v3", "rooms", roomID, "kick"},
client.WithJSONBody(t, map[string]string{
"user_id": knockingUser.UserID,
"reason": "Please try again",
}),
)
// Knock again, this time without a reason
mustKnockOnRoomSynced(t, knockingUser, roomID, "", []string{"hs1"})
})
t.Run("A user in the room can accept a knock", func(t *testing.T) {
inRoomUser.MustInviteRoom(t, roomID, knockingUser.UserID)
// Wait until the invite membership event has come down sync
inRoomUser.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas(roomID, func(ev gjson.Result) bool {
return ev.Get("type").Str != "m.room.member" ||
ev.Get("state_key").Str != knockingUser.UserID ||
ev.Get("content").Get("membership").Str != "invite"
}))
})
t.Run("A user cannot knock on a room they are already invited to", func(t *testing.T) {
reason := "I'm sticking my hand out the window and knocking again!"
knockOnRoomWithStatus(t, knockingUser, roomID, reason, []string{"hs1"}, 403)
})
t.Run("A user cannot knock on a room they are already in", func(t *testing.T) {
knockingUser.MustJoinRoom(t, roomID, []string{"hs1"})
reason := "I'm sticking my hand out the window and knocking again!"
knockOnRoomWithStatus(t, knockingUser, roomID, reason, []string{"hs1"}, 403)
})
t.Run("A user that is banned from a room cannot knock on it", func(t *testing.T) {
// Ban the user. Note that the knocking homeserver will *not* receive the ban
// event over federation due to event validation complications.
//
// In the case of federation, this test will still check that a knock can not be
// carried out after a ban.
inRoomUser.MustDo(
t,
"POST",
[]string{"_matrix", "client", "v3", "rooms", roomID, "ban"},
client.WithJSONBody(t, map[string]string{
"user_id": knockingUser.UserID,
"reason": "Turns out Bob wasn't that trustworthy after all!",
}),
)
// Wait until the ban membership event has come down sync
inRoomUser.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas(roomID, func(ev gjson.Result) bool {
return ev.Get("type").Str != "m.room.member" ||
ev.Get("state_key").Str != knockingUser.UserID ||
ev.Get("content").Get("membership").Str != "ban"
}))
knockOnRoomWithStatus(t, knockingUser, roomID, "I didn't mean it!", []string{"hs1"}, 403)
})
}
func syncKnockedOn(userID, roomID string) client.SyncCheckOpt {
return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
// two forms which depend on what the client user is:
// - passively viewing a membership for a room you're joined in
// - actively leaving the room
if clientUserID == userID {
events := topLevelSyncJSON.Get("rooms.knock." + client.GjsonEscape(roomID) + ".knock_state.events")
if events.Exists() && events.IsArray() {
// We don't currently define any required state event types to be sent.
// If we've reached this point, then an entry for this room was found
return nil
}
return fmt.Errorf("no knock section for room %s", roomID)
}
// passive
return client.SyncTimelineHas(roomID, func(ev gjson.Result) bool {
return ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "knock"
})(clientUserID, topLevelSyncJSON)
}
}
// mustKnockOnRoomSynced will knock on a given room on the behalf of a user, and block until the knock has persisted.
// serverNames should be populated if knocking on a room that the user's homeserver isn't currently a part of.
// Fails the test if the knock response does not return a 200 status code.
func mustKnockOnRoomSynced(t *testing.T, c *client.CSAPI, roomID, reason string, serverNames []string) {
knockOnRoomWithStatus(t, c, roomID, reason, serverNames, 200)
// The knock should have succeeded. Block until we see the knock appear down sync
c.MustSyncUntil(t, client.SyncReq{}, syncKnockedOn(c.UserID, roomID))
}
// knockOnRoomWithStatus will knock on a given room on the behalf of a user.
// serverNames should be populated if knocking on a room that the user's homeserver isn't currently a part of.
// expectedStatus allows setting an expected status code. If the response code differs, the test will fail.
func knockOnRoomWithStatus(t *testing.T, c *client.CSAPI, roomID, reason string, serverNames []string, expectedStatus int) {
b := []byte("{}")
var err error
if reason != "" {
// Add the reason to the request body
requestBody := struct {
Reason string `json:"reason"`
}{
// We specify a reason here instead of using the same one each time as implementations can
// cache responses to identical requests
reason,
}
b, err = json.Marshal(requestBody)
must.NotError(t, "knockOnRoomWithStatus failed to marshal JSON body", err)
}
// Add any server names to the query parameters
query := url.Values{
"server_name": serverNames,
}
// Knock on the room
res := c.Do(
t,
"POST",
[]string{"_matrix", "client", "v3", "knock", roomID},
client.WithQueries(query),
client.WithRawBody(b),
)
must.MatchResponse(t, res, match.HTTPResponse{
StatusCode: expectedStatus,
})
}
// TestKnockRoomsInPublicRoomsDirectory will create a knock room, attempt to publish it to the public rooms directory,
// and then check that the room appears in the directory. The room's entry should also have a 'join_rule' field
// representing a knock room. For sanity-checking, this test will also create a public room and ensure it has a
// 'join_rule' representing a publicly-joinable room.
func TestKnockRoomsInPublicRoomsDirectory(t *testing.T) {
// v7 is required for knocking
doTestKnockRoomsInPublicRoomsDirectory(t, "7", "knock")
}
func doTestKnockRoomsInPublicRoomsDirectory(t *testing.T, roomVersion string, joinRule string) {
deployment := complement.Deploy(t, 1)
defer deployment.Destroy(t)
// Create a client for a local user
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{})
// Create an invite-only room with the knock room version
roomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "private_chat", // Set to private in order to get an invite-only room
"room_version": roomVersion,
})
// Change the join_rule to allow knocking
emptyStateKey := ""
alice.SendEventSynced(t, roomID, b.Event{
Type: "m.room.join_rules",
Sender: alice.UserID,
StateKey: &emptyStateKey,
Content: map[string]interface{}{
"join_rule": joinRule,
},
})
// Publish the room to the public room directory and check that the 'join_rule' key is knock
publishAndCheckRoomJoinRule(t, alice, roomID, joinRule)
// Create a public room
roomID = alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
"room_version": roomVersion,
})
// Publish the room, and check that the public room directory presents a 'join_rule' key of public
publishAndCheckRoomJoinRule(t, alice, roomID, "public")
}
// publishAndCheckRoomJoinRule will publish a given room ID to the given user's public room directory.
// It will then query the directory and ensure the room is listed, and has a given 'join_rule' entry
func publishAndCheckRoomJoinRule(t *testing.T, c *client.CSAPI, roomID, expectedJoinRule string) {
// Publish the room to the public room directory
c.MustDo(
t,
"PUT",
[]string{"_matrix", "client", "v3", "directory", "list", "room", roomID},
client.WithJSONBody(t, map[string]string{
"visibility": "public",
}),
)
// Check that we can see the room in the directory
c.MustDo(t, "GET", []string{"_matrix", "client", "v3", "publicRooms"},
client.WithRetryUntil(time.Second, func(res *http.Response) bool {
roomFound := false
must.MatchResponse(t, res, match.HTTPResponse{
JSON: []match.JSON{
// For each public room directory chunk (representing a single room entry)
match.JSONArrayEach("chunk", func(r gjson.Result) error {
// If this is our room
if r.Get("room_id").Str == roomID {
roomFound = true
// Check that the join_rule key exists and is as we expect
if roomJoinRule := r.Get("join_rule").Str; roomJoinRule != expectedJoinRule {
return fmt.Errorf(
"'join_rule' key for room in public room chunk is '%s', expected '%s'",
roomJoinRule, expectedJoinRule,
)
}
}
return nil
}),
},
})
// Check that we did in fact see the room
if !roomFound {
t.Logf("Room was not present in public room directory response")
}
return roomFound
}),
)
}
// TestCannotSendNonKnockViaSendKnock checks that we cannot submit anything via /send_knock except a knock
func TestCannotSendNonKnockViaSendKnock(t *testing.T) {
testValidationForSendMembershipEndpoint(t, "/_matrix/federation/v1/send_knock", "knock",
map[string]interface{}{
"preset": "public_chat",
"room_version": "7",
},
)
}