Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 182 additions & 20 deletions tests/v12_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1338,39 +1339,200 @@ 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 `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) {
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"})

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",
})

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
}

// 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 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) {
Comment thread
MadLittleMods marked this conversation as resolved.
runtime.SkipIf(t, runtime.Dendrite) // does not implement it yet
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",
})
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)),

// 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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to alternatively use:

	srv := federation.NewServer(t, deployment,
		federation.HandleInviteRequests(func(p gomatrixserverlib.PDU) {
			// checks here
		}),
		federation.HandleKeyRequests(),
		federation.HandleMakeSendJoinRequests(),
		federation.HandleTransactionRequests(nil, nil),
	)

Copy link
Copy Markdown
Collaborator

@MadLittleMods MadLittleMods May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went down this route but the problem is that federation.HandleInviteRequests(...) doesn't allow you to inspect the invite_room_state yet. It would have to be updated to pass in the full invite request to the callback.

And gomatrixserverlib needs to be updated to handle the new world where invite_room_state can be stripped state or full PDUs.

We could have an infallible InviteV2Request.StrippedInviteRoomState() that would give a view of stripped state regardless of whether we were passed stripped or full PDU's (we can derive stripped state from the full PDU's). And then a fallible InviteV2Request.FullInviteRoomState() that would return an error if the homeserver didn't pass us full PDUs.

We would also need to update IRoomVersion to add new fields like CreateEventRequiredInInviteRoomState and FullPDUInviteRoomState so we can conditionally apply this behavior. Or maybe the flags could be combined as MSC4311InviteRoomState

If any of the events are not a PDU, not for the room ID specified, or fail signature checks, or the m.room.create event is missing, the receiving server MAY respond to invites with a 400 M_MISSING_PARAM standard Matrix error (new to the endpoint). For invites to room version 12+ rooms, servers SHOULD rather than MAY respond to such requests with 400 M_MISSING_PARAM.

-- matrix-org/matrix-spec-proposals#4311

t.Logf("Received invite over federation %s",
string(fr.Content()),
)
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"),
)

// 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",
}
}
if !found {
ct.Errorf(t, "failed to find create event in invite_state")

// 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"))
}),
Comment on lines +1457 to +1461
Copy link
Copy Markdown
Collaborator

@MadLittleMods MadLittleMods May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently fails in Synapse because it just pulls invite_room_state from the the unsigned part of the PDU which will be the stripped state client version, see synapse/federation/federation_client.py#L1361

Example flawed /_matrix/federation/v2/invite/{roomID}/{eventID} request body from Synapse
{
  "event": {
    "auth_events": [
      "$3-FVPb1HO_ZyxolbnthZ8qTVEMXyZ6px4qUdrL0exII",
      "$hRMnmEtlMElF2pqbd23ccVSfAIUq-Dqo9kxORAbs2Pk",
      "$luBrxrHgZcSqpdm5UMAXtGKxCvmOwGAHJrZ0iMeWrdw"
    ],
    "content": {
      "membership": "invite"
    },
    "depth": 6,
    "hashes": {
      "sha256": "xBOaaPtS1jAtu/qsWiBIXFZqDdOm6zI8R+RVgXfhm00"
    },
    "origin_server_ts": 1777676386564,
    "prev_events": [
      "$VoTAQZqsV4zWnRi3Uh9n42ioJ4J74UvwMoZuHNiHWU4"
    ],
    "room_id": "!gvlLh8tsK9LSCdF8y56dVfErfUiw7lZnLQ8JTB_kih4",
    "sender": "@user-1-alice:hs1",
    "signatures": {
      "hs1": {
        "ed25519:a_NKNg": "xS/15iTHLn3I65oaTdVRMFBrs+ASbKp9Lm9GM8oLoKlD3ZUwtuBP2pyN/tdUXgraKnRNDU1b3PLxpXYzsLGkCQ"
      }
    },
    "state_key": "@bob:host.docker.internal:44453",
    "type": "m.room.member",
    "unsigned": {
      "age": 11,
      "invite_room_state": [
        {
          "auth_events": [],
          "content": {
            "room_version": "12"
          },
          "depth": 1,
          "hashes": {
            "sha256": "vVkUaUxHUum571Kj4D0vo85iS2dssrPpILSm3XnELpc"
          },
          "origin_server_ts": 1777676385521,
          "prev_events": [],
          "sender": "@user-1-alice:hs1",
          "signatures": {
            "hs1": {
              "ed25519:a_NKNg": "m7pf3WIpaGZEgTR85+L+ewSY88eBXJSGecOXXJmLudEFq4mGxR4MBw6/x9+xMTz4byo+z0a+0h4IcvvSyYhiAA"
            }
          },
          "state_key": "",
          "type": "m.room.create",
          "unsigned": {
            "age_ts": 1777676385521
          }
        },
        {
          "content": {
            "join_rule": "public"
          },
          "sender": "@user-1-alice:hs1",
          "state_key": "",
          "type": "m.room.join_rules"
        },
        {
          "content": {
            "displayname": "user-1-alice",
            "membership": "join"
          },
          "sender": "@user-1-alice:hs1",
          "state_key": "@user-1-alice:hs1",
          "type": "m.room.member"
        }
      ]
    }
  },
  "invite_room_state": [
    {
      "auth_events": [],
      "content": {
        "room_version": "12"
      },
      "depth": 1,
      "hashes": {
        "sha256": "vVkUaUxHUum571Kj4D0vo85iS2dssrPpILSm3XnELpc"
      },
      "origin_server_ts": 1777676385521,
      "prev_events": [],
      "sender": "@user-1-alice:hs1",
      "signatures": {
        "hs1": {
          "ed25519:a_NKNg": "m7pf3WIpaGZEgTR85+L+ewSY88eBXJSGecOXXJmLudEFq4mGxR4MBw6/x9+xMTz4byo+z0a+0h4IcvvSyYhiAA"
        }
      },
      "state_key": "",
      "type": "m.room.create",
      "unsigned": {
        "age_ts": 1777676385521
      }
    },
    {
      "content": {
        "join_rule": "public"
      },
      "sender": "@user-1-alice:hs1",
      "state_key": "",
      "type": "m.room.join_rules"
    },
    {
      "content": {
        "displayname": "user-1-alice",
        "membership": "join"
      },
      "sender": "@user-1-alice:hs1",
      "state_key": "@user-1-alice:hs1",
      "type": "m.room.member"
    }
  ],
  "room_version": "12"
}

Copy link
Copy Markdown
Collaborator

@MadLittleMods MadLittleMods May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test passes with Synapse with the changes from element-hq/synapse#19723

COMPLEMENT_DIR=../complement ./scripts-dev/complement.sh -run TestMSC4311FullEventsOnStrippedStateFederation

)
inviteWaiter.Finish()

// 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)
Comment on lines +1469 to +1471
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better way to get a gomatrixserverlib.RoomVersion from a room version string?

}
}
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)
}

// 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.
//
// Will fail if the array is empty and the check never runs
func JSONArraySome(wantKey string, fn func(gjson.Result) error) match.JSON {
Comment on lines +1509 to +1514
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opinions on whether this is suitable to add to match/json.go. Seems general and good enough to me.

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
}
}
Loading