From d827c923d568c00f3b1b16e4a310cd7d90d96cde Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:29:11 +0100 Subject: [PATCH 1/8] Fix regression introduced in #872 (#917) * Fix regression introduced in #872 * Review comment * Fix CI now bullseye is dead --- .github/workflows/ci.yaml | 4 ++-- tests/room_timestamp_to_event_test.go | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5e07a8c5..3135cb1e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,8 +34,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/tests/room_timestamp_to_event_test.go b/tests/room_timestamp_to_event_test.go index 028315cc..4be803cc 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{ From 62887b4862869f41b0fa891f0869c27134cfbfd1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:44:27 +0000 Subject: [PATCH 2/8] Bump zizmorcore/zizmor-action in the minor-and-patches group Bumps the minor-and-patches group with 1 update: [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action). Updates `zizmorcore/zizmor-action` from 0.6.2 to 0.6.3 - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/3dc1ecc9bcb9e94e9b2c709687979e1298497054...70fb788f84895a7701f5643d103d587e460b5c99) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.6.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patches ... Signed-off-by: dependabot[bot] --- .github/workflows/zizmor.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/zizmor.yaml b/.github/workflows/zizmor.yaml index 7d595b64..e623e337 100644 --- a/.github/workflows/zizmor.yaml +++ b/.github/workflows/zizmor.yaml @@ -21,4 +21,4 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 From cbeebaacc30c1e236f11f5b8f7143ca9102b77e7 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Mon, 14 Sep 2026 13:55:17 +0100 Subject: [PATCH 3/8] MSC4429: Use `DELETE` to delete profile fields, not `PUT` with `null` (#914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Null is not interoperable as an allowable field value: > Servers MAY reject null values. — https://spec.matrix.org/v1.19/client-server-api/#put_matrixclientv3profileuseridkeyname --- tests/msc4429/msc4429_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 8cd318ce..eacf1467 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) { From d0811ca6c35c7187b9ee98cfd115a8b840f48dd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:40:10 +0000 Subject: [PATCH 4/8] Bump github.com/sirupsen/logrus from 1.10.1 to 1.10.2 in the minor-and-patches group (#918) Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 34b94eb2..088d284f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( 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/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 diff --git a/go.sum b/go.sum index a0442066..0f4df39c 100644 --- a/go.sum +++ b/go.sum @@ -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,6 +102,8 @@ 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= @@ -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= From f41b1234f2144b15c3f8892e075c78f462908fec Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Tue, 15 Sep 2026 12:47:14 +0200 Subject: [PATCH 5/8] Use m.mentions in TestThreadedReceipts instead of legacy body mentions (#920) The two messages that are expected to highlight Bob only contained his user ID in the body text. That relies on the legacy push rules `.m.rule.contains_user_name` / `.m.rule.contains_display_name`, which Matrix v1.17 removes (MSC4210). Synapse disables them by default from 1.161.0 (element-hq/synapse#20220), so the test fails there with `highlight_count: 0`. Add an `m.mentions` block to both messages so they are highlighted via `.m.rule.is_user_mention` (Matrix v1.7). The expected counts are unchanged: a message with `m.mentions` produces exactly one highlight whether or not the legacy rules are enabled. --- tests/csapi/thread_notifications_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/csapi/thread_notifications_test.go b/tests/csapi/thread_notifications_test.go index 33434675..c697aa74 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}, + }, }, }) From b465a032c6948c25b80e6f6111f236c1287fe780 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:45:23 +0100 Subject: [PATCH 6/8] bugfix: fix a federation connection leak which could cause tests to fail (#921) * bugfix: fix a federation connection leak which could cause tests to fail If you have a low fd limit (e.g. 256) then some test packages which do lots of federation requests from test -> server could result in the limit being reached due to there being a ton of HTTP transports each with a few idle connections because we did not reuse transports between HS names. This would result in obscure errors like "Invalid request signature" because the /key/server request was failing. * Review comments --- internal/docker/deployer.go | 9 +-------- internal/docker/deployment.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/internal/docker/deployer.go b/internal/docker/deployer.go index ad453aab..40a6a9a7 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 a3f3313d..0f2f9fc6 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. From ce633f3865ccb95e3324d31c5561bd4aaa7a4b33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:02:07 +0100 Subject: [PATCH 7/8] Bump the minor-and-patches group with 3 updates (#923) Bumps the minor-and-patches group with 3 updates: [github.com/moby/moby/api](https://github.com/moby/moby), [github.com/moby/moby/client](https://github.com/moby/moby) and [golang.org/x/crypto](https://github.com/golang/crypto). Updates `github.com/moby/moby/api` from 1.55.0 to 1.56.0 - [Release notes](https://github.com/moby/moby/releases) - [Commits](https://github.com/moby/moby/compare/api/v1.55.0...api/v1.56.0) Updates `github.com/moby/moby/client` from 0.5.1 to 0.6.0 - [Release notes](https://github.com/moby/moby/releases) - [Changelog](https://github.com/moby/moby/blob/v0.6.0/CHANGELOG.md) - [Commits](https://github.com/moby/moby/compare/v0.5.1...v0.6.0) Updates `golang.org/x/crypto` from 0.55.0 to 0.56.0 - [Commits](https://github.com/golang/crypto/compare/v0.55.0...v0.56.0) --- updated-dependencies: - dependency-name: github.com/moby/moby/api dependency-version: 1.56.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patches - dependency-name: github.com/moby/moby/client dependency-version: 0.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patches - dependency-name: golang.org/x/crypto dependency-version: 0.56.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patches ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 088d284f..659a3153 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/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 0f4df39c..67949def 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= @@ -107,8 +107,8 @@ 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= From c5c17f6b76e134b4fefdb7cf84a42c6d081e83bb Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 22 Sep 2026 11:13:19 -0600 Subject: [PATCH 8/8] Update MSC4311 tests to conform to the spec and better coverage (#796) Update MSC4311 tests to conform to the spec. MSC4311 states that the client API should still use the stripped state event format. For the federation API, it should use full PDU's. MSC4311 also requires the `m.room.create` event be included in the list of stripped state. ["Stripped state"](https://spec.matrix.org/v1.18/client-server-api/#stripped-state) is a generic term that refers to simplified slice of state shared in `invite_room_state`/`knock_room_state` (`invite_state`/`knock_state` with `/sync`) before someone is joined to a room and can see the full room state. This PR was originally authored by @turt2live taking a stab at fixing single the flawed test (self-reported: ~~"I have no idea what I'm doing" disclaimer goes here~~) but has since been taken over by @MadLittleMods. And has naturally evolved some new better test coverage as I've worked on a full solution. Synapse PR: ~~https://github.com/element-hq/synapse/pull/18822~~ https://github.com/element-hq/synapse/pull/19723 The previous tests passed because of a flawed implementation in Synapse which is being removed in https://github.com/element-hq/synapse/pull/19723 as well. --- client/client.go | 37 +++- federation/server.go | 105 +++++++++++- match/json.go | 31 ++++ match/json_test.go | 67 ++++++++ tests/v12_test.go | 393 ++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 602 insertions(+), 31 deletions(-) create mode 100644 match/json_test.go diff --git a/client/client.go b/client/client.go index 57f0f8e8..ef8f355d 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 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 078ee2b7..8f473a6f 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/match/json.go b/match/json.go index 49926c42..a149dcf1 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 00000000..81f71b39 --- /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/v12_test.go b/tests/v12_test.go index 611783d0..bd8c1baf 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"), + }, + }) + }) + }) }