From e5a5a8739b91f51ce63b844ad5ae4f99bc11db41 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 14 Aug 2025 19:32:19 -0600 Subject: [PATCH 01/16] Try to modify MSC4311 test to meet new proposal --- tests/v12_test.go | 65 +++++++++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 70c86f53..a31b6d22 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1357,39 +1357,54 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { return eventIDs } -func TestMSC4311FullCreateEventOnStrippedState(t *testing.T) { +func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet - deployment := complement.Deploy(t, 2) + deployment := complement.Deploy(t, 1) defer deployment.Destroy(t) + srv := federation.NewServer(t, deployment, + federation.HandleKeyRequests(), + federation.HandleMakeSendJoinRequests(), + federation.HandleTransactionRequests(nil, nil), + federation.HandleEventRequests(), + ) + srv.UnexpectedRequestsAreErrors = false + cancel := srv.Listen() + defer cancel() + + // Alice will invite Bob. Bob's server should receive full PDUs in `invite_room_state`. 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"}) + bob := srv.UserID("bob") + 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"), - ) + srv.Mux().HandleFunc("/_matrix/federation/v2/invite/{roomID}/{eventID}", srv.ValidFederationRequest(t, func(fr *fclient.FederationRequest, pathParams map[string]string) util.JSONResponse { + if pathParams["roomID"] != roomID { + t.Errorf("Received /invite_room_state for the wrong room: %s", roomID) + return util.JSONResponse{ + Code: 400, + JSON: "wrong room", } } - if !found { - ct.Errorf(t, "failed to find create event in invite_state") + inviteRoomState := gjson.ParseBytes(fr.Content()).Get("invite_room_state").Array() + for _, ev := range inviteRoomState { + // should have extra fields + must.MatchGJSON(t, ev, match.JSONKeyPresent("origin_server_ts")) } - } - + invite := []byte(gjson.ParseBytes(fr.Content()).Get("event").Raw) + signed, err := gomatrixserverlib.SignJSON(string(srv.ServerName()), srv.KeyID, srv.Priv, invite) + if err != nil { + t.Fatalf("failed to sign invite: %s", err) + } + return util.JSONResponse{ + Code: 200, + JSON: struct { + Event any `json:"event"` + }{ + Event: signed, + }, + } + })) + alice.MustInviteRoom(t, roomID, bob) } From 35dcccfb16487a7856c6563b691aca483afbe890 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 1 May 2026 18:10:47 -0500 Subject: [PATCH 02/16] More robust test --- tests/v12_test.go | 104 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 20 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index fcd8fd2d..3108a52a 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,41 +1339,61 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { return eventIDs } +// Alice will invite Bob. Bob's server should receive full PDUs in `invite_room_state`. func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 1) defer deployment.Destroy(t) - srv := federation.NewServer(t, deployment, - federation.HandleKeyRequests(), - federation.HandleMakeSendJoinRequests(), - federation.HandleTransactionRequests(nil, nil), - federation.HandleEventRequests(), - ) - srv.UnexpectedRequestsAreErrors = false - cancel := srv.Listen() - defer cancel() - // Alice will invite Bob. Bob's server should receive full PDUs in `invite_room_state`. + // Alice creates a room alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) - bob := srv.UserID("bob") - 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(), + federation.HandleMakeSendJoinRequests(), + federation.HandleTransactionRequests(nil, nil), + federation.HandleEventRequests(), + ) + // 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 srv.Mux().HandleFunc("/_matrix/federation/v2/invite/{roomID}/{eventID}", srv.ValidFederationRequest(t, func(fr *fclient.FederationRequest, pathParams map[string]string) util.JSONResponse { - if pathParams["roomID"] != roomID { - t.Errorf("Received /invite_room_state for the wrong room: %s", roomID) + 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: "wrong room", + JSON: "unexpected wrong room", } } - inviteRoomState := gjson.ParseBytes(fr.Content()).Get("invite_room_state").Array() - for _, ev := range inviteRoomState { - // should have extra fields - must.MatchGJSON(t, ev, match.JSONKeyPresent("origin_server_ts")) - } + + // Check to make sure the `invite_room_state` includes full PDUs (the main MSC4311 + // behavior we're trying to test) + inviteRequest := gjson.ParseBytes(fr.Content()) + must.MatchGJSON(t, inviteRequest, + 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() + invite := []byte(gjson.ParseBytes(fr.Content()).Get("event").Raw) signed, err := gomatrixserverlib.SignJSON(string(srv.ServerName()), srv.KeyID, srv.Priv, invite) if err != nil { @@ -1387,5 +1408,48 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { }, } })) + // 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) +} + + +// 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) match.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 + } } From 96b8d498b334902fe0d3d7a2670f1642ace8677a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 1 May 2026 19:04:32 -0500 Subject: [PATCH 03/16] Add TODO about testing `knock_room_state` --- tests/v12_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 3108a52a..163abb72 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1339,7 +1339,8 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { return eventIDs } -// Alice will invite Bob. Bob's server should receive full PDUs in `invite_room_state`. +// Alice will invite Bob. Bob's server should receive full PDUs in `invite_room_state` +// according to MSC4311 func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 1) @@ -1422,6 +1423,8 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { inviteWaiter.Wait(t, 5*time.Second) } +// TODO: Test `knock_room_state` according to MSC4311 + // 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, From 795a9519d205e15e56d94deca299ea7ede897fe9 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 1 May 2026 20:12:00 -0500 Subject: [PATCH 04/16] Fix raw bytes being encoded as `event` instead of actual JSON --- tests/v12_test.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 163abb72..b5e9f8cc 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1395,17 +1395,30 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { ) inviteWaiter.Finish() - invite := []byte(gjson.ParseBytes(fr.Content()).Get("event").Raw) - signed, err := gomatrixserverlib.SignJSON(string(srv.ServerName()), srv.KeyID, srv.Priv, invite) + // Craft a response that we can return + rawRoomVersion := inviteRequest.Get("room_version").Raw + rawInviteEventJson := inviteRequest.Get("event").Raw + + 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 sign invite: %s", err) + 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 any `json:"event"` }{ - Event: signed, + Event: signedInvite, }, } })) From 47474a0425e1a24644a4960dddccfea80b16a2fb Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 14:18:21 -0500 Subject: [PATCH 05/16] Use more specific type --- tests/v12_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index b5e9f8cc..5881ac3e 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1416,7 +1416,7 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { return util.JSONResponse{ Code: 200, JSON: struct { - Event any `json:"event"` + Event gomatrixserverlib.PDU `json:"event"` }{ Event: signedInvite, }, From 25ced8ba28d1fe0a8f82f5f088bd5d6df71ad122 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 15:33:00 -0500 Subject: [PATCH 06/16] WIP: Add `TestMSC4311CreateEventInStrippedStateClientApi` --- tests/v12_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 5881ac3e..08ed9445 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1339,8 +1339,52 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { return eventIDs } -// Alice will invite Bob. Bob's server should receive full PDUs in `invite_room_state` -// according to MSC4311 +// MSC4311 mandates that `m.room.create` is a required event in +// `invite_state`/`knock_state` (stripped state) in `/sync responses. And in under the +// `unsigned` `invite_room_state`/`knock_room_state` on `m.room.member` events. We're +// testing the client API which should still use stripped state event format. +func TestMSC4311CreateEventInStrippedStateClientApi(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"}) + + // Alice creates a room + 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) + + // Make a `/sync` request so we can check `invite_state` + syncRes, _ := target.MustSync(t, client.SyncReq{}) + syncInviteStateJSONFieldKey := fmt.Sprintf("rooms.invite.%s.invite_state.events", client.GjsonEscape(roomID)) + must.MatchGJSON(t, syncRes, + 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")) + }), + ) + + } +} + + +// 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) From 32566575b892431a37e62ca23bfd0d43d1adb072 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 17:53:30 -0500 Subject: [PATCH 07/16] Use more robust `MustSyncUntil` --- tests/v12_test.go | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 08ed9445..fef91a71 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1343,6 +1343,8 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { // `invite_state`/`knock_state` (stripped state) in `/sync responses. And in under the // `unsigned` `invite_room_state`/`knock_room_state` on `m.room.member` events. We're // testing the client API which should still use stripped state event format. +// +// TODO: Test `knock_state` and `knock_room_state` func TestMSC4311CreateEventInStrippedStateClientApi(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 2) @@ -1363,21 +1365,34 @@ func TestMSC4311CreateEventInStrippedStateClientApi(t *testing.T) { alice.MustInviteRoom(t, roomID, target.UserID) // Make a `/sync` request so we can check `invite_state` - syncRes, _ := target.MustSync(t, client.SyncReq{}) - syncInviteStateJSONFieldKey := fmt.Sprintf("rooms.invite.%s.invite_state.events", client.GjsonEscape(roomID)) - must.MatchGJSON(t, syncRes, - 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")) - }), - ) + 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, + 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 + }) + + // TODO: Check the `m.room.member` `invite_room_state` } } @@ -1385,6 +1400,8 @@ func TestMSC4311CreateEventInStrippedStateClientApi(t *testing.T) { // 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. +// +// TODO: Test `knock_room_state` func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 1) From cabb542368f2a989e43cf0b0020552f7c024d835 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 18:13:50 -0500 Subject: [PATCH 08/16] WIP: Add test for `invite_room_state` in client API But it doesn't seem possible to see. For example, Synapse strips it out when trying to view events, https://github.com/element-hq/synapse/blob/6100f6e4f7fb0c72f1ae2802683ebc811c0e3a77/synapse/events/utils.py#L590-L596 --- tests/v12_test.go | 108 +++++++++++++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index fef91a71..cf5f4222 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1345,7 +1345,7 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { // testing the client API which should still use stripped state event format. // // TODO: Test `knock_state` and `knock_room_state` -func TestMSC4311CreateEventInStrippedStateClientApi(t *testing.T) { +func TestMSC4311StrippedStateClientApi(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 2) defer deployment.Destroy(t) @@ -1354,46 +1354,84 @@ func TestMSC4311CreateEventInStrippedStateClientApi(t *testing.T) { local := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "local"}) remote := deployment.Register(t, "hs2", helpers.RegistrationOpts{LocalpartSuffix: "remote"}) - // Alice creates a room - roomID := alice.MustCreateRoom(t, map[string]interface{}{ - "room_version": roomVersion12, - "preset": "public_chat", - }) + t.Run("parallel", func(t *testing.T) { + t.Run("`invite_state` on `/sync`", func(t *testing.T) { + t.Parallel() - for _, target := range []*client.CSAPI{local, remote} { - t.Logf("checking %s", target.UserID) - alice.MustInviteRoom(t, roomID, target.UserID) + // Alice creates a room + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "room_version": roomVersion12, + "preset": "public_chat", + }) - // 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 - } + for _, target := range []*client.CSAPI{local, remote} { + t.Logf("checking %s", target.UserID) + alice.MustInviteRoom(t, roomID, target.UserID) - // 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, - 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 - } + // 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, + 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 + return nil + }) + + } }) - // TODO: Check the `m.room.member` `invite_room_state` - } + t.Run("`invite_room_state` on `m.room.member`", func(t *testing.T) { + t.Parallel() + + // Alice creates a room + 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) + + // Wait until the invite shows up + target.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(target.UserID, roomID)) + + // Check the `m.room.membership` event + memberInviteContent := alice.MustGetStateEventContent(t, roomID, spec.MRoomMembership, target.UserID) + must.MatchGJSON(t, memberInviteContent, + JSONArraySome("unsigned.invite_room_state", func(event gjson.Result) error { + // MSC4311 mandates that `m.room.create` event is required in `invite_room_state` + return should.MatchGJSON(event, match.JSONKeyEqual("type", "m.room.create")) + }), + match.JSONArrayEach("unsigned.invite_room_state", 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")) + }), + ) + } + }) + }) } From 7171d67356f9eaf4690f3e1c90a55a9e75650815 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 18:19:15 -0500 Subject: [PATCH 09/16] Remove `invite_room_state` client test --- tests/v12_test.go | 104 +++++++++++++++++----------------------------- 1 file changed, 37 insertions(+), 67 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index cf5f4222..61d177b3 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1340,9 +1340,14 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { } // MSC4311 mandates that `m.room.create` is a required event in -// `invite_state`/`knock_state` (stripped state) in `/sync responses. And in under the -// `unsigned` `invite_room_state`/`knock_room_state` on `m.room.member` events. We're -// testing the client API which should still use stripped state event format. +// `invite_state`/`knock_state` (stripped state) in `/sync responses. +// +// MSC4311 also mentions `unsigned` -> `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). // // TODO: Test `knock_state` and `knock_room_state` func TestMSC4311StrippedStateClientApi(t *testing.T) { @@ -1354,83 +1359,48 @@ func TestMSC4311StrippedStateClientApi(t *testing.T) { local := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "local"}) remote := deployment.Register(t, "hs2", helpers.RegistrationOpts{LocalpartSuffix: "remote"}) - t.Run("parallel", func(t *testing.T) { - t.Run("`invite_state` on `/sync`", func(t *testing.T) { - t.Parallel() - - // Alice creates a room - 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) - - // 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 - } + t.Run("`invite_state` on `/sync`", func(t *testing.T) { + t.Parallel() - // 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, - 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 - }) - - } + // Alice creates a room + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "room_version": roomVersion12, + "preset": "public_chat", }) - t.Run("`invite_room_state` on `m.room.member`", func(t *testing.T) { - t.Parallel() - - // Alice creates a room - 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) + for _, target := range []*client.CSAPI{local, remote} { + t.Logf("checking %s", target.UserID) + alice.MustInviteRoom(t, roomID, target.UserID) - // Wait until the invite shows up - target.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(target.UserID, roomID)) + // 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 + } - // Check the `m.room.membership` event - memberInviteContent := alice.MustGetStateEventContent(t, roomID, spec.MRoomMembership, target.UserID) - must.MatchGJSON(t, memberInviteContent, - JSONArraySome("unsigned.invite_room_state", func(event gjson.Result) error { - // MSC4311 mandates that `m.room.create` event is required in `invite_room_state` + // 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, + 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("unsigned.invite_room_state", func(event gjson.Result) error { + 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 + }) + + } }) } From 18d6da2e59a069df0e3ed54c96dd6774bb667c71 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 18:47:30 -0500 Subject: [PATCH 10/16] Remove stray parallel --- tests/v12_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 61d177b3..9cc049bb 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1360,8 +1360,6 @@ func TestMSC4311StrippedStateClientApi(t *testing.T) { remote := deployment.Register(t, "hs2", helpers.RegistrationOpts{LocalpartSuffix: "remote"}) t.Run("`invite_state` on `/sync`", func(t *testing.T) { - t.Parallel() - // Alice creates a room roomID := alice.MustCreateRoom(t, map[string]interface{}{ "room_version": roomVersion12, From 5d61d018cfb210e6bbfec2a6403d9130cfaa1e58 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 May 2026 18:47:54 -0500 Subject: [PATCH 11/16] Better casing --- tests/v12_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 9cc049bb..74459bd9 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1350,7 +1350,7 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { // this (or adjusting Synapse). // // TODO: Test `knock_state` and `knock_room_state` -func TestMSC4311StrippedStateClientApi(t *testing.T) { +func TestMSC4311StrippedStateClientAPI(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 2) defer deployment.Destroy(t) From 633c1a20ba123917b41368995d82e4a20419974a Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 16:12:13 -0500 Subject: [PATCH 12/16] Add client `knock_state` test --- client/client.go | 37 ++++++++++++- tests/v12_test.go | 137 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 131 insertions(+), 43 deletions(-) diff --git a/client/client.go b/client/client.go index 16d69e32..d641e0cc 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/tests/v12_test.go b/tests/v12_test.go index 74459bd9..a46b0107 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1342,14 +1342,12 @@ func asEventIDs(pdus []gomatrixserverlib.PDU) []string { // MSC4311 mandates that `m.room.create` is a required event in // `invite_state`/`knock_state` (stripped state) in `/sync responses. // -// MSC4311 also mentions `unsigned` -> `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 +// 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). -// -// TODO: Test `knock_state` and `knock_room_state` func TestMSC4311StrippedStateClientAPI(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 2) @@ -1359,50 +1357,108 @@ func TestMSC4311StrippedStateClientAPI(t *testing.T) { local := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "local"}) remote := deployment.Register(t, "hs2", helpers.RegistrationOpts{LocalpartSuffix: "remote"}) - t.Run("`invite_state` on `/sync`", func(t *testing.T) { - // Alice creates a room - roomID := alice.MustCreateRoom(t, map[string]interface{}{ - "room_version": roomVersion12, - "preset": "public_chat", - }) + t.Run("parallel", func(t *testing.T) { + t.Run("`invite_state` on `/sync`", func(t *testing.T) { + t.Parallel() - for _, target := range []*client.CSAPI{local, remote} { - t.Logf("checking %s", target.UserID) - alice.MustInviteRoom(t, roomID, target.UserID) + // Alice creates a room + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "room_version": roomVersion12, + "preset": "public_chat", + }) - // 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 - } + for _, target := range []*client.CSAPI{local, remote} { + t.Logf("checking %s", target.UserID) + alice.MustInviteRoom(t, roomID, target.UserID) - // 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, - 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 - } + // 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 + } - return nil + // 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, + 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 + }) + + } + }) + + t.Run("`knock_state` on `/sync`", func(t *testing.T) { + t.Parallel() + + // 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", + }, + }, + }, }) - } + for _, target := range []*client.CSAPI{local, remote} { + 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, + 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. @@ -1505,7 +1561,6 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { // TODO: Test `knock_room_state` according to MSC4311 - // 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. From 55df04e4305092fbae371eda42a0eecac02df6b7 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 17:04:23 -0500 Subject: [PATCH 13/16] Add `knock_room_state` test (`MustKnockRoom` not implemented) --- federation/server.go | 35 ++++++- tests/v12_test.go | 214 ++++++++++++++++++++++++++----------------- 2 files changed, 163 insertions(+), 86 deletions(-) diff --git a/federation/server.go b/federation/server.go index 3e0ed3cf..8fa5020a 100644 --- a/federation/server.go +++ b/federation/server.go @@ -357,7 +357,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 +443,39 @@ 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 { + // TODO + room := NewServerRoom(gomatrixserverlib.RoomVersion("v12"), 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/tests/v12_test.go b/tests/v12_test.go index a46b0107..36887834 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1469,98 +1469,142 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { deployment := complement.Deploy(t, 1) defer deployment.Destroy(t) - // 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(), - federation.HandleMakeSendJoinRequests(), - federation.HandleTransactionRequests(nil, nil), - federation.HandleEventRequests(), - ) - // 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 - 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) - inviteRequest := gjson.ParseBytes(fr.Content()) - must.MatchGJSON(t, inviteRequest, - 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() + 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", + }) - // Craft a response that we can return - rawRoomVersion := inviteRequest.Get("room_version").Raw - rawInviteEventJson := inviteRequest.Get("event").Raw + // 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", + } + } - 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) + // Check to make sure the `invite_room_state` includes full PDUs (the main MSC4311 + // behavior we're trying to test) + inviteRequest := gjson.ParseBytes(fr.Content()) + must.MatchGJSON(t, inviteRequest, + 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 := inviteRequest.Get("room_version").Raw + rawInviteEventJson := inviteRequest.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() + 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) + }) - // Alice invites bob - bob := srv.UserID("bob") - alice.MustInviteRoom(t, roomID, bob) + // 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", + }, + }, + }, + }) - // Wait for the invite to go over federation and be validated - inviteWaiter.Wait(t, 5*time.Second) + // 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(), + ) + }) + }) } -// TODO: Test `knock_room_state` according to MSC4311 - // 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. From 92cdd79a6bd9ac3ef198a137bca91baa5d7e6cc2 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 18:07:23 -0500 Subject: [PATCH 14/16] Implement `srv.MustKnockRoom` --- federation/server.go | 74 +++++++++++++++++++++++++++++++++++++++++--- match/json.go | 31 +++++++++++++++++++ tests/v12_test.go | 48 ++++++---------------------- 3 files changed, 111 insertions(+), 42 deletions(-) diff --git a/federation/server.go b/federation/server.go index 8fa5020a..adb3825b 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 @@ -471,8 +477,68 @@ func (s *Server) MustKnockRoom( userID string, opts ...KnockRoomOpt, ) *ServerRoom { - // TODO - room := NewServerRoom(gomatrixserverlib.RoomVersion("v12"), roomID) + 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 } diff --git a/match/json.go b/match/json.go index 49926c42..468215dd 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 36887834..12c0e8b9 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1381,7 +1381,7 @@ func TestMSC4311StrippedStateClientAPI(t *testing.T) { // 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, - JSONArraySome(syncInviteStateJSONFieldKey, func(event gjson.Result) error { + 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")) }), @@ -1436,7 +1436,7 @@ func TestMSC4311StrippedStateClientAPI(t *testing.T) { // 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, - JSONArraySome(syncKnockStateJSONFieldKey, func(event gjson.Result) error { + 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")) }), @@ -1509,9 +1509,9 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { // Check to make sure the `invite_room_state` includes full PDUs (the main MSC4311 // behavior we're trying to test) - inviteRequest := gjson.ParseBytes(fr.Content()) - must.MatchGJSON(t, inviteRequest, - JSONArraySome("invite_room_state", func(event gjson.Result) error { + 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")) }), @@ -1524,8 +1524,8 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { inviteWaiter.Finish() // Craft a response that we can return - rawRoomVersion := inviteRequest.Get("room_version").Raw - rawInviteEventJson := inviteRequest.Get("event").Raw + 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 { @@ -1601,37 +1601,9 @@ func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { // This does the heavy lifting for us federation.WithStrictKnockRoomStateChecks(), ) - }) - }) -} - -// 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) match.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 + // Sanity check bob actually knocked on the room + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncKnockedOn(bob, roomID)) }) - if !satisifed { - return fmt.Errorf("JSONArraySome('%s'): unable to find item that satisfies check", wantKey) - } - return nil - } + }) } From 2523631c3a9840ba59e3a920fe81050ddfbb9b7b Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 18:15:25 -0500 Subject: [PATCH 15/16] We're doing the TODO now --- tests/v12_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index 12c0e8b9..b313a812 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1462,8 +1462,6 @@ func TestMSC4311StrippedStateClientAPI(t *testing.T) { // 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. -// -// TODO: Test `knock_room_state` func TestMSC4311FullEventsOnStrippedStateFederation(t *testing.T) { runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet deployment := complement.Deploy(t, 1) From b4ab892f1a9ce1eb483a6ee579510713facef9b2 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 22 May 2026 20:22:56 -0500 Subject: [PATCH 16/16] Split up tests --- tests/v12_test.go | 74 ++++++++++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/tests/v12_test.go b/tests/v12_test.go index b313a812..639412b0 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1358,16 +1358,24 @@ func TestMSC4311StrippedStateClientAPI(t *testing.T) { remote := deployment.Register(t, "hs2", helpers.RegistrationOpts{LocalpartSuffix: "remote"}) t.Run("parallel", func(t *testing.T) { - t.Run("`invite_state` on `/sync`", func(t *testing.T) { - t.Parallel() - - // Alice creates a room - roomID := alice.MustCreateRoom(t, map[string]interface{}{ - "room_version": roomVersion12, - "preset": "public_chat", - }) + 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", + }) - for _, target := range []*client.CSAPI{local, remote} { t.Logf("checking %s", target.UserID) alice.MustInviteRoom(t, roomID, target.UserID) @@ -1399,28 +1407,36 @@ func TestMSC4311StrippedStateClientAPI(t *testing.T) { return nil }) - } - }) - - t.Run("`knock_state` on `/sync`", func(t *testing.T) { - t.Parallel() + }) + } - // 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", + 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", + }, }, }, - }, - }) + }) - for _, target := range []*client.CSAPI{local, remote} { t.Logf("checking %s", target.UserID) target.MustKnockRoom(t, roomID, []spec.ServerName{ deployment.GetFullyQualifiedHomeserverName(t, "hs1"), @@ -1454,8 +1470,8 @@ func TestMSC4311StrippedStateClientAPI(t *testing.T) { return nil }) - } - }) + }) + } }) }