diff --git a/client/client.go b/client/client.go index 16d69e32c..d641e0cca 100644 --- a/client/client.go +++ b/client/client.go @@ -268,14 +268,14 @@ func (c *CSAPI) LeaveRoom(t ct.TestLike, roomID string) *http.Response { return c.Do(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "leave"}, WithJSONBody(t, body)) } -// InviteRoom invites userID to the room ID, else fails the test. +// MustInviteRoom invites userID to the room ID, else fails the test. func (c *CSAPI) MustInviteRoom(t ct.TestLike, roomID string, userID string) { t.Helper() res := c.InviteRoom(t, roomID, userID) mustRespond2xx(t, res) } -// InviteRoom invites userID to the room ID, else fails the test. +// InviteRoom invites userID to the room ID. func (c *CSAPI) InviteRoom(t ct.TestLike, roomID string, userID string) *http.Response { t.Helper() // Invite the user to the room @@ -285,6 +285,39 @@ func (c *CSAPI) InviteRoom(t ct.TestLike, roomID string, userID string) *http.Re return c.Do(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "invite"}, WithJSONBody(t, body)) } +// MustKnockRoom will cause userID to knock on the room ID, else fails the test. +// +// Args: +// - `serverNames`: The list of servers to attempt to knock on the room through. +// These should be a resolvable addresses within the deployment network. +func (c *CSAPI) MustKnockRoom(t ct.TestLike, roomID string, serverNames []spec.ServerName) { + t.Helper() + res := c.KnockRoom(t, roomID, serverNames) + mustRespond2xx(t, res) +} + +// KnockRoom will cause userID to knock on the room ID. +// +// Args: +// - `serverNames`: The list of servers to attempt to knock on the room through. +// These should be a resolvable addresses within the deployment network. +func (c *CSAPI) KnockRoom(t ct.TestLike, roomID string, serverNames []spec.ServerName) *http.Response { + t.Helper() + // construct URL query parameters + serverNameStrings := make([]string, len(serverNames)) + for i, serverName := range serverNames { + serverNameStrings[i] = string(serverName) + } + query := url.Values{ + "via": serverNameStrings, + } + // User knocks on the room + return c.Do( + t, "POST", []string{"_matrix", "client", "v3", "knock", roomID}, + WithQueries(query), WithJSONBody(t, map[string]interface{}{}), + ) +} + func (c *CSAPI) MustGetGlobalAccountData(t ct.TestLike, eventType string) *http.Response { res := c.GetGlobalAccountData(t, eventType) mustRespond2xx(t, res) diff --git a/federation/server.go b/federation/server.go index 3e0ed3cfb..adb3825bf 100644 --- a/federation/server.go +++ b/federation/server.go @@ -18,6 +18,7 @@ import ( "math/big" "net" "net/http" + "net/url" "os" "path" "sync" @@ -26,6 +27,7 @@ import ( "github.com/matrix-org/gomatrix" "github.com/matrix-org/gomatrixserverlib/fclient" "github.com/matrix-org/gomatrixserverlib/spec" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" "github.com/gorilla/mux" @@ -35,6 +37,9 @@ import ( "github.com/matrix-org/complement/config" "github.com/matrix-org/complement/ct" "github.com/matrix-org/complement/internal" + "github.com/matrix-org/complement/match" + "github.com/matrix-org/complement/must" + "github.com/matrix-org/complement/should" ) // Subset of Deployment used in federation @@ -64,8 +69,9 @@ type Server struct { directoryHandlerSetup bool aliases map[string]string - rooms map[string]*ServerRoom - keyRing *gomatrixserverlib.KeyRing + // List of rooms known to this server + rooms map[string]*ServerRoom + keyRing *gomatrixserverlib.KeyRing } // EXPERIMENTAL @@ -357,7 +363,7 @@ func (s *Server) MustCreateEvent(t ct.TestLike, room *ServerRoom, ev Event) goma return pdu } -// MustJoinRoom will make the server send a make_join and a send_join to join a room +// MustJoinRoom will make the server send a /make_join and a /send_join to join a room // It returns the resultant room. // // Args: @@ -443,6 +449,99 @@ func (s *Server) MustJoinRoom(t ct.TestLike, deployment FederationDeployment, re return room } +type knockRoom struct { + strictKnockRoomStateChecks bool +} + +// KnockRoomOpt is an option for configuring how the server should knock on the room +type KnockRoomOpt func(kr *knockRoom) + +// WithStrictKnockRoomStateChecks tells the server to strictly check that the received +// `knock_room_state` is valid according to the spec (c.f. MSC4311). +func WithStrictKnockRoomStateChecks() KnockRoomOpt { + return func(kr *knockRoom) { + kr.strictKnockRoomStateChecks = true + } +} + +// MustKnockRoom will make the server send a /make_knock and a /send_knock to knock on a room +// It returns the resultant room. +// +// Args: +// - `remoteServer`: This should be a resolvable addresses within the deployment network. +func (s *Server) MustKnockRoom( + t ct.TestLike, + deployment FederationDeployment, + remoteServer spec.ServerName, + roomID string, + userID string, + opts ...KnockRoomOpt, +) *ServerRoom { + t.Helper() + var kr knockRoom + for _, opt := range opts { + opt(&kr) + } + + origin := spec.ServerName(s.serverName) + fedClient := s.FederationClient(deployment) + + makeKnockResp, err := fedClient.MakeKnock(context.Background(), origin, remoteServer, roomID, userID, SupportedRoomVersions()) + if err != nil { + ct.Fatalf(t, "MustKnockRoom: make_knock failed: %v", err) + } + + verImpl, err := gomatrixserverlib.GetRoomVersion(makeKnockResp.RoomVersion) + if err != nil { + ct.Fatalf(t, "MustKnockRoom: invalid room version: %v", err) + } + + stateKey := userID + makeKnockResp.KnockEvent.SenderID = userID + makeKnockResp.KnockEvent.StateKey = &stateKey + + eb := verImpl.NewEventBuilderFromProtoEvent(&makeKnockResp.KnockEvent) + knockEvent, err := eb.Build(time.Now(), origin, s.KeyID, s.Priv) + if err != nil { + ct.Fatalf(t, "MustKnockRoom: failed to sign event: %v", err) + } + + // FIXME: Use `fedClient.SendKnock()` once it supports full PDU's vs stripped state + sendKnockPath := "/_matrix/federation/v1/send_knock/" + url.PathEscape(roomID) + "/" + url.PathEscape(knockEvent.EventID()) + sendKnockReq := fclient.NewFederationRequest("PUT", origin, remoteServer, sendKnockPath) + if err := sendKnockReq.SetContent(knockEvent); err != nil { + ct.Fatalf(t, "MustKnockRoom: failed to set send_knock content: %v", err) + } + var rawResponse json.RawMessage + if err := s.SendFederationRequest(context.Background(), t, deployment, sendKnockReq, &rawResponse); err != nil { + ct.Fatalf(t, "MustKnockRoom: send_knock failed: %v", err) + } + knockResponse := gjson.ParseBytes(rawResponse) + + // Strictly check that the received `knock_room_state` is valid according to the spec + // (c.f. MSC4311). + if kr.strictKnockRoomStateChecks { + must.MatchGJSON(t, knockResponse, + match.JSONArraySome("knock_room_state", func(event gjson.Result) error { + // MSC4311 also mandates that `m.room.create` event is required + return should.MatchGJSON(event, match.JSONKeyEqual("type", "m.room.create")) + }), + match.JSONArrayEach("knock_room_state", func(event gjson.Result) error { + // Each event should have extra fields `origin_server_ts` that indicate we're + // seeing a full PDU and not just a "stripped state event" + return should.MatchGJSON(event, match.JSONKeyPresent("origin_server_ts")) + }), + ) + } + + room := NewServerRoom(makeKnockResp.RoomVersion, roomID) + s.rooms[room.RoomID] = room + + t.Logf("Server.MustKnockRoom knocked on room ID %s", room.RoomID) + + return room +} + // Leaves a room. If this is rejecting an invite then a make_leave request is made first, before send_leave. // // Args: diff --git a/match/json.go b/match/json.go index 49926c423..468215dd9 100644 --- a/match/json.go +++ b/match/json.go @@ -308,6 +308,37 @@ func JSONMapEach(wantKey string, fn func(k, v gjson.Result) error) JSON { } } +// JSONArraySome returns a matcher which will check that `wantKey` is an array then +// loops over each item calling `fn`. If `fn` returns nil, the matcher is satisifed, +// iterating stops and we return. +// +// Will fail if the array is empty and the check never runs +func JSONArraySome(wantKey string, fn func(gjson.Result) error) JSON { + return func(body gjson.Result) error { + if wantKey != "" { + body = body.Get(wantKey) + } + + if !body.Exists() { + return fmt.Errorf("JSONArraySome: missing key '%s'", wantKey) + } + if !body.IsArray() { + return fmt.Errorf("JSONArraySome: key '%s' is not an array", wantKey) + } + var satisifed bool = false + body.ForEach(func(_, val gjson.Result) bool { + err := fn(val) + satisifed = err != nil + // Stop iterating when we find a non-error + return !satisifed + }) + if !satisifed { + return fmt.Errorf("JSONArraySome('%s'): unable to find item that satisfies check", wantKey) + } + return nil + } +} + // EXPERIMENTAL // AnyOf takes 1 or more `checkers`, and builds a new checker which accepts a given // json body iff it's accepted by at least one of the original `checkers`. diff --git a/tests/v12_test.go b/tests/v12_test.go index 6139bc6f3..639412b0e 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -18,6 +18,7 @@ import ( "github.com/matrix-org/complement/match" "github.com/matrix-org/complement/must" "github.com/matrix-org/complement/runtime" + "github.com/matrix-org/complement/should" "github.com/matrix-org/gomatrixserverlib" "github.com/matrix-org/gomatrixserverlib/fclient" "github.com/matrix-org/gomatrixserverlib/spec" @@ -1338,39 +1339,285 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { return eventIDs } -func TestMSC4311FullCreateEventOnStrippedState(t *testing.T) { +// MSC4311 mandates that `m.room.create` is a required event in +// `invite_state`/`knock_state` (stripped state) in `/sync responses. +// +// MSC4311 also mentions `invite_room_state`/`knock_room_state` on `m.room.member` +// events but it doesn't seem possible to view this information from the client API's. +// For example, Synapse doesn't have any API's where it sets +// [`include_stripped_room_state=True`](https://github.com/element-hq/synapse/blob/6100f6e4f7fb0c72f1ae2802683ebc811c0e3a77/synapse/events/utils.py#L590-L596) +// when viewing full events. The spec is unclear here so we will hold off on a test for +// this (or adjusting Synapse). +func TestMSC4311StrippedStateClientAPI(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 2) defer deployment.Destroy(t) + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) local := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "local"}) remote := deployment.Register(t, "hs2", helpers.RegistrationOpts{LocalpartSuffix: "remote"}) - roomID := alice.MustCreateRoom(t, map[string]interface{}{ - "room_version": roomVersion12, - "preset": "public_chat", - }) - for _, target := range []*client.CSAPI{local, remote} { - t.Logf("checking %s", target.UserID) - alice.MustInviteRoom(t, roomID, target.UserID) - resp, _ := target.MustSync(t, client.SyncReq{}) - inviteState := resp.Get( - fmt.Sprintf("rooms.invite.%s.invite_state.events", client.GjsonEscape(roomID)), - ) - must.NotEqual(t, len(inviteState.Array()), 0, "no events in invite_state") - // find the create event - found := false - for _, ev := range inviteState.Array() { - if ev.Get("type").Str == spec.MRoomCreate { - found = true - // we should have extra fields - must.MatchGJSON(t, ev, - match.JSONKeyPresent("origin_server_ts"), - ) - } + + t.Run("parallel", func(t *testing.T) { + for _, testCase := range []struct { + label string + csapi *client.CSAPI + }{ + {"local", local}, + {"remote", remote}, + } { + t.Run(fmt.Sprintf("`invite_state` on `/sync` (%s invite)", testCase.label), func(t *testing.T) { + t.Parallel() + + target := testCase.csapi + + // Alice creates a room + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "room_version": roomVersion12, + "preset": "public_chat", + }) + + t.Logf("checking %s", target.UserID) + alice.MustInviteRoom(t, roomID, target.UserID) + + // Make a `/sync` request so we can check `invite_state` + target.MustSyncUntil(t, client.SyncReq{}, func(clientUserID string, topLevelSyncJSON gjson.Result) error { + // Sync until the target sees the invite + if err := client.SyncInvitedTo(target.UserID, roomID)(clientUserID, topLevelSyncJSON); err != nil { + return err + } + + // Then assert that we see the proper `invite_state` + syncInviteStateJSONFieldKey := fmt.Sprintf("rooms.invite.%s.invite_state.events", client.GjsonEscape(roomID)) + err := should.MatchGJSON(topLevelSyncJSON, + match.JSONArraySome(syncInviteStateJSONFieldKey, func(event gjson.Result) error { + // MSC4311 mandates that `m.room.create` event is required in `invite_state` + return should.MatchGJSON(event, match.JSONKeyEqual("type", "m.room.create")) + }), + match.JSONArrayEach(syncInviteStateJSONFieldKey, func(event gjson.Result) error { + // Each event should be using the "stripped state event" format; and *not* have + // extra fields like `origin_server_ts` as those indicate that we're seeing a + // full PDU and not just a "stripped state event". + return should.MatchGJSON(event, match.JSONKeyMissing("origin_server_ts")) + }), + ) + if err != nil { + return err + } + + return nil + }) + + }) } - if !found { - ct.Errorf(t, "failed to find create event in invite_state") + + for _, testCase := range []struct { + label string + csapi *client.CSAPI + }{ + {"local", local}, + {"remote", remote}, + } { + t.Run(fmt.Sprintf("`knock_state` on `/sync` (%s knock)", testCase.label), func(t *testing.T) { + t.Parallel() + + target := testCase.csapi + + // Alice creates a room + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "room_version": roomVersion12, + "preset": "private_chat", + "initial_state": []map[string]interface{}{ + { + "type": "m.room.join_rules", + "state_key": "", + "content": map[string]interface{}{ + "join_rule": "knock", + }, + }, + }, + }) + + t.Logf("checking %s", target.UserID) + target.MustKnockRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + // Make a `/sync` request so we can check `knock_state` + target.MustSyncUntil(t, client.SyncReq{}, func(clientUserID string, topLevelSyncJSON gjson.Result) error { + // Sync until the target sees the knock + if err := client.SyncKnockedOn(target.UserID, roomID)(clientUserID, topLevelSyncJSON); err != nil { + return err + } + + // Then assert that we see the proper `knock_state` + syncKnockStateJSONFieldKey := fmt.Sprintf("rooms.knock.%s.knock_state.events", client.GjsonEscape(roomID)) + err := should.MatchGJSON(topLevelSyncJSON, + match.JSONArraySome(syncKnockStateJSONFieldKey, func(event gjson.Result) error { + // MSC4311 mandates that `m.room.create` event is required in `knock_state` + return should.MatchGJSON(event, match.JSONKeyEqual("type", "m.room.create")) + }), + match.JSONArrayEach(syncKnockStateJSONFieldKey, func(event gjson.Result) error { + // Each event should be using the "stripped state event" format; and *not* have + // extra fields like `origin_server_ts` as those indicate that we're seeing a + // full PDU and not just a "stripped state event". + return should.MatchGJSON(event, match.JSONKeyMissing("origin_server_ts")) + }), + ) + if err != nil { + return err + } + + return nil + }) + + }) } - } + }) +} + +// Alice will invite Bob. Bob's server should receive full PDUs in +// `invite_room_state`/`knock_room_state` (stripped state) over the federation API's +// according to MSC4311. +func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { + runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet + deployment := complement.Deploy(t, 1) + defer deployment.Destroy(t) + + t.Run("parallel", func(t *testing.T) { + // Alice invites Bob (on engineered homeserver) over federation + // + // Make sure Bob can see the full PDU events in `invite_room_state` + t.Run("`invite_room_state`", func(t *testing.T) { + t.Parallel() + // Alice creates a room + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "room_version": roomVersion12, + "preset": "public_chat", + }) + + // Create an engineered homeserver that will listen for the invite and assert + inviteWaiter := helpers.NewWaiter() + srv := federation.NewServer(t, deployment, + federation.HandleKeyRequests(), + ) + // FIXME: Ideally, we'd use `federation.HandleInviteRequests(...)` but it doesn't + // allow us to access the `invite_room_state` yet and requires a bit more refactoring, + // see https://github.com/matrix-org/complement/pull/796#discussion_r2278442857 + // + // Spec: https://spec.matrix.org/v1.18/server-server-api/#put_matrixfederationv2inviteroomideventid + srv.Mux().HandleFunc("/_matrix/federation/v2/invite/{roomID}/{eventID}", srv.ValidFederationRequest(t, func(fr *fclient.FederationRequest, pathParams map[string]string) util.JSONResponse { + t.Logf("Received invite over federation %s", + string(fr.Content()), + ) + + // Invites for an unexpected rooms is an error + roomIDFromURL := pathParams["roomID"] + if roomIDFromURL != roomID { + t.Errorf("Received invite for unexpected room: %s (expected %s)", roomIDFromURL, roomID) + return util.JSONResponse{ + Code: 400, + JSON: "unexpected wrong room", + } + } + // Check to make sure the `invite_room_state` includes full PDUs (the main MSC4311 + // behavior we're trying to test) + inviteResponse := gjson.ParseBytes(fr.Content()) + must.MatchGJSON(t, inviteResponse, + match.JSONArraySome("invite_room_state", func(event gjson.Result) error { + // MSC4311 also mandates that `m.room.create` event is required + return should.MatchGJSON(event, match.JSONKeyEqual("type", "m.room.create")) + }), + match.JSONArrayEach("invite_room_state", func(event gjson.Result) error { + // Each event should have extra fields `origin_server_ts` that indicate we're + // seeing a full PDU and not just a "stripped state event" + return should.MatchGJSON(event, match.JSONKeyPresent("origin_server_ts")) + }), + ) + inviteWaiter.Finish() + + // Craft a response that we can return + rawRoomVersion := inviteResponse.Get("room_version").Raw + rawInviteEventJson := inviteResponse.Get("event").Raw + // Sign the event + var roomVersion gomatrixserverlib.RoomVersion + if err := json.Unmarshal([]byte(rawRoomVersion), &roomVersion); err != nil { + t.Fatalf("failed to parse room version: %s", err) + } + verImpl, err := gomatrixserverlib.GetRoomVersion(roomVersion) + if err != nil { + t.Fatalf("failed to get room version: %s", err) + } + inviteEvent, err := verImpl.NewEventFromUntrustedJSON([]byte(rawInviteEventJson)) + if err != nil { + t.Fatalf("failed to parse invite event: %s", err) + } + signedInvite := inviteEvent.Sign(string(srv.ServerName()), srv.KeyID, srv.Priv) + + return util.JSONResponse{ + Code: 200, + JSON: struct { + Event gomatrixserverlib.PDU `json:"event"` + }{ + Event: signedInvite, + }, + } + })) + // Synapse seems to send `/_matrix/federation/v1/query/profile` requests to us for + // some reason. + srv.UnexpectedRequestsAreErrors = false + cancel := srv.Listen() + defer cancel() + + // Alice invites bob + bob := srv.UserID("bob") + alice.MustInviteRoom(t, roomID, bob) + + // Wait for the invite to go over federation and be validated + inviteWaiter.Wait(t, 5*time.Second) + }) + + // Bob (engineered homeserver) knocks on remote room (Alice's homeserver) + // + // Make sure Bob can see the full PDU events in `knock_room_state` + t.Run("`knock_room_state`", func(t *testing.T) { + t.Parallel() + // Alice creates a room + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "room_version": roomVersion12, + "preset": "private_chat", + "initial_state": []map[string]interface{}{ + { + "type": "m.room.join_rules", + "state_key": "", + "content": map[string]interface{}{ + "join_rule": "knock", + }, + }, + }, + }) + + // Create an engineered homeserver that will knock and assert + srv := federation.NewServer(t, deployment, + federation.HandleKeyRequests(), + ) + cancel := srv.Listen() + defer cancel() + + // Bob knocks on the room + bob := srv.UserID("bob") + _ = srv.MustKnockRoom( + t, deployment, + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), roomID, + bob, + // This does the heavy lifting for us + federation.WithStrictKnockRoomStateChecks(), + ) + + // Sanity check bob actually knocked on the room + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncKnockedOn(bob, roomID)) + }) + }) }