diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 426c491bc..a2c4afc15 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,8 +28,8 @@ jobs: DOCKER_BUILDKIT: 1 run: | mkdir -p homeserver - # Latest official dendrite release is still using Debian Stretch as a base, hence the specific commit - wget -O - "https://github.com/matrix-org/dendrite/archive/0489d16f95a3d9f1f5bc532e2060bd2482d7b156.tar.gz" | tar -xz --strip-components=1 -C homeserver + # Dendrite is unmaintained to just pin to a known working commit + wget -O - "https://github.com/element-hq/dendrite/archive/08cac1ccf0a45471132ad24a29e3ec15643675fa.tar.gz" | tar -xz --strip-components=1 -C homeserver (cd homeserver && docker build -t complement-dendrite -f build/scripts/Complement.Dockerfile .) (cd cmd/homerunner/test && ./test.sh) diff --git a/.github/workflows/zizmor.yaml b/.github/workflows/zizmor.yaml new file mode 100644 index 000000000..e623e3370 --- /dev/null +++ b/.github/workflows/zizmor.yaml @@ -0,0 +1,24 @@ +# Taken from https://github.com/zizmorcore/zizmor-action/blob/f72bf176f67e8007f87b16d80f9880ece648aa65/README.md +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +permissions: {} + +jobs: + zizmor: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 diff --git a/client/client.go b/client/client.go index 62b818972..0a687acfb 100644 --- a/client/client.go +++ b/client/client.go @@ -303,14 +303,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 @@ -320,6 +320,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 address 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 address 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 078ee2b73..8f473a6fb 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 address 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/go.mod b/go.mod index 34b94eb2a..659a31533 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,18 @@ module github.com/matrix-org/complement -go 1.25.0 +go 1.26.0 require ( github.com/gorilla/mux v1.8.1 github.com/matrix-org/gomatrix v0.0.0-20220926102614-ceba4d9f7530 github.com/matrix-org/gomatrixserverlib v0.0.0-20260716140101-4fe595dc7f58 github.com/matrix-org/util v0.0.0-20221111132719-399730281e66 - github.com/moby/moby/api v1.55.0 - github.com/moby/moby/client v0.5.1 - github.com/sirupsen/logrus v1.10.1 + github.com/moby/moby/api v1.56.0 + github.com/moby/moby/client v0.6.0 + github.com/sirupsen/logrus v1.10.2 github.com/tidwall/gjson v1.19.0 github.com/tidwall/sjson v1.2.5 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 gonum.org/v1/plot v0.17.0 ) diff --git a/go.sum b/go.sum index a04420666..67949defe 100644 --- a/go.sum +++ b/go.sum @@ -61,10 +61,10 @@ github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= -github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= -github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= github.com/oleiade/lane/v2 v2.0.0 h1:XW/ex/Inr+bPkLd3O240xrFOhUkTd4Wy176+Gv0E3Qw= github.com/oleiade/lane/v2 v2.0.0/go.mod h1:i5FBPFAYSWCgLh58UkUGCChjcCzef/MI7PlQm2TKCeg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -73,10 +73,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/shoenig/test v1.11.0 h1:NoPa5GIoBwuqzIviCrnUJa+t5Xb4xi5Z+zODJnIDsEQ= github.com/shoenig/test v1.11.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= -github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= -github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -102,11 +102,13 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= @@ -149,8 +151,6 @@ gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index ad453aabd..40a6a9a7d 100644 --- a/internal/docker/deployer.go +++ b/internal/docker/deployer.go @@ -17,7 +17,6 @@ import ( "archive/tar" "bytes" "context" - "crypto/tls" "fmt" "log" "net/http" @@ -741,11 +740,5 @@ func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req.URL.Host = newURL.Host } req.URL.Scheme = "https" - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - ServerName: hsName, - InsecureSkipVerify: true, - }, - } - return transport.RoundTrip(req) + return t.Deployment.transportFor(hsName).RoundTrip(req) } diff --git a/internal/docker/deployment.go b/internal/docker/deployment.go index a3f3313d9..0f2f9fc61 100644 --- a/internal/docker/deployment.go +++ b/internal/docker/deployment.go @@ -1,6 +1,7 @@ package docker import ( + "crypto/tls" "fmt" "net/http" "sync" @@ -28,6 +29,31 @@ type Deployment struct { HS map[string]*HomeserverDeployment Config *config.Complement localpartCounter atomic.Int64 + // HTTP transports used by RoundTripper, keyed by homeserver. + // If we don't re-use transports, tests which speak federation to the homeserver will leak file descriptors (fd) + // for a period of time (the IdleConnTimeout) which can then exceed the fd limit on some runtimes e.g. macOS has + // a conservative 256 fd limit by default. This manifests as obscure errors like "Invalid request signature" + // because the `/key/server` request performed by gomatrixserverlib fails. + transports sync.Map +} + +// transportFor returns the (shared) HTTP transport used to talk to the given homeserver. +func (d *Deployment) transportFor(hsName string) *http.Transport { + if t, ok := d.transports.Load(hsName); ok { + return t.(*http.Transport) + } + t, _ := d.transports.LoadOrStore(hsName, &http.Transport{ + TLSClientConfig: &tls.Config{ + ServerName: hsName, + InsecureSkipVerify: true, + }, + // Explicitly set the max idle conns per host to ensure we bound how many file descriptors we use per-server. + MaxIdleConnsPerHost: 2, + // Set a limit for how long connections can remain idle (and consume file descriptors) for. + // By default this is 0 meaning unlimited. + IdleConnTimeout: 30 * time.Second, + }) + return t.(*http.Transport) } // HomeserverDeployment represents a running homeserver in a container. diff --git a/match/json.go b/match/json.go index 49926c423..a149dcf11 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 satisfied bool = false + body.ForEach(func(_, val gjson.Result) bool { + err := fn(val) + satisfied = err == nil + // Stop iterating when we find a non-error + return !satisfied + }) + if !satisfied { + 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/match/json_test.go b/match/json_test.go new file mode 100644 index 000000000..81f71b395 --- /dev/null +++ b/match/json_test.go @@ -0,0 +1,67 @@ +package match + +import ( + "fmt" + "testing" + + "github.com/tidwall/gjson" +) + +func TestJSONArraySome(t *testing.T) { + t.Run("parallel", func(t *testing.T) { + for _, testCase := range []struct { + name string + jsonString string + wantErr bool + }{ + { + name: "passes when target is first", + jsonString: `{ "test": [1,3,5] }`, + wantErr: false, + }, + { + name: "passes when target is last", + jsonString: `{ "test": [1,2,3] }`, + wantErr: false, + }, + { + name: "passes when target is in the middle", + jsonString: `{ "test": [1,3,5] }`, + wantErr: false, + }, + { + name: "fails when target not in array", + jsonString: `{ "test": [1,5,10] }`, + wantErr: true, + }, + { + name: "fails when not array", + jsonString: `{ "test": 3 }`, + wantErr: true, + }, + { + name: "fails when missing key", + jsonString: `{ }`, + wantErr: true, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + matcher := JSONArraySome("test", func(field gjson.Result) error { + value := field.Int() + if value == 3 { + // Found target + return nil + } + return fmt.Errorf("Expected to find target 3, found '%d'", value) + }) + + err := matcher(gjson.Parse(testCase.jsonString)) + if (err != nil) != testCase.wantErr { + t.Errorf("JSONArraySome() error = %v, wantErr %v", err, testCase.wantErr) + } + }) + } + }) +} diff --git a/tests/csapi/thread_notifications_test.go b/tests/csapi/thread_notifications_test.go index 334346759..c697aa74f 100644 --- a/tests/csapi/thread_notifications_test.go +++ b/tests/csapi/thread_notifications_test.go @@ -131,6 +131,9 @@ func TestThreadedReceipts(t *testing.T) { Content: map[string]interface{}{ "msgtype": "m.text", "body": fmt.Sprintf("Thread response %s!", bob.UserID), + "m.mentions": map[string]interface{}{ + "user_ids": []string{bob.UserID}, + }, "m.relates_to": map[string]interface{}{ "event_id": eventA, "rel_type": "m.thread", @@ -144,6 +147,9 @@ func TestThreadedReceipts(t *testing.T) { Content: map[string]interface{}{ "msgtype": "m.text", "body": fmt.Sprintf("Hello %s!", bob.UserID), + "m.mentions": map[string]interface{}{ + "user_ids": []string{bob.UserID}, + }, }, }) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 8cd318ce1..eacf1467b 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -172,7 +172,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { ) // Bob clears their status. - mustSetProfileField(t, bob, "m.status", nil) + mustDeleteProfileField(t, bob, "m.status") // Wait until alice sees the status be set to `null` (nil). alice.MustSyncUntil( @@ -276,6 +276,12 @@ func mustSetProfileField(t *testing.T, user *client.CSAPI, field string, value i ) } +// mustDeleteProfileField clears the given profile field ID on the given user's profile. +func mustDeleteProfileField(t *testing.T, user *client.CSAPI, field string) { + t.Helper() + user.MustDo(t, "DELETE", []string{"_matrix", "client", "v3", "profile", user.UserID, field}) +} + // getProfileUpdate extracts the given profile updates for a given user by field // ID from a legacy `/sync` response. func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, bool) { diff --git a/tests/room_timestamp_to_event_test.go b/tests/room_timestamp_to_event_test.go index 028315cc6..4be803cc4 100644 --- a/tests/room_timestamp_to_event_test.go +++ b/tests/room_timestamp_to_event_test.go @@ -362,7 +362,9 @@ func createTestRoom(t *testing.T, c *client.CSAPI) (roomID string, eventA, event roomID = c.MustCreateRoom(t, map[string]interface{}{ "preset": "public_chat", }) - + // timeBeforeEventA doubles as the initial creation events after-timestamp, so guard it on + // both sides to keep it between the two events. + time.Sleep(tsBoundaryGuard) timeBeforeEventA := time.Now() time.Sleep(tsBoundaryGuard) eventAID := c.SendEventSynced(t, roomID, b.Event{ diff --git a/tests/v12_test.go b/tests/v12_test.go index 611783d04..bd8c1bafc 100644 --- a/tests/v12_test.go +++ b/tests/v12_test.go @@ -1,6 +1,7 @@ package tests import ( + "context" "encoding/json" "fmt" "math" @@ -18,6 +19,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" @@ -1352,41 +1354,380 @@ 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 applies +// retroactively to any room versions but we're testing room version 12 as it *SHOULD* +// be expected and enforced instead of *MAY*. +// +// 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 // Venator: does not yet implement federation runtime.SkipIf(t, runtime.Venator) 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. +// +// MSC4311 applies retroactively to any room versions but we're testing room version 12 +// as it *SHOULD* be expected and enforced instead of *MAY*. +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 room 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)) + }) + }) +} + +// Test to make sure your homeserver implementation rejects invites which have +// invalid `invite_room_state`. +// +// > 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`. +// +// MSC4311 applies retroactively to any room versions but we're testing room version 12 +// as it *SHOULD* reject instead of *MAY*. +func TestMSC4311RejectInvalidStrippedStateFederation(t *testing.T) { + // FIXME: Run these tests after 2027-06-01 (to allow some time for the ecosystem to + // adapt and support MSC4311), see https://github.com/element-hq/synapse/issues/19943 + runtime.SkipIf(t, runtime.Synapse) + // does not implement it yet + runtime.SkipIf(t, runtime.Dendrite) + + deployment := complement.Deploy(t, 1) + defer deployment.Destroy(t) + + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) + + // In these tests, Bob (on engineered homeserver) invites Alice over federation. + // Alice's server should reject the invite request because of the (the tested reason) + // and should respond with a `400 M_MISSING_PARAM` response. + t.Run("parallel", func(t *testing.T) { + // TODO: Test events not full PDU's + + // TODO: Test events not from the same room + + // TODO: Test invalid signatures/hashes + + // Test `m.room.create` event missing from `invite_room_state` + t.Run("`m.room.create` event missing from `invite_room_state`", func(t *testing.T) { + t.Parallel() + + // Create an engineered homeserver that will invite Alice + srv := federation.NewServer(t, deployment, + federation.HandleKeyRequests(), + ) + cancel := srv.Listen() + defer cancel() + + // Bob creates a room + roomVersion := gomatrixserverlib.RoomVersion("12") + bob := srv.UserID("bob") + initalEvents := federation.InitialRoomEvents(roomVersion, bob) + room := srv.MustMakeRoom(t, roomVersion, initalEvents) + + // Bob invites Alice to the room + // + // Create the invite event + inviteEvent := srv.MustCreateEvent(t, room, federation.Event{ + Type: "m.room.member", + StateKey: &alice.UserID, + Sender: bob, + Content: map[string]interface{}{ + "membership": "invite", + }, + }) + // Send the invite request. + // + // There is `fclient.NewInviteV2Request(...)` (but it doesn't support full PDU's + // yet) and `fedClient.SendInviteV2(...)` but we want to be able to inspect the + // HTTP status code and `errcode` of the response. + sendInvitePath := "/_matrix/federation/v2/invite/" + url.PathEscape(room.RoomID) + "/" + url.PathEscape(inviteEvent.EventID()) + sendInviteReq := fclient.NewFederationRequest("PUT", srv.ServerName(), deployment.GetFullyQualifiedHomeserverName(t, "hs1"), sendInvitePath) + err := sendInviteReq.SetContent(map[string]interface{}{ + "event": inviteEvent, + // Doesn't include `m.room.create` (the thing we're testing) + "invite_room_state": []map[string]interface{}{}, + "room_version": roomVersion, + }) + must.NotError(t, "Failed to set invite request body", err) + res, err := srv.DoFederationRequest(context.Background(), t, deployment, sendInviteReq) + must.NotError(t, "Failed to send federation request for invite", err) + // Expect `400 M_MISSING_PARAM` response + must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 400, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "M_MISSING_PARAM"), + }, + }) + }) + }) }